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
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
|
#!/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 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" == *"withheld"* ]]
[[ "$output" == *"startup.org"* ]]
# The in-flight edit still must not land downstream.
[ "$(cat "$PROJ/.ai/workflows/startup.org")" = "stale" ]
}
@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 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 ]
# 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" {
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" ]
}
|