- Book: System Design Pocket Guide: Interviews
- Also by me: Thinking in Go (2-book series) — Complete Guide to Go Programming + Hexagonal Architecture in Go
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
When you run git status, Git mostly doesn't read your files. It reads a cache of their metadata, stored in the index, and if that metadata hasn't moved, it trusts that the file hasn't either.
That shortcut has a hole, and Git's own technical documentation names it: racy git. Change a file, stage it, then change it again in place, without changing its size, inside the same timestamp tick. The cached metadata still matches. A naive check would call the file clean while its contents are different.
For years this was an edge case for fast typists and scripts. A coding agent changes the odds. It can make several edits to one file back to back with no human pause in between, and a tool can ask Git "what changed this turn?" the instant the agent stops. Same second, same size, one flipped digit: that is the shape of the race, whenever the agent's edit tool writes the file in place rather than through a rename.
Git has a defense for it, and you can watch the defense work with a short shell script.
How Git decides a file is unchanged
The index records every tracked path with the object name of its contents. To avoid hashing every file on every command, each entry also caches the lstat(2) data from the moment it was last updated. According to the racy-git document, Git compares the file type, the executable bit, st_mtime, st_ctime, st_uid, st_gid, st_ino and st_size. If they all match, Git treats the file as unchanged and never opens it. A different size or file type is enough to call it modified. If only a timestamp or the inode moved, Git reads the contents to be sure.
The timestamps are the weak spot. The same document says that comparing the nanosecond part of the timestamps is optional, and that on Linux it is off by default, because in-memory timestamps can be finer than what the disk keeps. In that default, the tick is one second. Two writes in the same second carry the same st_mtime.
So the race needs all of these at once:
- The file is rewritten in place, so the inode stays the same. A tool that writes a temp file and renames it gets a new inode, which sends Git to the contents.
- The new contents are the same length, like
retries = 3becomingretries = 5. - The rewrite lands in the same tick as the index write that recorded the old version.

