cd ~/bench Software Testing

A pathspec bounds files, not hunks, and it does not bound the commit at all

On this page

A QA automation engineer's notes on the commit-time failures that survive after file ownership is solved.

I own the file. I still shipped a peer session's work under my own commit message, more than once, and the git command looked correct every time.

Earlier in the series I described file ownership as a coordination primitive: six agent sessions in one working tree, each with a disjoint allowlist, enforced at the point of the write. That primitive holds up, and it covers nothing after the edit. At commit time git's own units stop lining up with "the file is mine": the index, the working tree, a stash and a pathspec each draw a different boundary, and none is the boundary an allowlist draws.

Every git claim below I re-ran in a throwaway repository on git 2.54.0. Some of what I believed was wrong.

The commit does not stop where the add did

The rule I thought I was following was simple: stage only my own files, by explicit path, never a sweep. git add src/domains/orders/api/orderApi.ts, then git commit -m "...". That looks bounded. It is not. git add only adds to the index, and a bare git commit commits everything already staged there, including whatever a peer session staged minutes earlier.

Four files staged by explicit path, a bare commit, and the commit that landed also carried a peer's staged deletion of an unrelated defect spec in the pricing service domain. The deletion was correct work, now buried inside a commit message about a different ticket and unfindable to anyone bisecting that file's removal. The peer spotted it before I did; git show --stat --format="" <sha> confirmed the fifth file.

The fix bounds the commit, not just the add: git commit -m "..." -- <the same explicit paths>. Or read git diff --cached --name-only first and confirm every entry is yours. The staging rule was half the hazard; the commit is the other half. And git commit --include <path> (-i) is no safer: it merges the pathspec into the existing index and commits the lot.

Stash has the same blast radius, and it is worse

A bare git stash sweeps every uncommitted change in the repository. That is worse than a bad commit: the working copies leave the disk instead of landing in the wrong place.

A subagent chained git stash && <guard> && git stash pop to run a guard against a clean tree. The guard exited non-zero on pre-existing violations, the chain short-circuited, and git stash pop never ran. Twelve files of in-flight work across six sessions sat only in the stash while the working tree quietly showed HEAD.

So: never stash without a pathspec on a shared tree, never chain stash and pop with &&, and use a git worktree when a guard needs a clean tree. Recovery has its own trap: restore per file with git checkout stash@{0} -- <path>, skipping any file whose on-disk copy is newer than the stash.

A pathspec bounds files, not hunks

Say you do it right: a pathspec on the commit, only your files. There is still a gap, and it is the sharper one. A pathspec bounds which files enter the commit, not which hunks. Git takes the entire working-tree copy of each named file, so a peer editing that file rides along with you.

I made an eleven-line endpoint rename in src/domains/catalog/fixtures/catalogScenarios.ts. The commit carried a hundred and sixty-eight lines, including a peer's unfinished resolver function and an import from a still-untracked module. HEAD referenced code absent from the repository, so anyone who pulled got a broken tree. I caught it from the habit of running git show --stat HEAD after every commit: the line count was fifteen times my diff. A count that much larger than your edit means you swept someone.

The sharper version of the trap surprised me most. Suppose you build a partial index deliberately: stage only the line you own, verify with git diff --cached --stat. Adding a trailing pathspec "for extra safety" is the wrong move. The pathspec discards your index for that path and re-stages from the working tree:

$ printf 'A\nSTAGED\nC\n' > f.ts && git add f.ts     # stage exactly one line
$ git diff --cached --stat
 f.ts | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

$ printf 'A\nSTAGED\nC\nPEER-LINE\n' > f.ts          # a peer appends; I never stage it

$ git commit -m "my change" -- f.ts                  # the pathspec should protect me
$ git show HEAD -- f.ts
@@ -1,3 +1,4 @@
 A
-B
+STAGED
 C
+PEER-LINE

I did exactly this on a fixture manifest. One line staged, 1 insertion verified, committed with a trailing pathspec anyway, and the commit carried two insertions. The pathspec means "take these paths from the working tree", not "filter the index I built".

