feat: live-sync heartbeat commits machine-written paths
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
This commit is contained in:
Executable
+176
@@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env bats
|
||||
# tests/live-sync.bats — black-box tests for scripts/live-sync.sh (Refs #11)
|
||||
#
|
||||
# Contract (see script header): the heartbeat commits ONLY allowlisted live
|
||||
# paths, merges origin/main, pushes, and reaps stale wt/* worktrees. Nothing
|
||||
# is observed except exit status, stderr, the notify-sink log, and git state.
|
||||
|
||||
setup() {
|
||||
TEST_TMP="$(mktemp -d)"
|
||||
REPO="$TEST_TMP/repo"
|
||||
export XDG_RUNTIME_DIR="$TEST_TMP/xdg"
|
||||
mkdir -p "$XDG_RUNTIME_DIR"
|
||||
export GIT_CONFIG_GLOBAL="$TEST_TMP/gitconfig" GIT_CONFIG_SYSTEM=/dev/null
|
||||
export GIT_AUTHOR_DATE="2026-10-18T12:00:00+00:00" GIT_COMMITTER_DATE="$GIT_AUTHOR_DATE"
|
||||
export LIVE_SYNC_REPO="$REPO"
|
||||
export LIVE_SYNC_NTFY_TOPIC_FILE="$TEST_TMP/no-topic-file"
|
||||
export LIVE_SYNC_NOTIFY="$TEST_TMP/notify-stub"
|
||||
export LIVE_SYNC_AGE_DAYS=7
|
||||
|
||||
# notify sink: appends each alert line to alerts.log
|
||||
cat > "$LIVE_SYNC_NOTIFY" <<STUB
|
||||
#!/usr/bin/env bash
|
||||
cat >> "$TEST_TMP/alerts.log"
|
||||
echo >> "$TEST_TMP/alerts.log"
|
||||
STUB
|
||||
chmod +x "$LIVE_SYNC_NOTIFY"
|
||||
|
||||
# bare "origin" + seeded clone with all tiers represented
|
||||
git init -q --bare -b main "$TEST_TMP/origin.git"
|
||||
git clone -q "$TEST_TMP/origin.git" "$REPO"
|
||||
git -C "$REPO" config user.email frank@dank
|
||||
git -C "$REPO" config user.name Frank
|
||||
git -C "$REPO" checkout -q -b main
|
||||
mkdir -p "$REPO/agent/memories" "$REPO/agent/managed-skills/s1" \
|
||||
"$REPO/dotfiles/bash" "$REPO/plugins/bench" "$REPO/scripts"
|
||||
echo base > "$REPO/agent/config.yml"
|
||||
echo base > "$REPO/agent/models.yml"
|
||||
echo base > "$REPO/agent/memories/learned.md"
|
||||
echo base > "$REPO/agent/managed-skills/s1/SKILL.md"
|
||||
echo base > "$REPO/dotfiles/bash/bashrc"
|
||||
echo base > "$REPO/AGENTS.md"
|
||||
echo base > "$REPO/scripts/deploy.sh"
|
||||
echo base > "$REPO/plugins/bench/server.py"
|
||||
git -C "$REPO" add -A
|
||||
git -C "$REPO" commit -q -m seed
|
||||
git -C "$REPO" push -q origin main
|
||||
SEED="$(git -C "$REPO" rev-parse HEAD)"
|
||||
}
|
||||
|
||||
teardown() { rm -rf "$TEST_TMP"; }
|
||||
|
||||
@test "heartbeat commits allowlisted live dirt only; task dirt stays dirty" {
|
||||
echo new > "$REPO/agent/memories/learned.md"
|
||||
mkdir "$REPO/agent/managed-skills/s2"
|
||||
echo new > "$REPO/agent/managed-skills/s2/SKILL.md"
|
||||
echo new > "$REPO/AGENTS.md"
|
||||
echo new > "$REPO/plugins/bench/server.py"
|
||||
|
||||
run "$BATS_TEST_DIRNAME/../scripts/live-sync.sh"
|
||||
[ "$status" -eq 0 ]
|
||||
|
||||
# exactly one new commit ahead of the seed
|
||||
[ "$(git -C "$REPO" rev-list --count "$SEED"..main)" -eq 1 ]
|
||||
# it touches ONLY live paths (untracked new dirs picked up too)
|
||||
run git -C "$REPO" show --pretty=format: --name-only HEAD
|
||||
printf '%s\n' "${lines[@]}" | grep -v '^$' | sort | diff -q - <(
|
||||
printf '%s\n' agent/managed-skills/s2/SKILL.md agent/memories/learned.md)
|
||||
# task dirt untouched, uncommitted
|
||||
[ -n "$(git -C "$REPO" status --porcelain -- AGENTS.md plugins)" ]
|
||||
# pushed: origin caught up
|
||||
[ "$(git -C "$REPO" rev-parse origin/main)" = "$(git -C "$REPO" rev-parse main)" ]
|
||||
}
|
||||
|
||||
@test "clean tree is a silent no-op" {
|
||||
run "$BATS_TEST_DIRNAME/../scripts/live-sync.sh"
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$(git -C "$REPO" rev-list --count "$SEED"..main)" -eq 0 ]
|
||||
[ ! -e "$TEST_TMP/alerts.log" ]
|
||||
}
|
||||
|
||||
@test "non-allowlist dirt alone never commits" {
|
||||
echo new > "$REPO/AGENTS.md"
|
||||
echo new > "$REPO/scripts/deploy.sh"
|
||||
run "$BATS_TEST_DIRNAME/../scripts/live-sync.sh"
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$(git -C "$REPO" rev-list --count "$SEED"..main)" -eq 0 ]
|
||||
[ -n "$(git -C "$REPO" status --porcelain -- AGENTS.md scripts)" ]
|
||||
}
|
||||
|
||||
@test "merge conflict aborts, keeps local commit, alerts, leaves remote alone" {
|
||||
# a second clone advances origin/main on the same live file
|
||||
git clone -q -b main "$TEST_TMP/origin.git" "$TEST_TMP/pocket"
|
||||
git -C "$TEST_TMP/pocket" config user.email pocket@dank
|
||||
git -C "$TEST_TMP/pocket" config user.name Pocket
|
||||
echo remote-side > "$TEST_TMP/pocket/agent/models.yml"
|
||||
git -C "$TEST_TMP/pocket" commit -qam "remote advance"
|
||||
git -C "$TEST_TMP/pocket" push -q origin main
|
||||
|
||||
echo local-side > "$REPO/agent/models.yml"
|
||||
|
||||
run "$BATS_TEST_DIRNAME/../scripts/live-sync.sh"
|
||||
[ "$status" -eq 1 ]
|
||||
[[ "$output" == *"merge conflict"* ]]
|
||||
[ -s "$TEST_TMP/alerts.log" ]
|
||||
# HEAD is my local commit, merge left no residue, remote untouched
|
||||
[[ "$(git -C "$REPO" log --format=%s -1)" == live:*"heartbeat sync"* ]]
|
||||
[ ! -e "$REPO/.git/MERGE_HEAD" ]
|
||||
[ "$(git -C "$REPO" rev-parse origin/main)" = "$(git -C "$TEST_TMP/pocket" rev-parse main)" ]
|
||||
}
|
||||
|
||||
@test "branch guard refuses to sync on a feature branch" {
|
||||
git -C "$REPO" checkout -q -b feature/whatever
|
||||
echo new > "$REPO/agent/config.yml"
|
||||
|
||||
run "$BATS_TEST_DIRNAME/../scripts/live-sync.sh"
|
||||
[ "$status" -eq 1 ]
|
||||
[[ "$output" == *"not main"* ]]
|
||||
[ "$(git -C "$REPO" rev-list --count "$SEED"..feature/whatever)" -eq 0 ]
|
||||
[ -n "$(git -C "$REPO" status --porcelain -- agent/config.yml)" ]
|
||||
}
|
||||
|
||||
@test "pre-commit hook refusal is surfaced, not bypassed; changes stay staged" {
|
||||
mkdir "$TEST_TMP/hooks"
|
||||
printf '#!/bin/sh\nexit 1\n' > "$TEST_TMP/hooks/pre-commit"
|
||||
chmod +x "$TEST_TMP/hooks/pre-commit"
|
||||
git -C "$REPO" config core.hooksPath "$TEST_TMP/hooks"
|
||||
echo new > "$REPO/dotfiles/bash/bashrc"
|
||||
|
||||
run "$BATS_TEST_DIRNAME/../scripts/live-sync.sh"
|
||||
[ "$status" -eq 1 ]
|
||||
[[ "$output" == *"rejected"* ]]
|
||||
[ -n "$(git -C "$REPO" diff --cached --name-only -- dotfiles)" ]
|
||||
[ "$(git -C "$REPO" rev-list --count "$SEED"..main)" -eq 0 ]
|
||||
}
|
||||
|
||||
@test "repo without origin still commits locally and exits clean" {
|
||||
git -C "$REPO" remote remove origin
|
||||
echo new > "$REPO/dotfiles/bash/bashrc"
|
||||
run "$BATS_TEST_DIRNAME/../scripts/live-sync.sh"
|
||||
[ "$status" -eq 0 ]
|
||||
[ "$(git -C "$REPO" rev-list --count "$SEED"..main)" -eq 1 ]
|
||||
}
|
||||
|
||||
@test "stale wt/* worktree and its branch are reaped; fresh one survives" {
|
||||
git -C "$REPO" worktree add -q wt/wt-old -b wt/old
|
||||
git -C "$REPO" worktree add -q wt/wt-new -b wt/new
|
||||
touch -d "10 days ago" "$REPO/wt/wt-old"
|
||||
find "$REPO/wt/wt-old" -exec touch -d "10 days ago" {} +
|
||||
|
||||
run "$BATS_TEST_DIRNAME/../scripts/live-sync.sh"
|
||||
[ "$status" -eq 0 ]
|
||||
[ ! -d "$REPO/wt/wt-old" ]
|
||||
[ -d "$REPO/wt/wt-new" ]
|
||||
run git -C "$REPO" show-ref --verify refs/heads/wt/old
|
||||
[ "$status" -ne 0 ]
|
||||
git -C "$REPO" show-ref --verify --quiet refs/heads/wt/new
|
||||
}
|
||||
|
||||
@test "second heartbeat inside the throttle window alerts once, never twice" {
|
||||
# two conflicting heartbeats: alert sink must see exactly one line
|
||||
git clone -q -b main "$TEST_TMP/origin.git" "$TEST_TMP/pocket"
|
||||
git -C "$TEST_TMP/pocket" config user.email pocket@dank
|
||||
git -C "$TEST_TMP/pocket" config user.name Pocket
|
||||
echo remote-side > "$TEST_TMP/pocket/agent/config.yml"
|
||||
git -C "$TEST_TMP/pocket" commit -qam "remote advance"
|
||||
git -C "$TEST_TMP/pocket" push -q origin main
|
||||
echo local-side > "$REPO/agent/config.yml"
|
||||
|
||||
run "$BATS_TEST_DIRNAME/../scripts/live-sync.sh"
|
||||
[ "$status" -eq 1 ]
|
||||
rm -f "$XDG_RUNTIME_DIR/live-sync.lock" # flock releases with the process; state stays
|
||||
echo local-side2 > "$REPO/agent/config.yml"
|
||||
run "$BATS_TEST_DIRNAME/../scripts/live-sync.sh"
|
||||
[ "$status" -eq 1 ]
|
||||
[ "$(wc -l < "$TEST_TMP/alerts.log")" -eq 1 ]
|
||||
}
|
||||
Reference in New Issue
Block a user