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:

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

What it doesn't handle

These are gaps in the current code, not features:

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.

Download GitMon Free Trial Learn more

Related Guides