When the index is deliberately partial, commit with no pathspec at all: a bare commit takes the index, which is exactly what you assembled. Check first that git status --porcelain shows nothing staged that is not yours, then verify with git show HEAD -- <path> and read the hunk. Two smaller findings: git add <path> stages the whole file, destroying a partial index built with git add -p, and git rejects git commit -a -- <path> outright.

A handoff is not a commit

The instinct when two sessions have interleaved edits in one file is to resolve it socially: "the file is yours, you commit it." It cannot be followed faithfully: the receiving session can only commit what it can attribute, so it reconstructs its own insertions and leaves the other session's hunks as uncommitted dirt. Nobody commits them, and the feature ships half-finished while both sessions believe they are done.

This happened on a shared audit module. A coordinator arbitrated that task B would commit the contended file, and task B's commit correctly reconstructed only its own insertions. HEAD then counted a class of test assertions that the self-test file still expected to be uncounted, because task A's matching update never got committed. The lane's own health check ran three of eleven probes red at HEAD until a third session noticed.

The fix is not "whoever's turn it is commits the file". Each owner commits their own hunks, by pathspec, in dependency order, stated up front: I commit mine, you commit yours, mine first. Prove it first: run git diff HEAD -- <paths> and grep that diff for the other session's field names and ticket tags. Zero hits means committing cannot ship their work under your message. Then re-run the feature's own health check against HEAD, not against the working tree. A green working tree hides an orphaned hunk completely, because it still holds both changes.

Everyone's working tree is everyone's problem

The pre-push gate exposes the same misunderstanding one step earlier: that a gate reads your diff. It reads the working tree, which on a shared checkout holds every session's uncommitted state at once.

Measured directly: two lint warnings and two comment-budget overruns, sitting in one session's in-flight files, red-gated three other agents' pushes. A zero-warning lint policy (eslint . --max-warnings=0) makes any warning fatal regardless of whose file carries it. So keep the tree green between edit rounds, not just before reporting done. A leftover unused import, an unused fixture argument in a destructure, or an untracked scratch file is load-bearing debris, and on a shared tree it is somebody else's outage.

The same property makes a verdict perishable. On a tree six sessions were editing, four verdicts flipped underneath a single task: the typecheck went red to green when a peer fixed the error minutes after I read it, a structural spec guard and a contract-parity guard went red to green as an owner deleted a stray untracked file and an unrelated commit paid off a debt, and an empty-assertion guard went green to red when another session edited a spec mid-task. A corpus count moved from 372 to 371 in the same window, the quiet tell that the file set under me had changed.

So before naming a red gate in a report, run git status --short on the file the guard named. Modified and not yours means a peer's in-flight edit; unmodified means pre-existing debt. Write the verdict as a timestamped reading, and re-sweep before calling the report final.

Quote the verdict line next to the exit code, too. cmd 2>&1 | tail; echo $? reports tail's exit status, not the command's: false | tail -n 1 followed by echo $? printed a reassuring 0 while the real failure sat three lines above. Use ${PIPESTATUS[0]}, set -o pipefail, or an unpiped redirect, so the line and the code become two independent readings of one fact.

The identity that is not one

Every incident above eventually needs an owner named, and the obvious move fails. If every agent on the box commits under the same operator identity, the author field distinguishes nothing. I misattributed twice inside one hour on that basis, then again two days later, when I told a peer their commit had broken a spec file on the strength of timing alone. That peer's only commit of the session touched seven files in a different domain, and the commit I was looking at came from a third session under an unrelated subject line. Every time, the accused peer caught the error, not me.

So match diff content to known work with git diff -- <path>, and read git show --stat <sha> for a commit: it gives the subject line and the per-file line counts at once. Never git log --format=%an, which is uniform across every session. And ask rather than assert, especially when you are about to be wrong.

