DEV Community

Cover image for Git Wrote the Graph — I Taught It to Skip 3,287 Commits
Mustafa ERBAY
Mustafa ERBAY

Posted on Originally published at mustafaerbay.com.tr

Git Wrote the Graph — I Taught It to Skip 3,287 Commits

Asking for one file's history in this blog repo had started to drag. A single .mdx file, a two-commit history, and a stopwatch reading of 53.8 ms — too much for a two-line answer. My first reaction was "3,500 commits, that's just how it is." Then a look inside .git turned up a file dated October 3:

$ ls -l .git/objects/info/
-r--r--r--  1 mustafaerbay  staff  208832  Oct  3 09:29 commit-graph
-rw-r--r--  1 mustafaerbay  staff     107  Oct  3 09:29 packs

$ git config --get-regexp 'maintenance|commitgraph|fetch.write'
$                                   # not a single line
Enter fullscreen mode Exit fullscreen mode

Curious situation. Nothing configured, git maintenance never set up, yet Git had written itself a commit-graph eight days earlier. The explanation sits in gc.writeCommitGraph: it defaults to true, and its documentation says the commit-graph is updated when git gc --auto decides housekeeping is required. A cleanup round left that file behind: Git had handed me a cache without being asked. The thesis of this piece: what it handed over was the half of the cache that did not help. The part that speeds up file-history queries was missing, and neither git gc nor git maintenance ever writes it on its own. Five tables of measurements, one line of source code, and a two-line recipe follow.

Environment: macOS (arm64), Homebrew git 2.48.1. The live repo holds 46,012 objects / 278 MB of packs and reaches 3,542 commits across all refs; it stayed read-only throughout. The timings come from a full copy made with git clone --no-local --no-hardlinks, which has 3,357 commits from HEAD and 3,505 across all refs. The 37-commit difference is the copy not carrying stash and remote-tracking refs; the HEAD history is the same, so the figures transfer one to one.

Opening the graph: which chunks are there, which are not

A commit-graph is a binary file and its header is not human-readable — but the format is documented, so reading the chunk list directly is easy enough. The first 8 bytes are, in order: a 4-byte signature (CGPH), one byte of format version, one byte of hash version, one byte of chunk count and one byte of base-graph count — that last one earns its keep with chained graphs. Then each chunk gets a 4-byte id and an 8-byte offset:

# chunks.py — commit-graph chunk list and commit count
import struct, sys
d = open(sys.argv[1], 'rb').read()
assert d[:4] == b'CGPH'
n, off, toc = d[6], 8, []
for _ in range(n + 1):                       # the last entry is a terminator
    cid = d[off:off+4].decode('latin1'); o = struct.unpack('>Q', d[off+4:off+12])[0]
    toc.append((cid, o)); off += 12
print('base graphs:', d[7])
print('chunks:', ' '.join(c for c, _ in toc if c != '\0' * 4))
for c, o in toc:
    if c == 'OIDF':  # last fanout entry = commits in THIS layer
        print('commits:', struct.unpack('>I', d[o+255*4:o+256*4])[0])
Enter fullscreen mode Exit fullscreen mode

Do not try to drop the terminating entry with c.strip(): a NUL byte does not count as whitespace in Python, strip() leaves it alone, and four NULs the terminal never shows ride along at the end of the list — they surface the day you pipe the output into grep. That trap caught me on the first pass.

Run against the file Git had written here:

$ python3 chunks.py .git/objects/info/commit-graph
base graphs: 0
chunks: OIDF OIDL CDAT GDA2
commits: 3462
Enter fullscreen mode Exit fullscreen mode

Four chunks: object-id fanout and list, commit data, and GDA2 — corrected commit dates, that is, version-2 generation numbers. The two missing chunks are BIDX and BDAT: the changed-path Bloom filters. The part Git writes with --changed-paths, the part that answers "could this commit have touched that path?" without doing a tree comparison.

And one more thing: the file holds 3,462 commits while the live repo has reached 3,542 across all refs. Eighty commits sit outside the graph. More on that below, because my first instinct about it turned out to be wrong.

Measuring three states

Three states were timed back to back in the copy: no graph, graph without filters, graph with filters. Each command ran 15 times; the table shows the minimum (less noisy than the mean — the medians gave the same ordering). The three columns of this table come from one sitting; the tables further down are separate runs, and the same query drifts by about 0.6 ms from run to run. Read the differences within a table, not the absolute numbers:

