aboutsummaryrefslogtreecommitdiff
path: root/.ai
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-07-31 13:36:21 -0500
committerCraig Jennings <c@cjennings.net>2026-07-31 13:36:21 -0500
commitf69dc22c80156f92c6e3519e87bc014558d77dbf (patch)
tree6cee5efc1a2822abeb114ad90e25438426aca7b7 /.ai
parenta212eeb7c92f291045344f2769c9a7f85f2a38d5 (diff)
downloadrulesets-f69dc22c80156f92c6e3519e87bc014558d77dbf.tar.gz
rulesets-f69dc22c80156f92c6e3519e87bc014558d77dbf.zip
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.
Diffstat (limited to '.ai')
-rwxr-xr-x.ai/scripts/sync-templates88
-rw-r--r--.ai/scripts/tests/sync-templates.bats104
2 files changed, 167 insertions, 25 deletions
diff --git a/.ai/scripts/sync-templates b/.ai/scripts/sync-templates
index c4db212..b9769f3 100755
--- a/.ai/scripts/sync-templates
+++ b/.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/.ai/scripts/tests/sync-templates.bats b/.ai/scripts/tests/sync-templates.bats
index e4e92cf..6651c99 100644
--- a/.ai/scripts/tests/sync-templates.bats
+++ b/.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" {