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}