The checkout IS the deployment and symlinks make live state (memories, auto-learned skills, agent config, desktop settings) dirty tracked files continuously; no commit law covered machine churn, so it accumulated unpushed for weeks — one disk held the only copy of ~61 managed skills, a settings fix stayed local-only, and dirty trees refused merges. scripts/live-sync.sh (systemd user timer, ~15 min) commits ONLY the allowlisted machine/desktop-live tiers with hooks armed, merges origin/main, pushes, and reaps wt/* worktrees older than 7 days. Task dirt outside the allowlist is never staged; conflicts abort and alert via ntfy; branch guard refuses off main; never --no-verify, never force. Black-box contract pinned by tests/live-sync.bats (9 tests, suite 36/36). Refs #11
132 lines
4.7 KiB
Bash
Executable File
132 lines
4.7 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# live-sync.sh — heartbeat committer for machine-written paths in the command
|
|
# center checkout. Refs #11 (see omp-docs/tools/live-sync.md).
|
|
#
|
|
# The checkout IS the deployment; the harness, auto-learn, and desktop GUIs
|
|
# write tracked files continuously through symlinks. This script commits ONLY
|
|
# the allowlisted machine-live + desktop-live tiers (AGENTS.md § Live-sync
|
|
# tiers), merges the trunk, pushes, and reaps stale session worktrees. Task
|
|
# dirt outside the allowlist is NEVER committed — only surfaced.
|
|
#
|
|
# Black-box contract (tests/live-sync.bats pins it):
|
|
# input git state of $LIVE_SYNC_REPO
|
|
# output exit 0 = quiet heartbeat (synced, no-op, or reaped)
|
|
# exit 1 = alert raised (branch guard, hook refusal, merge conflict,
|
|
# fetch/push failure); alert goes to stderr + notify sink
|
|
# Env overrides (test seams):
|
|
# LIVE_SYNC_REPO target repo (default $HOME/.omp)
|
|
# LIVE_SYNC_NOTIFY alert command, message on stdin
|
|
# LIVE_SYNC_NTFY_TOPIC_FILE ntfy topic file, mode 600 (default
|
|
# ~/.config/ntfy/live-sync; absent/empty = no push)
|
|
# LIVE_SYNC_AGE_DAYS wt/wt-* reap threshold (default 7)
|
|
set -euo pipefail
|
|
|
|
REPO="${LIVE_SYNC_REPO:-$HOME/.omp}"
|
|
AGE_DAYS="${LIVE_SYNC_AGE_DAYS:-7}"
|
|
TOPIC_FILE="${LIVE_SYNC_NTFY_TOPIC_FILE:-$HOME/.config/ntfy/live-sync}"
|
|
STATE_DIR="${XDG_RUNTIME_DIR:-/tmp}"
|
|
|
|
# Allowlist tiers. Human prose (AGENTS.md, omp-docs, scripts, plugins,
|
|
# agent/skills) is deliberately absent: branch -> PR territory, never swept.
|
|
LIVE_PATHS=(
|
|
agent/memories
|
|
agent/managed-skills
|
|
agent/config.yml
|
|
agent/models.yml
|
|
dotfiles
|
|
)
|
|
|
|
notify() {
|
|
local msg="live-sync: $1" stamp now
|
|
stamp=$(cat "$STATE_DIR/live-sync.alert" 2>/dev/null || echo 0)
|
|
now=$(date +%s)
|
|
if (( now - stamp >= 3600 )); then
|
|
date +%s > "$STATE_DIR/live-sync.alert"
|
|
if [[ -n "${LIVE_SYNC_NOTIFY:-}" ]]; then
|
|
printf '%s' "$msg" | $LIVE_SYNC_NOTIFY || true
|
|
elif [[ -s "$TOPIC_FILE" ]]; then
|
|
curl -fsS -m 10 -d "$msg" "https://ntfy.sh/$(<"$TOPIC_FILE")" >/dev/null 2>&1 || true
|
|
fi
|
|
fi
|
|
printf '%s\n' "$msg" >&2
|
|
}
|
|
|
|
cd "$REPO" 2>/dev/null || exit 0
|
|
exec 9>"$STATE_DIR/live-sync.lock"
|
|
flock -n 9 || exit 0 # another heartbeat owns the repo
|
|
git rev-parse --verify -q HEAD >/dev/null || exit 0
|
|
|
|
branch=$(git symbolic-ref --quiet --short HEAD || true)
|
|
if [[ "$branch" != main ]]; then
|
|
notify "on branch '$branch', not main — refusing to sync, nothing changed"
|
|
exit 1
|
|
fi
|
|
|
|
# 1. stage only allowlisted paths that actually hold dirt (pathspecs that
|
|
# match nothing would make git add fatal, so check per-path)
|
|
for p in "${LIVE_PATHS[@]}"; do
|
|
if [[ -n $(git status --porcelain -- "$p") ]]; then
|
|
git add -A -- "$p"
|
|
fi
|
|
done
|
|
|
|
if ! git diff --cached --quiet; then
|
|
# 2. commit WITH hooks armed — the secret gate stays pointed at the diff;
|
|
# a refusal here is the system working, never bypassed
|
|
if ! git commit -q -m "live: heartbeat sync $(date -Is) (Refs #11)"; then
|
|
notify "commit rejected by hook/secret gate — live changes left STAGED for a human"
|
|
exit 1
|
|
fi
|
|
|
|
# 3. integrate trunk and publish; never fight the remote
|
|
if git remote get-url origin >/dev/null 2>&1; then
|
|
if ! git fetch -q origin; then
|
|
notify "fetch failed — commit is safe locally, will retry next heartbeat"
|
|
exit 1
|
|
fi
|
|
if git rev-parse --verify -q origin/main >/dev/null \
|
|
&& ! git merge -q --no-edit origin/main; then
|
|
git merge --abort || true
|
|
notify "merge conflict with origin/main — local commit kept; needs you at the keyboard"
|
|
exit 1
|
|
fi
|
|
if ! git push -q origin main; then
|
|
notify "push failed — commit is safe locally, will retry next heartbeat"
|
|
exit 1
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
# 4. reap session worktrees older than AGE_DAYS. Dirty worktrees are left
|
|
# alone (git refuses), and so are any with a live process cwd inside.
|
|
in_use() {
|
|
local p
|
|
for p in /proc/[0-9]*/cwd; do
|
|
if [[ "$(readlink "$p" 2>/dev/null || true)" == "$1"* ]]; then
|
|
return 0
|
|
fi
|
|
done
|
|
return 1
|
|
}
|
|
|
|
git worktree prune
|
|
reap_path="" reap_ref=""
|
|
reap_flush() {
|
|
if [[ "$reap_path" == "$REPO/wt/wt-"* && -d "$reap_path" ]] \
|
|
&& [[ -z $(find "$reap_path" -mtime "-$AGE_DAYS" -print -quit) ]] \
|
|
&& ! in_use "$reap_path"; then
|
|
git worktree remove -- "$reap_path" 2>/dev/null && git worktree prune || true
|
|
if [[ -n "$reap_ref" ]]; then
|
|
git branch -q -d "${reap_ref#refs/heads/}" 2>/dev/null || true
|
|
fi
|
|
fi
|
|
reap_path="" reap_ref=""
|
|
}
|
|
while IFS= read -r line; do
|
|
case "$line" in
|
|
"worktree "*) reap_flush; reap_path="${line#worktree }" ;;
|
|
"branch "*) reap_ref="${line#branch }" ;;
|
|
esac
|
|
done < <(git worktree list --porcelain)
|
|
reap_flush
|