command no graph graph, no Bloom graph + Bloom
git log --oneline -- <one .mdx> 63.7 ms 54.9 ms 9.0 ms
git log --oneline -- <directory> (726 hits) 57.8 ms 50.8 ms 39.7 ms
git rev-list --count HEAD (3,357) 16.6 ms 6.9 ms 6.8 ms
git log --oneline --since=2026-09-01 9.1 ms 8.7 ms 9.0 ms
git merge-base HEAD HEAD~3000 15.5 ms 6.6 ms 6.6 ms
git branch --contains HEAD~2000 12.9 ms 6.9 ms 6.8 ms

What the table says differs a little from what most write-ups about the commit-graph say. The plain graph does its job on commands that walk history: rev-list drops from 16.6 to 6.9, merge-base from 15.5 to 6.6 — two and a half times. Generation numbers let Git cut the walk short. But on the question actually being asked here, "what is this file's history," the plain graph gets from 63.7 to 54.9; fourteen percent. Getting down to nine milliseconds takes the filters, and the filters are not in that file.

Why such a sharp difference? Because a path-limited git log does not spend its time walking commits, it spends it doing a tree comparison at every commit. The filter makes most of those comparisons unnecessary. Git's own tracing counts it out:

$ GIT_TRACE2_PERF=1 git log --oneline -- src/content/blog/tutorials/zfs-snapshot-....mdx 2>&1 >/dev/null \
    | grep -o 'statistics:{.*}'
statistics:{"filter_not_present":0,"maybe":15,"definitely_not":3287,"false_positive":13}
Enter fullscreen mode Exit fullscreen mode

For 3,287 commits Git never looked at a tree: the filter said "this commit definitely did not touch this path." For 15 it said "it might have," so Git compared those trees; 13 came back empty, leaving the two commits that really did touch the file — which is exactly the two lines git log prints. That line is where the number in the title comes from: 3,287 commits learned to be skipped.

A reader doing the arithmetic will spot something: 3,287 + 15 = 3,302, while HEAD holds 3,357 commits. The 55 in between are never put to the filter, because a path-limited git log simplifies history and never walks some branches at all; this repo has 35 merges and one root commit, and the remainder went untraced. The fourth field, filter_not_present, counts commits that are in the graph but have no filter — zero here, but in a chained graph whose layers are partly filtered and partly not, this is where they pile up. Apply the recipe to a mixed graph and that field is your health indicator.

Diagram

The staleness scare: 80 commits cost nothing

My first reading was "the graph is eight days stale, that's the problem." That guess was measured, and it was wrong. Writing the graph deliberately behind (feeding one older commit through --stdin-commits) and repeating the same two commands:

graph lag log -- one file rev-list --count
0 commits (fresh) 9.3 ms 6.7 ms
80 commits (the state in this repo) 10.6 ms 7.4 ms
1,000 commits 31.9 ms 10.2 ms
2,000 commits 47.0 ms 13.6 ms
no graph 63.9 ms 16.6 ms

An 80-commit lag costs 1.3 milliseconds; barely above measurement noise, practically nothing. The cost grows roughly in proportion to the number of commits the graph fails to cover: derived from three of those rows, each uncovered commit costs somewhere between 16 and 23 µs (that range is a derivation, not a direct measurement; its lower end comes from the noisy 1.3 ms row at 80, so do not lean on it). In other words, nobody feels the difference until a repo's graph falls a thousand commits behind.

Git happens to agree. The function that decides whether the commit-graph task should run in automatic mode walks the refs, counts commits missing from the graph, and defaults the threshold to 100:

/* builtin/gc.c — should_write_commit_graph() */
data.limit = 100;
repo_config_get_int(the_repository, "maintenance.commit-graph.auto", &data.limit);
Enter fullscreen mode Exit fullscreen mode

80 < 100. So in this repo the automatic path would not even consider the graph worth refreshing. Granted, git maintenance was never set up here; but setting it up would win nothing on the staleness side. The win is entirely on the other side.

Why the filters never get written

This is the real finding. The commit-graph task in git maintenance does not write the graph itself; it spawns git commit-graph write as a child process. Its arguments are identical in my version and in 2.56.0, today's release:

/* builtin/gc.c — run_write_commit_graph() */
strvec_pushl(&child.args, "commit-graph", "write",
             "--split", "--reachable", NULL);
Enter fullscreen mode Exit fullscreen mode

No --changed-paths. The git gc path is similar: it triggers a graph write with no filter flag. So setting up maintenance keeps the graph fresh but never creates the filters. A measurement confirms it: in a repo whose graph had been deleted, git maintenance run --task=commit-graph produced a chained graph under objects/info/commit-graphs/ whose chunks were OIDF OIDL CDAT GDA2 — still no filters, and log -- file stayed around 55 ms.

The documentation says so plainly. The entry for commitGraph.changedPaths states that with the setting unset, filters are written only if they already exist in the current graph file. The source carries the same logic, with the comment left in:

