/** * 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 { execSync } = require('child_process'); const { createTempGitProject, cleanup, runGsdTools } = require('./helpers.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'); execSync('git add src-wip.txt', { cwd: tmpDir, stdio: 'pipe' }); // 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 = execSync('git diff HEAD~1 HEAD --name-only', { cwd: tmpDir, encoding: 'utf-8', }).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 = execSync('git status --porcelain', { cwd: tmpDir, encoding: 'utf-8', }).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 = execSync('git diff HEAD~1 HEAD --name-only', { cwd: tmpDir, encoding: 'utf-8', }); 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'); execSync('git add .planning/PLAN.md', { cwd: tmpDir, stdio: 'pipe' }); // Also stage an unrelated file. fs.writeFileSync(path.join(tmpDir, 'extra.txt'), 'extra\n'); execSync('git add extra.txt', { cwd: tmpDir, stdio: 'pipe' }); runGsdTools(['commit', 'docs: default commit'], tmpDir); // Default path (no --files) commits everything staged. const diffOutput = execSync('git diff HEAD~1 HEAD --name-only', { cwd: tmpDir, encoding: 'utf-8', }); 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'); execSync('git add .planning/STATE.md', { cwd: tmpDir, stdio: 'pipe' }); execSync('git commit -m "add STATE.md"', { cwd: tmpDir, stdio: 'pipe' }); 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 = execSync('git diff HEAD~1 HEAD --name-status', { cwd: tmpDir, encoding: 'utf-8', }); 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'); execSync('git add .planning/STATE.md', { cwd: tmpDir, stdio: 'pipe' }); execSync('git commit -m "add STATE.md"', { cwd: tmpDir, stdio: 'pipe' }); 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'); execSync('git add extra.txt', { cwd: tmpDir, stdio: 'pipe' }); 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 = execSync('git status --porcelain', { cwd: tmpDir, encoding: 'utf-8', }).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 = execSync('git diff HEAD~1 HEAD --name-only', { cwd: tmpDir, encoding: 'utf-8' }).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 = execSync('git diff HEAD~1 HEAD --name-only', { cwd: tmpDir, encoding: 'utf-8' }) .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 (nothing_to_commit), no index pollution', (t) => { // An absolute path resolving OUTSIDE the project root: git add rejects it → the // gated stagedPaths.push (on git-add exitCode) skips it → nothing_to_commit. No // index pollution (#2523). Not "path_outside_repo" (that guard was removed for // macOS symlink compatibility — the gated push + git's own rejection suffice). 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, 'nothing_to_commit', `out-of-repo: git rejects → nothing_to_commit: ${res.output}`); // No new commit created (still at the single initial commit). const logCount = execSync('git rev-list --count HEAD', { cwd: tmpDir, encoding: 'utf-8' }).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 = execSync('git status --porcelain', { cwd: tmpDir, encoding: 'utf-8' }).trim(); assert.strictEqual(status, '', `index must be clean (no pollution): ${status}`); }); });