Jan Smrcka
Jan Smrcka

Full-stack Engineer

Full-time at ELI · Taking select contracts
Back

differ

GoBubble TeaChroma
differ terminal diff viewer screenshot

You run git diff in a terminal. A wall of uncolored text scrolls past — no navigation, no syntax highlighting, no way to stage individual files. So you alt-tab to your IDE, wait for it to index, click through the diff viewer, lose your terminal state, alt-tab back. Repeat.

differ exists to kill that context switch.

The Gap

There's a spectrum of diff tools, and nothing sat in the right spot:

ToolReadabilityFriction
git diffLow — raw unified diff, no color, no navigationZero — already in terminal
IDE diff viewerHigh — syntax highlighting, side-by-side, stagingHigh — full context switch
differHigh — syntax highlighting, two panels, vim keysZero — stays in terminal

The moment it clicked was writing this tmux binding:

bind g display-popup -E -w 90% -h 90% "cd #{pane_current_path} && differ"

One keystroke. A floating popup with a file list on the left, syntax-highlighted diff on the right. Stage files with tab, commit with c (AI-generated message pre-filled), push with P. Close the popup. You never left the terminal.

Architecture in 10 Files

The entire tool is ~3,100 lines of Go across 10 source files with 5 dependencies:

main.go              →  entry point (7 lines)
cmd/root.go          →  CLI: differ, differ log, differ commit
internal/
  config/config.go   →  ~/.config/differ/config.json loader
  git/repo.go        →  all git operations via os/exec
  theme/theme.go     →  color hex values only (no UI imports)
  ui/
    model.go         →  Bubble Tea model: file list, diff, commit, branch picker
    log.go           →  commit log browser
    diff.go          →  unified diff parser + split/unified renderers
    highlight.go     →  Chroma syntax highlighting
    styles.go        →  lipgloss styles, bridges theme → UI

Three design decisions shaped everything:

Git via os/exec, not go-git. Every git operation is a subprocess call with --no-ext-diff --color=never for predictable, parseable output. This avoids a massive dependency and works with any git configuration — aliases, hooks, custom merge drivers. If git diff works in your terminal, differ works too.

Theme decoupled from styles. theme.go is pure data — a struct of 30 hex color strings, zero UI imports. styles.go converts those hex values to lipgloss styles. Themes can be validated with string checks and math (WCAG contrast ratios), no rendering needed.

Bubble Tea async messages. Every git call, AI commit generation, and file read returns as a tea.Cmd — the Update() loop never blocks. This is what makes 2-second auto-refresh and AI commit message generation feel instant. The UI stays responsive even when git diff takes 500ms on a large repo.

A concrete example — the syntax highlighting function that reconciles two color systems:

// highlightLine applies Chroma foreground colors but preserves
// the diff background (green for added, red for removed).
func highlightLine(content, filename, bgColor string) string {
    lexer := getLexer(filename)
    iterator, _ := lexer.Tokenise(nil, content)
 
    var b strings.Builder
    for _, token := range iterator.Tokens() {
        entry := chromaStyle.Get(token.Type)
        fg := tokenForeground(entry)
        if fg != "" {
            style := lipgloss.NewStyle().
                Foreground(lipgloss.Color(fg)).
                Background(lipgloss.Color(bgColor))
            b.WriteString(style.Render(token.Value))
        }
    }
    return b.String()
}

Chroma wants to set both foreground and background. The diff viewer needs the background to indicate added/removed lines. The solution: extract Chroma's foreground token-by-token, apply it with lipgloss, and force the background to the diff line's color. Two rendering systems, one composable result.

TDD as AI Development Methodology

TDD wasn't there from day one. The first 22 commits built the core — file list, diff parser, syntax highlighting, commit mode, log browser. No tests. Moving fast, seeing if the concept worked. It did.

Then I wrote the first test suite. Everything broke in CI — tests picked up the host machine's ~/.gitconfig, pulling in GPG signing, custom hook paths, and personal email as commit author. Classic "works on my machine."

That debugging session was the turning point. I adopted strict Red-Green-Refactor for all AI-assisted development. With AI coding assistants, RGR costs more context per feature — the AI has to process test files alongside implementation. But the payoff is clear: the test defines what "done" means, so the AI doesn't drift into speculative implementations. One pass, no back-and-forth.

22% of the codebase is tests.

Isolated git test infrastructure

