diff options
| author | Craig Jennings <c@cjennings.net> | 2026-05-28 09:11:47 -0500 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-05-28 09:11:47 -0500 |
| commit | 9f84ea2c7854e35ae30c0fb5fbd63f7b7115fb41 (patch) | |
| tree | c6cd922c6286c5a6a0c6803ed8311683a3e538af | |
| parent | beb646e827802c0491b422ce03c7687eb66a717b (diff) | |
| download | rulesets-9f84ea2c7854e35ae30c0fb5fbd63f7b7115fb41.tar.gz rulesets-9f84ea2c7854e35ae30c0fb5fbd63f7b7115fb41.zip | |
feat(sync-check): canonical/mirror drift detection + pre-commit hook
scripts/sync-check.sh diffs claude-templates/.ai/{protocols.org,
workflows,scripts} against the .ai/ mirror. Exits 0 when clean, 1 with
a diff report on drift, 2 outside a rulesets-shaped repo or git
checkout. --fix mode rsyncs canonical -> mirror and re-checks, then
prompts to re-stage.
githooks/pre-commit wraps the script. Commits abort on drift so the
issue surfaces at publish time, not at the next session's startup
rsync.
Two new Makefile targets:
- make sync-check [FIX=1] runs the script (FIX=1 passes --fix
through).
- make install-githooks sets core.hooksPath=githooks (idempotent).
scripts/tests/sync-check.bats holds 8 tests covering clean,
drift-per-path, --fix, extra-file removal, missing canonical, and
outside-git. All eight pass.
This catches the exact drift I had to fix manually during this
morning's audit pass. The mirror's open-tasks.org PROPERTIES drawer
sat below a sub-heading because the mirror commit was older than
canonical.
| -rw-r--r-- | Makefile | 7 | ||||
| -rwxr-xr-x | githooks/pre-commit | 15 | ||||
| -rwxr-xr-x | scripts/sync-check.sh | 72 | ||||
| -rw-r--r-- | scripts/tests/sync-check.bats | 86 | ||||
| -rw-r--r-- | todo.org | 3 |
5 files changed, 182 insertions, 1 deletions
@@ -431,6 +431,13 @@ audit: ## Verify project .ai/ dirs against canonical ([APPLY=1] [FORCE=1] [NO_DO $(if $(FORCE),--force) \ $(if $(NO_DOCTOR),--no-doctor) +sync-check: ## Verify claude-templates/.ai/ canonical matches .ai/ mirror ([FIX=1]) + @bash scripts/sync-check.sh $(if $(FIX),--fix) + +install-githooks: ## Point this repo's core.hooksPath at githooks/ (idempotent — enables pre-commit sync-check) + @git config core.hooksPath githooks + @echo "core.hooksPath=githooks (pre-commit will run scripts/sync-check.sh)" + install-ai: ## Bootstrap .ai/ in a fresh project ([PROJECT=<path>] [TRACK=1 | GITIGNORE=1]) @bash scripts/install-ai.sh \ $(if $(TRACK),--track) \ diff --git a/githooks/pre-commit b/githooks/pre-commit new file mode 100755 index 0000000..ece0c8a --- /dev/null +++ b/githooks/pre-commit @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# +# pre-commit hook — block commits when the canonical/mirror pair drifts. +# +# Enable by running: git config core.hooksPath githooks +# (handled by make install-hooks; idempotent on re-run.) + +set -euo pipefail + +repo_root="$(git rev-parse --show-toplevel)" +"$repo_root/scripts/sync-check.sh" || { + echo "" >&2 + echo "pre-commit: canonical/mirror drift detected. Commit blocked." >&2 + exit 1 +} diff --git a/scripts/sync-check.sh b/scripts/sync-check.sh new file mode 100755 index 0000000..8fb9ab0 --- /dev/null +++ b/scripts/sync-check.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# +# sync-check.sh — verify canonical claude-templates/.ai/ matches the .ai/ mirror. +# +# Usage: +# scripts/sync-check.sh # exit 0 if synced, 1 if drift +# scripts/sync-check.sh --fix # rsync canonical → mirror, then re-check +# +# Runs as a pre-commit hook (githooks/pre-commit) to catch drift before +# commit, and as a manual check via `make sync-check`. +# +# The three synced paths are: +# claude-templates/.ai/protocols.org ↔ .ai/protocols.org +# claude-templates/.ai/workflows/ ↔ .ai/workflows/ +# claude-templates/.ai/scripts/ ↔ .ai/scripts/ +# +# Source of truth is the canonical (claude-templates/) side. The mirror +# exists so rulesets-as-a-project has a working copy. Drift in either +# direction is a defect — both have to land in the same commit. + +set -euo pipefail + +if ! repo_root="$(git rev-parse --show-toplevel 2>/dev/null)" || [ -z "$repo_root" ]; then + echo "sync-check: not inside a git checkout" >&2 + exit 2 +fi + +canonical="$repo_root/claude-templates/.ai" +mirror="$repo_root/.ai" + +if [ ! -d "$canonical" ] || [ ! -d "$mirror" ]; then + echo "sync-check: not a rulesets-shaped repo (missing claude-templates/.ai or .ai)" >&2 + exit 2 +fi + +paths=(protocols.org workflows scripts) + +check_drift() { + local drift=0 + for relpath in "${paths[@]}"; do + if ! diff -rq "$canonical/$relpath" "$mirror/$relpath" >/dev/null 2>&1; then + echo "drift: claude-templates/.ai/$relpath ↔ .ai/$relpath" >&2 + diff -rq "$canonical/$relpath" "$mirror/$relpath" 2>&1 | head -20 >&2 + drift=1 + fi + done + return "$drift" +} + +if check_drift; then + exit 0 +fi + +if [ "${1:-}" = "--fix" ]; then + echo "" >&2 + echo "sync-check --fix: syncing canonical → mirror..." >&2 + rsync -a "$canonical/protocols.org" "$mirror/protocols.org" + rsync -a --delete "$canonical/workflows/" "$mirror/workflows/" + rsync -a --delete "$canonical/scripts/" "$mirror/scripts/" + if check_drift; then + echo "sync-check --fix: resolved." >&2 + echo "Re-stage the synced files and retry the commit." >&2 + exit 0 + else + echo "sync-check --fix: drift persists after sync. Inspect manually." >&2 + exit 1 + fi +fi + +echo "" >&2 +echo "Run 'scripts/sync-check.sh --fix' (or 'make sync-check FIX=1') to resolve." >&2 +exit 1 diff --git a/scripts/tests/sync-check.bats b/scripts/tests/sync-check.bats new file mode 100644 index 0000000..df775b3 --- /dev/null +++ b/scripts/tests/sync-check.bats @@ -0,0 +1,86 @@ +#!/usr/bin/env bats +# +# Tests for scripts/sync-check.sh +# +# Sandboxes a fake rulesets-shaped git repo under $BATS_TMPDIR, drops the +# real script in via PATH override, and exercises clean / drift / --fix +# behavior. + +setup() { + SANDBOX="$BATS_TEST_TMPDIR/repo" + SCRIPT_SRC="$BATS_TEST_DIRNAME/../sync-check.sh" + mkdir -p "$SANDBOX/scripts" "$SANDBOX/claude-templates/.ai/workflows" \ + "$SANDBOX/claude-templates/.ai/scripts" "$SANDBOX/.ai/workflows" \ + "$SANDBOX/.ai/scripts" + cp "$SCRIPT_SRC" "$SANDBOX/scripts/sync-check.sh" + chmod +x "$SANDBOX/scripts/sync-check.sh" + cd "$SANDBOX" + git init -q + git config user.email test@example.com + git config user.name test + echo "protocols content" > claude-templates/.ai/protocols.org + echo "protocols content" > .ai/protocols.org + echo "workflow a" > claude-templates/.ai/workflows/a.org + echo "workflow a" > .ai/workflows/a.org + echo "script b" > claude-templates/.ai/scripts/b.sh + echo "script b" > .ai/scripts/b.sh +} + +@test "clean tree: exit 0" { + run scripts/sync-check.sh + [ "$status" -eq 0 ] +} + +@test "drift in protocols.org: exit 1, names the file" { + echo "drifted" > .ai/protocols.org + run scripts/sync-check.sh + [ "$status" -eq 1 ] + [[ "$output" == *"protocols.org"* ]] +} + +@test "drift in workflows/: exit 1, names workflows" { + echo "workflow a modified" > .ai/workflows/a.org + run scripts/sync-check.sh + [ "$status" -eq 1 ] + [[ "$output" == *"workflows"* ]] +} + +@test "drift in scripts/: exit 1, names scripts" { + echo "script b modified" > .ai/scripts/b.sh + run scripts/sync-check.sh + [ "$status" -eq 1 ] + [[ "$output" == *"scripts"* ]] +} + +@test "drift with --fix: syncs and exits 0" { + echo "drifted" > .ai/protocols.org + run scripts/sync-check.sh --fix + [ "$status" -eq 0 ] + run cat .ai/protocols.org + [ "$output" = "protocols content" ] +} + +@test "extra file in mirror: --delete removes it" { + echo "stale" > .ai/workflows/stale.org + run scripts/sync-check.sh --fix + [ "$status" -eq 0 ] + [ ! -f .ai/workflows/stale.org ] +} + +@test "missing canonical: exit 2 with clear error" { + rm -rf claude-templates + run scripts/sync-check.sh + [ "$status" -eq 2 ] + [[ "$output" == *"not a rulesets-shaped repo"* ]] +} + +@test "outside a git checkout: exit 2" { + cd "$BATS_TEST_TMPDIR" + mkdir not-a-repo + cp "$SCRIPT_SRC" not-a-repo/sync-check.sh + chmod +x not-a-repo/sync-check.sh + cd not-a-repo + run ./sync-check.sh + [ "$status" -eq 2 ] + [[ "$output" == *"not inside a git checkout"* ]] +} @@ -1323,7 +1323,8 @@ Teach startup/routing to read =Summary= only at routing time, then =Execution= o After the pilot, evaluate: did the savings show up in real session token use? Did the structure constrain the workflow expressiveness too much? If yes to savings and no to constraint, expand to the next-largest workflows. If not, document why and stop. Don't templatize universally — shorter workflows don't need tiering. -** TODO [#C] Canonical/mirror drift detection via pre-commit hook or =make sync-check= :feature:quick:solo: +** DONE [#C] Canonical/mirror drift detection via pre-commit hook or =make sync-check= :feature:quick:solo: +CLOSED: [2026-05-28 Thu] :PROPERTIES: :CREATED: [2026-05-28 Thu] :LAST_REVIEWED: 2026-05-28 |