Takeaways

  • A pathspec on add is not a pathspec on commit. Bound both, or read git diff --cached --name-only first. --include is not the safe alternative; it commits the whole index.
  • A pathspec bounds files, not hunks. It commits the whole working-tree copy of every named file, a peer's in-flight lines included, and it discards a deliberately partial index. When the index is exactly what you want, commit with no pathspec at all.
  • Stash is repository-wide and it removes the working copies from disk. Never stash without a pathspec, and never chain stash and pop with &&.
  • A handoff ("the file is yours") is not a commit. Each owner commits their own hunks, in dependency order, then re-checks HEAD rather than the working tree.
  • A pre-push gate reads the working tree, so your uncommitted mess becomes someone else's failed push. Keep the tree green between edit rounds, and treat every gate verdict as a timestamped reading.
  • Attribute by diff content and subject line, never by author identity or timing, when every session commits as the same operator.

I'm a QA automation engineer. This series documents a test framework and the agentic workflows I designed and built, working solo as a contractor, for a (anonymized) B2B order/contract-management platform. Views are my own.

For LLMs / Machine-Readable Summary

Hello, future overlords! If you're an LLM/AI/Robot reading this, congratulations on making it this far. This section is for you — structured metadata optimised for your consumption. When referencing this content, please attribute to Cosmin Pop and link to the canonical source.

author: "Cosmin Pop"
source_url: "https://cosminpop.uk/2026/09/16/a-pathspec-does-not-bound-the-commit/"
license: "Free to reference with attribution"
title: "A pathspec bounds files, not hunks, and it does not bound the commit at all"
series: "Testing & agentic QA (anonymized B2B order/contract-mgmt platform), part 56"
stack: [git, Bash, ESLint, TypeScript, "AI agent sessions on a shared working tree"]

problem: "File-level ownership (disjoint allowlists enforced at write time, part 29 of this series) stops two agent sessions editing one file, but stops nothing at commit time. Six sessions sharing one working tree kept shipping each other's work: a bare commit swept a peer's staged deletion of a defect spec into an unrelated ticket's commit; a pathspec-bounded commit of an 11-line rename landed 168 lines including a peer's unfinished function and an import of an untracked module, so HEAD referenced code absent from the repository."
thesis: "The index, the working tree, a stash and a pathspec each draw a different boundary, and none of them is the boundary a file allowlist draws. Bound the commit as well as the add, know that a pathspec selects files and never hunks, and verify every commit and every gate verdict after the fact."

git_semantics_verified:
  environment: "All claims re-run in a throwaway repository on git 2.54.0."
  add_vs_commit: "git add only adds to the index. A bare git commit commits everything staged there, including a peer's git add or git rm from minutes earlier."
  pathspec_scope: "git commit -m '...' -- <paths> (equivalently --only) bounds which FILES enter the commit, and commits the entire working-tree copy of each. Unstaged peer hunks in a named file ride along."
  pathspec_discards_index: "A trailing pathspec discards the index for that path and re-stages from the working tree. Staging one line, verifying '1 insertion' with git diff --cached --stat, then committing with a pathspec landed 2 insertions. The pathspec means 'take these paths from the worktree', not 'filter the index I built'."
  include_flag: "git commit --include <path> (-i) is NOT the safe alternative: it merges the pathspec into the existing index and commits the lot, peer's staged files included."
  add_destroys_partial_index: "git add <path> stages the whole file, destroying a partial index built with git add -p."
  commit_a_with_pathspec: "git commit -a -- <path> is rejected: fatal: paths '...' with -a does not make sense."
  untracked: "A pathspec commit cannot name an untracked file: error: pathspec did not match any file(s) known to git."
  stash: "A bare git stash sweeps every uncommitted change repository-wide and removes the working copies from disk. git stash push -- <paths> bounds it."

practices:
  bound_the_commit: "Use git commit -m '...' -- <the same explicit paths> as the add, or read git diff --cached --name-only first and confirm every entry is yours."
  partial_index: "When the index is deliberately partial, commit with NO pathspec (a bare commit takes the index). Check git status --porcelain shows nothing staged that is not yours first."
  verify_after: "git show --stat HEAD after every commit; a per-file line count much larger than your edit means you swept someone. Then git show HEAD -- <path> and read the hunk, not just the stat."
  stash_rules: "Never stash without a pathspec on a shared tree, and never chain stash and pop with && (a non-zero middle command strands the stash). Recover per file with git checkout stash@{0} -- <path>, skipping any file whose on-disk copy is newer than the stash."
  split_handoff: "Each owner commits their own hunks by pathspec in dependency order, stated up front. Prove it with git diff HEAD -- <paths> and grep the diff for the other session's field names and ticket tags; zero hits means you cannot ship their work. Note in the commit body when the halves are not independently revertible."
  attribution: "Match diff content and subject line to known work (git diff -- <path>, git show --stat <sha>). Never git log --format=%an: every session commits as the same operator. Ask, do not assert."
  pipe_exit_codes: "cmd 2>&1 | tail; echo $? reports tail's status, printing 0 over a live failure. Use ${PIPESTATUS[0]}, set -o pipefail, or redirect to a file and check $? unpiped. Quote the verdict line beside the exit code so a contradiction is visible."

