Magit

Magit: git porcelain inside Lattice — status, commit, diff, log, blame, stash, branch, rebase, and transient dispatch menus, all backed by the VCS subsystem.

On this page

Magit is Lattice's git porcelain — a complete, modal, keyboard-driven interface for git that lives inside the editor. It is modeled on Emacs magit's section-collapsible status buffer and transient prefix menus, adapted to Lattice's vim-normal-mode conventions and everything-is-a-buffer architecture. Staging works at file, hunk and line granularity — including Emacs magit's visual-mode partial stage, where you select lines inside a hunk and move only those.

Every magit view is a buffer-backed Document with a major mode. You open, close, navigate, and search them the same way you do any other buffer — there are no special sidebars, no separate tool windows, and no hidden state.

Status: magit-status (the primary workhorse — staged, unstaged, untracked, stashes, recent commits), magit-commit, magit-diff, magit-log, magit-blame, magit-stash, magit-branch, magit-remote, magit-submodule, magit-rebase, bisect, side-by-side diffs (dv), and the transient dispatch menus (C-c g / C-c f) are shipped. Auto-gutter-diff against HEAD is on by default (git.auto-head-diff). See magit-status-mode for the workhorse view's full chord set.


Quick reference

Key / commandMeaning
C-x gOpen magit-status for the current repo
C-c gOpen the repo dispatch transient — one entry point per view (status/commit/log/branch/remote/stash/rebase), plus S (stage all) / U (unstage all), B (bisect) and f (fetch) / F (pull) / P (push), all real git operations run in the background
C-c fOpen the file dispatch transients stages / d diffs the file in your current buffer (not an entry under the cursor elsewhere)
:magit-statusSame as C-x g — open the status buffer
:magit-commitOpen the commit message buffer
:magit-diffOpen a read-only git diff HEAD view with file-level stage/unstage
:magit-logOpen the commit history log
:magit-blameToggle blame annotations on the current file
:magit-find-file <rev> <path>Open a file as it was at a revision
:magit-checkout <branch>Check out a branch
:magit-stash-listOpen the stash list
:magit-branchOpen the branch list
:magit-remoteOpen the remote list — add / rename / remove / set-url / prune
:magit-submoduleOpen the submodule list — add / update / sync / remove
:magit-refsOpen the refs buffer — every branch, remote-tracking branch and tag
:magit-clone <url> [<dest>]Clone a repository (does not switch you to it — see the dispatch)
:magit-note-edit <commit>Edit a commit's note
:magit-note-remove <commit>Remove a commit's note
:magit-note-merge <ref> [strategy]Merge a notes ref into this one
:magit-cherries <upstream> [<head>]Which commits are not upstream yet
:magit-am <patch>… [-3]Apply a mailbox of patches
:magit-format-patch <range>Write a commit range out as .patch files
:magit-subtree-add/-merge/-pull/-push/-splitgit subtree operations
:magit-log-merged <commit>Show the merge commit that brought a commit into HEAD
:magit-rebaseStart interactive rebase
:magit-rebase-continue / -skip / -abortLeave a rebase that stopped
:magit-fetchFetch from the default remote (--all, --prune)
:magit-pullPull from the upstream branch (fast-forward only)
:magit-pushPush the current branch (--force-with-lease, --set-upstream)
:magit-stashStash the working tree (--include-untracked, -m <message>); :magit-stash-list opens the list
C-c g(in any buffer, magit or not) Open the dispatch menu — every root menu is one key from there
g?(in any magit buffer) Open help for the current mode's keybindings

Every magit command name is dashed + namespaced (magit-status, magit-log, magit-blame, …) — type :magit-<Tab> to see the full command palette.

The last four run a git operation rather than opening a buffer. They are the same operations the C-c g transient offers, reaching the same implementation — the transient is the discoverable surface, the ex-command the scriptable one. Each returns immediately with a magit: pushing…-style echo; the outcome arrives as a notification when it lands, and the full text through the log (and *messages*), because the operation outlives the keystroke that started it. A missing or expired credential fails fast rather than hanging, since git is run with GIT_TERMINAL_PROMPT=0.


Concepts

Everything is a buffer

