/** * Regression test for #2112: msd-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 fc = require('fast-check'); const fs = require('fs'); const path = require('path'); const os = require('os'); const { spawnSync } = require('child_process'); const { createTempGitProject, cleanup, runMsdTools } = require('./helpers.cjs'); const { execFileSync } = require('node:child_process'); const { gitOrThrow } = require('./helpers/git-fixture.cjs'); // #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs. const { GIT_TIMEOUT_MS, PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs'); const { bareCommandName, tokenize, shellDashCPayloads, commentPortion, ISSUE_REF_RE, declarationReason, isDeclared, isUntrackedDeclaration, } = require('./helpers/shipped-command-scan.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 }); // MSD writes and commits a planning artifact, naming ONLY that file. fs.writeFileSync(path.join(tmpDir, '.planning', 'PLAN.md'), '# Plan\n'); runMsdTools( ['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'); runMsdTools( ['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 }); runMsdTools(['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'); runMsdTools( ['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 = runMsdTools( ['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', ); }); // ── #4454: skipped-missing --files paths are reported, not silently dropped ── test('#4454: --files with one existing + one missing file reports skipped_files and does not commit the deletion', () => { // Mirrors the issue's own repro shape: an existing modified file alongside // a missing tracked one (e.g. milestone.lock + state.json). state.json // must be TRACKED then removed from disk — an untracked-and-missing path // would make the #2014 assertion below vacuous, since `git rm --cached` // on a never-tracked path is a no-op with or without the skip guard. fs.writeFileSync(path.join(tmpDir, '.planning', 'milestone.lock'), 'a\n'); fs.writeFileSync(path.join(tmpDir, '.planning', 'state.json'), '{}\n'); gitOrThrow(['add', '.planning/milestone.lock', '.planning/state.json'], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS }); gitOrThrow(['commit', '-m', 'seed milestone.lock and state.json'], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS }); fs.writeFileSync(path.join(tmpDir, '.planning', 'milestone.lock'), 'b\n'); fs.unlinkSync(path.join(tmpDir, '.planning', 'state.json')); const res = runMsdTools( ['commit', 'docs: update milestone', '--files', '.planning/milestone.lock', '.planning/state.json'], tmpDir, ); const parsed = JSON.parse(res.output); assert.strictEqual(parsed.committed, true, `expected a commit: ${res.output}`); assert.ok(typeof parsed.hash === 'string' && parsed.hash.length > 0, 'hash must be a non-empty string'); assert.deepStrictEqual( parsed.skipped_files, ['.planning/state.json'], `skipped_files must name exactly the missing path: ${res.output}`, ); // #2014: state.json was TRACKED and is now missing from disk — the skip // guard must leave its deletion unstaged, not just leave an // already-untracked path alone (which would prove nothing). const diffNameStatus = gitOrThrow(['diff', 'HEAD~1', 'HEAD', '--name-status'], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS, }); assert.ok( !diffNameStatus.includes('state.json'), `no deletion of the still-tracked-on-disk-missing file may be recorded: ${diffNameStatus}`, ); const lsTree = gitOrThrow(['ls-tree', '-r', '--name-only', 'HEAD'], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS }); assert.ok( lsTree.includes('.planning/state.json'), `state.json must still be tracked at HEAD — its deletion must not have been committed: ${lsTree}`, ); }); test('#4454: --files with only existing files omits skipped_files entirely', () => { fs.writeFileSync(path.join(tmpDir, '.planning', 'PLAN.md'), '# Plan\n'); fs.writeFileSync(path.join(tmpDir, '.planning', 'RESEARCH.md'), '# Research\n'); const res = runMsdTools( ['commit', 'docs: artifacts only', '--files', '.planning/PLAN.md', '.planning/RESEARCH.md'], tmpDir, ); const parsed = JSON.parse(res.output); assert.strictEqual(parsed.committed, true, `expected a commit: ${res.output}`); assert.ok( !('skipped_files' in parsed), `no skipped_files key may be present when nothing was skipped: ${res.output}`, ); }); test('#4454: --files naming only missing file(s) reports nothing_to_commit with skipped_files', () => { 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')); const res = runMsdTools( ['commit', 'docs: try', '--files', '.planning/STATE.md'], tmpDir, ); const parsed = JSON.parse(res.output); assert.strictEqual(parsed.committed, false); assert.strictEqual(parsed.reason, 'nothing_to_commit'); assert.deepStrictEqual( parsed.skipped_files, ['.planning/STATE.md'], `an all-missing --files list reaches nothing_to_commit via stagedPaths.length === 0; ` + `skipped_files must still name the reason: ${res.output}`, ); }); test('#4454: default mode (no --files) with a missing tracked file is unaffected — no skipped_files anywhere', () => { 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')); fs.writeFileSync(path.join(tmpDir, '.planning', 'PLAN.md'), '# Plan\n'); const res = runMsdTools(['commit', 'docs: default mode'], tmpDir); const parsed = JSON.parse(res.output); assert.strictEqual(parsed.committed, true, `expected a commit: ${res.output}`); assert.ok(!('skipped_files' in parsed), `default mode never sets explicitFiles: ${res.output}`); // The deletion IS staged and committed as before — explicitFiles is false here. const diffNameStatus = gitOrThrow(['diff', 'HEAD~1', 'HEAD', '--name-status'], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS, }); assert.ok( diffNameStatus.includes('D\t.planning/STATE.md'), `default mode must still stage/commit the deletion: ${diffNameStatus}`, ); }); test('#4454: --files naming two missing files reports both, in the order they were named', () => { fs.writeFileSync(path.join(tmpDir, '.planning', 'PLAN.md'), '# Plan\n'); const res = runMsdTools( ['commit', 'docs: mixed missing', '--files', '.planning/PLAN.md', '.planning/GONE-A.md', '.planning/GONE-B.md'], tmpDir, ); const parsed = JSON.parse(res.output); assert.strictEqual(parsed.committed, true, `expected a commit: ${res.output}`); assert.deepStrictEqual( parsed.skipped_files, ['.planning/GONE-A.md', '.planning/GONE-B.md'], `both missing paths must be reported in the order named: ${res.output}`, ); }); 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 = runMsdTools(['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 = runMsdTools( ['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, '..', `msd-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 = runMsdTools( ['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; /** * `commitWithFailingAdd`/`subrepoCommitWithFailingAdd` below spawn * `process.execPath -e