Folds 15 tests/fix-*.test.cjs regression files (191 test() blocks) into their module's main suite, per the wave decomposition of #3315 (H3 of epic #3053). 187 blocks land in 8 existing suites (4 exact-duplicate cases dropped, documented inline); 4 blocks move via git mv into 2 new suite files with no prior coverage to merge into. Zero production behavior change. Also tightens two H1 (#3313) ratchets that the fold's own file-count reduction moved past their grace window, per the ratchets' documented dual failure mode (a stale/too-loose baseline fails exactly like a novel violation): - lint-test-file-count.allowlist.json: removes the stale "audit" entry (folding fix-2766 into tests/uat.test.cjs drops that module back to its 2-file cap). - lint-allow-test-rule-refs.ceiling.json: lowers maxFiles 314 -> 309, the real post-fold high-water mark (gsd-test's own repo-baseline test caught this — CI, not a human, found it). Two orthogonal review passes (Standards+Spec code-review, isolated security-review) found and this commit fixes two issues before push: a genuinely-distinct #2287 test case (file-absent vs. file-present- resolved) that a prior fold pass had wrongly dropped as a duplicate — restored verbatim into tests/uat.test.cjs; and a missing same-line allow-test-rule citation on the #2196 block in tests/debug-session-management.test.cjs, added for consistency with its sibling #2257 block. lint-removed-but-needed also caught two stale doc references to the now-folded-away fix-2285-claude-orchestration-wiring.test.cjs filename (docs/adr/1143-claude-orchestration-capability.md, gsd-core/references/execute-phase-response-language.md) — updated both to point at tests/claude-orchestration.test.cjs, its new home. Co-authored-by: sim <sim@local>
775 lines
34 KiB
JavaScript
775 lines
34 KiB
JavaScript
/**
|
|
* Regression test for #2112: gsd-tools commit --files commits the entire
|
|
* index, not the declared paths.
|
|
*
|
|
* `cmdCommit` staged exactly the files named in --files but then ran a bare
|
|
* `git commit` with no pathspec, absorbing anything else that happened to be
|
|
* staged into a commit whose message described only the named files.
|
|
*
|
|
* The fix adds `'--', ...stagedPaths` to the commit args **only when** the
|
|
* caller declared a scope (explicitFiles), and only for paths that were
|
|
* actually staged (skipped missing files are excluded to avoid #2014).
|
|
*/
|
|
|
|
const { describe, test, beforeEach, afterEach } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const os = require('os');
|
|
const { spawnSync } = require('child_process');
|
|
const { createTempGitProject, cleanup, runGsdTools } = require('./helpers.cjs');
|
|
const { gitOrThrow } = require('./helpers/git-fixture.cjs');
|
|
// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs.
|
|
const { GIT_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
|
|
|
|
describe('commit --files: pathspec honors declared scope (#2112)', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempGitProject();
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
test('commit --files does not absorb unrelated staged files', () => {
|
|
// Developer stages a WIP file via git add (not via --files).
|
|
fs.writeFileSync(path.join(tmpDir, 'src-wip.txt'), 'work in progress\n');
|
|
gitOrThrow(['add', 'src-wip.txt'], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS });
|
|
|
|
// GSD writes and commits a planning artifact, naming ONLY that file.
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'PLAN.md'), '# Plan\n');
|
|
runGsdTools(
|
|
['commit', 'docs(01): add PLAN.md', '--files', '.planning/PLAN.md'],
|
|
tmpDir,
|
|
);
|
|
|
|
// The commit must contain ONLY .planning/PLAN.md.
|
|
const diffOutput = gitOrThrow(['diff', 'HEAD~1', 'HEAD', '--name-only'], {
|
|
cwd: tmpDir,
|
|
timeoutMs: GIT_TIMEOUT_MS,
|
|
}).trim();
|
|
assert.strictEqual(
|
|
diffOutput,
|
|
'.planning/PLAN.md',
|
|
'commit --files must contain only the named files, got:\n' + diffOutput,
|
|
);
|
|
|
|
// The WIP file must still be staged, not committed.
|
|
const statusOutput = gitOrThrow(['status', '--porcelain'], {
|
|
cwd: tmpDir,
|
|
timeoutMs: GIT_TIMEOUT_MS,
|
|
}).trim();
|
|
assert.ok(
|
|
statusOutput.includes('A src-wip.txt') || statusOutput.includes('A\tsrc-wip.txt'),
|
|
'src-wip.txt should remain staged, not committed. Status:\n' + statusOutput,
|
|
);
|
|
});
|
|
|
|
test('commit --files with two files commits exactly those two', () => {
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'PLAN.md'), '# Plan\n');
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'RESEARCH.md'), '# Research\n');
|
|
|
|
runGsdTools(
|
|
['commit', 'docs: artifacts', '--files', '.planning/PLAN.md', '.planning/RESEARCH.md'],
|
|
tmpDir,
|
|
);
|
|
|
|
const diffOutput = gitOrThrow(['diff', 'HEAD~1', 'HEAD', '--name-only'], {
|
|
cwd: tmpDir,
|
|
timeoutMs: GIT_TIMEOUT_MS,
|
|
});
|
|
const files = diffOutput.trim().split('\n').sort();
|
|
assert.deepEqual(
|
|
files,
|
|
['.planning/PLAN.md', '.planning/RESEARCH.md'],
|
|
'commit should contain exactly the two named files',
|
|
);
|
|
});
|
|
|
|
test('commit without --files still commits the entire .planning/ index (default path)', () => {
|
|
// Write a planning artifact and stage it.
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'PLAN.md'), '# Plan\n');
|
|
gitOrThrow(['add', '.planning/PLAN.md'], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS });
|
|
|
|
// Also stage an unrelated file.
|
|
fs.writeFileSync(path.join(tmpDir, 'extra.txt'), 'extra\n');
|
|
gitOrThrow(['add', 'extra.txt'], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS });
|
|
|
|
runGsdTools(['commit', 'docs: default commit'], tmpDir);
|
|
|
|
// Default path (no --files) commits everything staged.
|
|
const diffOutput = gitOrThrow(['diff', 'HEAD~1', 'HEAD', '--name-only'], {
|
|
cwd: tmpDir,
|
|
timeoutMs: GIT_TIMEOUT_MS,
|
|
});
|
|
const files = diffOutput.trim().split('\n').sort();
|
|
assert.ok(
|
|
files.includes('.planning/PLAN.md') && files.includes('extra.txt'),
|
|
'default commit (no --files) should commit everything staged, got:\n' + files,
|
|
);
|
|
});
|
|
|
|
test('missing tracked file in --files is still not committed as deletion (#2014 guard)', () => {
|
|
// Create and commit STATE.md, then remove it from disk.
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), '# State\n');
|
|
gitOrThrow(['add', '.planning/STATE.md'], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS });
|
|
gitOrThrow(['commit', '-m', 'add STATE.md'], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS });
|
|
fs.unlinkSync(path.join(tmpDir, '.planning', 'STATE.md'));
|
|
|
|
// Also create a valid file to commit.
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'PLAN.md'), '# Plan\n');
|
|
|
|
runGsdTools(
|
|
['commit', 'docs: add plan', '--files', '.planning/PLAN.md', '.planning/STATE.md'],
|
|
tmpDir,
|
|
);
|
|
|
|
const diffOutput = gitOrThrow(['diff', 'HEAD~1', 'HEAD', '--name-status'], {
|
|
cwd: tmpDir,
|
|
timeoutMs: GIT_TIMEOUT_MS,
|
|
});
|
|
assert.ok(
|
|
!diffOutput.includes('D\t.planning/STATE.md'),
|
|
'missing tracked file must not appear as a deletion, diff was:\n' + diffOutput,
|
|
);
|
|
assert.ok(
|
|
diffOutput.includes('.planning/PLAN.md'),
|
|
'PLAN.md should be committed',
|
|
);
|
|
});
|
|
|
|
test('commit --files with only missing files returns nothing_to_commit', () => {
|
|
// Create and commit STATE.md, then remove it from disk.
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), '# State\n');
|
|
gitOrThrow(['add', '.planning/STATE.md'], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS });
|
|
gitOrThrow(['commit', '-m', 'add STATE.md'], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS });
|
|
fs.unlinkSync(path.join(tmpDir, '.planning', 'STATE.md'));
|
|
|
|
// Stage an unrelated file so the index is non-empty.
|
|
fs.writeFileSync(path.join(tmpDir, 'extra.txt'), 'extra\n');
|
|
gitOrThrow(['add', 'extra.txt'], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS });
|
|
|
|
const result = runGsdTools(
|
|
['commit', 'docs: try', '--files', '.planning/STATE.md'],
|
|
tmpDir,
|
|
);
|
|
|
|
const parsed = JSON.parse(result.output);
|
|
assert.strictEqual(
|
|
parsed.committed, false,
|
|
'should not commit when all --files are missing',
|
|
);
|
|
assert.strictEqual(
|
|
parsed.reason, 'nothing_to_commit',
|
|
'should report nothing_to_commit, not absorb the index',
|
|
);
|
|
|
|
// The unrelated staged file must still be staged, not committed.
|
|
const statusOutput = gitOrThrow(['status', '--porcelain'], {
|
|
cwd: tmpDir,
|
|
timeoutMs: GIT_TIMEOUT_MS,
|
|
}).trim();
|
|
assert.ok(
|
|
statusOutput.includes('extra.txt'),
|
|
'extra.txt should remain staged, not absorbed into a commit',
|
|
);
|
|
});
|
|
|
|
test('#2523: absolute --files path inside the repo is committed, not silently dropped', () => {
|
|
// init phase-op emits phase_dir as an ABSOLUTE path (#2428); cmdCommit must
|
|
// accept it. The bug was path.join(cwd, absPath) → cwd+absPath (non-existent)
|
|
// → silently skipped as nothing_to_commit (#2523).
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'A.md'), 'a\n');
|
|
const absPath = path.join(tmpDir, '.planning', 'A.md');
|
|
const res = runGsdTools(['commit', 'docs: abs path', '--files', absPath], tmpDir);
|
|
const parsed = JSON.parse(res.output);
|
|
assert.strictEqual(parsed.committed, true, `absolute path must commit, not nothing_to_commit: ${res.output}`);
|
|
|
|
// The absolute path must land in the commit, normalized to repo-relative.
|
|
const diff = gitOrThrow(['diff', 'HEAD~1', 'HEAD', '--name-only'], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS }).trim();
|
|
assert.strictEqual(diff, '.planning/A.md', `absolute --files path must be committed (normalized to relative); got: ${diff}`);
|
|
});
|
|
|
|
test('#2523: mixed relative+absolute --files list commits BOTH (no silent partial commit)', () => {
|
|
// The sharpest symptom: a mixed list committed the relative entry, dropped the
|
|
// absolute one, and reported committed:true (#2523). Both must land.
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'REL.md'), 'r\n');
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', 'ABS.md'), 'a\n');
|
|
const absPath = path.join(tmpDir, '.planning', 'ABS.md');
|
|
const res = runGsdTools(
|
|
['commit', 'docs: mixed', '--files', '.planning/REL.md', absPath],
|
|
tmpDir,
|
|
);
|
|
const parsed = JSON.parse(res.output);
|
|
assert.strictEqual(parsed.committed, true, `mixed list must commit: ${res.output}`);
|
|
|
|
const diff = gitOrThrow(['diff', 'HEAD~1', 'HEAD', '--name-only'], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS })
|
|
.trim().split('\n').sort();
|
|
assert.deepStrictEqual(
|
|
diff,
|
|
['.planning/ABS.md', '.planning/REL.md'],
|
|
`mixed relative+absolute list must commit BOTH entries (the bug dropped the absolute one); got: ${diff.join(',')}`,
|
|
);
|
|
});
|
|
|
|
test('#2523: out-of-repo --files path is rejected by git (staging_failed), no index pollution', (t) => {
|
|
// An absolute path resolving OUTSIDE the project root: git add rejects it. No
|
|
// index pollution (#2523). Not "path_outside_repo" (that guard was removed for
|
|
// macOS symlink compatibility — git's own rejection suffices).
|
|
//
|
|
// #2608 changed the REASON this reports, deliberately. It used to be
|
|
// `nothing_to_commit`, because a failed `git add` was skipped and the empty
|
|
// stagedPaths list fell through to the empty-changeset branch. But "nothing to
|
|
// commit" is not what happened — the caller named a file and git refused it —
|
|
// and that misreport is the very class of defect #2608 closes. The result now
|
|
// carries `staging_failed` plus the offending path and git's own message
|
|
// ("… is outside repository at …"), which is strictly more actionable.
|
|
//
|
|
// #2523's two substantive invariants are unchanged and still asserted below:
|
|
// no commit is created, and the index is left clean.
|
|
const outsideDir = path.join(tmpDir, '..', `gsd-2523-outside-${process.pid}-${Date.now()}`);
|
|
fs.mkdirSync(outsideDir, { recursive: true });
|
|
t.after(() => cleanup(outsideDir));
|
|
const outsideFile = path.join(outsideDir, 'secret.md');
|
|
fs.writeFileSync(outsideFile, 's\n');
|
|
|
|
const res = runGsdTools(
|
|
['commit', 'docs: outside', '--files', path.resolve(outsideFile)],
|
|
tmpDir,
|
|
);
|
|
const parsed = JSON.parse(res.output);
|
|
assert.strictEqual(parsed.committed, false, 'out-of-repo path must not commit');
|
|
assert.strictEqual(parsed.reason, 'staging_failed', `out-of-repo: git rejects → staging_failed (#2608): ${res.output}`);
|
|
assert.strictEqual(parsed.file, path.resolve(outsideFile), 'the rejected path must be named');
|
|
assert.match(parsed.error, /outside repository/, "git's own rejection message must be preserved (#2608)");
|
|
|
|
// No new commit created (still at the single initial commit).
|
|
const logCount = gitOrThrow(['rev-list', '--count', 'HEAD'], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS }).trim();
|
|
assert.strictEqual(logCount, '1', 'no new commit must be created for an out-of-repo path');
|
|
// Index stays clean (git add failed → nothing staged).
|
|
const status = gitOrThrow(['status', '--porcelain'], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS }).trim();
|
|
assert.strictEqual(status, '', `index must be clean (no pollution): ${status}`);
|
|
});
|
|
});
|
|
|
|
/**
|
|
* ── #2608: commit --files / commit-to-subrepo fail closed when `git add`
|
|
* fails ──────────────────────────────────────────────────────────────────
|
|
*
|
|
* A `git add` that fails (unwritable index in a linked worktree whose git
|
|
* dir is outside the managed writable root, permissions, timeout) was not
|
|
* surfaced. #2523 above had already stopped a failed path entering the
|
|
* commit pathspec, but skipping it silently left two bad outcomes, both
|
|
* reproduced by this suite against the pre-fix build:
|
|
*
|
|
* - SOME paths fail -> `{"committed":true}`. `git commit` still ran and
|
|
* PARTIALLY committed the subset that happened to
|
|
* stage, under a message describing the full
|
|
* requested scope.
|
|
* - EVERY path fails -> `{"reason":"nothing_to_commit"}`, which is not
|
|
* what happened and points the operator nowhere.
|
|
*
|
|
* In both cases the original `git add` stderr was discarded, so the user
|
|
* saw a downstream `commit_failed` / pathspec error naming an innocent
|
|
* file.
|
|
*
|
|
* The fix collects staging failures and fails closed BEFORE `git commit`
|
|
* runs, returning `staging_failed` (or `staging_timeout`) with the
|
|
* offending file and the original stderr preserved.
|
|
*
|
|
* ── INJECTION SEAM ──────────────────────────────────────────────────────
|
|
* `execGit` is monkeypatched on the shell-command-projection module object.
|
|
* The compiled call site is `(0, mod.execGit)(...)` — a property lookup at
|
|
* call time — so the override takes effect. Per CLAUDE.md this is required
|
|
* over `chmod 0o000` permission tricks, which do not fault under root (root
|
|
* Docker/CI) and would make these tests silently vacuous.
|
|
*
|
|
* The patched call runs in a short-lived `node -e` CHILD rather than
|
|
* in-process, for two reasons: `output()` writes with `fs.writeSync(1, …)`,
|
|
* which neither `process.stdout.write` nor `console.log` interception can
|
|
* capture; and a child keeps the patch from leaking into sibling suites. It
|
|
* is a plain `process.execPath` spawn — no PATH stub and no exec bit, so it
|
|
* is not subject to DEFECT.WINDOWS-TEST-PORTABILITY and runs on every
|
|
* platform.
|
|
*/
|
|
|
|
// Git plumbing (add/commit/status/rev-parse/rev-list/diff) on a small
|
|
// mkdtemp fixture repo, for the #2608 suite below only. Kept as its own
|
|
// local constant (distinct from the shared GIT_TIMEOUT_MS imported above)
|
|
// per helpers/timeouts.cjs's own guidance: a call site whose class
|
|
// genuinely differs keeps its own justified value rather than forcing a
|
|
// shared norm that doesn't describe it — 5000ms here vs. 15000ms for the
|
|
// shared DEFAULT_GIT_TIMEOUT_MS norm.
|
|
const STAGING_GIT_TIMEOUT_MS = 5000;
|
|
|
|
const LIB = path.join(__dirname, '..', 'gsd-core', 'bin', 'lib');
|
|
|
|
/**
|
|
* Run cmdCommit with `git add <file>` forced to fail for the paths in `failFor`,
|
|
* returning the parsed JSON result and the git argv list that was actually
|
|
* executed (so "git commit never ran" is asserted directly, not inferred).
|
|
*/
|
|
function commitWithFailingAdd({ cwd, files, failFor = [], stderr = 'fatal: injected staging failure', timeout = false, amend = false, gitVerb = 'add' }) {
|
|
const callsOut = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2608-')), 'calls.json');
|
|
// `timeout` is `false` | `true` (alias for `'posix'`) | `'posix'` | `'windows'` —
|
|
// #3050: the shared isSpawnTimeout predicate only requires `error.code ===
|
|
// 'ETIMEDOUT'`, NOT `signal === 'SIGTERM'` (Windows does not reliably report
|
|
// SIGTERM), so both shapes must be proven to still read as a timeout.
|
|
const timeoutShape = timeout === true ? 'posix' : timeout;
|
|
const script = `
|
|
const path = require('path');
|
|
const LIB = ${JSON.stringify(LIB)};
|
|
const projection = require(path.join(LIB, 'shell-command-projection.cjs'));
|
|
const { cmdCommit } = require(path.join(LIB, 'commands.cjs'));
|
|
const failFor = ${JSON.stringify(failFor)};
|
|
const stderrText = ${JSON.stringify(stderr)};
|
|
const timeoutShape = ${JSON.stringify(timeoutShape)};
|
|
const gitVerb = ${JSON.stringify(gitVerb)};
|
|
const real = projection.execGit;
|
|
const calls = [];
|
|
projection.execGit = (args, opts) => {
|
|
calls.push(args);
|
|
if (args[0] === gitVerb && failFor.includes(args[args.length - 1])) {
|
|
if (timeoutShape === 'posix') {
|
|
// The exact shape spawnSync produces on a POSIX timeout, which
|
|
// shell-command-projection surfaces as signal + error.code.
|
|
const e = new Error('spawnSync git ETIMEDOUT');
|
|
e.code = 'ETIMEDOUT';
|
|
return { exitCode: 1, stdout: '', stderr: stderrText, signal: 'SIGTERM', error: e };
|
|
}
|
|
if (timeoutShape === 'windows') {
|
|
// Windows shape: spawnSync's timeout kill does not reliably report
|
|
// signal:'SIGTERM' — only error.code:'ETIMEDOUT' is guaranteed (#3050).
|
|
const e = new Error('spawnSync git ETIMEDOUT');
|
|
e.code = 'ETIMEDOUT';
|
|
return { exitCode: 1, stdout: '', stderr: stderrText, signal: null, error: e };
|
|
}
|
|
return { exitCode: 128, stdout: '', stderr: stderrText, signal: null, error: null };
|
|
}
|
|
return real(args, opts);
|
|
};
|
|
process.on('exit', () => {
|
|
require('fs').writeFileSync(${JSON.stringify(callsOut)}, JSON.stringify(calls));
|
|
});
|
|
cmdCommit(${JSON.stringify(cwd)}, 'docs: map existing codebase', ${JSON.stringify(files)}, false, ${JSON.stringify(amend)}, false);
|
|
`;
|
|
|
|
const run = spawnSync(process.execPath, ['-e', script], {
|
|
encoding: 'utf8',
|
|
timeout: 30000,
|
|
killSignal: 'SIGKILL',
|
|
env: { ...process.env, GSD_TEST_MODE: '1' },
|
|
});
|
|
|
|
assert.ok(
|
|
run.stdout && run.stdout.trim(),
|
|
`cmdCommit child produced no stdout (status=${run.status}): ${run.stderr}`,
|
|
);
|
|
return {
|
|
result: JSON.parse(run.stdout),
|
|
gitCalls: JSON.parse(fs.readFileSync(callsOut, 'utf8')),
|
|
};
|
|
}
|
|
|
|
function headCount(cwd) {
|
|
return Number(gitOrThrow(['rev-list', '--count', 'HEAD'], { cwd, timeoutMs: STAGING_GIT_TIMEOUT_MS }).trim());
|
|
}
|
|
|
|
function committedFiles(cwd) {
|
|
return gitOrThrow(['diff', 'HEAD~1', 'HEAD', '--name-only'], { cwd, timeoutMs: STAGING_GIT_TIMEOUT_MS })
|
|
.trim().split('\n').filter(Boolean).sort();
|
|
}
|
|
|
|
/**
|
|
* Same harness for `cmdCommitToSubrepo` — the sub-repo twin of the staging loop,
|
|
* which carried the identical defect (failed `git add` dropped, commit proceeds
|
|
* with the subset that staged).
|
|
*/
|
|
function subrepoCommitWithFailingAdd({ cwd, files, failFor = [], timeout = false }) {
|
|
// See commitWithFailingAdd above for the timeoutShape rationale (#3050).
|
|
const timeoutShape = timeout === true ? 'posix' : timeout;
|
|
const script = `
|
|
const path = require('path');
|
|
const LIB = ${JSON.stringify(LIB)};
|
|
const projection = require(path.join(LIB, 'shell-command-projection.cjs'));
|
|
const { cmdCommitToSubrepo } = require(path.join(LIB, 'commands.cjs'));
|
|
const failFor = ${JSON.stringify(failFor)};
|
|
const timeoutShape = ${JSON.stringify(timeoutShape)};
|
|
const real = projection.execGit;
|
|
projection.execGit = (args, opts) => {
|
|
if (args[0] === 'add' && failFor.includes(args[args.length - 1])) {
|
|
if (timeoutShape === 'posix') {
|
|
const e = new Error('spawnSync git ETIMEDOUT');
|
|
e.code = 'ETIMEDOUT';
|
|
return { exitCode: 1, stdout: '', stderr: 'fatal: injected subrepo staging failure', signal: 'SIGTERM', error: e };
|
|
}
|
|
if (timeoutShape === 'windows') {
|
|
const e = new Error('spawnSync git ETIMEDOUT');
|
|
e.code = 'ETIMEDOUT';
|
|
return { exitCode: 1, stdout: '', stderr: 'fatal: injected subrepo staging failure', signal: null, error: e };
|
|
}
|
|
return { exitCode: 128, stdout: '', stderr: 'fatal: injected subrepo staging failure', signal: null, error: null };
|
|
}
|
|
return real(args, opts);
|
|
};
|
|
cmdCommitToSubrepo(${JSON.stringify(cwd)}, 'feat: subrepo change', ${JSON.stringify(files)}, false);
|
|
`;
|
|
const run = spawnSync(process.execPath, ['-e', script], {
|
|
encoding: 'utf8',
|
|
timeout: 30000,
|
|
killSignal: 'SIGKILL',
|
|
env: { ...process.env, GSD_TEST_MODE: '1' },
|
|
});
|
|
assert.ok(run.stdout && run.stdout.trim(),
|
|
`cmdCommitToSubrepo child produced no stdout (status=${run.status}): ${run.stderr}`);
|
|
return JSON.parse(run.stdout);
|
|
}
|
|
|
|
describe('#2608: commit-to-subrepo fails closed when git add fails', () => {
|
|
let rootDir;
|
|
let subDir;
|
|
|
|
beforeEach(() => {
|
|
rootDir = createTempGitProject();
|
|
fs.writeFileSync(
|
|
path.join(rootDir, '.planning', 'config.json'),
|
|
JSON.stringify({ planning: { sub_repos: ['backend'] } }, null, 2),
|
|
);
|
|
subDir = path.join(rootDir, 'backend');
|
|
fs.mkdirSync(subDir, { recursive: true });
|
|
for (const [cmd, args] of [['init', []], ['config', ['user.email', 'test@example.com']], ['config', ['user.name', 'Test']]]) {
|
|
gitOrThrow([cmd, ...args], { cwd: subDir, timeoutMs: STAGING_GIT_TIMEOUT_MS });
|
|
}
|
|
fs.writeFileSync(path.join(subDir, 'seed.js'), '// seed\n');
|
|
gitOrThrow(['add', 'seed.js'], { cwd: subDir, timeoutMs: STAGING_GIT_TIMEOUT_MS });
|
|
gitOrThrow(['commit', '-m', 'seed'], { cwd: subDir, timeoutMs: STAGING_GIT_TIMEOUT_MS });
|
|
fs.writeFileSync(path.join(subDir, 'a.js'), '// a\n');
|
|
fs.writeFileSync(path.join(subDir, 'b.js'), '// b\n');
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(rootDir);
|
|
});
|
|
|
|
test('a failed sub-repo git add reports staging_failed and commits nothing', () => {
|
|
const before = headCount(subDir);
|
|
const result = subrepoCommitWithFailingAdd({
|
|
cwd: rootDir,
|
|
files: ['backend/a.js', 'backend/b.js'],
|
|
failFor: ['b.js'],
|
|
});
|
|
|
|
assert.equal(result.repos.backend.reason, 'staging_failed',
|
|
`expected staging_failed for the sub-repo, got ${JSON.stringify(result)}`);
|
|
assert.equal(result.repos.backend.committed, false);
|
|
assert.match(result.repos.backend.error, /injected subrepo staging failure/,
|
|
"git's original stderr must be preserved");
|
|
assert.equal(headCount(subDir), before, 'no partial sub-repo commit may be created');
|
|
|
|
const status = gitOrThrow(['status', '--porcelain'], { cwd: subDir, timeoutMs: STAGING_GIT_TIMEOUT_MS });
|
|
assert.deepEqual(status.split('\n').filter((l) => /^A[ \t]/.test(l)), [],
|
|
`the sub-repo index must be rolled back, status:\n${status}`);
|
|
});
|
|
|
|
test('successful sub-repo staging still commits', () => {
|
|
const before = headCount(subDir);
|
|
const result = subrepoCommitWithFailingAdd({
|
|
cwd: rootDir,
|
|
files: ['backend/a.js', 'backend/b.js'],
|
|
failFor: [],
|
|
});
|
|
|
|
assert.equal(result.repos.backend.committed, true, `expected a commit, got ${JSON.stringify(result)}`);
|
|
assert.equal(headCount(subDir), before + 1);
|
|
});
|
|
});
|
|
|
|
describe('#2608: commit --files fails closed when git add fails', () => {
|
|
let tmpDir;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = createTempGitProject();
|
|
for (const name of ['ARCHITECTURE', 'CONCERNS', 'CONVENTIONS']) {
|
|
fs.writeFileSync(path.join(tmpDir, '.planning', `${name}.md`), `# ${name}\n`);
|
|
}
|
|
});
|
|
|
|
afterEach(() => {
|
|
cleanup(tmpDir);
|
|
});
|
|
|
|
// ── AC1 + AC3: the failure is reported, with its original stderr ──────────
|
|
|
|
test('a failed git add returns staging_failed with the file and original stderr', () => {
|
|
const before = headCount(tmpDir);
|
|
const { result, gitCalls } = commitWithFailingAdd({
|
|
cwd: tmpDir,
|
|
files: ['.planning/ARCHITECTURE.md'],
|
|
failFor: ['.planning/ARCHITECTURE.md'],
|
|
stderr: 'fatal: Unable to create index.lock: Permission denied',
|
|
});
|
|
|
|
assert.equal(result.committed, false);
|
|
assert.equal(result.hash, null);
|
|
assert.equal(result.reason, 'staging_failed',
|
|
'the staging cause must be reported, not a downstream commit_failed/pathspec error');
|
|
assert.equal(result.file, '.planning/ARCHITECTURE.md', 'the offending file must be named');
|
|
assert.match(result.error, /Unable to create index\.lock/,
|
|
'the original git add stderr must be preserved');
|
|
|
|
// AC2: git commit must never have been invoked.
|
|
assert.ok(
|
|
!gitCalls.some((a) => a[0] === 'commit'),
|
|
`git commit must not run after a staging failure, calls: ${JSON.stringify(gitCalls)}`,
|
|
);
|
|
assert.equal(headCount(tmpDir), before, 'no commit may be created');
|
|
});
|
|
|
|
// ── AC4: no partial commit of a multi-file explicit scope ─────────────────
|
|
|
|
test('when the second of three paths fails to stage, nothing is committed', () => {
|
|
// Pre-fix this returned {"committed":true} — the two paths that DID stage
|
|
// were committed under a message describing all three.
|
|
const before = headCount(tmpDir);
|
|
const { result, gitCalls } = commitWithFailingAdd({
|
|
cwd: tmpDir,
|
|
files: ['.planning/ARCHITECTURE.md', '.planning/CONCERNS.md', '.planning/CONVENTIONS.md'],
|
|
failFor: ['.planning/CONCERNS.md'],
|
|
});
|
|
|
|
assert.equal(result.reason, 'staging_failed');
|
|
assert.equal(result.file, '.planning/CONCERNS.md');
|
|
assert.ok(
|
|
!gitCalls.some((a) => a[0] === 'commit'),
|
|
'a partial commit of the paths that DID stage must not happen',
|
|
);
|
|
assert.equal(headCount(tmpDir), before, 'no partial commit may be created');
|
|
});
|
|
|
|
test('every failing path is reported, not just the first', () => {
|
|
const { result } = commitWithFailingAdd({
|
|
cwd: tmpDir,
|
|
files: ['.planning/ARCHITECTURE.md', '.planning/CONCERNS.md', '.planning/CONVENTIONS.md'],
|
|
failFor: ['.planning/ARCHITECTURE.md', '.planning/CONVENTIONS.md'],
|
|
});
|
|
|
|
assert.equal(result.failures.length, 2);
|
|
assert.deepEqual(
|
|
result.failures.map((f) => f.file).sort(),
|
|
['.planning/ARCHITECTURE.md', '.planning/CONVENTIONS.md'],
|
|
);
|
|
});
|
|
|
|
// ── An all-paths-fail run must not masquerade as nothing_to_commit ────────
|
|
|
|
test('when every path fails to stage, the reason is staging_failed not nothing_to_commit', () => {
|
|
const { result } = commitWithFailingAdd({
|
|
cwd: tmpDir,
|
|
files: ['.planning/ARCHITECTURE.md', '.planning/CONCERNS.md'],
|
|
failFor: ['.planning/ARCHITECTURE.md', '.planning/CONCERNS.md'],
|
|
});
|
|
|
|
assert.notEqual(result.reason, 'nothing_to_commit',
|
|
'every path failing to stage is a staging failure, not an empty changeset');
|
|
assert.equal(result.reason, 'staging_failed');
|
|
});
|
|
|
|
// ── AC5: a staging timeout is distinguishable from an ordinary failure ────
|
|
|
|
test('a staging timeout is reported as staging_timeout, not staging_failed', () => {
|
|
const { result } = commitWithFailingAdd({
|
|
cwd: tmpDir,
|
|
files: ['.planning/ARCHITECTURE.md'],
|
|
failFor: ['.planning/ARCHITECTURE.md'],
|
|
stderr: '',
|
|
timeout: true,
|
|
});
|
|
|
|
assert.equal(result.reason, 'staging_timeout',
|
|
'the projection exposes SIGTERM+ETIMEDOUT; a timeout must not read as an ordinary failure');
|
|
assert.equal(result.failures[0].timed_out, true);
|
|
});
|
|
|
|
// #3050 item 4: this site (commands.cts's `git add` staging loop) now routes
|
|
// through the shared isSpawnTimeout predicate, which drops the `signal ===
|
|
// 'SIGTERM'` requirement — a Windows-shaped timeout (no signal, only
|
|
// error.code === 'ETIMEDOUT') must still be detected.
|
|
test('a staging timeout is reported as staging_timeout even without SIGTERM (Windows shape, #3050)', () => {
|
|
const { result } = commitWithFailingAdd({
|
|
cwd: tmpDir,
|
|
files: ['.planning/ARCHITECTURE.md'],
|
|
failFor: ['.planning/ARCHITECTURE.md'],
|
|
stderr: '',
|
|
timeout: 'windows',
|
|
});
|
|
|
|
assert.equal(result.reason, 'staging_timeout');
|
|
assert.equal(result.failures[0].timed_out, true);
|
|
});
|
|
|
|
// #3050 item 4: the `git rm --cached` branch of the same staging loop (the
|
|
// default-mode "stage the deletion" path, distinct from `git add` above)
|
|
// carries its own inline copy of the timeout check pre-fix. Drive it
|
|
// directly: default mode (no explicit --files) stages '.planning/', and
|
|
// when that path is absent on disk the loop takes the `git rm --cached`
|
|
// branch instead of `git add`.
|
|
test('a `git rm --cached` timeout in default mode is reported as staging_timeout, POSIX and Windows shapes (#3050)', () => {
|
|
// Mid-test fixture mutation (simulating an absent '.planning/' on disk),
|
|
// not teardown; the outer afterEach still runs helpers.cleanup(tmpDir) on
|
|
// the whole tmpDir.
|
|
// eslint-disable-next-line local/no-raw-rmsync-in-tests -- see comment above
|
|
fs.rmSync(path.join(tmpDir, '.planning'), { recursive: true, force: true });
|
|
|
|
for (const shape of ['posix', 'windows']) {
|
|
const { result } = commitWithFailingAdd({
|
|
cwd: tmpDir,
|
|
files: undefined,
|
|
failFor: ['.planning/'],
|
|
gitVerb: 'rm',
|
|
stderr: '',
|
|
timeout: shape,
|
|
});
|
|
|
|
assert.equal(result.reason, 'staging_timeout', `shape=${shape}`);
|
|
assert.equal(result.failures[0].timed_out, true, `shape=${shape}`);
|
|
}
|
|
});
|
|
|
|
test('an ordinary non-zero git add is NOT reported as a timeout', () => {
|
|
// Boundary: the timeout carve-out must not swallow the ordinary case.
|
|
const { result } = commitWithFailingAdd({
|
|
cwd: tmpDir,
|
|
files: ['.planning/ARCHITECTURE.md'],
|
|
failFor: ['.planning/ARCHITECTURE.md'],
|
|
});
|
|
|
|
assert.equal(result.reason, 'staging_failed');
|
|
assert.equal(result.failures[0].timed_out, false);
|
|
});
|
|
|
|
// ── Successful staging preserves the current scoped-commit behaviour ──────
|
|
|
|
test('successful staging still commits exactly the declared scope', () => {
|
|
const before = headCount(tmpDir);
|
|
fs.writeFileSync(path.join(tmpDir, 'unrelated-wip.txt'), 'wip\n');
|
|
gitOrThrow(['add', 'unrelated-wip.txt'], { cwd: tmpDir, timeoutMs: STAGING_GIT_TIMEOUT_MS });
|
|
|
|
const { result } = commitWithFailingAdd({
|
|
cwd: tmpDir,
|
|
files: ['.planning/ARCHITECTURE.md', '.planning/CONCERNS.md'],
|
|
failFor: [],
|
|
});
|
|
|
|
assert.equal(result.committed, true, `expected a commit, got ${JSON.stringify(result)}`);
|
|
assert.equal(result.reason, 'committed');
|
|
assert.equal(headCount(tmpDir), before + 1);
|
|
assert.deepEqual(
|
|
committedFiles(tmpDir),
|
|
['.planning/ARCHITECTURE.md', '.planning/CONCERNS.md'],
|
|
'the declared scope must still be honoured, and the unrelated staged file left alone',
|
|
);
|
|
});
|
|
|
|
// ── Missing explicit files keep their existing documented handling ────────
|
|
|
|
test('a missing explicit file is still skipped, not reported as a staging failure', () => {
|
|
// #2014/#2523 behaviour: an explicitly-named file that does not exist is
|
|
// skipped rather than staged as a deletion. It never reaches `git add`, so
|
|
// it is not a staging failure and must not become one.
|
|
const { result } = commitWithFailingAdd({
|
|
cwd: tmpDir,
|
|
files: ['.planning/ARCHITECTURE.md', '.planning/DOES-NOT-EXIST.md'],
|
|
failFor: [],
|
|
});
|
|
|
|
assert.equal(result.committed, true, `expected a commit, got ${JSON.stringify(result)}`);
|
|
assert.deepEqual(committedFiles(tmpDir), ['.planning/ARCHITECTURE.md']);
|
|
});
|
|
|
|
// ── The index must be left clean, not partially staged ───────────────────
|
|
|
|
test('a staging failure rolls back the paths this call had already staged', () => {
|
|
// Without the rollback the paths that DID stage stay in the index with no
|
|
// commit made, so the next bare `git commit` sweeps them up — the same
|
|
// silent partial commit this fix exists to prevent, just deferred a step.
|
|
commitWithFailingAdd({
|
|
cwd: tmpDir,
|
|
files: ['.planning/ARCHITECTURE.md', '.planning/CONCERNS.md', '.planning/CONVENTIONS.md'],
|
|
failFor: ['.planning/CONCERNS.md'],
|
|
});
|
|
|
|
const status = gitOrThrow(['status', '--porcelain'], { cwd: tmpDir, timeoutMs: STAGING_GIT_TIMEOUT_MS });
|
|
const stagedAdds = status.split('\n').filter((l) => /^A[ \t]/.test(l));
|
|
assert.deepEqual(stagedAdds, [],
|
|
`no path may remain staged after a staging failure, status:\n${status}`);
|
|
});
|
|
|
|
test('the rollback does not unstage work the caller had staged before the call', () => {
|
|
// Boundary: the reset must touch only what THIS call staged. Unstaging a
|
|
// path the caller staged themselves would destroy their work.
|
|
fs.writeFileSync(path.join(tmpDir, 'caller-staged.txt'), 'mine\n');
|
|
gitOrThrow(['add', 'caller-staged.txt'], { cwd: tmpDir, timeoutMs: STAGING_GIT_TIMEOUT_MS });
|
|
|
|
commitWithFailingAdd({
|
|
cwd: tmpDir,
|
|
files: ['.planning/ARCHITECTURE.md', '.planning/CONCERNS.md'],
|
|
failFor: ['.planning/CONCERNS.md'],
|
|
});
|
|
|
|
const status = gitOrThrow(['status', '--porcelain'], { cwd: tmpDir, timeoutMs: STAGING_GIT_TIMEOUT_MS });
|
|
assert.match(status, /^A[ \t]+caller-staged\.txt$/m,
|
|
`the caller's own staged file must survive the rollback, status:\n${status}`);
|
|
});
|
|
|
|
// ── The default (non---files) staging path is guarded too ─────────────────
|
|
|
|
test('a failed default-mode git add fails closed instead of committing the index', () => {
|
|
// Default mode stages `.planning/`. Pre-fix a failure there also fell
|
|
// through to an unguarded `git commit`.
|
|
const before = headCount(tmpDir);
|
|
const { result, gitCalls } = commitWithFailingAdd({
|
|
cwd: tmpDir,
|
|
files: undefined,
|
|
failFor: ['.planning/'],
|
|
});
|
|
|
|
assert.equal(result.reason, 'staging_failed');
|
|
assert.ok(!gitCalls.some((a) => a[0] === 'commit'), 'git commit must not run');
|
|
assert.equal(headCount(tmpDir), before);
|
|
});
|
|
|
|
test('a failed default-mode git add blocks --amend too', () => {
|
|
// --amend has no carve-out: amending on top of a failed staging would
|
|
// rewrite the tip without the changes the caller asked for.
|
|
const before = gitOrThrow(['rev-parse', 'HEAD'], { cwd: tmpDir, timeoutMs: STAGING_GIT_TIMEOUT_MS }).trim();
|
|
const { result, gitCalls } = commitWithFailingAdd({
|
|
cwd: tmpDir,
|
|
files: undefined,
|
|
failFor: ['.planning/'],
|
|
amend: true,
|
|
});
|
|
|
|
assert.equal(result.reason, 'staging_failed');
|
|
assert.ok(!gitCalls.some((a) => a[0] === 'commit'), 'git commit --amend must not run');
|
|
assert.equal(
|
|
gitOrThrow(['rev-parse', 'HEAD'], { cwd: tmpDir, timeoutMs: STAGING_GIT_TIMEOUT_MS }).trim(),
|
|
before,
|
|
'HEAD must not be rewritten when staging failed',
|
|
);
|
|
});
|
|
|
|
test('when all explicit files are missing the reason is still nothing_to_commit', () => {
|
|
// The nothing_to_commit path must survive: no `git add` ran, so there is no
|
|
// staging failure to report.
|
|
const { result } = commitWithFailingAdd({
|
|
cwd: tmpDir,
|
|
files: ['.planning/GONE-A.md', '.planning/GONE-B.md'],
|
|
failFor: [],
|
|
});
|
|
|
|
assert.equal(result.reason, 'nothing_to_commit');
|
|
});
|
|
});
|