Git Developer Guide
Reading git status without running git: the index, packs and one cache that skips most of the work
What "status" means across a whole laptop
The question is the one you ask before closing a laptop: which of the dozens of repositories on this machine have something I'd lose? For each repository that means the current branch (or a detached HEAD), how far it is ahead of and behind its upstream, which files are modified, deleted, staged, conflicted or untracked, how many stashes are sitting there, and which local branches are already merged or long untouched.
The obvious answer is a loop that runs git status in every folder. That works, and git is quick: on an M5 Max, a serial loop of git --no-optional-locks status --porcelain over 15 repositories took 0.12 s. The reader described here took 0.11 s for the same 15. So this isn't a story about beating git on speed. It's about what a background tool shouldn't do: start a process per repository every time it refreshes, depend on git being installed, or write anything. By default git status refreshes the index and writes it back, which is why the git status docs tell scripts running it in the background to pass --no-optional-locks, since the lock can make other git commands fail. A reader that only opens files for reading can't collide with your own git commit.
Everything below is a description of one such reader, written in Swift, and every limit it has is listed near the end. Format details link to git's own documentation.
The files it reads
None of this needs a git library. These are plain files under .git, laid out in gitrepository-layout:
HEAD: eitherref: refs/heads/mainor a bare commit hash, which is a detached HEAD.- Loose refs under
refs/headsandrefs/remotes, pluspacked-refs. When a ref is in both, the loose file wins. Lines starting with^inpacked-refscarry the commit a tag points at and are skipped. config, only for the[branch "…"]sections'remoteandmergekeys (that is the upstream) and the list of[remote "…"]names.logs/refs/stash. Git rewrites this log on drop and pop, so its line count is the number of stashes.index, the binary staging area, and the object store: loose objects underobjects/xx/and pack files underobjects/pack.
The first trap is linked worktrees. In a worktree made with git worktree add, .git is a file containing gitdir: /path/main/.git/worktrees/name, and that directory holds only the per-worktree files such as HEAD and index. Refs, objects and config live in the main repository, named by the commondir file next to them (git-worktree details). An early version read refs from the worktree directory and found almost nothing, so every worktree showed no branches and no upstream. Now HEAD and index come from the worktree's directory and everything else from commondir.
The index, and what "modified" costs
The index starts with DIRC, a version and an entry count. Versions 2, 3 and 4 are read. Each entry has a 62-byte fixed part: ctime, mtime, device, inode, mode, uid, gid, size, the 20-byte blob hash and 16 bits of flags, two of which are the merge stage. A stage above 0 means a conflict, and those paths are reported as conflicted. Version 3 adds two bytes of extended flags when a bit says so. Version 2 and 3 entries are padded to a multiple of 8 bytes. Version 4 drops the padding and prefix-compresses each path against the one before it: a varint of bytes to strip from the end of the previous path, then a NUL-terminated suffix.
For each stage-0 entry the reader calls lstat on the file. Missing means deleted. A size or whole-second mtime that differs from the index means modified. Git compares more than that. The racy-git notes list mode, mtime, ctime, uid, gid, inode and size, and git then checks content in the racy case. The reader never hashes file contents. One side effect works in its favor: when git writes an index with racily clean entries, it truncates their cached size to zero, so the reader sees a size mismatch and says "modified" rather than missing an edit. Both the size and mtime are compared truncated to 32 bits, as git stores them, so a file over 4 GiB doesn't overflow anything.
Finding one object in a pack
Commits and trees are needed for ahead/behind counts, branch dates and staged changes. In any repository that has been cloned or garbage collected, almost all of them are in packs, not loose files. A pack comes in two files, described in gitformat-pack. The version 2 .idx holds the magic \377tOc, a version, a 256-entry fanout table, the sorted object names, a CRC32 per object, a 4-byte offset per object, and an 8-byte offset table for packs over 2 GiB.
The fanout table is what makes lookups cheap. Entry n is the number of objects whose first byte is at most n, so two reads give the slice of the sorted table that can hold the hash, and a binary search over that slice takes a handful of comparisons:
let firstByte = Int(target[0])
var low = firstByte == 0 ? 0 : fanout(firstByte - 1)
var high = fanout(firstByte)
// binary search names[low..<high] for the 20-byte hash
If the offset found has its top bit set, the low 31 bits index the large-offset table instead. Both files are memory-mapped, and packs are tried newest first, because the commits an ahead/behind walk asks for are usually the ones you just fetched.
At that offset in the .pack, a varint gives the object type in 3 bits and its inflated size. Types 1 to 4 are commit, tree, blob and tag, stored whole. Type 6, OFS_DELTA, names its base by a backward distance from its own start, in git's other varint form, where each continuation byte adds one so encodings stay unique. Type 7, REF_DELTA, names its base by a 20-byte hash, which this reader looks up in the same pack. A delta body is two varints, the base size and the result size, then instructions. A byte with the top bit set copies a range from the base, and its low bits say which offset and size bytes follow (a size of zero means 0x10000). A byte from 1 to 127 inserts that many literal bytes. Zero is reserved.
Two details protect against corrupt or hostile packs. Delta chains are followed at most 64 deep. Git packs to a depth of 50 unless told otherwise (git-pack-objects), so normal repositories are well inside that. And a pack never records how long an object's compressed data is, only the inflated size, so zlib is handed the rest of the file and stops at Z_STREAM_END. The size from the header bounds the output buffer, so a broken stream can't make the reader allocate without limit, and objects over 64 MiB aren't inflated at all. Commits and trees are far smaller than that, and blob contents are never read.
The shortcut: the index already knows if anything is staged
"Is anything staged?" means "does the index differ from HEAD's tree?". Answering it directly means flattening HEAD's tree into a path-to-hash map, one inflated tree object per directory. Across a machine's worth of repositories, that is most of a scan's cost. But git usually has the answer already. The index's cache-tree extension, TREE, stores the tree the index would write, and when a path is updated in the index, git invalidates every tree node above it by setting its entry count to -1 (index-format: cache tree).
Here is the extension in a fresh repository with README.md and src/app.swift, right after a commit. The root record has an empty path, then 2 1 (two entries, one subtree) and the root tree's hash, ba583c34…, which is exactly what git rev-parse HEAD^{tree} prints:
$ git rev-parse HEAD^{tree}
ba583c34e2619269aa044af07d437fc86a12406d
TREE extension:
00000000: 5452 4545 0000 0035 0032 2031 0aba 583c TREE...5.2 1..X<
00000010: 34e2 6192 69aa 044a f07d 437f c86a 1240 4.a.i..J.}C..j.@
00000020: 6d73 7263 0031 2030 0a27 6731 0723 4978 msrc.1 0.'g1.#Ix
00000030: 576a 467c f25c 18eb 65d3 2fe7 bc WjF|.\..e./..
After editing README.md and running git add README.md, the root record reads -1 1 and carries no hash. The src record is untouched, because nothing under it changed:
00000000: 5452 4545 0000 0022 002d 3120 310a 7372 TREE...".-1 1.sr
00000010: 6300 3120 300a 2767 3107 2349 7857 6a46 c.1 0.'g1.#IxWjF
00000020: 7cf2 5c18 eb65 d32f e7bc |.\..e./..
So the check is three steps. Read HEAD's commit (one object), read the root of the cache tree, and if it is valid and equal to the commit's tree, nothing is staged. No tree object is read at all:
guard let commit = store.commit(sha: headSHA) else { return .unknown }
if let cacheTree = index.cacheTreeSHA, cacheTree == commit.treeSHA {
return .knownClean
}
guard let tree = GitTreeReader.flatten(treeSHA: commit.treeSHA, store: store)
else { return .unknown }
return .tree(tree)
This is a proof, not a guess, because git invalidates the node whenever something is staged. It is also the state nearly every repository is in. It doesn't always hit, though. In the same test repository, git restore --staged README.md left the root at -1 even though nothing was staged any more. Then the reader falls back to the slow path: flatten HEAD's tree (skipping submodule entries), and call a path staged when the index has a different blob hash for it or HEAD has a path the index doesn't, which is a staged deletion. A repository with no commits yet has no HEAD tree, so everything in its index is staged.
Ahead, behind and merged
The ahead and behind counts are the quantity git rev-list --left-right --count main...origin/main prints (git-rev-list). The upstream comes from config, and its commit from the remote-tracking ref your last fetch left, so like git status this never touches the network. The walk starts from both tips, goes newest first by committer date, and marks each commit with the side or sides that reach it. It stops once every commit still waiting is reachable from both, because nothing below that line can be ahead or behind. Counting happens only after the walk, once "reachable from both" has been pushed down to every parent the walk touched. A commit is often reached from one side first and from the other later, and that is guaranteed when every commit shares one timestamp, as scripted history often does. Counting on the way past would lock in the first, wrong answer. The walk visits at most 8,000 commits, and if it runs out, or hits a missing object in a shallow or partial clone, the counts are marked approximate rather than shown as exact.
"Merged" for a local branch means its tip is an ancestor of the repository's default branch: whatever origin/HEAD points at, otherwise main, master, develop or trunk. It doesn't mean merged into whatever happens to be checked out. A branch on the same commit as the default branch counts as merged. The ancestor walk skips commits more than a day older than the branch tip, allowing for clocks that disagree between machines, and has the same 8,000-commit budget. When the walk can't settle the question, the answer is "unknown", and unknown is never shown as merged.
Untracked files without walking node_modules
Untracked files are whatever is on disk and not in the index, minus what git ignores. The reader loads ignore rules the way git does: the global excludes file ($XDG_CONFIG_HOME/git/ignore or ~/.config/git/ignore), .git/info/exclude, then each directory's .gitignore as the walk enters it. The last matching pattern wins, so !negations work. An ignored directory is pruned at once, so a node_modules listed in .gitignore costs one lstat, not a traversal.
Two habits come straight from git status. A directory with nothing tracked in it is reported once as scratch/, the way the default normal mode of --untracked-files shows untracked directories. And dotfiles are reported, because git reports them. Symlinks are treated as files and never followed, and a nested repository or submodule is left to be scanned as its own repository. The walk has a budget of 20,000 directory entries. If it runs out, the repository is flagged as incomplete instead of being called clean.
The traps, in one list
- A linked worktree's
.gitdirectory holds only its ownHEADandindex. Refs and config come fromcommondir. - A repository where nothing was ever staged has no
indexfile at all. That is an empty index, so every file is untracked. It is not an unreadable index. An index that exists but can't be parsed flags the repository as incomplete. - Staged work has to count as uncommitted. The worst possible answer is "clean" for a repository where you ran
git addand closed the laptop. - "Merged" means an ancestor of the default branch, and "can't tell" is never shown as merged.
- Every limit (the 20,000-entry walk, the 8,000-commit walks, a 200,000-path cap on flattening a tree) ends in "incomplete" or "approximate", never in a quiet "clean".
What it doesn't handle
These are gaps in the current code, not features:
- Version 1 pack indexes. Only version 2
.idxfiles are read. A version 1 index is skipped, not treated as "object absent". - SHA-256 repositories. Object names are assumed to be 20-byte SHA-1 hashes throughout. Repositories made with
git init --object-format=sha256(git-init) aren't supported. - Split index. With
core.splitIndexon, most entries live in a separate shared index file named by thelinkextension. That file isn't read. - Untracked cache and fsmonitor. The
UNTRandFSMNextensions are ignored. The reader does its own untracked walk every time, which is correct but doesn't get the speedup those caches give git. - Stat detail. Only size and whole-second mtime are compared, and contents are never hashed. A file you saved with identical bytes shows as modified until git refreshes the index.
git statusdoes that refresh, and this reader never writes. - A custom
core.excludesFile. Only the default global excludes path is read, so patterns in a file set through that key aren't applied. - Unusual packs. Delta chains deeper than 64, which git allows up to 4,095 with an explicit
--depth, andREF_DELTAbases stored in a different pack aren't resolved. An object that can't be read makes the result approximate or incomplete, never clean.
How it's checked against git
Hand-written .git fixtures only prove the reader agrees with its author. The real tests build repositories with the actual git binary, push to a local bare remote, often run git gc --prune=now (or --aggressive) so that everything ends up packed and delta-compressed, and then check the reader's answer against the state git produced. They cover a diverged branch counted on both sides, history where every commit has the same timestamp, staged work after git add agreeing on both sides of the cache-tree shortcut, a staged git rm, ignored files, a wholly untracked directory collapsed to one entry, index version 4, a merge conflict, a linked worktree, packed and loose copies of the same objects reading identically, and a tracked file over 4 GiB. The suite has 32 tests, and all of them passed on 2026-10-24 against Git 2.54 (Apple Git-157).
As for the numbers: it takes about 10 ms per repository, and 96 repositories scan in about 0.9 s on an M-series Mac, up to 16 at a time (twice the core count, capped at 16). The 15-repository rerun above took 0.11 s. Rerun it on your own machine before you trust either figure. Scans happen when the popover opens, then every 60 seconds while it stays open, plus one at launch so the menu bar count isn't blank. Nothing runs while it's closed, which is why CPU use is 0.0% when idle.
How GitMon shows it
This is how GitMon, a menu bar app that shows every repository with uncommitted or unpushed work, stays at zero CPU. It's read-only: it never fetches, commits, pulls or pushes, and it never writes to .git.