Skip to content

Latest commit

 

History

73 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

hunkr

CI Release License

terminal PR review for humans and agents.

hunkr TUI reviewing oven-sh/bun#30412

module github.com/wvvb/hunkr - Go 1.25.

hunkr 123                          # TUI for PR #123 (repo from git remote origin)
hunkr 123 --json                   # PR meta + structured hunks as JSON
hunkr 123 --json --file pkg/foo.go # one file only
hunkr diff 123                     # unified patch to stdout
hunkr diff 123 --json --file pkg/foo.go
hunkr mcp                          # MCP server on stdio
hunkr cache                        # list cached PRs (cache ls)
hunkr cache rm 123 -R owner/repo   # drop one PR
hunkr cache clear                  # drop all

Why this exists

  • Web PR UIs and pasting a million-line diff into an LLM are both bad.
  • You need to read hunks; agents need structured hunks (get_diff JSON), not a blob.
  • One process, one cache, two interfaces: TUI for you, MCP/JSON for agents.

Why it was built

Built because the author wanted a diff hub shared by their coding agents and themselves: same PR, same cache, no copy-paste through the browser. One fetch, many consumers.

How it’s built

Go CLI at cmd/hunkr. Packages:

  • internal/gh: thin GitHub REST (no go-github). Accept: application/vnd.github+json, X-GitHub-Api-Version: 2022-11-28. Token: HUNKR_TOKENGH_TOKENGITHUB_TOKENgh auth token. Host: GH_HOST / GITHUB_HOST (default github.com, enterprise becomes https://<host>/api/v3).
  • internal/cache: $HOME/.hunkr (override HUNKR_DIR). Patches on disk, metadata in RAM. Concurrent file-page fetch (pool of 6, per_page=100). --refresh / --force deletes the PR dir and refetches. complete=true only after every page succeeds; otherwise the cache is left incomplete and the next run refetches.
  • internal/diff: unified-diff parse + index. BuildIndex records hunk spans (HeaderRow, LineCount, BodyOff/BodyEnd) without building a full []Line. A window is decoded later via DecodeRange.
  • internal/tui: Bubble Tea camera over that index. Virtual list, not a buffer of all lines.
  • internal/mcp: modelcontextprotocol/go-sdk, stdio, same cache.Load path as the CLI.
  • internal/cli: argument parsing, runDiff/runPRJSON/runTUI/runCache, help rendering.

Cache layout (as written on disk):

$HUNKR_DIR/cache/<owner>/<repo>/<pr>/
  meta.json              # { version, fetched_at RFC3339, complete, pr{number,title,state,draft,author,body,base,head,url,additions,deletions,changed_files,head_sha,base_sha}, file_count }
  index.json             # []FileMeta{ filename, previous_filename, status, additions, deletions }
  patches/<sha256(path)>.patch   # one file per changed path, truncated/binary => empty or missing

$HUNKR_DIR defaults to $HOME/.hunkr; CacheDir() is $HUNKR_DIR/cache. PRDir sanitizes owner/repo (rejects .., \, bad chars) and caps at 3000 files (GitHub limit) with a warning on stderr. Patches are written per page as it arrives; only FileMeta is kept in memory.

What we used

  • Go 1.25
  • charmbracelet/bubbletea + charmbracelet/lipgloss (+ charmbracelet/x/ansi)
  • alecthomas/chroma/v2: visible-line syntax color only, not whole-file highlighting
  • modelcontextprotocol/go-sdk: MCP server (stdio)
  • GitHub REST (application/vnd.github+json)
  • Inspired by diffs.com CodeView (virtualized mixed file/diff list) and game-style render culling

Render culling (from games)

The TUI treats a PR diff like a large map. It never holds all patches decoded in RAM and never paints off-screen rows.

  • Frustum cull: paint only on-screen rows. renderDiffPane iterates [offset, offset+height), not [0, Total).
  • Chunk streaming: decode ~3 viewports around the cursor ([offset-height, offset+2*height) clamped to [0, Total)). Rest is an index (Spans) + raw patch on disk. Scrolling triggers Cull on demand.
  • LOD: unfocused tabs drop patch text and the decoded window (Tab.Sleep() clears Patch and decoded). Focusing reloads from cache (Tab.Wake() reads patches/<hash>.patch). No tab keeps its full diff decoded while hidden.
  • Spatial index: hunk header rows are indexed and looked up with sort.Search (binary search on Spans[].HeaderRow), not a linear scan per line. Lookup, NextHunkRow, PrevHunkRow are all binary searches.
  • Dirty frames: if the camera didn't move, return the last frame. Model.View() hashes width|height|repo|pr|loading|files|cursor|offset|tab|… into cameraKey(); a hit returns frame.out without re-rendering.
  • First GitHub fetch of a huge PR is still a download; culling stops the TUI from simulating the whole map every frame after that. This was forced by real loads like oven-sh/bun#30412 (~1M additions, 2188 files, 3000-file API cap) where decoding everything would OOM or hang the render loop.

How it works

  1. Resolve repo: -R owner/repo or git remote get-url origin parsed via internal/repo (handles https://, ssh://, git@host:owner/repo.git).
  2. GetPR → check HasCompleteCache (meta.complete==true + index.json). Hit with no --refresh returns cached Meta + index.json with no network.
  3. Miss: GET /repos/<repo>/pulls/<pr>/files?per_page=100&page=1, parse Link: <...>; rel="last" for total pages, then fetch remaining pages with a worker pool of 6. Each page's patches are written to patches/*.patch immediately; only FileMeta accumulates in memory. StreamPRCache emits filesPageMsg per page so the TUI shows files 400/2188 progressively; opening a file whose patch is already on disk works before the rest finish.
  4. TUI: Enter on a file → openTabcache.LoadPatchdiff.BuildIndexCull(offset, viewportHeight)DecodeRange for the window → chroma highlight visible lines only. tab / shift+tab switches tabs (sleeping the previous one), w closes.
  5. --json / hunkr diff --json stream file-by-file from cache: for each FileMeta, load patch, check diff.PatchOmitted, diff.ParseFile, json.MarshalIndent, write directly to stdout. Never builds a giant in-memory []diff.File.

Use

Install

Via Homebrew:

brew tap wvvb/hunkr
brew install hunkr

From GitHub Releases (tagged releases and nightly pre-release nightly):

Download the archive for your OS/arch from Releases: hunkr_<version>_<os>_<arch>.tar.gz (zip on Windows) plus checksums.txt with os=linux|darwin and arch=amd64|arm64 (not raw uname output). The rolling pre-release nightly is rebuilt nightly from main.

curl -fsSL https://raw.githubusercontent.com/wvvb/hunkr/main/install.sh | sh

Manual download archives are named hunkr_<version>_<os>_<arch>.tar.gz with os=linux|darwin and arch=amd64|arm64 (not raw uname).

Or via Go:

go install github.com/wvvb/hunkr/cmd/hunkr@latest
go build -o ./hunkr ./cmd/hunkr
hunkr version   # prints hunkr <version>
hunkr --version # same
hunkr -v        # same

Auth

export HUNKR_TOKEN=ghp_...          # highest priority
export GH_TOKEN=ghp_...             # fallback 1
export GITHUB_TOKEN=ghp_...         # fallback 2
gh auth login                       # fallback 3: gh auth token
export GH_HOST=github.example.com   # or GITHUB_HOST; default github.com

Token order is HUNKR_TOKENGH_TOKENGITHUB_TOKENgh auth token. GH_HOST/GITHUB_HOST is trimmed of scheme and trailing slash; github.com maps to https://api.github.com.

Commands

hunkr <pr>                 [--json] [--file path] [-R owner/repo] [--refresh] [--ascii]
hunkr diff <pr>            [--json] [--file path] [-R owner/repo] [--refresh]
hunkr mcp                  MCP server on stdio
hunkr cache                list cached PRs (alias: cache ls)
hunkr cache ls
hunkr cache rm <pr> [-R owner/repo]   remove PR cache
hunkr cache clear          clear all caches (with -R owner/repo, clear that repo only)
hunkr --help
hunkr help

Repo defaults to the origin remote when -R is omitted. hunkr <pr> --json --file path and hunkr diff <pr> --json --file path both emit a single diff.File object; without --file they emit { "files": [...] } (full PR JSON from hunkr <pr> --json also includes a top-level pr object).

Issue numbers return a 404 that is disambiguated: if /repos/<repo>/issues/<n> exists but has no pull_request field, the error is "<repo>#<n> is an issue, not a pull request: <title>".

Flags

-R, --repo owner/name  repo (default: git remote origin)
    --file path        limit to one file
    --json             machine-readable output
    --refresh          force refetch, overwrite cache (alias --force, -f)
    --ascii            disable nerd icons
-h, --help             show help

--ascii sets HUNKR_ASCII=1 for the TUI run. --refresh deletes the PR dir before fetching.

Keys (TUI)

Default keys (from ~/.config/hunkr/config.toml, mac-like cmd+ also matches ctrl+/alt+; on darwin help shows ⌘P as well):

tab / shift+tab  cycle tabs (not pane)
w / ctrl+w       close tab
enter            open file as tab
\                toggle file list
f                focus file list
/  ctrl+p / cmd+p (⌘P on darwin)  file search: picker overlay, fff-style, no clone
?  (or cmd+/)     help overlay
q / esc          close overlay or quit
j/k  n/p  [ ]  g/G  ctrl-d/u  navigate focused pane

Keys file_search, close_tab, quit, help, toggle_files, next_tab, prev_tab, confirm are wired through config (Match(msg.String(), binds)); j/k n/p [ ] g G ctrl-d/u remain hardcoded vim nav.

Header shows PR state/title and +add -del; second line shows files N and cached indicator; tab bar shows icons; file list (toggle) + virtualized diff; never blank while loading (streaming pages).

Nerd icons (internal/tui/icons.go): enabled when TTY and (HUNKR_NERD=1 or HUNKR_ASCII unset and TERM_PROGRAM in iTerm.app, WezTerm, ghostty, WarpTerminal, vscode or KITTY_WINDOW_ID set). --ascii or HUNKR_ASCII=1 forces ASCII. Private-use glyphs with ASCII fallback (langs: go, rs, ts/tsx, js/jsx, zig, py, c/h/cpp, md, json, yml, toml, sh).

Env

HUNKR_TOKEN    GitHub token (fallback GH_TOKEN, GITHUB_TOKEN, gh auth token)
HUNKR_DIR      cache root (default $HOME/.hunkr, CacheDir is $HUNKR_DIR/cache)
HUNKR_NERD=1   force nerd icons
HUNKR_ASCII=1  force ASCII icons (also set via --ascii)
GH_HOST        GitHub host (default github.com, alias GITHUB_HOST)

Cache

dir:    $HUNKR_DIR/cache   (default $HOME/.hunkr/cache)
layout: $HUNKR_DIR/cache/<owner>/<repo>/<pr>/{meta.json,index.json,patches/*.patch}

Hit with complete=true and no --refresh reads cache without GitHub calls. Fetch uses GET page 1, parses Link last page, then pool-of-6 for remaining pages; as each page arrives patches are written to disk and only meta is kept in RAM. complete=true only when all pages succeed.

Config

Path: $XDG_CONFIG_HOME/hunkr/config.toml else $HOME/.config/hunkr/config.toml. If missing, hunkr writes the default (mkdir 0755) then loads.

Default config.toml:

# ~/.config/hunkr/config.toml

[ui]
ascii = false

[keys]
# cmd+* also matches ctrl+* (terminals rarely deliver ⌘; Ghostty/Kitty can map ⌘P → ctrl+p)
file_search = ["cmd+p", "ctrl+p", "/"]
close_tab   = ["cmd+w", "ctrl+w", "w"]
quit        = ["cmd+q", "ctrl+q", "q"]
help        = ["?", "cmd+/"]
toggle_files = ["\\"]
next_tab    = ["tab"]
prev_tab    = ["shift+tab"]
confirm     = ["enter"]

Parse errors fall back to defaults (warning to stderr). --ascii overrides ui.ascii. Key matching is lowercase; cmd+/super+/ also matches ctrl+ and alt+.

File search (fff-style)

/ or ctrl+p / cmd+p (also ⌘P on macOS, shown as ⌘P on darwin) opens a file picker overlay, not vim search-in-buffer. It ranks the in-memory PR file list with an fff-inspired scorer: whole-path, smart-case (all-lower is case-insensitive), consecutive-run bonus, filename boost, and 1-skip typo tolerance when the query is ≥3 chars. No local clone is needed. The search runs on []FileMeta paths (including previous_filename when present, opening the current Filename). Results are virtualized (visible rows only), typing filters live, and even 2188 files is fine in-process. j/k and arrows move, enter opens the selected tab, esc closes, backspace deletes, ctrl+u clears.

The MCP tool find_changed_files uses the same fff.Search on filenames (ranking, not raw substring) with the same output shape.

MCP

Stdio server for Claude/Cursor/etc. Same cache as the CLI (no separate fetch).

{ "mcpServers": { "hunkr": { "command": "hunkr", "args": ["mcp"] } } }

Tools (via modelcontextprotocol/go-sdk, mcp.AddTool):

  • get_diff: diff for a PR, optionally filtered to a single file (repo, pr_number, file?diff.File or {files: diff.File[]})
  • get_pr: PR metadata (repo, pr_numbergh.PR)
  • get_file: file content at ref (repo, path, ref)
  • get_changed_files: list changed files without hunks (repo, pr_number)
  • get_checks: list check runs for PR head (repo, pr_number)
  • create_review: create a review (repo, pr_number, body, event=APPROVE|REQUEST_CHANGES|COMMENT)
  • add_review_comment: add review comment (repo, pr_number, path, line, body)
  • get_review_comments: list review comments (repo, pr_number)
  • reply_to_comment: reply to a review comment (repo, comment_id, body)
  • get_symbol_context: symbol context around a line (repo, path, line, ref)
  • list_cached_prs: list cached PRs ({})
  • refresh_cache: refresh cache for a PR (repo, pr_number)
  • clear_cache: clear cache (repo?, pr_number?: none clears all, repo clears repo, repo+pr_number clears one PR; pr_number without repo is an error)
  • find_changed_files: find changed files by substring query (repo, pr_number, query)

All cache-backed tools use EnsurePRCache / LoadMeta same as the CLI; get_pr returns cached PRMeta when complete.

Contribute

  • Format with gofmt. Verify with go test ./... and go build ./cmd/hunkr.
  • Wrap errors with %w. No panic in production code. No silent default on enums. Handle every case explicitly.
  • Don’t add dependencies unless the job needs them. Don’t hold all patches in RAM, stream per file, keep the index light.
  • Parser/index changes need tests (internal/diff: go test ./internal/diff -count=1).
  • PRs welcome against github.com/wvvb/hunkr. Keep the README as the docs.

License

MIT - see LICENSE.

About

Terminal PR review for humans and agents.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Contributors

Languages