Two versions of a file, one tick on the clock: the stat cache can't tell them apart.
Git's two defenses
The racy-git document describes two mechanisms, and Git's read-cache.c implements both.
The first is a suspicion rule. The index file is written after Git collects the stat data of the paths it records, so its own st_mtime is usually the same as or newer than any file it lists. An entry whose st_mtime is the same as or newer than the index file's timestamp is "racily clean": it looks clean but might not be. For those entries, Git doesn't trust the stat match. It compares the file's contents with the object recorded in the index. In the source, is_racy_stat makes that call, and ie_match_stat falls back to ce_modified_check_fs to check the contents.
The second covers a later problem. Suppose the index is rewritten a few seconds later, for some unrelated reason. Its timestamp moves forward, the old entry no longer looks racy, and the stat data would match again. To close that gap, when Git writes an index that contains a racily clean entry, it truncates that entry's cached st_size to zero. In ce_smudge_racily_clean_entry, Git checks the contents first and only zeroes the size when they really differ. A cached size of 0 never matches a non-empty file, so every later comparison falls through to a content check.
The defense costs time. The document's "Runtime penalty" section makes every entry in a Linux kernel checkout (over 20,000 files at the time) racily clean on purpose. git diff-files then took 2.22 seconds. After git update-index MAINTAINERS wrote a fresh index, the same command took 0.14 seconds. Those timings come from an Athlon 64 X2 3800+, a mid-2000s desktop CPU, so the ratio is what carries over.
That is one reason the first git status after a burst of edits can be slower than the next one. Per the git status manual, status refreshes the index by default, "updating the cached stat information from the working tree and writing out the result". Once a fresh index is on disk with a newer timestamp, the entries that really are clean stop being suspicious, and the hashing stops.
Watch it happen: a one-second script
This script waits for the start of a fresh second, then stages a file, rewrites it in place with the same length, and writes the index again, all inside that second. Then it waits, writes the index a third time, and asks Git for status. It runs in a throwaway directory from mktemp -d and touches nothing else.
#!/usr/bin/env bash
# racy.sh: rewrite a tracked file in the same second
# as an index write, then watch Git catch it.
set -eu
cd "$(mktemp -d)"
git init -q
# start at the top of a fresh second
s=$(date +%s)
while [ "$(date +%s)" = "$s" ]; do :; done
a=$(date +%s)
printf 'retries = 3\n' > conf.txt
printf 'notes\n' > notes.txt
git add conf.txt # index write 1
printf 'retries = 5\n' > conf.txt # same size, same inode
git add notes.txt # index write 2
b=$(date +%s)
if [ "$a" != "$b" ]; then
echo "crossed a second boundary, run it again"
exit 1
fi
echo "cached entry for conf.txt after write 2:"
git ls-files --debug conf.txt | grep 'size:'
sleep 2
printf 'later\n' > later.txt
git add later.txt # index write 3
git status --short
I haven't run this script myself, so here is what the source says should happen, step by step, and what to look for.
During git add notes.txt, Git writes a new index. The conf.txt entry has the same st_mtime second as the previous index file, so it is racily clean. Git reads its contents, sees retries = 5 where the index recorded retries = 3, and zeroes the cached size. git ls-files --debug prints the cached stat data per entry, and its source prints the cached size on a line that starts with size:. If the race happened, you should see size: 0 there, even though the file on disk is 12 bytes.
Two seconds later, git add later.txt writes the index again with a newer timestamp. Now conf.txt no longer looks racy by time. Its mtime and inode would still match, and if the size were still 12, a stat-only check would call it clean. Because the size is 0, it can't match, and git status --short should list conf.txt as AM: added to the index, then modified in the working tree.
One caveat. If your Git build compares nanosecond timestamps, the second write already has a different st_mtime, so Git compares contents, finds the change, and you'll see the real size instead of 0. The status line comes out the same. That's the correct answer by a different route.
What this means for tools that watch agents
Everything that reports what an agent did goes through some version of this question: did this file change since I last looked? Your prompt's dirty marker, a pre-commit hook and a per-turn snapshot tool all ask it, sometimes right after the agent's last write. If you write your own mtime-and-size check instead of asking Git, you inherit the race without the defense Git already ships.
If a tool runs git status in the background while the agent works, the same manual warns that the index write takes a lock that can make other Git processes fail, and points scripts at git --no-optional-locks status. A watcher shouldn't make the agent's own git commit fail.
This is where I declare an interest: I build Hermes IDE, so weigh this section with that in mind. Hermes saves every agent turn, so you can diff or restore a single turn, with undo, and the Review Desk (⌘G) shows changes by turn or by file (README).
Any per-turn snapshot is taken when a turn ends, which is when a same-second, same-size edit is most likely. That's why the logic in this post matters to me: a turn that looks empty when it changed 3 to 5 would be a nasty bug in a review tool.

The Review Desk (⌘G) in By turn view: two turns of a Claude Code task in orders-api, turn 1's diff of src/db.ts adding a refunded status, and a commit message drafted from the turns.
Hermes is free, source-available under BSL 1.1, and runs on macOS, Windows and Linux. You can download it or read the code on GitHub. It runs agents such as Claude Code in their own terminals and isn't affiliated with any agent vendor.
If you build a watcher
The stat cache bets that metadata moves when contents do; racy git is where it doesn't.
If you build anything that watches an agent's working tree, the cheapest correct move is to ask Git and let it pay for the hashing. If you must roll your own change detection, copy both rules from the document: treat anything as new as your last snapshot as suspect, and record that suspicion so it survives the next write. And run the script above on your own machine before you trust either.
If this was useful
Racy git is a cache-invalidation trade-off in miniature: a fast check, a known blind spot, and a fallback that costs time only where the blind spot is. That is the kind of reasoning a system design interview asks you to say out loud, and my System Design Pocket Guide: Interviews walks through 15 real designs step by step with the trade-offs named.

Top comments (0)