* fix(2112): scope commit to --files pathspec, not entire index cmdCommit/cmdCommitToSubrepo/cmdPrSubrepo staged exactly the files named in --files but then ran a bare 'git commit' with no pathspec, absorbing anything else in the index into a commit whose message described only the named files (#2112). Fix: append '-- ...stagedPaths' to the commit args when the caller declared a scope. Three guards are load-bearing: - stagedPaths (not filesToStage) excludes skipped missing files (#2014) - explicitFiles gate keeps the default .planning/ path byte-identical - MERGE_HEAD check via 'git rev-parse' falls back to bare commit during merge - --amend is left without pathspec (different operation) cmdPrSubrepo pathspec uses changedFiles (old+new for renames) so the full rename is captured atomically. Also fixes workflow markdown in spec-phase.md and add-tests.md. All-files-missing now short-circuits to nothing_to_commit instead of absorbing the entire index under a message describing files that were not committed. * docs(changeset): backfill PR number (#2148) * test: update golden-install-parity fixtures for workflow markdown changes (#2112) * test: update golden fixtures + workflow baselines for #2112 changes - claude-local.json golden fixture (now generated via gen script) - workflow-size-baseline.json (add-tests.md +16, spec-phase.md +42 bytes) - Extended gen-golden-install-parity-zcode.cjs to also regenerate the claude local-layout fixture
177 lines
6.4 KiB
JavaScript
177 lines
6.4 KiB
JavaScript
/**
|
|
* 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',
|
|
);
|
|
});
|
|
});
|