Skip to content
rsdlt.dev
Go back

Building levi, Night One: From First Commit to 0.1.0 in 90 Minutes

This is the second post in the Building levi series. The first one covers what levi is and the decisions behind it. This one covers the first session: the night of October 15.

levi’s commit log for that night runs from 22:14 to 23:43. In those 90 minutes it went from the first commit to a tagged 0.1.0 with a README, a license, a changelog and CI. The first commit wasn’t a blank slate, though: it already included docs/plan.md and a 470-line git layer with tests. Here’s what got built, in order, and how I worked with Claude Code along the way.

1. A git layer that streams

levi doesn’t link a git library. It runs the git you already have, so your config, such as rename detection and diff algorithm, applies. The whole branch comes from one git log call:

const LOG_FORMAT: &str = "--format=%x1e%H%x00%h%x00%an%x00%ar%x00%s";

git(root).args([
    "log",
    "--no-color",
    "--no-show-signature",
    "--diff-merges=first-parent",
    "--name-status",
    "-z",
    LOG_FORMAT,
])

Three details matter here:

Diffs are fetched one file at a time, only when you select a file. Merges are diffed against their first parent, which is why levi needs git 2.31: that’s the release that added --diff-merges.

2. Three panes, drawn on stderr

The UI is ratatui: a search bar over three panes for commits, files and the diff. One design choice shaped everything after it. levi draws on stderr, not stdout:

/// Draws on stderr so stdout stays free for the picked sha: `git checkout $(levi)`.
fn init() -> Result<Tui> {
    let hook = std::panic::take_hook();
    std::panic::set_hook(Box::new(move |info| {
        restore();
        hook(info);
    }));
    enable_raw_mode()?;
    execute!(io::stderr(), EnterAlternateScreen)?;
    Ok(Terminal::new(CrosstermBackend::new(io::stderr()))?)
}

Because stdout stays clean, pressing Enter prints the selected sha and levi works as a picker in any shell command. The panic hook matters just as much: if levi crashes, it restores your terminal before printing the error, so you aren’t left in raw mode with no visible cursor.

3. Load in the background, drop stale work

A TUI that blocks on git feels broken. Thirteen minutes after the first commit, loading moved off the UI thread:

while let Ok(mut req) = req_rx.recv() {
    while let Ok(newer) = req_rx.try_recv() {
        req = newer;
    }
    // ...run git show for `req` only
}

The result on git.git, git’s own repository with 82,500 commits: the first frame shows up in under 0.1 seconds.

4. fzf-style search on every core

Search is what levi is for, so it got the most attention. The search bar always has focus, and as you type, the commit and file columns narrow together. A commit matches by subject, author, sha prefix, or any file it touched. Typing parse.rs answers “which commits touched parse.rs?” with no extra syntax.

Matching uses nucleo-matcher, the engine behind the Helix editor’s picker, so fzf syntax works: 'exact, ^prefix, suffix$, !not and smart case. Getting it fast took four techniques:

  1. Refilter once per frame, not once per keystroke. A burst of typing costs one pass.
  2. Rescan only previous hits when the query narrows. If the new query just extends the old one, nothing that failed before can match now. Negations, anchors and escapes break that rule, so they force a full pass:
// Appending only narrows: longer atoms and extra atoms both match less.
// Negations (`!`), anchors (`$`) and escapes can widen or reinterpret
// the query, so start over whenever they're involved.
const SPECIAL: [char; 3] = ['!', '$', '\\'];
  1. Match on every core. Each thread gets its own matcher, since matchers hold scratch buffers. Below 4,096 commits per thread, spawning costs more than it saves, so small repositories stay single-threaded.
  2. Render only visible rows. Drawing costs the same for 100 commits or 100,000.

On git.git, a refilter takes 5–12 ms, down from 25–45 ms single-threaded.

One small decision made search feel right: sha matching is prefix-only, not fuzzy. Fuzzy-matching hex strings would make queries like add or bad match nearly every commit.

5. Fail before the TUI opens

A TUI that opens and then shows an error is worse than no TUI. levi runs a preflight check first and exits with a one-line message:

Bare repositories work too. Quitting without picking a commit exits with code 130, like fzf, so sha=$(levi) && git show "$sha" stops cleanly when you change your mind. Integration tests run the real binary through every one of these paths.

6. Shipping it

The last 20 minutes went to making levi a real project, not just working code:

JobWhat it checks
Testcargo test on Linux and macOS
Lintrustfmt, and clippy with warnings as errors
MSRVBuilds on Rust 1.88, the declared minimum
DenyLicenses and security advisories

At 0.1.0, levi was about 1,100 lines of Rust without tests, and a 1.6 MB binary.

How Claude Code fit in

Every commit that night was written with Claude Code. Three things kept it on track:

CI was the last commit of the night, but from then on every change had to pass it, whoever wrote it.

Takeaways

The next post covers what happened after midnight, when a code search that took 55 seconds got down to 0.16.


I’m Rodrigo Santiago, a product leader who builds. I co-founded Foambyte with my brother Diego, a brewmaster who leads the product as CPO, and I designed and built the platform: architecture, frontend, backend, mobile and infrastructure. If you need someone who can own both the product and the code, let’s talk on LinkedIn.


Share this post:

Next Post
Building levi: A Small, Fast Git Diff Viewer in Rust, Written with Claude Code