← The journal

Gitsanity 2.0: The Linux Kernel in 51 Seconds, From a Terminal

I rewrote my Git analytics Mac app as a Rust terminal app in one evening with Claude Code. No database, the whole Linux kernel indexed in 51 seconds, and dates that are finally right.

Back in February I built Gitsanity, a Mac app that turns a Git repository into dashboards, in four days with OpenAI Codex. It had a star schema, DuckDB, parallel sharded indexing. It also had two things that kept nagging me. The Linux kernel took long enough to index that my own performance notes set "under five minutes" as the goal. And days were stored in UTC, so a commit I made at 11pm in Denver landed on tomorrow's bar.

Last night I opened the project in Claude Code and asked a different question: could this be a terminal app, in Rust, and could it load the Linux kernel really fast? Everything was up for reinvention, the architecture, the design, DuckDB too, as long as it did what the Mac app does and looked good doing it.

Two and a half hours later, brew install danielwanja/tap/gitsanity worked.

Gitsanity in the terminal showing 21 years of Linux kernel history

Quick Links

The numbers

Measured on a 14-core Apple Silicon Mac:

RepositoryCommitsFirst screenFiles knownFully indexedReopen
Linux kernel1,414,2342.1 s9.8 s50.9 s0.33 s
Swift197,2060.3 s1.6 s10.1 s
VS Code145,3040.2 s1.0 s4.8 s
Rails97,0740.1 s0.4 s1.7 s0.02 s

Those columns are the three stages the screen fills in from. After the first one you have commits, contributors and timelines. Then which files each commit touched. Then lines added and removed. The app is usable from stage one and the rest arrives while you look around.

Fast is worthless if it's wrong, so the per-commit file and line counts were compared against git log --numstat for 30,000 kernel commits: zero differences. On Rails the whole-history totals match too, apart from submodule pointer changes, which the app skips.

Measure first

I asked for an evaluation, not an app. So the first thing Claude wrote was a 300-line throwaway: walk every kernel commit, diff every tree, count every line, print the time. 53 seconds. Before a single widget existed, "is this even possible" had a number next to it, and the number was nearly six times better than the goal I'd written down for the Swift version. Only then did the real thing get built.

Three passes, and git never runs

The Mac app shells out to git log --numstat, split into shards. The Rust version doesn't run git at all. It reads the object database directly with gitoxide and fans out over every core:

  1. Walk the commits. A parallel graph traversal: each worker follows the first-parent chain itself and hands side branches to the pool. Single-threaded this took 6 to 8 seconds on the kernel. Parallel, about one.
  2. Diff the trees. Which files did each commit touch? This is a hand-written merge walk over raw tree objects, and it has exactly one subtle rule:
/// Git's tree order: directories sort as if their name ended in `/`.
fn cmp_entries(a: &Entry, b: &Entry) -> Cmp {
    let n = a.name.len().min(b.name.len());
    match a.name[..n].cmp(&b.name[..n]) {
        Cmp::Equal => {}
        o => return o,
    }
    let tail = |e: &Entry| {
        e.name
            .get(n)
            .copied()
            .unwrap_or(if e.mode == MODE_TREE { b'/' } else { 0 })
    };
    tail(a).cmp(&tail(b))
}
  1. Count the lines. Load both versions of every changed file and diff them with Myers, the same algorithm git uses. This is 41 of the 51 seconds.

The most instructive moment was in pass two. Diffing commits in time order took 12.8 seconds. Diffing them along first-parent chains took about 7, with no other change. A commit's parent tree is the next job's own tree, so the object cache stays hot. Same work, nearly half the time, purely from the order you do it in.

No database

I liked DuckDB in the Mac app. Fact tables, dimension tables, daily and monthly rollups, a query router that picked the coarsest rollup that could answer. So what replaced it?

Nothing. The whole history is a handful of plain columns in memory, sorted by time. That one decision makes a time window a pair of binary searches:

/// Index range of commits with `start <= time < end`.
pub fn range(&self, start: i64, end: i64) -> std::ops::Range<usize> {
    self.time.partition_point(|t| *t < start)..self.time.partition_point(|t| *t < end)
}

Every screen is then one linear scan over a slice. Recomputing the full dashboard for all 21 years of the kernel takes 81 ms. One year takes 7. There are no rollups to build, none to go stale, and nothing to route. The cache on disk is just those columns, compressed: 100 MB for the kernel, loaded back in a third of a second.

My reaction when I saw it run was "it's way better without DuckDB." A star schema is the right tool when you don't know the questions. A dashboard knows its questions.

Dates, finally right

The Mac app pre-bucketed everything into UTC days. The rewrite stores epoch seconds and derives days, months, weekdays and hours in your time zone when it draws. This test is the bug I lived with since February, written down:

fn late_evening_commit_belongs_to_the_local_day_not_the_utc_day() {
    // 2026-02-03 23:30 in Denver is already Feb 4 in UTC.
    let t = ts("2026-02-04T06:30:00Z");
    let denver = Clock::named("America/Denver").unwrap();
    assert_eq!(denver.date(t), "2026-02-03");
    assert_eq!(denver.weekday_hour(t), (1, 23)); // Tuesday
    let tokyo = Clock::named("Asia/Tokyo").unwrap();
    assert_eq!(tokyo.date(t), "2026-02-04");
    assert_eq!(tokyo.weekday_hour(t), (2, 15)); // Wednesday
}

Ranges are whole local days, and stepping to the previous period tiles exactly: March 1–31 goes to February 1–28, not to "31 days earlier." The tests cover the 23-hour and 25-hour days around daylight saving, half-hour zones like Kolkata, and Beirut, where midnight sometimes doesn't exist.

