/** * 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 { 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: 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'); gitOrThrow(['add', '.planning/STATE.md'], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS }); gitOrThrow(['commit', '-m', 'add STATE.md'], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS }); // 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 = gitOrThrow(['diff', 'HEAD~1', 'HEAD', '--name-status'], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS }); } 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 = gitOrThrow(['diff', 'HEAD~1', 'HEAD', '--name-status'], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS }); 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 = gitOrThrow(['diff', 'HEAD~1', 'HEAD', '--name-status'], { cwd: tmpDir, timeoutMs: GIT_TIMEOUT_MS }); } 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' ); }); });