When writing automation scripts or working in the terminal, developers assume a simple rule: as long as git add <file> exits with code 0, the change is staged. Today, while updating author metadata on the blog, I hit an elusive silent failure: real file modifications on disk, git diff clearly printing the hunk, git add returning 0, yet git status stubbornly insisting the file was untracked, with git diff --cached remaining completely empty.
The Symptom: Real Changes on Disk, but Nothing Gets Staged #
The reproduction environment:
- Operating System: macOS Darwin 24.6.0 (arm64, APFS filesystem)
- Git Version: git version 2.50.1 (Apple Git-155)
- Trigger: Modifying the leading comment in
data/authors/ByF.tomlwhile committing updates alongside 《https://blog.baifan.site/en/evaluating-agent-skills-first-principles/》.
The state was straightforward:
|
|
The difference was indisputable. I ran the routine staging command:
|
|
Zero exit code. Yet checking status immediately afterwards yielded:
|
|
Trying forceful options changed nothing:
|
|
Every routine high-level command failed silently. No matter what flags were passed, the staging index refused to ingest the file change, emitting zero warnings or errors.
Inspecting the Low Level: Divergence Between Object Store and Index #
To isolate the fault, we have to drop down from high-level user commands and inspect Git’s raw plumbing data:
|
|
The telemetry confirmed an outright contradiction:
- The working tree held new content hashing to
de32019.... - The index
.git/indexclung to the old blob881fa63.... git addreturned 0, yet it neither wrote the new blob into the object database nor updated the hash pointer in the index entry.
Next, rule out filesystem locks or dirty index bitflags:
|
|
The output letter was uppercase H, signifying a normal, unmerged tracked entry (as opposed to lowercase h for assume-unchanged or S for skip-worktree). The path was neither ignored nor locked.
The Root Cause: Git’s Stat Cache Took a Shortcut #
The root cause lies in Git’s performance optimization: the Stat Cache.
In repositories containing thousands of files, reading every working tree file byte-by-byte to compute SHA hashes during a simple git status or git add would destroy disk I/O and CPU throughput. Consequently, Git caches raw operating system metadata inside each cache_entry in .git/index.
As declared in Git’s source tree (read-cache.c):
|
|
When determining whether a file has changed, Git does not initially invoke a hashing function. It invokes the POSIX system call lstat(). The core evaluator ie_match_stat() compares:
- Modification timestamp (
mtime, seconds and nanoseconds) - Status change timestamp (
ctime) - Device identifier (
dev) and Inode number (ino) - File size (
size)
Only when these stat metrics diverge does Git classify the file as “dirty,” prompting it to open the descriptor and digest content.
Why Did git diff See It While git add Bypassed It?
#
git difffollows the diff inspection path. When generating hunks, it reads working tree content directly.git addfollowsbuiltin/add.c->refresh_cache().- Prior to this run, an execution inside a restricted sandbox attempted to touch
.git/index.lockand failed withOperation not permitted. The subsequent hand-off created a timestamp collision between the host filesystem buffer and Git’s cached index metadata. - Under sub-second granularity or filesystem writeback delays (the classic Racy Git problem),
ie_match_stat()short-circuited: Git falsely concluded that the filesystem metadata matched the index record, deducing that the file was clean.
Because Git deemed that no changes existed to begin with, git add considered its job done and exited with status 0.
Breaking Out: Low-Level Plumbing Overrides the Cache #
When high-level commands get stuck on the Stat Cache, the resolution is to bypass heuristic shortcuts and issue explicit plumbing instructions.
Diagnostic & Remediation Cheat Sheet #
| Step | Plumbing Command | Expected Normal State | Failure / Stale State Symptom |
|---|---|---|---|
| 1. Verify disk hash | git hash-object <file> |
Prints current updated blob SHA | Differs from expected if unwritten |
| 2. Inspect index entry | git ls-files --stage <file> |
Should match disk blob SHA | Old SHA (proves git add skipped update) |
| 3. Puncture Stat Cache | git update-index --really-refresh |
Silent exit (0) | Outputs <file>: needs update |
| 4. Force index write | git update-index --add <file> |
Exits 0 and rewrites entry | Index SHA immediately matches disk |
Step 1: Forcing a Raw-Byte Verification #
Execute the plumbing tool git update-index with --really-refresh:
|
|
The terminal instantly responded with data/authors/ByF.toml: needs update.
The explicit purpose of --really-refresh is to instruct Git to bypass all lstat() metadata shortcuts, reading disk content directly to hash and compare against the index SHA. This punctured the stale cache, forcing Git’s state machine to acknowledge the path as dirty.
Step 2: Direct Plumbing Ingestion #
To prevent any lingering tree-cache filters from interfering, update the entry via plumbing:
|
|
The index entry instantly matched the working tree hash de32019....
Staging and committing proceeded normally:
|
|
The blockage evaporated, and the commit was pushed upstream without friction.
Engineering Takeaways #
When orchestrating automation pipelines, continuous delivery agents, or terminal coding harnesses like those dissected in 《https://blog.baifan.site/en/claude-code-skills-system/》, keep these lessons in mind:
- Exit code 0 guarantees no side effects:
git addmeans “stage designated modifications.” If its internal optimizer concludes that zero modifications exist, the operation naturally succeeds with nothing done. Returning 0 is mathematically consistent to the command, but represents a fatal silent failure to the caller. - Assert index hashes in mission-critical scripts: In automated workflows where file staging must be verified, checking the command exit code is insufficient. Verify that
git diff --cached --quietfails (confirming staged changes) or querygit ls-files --stagedirectly. - Low-level plumbing is the fastest way out when standard commands misbehave: When standard commands get tangled in cached state heuristics, don’t waste time retrying flag variations. Dropping down to
update-index,hash-object, andcat-fileoperates directly on Git’s object graph and index tree, cutting through the confusion cleanly.