We threw out the file watcher and used git instead
Why our CLI watches your repo with `git diff` and `.git/index`'s mtime instead of inotify, how we keep a 50,000-node code graph correct under live edits, and why the whole pipeline is safe to retry.
When you run supermodel, we keep a map of your codebase up to date as you edit. The agent reads the map. The map has to stay fresh.
The obvious way to do this is a file system watcher. Hook into the OS. Get a notification every time a file changes. React.
We tried that. We threw it out.
Now we let git tell us what changed. And once we know what changed, we patch a long-running graph in place — we never throw it away.
The problem with file system watchers
A file system watcher tells you about every file event on disk. Every save in your editor. Every temp file your formatter writes and deletes a millisecond later. Every node_modules install. Every .DS_Store. Every IDE autosave. Every git operation that touches working tree files.
You get a fire hose. You then spend your whole day filtering the fire hose.
And the watcher misses the thing you actually care about. If you git checkout a different branch, your editor didn’t change anything. The branch did. Two thousand files just changed underneath you. A file watcher sees a flood of events with no story. Was that a checkout? A pull? A stash pop? The watcher does not know. It just knows bytes moved.
We do not want bytes. We want changes a developer made.
Git already knows what those are.
What we do instead
Every three seconds we ask git two questions:
- Did
.git/indexchange? - Did
HEADmove?
Both questions are cheap. The first is a stat call. The second is git rev-parse HEAD. Together they cover almost everything a developer can do to a repo.
Here is the heart of it, lifted from internal/shards/watcher.go:
func (w *Watcher) poll() {
indexMod := w.gitIndexMtime()
indexChanged := indexMod != w.lastIndexMod
currentSHA := strings.TrimSpace(w.runGit("rev-parse", "HEAD"))
headChanged := currentSHA != "" &&
currentSHA != w.lastCommitSHA &&
w.lastCommitSHA != ""
currentDirty := w.gitDirtyFiles()
// ...emit events for the diff...
}
If HEAD moved, we run git diff --name-only OLD NEW and emit one event per file that changed between the two commits. That single check covers commit, pull, checkout, merge, rebase, stash pop, cherry-pick. Everything that moves the branch.
If only the working tree changed, we run git diff --name-only HEAD plus git ls-files --others --exclude-standard to get dirty files plus untracked files. We diff that against last poll’s set. New names are new edits. Names that disappeared got staged or reverted.
That’s it. No inotify. No fsevents. No ReadDirectoryChangesW. No platform-specific bugs.
The cost is a three-second floor on freshness. We accepted that trade.
A real bug from a stray newline
The first version of the watcher had a quiet bug. git rev-parse HEAD returns the SHA followed by a newline. We were saving the raw output as lastCommitSHA at startup, but trimming the newline on every later poll. So on the first poll, the trimmed current SHA never matched the untrimmed stored SHA. headChanged was always true on the first poll.
The downstream git diff against an invalid ref failed silently and produced an empty list, so nothing visibly broke. But every startup paid for one bogus diff and lied about HEAD moving. PR #99 trimmed both sides.
A whole class of subtle bugs lives in this kind of seam. We caught this one because it showed up as headChanged = true in our logs at a moment we knew nothing had happened. If you take one thing from this post, take that: log the conditions, not just the actions.
Why git is the right tool for this
Git already knows what counts as a change. It knows about .gitignore. It knows that node_modules doesn’t matter. It knows that the editor swap file doesn’t matter. It knows that the build artifact doesn’t matter.
If you trust git’s view of your repo — and you should, because that’s the view your teammates see when you push — then you should trust git to tell you what changed.
The watcher gets one more thing for free. The set of files git considers “yours” is the set of files we should map. The same filter does both jobs.
Knowing what changed is the easy half
The watcher hands the daemon a list of file paths. Now what?
The naive answer is: re-analyze the whole repo. Throw the old graph away. Build a new one. Render every shard.
That works for ten files. It is a disaster for fifty thousand. A full graph build for a real codebase takes minutes and costs real money. Doing it on every save would make the tool unusable.
So we do an incremental update. We send only the changed files to the API. The API returns a new piece of graph for those files. We merge that piece into the existing graph in memory.
This is the part that took us the longest to get right. The merge is roughly 230 lines in internal/shards/daemon.go. It has been rewritten three times.
Bug one: 99% churn from one saved file
The first version called a special “incremental” endpoint on the API. The endpoint didn’t actually understand the changedFiles parameter, so it cheerfully built a full graph from whatever was in the zip — and assigned every node a fresh UUID.
A one-file save produced a graph with new IDs for every node in the repo. The merge logic compared old IDs to new IDs, found zero matches, and concluded that 99% of the graph had changed. Every shard re-rendered. Every domain reclassified. Every save felt like a full rebuild.
PR #62 fixed it two ways. We routed incremental updates through the normal analyze endpoint, and we taught the server to honor a real changedFiles parameter. After the fix, a one-file save showed 0% node churn — exactly what we wanted from day one.
That server-side change is its own story. PR #739 in the API added changedFiles as an optional query parameter that re-parses only those files, and previousDomains to seed the domain classifier with the names from your last full build. Without seeding, domain names are 0–60% stable across runs. With seeding, 87–100%. The names you saw yesterday are mostly the names you see today.
Bug two: stale back-references
When function A in file foo.ts calls function B in file bar.ts, the shard for bar.ts says “B is called by A (foo.ts:42).”
If you then edit foo.ts and remove the call, what happens to bar.ts? Nothing changed in bar.ts. The watcher only emits foo.ts. The merge replaces foo’s outgoing edges with the new ones. bar.ts’s shard never re-renders. It still says “B is called by A.” Forever. Until the next full rebuild.
PR #99 fixed this by snapshotting the old callees of every function in every changed file before the merge runs:
for id, fn := range d.cache.FnByID {
if !changedSet[fn.File] {
continue
}
for _, callee := range d.cache.Callees[id] {
if callee.File != "" {
oldCalleeFiles[id] = append(oldCalleeFiles[id], callee.File)
}
}
}
After the merge, we walk the snapshot and mark every old callee file as affected. They re-render with the back-reference removed.
The same trick handles imports. If foo.ts used to import lib.ts and stopped, lib.ts’s shard needs to drop the “imported-by foo.ts” line. We snapshot oldImports the same way and feed both into computeAffectedFiles.
The pattern: before mutating shared state, snapshot the parts you’ll need to compare against later. You can’t ask “what changed?” if you only have the new state.
Bug three: dangling edges from unchanged files
Nodes in the graph have UUIDs. When the API re-analyzes a file, it assigns new UUIDs to that file’s nodes. The merge has to remap every relationship from the old UUIDs to the new ones — otherwise relationships from unchanged files end up pointing at IDs that no longer exist.
We had this working for File and Function nodes. We forgot Type and Class nodes. So if models.ts defined User, and service.ts (unchanged) had a relationship to User, that relationship still pointed at the old UUID after the merge. A dangling edge. The shard for service.ts still rendered fine because it had its own data, but the graph was internally inconsistent.
PR #999ac58 extended the remap table to cover Type and Class:
case n.HasLabel("Type"), n.HasLabel("Class"):
name := n.Prop("name")
if name != "" {
incTypeByKey[fp+":"+name] = n.ID
}
The lesson here is structural. Any time you have N kinds of node and you handle some of them, you have a bug in the ones you forgot. We now have a test that asserts every node label appears in the remap. New label types fail the test until they’re added.
One thing we deliberately don’t merge
Domains are different.
Domain classification is an LLM call that looks at the whole codebase and groups files into things like “auth,” “billing,” “ingestion.” If you send the API one file, the LLM produces domains based on that one file. The result is garbage at the repo level.
So PR #62 added a one-line rule to the merge: never replace domains on an incremental update. The domains you got from your last full build stay put. New files get assigned to the closest existing domain by directory-prefix matching. Domains only refresh on a full rebuild.
This is a deliberate piece of staleness. We picked it on purpose. A slightly old domain map beats a wrong fresh one every time.
Idempotency keys make polling safe
Once the merge logic is right, there’s still a network in front of it. What if the request times out and we retry? What if the network blinks and we send the same batch twice? What if you save the same file twice in two seconds?
Every request carries an Idempotency-Key header. From pkg/supermodel/client.go:
req.Header.Set("Idempotency-Key", idempotencyKey)
Full builds get a fresh UUID. Incremental updates get incremental- plus the first eight hex digits of a UUID. The server uses the key to deduplicate. Send the same key twice, get the same answer twice, do the work once.
This is the safety net under the polling loop. The loop is allowed to be a little dumb — duplicate triggers, retried requests, racing edits — because the key downstream guarantees we don’t double-build the graph.
Debounce, then batch
The watcher emits events. The daemon collects them. Then it waits.
If a new event arrives, the wait restarts. Two seconds of quiet means you’ve stopped typing, and we send everything pending as a single batch. From internal/shards/daemon.go:
case filePath, ok := <-d.notifyCh:
pendingFiles[filePath] = true
if debounceTimer != nil {
debounceTimer.Stop()
}
debounceTimer = time.NewTimer(d.cfg.Debounce)
case <-debounceCh:
files := daemonSortedKeys(pendingFiles)
pendingFiles = make(map[string]bool)
d.incrementalUpdate(ctx, files)
A “save all” across twelve files becomes one API call, not twelve. A formatter that touches a file three times in a second becomes one event, not three. The map updates once, the shards re-render once, and the agent sees the new state next time it reads.
The whole story end to end
You edit Foo.tsx and save. You also save Bar.tsx.
- Three seconds later, the watcher polls.
git diff --name-only HEADlistsFoo.tsxandBar.tsx. Two events emit. - The daemon stuffs both into a pending set and arms a two-second timer.
- You stop typing. The timer fires.
- We snapshot the old imports and old callees for both files.
- We zip the two files, generate a UUID like
incremental-3f2a9b1c, POST them with that key in the header. - The server re-analyzes the two files and returns a fresh graph piece.
- We merge: drop the old File/Function/Type/Class nodes for those two files, keep everything else, remap any dangling relationships from unchanged files to the new UUIDs, preserve the existing domain assignments.
- We compute affected files: the two changed files, everything that imports them, everything they import, everything that calls into them, plus the snapshotted old imports and old callees so disappearing references re-render.
- We re-render shards for that affected set.
- Your agent reads the fresh map on its next turn.
If step 5 fails and we retry with the same key, the server returns the same result without re-doing the work.
If you git checkout main instead of editing, step 1 takes a different branch: HEAD moved, so we ask git for the diff between the two commits and emit one event per file. Steps 2 through 10 are identical.
What we gave up
The three-second poll interval is the floor on how fast we react. For a tool that exists to feed an LLM, three seconds is invisible. Nobody types faster than git can answer.
We gave up sub-second reactivity. We got a watcher that does the right thing on every git operation, ignores everything that isn’t yours, runs the same on Mac and Linux and Windows, and has zero platform-specific bugs because it has zero platform-specific code.
We gave up a clean from-scratch build on every change. We got a long-running graph that costs cents to keep current instead of dollars to rebuild every save.
Worth it.
The code
The CLI is open source and Apache 2.0. The watcher is 170 lines. The merge is 230. Read it, fork it, steal the ideas.
- Watcher:
internal/shards/watcher.go - Merge, debounce, snapshot logic:
internal/shards/daemon.go - Idempotency header:
pkg/supermodel/client.go - Bug fixes worth reading: PR #62, PR #99
Repo: github.com/supermodeltools/cli
Try it
npm install -g @supermodeltools/cli
supermodel
Edit a file. Watch the shards update. Then git checkout a different branch and watch them update again. Same loop, both times.