muse-crew 0.14.0 → 0.14.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,340 @@
1
+ #!/bin/bash
2
+ # test-detached-integrate.sh — behavioral regression tests for the
3
+ # detached-HEAD Integrate fix (room #21, J3, 2026-09-19).
4
+ #
5
+ # The defect: cmd_integrate ran require_clean_main, whose unconditional
6
+ # `git checkout main` abandoned a detached-HEAD room line (reviewed SHA +
7
+ # crew-init commit). Publish then failed closed on ancestry and the journey
8
+ # parked. The fix: the integration target is the repo's current checkout
9
+ # (branch name, or HEAD when detached); nothing in the lifecycle ever
10
+ # checks out a branch.
11
+ #
12
+ # Fixture A (detached HEAD — the room #21 shape):
13
+ # reviewed commit A, detached HEAD, crew-init commit B on top, task
14
+ # branch from B with work commit C. Integrate must:
15
+ # 1. report integration-target = HEAD
16
+ # 2. MERGE with exit 0
17
+ # 3. leave HEAD detached (no checkout happened)
18
+ # 4. keep A (reviewed SHA) and B (crew-init) as ancestors of HEAD —
19
+ # the exact J3 failure was B being abandoned
20
+ # 5. land C (the task work) as an ancestor of HEAD via a real merge
21
+ # commit with two parents
22
+ # 6. NOT move the main branch (it still points at A)
23
+ # 7. write integration_target=HEAD in the merge record
24
+ # 8. verify-merge → VERIFIED
25
+ # 9. push-target → PUSH_SKIPPED (detached HEAD has no remote counterpart)
26
+ #
27
+ # Fixture B (branch — the old behavior is preserved):
28
+ # repo on main with a local bare origin. Integrate merges, reconciles,
29
+ # and pushes inline; push-target also PUSHEDs to origin.
30
+ #
31
+ # Fixture C (detached HEAD, live-tip verify-merge — 2026-09-19 REVIEW):
32
+ # verify-merge must check the record against the LIVE HEAD, never the
33
+ # record's word for the tip: a detached line reset past the merge does
34
+ # NOT verify; with the branch gone it verifies only when the recorded
35
+ # commit is an ancestor of the live tip.
36
+ #
37
+ # Fixture D (branch, no remote — 2026-09-19 REVIEW blocker 3):
38
+ # the old push-target ran `git push origin` with no origin → exit 128
39
+ # mislabeled PUSH_REJECTED. Now the inline push fails soft with
40
+ # NO_REMOTE_PUSH and exit 0.
41
+ #
42
+ # Fixture E (staged-base guard — 2026-09-19 REVIEW #10):
43
+ # CREW_STAGED_BASE naming a non-ancestor fails closed with
44
+ # STAGED_BASE_MISMATCH before any merge; the true staged base proceeds
45
+ # with STAGED_BASE_OK.
46
+ #
47
+ # Fixture F (merged-but-unpushed retry recovery — 2026-09-19 REVIEW):
48
+ # a prior attempt merged but never pushed (lock released to simulate the
49
+ # crash): re-running integrate takes the lock and pushes instead of
50
+ # parking on MERGED_EMPTY.
51
+ #
52
+ # Hermetic: scratch dirs under /tmp only, removed at start (and end).
53
+ set -u
54
+
55
+ REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
56
+ LIFECYCLE="$REPO_DIR/lib/worktree-lifecycle.sh"
57
+
58
+ fail() { echo "FAIL: $1"; exit 1; }
59
+
60
+ T=/tmp/crew-detached-integrate-test
61
+ rm -rf "$T"
62
+ mkdir -p "$T"
63
+
64
+ # ---------------- Fixture A: detached HEAD ----------------
65
+ A_DIR="$T/fixture-a"
66
+ mkdir -p "$A_DIR"
67
+ cd "$A_DIR"
68
+ git init -q -b main .
69
+ git config user.email test@test.t
70
+ git config user.name test
71
+ echo reviewed > f.txt
72
+ git add -A
73
+ git commit -qm "reviewed base"
74
+ A_SHA=$(git rev-parse HEAD)
75
+
76
+ # Detach at the reviewed SHA (clean-room staging pins it), then commit the
77
+ # crew-init stamp on top — the detached line Integrate must not abandon.
78
+ git checkout -q --detach HEAD
79
+ echo crew-init >> f.txt
80
+ git commit -qam "crew-init: stamp provenance"
81
+ B_SHA=$(git rev-parse HEAD)
82
+ git symbolic-ref -q HEAD >/dev/null && fail "fixture A: HEAD should be detached"
83
+
84
+ export CREW_REPO="$A_DIR"
85
+ export CREW_HOME="$A_DIR/home"
86
+ export CREW_LIB="$REPO_DIR/lib" # merge-lock.sh lives in the repo's lib, not the scratch crew home
87
+ tid="detached1"
88
+
89
+ # 1. integration-target resolves to HEAD
90
+ out=$(bash "$LIFECYCLE" integration-target) || fail "integration-target exited non-zero: $out"
91
+ [ "$out" = "HEAD" ] || fail "integration-target: expected HEAD, got: $out"
92
+
93
+ # prepare + task work
94
+ out=$(bash "$LIFECYCLE" prepare "$tid") || fail "prepare exited non-zero: $out"
95
+ echo task-work >> "$A_DIR/.worktrees/$tid/f.txt"
96
+ (cd "$A_DIR/.worktrees/$tid" && git commit -qam "task work")
97
+ C_SHA=$(cd "$A_DIR/.worktrees/$tid" && git rev-parse HEAD)
98
+
99
+ # 2. integrate
100
+ out=$(bash "$LIFECYCLE" integrate "$tid" "merge: $tid" 2>&1) || fail "integrate exited non-zero: $out"
101
+ echo "$out" | grep -q '^MERGED:' || fail "integrate: expected MERGED, got: $out"
102
+ echo "$out" | grep -q 'NO_REMOTE_RECONCILE: detached HEAD' || fail "integrate: expected NO_REMOTE_RECONCILE, got: $out"
103
+
104
+ # 3. HEAD is still detached — no checkout happened
105
+ git symbolic-ref -q HEAD >/dev/null && fail "integrate checked out a branch: HEAD is now attached"
106
+
107
+ # 4. the detached line survived: reviewed SHA and crew-init are ancestors
108
+ git merge-base --is-ancestor "$A_SHA" HEAD || fail "reviewed SHA $A_SHA is no longer an ancestor of HEAD"
109
+ git merge-base --is-ancestor "$B_SHA" HEAD || fail "crew-init commit $B_SHA was abandoned (the J3 defect)"
110
+
111
+ # 5. the task work landed via a real merge commit
112
+ git merge-base --is-ancestor "$C_SHA" HEAD || fail "task commit $C_SHA is not an ancestor of HEAD"
113
+ MERGE_SHA=$(git rev-parse HEAD)
114
+ parents=$(git rev-list --parents -n 1 "$MERGE_SHA" | wc -w)
115
+ [ "$parents" -eq 3 ] || fail "expected a 2-parent merge commit, got $parents words in: $(git rev-list --parents -n 1 "$MERGE_SHA")"
116
+
117
+ # 6. main was not touched
118
+ MAIN_SHA=$(git rev-parse main)
119
+ [ "$MAIN_SHA" = "$A_SHA" ] || fail "main moved during integrate: $MAIN_SHA != reviewed $A_SHA"
120
+
121
+ # 7. merge record names the target
122
+ rec=$(bash "$LIFECYCLE" merge-record "$tid")
123
+ echo "$rec" | grep -q '^integration_target=HEAD$' || fail "merge record missing integration_target=HEAD: $rec"
124
+
125
+ # 8. verify-merge
126
+ out=$(bash "$LIFECYCLE" verify-merge "$tid") || fail "verify-merge exited non-zero: $out"
127
+ echo "$out" | grep -q '^VERIFIED:' || fail "verify-merge: expected VERIFIED, got: $out"
128
+
129
+ # 9. push-target skips loudly on a detached HEAD
130
+ out=$(bash "$LIFECYCLE" push-target "$tid") || fail "push-target exited non-zero: $out"
131
+ echo "$out" | grep -q '^PUSH_SKIPPED: detached HEAD' || fail "push-target: expected PUSH_SKIPPED, got: $out"
132
+
133
+ echo "fixture A (detached HEAD): all checks passed"
134
+
135
+ # ---------------- Fixture C: verify-merge checks the record against the LIVE tip ----------------
136
+ # (2026-09-19 REVIEW blockers 1+2: the old verify-merge compared the merge
137
+ # record against itself for target=HEAD — is-ancestor(X, X) is a tautology
138
+ # that certified merges no longer on the line.)
139
+ # Still in fixture A's repo ($A_DIR); task detached1 is merged at $MERGE_SHA.
140
+ cd "$A_DIR"
141
+ export CREW_REPO="$A_DIR"
142
+ export CREW_HOME="$A_DIR/home"
143
+
144
+ # C1: reset the detached line backward past the merge — the merge is no
145
+ # longer on the line. verify-merge must NOT verify.
146
+ git reset -q --hard "$B_SHA"
147
+ out=$(bash "$LIFECYCLE" verify-merge "$tid" 2>&1) && fail "C1: verify-merge exited 0 for a merge no longer on the line: $out"
148
+ echo "$out" | grep -q '^VERIFIED:' && fail "C1: VERIFIED a reset-away merge: $out"
149
+ echo "C1: verify-merge rejects a detached line reset past the merge"
150
+
151
+ # C2: restore the merge, remove the worktree, delete the task branch
152
+ # (merge-lease reclaim path). verify-merge must VERIFY from the record —
153
+ # but only because the recorded commit is an ancestor of the LIVE tip.
154
+ git reset -q --hard "$MERGE_SHA"
155
+ BRANCH=$(bash "$LIFECYCLE" resolve-branch "$tid") || fail "C2: resolve-branch failed"
156
+ git worktree remove --force "$A_DIR/.worktrees/$tid" || fail "C2: worktree remove failed"
157
+ git branch -q -D "$BRANCH" || fail "C2: could not delete task branch $BRANCH"
158
+ out=$(bash "$LIFECYCLE" verify-merge "$tid" 2>&1) || fail "C2: verify-merge exited non-zero: $out"
159
+ echo "$out" | grep -q '^VERIFIED:' || fail "C2: expected VERIFIED via record fallback, got: $out"
160
+ echo "$out" | grep -q 'gone but recorded merge commit' || fail "C2: expected the branch-gone record path, got: $out"
161
+ echo "fixture C (live-tip verify-merge): all checks passed"
162
+
163
+ # ---------------- Fixture B: branch with a local origin ----------------
164
+ B_DIR="$T/fixture-b"
165
+ REMOTE="$T/remote.git"
166
+ mkdir -p "$B_DIR"
167
+ git init -q --bare "$REMOTE"
168
+ cd "$B_DIR"
169
+ git init -q -b main .
170
+ git config user.email test@test.t
171
+ git config user.name test
172
+ echo base > f.txt
173
+ git add -A
174
+ git commit -qm "base"
175
+ git remote add origin "$REMOTE"
176
+ git push -q origin main
177
+
178
+ export CREW_REPO="$B_DIR"
179
+ export CREW_HOME="$B_DIR/home"
180
+ tid="branch1"
181
+
182
+ out=$(bash "$LIFECYCLE" integration-target) || fail "B integration-target exited non-zero"
183
+ [ "$out" = "main" ] || fail "B integration-target: expected main, got: $out"
184
+
185
+ out=$(bash "$LIFECYCLE" prepare "$tid") || fail "B prepare exited non-zero: $out"
186
+ echo task-work >> "$B_DIR/.worktrees/$tid/f.txt"
187
+ (cd "$B_DIR/.worktrees/$tid" && git commit -qam "task work")
188
+
189
+ out=$(bash "$LIFECYCLE" integrate "$tid" "merge: $tid" 2>&1) || fail "B integrate exited non-zero: $out"
190
+ echo "$out" | grep -q '^MERGED:' || fail "B integrate: expected MERGED, got: $out"
191
+ echo "$out" | grep -q '^RECONCILED: merged origin/main into main' || fail "B integrate: expected RECONCILED, got: $out"
192
+
193
+ rec=$(bash "$LIFECYCLE" merge-record "$tid")
194
+ echo "$rec" | grep -q '^integration_target=main$' || fail "B merge record missing integration_target=main: $rec"
195
+
196
+ out=$(bash "$LIFECYCLE" verify-merge "$tid") || fail "B verify-merge exited non-zero: $out"
197
+ echo "$out" | grep -q '^VERIFIED:' || fail "B verify-merge: expected VERIFIED, got: $out"
198
+
199
+ # push-target pushes the branch to the local origin
200
+ out=$(bash "$LIFECYCLE" push-target "$tid" 2>&1) || fail "B push-target exited non-zero: $out"
201
+ echo "$out" | grep -q '^PUSHED: origin/main' || fail "B push-target: expected PUSHED, got: $out"
202
+ LOCAL_MAIN=$(git rev-parse main)
203
+ REMOTE_MAIN=$(git --git-dir="$REMOTE" rev-parse refs/heads/main)
204
+ [ "$LOCAL_MAIN" = "$REMOTE_MAIN" ] || fail "B origin/main not updated: $REMOTE_MAIN != $LOCAL_MAIN"
205
+
206
+ echo "fixture B (branch + origin): all checks passed"
207
+
208
+ # ---------------- Fixture D: branch target, no remote ----------------
209
+ # (2026-09-19 REVIEW blocker 3: push-target ran `git push origin` with no
210
+ # origin → exit 128 mislabeled PUSH_REJECTED. Now: fail-soft NO_REMOTE_PUSH.)
211
+ D_DIR="$T/fixture-d"
212
+ mkdir -p "$D_DIR"
213
+ cd "$D_DIR"
214
+ git init -q -b main .
215
+ git config user.email test@test.t
216
+ git config user.name test
217
+ echo base > f.txt
218
+ git add -A
219
+ git commit -qm "base"
220
+ export CREW_REPO="$D_DIR"
221
+ # CREW_HOME lives OUTSIDE the repo (as in real rooms): the lock files are
222
+ # crew state, and nesting them in the repo would trip the integrate
223
+ # preflight on the retry in fixture F.
224
+ export CREW_HOME="$T/home-d"
225
+ tid="noremote1"
226
+
227
+ out=$(bash "$LIFECYCLE" prepare "$tid") || fail "D prepare failed: $out"
228
+ echo work >> "$D_DIR/.worktrees/$tid/f.txt"
229
+ (cd "$D_DIR/.worktrees/$tid" && git commit -qam "work")
230
+
231
+ # integrate pushes inline now: MERGED + NO_REMOTE (reconcile) + NO_REMOTE_PUSH
232
+ out=$(bash "$LIFECYCLE" integrate "$tid" "merge: $tid" 2>&1) || fail "D integrate exited non-zero: $out"
233
+ echo "$out" | grep -q '^MERGED:' || fail "D: expected MERGED, got: $out"
234
+ echo "$out" | grep -q '^NO_REMOTE: no origin/main' || fail "D: expected NO_REMOTE reconcile skip, got: $out"
235
+ echo "$out" | grep -q '^NO_REMOTE_PUSH: no origin remote configured' || fail "D: expected NO_REMOTE_PUSH, got: $out"
236
+
237
+ # push-target directly (record exists, lock still held from integrate):
238
+ # same fail-soft, exit 0
239
+ out=$(bash "$LIFECYCLE" push-target "$tid" 2>&1) || fail "D push-target exited non-zero: $out"
240
+ echo "$out" | grep -q '^NO_REMOTE_PUSH' || fail "D push-target: expected NO_REMOTE_PUSH, got: $out"
241
+
242
+ echo "fixture D (branch, no remote): all checks passed"
243
+
244
+ # ---------------- Fixture E: staged-base guard ----------------
245
+ # (2026-09-19 REVIEW #10: a stray checkout redirecting the line must fail
246
+ # closed at Integrate — before the merge lands on the wrong line.)
247
+ E_DIR="$T/fixture-e"
248
+ mkdir -p "$E_DIR"
249
+ cd "$E_DIR"
250
+ git init -q -b main .
251
+ git config user.email test@test.t
252
+ git config user.name test
253
+ echo reviewed > f.txt
254
+ git add -A
255
+ git commit -qm "reviewed"
256
+ E_BASE=$(git rev-parse HEAD)
257
+ git checkout -q --detach HEAD
258
+ echo init >> f.txt
259
+ git commit -qam "crew-init"
260
+ E_LINE=$(git rev-parse HEAD)
261
+ export CREW_REPO="$E_DIR"
262
+ export CREW_HOME="$E_DIR/home"
263
+ tid="stagedbase1"
264
+
265
+ out=$(bash "$LIFECYCLE" prepare "$tid") || fail "E prepare failed: $out"
266
+ echo work >> "$E_DIR/.worktrees/$tid/f.txt"
267
+ (cd "$E_DIR/.worktrees/$tid" && git commit -qam "work")
268
+
269
+ # A base that is NOT an ancestor of the detached line: a sibling commit on
270
+ # main (the shape a stray `git checkout main` leaves behind).
271
+ git checkout -q main
272
+ echo stray >> f.txt
273
+ git commit -qam "stray main commit"
274
+ STRAY_SHA=$(git rev-parse HEAD)
275
+ git checkout -q --detach "$E_LINE"
276
+
277
+ out=$(CREW_STAGED_BASE="$STRAY_SHA" bash "$LIFECYCLE" integrate "$tid" "merge: $tid" 2>&1) && \
278
+ fail "E: integrate with a non-ancestor staged base should fail: $out"
279
+ echo "$out" | grep -q '^STAGED_BASE_MISMATCH' || fail "E: expected STAGED_BASE_MISMATCH, got: $out"
280
+ [ "$(git rev-parse HEAD)" = "$E_LINE" ] || fail "E: HEAD moved despite STAGED_BASE_MISMATCH"
281
+
282
+ # The real staged base is an ancestor — the merge proceeds.
283
+ out=$(CREW_STAGED_BASE="$E_BASE" bash "$LIFECYCLE" integrate "$tid" "merge: $tid" 2>&1) || \
284
+ fail "E: integrate with the true staged base failed: $out"
285
+ echo "$out" | grep -q '^STAGED_BASE_OK' || fail "E: expected STAGED_BASE_OK, got: $out"
286
+ echo "$out" | grep -q '^MERGED:' || fail "E: expected MERGED, got: $out"
287
+
288
+ echo "fixture E (staged-base guard): all checks passed"
289
+
290
+ # ---------------- Fixture F: merged-but-unpushed retry recovery ----------------
291
+ # (2026-09-19 REVIEW: a prior attempt's merge landed but its push never
292
+ # completed — the retry must push instead of parking on MERGED_EMPTY.)
293
+ # Reuse fixture D: task noremote1 is merged (recorded commit == live HEAD)
294
+ # with no origin. Release the lock to simulate the crashed attempt, then
295
+ # re-run integrate: ahead==0 and recorded==live → acquire + push now.
296
+ cd "$D_DIR"
297
+ export CREW_REPO="$D_DIR"
298
+ export CREW_HOME="$T/home-d"
299
+ "$REPO_DIR/lib/merge-lock.sh" release "noremote1" >/dev/null 2>&1 || fail "F: lock release failed"
300
+ out=$(bash "$LIFECYCLE" integrate "noremote1" "merge: noremote1" 2>&1) || \
301
+ fail "F integrate exited non-zero: $out"
302
+ echo "$out" | grep -q 'recovered: merge landed in a prior attempt' || \
303
+ fail "F: expected push recovery, got: $out"
304
+ echo "$out" | grep -q '^NO_REMOTE_PUSH' || fail "F: expected NO_REMOTE_PUSH in recovery, got: $out"
305
+
306
+ # F2: ancestor-based recovery — a prior attempt's push failed AFTER do_push's
307
+ # retry path added a reconcile merge on top of the recorded commit, then
308
+ # failed again. The record names M1 but live HEAD is M2 (M1's descendant).
309
+ # Equality-based recovery would miss this and wrongly report MERGED_EMPTY —
310
+ # a PASS with the push never completed.
311
+ "$REPO_DIR/lib/merge-lock.sh" release "noremote1" >/dev/null 2>&1 || fail "F2: lock release failed"
312
+ git commit -q --allow-empty -m "simulate: do_push retry-reconcile merge" || fail "F2: empty commit failed"
313
+ out=$(bash "$LIFECYCLE" integrate "noremote1" "merge: noremote1" 2>&1) || \
314
+ fail "F2 integrate exited non-zero: $out"
315
+ echo "$out" | grep -q 'recovered: merge landed in a prior attempt' || \
316
+ fail "F2: expected ancestor-based push recovery, got: $out"
317
+ echo "$out" | grep -q '^MERGED_EMPTY' && fail "F2: wrongly MERGED_EMPTY while the push never completed: $out"
318
+
319
+ echo "fixture F (merged-but-unpushed recovery): all checks passed"
320
+
321
+ # ---------------- Fixture G: push-target after MERGED_EMPTY ----------------
322
+ # (2026-09-19 REVIEW #8: MERGED_EMPTY takes no lock and writes no record —
323
+ # push-target must skip loudly, not report a misleading "lock lost".)
324
+ # Reuse fixture D's repo: a task with no commits ahead merges empty.
325
+ cd "$D_DIR"
326
+ export CREW_REPO="$D_DIR"
327
+ export CREW_HOME="$T/home-d"
328
+ tid="empty1"
329
+ out=$(bash "$LIFECYCLE" prepare "$tid") || fail "G prepare failed: $out"
330
+ # no commits in the worktree — nothing ahead of the target
331
+ out=$(bash "$LIFECYCLE" integrate "$tid" "merge: $tid" 2>&1) || fail "G integrate exited non-zero: $out"
332
+ echo "$out" | grep -q '^MERGED_EMPTY' || fail "G: expected MERGED_EMPTY, got: $out"
333
+ out=$(bash "$LIFECYCLE" push-target "$tid" 2>&1) || fail "G push-target exited non-zero: $out"
334
+ echo "$out" | grep -q 'no merge record' || fail "G: expected nothing-merged skip, got: $out"
335
+ echo "$out" | grep -qi 'lock lost' && fail "G: misleading lock-lost error: $out"
336
+
337
+ echo "fixture G (push-target after MERGED_EMPTY): all checks passed"
338
+
339
+ rm -rf "$T"
340
+ echo "PASS: detached-HEAD integrate fixtures"