Every test that touches git creates a completely isolated environment:

func setupTestRepo(t *testing.T) *Repo {
    dir := t.TempDir()
    fakeHome := t.TempDir()
    env := []string{
        "HOME=" + fakeHome,
        "GIT_CONFIG_NOSYSTEM=1",
        "GIT_CONFIG_GLOBAL=/dev/null",
    }
    // git init + repo-local config
    run("init")
    run("config", "commit.gpgsign", "false")
    run("config", "core.hooksPath", filepath.Join(fakeHome, "no-hooks"))
    return repo
}

GIT_CONFIG_NOSYSTEM=1 prevents reading /etc/gitconfig. GIT_CONFIG_GLOBAL=/dev/null blocks ~/.gitconfig. A fake HOME ensures nothing leaks. The hooks path points to a nonexistent directory so no pre-commit hooks fire. Complete isolation — the test creates its own universe.

WCAG contrast ratio validation

When I switched from GitHub's color scheme to Catppuccin, I needed to verify readability hadn't regressed. Instead of eyeballing it, I wrote the WCAG relative luminance formula into the tests:

func relativeLuminance(hex string) float64 {
    r, _ := strconv.ParseInt(hex[1:3], 16, 64)
    g, _ := strconv.ParseInt(hex[3:5], 16, 64)
    b, _ := strconv.ParseInt(hex[5:7], 16, 64)
    linearize := func(c int64) float64 {
        s := float64(c) / 255.0
        if s <= 0.04045 { return s / 12.92 }
        return math.Pow((s+0.055)/1.055, 2.4)
    }
    return 0.2126*linearize(r) + 0.7152*linearize(g) + 0.0722*linearize(b)
}
 
func checkContrast(t *testing.T, th Theme, label string) {
    pairs := []contrastPair{
        {th.Fg, th.Bg, 4.5, "Fg/Bg"},           // WCAG AA body text
        {th.AddedFg, th.AddedBg, 3.0, "Added"},  // WCAG AA UI elements
        {th.RemovedFg, th.RemovedBg, 3.0, "Removed"},
        // ... 8 pairs total
    }
    for _, p := range pairs {
        ratio := contrastRatio(p.fg, p.bg)
        if ratio < p.minRatio {
            t.Errorf("%s: contrast %.2f < %.1f", p.label, ratio, p.minRatio)
        }
    }
}

This caught two real issues: the help key color was too faint on the dark background, and the status bar contrast was below 3.0:1. One-line hex fix each — but without the test I'd have shipped unreadable UI.

The measurable difference

After adopting strict RGR, the commit patterns changed visibly. The search-in-diff feature (first post-TDD feature) shipped with 56% test code. The Catppuccin theme switch had a 1:1 ratio. Tests weren't an afterthought — they were the specification that both I and the AI coded against.

Human-AI Collaboration

differ uses AI in two ways. The tool itself generates commit messages — it pipes git diff --staged (truncated to 8000 chars) into a configurable command and pre-fills the commit input. Any CLI that reads stdin works.

The development process was also AI-assisted. The project includes a .claude/ directory with instruction files that serve a dual purpose: documentation for human contributors and constraints for the AI. The architecture rules ("no go-git", "no inline styles", "all styles in styles.go") prevent both humans and AI from making inconsistent decisions. The TDD workflow is encoded as a skill file — red-green-refactor, not "write code then maybe add tests."

The project's own development methodology became a feature of the tool. The same AI that helped write the diff parser also generates commit messages through it.

What I'd Do Differently

model.go is too large. At 1,302 lines, it handles four modes (file list, diff, commit, branch picker) in one file. Each mode should be its own file with update*Mode and render*Mode functions extracted. I knew this early and kept going — velocity over structure. That debt is now real.

Polling is crude. Auto-refresh uses a 2-second ticker. Filesystem watching (fsnotify) would be more efficient, but it adds a dependency and platform-specific edge cases. The ticker works, but it's not elegant.

Test infrastructure should've been shared from day one. The setupTestRepo helper is excellent, but I built it reactively after CI failures. A testutil package from the start would have prevented the "works on my machine" phase entirely.

Try It

brew install jansmrcka/tap/differ    # or: go install github.com/JanSmrcka/differ@latest

Best with tmux:

bind g display-popup -E -w 90% -h 90% "cd #{pane_current_path} && differ"

Source: github.com/JanSmrcka/differ