fix(#2112): scope commit to --files pathspec, not entire index (#2148)

* 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
This commit is contained in:
Tom Boucher
2026-07-13 00:21:46 -04:00
committed by GitHub
parent 481f00e38a
commit 4bb846b67a
25 changed files with 285 additions and 47 deletions

View File

@@ -694,6 +694,7 @@ function cmdCommit(cwd: string, message: string | undefined, files: string[] | u
// Stage files
const explicitFiles = files && files.length > 0;
const filesToStage = explicitFiles ? files : ['.planning/'];
const stagedPaths: string[] = [];
for (const file of filesToStage) {
const fullPath = path.join(cwd, file);
if (!fs.existsSync(fullPath)) {
@@ -708,12 +709,32 @@ function cmdCommit(cwd: string, message: string | undefined, files: string[] | u
execGit(['rm', '--cached', '--ignore-unmatch', file], { cwd });
} else {
execGit(['add', file], { cwd });
stagedPaths.push(file);
}
}
// Commit (--no-verify skips pre-commit hooks, used by parallel executor agents)
const commitArgs = amend ? ['commit', '--amend', '--no-edit'] : ['commit', '-m', sanitizedMessage as string];
// Commit — when the caller declared a scope (--files), append a pathspec so
// only the declared files land in the commit, not the entire index (#2112).
// The pathspec uses stagedPaths (not filesToStage) so skipped missing files
// are excluded — otherwise git would record them as deletions (#2014).
// During a merge, git refuses partial commits — fall back to a bare commit.
// --amend is left without a pathspec: amending with -- <paths> is a different
// operation that rewrites the tip with only those paths.
if (explicitFiles && stagedPaths.length === 0 && !amend) {
const result = { committed: false, hash: null, reason: 'nothing_to_commit' };
output(result, raw, 'nothing');
return;
}
const isMergeInProgress = execGit(['rev-parse', '-q', '--verify', 'MERGE_HEAD'], { cwd }).exitCode === 0;
const canScope = explicitFiles && stagedPaths.length > 0 && !amend
&& !isMergeInProgress;
const commitArgs = amend
? ['commit', '--amend', '--no-edit']
: ['commit', '-m', sanitizedMessage as string];
if (noVerify) commitArgs.push('--no-verify');
if (canScope) {
commitArgs.push('--', ...stagedPaths);
}
const commitResult = execGit(commitArgs, { cwd });
if (commitResult.exitCode !== 0) {
if (commitResult.stdout.includes('nothing to commit') || commitResult.stderr.includes('nothing to commit')) {
@@ -817,13 +838,22 @@ function cmdCommitToSubrepo(cwd: string, message: string | undefined, files: str
const repoCwd = path.join(cwd, repo);
// Stage files (strip sub-repo prefix for paths relative to that repo)
const stagedRelPaths: string[] = [];
for (const file of repoFiles) {
const relativePath = file.slice(repo.length + 1);
execGit(['add', relativePath], { cwd: repoCwd });
const addResult = execGit(['add', relativePath], { cwd: repoCwd });
if (addResult.exitCode === 0) {
stagedRelPaths.push(relativePath);
}
}
// Commit
const commitResult = execGit(['commit', '-m', message as string], { cwd: repoCwd });
// Commit — pathspec limits the commit to the staged files only (#2112)
const isMergeInProgressSub = execGit(['rev-parse', '-q', '--verify', 'MERGE_HEAD'], { cwd: repoCwd }).exitCode === 0;
const canScopeSub = stagedRelPaths.length > 0 && !isMergeInProgressSub;
const commitArgs = canScopeSub
? ['commit', '-m', message as string, '--', ...stagedRelPaths]
: ['commit', '-m', message as string];
const commitResult = execGit(commitArgs, { cwd: repoCwd });
if (commitResult.exitCode !== 0) {
if (commitResult.stdout.includes('nothing to commit') || commitResult.stderr.includes('nothing to commit')) {
repos[repo] = { committed: false, hash: null, files: repoFiles, reason: 'nothing_to_commit' };
@@ -969,8 +999,16 @@ function cmdPrSubrepo(
}
}
// 5. Commit
const commitResult = execGit(['commit', '-m', commitMessage as string], { cwd: repoCwd });
// 5. Commit — pathspec limits the commit to the staged files only (#2112).
// changedFiles includes both old and new paths for renames so the full
// rename is captured atomically (pathspec on newPath alone would leave the
// deletion of oldPath stranded in the index).
const isMergeInProgressPr = execGit(['rev-parse', '-q', '--verify', 'MERGE_HEAD'], { cwd: repoCwd }).exitCode === 0;
const canScopePr = changedFiles.length > 0 && !isMergeInProgressPr;
const commitArgs = canScopePr
? ['commit', '-m', commitMessage as string, '--', ...changedFiles]
: ['commit', '-m', commitMessage as string];
const commitResult = execGit(commitArgs, { cwd: repoCwd });
if (commitResult.exitCode !== 0) {
rollback();
error(`Failed to commit in ${repo}: ${commitResult.stderr}`);