/* commit-graph.c — write_commit_graph() */
if (!(flags & COMMIT_GRAPH_NO_WRITE_BLOOM_FILTERS)) {
    /* We have changed-paths already. Keep them in the next graph */
    if (g && g->bloom_filter_settings) {
        ctx.changed_paths = 1;
Enter fullscreen mode Exit fullscreen mode

That sentence is the whole recipe, really: once the filters exist, later writes carry them along. Verified by experiment — write a graph with filters, add five commits, run git maintenance run --task=commit-graph, and the new chain layer arrives with BIDX BDAT too:

  graph-2a9db0f357bd2c552674  base=1  chunks=OIDF OIDL CDAT GDA2 BIDX BDAT BASE  commit=5
  graph-fe19cc73eb301f911081  base=0  chunks=OIDF OIDL CDAT GDA2 BIDX BDAT      commit=3505
Enter fullscreen mode Exit fullscreen mode

git gc carried them along the same way, leaving the file byte-for-byte identical. So a single manual nudge is permanent; after that the upkeep can go back to Git.

The recipe: two lines, but check your version

There is a fork here, and skipping it makes the recipe fail silently. Git 2.52.0 introduced a setting called commitGraph.changedPaths: when it is on, every commit-graph write also writes filters — including the child process git maintenance spawns, since that process reads the same configuration. A tag-by-tag sweep of the config documentation puts it clearly: the setting is absent in 2.51 and present in 2.52. Worth stating plainly: that path could not be timed on this machine, whose Git is older; the two lines below are read from the source and the documentation, not from a measurement.

On Git 2.52 or later:

git config commitGraph.changedPaths true
git commit-graph write --reachable          # first write
git maintenance start                       # hand upkeep back to Git (optional)
Enter fullscreen mode Exit fullscreen mode

Below Git 2.52 — the case here — the setting is ignored, and no warning is printed. That was measured too: on 2.48.1, with the setting at true, a plain write produced OIDF OIDL CDAT GDA2, 211,412 bytes; no filters. Unknown configuration keys are silent for Git, which makes "I set it, so it's done" an easy trap. On an older version the right move is to write the filters once by hand:

git commit-graph write --reachable --changed-paths   # create the filters once
git maintenance start                                # later writes carry them along
Enter fullscreen mode Exit fullscreen mode

Either way, confirm the result through tracing rather than timings — milliseconds vary by machine, chunk lists do not:

python3 chunks.py .git/objects/info/commit-graph      # BIDX BDAT should appear
GIT_TRACE2_PERF=1 git log --oneline -- <a file> 2>&1 >/dev/null | grep -o 'statistics:{.*}'
Enter fullscreen mode Exit fullscreen mode

If definitely_not is greater than zero, the filters are working. With a chained graph the file is not standalone but under objects/info/commit-graphs/; point the script at the .graph files there, and remember the commit count is per layer (my chain printed two lines, 3,505 and 5).

One more warning for anyone turning the setting on: commitGraph.changedPaths is only picked up by the configuration reader of the git commit-graph write command itself. git gc writes the graph in-process, without spawning that command, and that path never reads the key. So even on 2.52 and later, gc --auto will not produce filters for you; what produces them is git commit-graph write and the child process git maintenance spawns. Given that the round which left the file in the opening story was precisely a gc --auto, that distinction changes the ending.

If the six-fold cost of the first write bothers you on a large repo, there is --max-new-filters <n>: at most n filters are computed in the new layer and the rest wait for later rounds (commitGraph.maxNewFilters makes it permanent). For computing filters retroactively over earlier layers, the documentation suggests --split=replace. And a version matter: the filters have a second hash version, selected through commitGraph.changedPathsVersion; the source says version 1 carries a bug in its hash function and version 2 uses seeded murmur3. Turn version 2 on and you have to account for everyone sharing that repo with older Git versions.

The recipes above say start, not register, on purpose: per the documentation, register only prepares the configuration so that any scheduled maintenance covers this repo; the command that installs the background scheduler (an hourly job through launchctl on macOS) is start. Run only register without having run start before, and nothing gets scheduled. register has one more side effect worth knowing: it sets maintenance.auto to false in that repo.

Where the filter quietly gives up

Expecting every path query to speed up once the filters are written is a mistake. Four variants, same repo, filters in place:

query time
git log -- <one file> 9.6 ms
git log -- <file1> <file2> 61.4 ms
git log -- 'src/content/blog/tutorials/*.mdx' 128.0 ms
git log --follow -- <one file> 328.2 ms

Rows two and three are my version's limit: on 2.48.1, passing two paths or a wildcard pattern disables the filter outright, because forbid_bloom_filters() allows neither multiple paths nor patterns containing wildcards. Tracing shows it bluntly — on the two-path query the Bloom statistics line is never printed at all. That restriction was later relaxed: multiple paths in 2.51.0, wildcards and pathspec magic such as :(attr) in 2.52.0. So "ask for two files and the filter goes quiet" is not a universal Git truth but the truth of one version range; do not decide for your own version without looking at the trace.

The --follow row is a version-independent warning: it delivers the same two-line answer at 34 times the price, because rename detection is engaged at every step. Unless the file really moved, do not ask for --follow.

There is also a place where the filter gives up by design. In bloom.h the ceiling is fixed at 512; if a commit touches more paths than that, Git writes an all-ones filter for it, so that commit answers "may have touched it" on every query. The bulk edits in this repo fall exactly into that class — seven of the 3,357 commits reachable from HEAD exceed 512, the largest at 1,309 files. At most seven of the 13 empty comparisons above are those; the rest are the false positives inherent to a Bloom filter (default settings give 10 bits per entry and 7 hashes, so a small but non-zero false-positive rate). A filter promises speed, not certainty; its "definitely unchanged" side is trustworthy, its "maybe" side is not.

This also explains why the directory query only went from 57.8 to 39.7: 726 commits really did touch that directory. The filter pays off when there are commits to skip; on a path every commit touches, it has nothing to do.

The bill, too

Filters are not free. Write cost and file size in the same repo:

write time file size
commit-graph write --reachable 32.6 ms 211,412 bytes
... --changed-paths 206.8 ms 269,015 bytes

Six times slower to write and 27 percent larger. In a 3,500-commit repo that is noise for a task running once an hour; in a repo with hundreds of thousands of commits the arithmetic has to be redone locally, because write cost grows with commit count. What it buys was measured not with a single query but with a real chore — asking for the last change date of all 760 .mdx files in this directory, one by one:

for f in src/content/blog/tutorials/*.mdx; do git log -1 --format=%ci -- "$f" >/dev/null; done
Enter fullscreen mode Exit fullscreen mode
graph state 760 files per file (mean)
no filters 20.8 s 27.4 ms
filters 7.6 s 9.9 ms
filters removed again (--no-changed-paths) 21.4 s 28.1 ms

The "per file" column here is the total divided by 760, that is, a mean — not directly comparable with the minimums in the tables above; that is why the filtered row's 9.9 ms sits above the 9.0 ms minimum of a single query. The third row exists purely as a check on myself: if the speed-up had come from a warming file cache, removing the filters would not have returned the old number. It did. Since git log -1 can stop early, the gain here is roughly three times rather than the seven of a single query; still thirteen seconds, and sweeps like this come up often during content maintenance.

Incidentally, this repo's build pipeline makes no per-file git log call — that was checked, there is none. So the gain is not billed to CI time, it is billed directly to waiting at the keyboard. Small, but every day.

A decision frame

Four questions before applying any of this to your own repo:

  • Do you ask for file history? git log -- path, git blame, per-file date sweeps, "when did this article last change" questions. If not, skip the filters; the plain graph already speeds up the walking commands, and Git writes that one by itself.
  • What version are you on? git --version. Below 2.52 the commitGraph.changedPaths setting is silently ignored; write the filters once by hand. Below 2.51 two-path and wildcard queries will not use filters at all.
  • How large is the repo? Write cost grows with commit count, and so does the gain. Start with GIT_TRACE2_PERF=1 and look at definitely_not: a large number means there is something to win.
  • Do you have bulk commits? Commits touching more than 512 paths stay unfiltered. Counting them in your repo is a matter of tallying git log --name-only output.

One thing to avoid: run a write with --no-changed-paths after creating the filters (or leave that flag in a script) and the filters vanish silently, with nobody warning you. Glancing at the chunk list now and then is the only honest health indicator this cache has.

What a half-done cache teaches

What stayed with me here is that the cache was not missing but half-done. A broken cache gets noticed; you run git commit-graph verify, you get an error, you write a new one. A half-done cache is correct, fresh and valid — it simply does not speed up the question you are asking. For eight days git log ran slowly and I read that as "the repo is growing," while a file inside .git was telling me otherwise; a look at the chunk list was all it took.

That, to me, is the real payoff of a measuring habit: not gaining speed, but dropping the wrong story early. Two stories went this round — "the repo grew," and then "the graph went stale." The second was the more convincing one, and measurement cut it anyway. The same reflex showed up in the piece where git status scanned 36,811 entries for three lines; there too the place I suspected was innocent. Another species of cache that counts without carrying anything turned up in SQLite's WAL checkpoint.

A five-minute homework assignment for your own repos: python3 chunks.py .git/objects/info/commit-graph. If there is no BIDX, Git did half a job for you too.

Official Sources

Top comments (0)