fix(#3776): decide nothing-to-commit from the staged diff, not from staging success (#3859)

* fix(#3776): decide nothing-to-commit from the staged diff, not from staging success

`cmdCommit`'s empty-diff guard tested `stagedPaths.length === 0`, but
`stagedPaths` records paths whose `git add` exited 0 — "did staging
succeed", not "is there anything to commit". Staging an already-committed,
unmodified file succeeds while contributing no diff, so the guard was
reachable only when every named path was missing from disk.

The ordinary empty-diff case therefore fell through to `git commit`, where
the only thing converting the failure back to `nothing_to_commit` was a
string match on git's output. Git runs the pre-commit hook before it
decides there is nothing to commit, so a rejecting hook pre-empted that
match and the caller was handed `commit_failed` carrying a gate message
about a commit that had nothing to gate.

Ask git whether the staged paths actually differ instead. Two conjuncts
are load-bearing: the `length === 0` short-circuit keeps the
all-missing-paths case exact (a pathspec-less `diff --cached` would test
the whole index, so unrelated staged work would suppress the guard), and
`!isMergeInProgress` keeps a merge from being abandoned — during a merge
git refuses a partial commit, so the pathspec describes nothing about what
would land.

Nine regression cases in tests/commands.test.cjs cover the brief's six
acceptance criteria plus the merge interaction. Against the pre-fix build
exactly one fails; the other eight pin behaviour that was already correct.

Residual sibling of #2608/#2693, which covered `git add` failing; this
covers `git add` succeeding and contributing nothing.

* fix(#3776): exempt a cherry-pick too, but never a revert

The empty-diff guard must not decide from a pathspec git will not honour.
That test was merge-only; git refuses a partial commit during a cherry-pick
for the same reason, so the guard would have fired there and reported a
silent `nothing_to_commit` where the pre-fix code surfaced git's refusal.

The three sequencer states do not agree, so this is driven rather than
reasoned by analogy (git 2.54):

  MERGE_HEAD        fatal: cannot do a partial commit during a merge.
  CHERRY_PICK_HEAD  fatal: cannot do a partial commit during a cherry-pick.
  REVERT_HEAD       permitted; behaves like an ordinary commit.

REVERT_HEAD is therefore deliberately excluded: enumerating it alongside the
other two — the obvious move — would suppress this fix during a revert and
reintroduce the very misreport it removes. Both new states are pinned by a
test, and the revert arm fails against the pre-fix build exactly as AC1 does.

`canScope` keeps its narrower merge-only test on purpose; widening it would
change pre-existing cherry-pick behaviour, which is outside this fix.

* fix(#3776): probe the working tree, not the index

`git commit -- <paths>` is a PARTIAL commit: it records the working-tree
content of those paths and ignores what is staged. The guard was probing
`git diff --cached` — the index — which answers a different question than
the commit asks.

Driven on the same path, in this order: `git add` an unmodified file, then
write to it, then probe.

  git diff --cached --quiet -- p   rc 0   ("nothing staged")
  git diff --quiet HEAD -- p       rc 1   ("the tree differs")
  git commit -m m -- p             committed the new content

So a working-tree write landing between the `git add` above and the probe —
another process in a shared checkout, which this project explicitly supports
— would let the guard report `nothing_to_commit` for a call that would have
recorded that content. Probing `HEAD` asks the question the commit answers.

Not reachable through this function single-threaded, because the staging
loop re-adds every named path immediately beforehand, so index and working
tree agree at the probe. The change is correctness by construction rather
than a fix for an observed miscommit.

An unborn HEAD makes `diff HEAD` fatal; that falls through to the commit as
any other probe error does, and is now pinned by a test — the first commit
in a repo must not be swallowed by an empty-diff guard.

Both added probes are now gated on the guard being able to fire at all, so
an unscoped commit and an `--amend` pay for neither.

Found by adversarial pre-filing review; the index/worktree distinction was
not something my own path-shape probes could have surfaced.

* test(#3776): pin the assume-unchanged boundary; correct a stale comment

`git update-index --assume-unchanged` makes `git add` stage nothing and makes
BOTH diff forms — `--cached` and `HEAD` — report no difference, so no
diff-based guard can see a change to such a path. `git commit -- <path>` is
the odd one out: it reads the working tree directly and records it.

So a modified assume-unchanged path now reports `nothing_to_commit` where it
previously committed. That is the answer consistent with this function's own
staging step, which honoured the flag one loop earlier — but it is a
behaviour change, and it belongs on the record as a decision rather than
surfacing later as a surprise.

Also corrects an AC3 comment still describing the `diff --cached` whole-index
form that the previous commit replaced.

* chore(#3776): set changeset fragment pr to 3859

The fragment carries the PR's own number, which is unknowable before the PR
exists. Backfilled post-create; the repo's changeset lint rejects the `pr: 0`
placeholder.

* fix(#3776): do not read an unanswered sequencer probe as "no merge"

`execGit` surfaces a spawn timeout as `exitCode: 1` (`_spawnResult`:
`result.status ?? 1`) — the same code `rev-parse --verify` returns for a ref
that does not exist. So the MERGE_HEAD and CHERRY_PICK_HEAD probes could not
tell "not in that state" from "never answered", and the empty-diff guard read
both as "not in that state". That is the one path in #3776 that did not fail
toward the previous behaviour: a timeout during a real merge decided
`nothing_to_commit` from a pathspec git will not honour and left the merge
unconcluded, where before it was a loud `commit_failed`.

Treat an unanswered probe as "assume the partial commit would be refused" —
which falls through to `git commit` and lets git speak for itself.

Routed into `partialCommitRefused` only, deliberately never into
`isMergeInProgress`. That flag also feeds the pre-existing `canScope`, and
widening it there is worse than the misreport it fixes: with `canScope` false
the commit runs bare, and a bare commit during a merge is PERMITTED — git
concludes the merge with the whole index under a message naming one file.
Driven: the whole-flag form reports `committed` where this form reports
`commit_failed`, and it drops the pathspec on the ordinary timeout, re-opening
the #2112 scope leak.

* fix(#3776): pin the empty-diff probe against diff-only configuration

`git diff` is porcelain and honours settings `git commit -- <paths>` does not,
so an unpinned probe let a caller's configuration decide whether the guard
fires. Driven against git 2.54, each with the paired `git commit -- <path>`
confirmed to record the change the unpinned probe reported as absent:

  diff.ignoreSubmodules=all      a gitlink bump is invisible to the probe
  .gitmodules  ignore = all      the same, and it needs NO local config at
                                 all — it is checked in, so it arrives with
                                 a clone
  diff=<driver> + textconv       two different blobs converge to one text,
                                 so the probe sees no change; no submodule
                                 involved

`--ignore-submodules=dirty` rather than `=none`, because `dirty` is what a
partial commit of a submodule path actually means: it records the gitlink,
which moves only when the submodule's HEAD does. Under `=none` a merely dirty
submodule work tree reports a difference the commit would not record, sending
an empty call back to `git commit` — the same misreport, re-entered from the
other side. `dirty` still overrides both `diff.ignoreSubmodules` and a
checked-in `.gitmodules` `ignore`, so the gitlink vectors stay closed.

`--no-ext-diff` is deliberately absent: `--quiet` short-circuits ahead of an
external diff driver, so `diff.<driver>.command` cannot invert the probe
(driven: rc 1 with and without the flag).

* docs(#3776): disclose the two outcome changes the changeset omitted

The body listed what stays unchanged and never named the arms whose
user-visible outcome moves, so neither would have reached the changelog:

  - a modified path under `git update-index --assume-unchanged` now reports
    `nothing_to_commit` where it was previously committed. `git add` already
    honoured the flag one loop earlier; the guard reports what staging did.
    Documented in a code comment and pinned by a test since the first round,
    but absent from the fragment.
  - naming a submodule whose work tree is dirty while its recorded commit has
    not moved now reports `nothing_to_commit` rather than `commit_failed`,
    because nothing would have landed. New in this round, from the
    `--ignore-submodules=dirty` pin.

* test(#3776): use helpers.cleanup() for the submodule fixture teardown

`local/no-raw-rmsync-in-tests` rejects a bare `fs.rmSync` in a test: the
helper carries the Windows-EBUSY retry budget (`maxRetries`/`retryDelay`)
that a raw call does not, and a submodule work tree is exactly the shape
that holds handles open on Windows.

Caught by CI, not locally — the round ran the two affected suites but not
`npm run lint:ci`, so the repo's own rule never fired until the push. The
chain now exits 0 locally against this tree.

* fix(#3776): never drop a named assume-unchanged path

`--assume-unchanged` is the one state where `git diff` and
`git commit -- <paths>` genuinely disagree: `git add` stages nothing,
both diff forms report no difference, and `git commit -- <path>` still
reads the working tree and records it. The empty-diff guard therefore
reported `nothing_to_commit` about content the caller named in `--files`
and git would have written.

commit is made — so suppressing its misreport must not be paid for by a
silent drop. Same rule the timeout routing already follows: a fix for a
misreport may not cost content.

The guard now asks `git commit --dry-run --porcelain` whether the commit
would record anything, and stands aside on rc 0. That is the same
decision the real commit makes, so there is no second implementation of
it to drift. It does not run the `pre-commit` hook (driven: a rejecting
one neither fires nor writes a marker), which is what matters — a firing
`pre-commit` is the whole of #3776. It is NOT hook-free in general: git
2.54 fires `post-index-change` here, so a repo using that hook sees it
once for the probe and once for the commit. Stated rather than claimed
away.

Asking git rather than reconstructing its answer was reached by
measurement. Comparing `git hash-object` against `HEAD:<path>` was tried
and is wrong three ways, each a silent drop of named content: it misses a
mode-only change (`chmod +x` leaves the blob identical while the commit
records `100755`); it cannot hash a submodule path at all (`fatal: Unable
to hash sub`, while the commit advances the gitlink); and the path it
needs must be parsed out of `ls-files` output, which `core.quotePath`
renders as `"caf\303\251.md"` by default. Each has its own arm, and the
non-ASCII arm pins `core.quotePath` so it cannot go vacuous.

Falling through on the `ls-files` tag alone — without asking whether
anything would land — is also wrong: an UNMODIFIED assume-unchanged path
would reach `git commit`, which with any unrelated modified file present
prints `no changes added to commit`, a string the fallback does not
match, and returns `commit_failed`. That is #3776 re-entered from the
other side, the same shape `--ignore-submodules=none` would have
re-entered it. Pinned by its own arm.

The `ls-files` read is an optimisation, not a gate: it keeps the dry run
off the hot path when no assume-unchanged entry is present, and when it
cannot answer the dry run simply runs, because the dry run needs nothing
from it. Failing closed there would drop content and failing open would
re-enter #3776 — both are wrong answers to a question that can be asked
directly.

`--skip-worktree` is not a second instance. A present, modified one exits
1 from `git add` and fails closed as `staging_failed` above the guard; an
absent one is skipped before `git add` runs (#2014) and is answered by
the `stagedPaths.length === 0` arm, exactly as it was pre-fix. Both
shapes pinned, because the shorter claim ("never reaches the guard") is
too strong.

* test(#3776): register fixture teardown so a failed assertion cannot leak

`bumpedSubmodule()` creates its sub-repo as a SIBLING of `tmpDir`, and
the unborn-HEAD arm creates `fresh` outside it too, so the describe's
`afterEach(() => cleanup(tmpDir))` reaches neither. Both were cleaned by
a trailing statement in the test body, which any failing assertion above
it skips — leaking a git repo into the temp root.

`bumpedSubmodule()` now records the path and a describe-scoped
`afterEach` drains it, which covers all three of its callers at once;
the unborn-HEAD arm takes `t.after`, the form already used elsewhere in
this file.

Negative-controlled both ways with a deliberate assertion failure
injected into the dirty-submodule arm, under an overridden TMPDIR:
before, one `*-sub` repo survives the run; after, none.

* fix(#3776): never read an unanswered dry-run probe as "nothing to record"

The `git commit --dry-run --porcelain` probe that decides the assume-unchanged
boundary is the one probe in the guard whose rc 0 is the reassuring answer, so
it inverts the diff probe's safety: `execGit` collapses a spawn timeout (or any
spawn error) to `exitCode: 1`, byte-identical to git's own "nothing to record",
and the guard then reported `nothing_to_commit` about content named in
`--files` that git was never asked to write. Same conflation the sequencer
probes already defend against.

Only a CONFIRMED rc 1 with no spawn error closes the path now; a timeout, a
spawn error, or rc 128 falls toward the real commit, where git speaks for
itself. Five arms in tests/commit-files-pathspec.test.cjs pin it (posix +
windows timeout shapes, rc 128, the ls-files optimisation's own timeout, and a
negative control on an unmodified path); the injection helper gains an optional
`matchArg` so the dry run can be targeted without intercepting the real commit.

Also corrects the comment that claimed both sequencer probes are gated on
`guardApplies` — the MERGE_HEAD probe predates this fix and is unconditional.

* fix(#3776): probe with --no-verify so a hook-firing git cannot close the guard

Round 4, review finding 3 (Minor). The `git commit --dry-run --porcelain`
probe's safety rested on an empirical claim about one git version: that
`--dry-run` does not run `pre-commit`. git 2.54 satisfies it, but the failure a
differing version would produce is silent and lands in exactly #3776's own
configuration.

A `pre-commit` that fires and rejects exits 1 — the same code git returns for
"nothing to record" — so the closure would read it as a CONFIRMED empty answer,
drop the content the caller named in `--files`, and report `nothing_to_commit`.
That is #3776 re-entered through the probe the fix added.

`--no-verify` forecloses it structurally rather than documenting the version
dependency. Driven on git 2.54: rc-identical in both directions (rc 0
would-record, rc 1 nothing) with and without the flag, so it is behaviour-
neutral where the version already agrees.

Two claims deliberately NOT widened: `--no-verify` does not suppress
`post-index-change`, which still fires on this call with or without it (driven
both ways); and the real `git commit` is untouched — #3776 is a bug about a
hook's message reaching the caller wrongly, never a licence to skip hooks.

The new arm pins the FLAG rather than an outcome, because the outcome it
protects is unobservable on a git that already declines to run the hook. It is
a seam assertion over the argv the guard actually issued, not a source grep.

* test(#3776): pin all-missing --files during a merge or cherry-pick

Round 4, review finding 1 (Major) and finding 7 (Nit, its coverage half). The
review asks for the `stagedPaths.length === 0` disjunct to be gated on
`!partialCommitRefused`, or for the combination to be documented and tested.
Documented and tested — the gating is refused, with cause.

The premise is confirmed: the state is reachable exactly as described, and
during a merge the `nothing_to_commit` report does not tell the caller the merge
is still open. The prescription is not. With every named path missing,
`stagedPaths` is empty, so `canScope` is false and the fall-through reaches a
BARE `git commit`, which git PERMITS during a merge and which then CONCLUDES it.

Driven, git 2.54, through cmdCommit with the prescription applied:

  cmdCommit(cwd, 'add the thing', ['.planning/never-produced.md'])
  -> { "committed": true, "hash": "8e6bf45", "reason": "committed" }
     MERGE_HEAD gone; HEAD is a 2-parent merge commit recording
     .planning/shared.md with the caller's resolution content.

So the gating trades a report that writes nothing for one that silently writes
the whole index under a message naming a path that does not exist, and reports
success. That is the same trade the timeout routing already refuses one block
up, which is why the sequencer states gate the DIFF branch only.

The behaviour is also pre-existing and unchanged by this PR: at 86452da7 the
identical short-circuit sat ABOVE the MERGE_HEAD probe, so it never consulted
the sequencer either. The residual — a merge held open behind a
`nothing_to_commit` report — is offered as a separate issue alongside the three
already deferred, not folded into this fix.

These are behaviour pins, not regression tests: they pass at base and red on the
gated implementation (both arms, verified).

* docs(#3776): state the git-version provenance once, not at three claims

Round 4, review finding 6 (Nit). The guard carries ~159 comment lines around 32
lines of executable logic, and the review's specific complaint is that "driven
against git 2.54" is repeated near-verbatim in three places, which makes the
decision tree harder to scan.

Hoists the provenance to a single block header and reduces the three repeats to
the observation each actually carries. One claim keeps its version explicitly
and now says why: the `--no-verify` reasoning is version-SENSITIVE rather than
merely version-observed, so it is the one place the version is load-bearing
instead of incidental.

The behavioural matrix stays inline rather than moving to an ADR or a doc block.
Every claim in it is a constraint on the four flags immediately below it, and the
value of having it here is that the next reader who wants to "simplify" one of
those flags meets the driven counter-example in the same screen. Splitting the
constraint from the code it constrains is how the flags get dropped.

Comments only. No behaviour change; suite and lint:ci unchanged.

* docs(#3776): correct two driven figures in the new guard commentary

Both found by this round's own pre-push adversarial review, and both re-driven
before adopting.

1. The empty-paths rationale said a bare commit during a merge produces a
   "three-parent commit". It produces a TWO-parent merge commit. Three was the
   token count of `git rev-list --parents -n1 HEAD` (commit + two parents) read
   as a parent count. `git cat-file -p HEAD | grep -c '^parent '` returns 2.
   This round's commit message for the pins already said two, so the tree
   contradicted itself.

2. The `post-index-change` disclosure said a repo using that hook "sees it once
   for the probe and once for the commit". Driven with a counting hook: git
   fires it TWICE per `git commit --dry-run`, and twice again for the real
   commit — 2/2/2 across the flagged probe, the unflagged probe and the real
   commit. The disclosure understated the cost by half in both halves.

Comments only. No behaviour change; suite 351/351 and lint:ci unchanged.

---------

Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
This commit is contained in:
0xdhx
2026-09-01 12:59:11 -05:00
committed by GitHub
parent 41466e8e88
commit 900504f985
4 changed files with 1165 additions and 4 deletions

View File

@@ -1807,12 +1807,236 @@ function cmdCommit(cwd: string, message: string | undefined, files: string[] | u
// 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 mergeHeadProbe = execGit(['rev-parse', '-q', '--verify', 'MERGE_HEAD'], { cwd });
const isMergeInProgress = mergeHeadProbe.exitCode === 0;
// PROVENANCE FOR THIS WHOLE BLOCK: every behavioural claim below was DRIVEN
// against git 2.54, not reasoned by analogy. Individual claims state what was
// observed and omit the version; where a claim is version-SENSITIVE rather
// than merely version-observed, it says so at the claim.
//
// #3776: git refuses a PARTIAL commit (`git commit -- <paths>`) while a merge
// or a cherry-pick is in progress, so in those states the pathspec describes
// nothing about what would actually land and the empty-diff decision below
// must not be made from it. The three sequencer states do NOT agree:
// MERGE_HEAD -> `fatal: cannot do a partial commit during a merge.`
// CHERRY_PICK_HEAD -> `fatal: cannot do a partial commit during a cherry-pick.`
// REVERT_HEAD -> permitted; behaves like an ordinary commit.
// REVERT_HEAD is therefore deliberately absent: including it would suppress
// this fix during a revert, reintroducing the very misreport it removes.
// `canScope` below keeps its narrower merge-only test on purpose — widening it
// would change pre-existing cherry-pick behaviour, which is outside this fix.
// Only the scoped, non-amend call can return through the guard below, so the
// cherry-pick probe and the guard's own probes are gated on that — an
// unscoped commit or an --amend would otherwise pay for git invocations whose
// answer it can never use. The MERGE_HEAD probe above predates this fix and
// stays unconditional: `canScope` needs it on every path.
// A non-zero exit from either sequencer probe means "not in that state" AND
// "the probe never answered" — `execGit` surfaces a spawn timeout as
// `exitCode: 1` (`_spawnResult`: `result.status ?? 1`), which is the exact
// code `rev-parse --verify` returns for a ref that does not exist. Conflating
// them is the one path in this fix that does NOT fail toward the old
// behaviour: a timeout during a real merge would leave `partialCommitRefused`
// false, the guard would decide `nothing_to_commit` from a pathspec git will
// not honour, and the merge would be silently abandoned where it previously
// reported a loud `commit_failed`. So an unanswered probe is treated as
// "assume the partial commit would be refused" — the conservative reading,
// which falls through to `git commit` and lets git speak for itself.
//
// This is deliberately routed into `partialCommitRefused` ONLY, never into
// `isMergeInProgress`: that flag also feeds the pre-existing `canScope` below,
// where a spurious timeout would convert a scoped commit into a bare one and
// record the whole index instead of the named paths. Suppressing a misreport
// must not be paid for by committing content the caller never named.
const guardApplies = explicitFiles && !amend;
const cherryPickProbe = guardApplies
? execGit(['rev-parse', '-q', '--verify', 'CHERRY_PICK_HEAD'], { cwd })
: null;
const partialCommitRefused = isMergeInProgress
|| isSpawnTimeout(mergeHeadProbe)
|| (cherryPickProbe !== null
&& (cherryPickProbe.exitCode === 0 || isSpawnTimeout(cherryPickProbe)));
// `stagedPaths` records paths whose `git add` exited 0 — that is "did
// staging succeed", not "is there anything to commit". Staging an
// already-committed, unmodified file succeeds while contributing no diff, so
// `length === 0` is reachable only when EVERY named path was missing from
// disk. For the ordinary empty-diff case control fell through to `git commit`,
// and the only thing converting that back to `nothing_to_commit` was the
// string match on git's output below — which a rejecting pre-commit hook
// pre-empts, because git runs the hook before it decides there is nothing to
// commit. The caller was then handed `commit_failed` carrying a gate message
// about a commit that had nothing to gate. Ask git whether the named paths
// actually differ instead. Three things about that probe are load-bearing:
// - it compares the WORKING TREE to HEAD (`diff HEAD`), not the index
// (`diff --cached`). `git commit -- <paths>` is a partial commit: it takes
// the working-tree content of those paths and ignores what is staged. A
// probe against the index therefore answers a different question than the
// commit asks, and a working-tree write landing between the `git add`
// above and this line — another process in a shared checkout — would make
// the index say "empty" while the commit would still have recorded the new
// content. Driven: `diff --cached` rc 0 and `diff HEAD` rc 1 on the same
// path, with `git commit -- <path>` then committing it.
// - the `length === 0` short-circuit keeps the all-missing-paths case exact.
// Spreading an empty array yields a pathspec-less `diff`, which tests the
// WHOLE tree — unrelated work elsewhere would then suppress the guard and
// regress the skip-missing contract (#2014).
// It is deliberately NOT gated on `partialCommitRefused`, and gating it
// would be a REGRESSION rather than a hardening. With every named path
// missing, `stagedPaths` is empty, so `canScope` is false and the
// fall-through reaches a BARE `git commit` — which git PERMITS during a
// merge, and which then CONCLUDES that merge: rc 0, a two-parent merge
// commit recording the entire index, under a message naming a path that
// does not exist, reported to the caller as `committed: true` (driven).
// Today's answer writes nothing at all. That is the same trade the timeout
// routing above already refuses — a misreport must not be paid for by
// committing content the caller never named — which is why the sequencer
// states gate the DIFF branch only. The behaviour is also PRE-EXISTING and
// unchanged by this fix: before it the identical short-circuit ran ABOVE
// the MERGE_HEAD probe, so it never consulted the sequencer either. The
// residual it leaves — a merge held open behind a `nothing_to_commit`
// report — is offered as a separate issue with the other three, not folded
// in here. Both sequencer shapes are pinned in
// tests/commit-files-pathspec.test.cjs.
// - `!partialCommitRefused`: see above — deciding "nothing to commit" from a
// pathspec git will not honour would abandon an in-progress merge, so those
// states keep their pre-existing behaviour untouched.
// - the probe is pinned against user configuration that would make `git diff`
// answer a DIFFERENT question than `git commit -- <paths>` asks. `git diff`
// is porcelain and honours settings the commit does not, so without these
// flags a caller's config decides whether the guard fires. Each vector
// below was driven with the paired `git commit -- <path>` confirmed to
// record the change the probe reported as absent:
// `diff.ignoreSubmodules=all` -> a gitlink bump is invisible to the probe
// `.gitmodules` `ignore = all` -> the same, and it needs NO local config:
// it is checked in, so it arrives with a
// clone
// `diff=<driver>` + `textconv` -> two different blobs converge to one
// text, so the probe sees no change at
// all; no submodule involved
// `--ignore-submodules=dirty` rather than `=none`, because `dirty` is what
// a partial commit of a submodule path actually means: it records the
// GITLINK, and the gitlink moves only when the submodule's HEAD does. Under
// `=none` a merely dirty submodule WORKTREE reports a difference the commit
// would not record, sending an empty call back to `git commit` — the #3776
// misreport, re-entered from the other side. `dirty` still overrides both
// `diff.ignoreSubmodules` and a checked-in `.gitmodules` `ignore`, so the
// gitlink vectors above stay closed (driven: rc 1 under every one of them).
// `--no-ext-diff` is deliberately absent: `--quiet` short-circuits ahead of
// an external diff driver, so an external `diff.<driver>.command` cannot
// invert the probe (driven: rc 1 with and without the flag).
// Any other non-zero exit from the probe (a genuine git error, or an unborn
// HEAD) leaves the guard shut and falls through to the commit — failing toward
// today's path rather than manufacturing a no-op.
// THE ONE STATE WHERE `git diff` AND `git commit -- <paths>` GENUINELY DISAGREE.
// `--assume-unchanged` tells git to skip the worktree stat for a path, so
// `git add` stages nothing and BOTH diff forms report no difference — while
// `git commit -- <path>` reads the working tree directly and records it
// (driven: probe rc 0, commit rc 0, new content in the tree). Left
// to the diff probe alone the guard reports `nothing_to_commit` about content
// the caller explicitly named in `--files` and git would have written. #3776
// is a purely diagnostic bug — nothing is corrupted and no wrong commit is
// made — so suppressing its misreport must not be paid for by dropping named
// content. The same rule the timeout routing already follows one block up.
//
// `git ls-files -v` is the discriminator for the STATE: it tags an
// assume-unchanged path with a LOWERCASE letter (`h`), where
// `--skip-worktree` is an uppercase `S` and never reaches THIS branch:
// `git add` exits 1 under it, so a present-but-modified skip-worktree path
// fails closed as `staging_failed` above the guard. (An ABSENT one is skipped
// before `git add` runs at all per #2014, and is answered by the
// `stagedPaths.length === 0` arm above — correctly, and exactly as it was
// pre-fix. Both shapes are pinned.)
//
// Then ASK GIT, rather than reconstructing its answer. `git commit --dry-run`
// is the same decision the real commit makes, and `--no-verify` is what keeps
// it a DECISION rather than an execution. git 2.54 already declines to run
// `pre-commit` on a dry run (driven: a rejecting one neither fires nor writes
// its marker), which is the property that matters here, because a firing
// `pre-commit` is the whole of #3776 — but that is an observed behaviour of
// one version, and the failure it would produce on a version that differs is
// SILENT. A `pre-commit` that fires and rejects exits 1, the same code git
// returns for `nothing to record`, so the closure below would read it as a
// CONFIRMED empty answer, drop the content the caller named, and report
// `nothing_to_commit` — #3776's exact shape, in #3776's exact configuration.
// `--no-verify` forecloses that structurally instead of resting on the
// version, and is behaviour-neutral where the version already agrees (driven:
// rc 0 would-record / rc 1 nothing, identical with and without it). This is
// VERSION-SENSITIVE reasoning, hence stated at the claim per the provenance
// note above.
//
// It is still NOT hook-free in general, and `--no-verify` does not widen that
// claim: `post-index-change` fires on this call with or without the flag
// (driven both ways), so a repo using that hook sees TWO extra invocations
// for the probe — git fires it twice per `commit --dry-run`, and twice again
// for the real commit (driven: 2/2/2 across flagged probe, unflagged probe
// and real commit). Stated rather than claimed away; the narrower
// claim is the true one. `--porcelain` keeps the output to a couple
// of machine-readable lines instead of a full status listing — the rc is
// identical either way (driven: 0 would-record / 1 nothing), but the plain
// form prints every untracked path, which on a large tree is output this
// probe has no use for and `execGit` would have to buffer. rc 0 means the
// commit would record something, so the guard must stand aside.
//
// Reconstructing it was tried and is WRONG in three measured ways, all of
// them silent drops of named content. Comparing `git hash-object` against
// `HEAD:<path>` misses a mode-only change (`chmod +x` leaves the blob
// identical while `git commit -- <path>` records `100755`); it cannot hash a
// submodule path at all (`fatal: Unable to hash sub`, while the commit
// advances the gitlink); and the path it needs must be parsed out of
// `ls-files` output, which `core.quotePath` renders as `"caf\303\251.md"`
// by default, so the probe reads a filename that does not exist. Asking git
// needs no path parsed and no case enumerated.
//
// Scoped to this branch on purpose. The diff probe above answers the ordinary
// case cheaply and is pinned against the configuration vectors below; the
// dry run is the heavier, exact answer, and it runs only when an
// assume-unchanged path is actually present.
//
// The `ls-files` read is an OPTIMISATION, never a gate — so an unreadable one
// must not decide anything. It exists only to keep the dry run off the hot
// path when no assume-unchanged entry is present; when it cannot answer, the
// dry run simply runs, because the dry run needs nothing from it. Both
// failing-closed (drop the content) and failing-open (re-enter #3776) are
// wrong answers to a question we can just ask directly.
const assumeUnchangedWouldRecord = (): boolean => {
const listed = execGit(['ls-files', '-v', '--', ...stagedPaths], { cwd });
// Only the TAG is read; the path is deliberately never parsed out — see the
// `core.quotePath` note above, and the dry run below needs no path anyway.
if (listed.exitCode === 0
&& !listed.stdout.split('\n').some((line) => /^[a-z] /.test(line))) return false;
const dryRun = execGit(
['commit', '--dry-run', '--porcelain', '--no-verify', '-m', sanitizedMessage as string, '--', ...stagedPaths],
{ cwd },
);
// Only a CONFIRMED "nothing to record" closes the path: rc 1 from a git
// that actually answered. This is the one probe in the guard whose rc 0
// is the REASSURING answer, so it inverts the diff probe's safety: there
// a timeout can only yield non-zero and reads as "not clean"; here
// `execGit` collapses a spawn timeout (or any spawn error) to
// `exitCode: 1` (`_spawnResult`: `result.status ?? 1`), byte-identical to
// git's own "nothing to record" — and the guard then reports
// `nothing_to_commit` about content it never asked git to write. Same
// conflation the sequencer probes above defend against, same remedy: an
// unanswered probe falls toward the commit, where git speaks for itself
// (and a genuine error there is reported loudly, as it always was). rc 128
// is likewise not an answer. Timeout kill of a dry run CAN leave a stale
// `index.lock` behind (it refreshes the index); the real commit then
// fails on it, loudly — never silently.
if (isSpawnTimeout(dryRun) || dryRun.error !== null) return true;
return dryRun.exitCode !== 1;
};
const nothingToCommit = guardApplies
&& (stagedPaths.length === 0
|| (!partialCommitRefused
&& execGit(
['diff', '--quiet', '--ignore-submodules=dirty', '--no-textconv', 'HEAD', '--', ...stagedPaths],
{ cwd },
).exitCode === 0
&& !assumeUnchangedWouldRecord()));
if (nothingToCommit) {
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