Magit views are ordinary Documents. They appear in panes, respond to :ls / :bn / :bp / :bd, and inherit all the standard vim grammar (yanks, searches, folds). The renderer treats them identically to code buffers — there is zero special-cased rendering for magit.

Which repository a magit buffer acts on

The one containing the file you were looking at — not the one the editor was started in.

Press C-x g on a file from another checkout and you get that checkout's status. Every chord in that buffer then acts there: s stages into it, c c commits to it, p pushes it. The dispatch menus follow too — C-c g opened over it offers the way out of its stopped rebase, not some other repository's.

Two checkouts open at once are two buffers, because the repository is part of the name:

*magit:status:lattice*      the status of ~/src/lattice
*magit:status:api*          …and of ~/work/api, at the same time
*magit:log:api*             that repository's log
*magit:diff:api:staged:src/main.rs*

:ls tells them apart, :b reaches either, and <C-6> returns to the one you left. If two checkouts share a directory name, the second one to open qualifies itself with its parent (*magit:status:work/api*) rather than sharing a buffer — sharing would mean s staging into whichever was opened last.

Nothing changes if you work in one repository: one repo means one status buffer, behaving exactly as before. The only visible difference is that :ls now says which repository it is, which it should always have said.

The three questions, in order, when you press a magit chord:

  1. Already in a magit buffer? That buffer's repository. A magit chord pressed inside magit never moves you to another repository.
  2. Looking at a file? That file's repository.
  3. Neither? The directory the editor was started in — which is what magit always did, kept so a fresh editor with nothing open still answers C-x g.

Two commands are deliberately about the working directory rather than a repository, because there is no repository yet: :magit-init seeds its prompt with the current directory, and :magit-clone defaults its destination there.

Modes own their surface