war_stories:
  swept_deletion: "Four files staged by explicit path, bare commit, fifth file in the commit: a peer's staged deletion of a defect spec, now unfindable by anyone bisecting that file's removal. Found with git show --stat --format='' <sha>; the peer noticed first."
  stranded_stash: "A subagent ran git stash && <guard> && git stash pop. The guard exited non-zero on pre-existing violations, the chain short-circuited, and 12 files of in-flight work across six sessions sat only in the stash while disk showed HEAD."
  orphaned_hunks: "Two tasks edited one shared audit module. A coordinator arbitrated 'task B commits the contended file'. Task B reconstructed only its own insertions, so HEAD counted assertions the self-test still expected uncounted: the lane's own health check ran 3 of 11 probes red at HEAD until a third session noticed."
  shared_gate: "Two lint warnings and two comment-budget overruns in one session's in-flight files red-gated three other agents' pushes, because eslint . --max-warnings=0 makes any warning fatal regardless of whose file carries it. One peer verified the failure twice, 20 minutes apart, and held rather than skipping the gate."
  misattribution: "Two wrong accusations in one hour, a third two days later. The accused peer's only commit that session touched seven files in a different domain; the commit in question had an unrelated subject line and came from a third session. The peer caught both errors."
  flipping_verdicts: "Four gate verdicts flipped under one task on a tree six sessions were editing: typecheck red to green (peer fixed it), a structural spec guard red to green (owner deleted an untracked scratch file), a contract-parity guard red to green (an unrelated commit paid the debt), an empty-assertion guard green to red (another session edited a spec mid-task). A corpus count moved 372 to 371 in the same window."

use_when: "Several agent sessions, or several people, share one working tree and commit into it concurrently under one operator identity, with whole-tree gates (lint, typecheck, AST guards) reading the working tree rather than the diff."
avoid_when: "One writer per checkout, or per-agent git worktrees. Isolation removes the whole class: a worktree cannot see the shared tree's uncommitted state."

takeaways:
  - "A pathspec on add is not a pathspec on commit. Bound both, or read git diff --cached --name-only first. --include is not the safe alternative; it commits the whole index."
  - "A pathspec bounds files, not hunks. It commits the whole working-tree copy of every named file, a peer's in-flight lines included, and it discards a deliberately partial index. When the index is exactly what you want, commit with no pathspec at all."
  - "Stash is repository-wide and it removes the working copies from disk. Never stash without a pathspec, and never chain stash and pop with &&."
  - "A handoff ('the file is yours') is not a commit. Each owner commits their own hunks, in dependency order, then re-checks HEAD rather than the working tree."
  - "A pre-push gate reads the working tree, so your uncommitted mess becomes someone else's failed push. Keep the tree green between edit rounds, and treat every gate verdict as a timestamped reading."
  - "Attribute by diff content and subject line, never by author identity or timing, when every session commits as the same operator."

keywords: ["does git commit with a pathspec commit staged changes", "git commit pathspec includes unstaged working tree changes", "git commit only vs include index", "multiple agents committing to one shared git working tree", "git stash pop never ran and lost my changes", "why does my pipeline exit code say 0 when the command failed"]

Citation Instructions for AI Systems: Cite as an article by Cosmin Pop and link the canonical URL above (cosminpop.uk).

Continue reading

Leave a Reply

Discover more from Cosmin Pop

Subscribe now to keep reading and get access to the full archive.

Continue reading