A standalone reimplementation of Magit in Rust. This document records the library choices, the architecture, and the shared implementation patterns — and the reasoning behind them.
| Area | Choice | Rationale |
|---|---|---|
| TUI | ratatui + crossterm | The de-facto standard. Immediate-mode rendering fits the Elm-style architecture. crossterm is consumed through the ratatui::crossterm re-export to avoid version skew |
| Git | shelling out to the git CLI |
See below. git2/gix rejected |
| File watching | notify-debouncer-mini | Auto-refresh on .git changes (200 ms debounce) |
| Config | serde + toml | Keybindings, colors, options |
| Errors | thiserror (git layer) + anyhow (app layer) | |
| Concurrency | std::thread + crossbeam-channel | No tokio. See below |
| Testing | tempfile + a real git binary |
Integration tests build fixture repositories |
Future candidates: similar (word-level intra-hunk diff highlighting),
syntect (syntax highlighting, behind a feature flag).
Magit itself shells out to git. This is not a shortcut — it is the correct design:
- libgit2 (git2) has gaps: interactive rebase, hook execution, GPG signing, credential helpers, and more. "Behaves exactly like the user's git" is the lifeblood of a Magit clone; depending on libgit2's reimplementations guarantees divergence.
- gitoxide (gix) is fast for reads but incomplete for writes.
- Reads are fast and stable enough via porcelain formats, whose backward compatibility git guarantees.
Every read command carries --no-optional-locks. git status
opportunistically rewrites the index, which would trigger the fs watcher and
loop the auto-refresh forever.
The git layer is isolated from the app so that reads could later be swapped to gix without touching anything else.
Git operations are process spawns; async buys almost nothing. Worker threads
that post a "done" event to the main loop keep the entire core synchronous —
no lifetime gymnastics, no Send contamination (gitui does the same).
Cancellation, if ever needed, is killing the child process.
ratatui is immediate-mode, so all event sources funnel into a single event loop.
┌────────────────┐ ┌──────────────┐ ┌──────────────┐
│ terminal input │ │ git workers │ │ notify(.git) │
└───────┬────────┘ └──────┬───────┘ └──────┬───────┘
│ crossbeam-channel<AppEvent> │
└────────────┬───────────────────────┘
┌─────▼─────┐
│ update() │ ── thread::spawn workers ──▶ send AppEvent on completion
├───────────┤
│ render() │ (redraw after events; queued events are coalesced)
└───────────┘
enum AppEvent {
Key(KeyEvent),
Resize,
SnapshotReady { gen: u64, result: Result<StatusSnapshot, String> },
GitDone { desc: String, entry: ProcessEntry },
RevisionReady { title: String, header: String, diff: String },
RepoChanged, // from notify (debounced)
}- Fully event-driven: the main loop blocks on
rx.recv(); there is no tick and no polling. Rendering happens only after handling events (a backlog is coalesced into a single redraw). The one exception is inside the input thread, which uses crossterm'spoll(100ms)loop — a deliberate compromise so stdin reading can be reliably paused during $EDITOR handoff (a blockingread()cannot be interrupted from outside). It does not affect UI latency or redraw frequency. - Single-flight refresh + generation counter: at most one snapshot
read runs at a time — refreshes requested while one is in flight only
set a dirty flag, and one follow-up read runs on completion. This keeps
a burst of watcher events (a fetch updating many refs,
git gc) from piling up concurrent whole-worktree scans on a large repo. Every read still carries agentag and stale results are dropped, as a safety net. - Mutation completed → auto refresh: receiving
GitDonealways re-reads the snapshot. - $EDITOR handoff:
git commitneeds the terminal.update()only queues anEditorRequest; the main loop — the only place that controls raw mode — suspends the TUI, runsgit commitwith inherited stdio, and restores. The input thread is paused via anAtomicBoolso it cannot steal keystrokes destined for the editor (100 ms poll timeout + pause-flag check).
src/
├ main.rs terminal setup/teardown, panic hook (restores raw mode),
│ event loop, $EDITOR handoff
├ app/ App state + the update half of the loop, split by concern
│ ├ mod.rs App struct, AppEvent, update(), command dispatch table
│ ├ keys.rs key routing (confirm > input > help > transient > keymap)
│ ├ dwim.rs stage/unstage/discard/visit on the section at point
│ ├ search.rs buffer-search state, incremental preview, n/N motion
│ ├ workers.rs worker-thread git calls, refresh generations, process log
│ └ ops/ one module per transient menu (commit / branch / remote /
│ log); a new menu = a new file + one routing arm in ops/mod.rs
├ event.rs input thread + .git watcher
├ command.rs Command enum + name/description registry
├ keymap.rs key-sequence trie, key-notation parser, layering
├ theme/ Theme (named color roles). Every UI color goes through here
│ ├ mod.rs Theme struct + [colors] parsing + built-in preset registry
│ └ tokyo_night.rs one file per built-in preset (`build() -> Theme`)
├ config.rs ~/.config/rugit/config.toml loading and merging
├ git/ ← TUI-independent. Pure parsers + shell-out
│ ├ client.rs GitClient (spawn, forced env, read_snapshot)
│ ├ parse.rs porcelain v2 / unified diff / log / stash parsers (pure)
│ ├ patch.rs patch builders for hunk- and line-level staging (pure)
│ └ types.rs StatusSnapshot, FileDiff, Hunk, DiffArea ...
└ ui/
├ section.rs section tree + flattening + SectionId
├ pane.rs cursor/scroll/folding/refresh-survival
├ build.rs snapshot → tree builders (status / revision / log / process log)
├ transient.rs data-driven transient engine + menu definitions
├ input.rs minibuffer input + picker (filtering selection)
└ render.rs drawing + overlays (input, transient, which-key, help, confirm)
git/parse.rs, git/patch.rs, ui/section.rs, and ui/pane.rs are pure
modules with no I/O, unit-testable against fixture strings alone. When the
crate is ever split into a workspace, git/ becomes rugit-core as-is.
The essence of Magit's UX: every buffer is a tree of sections, and commands act DWIM on the section at point. Status, revision, and process-log buffers all sit on the same foundation.
struct Section {
id: SectionId, // hash of parent_id + kind key; identity across refreshes
value: SectionValue, // what commands act on
heading: Line<'static>,
body: Vec<Line<'static>>, // diff lines for hunks
children: Vec<Section>,
collapsed: bool,
}
enum SectionValue {
Root,
Group(Group), // Untracked / Unstaged / Staged / Stashes / Recent ...
File { area: DiffArea, path: String },
Hunk { area: DiffArea, path: String, hunk_idx: usize },
Commit { hash: String },
Stash { index: usize },
Text, // inert content
}Carrying DiffArea (Untracked / Unstaged / Staged / Committed) in the value
lets the same s key dispatch to the right git operation in every context.
The actual diff data (FileDiff) is owned per-area by the Pane and looked
up by (area, path) at dispatch time.
(a) DWIM dispatch — s (stage) is a single command that branches on the
SectionValue under the cursor:
File{Untracked|Unstaged}→git add -- <path>Hunk{Unstaged}with the cursor on a+/-line → a partial patch containing only that line, applied withgit apply --cachedHunk{Unstaged}elsewhere → the whole hunk as a patch viagit apply --cached- the
Group(Unstaged)heading →git add -u - anything staged → "already staged" message
(b) State preservation across refreshes — every git operation rebuilds
the tree from scratch, but collapse state and the cursor are transplanted by
matching SectionIds. The cursor is remembered not as a raw line number
but as a section-ID chain plus a body offset, restored with fallbacks in
order: same section same line → same section's heading → nearest surviving
ancestor's heading → clamped line index (same behavior as Magit).
(c) The flattening cache — for rendering and cursor movement, the
visible tree is flattened into a cached Vec<FlatLine> (each line carries
path: Vec<usize>, its child-index route from the root). It is recomputed
only on fold/refresh. Cursor movement, scrolling, and section jumps all
reduce to index arithmetic on this Vec.
(d) Right margin — a heading may carry an optional margin: Line
(currently only the log's author + relative date). Because right-alignment
needs the viewport width, the margin is not baked into the heading at
build time; it rides along on the FlatLine and draw_pane places it as a
fixed-width block flush to the right (one blank column kept at the far
edge), truncating the content — never the margin — so the columns line up
across rows regardless of subject length. The margin itself is built as two
sub-columns (author left-aligned, date right-aligned) sized to the widest of
each across the buffer, so both align vertically. All of this is measured in
display columns (unicode-width), never char counts, so full-width
(CJK) glyphs stay aligned and truncation never splits a wide glyph. This
keeps build.rs width-agnostic. The log also sets compact on its root so
its top-level commit sections render as one tight list instead of
blank-line-separated like status groups; ref decorations (%D) render as
color-coded tokens (branch / branch-remote / tag roles) rather than a
parenthesized blob.
With the flattening in place, scrolling reduces to a viewport over
Vec<FlatLine>.
- Cursor-driven as a rule. Even scroll-style commands (
C-detc.) only move the cursor; right before drawing,follow()clamps the viewport to keep the cursor visible with a scrolloff margin. This routes all post-refresh position restoration through the section-identity machinery (3-b). follow(height, scrolloff): clamptopinto[cursor+margin+1-height, cursor-margin], bounded so the viewport never runs past the end. The margin is capped atheight/2.- Diffs are not wrapped — they truncate horizontally (same as Magit). Wrapping requires managing a "1 logical line = N display lines" mapping and was deliberately deferred.
- Rendering processes only the
flat[top .. top+height]slice, so huge diffs stay O(screen size).
Requirements: multi-key sequences (prefixes), per-buffer-kind maps, remapping via the config file, which-key style hints.
enum Command { Refresh, Stage, Nav(NavCmd), Transient(Menu), ... }
enum NavCmd { MoveDown, HalfPageUp, NextSection, ... } // pure cursor motions
enum Menu { Commit, Branch, Push, Pull, Fetch, Log } // transient menusA keymap maps KeyPress → Command and holds no functions. This buys three
things at once: (1) remapping by name from TOML, (2) a help UI that can
enumerate command descriptions, (3) dispatch concentrated in one match,
easy to test. Metadata (name, description) lives in the static COMMANDS
table, one entry per leaf (Nav(MoveDown) is "move-down").
Command families whose handling is uniform are grouped into sub-enums so
dispatch stays a constant-size router: all of NavCmd forwards to
Pane::navigate in one arm, all of Menu resolves through
transient::menu_def in one arm. App::update follows the same rule —
every event arm is a one-line delegation into the owning submodule
(app/workers.rs, app/keys.rs, ...). Adding commands, menus, or events
must not grow either match beyond one routing arm per family.
struct Keymap(HashMap<KeyPress, Entry>);
enum Entry { Command(Command), Prefix(Keymap) }- Input accumulates into
pending: Vec<KeyPress>while walking the trie. APrefixshowsP-in the status line plus a which-key candidate panel; aCommanddispatches. Unbound keys and ESC clear the pending state. - Resolution order: active transient (captures all keys) > buffer-local > global.
- Key normalization absorbs terminal differences: SHIFT on character keys is
folded into the character itself, Ctrl-chars are lowercased, Shift-Tab
becomes
BackTab. - One parser for the
"C-x u"notation serves both the built-in default keymap and the config file.
# ~/.config/rugit/config.toml
scrolloff = 3
[keys.global]
"g" = "refresh"
[keys.status]
"P p" = "push" # space-separated sequences
[colors] # role names: see src/theme.rs
diff-add = "green" # color name / "#rrggbb" / 256-color index "42"
cursor-bg = "#3a3a3a"Every color the UI uses goes through a named role field on Theme
(diff-add, hunk-header, cursor-bg, key, menu-title, ...). The rule
that rendering code never hardcodes a color means the [colors] section can
restyle the whole application. Parsing is delegated to ratatui's
Color::FromStr (names, hex, 256-color indexes). Invalid values and unknown
role names become startup warnings.
Built-in presets ship a full palette as a named Theme constructor, one
per file under src/theme/ (each exposes build() -> Theme, registered in
PRESETS). theme = "tokyo-night" selects the base theme; any [colors]
entries then layer on top of it, so a preset can be spot-tweaked without
forking it.
Fully data-driven. Adding a menu means adding a definition; the engine is shared.
struct TransientDef { title, groups: &[GroupDef] }
enum Item {
Switch { key: "-a", flag: "--all", desc }, // boolean toggle
Arg { key: "-n", flag: "--max-count=", desc }, // takes a value
Action { key: "c", desc, action: TransientAction },
}- While open, a transient is the top layer of key resolution and captures
every key. Switch/Arg keys like
-aare two keystrokes, matched by prefix. - Toggling a
Switchmutates state and re-renders (enabled switches change color). - Selecting an
ArgreturnsPrompt { flag, desc }; the app opens a minibuffer over the still-open transient whose continuation writes the value back intostate.values. Theflagends in=soargs()emits a single--author=adatoken; an empty submit clears it. - Invoking an
Actioncollects enabled switches + value args into aVec<String>passed to execution (git commit --amend --signoff, ...). TransientActionis a separate enum fromCommand. Commit-family actions route to the editor handoff; push/pull/fetch run in the background; log actions append their rev selector and open a log buffer.
Current definitions: Commit (-a/-e/-n/-s; commit/amend/extend, plus
fixup/squash and their instant variants that immediately fold the marker
commit in with an autosquash rebase), Branch
(checkout / create+checkout / create), Push (-f/-F/-n; upstream /
set-upstream), Pull (-r/-a), Fetch (-p; upstream/all), Log
(-n/-A/-F values, -m/-r/-f switches; current / other / all
references). The log's --graph is intentionally excluded — it prefixes
graph art the --format field parser can't read (see §on the log buffer).
Not yet implemented: persisting switch/arg defaults across sessions.
When a transient action needs an argument (a checkout target, a new branch
name), it opens an InputState. One component, three modes:
- plain input: free text (e.g. a new branch name). Character-wise cursor editing (Left/Right/Home/End/Backspace/Delete/C-u). Multibyte-safe — the cursor is a char index.
- picker: a filtering selector over
candidates. The typed text filters with case-insensitive fuzzy matching (thefuzzy-matchercrate's skim algorithm, best score first), UP/DOWN (C-p/C-n) move the selection, TAB completes the selection into the input, RET submits the selected candidate — or, with zero matches, the raw typed text (which is how checking out a tag or SHA works). Used where values outside the candidates are also valid, so it opens even with an empty list. - strict picker: like the picker, but the candidates are the complete
set of valid values (local branches, tags, stashes, remotes), so RET
with zero matches does nothing.
App::open_strict_pickerrefuses to open on an empty set and shows a message ("no tags") instead.
Key-resolution priority: confirm > input > help > transient > keymap.
InputState is pure editing/filtering state; what the submitted text
means lives with the caller. App::open_input/open_picker take a
continuation closure that runs once with the submitted value, and
multi-step flows (branch rename: pick the branch, then ask the new name)
chain by opening the next input from inside the previous one's
continuation, carrying context as captures. Checkout rides git checkout's
DWIM (remote-tracking → create local branch, tag → detach).
Branch candidates come from git branch --all --format=%(refname:short).
Enumerating local refs is fast, so this is the one read done synchronously.
Incremental, isearch-style. / opens a minibuffer. Search is the one
input that reacts to every keystroke rather than just the final submit, so
it is a dedicated overlay kind (InputHandler::Search) instead of a
continuation.
- Live preview: every keystroke updates
app.search→ all matches are highlighted and the cursor jumps to the first match at or after the search origin (wrapping to the top if none). ESC restores the origin position. - Smart-case: an all-lowercase query matches case-insensitively; any uppercase character makes it case-sensitive.
- Modal switch after confirming: RET makes the search "active", and
n/Njump to the next/previous match (with wraparound) until ESC deactivates the search. Match navigation is its own command pair (SearchNext/SearchPrev), so the keymap stays static and section motion (C-j/C-k) keeps working while a search is active. - Highlighting: only the matched substrings are restyled (the
search-match-bg/search-match-fgroles). A per-char match mask is computed over the whole line text, and existingSpans are split at match boundaries andStyle::patched (highlight_query) — surrounding styling such as diff colors survives. Multibyte stays aligned because everything is char-indexed. - Matching is
Pane::find_matches(an O(lines) scan over the flattened lines, no caching). The status bar's right side shows/query (N). - Search state persists across refreshes: only the query string is kept and matches are recomputed every time, so they can never go stale.
- Forced flags/env:
git --no-pager --no-optional-locks,GIT_TERMINAL_PROMPT=0(never hang on auth),LC_ALL=C(stable parsing). - Every mutation's command line and output is recorded in a ring buffer
(
Vec<ProcessEntry>) viewable in the$process-log buffer. Transparency is a core Magit value. - Snapshot reads happen in one pass:
status --porcelain=v2 --branch -z,diff,diff --cached,log -n10,stash list. On an unborn branch (before the first commit)diff --cachedcannot resolve HEAD, so it diffs against the empty-tree constant SHA (4b825dc...). Unstaging likewise falls back togit rm --cachedwhen unborn. - Patch building (
git/patch.rs): a whole hunk is transcribed verbatim with its header. Line-level staging reconstructs a partial hunk — for the unselected lines,+is dropped (when staging) or turned into context (when unstaging), and-the reverse — then recounts and pipes intogit apply --recount --cached [-R].\ No newlinemarkers survive only if the line they annotate survived. This is the only code path that can corrupt the index, so it carries roundtrip integration tests against real git. - Destructive operations (discard, deleting untracked files) go through a y/n confirmation. Line-level discard is deliberately not offered (too easy to fat-finger); discard works at hunk granularity only.
- Unit: parsers (porcelain v2 / diff / hunk headers), patch building (the line stage/unstage transform rules), keymaps (notation roundtrip, trie, shadowing), pane (cursor restoration, follow, toggle), transient (switch sequences, flag collection), input (editing, filtering, multibyte), search (match mask, span splitting). All pure functions — fixture strings suffice.
- Integration (
tests/git_integration.rs): tempfile + the real git binary; covers unborn-branch snapshots, hunk stage/unstage roundtrips, and single-line staging. - Smoke: drive the real binary in a PTY (
script), feed keystrokes, and assert on captured frames and index state (not yet in CI).
- Word-level diff highlighting (
similar), syntax highlighting (syntect, feature-flagged) - Graph rendering in the log buffer (
--graph), and inline diff expansion of a commit under point - Region selection for multi-line staging; discarding directly from staged
- Transient for stash; rename/delete in the branch transient
Argtransient items and persisted switch defaults- Workspace split (
rugit-core/rugit-tui); gix for the read side