Files
msd-core/tests/gsd-quick-batch-merge-integration.test.cjs
Tom Boucher 515191f07d feat(#3677): quick-batch hardening and acceptance (#4240)
* chore(#3677): checkpoint design artifacts (gitignored, dev-only)

* test(#3677): add failing regression test for the crash-window duplicate-dispatch gap (RED)

Independently re-traces resume-mode.md/planner-wave.md/worktree-dispatch.md/
merge-wave.md and src/quick-batch.cts's resumeBatch (lines 894-899) and
confirms the prior research pass's Open Question 1: a coordinator crash
between Step 6 (executor commits, SUMMARY.md written) and Step 7 (merge)
leaves BATCH.json at "pending" with no STATE.md row yet (only written in
Step 9), so --resume's eligibility re-derivation would dispatch a second
executor into a new worktree for the same item, orphaning the first.

This test asserts worktree-dispatch.md's Step 6 excludes an item whose
SUMMARY.md already exists from the spawn set, mirroring planner-wave.md's
existing PLAN.md-existence check one layer earlier. Fails against the
current worktree-dispatch.md, which has no such guard.

See .gsd/phase/feat-3677-quick-batch-hardening-acceptance/40-design.md §1
for the full trace and fix-location rationale.

* fix(#3677): guard worktree-dispatch.md against re-dispatching an already-executed item (GREEN)

worktree-dispatch.md's Step 6 re-derives eligibility every dispatch round
via the same quick-batch resume call resume-mode.md uses, but had no check
for "did this item already finish executing" the way planner-wave.md
already checks "did this item already get planned" (PLAN.md existence)
before re-planning. A coordinator crash between Step 6 (executor commits,
SUMMARY.md written) and Step 7 (merge) left the item eligible for a second
dispatch on --resume, orphaning the first worktree's real, already-
committed work and silently losing it once the second executor's SUMMARY.md
write clobbered the first at the same item_dir path.

Adds a SUMMARY.md-existence exclusion before spawn-plan is computed,
symmetric to planner-wave.md's PLAN.md check. The excluded item is not
lost: merge-wave.md's own mergeable-wave criterion (status=pending,
SUMMARY.md on disk, not yet merged) already picks it up independently of
this eligible/spawn list.

Workflow-prose-only fix — touches no already-merged/reviewed .cts module.
See .gsd/phase/feat-3677-quick-batch-hardening-acceptance/40-design.md §1
for the fix-location rationale (why not resumeBatch itself).

* test(#3677): add real-git coverage for worktree-ownership tampering, scope drift, and submodules

Closes the three coverage gaps identified in 40-design.md §2/§3 (#3677,
epic #3344 Phase 5's own AC bullets: "arbitrary-worktree ownership
attempts", "scope drift", "submodules"):

- Arbitrary-worktree ownership tampering: a manifest entry naming a
  non-agent branch is silently dropped at normalization before any git
  subprocess runs; a manifest entry naming a plausible agent-branch that
  was never actually created by this repo's own worktree.create (a
  genuinely foreign repo/branch) is blocked via base_mismatch. Both leave
  the foreign location and repoRoot's HEAD provably untouched.

- Advisory scope drift: a committed path outside declared files_modified
  still merges successfully (advisory, never blocking) while surfacing a
  scope_out_of_declared warning naming the drifted path; an exact
  declared-scope match produces zero warnings (boundary case).

- Real .gitmodules submodule integration: a repo containing a real local
  git submodule merges cleanly through executeWorktreeWaveCleanupPlan for
  an unrelated plan; a real gitlink pointer bump (declared) merges cleanly
  with the superproject tree reflecting the new pinned commit; an
  undeclared bump is advisory-only and surfaces a scope warning naming
  vendor/sub, same as any other undeclared modification.

No src/*.cts changes — all three gaps were coverage-only; the underlying
primitives already behaved correctly (independently verified against real
git subprocess output before writing each assertion).

* docs(#3677): document how to diagnose a preserved quick-batch worktree

Extends the one-sentence "worktree is preserved (never deleted)" mention
into a concrete diagnosis procedure: where the preserved directory is, how
to read the executor's real commits/diff against the plan's declared
files_modified, how to read the item's own SUMMARY.md independent of merge
outcome, how to manually merge-and-clean-up or discard, and how to re-run
--resume afterward. Also documents that a SUMMARY.md-written-but-still-
pending item (the crash-window case fixed in this same PR) needs no manual
intervention — --resume routes it straight to the merge step.

* chore(#3677): checkpoint final acceptance-evidence mapping (gitignored, dev-only)

* fix(#3677): make crash-window duplicate-dispatch guard behaviorally provable and durably recoverable

Orthogonal review (Spec finding): the crash-window regression test added
earlier this phase only asserted readStep('worktree-dispatch.md') + regex
matches against the markdown prose — proving the DOCUMENTATION says the
right thing, never that the runtime condition (pending status + on-disk
SUMMARY.md + absent STATE row) is actually handled correctly. #3677's own
"Alternatives considered" explicitly rejects "document recovery without
fault injection" for exactly this reason.

Extracts the filtering decision into a pure, independently testable
function, filterAlreadyExecuted(eligibleIds, executedIds) in
src/quick-batch-dispatch.cts, wired to a new `quick-batch filter-executed`
CLI verb (src/quick-batch-command-router.cts) — the same pure-decision-
then-CLI-wired pattern computeSpawnPlan/computeMergeOrder already
establish. worktree-dispatch.md now calls this verb explicitly instead of
only describing the decision in prose. A genuine fixture-based test in
tests/quick-batch.test.cjs constructs a REAL BATCH.json (createBatch),
writes a REAL SUMMARY.md on disk at the item's real item_dir, calls the
REAL resumeBatch, and proves both that resumeBatch alone still reports the
item eligible AND that filterAlreadyExecuted (fed a real filesystem check)
correctly excludes it. The prior prose-assertion tests are kept — they now
prove the workflow markdown is correctly WIRED to the verb — but are no
longer the only proof.

Self-discovered defect while building that fixture (fixed inline, not
deferred): tracing merge-wave.md against /gsd:quick's own prior art
(QUICK_WORKTREE_MANIFEST=$(mktemp ...), quick.md:415) showed
$QUICK_BATCH_WORKTREE_MANIFEST is a fresh PER-PROCESS temp file. A resumed
coordinator correctly does not re-dispatch an already-executed item (this
fix), but nothing durably recorded that item's worktree_path/branch/base
either — Step 7 in the resumed process would have had no data to build its
cleanup-wave entry from. Adds dispatched_worktree/dispatched_branch/
dispatched_base to QuickBatchItem (src/quick-batch.cts) — deliberately NOT
a reuse of the pre-existing `worktree` field, whose loadBatch validation
requires the path to exist on disk (verified empirically: reusing it made
the batch permanently unloadable the moment a legitimately-merged worktree
was removed). worktree-dispatch.md persists the triple once a worktree is
created; merge-wave.md falls back to it when the ephemeral manifest lacks
an entry, clears it after a successful merge, and fails closed rather than
guessing if no record exists anywhere.

See .gsd/phase/feat-3677-quick-batch-hardening-acceptance/40-design.md §9.1
and §9.3 for the full trace, empirical verification notes, and rejected
alternatives (reusing `worktree` directly).

* test(#3677): prove the arbitrary-worktree-ownership boundary against two real sibling worktrees

Orthogonal review (Security finding): the two existing ownership-tampering
tests didn't test ownership — one was trivially rejected by
WORKTREE_AGENT_BRANCH_RE's shape check before any git call (proves branch-
NAME filtering, not ownership), the other pointed at a wholly separate,
never-linked foreign repo, so merge-base failed immediately because the
branch didn't exist as a ref at all. Neither exercised the real scenario:
a manifest entry whose worktree_path/branch are swapped to point at a
DIFFERENT, GENUINELY-REGISTERED sibling worktree of the SAME repoRoot,
with a branch name passing the shape check and a base in allowed_bases.

Investigated executeWorktreeWaveCleanupPlan (src/worktree-safety.cts)
directly: this is NOT a reachable gap. Git enforces branch-per-worktree
uniqueness, so a swapped-in entry.branch can only match worktree_path's
ACTUAL checked-out branch if it names that sibling's own real, uniquely-
generated branch name — which manifest tampering confined to one batch's
own record has no way to know (branch names are
agent-<quick_id>[-<timestamp>]-shaped, and quick_id allocation is
collision-checked GLOBALLY across every existing quick task and batch, not
merely within one batch).

Adds a stronger test that empirically proves this: two REAL, concurrently-
alive sibling worktrees of the same repo (both via real `git worktree add`,
both WORKTREE_AGENT_BRANCH_RE-passing, both sharing one merge-base), with
worktree_path/branch swapped between them in both directions. Both attempts
are blocked via branch_mismatch; both real worktrees, their branches, and
one sibling's real uncommitted-to-main commit survive completely untouched.
Supplements (does not replace) the original two tests, which still prove
distinct, real boundaries.

See .gsd/phase/feat-3677-quick-batch-hardening-acceptance/40-design.md §9.2
for the full trace, including the one explicitly-documented (not fixed)
trust boundary this investigation surfaced: the primitive defends against
fabricated data, not a caller bug that misattributes a real-but-wrong
item's own triple to a different item.

* chore(#3677): checkpoint design-doc addendum for review pass 2 findings (gitignored, dev-only)

* docs(#3677): add changeset for PR 4240

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 09:47:22 -04:00

658 lines
30 KiB
JavaScript

'use strict';
/**
* gsd-quick-batch-merge-integration.test.cjs — real-git-fixture integration
* tests connecting quick-batch's PURE merge routing (`routeMergeOutcome`,
* `src/quick-batch-dispatch.cts`) to the REAL underlying bounded primitive
* (`executeWorktreeWaveCleanupPlan`, `src/worktree-safety.cts`) it wraps.
*
* #3676 review pass 3 (Spec finding): rows 34/35 were previously asserted
* only at the pure-function level (`routeMergeOutcome({kind:'merge_failed'})`
* returns `preserveWorktree:true` as a field on an object) — never against a
* REAL worktree directory or a REAL undeclared-deletion diff. This file
* closes that gap using the SAME real-git-fixture pattern `tests/
* worktree-safety.test.cjs` already establishes for `executeWorktreeWaveCleanupPlan`
* (`initRepo`/`addWorktree`/`commitInWorktree`, real `git`, real
* `fs.existsSync(wtDir)` assertions) — reimplemented locally since those
* helpers are module-private there, never duplicating the underlying
* primitive's OWN extensive test coverage (conflict isolation, deletion
* declaration parsing, etc. — that stays exclusively in worktree-safety's
* own suite).
*
* Named `gsd-quick-batch-*` (not `quick-batch-*`) so `scripts/
* lint-test-file-count.cjs`'s longest-prefix bucketing does not fold this
* cross-module integration test into any already-capped production-module
* bucket.
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const { gitOrThrow } = require('./helpers/git-fixture.cjs');
const { createTempDir, cleanup } = require('./helpers.cjs');
const { executeWorktreeWaveCleanupPlan, planWorktreeWaveCleanup } = require('../gsd-core/bin/lib/worktree-safety.cjs');
const { routeMergeOutcome } = require('../gsd-core/bin/lib/quick-batch-dispatch.cjs');
const SUBPROCESS_TIMEOUT_MS = 30_000;
function git(args, cwd) {
return gitOrThrow(args, { cwd, timeoutMs: SUBPROCESS_TIMEOUT_MS });
}
function initRepo(dir) {
fs.mkdirSync(dir, { recursive: true });
git(['init'], dir);
git(['config', 'user.email', 'test@test.com'], dir);
git(['config', 'user.name', 'Test'], dir);
git(['config', 'commit.gpgsign', 'false'], dir);
fs.writeFileSync(path.join(dir, 'README.md'), '# Test\n');
git(['add', '-A'], dir);
git(['commit', '-m', 'initial commit'], dir);
try { git(['branch', '-m', 'master', 'main'], dir); } catch { /* already main */ }
}
function addWorktree(repoDir, wtDir, branchName) {
git(['worktree', 'add', wtDir, '-b', branchName], repoDir);
}
describe('quick-batch merge routing — real worktree preserved on merge_failed (row 34)', () => {
test('a genuine merge conflict blocks the entry AND leaves the real worktree directory on disk; routeMergeOutcome confirms preserveWorktree', () => {
const tmpBase = createTempDir('qb-merge-fail-');
try {
const repoDir = path.join(tmpBase, 'repo');
const wtDir = path.join(tmpBase, 'wt-conflict');
const branchName = 'worktree-agent-conflict';
initRepo(repoDir);
addWorktree(repoDir, wtDir, branchName);
const baseCommit = git(['merge-base', 'HEAD', branchName], repoDir).trim();
// Diverge BOTH sides on the SAME file so the merge produces a real
// conflict — not a refused merge, an actual MERGE_HEAD conflict.
fs.writeFileSync(path.join(repoDir, 'shared.txt'), 'main branch version\n');
git(['add', '-A'], repoDir);
git(['commit', '-m', 'main: edit shared.txt'], repoDir);
fs.writeFileSync(path.join(wtDir, 'shared.txt'), 'worktree branch version\n');
git(['add', '-A'], wtDir);
git(['commit', '-m', 'worktree: edit shared.txt'], wtDir);
const plan = {
ok: true,
repoRoot: repoDir,
action: 'cleanup_wave',
discovery: 'manifest',
entries: [{
agent_id: 'conflict1',
worktree_path: wtDir,
branch: branchName,
expected_base: baseCommit,
}],
};
const result = executeWorktreeWaveCleanupPlan(plan);
assert.equal(result.entries[0].status, 'blocked', `expected a blocked entry, got: ${JSON.stringify(result.entries[0])}`);
assert.equal(result.entries[0].reason, 'merge_failed');
// The REAL worktree directory must still exist — the primitive never
// removed it for a blocked entry.
assert.ok(fs.existsSync(wtDir), 'worktree directory must survive a real merge conflict');
// quick-batch's own pure routing over this REAL result must agree:
// fail, with preserveWorktree explicitly true.
const routing = routeMergeOutcome({ kind: 'merge_failed', detail: result.entries[0].reason });
assert.equal(routing.action, 'fail');
assert.equal(routing.preserveWorktree, true);
// Consistency check: quick-batch's routing decision and the real
// primitive's own behavior agree — neither removed the worktree.
assert.ok(fs.existsSync(wtDir), 'worktree directory still exists after routing — routeMergeOutcome never performs I/O, this reasserts the invariant held');
} finally {
cleanup(tmpBase);
}
});
});
describe('quick-batch merge routing — real undeclared-deletion detection (row 35)', () => {
test('a real, undeclared file deletion blocks the merge and preserves the worktree; a declared one merges and removes it', () => {
const tmpBase = createTempDir('qb-merge-deletion-');
try {
const repoDir = path.join(tmpBase, 'repo');
initRepo(repoDir);
fs.writeFileSync(path.join(repoDir, 'legacy.txt'), 'to be deleted\n');
git(['add', '-A'], repoDir);
git(['commit', '-m', 'add legacy.txt'], repoDir);
// ── Case 1: UNDECLARED deletion — must block, worktree preserved ─────
const wtDirUndeclared = path.join(tmpBase, 'wt-undeclared');
const branchUndeclared = 'worktree-agent-undeclared';
addWorktree(repoDir, wtDirUndeclared, branchUndeclared);
const baseCommit = git(['merge-base', 'HEAD', branchUndeclared], repoDir).trim();
// A REAL deletion — actually remove the file and commit that removal.
fs.unlinkSync(path.join(wtDirUndeclared, 'legacy.txt'));
git(['add', '-A'], wtDirUndeclared);
git(['commit', '-m', 'delete legacy.txt'], wtDirUndeclared);
const undeclaredPlan = {
ok: true,
repoRoot: repoDir,
action: 'cleanup_wave',
discovery: 'manifest',
entries: [{
agent_id: 'undeclared1',
worktree_path: wtDirUndeclared,
branch: branchUndeclared,
expected_base: baseCommit,
// declared_deletions intentionally OMITTED — an undeclared deletion
// is indistinguishable from a forgotten one, per the design doc's
// own documented negative space; the guard blocks either way.
}],
};
const undeclaredResult = executeWorktreeWaveCleanupPlan(undeclaredPlan);
assert.equal(undeclaredResult.entries[0].status, 'blocked', `expected a blocked entry for the undeclared deletion, got: ${JSON.stringify(undeclaredResult.entries[0])}`);
assert.equal(undeclaredResult.entries[0].reason, 'branch_contains_deletions');
assert.ok(fs.existsSync(wtDirUndeclared), 'worktree with an undeclared real deletion must be preserved on disk');
const scopeRouting = routeMergeOutcome({ kind: 'scope_violation', detail: undeclaredResult.entries[0].reason });
assert.equal(scopeRouting.action, 'fail');
assert.equal(scopeRouting.preserveWorktree, true);
assert.match(scopeRouting.failureReason, /branch_contains_deletions/);
// ── Case 2: DECLARED deletion of the SAME real change — must merge ───
const wtDirDeclared = path.join(tmpBase, 'wt-declared');
const branchDeclared = 'worktree-agent-declared';
addWorktree(repoDir, wtDirDeclared, branchDeclared);
const baseCommit2 = git(['merge-base', 'HEAD', branchDeclared], repoDir).trim();
fs.unlinkSync(path.join(wtDirDeclared, 'legacy.txt'));
git(['add', '-A'], wtDirDeclared);
git(['commit', '-m', 'delete legacy.txt (declared)'], wtDirDeclared);
const declaredPlan = {
ok: true,
repoRoot: repoDir,
action: 'cleanup_wave',
discovery: 'manifest',
entries: [{
agent_id: 'declared1',
worktree_path: wtDirDeclared,
branch: branchDeclared,
expected_base: baseCommit2,
declared_deletions: ['legacy.txt'],
}],
};
const declaredResult = executeWorktreeWaveCleanupPlan(declaredPlan);
assert.equal(declaredResult.entries[0].status, 'merged_removed', `expected a declared deletion to merge cleanly, got: ${JSON.stringify(declaredResult.entries[0])}`);
assert.ok(!fs.existsSync(wtDirDeclared), 'worktree with a fully declared deletion must be removed after a successful merge');
const mergedRouting = routeMergeOutcome({ kind: 'merged' });
assert.equal(mergedRouting.action, 'complete');
} finally {
cleanup(tmpBase);
}
});
});
/**
* #3677 Phase 5 (epic #3344) — closes the three coverage gaps identified in
* `.gsd/phase/feat-3677-quick-batch-hardening-acceptance/40-design.md` §2/§3:
* arbitrary-worktree ownership tampering (security AC), advisory scope drift
* (scheduling AC), and a real-git submodule integration test (scheduling AC).
* Same real-git-fixture discipline as the two describe blocks above — no new
* mocks, no source-grep, real subprocess `git`.
*/
describe('quick-batch merge routing — arbitrary-worktree ownership tampering (Security AC)', () => {
test('a manifest entry naming a non-agent branch (e.g. the repo\'s own primary branch) is silently dropped at normalization and NEVER reaches git execution', () => {
const tmpBase = createTempDir('qb-ownership-tamper-');
try {
const repoDir = path.join(tmpBase, 'repo');
initRepo(repoDir);
const headBefore = git(['rev-parse', 'HEAD'], repoDir).trim();
// A tampered/corrupt manifest naming the repo's OWN primary branch as
// if it were a batch-owned worktree entry — the branch name fails
// WORKTREE_AGENT_BRANCH_RE (`^((worktree-)?agent-|worktree-wf_)...`),
// so it must never reach a git subprocess at all.
const tamperedManifest = {
worktrees: [{
agent_id: 'tampered',
worktree_path: repoDir,
branch: 'main',
expected_base: headBefore,
}],
};
const plan = planWorktreeWaveCleanup(repoDir, tamperedManifest);
assert.equal(plan.ok, false, `a non-agent branch name must be rejected before a plan is built, got: ${JSON.stringify(plan)}`);
assert.equal(plan.reason, 'empty_manifest');
assert.equal(plan.entries.length, 0);
const result = executeWorktreeWaveCleanupPlan(plan);
assert.equal(result.ok, false);
assert.equal(result.entries.length, 0, 'no entry may reach git execution for a rejected manifest');
// repoRoot must be provably untouched — same HEAD, no merge commit.
const headAfter = git(['rev-parse', 'HEAD'], repoDir).trim();
assert.equal(headAfter, headBefore, 'repo HEAD must not move when the only manifest entry was rejected at normalization');
} finally {
cleanup(tmpBase);
}
});
test('a manifest entry naming a plausible agent-branch that was never actually created by this repo\'s own worktree.create is blocked (base_mismatch), never merged', () => {
const tmpBase = createTempDir('qb-ownership-foreign-');
try {
const repoDir = path.join(tmpBase, 'repo');
const foreignDir = path.join(tmpBase, 'foreign');
initRepo(repoDir);
const headBefore = git(['rev-parse', 'HEAD'], repoDir).trim();
// A completely separate repository — never created via `git worktree
// add` against repoDir — whose HEAD branch happens to be named
// plausibly (passes WORKTREE_AGENT_BRANCH_RE). This is the "arbitrary"
// ownership-tampering case: the manifest ENTRY looks legitimate, but
// nothing about worktree_path proves it is actually repoDir's own
// worktree.
fs.mkdirSync(foreignDir, { recursive: true });
git(['init'], foreignDir);
git(['config', 'user.email', 'test@test.com'], foreignDir);
git(['config', 'user.name', 'Test'], foreignDir);
git(['config', 'commit.gpgsign', 'false'], foreignDir);
fs.writeFileSync(path.join(foreignDir, 'secret.txt'), 'not part of this batch\n');
git(['add', '-A'], foreignDir);
git(['commit', '-m', 'foreign repo initial commit'], foreignDir);
git(['checkout', '-b', 'agent-hostile-1'], foreignDir);
const plan = {
ok: true,
repoRoot: repoDir,
action: 'cleanup_wave',
discovery: 'manifest',
entries: [{
agent_id: 'hostile',
worktree_path: foreignDir,
branch: 'agent-hostile-1',
expected_base: headBefore,
allowed_bases: [headBefore],
}],
};
const result = executeWorktreeWaveCleanupPlan(plan);
assert.equal(result.ok, false);
assert.equal(result.entries[0].status, 'blocked', `expected a blocked entry for a foreign worktree_path, got: ${JSON.stringify(result.entries[0])}`);
// repoDir has no local ref named `agent-hostile-1` (it was only ever
// created in the FOREIGN repo) — merge-base against it fails closed.
assert.equal(result.entries[0].reason, 'base_mismatch');
// Neither side was touched: the foreign repo's secret file is intact
// and untouched by any merge/remove, and repoDir's HEAD never moved.
assert.ok(fs.existsSync(path.join(foreignDir, 'secret.txt')), 'foreign repo must be left completely alone');
const headAfter = git(['rev-parse', 'HEAD'], repoDir).trim();
assert.equal(headAfter, headBefore, 'repoDir HEAD must not move for a blocked foreign entry');
} finally {
cleanup(tmpBase);
}
});
// #3677 review pass 2 (Security finding): the two tests above prove
// branch-shape rejection and a wholly-foreign, never-registered repo are
// both handled — but neither exercises the scenario "arbitrary-worktree
// ownership" actually names: a manifest entry whose worktree_path/branch
// are SWAPPED to point at a DIFFERENT, GENUINELY-REGISTERED sibling
// worktree of the SAME repoRoot (a concurrent batch's own agent worktree,
// or a stale worktree from a prior crashed run), with a branch name that
// passes WORKTREE_AGENT_BRANCH_RE's shape check and a base that is
// legitimately in allowed_bases.
//
// Investigation conclusion (see
// `.gsd/phase/feat-3677-quick-batch-hardening-acceptance/40-design.md`
// §1's companion note): this is NOT a reachable gap in
// `executeWorktreeWaveCleanupPlan` itself. Git enforces branch-per-
// worktree uniqueness — the SAME branch cannot be checked out in two
// worktrees of one repo at once — so `worktree_path`'s ACTUAL checked-out
// branch (`git -C worktree_path rev-parse --abbrev-ref HEAD`,
// `src/worktree-safety.cts:1047`) can only equal a swapped-in
// `entry.branch` if that `entry.branch` is the SIBLING's own real,
// uniquely-generated branch name — which manifest tampering confined to
// ONE batch's own record has no way to know (branch names are
// `agent-<quick_id>[-<timestamp>]`-shaped, and `quick_id` allocation is
// collision-checked GLOBALLY across every existing quick task and batch,
// `src/quick-batch.cts` `collectExistingBatchQuickIds`). This test proves
// the boundary directly against TWO real, concurrently-alive sibling
// worktrees of the SAME repo, both created via real `git worktree add`,
// both with `WORKTREE_AGENT_BRANCH_RE`-passing names, both sharing the
// SAME merge-base — so base/branch-shape checks ALONE could not
// distinguish them if the primitive were naive; `branch_mismatch` is what
// actually does.
test('a manifest entry with one sibling worktree\'s real PATH but the OTHER sibling\'s real BRANCH name is blocked (branch_mismatch); both real, concurrently-alive worktrees survive untouched', () => {
const tmpBase = createTempDir('qb-ownership-sibling-swap-');
try {
const repoDir = path.join(tmpBase, 'repo');
const wt1Dir = path.join(tmpBase, 'wt-item1');
const wt2Dir = path.join(tmpBase, 'wt-item2');
const branch1 = 'agent-item1';
const branch2 = 'agent-item2';
initRepo(repoDir);
const headBefore = git(['rev-parse', 'HEAD'], repoDir).trim();
// TWO real, concurrently-alive sibling worktrees of the SAME repo —
// e.g. two items in the same batch dispatch round, or one item's
// worktree from THIS batch and a stale one left by a prior crashed
// run. Both branch off the SAME base commit.
addWorktree(repoDir, wt1Dir, branch1);
addWorktree(repoDir, wt2Dir, branch2);
fs.writeFileSync(path.join(wt2Dir, 'item2-own-work.txt'), 'item 2 real, uncommitted-to-main work\n');
git(['add', '-A'], wt2Dir);
git(['commit', '-m', 'item2: real work'], wt2Dir);
const item2CommitBefore = git(['rev-parse', 'HEAD'], wt2Dir).trim();
// Tampered/corrupted entry: intends item1's cleanup (branch1,
// item1's own base), but worktree_path has been swapped to point at
// item2's REAL, currently-in-use worktree.
const swappedPathPlan = {
ok: true,
repoRoot: repoDir,
action: 'cleanup_wave',
discovery: 'manifest',
entries: [{
agent_id: 'agent-item1',
worktree_path: wt2Dir,
branch: branch1,
expected_base: headBefore,
allowed_bases: [headBefore],
}],
};
const swappedPathResult = executeWorktreeWaveCleanupPlan(swappedPathPlan);
assert.equal(swappedPathResult.ok, false);
assert.equal(swappedPathResult.entries[0].status, 'blocked', `expected a blocked entry for a path/branch swap, got: ${JSON.stringify(swappedPathResult.entries[0])}`);
assert.equal(swappedPathResult.entries[0].reason, 'branch_mismatch', 'wt2Dir is really checked out on branch2, not branch1 — the swap cannot pass the branch check');
// The reverse swap is blocked the same way: item2's real path is
// untouched here too, so re-verify with wt1Dir/branch2 sharing the
// SAME base commit (no distinguishing signal except the branch
// check itself).
const reverseSwapPlan = {
ok: true,
repoRoot: repoDir,
action: 'cleanup_wave',
discovery: 'manifest',
entries: [{
agent_id: 'agent-item2',
worktree_path: wt1Dir,
branch: branch2,
expected_base: headBefore,
allowed_bases: [headBefore],
}],
};
const reverseSwapResult = executeWorktreeWaveCleanupPlan(reverseSwapPlan);
assert.equal(reverseSwapResult.entries[0].status, 'blocked');
assert.equal(reverseSwapResult.entries[0].reason, 'branch_mismatch');
// Both real, concurrently-alive sibling worktrees survive completely
// untouched by either tampered attempt — no merge landed, no
// worktree/branch removed, item2's real work is exactly as it was.
const headAfter = git(['rev-parse', 'HEAD'], repoDir).trim();
assert.equal(headAfter, headBefore, 'repoDir HEAD must not move for either blocked swap attempt');
assert.ok(fs.existsSync(wt1Dir), 'sibling worktree 1 must survive untouched');
assert.ok(fs.existsSync(wt2Dir), 'sibling worktree 2 must survive untouched');
assert.equal(git(['rev-parse', '--abbrev-ref', 'HEAD'], wt1Dir).trim(), branch1, 'worktree 1 must still be on its own real branch, never repointed');
assert.equal(git(['rev-parse', '--abbrev-ref', 'HEAD'], wt2Dir).trim(), branch2, 'worktree 2 must still be on its own real branch, never repointed');
assert.equal(git(['rev-parse', 'HEAD'], wt2Dir).trim(), item2CommitBefore, 'item 2\'s real, uncommitted-to-main work must be exactly as it was — never merged, never lost, never attributed to item1');
} finally {
cleanup(tmpBase);
}
});
});
describe('quick-batch merge routing — advisory scope drift merges but warns (Scheduling AC)', () => {
test('a committed path outside declared files_modified merges successfully with an advisory scope_out_of_declared warning, never blocked', () => {
const tmpBase = createTempDir('qb-scope-drift-');
try {
const repoDir = path.join(tmpBase, 'repo');
const wtDir = path.join(tmpBase, 'wt-drift');
const branchName = 'worktree-agent-drift';
initRepo(repoDir);
addWorktree(repoDir, wtDir, branchName);
const baseCommit = git(['merge-base', 'HEAD', branchName], repoDir).trim();
// The plan declared ONLY declared.txt, but the executor's real commit
// touches declared.txt AND an undeclared drifted.txt.
fs.writeFileSync(path.join(wtDir, 'declared.txt'), 'in scope\n');
fs.writeFileSync(path.join(wtDir, 'drifted.txt'), 'NOT declared\n');
git(['add', '-A'], wtDir);
git(['commit', '-m', 'touch declared.txt and drifted.txt'], wtDir);
const plan = {
ok: true,
repoRoot: repoDir,
action: 'cleanup_wave',
discovery: 'manifest',
entries: [{
agent_id: 'drift1',
worktree_path: wtDir,
branch: branchName,
expected_base: baseCommit,
files_modified: ['declared.txt'],
}],
};
const result = executeWorktreeWaveCleanupPlan(plan);
// Advisory only: the merge still SUCCEEDS despite the drift.
assert.equal(result.entries[0].status, 'merged_removed', `scope drift must be advisory, not blocking — got: ${JSON.stringify(result.entries[0])}`);
assert.ok(!fs.existsSync(wtDir), 'worktree removed after a successful (advisory-only) merge');
assert.ok(fs.existsSync(path.join(repoDir, 'drifted.txt')), 'the undeclared file is still merged in, not rejected');
// But the drift is surfaced, not silently swallowed.
assert.equal(result.warnings.length, 1);
assert.equal(result.warnings[0].code, 'scope_out_of_declared');
assert.equal(result.warnings[0].path, 'drifted.txt');
assert.equal(result.warnings[0].branch, branchName);
const routing = routeMergeOutcome({ kind: 'merged' });
assert.equal(routing.action, 'complete', 'a scope-drift warning must not change merge routing to a failure');
} finally {
cleanup(tmpBase);
}
});
test('a fully-declared commit (files_modified matches exactly) merges with zero warnings — boundary against the drift case above', () => {
const tmpBase = createTempDir('qb-scope-nodrift-');
try {
const repoDir = path.join(tmpBase, 'repo');
const wtDir = path.join(tmpBase, 'wt-nodrift');
const branchName = 'worktree-agent-nodrift';
initRepo(repoDir);
addWorktree(repoDir, wtDir, branchName);
const baseCommit = git(['merge-base', 'HEAD', branchName], repoDir).trim();
fs.writeFileSync(path.join(wtDir, 'declared.txt'), 'in scope\n');
git(['add', '-A'], wtDir);
git(['commit', '-m', 'touch only declared.txt'], wtDir);
const plan = {
ok: true,
repoRoot: repoDir,
action: 'cleanup_wave',
discovery: 'manifest',
entries: [{
agent_id: 'nodrift1',
worktree_path: wtDir,
branch: branchName,
expected_base: baseCommit,
files_modified: ['declared.txt'],
}],
};
const result = executeWorktreeWaveCleanupPlan(plan);
assert.equal(result.entries[0].status, 'merged_removed');
assert.equal(result.warnings.length, 0, 'an exact declared-scope match must produce no advisory warnings');
} finally {
cleanup(tmpBase);
}
});
});
describe('quick-batch merge routing — real .gitmodules submodule integration (Scheduling AC)', () => {
/**
* Builds `<repoDir>/vendor/sub` as a REAL git submodule (local file://
* remote, no network) pinned at `pinnedCommit`, committed on repoDir's
* primary branch. Returns the two submodule commits so a test can bump
* between them inside a worktree branch — the real "submodule-touch"
* scenario #3344's AC names.
*/
function buildRepoWithSubmodule(tmpBase) {
const subDir = path.join(tmpBase, 'subsrc');
fs.mkdirSync(subDir, { recursive: true });
git(['init'], subDir);
git(['config', 'user.email', 'test@test.com'], subDir);
git(['config', 'user.name', 'Test'], subDir);
git(['config', 'commit.gpgsign', 'false'], subDir);
fs.writeFileSync(path.join(subDir, 'f.txt'), 'hello\n');
git(['add', '-A'], subDir);
git(['commit', '-m', 'sub commit 1'], subDir);
const sub1 = git(['rev-parse', 'HEAD'], subDir).trim();
fs.appendFileSync(path.join(subDir, 'f.txt'), 'world\n');
git(['add', '-A'], subDir);
git(['commit', '-m', 'sub commit 2'], subDir);
const sub2 = git(['rev-parse', 'HEAD'], subDir).trim();
const repoDir = path.join(tmpBase, 'repo');
initRepo(repoDir);
git(['-c', 'protocol.file.allow=always', 'submodule', 'add', subDir, 'vendor/sub'], repoDir);
// Pin the just-added submodule checkout to sub1 so the worktree branch
// below has a REAL pointer bump (sub1 -> sub2) to commit, not a no-op.
git(['checkout', sub1], path.join(repoDir, 'vendor', 'sub'));
git(['add', 'vendor/sub', '.gitmodules'], repoDir);
git(['commit', '-m', 'add submodule pinned at sub1'], repoDir);
return { repoDir, sub1, sub2 };
}
test('a repo with .gitmodules and a plan that never touches the submodule merges cleanly through the cleanup primitive', () => {
const tmpBase = createTempDir('qb-submodule-untouched-');
try {
const { repoDir } = buildRepoWithSubmodule(tmpBase);
const wtDir = path.join(tmpBase, 'wt-unrelated');
const branchName = 'worktree-agent-sub-unrelated';
addWorktree(repoDir, wtDir, branchName);
const baseCommit = git(['merge-base', 'HEAD', branchName], repoDir).trim();
fs.writeFileSync(path.join(wtDir, 'unrelated.txt'), 'nothing to do with the submodule\n');
git(['add', '-A'], wtDir);
git(['commit', '-m', 'touch unrelated.txt only'], wtDir);
const plan = {
ok: true,
repoRoot: repoDir,
action: 'cleanup_wave',
discovery: 'manifest',
entries: [{
agent_id: 'sub-unrelated',
worktree_path: wtDir,
branch: branchName,
expected_base: baseCommit,
files_modified: ['unrelated.txt'],
}],
};
const result = executeWorktreeWaveCleanupPlan(plan);
assert.equal(result.entries[0].status, 'merged_removed', `a .gitmodules-bearing repo must not break an unrelated merge — got: ${JSON.stringify(result.entries[0])}`);
assert.equal(result.warnings.length, 0);
} finally {
cleanup(tmpBase);
}
});
test('a real submodule pointer bump (sub1 -> sub2) merges cleanly and the superproject tree reflects the new pinned commit', () => {
const tmpBase = createTempDir('qb-submodule-bump-');
try {
const { repoDir, sub2 } = buildRepoWithSubmodule(tmpBase);
const wtDir = path.join(tmpBase, 'wt-bump');
const branchName = 'worktree-agent-sub-bump';
addWorktree(repoDir, wtDir, branchName);
const baseCommit = git(['merge-base', 'HEAD', branchName], repoDir).trim();
git(['-c', 'protocol.file.allow=always', 'submodule', 'update', '--init'], wtDir);
git(['checkout', sub2], path.join(wtDir, 'vendor', 'sub'));
git(['add', 'vendor/sub'], wtDir);
git(['commit', '-m', 'bump vendor/sub to sub2'], wtDir);
const plan = {
ok: true,
repoRoot: repoDir,
action: 'cleanup_wave',
discovery: 'manifest',
entries: [{
agent_id: 'sub-bump',
worktree_path: wtDir,
branch: branchName,
expected_base: baseCommit,
files_modified: ['vendor/sub'],
}],
};
const result = executeWorktreeWaveCleanupPlan(plan);
assert.equal(result.entries[0].status, 'merged_removed', `a real gitlink pointer bump must merge like any other file change — got: ${JSON.stringify(result.entries[0])}`);
assert.equal(result.warnings.length, 0, 'declaring vendor/sub in files_modified must suppress the scope-drift advisory for this exact bump');
// The superproject's own tree now points at the new submodule commit —
// the concrete, diagnosable outcome of a "submodule-touch" merge.
const treeEntry = git(['ls-tree', 'HEAD', 'vendor/sub'], repoDir).trim();
assert.match(treeEntry, /^160000 commit /, 'vendor/sub must remain a gitlink (mode 160000), never mis-parsed as a regular file');
assert.match(treeEntry, new RegExp(sub2), 'the merged tree must point at the bumped submodule commit');
} finally {
cleanup(tmpBase);
}
});
test('a submodule pointer bump NOT declared in files_modified still merges (advisory, not blocking) but surfaces a scope warning naming vendor/sub', () => {
const tmpBase = createTempDir('qb-submodule-undeclared-');
try {
const { repoDir, sub2 } = buildRepoWithSubmodule(tmpBase);
const wtDir = path.join(tmpBase, 'wt-bump-undeclared');
const branchName = 'worktree-agent-sub-bump-undeclared';
addWorktree(repoDir, wtDir, branchName);
const baseCommit = git(['merge-base', 'HEAD', branchName], repoDir).trim();
git(['-c', 'protocol.file.allow=always', 'submodule', 'update', '--init'], wtDir);
git(['checkout', sub2], path.join(wtDir, 'vendor', 'sub'));
git(['add', 'vendor/sub'], wtDir);
git(['commit', '-m', 'bump vendor/sub to sub2, undeclared'], wtDir);
const plan = {
ok: true,
repoRoot: repoDir,
action: 'cleanup_wave',
discovery: 'manifest',
entries: [{
agent_id: 'sub-bump-undeclared',
worktree_path: wtDir,
branch: branchName,
expected_base: baseCommit,
files_modified: ['README.md'],
}],
};
const result = executeWorktreeWaveCleanupPlan(plan);
assert.equal(result.entries[0].status, 'merged_removed', 'an undeclared submodule touch is advisory-only, same as any other undeclared modification');
assert.equal(result.warnings.length, 1);
assert.equal(result.warnings[0].code, 'scope_out_of_declared');
assert.equal(result.warnings[0].path, 'vendor/sub');
} finally {
cleanup(tmpBase);
}
});
});