Margin documentation
Margin is the terminal review workspace for agent-assisted code: inspect changes, accept good hunks, reject bad ones safely, and return precise feedback while remaining accountable for every change.
brew install imrajyavardhan12/tap/margin
margin # review the working tree
Where to go
| If you want to… | Read |
|---|---|
| learn the keys | Keybindings |
| change themes, layout, or behaviour | Configuration · Themes |
| script Margin or feed an agent | JSON output |
| understand current direction | Roadmap |
| understand how it is built now | Architecture |
| know why it is built that way | Decision records |
| see what shipped | Changelog |
| read the original plan | Historical blueprint |
| contribute | Contributing |
Documentation authority
The root CONTEXT.md defines domain language, the README describes the current
product, the roadmap describes direction, this book’s architecture page
describes the current Implementation, ADRs preserve durable decisions, and the
changelog records shipped history. The blueprint is historical and is not
maintained as a live specification.
The review loop
Margin exists for the case where something else wrote the code and you have to decide whether to keep it:
marginopens the working tree — untracked files included.J/Kmove by hunk,]/[by file,/searches the whole changeset.sstages a hunk you accept;xdiscards one you don’t (backed up to.git/margin/trash/first —margin undorestores it).cleaves a note on a hunk that needs a human answer.mchecks a file off. The mark is keyed to the file’s content, so it survives a rebase and clears itself if the file changes again.margin --notesprints every note as Markdown withpath:lineanchors — paste it into a pull request, or hand it back to the agent.
Nothing is written to your repository unless you press a key that says
so, and the only destructive key asks you to type yes.
Keybindings
Margin is keyboard-first with a vim grammar. Press ? in the app for this
list. Keybindings are a stability surface (ADR-0021): they only change with
a deprecation cycle. The key set is fixed — there is no custom-keymap
surface yet.
Navigation
| Key | Action |
|---|---|
j / k, Down / Up | move down / up one row |
J / K | next / previous hunk |
] / [, Tab / Shift-Tab | next / previous file |
gg / G | jump to top / bottom |
Ctrl-d / Ctrl-u, PgDn / PgUp | half page down / up |
/ | search (regex, smart-case: capitals make it case-sensitive) |
n / N | next / previous match (wrapping) |
f | fuzzy file picker (type to filter, Up/Down or Ctrl-n/Ctrl-p, Enter jumps, Esc closes) |
View
| Key | Action |
|---|---|
v | switch unified / side-by-side (pins your choice over the width-based auto layout) |
w | toggle line wrap (off clips long lines; wrapped rows scroll as one unit) |
za / zA | collapse the cursor’s file / everything (vim folds). Lockfiles and generated bundles fold automatically; the collapse config globs extend the list. Collapsed files show a ▸ header with counts; navigation skips their bodies. |
b | toggle the file sidebar |
? | toggle help |
Esc | close help, then clear the active search |
The layout defaults to auto: side-by-side when the diff pane is at least
120 columns wide, unified below that. The status bar shows [split] when
side-by-side is active, and [wrap] when line wrap is on.
Act on the diff
| Key | Action |
|---|---|
s / u | stage / unstage the hunk under the cursor (index-only; ADR-0013). Refusals report in the status bar. Available in git worktree and --staged reviews. |
x | discard the hunk from the working tree (ADR-0014) — Margin’s only destructive action. Opens a prompt; only typing yes and Enter applies it, Esc cancels. A backup patch lands in .git/margin/trash/ first (margin undo restores). Worktree reviews only. Pass --discard-without-backup to skip the recovery copy for one invocation (ADR-0017); the prompt says so. |
m | mark the cursor’s file viewed: sidebar checkmark + the file folds (za reopens it without unmarking). Marks persist per review in the data dir ($XDG_DATA_HOME/margin, $MARGIN_DATA overrides) keyed by content digest — a changed file un-views itself, so a rebase keeps only untouched files marked. Patch/pager reviews keep marks session-only. |
c | note on the hunk under the cursor (issue #23): a one-line remark shown inline on the hunk header with a ✎ count in the sidebar. Enter saves, empty input deletes, Esc cancels; reopening an annotated hunk pre-fills the text for editing. Notes persist per review beside viewed marks, keyed by a digest of that hunk — an edited hunk drops its note rather than mislabeling new code. Export them with margin --notes (Markdown, with path:line anchors). |
r | reload the diff from its source (also refreshes the sidebar’s staged markers). With -w this happens automatically after changes, waiting while a discard prompt, the file picker, or the note editor is open; notes and the cursor stay with their hunk |
In worktree reviews the sidebar marks files that have staged content with a
● dot, so partial staging is visible at a glance.
Session
| Key | Action |
|---|---|
q, Ctrl-c | quit |
Mouse
Strictly additive (issue #26) — everything above works without it.
mouse = false in config or --no-mouse disables
capture and keeps the terminal’s own text selection.
| Input | Action |
|---|---|
| wheel | move the cursor three rows |
| left click (diff) | place the cursor on the clicked row |
| left click (sidebar) | jump to that file |
| left click (help open) | close the help overlay |
Configuration
Margin needs zero configuration; everything below is optional. Config keys are a stability surface (ADR-0021): renames get a deprecation cycle.
Files and precedence
Later sources win:
- Built-in defaults
- User config:
$XDG_CONFIG_HOME/margin/config.toml(~/.config/margin/config.tomlfallback;%APPDATA%\margin\config.tomlon Windows). Set$MARGIN_CONFIGto point at an explicit file. - Repo config:
.margin.tomlat the repository root (searched upward from the working directory, stopping at the.gitboundary) - CLI flags:
--theme,--layout,--no-untracked,--no-mouse.collapseand custom themes are file-only;--discard-without-backupis flag-only by design (ADR-0017) and never stored.
Inspect the merged result with margin --dump-config.
Unknown or misspelled keys are errors with did-you-mean suggestions, never silently ignored.
Keys
# User config: full surface.
theme = "auto" # auto | ledger | foolscap | carbon | blueprint
# auto matches the terminal background (themes.md)
# or the name of a [themes.<name>] section
layout = "auto" # auto | unified | split
include_untracked = true # show untracked files in `margin` / `margin diff`
# (`--no-untracked` overrides per invocation)
mouse = true # wheel scroll + click in the TUI; false keeps the
# terminal's own text selection (`--no-mouse` too)
discard_trash = true # back up discarded hunks to .git/margin/trash/
# before applying (ADR-0014); `margin undo` restores
# DEPRECATED (ADR-0017): `false` still opts out
# until removal, but every invocation warns — use
# `--discard-without-backup` per invocation instead
collapse = [] # globs to auto-collapse (za expands), on top of
# built-in lockfile/generated heuristics; `*` stays
# within a path segment, `**` crosses, `?` is one
# char; slash-less patterns match the basename
[themes.mocha] # custom themes (themes.md#custom-themes): a
base = "carbon" # built-in base plus #rrggbb overrides; user
addition = "#3ddc84" # config only — never the repo file
Repo config is display-only
.margin.toml accepts theme, layout, and collapse only. This is
the ADR-0008 trust rule: checking out a repository must never change what
Margin does, only how it looks. (collapse qualifies: folding hides no
content permanently — za reopens anything.) Behavior keys in a repo
config are an error — in particular, a checked-out repository can never
disable discard backups.
The user-level discard_trash = false opt-out is deprecated (ADR-0017):
it still applies until removal, but Margin warns on every invocation and
the confirmation prompt states that recovery is unavailable. To discard
without a recovery copy, pass --discard-without-backup for that
invocation instead — the choice is never stored.
Environment
| Variable | Effect |
|---|---|
NO_COLOR (non-empty) | Monochrome rendering: structure via bold/dim/reverse only |
COLORTERM=truecolor/24bit | Forces truecolor themes |
TERM containing 256color, kitty, ghostty, alacritty, wezterm, iterm | Treated as truecolor-capable |
| anything else | 16-color-safe palette (syntax highlighting off) |
MARGIN_CONFIG | Explicit user-config path (also handy in scripts/tests) |
See themes.md for what each theme looks like and how the degraded modes behave.
Themes
Four built-ins, chosen with theme = "<name>" in
config or --theme <name>.
The default is theme = "auto": Margin asks the terminal for its
background color (an OSC 11 query with a 50 ms budget) and picks
ledger on dark, foolscap on light. Terminals that don’t answer —
tmux or screen without passthrough, some ssh hops, CI — fall back to
ledger. Setting any explicit theme, in config or with --theme,
skips the query entirely.
| Theme | For | Character |
|---|---|---|
ledger (auto: dark) | dark terminals | calm green/red ink on subtle tints; base16-ocean syntax |
foolscap (auto: light) | light terminals | dark ink on paper-colored tints; InspiredGitHub syntax |
carbon | dark, high contrast | bright ink, deep tints, amber hunk headers; base16-eighties syntax |
blueprint | dark, blue-tinted | the drafting-table look; Solarized syntax |
All themes share the same layout and markers — switching themes never changes what you see, only the palette.
Degraded modes
Margin degrades deliberately instead of accidentally:
- 16-color terminals (no truecolor signal in
COLORTERM/TERM): every theme maps to one ANSI-named-colors palette. Syntax highlighting is disabled — its RGB output would render as garbage — while additions, deletions, and intra-line emphasis (reverse video) remain. NO_COLOR: no color at all; structure is carried by bold (additions), dim (deletions), reverse (emphasis, cursor, headers), and underline (hunk headers).
Custom themes
Define your own in the user config file (issue #15): a
[themes.<name>] section names a built-in base and overrides only the
keys you care about, then theme = "<name>" selects it:
theme = "mocha"
[themes.mocha]
base = "carbon" # any built-in: ledger, foolscap, carbon, blueprint
addition = "#3ddc84" # ink keys set the foreground
addition_tint = "#0d3318" # tint keys set the background
syntax_theme = "base16-mocha.dark"
Rules:
- All colors are strict
#rrggbb. Anything else is a config error naming the key, as are misspelled keys (ADR-0008). - Unset keys inherit the base’s full style — colors and modifiers (bold, italic), so overriding a color never un-bolds a header.
- Naming a custom theme after a built-in (
[themes.ledger]) shadows it — including whenautopicks it — which is how you tweak the default rather than replace it. - Degraded modes win regardless: on a 16-color terminal or under
NO_COLORa custom theme renders as the standard degraded palette, never as mangled RGB. - User config only. A repo-local
.margin.tomlmay select a theme but never define one: a checked-out repository that could restyle your diff could also paint additions in the background color and hide injected code from review.
Schema (a stability surface)
Ink keys (foreground): addition, deletion, context, line_no,
hunk_header, meta, sidebar_title, sidebar_selected,
sidebar_staged, help_border, note (review notes shown inline on a
hunk header).
Background keys: addition_tint, deletion_tint, addition_emphasis,
deletion_emphasis, cursor_line, search_match.
Two-color surfaces: file_header_fg / file_header_bg,
status_bar_fg / status_bar_bg.
Other: base (required), syntax_theme — one of the bundled syntect
themes: InspiredGitHub, Solarized (dark), Solarized (light),
base16-eighties.dark, base16-mocha.dark, base16-ocean.dark,
base16-ocean.light. An unknown name errors listing these.
These key names are stable: removing or renaming one is a breaking change and gets a CHANGELOG entry like any CLI surface.
JSON output (--json)
margin diff --json, margin show --json, and margin patch --json emit
the parsed changeset as a single JSON document on stdout — how agents and
scripts consume Margin without scraping a terminal.
margin diff --json | jq '.files[] | {path: .new_path, adds: .additions}'
git show HEAD --format= | margin patch --json - | jq '.additions'
pager mode never emits JSON: its piped output is byte-identical to its
input by contract (ADR-0007). --json cannot combine with --watch.
Exit status
0 means a clean document; 1 means the document is usable but the
parser warned (warnings go to stderr, never into the JSON). 2 means
the invocation itself was invalid (ADR-0022).
Stability
The document carries "schema": 1. Within schema 1, changes are
additive only (new optional fields may appear; nothing is removed or
retyped). A breaking change would ship as "schema": 2 — consumers should
check the field.
Encoding
Margin’s model is bytes-first; JSON strings are UTF-8. Every string in the
document is lossy UTF-8: invalid bytes become U+FFFD (�), and any
value that was altered carries a sibling flag — "lossy": true on lines,
"path_lossy": true on files, "heading_lossy": true on hunks. The
flags are omitted when false. Consumers
needing exact bytes should read the raw diff itself; the JSON view is for
structure.
Schema 1
{
"schema": 1,
"files": [
{
"status": "modified", // added | deleted | modified | renamed | copied
"old_path": "src/app.rs", // null for added files
"new_path": "src/app.rs", // null for deleted files
"path_lossy": true, // OMITTED unless a path had invalid UTF-8
"binary": false, // binary files carry no hunks
"old_mode": "100644", // octal string, git-style; null when unknown
"new_mode": "100755",
"similarity": 90, // OMITTED except for renames/copies with an index
"additions": 3, // line counts for this file
"deletions": 1,
"hunks": [
{
"old_start": 1, "old_count": 5,
"new_start": 1, "new_count": 6,
"heading": "fn main()", // the @@ ... @@ section text; OMITTED when absent
"heading_lossy": true, // OMITTED unless the heading had invalid UTF-8
"lines": [
{
"kind": "context", // context | addition | deletion
"content": "…", // no +/-/space marker, no trailing newline
"lossy": true, // OMITTED unless content had invalid UTF-8
"no_newline": true // OMITTED unless "\ No newline at end of file"
}
]
}
]
}
],
"additions": 3, // totals across all files
"deletions": 1
}
The producing types live in margin-core/src/json.rs — a deliberate
public surface, decoupled from the internal model so refactors cannot
change this document shape silently.
v0.6 trust scenario (beta-tester guide)
~30 minutes. You need a terminal, Git, and a disposable practice repository — nothing you care about. Every step below says what you should see; anything else is a defect worth reporting.
0. Build the disposable repo
rm -rf /tmp/margin-beta && mkdir /tmp/margin-beta && cd /tmp/margin-beta
git init -q && git config user.email beta@example.com && git config user.name Beta
printf 'one\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\neleven\ntwelve\n' > notes.txt
git add . && git commit -qm base
printf 'one\nTWO edited\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\nELEVEN edited\ntwelve\n' > notes.txt
The two edits are far enough apart to form two separate hunks (steps 2–4
need both: one to act on, one left over). Confirm with
margin diff --json | jq '.files[0].hunks | length' → 2.
1. Install (pick one)
# Homebrew (stable releases only — skips candidates)
brew install imrajyavardhan12/tap/margin
# Shell script (macOS / Linux) — replace VERSION with the candidate
curl -fsSL https://github.com/imrajyavardhan12/Margin/releases/download/VERSION/margin-review-installer.sh | sh
# Rust (any OS, including candidates)
cargo install margin-review --version VERSION
Confirm: margin --version prints the candidate version.
2. Stage good, leave bad (s)
cd /tmp/margin-beta && margin
Move to the first hunk (J/K), press s. Quit (q), then run
git status: exactly one hunk staged, the other still in the worktree.
git diff --cached shows only the hunk you staged.
3. Unstage (u)
margin diff --staged
Cursor on the staged hunk, press u. Quit; git status shows nothing
staged and both edits back in the worktree.
4. Discard and undo (x, typed yes, margin undo)
In margin, open the confirm prompt on a hunk with x. Check the
prompt states that margin undo restores it. Type yes + Enter.
Quit; the hunk is gone from the file. Run margin undo → restored …;
the hunk is back, byte-identical (git diff matches the pre-discard
state).
Then try x + typing no: nothing happens except a cancellation
message. The file is untouched.
5. Notes and Markdown export (c, m, --notes)
In margin: c on a hunk, type needs a test, Enter. m on the file
(checkmark + folds). Quit, then:
margin --notes
Your note prints as Markdown with a path:line anchor. Reopen
margin: the note is still there, the file still checked.
6. Watch mode (-w)
margin -w
From another terminal in the same repo: printf 'seven\n' >> notes.txt.
The review reloads within a second (status bar shows [watch]).
Then press c on a hunk and start typing; while you type, create a new
file from the other terminal (printf 'x\n' > aaa.txt). The review waits
until you press Enter, and the note stays on the hunk you chose — not on
aaa.txt.
Quit with q.
7. Terminal cleanup
These must all leave a usable prompt — no garbled input, no invisible typing, no stuck alternate screen:
- Open
margin, quit withq. Type a command: it echoes normally. - Open
margin, pressCtrl-C. Same check. margin diff | head -c 100exits 0 with partial output.
If the terminal ever breaks: reset recovers it — and that recovery
being necessary is itself the defect to report.
8. Report back (no private source)
Reply on the beta thread with:
margin --version, OS, terminal emulator, multiplexer if any- Which steps passed; where reality differed (copy the exact message)
- Any friction: what confused you, what you had to look up
- Trust failures (lost work, scary prompt, silent anything): report these immediately, separately, with full detail
Do not paste proprietary code, file paths from work projects, or review notes containing either. The disposable repo above keeps everything shareable. Margin collects no telemetry — silence from your machine is by design; your words in the thread are the entire feedback channel.
Architecture
Decision history lives in docs/adr/. This file is the current map.
Crate graph
margin (bin) ──► margin-tui ──► margin-core
│ ▲
└──────► margin-vcs ───────┘
| Crate | Role | May do I/O? | Key contract |
|---|---|---|---|
margin-core | Diff model, unified-diff parser, intra-line diff, collapse heuristics | No | Pure, panic-free on untrusted input, fuzzed (ADR-0003, 0009) |
margin-vcs | Read-only DiffSource adapters + repository write operations | Yes — VCS, files, gh | git2 quarantined; no git2 types in public APIs (ADR-0005, 0019) |
margin-tui | Elm-architecture UI: AppState, Msg, update, view, themes, keymap | Terminal only (review effects → Command) | Never imports margin-vcs; view() is pure → snapshot-testable (ADR-0002, 0003) |
margin | CLI, config, capability-aware Review Session, persistence, watching | Yes — runtime shell | Pager passthrough guarantee; exit codes are API (ADR-0007, 0019, 0022) |
Data flow
CLI args + config ─► Review Session ─► DiffSource ─► Changeset ─► AppState
│ ▲ │ view()
│ key events ─► Msg ─► update() ─────┘
│ │ ▼
└─ capabilities ◄──── Commands terminal frame
│
runtime adapters execute effects; results return as Msgs
The model (margin-core)
Changeset → FileDiff (paths, status, mode) → Hunk (@@ ranges) → Line
└ intra-line spans
Everything downstream — rendering, search, --json, staging —
consumes this model. The parser that builds it from unified diffs is the
single most security-sensitive code in the project (stdin is untrusted) and
is correspondingly fuzzed and corpus-tested (ADR-0010).
Performance strategy
Parse eagerly (cheap, linear). Highlight and intra-line-diff lazily — only what the current frame requests, cached per file, bounded by a per-frame budget with unfinished work resuming across fill-in frames (ADR-0006, ADR-0026: there is no background warming thread). The input loop never blocks on rendering work.
Budgets (< 50 ms first paint on 100 files; smooth 250k-line scrolling)
are enforced in CI by workload completion tests with exact result
assertions (crates/margin-core/tests/perf_budgets.rs,
crates/margin-tui/tests/perf_budgets.rs) plus the deterministic
budget-exhaustion test in highlight.rs. The criterion benches
(parse/*, first_frame/*, scroll_frame/*, search_keystroke/* —
run with cargo bench -p margin-core / -p margin-tui) are
informational observations for local comparison, not gates: shared
runners cannot make statistically trustworthy timing decisions.
Where things live
margin-core:model.rs(bytes-first changeset model),patch.rs(tolerant unified-diff parser),intraline.rs(word-level emphasis),ansi.rs(escape stripping for pager input)margin-vcs:lib.rs(DiffSourcetrait +SourceError, write transactions),git.rs(worktree/staged/show/range sources + git2 conversion),files.rs(two-file diffs),gh.rs(PRs via the user’sgh),staging.rs(index writes),discard.rs(recoverable discard + trash + undo)margin-tui:app.rs(AppState/Msg/update, per-layoutRowstream),keymap.rs,theme.rs(built-ins + color modes),highlight.rs(budgeted lazy syntax/emphasis cache),runtime.rs(terminal session + panic guard),view/{mod,diff,sidebar,help,split,style}.rsmargin:main.rs(clap dispatch, passthrough guarantee),review.rs(Review Session modes, capabilities, effect execution, watching and display),review_state.rs(atomic versioned per-DiffId review state, ADR-0020),config.rs(discovery/merge, color-mode detection)
For agent-oriented operational detail (commands, gotchas, testing playbook), see AGENTS.md.
Architecture Decision Records
Significant technical decisions in Margin are recorded here, with the context and alternatives at the time they were made. The code says what; ADRs say why — so future contributors (and future us) don’t relitigate settled questions or, worse, silently unsettle them.
Process
- A decision is significant if reversing it later would cost more than a day, or if a reviewer could reasonably ask “why is it done this way?” Dependency choices, crate boundaries, file formats, user-facing contracts, and policies (errors, MSRV, releases) all qualify. Naming a variable does not.
- Copy
template.mdtoNNNN-short-slug.md(next number), open a PR. The PR discussion is the decision review. - Statuses:
Proposed→Accepted→ (Deprecated|Superseded by ADR-NNNN). Never edit an accepted ADR’s decision — write a new one that supersedes it. History is the point. - PRs that contradict an accepted ADR must include the superseding ADR.
Index
Relationships (amended/superseded clauses) are noted here so no one trusts a stale sentence: the linked ADRs themselves are never rewritten.
| ADR | Title | Status | Notes |
|---|---|---|---|
| 0001 | Implementation language: Rust | Accepted | |
| 0002 | TUI stack: ratatui + crossterm | Accepted | |
| 0003 | App pattern: Elm architecture with a pure core | Accepted | |
| 0004 | Four-crate workspace with one I/O seam | Accepted | I/O seam widened by 0019 |
| 0005 | Git access via git2, quarantined behind DiffSource | Accepted | DiffSource is the read-only seam; writes joined it via 0019 |
| 0006 | Highlighting via syntect; intra-line diff via similar | Accepted | Warming clause superseded by 0026 |
| 0007 | CLI mirrors Git verbs; pager passthrough guarantee | Accepted | Exit-code wording superseded by 0022 |
| 0008 | Configuration: TOML, XDG + repo-local | Accepted | Parity rule exempted for safety hatches by 0017 |
| 0009 | Errors: thiserror in libs, anyhow at the edge, no panics | Accepted | |
| 0010 | Testing: snapshots, temp repos, corpus, fuzz, benches | Accepted | |
| 0011 | Releases: cargo-dist, Conventional Commits, MSRV policy | Accepted | Package identity amended by 0025 |
| 0012 | License: MIT OR Apache-2.0 | Accepted | |
| 0013 | Hunk staging: index-only, dry-run first, exact bytes | Accepted | |
| 0014 | Discard: trash before destroy, worktree-only, typed confirm | Accepted | Persistent opt-out superseded by 0017 |
| 0015 | Forge access through the user’s gh CLI, quarantined in margin-vcs | Accepted | |
| 0016 | Completions and the man page generate at runtime from the binary | Accepted | |
| 0017 | Unbacked discard is an explicit, per-invocation escape hatch | Accepted | |
| 0018 | No implicit network access or telemetry | Accepted | |
| 0019 | Explicit read, write, and runtime effect seams | Accepted | |
| 0020 | Persist review state as one versioned, atomic record | Accepted | |
| 0021 | Selective user-facing compatibility before 1.0 | Accepted | |
| 0022 | Distinguish clean, operational, and invocation outcomes | Accepted | |
| 0023 | Tool-neutral human review boundary through 1.0 | Accepted | |
| 0024 | SHA-pinned, least-privilege CI supply chain | Accepted | |
| 0025 | Publish the Margin binary as the margin-review package | Accepted | |
| 0026 | Highlight warming is per-frame budgeted fill-in, not a background thread | Accepted | |
| 0027 | Refuse invocations that would review something other than what was given | Proposed | Applies 0007’s “refuse loudly” to piped stdin and misplaced root flags |
ADR-0001: Implementation language: Rust
- Status: Accepted
- Date: 2026-06-10
Context
Margin’s positioning (see BLUEPRINT.md) is explicitly against the runtime weight of the incumbent (Hunk: TypeScript on Bun/Node). The product promises are: single static binary, < 50 ms cold start to first paint, smooth scrolling on 250k-line diffs, and installation without any language runtime. The choice of language is therefore a product decision, not a taste decision.
We also want the contributor pool most likely to show up: the people who build and maintain delta, bat, gitui, difftastic, and ratatui itself.
Decision
Margin is written in Rust, stable channel, unsafe_code = "forbid"
workspace-wide. MSRV is pinned in Cargo.toml (rust-version) and follows
the policy in ADR-0011 (current stable minus two releases).
Consequences
- Single static binary per target; cargo-dist handles distribution (ADR-0011).
- Startup and scroll performance budgets are achievable without heroics.
- The ecosystem hands us the hard parts: git2, syntect, similar, ratatui, insta.
- Cost: slower iteration than a scripting language, compile times, and a steeper ramp for contributors who don’t know Rust. We mitigate with a pure, small core crate (ADR-0003/0004) where most first contributions land.
- Cost:
unsafe_code = "forbid"may need a future ADR exception if a performance hotspot demands it; none is anticipated.
Alternatives considered
- TypeScript (Bun/OpenTUI), like Hunk — would inherit the exact weaknesses we position against: runtime dependency, install friction, JS-class startup.
- Go + bubbletea — viable and fast to write, but heavier binaries, GC pauses are observable in tight render loops, and the diff/highlighting library ecosystem is thinner (no syntect/similar equivalents of the same maturity).
- Zig / C++ — performance is there, ecosystem and contributor funnel are not.
ADR-0002: TUI stack: ratatui + crossterm
- Status: Accepted
- Date: 2026-06-10
Context
We need a terminal UI layer that (a) renders efficiently enough for virtualized scrolling over huge diffs, (b) works on macOS, Linux, and Windows Terminal, (c) is testable headlessly in CI, and (d) is familiar to the contributors we want to attract.
Decision
Use ratatui (immediate-mode TUI) with the crossterm backend.
Terminal capability handling (raw mode, alternate screen, resize, color
depth) goes through crossterm only — no direct escape-sequence writing
outside margin-tui.
Consequences
- Immediate-mode rendering fits the Elm architecture (ADR-0003):
view()is a pure function of state, re-rendered per frame, which makes frame snapshots withTestBackend+ insta the backbone of UI testing (ADR-0010). - crossterm gives us the three OS targets including Windows, which delta-class tools often neglect — part of our “terminal citizenship” pitch.
- ratatui is the de-facto standard: largest contributor familiarity, active maintenance, a showcase channel that doubles as a launch venue.
- Cost: immediate mode means we own scroll virtualization and damage avoidance ourselves; the < 50 ms first-paint and 60 fps budgets are our job, enforced by benchmarks (ADR-0010).
- Cost: no built-in widget retained state; all state lives in
AppState, which is exactly what ADR-0003 wants anyway.
Alternatives considered
- Cursive (retained mode) — widget-owned state fights the Elm pattern and headless testing; smaller community.
- Notcurses bindings — powerful, but C dependency breaks the pure-static-binary story and Windows support is weak.
- Custom renderer on raw crossterm — maximum control, but we would reimplement layout/buffer-diffing that ratatui already does well; revisit only if profiling proves ratatui is the bottleneck (write a superseding ADR).
ADR-0003: App pattern: Elm architecture with a pure core
- Status: Accepted
- Date: 2026-06-10
Context
TUI apps rot in a specific way: input handling, state mutation, and rendering interleave until no interaction can be tested without a live terminal. We are also about to add destructive operations in v0.2 (discard hunks), where “I’m not sure which state the keypress fired in” becomes data loss.
Decision
margin-tui follows the Elm architecture (TEA):
- One
AppStatestruct — the single source of truth. - One
Msgenum — every interaction and async result is a message. update(&mut AppState, Msg) -> Vec<Command>— the only place state changes. Side effects (load source, write to index) are returned asCommandvalues and executed by the runtime shell, never performed insideupdate.view(&AppState, &mut Frame)— pure rendering, no side effects, no state.
Supporting rule: margin-core is pure — no I/O, no TUI types, panic-free
on untrusted input. All effectful code lives in margin-vcs (ADR-0004) or the
binary’s runtime shell.
Consequences
- Every interaction is testable as plain function calls: feed
Msgs, assert state, snapshot the frame. CI covers keybinding flows without a TTY. - Destructive operations become auditable: exactly one
Msgcan trigger a discard, and it’s gated by a confirmation state machine visible in one place. - Custom keymaps later are a data change (key →
Msgtable), not a refactor. - Cost: boilerplate — every new interaction touches
Msg,update, and possiblyCommand. We accept this; it’s the price of the audit trail. - Cost: discipline is needed to keep
updatefrom doing I/O “just this once”. Code review enforces; clippy’sdisallowed_methodscan enforce mechanically later if violations appear.
Alternatives considered
- Ad-hoc widget state (typical ratatui example style) — fastest to start, unmaintainable at the feature count we’re planning; untestable interactions.
- Full async actor model — overkill for one window and one data source;
TEA’s
Commandlist gives us async loading without the machinery.
ADR-0004: Four-crate workspace with one I/O seam
- Status: Accepted
- Date: 2026-06-10
Context
A single-crate binary is simpler today, but crate boundaries are the only
dependency boundaries the Rust compiler actually enforces. We know our growth
axes already: more diff sources (jj, GitHub PRs, watch mode), more views,
a future --json/library consumer of the diff model, and write operations.
Retrofitting boundaries after launch is the expensive path.
Decision
A Cargo workspace with exactly four crates and a strict dependency direction:
margin (bin) ──► margin-tui ──► margin-core
│ ▲
└──────► margin-vcs ───────┘
- margin-core — diff model, patch parser, intra-line diff. Pure: no I/O, no TUI deps, panic-free (ADR-0009).
- margin-vcs — the
DiffSourcetrait and all implementations. The only crate that touches git, the filesystem, or stdin. - margin-tui — Elm-architecture UI (ADR-0003). Depends on core only; never on vcs. Changesets are handed in by the binary.
- margin — CLI, config, wiring, the runtime shell that executes Commands.
Forbidden edges (enforced by Cargo.toml, checked in review):
margin-tui ✗→ margin-vcs, margin-core ✗→ anything with I/O.
Consequences
- The compiler enforces that the UI cannot reach around the model to touch a repo, and that the parser cannot acquire ambient I/O.
margin-coreandmargin-vcsare independently publishable;--jsonoutput and third-party tooling fall out of serializing core types.- Tests get cheap: synthetic
DiffSourcefor the TUI, no terminal for the core. - Cost: cross-crate changes need version-lockstep (workspace versioning handles it) and slightly more ceremony per feature.
- Cost: four crates to publish per release; cargo-dist + CI automate it.
Alternatives considered
- Single crate with modules — module privacy is advisory (any module can
use crate::...); boundaries erode silently under deadline pressure. - More granular crates (themes, keymap, parser separately) — premature; split further only when an external consumer exists (superseding ADR).
ADR-0005: Git access via git2, quarantined behind DiffSource
- Status: Accepted
- Date: 2026-06-10
Context
Margin needs: working-tree and staged diffs (including untracked files),
revision-range diffs, show, rename detection — and in v0.2, writing:
staging/unstaging hunks to the index and discarding worktree hunks. Options
are libgit2 bindings (git2), pure-Rust gitoxide (gix), or shelling out to the
git binary.
The write path decides it: hunk staging needs patch application to the index.
git2 provides Repository::apply(ApplyLocation::Index) — proven in production
by gitui for exactly this feature. gix does not yet cover this surface;
shelling out means generating reverse/partial patches and driving git apply --cached --unidiff-zero with its edge cases as our API.
Decision
Use git2 for all repository access — reads now, writes in v0.2 — confined
to margin-vcs behind the DiffSource trait (and a future ApplyTarget
trait for writes). No git2 types in any public signature of margin-vcs;
everything crossing the boundary is translated to margin-core types.
default-features = false on git2: no network transports — Margin never
fetches; it reads local state only. This shrinks the binary and the supply-
chain surface.
Consequences
- Hunk-level staging in v0.2 has a proven implementation path.
- Diff semantics (renames, mode changes, untracked) come from libgit2’s battle-tested implementation rather than our reimplementation.
- The trait quarantine means migrating to gix when it matures — or adding jj via its CLI — touches one module each (verified by the synthetic-source tests).
- Cost: a C dependency (libgit2, vendored + static). Build times grow; the binary stays static. Acceptable.
- Cost: libgit2 occasionally diverges from git’s behavior in corners; the
integration suite (ADR-0010) compares against real
git diffoutput on generated repos to catch this.
Alternatives considered
- gitoxide (gix) — pure Rust, impressive performance, but index-apply for staging isn’t there yet. Re-evaluate post-1.0 with a superseding ADR.
- Shelling out to
git— maximum semantic fidelity and zero C deps, but fragile output parsing, version skew across user machines, and an awkward hunk-staging path. We do keep it in tests as the semantic oracle. - git2 with network features — unnecessary attack/bloat surface for a tool that never touches a remote.
ADR-0006: Highlighting via syntect; intra-line diff via similar
- Status: Accepted
- Date: 2026-06-10
Context
Two rendering-quality features define how the diff looks: syntax highlighting and intra-line (word-level) change emphasis. Both have a performance dimension — they are the expensive part of rendering and must never block scrolling (quality bar: 250k-line diffs at 60 fps).
The tempting maximalist choice is tree-sitter: real parse trees, and a path to structural diffing. But grammars must be bundled per language (binary bloat), C compilation per grammar, and structural diffing is a product we explicitly deferred (difftastic owns it; BLUEPRINT §3).
Decision
- syntect (Sublime-grammar engine, as used by bat and delta) for syntax
highlighting, with precompiled binary assets,
default-features = falsedefault-fancy(pure-Rust regex engine — keeps the no-C-toolchain build for contributors and musl targets trivial).
- similar for intra-line diffing: word-granularity inline spans computed per hunk-line-pair, unicode-aware.
- Both run lazily: highlight/intra-line only what scrolls into view, cached per file, warmed by a background thread. The input loop never waits.
Consequences
- Same visual quality users already trust from bat/delta, ~200 languages free.
- Pure-Rust build keeps
cargo install marginworking everywhere without oniguruma headaches. - Laziness makes the giant-diff budgets achievable; the criterion benchmarks (ADR-0010) hold the line.
- Cost: regex-based highlighting is approximate; occasional mis-highlights in exotic languages. Accepted — every comparable tool shares this.
- Cost: no structural diff. Deliberate scope decision; a future tree-sitter ADR would supersede this one only with benchmarks and a binary-size budget.
Alternatives considered
- tree-sitter — better fidelity + structural future, but binary bloat, per-grammar C builds, and it drags us toward a product we chose not to build.
- No highlighting in MVP — would torpedo the “screenshot quality” first impression that drives adoption of this category of tool.
- imara-diff for intra-line — faster on large inputs, but intra-line works
on short strings where
similar’s ergonomics and unicode segmentation win. (imara-diff remains the candidate if file-level diffing ever moves in-process.)
ADR-0007: CLI mirrors Git verbs; pager passthrough guarantee
- Status: Accepted
- Date: 2026-06-10
Context
A diff viewer lives inside existing muscle memory. Every new concept in the
CLI is adoption friction; every deviation from git semantics is a bug report.
Hunk validated this: its diff/show/patch/pager verb set is one of the
things it got right. Separately, the single most dangerous integration point
is git config core.pager — if we misbehave when output is piped, we corrupt
scripts the user didn’t even know invoked a pager.
Decision
The CLI mirrors Git’s verbs and defers revision semantics to git:
margin # = margin diff
margin diff [--staged] [<rev>|<a>..<b>] [-- <paths>]
margin diff <fileA> <fileB> # two arbitrary files
margin show [<rev>]
margin patch [-|<file>] # unified diff from stdin or a file
margin pager # core.pager mode
Contracts:
- Passthrough guarantee: in
pagermode (andpatch -), if stdout is not a TTY, Margin writes its input through byte-identical and exits 0. Settingcore.pager = "margin pager"must never breakgit log -p | grep. This is tested in CI as a release blocker. - No revision parsing of our own:
<rev>arguments are resolved by the VCS layer with git’s own semantics (revspec strings handed to git2). - Exit codes are an API: 0 success, 1 displayed-with-errors, 2 usage. Stable from v0.1 so scripts and agents can rely on them.
- New verbs require a new ADR; flags within existing verbs do not.
Consequences
- Zero learning curve for git users; documentation writes itself by analogy.
core.pagerintegration is safe to recommend in the README from day one.- The future
--jsonflag (BLUEPRINT §3) slots in as an output mode on existing verbs, not a new surface. - Cost: git-semantics delegation means odd revspecs behave exactly as oddly as git — we own rendering, not resolution. That’s the right ownership line.
Alternatives considered
- Own subcommand vocabulary (
margin review,margin open) — reads nicely in a README, fails the muscle-memory test, doubles docs burden. - Flags-only, no subcommands (delta-style) — fine for a pipe prettifier, too cramped for an interactive tool with multiple sources.
ADR-0008: Configuration: TOML, XDG + repo-local
- Status: Accepted
- Date: 2026-06-10
Context
Users expect modern CLI tools to be configurable via a dotfile they can keep in their dotfiles repo, with sane zero-config defaults. Teams additionally want per-repository settings (e.g., collapse globs for generated files).
Decision
- Format: TOML, parsed with serde — typo’d keys are errors with suggestions, not silent ignores.
- Discovery and precedence (later wins):
- built-in defaults
$XDG_CONFIG_HOME/margin/config.toml(~/.config/margin/fallback;%APPDATA%\margin\on Windows).margin.tomlat the repository root (checked in by teams)- CLI flags
- Repo-local config is trusted for display options only (themes, collapse globs, layout). Settings with side effects (anything that writes, external commands if ever added) are user-config-only — a checked-out repo must not be able to make Margin do things.
- Every option has a CLI-flag equivalent;
margin --dump-configprints the merged effective config for debugging. - Config keys are a stability surface: renames require a deprecation cycle (old key keeps working with a warning for ≥2 minor versions).
Consequences
- Matches expectations set by every comparable tool (Hunk included); zero surprise for users.
- The display-only trust rule closes the “malicious repo configures the tool” hole before v0.2 adds write operations.
- Cost: precedence merging and deprecation tracking is real code; it lives in
the binary crate (
config.rs) and is unit-tested with fixture files.
Alternatives considered
- YAML — whitespace-sensitive, surprising type coercions, heavier parsers.
- Lua/embedded scripting — powerful, but a scripting runtime in a tool that will hold staging powers is surface area we refuse on principle.
- gitconfig sections (
[margin]in .gitconfig) — cute for git affinity, but stringly-typed, no nesting, and useless for the two-file/patch modes.
ADR-0009: Errors: thiserror in libs, anyhow at the edge, no panics
- Status: Accepted
- Date: 2026-06-10
Context
Margin consumes untrusted input (arbitrary patches on stdin, arbitrary repo states) inside a raw-mode terminal. A panic in raw mode doesn’t just crash — it leaves the user’s terminal broken. And from v0.2, Margin holds write capabilities over the user’s working tree, where “crashed mid-operation” must have a defined answer.
Decision
- Library crates (
margin-core,margin-vcs,margin-tui) never panic on input. They return typed errors (thiserror-derived enums). Workspace lints warn onunwrap/expect; CI runs clippy with-D warnings, so every use needs an explicit#[allow]with a justification comment. Slice indexing on untrusted data usesget(). - The binary uses
anyhowfor context-rich error chains at the edge:margin: cannot open repository at ./x: not a git repository— message first, chain below, exit code 2 or 1 per ADR-0007. - A panic hook + terminal guard restore the terminal (leave raw mode, alternate screen) before any abort, then print the panic with an issue-link footer. The guard is RAII so early returns restore too.
- Recoverable runtime errors are
Msgs, rendered as a status-line message in the UI (e.g., reload failed because the repo is mid-rebase), never a crash, never a silent swallow. EveryErris either shown to the user or converted to a typed state — discarding errors is a review reject. - Fuzzing enforces rule 1 for the parser (ADR-0010).
Consequences
- “Never breaks your terminal, never eats an error” becomes testable, not aspirational.
- Error messages are a designed surface: libs carry structured causes, the edge renders them with context.
- Cost: typed error enums are more work than
anyhoweverywhere — accepted, because callers (the TUI) must distinguish failures to react correctly.
Alternatives considered
anyhoweverywhere — fine at the edge, but erases the type information the TUI needs to decide between “show message” and “degrade view”.- Panic-as-control-flow with
catch_unwind— hides logic errors and is unacceptable in the same process that applies patches to the index.
ADR-0010: Testing: snapshots, temp repos, corpus, fuzz, benches
- Status: Accepted
- Date: 2026-06-10
Context
TUIs are notoriously undertested because “you need a terminal to see it”.
Our architecture (ADR-0003: pure view(), message-driven update()) was
chosen partly to make that excuse impossible. Separately, our two product
promises — performance and never-mangles-your-diff correctness — must be
regression-guarded mechanically, because manual QA won’t scale past one
maintainer.
Decision
Five layers, all in CI (ci.yml):
- Unit tests in
margin-core: parser cases, model invariants, intra-line spans, collapse heuristics. The crate is pure, so coverage here is cheap; it should stay near-total. - Frame snapshots in
margin-tui: render fixed changesets at 80×24, 200×50, and 40×20 via ratatuiTestBackend; snapshot with insta. Interaction tests areupdate()message sequences followed by a snapshot. Snapshot review (cargo insta review) is the UI-change review mechanism. - Integration tests in
margin-vcs+ the binary: build real temp git repos (tempfile+ git2) covering staged/unstacked/untracked/renames/ binary/mode-change/submodule; assert producedChangesets. Where cheap, compare against realgit diffoutput as the semantic oracle (ADR-0005). Binary-level tests cover passthrough byte-identity (ADR-0007) and exit codes. - Corpus + fuzz:
tests/corpus/holds real-world patch fixtures — every patch that ever breaks Margin gets a fixture before the fix lands (the regression ratchet).cargo-fuzztarget on the parser; CI runs a 60-second smoke on PRs touchingmargin-core; longer runs scheduled weekly. - Benchmarks: criterion on parse-time and first-frame-time for a giant fixture (100-file/10k-line, and a 250k-line lockfile monster). Budgets from BLUEPRINT §4 are encoded in the bench harness; CI posts numbers on main, and regressions >20% block release tags (informational on PRs until v0.3).
Policy: a bug fix without a test that fails before the fix is an incomplete PR (stated in CONTRIBUTING; enforced in review).
Consequences
- UI regressions show up as readable text-frame diffs in PRs — reviewable by anyone, no terminal needed.
- The corpus turns every user bug report into permanent armor.
- Cost: snapshot churn when views change intentionally —
cargo insta reviewmakes this a one-command workflow; we accept the noise. - Cost: temp-repo tests are the slowest layer (~seconds); they stay in the
integration tier, not the unit tier, to keep
cargo test -p margin-coreinstant.
Alternatives considered
- End-to-end PTY tests (drive a real terminal) — flaky, slow, and TEA makes them mostly redundant; we keep at most one smoke test for raw-mode setup/teardown.
- Coverage-percentage gates — measures lines, not promises; the budgets and corpus ratchet guard what users actually feel.
ADR-0011: Releases: cargo-dist, Conventional Commits, MSRV policy
- Status: Accepted
- Date: 2026-06-10
Context
Install friction is our competitive wedge against Hunk (npm/Node). That promise dies if releases are hand-rolled: missing targets, broken checksums, stale Homebrew formulas. Release engineering must be boring and automated from the first tag.
Decision
- cargo-dist owns release builds. Targets from v0.1:
aarch64-apple-darwin,x86_64-apple-darwin,x86_64-unknown-linux-musl,aarch64-unknown-linux-musl(static),x86_64-pc-windows-msvc. Artifacts: tarballs/zips + sha256 checksums,curl | shand PowerShell installers, Homebrew formula pushed toimrajyavardhan12/homebrew-tap, cargo-binstall metadata. - Versioning: SemVer. Pre-1.0, minor = features, patch = fixes; breaking config/keybinding changes get a deprecation cycle of ≥2 minors (ADR-0008). All workspace crates version in lockstep and publish to crates.io.
- Commits: Conventional Commits, enforced on PR titles (squash-merge strategy, so PR title = commit message). git-cliff generates CHANGELOG.md; release notes are hand-curated on top.
- MSRV: current stable minus two releases, recorded as
rust-versionin the workspace, tested in CI. Raising MSRV is afeat!-level note in the changelog, never in a patch release. - Cadence: tag when ready pre-1.0, aiming for a minor every 4–6 weeks. A release tag requires: CI green, benches within budget, CHANGELOG updated, install verified from artifacts on the three OSes (checklist in RELEASING section of CONTRIBUTING).
- Post-1.0 channels (AUR, nixpkgs, winget, scoop, mise) are community-driven; we keep cargo-dist outputs stable so packagers have an easy time.
Consequences
brew install,curl | sh,cargo binstall, and direct download all work from the first public tag — the README’s install section is honest on day one.- Squash + Conventional Commits keeps history linear and the changelog free.
- Cost: cargo-dist config is generated and occasionally needs regeneration on upgrades; we pin its version and upgrade deliberately.
- Cost: musl static builds constrain us to pure-Rust or vendored-C deps — already satisfied by ADR-0005 (vendored libgit2) and ADR-0006 (fancy-regex).
Alternatives considered
- Hand-written release workflows — every project that starts here rewrites to cargo-dist or goreleaser-style tooling after the third broken release.
- npm distribution as an extra channel (Hunk’s model) — wrong signal for a no-runtime tool; binstall + brew + curl cover the same users better.
- CalVer — SemVer’s compatibility signal matters for a tool with config and keybinding stability promises.
ADR-0012: License: MIT OR Apache-2.0
- Status: Accepted
- Date: 2026-06-10
Context
License choice is permanent in practice (relicensing requires every contributor’s consent). Goals: maximum adoption (individuals, companies, distro packagers), zero friction for contributors, patent safety, and compatibility with the Rust ecosystem we depend on and hope to attract contributors from.
Decision
Dual-license under MIT OR Apache-2.0 (the Rust ecosystem convention).
Both license texts ship in the repo (LICENSE-MIT, LICENSE-APACHE);
Cargo.toml declares license = "MIT OR Apache-2.0". Contributions are
accepted under the same terms (inbound = outbound, stated in CONTRIBUTING);
no CLA.
Dependency hygiene is enforced mechanically: cargo-deny (deny.toml) allows
only permissive licenses (+ MPL-2.0 weak copyleft, compatible for a binary)
and fails CI otherwise.
Consequences
- Compatible with effectively everything; packagers and corporate users need no review cycle.
- Apache-2.0 contributes an explicit patent grant; MIT keeps the maximum-simplicity option open. Users pick.
- No CLA keeps the contribution funnel frictionless; the cost is that relicensing later is practically impossible — we accept that permanence.
- Cost: permissive licensing allows closed-source forks. Accepted: for a developer tool, distribution and trust are the moat, not the license.
Alternatives considered
- MIT only — fine, but drops the patent grant for no benefit.
- GPL-3.0 — would exclude some corporate users and complicate the contributor funnel; the protection it buys is not where this project’s risk is.
- BSL / fair-source — poison for a tool whose adoption depends on distro packaging and being recommended in dotfiles threads.
ADR-0013: Hunk staging safety model
- Status: Accepted
- Date: 2026-07-05
- Supersedes / Superseded by: —
Context
Issue #10 makes Margin act on the diff: s stages the hunk under the
cursor, u unstages it. These are Margin’s first write operations, and
the review happens while agents may be editing the same files. The
constraints:
- Zero data-loss paths. A reviewer tool that can corrupt work is worse than no tool. The working tree is sacred; the index must never end up in a state plain git can’t explain.
- The diff on screen can be stale: the file may have changed since the changeset was loaded (that is the normal case in agent workflows).
- ADR-0003: no side effects in
update(); ADR-0005: git2 stays quarantined in margin-vcs, and margin-tui cannot depend on it.
Decision
Staging applies exactly the reviewed hunk bytes to the index only, via libgit2:
- margin-core re-renders the selected hunk as a minimal single-hunk
patch (
render_hunk_patch) from the parsed model — the same bytes the reviewer saw, not a recomputed diff. Unstaging renders the textual reverse (reversedswaps additions/deletions and the before/after headers); both are pure and property-tested (parse∘render round-trips; reverse∘reverse is identity). - margin-vcs feeds those bytes to
git2::Diff::from_bufferandRepository::apply(&diff, ApplyLocation::Index).Indexlocation means libgit2 structurally cannot touch the working tree. Acheck_onlypass runs first; if the hunk no longer applies (stale review), the operation fails cleanly, the index is untouched, and the UI reports “changed since load — reload (r)”. - The TUI requests writes as data:
update()returns aCommand, and the runtime executes it through aCommandExecutortrait implemented by the binary over margin-vcs (dependency inversion, same seam asDiffSource). Write commands are produced only by explicitMsg::StageHunk/Msg::UnstageHunk— never by navigation. - After a successful apply, the changeset reloads from the source and
the cursor re-anchors via
locate()(the existing layout-switch mechanism). v1 refuses to stage binary files and renames (FileStatus::Renamed | Copied) rather than guess header semantics.
Consequences
- Easier: the whole write path is testable without a terminal —
pure render/reverse in margin-core, temp-repo round-trips in
margin-vcs asserting
git statusparity with plain git. - Easier: “what got staged” is auditable — it is byte-for-byte the hunk the reviewer approved.
- Harder: hunks must apply atomically or not at all; there is no line-level staging yet (that is a later issue, and it composes: it is just a smaller rendered patch).
- Committed to: reload-on-conflict rather than merge heroics. If the world moved, we show the new world; we never force a stale hunk in.
Alternatives considered
- Hand-rolled index blob surgery (read index blob, splice hunk,
write blob): full control, but re-implements
git apply’s context matching — exactly where index corruption bugs would live. Lost to libgit2’s battle-tested apply. ApplyOptions::hunk_callbackfiltering over a recomputed diff: avoids re-rendering, but stages a recomputed hunk that may not be what the reviewer saw if the file changed. Lost on the staleness guarantee.- Shelling out to
git apply --cached: correct semantics, but breaks the vendored-libgit2/no-subprocess portability stance (ADR-0005) and complicates Windows.
ADR-0014: Discard safety model (trash before destroy)
- Status: Accepted
- Date: 2026-07-09
- Supersedes / Superseded by: — (complements ADR-0013)
Context
Issue #11 adds x: discard the hunk under the cursor from the working
tree. This is Margin’s only destructive operation — everything else
(staging, ADR-0013) is index-only and reversible with plain git. The
working tree may hold work that exists nowhere else, and Margin reviews
often run while agents are editing the same files. The constraints:
- Nothing is destroyed before a copy exists. A reviewer tool that loses work once is dead; “the user confirmed” is not a recovery path.
- The on-screen hunk can be stale (the file changed since load) — the normal case in agent workflows, per ADR-0013.
- ADR-0003/0005/0013 seams stand: no I/O in
update(), git2 stays in margin-vcs, writes flow throughCommand/CommandExecutor, and write commands come only from explicit messages, never navigation.
Decision
Discard applies the textual reverse of exactly the reviewed hunk to the working tree only, and a forward copy of that hunk is persisted before anything is applied:
- Trash before destroy. The forward single-hunk patch (the same
bytes
render_hunk_patchproduces for staging — what the reviewer saw) is written to.git/margin/trash/<millis>.patchfirst. If the trash write fails, the discard aborts; there is no code path that destroys without a backup while trash is enabled. Trash lives under the repository’s gitdir (correct for linked worktrees), is plaingit apply-able by hand, and file names are zero-padded epoch-millisecond timestamps (collisions bump the timestamp, never suffix — suffixes break lexical ordering), so “newest” is lexical order and undo can never restore the wrong entry. - Worktree only, dry-run first. margin-vcs applies the reversed
patch via
Repository::apply(.., ApplyLocation::WorkDir)after acheck_onlypass, mirroring ADR-0013: a stale hunk fails cleanly and the working tree is untouched — reload (r) and re-review. The index is never written: discarding a staged-and-modified hunk leaves its staged copy staged, exactly likegit restore <file>. - Typed confirmation in the TUI.
xopens a prompt that names the target; only typingyesand Enter issues the command (Esc or any other input cancels). One keystroke must never destroy. - Undo is a CLI verb.
margin undore-applies the newest trash entry to the working tree (same dry-run discipline) and deletes it on success; if the tree moved since, the entry is kept and the command fails with the patch path so the user can resolve by hand. The trash is config-off-able (discard_trash = false) for users who accept the risk — but only from user config: repo-local.margin.tomlcannot touch discard settings, so a hostile repository can never silently disable backups (same posture as SECURITY.md’s repo-local schema restriction).
Consequences
- Easier: recovery is boring — a trash entry is a normal patch; even
without Margin,
git apply .git/margin/trash/<file>.patchrestores. - Easier: the whole path reuses ADR-0013 machinery (render + reverse, dry-run apply, Command/executor seam, reload + re-anchor), so the new surface is small and already property-tested.
- Harder: trash accumulates until undone or hand-pruned; v1 ships no auto-expiry (revisit if it ever bites — entries are tiny).
- Committed to: refusing rather than guessing — binary, rename, and unsafe-path hunks refuse exactly as staging does; stale hunks are never forced in.
Alternatives considered
- y/n confirmation: one accidental keystroke from data loss; the issue demands typed confirmation and the product identity is safety-first. Rejected.
- Undo inside the TUI session only (in-memory backup): dies with the process — a crash after discard would lose the safety copy. Rejected; the trash must survive the session (and Margin itself).
git stash push -p-style backups: stashes are visible, garbage-collected state with user-facing semantics we’d be polluting; a private trash dir under.git/margin/is inert and clearly ours.- Auto-undo via re-applying from the changeset model: after a reload the model may no longer contain the discarded hunk; only a persisted copy guarantees restoration.
ADR-0015: Forge access through the user’s gh CLI
- Status: Accepted
- Date: 2026-07-12
- Supersedes / Superseded by: — (complements ADR-0005)
Context
Issue #24 wants margin pr 123: review a GitHub pull request without
leaving the terminal. That requires talking to a forge — authentication,
API endpoints, rate limits, enterprise hosts — none of which Margin
handles today, and all of which are somebody’s full-time job to get
right. Meanwhile ADR-0005 keeps git access in-process (vendored
libgit2, no subprocess) for hermetic builds and byte-level control.
Decision
Forge access goes through the user’s own gh CLI as a subprocess;
Margin never holds a token, opens a network connection, or implements
an API client.
ghalready solves auth (keyring, SSO, enterprise hosts), pagination, and rate limits, and the user has already trusted it with their credentials. Margin inherits all of that by exec’ing it.- The subprocess boundary lives in margin-vcs (
gh.rs), the crate that owns every way a changeset enters Margin.GhPris just anotherDiffSource:gh pr viewonce for the canonical identity,gh pr difffor the bytes, parsed by the same tolerant parser as stdin patches. Nothing anywhere else knows a subprocess exists. - This does not loosen ADR-0005: git object access stays in-process via vendored libgit2. The distinction is principled — git repositories are local data Margin owns reading; forges are remote authenticated services Margin refuses to hold credentials for.
- Viewed-state identity is
(host, repo, PR number)— not the head SHA the issue sketched: per-file content digests (ADR of record: issue #20’s design) already invalidate precisely, so untouched files stay checked across force-pushes, like the rebase case. - Errors are the user’s
ghspeaking: a missing binary yields “margin pr needs the GitHub CLI”, a nonzero exit passesgh’s own stderr through (auth prompts, 404s, disabled PRs) — Margin adds no second vocabulary of forge errors.
Consequences
- Easier: enterprise GitHub, tokens, SSO, 2FA — all inherited, zero code.
- Easier:
gh’s stderr is the error UX; users already know it. - Harder:
ghbecomes a runtime (not build) dependency for this one verb; everything else works without it. CI tests use a fakeghon PATH, so the suite stays hermetic. - Committed to: other forges arrive the same way (
glabfor GitLab,jj’s tooling for #25) rather than as API clients.
Alternatives considered
- octocrab / a GitHub API client: tokens in Margin’s config, a
second auth story, enterprise-host handling, rate-limit code — a
liability surface for zero user benefit over
gh. Rejected. git fetchofrefs/pull/N/head+ local diff: keeps everything in libgit2, but requires fetch rights/refspec knowledge, misses PR metadata, and silently diverges from what GitHub shows. Rejected.- Bundling a token prompt: Margin holding credentials is exactly what this ADR exists to refuse.
ADR-0016: Completions and the man page are generated at runtime
- Status: Accepted
- Date: 2026-08-01
- Supersedes / Superseded by: —
Context
Issue #16 wants shell completions (bash/zsh/fish/powershell) and a man
page from the existing clap definitions. clap offers two delivery
routes: generate files at build time (a build.rs running
clap_complete/clap_mangen, artifacts wired into packaging) or
generate on demand from the installed binary (a subcommand).
Decision
Generation happens at runtime, from the installed binary:
margin completions <shell>prints the completion script to stdout — a visible verb, because users run it themselves (margin completions zsh > "$fpath[1]/_margin"or anevalin a shell rc file).margin manprints the roff page to stdout — hidden, because its audience is packagers and the curious (margin man | man -l -); interactive users read--help. Hidden still means stable: it is a CLI verb and carries the same compatibility promise as the rest (ADR-0007).- Both dispatch before config loading. Completions are eval’d from
shell startup files; a typo in
config.tomlmust never break a shell. This ordering is load-bearing and tested. - Output goes through a buffer, and a broken pipe on stdout is success,
not an error —
margin completions zsh | headis normal use, andclap_complete::generatepanics if handed a failing writer directly.
Why not build time:
- Drift-proof. The script always matches the binary that generates it. Pre-built files go stale against whatever version a user actually has installed — the classic completions bug.
- cargo-dist cannot run what it builds. Release artifacts are
cross-compiled; the build host cannot execute an aarch64-linux binary
to harvest its completions. Every packaging route that wants files
(Homebrew’s
generate_completions_from_executable, distro packages) runs the target binary at install time — which is exactly the interface this ADR ships. - Hermetic builds stay simple. No
build.rs, no generated files in the archive layout, nodist generatechurn.
Consequences
- Easier: one code path, zero packaging plumbing,
--help/man/completions can never disagree about the CLI surface. - Harder: completions are not pre-installed by the shell archives; users (or formulas) run one command once. The README documents each shell’s one-liner.
- Committed to: new verbs and flags appear in completions and the man page automatically — no release checklist item exists to forget.
Alternatives considered
build.rs+ cargo-dist artifact wiring: broken by design for cross-compiled targets (see above); adds generated-file drift and build complexity for zero user benefit. Rejected.- A
margin generateumbrella verb (completions + man under one namespace): two flat verbs are simpler, andcompletionsmatches the de-facto convention users already know from other tools. Rejected.
ADR-0017: Unbacked discard is an explicit, per-invocation escape hatch
- Status: Accepted
- Date: 2026-08-02
- Amends: ADR-0008 and ADR-0014
Context
ADR-0014 allows users to disable discard backups persistently with
discard_trash = false, and ADR-0008 says every configuration option has a
CLI equivalent. Persistent opt-out weakens Margin’s central trust promise: a
forgotten setting can make an ordinary discard unrecoverable. Always retaining
a backup is also inappropriate when a developer is deliberately removing
sensitive material and does not want another copy persisted under the gitdir.
Decision
Discard remains recoverable by default. An unbacked discard is permitted only
through a deliberately explicit CLI option for the current invocation, named
--discard-without-backup; the choice cannot be stored in user or repository
configuration. Typed confirmation must state that recovery will not be
available before issuing an unbacked discard.
The existing discard_trash = false setting is deprecated and will be removed
under ADR-0008’s configuration deprecation policy. ADR-0008’s flag/config
parity rule does not apply to safety-critical, one-invocation escape hatches.
This supersedes only ADR-0014’s persistent opt-out and ADR-0008’s universal
parity rule; their remaining decisions stand.
Consequences
- A normal Margin invocation cannot silently inherit unrecoverable discard behavior from old configuration.
- Developers can avoid retaining sensitive discarded content, but must make that decision explicitly each time.
- Issue #89 must not add the proposed
--no-trashflag as written. - The unsafe path needs dedicated CLI, confirmation, and transaction tests.
ADR-0018: No implicit network access or telemetry
- Status: Accepted
- Date: 2026-08-02
- Complements: ADR-0015
Context
Margin handles private source code, repository paths, patches, and review notes. Usage telemetry or background network behavior would create a privacy and trust obligation disproportionate to the product evidence it provides, especially for a tool positioned around review of agent-assisted changes.
Decision
Margin does not collect telemetry and does not make implicit network requests. It has no background update check, remote crash reporting, usage analytics, or transmission of code and review data.
A remote integration may run only in response to an explicit user command and
must delegate authentication and networking to a tool the user already controls.
For example, margin pr invokes the user’s gh CLI under ADR-0015; Margin does
not hold credentials or open the connection itself. Any future exception
requires a new decision and an explicit user action rather than an opt-out.
Consequences
- Local review remains local by default, with behavior that can be understood from the invoked command.
- Adoption is measured through release downloads, voluntary feedback, issue reports, discussions, and user interviews rather than runtime instrumentation.
- Margin cannot silently check for new versions or automatically submit crash diagnostics.
ADR-0019: Explicit read, write, and runtime effect seams
- Status: Accepted
- Date: 2026-08-02
- Amends: ADR-0004, ADR-0005, ADR-0013, and ADR-0014
Context
The founding ADRs describe DiffSource as Margin’s only outside-world seam.
That was sufficient for the read-only product, but it is no longer true:
Margin now applies index and worktree changes, persists review state, watches
files, reads configuration and stdin, controls a terminal, and invokes gh.
The binary currently coordinates some review capabilities through independent
Option fields and booleans, allowing invalid combinations and distributing
discard safety across multiple modules.
Decision
The four-crate dependency direction remains unchanged, but its effect boundaries are made explicit:
DiffSourceis the read-only Interface for producing and identifying a changeset; it is not described as the only I/O seam.margin-vcsowns VCS and forge Adapters and exposes deep write operations without leakinggit2types.- A discard transaction in
margin-vcsowns backup creation, checked apply, and failed-apply backup cleanup as one testable operation. - A capability-aware review-session Module in the binary composes a source, supported write behavior, persistence, and watching without independent flags representing impossible states.
margin-tuicontinues to express effects asCommandvalues and consume results; it never importsmargin-vcsor performs review-domain I/O.- Configuration, review-state persistence, terminal control, and filesystem watching remain explicit runtime concerns rather than being hidden behind a universal I/O service.
This is targeted deepening for safety and testability, not authorization for a broad TUI rewrite or additional workspace crates.
Consequences
- Read adapters remain small and independently testable while write safety gains a single locality.
- Unsupported operations derive from session capabilities instead of loosely coordinated optional fields and booleans.
- Founding claims that
DiffSourceormargin-vcsare the only outside-world seam are historical descriptions, not current architecture. - The Elm update/view boundary and existing crate dependency graph remain stable through v0.6.
ADR-0020: Persist review state as one versioned, atomic record
- Status: Accepted
- Date: 2026-08-02
- Supersedes: the separate
ViewedStoreandNotesStorepersistence formats
Context
Viewed marks and review notes are currently stored by two nearly identical modules. Both write directly to their destination file, collapse every load failure into an empty result, and have save failures ignored by the runtime. A crash can therefore truncate a record and make valid review state disappear silently. The string-based path format also conflicts with Margin’s bytes-first model on repositories containing non-UTF-8 paths.
Decision
The binary owns one ReviewStore per changeset identity. Its versioned record
contains the complete persisted review state: viewed marks and review notes,
with paths represented without lossy Unicode conversion.
Saving writes a temporary file in the destination directory, flushes it, and atomically replaces the prior record. Loading distinguishes absence from I/O, format, identity, and version failures. Failures do not block review, but they produce a visible warning and never cause a damaged record to be silently overwritten.
On first successful load, the store imports the legacy viewed and notes files. Legacy data is removed only after the combined record is durably installed, so migration is retryable after interruption.
Consequences
- Viewed marks and notes have one consistency and failure boundary.
- The TUI emits a complete review-state snapshot rather than independent writes that could overwrite one another.
- Persistence remains optional and local; patch and pager reviews can remain session-only.
- The on-disk version and migration behavior become user-facing compatibility responsibilities even though the Rust type remains private.
ADR-0021: Selective user-facing compatibility before 1.0
- Status: Accepted
- Date: 2026-08-02
Context
Margin is pre-1.0 and still validating its architecture, but users and agents already depend on command behavior, configuration, structured JSON, install paths, keybindings, and persisted review state. Treating every minor release as permission to break those surfaces would undermine adoption; freezing private Rust APIs now would preserve the least valuable constraints.
Decision
The following user-facing surfaces are stable before 1.0:
- CLI verbs, flag meanings, install URLs, and documented exit-code semantics;
- configuration keys, with at least two minor releases of deprecation before removal;
- each published JSON schema version, which may change additively only;
- default keybindings, with documented migration when a change is unavoidable;
- persisted review state, with automatic forward migration.
Additive changes remain allowed. An intentional incompatibility requires a migration path, prominent release notes, and a superseding decision when it contradicts an ADR.
Internal Rust APIs, private Module layout, tests and snapshots not exposed as a documented format, and other Implementation details remain unstable until 1.0. Margin’s workspace crates are not published as a supported library API.
Consequences
- Developers can build habits and scripts around Margin during product validation without freezing its internals.
- Compatibility tests are required for stable surfaces, including old review state fixtures and versioned JSON fixtures.
- Release review must classify changes by user-facing impact rather than relying only on Cargo semver.
ADR-0022: Distinguish clean, operational, and invocation outcomes
- Status: Accepted
- Date: 2026-08-02
- Supersedes: ADR-0007’s ambiguous
displayed-with-errorsexit-code wording
Context
ADR-0007 reserves exit codes 0, 1, and 2, but the Implementation never
returns 1; repository, I/O, undo, and source failures currently return 2
alongside malformed command usage and configuration. Scripts therefore cannot
rely on the documented distinction, and a usable partial parse cannot signal
that warnings occurred.
Decision
Margin’s stable exit-code contract is:
0: the requested operation completed cleanly;1: an operational failure occurred, or usable output was produced with one or more warnings;2: the invocation or configuration is invalid.
Pager passthrough remains byte-identical and exits 0, including an ordinary
broken pipe caused by a downstream command finishing early. Patch or JSON
output that is usable but has parser warnings is emitted and exits 1.
Interactive review exits 1 when the session showed an operational write or
persistence failure, even if the developer continued reviewing before quit.
Consequences
- Existing successful and usage-error values do not change; the previously
unused
1gains precise semantics. - Runtime errors currently returning
2must be reclassified and covered by binary integration tests. - The TUI runtime must return a session outcome rather than only terminal I/O success, without making non-fatal errors end the review.
ADR-0023: Margin remains a tool-neutral human review boundary through 1.0
- Status: Accepted
- Date: 2026-08-02
Context
Margin’s primary user develops with coding-agent assistance, which creates an obvious temptation to launch or authenticate agent products directly. Such integration would couple Margin to vendor APIs, prompt formats, credentials, and rapidly changing CLI behavior. It would also blur the product’s role as the independent place where a human evaluates proposed changes.
Decision
Through 1.0, Margin does not launch, control, authenticate, or retain state for coding agents. It consumes tool-neutral review inputs—local Git changes, revisions, and standard patches—and emits tool-neutral feedback as Markdown or versioned JSON.
Developers and external tools may connect those inputs and outputs to any agent
through files, pipes, or explicit orchestration outside Margin. Explicit forge
input such as margin pr remains permitted under ADR-0015 because it acquires a
changeset for review rather than operating a coding agent.
Consequences
- The agent proposes changes; the developer remains accountable and uses Margin as the independent review boundary.
- Margin avoids vendor SDKs, prompt management, agent credentials, and an open-ended integration compatibility matrix.
- Agent-specific convenience must be built outside Margin through its stable CLI and output contracts until this decision is revisited after 1.0.
ADR-0024: SHA-pinned, least-privilege CI supply chain
- Status: Accepted
- Date: 2026-08-02
Context
Margin’s GitHub Actions build, test, publish documentation, update the Homebrew formula, and produce release binaries. Mutable action tags make those trusted jobs depend on code that can change without a repository commit, including jobs with write permissions and artifact authority.
Decision
Every reusable GitHub Action, including GitHub-owned actions, is pinned to a full commit SHA with a version comment for readability. Dependabot maintains those pins. Every workflow declares the minimum permissions it needs, and the repository restricts execution to an approved action set.
After existing workflows are migrated, repository policy enforces full-SHA pinning. Generated cargo-dist workflow changes receive the same review and must be repinned before merge; generated ownership does not exempt release code from supply-chain policy. Dependabot security updates remain enabled alongside the scheduled Rust advisory check.
Consequences
- Workflow code changes become visible, reviewable repository changes.
- Release credentials and artifacts have a smaller compromise surface.
- Action updates require routine maintenance, and cargo-dist regeneration may require a pin-refresh step before CI accepts the result.
ADR-0025: Publish the Margin binary as the margin-review package
- Status: Accepted
- Date: 2026-08-02
- Amends: ADR-0011 and ADR-0021
Context
The margin name on crates.io was claimed by an unrelated project after
Margin’s founding plan checked availability. Avoiding the registry removes a
normal installation and discovery path for Rust users, while renaming the
product and executable would discard a fitting identity before that is
necessary.
Decision
The product and executable remain named margin. Its crates.io package is
named margin-review, so the supported registry installation command is:
cargo install margin-review
The workspace dependency packages are published under their existing
margin-core, margin-vcs, and margin-tui names, in dependency order and at
the same lockstep version as margin-review. Installation documentation must
state plainly that cargo install margin installs an unrelated project.
Publishing implementation crates does not stabilize their Rust APIs before 1.0; ADR-0021’s user-facing compatibility contract remains scoped to documented Margin behavior. Normal Cargo semver rules and release notes still apply to package consumers.
Consequences
- Margin regains a conventional source-install and discovery channel without a product or command rename.
- Release candidates and stable releases must verify packaging and publish in dependency order after registry dry runs succeed.
- Four registry packages add release and ownership maintenance, and their names must be secured by a real release rather than empty placeholders.
ADR-0026: Highlight warming is per-frame budgeted fill-in, not a background thread
- Status: Accepted
- Date: 2026-09-12
- Supersedes: the “warmed by a background thread” clause of ADR-0006
Context
ADR-0006 promises that syntax highlighting and intra-line emphasis run
“lazily … cached per file, warmed by a background thread”. The
implementation never had that thread: HighlightCache works on a
per-frame line budget (FRAME_BUDGET), set at the top of every view,
with unfinished work resuming across fill-in frames. The budgets the
decision cares about — first paint and scrolling on giant diffs — are met
by bounding work per frame, and the budget mechanics are covered by
deterministic tests rather than by warming.
Decision
The background-thread warming clause is retracted. The mechanism is:
- highlight and emphasize only lines the current frame requests;
- stop at the per-frame budget and report pending work;
- resume across subsequent frames until complete.
No new thread, no shared cache invalidation, no shutdown protocol — the simplest mechanism that meets the budgets. The laziness and caching clauses of ADR-0006 stand unchanged.
Consequences
- Architecture documentation must describe budgeted fill-in, not warming.
- Performance enforcement (issue #99) rests on the deterministic budget test plus workload completion tests, not on warming behavior that was never built.
ADR-0027: Refuse invocations that would review something other than what was given
- Status: Proposed
- Date: 2026-10-05
- Supersedes / Superseded by: — (applies ADR-0007’s “refuse loudly” rule to two cases it did not name)
Context
Margin’s worst failure is showing a reviewer a different changeset than the one they meant to review. They then accept or annotate the wrong code while believing they checked the right code. Two invocations did exactly that, silently:
git diff | margin(andmargin < file): a baremarginreviews the working tree and never reads stdin, so the piped diff was ignored (issue #119). Forgit diffthe two often coincide. Forgit show | marginorgit diff main | marginthey do not.- Root flags placed before a subcommand:
margin --staged diffreviewed the working tree,margin -w showdropped-w, andmargin --json undoran a real working-tree write instead of producing a document.
Stdin is a hard signal. Non-interactive callers hand over a stdin pipe
that means nothing: scripts, git hooks, ssh host margin, while read
loops, and agent tools whose subprocesses default to stdin=PIPE. Those
callers mostly want documents (--json, --notes) or a piped summary.
Margin’s primary users drive it from coding agents (ADR-0023), so breaking
margin --json under a pipe would break the core audience. Reading stdin
“just in case” would swallow a script’s loop input, and blocks forever on
a pipe whose writer never closes.
Decision
Margin refuses (exit 2, ADR-0022) an invocation whose input or flags it would otherwise ignore while reviewing something else. It never guesses a different mode.
- A bare
marginrefuses redirected stdin (a pipe, socket, or regular file; not a terminal or/dev/null) only when it would open the interactive review: stdout is a terminal and neither--jsonnor--noteswas given. The message names both explicit forms (... | margin patch, andmargin difformargin diff --staged). Stdin is classified by file type and never read. The check runs before any terminal query, such as auto-theme detection. - Root
--stagedand-wapply todiffwhen the subcommand isdiff. Withshow,patch,pr, orundo, they refuse. Root--jsonand--notesrefuse withundo. margin pageris exempt from all of the above. Its byte-identical passthrough contract (ADR-0007) outranks any flag.- Detection is Unix-only for now. On Windows, file attributes alone cannot
tell a redirected file from the
NULdevice, and a false positive would refuse every script run, so Windows keeps the previous behavior until it can classify the handle (e.g.GetFileType).
Consequences
- No successful invocation changes meaning: each refusal replaces a review of the wrong changeset. Scripts that depended on the old behavior get a loud, actionable exit 2 rather than different output.
- Error-first leaves the door open. If users want
git diff | marginto just work, a later ADR can turn the refusal into patch mode without breaking anyone. A silent behavior could not be taken back that way. - Document modes still ignore a redirected stdin silently:
git diff | margin --jsondocuments the working tree. That is the price of not breaking non-interactive callers;margin patch --jsonis the explicit form. - Windows users keep the silent
git diff | marginbehavior until the platform gap closes.
Alternatives considered
- Adopt patch mode on redirected stdin. It guesses intent, reads input scripts never meant to give, and hangs on open pipes. It also cannot be reversed later without breaking whoever came to rely on it.
- Warn on stderr and continue. In the interactive case, the warning is hidden behind the alternate screen until after the reviewer has already reviewed the wrong changeset.
- Refuse in every mode. It breaks
margin --jsonfor agents, hooks, andssh, the callers Margin most needs to serve. - Make root flags conflict with all subcommands (clap
args_conflicts_with_subcommands). It breaks the documented, workingmargin --json showform.
ADR-NNNN: Title
- Status: Proposed
- Date: YYYY-MM-DD
- Supersedes / Superseded by: —
Context
What problem are we solving? What forces are in play (constraints, goals, prior art)? Written so a newcomer two years from now understands the situation without the PR thread.
Decision
The decision, stated in one or two sentences, in the active voice. Then any detail needed to apply it consistently.
Consequences
What becomes easier, what becomes harder, what we are now committed to. Include the costs honestly — an ADR with no downsides wasn’t thought through.
Alternatives considered
Each rejected option with the one real reason it lost.
Margin — Project Blueprint
Historical document. This is the founding plan captured before Margin’s first release. It is preserved as project history and is not a current specification. See the root
README.mdfor the product,ROADMAP.mdfor direction,docs/architecture.mdfor current architecture, anddocs/adr/for decisions. Availability claims and future-tense statements below are intentionally not maintained.
A fast, keyboard-first terminal diff viewer for reviewing Git changes, patches, and AI-authored code. Written in Rust. Single binary. Starts instantly.
This document is the complete founding blueprint: analysis of the prior art (Hunk), product positioning, MVP scope, architecture, repo layout, process, and a step-by-step path from first commit to public launch.
The actual project foundation lives in the repository root — a compiling
Cargo workspace with governance docs, CI, and Architecture Decision Records
in adr/ that lock in every major technical
decision below with context and alternatives considered.
Naming note: margin is available on crates.io (verified 2026-06-10).
Verify GitHub org/repo and Homebrew formula availability before committing to it.
Backup candidates also free on crates.io: onceover, perusal, mull (collides
with the mull mutation-testing project — avoid). The rest of this document uses
Margin / binary margin.
1. What makes Hunk useful (honest analysis)
Hunk (modem-dev/hunk, MIT, TypeScript/Bun on OpenTUI + Pierre diffs) earned its
audience by getting a few things genuinely right:
- Review-first framing. It’s not a pager or a prettifier — it treats a changeset as a thing you review, with a sidebar of files and a review stream. That single framing decision separates it from delta/diff-so-fancy (stream prettifiers) and difftastic (a better diff algorithm).
- Mirrors Git’s mental model.
hunk diff,hunk show HEAD~1,hunk patch -,hunk pager— zero new concepts to learn. You can drop it intocore.pagerand it composes with what you already do. - The AI-review wedge. Inline agent annotations and a live agent-session workflow (“open Hunk, point your agent’s skill at the session”). It’s the first diff tool positioned for the human half of the human–agent loop, and that’s why it took off now.
- Responsive layout. Auto split/stack based on terminal width — a small thing that makes it feel like a designed product, not a curses demo.
- Meets users where they are. Watch mode, mouse support, Jujutsu and Sapling auto-detection, themes via TOML, npm + brew + nix packaging.
- Tasteful defaults. Syntax highlighting, line numbers, good themes out of the box. No configuration required to get the screenshot experience.
Its structural weaknesses (our opening):
- Runtime weight. Node 18+ / Bun, npm global install, a session-broker daemon for agent workflows. Startup and memory are JS-class, and the install story excludes people who don’t have Node.
- View-only. You can look at a hunk but you cannot act on it — no stage, unstage, or discard. After reviewing, you switch tools to do anything.
- No search, no structural diff, weaker giant-diff story. Their own feature table concedes structural diffing; lockfile-heavy agent diffs are exactly where TUI diff viewers fall over.
- Complexity creep. Daemon + session broker + embeddable component API at v0.15 is a lot of surface area for a young project.
2. Features to take inspiration from (not code)
Take the ideas, reimplement from scratch:
| Idea | Why it matters |
|---|---|
Git-verb CLI (diff, show, pager, stdin patch) | Zero learning curve; composes with git config |
| File sidebar + review stream | The core “review a changeset” interaction |
| Auto split/stack responsive layout | Feels designed; works in narrow tmux panes |
| Watch mode with auto-reload | Perfect for “agent is editing right now” |
| Pager mode with non-TTY passthrough | Safe to set as core.pager permanently |
| TOML config + custom themes inheriting a base | Low-effort personalization |
| Untracked files included in working-tree diff | Agents create new files constantly; git diff hides them |
| Honest feature-comparison table in README | Builds trust, frames the category |
| Examples directory with runnable demo diffs | Best onboarding device a diff tool can have |
Explicitly not copying: their branding/visual identity, OpenTUI component API, the agent daemon/session-broker architecture (right idea, too heavy too early), and the npm distribution model.
3. Where Margin improves beyond Hunk
- Single static Rust binary.
brew install margin,cargo install margin, orcurl | sh. No runtime. Cold start target: < 50 ms to first paint on a 100-file diff. This alone is a reason to switch. - Act on the diff. Stage, unstage, and discard at hunk (later: line) granularity, inside the review flow. Review → decide → done, one tool. This is the headline feature Hunk lacks and the killer workflow for AI changes: accept/reject an agent’s edits hunk by hunk.
- Search and jump.
/regex search across the changeset,n/Nbetween matches, fuzzy file picker. Table stakes from vim/less that review TUIs keep forgetting. - Review state. Mark files viewed (GitHub-style), persists per
(repo, diff-id)so re-running after a rebase keeps your place. - Giant-diff performance as a feature. Lazy syntax highlighting,
virtualized rendering, auto-collapse of lockfiles/generated files
(
linguist-generated, configurable globs). Benchmarked in CI; the README publishes the numbers. - Structured output for tooling.
margin --jsonemits the parsed changeset and (later) review decisions. Agents and scripts consume Margin without scraping a TTY. Our agent story is files and exit codes, not a daemon — simpler, scriptable, debuggable. - Terminal citizenship. Correct behavior in tmux/ssh/16-color terminals,
NO_COLOR, light/dark detection, non-TTY passthrough, graceful wide-char handling. Boring, and the difference between a toy and a tool. - Review notes that go somewhere. Per-hunk notes exported as Markdown — paste into a PR or feed back to the agent that wrote the code.
What we deliberately skip (scope discipline): structural/AST diffing (difftastic owns it; revisit post-1.0), commit graph/log browsing (lazygit/gitui own it), merge-conflict resolution (separate product), embeddable UI component.
4. MVP feature set (v0.1.0)
Principle: the best read-only diff review experience in a terminal, period. Writing operations land in v0.2 — the MVP must be flawless at viewing first, because trust in a tool that can discard your code starts with trust in how it displays your code.
Inputs
margin/margin diff— working tree vs HEAD, untracked files includedmargin diff --staged— index vs HEADmargin show [rev],margin diff A..B,margin diff A B -- path/(revision args delegated to git semantics)margin diff fileA fileB— two arbitrary filesgit diff | margin/margin patch -— any unified diff on stdinmargin pager—core.pagermode; passes input through unchanged when stdout is not a TTY
UI
- File sidebar (toggle
b): status glyph, +/- counts, viewed checkmarks - Unified and side-by-side views;
autopicks by width; togglev - Syntax highlighting (syntect) + intra-line word-level diff highlighting
- Line numbers, hunk headers, file headers with old/new paths and mode changes
- Collapse/expand files (
zaper file,zAall); auto-collapse generated files - Renames, binary files, mode changes, submodules rendered sensibly
- Help overlay (
?), command footer, 4 built-in themes + ANSI-16 fallback
Navigation (keyboard-first; vim grammar)
j/kline,J/Khunk,]/[orTabfile,gg/G,Ctrl-d/u/search (regex),n/Nnext/prev matchffuzzy file pickermmark file viewed (session-scoped in MVP),rreload,qquitwtoggle wrap,1..4theme quick-switch
Quality bars (release blockers, tested in CI)
- < 50 ms to first paint, 100-file / 10k-line diff (M-series Mac, release build)
- 250k-line diff scrolls at 60 fps; highlighting is lazy and never blocks input
- Works on macOS, Linux, Windows Terminal; tmux and ssh verified
- Zero panics on the patch-corpus fuzz set; non-UTF-8 content degrades, never crashes
Explicitly deferred
v0.2: stage/unstage/discard hunks, watch mode, persistent viewed-state.
v0.3: review notes + Markdown export, --json, gh PR integration, jj support.
Later: mouse, line-level staging, interdiff, custom keymaps, MCP server (maybe).
5. Tech stack and architecture
Every row below is locked in by an ADR in adr/ — change the ADR
to change the decision.
| Concern | Choice | ADR |
|---|---|---|
| Language | Rust (stable, MSRV = stable−2) | 0001 |
| TUI | ratatui + crossterm | 0002 |
| App pattern | Elm architecture, pure core | 0003 |
| Code layout | 4-crate Cargo workspace, DiffSource boundary | 0004 |
| Git | git2 behind a trait | 0005 |
| Highlight / intra-line | syntect + similar | 0006 |
| CLI | git-verb subcommands, pager passthrough guarantee | 0007 |
| Config | TOML, XDG + repo-local | 0008 |
| Errors | thiserror in libs, anyhow at edge, no-panic policy | 0009 |
| Testing | unit + insta snapshots + temp-repo integration + corpus + fuzz + bench | 0010 |
| Release | cargo-dist, Conventional Commits, git-cliff | 0011 |
| License | MIT OR Apache-2.0 | 0012 |
Why not TypeScript/OpenTUI like Hunk: we’d inherit exactly the weaknesses we’re positioning against (runtime, startup, install). Why not Go/bubbletea: viable, but ratatui’s ecosystem (syntect/similar/git2/insta) is a better fit and Rust attracts the contributor base that builds tools like this (delta, gitui, bat).
Architecture: Cargo workspace, Elm-shaped app
margin/ # workspace root
├── crates/
│ ├── margin-core/ # PURE: no I/O, no TUI deps
│ │ ├── model.rs # Changeset → FileDiff → Hunk → Line (+ intra-line spans)
│ │ ├── patch.rs # unified-diff parser (fuzzed)
│ │ ├── intraline.rs # word-level diff via `similar`
│ │ └── collapse.rs # generated-file detection, lockfile heuristics
│ ├── margin-vcs/ # DiffSource trait + implementations
│ │ ├── source.rs # trait DiffSource { fn load(&self) -> Changeset; fn id(&self) -> DiffId }
│ │ ├── git.rs # git2: worktree/staged/rev-range/show (+ apply in v0.2)
│ │ ├── files.rs # two-file comparison
│ │ └── patch_input.rs # stdin/patch-file
│ ├── margin-tui/ # rendering + interaction, depends on core only
│ │ ├── app.rs # Model (AppState), Msg, update() — Elm architecture
│ │ ├── view/ # sidebar, unified, side_by_side, help, picker, search
│ │ ├── highlight.rs # lazy syntect cache (highlight on first visibility)
│ │ ├── keymap.rs # Msg ← key event mapping (custom keymaps later = data change)
│ │ └── theme.rs # built-ins + custom-from-TOML
│ └── margin/ # the binary
│ ├── main.rs # clap → pick DiffSource → run TUI or passthrough
│ └── config.rs # config discovery/merge
Load-bearing decisions:
margin-coreis pure and I/O-free. The diff model, parser, and intra-line logic are plain functions over data — trivially unit-testable, fuzzable, and reusable (the future--jsonoutput is just serializing this model).DiffSourceis the only seam to the outside world. Git, files, and stdin all produce the sameChangeset. Watch mode (v0.2) is “a source that can notify”; jj support (v0.3) is one new impl; tests inject synthetic sources.- Elm architecture in the TUI: one
AppState, oneMsgenum, oneupdate(state, msg), pureview(state) -> Frame. Every interaction is testable asupdatecalls + an insta snapshot of the rendered frame. No scattered mutable widget state. - Lazy everything for big diffs: parse the changeset eagerly (cheap), highlight and intra-line-diff only what scrolls into view, cache per file. The UI thread never blocks; a background thread warms the cache.
- Write operations (v0.2) live in
margin-vcsbehind explicit, confirmation-gated Msgs. Discard is destructive: it requires a typed confirm and (config-on by default) drops a backup patch in.git/margin/trash/for undo.
6. Repository structure
Implemented in the repository root:
margin/
├── crates/ # (as above — compiles today)
├── tests/corpus/ # real-world .patch fixtures: renames, binary,
│ # CRLF, unicode, mode changes, 100k-line lockfile
├── benches/ # criterion giant-diff benchmarks (issue #6)
├── fuzz/ # cargo-fuzz parser target (issue #2)
├── examples/ # runnable demo diffs
├── docs/
│ ├── adr/ # Architecture Decision Records (0001–0012 + template)
│ ├── architecture.md # the crate map, kept current
│ ├── keybindings.md configuration.md themes.md
├── assets/ # demo.tape (vhs) → README GIF
├── .github/
│ ├── workflows/ci.yml # fmt, clippy, deny, 3-OS test matrix, MSRV
│ ├── ISSUE_TEMPLATE/{bug_report.yml,feature_request.yml,config.yml}
│ ├── PULL_REQUEST_TEMPLATE.md dependabot.yml
├── README.md CONTRIBUTING.md ROADMAP.md CHANGELOG.md
├── CODE_OF_CONDUCT.md SECURITY.md LICENSE-MIT LICENSE-APACHE
├── Cargo.toml # workspace + pinned workspace.dependencies
├── rust-toolchain.toml rustfmt.toml deny.toml cliff.toml
└── AGENTS.md # agent briefing: build/test commands, architecture map
7. README, docs, roadmap, contributing, issue templates
All written and in place in the repository root:
README.md— hero GIF slot, 30-second pitch, install, quick start, honest comparison table (delta, difftastic, Hunk, gitui — with credit to Hunk as inspiration), config, FAQ.CONTRIBUTING.md— dev setup in 3 commands, architecture + ADR pointers, test/snapshot workflow, commit conventions, “what makes a good PR”.ROADMAP.md— v0.1 → v1.0 published in-repo (roadmap-as-promise)..github/ISSUE_TEMPLATE/— structured YAML bug/feature forms + config.yml routing questions to Discussions.adr/— decision log; new significant decisions require an ADR (process indocs/adr/README.md).
Docs philosophy: the README is the product page; docs/ is the manual;
? in-app is the cheat sheet. Every feature PR updates all three or explains
why not (PR template enforces).
8. Testing, CI, release, packaging
Testing pyramid (ADR 0010)
- Unit (
margin-core): parser, model invariants, intra-line spans, collapse heuristics. Target: core is ~fully covered because it’s pure. - Snapshot (
margin-tui): render known changesets at fixed sizes (80×24, 200×50, 40×20) via ratatuiTestBackend, snapshot with insta. Keybinding flows = sequences ofupdate()calls then snapshot. - Integration (
margin-vcs+ bin): create real temp git repos (staged, untracked, renamed, binary, submodule cases), assert the Changeset; run the binary with--json/passthrough modes and assert stdout. - Corpus: every weird real-world patch that ever breaks us gets a fixture and a regression test. Fuzz target runs the parser on arbitrary bytes.
- Benchmarks: criterion on parse + first-frame for the giant fixture; CI fails on >20% regression (informational at first, blocking by v0.3).
CI (GitHub Actions — .github/workflows/ci.yml)
check: rustfmt, clippy-D warnings, cargo-deny (advisories + licenses)test: matrix {ubuntu, macos, windows} × {stable}, plus MSRV on ubuntufuzz-smoke: 60s parser fuzz on PRs touchingmargin-core(added with issue #2)bench(main only): post numbers as commit comment- Caching via
Swatinem/rust-cache; total PR wall-time target < 8 min
Release & packaging (cargo-dist, ADR 0011)
- Tag
v*→ release workflow builds:aarch64/x86_64-apple-darwin,x86_64/aarch64-unknown-linux-musl(static),x86_64-pc-windows-msvc - Artifacts: tarballs + checksums,
curl | sh+ PowerShell installers, Homebrew formula pushed to<org>/homebrew-tap cargo publish(all crates), cargo-binstall metadata for free- git-cliff generates CHANGELOG from Conventional Commits; release notes curated by hand on top
- Post-1.0 channels: AUR, nixpkgs, winget, scoop, mise — driven by community PRs
9. The first 10 GitHub issues
Create these on day one; they make the project look alive and give early
visitors something to grab. Each gets labels from:
area:core area:tui area:vcs area:ci type:feat type:infra
good first issue help wanted M1 (milestone v0.1).
-
#1 Scaffold workspace, CI, and quality gates
type:infraM1Cargo workspace with the four crates (stub lib.rs + doc comments stating each crate’s contract), rustfmt/clippy/deny configs, ci.yml green, README, dual license, CODE_OF_CONDUCT, PR/issue templates, ADRs. AC:cargo testgreen in CI on all three OSes from a fresh clone. ← largely done by this scaffold; close after first CI run. -
#2
margin-core: changeset model + unified-diff parserarea:coreM1DefineChangeset/FileDiff/Hunk/Line(+ rename/binary/mode metadata). Parse unified diffs incl. git extensions (rename from/to, mode, binary). Corpus fixtures + fuzz target. AC: parses every fixture byte-exactly round-trippable; fuzz 10⁶ iters clean. -
#3
margin-vcs:DiffSourcetrait + git worktree/staged/rev sourcesarea:vcsM1git2-backed sources for worktree (untracked included),--staged, revision ranges,show. Integration tests on generated temp repos. AC: matchesgit diffsemantics on the test matrix incl. renames. -
#4 Minimal TUI: file list + unified view + vim navigation
area:tuiM1Elm skeleton (AppState/Msg/update/view), sidebar, unified diff pane, j/k/J/K/]/[/gg/G/q, help overlay. Insta snapshots at 3 terminal sizes. AC: review a real repo’s diff end-to-end with keyboard only. -
#5 Side-by-side view + responsive auto layout
area:tuiM1Split view,vtoggle, auto mode switching on width threshold, wrap toggle. AC: snapshot tests for both layouts; no panic at 40×10. -
#6 Syntax highlighting + intra-line word diff, lazily
area:tuiarea:coreM1syntect with precompiled assets, highlight-on-visibility cache,similar-based word spans. Bench: first frame of giant fixture < 50 ms. AC: bench in CI; scrolling never blocks on highlighting. -
#7 stdin patch mode + two-file mode + pager passthrough
area:vcsM1margin patch -,margin diff a b,margin pagerwith non-TTY passthrough. The “safe as core.pager” guarantee. AC:git -c core.pager='margin pager' log -p | catis byte-identical to git’s output. -
#8 Config file + themes (4 built-in, custom via TOML, ANSI-16 fallback)
area:tuiM1Config discovery/merge, theme definitions,NO_COLOR, light/dark detection. AC: docs/configuration.md + docs/themes.md written; snapshot per theme. -
#9 Search (
/,n/N) and fuzzy file picker (f)area:tuiM1Regex search across changeset with match highlighting; fuzzy picker over file paths.good first issuecandidates inside (e.g., match-count badge). AC: snapshots; search on giant fixture < 100 ms. -
#10 Release pipeline + demo assets
type:infraM1cargo-dist config, release workflow, Homebrew tap repo, vhs tape rendering the README GIF, install docs. AC: taggingv0.1.0-rc1produces installable artifacts on all 5 targets.
(Then immediately file #11 “stage/unstage hunks” and #12 “watch mode” tagged
M2, so visitors see the trajectory.)
10. Roadmap: MVP → polished OSS launch
Phase 0 — Foundation (week 1)
Issue #1 (this scaffold) + #2–#3 started. Private or quiet-public repo. Decide name (crates.io ✅; check GitHub org + brew). Reserve the crate name with a 0.0.1 stub publish.
Phase 1 — Vertical slice (weeks 2–4)
Issues #4–#7. Gate: you stop using git diff yourself. Dogfood daily;
every paper cut becomes an issue.
Phase 2 — MVP polish (weeks 5–7)
Issues #8–#10. Windows/tmux/ssh verification pass. Fuzz + corpus hardening. Write docs/. Render demo GIF. Tag v0.1.0 — quietly. Post in 2–3 friendly spots (ratatui Discord showcase, a couple of git-tooling threads) for early feedback, not reach.
Phase 3 — The differentiator (weeks 8–10) → v0.2.0
Stage/unstage hunk (s/u), discard with typed confirm + trash-patch undo,
watch mode, persistent viewed-state. This release is the launch payload:
“review and act on your agent’s changes without leaving the terminal.”
Phase 4 — Launch prep (weeks 11–12)
- README final pass: GIF above the fold, comparison table, 60-second install→review
good first issue× ≥6, CONTRIBUTING tested by an outsider following it cold- Cut v0.2.0; verify brew/curl/cargo installs on clean machines
- Write the launch post: lead with the accept/reject-AI-changes workflow demo
Phase 5 — Launch (week 13)
Show HN (Tue–Thu morning ET), r/rust + r/commandline + r/git (staggered, tailored titles), lobste.rs, Terminal Trove, This Week in Rust PR. Be present in comments all day; convert every feature request into a labeled issue and reply with the link. Triage SLA for launch week: respond < 24 h.
Phase 6 — Post-launch (months 4–6) → v0.3, then 1.0 trajectory
- v0.3: review notes + Markdown export,
--json,margin pr <n>viagh, jj support - Release cadence: minor every 4–6 weeks, patch as needed; never break keybindings without a deprecation cycle
- Community: label discipline, monthly “what’s new” discussion post, recognize repeat contributors with triage rights
- 1.0 criteria: config/keybinding stability promise, zero known data-loss bugs in write paths, packaged in ≥4 ecosystems, 3 consecutive boring releases
Success metrics (12 months)
Honest ones: you and ≥5 people you don’t know use it daily (retention beats stars); median time-to-first-response on issues < 48 h; ≥10 non-trivial outside contributors; “margin” appears unprompted in “best terminal diff tool” threads.
Changelog
All notable changes to Margin. Generated from Conventional Commits by git-cliff; release notes are hand-curated on top in GitHub Releases.
[0.5.1] - 2026-08-02
Fixed
- Review notes were invisible while the cursor sat on their hunk — the
moment right after typing one. They used the dim
metastyle, whose grey is the cursor line’s background grey in several themes. Notes now have their own theme slot (note, overridable in custom themes), and a style-assertion test checks legibility on the cursor line in every theme and colour mode — symbol snapshots cannot see colour, which is why this shipped in 0.5.0 unnoticed.
[0.5.0] - 2026-08-01
The reviewer’s half of the agent loop: annotate what you read, export it for whoever fixes it.
Added
- Review notes (issue #23):
cannotates the hunk under the cursor with a one-line remark, shown inline on the hunk header with a✎count in the sidebar. Enter saves, empty input deletes, Esc cancels. Notes persist per review beside viewed marks, keyed by a digest of that hunk, so they survive reloads and detach when the hunk itself changes. margin --notesprints those notes as Markdown instead of opening the TUI — each note under its file with apath:lineanchor and its hunk header, ready to paste into a pull request or hand to an agent. Works for every review source (--notesis global, like--json, and the two refuse to be combined).
Performance
- The sidebar’s staged-file summary no longer loads a full index-vs-HEAD changeset (issue #62). It enumerates diff deltas instead, so the cost tracks the number of staged files rather than their content — measured at 101 ms → 2.2 ms (45x) on a repo with 800 staged files, on every stage, unstage, discard, and reload.
[0.4.0] - 2026-08-01
Make it yours: custom themes, mouse support, shell completions, and runnable examples.
Added
-
Mouse support (issue #26): the wheel scrolls, a left click places the cursor on the clicked row, a click in the sidebar jumps to that file, and a click closes the help overlay. Strictly additive — the keyboard stays primary, overlays keep their keyboard grammar, and
mouse = falsein config (or--no-mouse) opts out entirely, keeping the terminal’s own text selection. -
Custom themes (issue #15): a
[themes.<name>]section in the user config inherits a built-inbaseand overrides individual#rrggbbcolors and/or thesyntax_theme;theme = "<name>"selects it, and naming one after a built-in tweaks whatautopicks. Unknown keys and malformed colors are config errors naming the key. Repo-local.margin.tomlcannot define themes (a hostile repo could restyle additions into invisibility). Degraded modes (16-color,NO_COLOR) still apply unchanged. Schema documented in docs/themes.md. -
margin completions <bash|zsh|fish|powershell>prints shell completions, and the hiddenmargin manprints the roff man page (issue #16, ADR-0016) — generated at runtime from the installed binary, so they can never drift from your version. Both work even with a broken config file, so anevalin a shell rc is safe. -
An
examples/directory of runnable demo patches (issue #17): syntax + intraline, rename + mode change, binary files, format-patch mail, unicode paths — try Margin with no repository at hand. CI asserts every example opens with zero parse warnings.
[0.3.0] - 2026-07-30
Pull-request review, review persistence, and a terminal-aware default theme.
Security
margin prrejects flag-shaped selectors before they ever reach the authenticatedghCLI’s argv (argument injection), and bothghinvocations pass the selector after--as defense in depth.
Added
-
The default theme is now
auto(issue #27): Margin queries the terminal’s background color (OSC 11, 50 ms budget) and picksledgeron dark,foolscapon light. Unanswering terminals (tmux/screen without passthrough, CI, pipes) fall back toledger— the previous default — and an explicit theme anywhere skips the query. -
--no-untrackedexcludes untracked files from worktree reviews (issue #18) — the flag form of theinclude_untrackedconfig key, completing flag/config parity (ADR-0008).--dump-configreflects it. -
margin pr <number|branch|url>reviews a GitHub pull request through the authenticatedghCLI (issue #24, ADR-0015) — Margin never holds a token. Viewed marks persist per PR and survive force-pushes for untouched files (content digests do the invalidation).--jsonworks here too. Clear errors whenghis missing or not logged in. -
The status bar shows
hunk x/yfor the hunk under the cursor (issue #19) — reviewers think in hunks, not rows. -
Mark viewed (issue #20):
mchecks off the cursor’s file — sidebar checkmark, body folded (zareopens without unmarking). Marks persist per review under the data dir, keyed by a content digest: quitting and relaunching the same diff keeps your place, a rebase keeps untouched files marked, and any file that changed un-views itself. Patch and pager reviews keep marks session-only; nothing is written for them.
[0.2.0] - 2026-07-11
Structured output for scripts and agents, plus hardening from the post-M2 review sweep.
Fixed
- Watch mode no longer reloads while the fuzzy picker is open (the world shifting under a half-made choice could jump to the wrong file), and a reload that does happen refilters an open picker against the new changeset instead of leaving stale indices.
- Watch mode’s debounce now has a maximum wait: a sustained write storm (an agent writing continuously) can no longer starve the reload — after ~8 quiet-windows of continuous activity the review refreshes anyway.
- Discard backups are written with an atomic create-new, closing a window where two same-millisecond discards from different margin instances could silently overwrite each other’s trash entry.
- Watch mode no longer re-renders an unchanged frame ten times a second while idle.
Added
- JSON output (issue #22):
--jsonondiff,show, andpatchemits the parsed changeset as a versioned document ("schema": 1) — files, hunks, and lines with statuses, renames, binary flags, modes, and counts. Strings are lossy UTF-8 with honesty flags on anything that had invalid bytes. Schema documented indocs/json-output.md; within schema 1 changes are additive only. Pager passthrough is untouched;--watchand--jsonrefuse to combine.
[0.1.0] - 2026-07-12
The first stable release. Everything from the release candidate, plus the “act on the diff” feature set: stage, unstage, discard (with undo), reload, watch mode, and collapse.
Added
- Act on the diff (issue #10):
sstages anduunstages the hunk under the cursor, applying exactly the reviewed hunk bytes to the git index — never the working tree — then reloading and re-anchoring the cursor. Stale hunks, binary files, renames, and non-git sources report in the status bar instead of failing. In worktree reviews the sidebar marks files that have staged content with a dot, so partial staging is visible at a glance; the marker refreshes as you stage and unstage. - Reload:
rre-reads the diff from its source without leaving the review, keeping your place. Works in every mode with a live source (worktree,--staged, revisions, files); staging feedback points at it when a hunk no longer applies. - Staging feedback tells the truth about the common misfires: staging an already-staged hunk says so (instead of “changed since load”), and unstaging a file with nothing staged refuses up front.
- Collapse (issue #21):
zafolds the cursor’s file to its header (counts stay visible),zAfolds or unfolds everything. Lockfiles (Cargo.lock,package-lock.json,go.sum, …) and generated artifacts (*.min.js,*.pb.go, source maps, …) fold automatically; thecollapseconfig key (user or repo — it is a display option) adds globs. Navigation skips folded bodies, search never matches inside them, and fold choices survive watch-mode reloads. - Watch mode (issue #12):
margin -w/margin diff -wreloads the review automatically while an agent edits — OS file events, debounced (rapid writes collapse into one reload), cursor and search kept in place,[watch]in the status bar. External staging and new commits refresh too (the index and reflog are watched; object churn is not). Auto-reload never fires while the discard confirmation is open. Worktree and--stagedreviews only; static views refuse the flag. - Discard (issue #11):
xremoves the hunk under the cursor from the working tree — Margin’s only destructive action, so it is guarded twice (ADR-0014): a prompt that only typedyes+ Enter confirms, and a backup patch written to.git/margin/trash/before anything is applied.margin undorestores the most recent discard;discard_trash = false(user config only) opts out of backups. Stale hunks refuse cleanly; the index is never touched, so staged copies survive a discard exactly as withgit restore.
[0.1.0-rc.1] - 2026-07-03
Added
- Line wrap:
wwraps long lines in both unified and split views instead of clipping them (issue #14). Wrapped rows scroll as one unit, the cursor keeps its full row on screen, wrapping never splits a double-width character, and syntax colors, intra-line emphasis, and search highlights all carry across continuation rows. The status bar shows[wrap]. - The
?help overlay now lists search, the file picker, and the layout toggle (it had fallen behind the keymap). - Fuzzing (issue #8): three cargo-fuzz targets —
parse_unified(full parse → display → intraline pipeline with safety-contract asserts),strip_ansi(never grows input, never leaks ESC, idempotent), andintraline(every range sliceable on UTF-8 boundaries) — seeded from the patch corpus, run weekly in CI with a smoke run on parser PRs. - Search:
/opens incremental smart-case regex search over file paths and line contents (both sides in split view); matches highlight inline,n/Nwrap-navigate, the status bar shows a position badge, and invalid regexes report instead of failing. A keystroke over a 250k-line diff costs ~10 ms (allocation-free byte scanning). - Fuzzy file picker:
fopens a jump-to-file overlay with dependency-free subsequence matching that prefers tight, early matches. - Configuration (ADR-0008): user
config.toml(XDG paths,$MARGIN_CONFIGoverride), repo-local.margin.tomlrestricted by schema to display options,--theme/--layoutflags, andmargin --dump-config. Unknown keys error with did-you-mean suggestions. - Four built-in themes —
ledger(default dark),foolscap(light),carbon(high contrast),blueprint(blue dark) — each with a matched syntax palette, plus deliberate degradation: one ANSI-16-safe palette on non-truecolor terminals (syntax off) and aNO_COLORmonochrome mode using bold/dim/reverse only. - The full git-verb CLI (clap):
margin diff [--staged] [<rev>|A..B|fileA fileB],margin show [rev],margin patch [-|file], andmargin pager.margin diff <rev>diffs the working tree against that revision. - The pager passthrough guarantee:
pager/patchmodes with piped stdout write input through byte-identical and exit 0 — safe to set asgit config core.pagerpermanently (integration-tested against coloredgit log -p, invalid UTF-8, and missing trailing newlines). - ANSI stripping in margin-core: git colorizes output sent to pagers; Margin parses it cleanly and still passes raw bytes through untouched.
- Two-file diffs without a repository (git2 buffer diffing, binary-aware).
[0.1.0-alpha.2] - 2026-06-12
Added
- Syntax highlighting (syntect, ~200 languages by extension) layered under
addition/deletion background tints, plus word-level intra-line emphasis
(
similar) on paired changed lines — with a rewrite heuristic that keeps emphasis off mostly-changed lines. - Lazy, budgeted rendering: at most a few hundred lines are highlighted per frame app-wide; oversized hunks fill in across frames while the input loop stays responsive. Measured first paint (release): ~4 ms on a 100-file/10k-line diff, ~15 ms on a 250k-line single-hunk file.
- Criterion benchmarks for parsing and frame times, wired into CI as an informational job on main.
[0.1.0-alpha.1] - 2026-06-11
First installable pre-release: the read-only viewer core. Expect rough edges; syntax highlighting (#4), stdin/pager modes (#5), themes (#6), and search (#7) are still in flight on the road to v0.1.0.
Added
- Project foundation: four-crate workspace, ADRs 0001–0012, CI, governance docs, issue/PR templates.
margin-core: changeset data model (bytes-first) and a tolerant unified-diff parser covering git extended headers, renames, binary files, mode changes, C-quoted paths, no-newline markers, plaindiff -uoutput, andgit log -pstreams, with a corpus regression suite.margin-vcs: git2-backed sources — worktree vs HEAD (untracked files included by default), staged,show(incl. root commits), and revision ranges — with rename/copy detection, typed errors, and temp-repo integration tests. The binary prints a changeset summary as a walking skeleton until the TUI lands.margin-tui: the interactive review UI — file sidebar with statuses and counts, unified diff pane with dual line numbers and hunk headings, vim-grammar navigation (j/k,J/K,]/[,gg/G,Ctrl-d/u), help overlay, responsive sidebar, control-character sanitization, and a panic guard that always restores the terminal. The binary launches the TUI on a terminal and prints a plain summary when piped.- Side-by-side layout: deletions and additions paired on aligned rows with
per-side line numbers, unicode-width-aware fitting, and a width-based
automode (split at 120+ columns);vpins unified or split, and the cursor keeps its place when layouts switch.