Then there are clocks that were simply wrong. The kernel has commits "authored" in 1970 and in 2085. Left alone, they stretch the "All" range across a century of mostly empty chart:

if c.author_time > c.commit_time + 2 * 86_400 || c.author_time < first_commit - 86_400 {
    c.author_time = c.commit_time;
    repaired += 1;
}

A date later than its own commit, or earlier than the first commit the repository ever recorded, falls back to the commit date. That's 618 kernel commits, and "All" now reads April 16, 2005 to today, which is when the kernel moved to Git.

The commit view shows both clocks: when it happened for you, and what time it was for the author.

The Commits view with message body, both clocks and the file list

What my terminal showed that the tests didn't

Claude checks a terminal UI the way I described for contextls: render into ratatui's TestBackend, turn the cell buffer into HTML, screenshot it with headless Chrome. The renders looked great.

Then I ran the real thing in Terminal at 185×78 and sent back five screenshots. The bars were sliced into stripes. The main chart had swallowed most of a very tall window. A sixth of its width sat unused. The Contributors view was eighteen rows and a lot of nothing.

The stripes were the interesting one. Terminal adds a little spacing between rows, so a column of █ glyphs shows gaps. Backgrounds don't:

// A full cell paints its background too: terminals that add line
// spacing leave a gap between glyph rows, but never between backgrounds.
let style = if fill == 8 {
    Style::new().fg(c).bg(c)
} else {
    Style::new().fg(c)
};

The empty space got filled with things worth having: a side panel for the selected contributor, the full commit message, a list of the largest commits. An off-screen render is not a terminal, and my screenshots did more for the design than any amount of generated ones.

One more from the honesty file. The first automated "does it launch" checks ran in a pseudo-terminal that was zero columns wide. They passed, having drawn nothing at all. Claude noticed, said so, and redid them at 160×50 with a small script that replays the escape codes into a grid.

The Contributors view with the side panel for the selected person

Lens: a commit graph for 1.4 million commits

Lens: refs on the left, the commit graph and the selected commit on the right

The first version of the graph ordered commits by date. Correct, and useless: the kernel in date order is hundreds of lanes wide, a wall of vertical lines with your commit somewhere off to the right. git log --topo-order has the answer, and it's a stack instead of a heap. Push a commit's parents when all their children are out, first parent first, so a merged branch comes off the stack in one run right under its merge:

while let Some(c) = stack.pop() {
    pos[c as usize] = order.len() as u32;
    order.push(c);
    // First parent goes on first, so side branches come off the stack before the mainline.
    for p in store.parents_of(c as usize) {
        if *p != NO_PARENT {
            pending[*p as usize] -= 1;
            if pending[*p as usize] == 0 {
                stack.push(*p);
            }
        }
    }
}

Now the kernel is a few lanes wide. The lanes are computed lazily, with their state checkpointed every 128 rows, so drawing any page means replaying at most 128 commits. Jumping to the very first kernel commit takes about a third of a second.

Press Enter on a branch or tag and the whole analysis re-runs on that ref. Rails at tag v8.1.1, 96,227 commits, was ready in 1.9 seconds.

Shipping, and what broke

The release pipeline is the one from contextls: cargo-dist, a v* tag, a GitHub Release, a formula pushed to my Homebrew tap. Copying a pipeline that worked two weeks ago should be boring. It wasn't:

  • CI failed on the first push. GitHub's runners had moved to Rust 1.100, released three days earlier, and it flags a loop that 1.99 on my Mac accepts.
  • The first release was cancelled. cargo-dist 0.33.1 asks for a macOS 14 runner to build for Apple Silicon, and GitHub has retired it. One override line pointing at macos-15 fixed it. contextls has the same problem waiting for its next release.
  • Secrets are write-only. I went to copy the tap token from the contextls repo. You can't. I had to go find where I'd saved it.
  • Homebrew 7 wants to trust the tap first. On my Mac it refused to load the formula until I ran brew trust danielwanja/tap.

What's not there yet

The Mac app still does things the terminal version doesn't: staleness and new-file tables, the storage treemap, repo hygiene checks, PDF reports. Renames are only detected when the content is identical, so a file that moved and changed counts as a delete plus an add. There's no mailmap support, and when a branch moves the app re-reads the whole history rather than just the new commits. With these numbers that's mostly fine, but it's not finished.

Try It

brew install danielwanja/tap/gitsanity
# Homebrew 7 may ask you to trust the tap first: brew trust danielwanja/tap

gitsanity                      # the repository you're in
gitsanity ~/code/some-repo
gitsanity --ref v1.2           # a branch or tag instead of HEAD
gitsanity --tz Europe/Zurich   # someone else's point of view

Number keys switch between the six views, the arrow keys step through time, - and + change the range, a filters everything to one contributor, and Enter on a file narrows every view to it. ? lists the rest.

Or without Homebrew: curl --proto '=https' --tlsv1.2 -LsSf https://github.com/danielwanja/gitsanity/releases/latest/download/gitsanity-installer.sh | sh

Timeline

One evening. At 8:30pm there was an empty folder. At 9:45, the first commit: five views and the kernel in 51 seconds. By 10:00 the layout had been reworked from my screenshots, at 10:10 Lens, file drill-down and live refresh were in, and at 11:00 the release was on Homebrew. About 6,400 lines of Rust, 13 tests, a 3.8 MB binary.

I didn't type any of it. I set the benchmark, said what looked off, and decided what shipped. The February version, the four days and the star schema, is what taught me what to ask for.


Daniel Wanja is a developer and founder of Nouvelles Solutions, Inc. When not rewriting his own apps, he's checking what the Linux kernel was up to on a Tuesday at 3pm.