Skip to main content

lattice_magit/
magit_core_mode.rs

1//! MG.1: magit-core shared minor mode.
2//!
3//! Activates on EVERY magit buffer. Provides the chords that mean
4//! something in all of them: `gr` refresh, `q` close, `]]`/`[[`
5//! (sections), `]f`/`[f` (files/entries), folds, and the commit
6//! operations. Each navigation chord returns Effect::SelectionChange.
7//!
8//! MG.24a: `]c`/`[c`, `s`/`u`/`x` and `a`/`-` are NOT here. They act on
9//! diff content, which only five of the eleven majors have, so they
10//! live on `magit-hunk-mode` — a chord bound by a mode is consumed
11//! unconditionally, so binding them here made them dead keys in a
12//! branch list, a log, a stash list, a rebase todo and a blame.
13
14use std::sync::{Arc, OnceLock};
15
16use lattice_core::BufferId;
17use lattice_grammar::Effect;
18use lattice_mode::{
19    ActionContext, ActivationPolicy, BufferStoreHandle, CapabilitySet, Keymap, KeymapEntry,
20    LifecycleFuture, Mode, ModeContext, ModeId, ModeKind, OptionOverrideSet, keymap_entry,
21};
22use lattice_protocol::position::Position;
23
24use crate::buffer_state::DiffSource;
25use crate::magit_branch_mode::MagitBranchMode;
26use crate::magit_diff_mode::MagitDiffMode;
27use crate::magit_file_revision_mode::MagitFileRevisionMode;
28use crate::magit_log_mode::MagitLogMode;
29use crate::magit_rebase_mode::MagitRebaseMode;
30use crate::magit_revision_mode::MagitRevisionMode;
31use crate::magit_stash_mode::MagitStashMode;
32use crate::magit_status_mode::MagitStatusMode;
33
34/// Empty RAII guard — vestigial after MG.13.
35///
36/// It used to hold a `Vec<ActionHandlerRegistration>`, and its doc
37/// comment already named the hazard that motivated MG.13: "two buffers
38/// of the same major mode open at once silently let the second's
39/// `on_activate` replace the first's handler (registry is
40/// last-write-wins per `CommandId`), so firing the chord in buffer A
41/// can execute buffer B's captured state against A's cursor."
42///
43/// Holding the tokens bounded the damage — the guard unregistered on
44/// close — but could not prevent it, because the registry has no buffer
45/// dimension: two live registrations of one `CommandId` cannot coexist
46/// no matter who owns the tokens. MG.13 removes the hazard at the
47/// source instead: every magit handler is registered **once** at boot
48/// via `Mode::action_handlers()` and resolves per-buffer state from a
49/// service at call time, so there is nothing per-activation left to
50/// unwind. Kept only because `Mode` requires an associated `Guard`.
51#[derive(Default)]
52pub struct ActionRegsGuard;
53
54pub struct MagitCoreMode;
55
56impl MagitCoreMode {
57    pub fn mode_id() -> ModeId {
58        ModeId::new("magit-core-mode")
59    }
60}
61
62fn magit_core_keymap_entries() -> &'static [KeymapEntry] {
63    static ENTRIES: OnceLock<Vec<KeymapEntry>> = OnceLock::new();
64    ENTRIES.get_or_init(|| {
65        // RV.2 (2026-08-10): `gr` is NOT declared here. It lives once on
66        // `refreshable-view-mode`; this mode names its refresh target
67        // via `Mode::refresh_action()` below, and the shared minor
68        // arrives through the implies cascade. `action:magit-refresh`
69        // and its handler are unchanged — only the binding moved.
70        vec![
71            keymap_entry! { mode: Normal, chord: "q", doc: "Close magit buffer", cmd: "action:magit-close" },
72            // `]]` / `[[` / `<Tab>` / `<S-Tab>` moved to
73            // `magit-nav-mode` (implied below): they are the only chords
74            // here that mean something in a buffer the user can edit.
75            // MG.23k: magit's `D`. Bound here rather than per-view for
76            // the same reason `gr` is — the chord is one question
77            // ("re-run this with different arguments") and the view
78            // answers it. `D` is an editing operator, so it is inert
79            // in a read-only magit buffer and free to take; magit's
80            // `L` for log arguments is NOT free, being the
81            // bottom-of-screen motion, which is why one chord covers
82            // both here.
83            keymap_entry! { mode: Normal, chord: "D", doc: "Re-run this view with different git arguments", cmd: "action:magit-view-arguments" },
84            // Operations on the commit under the cursor. Keys follow
85            // **evil-collection-magit**, not raw magit — the reference
86            // set for a modal editor, because it is the one that already
87            // resolved magit-vs-vim collisions:
88            //
89            //   revert  magit `V` → evil `_`   ("subtracting a commit")
90            //   reset   magit `X` → evil `O`
91            //   discard magit `k` → evil `x`
92            //   apply   `A` in both
93            //
94            // MG.20 originally took `V` for revert, citing "Emacs
95            // magit's own keys". Magit does bind `V` — but magit is not
96            // modal, so it costs magit nothing. Here it cost linewise
97            // Visual in every magit buffer: the chord is consumed even
98            // on a row with no commit, so `V` could not start a
99            // selection at all, which MG.18e's region staging needs.
100            // evil-magit frees `V` for `evil-visual-line` for exactly
101            // that reason, and vim-fugitive likewise keeps `V` unbound
102            // so its visual-mode staging works. `_` is free here (not
103            // even a builtin motion yet), so this costs nothing.
104            //
105            // (The same commit also mis-attributed `O` to magit, which
106            // uses `X`. `O` is evil-magit's remap — the binding was
107            // right, the reason was not.)
108            // MG.49b: ONE key for the whole menu tree.
109            //
110            // The first cut gave each root menu its own chord. That put
111            // `c` / `d` / `p` / `r` on this mode — and this mode is a
112            // MINOR, which beats a major, so it silently ate
113            // `magit-branch`'s `c`/`d`, `magit-remote`'s `d`/`p`/`r`,
114            // `magit-stash`'s `d`/`p` and `magit-submodule`'s `d`. Nine
115            // bindings, every one a core operation.
116            //
117            // A single key has no such surface: it cannot collide with
118            // nine majors' vocabularies because it does not reach into
119            // them. The menus are all still there, one keystroke deeper.
120            //
121            // NO buffer-local dispatch chord — deliberately, per
122            // `docs/dev/architecture/magit.md` §12.0/§12.1: "the dispatch
123            // and file-dispatch transients are accessed through the same
124            // global bindings ... single-key candidates like `?` clash
125            // with reverse-search, `h` clashes with left motion", and
126            // §12.1's rule that bindings clashing with `h`/`j`/`k`/`l`/
127            // `w`/`b`/`e` "are never overridden".
128            //
129            // MG.49 added `h` anyway, against both. This mode is a MINOR,
130            // so it beat the builtin grammar and `h` moved the cursor in
131            // every buffer in the editor EXCEPT the git ones — the worst
132            // possible place for a reflex to diverge. Emacs magit does
133            // bind `h`, and evil-collection-magit keeps it, but it also
134            // ships `want-horizontal-movement` to trade it back; the
135            // trade-off is contested upstream and the vim grammar
136            // (paramount #3) wins here.
137            //
138            // Nothing replaces it, because nothing needs to:
139            // `magit-global-mode` binds `<C-c>g` → `magit-dispatch` with
140            // `ActivationPolicy::Universal`, so the menu already opens
141            // from inside magit buffers — and from everywhere else. Any
142            // single-chord replacement would have to shadow SOMETHING
143            // (`?` reverse-search, `<C-t>` tag-stack pop); a second
144            // keystroke is cheaper than another silent divergence.
145            // MG.49c: the repo-level rows that are worth a chord.
146            //
147            // All four are vim EDITING operators — inert where nothing is
148            // editable — which is the rule MG.49 settled on, and no magit
149            // major claims any of them. `yr` rides vim's yank operator the
150            // same way `dv` rides delete: `y` short-circuits into
151            // operator-pending rather than terminating, and `r` is not a
152            // motion, so the two-chord sequence is free.
153            keymap_entry! { mode: Normal, chord: "S", doc: "Stage every tracked modification", cmd: "action:magit-global-stage-all" },
154            keymap_entry! { mode: Normal, chord: "U", doc: "Unstage everything, keeping the working tree", cmd: "action:magit-global-unstage-all" },
155            keymap_entry! { mode: Normal, chord: "C", doc: "Clone a repository", cmd: "action:magit-global-clone" },
156            keymap_entry! { mode: Normal, chord: "i", doc: "Add a path to .gitignore", cmd: "action:magit-global-gitignore" },
157            keymap_entry! { mode: Normal, chord: "yr", doc: "Show refs", cmd: "action:magit-global-refs" },
158            // MG.49: `A` / `_` / `O` are the ROOT MENUS now — see the
159            // block below. The direct actions they used to fire did not
160            // go anywhere: `A` is `A A`, `_` is `_ V`, and the three
161            // resets are `O s` / `O m` / `O h`, because each menu's own
162            // keys were already chosen to match the chord it replaced
163            // (`reset_transient`'s doc says so in as many words).
164            //
165            // They had to move: the trie checks a node's own binding
166            // BEFORE its children, so binding `O` would have made
167            // `Os` / `Om` / `Oh` unreachable rather than merely
168            // redundant.
169        ]
170        .into_iter()
171        .chain(root_menu_entries())
172        .collect()
173    })
174}
175
176/// MG.49: one keymap entry per root menu.
177///
178/// Emacs binds these on `magit-mode-map` — the parent keymap every
179/// magit-derived mode inherits — so `z` opens stash from a log buffer
180/// and a diff buffer as much as from status. `magit-core-mode` is that
181/// same surface here, which is why they live on this mode and not on
182/// `magit-status-mode`.
183///
184/// Generated from [`crate::transients::ROOT_MENUS`], which also drives
185/// the source registrations in `install` and the dispatch's own nested
186/// rows. One table, three consumers, no way for a menu to exist with no
187/// chord or a chord with no menu.
188/// MG.49: one handler per root menu — each opens the SAME transient
189/// source the dispatch nests, by name.
190///
191/// Not gated on a view. The keymap layer already scopes these to
192/// buffers where `magit-core-mode` is active (K.1.c's per-keystroke
193/// filter), and a menu like stash or pull answers a repo-level question
194/// that does not need a cursor on anything — gating would make `z` dead
195/// in the magit buffers whose major registers no view state.
196fn root_menu_handlers() -> Vec<lattice_mode::ActionHandlerContribution> {
197    crate::transients::ROOT_MENUS
198        .iter()
199        .filter(|m| m.chord.is_some())
200        .map(|m| lattice_mode::ActionHandlerContribution {
201            action_name: m.action,
202            handler: Arc::new(move |_ctx: &ActionContext<'_>| {
203                Some(Effect::OpenTransient {
204                    source: m.source.to_string(),
205                    // TR.3a: a plain open — every native menu is opened for
206                    // itself rather than for a subject.
207                    args: lattice_grammar::Args::None,
208                })
209            }),
210        })
211        .collect()
212}
213
214fn root_menu_entries() -> Vec<KeymapEntry> {
215    crate::transients::ROOT_MENUS
216        .iter()
217        .filter_map(|m| {
218            let chord = m.chord?;
219            Some(keymap_entry! { mode: Normal, chord: chord, doc: m.doc, cmd: Some(m.action) })
220        })
221        .collect()
222}
223
224/// MG.20: build the handler for a commit operation.
225///
226/// Resolves the commit under the cursor through the buffer's
227/// [`MagitView`], then either asks (destructive) or runs.
228fn commit_op(
229    action_name: &'static str,
230    op: crate::magit_global_mode::CommitOp,
231) -> lattice_mode::ActionHandlerContribution {
232    lattice_mode::ActionHandlerContribution {
233        action_name,
234        handler: Arc::new(move |ctx: &ActionContext<'_>| {
235            // MG.23j: no commit under the cursor — ask for one.
236            //
237            // This is the same action the root dispatch's `A` / `_` /
238            // `O` rows fire, and the menu can be opened from a buffer
239            // with no commits in it at all. Rather than a second action
240            // for the menu, the one action answers both: the cursor
241            // when there is something under it, a picker when there is
242            // not. Magit reaches the same place — its `A` / `V` / `X`
243            // are transients that prompt, which is why they sit in the
244            // *ungated* group of its dispatch.
245            //
246            // It also retires a dead key: `A` on a `--graph` connector
247            // line used to return `None`, and a Normal-mode chord a
248            // mode binds is consumed unconditionally, so it read as
249            // broken.
250            let resolved = crate::buffer_state::view_for(ctx)
251                .and_then(|view| view.commit_at_cursor(ctx.cursor).map(|c| (view, c)));
252            let Some((view, commit)) = resolved else {
253                return Some(Effect::OpenPicker {
254                    source: crate::picker_sources::COMMIT_PICK_SOURCE.to_string(),
255                    args: vec![op.ex_command.to_string()],
256                    root: None,
257                    fill_action: None,
258                    query: None,
259                });
260            };
261            match op.confirm_action {
262                // Destructive: the ask half performs no git call at
263                // all, so answering `n` cannot mutate — MG.12's rule.
264                // IX.2: carry the SHA. `reset --hard` is the most
265                // destructive thing magit does, so the commit it lands
266                // on must be the one the prompt named — not whatever
267                // row the cursor points at once the answer arrives.
268                Some(yes) => Some(crate::confirm::ask_target(
269                    format!("git {} {commit} — discard uncommitted changes?", op.what),
270                    yes,
271                    commit.clone(),
272                )),
273                None => {
274                    let workdir = view.workdir()?;
275                    Some(crate::magit_global_mode::spawn_commit_op(
276                        op, workdir, &commit,
277                    ))
278                }
279            }
280        }),
281    }
282}
283
284/// MG.43c: magit's rebase `m` / `w` / `k` — act on ONE commit by
285/// rewriting its verb in the todo list.
286///
287/// Same cursor-then-picker resolution as [`commit_op`]. `verb` is the
288/// only difference between the three rows, which is why they share a
289/// builder rather than getting three near-identical handlers.
290///
291/// `reword` is NOT here: it needs a message, so it opens the compose
292/// buffer instead (see `CommitIntent::RewordCommit`).
293fn rebase_verb_op(
294    action_name: &'static str,
295    verb: &'static str,
296    ex_command: &'static str,
297) -> lattice_mode::ActionHandlerContribution {
298    lattice_mode::ActionHandlerContribution {
299        action_name,
300        handler: Arc::new(move |ctx: &ActionContext<'_>| {
301            let resolved = crate::buffer_state::view_for(ctx)
302                .and_then(|view| view.commit_at_cursor(ctx.cursor));
303            let Some(commit) = resolved else {
304                return Some(Effect::OpenPicker {
305                    source: crate::picker_sources::COMMIT_PICK_SOURCE.to_string(),
306                    args: vec![ex_command.to_string()],
307                    root: None,
308                    fill_action: None,
309                    query: None,
310                });
311            };
312            Some(crate::magit_global_mode::spawn_rebase_verb(
313                crate::repo_scope::action_workdir(ctx),
314                verb,
315                &commit,
316            ))
317        }),
318    }
319}
320
321/// MG.43d: the first half of a cherry-move row.
322///
323/// Resolves the commit the way every other commit row does — the
324/// cursor, or a picker when there is nothing under it — carries it
325/// across the prompt, then opens the branch prompt. The finish half
326/// lives with the other `spawn_*` bodies in `magit_global_mode`.
327fn cherry_move_entry(
328    action_name: &'static str,
329    ex_command: &'static str,
330    prompt: &'static str,
331    finish: &'static str,
332) -> lattice_mode::ActionHandlerContribution {
333    lattice_mode::ActionHandlerContribution {
334        action_name,
335        handler: Arc::new(move |ctx: &ActionContext<'_>| {
336            let resolved = crate::buffer_state::view_for(ctx)
337                .and_then(|view| view.commit_at_cursor(ctx.cursor));
338            let Some(commit) = resolved else {
339                return Some(Effect::OpenPicker {
340                    source: crate::picker_sources::COMMIT_PICK_SOURCE.to_string(),
341                    args: vec![ex_command.to_string()],
342                    root: None,
343                    fill_action: None,
344                    query: None,
345                });
346            };
347            crate::magit_global_mode::stash_pending_commit(commit);
348            Some(crate::magit_global_mode::prompt_for_pub(prompt, finish))
349        }),
350    }
351}
352
353/// MG.42-E2: a [`commit_op`] whose work is a SEQUENCE.
354///
355/// Same cursor-then-picker resolution as `commit_op` — the row can be
356/// reached from a buffer with no commit under the cursor — but the
357/// resolved commit feeds a multi-step composition instead of one argv.
358fn commit_sequence_op(
359    action_name: &'static str,
360    ex_command: &'static str,
361    label: &'static str,
362    steps: fn(&str) -> Vec<crate::magit_global_mode::GitStep>,
363) -> lattice_mode::ActionHandlerContribution {
364    lattice_mode::ActionHandlerContribution {
365        action_name,
366        handler: Arc::new(move |ctx: &ActionContext<'_>| {
367            let resolved = crate::buffer_state::view_for(ctx)
368                .and_then(|view| view.commit_at_cursor(ctx.cursor));
369            let Some(commit) = resolved else {
370                return Some(Effect::OpenPicker {
371                    source: crate::picker_sources::COMMIT_PICK_SOURCE.to_string(),
372                    args: vec![ex_command.to_string()],
373                    root: None,
374                    fill_action: None,
375                    query: None,
376                });
377            };
378            Some(crate::magit_global_mode::spawn_git_sequence(
379                crate::repo_scope::action_workdir(ctx),
380                format!("{label} {}", crate::magit_global_mode::short_rev(&commit)),
381                steps(&commit),
382            ))
383        }),
384    }
385}
386
387/// The post-confirmation half of a destructive [`commit_op`].
388fn commit_op_execute(
389    action_name: &'static str,
390    op: crate::magit_global_mode::CommitOp,
391) -> lattice_mode::ActionHandlerContribution {
392    lattice_mode::ActionHandlerContribution {
393        action_name,
394        handler: Arc::new(move |ctx: &ActionContext<'_>| {
395            let view = crate::buffer_state::view_for(ctx)?;
396            // IX.2: the commit the prompt named, falling back to the
397            // cursor only when nothing was carried.
398            let commit = match crate::confirm::carried_target(ctx) {
399                Some(carried) => carried,
400                None => view.commit_at_cursor(ctx.cursor)?,
401            };
402            let workdir = view.workdir()?;
403            Some(crate::magit_global_mode::spawn_commit_op(
404                op, workdir, &commit,
405            ))
406        }),
407    }
408}
409
410/// Move cursor to `target_row`. Returns `Effect::CursorMove` —
411/// the canonical cursor-jump primitive.
412fn cursor_at(target_row: u32) -> Effect {
413    Effect::CursorMove(Position::new(target_row, 0))
414}
415
416/// Scan buffer for section header lines and return their row numbers.
417fn section_headers(store: &BufferStoreHandle, buffer_id: BufferId) -> Vec<u32> {
418    let Some(h) = store.handle_for(buffer_id) else {
419        return vec![];
420    };
421    let snap = h.snapshot();
422    let mut lines = Vec::new();
423    for l in 0..snap.buffer.content_line_count() {
424        if let Some(t) = snap.buffer.line(l)
425            && crate::sections::is_section_header(t.trim())
426        {
427            lines.push(l);
428        }
429    }
430    lines
431}
432
433/// Scan buffer for file/entry lines (indented, non-header).
434///
435/// Fold audit fix: this used to check `starts_with("  ")` on the
436/// line AFTER trimming it — `trim()` strips all leading whitespace,
437/// so a trimmed string can never start with two spaces. The check
438/// was unsatisfiable; `]f`/`[f` never navigated anywhere, on any
439/// magit buffer, from the moment they were written. Now checks the
440/// RAW (untrimmed) line, and trims only for the prefix comparisons
441/// that follow it.
442fn entry_lines(store: &BufferStoreHandle, buffer_id: BufferId) -> Vec<u32> {
443    let Some(h) = store.handle_for(buffer_id) else {
444        return vec![];
445    };
446    let snap = h.snapshot();
447    let mut lines = Vec::new();
448    for l in 0..snap.buffer.content_line_count() {
449        if let Some(raw) = snap.buffer.line(l) {
450            // Section headers and one-off status messages ("No
451            // changes...") all render at column 0 — never indented —
452            // so this guard alone already excludes them; no need to
453            // separately re-check their text.
454            if raw.starts_with("  ") && !raw.trim().is_empty() {
455                lines.push(l);
456            }
457        }
458    }
459    lines
460}
461
462#[cfg(test)]
463mod compose_buffers_are_not_browsers {
464    use super::*;
465    // Only the exclusion test names this mode now — the production
466    // policy list deliberately does not mention it.
467    use crate::magit_commit_mode::MagitCommitMode;
468
469    /// Reported 2026-08-09: writing a commit message was impossible —
470    /// `i` fired `magit-global-gitignore` instead of entering Insert.
471    ///
472    /// `magit-core-mode` is the shared vocabulary of magit's READ-ONLY
473    /// list buffers: `i` ignores a path, `S`/`U` stage and unstage,
474    /// `h` opens the dispatch menu. Those are safe there precisely
475    /// because nothing in those buffers is editable — the rule MG.49
476    /// settled on, and the reason single letters could be claimed at
477    /// all. It is a MINOR mode, so it beats the builtin vim grammar.
478    ///
479    /// `magit-commit-mode` is the odd one out: it is not a browser, it
480    /// is the compose buffer for a commit message (`*magit:commit*`,
481    /// `*magit:amend*`, reword, augment, merge-edit). Listing it here
482    /// pointed the whole browsing vocabulary at the one magit buffer
483    /// that exists to be typed into, and `i` — the single most
484    /// important key in an editable buffer — was the casualty.
485    ///
486    /// Emacs draws the same line: a commit message is composed in a
487    /// text buffer under `with-editor`, not in a `magit-mode` buffer,
488    /// so none of magit's browsing keys reach it.
489    #[test]
490    fn the_commit_compose_buffer_does_not_get_the_browsing_keymap() {
491        let ActivationPolicy::Majors(majors) = MagitCoreMode.activation_policy() else {
492            panic!("magit-core-mode activates on a fixed set of majors");
493        };
494        assert!(
495            !majors.contains(&MagitCommitMode::mode_id()),
496            "magit-commit-mode is the commit-message COMPOSE buffer, not a browser — \
497             giving it the core keymap shadows `i` (and `S`/`U`/`C`/`h`/`yr`) in the one \
498             magit buffer the user types into"
499        );
500        // The browsers still have it — this is a scoping fix, not a
501        // retreat from the shared-minor-mode pattern.
502        assert!(majors.contains(&MagitStatusMode::mode_id()));
503        assert!(majors.contains(&MagitDiffMode::mode_id()));
504        assert!(majors.contains(&MagitLogMode::mode_id()));
505    }
506
507    /// `h` must stay a MOTION in magit buffers, and no chord here may
508    /// replace it.
509    ///
510    /// The dispatch menu owned `h` (MG.49), and because this mode is a
511    /// minor it beat the builtin grammar — so `h` moved the cursor in
512    /// every buffer in the editor except the git ones, the worst place
513    /// for a reflex to diverge. `docs/dev/architecture/magit.md`
514    /// §12.0/§12.1 had already ruled this out twice: `h` clashes with
515    /// left motion, `?` with reverse-search, and the navigation keys
516    /// "are never overridden".
517    ///
518    /// The menu needs no buffer-local chord at all —
519    /// `magit-global-mode` binds `<C-c>g` → `magit-dispatch` with
520    /// `ActivationPolicy::Universal`, which reaches inside magit
521    /// buffers too. So this asserts BOTH halves: the motion keys stay
522    /// free, and no chord here claims `magit-dispatch`. The second is
523    /// the one that would catch MG.49 happening again — a future
524    /// single-key replacement would have to shadow something, and
525    /// `<C-t>` (tag-stack pop) was the candidate considered and
526    /// rejected.
527    #[test]
528    fn the_core_keymap_leaves_the_motion_keys_alone() {
529        const MOTIONS: &[&str] = &["h", "j", "k", "l", "w", "b", "e", "0", "$", "G", "?"];
530        for m in MOTIONS {
531            assert!(
532                !magit_core_keymap_entries().iter().any(|e| e.chord == *m),
533                "`{m}` is vim grammar — a minor mode claiming it shadows it in magit \
534                 buffers only, which is exactly the divergence `h` caused"
535            );
536        }
537        assert!(
538            !magit_core_keymap_entries()
539                .iter()
540                .any(|e| e.command == Some("magit-dispatch")),
541            "the dispatch menu is reached through the universal `<C-c>g`; a \
542             buffer-local chord for it would have to shadow something (magit.md §12.0)"
543        );
544    }
545
546    /// A compose buffer must never QUIT THE EDITOR when it closes.
547    ///
548    /// Reported 2026-08-10: `C-c C-c` in the commit buffer exited
549    /// lattice. Every magit view is a full-pane buffer opened IN PLACE,
550    /// so `Effect::QuitEditor { scope: Pane }` carries vim's `:q`
551    /// semantics — "close the pane; if it is the last one, quit" — and
552    /// on a single-pane layout the most routine action in the whole
553    /// feature took the session down with it.
554    ///
555    /// `magit-core`'s `q` already carried this fix (see the comment at
556    /// `action:magit-close`); the three COMPOSE modes were missed, which
557    /// is how a fixed bug came back on a different chord. `BuryBuffer`
558    /// restores the buffer the compose view displaced and cannot exit.
559    ///
560    /// Checked against the sources because the handlers are closures
561    /// needing a full `ActionContext` to invoke — the same mechanical
562    /// style as `status_label_is_a_subset_of_actions_file_labels`. If a
563    /// magit buffer ever genuinely needs to quit the editor, this test
564    /// is the place to say so deliberately.
565    #[test]
566    fn no_compose_mode_can_quit_the_editor() {
567        for (name, src) in [
568            ("magit_commit_mode", include_str!("magit_commit_mode.rs")),
569            ("magit_notes_mode", include_str!("magit_notes_mode.rs")),
570            ("magit_rebase_mode", include_str!("magit_rebase_mode.rs")),
571        ] {
572            assert!(
573                !src.contains("Effect::QuitEditor"),
574                "{name} returns `Effect::QuitEditor` — on a single-pane layout that \
575                 exits lattice. Compose buffers close with `Effect::KillBuffer`."
576            );
577        }
578    }
579
580    /// The other half of the bug: `i` really is claimed by this mode,
581    /// so the exclusion above is load-bearing rather than incidental.
582    /// If `i` is ever moved off the core keymap this test should be
583    /// deleted, not relaxed.
584    #[test]
585    fn the_core_keymap_claims_i_in_normal_mode() {
586        assert!(
587            magit_core_keymap_entries()
588                .iter()
589                .any(|e| e.chord == "i" && e.command == Some("action:magit-global-gitignore")),
590            "`i` is a magit-core browsing chord — that is why an editable \
591             magit buffer must not carry this keymap"
592        );
593    }
594}
595
596#[cfg(test)]
597mod file_nav {
598
599    /// The bug this scanner exists to remove: in a diff, the generic
600    /// indented-row scan matches every CONTEXT line, so `]f` walked
601    /// through arbitrary code claiming to move between files.
602    ///
603    /// Both scanners are run over the same realistic diff so the
604    /// difference is visible rather than asserted in the abstract.
605    #[test]
606    fn a_diff_has_file_headers_where_the_generic_scan_sees_context_lines() {
607        // The context lines here are INDENTED CODE, which is the
608        // realistic case and the whole hazard: a diff's leading space
609        // plus the code's own indent starts the row with two spaces,
610        // exactly what the generic entry scan looks for.
611        let diff = "\
612diff --git a/src/a.rs b/src/a.rs
613@@ -1,4 +1,4 @@
614 fn a() {
615     let keep = 1;
616-    let old = 2;
617+    let new = 2;
618 }
619diff --git a/src/b.rs b/src/b.rs
620@@ -1,2 +1,2 @@
621 fn b() {
622     let also_indented = 3;
623";
624        let indented: Vec<u32> = diff
625            .lines()
626            .enumerate()
627            .filter(|(_, l)| l.starts_with("  ") && !l.trim().is_empty())
628            .map(|(i, _)| i as u32)
629            .collect();
630        let headers: Vec<u32> = diff
631            .lines()
632            .enumerate()
633            .filter(|(_, l)| l.starts_with("diff --git"))
634            .map(|(i, _)| i as u32)
635            .collect();
636
637        assert_eq!(headers, vec![0, 7], "two files in this diff");
638        assert!(
639            !indented.is_empty(),
640            "the generic scan matches context lines here — which is the bug"
641        );
642        assert_ne!(
643            indented, headers,
644            "if these agreed there would have been nothing to fix"
645        );
646    }
647}
648
649/// The `diff --git` header rows — what "a file" means in a buffer
650/// whose content is a unified diff.
651///
652/// Column 0 only. A `diff --git` inside an inline expansion in
653/// magit-status is indented, and that view answers `file_lines` with
654/// its own entry rows anyway.
655pub(crate) fn diff_file_lines(store: &BufferStoreHandle, buffer_id: BufferId) -> Vec<u32> {
656    let Some(h) = store.handle_for(buffer_id) else {
657        return vec![];
658    };
659    let snap = h.snapshot();
660    (0..snap.buffer.content_line_count())
661        .filter(|l| {
662            snap.buffer
663                .line(*l)
664                .is_some_and(|raw| raw.starts_with("diff --git"))
665        })
666        .collect()
667}
668
669/// Scan for hunk-start lines (@@ or diff --git) and return their
670/// row numbers.
671fn hunk_lines(store: &BufferStoreHandle, buffer_id: BufferId) -> Vec<u32> {
672    let Some(h) = store.handle_for(buffer_id) else {
673        return vec![];
674    };
675    let snap = h.snapshot();
676    let mut lines = Vec::new();
677    for l in 0..snap.buffer.content_line_count() {
678        if let Some(t) = snap.buffer.line(l) {
679            let t = t.trim();
680            if t.starts_with("@@") || t.starts_with("diff --git") {
681                lines.push(l);
682            }
683        }
684    }
685    lines
686}
687
688// ── MG.18c: hunk-level staging ──────────────────────────
689//
690// The resolution lives here, not in each view, for the reason
691// `]c` / `[c` above live here: a hunk is a property of diff *text*,
692// identical in every magit buffer, so one implementation serves
693// magit-status's inline diffs, magit-diff's buffer, and whatever
694// binds `s` next. What genuinely differs per view — which file a
695// non-hunk line names, and which tree the text was diffed against —
696// stays behind `MagitView`.
697
698/// The hunk under `cursor` in the buffer an action fired in, read
699/// straight from the buffer text (`magit.md` §7.5's precedent: `]c`
700/// and `[c` derive hunk boundaries the same way, so navigation and
701/// staging cannot disagree about where a hunk begins).
702///
703/// Reads through `hunk_at_with`'s accessor rather than materialising
704/// the buffer: a `*magit:diff*` against a large change is tens of
705/// thousands of lines, and staging one hunk must not copy all of them.
706pub(crate) fn hunk_at_cursor(
707    store: &BufferStoreHandle,
708    buffer_id: BufferId,
709    cursor: u32,
710) -> Option<crate::hunk::HunkPatch> {
711    let handle = store.handle_for(buffer_id)?;
712    let snap = handle.snapshot();
713    crate::hunk::hunk_at_with(
714        |i| u32::try_from(i).ok().and_then(|l| snap.buffer.line(l)),
715        cursor as usize,
716    )
717}
718
719/// What `s` / `u` / `x` / `a` / `-` do to a resolved hunk.
720#[derive(Debug, Clone, Copy, PartialEq, Eq)]
721pub(crate) enum HunkOp {
722    /// `s` — apply the unstaged hunk forward into the index.
723    Stage,
724    /// `u` — reverse the staged hunk back out of the index.
725    Unstage,
726    /// `x` — reverse the unstaged hunk out of the working tree.
727    Discard,
728    /// MG.23g: `a` — apply a committed hunk forward into the working
729    /// tree. One hunk of a commit, where `A` cherry-picks all of it.
730    Apply,
731    /// MG.23g: `-` — reverse a committed hunk out of the working tree.
732    /// One hunk of a commit, where `_` reverts all of it.
733    Reverse,
734}
735
736impl HunkOp {
737    /// MG.18e: which side of the patch the target already holds, which
738    /// is what the region rewrite needs to know. The same fact
739    /// [`Self::apply_flags`]' `reverse` encodes, named for the rewrite
740    /// rather than for git's argv so the two cannot drift apart.
741    fn direction(self) -> crate::hunk::ApplyDirection {
742        match self {
743            HunkOp::Stage | HunkOp::Apply => crate::hunk::ApplyDirection::Forward,
744            HunkOp::Unstage | HunkOp::Discard | HunkOp::Reverse => {
745                crate::hunk::ApplyDirection::Reverse
746            }
747        }
748    }
749
750    /// `(cached, reverse)` for `Index::apply_patch`.
751    fn apply_flags(self) -> (bool, bool) {
752        match self {
753            HunkOp::Stage => (true, false),
754            HunkOp::Unstage => (true, true),
755            // The worktree, not the index — matching file-level `x`,
756            // which is `git checkout -- <path>` and likewise leaves
757            // the index alone.
758            HunkOp::Discard => (false, true),
759            // MG.23g: also the worktree, and for the same reason —
760            // `a` and `-` answer "put this change here" / "take it
761            // back out", which is a question about the file you would
762            // edit, not about what is queued for the next commit. The
763            // result shows up as an ordinary unstaged change, which
764            // `s` can then stage in the usual way.
765            HunkOp::Apply => (false, false),
766            HunkOp::Reverse => (false, true),
767        }
768    }
769
770    /// The only [`DiffSource`] this operation can act on. A hunk from
771    /// the other side is refused rather than handed to git, whose
772    /// "patch does not apply" says nothing about which key to press.
773    fn requires(self) -> DiffSource {
774        match self {
775            HunkOp::Stage | HunkOp::Discard => DiffSource::Unstaged,
776            HunkOp::Unstage => DiffSource::Staged,
777            HunkOp::Apply | HunkOp::Reverse => DiffSource::Committed,
778        }
779    }
780
781    fn present(self) -> &'static str {
782        match self {
783            HunkOp::Stage => "stage",
784            HunkOp::Unstage => "unstage",
785            HunkOp::Discard => "discard",
786            HunkOp::Apply => "apply",
787            HunkOp::Reverse => "reverse",
788        }
789    }
790
791    fn past(self) -> &'static str {
792        match self {
793            HunkOp::Stage => "staged",
794            HunkOp::Unstage => "unstaged",
795            HunkOp::Discard => "discarded",
796            HunkOp::Apply => "applied",
797            HunkOp::Reverse => "reversed",
798        }
799    }
800
801    /// Why a hunk from the wrong side cannot be acted on, phrased as
802    /// what to do instead.
803    fn wrong_source_hint(self) -> &'static str {
804        match self {
805            HunkOp::Stage => "that hunk is already staged",
806            HunkOp::Unstage => "that hunk isn't staged",
807            HunkOp::Discard => "that hunk is staged — unstage it with `u` first",
808            // MG.23g: the two directions of "act on history from here",
809            // so the hint names the key that does the same thing to a
810            // hunk of the current checkout instead.
811            HunkOp::Apply => "that change is already in the working tree",
812            HunkOp::Reverse => "that hunk isn't from a commit — `x` discards a working-tree change",
813        }
814    }
815}
816
817/// What the cursor resolved to for a hunk-level operation.
818pub(crate) enum HunkResolution {
819    /// Not inside a hunk. The caller runs its file-level path
820    /// unchanged — this is what keeps every pre-MG.18c behaviour.
821    FileLevel,
822    /// Inside a hunk this operation cannot act on. Carries the
823    /// explanation; the file-level path must NOT run, or `s` would
824    /// silently stage the whole file the user was inspecting a hunk of.
825    Refused(Effect),
826    Ready {
827        view: Arc<dyn crate::buffer_state::MagitView>,
828        workdir: std::path::PathBuf,
829        patch: crate::hunk::HunkPatch,
830        /// MG.18d: where to put the cursor once the rebuild lands.
831        /// `None` when the hunk's own header names no file — nothing to
832        /// find again, so the refresh leaves the cursor alone.
833        site: Option<crate::cursor_restore::HunkSite>,
834        /// MG.18e: how many changed lines a Visual-mode region selected,
835        /// or `None` for a whole hunk. Named in the echo and the discard
836        /// prompt so a selection that reached past this hunk reads as
837        /// what it did, not as what the user drew.
838        region_lines: Option<usize>,
839    },
840}
841
842/// MG.18e: what the active region did to the hunk under the cursor.
843enum RegionOutcome {
844    /// No region, or one that covers the whole hunk — the unrestricted
845    /// patch, byte-identical to what a Normal-mode press produces.
846    Whole,
847    /// The region selected some of the hunk's changes.
848    Restricted(crate::hunk::HunkPatch),
849    /// The region is inside the hunk but holds no `+`/`-` line, so
850    /// there is nothing to move.
851    Empty,
852}
853
854/// Narrow `whole` to the active region, if there is one.
855///
856/// The region is intersected with the hunk under the cursor: rows
857/// outside it belong to other hunks or other entries, and a selection
858/// that reaches past this hunk acts on the part inside it. That is a
859/// deliberate one-hunk-at-a-time limit — magit's own region can span
860/// hunks, which needs a multi-hunk patch builder, so the echo names the
861/// hunk it acted on rather than implying it did more.
862fn region_of(whole: &crate::hunk::HunkPatch, ctx: &ActionContext<'_>, op: HunkOp) -> RegionOutcome {
863    let Some(region) = ctx.selection else {
864        return RegionOutcome::Whole;
865    };
866    let rows = region.start.line as usize..=region.end.line as usize;
867    // A region covering the hunk end-to-end is not a special case: the
868    // rewrite reproduces the whole patch. Short-circuiting it keeps the
869    // verbatim header (and its round-trip proof) on the common path.
870    if *rows.start() <= whole.header_line + 1 && *rows.end() >= whole.end_line.saturating_sub(1) {
871        return RegionOutcome::Whole;
872    }
873    match whole.restrict_to_rows(rows, op.direction()) {
874        Some(patch) => RegionOutcome::Restricted(patch),
875        None => RegionOutcome::Empty,
876    }
877}
878
879fn echo(text: String) -> Effect {
880    Effect::Echo {
881        level: lattice_grammar::EchoLevel::Info,
882        text,
883    }
884}
885
886/// MG.18d: name the work this hunk is, so the rebuilt buffer can be
887/// searched for it. File + side + ordinal — deliberately not a row,
888/// which the rebuild invalidates.
889fn hunk_site(
890    store: &BufferStoreHandle,
891    buffer_id: BufferId,
892    patch: &crate::hunk::HunkPatch,
893    source: DiffSource,
894) -> Option<crate::cursor_restore::HunkSite> {
895    let path = std::path::PathBuf::from(patch.file_path()?);
896    let handle = store.handle_for(buffer_id)?;
897    let snap = handle.snapshot();
898    let ordinal = crate::hunk::hunk_ordinal_at(
899        |i| u32::try_from(i).ok().and_then(|l| snap.buffer.line(l)),
900        patch.header_line,
901    );
902    Some(crate::cursor_restore::HunkSite {
903        path,
904        staged: source == DiffSource::Staged,
905        ordinal,
906    })
907}
908
909/// Resolve the hunk at the cursor for `op`, per magit-hunk-staging.md
910/// §"Resolution order: hunk, then file".
911/// Wrap a content action so that finishing it **collapses the Visual
912/// selection**, the way acting on a region does everywhere else.
913///
914/// evil-magit deactivates the region when the command runs, and so does vim
915/// for its own visual operators: you selected a thing in order to act on it,
916/// and once acted upon the selection has no referent — worse, the refresh
917/// rebuilds the buffer underneath it, so what stays highlighted is whatever
918/// rows now occupy those line numbers.
919///
920/// `do_exit_visual` stashes the range as `last_visual` first, so `gv` brings
921/// it back. That is why this is a collapse rather than a loss, and it is what
922/// makes "stage these three, then discard those same three" still cheap.
923///
924/// **Only when the handler returned `None`.** A handler with an effect to
925/// return has not finished — `x` over a selection returns
926/// `Effect::Confirm`, and the action has not happened yet. Collapsing there
927/// would drop the selection for a question the user may answer *no* to, and
928/// `Option<Effect>` has no room to carry both. The execute half is wrapped
929/// too, so the collapse lands when the work actually does.
930///
931/// **Only when a selection was live.** Every one of these chords is bound in
932/// Normal as well, where there is nothing to collapse and emitting
933/// `ExitVisual` would be a no-op that still costs an effect round-trip.
934///
935/// Applied at the five registration sites rather than inside each body: the
936/// bodies are three different shapes (`stage_or_unstage`, `apply_or_reverse`,
937/// discard's own branch) and a rule written three times is the one that ends
938/// up written twice — the gap `magit-diff-mode`'s missing `x` already
939/// demonstrated.
940pub(crate) fn consuming_selection(
941    inner: impl Fn(&ActionContext<'_>) -> Option<Effect> + Send + Sync + 'static,
942) -> impl Fn(&ActionContext<'_>) -> Option<Effect> + Send + Sync + 'static {
943    move |ctx: &ActionContext<'_>| {
944        let had_selection = ctx.selection.is_some();
945        match inner(ctx) {
946            None if had_selection => {
947                Some(Effect::AppAction(lattice_grammar::AppEffect::ExitVisual))
948            }
949            other => other,
950        }
951    }
952}
953
954pub(crate) fn resolve_hunk(ctx: &ActionContext<'_>, op: HunkOp) -> HunkResolution {
955    let (Some(store), Some(view)) = (
956        ctx.services.get::<BufferStoreHandle>(),
957        crate::buffer_state::view_for(ctx),
958    ) else {
959        return HunkResolution::FileLevel;
960    };
961    let buffer_id = BufferId(ctx.buffer_id.0 as u32);
962    let Some(whole) = hunk_at_cursor(&store, buffer_id, ctx.cursor.line) else {
963        return HunkResolution::FileLevel;
964    };
965    // MG.18e: a Visual-mode selection narrows the hunk to the lines it
966    // covers. Resolved BEFORE the source gate so "nothing selectable
967    // there" is answered ahead of "wrong side" — the user picked those
968    // rows deliberately, and telling them the selection was empty is
969    // more useful than a staged/unstaged lecture.
970    let (patch, region_lines) = match region_of(&whole, ctx, op) {
971        RegionOutcome::Whole => (whole, None),
972        RegionOutcome::Restricted(patch) => {
973            // Every `+`/`-` still carrying its marker is a selected
974            // change: the rewrite contextualised or dropped the rest.
975            let lines = patch
976                .hunk
977                .iter()
978                .skip(1)
979                .filter(|l| l.starts_with('+') || l.starts_with('-'))
980                .count();
981            (patch, Some(lines))
982        }
983        RegionOutcome::Empty => {
984            return HunkResolution::Refused(echo(format!(
985                "magit: nothing to {} in the selection — it holds no added or removed lines",
986                op.present()
987            )));
988        }
989    };
990    let hint = match view.diff_source(ctx.cursor) {
991        Some(source) if source == op.requires() => {
992            return match view.workdir() {
993                Some(workdir) => HunkResolution::Ready {
994                    site: hunk_site(&store, buffer_id, &patch, source),
995                    view,
996                    workdir,
997                    patch,
998                    region_lines,
999                },
1000                // A view that stages but cannot name its repository is
1001                // a wiring bug, not a user error; decline rather than
1002                // guess at a working directory.
1003                None => HunkResolution::FileLevel,
1004            };
1005        }
1006        Some(_) => op.wrong_source_hint().to_string(),
1007        None => format!(
1008            "hunk-level staging isn't available in this view — move to the file header to {} the whole file",
1009            op.present()
1010        ),
1011    };
1012    HunkResolution::Refused(Effect::Echo {
1013        level: lattice_grammar::EchoLevel::Info,
1014        text: format!("magit: {hint}"),
1015    })
1016}
1017
1018/// Apply `patch` off the actor thread, then rebuild the view.
1019///
1020/// Returns immediately with the echo naming what is being done; the
1021/// git call has not started yet. Failure is reported the way every
1022/// other async magit mutation reports it — `tracing::error!`, which
1023/// the `MessagesLayer` fans into `*messages*` — and the refresh runs
1024/// either way, so a refused patch leaves the buffer showing the truth
1025/// rather than a state the user may believe they changed.
1026/// IX.2: discard a patch a confirmation carried.
1027///
1028/// The peer of [`spawn_hunk_apply`] for the confirmed path, where the
1029/// patch arrives as text rather than as a freshly-parsed `HunkPatch` —
1030/// there is deliberately nothing to re-parse, because re-parsing would
1031/// read a buffer that may have been rebuilt since the question was
1032/// asked.
1033///
1034/// `view` refreshes afterwards when there is one; a confirm fired from
1035/// a buffer whose view has since gone still applies, it just does not
1036/// repaint anything.
1037pub(crate) fn spawn_patch_discard(
1038    workdir: std::path::PathBuf,
1039    patch: String,
1040    view: Option<Arc<dyn crate::buffer_state::MagitView>>,
1041) -> Effect {
1042    let scope_dir = workdir.clone();
1043    // NC.4: name the file — "discard hunk" alone says nothing about
1044    // which of several discards this was.
1045    let label = match patch_path(&patch) {
1046        Some(path) => format!("discard a hunk in {path}"),
1047        None => "discard a hunk".to_string(),
1048    };
1049    tokio::task::spawn(async move {
1050        let result = tokio::task::spawn_blocking(move || {
1051            let repo = lattice_vcs::Repository::discover(&workdir)
1052                .map_err(|e| format!("not a git repository: {e}"))?;
1053            // `(cached = false, reverse = true)` — the worktree, matching
1054            // file-level `x`, which is `git checkout --` and likewise
1055            // leaves the index alone.
1056            lattice_vcs::Index::apply_patch(&repo, &patch, false, true).map_err(|e| e.to_string())
1057        })
1058        .await
1059        .unwrap_or_else(|e| Err(e.to_string()));
1060        // MG.54: publish. The `Effect::Echo` below fires when the task
1061        // is SPAWNED, so it said "magit: discarded" whether or not the
1062        // discard succeeded — an optimistic report with no correction
1063        // path. `finish_task` is that correction path.
1064        crate::magit_global_mode::finish_task(&scope_dir, &label, result.map(|()| String::new()));
1065        if let Some(view) = view {
1066            let _ = view.refresh();
1067        }
1068    });
1069    Effect::Echo {
1070        level: lattice_grammar::EchoLevel::Info,
1071        text: "magit: discarded".to_string(),
1072    }
1073}
1074
1075/// The file a unified diff patch touches, from its `+++ b/` header —
1076/// or `--- a/` for a deletion, whose new side is `/dev/null`.
1077fn patch_path(patch: &str) -> Option<&str> {
1078    let side = |prefix: &str| {
1079        patch
1080            .lines()
1081            .find_map(|l| l.strip_prefix(prefix))
1082            .map(str::trim)
1083            .filter(|p| !p.is_empty() && *p != "/dev/null")
1084    };
1085    side("+++ b/").or_else(|| side("--- a/"))
1086}
1087
1088pub(crate) fn spawn_hunk_apply(
1089    view: Arc<dyn crate::buffer_state::MagitView>,
1090    workdir: std::path::PathBuf,
1091    patch: crate::hunk::HunkPatch,
1092    op: HunkOp,
1093    site: Option<crate::cursor_restore::HunkSite>,
1094    region_lines: Option<usize>,
1095) -> Effect {
1096    let location = patch.display_location();
1097    let text = patch.to_patch();
1098    let (cached, reverse) = op.apply_flags();
1099    let logged = location.clone();
1100    let scope_dir = workdir.clone();
1101    tokio::task::spawn(async move {
1102        let result = tokio::task::spawn_blocking(move || {
1103            let repo = lattice_vcs::Repository::discover(&workdir)
1104                .map_err(|e| format!("not a git repository: {e}"))?;
1105            lattice_vcs::Index::apply_patch(&repo, &text, cached, reverse)
1106                .map_err(|e| e.to_string())
1107        })
1108        .await
1109        .unwrap_or_else(|e| Err(e.to_string()));
1110        // MG.54: publish, not just log.
1111        //
1112        // `announce` below already echoed optimistically the moment the
1113        // task was spawned, so a REFUSED apply left the user told it
1114        // had worked. `git apply` refuses a patch whose context does
1115        // not match the target exactly — the safeguard, not a
1116        // malfunction: the buffer had drifted from the tree. That is
1117        // precisely the outcome worth surfacing, and it was the one
1118        // outcome nothing surfaced.
1119        crate::magit_global_mode::finish_task(
1120            &scope_dir,
1121            &format!("{} hunk at {logged}", op.present()),
1122            result.map(|()| String::new()),
1123        );
1124        // Both views drive their own async rebuild and return `None`;
1125        // there is no effect to propagate from inside a spawned task.
1126        //
1127        // MG.18d: the rebuild is also what puts the cursor back — it is
1128        // the only thing that knows the new text, so the restore rides
1129        // with it rather than racing it from here.
1130        let _ = match site {
1131            Some(site) => view.refresh_restoring(site),
1132            None => view.refresh(),
1133        };
1134    });
1135    announce(op, &location, region_lines)
1136}
1137
1138/// What the user is told, and whether Visual mode ends.
1139///
1140/// Split out so both are testable without spawning the git call the
1141/// caller has already started.
1142fn announce(op: HunkOp, location: &str, region_lines: Option<usize>) -> Effect {
1143    let echo = Effect::Echo {
1144        level: lattice_grammar::EchoLevel::Info,
1145        text: match region_lines {
1146            // Name the count, not "the selection": a region that reached
1147            // past this hunk acted on the part inside it, and "3 lines"
1148            // says so where "the selection" would not.
1149            Some(1) => format!("magit: {} 1 line of {location}", op.past()),
1150            Some(n) => format!("magit: {} {n} lines of {location}", op.past()),
1151            None => format!("magit: {} hunk at {location}", op.past()),
1152        },
1153    };
1154    match region_lines {
1155        // Acting on a region consumes it, the way a Visual-mode operator
1156        // does in vim — staying selected would invite a second `s` over
1157        // rows whose meaning just changed under the refresh.
1158        Some(_) => Effect::Many(vec![
1159            Effect::EnterMode(lattice_grammar::ModalState::Normal),
1160            echo,
1161        ]),
1162        None => echo,
1163    }
1164}
1165
1166/// The `s` / `u` handler body: hunk first, then the view's file-level
1167/// path. `x` runs the same resolution through its confirm pair in
1168/// `actions.rs`.
1169fn stage_or_unstage(ctx: &ActionContext<'_>, op: HunkOp) -> Option<Effect> {
1170    match resolve_hunk(ctx, op) {
1171        HunkResolution::Ready {
1172            view,
1173            workdir,
1174            patch,
1175            site,
1176            region_lines,
1177        } => Some(spawn_hunk_apply(
1178            view,
1179            workdir,
1180            patch,
1181            op,
1182            site,
1183            region_lines,
1184        )),
1185        HunkResolution::Refused(effect) => Some(effect),
1186        HunkResolution::FileLevel => {
1187            let view = crate::buffer_state::view_for(ctx)?;
1188            // A Visual selection over ENTRY rows means "these files",
1189            // not "this file". Asked of the view because what an entry
1190            // is differs per view; a view with no range answer falls
1191            // through to the cursor's single entry, so nothing that
1192            // worked before changes.
1193            let rows = ctx
1194                .selection
1195                .map(|r| r.start.line.min(r.end.line)..=r.start.line.max(r.end.line));
1196            match op {
1197                HunkOp::Stage => rows
1198                    .clone()
1199                    .and_then(|r| view.stage_rows(r))
1200                    .or_else(|| view.stage(ctx.cursor)),
1201                HunkOp::Unstage => rows
1202                    .and_then(|r| view.unstage_rows(r))
1203                    .or_else(|| view.unstage(ctx.cursor)),
1204                // MG.23g: `a` / `-` have no file-level fallback, which
1205                // is deliberate rather than missing. The file-level
1206                // meaning of "apply this commit" is a cherry-pick and
1207                // of "reverse it" a revert — `A` and `_` already do
1208                // both, at a scale far larger than these keys promise.
1209                // Doing it because the cursor missed a hunk would be
1210                // the worst kind of surprise.
1211                HunkOp::Discard | HunkOp::Apply | HunkOp::Reverse => None,
1212            }
1213        }
1214    }
1215}
1216
1217/// MG.23g: the `a` / `-` handler body.
1218///
1219/// Shares [`resolve_hunk`] with `s`/`u`/`x` — the resolution, the
1220/// region rewrite and the source gate are the same question asked of a
1221/// different [`DiffSource`] — and differs only in having no
1222/// file-level path to fall back to (see [`stage_or_unstage`]'s
1223/// `FileLevel` arm for why).
1224///
1225/// Neither op confirms. `a` adds a change to the working tree, which
1226/// `-` takes straight back out; `-` removes one that is still in the
1227/// commit it came from, so `a` restores it. Both are recoverable
1228/// without consulting anything the user cannot see, which is §12.13's
1229/// actual test — and `git apply` refuses outright when the context
1230/// does not match, so neither can quietly damage an edit in progress.
1231fn apply_or_reverse(ctx: &ActionContext<'_>, op: HunkOp) -> Option<Effect> {
1232    match resolve_hunk(ctx, op) {
1233        HunkResolution::Ready {
1234            view,
1235            workdir,
1236            patch,
1237            site,
1238            region_lines,
1239        } => Some(spawn_hunk_apply(
1240            view,
1241            workdir,
1242            patch,
1243            op,
1244            site,
1245            region_lines,
1246        )),
1247        HunkResolution::Refused(effect) => Some(effect),
1248        // Not inside a hunk at all. Say so rather than returning
1249        // `None`: a Normal-mode chord a mode binds is consumed
1250        // unconditionally, so a bare `None` is a key that visibly does
1251        // nothing.
1252        HunkResolution::FileLevel => Some(echo(format!(
1253            "magit: put the cursor inside a hunk to {} it",
1254            op.present()
1255        ))),
1256    }
1257}
1258
1259/// Walk `items` forward from `cursor_row` and return the first
1260/// item strictly greater. Wraps to the first item if none found.
1261fn next_item(items: &[u32], cursor_row: u32) -> Option<u32> {
1262    items
1263        .iter()
1264        .copied()
1265        .find(|&r| r > cursor_row)
1266        .or_else(|| items.first().copied())
1267}
1268
1269/// Walk `items` backward from `cursor_row` and return the first
1270/// item strictly less. Wraps to the last item if none found.
1271fn prev_item(items: &[u32], cursor_row: u32) -> Option<u32> {
1272    items
1273        .iter()
1274        .rev()
1275        .copied()
1276        .find(|&r| r < cursor_row)
1277        .or_else(|| items.last().copied())
1278}
1279
1280impl Mode for MagitCoreMode {
1281    type Guard = ActionRegsGuard;
1282
1283    fn id(&self) -> ModeId {
1284        Self::mode_id()
1285    }
1286    fn kind(&self) -> ModeKind {
1287        ModeKind::Minor
1288    }
1289
1290    /// The navigation chords live in `magit-nav-mode`; read-only views
1291    /// get them by implication so nothing changes for them, and an
1292    /// EDITABLE magit view can imply that mode alone without inheriting
1293    /// the bare letters below.
1294    fn implies(&self) -> &[ModeId] {
1295        static IDS: OnceLock<Vec<ModeId>> = OnceLock::new();
1296        IDS.get_or_init(|| vec![crate::magit_nav_mode::MagitNavMode::mode_id()])
1297    }
1298
1299    fn activation_policy(&self) -> ActivationPolicy {
1300        ActivationPolicy::Majors(vec![
1301            MagitStatusMode::mode_id(),
1302            // `magit-commit-mode` is deliberately ABSENT. Every other
1303            // major here is a read-only list, which is what lets this
1304            // mode claim bare letters at all (MG.49's rule: the chords
1305            // are inert where nothing is editable). The commit buffer
1306            // is the exception — it exists to be typed into — and this
1307            // mode is a MINOR, so it beats the builtin vim grammar:
1308            // listing it here made `i` open the .gitignore prompt
1309            // instead of entering Insert, and there was no way to write
1310            // a commit message at all. Emacs draws the same line, from
1311            // the other side: a message is composed in a text buffer
1312            // under `with-editor`, never in a `magit-mode` buffer.
1313            MagitDiffMode::mode_id(),
1314            MagitLogMode::mode_id(),
1315            // MG.26b: `magit-blame-mode` is gone from this list because
1316            // it is no longer a major. It annotates a file buffer,
1317            // whose chords are the file's own — `gr` (re-run git) and
1318            // `]]` (next section) have nothing to act on there.
1319            MagitStashMode::mode_id(),
1320            MagitBranchMode::mode_id(),
1321            MagitRebaseMode::mode_id(),
1322            MagitRevisionMode::mode_id(),
1323            MagitFileRevisionMode::mode_id(),
1324            crate::magit_stash_show_mode::MagitStashShowMode::mode_id(),
1325        ])
1326    }
1327
1328    fn options(&self) -> OptionOverrideSet {
1329        lattice_config::overrides! {
1330            // IG.6: no indentation guides in a magit buffer.
1331            //
1332            // Every magit body has leading whitespace that is not indent
1333            // structure: a diff line's ` ` / `+` / `-` prefix, a section's
1334            // two-space item indent. Guides would draw rules down those and
1335            // claim a nesting that does not exist.
1336            //
1337            // On the core minor rather than on each major, so a new magit
1338            // buffer inherits it instead of being one more place to remember
1339            // — the `prefer-minor-modes-over-duplication` rule.
1340            lattice_config::core_options::IndentGuides = false,
1341        }
1342    }
1343    fn required_capabilities(&self) -> CapabilitySet {
1344        CapabilitySet::empty()
1345    }
1346    fn keymap(&self) -> Keymap {
1347        Keymap::from_entries(magit_core_keymap_entries())
1348    }
1349
1350    /// RV.2 (2026-08-10): magit-refresh is every magit buffer's refresh.
1351    ///
1352    /// Declared once here, on the minor that spans every magit view —
1353    /// which is the same reason `gr` was bound here rather than
1354    /// per-view. The chord itself now lives on `refreshable-view-mode`,
1355    /// pulled in by the implies cascade because this returns `Some`; the
1356    /// handler body is untouched. See
1357    /// `docs/dev/architecture/mode-architecture.md` §5.5.
1358    fn refresh_action(&self) -> Option<&'static str> {
1359        Some("action:magit-refresh")
1360    }
1361
1362    /// OA.4b: `<Tab>` comes from `foldable-view-mode` now; magit declares
1363    /// only its BODY. The specialisation is real and stays — on a status file
1364    /// line the first press expands the diff so `<Tab>` and `=` agree, and
1365    /// everywhere else it is the plain fold toggle.
1366    ///
1367    /// `<S-Tab>` is not declared because it never needed to be: magit's
1368    /// `action:magit-cycle-sections` body was literally
1369    /// `Effect::AppAction(AppEffect::CycleFoldsGlobal)`, which is what the
1370    /// shared mode's own action evaluates to. The copy is deleted.
1371    fn fold_toggle_action(&self) -> Option<&'static str> {
1372        Some("action:magit-toggle-fold")
1373    }
1374
1375    /// Re-opening a magit buffer re-runs that refresh.
1376    ///
1377    /// Every magit view's content is a snapshot of the repository, and
1378    /// synthetic buffers are created once and reused by name — so the
1379    /// mode's `on_activate`, which fills the buffer, ran on the first
1380    /// open only. `C-x g` on an already-open `*magit:status*` therefore
1381    /// showed the repo as it was when the buffer was first created:
1382    /// commits made since, files staged in a terminal, a branch switch,
1383    /// none of it visible, and nothing on screen saying the view was
1384    /// old. Reported from use, and the failure is quiet by nature — a
1385    /// stale status buffer looks exactly like a current one.
1386    ///
1387    /// Declared here rather than per-view for the same reason
1388    /// `refresh_action` is: it is true of every magit buffer (status,
1389    /// log, branch, stash, diff, …), and the copied-set gap this rule
1390    /// prevents is precisely the one where a view is left out and nobody
1391    /// notices.
1392    ///
1393    /// The body satisfies the self-contained contract: `trigger_refresh`
1394    /// spawns the git work off-thread and returns no effect, so nothing
1395    /// on this path needs the dispatch outcome — and the refresh costs
1396    /// the actor thread nothing.
1397    fn refresh_on_open(&self) -> bool {
1398        true
1399    }
1400
1401    /// MG.13: every magit-core chord, registered once at boot.
1402    ///
1403    /// None of these need per-buffer state — they read the buffer
1404    /// through `BufferStoreHandle` using `ctx.buffer_id`, so they are
1405    /// pure functions of the `ActionContext`. That matters twice over:
1406    /// this mode is a *minor* active on **every** magit buffer, so
1407    /// per-activation registration meant N registrations of the same
1408    /// action id with two magit buffers open — last-wins, and the first
1409    /// deactivation unregistering the chord for both.
1410    ///
1411    /// `gr`, `s` and `u` are the shared actions: `gr` is bound here,
1412    /// while `s`/`u` are bound by `magit-status-mode` and
1413    /// `magit-diff-mode`. Either way the *handler* must exist exactly
1414    /// once, so all three live here and dispatch per-buffer through
1415    /// `MagitView`. The binding still belongs to whichever mode offers
1416    /// the chord — a buffer whose mode does not bind `s` never routes
1417    /// one here.
1418    fn action_handlers(&self) -> Vec<lattice_mode::ActionHandlerContribution> {
1419        use crate::buffer_state::view_for;
1420
1421        /// Read the buffer this action fired in. No per-buffer state
1422        /// needed — the store is a service and the buffer comes from
1423        /// the `ActionContext`.
1424        fn store_and_buffer(ctx: &ActionContext<'_>) -> Option<(Arc<BufferStoreHandle>, BufferId)> {
1425            let store = ctx.services.get::<BufferStoreHandle>()?;
1426            Some((store, BufferId(ctx.buffer_id.0 as u32)))
1427        }
1428
1429        macro_rules! nav {
1430            ($name:literal, $lines:ident, $step:ident) => {
1431                lattice_mode::ActionHandlerContribution {
1432                    action_name: $name,
1433                    handler: Arc::new(|ctx: &ActionContext<'_>| {
1434                        let (store, buffer_id) = store_and_buffer(ctx)?;
1435                        let items = $lines(&store, buffer_id);
1436                        Some(cursor_at($step(&items, ctx.cursor.line)?))
1437                    }),
1438                }
1439            };
1440        }
1441
1442        macro_rules! file_nav {
1443            ($name:literal, $step:ident) => {
1444                lattice_mode::ActionHandlerContribution {
1445                    action_name: $name,
1446                    handler: Arc::new(|ctx: &ActionContext<'_>| {
1447                        let (store, buffer_id) = store_and_buffer(ctx)?;
1448                        let items = view_for(ctx)
1449                            .and_then(|v| v.file_lines(&store, buffer_id))
1450                            .unwrap_or_else(|| entry_lines(&store, buffer_id));
1451                        Some(cursor_at($step(&items, ctx.cursor.line)?))
1452                    }),
1453                }
1454            };
1455        }
1456
1457        vec![
1458            // ── shared actions: one handler, per-view body ──────
1459            lattice_mode::ActionHandlerContribution {
1460                action_name: "action:magit-refresh",
1461                handler: Arc::new(|ctx: &ActionContext<'_>| view_for(ctx)?.refresh()),
1462            },
1463            // MG.23k: `D` opens the menu; the menu's run row fires
1464            // `action:magit-view-refresh-args` below.
1465            lattice_mode::ActionHandlerContribution {
1466                action_name: "action:magit-view-arguments",
1467                handler: Arc::new(|ctx: &ActionContext<'_>| {
1468                    // Gated on there being a view at all, so `D` in a
1469                    // non-magit buffer stays the vim operator.
1470                    let _ = view_for(ctx)?;
1471                    Some(Effect::OpenTransient {
1472                        source: "magit-view-arguments".to_string(),
1473                        // TR.3a: a plain open — every native menu is opened for
1474                        // itself rather than for a subject.
1475                        args: lattice_grammar::Args::None,
1476                    })
1477                }),
1478            },
1479            // The run row. Builds argv from the VIEW's own flag table,
1480            // so a slot belonging to the other table cannot leak in
1481            // even though the action's schema is the union of both.
1482            lattice_mode::ActionHandlerContribution {
1483                action_name: "action:magit-view-refresh-args",
1484                handler: Arc::new(|ctx: &ActionContext<'_>| {
1485                    let view = view_for(ctx)?;
1486                    view.refresh_with_args(view_argv(view.argument_flags(), &ctx.args))
1487                }),
1488            },
1489            // MG.20: one handler per operation, each resolving its
1490            // target through the view — the same shape `gr` / `s` / `u`
1491            // use. A view with no commit under the cursor declines, so
1492            // pressing `V` in a branch list does nothing rather than
1493            // acting on something arbitrary.
1494            commit_op(
1495                "action:magit-cherry-pick",
1496                crate::magit_global_mode::CommitOp::CHERRY_PICK,
1497            ),
1498            commit_op(
1499                "action:magit-revert",
1500                crate::magit_global_mode::CommitOp::REVERT,
1501            ),
1502            // MG.43d: the cherry-move rows. Resolve the commit (cursor
1503            // or picker), stash it, then prompt for the branch — the
1504            // second half runs in `magit_global_mode`.
1505            cherry_move_entry(
1506                "action:magit-cherry-harvest",
1507                "magit-cherry-harvest",
1508                "Harvest cherry from branch: ",
1509                "action:magit-cherry-harvest-finish",
1510            ),
1511            cherry_move_entry(
1512                "action:magit-cherry-donate",
1513                "magit-cherry-donate",
1514                "Donate cherry to branch: ",
1515                "action:magit-cherry-donate-finish",
1516            ),
1517            cherry_move_entry(
1518                "action:magit-cherry-spinout",
1519                "magit-cherry-spinout",
1520                "Spin out cherry to new branch: ",
1521                "action:magit-cherry-spinout-finish",
1522            ),
1523            cherry_move_entry(
1524                "action:magit-cherry-spinoff",
1525                "magit-cherry-spinoff",
1526                "Spin off cherry to new branch: ",
1527                "action:magit-cherry-spinoff-finish",
1528            ),
1529            // MG.43c: rebase's todo-rewriting rows. One builder, three
1530            // verbs — the verb IS the operation.
1531            rebase_verb_op(
1532                "action:magit-rebase-edit-commit",
1533                "edit",
1534                "magit-rebase-edit-commit",
1535            ),
1536            rebase_verb_op(
1537                "action:magit-rebase-remove-commit",
1538                "drop",
1539                "magit-rebase-remove-commit",
1540            ),
1541            // MG.43c: `w` needs a message, so it opens the compose
1542            // buffer with the target in the name rather than spawning.
1543            lattice_mode::ActionHandlerContribution {
1544                action_name: "action:magit-rebase-reword-commit",
1545                handler: Arc::new(move |ctx: &ActionContext<'_>| {
1546                    let resolved = crate::buffer_state::view_for(ctx)
1547                        .and_then(|view| view.commit_at_cursor(ctx.cursor));
1548                    let Some(commit) = resolved else {
1549                        return Some(Effect::OpenPicker {
1550                            source: crate::picker_sources::COMMIT_PICK_SOURCE.to_string(),
1551                            args: vec!["magit-rebase-reword-commit".to_string()],
1552                            root: None,
1553                            fill_action: None,
1554                            query: None,
1555                        });
1556                    };
1557                    Some(crate::magit_global_mode::open_repo_view_from_action_with(
1558                        ctx,
1559                        "reword-commit",
1560                        "magit-commit-mode",
1561                        Some(&commit),
1562                    ))
1563                }),
1564            },
1565            // MG.43a: the `--no-commit` halves. Same resolution, same
1566            // shape — only the argv differs, which is the point of
1567            // `CommitOp` being data.
1568            commit_op(
1569                "action:magit-revert-changes",
1570                crate::magit_global_mode::CommitOp::REVERT_CHANGES,
1571            ),
1572            commit_op(
1573                "action:magit-cherry-pick-apply",
1574                crate::magit_global_mode::CommitOp::CHERRY_PICK_APPLY,
1575            ),
1576            // MG.43f: magit's reset `w`.
1577            commit_op(
1578                "action:magit-reset-worktree",
1579                crate::magit_global_mode::CommitOp::RESET_WORKTREE,
1580            ),
1581            commit_op(
1582                "action:magit-reset-soft",
1583                crate::magit_global_mode::CommitOp::RESET_SOFT,
1584            ),
1585            commit_op(
1586                "action:magit-reset-mixed",
1587                crate::magit_global_mode::CommitOp::RESET_MIXED,
1588            ),
1589            commit_op(
1590                "action:magit-reset-hard",
1591                crate::magit_global_mode::CommitOp::RESET_HARD,
1592            ),
1593            // MG.41d: the rest of magit's reset modes plus the
1594            // autosquash pair. Same handler, different argv — the
1595            // whole reason `CommitOp` is data.
1596            commit_op(
1597                "action:magit-reset-keep",
1598                crate::magit_global_mode::CommitOp::RESET_KEEP,
1599            ),
1600            commit_op(
1601                "action:magit-reset-index",
1602                crate::magit_global_mode::CommitOp::RESET_INDEX,
1603            ),
1604            commit_op(
1605                "action:magit-commit-fixup",
1606                crate::magit_global_mode::CommitOp::COMMIT_FIXUP,
1607            ),
1608            commit_op(
1609                "action:magit-commit-squash",
1610                crate::magit_global_mode::CommitOp::COMMIT_SQUASH,
1611            ),
1612            // MG.42-E1: magit's `A` augment — a squash marker carrying
1613            // the user's own note, so it opens the compose buffer with
1614            // the target encoded in the name.
1615            lattice_mode::ActionHandlerContribution {
1616                action_name: "action:magit-commit-augment",
1617                handler: Arc::new(move |ctx: &ActionContext<'_>| {
1618                    let resolved = crate::buffer_state::view_for(ctx)
1619                        .and_then(|view| view.commit_at_cursor(ctx.cursor));
1620                    let Some(commit) = resolved else {
1621                        return Some(Effect::OpenPicker {
1622                            source: crate::picker_sources::COMMIT_PICK_SOURCE.to_string(),
1623                            args: vec!["magit-augment".to_string()],
1624                            root: None,
1625                            fill_action: None,
1626                            query: None,
1627                        });
1628                    };
1629                    Some(crate::magit_global_mode::open_repo_view_from_action_with(
1630                        ctx,
1631                        "augment",
1632                        "magit-commit-mode",
1633                        Some(&commit),
1634                    ))
1635                }),
1636            },
1637            // MG.42-E2: magit's `F` / `S` — record the marker commit
1638            // AND fold it in, as one operation.
1639            commit_sequence_op(
1640                "action:magit-commit-instant-fixup",
1641                "magit-commit-instant-fixup",
1642                "fold a fixup into",
1643                |c| crate::magit_global_mode::instant_squash_steps("fixup", c),
1644            ),
1645            commit_sequence_op(
1646                "action:magit-commit-instant-squash",
1647                "magit-commit-instant-squash",
1648                "fold a squash into",
1649                |c| crate::magit_global_mode::instant_squash_steps("squash", c),
1650            ),
1651            // The execute half of reset --hard, reached only through
1652            // its confirm. Re-resolves the commit at the cursor rather
1653            // than carrying it through the prompt: the confirm
1654            // transient owns every keystroke while open, so the cursor
1655            // cannot have moved (same argument as branch-delete).
1656            commit_op_execute(
1657                "action:magit-reset-hard-execute",
1658                crate::magit_global_mode::CommitOp::RESET_HARD,
1659            ),
1660            // MG.18c: hunk-at-cursor first, the view's file-level path
1661            // second. The hunk half is identical in every magit
1662            // buffer, so it resolves here; only the fallback is
1663            // per-view.
1664            lattice_mode::ActionHandlerContribution {
1665                action_name: "action:magit-stage",
1666                handler: Arc::new(consuming_selection(|ctx| {
1667                    stage_or_unstage(ctx, HunkOp::Stage)
1668                })),
1669            },
1670            lattice_mode::ActionHandlerContribution {
1671                action_name: "action:magit-unstage",
1672                handler: Arc::new(consuming_selection(|ctx| {
1673                    stage_or_unstage(ctx, HunkOp::Unstage)
1674                })),
1675            },
1676            // MG.23g: the committed-hunk pair, through the same
1677            // resolution. They live here rather than on the revision
1678            // and stash-show modes for the reason `]c` / `[c` do: a
1679            // hunk is a property of diff text, identical wherever it
1680            // is shown, and two modes contributing one action id would
1681            // leave one of them dead (MG.13's collision class).
1682            lattice_mode::ActionHandlerContribution {
1683                action_name: "action:magit-apply-hunk",
1684                handler: Arc::new(consuming_selection(|ctx| {
1685                    apply_or_reverse(ctx, HunkOp::Apply)
1686                })),
1687            },
1688            lattice_mode::ActionHandlerContribution {
1689                action_name: "action:magit-reverse-hunk",
1690                handler: Arc::new(consuming_selection(|ctx| {
1691                    apply_or_reverse(ctx, HunkOp::Reverse)
1692                })),
1693            },
1694            // ── close (q) ─────────────────────────────────
1695            // Bug fix: this used to return `Effect::QuitEditor { scope:
1696            // Pane, .. }` — vim's `:q` semantics ("close the pane; if
1697            // it's the last one, quit the editor"). With magit buffers
1698            // opened IN PLACE in the current pane (not a split), `:q`
1699            // semantics on the only pane open QUIT THE WHOLE EDITOR —
1700            // the exact live-reported bug. magit's `q` means "bury this
1701            // buffer" (Emacs `bury-buffer` / vim alternate-buffer), not
1702            // "close a window" — it must never risk quitting. Fixed by
1703            // returning `Effect::DismissPopup`, which restores the
1704            // pane's pre-open buffer/cursor/scroll from
1705            // `Editor::prev_pane_for_popup` without touching the
1706            // editor's pane count at all.
1707            lattice_mode::ActionHandlerContribution {
1708                action_name: "action:magit-close",
1709                // `Effect::BuryBuffer`, not `DismissPopup`: a magit view
1710                // is a full-pane buffer, not a popup. Opening one swaps
1711                // the pane AND the editor's active-document handle;
1712                // dismissing a popup only drops an overlay, so it left
1713                // the document pointing at magit while the pane pointed
1714                // at the file — the pane named one buffer and the screen
1715                // painted another, and no redraw could fix it because
1716                // the data was stale, not the paint.
1717                handler: Arc::new(|_ctx: &ActionContext<'_>| Some(Effect::BuryBuffer)),
1718            },
1719            // ── navigation: ]] [[ ]f [f ]c [c ────────────
1720            nav!("action:magit-next-section", section_headers, next_item),
1721            nav!("action:magit-prev-section", section_headers, prev_item),
1722            // `]f` / `[f` ask the VIEW first: "a file" is an indented
1723            // entry row in magit-status and a `diff --git` header in a
1724            // buffer whose content is a diff. The generic scan matches
1725            // any two-space-indented line, so in a diff it walked
1726            // through context lines while claiming to move between
1727            // files.
1728            file_nav!("action:magit-next-file", next_item),
1729            file_nav!("action:magit-prev-file", prev_item),
1730            nav!("action:magit-next-hunk", hunk_lines, next_item),
1731            nav!("action:magit-prev-hunk", hunk_lines, prev_item),
1732            // TAB — toggle the fold at cursor (per-entry/per-hunk,
1733            // per `MagitStatusFoldSource`'s nested ranges).
1734            lattice_mode::ActionHandlerContribution {
1735                action_name: "action:magit-toggle-fold",
1736                // MG.44: on a status file line this expands the diff
1737                // (first press) or folds it (after), so `<Tab>` and
1738                // `=` agree. Everywhere else it is the plain fold
1739                // toggle it has always been.
1740                handler: Arc::new(|ctx: &ActionContext<'_>| {
1741                    crate::actions::toggle_diff_or_fold(ctx)
1742                }),
1743            },
1744        ]
1745        .into_iter()
1746        .chain(root_menu_handlers())
1747        .collect()
1748    }
1749
1750    /// MG.13: nothing to do per activation — every chord this mode
1751    /// contributes is registered at boot by `action_handlers()`. The
1752    /// Guard is empty; it exists only to satisfy the lifecycle
1753    /// contract (a fresh Guard per activation).
1754    fn on_activate(&self, _ctx: ModeContext) -> LifecycleFuture<'_, Self::Guard> {
1755        Box::pin(async move { Ok(ActionRegsGuard) })
1756    }
1757}
1758
1759/// MG.18c — the `s` / `u` / `x` resolution ladder, exercised through a
1760/// MG.23k: every flag table `D` can offer, in the order they
1761/// contribute to `action:magit-view-refresh-args`'s schema.
1762///
1763/// **One list, two consumers.** The action's `args_schema` is built
1764/// from this, and [`view_argv`] resolves each of a view's flags back to
1765/// its slot through it. Two hand-kept lists would drift, and the
1766/// failure would be silent in the worst way: the action receives a
1767/// POSITIONAL list, so a mismatch means a toggle lands in a
1768/// neighbour's slot and the wrong git flag runs.
1769pub(crate) const VIEW_ARG_TABLES: &[&[crate::magit_global_mode::RemoteFlag]] = &[
1770    crate::magit_diff_mode::DIFF_ARGS,
1771    crate::magit_log_mode::LOG_ARGS,
1772];
1773
1774/// Build the git arguments for `flags` out of a projected transient
1775/// state.
1776///
1777/// `args` is positional over the *union* schema ([`VIEW_ARG_TABLES`]),
1778/// while `flags` is the one table the current view understands — so
1779/// each flag is looked up by its position in the union, not in its own
1780/// table. A view therefore cannot be handed the other view's arguments
1781/// even though both share one action.
1782pub(crate) fn view_argv(
1783    flags: &[crate::magit_global_mode::RemoteFlag],
1784    args: &lattice_grammar::Args,
1785) -> Vec<String> {
1786    use crate::magit_global_mode::RemoteArgKind;
1787    let slot_of = |name: &str| -> Option<usize> {
1788        VIEW_ARG_TABLES
1789            .iter()
1790            .flat_map(|t| t.iter())
1791            .position(|f| f.name == name)
1792    };
1793    let mut argv = Vec::new();
1794    for flag in flags {
1795        let Some(i) = slot_of(flag.name) else {
1796            continue;
1797        };
1798        let slot = args.as_list().and_then(|l| l.get(i));
1799        match flag.kind {
1800            RemoteArgKind::Flag => {
1801                if matches!(slot, Some(lattice_grammar::ArgValue::Bool(true))) {
1802                    argv.push(flag.arg.to_string());
1803                }
1804            }
1805            RemoteArgKind::Value { .. } => {
1806                if let Some(lattice_grammar::ArgValue::String(v)) = slot
1807                    && !v.is_empty()
1808                {
1809                    argv.push(flag.arg.to_string());
1810                    argv.push(v.clone());
1811                }
1812            }
1813            RemoteArgKind::ValueJoined { .. } => {
1814                if let Some(lattice_grammar::ArgValue::String(v)) = slot
1815                    && !v.is_empty()
1816                {
1817                    argv.push(format!("{}{v}", flag.arg));
1818                }
1819            }
1820        }
1821    }
1822    argv
1823}
1824
1825/// real buffer and a published view.
1826///
1827/// The unit tests in `hunk.rs` prove the parser; these prove the
1828/// *wiring*, which is where this crate's history says the bugs live
1829/// (MG.13's handler race, MG.15's dead stash chords). Each case builds
1830/// the same `ActionContext` shape production dispatch builds.
1831#[cfg(test)]
1832mod patch_path_tests {
1833    use super::patch_path;
1834
1835    #[test]
1836    fn a_patch_names_its_file() {
1837        let patch = "diff --git a/src/a.rs b/src/a.rs\n--- a/src/a.rs\n+++ b/src/a.rs\n@@ -1 +1 @@\n-x\n+y\n";
1838        assert_eq!(patch_path(patch), Some("src/a.rs"));
1839    }
1840
1841    /// A deletion's new side is `/dev/null`; the file is on the old side.
1842    #[test]
1843    fn a_deletion_names_the_old_side() {
1844        let patch = "--- a/gone.rs\n+++ /dev/null\n@@ -1 +0,0 @@\n-x\n";
1845        assert_eq!(patch_path(patch), Some("gone.rs"));
1846    }
1847
1848    #[test]
1849    fn a_patch_with_no_header_names_nothing() {
1850        assert_eq!(patch_path("@@ -1 +1 @@\n-x\n+y\n"), None);
1851    }
1852}
1853
1854#[cfg(test)]
1855mod hunk_staging {
1856    use super::*;
1857    use crate::buffer_state::{MagitView, MagitViews, MagitViewsHandle};
1858    use lattice_mode::{BufferStore, ServiceRegistry};
1859
1860    const DIFF: &str = "\
1861diff --git a/a.txt b/a.txt
1862index 111..222 100644
1863--- a/a.txt
1864+++ b/a.txt
1865@@ -1,2 +1,2 @@
1866 keep
1867-old
1868+new
1869  modified src/other.rs
1870";
1871    /// Row 6 is `+new` — inside the hunk. Row 8 is the status entry
1872    /// below it, where staging must stay file-level.
1873    const IN_HUNK: u32 = 6;
1874    const BELOW_HUNK: u32 = 8;
1875
1876    struct OneBufferStore {
1877        id: lattice_core::BufferId,
1878        doc: Arc<dyn lattice_runtime::Document>,
1879    }
1880
1881    impl BufferStore for OneBufferStore {
1882        fn find_by_name(&self, _name: &str) -> Option<lattice_core::BufferId> {
1883            None
1884        }
1885        fn name_for(&self, _id: lattice_core::BufferId) -> Option<String> {
1886            None
1887        }
1888        fn handle_for(
1889            &self,
1890            id: lattice_core::BufferId,
1891        ) -> Option<Arc<dyn lattice_runtime::Document>> {
1892            (id == self.id).then(|| self.doc.clone())
1893        }
1894        fn insert_document_buffer(
1895            &self,
1896            _id: lattice_core::BufferId,
1897            _kind: lattice_core::BufferKind,
1898            _handle: Arc<dyn lattice_runtime::Document>,
1899            _flags: lattice_core::BufferFlags,
1900            _name: Option<String>,
1901        ) {
1902        }
1903    }
1904
1905    /// A view that answers only what the ladder asks it.
1906    struct StubView(Option<DiffSource>);
1907
1908    impl MagitView for StubView {
1909        fn refresh(&self) -> Option<Effect> {
1910            None
1911        }
1912        fn diff_source(&self, _cursor: Position) -> Option<DiffSource> {
1913            self.0
1914        }
1915        fn workdir(&self) -> Option<std::path::PathBuf> {
1916            Some(std::path::PathBuf::from("/tmp/repo"))
1917        }
1918    }
1919
1920    fn services_for(
1921        text: &str,
1922        source: Option<DiffSource>,
1923    ) -> (ServiceRegistry, lattice_core::BufferId) {
1924        let id = lattice_core::BufferId::next();
1925        let registry: lattice_grammar::CommandRegistryHandle = Arc::new(
1926            arc_swap::ArcSwap::from_pointee(lattice_grammar::CommandRegistry::new()),
1927        );
1928        let doc: Arc<dyn lattice_runtime::Document> = Arc::new(lattice_runtime::spawn_document(
1929            id,
1930            lattice_core::Document::from_text(text),
1931            registry,
1932        ));
1933        let store: Arc<dyn BufferStore> = Arc::new(OneBufferStore { id, doc });
1934        let views: MagitViewsHandle = Arc::new(MagitViews::default());
1935        views.publish(id, Arc::new(StubView(source)));
1936        let mut services = ServiceRegistry::new();
1937        services.register(BufferStoreHandle::new(store));
1938        services.register(views);
1939        (services, id)
1940    }
1941
1942    /// Run the ladder the way a chord press does.
1943    fn resolve(cursor_line: u32, source: Option<DiffSource>, op: HunkOp) -> HunkResolution {
1944        resolve_with_region(cursor_line, None, source, op)
1945    }
1946
1947    /// Acting on a selection in magit must END Visual mode.
1948    ///
1949    /// evil-magit deactivates the region when the command runs, and vim does
1950    /// the same for its own visual operators: you selected a thing in order to
1951    /// act on it, and once acted upon the selection has no referent. In magit
1952    /// it is worse than untidy — the action triggers a refresh that rebuilds
1953    /// the buffer, so what stays highlighted is whatever rows now happen to
1954    /// occupy those line numbers.
1955    ///
1956    /// Asserted on the COMBINATOR rather than on each of the five chords: the
1957    /// bodies are three different shapes (`stage_or_unstage`,
1958    /// `apply_or_reverse`, discard's own branch) and the wrapper is the only
1959    /// thing common to them, so it is the only place the rule can be stated
1960    /// once. A sixth content chord that forgets to wrap is caught by
1961    /// `every_content_chord_collapses_the_selection` below.
1962    #[test]
1963    fn acting_with_a_selection_collapses_it() {
1964        let (services, id) = services_for(DIFF, None);
1965        let events = lattice_runtime::EventBus::new();
1966        let ctx = ActionContext {
1967            buffer_id: lattice_protocol::ids::BufferId::new(id.0 as u64),
1968            cursor: Position::new(0, 0),
1969            selection: Some(lattice_protocol::position::Range::new(
1970                Position::new(1, 0),
1971                Position::new(3, 0),
1972            )),
1973            services: &services,
1974            events: &events,
1975            prompt_value: None,
1976            args: lattice_grammar::Args::None,
1977            buffer_locals: None,
1978        };
1979
1980        // A body that acted and had nothing to return — every mutating magit
1981        // handler's shape, since `spawn_mutation_and_refresh` returns `None`.
1982        let wrapped = consuming_selection(|_| None);
1983        assert!(
1984            matches!(
1985                wrapped(&ctx),
1986                Some(Effect::AppAction(lattice_grammar::AppEffect::ExitVisual))
1987            ),
1988            "the highlight must not outlive the rows it referred to, over a \
1989             buffer the action itself just rebuilt"
1990        );
1991    }
1992
1993    /// In Normal there is nothing to collapse, and emitting the effect anyway
1994    /// would be a no-op costing an effect round-trip on every `s`.
1995    #[test]
1996    fn acting_without_a_selection_emits_nothing() {
1997        let (services, id) = services_for(DIFF, None);
1998        let events = lattice_runtime::EventBus::new();
1999        let ctx = ActionContext {
2000            buffer_id: lattice_protocol::ids::BufferId::new(id.0 as u64),
2001            cursor: Position::new(0, 0),
2002            selection: None,
2003            services: &services,
2004            events: &events,
2005            prompt_value: None,
2006            args: lattice_grammar::Args::None,
2007            buffer_locals: None,
2008        };
2009        assert!(
2010            consuming_selection(|_| None)(&ctx).is_none(),
2011            "every one of these chords is bound in Normal too"
2012        );
2013    }
2014
2015    /// A handler that returned an effect has NOT finished, so the selection
2016    /// stays.
2017    ///
2018    /// `x` over a selection returns `Effect::Confirm` — the discard has not
2019    /// happened and the user may still answer `no`. Collapsing there would
2020    /// drop a selection they never spent, and `Option<Effect>` has no room to
2021    /// carry both. The three discard EXECUTE halves are wrapped instead, so
2022    /// the collapse lands when the work does.
2023    #[test]
2024    fn an_action_awaiting_confirmation_keeps_the_selection() {
2025        let (services, id) = services_for(DIFF, None);
2026        let events = lattice_runtime::EventBus::new();
2027        let ctx = ActionContext {
2028            buffer_id: lattice_protocol::ids::BufferId::new(id.0 as u64),
2029            cursor: Position::new(0, 0),
2030            selection: Some(lattice_protocol::position::Range::new(
2031                Position::new(1, 0),
2032                Position::new(3, 0),
2033            )),
2034            services: &services,
2035            events: &events,
2036            prompt_value: None,
2037            args: lattice_grammar::Args::None,
2038            buffer_locals: None,
2039        };
2040        let pending = || {
2041            Some(Effect::Confirm {
2042                prompt: "Discard 3 files?".to_string(),
2043                yes_action: "action:magit-discard-batch-execute".to_string(),
2044                args: lattice_grammar::Args::None,
2045            })
2046        };
2047        assert!(
2048            matches!(
2049                consuming_selection(move |_| pending())(&ctx),
2050                Some(Effect::Confirm { .. })
2051            ),
2052            "the handler's own effect survives — the collapse must not \
2053             displace the question it was asking"
2054        );
2055    }
2056
2057    /// MG.18e: the same, with a Visual-mode region live — `rows` is the
2058    /// inclusive buffer-row span the selection covers.
2059    fn resolve_with_region(
2060        cursor_line: u32,
2061        rows: Option<(u32, u32)>,
2062        source: Option<DiffSource>,
2063        op: HunkOp,
2064    ) -> HunkResolution {
2065        let (services, id) = services_for(DIFF, source);
2066        let events = lattice_runtime::EventBus::new();
2067        let ctx = ActionContext {
2068            buffer_id: lattice_protocol::ids::BufferId::new(id.0 as u64),
2069            cursor: Position::new(cursor_line, 0),
2070            selection: rows.map(|(a, b)| {
2071                lattice_protocol::position::Range::new(Position::new(a, 0), Position::new(b, 0))
2072            }),
2073            services: &services,
2074            events: &events,
2075            prompt_value: None,
2076            args: lattice_grammar::Args::None,
2077            buffer_locals: None,
2078        };
2079        resolve_hunk(&ctx, op)
2080    }
2081
2082    fn refusal_text(r: HunkResolution) -> String {
2083        match r {
2084            HunkResolution::Refused(Effect::Echo { text, .. }) => text,
2085            HunkResolution::Refused(other) => panic!("expected an Echo, got {other:?}"),
2086            HunkResolution::Ready { .. } => panic!("expected a refusal, got Ready"),
2087            HunkResolution::FileLevel => panic!("expected a refusal, got FileLevel"),
2088        }
2089    }
2090
2091    #[test]
2092    fn s_on_an_unstaged_hunk_builds_that_hunks_patch() {
2093        match resolve(IN_HUNK, Some(DiffSource::Unstaged), HunkOp::Stage) {
2094            HunkResolution::Ready { patch, workdir, .. } => {
2095                assert_eq!(workdir, std::path::PathBuf::from("/tmp/repo"));
2096                let text = patch.to_patch();
2097                assert!(text.starts_with("diff --git a/a.txt b/a.txt\n"), "{text}");
2098                assert!(text.contains("+new"), "{text}");
2099                assert!(
2100                    !text.contains("modified src/other.rs"),
2101                    "the status entry below the diff must not reach the patch:\n{text}"
2102                );
2103            }
2104            other => panic!("expected Ready, got {}", label(&other)),
2105        }
2106    }
2107
2108    /// The file-level path is what every pre-MG.18c press did, and it
2109    /// must survive: a cursor on an entry line is not in a hunk.
2110    #[test]
2111    fn a_cursor_below_the_diff_falls_through_to_file_level() {
2112        assert!(matches!(
2113            resolve(BELOW_HUNK, Some(DiffSource::Unstaged), HunkOp::Stage),
2114            HunkResolution::FileLevel
2115        ));
2116    }
2117
2118    /// Pressing `u` on an unstaged hunk would hand git a patch it
2119    /// refuses. Saying so beats `error: patch does not apply`.
2120    #[test]
2121    fn u_on_an_unstaged_hunk_is_refused_with_a_reason() {
2122        let text = refusal_text(resolve(IN_HUNK, Some(DiffSource::Staged), HunkOp::Stage));
2123        assert!(text.contains("already staged"), "{text}");
2124        let text = refusal_text(resolve(
2125            IN_HUNK,
2126            Some(DiffSource::Unstaged),
2127            HunkOp::Unstage,
2128        ));
2129        assert!(text.contains("isn't staged"), "{text}");
2130    }
2131
2132    /// The destructive one. `x` on a staged hunk must not reverse it
2133    /// out of the worktree while leaving it in the index — the change
2134    /// would vanish from the file and still be committed by `cc`.
2135    #[test]
2136    fn x_on_a_staged_hunk_refuses_rather_than_half_discarding() {
2137        let text = refusal_text(resolve(IN_HUNK, Some(DiffSource::Staged), HunkOp::Discard));
2138        assert!(
2139            text.contains("unstage it with `u` first"),
2140            "the refusal must say what to do instead: {text}"
2141        );
2142    }
2143
2144    /// `*magit:diff*` (against HEAD) mixes both sides into one hunk,
2145    /// and a commit's inline patch in magit-status belongs to neither
2146    /// tree. Refusing beats falling through — falling through would
2147    /// stage the WHOLE FILE from a keypress aimed at one hunk.
2148    #[test]
2149    fn an_unclassifiable_diff_refuses_hunk_staging_instead_of_staging_the_file() {
2150        let text = refusal_text(resolve(IN_HUNK, None, HunkOp::Stage));
2151        assert!(text.contains("isn't available in this view"), "{text}");
2152        assert!(
2153            text.contains("file header"),
2154            "and must point at the way to stage the file deliberately: {text}"
2155        );
2156    }
2157
2158    // ── MG.23g: the committed-hunk pair ──
2159
2160    /// `a` / `-` are the only ops a committed patch accepts, and the
2161    /// only ops that accept one. Both directions of the gate, because
2162    /// getting either wrong hands git a patch it refuses.
2163    #[test]
2164    fn only_apply_and_reverse_act_on_a_committed_hunk() {
2165        for op in [HunkOp::Apply, HunkOp::Reverse] {
2166            assert!(
2167                matches!(
2168                    resolve(IN_HUNK, Some(DiffSource::Committed), op),
2169                    HunkResolution::Ready { .. }
2170                ),
2171                "{op:?} must act on a committed hunk"
2172            );
2173        }
2174        for op in [HunkOp::Stage, HunkOp::Unstage, HunkOp::Discard] {
2175            let text = refusal_text(resolve(IN_HUNK, Some(DiffSource::Committed), op));
2176            assert!(
2177                !text.is_empty(),
2178                "{op:?} on a commit's patch must refuse with a reason"
2179            );
2180        }
2181    }
2182
2183    /// And the mirror: pressing `a` at a working-tree hunk says the
2184    /// change is already there rather than applying it twice.
2185    #[test]
2186    fn apply_on_a_working_tree_hunk_says_the_change_is_already_there() {
2187        let text = refusal_text(resolve(IN_HUNK, Some(DiffSource::Unstaged), HunkOp::Apply));
2188        assert!(text.contains("already in the working tree"), "{text}");
2189        let text = refusal_text(resolve(
2190            IN_HUNK,
2191            Some(DiffSource::Unstaged),
2192            HunkOp::Reverse,
2193        ));
2194        assert!(
2195            text.contains("`x` discards"),
2196            "the refusal must name the key that does this to a \
2197             working-tree change: {text}"
2198        );
2199    }
2200
2201    /// Both write to the working tree and neither touches the index —
2202    /// the whole point of `a` being different from `s`. A `cached`
2203    /// slip would stage a commit's hunk invisibly.
2204    #[test]
2205    fn neither_committed_op_touches_the_index() {
2206        assert_eq!(HunkOp::Apply.apply_flags(), (false, false));
2207        assert_eq!(HunkOp::Reverse.apply_flags(), (false, true));
2208    }
2209
2210    /// `a` / `-` have no file-level fallback, and must SAY so rather
2211    /// than returning `None`: a Normal-mode chord a mode binds is
2212    /// consumed unconditionally, so a bare `None` is a key that
2213    /// visibly does nothing.
2214    ///
2215    /// The alternative — falling through — would turn a missed cursor
2216    /// into a whole-commit cherry-pick or revert.
2217    #[test]
2218    fn apply_outside_a_hunk_explains_itself_rather_than_doing_nothing() {
2219        for (op, word) in [(HunkOp::Apply, "apply"), (HunkOp::Reverse, "reverse")] {
2220            let (services, id) = services_for(DIFF, Some(DiffSource::Committed));
2221            let events = lattice_runtime::EventBus::new();
2222            let ctx = ActionContext {
2223                buffer_id: lattice_protocol::ids::BufferId::new(id.0 as u64),
2224                cursor: Position::new(BELOW_HUNK, 0),
2225                selection: None,
2226                services: &services,
2227                events: &events,
2228                prompt_value: None,
2229                args: lattice_grammar::Args::None,
2230                buffer_locals: None,
2231            };
2232            match apply_or_reverse(&ctx, op) {
2233                Some(Effect::Echo { text, .. }) => {
2234                    assert!(text.contains(word) && text.contains("hunk"), "{text}")
2235                }
2236                other => panic!("expected an explained refusal, got {other:?}"),
2237            }
2238        }
2239    }
2240
2241    // ── MG.18e: the region path through a real buffer ──
2242    //
2243    // `DIFF`'s body is row 5 ` keep`, row 6 `-old`, row 7 `+new`.
2244
2245    /// A region over one changed line narrows the patch to it and
2246    /// reports the count, so the echo cannot imply more than happened.
2247    #[test]
2248    fn a_region_over_one_line_narrows_the_patch_and_counts_it() {
2249        match resolve_with_region(7, Some((7, 7)), Some(DiffSource::Unstaged), HunkOp::Stage) {
2250            HunkResolution::Ready {
2251                patch,
2252                region_lines,
2253                ..
2254            } => {
2255                assert_eq!(region_lines, Some(1), "one changed line selected");
2256                let text = patch.to_patch();
2257                assert!(text.contains("+new"), "{text}");
2258                assert!(
2259                    text.contains(" old"),
2260                    "the unselected removal became context, not a deletion:\n{text}"
2261                );
2262                assert!(
2263                    !text.contains("-old"),
2264                    "and must NOT still be a removal:\n{text}"
2265                );
2266            }
2267            other => panic!("expected Ready, got {}", label(&other)),
2268        }
2269    }
2270
2271    /// A region covering the whole body is not a special case — it must
2272    /// produce the identical whole-hunk patch, with no region reported,
2273    /// so `V` over a hunk and a bare `s` on it cannot diverge.
2274    #[test]
2275    fn a_region_covering_the_whole_hunk_is_the_whole_hunk() {
2276        let whole = match resolve(6, Some(DiffSource::Unstaged), HunkOp::Stage) {
2277            HunkResolution::Ready { patch, .. } => patch.to_patch(),
2278            other => panic!("expected Ready, got {}", label(&other)),
2279        };
2280        match resolve_with_region(6, Some((5, 7)), Some(DiffSource::Unstaged), HunkOp::Stage) {
2281            HunkResolution::Ready {
2282                patch,
2283                region_lines,
2284                ..
2285            } => {
2286                assert_eq!(patch.to_patch(), whole);
2287                assert_eq!(
2288                    region_lines, None,
2289                    "no region to announce — this IS the hunk"
2290                );
2291            }
2292            other => panic!("expected Ready, got {}", label(&other)),
2293        }
2294    }
2295
2296    /// Selecting only context is refused with a reason, not handed to
2297    /// git as a patch that does nothing.
2298    #[test]
2299    fn a_region_holding_only_context_is_refused() {
2300        let text = refusal_text(resolve_with_region(
2301            5,
2302            Some((5, 5)),
2303            Some(DiffSource::Unstaged),
2304            HunkOp::Stage,
2305        ));
2306        assert!(text.contains("nothing to stage in the selection"), "{text}");
2307    }
2308
2309    /// The refusal for an empty selection comes BEFORE the staged/unstaged
2310    /// gate: the user picked those rows deliberately, and "there is
2311    /// nothing there" is more useful than a lecture about which side of
2312    /// the index they are on.
2313    #[test]
2314    fn an_empty_region_is_answered_before_the_source_gate() {
2315        let text = refusal_text(resolve_with_region(
2316            5,
2317            Some((5, 5)),
2318            // Wrong side for `s` — which would normally refuse first.
2319            Some(DiffSource::Staged),
2320            HunkOp::Stage,
2321        ));
2322        assert!(
2323            text.contains("nothing to stage in the selection"),
2324            "the selection is answered first: {text}"
2325        );
2326    }
2327
2328    /// A region outside the hunk entirely leaves nothing selected inside
2329    /// it, so the operation declines rather than silently acting on the
2330    /// whole hunk.
2331    #[test]
2332    fn a_region_that_misses_the_hunk_body_is_refused() {
2333        let text = refusal_text(resolve_with_region(
2334            6,
2335            // Rows 0..=2 are the `diff --git` / `index` / `---` header.
2336            Some((0, 2)),
2337            Some(DiffSource::Unstaged),
2338            HunkOp::Stage,
2339        ));
2340        assert!(text.contains("nothing to stage in the selection"), "{text}");
2341    }
2342
2343    /// A region action ends Visual mode, like any vim operator on a
2344    /// selection — and the echo says how many lines moved.
2345    #[test]
2346    fn acting_on_a_region_leaves_visual_mode_and_names_the_count() {
2347        match announce(HunkOp::Stage, "a.txt:1", Some(2)) {
2348            Effect::Many(parts) => {
2349                assert!(
2350                    matches!(
2351                        parts.first(),
2352                        Some(Effect::EnterMode(lattice_grammar::ModalState::Normal))
2353                    ),
2354                    "Visual ends first, so the echo is what the user is left looking at"
2355                );
2356                match parts.get(1) {
2357                    Some(Effect::Echo { text, .. }) => {
2358                        assert!(text.contains("staged 2 lines of a.txt:1"), "{text}")
2359                    }
2360                    other => panic!("expected an Echo, got {other:?}"),
2361                }
2362            }
2363            other => panic!("expected Many, got {other:?}"),
2364        }
2365    }
2366
2367    /// A whole-hunk press was never in Visual mode, so it must not emit
2368    /// a mode change — that would exit Visual for an unrelated reason if
2369    /// the user happened to be in it.
2370    #[test]
2371    fn a_whole_hunk_action_only_echoes() {
2372        match announce(HunkOp::Unstage, "a.txt:1", None) {
2373            Effect::Echo { text, .. } => {
2374                assert!(text.contains("unstaged hunk at a.txt:1"), "{text}")
2375            }
2376            other => panic!("expected a bare Echo, got {other:?}"),
2377        }
2378    }
2379
2380    /// One line reads as "1 line", not "1 lines".
2381    #[test]
2382    fn a_single_line_region_is_announced_in_the_singular() {
2383        match announce(HunkOp::Discard, "a.txt:9", Some(1)) {
2384            Effect::Many(parts) => match parts.get(1) {
2385                Some(Effect::Echo { text, .. }) => {
2386                    assert!(text.contains("discarded 1 line of"), "{text}")
2387                }
2388                other => panic!("expected an Echo, got {other:?}"),
2389            },
2390            other => panic!("expected Many, got {other:?}"),
2391        }
2392    }
2393
2394    fn label(r: &HunkResolution) -> &'static str {
2395        match r {
2396            HunkResolution::Ready { .. } => "Ready",
2397            HunkResolution::Refused(_) => "Refused",
2398            HunkResolution::FileLevel => "FileLevel",
2399        }
2400    }
2401
2402    /// The flag table is the whole safety contract of the three
2403    /// operations: a wrong pair silently mutates the wrong tree.
2404    #[test]
2405    fn the_apply_flag_table_is_the_documented_one() {
2406        assert_eq!(HunkOp::Stage.apply_flags(), (true, false), "index, forward");
2407        assert_eq!(
2408            HunkOp::Unstage.apply_flags(),
2409            (true, true),
2410            "index, reversed"
2411        );
2412        assert_eq!(
2413            HunkOp::Discard.apply_flags(),
2414            (false, true),
2415            "the WORKTREE reversed — `--cached` here would discard from the index instead, \
2416             leaving the worktree edit in place and staging its removal"
2417        );
2418    }
2419}
2420
2421/// MG.18c — the discard flags against real git.
2422///
2423/// `hunk.rs`'s round-trips prove the *patch* is one git accepts for
2424/// stage and unstage. This proves the third pairing, which is the one
2425/// with no second chance: `x` must reverse the hunk out of the
2426/// **working tree** and leave the index alone. `--cached` here would
2427/// stage the removal instead, which reads on screen as the discard
2428/// having worked while the change is still queued for the next commit.
2429#[cfg(test)]
2430mod discard_round_trip {
2431    use super::*;
2432    use std::process::Command;
2433
2434    fn git_ok(dir: &std::path::Path, args: &[&str]) {
2435        let st = Command::new("git")
2436            .args(args)
2437            .current_dir(dir)
2438            .status()
2439            .expect("git");
2440        assert!(st.success(), "git {args:?} failed");
2441    }
2442
2443    fn git_out(dir: &std::path::Path, args: &[&str]) -> String {
2444        let out = Command::new("git")
2445            .args(args)
2446            .current_dir(dir)
2447            .output()
2448            .expect("git");
2449        String::from_utf8_lossy(&out.stdout).into_owned()
2450    }
2451
2452    #[test]
2453    fn discard_reverses_the_hunk_out_of_the_worktree_and_leaves_the_index_alone() {
2454        let dir = tempfile::tempdir().expect("tempdir");
2455        let p = dir.path();
2456        git_ok(p, &["init"]);
2457        git_ok(p, &["config", "user.email", "t@lattice.dev"]);
2458        git_ok(p, &["config", "user.name", "lattice-test"]);
2459        let base: String = (1..=20).map(|i| format!("line {i}\n")).collect();
2460        std::fs::write(p.join("a.txt"), &base).unwrap();
2461        git_ok(p, &["add", "a.txt"]);
2462        git_ok(p, &["commit", "-m", "base"]);
2463        // Two changes far enough apart that git reports two hunks.
2464        let edited: String = (1..=20)
2465            .map(|i| match i {
2466                2 => "line 2 EDITED\n".to_string(),
2467                19 => "line 19 EDITED\n".to_string(),
2468                _ => format!("line {i}\n"),
2469            })
2470            .collect();
2471        std::fs::write(p.join("a.txt"), &edited).unwrap();
2472
2473        let diff = git_out(p, &["diff", "--", "a.txt"]);
2474        let lines: Vec<&str> = diff.lines().collect();
2475        let first_hunk = lines
2476            .iter()
2477            .position(|l| l.starts_with("@@ "))
2478            .expect("a hunk header");
2479        let patch =
2480            crate::hunk::hunk_at_with(|i| lines.get(i).map(|l| (*l).to_string()), first_hunk + 1)
2481                .expect("cursor inside hunk 1")
2482                .to_patch();
2483
2484        let (cached, reverse) = HunkOp::Discard.apply_flags();
2485        let repo = lattice_vcs::Repository::discover(p).expect("discover");
2486        lattice_vcs::Index::apply_patch(&repo, &patch, cached, reverse).expect("discard applies");
2487
2488        let worktree = std::fs::read_to_string(p.join("a.txt")).unwrap();
2489        assert!(
2490            !worktree.contains("line 2 EDITED"),
2491            "the discarded hunk is gone from the file:\n{worktree}"
2492        );
2493        assert!(
2494            worktree.contains("line 19 EDITED"),
2495            "the neighbouring hunk survives:\n{worktree}"
2496        );
2497        assert_eq!(
2498            git_out(p, &["diff", "--cached", "--name-only"]).trim(),
2499            "",
2500            "discard must not touch the index"
2501        );
2502    }
2503}
2504
2505// ── MG.34: `gM` — the merge that brought a commit into HEAD ──────────
2506
2507/// The argv for "which merge introduced `sha` into HEAD".
2508///
2509/// Pure, so the flags and their order are pinned without a repository —
2510/// the same shape `blame_argv` / `run_diff_argv` / `tag_argv` already
2511/// have.
2512///
2513/// `--ancestry-path` restricts the walk to commits that are both
2514/// descendants of `sha` and ancestors of HEAD, `--merges` keeps only
2515/// merge commits, and `--reverse` puts the **oldest first**. The oldest
2516/// merge on that path is the one that brought `sha` in; later ones
2517/// merely carried it along, and reporting one of those would answer a
2518/// question nobody asked.
2519pub(crate) fn log_merged_argv(sha: &str) -> Vec<String> {
2520    vec![
2521        "log".to_string(),
2522        "--merges".to_string(),
2523        "--ancestry-path".to_string(),
2524        "--reverse".to_string(),
2525        "--format=%H".to_string(),
2526        format!("{sha}..HEAD"),
2527    ]
2528}
2529
2530/// Resolve the merge commit that introduced `sha` into HEAD.
2531///
2532/// `None` when nothing merged it — which is the ordinary answer for a
2533/// commit made directly on the current branch, not an error. The caller
2534/// says so rather than showing an empty buffer.
2535///
2536/// Blocking; call on `spawn_blocking`.
2537pub(crate) fn resolve_merge_commit(workdir: &std::path::Path, sha: &str) -> Option<String> {
2538    let repo = lattice_vcs::Repository::discover(workdir).ok()?;
2539    let lines = repo.run_git_lines(log_merged_argv(sha)).ok()?;
2540    lines.into_iter().next()
2541}
2542
2543/// MG.34: the log-merged walk. Consumed by `magit_revision_mode`'s
2544/// `*magit:merged:<sha>*` name form, which runs it inside the
2545/// `spawn_blocking` it already had — see that module for why the
2546/// question, not the answer, goes in the buffer name.
2547#[cfg(test)]
2548mod log_merged {
2549    use super::*;
2550    use std::path::Path;
2551    use std::process::Command;
2552
2553    fn git(dir: &Path, args: &[&str]) -> String {
2554        let out = Command::new("git")
2555            .args(args)
2556            .current_dir(dir)
2557            .output()
2558            .expect("git");
2559        assert!(
2560            out.status.success(),
2561            "git {args:?}: {}",
2562            String::from_utf8_lossy(&out.stderr)
2563        );
2564        String::from_utf8_lossy(&out.stdout).trim().to_string()
2565    }
2566
2567    /// The flags are the whole correctness argument, so they are pinned
2568    /// without needing a repository — `--reverse` in particular, since
2569    /// dropping it silently returns the *newest* merge instead of the
2570    /// one that introduced the commit, which is a plausible-looking
2571    /// wrong answer.
2572    #[test]
2573    fn the_argv_walks_oldest_first_along_the_ancestry_path() {
2574        let argv = log_merged_argv("abc123");
2575        assert_eq!(
2576            argv,
2577            vec![
2578                "log",
2579                "--merges",
2580                "--ancestry-path",
2581                "--reverse",
2582                "--format=%H",
2583                "abc123..HEAD",
2584            ]
2585        );
2586    }
2587
2588    /// A commit merged in from a side branch resolves to the merge
2589    /// commit, not to itself and not to HEAD.
2590    #[test]
2591    fn a_side_branch_commit_resolves_to_the_merge_that_brought_it_in() {
2592        let dir = tempfile::tempdir().expect("tempdir");
2593        let p = dir.path();
2594        git(p, &["init", "-b", "main"]);
2595        git(p, &["config", "user.email", "t@lattice.dev"]);
2596        git(p, &["config", "user.name", "lattice-test"]);
2597        std::fs::write(p.join("a.txt"), "base\n").expect("write");
2598        git(p, &["add", "a.txt"]);
2599        git(p, &["commit", "-m", "base"]);
2600
2601        git(p, &["checkout", "-b", "side"]);
2602        std::fs::write(p.join("b.txt"), "side\n").expect("write");
2603        git(p, &["add", "b.txt"]);
2604        git(p, &["commit", "-m", "on side"]);
2605        let side = git(p, &["rev-parse", "HEAD"]);
2606
2607        git(p, &["checkout", "main"]);
2608        git(p, &["merge", "--no-ff", "side", "-m", "merge side"]);
2609        let merge = git(p, &["rev-parse", "HEAD"]);
2610
2611        assert_eq!(
2612            resolve_merge_commit(p, &side),
2613            Some(merge.clone()),
2614            "must name the merge commit, not the side commit or HEAD"
2615        );
2616        assert_ne!(resolve_merge_commit(p, &side), Some(side));
2617    }
2618
2619    /// A commit made straight onto the branch was never merged in.
2620    /// `None` is the ordinary answer, not a failure — the caller says
2621    /// so rather than opening an empty buffer.
2622    #[test]
2623    fn a_mainline_commit_has_no_merge_and_that_is_not_an_error() {
2624        let dir = tempfile::tempdir().expect("tempdir");
2625        let p = dir.path();
2626        git(p, &["init", "-b", "main"]);
2627        git(p, &["config", "user.email", "t@lattice.dev"]);
2628        git(p, &["config", "user.name", "lattice-test"]);
2629        std::fs::write(p.join("a.txt"), "base\n").expect("write");
2630        git(p, &["add", "a.txt"]);
2631        git(p, &["commit", "-m", "base"]);
2632        let first = git(p, &["rev-parse", "HEAD"]);
2633        std::fs::write(p.join("a.txt"), "more\n").expect("write");
2634        git(p, &["add", "a.txt"]);
2635        git(p, &["commit", "-m", "second"]);
2636
2637        assert_eq!(resolve_merge_commit(p, &first), None);
2638    }
2639}