muse-crew 0.14.1 → 0.14.3
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.
- package/AGENTS.md +1 -1
- package/docs/decisions/AGENTS.md +3 -0
- package/docs/decisions/publish-path.md +195 -6
- package/docs/decisions/workflow-core.md +1 -1
- package/docs/guide.md +3 -3
- package/docs/publish-verification.md +5 -2
- package/docs/reviews/critic-0143-redo.md +94 -0
- package/docs/reviews/critic-0143.md +108 -0
- package/lib/AGENTS.md +5 -3
- package/lib/crew-api.js +8 -6
- package/lib/publish-npm.sh +39 -2
- package/lib/retry-publish.js +5 -2
- package/lib/test-detached-integrate.sh +416 -0
- package/lib/test-publish-preflight.sh +94 -5
- package/lib/verify-publish.js +3 -4
- package/lib/worktree-lifecycle.sh +380 -94
- package/package.json +1 -1
- package/workflows/bugfix.js +67 -70
- package/workflows/chore.js +65 -68
- package/workflows/standard.js +67 -70
package/lib/AGENTS.md
CHANGED
|
@@ -7,12 +7,14 @@ Shell scripts for the crew's infrastructure. Called by workflow scripts, cron, a
|
|
|
7
7
|
- `schema.sql` — the crew-owned state schema: projects, tasks, poll_state, config, agent_sessions, events. Vocabularies enforced by CHECK constraints; `rejected` is a valid event type (the 2026-09-11 crash was a stored session whose event was rejected). Column names match the historical dashboard tables for a verbatim migration.
|
|
8
8
|
- `crew-release.sh` — immutable release manager: deploy, rollback, prune
|
|
9
9
|
- `merge-lock.sh` — serialized merge lock for concurrent agents: time-based holder lease (bug 2fc8f52f — an unexpired lease is held regardless of process liveness; only an expired lease may be broken). Requires both `CREW_REPO` and `CREW_HOME` (fail closed: BLOCKED, exit 2 when either is unset). Lock file is key=value: task_id, opaque holder identity (never a PID), acquired_at epoch, lease_seconds (default 600, override via MERGE_LOCK_LEASE_SECONDS). acquire/refresh/release/status/force-release; holder-only refresh and release; every op appends to $CREW_HOME/.merge-lock.log
|
|
10
|
-
- `worktree-lifecycle.sh` — the worktree lifecycle seam: prepare/cleanup/inspect/integrate/verify-merge/status/post-deploy/terminal-cleanup/refresh-lock/lock-status over git worktrees (`integrate` reconciles `origin
|
|
10
|
+
- `worktree-lifecycle.sh` — the worktree lifecycle seam: prepare/cleanup/inspect/integrate/verify-merge/status/post-deploy/terminal-cleanup/refresh-lock/lock-status over git worktrees (the integration target is the repo's current checkout — a branch name, or HEAD when detached — resolved by `integration_target()`; nothing in the lifecycle ever checks out a branch. `integrate` merges into the target, reconciles `origin/<target>` under the merge lock after the task merge — canary `a6d8b0c8`, 2026-09-11 — then pushes inline via `do_push` (no agent round-trip; 2026-09-19 REVIEW collapsed the old STEP-2 agent run, which added only a lock refresh). `do_push`: PUSHED (the refspec is always explicit — a branch pushes as `<branch>`, a detached HEAD as `HEAD:<destination>` where the destination is the remote's default branch from `resolve_push_destination()`; 2026-09-19 detached-HEAD audit REDO) / NO_REMOTE_PUSH (no origin — fail-soft, 2026-09-19 REVIEW: the old unconditional push exited 128 and mislabeled it); an unresolvable detached destination fails closed (never a silent skip, never a refusal of a knowable push); on push failure one fetch+merge retry while the lock is held, then fail closed without asserting a cause (never force-push). PUSH_SKIPPED survives only for the nothing-merged R5 path (no merge record + no lock held). Merged-but-unpushed retry recovery: a record whose merge commit IS the live HEAD takes the lock and pushes instead of reporting MERGED_EMPTY. `CREW_STAGED_BASE` guard (ferried from get-provenance): the line must descend from the staged publish base — STAGED_BASE_MISMATCH fails closed before the merge (room #21 J3 shape). `verify-merge` always re-resolves the target from the current checkout and checks the recorded commit against the LIVE tip — never the record's word for the tip (2026-09-19 REVIEW: the old read-back compared the record to itself for target=HEAD). A detached HEAD skips the inline reconcile loudly (NO_REMOTE_RECONCILE) — the inline reconcile needs a static branch target, so on detached the destination is resolved explicitly in do_push, which reconciles lazily on push failure (2026-09-19 detached-HEAD audit REDO; the old "local by design" rationale is rejected); `push-target` is the thin R5 wrapper over `do_push`: refreshes the lock, distinguishes nothing-merged (no record + no lock → loud skip) from lock-lost (fail closed). (`.worktrees/<id>`, branch `task/<id>`). Branch resolution order: the crew registry (`<repo>/.worktrees/.registry/<id>`), then the canonical `task/<full-id>` ref, then a `task/<id-prefix>` ref matched by strict prefix enumeration (task 4e1a1bba — a Build agent may create the branch with raw git from an abbreviated id, bypassing prepare; prefix names are tolerated, never created; ambiguous prefixes fail closed). `resolve-branch` prints the resolved branch for agent-side one-liners. Nothing reconstructs the branch name — every command resolves it. Prepare fails closed on a dirty integration target; cleanup is forgiving. `terminal-cleanup` is the run's last act at every park/fail boundary (called from `parkTask` in standard/bugfix/chore): releases the merge lock unconditionally and reclaims the worktree+branch only when the task branch is fully merged into the integration target — unmerged work is preserved for the human by design, and a dirty worktree is reported, never force-removed. `post-deploy` reports worktree removal honestly (a lying "removed" echo hid real leftovers — canary run 9, 2026-09-12). Requires both `CREW_REPO` and `CREW_HOME` (fail closed: BLOCKED, exit 2 when either is unset); `CREW_REPO` is exported so internal merge-lock.sh calls inherit the repo being worked on; `LIB_DIR` defaults to `$CREW_HOME/lib` (`CREW_LIB` override). `lock-status` reports the merge-lock state explicitly (`UNLOCKED`, or key=value: locked=true, task_id, holder, acquired_at, lease_seconds, age_seconds, remaining_seconds — always exit 0) so Publish can distinguish an empty-diff Integrate (no lock taken) from a refresh failure. post-deploy's `.worktrees/` guard is add-then-reset, never an all-negative pathspec: `git add -A -- ':!.worktrees/'` still exits 1 on git 2.43.0 against the crew's own gitignored non-empty `.worktrees/` (room #15 J3), killing post-deploy under `set -e` — a plain `add -A` never errors on ignored paths and the follow-up `git reset -q -- .worktrees/` keeps stray lock files out of the integration target even on pre-gitignore-entry repos.
|
|
11
|
+
- `test-detached-integrate.sh` — behavioral regression tests for the detached-HEAD Integrate fix (room #21 J3, 2026-09-19; extended 2026-09-19 REVIEW): fixture A stages a detached HEAD (reviewed SHA + crew-init commit) and proves integrate leaves HEAD detached, keeps the reviewed SHA and crew-init as ancestors, lands the task merge, never moves main, records `integration_target=HEAD`, verify-merge VERIFIEDs, and push-target reports NO_REMOTE_PUSH (fixture A has no origin); fixture B proves the branch path still merges, reconciles, and pushes inline to a local origin; fixture C proves verify-merge checks the record against the LIVE tip (a detached line reset past the merge does NOT verify; the branch-gone path verifies only when the recorded commit is an ancestor of the live tip); fixture D proves the no-remote branch path fails soft with NO_REMOTE_PUSH (exit 0); fixture E proves the CREW_STAGED_BASE guard (non-ancestor base → STAGED_BASE_MISMATCH before any merge; true base → STAGED_BASE_OK); fixture F proves merged-but-unpushed retry recovery (re-running integrate after a crashed push takes the lock and pushes instead of parking on MERGED_EMPTY); fixture H proves the detached-HEAD audit REDO contract (2026-09-19) on a detached checkout with a local origin: push-destination fails closed when origin/HEAD is unset (genuinely unknowable destination), otherwise resolves the remote default branch; integrate pushes inline as `HEAD:main` (`PUSHED: origin/main (refspec HEAD:main)`), the remote ref advances to the merge commit, local main stays at the reviewed base, HEAD stays detached, verify-merge VERIFIEDs, and push-target re-pushes idempotently. Scratch dirs under /tmp only; CREW_HOME lives outside the scratch repos (as in real rooms) so lock files never trip the integrate preflight.
|
|
11
12
|
- `test-worktree-backend.sh` — regression tests for the lifecycle script (validate, prepare/reuse, inspect, status, cleanup, idempotent cleanup, dirty-main preflight, integrate remote-reconcile with fixture sensitivity + no-remote fail-soft) on scratch repos
|
|
12
13
|
- `test-version-write.sh` — regression tests for the escape-preserving step-8 version write in publish-npm.sh (fixture: current package.json with the \\u2014 escape; extracts the shipped block by anchor)
|
|
13
14
|
- `test-publish-verify.sh` — regression tests for the retry-tolerant step-12 verification in publish-npm.sh (canary 5a027278): extracts the shipped block by anchor and runs it against a fake npm whose read replica lags (non-zero exits, then the old version, then the target) — requires convergence on success, fail-closed `PUBLISH_FAILED=verify` on exhaustion, and `--prefer-online` on every read
|
|
14
15
|
- `test-publish-skip.sh` — regression tests for the no-lock graceful publish skip (park 2026-09-11): `lock-status` returns exact `UNLOCKED`/`LOCKED by <holder> since <ts> (pid <pid>)`; a fake empty-diff integrate takes the MERGED_EMPTY branch and never takes the lock; the shipped step-5 block from publish-npm.sh (extracted by anchor) skips gracefully on UNLOCKED (no park, no `PUBLISH_COMPLETE`), follows the refresh path when a lock is held, and fails closed on a `lock-status` query failure; the gate sits inside the ALREADY_PUBLISHED=0 branch and mutation steps are gated on NO_LOCK_HELD=0
|
|
15
|
-
- `publish-npm.sh` — deterministic npm publish: lock refresh, release install, version write/commit, pack, registry publish, verify, push, post-deploy. Takes TARGET_VERSION as input; idempotent on retry/resume.
|
|
16
|
+
- `publish-npm.sh` — deterministic npm publish: preflight (npm capability + push-destination resolution — the push refspec is resolved before any mutation: a branch name, or `HEAD:<destination>` on a detached checkout via the lifecycle's `push-destination` command; a genuinely unknowable destination fails fast here with `PUBLISH_FAILED=env`, before any mutation — 2026-09-19 detached-HEAD audit REDO: detached publication is legitimate and must work, not refuse), lock refresh, release install, version write/commit, pack, registry publish, verify, push, post-deploy. Takes TARGET_VERSION as input; idempotent on retry/resume.
|
|
17
|
+
- `test-publish-preflight.sh` — regression tests for the npm publish capability preflight: the PREFLIGHT block extracted by anchor, helper -f/-r checks, placement, full-script runs (helper missing / credential missing / credential present + DRY_RUN / old helper without --check / real-helper probe), plus the detached-HEAD audit REDO cases (2026-09-19): a detached fixture repo with a local origin — origin/HEAD set + DRY_RUN=1 passes through to PUBLISH_DRY_RUN=1 (no refusal), origin/HEAD unset fails closed with PUBLISH_FAILED=env before any mutation, repo untouched; and the attached+no-origin case (critic F-A1: the anchor used to check no-origin only on the detached branch — attached+no-origin now fails closed pre-mutation too). Runs against scratch repos; the real-helper probe is credential-dependent (author machine only).
|
|
16
18
|
- `compose-evidence.py` — deterministic visual-evidence compositor (Pillow): pairs identical PNG stems from baseline/ and postchange/ dirs, emits `<stem>-sidebyside.png` and amplified-difference `<stem>-overlay.png` into composites/, prints `PAIR`/`SKIP` manifests. Byte-deterministic; nonzero exit on errors.
|
|
17
19
|
- `compose-evidence-caption.js` — deterministic QA-evidence caption composer (2026-09-14): given `--crew-home`, `--task-id`, and the resolved `--audit-dir` name, extracts the task title/description, the QA verdict, the `publish: verified` commit (or the task's project-scoped provenance record when it belongs to this task), and the merge commit's subject line from the project repo — and prints the out-of-context transition Eric asked for (problem → solution → evidence handoff) to stdout. The caller appends the desktop/mobile attachment lines after this caption. Verdict source (2026-09-16): Hazel's `task-evidence/<taskId>/postchange/verdict.json` is authoritative, then the last line of the append-only `verdicts.jsonl` ledger, then the newest legacy `visual_verdict:` note (the parent visual-verdict protocol was retired 2026-09-15). Honest labels: "provenance not yet stamped" when no commit is known, "no QA verdict recorded" when none exists, and the visual-protocol-unavailable note only when no verdict evidence exists at all, the newest `baseline:` note is `baseline: none`, AND captures exist (a `baseline: none` note means no baseline captures to compare against — it never overrules a real QA verdict; the note's premise is the post-deploy capture, so without captures it would fabricate evidence again). Capture guard (2026-09-17, room #14): the composer resolves the audit dir itself (`<home>/workspace/ts-spaces/<deploy_slug>/audits/<auditDir>/`, the same root crew-api.js and readback-disk.js resolve) and verifies `screenshot.png` + `screenshot-mobile.png` are files before claiming "desktop + mobile captures below" — a bare `--audit-dir` name is never trusted. Missing deploy_slug, missing dir, or missing PNGs gets the honest no-captures opener and Evidence line (terminal surfaces named explicitly: "terminal surface — QA judges the CLI, not pixels"); the caller only appends attachment lines when its own freshness check passes. "Latest" is by event timestamp, sorted explicitly. Exits non-zero on missing input.
|
|
18
20
|
- `edit-image.py` — deterministic pixel-level evidence editing (2026-09-15, Pillow): `crop --region x,y,w,h` (pixel-exact, rejects out-of-bounds), `zoom --factor <f> [--center x,y]` (nearest-neighbor, returns to source frame size), `label --text <caption>` (caption bar), `nup --cols <n> --in <a.png> --in <b.png> [--labels "a|b"]` (side-by-side grid). Byte-deterministic PNG output; no network, no time, no randomness. Derivatives supplement raw frames — the source frame stays archived.
|
|
@@ -31,7 +33,7 @@ Shell scripts for the crew's infrastructure. Called by workflow scripts, cron, a
|
|
|
31
33
|
- `update-watch.js` — deterministic automatic update watcher (2026-09-16, zero deps): `node update-watch.js --crew-home <path>` (missing arg → usage, exit 2; every other path exits 0). Watches the public npm registry (`npm view muse-crew version` pinned to `https://registry.npmjs.org/`) vs `crew-release.sh current` and files a `workflow: "upgrade"` task with `source: npm@<version>` when policy (`auto_update_crew`, `update_channel`) and channel gating allow; watches `git ls-remote origin HEAD` on the first `deploy_type=artifact` project vs `$CREW_HOME/.update-watch.json` and files a `workflow: "chore"` task carrying the mechanical dashboard-upgrade journey. Reads the `.crew-version` compatibility anchor at the new ref via `git fetch` + `git show <sha>:.crew-version` (never the working tree) and orders dashboard-led: a declared newer crew files the crew upgrade task FIRST and the dashboard task notes it follows the crew upgrade (declaration bypasses `update_channel`, not the `auto_update_crew=false` opt-out); a declared older crew skips the dashboard leg entirely as a human decision; a missing/invalid/unfetchable anchor fails open to the dashboard leg as today. Idempotency via the same state file (records at file time); check failures log to `$CREW_HOME/update-watch.log` and are never thrown. Safety: only files tasks — never deploys, never touches the artifact/config/scheduler. Run by the daily `crew-update-watch` cron through the `current` symlink (latest release); deliberately NOT in the lib-pinning `PIN_BASENAMES`.
|
|
32
34
|
- `gitignore.js` — deterministic .gitignore management for crew-owned paths (2026-09-17): the crew touches exactly one user-owned file outside `.orchestration/` — the repo's `.gitignore`. `ensureGitignoreEntries(repoPath, entries)` creates the file when missing, appends missing entries (exact line match, no duplicates), preserves existing content byte-for-byte, and is idempotent. `describeGitignoreChange(repoPath, entries)` renders the exact diff for the setup consent conversation. Crew-owned entries: `.worktrees/`, `.orchestration/user/`. CLI: `--repo <path> [--dry-run]`.
|
|
33
35
|
- `repo-orchestration.js` — repository-local `.orchestration/` scaffold (2026-09-17): `scaffoldRepoOrchestration(repoPath, crewRepoPath)` creates `$REPO/.orchestration/{workflows,identities,phases,user}/`, seeds workflows/identities/phases from the crew repo's platform defaults with no-clobber semantics (existing project customizations never overwritten), and writes a README in `user/` explaining it's for local config. Idempotent. CLI: `--repo <path> --crew-repo <path>`.
|
|
34
|
-
- `setup-project-repo.js` — repo-local setup composer for a registered project (2026-09-17, setup-commit 2026-09-18): runs the `.orchestration/` scaffold plus the `.gitignore` update (only with explicit `--consent`), then COMMITS both via `commit-scaffold.js` so the repo's
|
|
36
|
+
- `setup-project-repo.js` — repo-local setup composer for a registered project (2026-09-17, setup-commit 2026-09-18): runs the `.orchestration/` scaffold plus the `.gitignore` update (only with explicit `--consent`), then COMMITS both via `commit-scaffold.js` so the repo's primary checkout is clean when setup finishes (room #13: an uncommitted scaffold deterministically broke the first task's Integrate). Refuses loudly when `.gitignore` has pre-existing uncommitted changes (never sweeps the user's edits into the setup commit); `.orchestration/` is crew-owned so pre-existing content there is committed, not refused. The CLI-path equivalent of crew-init's dashboard-mode repo setup; the dashboard calls the same script. No new API endpoint — composition of existing deterministic modules. CLI: `--repo <path> --crew-repo <path> [--consent]`; prints `{ orchestration, gitignore, commit }`, exits non-zero on any failure.
|
|
35
37
|
- `commit-scaffold.js` — deterministic setup-scaffold commit (2026-09-18): stages only `.orchestration/` (+ `.gitignore` with `--gitignore`) and commits with a message naming exactly what was staged. Idempotent (`nothing-to-commit`, never an empty commit); not-a-git-repo is a clean no-op; missing `.orchestration/` and commit failures (e.g. no git identity) throw loudly — the commit belongs to the user, so no identity is ever invented here. Exports `isPathDirty` (porcelain check for one path) for the composer's pre-mutation guard. CLI: `--repo <path> [--gitignore]`.
|
|
36
38
|
- `sample-project.js` — optional sample-project setup (2026-09-17): extracts `seed/sample-project.tar.gz` (stripping the `sample-project/` prefix), `git init`, initial commit. No remote required. Registration happens via the existing `create-project` action — no sample-specific endpoint. CLI: `--tarball <path> --dest <dir> --name <project-name>`.
|
|
37
39
|
- `compute-publish-diff.js` — deterministic publish-diff computer (2026-09-18, room #14): replaces the agent-ferried `git diff` JSON string field (the JSON ferry dropped a valid 700-line diff — "Publish diff parsed to zero files"). Runs `git diff <base> <HEAD>` via argv (no shell), writes the raw diff bytes to `--out`, and prints only a small JSON summary (`commit`, `base`, `diff_file`, `sha256`, `bytes`, `file_count`, `changed_lines` = added+removed, never raw diff output lines, `files`, `has_binary`, `has_rename`). Empty-tree base is the first-publish ancestor (hardcoded, never `merge-base` on the empty tree); non-ancestor is semantic JSON (`ok:false, error:"not-ancestor"`, exit 0); usage errors exit 2 with nothing on stdout. Pinned per-run alongside the other lifecycle files (`COMPUTE_DIFF` in `PIN_BASENAMES`) so a mid-run release swap cannot change it under the workflow.
|
package/lib/crew-api.js
CHANGED
|
@@ -1721,13 +1721,15 @@ function latestPublishNote(db, taskId) {
|
|
|
1721
1721
|
).get(taskId) || null;
|
|
1722
1722
|
}
|
|
1723
1723
|
|
|
1724
|
-
// The trigger anchor for an unknown attempt: the "submitted" ledger
|
|
1725
|
-
// for the same attempt
|
|
1726
|
-
//
|
|
1727
|
-
//
|
|
1728
|
-
//
|
|
1724
|
+
// The trigger anchor for an unknown attempt: the OLDEST "submitted" ledger
|
|
1725
|
+
// entry for the same attempt (the trigger issuance). Oldest binds the trigger
|
|
1726
|
+
// instant — verify-publish.js and the decision doc bind oldest; binding newest
|
|
1727
|
+
// misclassifies builds that complete between issuance and receipt (A2/R2/O4).
|
|
1728
|
+
// Returns the entry or null. A null means the trigger instant is unknowable
|
|
1729
|
+
// (or the trigger was never issued) — classification cannot run; the task
|
|
1730
|
+
// stays parked for human attention. Fail closed, never guess the anchor.
|
|
1729
1731
|
function findTriggerEntry(entries, commit, attempt) {
|
|
1730
|
-
for (let i =
|
|
1732
|
+
for (let i = 0; i < entries.length; i++) {
|
|
1731
1733
|
const e = entries[i];
|
|
1732
1734
|
if (e.outcome === "submitted" && e.commit === commit &&
|
|
1733
1735
|
(e.attempt || null) === (attempt || null)) {
|
package/lib/publish-npm.sh
CHANGED
|
@@ -82,7 +82,42 @@ if [ "$PREFLIGHT_CHECK_CODE" -ne 0 ] \
|
|
|
82
82
|
fi
|
|
83
83
|
# PREFLIGHT-END
|
|
84
84
|
|
|
85
|
-
#
|
|
85
|
+
# PUSH-TARGET-ANCHOR: resolve the push destination before ANY mutation.
|
|
86
|
+
# The destination is the integration target resolved by the lifecycle, not a
|
|
87
|
+
# hardcoded `main` (2026-09-19 REVIEW: the old `git push origin main` was a
|
|
88
|
+
# stale clone of the same hardcoding the integrate fix removed). A detached
|
|
89
|
+
# HEAD pushes as HEAD:<destination>, with the destination resolved explicitly
|
|
90
|
+
# by the lifecycle's push-destination command (2026-09-19 detached-HEAD audit
|
|
91
|
+
# REDO: the old code refused every detached checkout; detached publication
|
|
92
|
+
# is legitimate and must work). Fail here, before the version-bump commit
|
|
93
|
+
# and the registry publish, only when the destination is genuinely
|
|
94
|
+
# unknowable -- never after a side effect you cannot roll back. (The old
|
|
95
|
+
# position of this check -- step 13, after the npm publish -- left a shipped
|
|
96
|
+
# version with a failed phase and a retry loop.)
|
|
97
|
+
# No-origin is checked for BOTH shapes (2026-09-19 critic finding F-A1):
|
|
98
|
+
# step 13 pushes unconditionally, so an attached checkout with no origin
|
|
99
|
+
# would otherwise fail PUBLISH_FAILED=push after the version-bump commit
|
|
100
|
+
# and the registry publish. A version-bump commit with nowhere to record it
|
|
101
|
+
# would ship an npm version with no git provenance, so no-remote fails
|
|
102
|
+
# closed here rather than skipping like the integrate path (F-F6).
|
|
103
|
+
[ -n "${LIFECYCLE:-}" ] || fail "env" "LIFECYCLE is not set -- cannot resolve the push destination before mutation (F-R4)"
|
|
104
|
+
PUSH_TARGET="$(CREW_HOME="${CREW_HOME:-}" CREW_REPO="$REPO_PATH" bash "$LIFECYCLE" integration-target 2>&1)" \
|
|
105
|
+
|| fail "integration-target" "$PUSH_TARGET"
|
|
106
|
+
if ! git -C "$REPO_PATH" remote get-url origin >/dev/null 2>&1; then
|
|
107
|
+
fail "env" "no origin remote configured: the version-bump commit could not be pushed anywhere -- no version-bump commit was created and nothing was published"
|
|
108
|
+
fi
|
|
109
|
+
PUSH_REFSPEC="$PUSH_TARGET"
|
|
110
|
+
if [ "$PUSH_TARGET" = "HEAD" ]; then
|
|
111
|
+
PUSH_DEST="$(CREW_HOME="${CREW_HOME:-}" CREW_REPO="$REPO_PATH" bash "$LIFECYCLE" push-destination 2>&1)" \
|
|
112
|
+
|| fail "env" "$PUSH_DEST"
|
|
113
|
+
PUSH_REFSPEC="HEAD:$PUSH_DEST"
|
|
114
|
+
fi
|
|
115
|
+
# PUSH-TARGET-END
|
|
116
|
+
|
|
117
|
+
# 3. Dry run: markers only, no lock, no deploy, no mutation. The push
|
|
118
|
+
# preflight above already ran, so even a dry run fails closed on a
|
|
119
|
+
# genuinely unknowable push destination (see case 7 in
|
|
120
|
+
# lib/test-publish-preflight.sh -- F-R9).
|
|
86
121
|
if [ "${DRY_RUN:-0}" = "1" ]; then
|
|
87
122
|
echo "PUBLISH_DRY_RUN=1"
|
|
88
123
|
exit 0
|
|
@@ -281,7 +316,9 @@ LOCK_OUT2="$(CREW_HOME="${CREW_HOME:-}" CREW_REPO="$REPO_PATH" bash "${LIFECYCLE
|
|
|
281
316
|
|| fail "lock-refresh" "$LOCK_OUT2"
|
|
282
317
|
|
|
283
318
|
# 13. Push the version-bump commit (idempotent: no-op if already pushed).
|
|
284
|
-
|
|
319
|
+
# PUSH_REFSPEC was resolved in the preflight above -- a branch name, or
|
|
320
|
+
# HEAD:<destination> on a detached checkout (never a bare HEAD).
|
|
321
|
+
PUSH_OUT="$(git -C "$REPO_PATH" push origin "$PUSH_REFSPEC" 2>&1)" \
|
|
285
322
|
|| fail "push" "$PUSH_OUT"
|
|
286
323
|
echo "PUBLISH_PUSHED=1"
|
|
287
324
|
|
package/lib/retry-publish.js
CHANGED
|
@@ -180,8 +180,11 @@ if (phase === "prepare") {
|
|
|
180
180
|
}
|
|
181
181
|
|
|
182
182
|
const classifierPath = join(crewHome, "lib", "classify-publish-absence.js");
|
|
183
|
-
// Find the trigger entry for the manifest baseline
|
|
184
|
-
|
|
183
|
+
// Find the trigger entry for the manifest baseline: the OLDEST matching
|
|
184
|
+
// "submitted" (the trigger issuance), matching verify-publish.js's anchor.
|
|
185
|
+
// agent_id is null on the issuance entry (no receipt observed at trigger
|
|
186
|
+
// time) — the baseline comes from manifest_before, never agent_id.
|
|
187
|
+
const trigger = entries.find((e) =>
|
|
185
188
|
e.outcome === "submitted" && e.commit === commit && (attempt == null || e.attempt === attempt));
|
|
186
189
|
const manifestBefore = trigger && trigger.manifest_before ? JSON.stringify(trigger.manifest_before) : null;
|
|
187
190
|
|
|
@@ -0,0 +1,416 @@
|
|
|
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 → NO_REMOTE_PUSH (fixture A has no origin; a detached
|
|
26
|
+
# line with no remote has nowhere to push -- loud, not silent)
|
|
27
|
+
#
|
|
28
|
+
# Fixture B (branch — the old behavior is preserved):
|
|
29
|
+
# repo on main with a local bare origin. Integrate merges, reconciles,
|
|
30
|
+
# and pushes inline; push-target also PUSHEDs to origin.
|
|
31
|
+
#
|
|
32
|
+
# Fixture C (detached HEAD, live-tip verify-merge — 2026-09-19 REVIEW):
|
|
33
|
+
# verify-merge must check the record against the LIVE HEAD, never the
|
|
34
|
+
# record's word for the tip: a detached line reset past the merge does
|
|
35
|
+
# NOT verify; with the branch gone it verifies only when the recorded
|
|
36
|
+
# commit is an ancestor of the live tip.
|
|
37
|
+
#
|
|
38
|
+
# Fixture D (branch, no remote — 2026-09-19 REVIEW blocker 3):
|
|
39
|
+
# the old push-target ran `git push origin` with no origin → exit 128
|
|
40
|
+
# mislabeled PUSH_REJECTED. Now the inline push fails soft with
|
|
41
|
+
# NO_REMOTE_PUSH and exit 0.
|
|
42
|
+
#
|
|
43
|
+
# Fixture E (staged-base guard — 2026-09-19 REVIEW #10):
|
|
44
|
+
# CREW_STAGED_BASE naming a non-ancestor fails closed with
|
|
45
|
+
# STAGED_BASE_MISMATCH before any merge; the true staged base proceeds
|
|
46
|
+
# with STAGED_BASE_OK.
|
|
47
|
+
#
|
|
48
|
+
# Fixture F (merged-but-unpushed retry recovery — 2026-09-19 REVIEW):
|
|
49
|
+
# a prior attempt merged but never pushed (lock released to simulate the
|
|
50
|
+
# crash): re-running integrate takes the lock and pushes instead of
|
|
51
|
+
# parking on MERGED_EMPTY.
|
|
52
|
+
#
|
|
53
|
+
# Fixture H (detached HEAD with an origin — 2026-09-19 detached-HEAD audit
|
|
54
|
+
# REDO): H1 proves push-destination fails closed when origin/HEAD is unset
|
|
55
|
+
# (genuinely unknowable destination); H2-H5 prove the detached line pushes
|
|
56
|
+
# explicitly as HEAD:main — inline push reports
|
|
57
|
+
# `PUSHED: origin/main (refspec HEAD:main)`, the remote ref advances to the
|
|
58
|
+
# merge commit, local main stays at the reviewed base, HEAD stays detached,
|
|
59
|
+
# verify-merge VERIFIEDs, and push-target re-pushes idempotently.
|
|
60
|
+
#
|
|
61
|
+
# Hermetic: scratch dirs under /tmp only, removed at start (and end).
|
|
62
|
+
set -u
|
|
63
|
+
|
|
64
|
+
REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
|
65
|
+
LIFECYCLE="$REPO_DIR/lib/worktree-lifecycle.sh"
|
|
66
|
+
|
|
67
|
+
fail() { echo "FAIL: $1"; exit 1; }
|
|
68
|
+
|
|
69
|
+
T=/tmp/crew-detached-integrate-test
|
|
70
|
+
rm -rf "$T"
|
|
71
|
+
mkdir -p "$T"
|
|
72
|
+
|
|
73
|
+
# ---------------- Fixture A: detached HEAD ----------------
|
|
74
|
+
A_DIR="$T/fixture-a"
|
|
75
|
+
mkdir -p "$A_DIR"
|
|
76
|
+
cd "$A_DIR"
|
|
77
|
+
git init -q -b main .
|
|
78
|
+
git config user.email test@test.t
|
|
79
|
+
git config user.name test
|
|
80
|
+
echo reviewed > f.txt
|
|
81
|
+
git add -A
|
|
82
|
+
git commit -qm "reviewed base"
|
|
83
|
+
A_SHA=$(git rev-parse HEAD)
|
|
84
|
+
|
|
85
|
+
# Detach at the reviewed SHA (clean-room staging pins it), then commit the
|
|
86
|
+
# crew-init stamp on top — the detached line Integrate must not abandon.
|
|
87
|
+
git checkout -q --detach HEAD
|
|
88
|
+
echo crew-init >> f.txt
|
|
89
|
+
git commit -qam "crew-init: stamp provenance"
|
|
90
|
+
B_SHA=$(git rev-parse HEAD)
|
|
91
|
+
git symbolic-ref -q HEAD >/dev/null && fail "fixture A: HEAD should be detached"
|
|
92
|
+
|
|
93
|
+
export CREW_REPO="$A_DIR"
|
|
94
|
+
export CREW_HOME="$A_DIR/home"
|
|
95
|
+
export CREW_LIB="$REPO_DIR/lib" # merge-lock.sh lives in the repo's lib, not the scratch crew home
|
|
96
|
+
tid="detached1"
|
|
97
|
+
|
|
98
|
+
# 1. integration-target resolves to HEAD
|
|
99
|
+
out=$(bash "$LIFECYCLE" integration-target) || fail "integration-target exited non-zero: $out"
|
|
100
|
+
[ "$out" = "HEAD" ] || fail "integration-target: expected HEAD, got: $out"
|
|
101
|
+
|
|
102
|
+
# prepare + task work
|
|
103
|
+
out=$(bash "$LIFECYCLE" prepare "$tid") || fail "prepare exited non-zero: $out"
|
|
104
|
+
echo task-work >> "$A_DIR/.worktrees/$tid/f.txt"
|
|
105
|
+
(cd "$A_DIR/.worktrees/$tid" && git commit -qam "task work")
|
|
106
|
+
C_SHA=$(cd "$A_DIR/.worktrees/$tid" && git rev-parse HEAD)
|
|
107
|
+
|
|
108
|
+
# 2. integrate
|
|
109
|
+
out=$(bash "$LIFECYCLE" integrate "$tid" "merge: $tid" 2>&1) || fail "integrate exited non-zero: $out"
|
|
110
|
+
echo "$out" | grep -q '^MERGED:' || fail "integrate: expected MERGED, got: $out"
|
|
111
|
+
echo "$out" | grep -q 'NO_REMOTE_RECONCILE: detached HEAD' || fail "integrate: expected NO_REMOTE_RECONCILE, got: $out"
|
|
112
|
+
|
|
113
|
+
# 3. HEAD is still detached — no checkout happened
|
|
114
|
+
git symbolic-ref -q HEAD >/dev/null && fail "integrate checked out a branch: HEAD is now attached"
|
|
115
|
+
|
|
116
|
+
# 4. the detached line survived: reviewed SHA and crew-init are ancestors
|
|
117
|
+
git merge-base --is-ancestor "$A_SHA" HEAD || fail "reviewed SHA $A_SHA is no longer an ancestor of HEAD"
|
|
118
|
+
git merge-base --is-ancestor "$B_SHA" HEAD || fail "crew-init commit $B_SHA was abandoned (the J3 defect)"
|
|
119
|
+
|
|
120
|
+
# 5. the task work landed via a real merge commit
|
|
121
|
+
git merge-base --is-ancestor "$C_SHA" HEAD || fail "task commit $C_SHA is not an ancestor of HEAD"
|
|
122
|
+
MERGE_SHA=$(git rev-parse HEAD)
|
|
123
|
+
parents=$(git rev-list --parents -n 1 "$MERGE_SHA" | wc -w)
|
|
124
|
+
[ "$parents" -eq 3 ] || fail "expected a 2-parent merge commit, got $parents words in: $(git rev-list --parents -n 1 "$MERGE_SHA")"
|
|
125
|
+
|
|
126
|
+
# 6. main was not touched
|
|
127
|
+
MAIN_SHA=$(git rev-parse main)
|
|
128
|
+
[ "$MAIN_SHA" = "$A_SHA" ] || fail "main moved during integrate: $MAIN_SHA != reviewed $A_SHA"
|
|
129
|
+
|
|
130
|
+
# 7. merge record names the target
|
|
131
|
+
rec=$(bash "$LIFECYCLE" merge-record "$tid")
|
|
132
|
+
echo "$rec" | grep -q '^integration_target=HEAD$' || fail "merge record missing integration_target=HEAD: $rec"
|
|
133
|
+
|
|
134
|
+
# 8. verify-merge
|
|
135
|
+
out=$(bash "$LIFECYCLE" verify-merge "$tid") || fail "verify-merge exited non-zero: $out"
|
|
136
|
+
echo "$out" | grep -q '^VERIFIED:' || fail "verify-merge: expected VERIFIED, got: $out"
|
|
137
|
+
|
|
138
|
+
# 9. push-target on a detached HEAD with no remote: loud NO_REMOTE_PUSH
|
|
139
|
+
out=$(bash "$LIFECYCLE" push-target "$tid") || fail "push-target exited non-zero: $out"
|
|
140
|
+
echo "$out" | grep -q '^NO_REMOTE_PUSH: no origin remote configured' || fail "push-target: expected NO_REMOTE_PUSH, got: $out"
|
|
141
|
+
|
|
142
|
+
echo "fixture A (detached HEAD): all checks passed"
|
|
143
|
+
|
|
144
|
+
# ---------------- Fixture C: verify-merge checks the record against the LIVE tip ----------------
|
|
145
|
+
# (2026-09-19 REVIEW blockers 1+2: the old verify-merge compared the merge
|
|
146
|
+
# record against itself for target=HEAD — is-ancestor(X, X) is a tautology
|
|
147
|
+
# that certified merges no longer on the line.)
|
|
148
|
+
# Still in fixture A's repo ($A_DIR); task detached1 is merged at $MERGE_SHA.
|
|
149
|
+
cd "$A_DIR"
|
|
150
|
+
export CREW_REPO="$A_DIR"
|
|
151
|
+
export CREW_HOME="$A_DIR/home"
|
|
152
|
+
|
|
153
|
+
# C1: reset the detached line backward past the merge — the merge is no
|
|
154
|
+
# longer on the line. verify-merge must NOT verify.
|
|
155
|
+
git reset -q --hard "$B_SHA"
|
|
156
|
+
out=$(bash "$LIFECYCLE" verify-merge "$tid" 2>&1) && fail "C1: verify-merge exited 0 for a merge no longer on the line: $out"
|
|
157
|
+
echo "$out" | grep -q '^VERIFIED:' && fail "C1: VERIFIED a reset-away merge: $out"
|
|
158
|
+
echo "C1: verify-merge rejects a detached line reset past the merge"
|
|
159
|
+
|
|
160
|
+
# C2: restore the merge, remove the worktree, delete the task branch
|
|
161
|
+
# (merge-lease reclaim path). verify-merge must VERIFY from the record —
|
|
162
|
+
# but only because the recorded commit is an ancestor of the LIVE tip.
|
|
163
|
+
git reset -q --hard "$MERGE_SHA"
|
|
164
|
+
BRANCH=$(bash "$LIFECYCLE" resolve-branch "$tid") || fail "C2: resolve-branch failed"
|
|
165
|
+
git worktree remove --force "$A_DIR/.worktrees/$tid" || fail "C2: worktree remove failed"
|
|
166
|
+
git branch -q -D "$BRANCH" || fail "C2: could not delete task branch $BRANCH"
|
|
167
|
+
out=$(bash "$LIFECYCLE" verify-merge "$tid" 2>&1) || fail "C2: verify-merge exited non-zero: $out"
|
|
168
|
+
echo "$out" | grep -q '^VERIFIED:' || fail "C2: expected VERIFIED via record fallback, got: $out"
|
|
169
|
+
echo "$out" | grep -q 'gone but recorded merge commit' || fail "C2: expected the branch-gone record path, got: $out"
|
|
170
|
+
echo "fixture C (live-tip verify-merge): all checks passed"
|
|
171
|
+
|
|
172
|
+
# ---------------- Fixture B: branch with a local origin ----------------
|
|
173
|
+
B_DIR="$T/fixture-b"
|
|
174
|
+
REMOTE="$T/remote.git"
|
|
175
|
+
mkdir -p "$B_DIR"
|
|
176
|
+
git init -q --bare "$REMOTE"
|
|
177
|
+
cd "$B_DIR"
|
|
178
|
+
git init -q -b main .
|
|
179
|
+
git config user.email test@test.t
|
|
180
|
+
git config user.name test
|
|
181
|
+
echo base > f.txt
|
|
182
|
+
git add -A
|
|
183
|
+
git commit -qm "base"
|
|
184
|
+
git remote add origin "$REMOTE"
|
|
185
|
+
git push -q origin main
|
|
186
|
+
|
|
187
|
+
export CREW_REPO="$B_DIR"
|
|
188
|
+
export CREW_HOME="$B_DIR/home"
|
|
189
|
+
tid="branch1"
|
|
190
|
+
|
|
191
|
+
out=$(bash "$LIFECYCLE" integration-target) || fail "B integration-target exited non-zero"
|
|
192
|
+
[ "$out" = "main" ] || fail "B integration-target: expected main, got: $out"
|
|
193
|
+
|
|
194
|
+
out=$(bash "$LIFECYCLE" prepare "$tid") || fail "B prepare exited non-zero: $out"
|
|
195
|
+
echo task-work >> "$B_DIR/.worktrees/$tid/f.txt"
|
|
196
|
+
(cd "$B_DIR/.worktrees/$tid" && git commit -qam "task work")
|
|
197
|
+
|
|
198
|
+
out=$(bash "$LIFECYCLE" integrate "$tid" "merge: $tid" 2>&1) || fail "B integrate exited non-zero: $out"
|
|
199
|
+
echo "$out" | grep -q '^MERGED:' || fail "B integrate: expected MERGED, got: $out"
|
|
200
|
+
echo "$out" | grep -q '^RECONCILED: merged origin/main into main' || fail "B integrate: expected RECONCILED, got: $out"
|
|
201
|
+
|
|
202
|
+
rec=$(bash "$LIFECYCLE" merge-record "$tid")
|
|
203
|
+
echo "$rec" | grep -q '^integration_target=main$' || fail "B merge record missing integration_target=main: $rec"
|
|
204
|
+
|
|
205
|
+
out=$(bash "$LIFECYCLE" verify-merge "$tid") || fail "B verify-merge exited non-zero: $out"
|
|
206
|
+
echo "$out" | grep -q '^VERIFIED:' || fail "B verify-merge: expected VERIFIED, got: $out"
|
|
207
|
+
|
|
208
|
+
# push-target pushes the branch to the local origin
|
|
209
|
+
out=$(bash "$LIFECYCLE" push-target "$tid" 2>&1) || fail "B push-target exited non-zero: $out"
|
|
210
|
+
echo "$out" | grep -q '^PUSHED: origin/main' || fail "B push-target: expected PUSHED, got: $out"
|
|
211
|
+
LOCAL_MAIN=$(git rev-parse main)
|
|
212
|
+
REMOTE_MAIN=$(git --git-dir="$REMOTE" rev-parse refs/heads/main)
|
|
213
|
+
[ "$LOCAL_MAIN" = "$REMOTE_MAIN" ] || fail "B origin/main not updated: $REMOTE_MAIN != $LOCAL_MAIN"
|
|
214
|
+
|
|
215
|
+
echo "fixture B (branch + origin): all checks passed"
|
|
216
|
+
|
|
217
|
+
# ---------------- Fixture D: branch target, no remote ----------------
|
|
218
|
+
# (2026-09-19 REVIEW blocker 3: push-target ran `git push origin` with no
|
|
219
|
+
# origin → exit 128 mislabeled PUSH_REJECTED. Now: fail-soft NO_REMOTE_PUSH.)
|
|
220
|
+
D_DIR="$T/fixture-d"
|
|
221
|
+
mkdir -p "$D_DIR"
|
|
222
|
+
cd "$D_DIR"
|
|
223
|
+
git init -q -b main .
|
|
224
|
+
git config user.email test@test.t
|
|
225
|
+
git config user.name test
|
|
226
|
+
echo base > f.txt
|
|
227
|
+
git add -A
|
|
228
|
+
git commit -qm "base"
|
|
229
|
+
export CREW_REPO="$D_DIR"
|
|
230
|
+
# CREW_HOME lives OUTSIDE the repo (as in real rooms): the lock files are
|
|
231
|
+
# crew state, and nesting them in the repo would trip the integrate
|
|
232
|
+
# preflight on the retry in fixture F.
|
|
233
|
+
export CREW_HOME="$T/home-d"
|
|
234
|
+
tid="noremote1"
|
|
235
|
+
|
|
236
|
+
out=$(bash "$LIFECYCLE" prepare "$tid") || fail "D prepare failed: $out"
|
|
237
|
+
echo work >> "$D_DIR/.worktrees/$tid/f.txt"
|
|
238
|
+
(cd "$D_DIR/.worktrees/$tid" && git commit -qam "work")
|
|
239
|
+
|
|
240
|
+
# integrate pushes inline now: MERGED + NO_REMOTE (reconcile) + NO_REMOTE_PUSH
|
|
241
|
+
out=$(bash "$LIFECYCLE" integrate "$tid" "merge: $tid" 2>&1) || fail "D integrate exited non-zero: $out"
|
|
242
|
+
echo "$out" | grep -q '^MERGED:' || fail "D: expected MERGED, got: $out"
|
|
243
|
+
echo "$out" | grep -q '^NO_REMOTE: no origin/main' || fail "D: expected NO_REMOTE reconcile skip, got: $out"
|
|
244
|
+
echo "$out" | grep -q '^NO_REMOTE_PUSH: no origin remote configured' || fail "D: expected NO_REMOTE_PUSH, got: $out"
|
|
245
|
+
|
|
246
|
+
# push-target directly (record exists, lock still held from integrate):
|
|
247
|
+
# same fail-soft, exit 0
|
|
248
|
+
out=$(bash "$LIFECYCLE" push-target "$tid" 2>&1) || fail "D push-target exited non-zero: $out"
|
|
249
|
+
echo "$out" | grep -q '^NO_REMOTE_PUSH' || fail "D push-target: expected NO_REMOTE_PUSH, got: $out"
|
|
250
|
+
|
|
251
|
+
echo "fixture D (branch, no remote): all checks passed"
|
|
252
|
+
|
|
253
|
+
# ---------------- Fixture E: staged-base guard ----------------
|
|
254
|
+
# (2026-09-19 REVIEW #10: a stray checkout redirecting the line must fail
|
|
255
|
+
# closed at Integrate — before the merge lands on the wrong line.)
|
|
256
|
+
E_DIR="$T/fixture-e"
|
|
257
|
+
mkdir -p "$E_DIR"
|
|
258
|
+
cd "$E_DIR"
|
|
259
|
+
git init -q -b main .
|
|
260
|
+
git config user.email test@test.t
|
|
261
|
+
git config user.name test
|
|
262
|
+
echo reviewed > f.txt
|
|
263
|
+
git add -A
|
|
264
|
+
git commit -qm "reviewed"
|
|
265
|
+
E_BASE=$(git rev-parse HEAD)
|
|
266
|
+
git checkout -q --detach HEAD
|
|
267
|
+
echo init >> f.txt
|
|
268
|
+
git commit -qam "crew-init"
|
|
269
|
+
E_LINE=$(git rev-parse HEAD)
|
|
270
|
+
export CREW_REPO="$E_DIR"
|
|
271
|
+
export CREW_HOME="$E_DIR/home"
|
|
272
|
+
tid="stagedbase1"
|
|
273
|
+
|
|
274
|
+
out=$(bash "$LIFECYCLE" prepare "$tid") || fail "E prepare failed: $out"
|
|
275
|
+
echo work >> "$E_DIR/.worktrees/$tid/f.txt"
|
|
276
|
+
(cd "$E_DIR/.worktrees/$tid" && git commit -qam "work")
|
|
277
|
+
|
|
278
|
+
# A base that is NOT an ancestor of the detached line: a sibling commit on
|
|
279
|
+
# main (the shape a stray `git checkout main` leaves behind).
|
|
280
|
+
git checkout -q main
|
|
281
|
+
echo stray >> f.txt
|
|
282
|
+
git commit -qam "stray main commit"
|
|
283
|
+
STRAY_SHA=$(git rev-parse HEAD)
|
|
284
|
+
git checkout -q --detach "$E_LINE"
|
|
285
|
+
|
|
286
|
+
out=$(CREW_STAGED_BASE="$STRAY_SHA" bash "$LIFECYCLE" integrate "$tid" "merge: $tid" 2>&1) && \
|
|
287
|
+
fail "E: integrate with a non-ancestor staged base should fail: $out"
|
|
288
|
+
echo "$out" | grep -q '^STAGED_BASE_MISMATCH' || fail "E: expected STAGED_BASE_MISMATCH, got: $out"
|
|
289
|
+
[ "$(git rev-parse HEAD)" = "$E_LINE" ] || fail "E: HEAD moved despite STAGED_BASE_MISMATCH"
|
|
290
|
+
|
|
291
|
+
# The real staged base is an ancestor — the merge proceeds.
|
|
292
|
+
out=$(CREW_STAGED_BASE="$E_BASE" bash "$LIFECYCLE" integrate "$tid" "merge: $tid" 2>&1) || \
|
|
293
|
+
fail "E: integrate with the true staged base failed: $out"
|
|
294
|
+
echo "$out" | grep -q '^STAGED_BASE_OK' || fail "E: expected STAGED_BASE_OK, got: $out"
|
|
295
|
+
echo "$out" | grep -q '^MERGED:' || fail "E: expected MERGED, got: $out"
|
|
296
|
+
|
|
297
|
+
echo "fixture E (staged-base guard): all checks passed"
|
|
298
|
+
|
|
299
|
+
# ---------------- Fixture F: merged-but-unpushed retry recovery ----------------
|
|
300
|
+
# (2026-09-19 REVIEW: a prior attempt's merge landed but its push never
|
|
301
|
+
# completed — the retry must push instead of parking on MERGED_EMPTY.)
|
|
302
|
+
# Reuse fixture D: task noremote1 is merged (recorded commit == live HEAD)
|
|
303
|
+
# with no origin. Release the lock to simulate the crashed attempt, then
|
|
304
|
+
# re-run integrate: ahead==0 and recorded==live → acquire + push now.
|
|
305
|
+
cd "$D_DIR"
|
|
306
|
+
export CREW_REPO="$D_DIR"
|
|
307
|
+
export CREW_HOME="$T/home-d"
|
|
308
|
+
"$REPO_DIR/lib/merge-lock.sh" release "noremote1" >/dev/null 2>&1 || fail "F: lock release failed"
|
|
309
|
+
out=$(bash "$LIFECYCLE" integrate "noremote1" "merge: noremote1" 2>&1) || \
|
|
310
|
+
fail "F integrate exited non-zero: $out"
|
|
311
|
+
echo "$out" | grep -q 'recovered: merge landed in a prior attempt' || \
|
|
312
|
+
fail "F: expected push recovery, got: $out"
|
|
313
|
+
echo "$out" | grep -q '^NO_REMOTE_PUSH' || fail "F: expected NO_REMOTE_PUSH in recovery, got: $out"
|
|
314
|
+
|
|
315
|
+
# F2: ancestor-based recovery — a prior attempt's push failed AFTER do_push's
|
|
316
|
+
# retry path added a reconcile merge on top of the recorded commit, then
|
|
317
|
+
# failed again. The record names M1 but live HEAD is M2 (M1's descendant).
|
|
318
|
+
# Equality-based recovery would miss this and wrongly report MERGED_EMPTY —
|
|
319
|
+
# a PASS with the push never completed.
|
|
320
|
+
"$REPO_DIR/lib/merge-lock.sh" release "noremote1" >/dev/null 2>&1 || fail "F2: lock release failed"
|
|
321
|
+
git commit -q --allow-empty -m "simulate: do_push retry-reconcile merge" || fail "F2: empty commit failed"
|
|
322
|
+
out=$(bash "$LIFECYCLE" integrate "noremote1" "merge: noremote1" 2>&1) || \
|
|
323
|
+
fail "F2 integrate exited non-zero: $out"
|
|
324
|
+
echo "$out" | grep -q 'recovered: merge landed in a prior attempt' || \
|
|
325
|
+
fail "F2: expected ancestor-based push recovery, got: $out"
|
|
326
|
+
echo "$out" | grep -q '^MERGED_EMPTY' && fail "F2: wrongly MERGED_EMPTY while the push never completed: $out"
|
|
327
|
+
|
|
328
|
+
echo "fixture F (merged-but-unpushed recovery): all checks passed"
|
|
329
|
+
|
|
330
|
+
# ---------------- Fixture G: push-target after MERGED_EMPTY ----------------
|
|
331
|
+
# (2026-09-19 REVIEW #8: MERGED_EMPTY takes no lock and writes no record —
|
|
332
|
+
# push-target must skip loudly, not report a misleading "lock lost".)
|
|
333
|
+
# Reuse fixture D's repo: a task with no commits ahead merges empty.
|
|
334
|
+
cd "$D_DIR"
|
|
335
|
+
export CREW_REPO="$D_DIR"
|
|
336
|
+
export CREW_HOME="$T/home-d"
|
|
337
|
+
tid="empty1"
|
|
338
|
+
out=$(bash "$LIFECYCLE" prepare "$tid") || fail "G prepare failed: $out"
|
|
339
|
+
# no commits in the worktree — nothing ahead of the target
|
|
340
|
+
out=$(bash "$LIFECYCLE" integrate "$tid" "merge: $tid" 2>&1) || fail "G integrate exited non-zero: $out"
|
|
341
|
+
echo "$out" | grep -q '^MERGED_EMPTY' || fail "G: expected MERGED_EMPTY, got: $out"
|
|
342
|
+
out=$(bash "$LIFECYCLE" push-target "$tid" 2>&1) || fail "G push-target exited non-zero: $out"
|
|
343
|
+
echo "$out" | grep -q 'no merge record' || fail "G: expected nothing-merged skip, got: $out"
|
|
344
|
+
echo "$out" | grep -qi 'lock lost' && fail "G: misleading lock-lost error: $out"
|
|
345
|
+
|
|
346
|
+
echo "fixture G (push-target after MERGED_EMPTY): all checks passed"
|
|
347
|
+
|
|
348
|
+
# ---------------- Fixture H: detached HEAD with an origin ----------------
|
|
349
|
+
# (2026-09-19 detached-HEAD audit REDO: a detached line with a remote is
|
|
350
|
+
# pushed explicitly as HEAD:<destination> -- the remote's default branch --
|
|
351
|
+
# never silently skipped, never refused. The local branch the line
|
|
352
|
+
# descends from is NOT moved; only the remote ref advances.)
|
|
353
|
+
H_DIR="$T/fixture-h"
|
|
354
|
+
H_REMOTE="$T/remote-h.git"
|
|
355
|
+
git init -q --bare "$H_REMOTE"
|
|
356
|
+
mkdir -p "$H_DIR"
|
|
357
|
+
cd "$H_DIR"
|
|
358
|
+
git init -q -b main .
|
|
359
|
+
git config user.email test@test.t
|
|
360
|
+
git config user.name test
|
|
361
|
+
echo reviewed > f.txt
|
|
362
|
+
git add -A
|
|
363
|
+
git commit -qm "reviewed base"
|
|
364
|
+
H_BASE=$(git rev-parse HEAD)
|
|
365
|
+
git remote add origin "$H_REMOTE"
|
|
366
|
+
git push -q origin main
|
|
367
|
+
git fetch -q origin
|
|
368
|
+
# Detach at the reviewed base, crew-init stamp on top -- the detached line
|
|
369
|
+
# the REDO must push explicitly (set origin/HEAD only after H1).
|
|
370
|
+
git checkout -q --detach HEAD
|
|
371
|
+
echo crew-init >> f.txt
|
|
372
|
+
git commit -qam "crew-init: stamp provenance"
|
|
373
|
+
H_LINE=$(git rev-parse HEAD)
|
|
374
|
+
git symbolic-ref -q HEAD >/dev/null && fail "fixture H: HEAD should be detached"
|
|
375
|
+
export CREW_REPO="$H_DIR"
|
|
376
|
+
export CREW_HOME="$H_DIR/home"
|
|
377
|
+
export CREW_LIB="$REPO_DIR/lib"
|
|
378
|
+
# H1: origin/HEAD not set -- the destination is genuinely unknowable --
|
|
379
|
+
# push-destination fails closed (never a silent skip, never a bare-HEAD push)
|
|
380
|
+
out=$(bash "$LIFECYCLE" push-destination 2>&1) && fail "H1: push-destination should fail without origin/HEAD: $out"
|
|
381
|
+
echo "$out" | grep -q 'genuinely unknowable' || fail "H1: expected unknowable error, got: $out"
|
|
382
|
+
echo "fixture H1 (unknowable destination fails closed): passed"
|
|
383
|
+
# Now set the remote default branch explicitly (hermetic: no `remote set-head -a` network round-trip)
|
|
384
|
+
git symbolic-ref refs/remotes/origin/HEAD refs/remotes/origin/main
|
|
385
|
+
tid="detachedorigin1"
|
|
386
|
+
|
|
387
|
+
# H2: push-destination resolves the remote default branch
|
|
388
|
+
out=$(bash "$LIFECYCLE" push-destination) || fail "H2 push-destination exited non-zero: $out"
|
|
389
|
+
[ "$out" = "main" ] || fail "H2 push-destination: expected main, got: $out"
|
|
390
|
+
|
|
391
|
+
# H3: prepare + task work, then integrate pushes inline as HEAD:main
|
|
392
|
+
out=$(bash "$LIFECYCLE" prepare "$tid") || fail "H3 prepare exited non-zero: $out"
|
|
393
|
+
echo task-work >> "$H_DIR/.worktrees/$tid/f.txt"
|
|
394
|
+
(cd "$H_DIR/.worktrees/$tid" && git commit -qam "task work")
|
|
395
|
+
out=$(bash "$LIFECYCLE" integrate "$tid" "merge: $tid" 2>&1) || fail "H3 integrate exited non-zero: $out"
|
|
396
|
+
echo "$out" | grep -q '^MERGED:' || fail "H3 integrate: expected MERGED, got: $out"
|
|
397
|
+
echo "$out" | grep -q '^PUSHED: origin/main (refspec HEAD:main)' || fail "H3 integrate: expected explicit detached PUSHED, got: $out"
|
|
398
|
+
|
|
399
|
+
# H4: HEAD still detached; the detached line survived; the remote advanced
|
|
400
|
+
git symbolic-ref -q HEAD >/dev/null && fail "H4: HEAD should still be detached"
|
|
401
|
+
H_MERGE=$(git rev-parse HEAD)
|
|
402
|
+
git merge-base --is-ancestor "$H_BASE" HEAD || fail "H4: reviewed base abandoned"
|
|
403
|
+
git merge-base --is-ancestor "$H_LINE" HEAD || fail "H4: detached line abandoned"
|
|
404
|
+
[ "$(git rev-parse main)" = "$H_BASE" ] || fail "H4: local main moved (must stay at the reviewed base)"
|
|
405
|
+
[ "$(git --git-dir="$H_REMOTE" rev-parse refs/heads/main)" = "$H_MERGE" ] || fail "H4: origin/main not updated to the merge commit"
|
|
406
|
+
|
|
407
|
+
# H5: verify-merge VERIFIEDs; push-target re-pushes idempotently as HEAD:main
|
|
408
|
+
out=$(bash "$LIFECYCLE" verify-merge "$tid") || fail "H5 verify-merge exited non-zero: $out"
|
|
409
|
+
echo "$out" | grep -q '^VERIFIED:' || fail "H5: expected VERIFIED, got: $out"
|
|
410
|
+
out=$(bash "$LIFECYCLE" push-target "$tid") || fail "H5 push-target exited non-zero: $out"
|
|
411
|
+
echo "$out" | grep -q '^PUSHED: origin/main (refspec HEAD:main)' || fail "H5 push-target: expected idempotent PUSHED, got: $out"
|
|
412
|
+
|
|
413
|
+
echo "fixture H (detached HEAD + origin): all checks passed"
|
|
414
|
+
|
|
415
|
+
rm -rf "$T"
|
|
416
|
+
echo "PASS: detached-HEAD integrate fixtures"
|