Files
msd-core/tests/execute-phase-worktree-guard.test.cjs
Tom Boucher 1d208e5af6 test(#3144): bound the git/worktree cluster onto the process seam (#3152)
* test(#3144): bound the git/worktree cluster onto the process seam

Migrates 180 unbounded sync spawn sites across 19 files. Every previously
unbounded call now carries an explicit timeout with a comment giving the
number and why.

The migration is not a callee swap. execSync and execFileSync throw on a
non-zero exit and the seam never does, so each site was classified first:
sites that rely on the throw route to gitOrThrow, and sites that already read
.status to detect an EXPECTED non-zero -- an intended cherry-pick conflict, a
rev-parse outside a repo driving a skip -- route to the never-throwing runGit
instead, which would otherwise throw on exactly the exit being probed for.

Two same-named git() helpers in worktree-cleanup.test.cjs have different
return contracts, one trimmed and one raw; both are preserved rather than
unified.

Collapses five hand-rolled throw wrappers onto one throwIfFailed in
git-fixture.cjs, which gitOrThrow now also uses so the shape cannot drift.

Allowlist drops 139 to 120; BASELINE lowered to match.

* test(#3144): fix pre-PR review findings

Documents throwIfFailed in the CONTEXT.md glossary and CONTRIBUTING.md --
it became the shared throw mechanism without either doc naming it.

Routes the sixth and seventh hand-rolled copies of the throw shape through
throwIfFailed (worktree-baseref-install, worktree-safety-reap); the first
consolidation missed both.

Converts ci-rebase-check's 8 fixture-setup calls from unchecked runGit to
gitOrThrow so a failed setup step aborts where it fails rather than
surfacing later as a confusing failure against the wrong subject.

Adds 12 direct unit tests for throwIfFailed, which until now was only
exercised transitively.

Splits verify.test.cjs's non-git grep/sed bound off GIT_TIMEOUT_MS.

---------

Co-authored-by: sim <sim@local>
2026-08-07 10:58:34 -04:00

235 lines
8.5 KiB
JavaScript

// allow-test-rule: source-text-is-the-product see #1856
// The orchestrator cwd-drift guard (#48) is shell EMBEDDED in execute-phase.md.
// The shipped text IS the runtime contract, so these tests extract the block and
// EXECUTE it against real git fixtures rather than asserting on its characters —
// the readFileSync here is extraction for execution, not a source-grep assertion.
'use strict';
const { test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { cleanup } = require('./helpers.cjs');
const { runHook } = require('./helpers/process-seam.cjs');
const { gitOrThrow } = require('./helpers/git-fixture.cjs');
const ROOT = path.resolve(__dirname, '..');
const WORKFLOW = path.join(ROOT, 'gsd-core', 'workflows', 'execute-phase.md');
const GUARD_MARKER = 'gsd:guard=orchestrator-cwd-drift';
// 30s: git plumbing (init/config/add/commit/checkout/rev-parse) against a
// small mkdtemp fixture repo — already the file's calibrated bound
// pre-migration (see runGuard below), reused here for consistency.
const GIT_TIMEOUT_MS = 30_000;
/**
* Pull the guard's bash block out of the workflow. Anchored on a stable marker
* comment rather than a line range so the test does not rot when the file moves.
*/
function guardScript() {
const md = fs.readFileSync(WORKFLOW, 'utf8');
const fences = md.split('```');
for (let i = 1; i < fences.length; i += 2) {
const body = fences[i].replace(/^bash\r?\n/, '');
if (body.includes(GUARD_MARKER)) return body;
}
throw new Error(
`no fenced block carrying "${GUARD_MARKER}" in ${WORKFLOW} — the guard must be ` +
'marked so this contract test can execute the shipped text.',
);
}
const git = (cwd, ...args) => gitOrThrow(args, { cwd, timeoutMs: GIT_TIMEOUT_MS });
/** A real repo with a base branch and one commit. */
function makeRepo() {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1856-'));
git(dir, 'init', '--quiet', '--initial-branch', 'next');
git(dir, 'config', 'user.email', 'test@example.com');
git(dir, 'config', 'user.name', 'Test');
fs.writeFileSync(path.join(dir, 'base.txt'), 'base\n');
git(dir, 'add', '-A');
git(dir, 'commit', '--quiet', '-m', 'base');
return dir;
}
function commitFile(dir, name, msg) {
fs.writeFileSync(path.join(dir, name), `${name}\n`);
git(dir, 'add', '-A');
git(dir, 'commit', '--quiet', '-m', msg);
}
/** Run the extracted guard in `dir`. Never throws — returns the observed result. */
function runGuard(dir) {
// 30s: already bounded pre-migration (unchanged) — the guard runs a handful
// of git plumbing calls (rev-parse, log, status) against a small fixture repo.
const res = runHook('-c', [guardScript()], {
interpreter: 'bash',
cwd: dir,
timeoutMs: 30_000,
env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
});
return { status: res.exitCode, stdout: res.stdout || '', stderr: res.stderr || '' };
}
test('#1856: refusal names the stranded commits and the dirty tree', () => {
const dir = makeRepo();
try {
git(dir, 'checkout', '--quiet', '-b', 'worktree-agent-36.12-05-090827');
for (const m of ['wire roto toggle', 'preserve roto keys', 'simplify controls',
'count semantics', 'bound regeneration']) {
commitFile(dir, m.replace(/\s/g, '-') + '.txt', `fix(36.12-05): ${m}`);
}
fs.writeFileSync(path.join(dir, 'uncommitted.txt'), 'work in progress\n');
const r = runGuard(dir);
assert.equal(r.status, 1, 'the guard must still REFUSE — #48 is load-bearing');
assert.match(r.stderr, /worktree-agent-36\.12-05-090827/, 'names the branch');
// The whole point of #1856: the refusal must not be a dead end.
assert.match(
r.stderr,
/5\s+commit/i,
'must report how many commits are stranded on the agent branch — without this the ' +
'user cannot tell that switching away loses work (#1856)',
);
assert.match(
r.stderr,
/uncommitted|dirty|unstaged/i,
'must report that the worktree still has uncommitted changes (#1856)',
);
assert.match(
r.stderr,
/cherry-pick|merge/i,
'must give an integration command, not just "re-run from the orchestrator worktree"',
);
} finally {
cleanup(dir);
}
});
test('#1856: the bare agent- namespace is handled identically', () => {
const dir = makeRepo();
try {
git(dir, 'checkout', '--quiet', '-b', 'agent-a1b2c3');
commitFile(dir, 'one.txt', 'feat: one');
const r = runGuard(dir);
assert.equal(r.status, 1);
assert.match(r.stderr, /agent-a1b2c3/);
assert.match(r.stderr, /1\s+commit/i);
} finally {
cleanup(dir);
}
});
test('#1856: a clean agent worktree still refuses, without a wall of empty sections', () => {
const dir = makeRepo();
try {
git(dir, 'checkout', '--quiet', '-b', 'agent-clean');
const r = runGuard(dir);
assert.equal(r.status, 1, 'still refuses');
// Nothing is stranded, so nothing must be claimed to be.
assert.doesNotMatch(
r.stderr,
/\b[1-9]\d*\s+commit/i,
'a branch with no commits ahead must not report stranded commits',
);
assert.doesNotMatch(
r.stderr,
/uncommitted changes/i,
'a clean tree must not be reported as dirty',
);
} finally {
cleanup(dir);
}
});
test('#1856: boundary — 1 and 2 commits ahead are both reported accurately', () => {
for (const n of [1, 2]) {
const dir = makeRepo();
try {
git(dir, 'checkout', '--quiet', '-b', 'agent-count');
for (let i = 0; i < n; i += 1) commitFile(dir, `c${i}.txt`, `feat: c${i}`);
const r = runGuard(dir);
assert.equal(r.status, 1);
assert.match(r.stderr, new RegExp(`\\b${n}\\s+commit`, 'i'), `${n} ahead reported`);
} finally {
cleanup(dir);
}
}
});
test('#1856: does not fire on an ordinary orchestrator branch', () => {
const dir = makeRepo();
try {
const r = runGuard(dir); // still on `next`
assert.equal(r.status, 0, `guard must not fire on a normal branch: ${r.stderr}`);
assert.doesNotMatch(r.stderr, /refusing to execute waves/);
} finally {
cleanup(dir);
}
});
test('#1856: does not fire on a branch that merely starts with "agent"', () => {
const dir = makeRepo();
try {
// The discriminator is the `agent-` NAMESPACE. `agentic-refactor` is an
// ordinary feature branch and must run.
git(dir, 'checkout', '--quiet', '-b', 'agentic-refactor');
const r = runGuard(dir);
assert.equal(r.status, 0, `guard must not fire on agentic-refactor: ${r.stderr}`);
} finally {
cleanup(dir);
}
});
test('#1856: does not fire on a legitimate feature worktree', () => {
// Recorded constraint in the guard's own comment: the discriminator is the branch
// namespace, NOT the .claude/worktrees/ path — the orchestrator may legitimately
// run from a feature worktree there, and a path check would break that.
const dir = makeRepo();
try {
const wt = path.join(dir, '.claude', 'worktrees', 'feature');
fs.mkdirSync(path.dirname(wt), { recursive: true });
git(dir, 'worktree', 'add', '--quiet', '-b', 'feat/legit', wt);
const r = runGuard(wt);
assert.equal(r.status, 0, `a feature worktree must run: ${r.stderr}`);
} finally {
cleanup(dir);
}
});
test('#1856: reporting degrades to the plain refusal when no base ref resolves', () => {
// No origin, no main/next to compare against — the guard must still refuse
// cleanly rather than crash or hang before printing.
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-1856-nobase-'));
try {
git(dir, 'init', '--quiet', '--initial-branch', 'agent-orphan');
git(dir, 'config', 'user.email', 'test@example.com');
git(dir, 'config', 'user.name', 'Test');
fs.writeFileSync(path.join(dir, 'a.txt'), 'a\n');
git(dir, 'add', '-A');
git(dir, 'commit', '--quiet', '-m', 'only');
const r = runGuard(dir);
assert.equal(r.status, 1, 'still refuses with no resolvable base');
assert.match(r.stderr, /refusing to execute waves/);
} finally {
cleanup(dir);
}
});
test('#1856: detached HEAD does not crash the guard', () => {
const dir = makeRepo();
try {
const sha = git(dir, 'rev-parse', 'HEAD').trim();
git(dir, 'checkout', '--quiet', '--detach', sha);
const r = runGuard(dir);
// `rev-parse --abbrev-ref HEAD` yields "HEAD" when detached — not an agent
// branch, so the guard must pass through without erroring.
assert.equal(r.status, 0, `detached HEAD must not crash the guard: ${r.stderr}`);
} finally {
cleanup(dir);
}
});