Files
msd-core/tests/execute-phase-worktree-guard.test.cjs
Jakub Zych a9a7a328e6 refactor: hard-fork GSD -> MSD (Make Software Done)
Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD
across contents and paths, upstream package/repo coordinates -> @golem15/msd-core
and golem15com/msd-core. Deep links into upstream history, sibling upstream
packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is.

Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line,
package/plugin identity, regenerated lockfile, install-tree fixtures, derived
registries and benchmark baseline; migration checksum baseline re-locked
(MSD keeps its own install state, so no install had applied the old sums);
sort-order and regex-escaped expectations in tests adjusted.
2026-10-06 01:47:40 +02:00

241 lines
8.9 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 { HOOK_FANOUT_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
const ROOT = path.resolve(__dirname, '..');
const WORKFLOW = path.join(ROOT, 'msd-core', 'workflows', 'execute-phase.md');
const GUARD_MARKER = 'msd: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(), 'msd-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) {
// Bash FAN-OUT: the guard runs a sequence of git plumbing calls
// (rev-parse, log, status) under one `bash` interpreter, not a single git
// call — the wrong class for a plumbing-sized bound. Same class as the
// observed CI failures in tests/quick-branching.test.cjs (PR #3787 run
// 32668773524) and tests/worktree-safety.test.cjs (`next` run
// 32608945654). See HOOK_FANOUT_TIMEOUT_MS in ./helpers/timeouts.cjs for
// the class rationale.
const res = runHook('-c', [guardScript()], {
interpreter: 'bash',
cwd: dir,
timeoutMs: HOOK_FANOUT_TIMEOUT_MS,
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(), 'msd-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);
}
});