diff --git a/get-shit-done/bin/lib/commands.cjs b/get-shit-done/bin/lib/commands.cjs index e2f754e06..630b47038 100644 --- a/get-shit-done/bin/lib/commands.cjs +++ b/get-shit-done/bin/lib/commands.cjs @@ -313,11 +313,19 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) { } // Stage files - const filesToStage = files && files.length > 0 ? files : ['.planning/']; + const explicitFiles = files && files.length > 0; + const filesToStage = explicitFiles ? files : ['.planning/']; for (const file of filesToStage) { const fullPath = path.join(cwd, file); if (!fs.existsSync(fullPath)) { - // File was deleted/moved — stage the deletion + if (explicitFiles) { + // Caller passed an explicit --files list: missing files are skipped. + // Staging a deletion here would silently remove tracked planning files + // (e.g. STATE.md, ROADMAP.md) when they are temporarily absent (#2014). + continue; + } + // Default mode (staging all of .planning/): stage the deletion so + // removed planning files are not left dangling in the index. execGit(cwd, ['rm', '--cached', '--ignore-unmatch', file]); } else { execGit(cwd, ['add', file]); diff --git a/tests/commit-files-deletion.test.cjs b/tests/commit-files-deletion.test.cjs new file mode 100644 index 000000000..86e4c18c6 --- /dev/null +++ b/tests/commit-files-deletion.test.cjs @@ -0,0 +1,101 @@ +/** + * Regression test for #2014: gsd-tools commit --files silently deletes + * planning files when a filename passed via --files does not exist on disk. + * + * Prior to this fix, when --files STATE.md was passed and STATE.md did not + * exist on disk, the code called `git rm --cached --ignore-unmatch STATE.md` + * which staged and committed a deletion. The caller passed explicit --files + * expecting only those specific files to be staged -- missing files should + * be skipped, not deleted. + */ + +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: missing files must not stage deletions (#2014)', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempGitProject(); + // Commit STATE.md so it exists in git history + fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), '# State\n\nInitial state.\n'); + execSync('git add .planning/STATE.md', { cwd: tmpDir, stdio: 'pipe' }); + execSync('git commit -m "add STATE.md"', { cwd: tmpDir, stdio: 'pipe' }); + // Delete STATE.md from disk -- now missing but tracked in git + fs.unlinkSync(path.join(tmpDir, '.planning', 'STATE.md')); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('passing --files for a missing tracked file does not commit a deletion', () => { + // STATE.md is tracked in git but deleted from disk. + // commit --files .planning/STATE.md should skip it (no deletion committed). + runGsdTools( + ['commit', 'test commit', '--files', '.planning/STATE.md'], + tmpDir + ); + + // Check git log: the new commit (HEAD) must NOT have deleted STATE.md. + // git diff HEAD~1 HEAD --name-status shows what changed between commits. + let diffOutput = ''; + try { + diffOutput = execSync('git diff HEAD~1 HEAD --name-status', { cwd: tmpDir, encoding: 'utf-8' }); + } catch (e) { + // If nothing to commit, there is no HEAD~1 -- that's also acceptable + return; + } + assert.ok( + !diffOutput.includes('D\t.planning/STATE.md'), + 'commit --files must not commit a deletion of a missing file, diff was:\n' + diffOutput + ); + }); + + test('passing --files for a file that exists stages and commits it normally', () => { + // Create ROADMAP.md -- this file exists, should be staged normally + fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), '# Roadmap\n\nPhase 01.\n'); + + const result = runGsdTools( + ['commit', 'add roadmap', '--files', '.planning/ROADMAP.md'], + tmpDir + ); + + const parsed = JSON.parse(result.output); + assert.strictEqual(parsed.committed, true, 'should have committed when file exists'); + + // Verify ROADMAP.md was added in the commit + const diffOutput = execSync('git diff HEAD~1 HEAD --name-status', { cwd: tmpDir, encoding: 'utf-8' }); + assert.ok( + diffOutput.includes('A\t.planning/ROADMAP.md'), + 'ROADMAP.md should appear as added in the commit' + ); + }); + + test('--files with mix of existing and missing files only stages the existing ones', () => { + // ROADMAP.md exists on disk, STATE.md does not + fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), '# Roadmap\n'); + + runGsdTools( + ['commit', 'partial files', '--files', '.planning/ROADMAP.md', '.planning/STATE.md'], + tmpDir + ); + + // The commit must not include a deletion of STATE.md + let diffOutput = ''; + try { + diffOutput = execSync('git diff HEAD~1 HEAD --name-status', { cwd: tmpDir, encoding: 'utf-8' }); + } catch (e) { + return; // nothing committed is fine + } + assert.ok( + !diffOutput.includes('D\t.planning/STATE.md'), + 'missing file in --files list must not be committed as a deletion' + ); + }); +});