From f69dc22c80156f92c6e3519e87bc014558d77dbf Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Fri, 31 Jul 2026 13:36:21 -0500 Subject: fix(sync): narrow the dirty-rulesets guard to per-file excludes One uncommitted file under the synced template paths used to skip all three rsyncs for every project. On 2026-07-30 that froze every template everywhere for a full day, and five workflow files went stale in one project alone with nothing reporting it. The blast radius had no relation to what I was editing. Each dirty path now becomes an --exclude on its own rsync. rsync honors an exclude on both sides, so a withheld file is neither overwritten nor deleted downstream. Every clean file still propagates. A dirty protocols.org skips only its own single-file transfer. A rename withholds both names, since sweeping the old copy would delete a file the project still runs mid-rename. The run now names what it held back, so a project can see it's one file behind. The behind-upstream guard stays all-or-nothing. That staleness lives in the destination, so there's no single source file to narrow to. I verified it against the real checkout mid-change: 48 workflows propagated while the two files dirty at the time were withheld and named. The same run under the old guard would have synced nothing. --- claude-templates/.ai/scripts/sync-templates | 88 +++++++++++++---- .../.ai/scripts/tests/sync-templates.bats | 104 +++++++++++++++++++-- 2 files changed, 167 insertions(+), 25 deletions(-) (limited to 'claude-templates') diff --git a/claude-templates/.ai/scripts/sync-templates b/claude-templates/.ai/scripts/sync-templates index c4db212..b9769f3 100755 --- a/claude-templates/.ai/scripts/sync-templates +++ b/claude-templates/.ai/scripts/sync-templates @@ -16,16 +16,23 @@ # Output: one line naming the outcome, matching the previous inline wording # Exit: 0 always, as the inline block did — the outcome is on stdout # -# Two guards decide whether the sync runs at all: +# Two guards, and they work differently. One withholds files; the other skips +# the whole run: # -# Rulesets dirty under the synced paths → skip. rsync -a --delete copies the -# working tree by disk presence, so an in-flight edit in rulesets would land -# downstream as drift the project never authored. +# Rulesets dirty under the synced paths → withhold exactly those files. +# rsync -a --delete copies the working tree by disk presence, so an in-flight +# edit in rulesets would otherwise land downstream as drift the project never +# authored. Each dirty path becomes an --exclude, which rsync honors on both +# sides: the file is neither overwritten nor deleted downstream, and every +# clean file still propagates. This was a global skip until 2026-07-31, which +# meant one uncommitted file in rulesets froze every template for every +# project until it was committed. # -# Project branch behind its upstream → skip. Syncing onto a stale committed -# .ai/ baseline measures the diff against old content, so it comes out huge -# and conflicts once the branch reconciles to an upstream that already -# carries the newer templates. +# Project branch behind its upstream → skip the whole sync. Syncing onto a +# stale committed .ai/ baseline measures the diff against old content, so it +# comes out huge and conflicts once the branch reconciles to an upstream that +# already carries the newer templates. This one stays all-or-nothing because +# the staleness is in the destination, not in any particular source file. # # The rulesets location is injectable (SYNC_RULESETS_DIR) so the guards can be # exercised against a fixture instead of the real checkout. @@ -38,11 +45,59 @@ cd "$proj" || { exit 0 } -synced_dirty=$(cd "$rs" && git status --porcelain -- \ +# The dirty set under the synced paths, one repo-relative path per line. +# core.quotePath=false keeps a non-ASCII filename literal instead of \xNN-escaped, +# so the path we build an --exclude from is the path on disk. +synced_dirty=$(cd "$rs" && git -c core.quotePath=false status --porcelain -- \ claude-templates/.ai/protocols.org \ claude-templates/.ai/workflows/ \ claude-templates/.ai/scripts/ 2>/dev/null) +# Turn that set into per-rsync --exclude flags rather than a global skip. An +# excluded path is neither overwritten nor deleted on the receiving side, so an +# in-flight edit stays in-flight while every file it doesn't touch propagates +# normally. The all-or-nothing skip this replaces is what caused the 2026-07-30 +# outage: one uncommitted workflow file withheld every template from every +# project for a full day. +protocols_dirty=0 +wf_excludes=() +sc_excludes=() +withheld=() + +while IFS= read -r line; do + [ -z "$line" ] && continue + path="${line:3}" + # A rename reports "old -> new". Withhold both sides: the new name is + # half-landed, and sweeping the old copy downstream would delete a file the + # project still runs while the rename sits uncommitted. + if [[ "$path" == *" -> "* ]]; then + paths=("${path%% -> *}" "${path##* -> }") + else + paths=("$path") + fi + for p in "${paths[@]}"; do + p="${p%\"}"; p="${p#\"}" + case "$p" in + claude-templates/.ai/protocols.org) + # A single-file rsync has nothing to exclude within, so this one + # transfer is skipped outright while the other two still run. + protocols_dirty=1 + withheld+=("$p") + ;; + claude-templates/.ai/workflows/*) + # Leading / anchors the pattern to the transfer root, so a dirty + # workflows/foo.org can't also suppress scripts/tests/foo.org. + wf_excludes+=("--exclude=/${p#claude-templates/.ai/workflows/}") + withheld+=("$p") + ;; + claude-templates/.ai/scripts/*) + sc_excludes+=("--exclude=/${p#claude-templates/.ai/scripts/}") + withheld+=("$p") + ;; + esac + done +done <<< "$synced_dirty" + # behind==0 (up-to-date or ahead-only) means HEAD contains all of upstream, so # the baseline is current. No upstream (new/unpushed branch) → rev-list fails → # proj_behind stays 0 → the sync runs. @@ -53,23 +108,24 @@ if [ -d .git ]; then && proj_behind=1 fi -if [ -n "$synced_dirty" ]; then - echo "rulesets has uncommitted changes under the synced template paths — skipping .ai/ sync this session (catches up when rulesets is clean):" - echo "$synced_dirty" | sed 's/^/ /' -elif [ "$proj_behind" -eq 1 ]; then +if [ "$proj_behind" -eq 1 ]; then echo "project branch is behind upstream — skipping .ai/ sync this session (templates never land on a stale baseline; the sync runs once the branch is current)" else - rsync -a "$rs/claude-templates/.ai/protocols.org" .ai/protocols.org - rsync -a --delete "$rs/claude-templates/.ai/workflows/" .ai/workflows/ + [ "$protocols_dirty" -eq 0 ] && rsync -a "$rs/claude-templates/.ai/protocols.org" .ai/protocols.org + rsync -a --delete "${wf_excludes[@]}" "$rs/claude-templates/.ai/workflows/" .ai/workflows/ # Running rulesets' own pytest leaves these in the canonical scripts/tests/, # and rsync -a copies by disk presence regardless of .gitignore, so without # the excludes every project's tree collects machine-specific cache files. rsync -a --delete --exclude='__pycache__' --exclude='.pytest_cache' --exclude='*.pyc' \ - "$rs/claude-templates/.ai/scripts/" .ai/scripts/ + "${sc_excludes[@]}" "$rs/claude-templates/.ai/scripts/" .ai/scripts/ # Known false-success path, inherited and characterized rather than fixed # here: this line prints unconditionally, so a run where all three rsyncs # failed (an absent canonical source, say) still reports a successful sync. # A last-synced manifest must not be written from this branch as it stands — # it would stamp success onto a sync that did nothing. echo ".ai/ synced from templates" + if [ ${#withheld[@]} -gt 0 ]; then + echo " withheld — uncommitted in rulesets, lands once committed:" + printf ' %s\n' "${withheld[@]}" + fi fi diff --git a/claude-templates/.ai/scripts/tests/sync-templates.bats b/claude-templates/.ai/scripts/tests/sync-templates.bats index e4e92cf..6651c99 100644 --- a/claude-templates/.ai/scripts/tests/sync-templates.bats +++ b/claude-templates/.ai/scripts/tests/sync-templates.bats @@ -104,35 +104,121 @@ _mk_repo() { # --- guard one: rulesets dirty under the synced paths -------------------------- -@test "a dirty synced path in rulesets skips the sync and names the file" { +@test "a dirty file is withheld from the sync and named in the output" { 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" == *"withheld"* ]] [[ "$output" == *"startup.org"* ]] - # Nothing propagated — this is the whole-window blast radius from 2026-07-30. + # The in-flight edit still must not land downstream. [ "$(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. +@test "ONE dirty file no longer blocks the other two rsyncs" { + # The 2026-07-30 incident, inverted. An edit to a workflow file used to + # withhold protocols.org and every script for every project; now it withholds + # only itself. 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")" = "canonical protocols" ] + [ "$(cat "$PROJ/.ai/scripts/helper")" = "canonical helper" ] +} + +@test "a dirty file's clean siblings under the SAME path still sync" { + printf 'canonical wrap\n' > "$RS/claude-templates/.ai/workflows/wrap.org" + git -C "$RS" add -A && git -C "$RS" commit -q -m 'add wrap' + printf 'in-flight edit\n' >> "$RS/claude-templates/.ai/workflows/startup.org" + printf 'stale wrap\n' > "$PROJ/.ai/workflows/wrap.org" + run bash "$SYNC" "$PROJ" + [ "$status" -eq 0 ] + # The narrowing is per-file, not per-directory: only startup.org is held back. + [ "$(cat "$PROJ/.ai/workflows/wrap.org")" = "canonical wrap" ] +} + +@test "an excluded file is not deleted by --delete either" { + # rsync honors --exclude on both sides, so a withheld file that exists + # downstream must survive the run rather than being swept as a stray. + printf 'in-flight edit\n' >> "$RS/claude-templates/.ai/workflows/startup.org" + printf 'project copy\n' > "$PROJ/.ai/workflows/startup.org" + run bash "$SYNC" "$PROJ" + [ "$status" -eq 0 ] + [ -e "$PROJ/.ai/workflows/startup.org" ] + [ "$(cat "$PROJ/.ai/workflows/startup.org")" = "project copy" ] +} + +@test "a dirty protocols.org withholds only itself; workflows and scripts sync" { + printf 'in-flight edit\n' >> "$RS/claude-templates/.ai/protocols.org" + printf 'stale protocols\n' > "$PROJ/.ai/protocols.org" + printf 'stale startup\n' > "$PROJ/.ai/workflows/startup.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/workflows/startup.org")" = "canonical startup" ] + [ "$(cat "$PROJ/.ai/scripts/helper")" = "canonical helper" ] +} + +@test "dirty files across two synced paths withhold both, sync the third" { + printf 'in-flight\n' >> "$RS/claude-templates/.ai/workflows/startup.org" + printf 'in-flight\n' >> "$RS/claude-templates/.ai/scripts/helper" + printf 'stale startup\n' > "$PROJ/.ai/workflows/startup.org" + printf 'stale helper\n' > "$PROJ/.ai/scripts/helper" + printf 'stale protocols\n' > "$PROJ/.ai/protocols.org" + run bash "$SYNC" "$PROJ" + [ "$status" -eq 0 ] + [ "$(cat "$PROJ/.ai/workflows/startup.org")" = "stale startup" ] [ "$(cat "$PROJ/.ai/scripts/helper")" = "stale helper" ] + [ "$(cat "$PROJ/.ai/protocols.org")" = "canonical protocols" ] } -@test "an untracked file under a synced path also blocks the sync" { +@test "an untracked file under a synced path is withheld, not blocking" { printf 'new template\n' > "$RS/claude-templates/.ai/workflows/brand-new.org" + printf 'stale protocols\n' > "$PROJ/.ai/protocols.org" + run bash "$SYNC" "$PROJ" + [ "$status" -eq 0 ] + [[ "$output" == *"withheld"* ]] + # An unfinished new template must not ship half-written... + [ ! -e "$PROJ/.ai/workflows/brand-new.org" ] + # ...and must not hold back everything else. + [ "$(cat "$PROJ/.ai/protocols.org")" = "canonical protocols" ] +} + +@test "an untracked DIRECTORY under a synced path is withheld whole" { + # git collapses an untracked dir to one porcelain line with a trailing slash + # ("?? .../plugins/"), so the exclude has to match the directory rather than + # the files inside it. A half-written plugin dir must not ship. + mkdir -p "$RS/claude-templates/.ai/workflows/plugins" + printf 'half written\n' > "$RS/claude-templates/.ai/workflows/plugins/new.org" + printf 'stale protocols\n' > "$PROJ/.ai/protocols.org" + run bash "$SYNC" "$PROJ" + [ "$status" -eq 0 ] + [ ! -e "$PROJ/.ai/workflows/plugins" ] + [ "$(cat "$PROJ/.ai/protocols.org")" = "canonical protocols" ] +} + +@test "a file dirty in the index (staged, uncommitted) is withheld too" { + printf 'staged edit\n' >> "$RS/claude-templates/.ai/workflows/startup.org" + git -C "$RS" add claude-templates/.ai/workflows/startup.org + printf 'stale\n' > "$PROJ/.ai/workflows/startup.org" + run bash "$SYNC" "$PROJ" + [ "$status" -eq 0 ] + [ "$(cat "$PROJ/.ai/workflows/startup.org")" = "stale" ] +} + +@test "a renamed template withholds both the old and the new path" { + git -C "$RS" mv claude-templates/.ai/workflows/startup.org \ + claude-templates/.ai/workflows/renamed.org + printf 'stale startup\n' > "$PROJ/.ai/workflows/startup.org" run bash "$SYNC" "$PROJ" [ "$status" -eq 0 ] - [[ "$output" == *"uncommitted changes under the synced template paths"* ]] + # The new name must not ship mid-rename, and the old copy must not be swept + # while the rename is still uncommitted. + [ ! -e "$PROJ/.ai/workflows/renamed.org" ] + [ "$(cat "$PROJ/.ai/workflows/startup.org")" = "stale startup" ] } @test "rulesets dirt OUTSIDE the synced paths does not block the sync" { -- cgit v1.2.3