* fix(#4454): surface skipped explicit --files paths instead of silent success cmdCommit's staging loop deliberately skips a --files path that no longer exists on disk when the caller passed --files explicitly (#2014 guards against staging an unwanted deletion for a temporarily-absent file), but recorded nothing about the skip. The final result reported unqualified committed: true, so a caller had no way to distinguish "everything in --files landed" from "some paths were silently excluded because they didn't exist" -- a real file deletion meant to be committed (e.g. phase.complete removing .planning/milestone.lock) would silently never land, with git status still showing it unstaged after a "successful" commit. Tracks skipped paths in a skippedFiles array and surfaces them as skipped_files (matching this result family's existing snake_case precedent, timed_out) in the success result AND the nothing_to_commit result (reachable when every named path was missing), included only when non-empty so the common case's payload shape is unchanged. Also extended to the SECOND nothing_to_commit result (reached when git itself reports "nothing to commit" after the nothingToCommit guard was false -- the residual partial-skip + partialCommitRefused window the surrounding comments already document) for the same consistency. The #2014 staging/deletion guard itself is untouched -- purely a visibility fix, exactly as the issue requested. Regression tests cover: existing-plus-missing file (the issue's own repro shape, with the missing path genuinely tracked-then-deleted so the #2014 assertion is meaningful, not vacuous against an never-tracked path); only-existing files (no skipped_files key at all); only-missing files (nothing_to_commit with skipped_files); default mode unaffected; and multiple missing files reported in order. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * fix: recover ack trailers buried by squash-merge concatenation Discovered while verifying an unrelated PR (#4454): tests/emitted-attribution.test.cjs failed citing pr-branch.md's growth as unacknowledged, even though PR #4537 (#4447) had genuinely acked it via an Emitted-Drift-Ack-Growth commit trailer. Root cause: git's %(trailers:...) placeholder (used by readAckTrailers) only recognizes a trailer block that is the TRUE TERMINAL block of a commit message. GitHub's squash-merge commit body concatenates every constituent commit's subject+body in order, then unconditionally appends its own `---------` separator and Co-authored-by trailers. Confirmed directly against a real squash commit: %(trailers) returns ONLY GitHub's own two Co-authored-by lines -- not even the LAST original commit's own trailer survives, since GitHub's appended suffix breaks the backward scan before it ever reaches past `---------` to any original commit's content, last or not. An ack trailer added on any non-final commit of a PR (the common case -- more commits routinely land after an ack, e.g. a lint fix or a changeset) is therefore silently invisible forever to any future comparison against a base predating that squash. Fix: readAckTrailers now runs a second pass (extractSquashBuriedTrailers) alongside the existing whole-message read. For each commit in range, if its raw body contains GitHub's squash-suffix signal, the pre-suffix text (everything before the LAST such signal -- an earlier bullet's own body may legitimately contain a markdown horizontal rule that looks the same, so anchoring on the first occurrence would truncate too early and miss a later bullet's real ack) is split on squash-bullet (`* <subject>`) boundaries, and each chunk is independently trailer-parsed via `git interpret-trailers --parse` -- the same underlying algorithm as %(trailers:...), but runnable against arbitrary text rather than only a real commit object. This finds a trailer buried in ANY bullet, not just the last one. Scoped tightly to avoid reintroducing the false-positive class %(trailers:...) was originally chosen to prevent (a mid-body MENTION of trailer syntax must stay inert): the sub-chunk pass activates only on commits matching the squash-suffix signal, so an ordinary commit whose body happens to contain markdown bullets is completely unaffected, and each chunk still goes through git's own strict per-chunk terminal-block detection. Verified end-to-end against a real squash commit (recovers the buried trailer) and four adversarial fixtures now pinned as regression tests: ordinary bullet prose with no squash suffix (stays empty); a squash-shaped commit where one bullet's body merely mentions trailer syntax mid-paragraph (stays inert, the "row 32" false-positive class, now verified at per-chunk granularity); and an earlier bullet's own markdown horizontal rule not truncating the scan before a later bullet's real ack (the last-match-not-first-match case an isolated review pass caught during this same fix). This overrides one-concern-per-PR per CLAUDE.md's Defects & Warnings policy -- a genuine defect discovered mid-work is fixed inline, not deferred to a separate issue (spawn_task for this was correctly blocked by the no-defer guard). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> * docs(#4454): backfill changeset PR number Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1727,6 +1727,10 @@ function cmdCommit(cwd: string, message: string | undefined, files: string[] | u
|
||||
// a linked worktree, timeout) was discarded and the operator saw a downstream
|
||||
// pathspec error pointing at an innocent file.
|
||||
const stagingFailures: Array<{ file: string; error: string; timed_out: boolean }> = [];
|
||||
// #4454: explicit --files paths skipped because they were missing from disk
|
||||
// (the #2014 guard below). Tracked so the caller can tell a partial commit
|
||||
// from a complete one instead of an unqualified `committed: true`.
|
||||
const skippedFiles: string[] = [];
|
||||
// Paths already in the index BEFORE this call. On a staging failure the
|
||||
// rollback below unstages only what THIS call added — unstaging a path the
|
||||
// caller had staged themselves would destroy their work.
|
||||
@@ -1741,6 +1745,9 @@ function cmdCommit(cwd: string, message: string | undefined, files: string[] | u
|
||||
// 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).
|
||||
// #4454: record what was skipped so the caller can tell a partial
|
||||
// commit from a complete one, instead of an unqualified success.
|
||||
skippedFiles.push(file);
|
||||
continue;
|
||||
}
|
||||
// Default mode (staging all of .planning/): stage the deletion so
|
||||
@@ -2041,7 +2048,15 @@ function cmdCommit(cwd: string, message: string | undefined, files: string[] | u
|
||||
).exitCode === 0
|
||||
&& !assumeUnchangedWouldRecord()));
|
||||
if (nothingToCommit) {
|
||||
const result = { committed: false, hash: null, reason: 'nothing_to_commit' };
|
||||
// #4454: an explicit --files list where every named path was missing
|
||||
// reaches this branch via `stagedPaths.length === 0` above — surface
|
||||
// which path(s) were the reason, same as the success result below.
|
||||
const result = {
|
||||
committed: false,
|
||||
hash: null,
|
||||
reason: 'nothing_to_commit',
|
||||
...(skippedFiles.length > 0 ? { skipped_files: skippedFiles } : {}),
|
||||
};
|
||||
output(result, raw, 'nothing');
|
||||
return;
|
||||
}
|
||||
@@ -2110,7 +2125,18 @@ function cmdCommit(cwd: string, message: string | undefined, files: string[] | u
|
||||
return;
|
||||
}
|
||||
if (commitResult.stdout.includes('nothing to commit') || commitResult.stderr.includes('nothing to commit')) {
|
||||
const result = { committed: false, hash: null, reason: 'nothing_to_commit' };
|
||||
// #4454: this is the residual window the surrounding comments already
|
||||
// document (a partial skip + partialCommitRefused bypassing the diff
|
||||
// probe + git's own empty-commit refusal) — skippedFiles can be
|
||||
// non-empty here too, and omitting it would be the same misreport
|
||||
// this fix exists to close, just on the other branch that reaches
|
||||
// "nothing to commit".
|
||||
const result = {
|
||||
committed: false,
|
||||
hash: null,
|
||||
reason: 'nothing_to_commit',
|
||||
...(skippedFiles.length > 0 ? { skipped_files: skippedFiles } : {}),
|
||||
};
|
||||
output(result, raw, 'nothing');
|
||||
return;
|
||||
}
|
||||
@@ -2127,7 +2153,15 @@ function cmdCommit(cwd: string, message: string | undefined, files: string[] | u
|
||||
// Get short hash
|
||||
const hashResult = execGit(['rev-parse', '--short', 'HEAD'], { cwd });
|
||||
const hash = hashResult.exitCode === 0 ? hashResult.stdout : null;
|
||||
const result = { committed: true, hash, reason: 'committed' };
|
||||
// #4454: report explicit --files paths that were skipped as missing (the
|
||||
// #2014 guard above) so a caller can tell a partial commit from a complete
|
||||
// one, without changing the payload shape when nothing was skipped.
|
||||
const result = {
|
||||
committed: true,
|
||||
hash,
|
||||
reason: 'committed',
|
||||
...(skippedFiles.length > 0 ? { skipped_files: skippedFiles } : {}),
|
||||
};
|
||||
output(result, raw, hash || 'committed');
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user