Every chord (s, u, x, q, gr, TAB, ]]/[[) and every action-handler body lives inside the lattice-magit crate. The editor host has no do_magit_stage_hunk method — magit is fully inverted out of the host, installing through the same SubsystemBoot seam as every other core feature (terminal, compilation, LSP, oil, dashboard).

Three-layer architecture

lattice-magit          FEATURE — modes, keymaps, action handlers, synthetic buffers
lattice-host::vcs      CORE    — RepositoryWatcher, auto-gutter-diff, RepositoryEvent
lattice-vcs            DATA    — Repository, WorkingTree, Index, Commit, Branch, Stash

lattice-vcs is a pure data crate (zero lattice-* dependencies) wrapping gix and the git CLI. lattice-host::vcs auto-registers a DiffSession against HEAD whenever you open a file in a git repository, producing immediate gutter signs. lattice-magit consumes both layers to provide the full porcelain.

Every mutation reports

Any magit operation that changes the repository tells you how it went — staging, unstaging, discarding, applying and reversing hunks, branch checkout / create / delete / merge, stash apply / pop / drop / create, remote and submodule operations, and everything the transient menus run. Success and failure both, as a notification.

This matters most when it fails. A magit buffer refreshes after every mutation, so a failed stage used to look exactly like a successful one: the buffer redrew, the file stayed where it was, and nothing said why. Now the failure is the notification, with the line of git's output that explains it; the full text is in the log and *messages*.

Each notification names the repository and what was acted on — lattice · push to origin/main — main, dotfiles · drop stash@{2} — so a burst of them never reads the same twice. Work that stopped part-way is neither a success nor a failure, and is shown as a warning that says what to do next:

▲ lattice · merge feature stopped — CONFLICT (content): Merge conflict in a.rs — resolve, then continue
▲ lattice · rebase to edit 3f2a1c0 stopped — amend, then continue the rebase

Operations that only read stay quiet — a refresh (gr), opening a log, a diff or a blame. The buffer appearing is the report, and a notification per refresh would bury the ones that matter.

Notifications are how magit reports, but magit does not know that: it publishes a "background task finished" event and the notification layer subscribes. See notifications for where they appear, how long they linger, and :notifications for the log of them.

Lazy by default

Magit buffers load only the data needed to paint the viewport. Expensive operations — diffs, blame data, commit details — are deferred until you explicitly invoke them.

Buffer names below are written without their repository segment for brevity — every view is really *magit:<view>:<repo>…*, per which repository a magit buffer acts on.

ViewOn openOn demand
*magit:status:<repo>*File paths + status labels (fast list view)= loads git diff --cached <path> / git diff <path> per-file
*magit:diff:<repo>*Diff loaded on open (the view IS the diff)
*magit:log:<repo>*git log --oneline --graph --decorate -50 (count is currently hardcoded)<CR> opens *magit:show:<repo>:<sha>*, a git show <sha> view, for the commit at cursor
*magit:blame*Blame loaded on open (the view IS the blame)<CR> shows the commit for the blamed line; p blames back one commit
*magit:blame-reverse:<rev>:<path>*Reverse blame loaded on open — for each line of <rev>'s version, the last commit it existed inSame chords; p walks the starting revision back
*magit:commit:<repo>*Staged diff loaded on open (the purpose of this view)

This is the single most important performance decision in the magit design — status opens in 10-50ms regardless of repository size, because no diffs are pre-computed.

Shared navigation (magit-core)

Every magit buffer inherits a shared minor mode, magit-core-mode, which supplies gr (refresh), q (close), ]] / [[ (move between sections), TAB / S-TAB (fold), and the repo-level S / U / C / i / yr. One movement vocabulary across status, log, diff, blame, stash, branch and rebase — see that page for the full table and what gr means per view.

The finer two navigation scales — ]f / [f between files and ]c / [c between hunks — belong to magit-hunk-mode, which is active only where a diff is rendered. They used to be on the core mode, and so were bound in a branch list and a blame, where there are no files or hunks to step between.


Global entry points

C-x g — open status

Press C-x g from any buffer to open the status of the repository that buffer belongs to — *magit:status:<repo>*. This is the primary entry point, the same way C-x g opens magit-status in Emacs.

The repository is discovered by walking up from the buffer's own file, so a file opened from another checkout gives you that checkout's status (see which repository a magit buffer acts on). With nothing open, or a buffer with no file, it falls back to the directory the editor was started in. If none of those is a git repository, the buffer shows "Not a git repository."

:magit-status does exactly the same thing, from the same buffer — the : line and the chord are one command reached two ways.

C-c g — dispatch transient

Press C-c g from any buffer to open the repo-level dispatch transient — a grouped menu with one entry point per magit view: status, commit, log, branch, stash, rebase all genuinely open their buffer, from wherever you happen to be — in the repository the buffer you pressed it over belongs to, which is also the repository F / P / S / U below act on.

The menu's rows follow that repository too: the ways out of a stopped rebase, cherry-pick, merge or bisect appear only when it has one half-done. F (pull) and P (push) are also real: F runs git fetch + a fast-forward-only merge (it will never create a merge commit — if your branch has diverged it fails cleanly instead of merging), P runs git push. S stages every tracked modification (git add --update, untracked files deliberately left out) and U unstages everything while leaving your working tree alone. These all run in the background and fail fast if git needs credentials it doesn't have; the result shows up in the *messages* buffer / debug log, not as an immediate on-screen confirmation.

Four entries are submenus rather than direct actions — c (commit / amend), z (stash push / list), and f / P, whose menus hold the toggleable flags their git operation accepts. BS returns from a submenu to the parent. See transient menus for the full rendered menu and for which magit entries are deliberately absent.

C-c f — file dispatch transient

Press C-c f to open the file-level dispatch transient. s stages and d opens a diff scoped to just that one file — both act on the file belonging to whatever buffer was active when you pressed C-c f, not an entry at the cursor in some other buffer (pressing C-c f while inside magit-status, for instance, does not act on the entry under the cursor there). If the active buffer has no file (a synthetic buffer, an empty scratch buffer, …) there's no path to resolve and the action does nothing.

Because the target is the file, so is the repository: C-c f s on a file from another checkout stages it there.

There is no "which file?" prompt — the one deliberate deviation from Emacs magit, which asks even though the default is always the file you're visiting. For a file you are not visiting there is a separate stand-alone command, :magit-other-file-dispatch, which offers the same rows plus a target you set with =f. It is bound to no chord; bind it if you prefer always being asked. See transient menus.

All three chords follow Emacs convention and are unused in default vim normal mode — they map cleanly over the vim grammar.


Headerline

Every magit buffer carries a sticky row above its first line saying what you are looking at — the thing the buffer's own text usually cannot tell you. A diff does not say which scope it diffed; a blame does not say how far back p has walked; a file-at-revision looks exactly like the live file. The row answers that:

BufferHeaderline
magit-status-modelattice main ↑2 ↓1 3 staged 5 unstaged — plus BISECTING 3 left, ~2 steps while bisecting
magit-commit-modemain 3 files +120 −18 — plus AMEND
magit-revision-modea1b2c3d Jane Doe 3 days ago Fix the thing
magit-file-revision-modesrc/main.rs @ a1b2c3d, or @ index
magit-diff-modestaged src/main.rs
magit-log-modeHEAD 50 commits src/main.rs
magit-branch-modemain 12 branches
magit-remote-mode2 remotes
magit-submodule-mode3 submodules 1 uninitialised
magit-refs-mode12 branches 4 remotes 3 tags
magit-notes-modeediting note a1b2c3d Jane Doe 3 days ago Fix the thing
magit-cherry-modeHEAD vs origin/main 3 ahead 1 already upstream
magit-stash-mode3 stashes
magit-stash-show-modestash@{2} WIP on main: fix the thing
magit-rebase-modeonto origin/main 4 commits — plus REBASE IN PROGRESS

While a refresh is running, the row appends refreshing after its own fields — a gr, a stage, or anything else that re-reads git. It disappears when the new content lands, so a slow git call in a large repository looks busy rather than frozen.

Fields are coloured by what they are — SHAs, branches, refs, and authors each take their own theme colour — rather than labelled, so the row stays short on a narrow split. Two theme elements are magit's own: magit.headerline.label (counts, paths, dates) and magit.headerline.alert (AMEND, REBASE IN PROGRESS); the rest reuse the magit.* colours the buffer bodies already use. :colorscheme repaints the row live.

The row refreshes with its buffer — gr, and any action that rebuilds the view. It stays hidden until the buffer's first content lands, so nothing shifts down and back up while git answers.


Diffs are syntax-highlighted

Every magit view that shows a diff — the status buffer's inline =, :magit-diff, a commit's detail view, a stash, and the staged diff in the commit-message buffer — highlights the code inside it, with the diff colouring layered on top:

  • the + / - column keeps its green / red, and the row keeps its add / remove background tint;
  • everything to the right of that column is coloured by the file's language.

The language comes from the path in the diff's own header, so a multi-file diff highlights each file with its own grammar, and a file whose type has no grammar simply stays uncoloured.

Hunks are fragments, not whole files — a hunk starts mid-function, so the parser has no enclosing context. Tokens it can resolve (keywords, strings, comments, numbers) are coloured; tokens that need the surrounding code are left plain. It errs toward uncoloured rather than miscoloured: a hunk that will not parse looks exactly like a magit diff did before this existed.

Turn it off with :set magit.hunk.syntax-highlight=off. It takes effect on the next refresh (gr), not only on reopen.

Conflicts, and finishing what you started

A merge, rebase, cherry-pick, revert or git am that hits a conflict stops and leaves the repository mid-operation. Three things tell you where you are and get you out.

1. The headerline says what you are in

The status buffer announces the stopped operation as an alert:

MERGING · REBASING · CHERRY-PICKING · REVERTING · APPLYING

It is read from git's own marker files in the gitdir every refresh, so it stays right even when you run git in a terminal alongside lattice. It appears whether or not the tree looks dirty — a conflict resolved into the index but not yet committed leaves the counts looking ordinary, and that is exactly when you most need telling.

2. The unmerged files say how they conflict

Conflicted paths appear in Unstaged changes with git's own wording rather than a flat "unmerged":

LabelMeaning
both modifiedChanged on both sides — the ordinary conflict
both addedCreated on both sides
both deletedDeleted on both sides
added by usOnly our side created it
added by themOnly their side created it
deleted by usWe deleted it, they changed it
deleted by themThey deleted it, we changed it

"Us" and "them" invert. In a merge, "us" is the branch you are on. In a rebase, cherry-pick, revert or am, git replays your work onto the other side — so "us" is the upstream and "them" is your own commit. Read the headerline alert first; it tells you which reading applies.

3. Resolve, stage, continue

Edit the file to resolve it — or use the diff-conflict-mode chords (d3o to take their side, dB to keep both) on a diffed conflict region. Then stage the resolved file with s, exactly like any other change.

With everything staged, finish the operation from its menu. The menus are state-gated: while a sequence is stopped they offer only the ways OUT, because --continue / --skip / --abort error when nothing is running.

OperationMenuWhile stopped
MergeC-c g → mergecontinue · abort
Cherry-pickAcontinue · skip · abort
Revert_continue · skip · abort
RebaseC-c g → rebasecontinue · skip · abort
git amC-c g → patchescontinue · skip · abort

A merge has no skip — that is a sequencer verb, and a merge is one operation with nothing to skip to.

Keys are deliberately overloaded between the two states — A is pick when idle and continue when stopped — which is magit's own arrangement and is safe only because the gate never shows both sets at once.

There are also ex-commands for the same operations, e.g. :magit-rebase-continue, if you would rather type than navigate a menu.

What is not there yet

  • No conflict gutter. Conflict regions carry the diff sign map's conflict kind, but no marker column distinguishes a conflict from an ordinary change at a glance.

When magit changes files on disk

Plenty of magit actions rewrite your working tree: checking out a branch, popping a stash, resetting, rebasing, discarding a hunk. Magit runs git as a subprocess, so those are ordinary external writes — and autoread picks them up. You do not need to reload anything by hand.

Two details worth knowing:

  • Open buffers refresh on their own. No keypress required. A file with no unsaved edits reloads silently, cursor and scroll preserved.
  • A file you're not currently in waits until you focus it. During a magit action the magit buffer is the one you're in, so a file showing in another split keeps its old contents until you switch to it. This is vim's checktime-on-BufEnter behaviour, not a bug.

If a file has unsaved edits when git rewrites it, magit never clobbers them — autoread opens the diff resolver and you reconcile hunk by hunk. See External file changes for the full policy.


Options

git.auto-head-diff is registered through the typed-options system (:set / :customize), owned by lattice-host's VCS subsystem:

OptionTypeDefaultDescription
git.auto-head-diffbooltrueAuto-register a gutter-diff against HEAD when opening files in git repos

Everything else that earlier revisions of this page listed here (magit.auto-refresh, magit.refresh-debounce-ms, magit.status.show-untracked, magit.status.show-stashes, magit.status.recent-commits-count, magit.log.count, magit.log.graph, magit.log.decorate, magit.blame.author-width, magit.blame.date-format, magit.commit.show-diff) is not currently a registered option. :set on any of them fails loudly with unknown option rather than silently accepting and ignoring the value. The behaviour each name implies is often real (untracked files do show by default, the log does default to -50 entries, blame dates are relative, …) but today it's hardcoded — treat that list as a roadmap for options that should exist, not ones that do.

Three are real, and they are the ones magit registers:

OptionDefaultWhat it does
magit.hunk.context-lines3Unchanged lines of context around each hunk in every patch magit generates — the status buffer's inline =, :magit-diff, and a commit's detail view. D overrides it for one view.
magit.hunk.syntax-highlightonSyntax-highlight the code inside a diff, with the + / - colouring layered over it. Off gives the flat per-line colouring — every added line one green, every removed line one red — and skips the parse.
magit.revision-previewonShow the file's content while choosing a revision in C-c f v. Runs git show on the input thread — debounced, so scrolling the picker costs nothing and only settling on a revision fetches. Blobs over 256 KiB are refused with a note.
ui.diff.line-backgroundstrueTint whole rows by what the diff did to them. false leaves foreground colouring only, for themes where a full-row wash fights the syntax colours underneath.

ui.diff.line-backgrounds is not under magit.* on purpose: the mechanism is shared by every diff-showing buffer in the editor, not just magit's, and naming it for one consumer would understate what it turns off. It sits beside ui.diff.context and ui.diff.fold-unchanged.

Note magit.hunk.context-lines and ui.diff.context are different things: the first decides how much context git puts into a patch, the second how much an unchanged-region fold leaves visible inside a two-pane diff session.


Help and discovery

Earlier drafts of this page promised g? as a future Lattice-wide "help for this buffer's major mode" chord. That shipped as <C-h> m instead — the emacs help-prefix slot, which costs no vim key (vim's g? is the rot13 operator) — and it shows major plus minors rather than the major alone.