#!/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" <> "$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 ] }