Files
msd-core/tests/git-base-branch.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

2889 lines
133 KiB
JavaScript

'use strict';
/**
* #1146: git.base-branch resolver — single source of truth for default-branch detection.
*
* Tests:
* A. Config override wins (git.base_branch set → returned as-is, no git calls needed)
* B. origin/HEAD symref resolves → used
* C. origin/HEAD unset but git remote show origin knows HEAD → AUTHORITATIVE fallback
* (key regression: master repo with no origin/HEAD → must return "master", NOT "main")
* D. No origin/HEAD, no remote show, local branch "master" present → returns "master"
* E. No origin/HEAD, no remote show, local branch "main" present → returns "main"
* F. No origin/HEAD, no remote show, no local branches → returns "main" (last resort)
* G. Anti-regression guard: five affected workflows must NOT contain the
* duplicated bare `:-main` / `:-master` fallback pattern that was the root cause.
* They must call `msd_run query git.base-branch` instead.
* (see the source-text-is-the-product exemption declared below this docblock —
* the workflow .md content IS the runtime surface; the absence of the bad
* pattern is what ships to agents.)
*/
// allow-test-rule: source-text-is-the-product
// Justification: the workflow .md files ARE the product surface — agents read and
// execute them directly. Guard G asserts that the resolved command appears in all five
// workflows, which requires reading those workflow files. Per TESTING-STANDARDS.md §6.
const { describe, 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 { runMsdTools, cleanup, readFileNormalized } = require('./helpers.cjs');
const { ExitError } = require('../msd-core/bin/lib/cli-exit.cjs');
const { makeFaultyGit } = require('./helpers/faulty-deps.cjs');
const { gitOrThrow, throwIfFailed } = require('./helpers/git-fixture.cjs');
const { runHook } = require('./helpers/process-seam.cjs');
// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs.
const { GIT_TIMEOUT_MS, HOOK_FANOUT_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
/**
* The #3819 executor pre-commit protected-branch guard test harness below
* invokes a bash script with fake `git`/`msd_run` shell functions defined
* INLINE — no real subprocess fan-out happens, so this is lighter than
* `HOOK_FANOUT_TIMEOUT_MS` (60000ms, sized for a hook that fans out to
* nested shell spawns). Pre-existing value, unchanged by this migration.
*/
const EXECUTOR_GUARD_TIMEOUT_MS = 10000;
/**
* These four constants are NOT bounds on this suite's own subprocess calls —
* they are the EXACT timeout values production code (gitBaseBranch's
* trySymbolicRef / tryRemoteShow / tryLocalBranch / gitWorktreeInfoInternal,
* in src/, out of this batch's scope) hardcodes when calling its own git
* execution callback. Each test below asserts the captured call args to
* prove production didn't drift from its documented bound. Kept as FOUR
* separate constants rather than consolidated, even where values coincide
* today (TIER2 and TIER4 are both 5000ms) — collapsing them would let two
* independently-implemented production tiers drift from each other while
* the test kept passing, which defeats the point of a pinned-value
* assertion. All four are pre-existing values, unchanged by this migration.
*/
const TIER2_SYMBOLIC_REF_TIMEOUT_MS = 5000;
const TIER3_REMOTE_SHOW_TIMEOUT_MS = 15000;
const TIER4_LOCAL_BRANCH_TIMEOUT_MS = 5000;
const WORKTREE_INFO_TIMEOUT_MS = 5000;
// ─── helpers ──────────────────────────────────────────────────────────────────
/**
* Create a minimal git repo in a temp dir, optionally setting up a remote
* and local branches.
*/
function createGitRepo(opts = {}) {
const { prefix = 'msd-1146-', defaultBranch = 'master' } = opts;
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
gitOrThrow(['init', '-b', defaultBranch], { cwd: dir, timeoutMs: GIT_TIMEOUT_MS });
gitOrThrow(['config', 'user.email', 'test@test.com'], { cwd: dir, timeoutMs: GIT_TIMEOUT_MS });
gitOrThrow(['config', 'user.name', 'Test'], { cwd: dir, timeoutMs: GIT_TIMEOUT_MS });
gitOrThrow(['config', 'commit.gpgsign', 'false'], { cwd: dir, timeoutMs: GIT_TIMEOUT_MS });
// Need at least one commit so branches exist
fs.writeFileSync(path.join(dir, 'README.md'), '# test\n');
gitOrThrow(['add', 'README.md'], { cwd: dir, timeoutMs: GIT_TIMEOUT_MS });
gitOrThrow(['commit', '-m', 'init'], { cwd: dir, timeoutMs: GIT_TIMEOUT_MS });
return dir;
}
/**
* Create a .planning dir so msd-tools resolveProjectRoot doesn't bail.
*/
function addPlanning(dir) {
fs.mkdirSync(path.join(dir, '.planning', 'phases'), { recursive: true });
}
/** Snapshot every file below .planning, including bytes and relative paths. */
function snapshotPlanningTree(dir) {
const root = path.join(dir, '.planning');
const snapshot = new Map();
function visit(current) {
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
const absolute = path.join(current, entry.name);
if (entry.isDirectory()) visit(absolute);
else snapshot.set(path.relative(root, absolute), fs.readFileSync(absolute));
}
}
visit(root);
return snapshot;
}
/**
* Write a msd config.json with git.base_branch set.
*/
function setMsdConfig(dir, key, value) {
const cfgDir = path.join(dir, '.planning');
fs.mkdirSync(cfgDir, { recursive: true });
const cfgPath = path.join(cfgDir, 'config.json');
let cfg = {};
try { cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); } catch (_) { /* new file */ }
// Set nested key (dot notation). Guard every segment against prototype
// pollution with inline literal checks at each write site — mirrors the
// production guard in src/config.cts. A Set/pre-loop guard is NOT recognised
// by CodeQL's js/prototype-pollution-utility query (see PR #752 / alert #40).
const parts = key.split('.');
let obj = cfg;
for (let i = 0; i < parts.length - 1; i++) {
const k = parts[i];
if (k === '__proto__' || k === 'prototype' || k === 'constructor') {
throw new Error(`setMsdConfig: unsafe config key segment '${k}'`);
}
if (typeof obj[k] !== 'object' || obj[k] === null) obj[k] = {};
obj = obj[k];
}
const lastKey = parts[parts.length - 1];
if (lastKey === '__proto__' || lastKey === 'prototype' || lastKey === 'constructor') {
throw new Error(`setMsdConfig: unsafe config key segment '${lastKey}'`);
}
obj[lastKey] = value;
fs.writeFileSync(cfgPath, JSON.stringify(cfg, null, 2) + '\n');
}
// Paths to the five affected workflow files
const WORKFLOW_DIR = path.join(__dirname, '..', 'msd-core', 'workflows');
const AFFECTED_WORKFLOWS = [
path.join(WORKFLOW_DIR, 'execute-phase.md'),
path.join(WORKFLOW_DIR, 'quick.md'),
path.join(WORKFLOW_DIR, 'ship.md'),
path.join(WORKFLOW_DIR, 'complete-milestone.md'),
path.join(WORKFLOW_DIR, 'pr-branch.md'),
];
// ─── Test suite ───────────────────────────────────────────────────────────────
describe('#1146: git.base-branch resolver', () => {
test('A. config override git.base_branch → returned immediately', (t) => {
const dir = createGitRepo({ prefix: 'msd-1146-a-', defaultBranch: 'master' });
t.after(() => cleanup(dir));
addPlanning(dir);
setMsdConfig(dir, 'git.base_branch', 'develop');
const result = runMsdTools(['query', 'git.base-branch'], dir);
assert.ok(result.success, `git.base-branch with config override failed:\n${result.error}`);
const branch = result.output.trim();
assert.strictEqual(branch, 'develop',
`Expected config override 'develop', got: '${branch}'`);
});
test('B. origin/HEAD symref resolves → returned', (t) => {
// Create an "origin" bare repo with main branch
const originDir = createGitRepo({ prefix: 'msd-1146-b-origin-', defaultBranch: 'main' });
const worktreeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-1146-b-wt-'));
t.after(() => { cleanup(originDir); cleanup(worktreeDir); });
// Clone from origin — this sets origin/HEAD
gitOrThrow(['clone', originDir, worktreeDir], { timeoutMs: GIT_TIMEOUT_MS });
gitOrThrow(['config', 'user.email', 'test@test.com'], { cwd: worktreeDir, timeoutMs: GIT_TIMEOUT_MS });
gitOrThrow(['config', 'user.name', 'Test'], { cwd: worktreeDir, timeoutMs: GIT_TIMEOUT_MS });
addPlanning(worktreeDir);
// Verify origin/HEAD is set (it should be after clone)
const symref = gitOrThrow(['symbolic-ref', 'refs/remotes/origin/HEAD'], { cwd: worktreeDir, timeoutMs: GIT_TIMEOUT_MS }).trim();
assert.ok(symref.includes('origin/main'), `Expected origin/HEAD→origin/main, got: ${symref}`);
const result = runMsdTools(['query', 'git.base-branch'], worktreeDir);
assert.ok(result.success, `git.base-branch symref test failed:\n${result.error}`);
const branch = result.output.trim();
assert.strictEqual(branch, 'main',
`Expected 'main' from origin/HEAD, got: '${branch}'`);
});
test('C. KEY REGRESSION — master repo, origin/HEAD unset → returns "master" not "main"', (t) => {
// This is the bug: git init + remote add without git remote set-head → no origin/HEAD
// Current code falls back to :-main → wrong. Fixed code uses `git remote show origin`.
const originDir = createGitRepo({ prefix: 'msd-1146-c-origin-', defaultBranch: 'master' });
const cloneDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-1146-c-clone-'));
t.after(() => { cleanup(originDir); cleanup(cloneDir); });
// Manually add remote WITHOUT cloning (so origin/HEAD is never set)
gitOrThrow(['init'], { cwd: cloneDir, timeoutMs: GIT_TIMEOUT_MS });
gitOrThrow(['config', 'user.email', 'test@test.com'], { cwd: cloneDir, timeoutMs: GIT_TIMEOUT_MS });
gitOrThrow(['config', 'user.name', 'Test'], { cwd: cloneDir, timeoutMs: GIT_TIMEOUT_MS });
gitOrThrow(['config', 'commit.gpgsign', 'false'], { cwd: cloneDir, timeoutMs: GIT_TIMEOUT_MS });
gitOrThrow(['remote', 'add', 'origin', originDir], { cwd: cloneDir, timeoutMs: GIT_TIMEOUT_MS });
gitOrThrow(['fetch', 'origin'], { cwd: cloneDir, timeoutMs: GIT_TIMEOUT_MS });
// Explicitly delete origin/HEAD in case git fetch auto-set it (newer git versions may do this)
try {
gitOrThrow(['remote', 'set-head', 'origin', '--delete'], { cwd: cloneDir, timeoutMs: GIT_TIMEOUT_MS });
} catch (_) { /* ignore — may not exist */ }
addPlanning(cloneDir);
// Confirm origin/HEAD is unset
let hasSymref = true;
try {
gitOrThrow(['symbolic-ref', 'refs/remotes/origin/HEAD'], { cwd: cloneDir, timeoutMs: GIT_TIMEOUT_MS });
} catch (_) {
hasSymref = false;
}
assert.strictEqual(hasSymref, false, 'Test setup: origin/HEAD must be unset for this test case');
const result = runMsdTools(['query', 'git.base-branch'], cloneDir);
assert.ok(result.success, `git.base-branch regression test failed:\n${result.error}`);
const branch = result.output.trim();
assert.strictEqual(branch, 'master',
`BUG REGRESSION: master repo with origin/HEAD unset must return 'master', got: '${branch}'`);
});
test('D. No remote, local branch "master" present, "main" absent → returns "master"', (t) => {
const dir = createGitRepo({ prefix: 'msd-1146-d-', defaultBranch: 'master' });
t.after(() => cleanup(dir));
addPlanning(dir);
// No remote configured — falls through to local branch detection
const result = runMsdTools(['query', 'git.base-branch'], dir);
assert.ok(result.success, `git.base-branch local branch test failed:\n${result.error}`);
const branch = result.output.trim();
assert.strictEqual(branch, 'master',
`Expected 'master' from local branch detection, got: '${branch}'`);
});
test('E. No remote, local branch "main" present → returns "main"', (t) => {
const dir = createGitRepo({ prefix: 'msd-1146-e-', defaultBranch: 'main' });
t.after(() => cleanup(dir));
addPlanning(dir);
const result = runMsdTools(['query', 'git.base-branch'], dir);
assert.ok(result.success, `git.base-branch main branch test failed:\n${result.error}`);
const branch = result.output.trim();
assert.strictEqual(branch, 'main',
`Expected 'main' from local branch detection, got: '${branch}'`);
});
test('F. No remote, no main/master local branch → returns "main" (last resort default)', (t) => {
const dir = createGitRepo({ prefix: 'msd-1146-f-', defaultBranch: 'develop' });
t.after(() => cleanup(dir));
addPlanning(dir);
// Branch named "develop" — neither main nor master
const result = runMsdTools(['query', 'git.base-branch'], dir);
assert.ok(result.success, `git.base-branch default fallback test failed:\n${result.error}`);
const branch = result.output.trim();
assert.strictEqual(branch, 'main',
`Expected 'main' as last resort default, got: '${branch}'`);
});
test('A2. config override with flat base_branch key (legacy form) → returned immediately', (t) => {
const dir = createGitRepo({ prefix: 'msd-1146-a2-', defaultBranch: 'master' });
t.after(() => cleanup(dir));
addPlanning(dir);
// Write flat base_branch directly to config root (legacy form, not nested under "git")
const cfgPath = require('node:path').join(dir, '.planning', 'config.json');
require('node:fs').writeFileSync(cfgPath, JSON.stringify({ base_branch: 'release' }, null, 2) + '\n');
const result = runMsdTools(['query', 'git.base-branch'], dir);
assert.ok(result.success, `git.base-branch with flat config key failed:\n${result.error}`);
const branch = result.output.trim();
assert.strictEqual(branch, 'release',
`Expected flat config override 'release', got: '${branch}'`);
});
test('A3. #3648 precedence: both flat base_branch and nested git.base_branch set → nested (canonical) wins', (t) => {
// Regression for #3648 review Blocker 1: production config resolution
// (config-loader's `get()`) is flat-first, so a project that migrated to
// the namespaced `git.base_branch` but still carries a stale flat
// `base_branch` from before migration would silently get the old flat
// value back. `normalizeLegacyKeys` must hoist/resolve `base_branch` the
// same way it already does `branching_strategy` — canonical nested wins,
// stale flat top-level is dropped.
const dir = createGitRepo({ prefix: 'msd-3648-a3-', defaultBranch: 'master' });
t.after(() => cleanup(dir));
addPlanning(dir);
const cfgPath = require('node:path').join(dir, '.planning', 'config.json');
require('node:fs').writeFileSync(
cfgPath,
JSON.stringify({ base_branch: 'stale-flat', git: { base_branch: 'canonical-nested' } }, null, 2) + '\n',
);
const result = runMsdTools(['query', 'git.base-branch'], dir);
assert.ok(result.success, `git.base-branch with both config keys failed:\n${result.error}`);
const branch = result.output.trim();
assert.strictEqual(branch, 'canonical-nested',
`Expected namespaced 'git.base_branch' to win over stale flat 'base_branch', got: '${branch}'`);
});
test('H. No remote, both "main" and "master" local branches exist → returns "main" (main wins tie-break)', (t) => {
// Tier-4 tie-break: when both main and master exist locally and no remote info is available,
// "main" wins (documented in tryLocalBranch JSDoc — modern default).
const dir = createGitRepo({ prefix: 'msd-1146-h-', defaultBranch: 'master' });
t.after(() => cleanup(dir));
addPlanning(dir);
// Create a "main" branch alongside the existing "master"
gitOrThrow(['branch', 'main'], { cwd: dir, timeoutMs: GIT_TIMEOUT_MS });
// No remote configured — falls to tier-4 (local branch existence)
const result = runMsdTools(['query', 'git.base-branch'], dir);
assert.ok(result.success, `git.base-branch both-branches test failed:\n${result.error}`);
const branch = result.output.trim();
assert.strictEqual(branch, 'main',
`Expected 'main' to win when both main and master exist locally, got: '${branch}'`);
});
test('G. Anti-regression: all five affected workflows use msd_run query git.base-branch, not bare :-main / :-master', () => {
// The root-cause pattern: DEFAULT_BRANCH=${DEFAULT_BRANCH:-main} or BASE_BRANCH="${BASE_BRANCH:-main}"
// After fix: workflows call msd_run query git.base-branch and remove the bare fallback.
const BAD_PATTERN = /\$\{(?:DEFAULT_BRANCH|BASE_BRANCH):-(?:main|master)\}/;
const RESOLVER_CALL = /msd_run query git\.base-branch/;
for (const wfPath of AFFECTED_WORKFLOWS) {
const name = path.basename(wfPath);
const content = fs.readFileSync(wfPath, 'utf8');
assert.ok(
!BAD_PATTERN.test(content),
`${name} still contains the bare :-main/:-master fallback pattern. ` +
'Must be replaced with msd_run query git.base-branch (Issue #1146).',
);
assert.ok(
RESOLVER_CALL.test(content),
`${name} does not call \`msd_run query git.base-branch\`. ` +
'All five affected workflows must delegate to the single resolver (Issue #1146).',
);
}
});
});
// ─── gitWorktreeInfoInternal: behaviour (#1268 T0, T1 #1277) ─────────────────
const gitBaseBranch = require(path.join(__dirname, '..', 'msd-core', 'bin', 'lib', 'git-base-branch.cjs'));
const { createTempGitProject, createTempDir } = require('./helpers.cjs');
describe('#1268 gitWorktreeInfoInternal: relocation to git-base-branch', () => {
test('gitWorktreeInfoInternal(createTempGitProject()) returns {inside:true, worktreeRoot:<non-empty string>}', (t) => {
const dir = createTempGitProject('msd-wt-info-');
t.after(() => cleanup(dir));
const result = gitBaseBranch.gitWorktreeInfoInternal(dir);
// `git rev-parse --show-toplevel` reports the resolved (symlink-free) path,
// which on macOS differs from the mkdtemp path (/var → /private/var). Pin the
// exact value rather than "a non-empty string": a resolver that returned the
// .git dir, the cwd, or any other plausible-looking path would pass the weaker
// shape check while being wrong.
//
// git always reports POSIX forward slashes, on every platform including
// Windows, while `fs.realpathSync.native` returns the platform's native
// form (backslashes on Windows). The expected side must therefore be
// normalized to git's convention rather than compared to the raw native
// realpath, or the assertion just encodes the separator convention of
// whatever platform it was written on. This is separators only — POSIX's
// `replace` is a no-op there, so the assertion keeps its full strength on
// POSIX. The remote msd-test matrix is Linux-only and cannot exercise this
// path; it only surfaced on the Windows GitHub Actions shard.
assert.strictEqual(result.inside, true, 'inside must be true for a git project dir');
assert.strictEqual(result.worktreeRoot, fs.realpathSync.native(dir).replace(/\\/g, '/'),
'worktreeRoot must be the resolved worktree root path');
});
test('gitWorktreeInfoInternal(createTempDir()) returns {inside:false, worktreeRoot:null} for a non-git dir', (t) => {
const dir = createTempDir('msd-wt-info-nongit-');
t.after(() => cleanup(dir));
const result = gitBaseBranch.gitWorktreeInfoInternal(dir);
assert.strictEqual(result.inside, false, 'inside must be false for a non-git dir');
assert.strictEqual(result.worktreeRoot, null, 'worktreeRoot must be null for a non-git dir');
});
// NOTE: the former "never throws" liveness test that sat here was replaced
// (#3057 W3). "It did not throw" is satisfied by a function that returns
// undefined, the wrong branch, or nothing useful at all. The
// `execGit throws → {inside:false, worktreeRoot:null}` test below asserts the
// exact value the catch arm is contracted to produce, which is what the old
// test was gesturing at.
});
// ─── #3057 B4: last-resort "main" — verified vs unverified ───────────────────
//
// `resolveBaseBranch` alone collapses two very different situations into the
// same `'main'` string: a repository that genuinely has no candidate branch
// (every git query on tiers 2-4 completed and cleanly answered "nothing"),
// and a total resolution failure (every query timed out). `resolveBaseBranchDiagnostics`
// exposes `verified` so a caller can tell them apart; `cmdGitBaseBranch`
// surfaces the unverified case as a stderr diagnostic without touching its
// stdout contract (five workflows parse that stdout literally).
describe('#3057 B4: resolveBaseBranchDiagnostics — verified vs unverified last-resort default', () => {
test('every tier-2/3/4 git query TIMES OUT → last-resort "main" is UNVERIFIED', (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-3057-b4-fault-'));
t.after(() => cleanup(dir));
// No .planning/config.json in this dir → the config-override tier is
// skipped naturally (loadConfig's real-fs read misses cleanly).
const faultyGit = makeFaultyGit({ faults: [{ kind: 'timeout' }] });
const result = gitBaseBranch.resolveBaseBranchDiagnostics(dir, { execGit: faultyGit });
assert.strictEqual(result.branch, 'main');
assert.strictEqual(result.verified, false);
});
test('every tier-2/3/4 git query cleanly reports no candidate → last-resort "main" is VERIFIED', (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-3057-b4-clean-'));
t.after(() => cleanup(dir));
// Default passthrough: exitCode 0, empty stdout for every call — a real,
// completed "no answer" from git, not a failure (timedOut:false, error:null).
const faultyGit = makeFaultyGit();
const result = gitBaseBranch.resolveBaseBranchDiagnostics(dir, { execGit: faultyGit });
assert.strictEqual(result.branch, 'main');
assert.strictEqual(result.verified, true);
});
test('resolveBaseBranch (string-returning) is unaffected — both cases still return "main"', (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-3057-b4-compat-'));
t.after(() => cleanup(dir));
assert.strictEqual(
gitBaseBranch.resolveBaseBranch(dir, { execGit: makeFaultyGit({ faults: [{ kind: 'timeout' }] }) }),
'main',
);
assert.strictEqual(
gitBaseBranch.resolveBaseBranch(dir, { execGit: makeFaultyGit() }),
'main',
);
});
test('cmdGitBaseBranch writes an unverified-fallback diagnostic to stderr ONLY when unverified', (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-3057-b4-cmd-'));
t.after(() => cleanup(dir));
let stdoutText = '';
let stderrText = '';
gitBaseBranch.cmdGitBaseBranch(dir, [], {
execGit: makeFaultyGit({ faults: [{ kind: 'timeout' }] }),
write: (s) => { stdoutText += s; },
writeDiagnostic: (s) => { stderrText += s; },
});
assert.strictEqual(stdoutText, 'main\n');
assert.strictEqual(
stderrText,
`⚠ git-base-branch: defaulted to 'main' WITHOUT verifying against this repository — ` +
`a git query timed out or could not run. See #3057.\n`,
);
stdoutText = '';
stderrText = '';
gitBaseBranch.cmdGitBaseBranch(dir, [], {
execGit: makeFaultyGit(),
write: (s) => { stdoutText += s; },
writeDiagnostic: (s) => { stderrText += s; },
});
assert.strictEqual(stdoutText, 'main\n');
assert.strictEqual(stderrText, '', 'a verified fallback must not write any diagnostic');
});
test('#3648 Major: --is-protected fails CLOSED (reports protected) when the base branch is unverified', (t) => {
// Regression for #3648 review's Major finding: `--is-protected` used to
// discard `verified` entirely, so a degraded git (timeout / spawn
// failure) silently answered `false` — the wrong failure direction for a
// protection guard. An unverified guess must not let a caller conclude
// "definitely not protected".
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-3648-major-'));
t.after(() => cleanup(dir));
let stdoutText = '';
let stderrText = '';
gitBaseBranch.cmdGitBaseBranch(dir, ['--is-protected', 'some-topic-branch'], {
execGit: makeFaultyGit({ faults: [{ kind: 'timeout' }] }),
write: (s) => { stdoutText += s; },
writeDiagnostic: (s) => { stderrText += s; },
});
assert.strictEqual(stdoutText, 'true\n',
'an unverified answer must fail closed (report protected), not silently false');
assert.match(stderrText, /could not verify repository branch metadata/);
// Negative control: a verified resolution for the same non-matching
// branch must still cleanly report false — the fail-closed path must
// trigger on non-verification, not on every "not protected" answer.
stdoutText = '';
stderrText = '';
gitBaseBranch.cmdGitBaseBranch(dir, ['--is-protected', 'some-topic-branch'], {
execGit: makeFaultyGit(),
write: (s) => { stdoutText += s; },
writeDiagnostic: (s) => { stderrText += s; },
});
assert.strictEqual(stdoutText, 'false\n');
assert.strictEqual(stderrText, '', 'a verified non-match must not write any diagnostic');
});
});
// ─── #3552: protected-branch policy and execute-phase warning ────────────────
function extractProtectedBranchWarningBash(workflowFile, stepName) {
const content = readFileNormalized(path.join(WORKFLOW_DIR, workflowFile));
const lines = content.split('\n');
const blocks = [];
let inStep = false;
let inBash = false;
let buffer = [];
for (const line of lines) {
if (!inStep && line === `<step name="${stepName}">`) {
inStep = true;
continue;
}
if (inStep && /^<\/step>\s*$/.test(line)) break;
if (inStep && !inBash && /^\s*```bash\s*$/.test(line)) {
inBash = true;
buffer = [];
continue;
}
if (inBash && /^\s*```\s*$/.test(line)) {
blocks.push(buffer.join('\n'));
inBash = false;
continue;
}
if (inBash) buffer.push(line);
}
let block = blocks.find((candidate) => candidate.includes('--is-protected'));
if (!block) {
// #3648: the step may point at an extracted step file (e.g. execute-phase.md's
// ADR-857 byte-ceiling extraction) instead of carrying the bash block inline.
const stepBody = lines.slice(lines.indexOf(`<step name="${stepName}">`) + 1);
const ref = stepBody.find((l) => /`execute-phase\/steps\/[\w-]+\.md`/.test(l));
const refMatch = ref && ref.match(/`(execute-phase\/steps\/[\w-]+\.md)`/);
if (refMatch) {
const refLines = readFileNormalized(path.join(WORKFLOW_DIR, refMatch[1])).split('\n');
const refBlocks = [];
let refInBash = false;
let refBuffer = [];
for (const line of refLines) {
if (!refInBash && /^\s*```bash\s*$/.test(line)) {
refInBash = true;
refBuffer = [];
continue;
}
if (refInBash && /^\s*```\s*$/.test(line)) {
refBlocks.push(refBuffer.join('\n'));
refInBash = false;
continue;
}
if (refInBash) refBuffer.push(line);
}
block = refBlocks.find((candidate) => candidate.includes('--is-protected'));
// #3648: sync-runtime-launcher.cjs adds the canonical msd_run resolver
// preamble to this standalone step file (runtime-launcher-parity's
// one-preamble-per-file rule). That preamble defines its own msd_run(),
// which would shadow this harness's injected mock and reach the real
// msd-tools.cjs on the machine running the test. The preamble's own
// correctness is covered by tests/runtime-launcher-parity.test.cjs; this
// harness only needs the #3552 warning logic, so strip it here.
if (block) {
block = block.split('\n').filter((l) => !l.trimStart().startsWith('_MSD_SHIM_NAME=')).join('\n');
}
}
}
if (!block) {
throw new Error(`${workflowFile} ${stepName} has no protected-branch warning bash block`);
}
return block;
}
function writeProtectedBranchWarningScript(prefix, bash) {
const scriptDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
const scriptPath = path.join(scriptDir, 'warning.sh');
fs.writeFileSync(scriptPath, [
'#!/usr/bin/env bash',
'set -eu',
'git() {',
' if [ "$#" -ne 2 ] || [ "$1" != branch ] || [ "$2" != --show-current ]; then',
' printf "unexpected git invocation\\n" >&2',
' return 97',
' fi',
' printf "%s\\n" "$CURRENT_BRANCH_VALUE"',
'}',
'msd_run() {',
' if [ "$#" -ne 4 ] || [ "$1" != query ] || [ "$2" != git.base-branch ] ||',
' [ "$3" != --is-protected ] || [ "$4" != "$CURRENT_BRANCH_VALUE" ]; then',
' printf "unexpected protected-branch query\\n"',
' return 0',
' fi',
// The real command writes its fail-closed and rejected-entry
// explanations to stderr. Emitting one here is what makes a swallowed
// `2>/dev/null` at the call site visible to a test.
' if [ -n "${DIAGNOSTIC_TEXT:-}" ]; then printf "%s\\n" "$DIAGNOSTIC_TEXT" >&2; fi',
// QUERY_EXIT lets a test make the query FAIL (msd-tools missing, a crash, a
// non-zero exit). Defaults to 0, so every pre-existing caller of this
// harness is unaffected. Nothing is printed on stdout in that case —
// matching a real failed command substitution.
' if [ "${QUERY_EXIT:-0}" != 0 ]; then return "${QUERY_EXIT}"; fi',
' printf "%s\\n" "$PROTECTED_RESULT"',
'}',
bash,
'printf "IS_PROTECTED=%s\\n" "${IS_PROTECTED:-unbound}"',
'printf "continued\\n"',
].join('\n'), { mode: 0o755 });
return { scriptDir, scriptPath };
}
describe('#3552: configured protected branches', () => {
const configuredLoad = (requestedCwd) => {
assert.strictEqual(requestedCwd, '/repo');
return {
base_branch: 'main',
protected_branches: ['develop', 'next', 'develop'],
};
};
test('#3552 configured match and unrelated branch produce opposite results', () => {
const match = gitBaseBranch.resolveProtectedBranchStatus('/repo', 'develop', {
loadConfig: configuredLoad,
});
const control = gitBaseBranch.resolveProtectedBranchStatus('/repo', 'topic/3552', {
loadConfig: configuredLoad,
});
assert.deepStrictEqual(match, {
baseBranch: 'main',
protectedBranches: ['main', 'develop', 'next'],
rejectedProtectedBranches: [],
isProtected: true,
verified: true,
allowDefaultBranchCommits: false,
});
assert.strictEqual(control.isProtected, false);
assert.notStrictEqual(match.isProtected, control.isProtected,
'negative control must disagree with the configured protected-branch match');
});
test('#3552 absent list retains resolved-base protection and unrelated control', () => {
const deps = { loadConfig: () => ({ base_branch: 'main' }) };
const base = gitBaseBranch.resolveProtectedBranchStatus('/repo', 'main', deps);
const control = gitBaseBranch.resolveProtectedBranchStatus('/repo', 'topic/3552', deps);
assert.deepStrictEqual(base.protectedBranches, ['main']);
assert.strictEqual(base.isProtected, true);
assert.strictEqual(control.isProtected, false);
assert.notStrictEqual(base.isProtected, control.isProtected,
'negative control must disagree with the resolved-base match');
});
test('#3552 a bad element drops only itself — valid names still protect', () => {
// A protection predicate must not fail OPEN. config-set validation is
// bypassable by a direct edit of .planning/config.json, so one bad element
// discarding the whole list is the exact failure #3552 exists to close,
// reintroduced through a different door (#3648 review Blocker 3).
for (const protectedBranches of [['develop', 42], ['develop', ' '], ['develop', null]]) {
const loadConfig = () => ({ base_branch: 'main', protected_branches: protectedBranches });
const configuredName = gitBaseBranch.resolveProtectedBranchStatus('/repo', 'develop', { loadConfig });
assert.strictEqual(configuredName.isProtected, true,
`'develop' must stay protected alongside a bad sibling: ${JSON.stringify(protectedBranches)}`);
assert.deepStrictEqual(configuredName.protectedBranches, ['main', 'develop']);
assert.strictEqual(configuredName.rejectedProtectedBranches.length, 1,
'the bad element must be reported, not silently swallowed');
}
});
test('#3552 a non-array value contributes no names and is reported', () => {
const loadConfig = () => ({ base_branch: 'main', protected_branches: 'develop' });
const status = gitBaseBranch.resolveProtectedBranchStatus('/repo', 'develop', { loadConfig });
assert.strictEqual(status.isProtected, false,
'a bare string is not a list of branch names — it must not protect');
assert.deepStrictEqual(status.protectedBranches, ['main']);
assert.deepStrictEqual(status.rejectedProtectedBranches, ['"develop"']);
});
test('#3552 negative control: a well-formed list reports nothing rejected', () => {
// Must disagree with every case above — otherwise the reject channel is
// reporting unconditionally and proves nothing.
const loadConfig = () => ({ base_branch: 'main', protected_branches: ['develop'] });
const status = gitBaseBranch.resolveProtectedBranchStatus('/repo', 'develop', { loadConfig });
assert.strictEqual(status.isProtected, true);
assert.deepStrictEqual(status.rejectedProtectedBranches, []);
});
test('#3552 an empty list is well-formed, not malformed', () => {
const loadConfig = () => ({ base_branch: 'main', protected_branches: [] });
const status = gitBaseBranch.resolveProtectedBranchStatus('/repo', 'main', { loadConfig });
assert.deepStrictEqual(status.protectedBranches, ['main']);
assert.deepStrictEqual(status.rejectedProtectedBranches, [],
'declaring no extra protected branches is a valid choice, not an error');
});
test('#3552 --is-protected writes a diagnostic naming the rejected elements', () => {
const diagnostics = [];
const out = [];
gitBaseBranch.cmdGitBaseBranch('/repo', ['--is-protected', 'develop'], {
loadConfig: () => ({ base_branch: 'main', protected_branches: ['develop', 42] }),
write: (chunk) => out.push(chunk),
writeDiagnostic: (chunk) => diagnostics.push(chunk),
});
assert.strictEqual(out.join('').trim(), 'true');
assert.strictEqual(diagnostics.length, 1, 'the rejection must be surfaced, not swallowed');
assert.match(diagnostics.join(''), /protected_branches/);
assert.match(diagnostics.join(''), /42/);
});
test('#3552 negative control: a clean list writes no diagnostic', () => {
const diagnostics = [];
gitBaseBranch.cmdGitBaseBranch('/repo', ['--is-protected', 'develop'], {
loadConfig: () => ({ base_branch: 'main', protected_branches: ['develop'] }),
write: () => {},
writeDiagnostic: (chunk) => diagnostics.push(chunk),
});
assert.deepStrictEqual(diagnostics, [],
'a well-formed list must be silent — otherwise the diagnostic carries no signal');
});
test('#3648 Nit F-7: renderRejected sanitizes control and ANSI characters in diagnostics', () => {
const diagnostics = [];
gitBaseBranch.cmdGitBaseBranch('/repo', ['--is-protected', 'develop'], {
loadConfig: () => ({ base_branch: 'main', protected_branches: ['develop', { malicious: 'bad\x1b[31m\nbranch' }] }),
write: () => {},
writeDiagnostic: (chunk) => diagnostics.push(chunk),
});
assert.strictEqual(diagnostics.length, 1);
const diag = diagnostics.join('');
assert.match(diag, /bad\\u001b\[31m/);
assert.strictEqual(diag.includes('\x1b'), false, 'diagnostic must not contain raw ESC control bytes');
});
test('#3552 execute-phase handle_branching wires protected-branch step with Read and execute (not Skip.)', () => {
const content = readFileNormalized(path.join(WORKFLOW_DIR, 'execute-phase.md'));
const stepMatch = content.match(/<step name="handle_branching">([\s\S]*?)<\/step>/);
assert.ok(stepMatch, 'handle_branching step must exist in execute-phase.md');
const stepBody = stepMatch[1];
assert.match(
stepBody,
/\*\*"none":\*\*\s+Read and execute `execute-phase\/steps\/protected-branch\.md`\./,
'execute-phase handle_branching "none" arm must instruct executor to Read and execute the step file',
);
assert.doesNotMatch(
stepBody,
/\*\*"none":\*\*\s*Skip\./,
'execute-phase handle_branching "none" arm must not say "Skip."',
);
// Negative control: verify the assertion rejects an advisory "Skip." pointer
const fakeAdvisoryBody = stepBody.replace(
/\*\*"none":\*\*\s+Read and execute `execute-phase\/steps\/protected-branch\.md`\./,
'**"none":** Skip. See `execute-phase/steps/protected-branch.md`.',
);
assert.doesNotMatch(
fakeAdvisoryBody,
/\*\*"none":\*\*\s+Read and execute `execute-phase\/steps\/protected-branch\.md`\./,
'negative control: fake advisory pointer must fail the wiring assertion',
);
});
test('#3552 active workstream CLI uses configured list and excludes root-only names', (t) => {
const dir = createGitRepo({ prefix: 'msd-3552-cli-', defaultBranch: 'main' });
t.after(() => cleanup(dir));
addPlanning(dir);
setMsdConfig(dir, 'git.protected_branches', ['root-only']);
fs.mkdirSync(path.join(dir, '.planning', 'workstreams', 'alpha'), { recursive: true });
// Both HOME and USERPROFILE must be redirected — Node's os.homedir() (and
// anything relying on it) consults USERPROFILE first on Windows, where
// setting HOME alone leaves the real home directory in effect and makes
// this isolation silently vacuous on that platform.
const workstreamEnv = { MSD_WORKSTREAM: 'alpha', HOME: dir, USERPROFILE: dir };
const setResult = runMsdTools(
['config-set', 'git.protected_branches', '["develop","next"]'],
dir,
workstreamEnv,
);
assert.ok(setResult.success, setResult.error);
const workstreamConfig = JSON.parse(fs.readFileSync(
path.join(dir, '.planning', 'workstreams', 'alpha', 'config.json'),
'utf8',
));
assert.deepStrictEqual(workstreamConfig.git.protected_branches, ['develop', 'next']);
const match = runMsdTools(
['query', 'git.base-branch', '--is-protected', 'develop'], dir, workstreamEnv,
);
const control = runMsdTools(
['query', 'git.base-branch', '--is-protected', 'topic/3552'], dir, workstreamEnv,
);
const rootOnly = runMsdTools(
['query', 'git.base-branch', '--is-protected', 'root-only'], dir, workstreamEnv,
);
assert.ok(match.success, match.error);
assert.ok(control.success, control.error);
assert.ok(rootOnly.success, rootOnly.error);
assert.strictEqual(match.output, 'true');
assert.strictEqual(control.output, 'false');
assert.strictEqual(rootOnly.output, 'false');
assert.notStrictEqual(match.output, control.output,
'CLI negative control must disagree with the configured protected-branch match');
assert.notStrictEqual(match.output, rootOnly.output,
'workstream override must replace the root-only configured name');
let written = '';
const returned = gitBaseBranch.cmdGitBaseBranch('/repo', ['--is-protected', 'develop'], {
loadConfig: configuredLoad,
write: (text) => { written += text; },
});
assert.strictEqual(returned, 'true');
assert.strictEqual(written, 'true\n', 'direct command contract must remain newline-terminated');
});
test('#3552 execute-phase warns on true, stays silent on false, and continues both', (t) => {
const bash = extractProtectedBranchWarningBash('execute-phase.md', 'handle_branching');
const { scriptDir, scriptPath } = writeProtectedBranchWarningScript(
'msd-3552-execute-',
bash,
);
t.after(() => cleanup(scriptDir));
const baseEnv = { ...process.env, CURRENT_BRANCH_VALUE: 'develop' };
const match = runHook(scriptPath, [], {
interpreter: 'bash',
env: { ...baseEnv, PROTECTED_RESULT: 'true' },
});
const control = runHook(scriptPath, [], {
interpreter: 'bash',
env: { ...baseEnv, PROTECTED_RESULT: 'false' },
});
assert.strictEqual(match.exitCode, 0, match.stderr);
assert.strictEqual(control.exitCode, 0, control.stderr);
assert.match(match.stdout, /continued\n$/);
assert.match(control.stdout, /continued\n$/);
assert.match(match.stderr, /protected branch/i);
assert.doesNotMatch(control.stderr, /protected branch/i);
assert.notStrictEqual(match.stderr, control.stderr,
'warning negative control must disagree with the protected-branch match');
});
test('#3552 ship warns on true, stays silent on false, and keeps the none-strategy offer', (t) => {
const bash = extractProtectedBranchWarningBash('ship.md', 'preflight_checks');
const { scriptDir, scriptPath } = writeProtectedBranchWarningScript(
'msd-3552-ship-',
bash,
);
t.after(() => cleanup(scriptDir));
const baseEnv = { ...process.env, CURRENT_BRANCH_VALUE: 'develop' };
const match = runHook(scriptPath, [], {
interpreter: 'bash',
env: { ...baseEnv, PROTECTED_RESULT: 'true' },
});
const control = runHook(scriptPath, [], {
interpreter: 'bash',
env: { ...baseEnv, PROTECTED_RESULT: 'false' },
});
assert.strictEqual(match.exitCode, 0, match.stderr);
assert.strictEqual(control.exitCode, 0, control.stderr);
assert.match(match.stdout, /continued\n$/);
assert.match(control.stdout, /continued\n$/);
assert.match(match.stderr, /protected branch/i);
assert.doesNotMatch(control.stderr, /protected branch/i);
assert.notStrictEqual(match.stderr, control.stderr,
'ship warning negative control must disagree with the protected-branch match');
const ship = readFileNormalized(path.join(WORKFLOW_DIR, 'ship.md'));
const preflight = ship.slice(
ship.indexOf('<step name="preflight_checks">'),
ship.indexOf('</step>', ship.indexOf('<step name="preflight_checks">')),
);
assert.match(preflight, /branching_strategy is `none`: offer to create a branch now/i,
'protected-branch warning must retain the none-strategy feature-branch offer');
});
test('#3648 Nit: detached HEAD answers false; a missing argument is reported', () => {
// `git branch --show-current` prints nothing on a detached HEAD, so the
// call sites pass an explicit empty string. That must answer false — no
// protected branch is named '' — and must stay SILENT, because a detached
// HEAD is a normal state, not a misconfiguration.
const detachedDiagnostics = [];
const detachedOut = [];
gitBaseBranch.cmdGitBaseBranch('/repo', ['--is-protected', ''], {
loadConfig: () => ({ base_branch: 'main', protected_branches: ['develop'] }),
write: (chunk) => detachedOut.push(chunk),
writeDiagnostic: (chunk) => detachedDiagnostics.push(chunk),
});
assert.strictEqual(detachedOut.join('').trim(), 'false');
assert.deepStrictEqual(detachedDiagnostics, [],
'a detached HEAD is not an error and must not warn');
// The flag with NO argument at all is a caller bug that `args[1] ?? ''`
// silently collapsed into the detached-HEAD case. Same answer, but said
// out loud so the two are distinguishable.
const missingDiagnostics = [];
const missingOut = [];
gitBaseBranch.cmdGitBaseBranch('/repo', ['--is-protected'], {
loadConfig: () => ({ base_branch: 'main', protected_branches: ['develop'] }),
write: (chunk) => missingOut.push(chunk),
writeDiagnostic: (chunk) => missingDiagnostics.push(chunk),
});
assert.strictEqual(missingOut.join('').trim(), 'false');
assert.strictEqual(missingDiagnostics.length, 1,
'a missing branch argument must be reported, not read as a detached HEAD');
assert.match(missingDiagnostics.join(''), /--is-protected/);
assert.notDeepStrictEqual(detachedDiagnostics, missingDiagnostics,
'the two paths must be distinguishable — that is the whole point of the arm');
assert.throws(
() => gitBaseBranch.cmdGitBaseBranch('/repo', ['--is-protectd', 'develop'], {
loadConfig: () => ({ base_branch: 'main', protected_branches: ['develop'] }),
}),
(err) => err instanceof ExitError && err.code === 1,
'a misspelled predicate flag must be rejected with exit 1 via error()',
);
assert.throws(
() => gitBaseBranch.cmdGitBaseBranch('/repo', ['--is-protected', 'develop', 'extra'], {
loadConfig: () => ({ base_branch: 'main', protected_branches: ['develop'] }),
}),
(err) => err instanceof ExitError && err.code === 1,
'surplus predicate arguments must be rejected with exit 1 via error()',
);
});
test('#3648 cmdGitBaseBranch usage errors emit structured ERROR_REASON.USAGE and do not print stack trace', (t) => {
const dir = createGitRepo({ prefix: 'msd-3648-usage-', defaultBranch: 'main' });
t.after(() => cleanup(dir));
addPlanning(dir);
const unknownFlag = runMsdTools(['query', 'git.base-branch', '--unknown-flag', '--json-errors'], dir);
assert.strictEqual(unknownFlag.success, false);
assert.strictEqual(unknownFlag.exitCode, 1);
assert.doesNotMatch(unknownFlag.error, /^\s*at\s+/m, 'usage errors must not print stack traces');
let parsed = null;
try { parsed = JSON.parse(unknownFlag.error); } catch {}
assert.strictEqual(parsed?.reason, 'usage');
const surplusPositional = runMsdTools(['query', 'git.base-branch', '--is-protected', 'main', 'extra', '--json-errors'], dir);
assert.strictEqual(surplusPositional.success, false);
assert.strictEqual(surplusPositional.exitCode, 1);
assert.doesNotMatch(surplusPositional.error, /^\s*at\s+/m);
let parsedSurplus = null;
try { parsedSurplus = JSON.parse(surplusPositional.error); } catch {}
assert.strictEqual(parsedSurplus?.reason, 'usage');
});
test('#3648 Minor F-1: nested and flat predicate reads stay aligned with the loader', () => {
const configLoader = require('../msd-core/bin/lib/config-loader.cjs');
const cases = [
{ git: { base_branch: 'nested', protected_branches: ['develop'] }, base_branch: 'flat', protected_branches: ['bogus'] },
{ git: { base_branch: 'nested' }, base_branch: 'flat' },
{ git: 'not-an-object', base_branch: 'flat', protected_branches: ['bogus'] },
];
for (const fixture of cases) {
const seam = gitBaseBranch.resolveProtectedBranchStatus('/repo', 'develop', {
readFile: () => JSON.stringify(fixture),
});
assert.strictEqual(
gitBaseBranch._readGitKey(fixture, 'base_branch'),
configLoader._getConfigValue(fixture, 'base_branch', { section: 'git', field: 'base_branch' }),
);
assert.strictEqual(
gitBaseBranch._readGitNested(fixture, 'protected_branches'),
configLoader._getConfigNested(fixture, 'git', 'protected_branches'),
);
assert.ok(seam);
}
});
test('#3648 Blocker 4: the predicate diagnostic reaches the user at both call sites', (t) => {
// Both call sites piped the query's stderr to /dev/null, so the
// fail-closed explanation ("could not verify the base branch ... defaulting
// to protected") was discarded and the user saw a bare protected-branch
// warning on a branch that is not protected. The diagnostic was exercised
// only by its unit test — dead in production.
const cases = [
['execute-phase.md', 'handle_branching', 'msd-3648-b4-execute-'],
['ship.md', 'preflight_checks', 'msd-3648-b4-ship-'],
];
for (const [workflow, step, prefix] of cases) {
const bash = extractProtectedBranchWarningBash(workflow, step);
const { scriptDir, scriptPath } = writeProtectedBranchWarningScript(prefix, bash);
t.after(() => cleanup(scriptDir));
const baseEnv = {
...process.env,
CURRENT_BRANCH_VALUE: 'develop',
PROTECTED_RESULT: 'true',
};
const surfaced = runHook(scriptPath, [], {
interpreter: 'bash',
env: { ...baseEnv, DIAGNOSTIC_TEXT: 'could not verify the base branch' },
});
const control = runHook(scriptPath, [], {
interpreter: 'bash',
env: { ...baseEnv, DIAGNOSTIC_TEXT: '' },
});
assert.strictEqual(surfaced.exitCode, 0, surfaced.stderr);
assert.match(surfaced.stderr, /could not verify the base branch/,
workflow + " must not discard the predicate's explanation");
// Negative control: with nothing emitted that text must be absent,
// otherwise the assertion above could pass on unrelated output.
assert.doesNotMatch(control.stderr, /could not verify the base branch/);
assert.notStrictEqual(surfaced.stderr, control.stderr);
// The warning itself still fires in both, so the diagnostic is additive.
assert.match(surfaced.stderr, /protected branch/i);
assert.match(control.stderr, /protected branch/i);
}
});
test('#3648 round-4: a FAILED query degrades visibly, and does not abort under set -e', (t) => {
// Found by the round-4 external review. `IS_PROTECTED=$(msd_run ...)` had
// two problems when the query itself failed (msd-tools absent, a crash, any
// non-zero exit): the substitution yielded an empty string, so `[ "$X" = true ]`
// was simply false and the workflow continued with NO warning and no trace —
// a silent fail-open in the guard whose whole purpose is to warn; and the
// bare assignment is the last command in its own right, so a non-zero exit
// aborted the step under `set -e` before any branch ran.
//
// The contract is neither fail-open nor fail-closed: it degrades VISIBLY.
// Claiming "protected" on no evidence would warn on every branch whenever
// msd-tools is unavailable; claiming "not protected" is the silent hole.
const cases = [
['execute-phase.md', 'handle_branching', 'msd-3648-r4-execute-'],
['ship.md', 'preflight_checks', 'msd-3648-r4-ship-'],
];
for (const [workflow, step, prefix] of cases) {
const bash = extractProtectedBranchWarningBash(workflow, step);
const { scriptDir, scriptPath } = writeProtectedBranchWarningScript(prefix, bash);
t.after(() => cleanup(scriptDir));
const baseEnv = {
...process.env,
CURRENT_BRANCH_VALUE: 'topic/3648',
PROTECTED_RESULT: 'false',
};
const failed = runHook(scriptPath, [], {
interpreter: 'bash',
env: { ...baseEnv, QUERY_EXIT: '3' },
});
// Control: the SAME script with a working query must stay silent on a
// non-protected branch. Without it, an assertion that the failure warns
// could pass against a script that warns unconditionally.
const working = runHook(scriptPath, [], {
interpreter: 'bash',
env: { ...baseEnv, QUERY_EXIT: '0' },
});
// The harness runs under `set -eu`, so this also pins the set -e half.
assert.strictEqual(failed.exitCode, 0,
workflow + ' must survive a failed query under set -e: ' + failed.stderr);
assert.match(failed.stdout, /continued/,
workflow + ' must reach the end of the step');
assert.match(failed.stderr, /Could not determine whether/,
workflow + ' must say the check did not run, rather than pass silently');
assert.doesNotMatch(failed.stderr, /is a protected branch/,
'and must NOT assert protectedness it never established');
assert.strictEqual(working.exitCode, 0, working.stderr);
assert.doesNotMatch(working.stderr, /Could not determine whether/,
'a working query must not emit the degradation notice');
assert.doesNotMatch(working.stderr, /is a protected branch/,
'and must not warn about a branch that is not protected');
assert.notStrictEqual(failed.stderr, working.stderr,
'the two paths must be distinguishable');
}
});
test('#3648 Minor 2: ship binds the predicate result instead of discarding it', (t) => {
// ship.md's prose branches on protectedness twice ("warn - should be on a
// feature branch", "if branching_strategy is none, offer to create a
// branch"). Echoing the warning without binding leaves the agent inferring
// state from warning text in tool output; the comparison it replaced was
// directly evaluable.
const bash = extractProtectedBranchWarningBash('ship.md', 'preflight_checks');
const { scriptDir, scriptPath } = writeProtectedBranchWarningScript('msd-3648-bind-', bash);
t.after(() => cleanup(scriptDir));
const baseEnv = { ...process.env, CURRENT_BRANCH_VALUE: 'develop' };
const match = runHook(scriptPath, [], {
interpreter: 'bash',
env: { ...baseEnv, PROTECTED_RESULT: 'true' },
});
const control = runHook(scriptPath, [], {
interpreter: 'bash',
env: { ...baseEnv, PROTECTED_RESULT: 'false' },
});
assert.match(match.stdout, /^IS_PROTECTED=true$/m,
'ship must bind the predicate result to a variable the following prose can branch on');
assert.match(control.stdout, /^IS_PROTECTED=false$/m,
'the binding must track the predicate, not be hardcoded');
assert.notStrictEqual(match.stdout, control.stdout);
});
});
// ─── #3819: allow_default_branch_commits escape hatch ────────────────────────
describe('#3819: allow_default_branch_commits escape hatch', () => {
test('#3819 allow_default_branch_commits:true + currentBranch on base branch → not protected', () => {
const loadConfig = () => ({ base_branch: 'main', allow_default_branch_commits: true });
const status = gitBaseBranch.resolveProtectedBranchStatus('/repo', 'main', { loadConfig });
assert.strictEqual(status.isProtected, false,
'the escape hatch must exempt the base branch from auto-included protection');
assert.deepStrictEqual(status.protectedBranches, [],
'the base branch must not appear in protectedBranches when the escape hatch is on');
assert.strictEqual(status.allowDefaultBranchCommits, true);
});
test('#3819 explicit protected_branches still applies with the escape hatch on', () => {
const loadConfig = () => ({
base_branch: 'main',
allow_default_branch_commits: true,
protected_branches: ['develop'],
});
const onBase = gitBaseBranch.resolveProtectedBranchStatus('/repo', 'main', { loadConfig });
const onExplicit = gitBaseBranch.resolveProtectedBranchStatus('/repo', 'develop', { loadConfig });
assert.strictEqual(onBase.isProtected, false,
'the base branch escapes protection even though an explicit list is also configured');
assert.strictEqual(onExplicit.isProtected, true,
'an explicitly configured protected branch must still be enforced despite the escape hatch');
assert.notStrictEqual(onBase.isProtected, onExplicit.isProtected,
'the two cases must disagree — otherwise the explicit list is not actually being enforced');
});
test('#3819 negative control: allow_default_branch_commits absent → base-branch protection unchanged', () => {
const loadConfig = () => ({ base_branch: 'main' });
const status = gitBaseBranch.resolveProtectedBranchStatus('/repo', 'main', { loadConfig });
assert.strictEqual(status.allowDefaultBranchCommits, false,
'default must be false/off when the key is not set at all');
assert.strictEqual(status.isProtected, true,
'without the escape hatch, the base branch must remain protected as before #3819');
});
test('#3819 allow_default_branch_commits:false explicitly → same as absent', () => {
const loadConfig = () => ({ base_branch: 'main', allow_default_branch_commits: false });
const status = gitBaseBranch.resolveProtectedBranchStatus('/repo', 'main', { loadConfig });
assert.strictEqual(status.allowDefaultBranchCommits, false);
assert.strictEqual(status.isProtected, true,
'an explicit false must behave identically to leaving the key unset');
});
test('#3819 CLI: git.allow_default_branch_commits:true makes --is-protected report false on the base branch', (t) => {
const dir = createGitRepo({ prefix: 'msd-3819-cli-', defaultBranch: 'main' });
t.after(() => cleanup(dir));
addPlanning(dir);
setMsdConfig(dir, 'git.allow_default_branch_commits', true);
const escaped = runMsdTools(['query', 'git.base-branch', '--is-protected', 'main'], dir);
assert.ok(escaped.success, escaped.error);
assert.strictEqual(escaped.output, 'false',
'the escape hatch must make the CLI report the base branch as not protected');
// Negative control: a sibling fixture without the config key must still
// report the base branch as protected — proves the flag, not some other
// difference between the two fixtures, drives the result above.
const controlDir = createGitRepo({ prefix: 'msd-3819-cli-control-', defaultBranch: 'main' });
t.after(() => cleanup(controlDir));
addPlanning(controlDir);
const control = runMsdTools(['query', 'git.base-branch', '--is-protected', 'main'], controlDir);
assert.ok(control.success, control.error);
assert.strictEqual(control.output, 'true');
assert.notStrictEqual(escaped.output, control.output,
'CLI negative control must disagree with the escape-hatch fixture');
});
});
// ─── #3819: msd-executor.md pre-commit protected-branch guard ────────────────
/**
* Extract the FIRST ```bash fenced block that follows the "Pre-commit
* protected-branch safety assertion" heading in agents/msd-executor.md.
* Keyed off the heading text (not a `<step name>` tag — this step has no
* such wrapper) so a future rewording of the heading fails the test loudly
* instead of silently extracting nothing.
*/
function extractExecutorPreCommitBash() {
const executorPath = path.join(__dirname, '..', 'agents', 'msd-executor.md');
const content = readFileNormalized(executorPath);
const lines = content.split('\n');
const headingIndex = lines.findIndex(
(line) => line.includes('Pre-commit HEAD safety assertion'),
);
if (headingIndex === -1) {
throw new Error(
'agents/msd-executor.md: could not find the "Pre-commit HEAD safety ' +
'assertion" heading — has it been reworded?',
);
}
let inBash = false;
const buffer = [];
for (let i = headingIndex + 1; i < lines.length; i++) {
const line = lines[i];
if (!inBash && /^\s*```bash\s*$/.test(line)) {
inBash = true;
continue;
}
if (inBash && /^\s*```\s*$/.test(line)) {
return buffer.join('\n');
}
if (inBash) buffer.push(line);
}
throw new Error(
'agents/msd-executor.md: no ```bash block found after the pre-commit protected-branch ' +
'heading — has the step been restructured?',
);
}
/**
* Write a standalone script that mocks `git` and `msd_run`, then runs the
* extracted pre-commit guard bash verbatim. Deliberately does NOT `set -e`:
* the guard's fatal paths call `exit 1` explicitly, and this script must run
* to completion on the non-fatal path to print the GUARD_PASSED sentinel.
*
* `isWorktree` controls whether `scriptDir` gets a `.git` FILE (worktree) or
* a `.git` DIRECTORY (ordinary checkout) — the guard's own `[ -f .git ]`
* branch reads this from the script's cwd, so the test runs the script with
* `cwd: scriptDir`.
*/
function writeExecutorGuardScript(prefix, bash, { branch, headRef, isWorktree, queryResult, queryExit = 0 }) {
const scriptDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
const gitPath = path.join(scriptDir, '.git');
if (isWorktree) {
fs.writeFileSync(gitPath, 'gitdir: /nonexistent\n');
} else {
fs.mkdirSync(gitPath);
}
const scriptPath = path.join(scriptDir, 'guard.sh');
fs.writeFileSync(scriptPath, [
'#!/usr/bin/env bash',
'git() {',
' if [ "$1" = symbolic-ref ]; then',
headRef === 'DETACHED'
? ' return 1'
: ` printf "%s\\n" "${headRef}"\n return 0`,
' elif [ "$1" = rev-parse ] && [ "$2" = --abbrev-ref ]; then',
` printf "%s\\n" "${branch}"`,
' return 0',
' else',
' printf "unexpected git invocation: %s\\n" "$*" >&2',
' return 97',
' fi',
'}',
'msd_run() {',
` if [ "$#" -ne 4 ] || [ "$1" != query ] || [ "$2" != git.base-branch ] || ` +
`[ "$3" != --is-protected ] || [ "$4" != "${branch}" ]; then`,
' printf "unexpected msd_run invocation: %s\\n" "$*" >&2',
' return 0',
' fi',
queryExit
? ` return ${queryExit}`
: ` printf "%s\\n" "${queryResult}"`,
'}',
bash,
'printf "GUARD_PASSED\\n"',
].join('\n'), { mode: 0o755 });
return { scriptDir, scriptPath };
}
describe('#3819: msd-executor.md pre-commit protected-branch guard', () => {
const bash = extractExecutorPreCommitBash();
test('#3819 non-worktree, branch is protected (query says true) → HALT with exit 1, no GUARD_PASSED', (t) => {
const { scriptDir, scriptPath } = writeExecutorGuardScript('msd-3819-guard-nonwt-protected-', bash, {
branch: 'main',
headRef: 'main',
isWorktree: false,
queryResult: 'true',
});
t.after(() => cleanup(scriptDir));
const result = runHook(scriptPath, [], { interpreter: 'bash', cwd: scriptDir, timeoutMs: EXECUTOR_GUARD_TIMEOUT_MS });
assert.strictEqual(result.exitCode, 1, result.stderr);
assert.match(result.stderr, /protected\/default branch/);
assert.doesNotMatch(result.stdout, /GUARD_PASSED/);
});
test('#3819 non-worktree, branch is not protected (query says false) → continues, GUARD_PASSED', (t) => {
const { scriptDir, scriptPath } = writeExecutorGuardScript('msd-3819-guard-nonwt-clean-', bash, {
branch: 'feature-x',
headRef: 'feature-x',
isWorktree: false,
queryResult: 'false',
});
t.after(() => cleanup(scriptDir));
const result = runHook(scriptPath, [], { interpreter: 'bash', cwd: scriptDir, timeoutMs: EXECUTOR_GUARD_TIMEOUT_MS });
assert.strictEqual(result.exitCode, 0, result.stderr);
assert.match(result.stdout, /GUARD_PASSED/);
});
test('#3819 worktree, branch is protected → HALT with exit 1 (protected check fires before the allow-list check)', (t) => {
const { scriptDir, scriptPath } = writeExecutorGuardScript('msd-3819-guard-wt-protected-', bash, {
branch: 'main',
headRef: 'main',
isWorktree: true,
queryResult: 'true',
});
t.after(() => cleanup(scriptDir));
const result = runHook(scriptPath, [], { interpreter: 'bash', cwd: scriptDir, timeoutMs: EXECUTOR_GUARD_TIMEOUT_MS });
assert.strictEqual(result.exitCode, 1, result.stderr);
assert.match(result.stderr, /protected\/default branch/);
});
test('#3819 worktree, branch not protected but outside the agent-*/worktree-agent-*/worktree-wf_* namespace → HALT', (t) => {
const { scriptDir, scriptPath } = writeExecutorGuardScript('msd-3819-guard-wt-outside-namespace-', bash, {
branch: 'some-random-branch',
headRef: 'some-random-branch',
isWorktree: true,
queryResult: 'false',
});
t.after(() => cleanup(scriptDir));
const result = runHook(scriptPath, [], { interpreter: 'bash', cwd: scriptDir, timeoutMs: EXECUTOR_GUARD_TIMEOUT_MS });
assert.strictEqual(result.exitCode, 1, result.stderr);
assert.match(result.stderr, /not in the agent-\* \/ worktree-agent-\* \/ worktree-wf_\* namespace/);
});
test('#3819 worktree, branch not protected AND in the agent-* namespace → continues, GUARD_PASSED', (t) => {
const { scriptDir, scriptPath } = writeExecutorGuardScript('msd-3819-guard-wt-clean-', bash, {
branch: 'agent-42',
headRef: 'agent-42',
isWorktree: true,
queryResult: 'false',
});
t.after(() => cleanup(scriptDir));
const result = runHook(scriptPath, [], { interpreter: 'bash', cwd: scriptDir, timeoutMs: EXECUTOR_GUARD_TIMEOUT_MS });
assert.strictEqual(result.exitCode, 0, result.stderr);
assert.match(result.stdout, /GUARD_PASSED/);
});
test('#3819 detached HEAD (non-worktree) → HALT with exit 1 before any protected-branch query', (t) => {
const { scriptDir, scriptPath } = writeExecutorGuardScript('msd-3819-guard-detached-', bash, {
branch: 'HEAD',
headRef: 'DETACHED',
isWorktree: false,
queryResult: 'false',
});
t.after(() => cleanup(scriptDir));
const result = runHook(scriptPath, [], { interpreter: 'bash', cwd: scriptDir, timeoutMs: EXECUTOR_GUARD_TIMEOUT_MS });
assert.strictEqual(result.exitCode, 1, result.stderr);
assert.match(result.stderr, /detached/);
});
test('#3819 HOSTILE: msd_run RUNS but reports the branch as protected (resolver-internal fail-closed) → HALT', (t) => {
// This is the scenario that must stay fail-closed unconditionally:
// msd_run itself succeeds (exit 0) — e.g. because cmdGitBaseBranch's own
// pre-existing #3057 B4 logic already answered "true" when it could not
// VERIFY the real default branch — and the bash guard must never
// second-guess that answer. It is deliberately indistinguishable, at
// this bash layer, from an ordinary "yes this branch is protected"
// answer (see the "non-worktree, branch is protected" test above) —
// that IS the fail-closed contract: the guard trusts what msd_run says
// when msd_run actually says something.
//
// This is a DIFFERENT failure mode from "msd_run could not be invoked at
// all" (nonzero exit / no output), which is covered by the two
// "msd_run itself is unavailable" tests below and — per the #3819
// approval's item 2 ("keep the existing names as a fallback where the
// default cannot be resolved") — deliberately does NOT fail closed for
// every branch; it falls back to the pre-#3819 five-name list instead.
const { scriptDir, scriptPath } = writeExecutorGuardScript('msd-3819-guard-resolver-unverified-', bash, {
branch: 'feature-x',
headRef: 'feature-x',
isWorktree: false,
queryResult: 'true',
});
t.after(() => cleanup(scriptDir));
const result = runHook(scriptPath, [], { interpreter: 'bash', cwd: scriptDir, timeoutMs: EXECUTOR_GUARD_TIMEOUT_MS });
assert.strictEqual(result.exitCode, 1, result.stderr);
assert.match(result.stderr, /protected\/default branch/);
assert.doesNotMatch(result.stdout, /GUARD_PASSED/);
});
test('#3819 msd_run itself is unavailable (query cannot run) + branch matches the old five-name list → still HALTs via the fallback', (t) => {
const { scriptDir, scriptPath } = writeExecutorGuardScript('msd-3819-guard-unavailable-listed-', bash, {
branch: 'develop',
headRef: 'develop',
isWorktree: false,
queryResult: 'false',
queryExit: 1,
});
t.after(() => cleanup(scriptDir));
const result = runHook(scriptPath, [], { interpreter: 'bash', cwd: scriptDir, timeoutMs: EXECUTOR_GUARD_TIMEOUT_MS });
assert.strictEqual(result.exitCode, 1, result.stderr);
assert.match(result.stderr, /protected\/default branch/);
});
test('#3819 msd_run itself is unavailable (query cannot run) + branch does NOT match the old five-name list → falls back to allowing it', (t) => {
// Deliberate #3819-approved tradeoff: when msd-tools itself cannot even be
// invoked, an unlisted branch is allowed through rather than blocking every
// branch outright — unlike the "HOSTILE: the protected-branch query itself
// fails" test above (msd-tools runs fine but the resolver's own internal
// verification fails), which stays fail-closed.
const { scriptDir, scriptPath } = writeExecutorGuardScript('msd-3819-guard-unavailable-unlisted-', bash, {
branch: 'my-feature-branch',
headRef: 'my-feature-branch',
isWorktree: false,
queryResult: 'false',
queryExit: 1,
});
t.after(() => cleanup(scriptDir));
const result = runHook(scriptPath, [], { interpreter: 'bash', cwd: scriptDir, timeoutMs: EXECUTOR_GUARD_TIMEOUT_MS });
assert.strictEqual(result.exitCode, 0, result.stderr);
assert.match(result.stdout, /GUARD_PASSED/);
});
// allow-test-rule: source-text-is-the-product
// Justification: this test's whole point is asserting on the prose text of the
// <final_commit> block itself — the workflow .md IS the product surface here,
// same rationale as Guard G (see the top-of-file exemption above).
test('#3819 <final_commit> block instructs the executor to re-run the Step 0 guard before the final commit', () => {
const executorPath = path.join(__dirname, '..', 'agents', 'msd-executor.md');
const content = readFileNormalized(executorPath);
const startIndex = content.indexOf('<final_commit>');
assert.notStrictEqual(startIndex, -1, 'agents/msd-executor.md: could not find <final_commit> tag — has it been renamed?');
const fenceIndex = content.indexOf('```bash', startIndex);
const excerpt = fenceIndex === -1
? content.slice(startIndex, startIndex + 2000)
: content.slice(startIndex, fenceIndex);
assert.match(excerpt, /re-run the Step 0/);
assert.match(excerpt, /#3819/);
});
});
// ─── #3648 Major 1: negative space for readEffectiveGitConfig's readFile seam ─
//
// The #3057 W3 suite that pinned readConfigBaseBranch's unusable-config arms
// was deleted with the function it targeted, but every arm it covered survives
// verbatim in readEffectiveGitConfig's readFile branch: the JSON.parse catch,
// the non-object guard, the git-section object guard, .trim(), and blank-string
// rejection. Deleting the tests left all of them unexercised — the four
// surviving readFile injections are positive-path only, and protected_branches
// was never driven through this seam at all.
//
// Reaching the seam requires readFile WITHOUT loadConfig. execGit is stubbed to
// answer cleanly-but-emptily so tiers 2-4 fall to a VERIFIED 'main', which
// makes 'main' the unambiguous signal that the config tier contributed nothing.
//
// WHAT THIS SUITE DOES AND DOES NOT PIN. Verified by mutating the built lib and
// re-running:
//
// .trim() on the resolved value -> KILLED by the positive controls
// the non-object guard on JSON.parse output -> SURVIVES
// the blank-string rejection -> SURVIVES
//
// The two survivors are unreachable through this entry point, for the same
// reason the deleted #3057 W3 suite recorded against its own equivalents:
//
// * A JSON-parsed value can never carry a `.git` or `.base_branch` own
// property unless it is already an object, so `null` / `42` / `"x"` / `[]`
// read as "no keys" whether or not the guard runs.
// * A blank base_branch is rejected a second time downstream — the resolver's
// `if (configured)` treats '' as falsy — so removing the guard here changes
// no observable output.
//
// Both remain defence-in-depth for a future non-JSON caller. They are recorded
// here as known-unkillable rather than left to look like coverage this suite
// does not provide.
describe('#3648 property: config-set validation and resolver filtering must agree', () => {
// The two new validating surfaces this PR adds are deliberately different
// shapes: `config-set` is all-or-nothing (reject the whole write), while the
// resolver is per-entry (drop the bad names, keep the good ones, report), so
// that a direct edit of config.json cannot fail the predicate OPEN. Nothing
// structural keeps the two definitions of "usable branch name" in step —
// only this property, which asks both about the same values.
const fc = require('./helpers/fast-check-setup.cjs');
const { isValidProtectedBranches } = require('../msd-core/bin/lib/config.cjs');
/** A value generator weighted towards the boundary cases both surfaces care about. */
const entry = () => fc.oneof(
fc.string(),
fc.constantFrom('', ' ', '\t\n', ' develop ', 'develop'),
fc.integer(),
fc.boolean(),
fc.constant(null),
fc.constant(undefined),
);
/**
* `fc.array` never produces a HOLE, and a hole is exactly where the two
* surfaces disagreed: `.every()` skips holes, `for...of` yields `undefined`
* for them. The property passed only because the generator could not reach
* the case (round-4 external review). Punching holes into a generated array
* is what makes this axis falsifiable.
*/
const withHoles = () => fc.tuple(
fc.array(entry(), { maxLength: 5 }),
fc.array(fc.nat({ max: 5 }), { maxLength: 3 }),
).map(([values, holeIndices]) => {
const arr = values.slice();
for (const i of holeIndices) {
if (i < arr.length) delete arr[i];
}
return arr;
});
test('accepted by config-set ⟺ the resolver rejects nothing from a non-empty list', () => {
fc.assert(
fc.property(
fc.oneof(fc.array(entry(), { maxLength: 6 }), withHoles(), entry()),
(configured) => {
const status = gitBaseBranch.resolveProtectedBranchStatus('/repo', 'topic/x', {
loadConfig: () => ({ base_branch: 'main', protected_branches: configured }),
});
const resolverKeptEverything = Array.isArray(configured)
&& configured.length > 0
&& status.rejectedProtectedBranches.length === 0;
assert.strictEqual(isValidProtectedBranches(configured), resolverKeptEverything,
`disagreement on ${JSON.stringify(configured)}`);
},
),
{ numRuns: 400 },
);
});
test('a top-level `protected_branches` is NOT an alias for the canonical nested key', (t) => {
// Round-4 external review. `get(key, {section, field})` is flat-then-nested,
// which is back-compat for keys `normalizeLegacyKeys` migrates. Routing a
// BRAND-NEW key through it invents an undocumented top-level spelling that
// silently outranks `git.protected_branches`. `protected_branches` has no
// legacy form, so it resolves nested-only — in production and in the seam.
const dir = createGitRepo({ prefix: 'msd-3648-flat-', defaultBranch: 'main' });
t.after(() => cleanup(dir));
addPlanning(dir);
fs.writeFileSync(
path.join(dir, '.planning', 'config.json'),
JSON.stringify({ protected_branches: ['bogus'], git: { protected_branches: ['develop'] } }),
);
const configLoader = require('../msd-core/bin/lib/config-loader.cjs');
assert.deepStrictEqual(
configLoader.loadConfig(dir, { persist: false })['protected_branches'], ['develop'],
'the canonical nested key must win outright');
// Control: `base_branch` DOES keep flat-then-nested, because it has a legacy
// flat spelling #3760's refusal path can leave behind. Without this, the
// assertion above could pass against a loader that lost flat support wholesale.
const seam = gitBaseBranch.resolveProtectedBranchStatus(dir, 'develop', {
readFile: () => JSON.stringify({
git: 'not-an-object', base_branch: 'release', protected_branches: ['bogus'],
}),
});
assert.strictEqual(seam.baseBranch, 'release',
'a refused migration must still let the surviving flat base_branch through');
assert.deepStrictEqual(seam.protectedBranches, ['release'],
'while a top-level protected_branches contributes nothing');
});
test('every surviving name is trimmed, non-empty, and de-duplicated against the base', () => {
fc.assert(
fc.property(
fc.array(entry(), { maxLength: 6 }),
(configured) => {
const status = gitBaseBranch.resolveProtectedBranchStatus('/repo', 'topic/x', {
loadConfig: () => ({ base_branch: 'main', protected_branches: configured }),
});
for (const name of status.protectedBranches) {
assert.strictEqual(typeof name, 'string');
assert.strictEqual(name, name.trim(), 'names must be stored trimmed');
assert.notStrictEqual(name, '', 'a blank name would silently match a detached HEAD');
}
assert.deepStrictEqual(
status.protectedBranches, [...new Set(status.protectedBranches)],
'duplicates (including one equal to the base branch) must collapse');
assert.strictEqual(status.protectedBranches[0], 'main',
'the resolved base branch is always protected and always leads');
// Nothing is lost silently: each input element is either kept
// (trimmed) or reported as rejected.
const kept = configured.filter((b) => typeof b === 'string' && b.trim() !== '');
assert.strictEqual(
kept.length + status.rejectedProtectedBranches.length, configured.length);
},
),
{ numRuns: 400 },
);
});
});
describe('#3648 Blocker 1: --is-protected is a QUERY and must not rewrite config.json', () => {
// The predicate runs on every execute-phase and every ship. It resolves config
// through config-loader's `loadConfig`, which normalizes legacy keys and then
// WRITES the migrated shape back to .planning/config.json. A boolean question
// was therefore silently rewriting the user's checked-in config — and this PR
// widened the trigger by adding a fifth normalization block (top-level
// `base_branch` -> `git.base_branch`). The read now passes `persist: false`.
//
// Asserted on BYTES, not on parsed shape: the persisted rewrite reorders keys
// and reflows whitespace even when the resolved values are equivalent.
/** A config whose ONLY interesting property is that it triggers a normalization. */
function writeLegacyConfig(dir) {
fs.mkdirSync(path.join(dir, '.planning'), { recursive: true });
const cfgPath = path.join(dir, '.planning', 'config.json');
// Hand-written formatting deliberately unlike JSON.stringify(cfg, null, 2):
// if anything rewrites this file, the bytes cannot come back identical.
fs.writeFileSync(cfgPath, '{"base_branch": "develop", "granularity": "standard"}\n');
return cfgPath;
}
test('a legacy-key config survives --is-protected byte-for-byte, and is still honoured', (t) => {
const dir = createGitRepo({ prefix: 'msd-3648-b1-', defaultBranch: 'main' });
t.after(() => cleanup(dir));
addPlanning(dir);
const cfgPath = writeLegacyConfig(dir);
fs.appendFileSync(path.join(dir, '.git', 'info', 'exclude'), '\n.planning/\n');
const before = fs.readFileSync(cfgPath);
const planningBefore = snapshotPlanningTree(dir);
const match = runMsdTools(['query', 'git.base-branch', '--is-protected', 'develop'], dir);
const control = runMsdTools(['query', 'git.base-branch', '--is-protected', 'topic/3648'], dir);
assert.ok(match.success, match.error);
assert.ok(control.success, control.error);
// Positive control: the flat legacy `base_branch` DID reach the predicate.
// Without this the byte assertion below would also pass for a query that
// ignored the config entirely.
assert.strictEqual(match.output, 'true');
assert.strictEqual(control.output, 'false');
assert.deepStrictEqual(
fs.readFileSync(cfgPath), before,
'a read-only predicate must leave .planning/config.json byte-identical',
);
assert.deepStrictEqual(
snapshotPlanningTree(dir), planningBefore,
'a read-only predicate must leave the entire .planning tree byte-identical',
);
const status = gitOrThrow(['status', '--porcelain'], { cwd: dir });
assert.strictEqual(status, '', 'read-only predicate must not leave a sibling write');
});
test('negative control: an ordinary persisting load DOES rewrite the same fixture', (t) => {
// Without this the test above cannot fail for the right reason — a fixture
// that never triggered a normalization would keep its bytes no matter what
// `persist` did. This proves the fixture is live.
const dir = createGitRepo({ prefix: 'msd-3648-b1-neg-', defaultBranch: 'main' });
t.after(() => cleanup(dir));
addPlanning(dir);
const cfgPath = writeLegacyConfig(dir);
const before = fs.readFileSync(cfgPath);
const configLoader = require('../msd-core/bin/lib/config-loader.cjs');
const persisted = configLoader.loadConfig(dir);
assert.notDeepStrictEqual(
fs.readFileSync(cfgPath), before,
'the default (omitted) option must keep migrating legacy configs on disk',
);
assert.strictEqual(persisted['base_branch'], 'develop',
'and it must resolve the same value the non-persisting read resolves');
});
test('negative control: a planted .planning sibling changes the tree snapshot', (t) => {
const dir = createGitRepo({ prefix: 'msd-3648-b1-stray-', defaultBranch: 'main' });
t.after(() => cleanup(dir));
addPlanning(dir);
const before = snapshotPlanningTree(dir);
fs.writeFileSync(path.join(dir, '.planning', 'sibling'), 'stray write\n');
assert.notDeepStrictEqual(
snapshotPlanningTree(dir), before,
'the snapshot must detect a newly planted .planning sibling',
);
});
test('persist:false changes only the side effect, not the resolved config', (t) => {
const dir = createGitRepo({ prefix: 'msd-3648-b1-parity-', defaultBranch: 'main' });
t.after(() => cleanup(dir));
addPlanning(dir);
const cfgPath = writeLegacyConfig(dir);
const before = fs.readFileSync(cfgPath);
const configLoader = require('../msd-core/bin/lib/config-loader.cjs');
const quiet = configLoader.loadConfig(dir, { persist: false });
assert.deepStrictEqual(fs.readFileSync(cfgPath), before,
'persist:false must not write');
const loud = configLoader.loadConfig(dir);
assert.notDeepStrictEqual(fs.readFileSync(cfgPath), before,
'the same directory, without the option, must write — proving the two differ');
assert.deepStrictEqual(quiet, loud,
'resolution must be identical; only the write is suppressed');
});
test('persist survives the workstream fallback recursion', (t) => {
// `loadConfigResolved` re-enters ITSELF with `{ workstream: null }` when a
// workstream was requested but has no config.json of its own. That literal
// dropped every other option, so the recursive pass ran with the DEFAULT
// persistence and rewrote the root config — the predicate's `persist:false`
// was silently discarded for exactly the projects that use workstreams.
// Found by the round-4 external review.
const dir = createGitRepo({ prefix: 'msd-3648-b1-ws-', defaultBranch: 'main' });
t.after(() => cleanup(dir));
addPlanning(dir);
// The workstream directory exists but carries no config.json — the shape
// that forces the fallback. Without it the recursion never runs and this
// test degenerates into the non-workstream case above.
fs.mkdirSync(path.join(dir, '.planning', 'workstreams', 'alpha'), { recursive: true });
const cfgPath = writeLegacyConfig(dir);
const before = fs.readFileSync(cfgPath);
const configLoader = require('../msd-core/bin/lib/config-loader.cjs');
const quiet = configLoader.loadConfig(dir, { persist: false, workstream: 'alpha' });
assert.strictEqual(quiet['base_branch'], 'develop',
'positive control: the fallback really did resolve the root config');
assert.deepStrictEqual(fs.readFileSync(cfgPath), before,
'the recursive fallback pass must inherit persist:false');
const loud = configLoader.loadConfig(dir, { workstream: 'alpha' });
assert.notDeepStrictEqual(fs.readFileSync(cfgPath), before,
'negative control: the same fallback without the option must still write');
assert.deepStrictEqual(quiet, loud,
'and suppressing the write must not change what the fallback resolves');
});
});
describe('#3648 Major 1: readEffectiveGitConfig readFile seam — config present but unusable', () => {
const CWD = path.join(path.sep, 'msd-3648-seam');
/** Drive the seam with raw config text, recording the paths requested. */
function readWith(raw, seenPaths) {
return gitBaseBranch.resolveProtectedBranchStatus(CWD, 'some-topic-branch', {
execGit: makeFaultyGit(),
readFile: (requested) => { if (seenPaths) seenPaths.push(requested); return raw; },
});
}
test('config.json exists but is not valid JSON → parse failure swallowed, base falls through', () => {
const seen = [];
assert.strictEqual(readWith('{ not json', seen).baseBranch, 'main');
assert.deepStrictEqual(seen, [path.join(CWD, '.planning', 'config.json')],
'the seam must look for config.json inside the planning dir under the cwd it was given');
});
test('config.json parses to a non-object → ignored for null / string / number / array', () => {
assert.strictEqual(readWith('null').baseBranch, 'main', 'JSON null must not be treated as a config');
assert.strictEqual(readWith('"master"').baseBranch, 'main', 'a bare JSON string must not be treated as a config');
assert.strictEqual(readWith('42').baseBranch, 'main', 'a bare JSON number must not be treated as a config');
assert.strictEqual(readWith('[]').baseBranch, 'main', 'a JSON array must not be treated as a config');
});
test('"git" section present but base_branch missing / non-string / blank → no override', () => {
assert.strictEqual(readWith('{"git":{}}').baseBranch, 'main');
assert.strictEqual(readWith('{"git":{"base_branch":42}}').baseBranch, 'main');
assert.strictEqual(readWith('{"git":{"base_branch":null}}').baseBranch, 'main');
assert.strictEqual(readWith('{"git":{"base_branch":""}}').baseBranch, 'main');
assert.strictEqual(readWith('{"git":{"base_branch":" "}}').baseBranch, 'main',
'a whitespace-only override must not win the precedence ladder');
});
test('"git" key present but not a usable object → the flat legacy key is still honoured', () => {
// These also guard the hoist: a non-object "git" must not be spread into
// index keys on the way to carrying the flat value across.
assert.strictEqual(readWith('{"git":"main","base_branch":"release"}').baseBranch, 'release');
assert.strictEqual(readWith('{"git":[],"base_branch":"release"}').baseBranch, 'release');
assert.strictEqual(readWith('{"git":null,"base_branch":"release"}').baseBranch, 'release');
});
test('flat base_branch present but non-string / blank → no override', () => {
assert.strictEqual(readWith('{"base_branch":true}').baseBranch, 'main');
assert.strictEqual(readWith('{"base_branch":["main"]}').baseBranch, 'main');
assert.strictEqual(readWith('{"base_branch":""}').baseBranch, 'main');
assert.strictEqual(readWith('{"base_branch":" "}').baseBranch, 'main');
});
test('config parses cleanly but carries neither key → distinct from an absent file', () => {
// The absent-file path returns before JSON.parse; these parse a real object
// and fall all the way through both lookups.
assert.strictEqual(readWith('{"other":1}').baseBranch, 'main');
assert.strictEqual(readWith('{}').baseBranch, 'main');
assert.strictEqual(readWith('').baseBranch, 'main', 'absent file (empty read) also yields no override');
assert.strictEqual(readWith(null).baseBranch, 'main', 'a null read is an absent file');
});
test('positive controls: values are trimmed and the nested key outranks the flat one', () => {
// Without these the whole describe could pass against a seam that ignored
// config entirely — every assertion above expects the fallback value.
assert.strictEqual(readWith('{"git":{"base_branch":" develop "}}').baseBranch, 'develop');
assert.strictEqual(readWith('{"base_branch":" release\\n"}').baseBranch, 'release');
assert.strictEqual(readWith('{"git":{"base_branch":"nested"},"base_branch":"flat"}').baseBranch, 'nested');
});
test('protected_branches is honoured and validated through this seam too', () => {
const clean = readWith('{"git":{"base_branch":"main","protected_branches":["develop"," next "]}}');
assert.deepStrictEqual(clean.protectedBranches, ['main', 'develop', 'next']);
assert.deepStrictEqual(clean.rejectedProtectedBranches, []);
const partial = readWith('{"git":{"base_branch":"main","protected_branches":["develop",42]}}');
assert.deepStrictEqual(partial.protectedBranches, ['main', 'develop'],
'a bad element must drop only itself on this path as well');
assert.deepStrictEqual(partial.rejectedProtectedBranches, ['42']);
const notAList = readWith('{"git":{"base_branch":"main","protected_branches":"develop"}}');
assert.deepStrictEqual(notAList.protectedBranches, ['main']);
assert.deepStrictEqual(notAList.rejectedProtectedBranches, ['"develop"']);
});
test('loadConfig wins when both seams are supplied — readFile is the test-only path', () => {
const status = gitBaseBranch.resolveProtectedBranchStatus(CWD, 'from-loader', {
execGit: makeFaultyGit(),
readFile: () => '{"git":{"base_branch":"from-readfile"}}',
loadConfig: () => ({ base_branch: 'from-loader' }),
});
assert.strictEqual(status.baseBranch, 'from-loader',
'production resolution must not be displaced by the low-level seam');
assert.strictEqual(status.isProtected, true);
});
});
// ─── #3057 W3: negative-space coverage for the resolver's failure arms ───────
//
// Everything below drives the *unhappy* halves of git-base-branch: malformed
// config, git output that parses but says nothing useful, git that cannot run
// at all, and a repository with no work tree. Each test asserts the exact value
// the arm is contracted to produce — never "it did not throw", never a shape
// check — because an arm that silently returns `undefined` instead of `null`
// changes the precedence ladder's behaviour while passing any weaker assertion.
/**
* Build a result object shaped exactly like `execGit`'s (see `_spawnResult` in
* shell-command-projection). Defaults are a benign, completed, zero-exit call.
*/
function gitResult(overrides) {
return {
exitCode: 0,
stdout: '',
stderr: '',
signal: null,
error: null,
timedOut: false,
...overrides,
};
}
/** An `execGit` stand-in that always returns the same shaped result. */
function constGit(overrides) {
return () => gitResult(overrides);
}
/**
* An `execGit` stand-in that throws. `makeFaultyGit` deliberately never throws
* (it returns a shaped failure result), so the resolver's `catch` arms need
* this instead.
*/
function throwingGit(message) {
return () => { throw new Error(message); };
}
describe('#3057 W3: trySymbolicRef — tier-2 output that resolves to nothing', () => {
test('stdout is exactly "origin/" → null (prefix strip leaves an empty name)', () => {
assert.strictEqual(gitBaseBranch.trySymbolicRef('/x', constGit({ stdout: 'origin/' })), null);
assert.strictEqual(gitBaseBranch.trySymbolicRef('/x', constGit({ stdout: 'origin/\n' })), null);
});
test('only ONE leading "origin/" is stripped — slashes inside the name survive', () => {
assert.strictEqual(
gitBaseBranch.trySymbolicRef('/x', constGit({ stdout: 'origin/feature/long-name\n' })),
'feature/long-name');
assert.strictEqual(
gitBaseBranch.trySymbolicRef('/x', constGit({ stdout: 'origin/origin/main\n' })),
'origin/main');
});
test('execGit THROWS → null (catch arm; makeFaultyGit cannot reach this)', () => {
assert.strictEqual(gitBaseBranch.trySymbolicRef('/x', throwingGit('symbolic-ref exploded')), null);
});
test('the tier-2 subprocess is bounded (argv + timeout are pinned)', () => {
const seen = [];
gitBaseBranch.trySymbolicRef('/some/cwd', (args, opts) => {
seen.push({ args, opts });
return gitResult({ stdout: 'origin/main\n' });
});
assert.deepStrictEqual(seen, [{
args: ['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'],
opts: { cwd: '/some/cwd', timeout: TIER2_SYMBOLIC_REF_TIMEOUT_MS },
}]);
});
});
describe('#3057 W3: tryRemoteShow — tier-3 output that is present but not authoritative', () => {
const REMOTE_SHOW_NO_HEAD = [
'* remote origin',
' Fetch URL: /tmp/origin.git',
' Push URL: /tmp/origin.git',
' Remote branch:',
' main tracked',
'',
].join('\n');
test('stdout has no "HEAD branch:" line → null', () => {
assert.strictEqual(
gitBaseBranch.tryRemoteShow('/x', constGit({ stdout: REMOTE_SHOW_NO_HEAD })), null);
});
test('"HEAD branch:" with no value on the line → null (no capture, no guess)', () => {
assert.strictEqual(
gitBaseBranch.tryRemoteShow('/x', constGit({ stdout: ' HEAD branch: \n' })), null);
});
test('"HEAD branch: (unknown)" → null — the documented offline-remote case', () => {
// git prints "(unknown)" when it could not reach the remote. Returning it
// verbatim would hand a literal branch named "(unknown)" to five workflows;
// returning null lets tier 4 answer instead.
assert.strictEqual(
gitBaseBranch.tryRemoteShow('/x', constGit({ stdout: ' HEAD branch: (unknown)\n' })), null);
});
test('execGit THROWS → null (catch arm)', () => {
assert.strictEqual(gitBaseBranch.tryRemoteShow('/x', throwingGit('remote show exploded')), null);
});
test('positive control: the HEAD branch line is found mid-output and returned verbatim', () => {
const stdout = [
'* remote origin',
' Fetch URL: /tmp/origin.git',
' HEAD branch: master',
' Remote branch:',
' master tracked',
'',
].join('\n');
assert.strictEqual(gitBaseBranch.tryRemoteShow('/x', constGit({ stdout })), 'master');
});
test('the tier-3 subprocess is bounded (argv + timeout are pinned)', () => {
const seen = [];
gitBaseBranch.tryRemoteShow('/some/cwd', (args, opts) => {
seen.push({ args, opts });
return gitResult({ stdout: ' HEAD branch: main\n' });
});
assert.deepStrictEqual(seen, [{
args: ['remote', 'show', 'origin'],
opts: { cwd: '/some/cwd', timeout: TIER3_REMOTE_SHOW_TIMEOUT_MS },
}]);
});
});
describe('#3057 W3: tryLocalBranch — non-empty stdout that names neither main nor master', () => {
test('stdout is exactly "\\n" → null, reached PAST the empty-stdout guard', () => {
// This is the branch that was once deleted as "unreachable". The guard is
// `if (r.exitCode !== 0 || !r.stdout) return null` — `"\n"` is a truthy
// string, so the guard does NOT fire; `split('\n')` yields ["", ""], both
// main/master checks are false, and the FINAL `return null` executes.
// Deleting that line makes this function return `undefined`, which
// strictEqual(null) catches.
assert.strictEqual(gitBaseBranch.tryLocalBranch('/x', constGit({ stdout: '\n' })), null);
});
test('stdout is exactly "" → null via the EARLY guard (a different arm)', () => {
assert.strictEqual(gitBaseBranch.tryLocalBranch('/x', constGit({ stdout: '' })), null);
});
// Boundary trio over the number of branch lines `git branch --list main master`
// can emit: 0 (below the smallest useful listing), 1, and 2 (the maximum this
// argv can produce).
test('0 branch lines → null', () => {
assert.strictEqual(gitBaseBranch.tryLocalBranch('/x', constGit({ stdout: '\n' })), null);
});
test('1 branch line → that branch', () => {
assert.strictEqual(gitBaseBranch.tryLocalBranch('/x', constGit({ stdout: ' main\n' })), 'main');
assert.strictEqual(gitBaseBranch.tryLocalBranch('/x', constGit({ stdout: ' master\n' })), 'master');
assert.strictEqual(gitBaseBranch.tryLocalBranch('/x', constGit({ stdout: '* master\n' })), 'master',
'the checked-out marker "* " must be stripped before matching');
});
test('2 branch lines → "main" wins the tie-break', () => {
assert.strictEqual(
gitBaseBranch.tryLocalBranch('/x', constGit({ stdout: ' main\n master\n' })), 'main');
assert.strictEqual(
gitBaseBranch.tryLocalBranch('/x', constGit({ stdout: '* master\n main\n' })), 'main');
});
test('execGit THROWS → null (catch arm)', () => {
assert.strictEqual(gitBaseBranch.tryLocalBranch('/x', throwingGit('branch --list exploded')), null);
});
test('the tier-4 subprocess is bounded (argv + timeout are pinned)', () => {
const seen = [];
gitBaseBranch.tryLocalBranch('/some/cwd', (args, opts) => {
seen.push({ args, opts });
return gitResult({ stdout: ' main\n' });
});
assert.deepStrictEqual(seen, [{
args: ['branch', '--list', 'main', 'master'],
opts: { cwd: '/some/cwd', timeout: TIER4_LOCAL_BRANCH_TIMEOUT_MS },
}]);
});
});
describe('#3057 W3: resolveBaseBranchDiagnostics — which tier actually answered', () => {
// Every tier can produce a plausible-looking branch name, so asserting the
// returned string alone cannot tell a tier-2 answer from a tier-3 or tier-4
// one. Each test below rigs the LOWER tiers to answer with a DIFFERENT branch
// than the tier under test, so a resolver that consulted them in the wrong
// order returns the wrong string, and additionally pins the recorded argv so
// an early return is provably an early return.
/** A passthrough answering each tier with a distinct, recognisable branch. */
function tieredPassthrough({ symref, remote, local }) {
return (args) => {
if (args[0] === 'symbolic-ref') {
return symref === null ? gitResult({ exitCode: 1 }) : gitResult({ stdout: `origin/${symref}\n` });
}
if (args[0] === 'remote') {
return remote === null ? gitResult({ exitCode: 128 }) : gitResult({ stdout: ` HEAD branch: ${remote}\n` });
}
if (args[0] === 'branch') {
return local === null ? gitResult({ stdout: '\n' }) : gitResult({ stdout: ` ${local}\n` });
}
return gitResult({});
};
}
const NO_CONFIG = { readFile: () => null };
test('tier 2 answers → tiers 3 and 4 are never consulted', () => {
const git = makeFaultyGit({
passthrough: tieredPassthrough({ symref: 'from-symref', remote: 'from-remote', local: 'master' }),
});
const result = gitBaseBranch.resolveBaseBranchDiagnostics('/x', { ...NO_CONFIG, execGit: git });
assert.deepStrictEqual(result, { branch: 'from-symref', verified: true });
assert.deepStrictEqual(git.calls.map((c) => c.args[0]), ['symbolic-ref'],
'a tier-2 hit must stop the ladder before `remote show` and `branch --list`');
});
test('tier 3 answers → tier 4 is never consulted, even though it WOULD answer "master"', () => {
const git = makeFaultyGit({
passthrough: tieredPassthrough({ symref: null, remote: 'from-remote', local: 'master' }),
});
const result = gitBaseBranch.resolveBaseBranchDiagnostics('/x', { ...NO_CONFIG, execGit: git });
assert.deepStrictEqual(result, { branch: 'from-remote', verified: true });
assert.deepStrictEqual(git.calls.map((c) => c.args[0]), ['symbolic-ref', 'remote'],
'a tier-3 hit must stop the ladder before `branch --list`');
});
test('tier 4 answers only after tiers 2 and 3 both decline', () => {
const git = makeFaultyGit({
passthrough: tieredPassthrough({ symref: null, remote: null, local: 'master' }),
});
const result = gitBaseBranch.resolveBaseBranchDiagnostics('/x', { ...NO_CONFIG, execGit: git });
assert.deepStrictEqual(result, { branch: 'master', verified: true });
assert.deepStrictEqual(git.calls.map((c) => c.args[0]), ['symbolic-ref', 'remote', 'branch']);
});
test('a config override answers before ANY git subprocess runs', () => {
const git = makeFaultyGit({
passthrough: tieredPassthrough({ symref: 'from-symref', remote: 'from-remote', local: 'master' }),
});
const result = gitBaseBranch.resolveBaseBranchDiagnostics('/x', {
readFile: () => '{"git":{"base_branch":"from-config"}}',
execGit: git,
});
assert.deepStrictEqual(result, { branch: 'from-config', verified: true });
assert.deepStrictEqual(git.calls, [], 'tier 1 must not spawn git at all');
});
test('git cannot be SPAWNED at all (exit 127 + error) → "main", verified:false', () => {
// Distinct from the timeout case already covered by #3057 B4: here every
// call returns exitCode 127 with `error` set and `timedOut:false`, which is
// the `r.error` disjunct of the failure detector rather than `r.timedOut`.
const git = makeFaultyGit({ faults: [{ kind: 'spawnFail' }] });
const result = gitBaseBranch.resolveBaseBranchDiagnostics('/x', { ...NO_CONFIG, execGit: git });
assert.deepStrictEqual(result, { branch: 'main', verified: false });
assert.deepStrictEqual(git.calls.map((c) => c.args[0]), ['symbolic-ref', 'remote', 'branch'],
'all three tiers must still be attempted before the unverified default');
});
test('a spawn failure on ONE tier alone is enough to mark the default unverified', () => {
// Tiers 2 and 3 complete cleanly with "no answer"; only tier 4 fails to run.
const git = makeFaultyGit({
faults: [{ kind: 'spawnFail', when: ['branch', '--list'] }],
passthrough: tieredPassthrough({ symref: null, remote: null, local: null }),
});
const result = gitBaseBranch.resolveBaseBranchDiagnostics('/x', { ...NO_CONFIG, execGit: git });
assert.deepStrictEqual(result, { branch: 'main', verified: false });
});
test('tier-4 stdout of "\\n" (no branches) is a VERIFIED "no candidate", not a failure', () => {
// The counterpart to the tryLocalBranch "\n" test, one level up: git ran,
// answered, and the answer was "neither branch exists". That must still be
// verified:true — collapsing it into verified:false would re-fail-open the
// exact distinction #3057 B4 introduced.
const git = makeFaultyGit({
passthrough: tieredPassthrough({ symref: null, remote: null, local: null }),
});
const result = gitBaseBranch.resolveBaseBranchDiagnostics('/x', { ...NO_CONFIG, execGit: git });
assert.deepStrictEqual(result, { branch: 'main', verified: true });
});
});
describe('#3057 W3: gitWorktreeInfoInternal — no work tree, and git failing mid-sequence', () => {
test('a REAL bare repository reports {inside:false, worktreeRoot:null}', (t) => {
// `git rev-parse --is-inside-work-tree` exits 0 in a bare repo and prints
// "false" — the exitCode guard does NOT fire, so this is the stdout check,
// and it is reachable without any injection.
const dir = createTempDir('msd-3057-w3-bare-');
t.after(() => cleanup(dir));
gitOrThrow(['init', '--bare'], { cwd: dir, timeoutMs: GIT_TIMEOUT_MS });
assert.deepStrictEqual(
gitBaseBranch.gitWorktreeInfoInternal(dir),
{ inside: false, worktreeRoot: null });
});
test('is-inside-work-tree prints "false" with exit 0 → no second git call is made', () => {
const git = makeFaultyGit({ passthrough: () => gitResult({ stdout: 'false\n' }) });
assert.deepStrictEqual(
gitBaseBranch.gitWorktreeInfoInternal('/x', { execGit: git }),
{ inside: false, worktreeRoot: null });
assert.deepStrictEqual(git.calls.map((c) => c.args), [['rev-parse', '--is-inside-work-tree']],
'--show-toplevel must not be queried once we know there is no work tree');
});
test('inside a work tree but --show-toplevel FAILS → {inside:true, worktreeRoot:null}', () => {
// inside is still reported truthfully; only the root is unknown. Reporting
// inside:false here would be a lie about a repository we just confirmed.
const git = makeFaultyGit({
faults: [{
kind: 'exit',
exitCode: 128,
stderr: 'fatal: no work tree',
when: ['rev-parse', '--show-toplevel'],
}],
passthrough: () => gitResult({ stdout: 'true\n' }),
});
assert.deepStrictEqual(
gitBaseBranch.gitWorktreeInfoInternal('/x', { execGit: git }),
{ inside: true, worktreeRoot: null });
assert.deepStrictEqual(git.calls.map((c) => c.args[1]),
['--is-inside-work-tree', '--show-toplevel']);
});
test('--show-toplevel succeeds with blank stdout → {inside:true, worktreeRoot:null}', () => {
const git = makeFaultyGit({
passthrough: (args) => gitResult({ stdout: args[1] === '--show-toplevel' ? ' \n' : 'true\n' }),
});
assert.deepStrictEqual(
gitBaseBranch.gitWorktreeInfoInternal('/x', { execGit: git }),
{ inside: true, worktreeRoot: null });
});
test('--show-toplevel succeeds → the trimmed path is returned', () => {
const git = makeFaultyGit({
passthrough: (args) => gitResult({ stdout: args[1] === '--show-toplevel' ? ' /repo/root \n' : 'true\n' }),
});
assert.deepStrictEqual(
gitBaseBranch.gitWorktreeInfoInternal('/x', { execGit: git }),
{ inside: true, worktreeRoot: '/repo/root' });
});
test('execGit THROWS → {inside:false, worktreeRoot:null} (catch arm)', () => {
assert.deepStrictEqual(
gitBaseBranch.gitWorktreeInfoInternal('/x', { execGit: throwingGit('git is gone') }),
{ inside: false, worktreeRoot: null });
});
test('both worktree probes are bounded and receive the caller cwd', () => {
const git = makeFaultyGit({
passthrough: (args) => gitResult({ stdout: args[1] === '--show-toplevel' ? '/repo/root\n' : 'true\n' }),
});
gitBaseBranch.gitWorktreeInfoInternal('/some/cwd', { execGit: git });
assert.deepStrictEqual(git.calls, [
{ args: ['rev-parse', '--is-inside-work-tree'], opts: { cwd: '/some/cwd', timeout: WORKTREE_INFO_TIMEOUT_MS } },
{ args: ['rev-parse', '--show-toplevel'], opts: { cwd: '/some/cwd', timeout: WORKTREE_INFO_TIMEOUT_MS } },
]);
});
});
describe('#3057 W3: cmdGitBaseBranch — the DEFAULT diagnostic sink', () => {
test('with no writeDiagnostic injected, the unverified warning goes to process.stderr', (t) => {
const written = [];
t.mock.method(process.stderr, 'write', (chunk) => { written.push(String(chunk)); return true; });
const stdout = [];
const branch = gitBaseBranch.cmdGitBaseBranch('/x', [], {
readFile: () => null,
execGit: makeFaultyGit({ faults: [{ kind: 'timeout' }] }),
write: (s) => { stdout.push(s); },
// writeDiagnostic deliberately omitted → the process.stderr default arm.
});
assert.strictEqual(branch, 'main');
assert.deepStrictEqual(stdout, ['main\n'], 'the stdout contract five workflows parse is unchanged');
assert.strictEqual(written.length, 1, 'exactly one diagnostic must reach the default stderr sink');
assert.match(written[0], /WITHOUT verifying/);
});
test('with no writeDiagnostic injected and a VERIFIED answer, process.stderr is untouched', (t) => {
const written = [];
t.mock.method(process.stderr, 'write', (chunk) => { written.push(String(chunk)); return true; });
const stdout = [];
const branch = gitBaseBranch.cmdGitBaseBranch('/x', [], {
readFile: () => '{"git":{"base_branch":"develop"}}',
execGit: makeFaultyGit(),
write: (s) => { stdout.push(s); },
});
assert.strictEqual(branch, 'develop');
assert.deepStrictEqual(stdout, ['develop\n']);
assert.deepStrictEqual(written, [], 'a verified answer must write nothing to the default stderr sink');
});
});
// ─── setMsdConfig prototype-pollution guard (#1406) ───────────────────────────
describe('#1406: setMsdConfig prototype-pollution guard', () => {
test('rejects __proto__ as a key segment', (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-1406-'));
t.after(() => cleanup(dir));
assert.throws(() => setMsdConfig(dir, '__proto__', 'x'), /unsafe config key segment/);
assert.throws(() => setMsdConfig(dir, '__proto__.polluted', true), /unsafe config key segment/);
});
test('rejects constructor / prototype chain segments', (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-1406-'));
t.after(() => cleanup(dir));
assert.throws(() => setMsdConfig(dir, 'constructor.prototype.polluted', true), /unsafe config key segment/);
assert.throws(() => setMsdConfig(dir, 'safe.__proto__', true), /unsafe config key segment/);
assert.throws(() => setMsdConfig(dir, 'a.prototype.b', true), /unsafe config key segment/);
});
test('does not pollute Object.prototype after rejected attempts', (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-1406-'));
t.after(() => cleanup(dir));
try { setMsdConfig(dir, '__proto__.polluted', true); } catch (_) { /* expected */ }
try { setMsdConfig(dir, 'constructor.prototype.polluted', true); } catch (_) { /* expected */ }
try { setMsdConfig(dir, 'a.__proto__.polluted', true); } catch (_) { /* expected */ }
assert.strictEqual(({}).polluted, undefined);
assert.strictEqual(Object.prototype.polluted, undefined);
});
test('still writes a normal nested key', (t) => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-1406-'));
t.after(() => cleanup(dir));
setMsdConfig(dir, 'git.base_branch', 'develop');
const cfgPath = path.join(dir, '.planning', 'config.json');
const cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
assert.strictEqual(cfg.git.base_branch, 'develop');
});
});
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-2004-pr-branch-milestone.test.cjs — consolidation epic #1969 (B4 #1973)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-2004-pr-branch-milestone (consolidation epic #1969 B4 #1973)", () => {
/**
* Regression tests for bug #2004
*
* /msd-pr-branch must not exclude milestone archive and structural planning
* commits. The previous implementation filtered ALL .planning/-only commits,
* including STATE.md, ROADMAP.md, MILESTONES.md, and milestones/** updates
* that are needed to preserve repository planning state after a merge.
*
* Fixed: pr-branch.md now distinguishes:
* - Transient planning commits (phase plans, summaries, research, context) → EXCLUDE
* - Structural planning commits (STATE.md, ROADMAP.md, MILESTONES.md,
* PROJECT.md, milestones/**) → INCLUDE
* - Code commits (any non-.planning/ file) → INCLUDE
* - Mixed commits (code + planning) → INCLUDE
*/
'use strict';
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const workflowPath = path.resolve(
__dirname, '..', 'msd-core', 'workflows', 'pr-branch.md'
);
describe('bug #2004: pr-branch preserves structural planning commits', () => {
let content;
test('setup: pr-branch workflow is readable', () => {
content = fs.readFileSync(workflowPath, 'utf-8');
assert.ok(content.length > 0, 'pr-branch.md must not be empty');
});
test('workflow distinguishes structural vs transient planning commits', () => {
content = content || fs.readFileSync(workflowPath, 'utf-8');
// Must contain language distinguishing structural from transient/phase planning files
assert.ok(
/structural|milestone.*archive|STATE\.md.*INCLUDE|preserve.*milestone|milestone.*preserve/i.test(content),
'pr-branch.md must distinguish structural planning commits from transient ones'
);
});
test('workflow lists STATE.md and ROADMAP.md as structural files to preserve', () => {
content = content || fs.readFileSync(workflowPath, 'utf-8');
assert.ok(
content.includes('STATE.md'),
'pr-branch.md must reference STATE.md as a structural file to preserve'
);
assert.ok(
content.includes('ROADMAP.md'),
'pr-branch.md must reference ROADMAP.md as a structural file to preserve'
);
});
test('workflow lists MILESTONES.md or milestones/ as structural files to preserve', () => {
content = content || fs.readFileSync(workflowPath, 'utf-8');
assert.ok(
content.includes('MILESTONES.md') || content.includes('milestones/'),
'pr-branch.md must reference MILESTONES.md or milestones/ as structural files to preserve'
);
});
test('workflow has four commit categories (code, planning-only, mixed, structural)', () => {
content = content || fs.readFileSync(workflowPath, 'utf-8');
// Must have at least a "structural" or "milestone" category beyond the original three
assert.ok(
/structural.*commit|milestone.*commit|commit.*structural|commit.*milestone/i.test(content) ||
/INCLUDE.*STATE\.md|STATE\.md.*INCLUDE/i.test(content),
'pr-branch.md must classify structural planning commits as INCLUDE'
);
});
test('create_pr_branch step does not rm -r --cached all of .planning/', () => {
content = content || fs.readFileSync(workflowPath, 'utf-8');
// The original bug: `git rm -r --cached .planning/` nuked structural files.
// The fix must either remove this wholesale rm or scope it to transient dirs.
// Acceptable: narrowed rm targeting only phase/, quick/, research/, etc.
// Not acceptable: `git rm -r --cached .planning/` with no scoping.
const hasUnscoped = /git rm -r --cached \.planning\/(?!\*)?(?!phases|quick|research|threads|todos|debug|seeds|ui-reviews|codebase)/
.test(content);
assert.ok(
!hasUnscoped,
'create_pr_branch must not use unscoped "git rm -r --cached .planning/" — scope to transient subdirectories only'
);
});
});
});
}
// ────────────────────────────────────────────────────────────────────────
// Folded from tests/bug-2916-handle-branching-default-base.test.cjs — consolidation epic #1969 (B6 #1975)
// ────────────────────────────────────────────────────────────────────────
{
const { describe: __foldDescribe } = require('node:test');
__foldDescribe("folded:bug-2916-handle-branching-default-base (consolidation epic #1969 B6 #1975)", () => {
/**
* Regression test for #2916: execute-phase `handle_branching` step creates the
* per-phase branch off whatever HEAD is currently checked out (typically the
* previous phase's unmerged branch) instead of off `origin/HEAD`.
*
* The bug compounded phases on top of each other and stranded them unpushed
* for weeks. The fix:
* 1. Detect the default branch via `git symbolic-ref refs/remotes/origin/HEAD`.
* 2. If $BRANCH_NAME exists, switch to it (preserve existing behavior).
* 3. Otherwise, ff-update the default branch from origin and create the new
* phase branch off the default-branch tip.
* 4. Refuse-or-warn on dirty working tree.
* 5. Post-creation, assert `git rev-list --count $DEFAULT_BRANCH..HEAD == 0`.
*
* This test extracts the bash payload from the <step name="handle_branching">
* block in execute-phase.md (parsed structurally — no regex on prose), executes
* it inside a fixture git repo where HEAD sits on a previous-phase branch with
* extra commits, and asserts that the new phase branch's tip equals
* `origin/main` (no commits inherited from the previous phase).
*/
const { describe, 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 EXECUTE_PHASE_PATH = path.join(
__dirname,
'..',
'msd-core',
'workflows',
'execute-phase.md'
);
const GIT_ENV = Object.freeze({
...process.env,
GIT_AUTHOR_NAME: 'Test',
GIT_AUTHOR_EMAIL: 'test@test.com',
GIT_COMMITTER_NAME: 'Test',
GIT_COMMITTER_EMAIL: 'test@test.com',
});
function git(cwd, ...args) {
return gitOrThrow(args, { cwd, env: GIT_ENV, timeoutMs: GIT_TIMEOUT_MS }).trim();
}
/**
* Structurally extract the bash code that the handle_branching step instructs
* the agent to run. We:
* 1. Locate the <step name="handle_branching"> ... </step> block.
* 2. Walk its body looking for fenced ```bash blocks.
* 3. Concatenate every bash block in the step (the fix may use more than one).
*
* No `.includes()` content checks — we parse fence-delimited code blocks the
* same way a markdown parser would.
*/
function extractHandleBranchingBash() {
const content = readFileNormalized(EXECUTE_PHASE_PATH);
const lines = content.split('\n');
let start = -1;
let end = -1;
for (let i = 0; i < lines.length; i += 1) {
if (start === -1 && /^<step\s+name="handle_branching">\s*$/.test(lines[i])) {
start = i + 1;
} else if (start !== -1 && /^<\/step>\s*$/.test(lines[i])) {
end = i;
break;
}
}
if (start === -1 || end === -1) {
throw new Error(
'execute-phase.md does not contain a <step name="handle_branching"> ... </step> block'
);
}
const bashBlocks = [];
let inBash = false;
let buffer = [];
for (let i = start; i < end; i += 1) {
const line = lines[i];
if (!inBash && /^```bash\s*$/.test(line)) {
inBash = true;
buffer = [];
continue;
}
if (inBash && /^```\s*$/.test(line)) {
bashBlocks.push(buffer.join('\n'));
inBash = false;
continue;
}
if (inBash) buffer.push(line);
}
if (bashBlocks.length === 0) {
throw new Error(
'handle_branching step contains no ```bash code blocks to execute'
);
}
return bashBlocks.join('\n');
}
/**
* Build a fixture: a bare "origin" repo with the named default branch (one
* commit), a clone with `origin/HEAD` pointed at it, and a checked-out
* previous-phase branch carrying its own unmerged commit.
*
* `defaultBranch` is parameterized so callers can lock in that the workflow
* honors `git symbolic-ref refs/remotes/origin/HEAD` rather than silently
* defaulting to `main` (#2921 CR feedback — quick-branching.test.cjs got the
* same treatment in 80f14cac; this test deserves the same coverage).
*/
function setupFixture(defaultBranch = 'main') {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-2916-'));
const seedPath = path.join(root, 'seed');
const originPath = path.join(root, 'origin.git');
const clonePath = path.join(root, 'clone');
fs.mkdirSync(seedPath);
git(seedPath, 'init', '-b', defaultBranch);
git(seedPath, 'config', 'commit.gpgsign', 'false');
fs.writeFileSync(path.join(seedPath, 'README.md'), '# seed\n');
git(seedPath, 'add', 'README.md');
git(seedPath, 'commit', '-m', 'initial');
git(root, 'clone', '--bare', seedPath, originPath);
git(originPath, 'symbolic-ref', 'HEAD', `refs/heads/${defaultBranch}`);
git(root, 'clone', originPath, clonePath);
git(clonePath, 'config', 'commit.gpgsign', 'false');
git(clonePath, 'config', 'user.email', 'test@test.com');
git(clonePath, 'config', 'user.name', 'Test');
// Simulate finishing a previous phase: branch off the default branch, add
// a commit, and *stay* on it (the failure scenario described in the bug).
git(clonePath, 'checkout', '-b', 'feature/phase-01-foundation');
fs.writeFileSync(path.join(clonePath, 'phase01.txt'), 'phase 1 work\n');
git(clonePath, 'add', 'phase01.txt');
git(clonePath, 'commit', '-m', 'phase 01 work');
return { root, clonePath, defaultBranch };
}
function runHandleBranchingStep(bash, cwd, branchName) {
// Write the script to a sibling tempdir, not inside the repo — putting it in
// `cwd` would create an untracked file that trips `git status --porcelain`
// and steers the step into its dirty-tree fallback path.
const scriptDir = fs.mkdtempSync(path.join(os.tmpdir(), 'msd-2916-step-'));
const scriptPath = path.join(scriptDir, 'handle-branching.sh');
const script = `#!/usr/bin/env bash\nset -uo pipefail\nBRANCH_NAME="${branchName}"\n${bash}\n`;
fs.writeFileSync(scriptPath, script, { mode: 0o755 });
try {
// Bash FAN-OUT (a sequence of git commands under one `bash` interpreter),
// not a single git plumbing call — the wrong class for `GIT_TIMEOUT_MS`.
// 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 r = runHook(scriptPath, [], { interpreter: 'bash', cwd, env: GIT_ENV, timeoutMs: HOOK_FANOUT_TIMEOUT_MS });
throwIfFailed(r, `runHandleBranchingStep: bash ${scriptPath}`);
return r.stdout;
} finally {
cleanup(scriptDir);
}
}
describe('handle_branching branches off origin/HEAD, not current HEAD (#2916)', () => {
// Run against `main` (conventional default) and `trunk` (non-main default
// exercising the symbolic-ref code path) so a regression that hard-codes
// `main` instead of consulting origin/HEAD will fail the trunk variant.
for (const defaultBranch of ['main', 'trunk']) {
test(`new phase branch branches off origin/${defaultBranch} with 0 inherited commits`, (t) => {
const bash = extractHandleBranchingBash();
const { root, clonePath } = setupFixture(defaultBranch);
// Teardown via t.after, not try/finally — CONTRIBUTING.md "Setup and
// Cleanup" reserves try/finally for context-free helper functions.
t.after(() => cleanup(root));
const upstream = `origin/${defaultBranch}`;
assert.equal(
git(clonePath, 'rev-parse', '--abbrev-ref', 'HEAD'),
'feature/phase-01-foundation'
);
assert.equal(
git(clonePath, 'rev-list', '--count', `${upstream}..HEAD`),
'1',
`fixture should be 1 commit ahead of ${upstream}`
);
runHandleBranchingStep(bash, clonePath, 'feature/phase-02-content-sync');
assert.equal(
git(clonePath, 'rev-parse', '--abbrev-ref', 'HEAD'),
'feature/phase-02-content-sync',
'handle_branching should switch to the new phase branch'
);
const inherited = git(clonePath, 'rev-list', '--count', `${upstream}..HEAD`);
assert.equal(
inherited,
'0',
`new phase branch must branch off ${upstream}, but inherited ${inherited} commit(s) from previous-phase HEAD`
);
assert.equal(
git(clonePath, 'rev-parse', 'HEAD'),
git(clonePath, 'rev-parse', upstream),
`new phase branch tip must equal ${upstream} tip`
);
});
}
test('handle_branching reuses an existing branch instead of forking again', (t) => {
const bash = extractHandleBranchingBash();
const { root, clonePath } = setupFixture();
t.after(() => cleanup(root));
// Pre-create the target branch off origin/main with its own commit, then
// walk away to a different branch — the step must switch back to it.
git(clonePath, 'checkout', '-B', 'feature/phase-02-content-sync', 'origin/main');
fs.writeFileSync(path.join(clonePath, 'phase02.txt'), 'phase 2 work\n');
git(clonePath, 'add', 'phase02.txt');
git(clonePath, 'commit', '-m', 'phase 02 wip');
const phase02Sha = git(clonePath, 'rev-parse', 'HEAD');
git(clonePath, 'checkout', 'feature/phase-01-foundation');
runHandleBranchingStep(bash, clonePath, 'feature/phase-02-content-sync');
assert.equal(
git(clonePath, 'rev-parse', '--abbrev-ref', 'HEAD'),
'feature/phase-02-content-sync'
);
assert.equal(
git(clonePath, 'rev-parse', 'HEAD'),
phase02Sha,
'existing-branch tip must be preserved (no rebase/reset)'
);
});
});
});
}
// ─── #3679 — pr-branch: pre-existing planning content + verify deletion gate ──
//
// The original deletion class (blanket `git rm -r --cached` of transient dirs
// stripping pre-existing base-branch files) was fixed as a side effect of the
// #2971 strict-mode rewrite (rm -f --ignore-unmatch + `git checkout HEAD --`
// restore). These rows PIN that guarantee behaviorally so it cannot silently
// regress, and add the remaining piece from the #3679 brief: the verify step
// must FAIL when the PR-branch diff deletes planning files the target tracks
// (a deleted allowed/structural path verifies clean today — name-only
// counting cannot see status).
describe('#3679 — pr-branch pre-existing planning content + verify deletion gate', () => {
const PR_BRANCH_MD = path.join(WORKFLOW_DIR, 'pr-branch.md');
function extractStepBash(stepName) {
const content = readFileNormalized(PR_BRANCH_MD);
const lines = content.split('\n');
let start = -1;
let end = -1;
for (let i = 0; i < lines.length; i += 1) {
if (start === -1 && new RegExp(`^<step\\s+name="${stepName}">\\s*$`).test(lines[i])) {
start = i + 1;
} else if (start !== -1 && /^<\/step>\s*$/.test(lines[i])) {
end = i;
break;
}
}
assert.ok(start !== -1 && end !== -1, `pr-branch.md must contain the ${stepName} step`);
const bashBlocks = [];
let inBash = false;
let buffer = [];
for (let i = start; i < end; i += 1) {
const line = lines[i];
if (!inBash && /^```bash\s*$/.test(line)) {
inBash = true;
buffer = [];
continue;
}
if (inBash && /^```\s*$/.test(line)) {
bashBlocks.push(buffer.join('\n'));
inBash = false;
continue;
}
if (inBash) buffer.push(line);
}
assert.ok(bashBlocks.length > 0, `${stepName} step contains bash blocks`);
return bashBlocks.join('\n');
}
test('verify step gates on planning-tree deletions', () => {
const bash = extractStepBash('verify');
assert.ok(
/diff-filter=D|--name-status/.test(bash),
'verify must distinguish deletions (diff-filter=D or --name-status)',
);
assert.ok(
/PLANNING_DELETIONS/.test(bash),
'verify must compute a planning-deletions count',
);
// The must-be-0 gate lives in the step PROSE and the display template,
// outside every ```bash block — assert it against the full file text so
// the enforcement half of criterion 4 is pinned, not just the computation.
const fullText = readFileNormalized(PR_BRANCH_MD);
assert.ok(
/PLANNING_DELETIONS[^\n]*must be .?0/.test(fullText),
'verify prose must gate on a zero PLANNING_DELETIONS count',
);
assert.ok(
/Planning deletions: \{PLANNING_DELETIONS\}/.test(fullText),
'verify display must surface the deletion count',
);
});
test('verify fails when the PR diff deletes target-tracked planning files', (t) => {
const repo = createTempDir('msd-3679-verify-fail-');
t.after(() => cleanup(repo));
const g = (args) => gitOrThrow(args, { cwd: repo });
g(['init', '-q', '-b', 'main']);
g(['config', 'user.email', 't@t']);
g(['config', 'user.name', 't']);
fs.mkdirSync(path.join(repo, '.planning'), { recursive: true });
fs.writeFileSync(path.join(repo, '.planning', 'STATE.md'), 'state\n');
fs.writeFileSync(path.join(repo, 'code.sh'), 'code\n');
g(['add', '-A']);
g(['commit', '-qm', 'base']);
g(['checkout', '-qb', 'pr']);
fs.writeFileSync(path.join(repo, 'code.sh'), 'code2\n');
// The failure class under test: the PR branch deletes a planning file the
// target tracks (here structural — an allowed category, so the existing
// name-only forbidden count cannot catch it).
g(['rm', '-q', '.planning/STATE.md']);
g(['add', '-A']);
g(['commit', '-qm', 'changes + planning deletion']);
const verifyBash = extractStepBash('verify');
const script = [
'set -u',
'TARGET=main',
'PR_BRANCH=pr',
'FORBIDDEN_RE="^\\.planning/(phases|quick|research|threads|todos|debug|seeds|codebase|ui-reviews)/"',
'STRUCTURAL_RE="^\\.planning/(STATE|ROADMAP|MILESTONES|PROJECT|REQUIREMENTS)\\.md$|^\\.planning/milestones/"',
verifyBash,
'echo "GATE_FORBIDDEN=$FORBIDDEN"',
'echo "GATE_PLANNING_DELETIONS=${PLANNING_DELETIONS:-unset}"',
].join('\n');
fs.writeFileSync(path.join(repo, 'verify.sh'), script + '\n');
const r = runHook(path.join(repo, 'verify.sh'), [], {
interpreter: 'bash',
cwd: repo,
timeoutMs: HOOK_FANOUT_TIMEOUT_MS,
});
const out = r.stdout + r.stderr;
assert.match(out, /GATE_PLANNING_DELETIONS=1/, `deletion count must be non-zero: ${out.slice(0, 400)}`);
});
test('verify passes a clean diff with zero deletions', (t) => {
const repo = createTempDir('msd-3679-verify-clean-');
t.after(() => cleanup(repo));
const g = (args) => gitOrThrow(args, { cwd: repo });
g(['init', '-q', '-b', 'main']);
g(['config', 'user.email', 't@t']);
g(['config', 'user.name', 't']);
fs.mkdirSync(path.join(repo, '.planning'), { recursive: true });
fs.writeFileSync(path.join(repo, '.planning', 'STATE.md'), 'state\n');
fs.writeFileSync(path.join(repo, 'code.sh'), 'code\n');
g(['add', '-A']);
g(['commit', '-qm', 'base']);
g(['checkout', '-qb', 'pr']);
fs.writeFileSync(path.join(repo, 'code.sh'), 'code2\n');
g(['add', '-A']);
g(['commit', '-qm', 'code only']);
const verifyBash = extractStepBash('verify');
const script = [
'set -u',
'TARGET=main',
'PR_BRANCH=pr',
'FORBIDDEN_RE="^\\.planning/(phases|quick|research|threads|todos|debug|seeds|codebase|ui-reviews)/"',
'STRUCTURAL_RE="^\\.planning/(STATE|ROADMAP|MILESTONES|PROJECT|REQUIREMENTS)\\.md$|^\\.planning/milestones/"',
verifyBash,
'echo "GATE_FORBIDDEN=$FORBIDDEN"',
'echo "GATE_PLANNING_DELETIONS=${PLANNING_DELETIONS:-unset}"',
].join('\n');
fs.writeFileSync(path.join(repo, 'verify.sh'), script + '\n');
const r = runHook(path.join(repo, 'verify.sh'), [], {
interpreter: 'bash',
cwd: repo,
timeoutMs: HOOK_FANOUT_TIMEOUT_MS,
});
const out = r.stdout + r.stderr;
assert.match(out, /GATE_PLANNING_DELETIONS=0/, `clean diff must count zero deletions: ${out.slice(0, 400)}`);
});
test('create loop preserves pre-existing transient content (#3720 guarantee pinned)', (t) => {
const repo = createTempDir('msd-3679-create-mixed-');
t.after(() => cleanup(repo));
const g = (args) => gitOrThrow(args, { cwd: repo });
g(['init', '-q', '-b', 'main']);
g(['config', 'user.email', 't@t']);
g(['config', 'user.name', 't']);
fs.mkdirSync(path.join(repo, '.planning', 'phases'), { recursive: true });
fs.mkdirSync(path.join(repo, '.planning', 'research'), { recursive: true });
fs.mkdirSync(path.join(repo, 'infra'), { recursive: true });
fs.writeFileSync(path.join(repo, '.planning', 'phases', '1.0-PLAN.md'), 'plan\n');
fs.writeFileSync(path.join(repo, '.planning', 'research', 'context.md'), 'ctx\n');
// Structural planning state on the TARGET (criterion 3): must survive the
// create loop byte-identical alongside the transient content.
fs.writeFileSync(path.join(repo, '.planning', 'STATE.md'), 'state\n');
fs.writeFileSync(path.join(repo, '.planning', 'ROADMAP.md'), 'roadmap\n');
fs.writeFileSync(path.join(repo, 'infra', 'script.sh'), 'code\n');
g(['add', '-A']);
g(['commit', '-qm', 'base: code + pre-existing planning']);
g(['checkout', '-qb', 'feature']);
fs.writeFileSync(path.join(repo, 'infra', 'script2.sh'), 'more\n');
fs.writeFileSync(path.join(repo, '.planning', 'phases', '2.0-SUMMARY.md'), 'summary\n');
g(['add', '-A']);
g(['commit', '-qm', 'mixed: code + new transient']);
const hash = g(['rev-parse', 'HEAD']).trim();
// Execute the shipped create_pr_branch loop verbatim (default-mode paths).
const createBash = extractStepBash('create_pr_branch');
const script = [
'set -u',
`CURRENT_BRANCH=feature`,
'TARGET=main',
`INCLUDED_COMMITS="${hash}"`,
'FILTER_PATHS=".planning/phases/ .planning/quick/ .planning/research/ .planning/threads/ .planning/todos/ .planning/debug/ .planning/seeds/ .planning/codebase/ .planning/ui-reviews/ "',
createBash,
].join('\n');
fs.writeFileSync(path.join(repo, 'create.sh'), script + '\n');
const r = runHook(path.join(repo, 'create.sh'), [], {
interpreter: 'bash',
cwd: repo,
timeoutMs: HOOK_FANOUT_TIMEOUT_MS,
});
assert.equal(r.exitCode, 0, `create loop must succeed: ${r.stderr.slice(0, 400)}`);
const status = g(['diff', '--name-status', 'main..feature-pr']);
assert.equal((status.match(/^D/gm) || []).length, 0, `no deletions allowed: ${status}`);
const diffPaths = g(['diff', '--name-only', 'main..feature-pr']);
assert.ok(!diffPaths.includes('2.0-SUMMARY.md'), "commit's own transient file must be excluded");
assert.ok(diffPaths.includes('script2.sh'), 'code change must be present');
assert.ok(!diffPaths.includes('1.0-PLAN.md'), 'pre-existing plan must be untouched');
assert.ok(!diffPaths.includes('STATE.md'), 'structural STATE.md must survive (criterion 3)');
assert.ok(!diffPaths.includes('ROADMAP.md'), 'structural ROADMAP.md must survive (criterion 3)');
});
test('create loop preserves planning content on pure-code commits', (t) => {
const repo = createTempDir('msd-3679-create-pure-');
t.after(() => cleanup(repo));
const g = (args) => gitOrThrow(args, { cwd: repo });
g(['init', '-q', '-b', 'main']);
g(['config', 'user.email', 't@t']);
g(['config', 'user.name', 't']);
fs.mkdirSync(path.join(repo, '.planning', 'phases'), { recursive: true });
fs.mkdirSync(path.join(repo, 'infra'), { recursive: true });
fs.writeFileSync(path.join(repo, '.planning', 'phases', '1.0-PLAN.md'), 'plan\n');
fs.writeFileSync(path.join(repo, 'infra', 'script.sh'), 'code\n');
g(['add', '-A']);
g(['commit', '-qm', 'base']);
g(['checkout', '-qb', 'feature']);
fs.writeFileSync(path.join(repo, 'infra', 'script2.sh'), 'more\n');
g(['add', '-A']);
g(['commit', '-qm', 'pure code']);
const hash = g(['rev-parse', 'HEAD']).trim();
const createBash = extractStepBash('create_pr_branch');
const script = [
'set -u',
'CURRENT_BRANCH=feature',
'TARGET=main',
`INCLUDED_COMMITS="${hash}"`,
'FILTER_PATHS=".planning/phases/ .planning/quick/ .planning/research/ .planning/threads/ .planning/todos/ .planning/debug/ .planning/seeds/ .planning/codebase/ .planning/ui-reviews/ "',
createBash,
].join('\n');
fs.writeFileSync(path.join(repo, 'create.sh'), script + '\n');
const r = runHook(path.join(repo, 'create.sh'), [], {
interpreter: 'bash',
cwd: repo,
timeoutMs: HOOK_FANOUT_TIMEOUT_MS,
});
assert.equal(r.exitCode, 0, `create loop must succeed: ${r.stderr.slice(0, 400)}`);
const status = g(['diff', '--name-status', 'main..feature-pr']);
assert.equal((status.match(/^D/gm) || []).length, 0, `no deletions allowed: ${status}`);
assert.ok(status.includes('script2.sh'), 'code change must be present');
});
});