1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
|
#!/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" ]
}
|