/** * 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', ); }); });