#!/usr/bin/env bats # Characterization tests for sync-templates — the mechanism that distributes # every template change to every project. # # These pin CURRENT behavior (record-not-spec) ahead of the guard changes the # 2026-07-30 propagation incident calls for. That incident is what these exist # for: an uncommitted edit in rulesets silently blocked all three rsyncs for a # whole day, and five workflow files went stale in one downstream project alone # with nothing anywhere reporting it. Any change to this script's guards has to # come with a red test here first. # # Everything runs against fixture directories via SYNC_RULESETS_DIR, so no test # touches the real rulesets checkout or any real project. setup() { # This suite lives beside the script it tests and travels with it, so it # resolves the script relative to itself rather than to a repo root. SYNC="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)/sync-templates" WORK="$(mktemp -d)" RS="$WORK/rulesets" PROJ="$WORK/proj" export SYNC_RULESETS_DIR="$RS" # A minimal rulesets fixture: a git repo with the three synced source paths. mkdir -p "$RS/claude-templates/.ai/workflows" "$RS/claude-templates/.ai/scripts" printf 'canonical protocols\n' > "$RS/claude-templates/.ai/protocols.org" printf 'canonical startup\n' > "$RS/claude-templates/.ai/workflows/startup.org" printf 'canonical helper\n' > "$RS/claude-templates/.ai/scripts/helper" # Real rulesets gitignores the python cache paths, so they never make the # tree dirty. Without this the fixture diverges from production in a way # that silently disarms the exclusion test: the cache files read as # untracked, guard one fires, the sync never runs, and assertions that the # cache did NOT arrive pass because nothing arrived at all. printf '__pycache__/\n.pytest_cache/\n*.pyc\n' > "$RS/.gitignore" _mk_repo "$RS" # A consuming project with the destination dirs. mkdir -p "$PROJ/.ai/workflows" "$PROJ/.ai/scripts" } teardown() { rm -rf "$WORK"; } _mk_repo() { local d="$1" git init -q "$d" git -C "$d" config user.email t@example.com git -C "$d" config user.name tester git -C "$d" config commit.gpgsign false git -C "$d" config gc.auto 0 git -C "$d" config maintenance.auto false git -C "$d" add -A # --allow-empty: the project fixture holds only empty directories, which git # has nothing to commit, and these tests need it to be a repo with a HEAD. git -C "$d" commit -q --allow-empty -m init } # --- the happy path ------------------------------------------------------------ @test "clean rulesets and a non-git project: syncs all three paths" { run bash "$SYNC" "$PROJ" [ "$status" -eq 0 ] [[ "$output" == *"synced from templates"* ]] [ "$(cat "$PROJ/.ai/protocols.org")" = "canonical protocols" ] [ "$(cat "$PROJ/.ai/workflows/startup.org")" = "canonical startup" ] [ "$(cat "$PROJ/.ai/scripts/helper")" = "canonical helper" ] } @test "--delete removes a retired template file from the project" { printf 'retired\n' > "$PROJ/.ai/workflows/gone.org" run bash "$SYNC" "$PROJ" [ "$status" -eq 0 ] [[ "$output" == *"synced from templates"* ]] [ ! -e "$PROJ/.ai/workflows/gone.org" ] } @test "the scripts sync excludes python cache artifacts" { mkdir -p "$RS/claude-templates/.ai/scripts/__pycache__" \ "$RS/claude-templates/.ai/scripts/.pytest_cache" printf 'junk\n' > "$RS/claude-templates/.ai/scripts/__pycache__/x.pyc" printf 'junk\n' > "$RS/claude-templates/.ai/scripts/.pytest_cache/y" printf 'junk\n' > "$RS/claude-templates/.ai/scripts/stray.pyc" run bash "$SYNC" "$PROJ" [ "$status" -eq 0 ] # Assert the sync RAN before asserting what it didn't copy. Without this the # absences below are satisfied by a skipped sync, and the whole test passes # with every --exclude flag deleted from the script. [[ "$output" == *"synced from templates"* ]] [ -e "$PROJ/.ai/scripts/helper" ] [ ! -e "$PROJ/.ai/scripts/__pycache__" ] [ ! -e "$PROJ/.ai/scripts/.pytest_cache" ] [ ! -e "$PROJ/.ai/scripts/stray.pyc" ] } @test "project-owned directories are never touched by the sync" { mkdir -p "$PROJ/.ai/project-workflows" "$PROJ/.ai/project-scripts" printf 'mine\n' > "$PROJ/.ai/project-workflows/local.org" printf 'mine\n' > "$PROJ/.ai/project-scripts/local.py" run bash "$SYNC" "$PROJ" [ "$status" -eq 0 ] [[ "$output" == *"synced from templates"* ]] [ "$(cat "$PROJ/.ai/project-workflows/local.org")" = "mine" ] [ "$(cat "$PROJ/.ai/project-scripts/local.py")" = "mine" ] } # --- guard one: rulesets dirty under the synced paths -------------------------- @test "a dirty synced path in rulesets skips the sync and names the file" { printf 'in-flight edit\n' >> "$RS/claude-templates/.ai/workflows/startup.org" printf 'stale\n' > "$PROJ/.ai/workflows/startup.org" run bash "$SYNC" "$PROJ" [ "$status" -eq 0 ] [[ "$output" == *"uncommitted changes under the synced template paths"* ]] [[ "$output" == *"startup.org"* ]] # Nothing propagated — this is the whole-window blast radius from 2026-07-30. [ "$(cat "$PROJ/.ai/workflows/startup.org")" = "stale" ] } @test "ONE dirty file blocks ALL THREE rsyncs, not just its own" { # The 2026-07-30 incident in one assertion: an edit to a workflow file also # withheld protocols.org and every script. This is current behavior, and the # narrowing change lands by making this test fail on purpose. printf 'in-flight edit\n' >> "$RS/claude-templates/.ai/workflows/startup.org" printf 'stale protocols\n' > "$PROJ/.ai/protocols.org" printf 'stale helper\n' > "$PROJ/.ai/scripts/helper" run bash "$SYNC" "$PROJ" [ "$status" -eq 0 ] [ "$(cat "$PROJ/.ai/protocols.org")" = "stale protocols" ] [ "$(cat "$PROJ/.ai/scripts/helper")" = "stale helper" ] } @test "an untracked file under a synced path also blocks the sync" { printf 'new template\n' > "$RS/claude-templates/.ai/workflows/brand-new.org" run bash "$SYNC" "$PROJ" [ "$status" -eq 0 ] [[ "$output" == *"uncommitted changes under the synced template paths"* ]] } @test "rulesets dirt OUTSIDE the synced paths does not block the sync" { printf 'scratch\n' > "$RS/scratch.txt" printf 'edit\n' >> "$RS/claude-templates/bin-ish.txt" run bash "$SYNC" "$PROJ" [ "$status" -eq 0 ] [[ "$output" == *"synced from templates"* ]] } # --- guard two: the project branch is behind its upstream ---------------------- @test "a project behind its upstream skips the sync" { _mk_repo "$PROJ" git init -q --bare "$WORK/remote" git -C "$PROJ" remote add origin "$WORK/remote" git -C "$PROJ" push -q -u origin HEAD git -C "$PROJ" commit -q --allow-empty -m ahead git -C "$PROJ" push -q origin HEAD git -C "$PROJ" reset -q --hard HEAD~1 printf 'stale\n' > "$PROJ/.ai/workflows/startup.org" run bash "$SYNC" "$PROJ" [ "$status" -eq 0 ] [[ "$output" == *"behind upstream"* ]] [ "$(cat "$PROJ/.ai/workflows/startup.org")" = "stale" ] } @test "a project AHEAD of its upstream still syncs" { _mk_repo "$PROJ" git init -q --bare "$WORK/remote" git -C "$PROJ" remote add origin "$WORK/remote" git -C "$PROJ" push -q -u origin HEAD git -C "$PROJ" commit -q --allow-empty -m ahead run bash "$SYNC" "$PROJ" [ "$status" -eq 0 ] [[ "$output" == *"synced from templates"* ]] } @test "a git project with no upstream syncs (rev-list fails, guard stays off)" { _mk_repo "$PROJ" run bash "$SYNC" "$PROJ" [ "$status" -eq 0 ] [[ "$output" == *"synced from templates"* ]] } # --- edges --------------------------------------------------------------------- @test "a nonexistent project directory reports and exits 0 without syncing" { run bash "$SYNC" "$WORK/does-not-exist" [ "$status" -eq 0 ] [[ "$output" == *"cannot enter"* ]] [[ "$output" != *"synced from templates"* ]] [ ! -e "$WORK/does-not-exist" ] } @test "the sync is idempotent — a second run changes nothing" { bash "$SYNC" "$PROJ" first="$(find "$PROJ/.ai" -type f -exec sha256sum {} + | sort)" run bash "$SYNC" "$PROJ" [ "$status" -eq 0 ] [[ "$output" == *"synced from templates"* ]] second="$(find "$PROJ/.ai" -type f -exec sha256sum {} + | sort)" [ "$first" = "$second" ] } @test "a missing canonical source still reports success — the false-success path" { # Faithfully inherited from the inline block, and pinned here because the # manifest step depends on it: a last-synced record written after this # branch would stamp a successful sync onto one where all three rsyncs # failed. The guard work has to fix this before it can trust the record. # Point at a rulesets that isn't there at all, rather than deleting the # canonical subtree inside a live repo — that would show up as staged # deletions and trip guard one, which is a different path entirely. SYNC_RULESETS_DIR="$WORK/no-such-rulesets" run bash "$SYNC" "$PROJ" [ "$status" -eq 0 ] [[ "$output" == *"synced from templates"* ]] # Nothing arrived, and the success line says otherwise. Asserted against a # file that DOES arrive on a real sync — protocols.org is absent before any # sync too, so its absence alone would prove nothing. [ ! -e "$PROJ/.ai/scripts/helper" ] } @test "a locally-edited template is silently overwritten, with no record kept" { # Work's 2026-07-30 regression, pinned: a project patches a rulesets-owned # file, the next sync reverts it to canonical, and nothing anywhere says so. # The output is indistinguishable from an ordinary successful sync. bash "$SYNC" "$PROJ" printf 'local fix for a real bug\n' > "$PROJ/.ai/workflows/startup.org" run bash "$SYNC" "$PROJ" [ "$status" -eq 0 ] [ "$(cat "$PROJ/.ai/workflows/startup.org")" = "canonical startup" ] [[ "$output" == *"synced from templates"* ]] # No warning, no backup, no manifest — the loss leaves no trace at all. [[ "$output" != *"overwrote"* ]] [[ "$output" != *"local edit"* ]] [ ! -d "$PROJ/.ai/.sync-backups" ] }