Skip to main content

lattice_magit/
transients.rs

1//! MG.8: magit transient menu definitions.
2//!
3//! Defines the `TransientSpec` instances for the repo-level
4//! dispatch (C-c g) and file-level dispatch (C-c f) menus.
5//! Each is a grouped action menu rendered by the PICK.1
6//! transient picker overlay.
7
8use std::sync::Arc;
9
10use lattice_picker::{
11    TransientContext, TransientGroup, TransientItem, TransientItemKind, TransientSpec,
12    TransientState, TransientValue,
13};
14use lattice_protocol::ids::CommandId;
15
16use crate::magit_global_mode::RemoteOp;
17
18/// MG.41a: every registered magit action, keyed by its registered name.
19///
20/// Replaces the per-row `Option<CommandId>` struct field. Adding a
21/// transient row used to mean editing four places that had to stay in
22/// sync — `register_action_commands`, a struct field, a
23/// `resolve_dispatch_ids` line, and the builder — none of which failed
24/// to compile when they drifted; the row just silently rendered as a
25/// disabled placeholder. With rows naming their command directly, two
26/// of those four disappear.
27///
28/// **Resolution is automatic.** `resolve` scans the registry for every
29/// `action:magit-` name rather than reading a hand-kept list, so there
30/// is no third enumeration hiding here either: registering an action is
31/// the only step, and a row referencing it works immediately.
32///
33/// The cost is losing compile-time field checking. Mitigated the way
34/// this repo already mitigates it for the `<C-h>` help prefix: a test
35/// asserts every name any row references actually resolves, so drift
36/// fails loudly instead of rendering a placeholder.
37#[derive(Debug, Clone, Default)]
38pub struct MagitActionIds {
39    by_name: std::collections::HashMap<String, CommandId>,
40}
41
42impl MagitActionIds {
43    /// Prefix every magit action shares. Anything registered under it
44    /// is reachable from a transient row without further bookkeeping.
45    const PREFIX: &'static str = "action:magit-";
46
47    pub fn resolve(registry: &lattice_grammar::CommandRegistry) -> Self {
48        let names: Vec<String> = registry
49            .names()
50            .filter(|n| n.starts_with(Self::PREFIX))
51            .map(str::to_string)
52            .collect();
53        let by_name = names
54            .into_iter()
55            .filter_map(|n| registry.id_by_name(&n).map(|id| (n, id)))
56            .collect();
57        Self { by_name }
58    }
59
60    pub fn get(&self, name: &str) -> Option<CommandId> {
61        self.by_name.get(name).copied()
62    }
63
64    pub fn is_empty(&self) -> bool {
65        self.by_name.is_empty()
66    }
67}
68
69/// MG.41a: one transient row, as data.
70///
71/// `action` is the registered command name — the single place a row's
72/// behaviour is identified. `placeholder` is the disabled-row marker
73/// shown when the action is missing, kept per-row so an unresolved row
74/// is still visually distinct from its neighbours.
75pub struct TransientRow {
76    pub key: &'static str,
77    pub label: &'static str,
78    pub doc: &'static str,
79    pub action: &'static str,
80    pub placeholder: &'static str,
81}
82
83/// Build a row from the table entry, degrading to a disabled
84/// placeholder when its action is not registered.
85fn row_item(ids: &MagitActionIds, row: &TransientRow) -> TransientItem {
86    action_or_placeholder(
87        ids.get(row.action),
88        row.key,
89        row.label,
90        row.doc,
91        row.placeholder,
92    )
93}
94
95/// Build a whole group from a static table — the shape every
96/// non-gated transient group now uses.
97fn row_group(label: &str, ids: &MagitActionIds, rows: &'static [TransientRow]) -> TransientGroup {
98    TransientGroup {
99        label: label.into(),
100        items: rows.iter().map(|r| row_item(ids, r)).collect(),
101    }
102}
103
104/// Every static row table in this module, for the drift tests.
105///
106/// A table not listed here is not covered by
107/// `every_row_action_is_registered`, so new tables must be added — the
108/// one piece of bookkeeping this design keeps, and the test below
109/// makes forgetting it visible by counting.
110#[cfg(test)]
111pub(crate) fn all_row_tables() -> &'static [(&'static str, &'static [TransientRow])] {
112    &[
113        ("branch/checkout", BRANCH_CHECKOUT_ROWS),
114        ("branch/create", BRANCH_CREATE_ROWS),
115        ("branch/do", BRANCH_DO_ROWS),
116        ("reset", RESET_ROWS),
117        ("commit", COMMIT_ROWS),
118        ("stash", STASH_ROWS),
119        ("subtree", SUBTREE_ROWS),
120        ("jump", JUMP_ROWS),
121        ("push", PUSH_ROWS),
122        ("pull", PULL_ROWS),
123        ("fetch", FETCH_ROWS),
124        ("cherry-pick", CHERRY_PICK_ROWS),
125        ("cherry-pick/sequence", CHERRY_PICK_SEQUENCE_ROWS),
126        ("revert", REVERT_ROWS),
127        ("revert/sequence", REVERT_SEQUENCE_ROWS),
128        ("merge", MERGE_ROWS),
129        ("merge/sequence", MERGE_SEQUENCE_ROWS),
130        ("tag", TAG_ROWS),
131        ("rebase/start", REBASE_START_ROWS),
132        ("rebase/sequence", REBASE_SEQUENCE_ROWS),
133        // PD.3 (2026-08-12): these two were never listed, so the drift
134        // tests below had never covered the Diff or Log menus — `d`,
135        // `f` and `v` included. Exactly the bookkeeping lapse this
136        // function's doc comment warns about, found by adding a fourth
137        // Diff row and noticing nothing checked it.
138        ("diff/show", DIFF_SHOW_ROWS),
139        ("log/show", LOG_SHOW_ROWS),
140    ]
141}
142
143// MG.41c: magit's destination rows. Keys are magit's own.
144
145const PUSH_ROWS: &[TransientRow] = &[
146    TransientRow {
147        key: "p",
148        label: "pushRemote",
149        doc: "Push to the configured push-remote",
150        action: "action:magit-global-push-configured",
151        placeholder: "push_configured_op",
152    },
153    TransientRow {
154        key: "u",
155        label: "@{upstream}",
156        doc: "Push to this branch's upstream — differs from pushRemote in a triangular workflow",
157        action: "action:magit-global-push-upstream",
158        placeholder: "push_upstream_op",
159    },
160    TransientRow {
161        key: "e",
162        label: "elsewhere",
163        doc: "Push to a remote you name",
164        action: "action:magit-global-push-elsewhere",
165        placeholder: "push_elsewhere_op",
166    },
167    TransientRow {
168        key: "o",
169        label: "another branch",
170        doc: "Push a branch other than HEAD",
171        action: "action:magit-global-push-other-branch",
172        placeholder: "push_other_op",
173    },
174    TransientRow {
175        key: "r",
176        label: "refspecs",
177        doc: "Push explicit refspecs",
178        action: "action:magit-global-push-refspecs",
179        placeholder: "push_refspecs_op",
180    },
181    TransientRow {
182        key: "T",
183        label: "a tag",
184        doc: "Push a single tag",
185        action: "action:magit-global-push-tag",
186        placeholder: "push_tag_op",
187    },
188    TransientRow {
189        key: "t",
190        label: "all tags",
191        doc: "Push every tag",
192        action: "action:magit-global-push-all-tags",
193        placeholder: "push_all_tags_op",
194    },
195];
196
197const PULL_ROWS: &[TransientRow] = &[
198    TransientRow {
199        key: "p",
200        label: "pushRemote",
201        doc: "Pull from the configured remote",
202        action: "action:magit-global-pull-configured",
203        placeholder: "pull_configured_op",
204    },
205    TransientRow {
206        key: "u",
207        label: "@{upstream}",
208        doc: "Pull from this branch's upstream",
209        action: "action:magit-global-pull-upstream",
210        placeholder: "pull_upstream_op",
211    },
212    TransientRow {
213        key: "e",
214        label: "elsewhere",
215        doc: "Pull from a remote you name",
216        action: "action:magit-global-pull-elsewhere",
217        placeholder: "pull_elsewhere_op",
218    },
219];
220
221const FETCH_ROWS: &[TransientRow] = &[
222    TransientRow {
223        key: "p",
224        label: "pushRemote",
225        doc: "Fetch from the configured remote",
226        action: "action:magit-global-fetch-configured",
227        placeholder: "fetch_configured_op",
228    },
229    TransientRow {
230        key: "u",
231        label: "@{upstream}",
232        doc: "Fetch this branch's upstream",
233        action: "action:magit-global-fetch-upstream",
234        placeholder: "fetch_upstream_op",
235    },
236    TransientRow {
237        key: "e",
238        label: "elsewhere",
239        doc: "Fetch from a remote you name",
240        action: "action:magit-global-fetch-elsewhere",
241        placeholder: "fetch_elsewhere_op",
242    },
243    TransientRow {
244        key: "o",
245        label: "another branch",
246        doc: "Fetch a branch you name",
247        action: "action:magit-global-fetch-other-branch",
248        placeholder: "fetch_other_op",
249    },
250    TransientRow {
251        key: "r",
252        label: "refspecs",
253        doc: "Fetch explicit refspecs",
254        action: "action:magit-global-fetch-refspecs",
255        placeholder: "fetch_refspecs_op",
256    },
257    TransientRow {
258        key: "a",
259        label: "all remotes",
260        doc: "Fetch from every configured remote",
261        action: "action:magit-global-fetch-all-remotes",
262        placeholder: "fetch_all_op",
263    },
264    // MG.43f: magit's `m` — fetch submodules alongside the superproject.
265    TransientRow {
266        key: "m",
267        label: "submodules",
268        doc: "Fetch the superproject and its submodules",
269        action: "action:magit-global-fetch-submodules",
270        placeholder: "fetch_submodules_op",
271    },
272];
273
274// ---- MG.41a: static row tables ----
275//
276// One entry per row. Keys are magit's own — inside a transient the menu
277// owns every keystroke, so there is no vim-grammar conflict to dodge
278// (see the slice plan's scoping note).
279
280const BRANCH_CHECKOUT_ROWS: &[TransientRow] = &[
281    TransientRow {
282        key: "b",
283        label: "branch/revision",
284        doc: "Check out anything git can: a branch, tag, remote ref or SHA",
285        action: "action:magit-global-branch-checkout-rev",
286        placeholder: "branch_checkout_rev_op",
287    },
288    TransientRow {
289        key: "l",
290        label: "local branch",
291        doc: "Pick a local branch and check it out",
292        action: "action:magit-global-branch-checkout",
293        placeholder: "branch_checkout_op",
294    },
295];
296
297const BRANCH_CREATE_ROWS: &[TransientRow] = &[
298    TransientRow {
299        key: "c",
300        label: "new branch and checkout",
301        doc: "Pick a base, then name a new branch and check it out",
302        action: "action:magit-global-branch-create",
303        placeholder: "branch_create_op",
304    },
305    TransientRow {
306        key: "n",
307        label: "new branch",
308        doc: "Pick a base, then name a new branch — without checking it out",
309        action: "action:magit-global-branch-create-no-checkout",
310        placeholder: "branch_create_no_checkout_op",
311    },
312];
313
314const BRANCH_DO_ROWS: &[TransientRow] = &[
315    // MG.43d: magit's `s` / `S` — a branch from the unpushed commits.
316    // The pair differs only in where you end up.
317    TransientRow {
318        key: "s",
319        label: "spin-off",
320        doc: "Branch the unpushed commits and check it out",
321        action: "action:magit-global-branch-spinoff",
322        placeholder: "branch_spinoff_op",
323    },
324    TransientRow {
325        key: "S",
326        label: "spin-out",
327        doc: "Branch the unpushed commits, staying on this branch",
328        action: "action:magit-global-branch-spinout",
329        placeholder: "branch_spinout_op",
330    },
331    // MG.43a: magit's `x` — reset this branch to another ref.
332    TransientRow {
333        key: "x",
334        label: "reset",
335        doc: "Reset the current branch to another ref (asks first)",
336        action: "action:magit-global-branch-reset",
337        placeholder: "branch_reset_op",
338    },
339    TransientRow {
340        key: "m",
341        label: "rename",
342        doc: "Pick a branch, then type its new name",
343        action: "action:magit-global-branch-rename",
344        placeholder: "branch_rename_op",
345    },
346    // MG.41a: magit's own keys. `k` deletes; `x` is reset (MG.41d adds
347    // it). Before this, `x` deleted — putting the destructive
348    // operation where a magit user expects reset.
349    TransientRow {
350        key: "k",
351        label: "delete",
352        doc: "Pick a branch to delete — asks first",
353        action: "action:magit-global-branch-delete",
354        placeholder: "branch_delete_op",
355    },
356    TransientRow {
357        key: "L",
358        label: "list",
359        doc: "Open the branch list buffer",
360        action: "action:magit-global-branch",
361        placeholder: "branch_op",
362    },
363];
364
365const RESET_ROWS: &[TransientRow] = &[
366    TransientRow {
367        key: "s",
368        label: "soft",
369        doc: "Move HEAD, keep the index and working tree",
370        action: "action:magit-reset-soft",
371        placeholder: "reset_soft_op",
372    },
373    TransientRow {
374        key: "m",
375        label: "mixed",
376        doc: "Move HEAD and reset the index, keep the working tree",
377        action: "action:magit-reset-mixed",
378        placeholder: "reset_mixed_op",
379    },
380    TransientRow {
381        key: "h",
382        label: "hard",
383        doc: "Move HEAD and discard index + working-tree changes",
384        action: "action:magit-reset-hard",
385        placeholder: "reset_hard_op",
386    },
387    // MG.41d: magit's own keys for the rest of the modes.
388    TransientRow {
389        key: "k",
390        label: "keep",
391        doc: "Move HEAD but refuse if that would discard uncommitted work",
392        action: "action:magit-reset-keep",
393        placeholder: "reset_keep_op",
394    },
395    TransientRow {
396        key: "i",
397        label: "index",
398        doc: "Set the index to a commit without moving HEAD or touching the working tree",
399        action: "action:magit-reset-index",
400        placeholder: "reset_index_op",
401    },
402    // MG.43f: magit's `w` — the working tree only; HEAD and the index
403    // are left alone.
404    TransientRow {
405        key: "w",
406        label: "worktree",
407        doc: "Reset the working tree to a commit, keeping HEAD and the index",
408        action: "action:magit-reset-worktree",
409        placeholder: "reset_worktree_op",
410    },
411    // MG.42-E3: two inputs — the commit, then the path.
412    TransientRow {
413        key: "f",
414        label: "a file",
415        doc: "Restore one file from a commit, leaving everything else alone",
416        action: "action:magit-global-reset-file",
417        placeholder: "reset_file_op",
418    },
419];
420
421const COMMIT_ROWS: &[TransientRow] = &[
422    TransientRow {
423        key: "c",
424        label: "commit",
425        doc: "Commit the staged changes",
426        action: "action:magit-global-commit",
427        placeholder: "commit_op",
428    },
429    TransientRow {
430        key: "a",
431        label: "amend",
432        doc: "Amend the previous commit",
433        action: "action:magit-global-amend",
434        placeholder: "amend_op",
435    },
436    // MG.41d: the autosquash pair, on magit's own keys. Both record a
437    // marker commit a later `rebase --autosquash` folds in — `fixup`
438    // discards its message, `squash` keeps it for editing.
439    TransientRow {
440        key: "f",
441        label: "fixup",
442        doc: "Record a fixup! commit for a commit you pick",
443        action: "action:magit-commit-fixup",
444        placeholder: "commit_fixup_op",
445    },
446    TransientRow {
447        key: "s",
448        label: "squash",
449        doc: "Record a squash! commit for a commit you pick",
450        action: "action:magit-commit-squash",
451        placeholder: "commit_squash_op",
452    },
453    // MG.42-E1: reword — message only. Deliberately NOT amend: a
454    // reword that swept in staged changes would be a content change
455    // nobody asked for.
456    TransientRow {
457        key: "w",
458        label: "reword",
459        doc: "Change the last commit's message, leaving the index alone",
460        action: "action:magit-global-reword",
461        placeholder: "commit_reword_op",
462    },
463    // MG.43a: magit's `e` — the one commit row that takes no target.
464    TransientRow {
465        key: "e",
466        label: "extend",
467        doc: "Add staged changes to the last commit, keeping its message",
468        action: "action:magit-global-commit-extend",
469        placeholder: "commit_extend_op",
470    },
471    // MG.42-E1: augment — a squash marker you annotate.
472    TransientRow {
473        key: "A",
474        label: "augment",
475        doc: "Record a squash! for a commit, with a note you write",
476        action: "action:magit-commit-augment",
477        placeholder: "commit_augment_op",
478    },
479    // MG.42-E2: magit's instant variants — record AND fold in.
480    TransientRow {
481        key: "F",
482        label: "instant fixup",
483        doc: "Record a fixup! and fold it in immediately",
484        action: "action:magit-commit-instant-fixup",
485        placeholder: "commit_instant_fixup_op",
486    },
487    TransientRow {
488        key: "S",
489        label: "instant squash",
490        doc: "Record a squash! and fold it in immediately",
491        action: "action:magit-commit-instant-squash",
492        placeholder: "commit_instant_squash_op",
493    },
494];
495
496const STASH_ROWS: &[TransientRow] = &[
497    TransientRow {
498        key: "z",
499        label: "stash",
500        doc: "Stash the working tree and index",
501        action: "action:magit-global-stash-create",
502        placeholder: "stash_create_op",
503    },
504    TransientRow {
505        key: "l",
506        label: "list",
507        doc: "Open the stash list buffer",
508        action: "action:magit-global-stash",
509        placeholder: "stash_op",
510    },
511    // MG.41d: magit's other stash-creation variants.
512    TransientRow {
513        key: "i",
514        label: "index",
515        doc: "Stash only the staged changes",
516        action: "action:magit-global-stash-staged",
517        placeholder: "stash_staged_op",
518    },
519    TransientRow {
520        key: "x",
521        label: "keeping index",
522        doc: "Stash everything but leave the index staged",
523        action: "action:magit-global-stash-keep-index",
524        placeholder: "stash_keep_index_op",
525    },
526    // MG.42-E2: magit's snapshots — a restore point that leaves the
527    // working tree exactly as it was.
528    TransientRow {
529        key: "Z",
530        label: "snapshot",
531        doc: "Stash everything and put it straight back",
532        action: "action:magit-global-stash-snapshot",
533        placeholder: "stash_snapshot_op",
534    },
535    TransientRow {
536        key: "I",
537        label: "snapshot index",
538        doc: "Snapshot the staged changes, leaving the working tree alone",
539        action: "action:magit-global-stash-snapshot-index",
540        placeholder: "stash_snapshot_index_op",
541    },
542    TransientRow {
543        key: "W",
544        label: "snapshot worktree",
545        doc: "Snapshot the working tree, leaving the index alone",
546        action: "action:magit-global-stash-snapshot-worktree",
547        placeholder: "stash_snapshot_worktree_op",
548    },
549    // MG.41d: magit's use rows. These reuse the SAME actions the stash
550    // buffer's chords fire — a menu path to an operation must not grow
551    // a second handler with its own idea of the confirm contract, which
552    // is the property `the_commit_rows_reuse_the_chords_actions` pins
553    // for the reset rows.
554    TransientRow {
555        key: "a",
556        label: "apply",
557        doc: "Apply a stash, keeping it on the stack",
558        action: "action:magit-stash-apply",
559        placeholder: "stash_apply_op",
560    },
561    TransientRow {
562        key: "p",
563        label: "pop",
564        doc: "Apply a stash and drop it",
565        action: "action:magit-stash-pop",
566        placeholder: "stash_pop_op",
567    },
568    TransientRow {
569        key: "k",
570        label: "drop",
571        doc: "Delete a stash without applying it — asks first",
572        action: "action:magit-stash-drop",
573        placeholder: "stash_drop_op",
574    },
575    TransientRow {
576        key: "v",
577        label: "show",
578        doc: "Show a stash's diff",
579        action: "action:magit-stash-show",
580        placeholder: "stash_show_op",
581    },
582    // MG.42-E3: two inputs — the branch name, then the stash.
583    TransientRow {
584        key: "b",
585        label: "branch",
586        doc: "Start a branch from a stash — for when it no longer applies to HEAD",
587        action: "action:magit-global-stash-branch",
588        placeholder: "stash_branch_op",
589    },
590];
591
592const SUBTREE_ROWS: &[TransientRow] = &[
593    TransientRow {
594        key: "a",
595        label: "add",
596        doc: "Add a repository as a subtree at a prefix",
597        action: "action:magit-global-subtree-add",
598        placeholder: "subtree_add_op",
599    },
600    TransientRow {
601        key: "m",
602        label: "merge",
603        doc: "Merge a repository into an existing subtree prefix",
604        action: "action:magit-global-subtree-merge",
605        placeholder: "subtree_merge_op",
606    },
607    TransientRow {
608        key: "f",
609        label: "pull",
610        doc: "Fetch and merge upstream changes into a subtree prefix",
611        action: "action:magit-global-subtree-pull",
612        placeholder: "subtree_pull_op",
613    },
614    TransientRow {
615        key: "p",
616        label: "push",
617        doc: "Push a subtree prefix to its upstream repository",
618        action: "action:magit-global-subtree-push",
619        placeholder: "subtree_push_op",
620    },
621    TransientRow {
622        key: "s",
623        label: "split",
624        doc: "Split a prefix into its own synthetic history",
625        action: "action:magit-global-subtree-split",
626        placeholder: "subtree_split_op",
627    },
628];
629
630/// MG.43h: the `d` / `l` argument menus.
631///
632/// MG.41f built this, found the toggles would render and be silently
633/// discarded, and reverted the wiring — correctly, because
634/// `action:magit-global-diff` declared no `args_schema` for them to
635/// project onto. It concluded the fix was "teach the open actions to
636/// accept arguments", i.e. an operation change.
637///
638/// It was narrower than that. MG.17a's projection was already
639/// generic; only the empty schema was missing. Declaring each open
640/// action's own flag table, plus a place to leave the values for a
641/// buffer that does not exist yet (`ViewArgsRequests`), is the whole
642/// of it.
643///
644/// Each view declares its OWN table rather than the union
645/// `action:magit-view-refresh-args` uses — that one action serves both
646/// views, whereas these are two, and a diff must never be handed a log
647/// flag.
648fn view_open_transient(
649    title: &str,
650    ids: &MagitActionIds,
651    flags: &'static [crate::magit_global_mode::RemoteFlag],
652    rows: &'static [TransientRow],
653) -> TransientSpec {
654    let mut groups = Vec::new();
655    if !flags.is_empty() {
656        groups.push(TransientGroup {
657            label: "Arguments".into(),
658            items: flag_items_from(flags),
659        });
660    }
661    groups.push(TransientGroup {
662        label: "Show".into(),
663        items: rows.iter().map(|r| row_item(ids, r)).collect(),
664    });
665    TransientSpec {
666        title: title.into(),
667        groups,
668        preview: None,
669        footer: Some("q dismiss  Esc/BS back".into()),
670    }
671}
672
673/// MG.49: the Diff menu's targets.
674///
675/// `f` and `v` are here because binding `d` to this menu takes their
676/// chords: the trie checks a node's own binding before its children, so
677/// a bound `d` makes `dv` unreachable, and `d` itself was
678/// `magit-diff-file` on magit-status. Both keep working through the
679/// menu instead of being silently lost — which is the whole reason the
680/// rows moved rather than the chords being dropped.
681const DIFF_SHOW_ROWS: &[TransientRow] = &[
682    TransientRow {
683        key: "d",
684        label: "diff",
685        doc: "Diff the working tree against HEAD",
686        action: "action:magit-global-diff",
687        placeholder: "diff_op",
688    },
689    TransientRow {
690        key: "f",
691        label: "file",
692        doc: "Diff the file at cursor in a dedicated buffer",
693        action: "action:magit-diff-file",
694        placeholder: "diff_file",
695    },
696    TransientRow {
697        key: "v",
698        label: "side-by-side",
699        doc: "Open the file at cursor side-by-side against its baseline",
700        action: "action:magit-diff-side-by-side",
701        placeholder: "diff_side_by_side",
702    },
703    // PD.3 (2026-08-12): the editable cross-file view. `e` for "edit"
704    // reads correctly and is free. `p` (for "project") was considered
705    // and passed over — real magit binds `p` to *diff paths* in this
706    // same menu, so reusing it would fight muscle memory people already
707    // have.
708    //
709    // A peer of `d`, not a replacement for it: `d` is the patch view
710    // people already know, and the editable view earns its own row.
711    TransientRow {
712        key: "e",
713        label: "edit",
714        doc: "Edit the working-tree diff across files",
715        action: "action:magit-project-diff",
716        placeholder: "diff_project",
717    },
718];
719
720const LOG_SHOW_ROWS: &[TransientRow] = &[TransientRow {
721    key: "l",
722    label: "log",
723    doc: "Show commit history",
724    action: "action:magit-global-log",
725    placeholder: "show_log",
726}];
727
728// MG.42-E4: cherry-pick / revert, idle and stopped.
729//
730// The ways OUT are identical in shape but NOT interchangeable:
731// `git revert --continue` errors during a cherry-pick and vice versa,
732// which is why each sequence has its own rows rather than a shared set.
733
734const CHERRY_PICK_ROWS: &[TransientRow] = &[
735    TransientRow {
736        key: "A",
737        label: "pick",
738        doc: "Cherry-pick a commit onto this branch",
739        action: "action:magit-cherry-pick",
740        placeholder: "cherry_pick_op",
741    },
742    // MG.43a: magit's `a` — apply the change WITHOUT recording a
743    // commit, so it can be edited or split before committing.
744    TransientRow {
745        key: "a",
746        label: "apply",
747        doc: "Apply a commit's changes without committing",
748        action: "action:magit-cherry-pick-apply",
749        placeholder: "cherry_pick_apply_op",
750    },
751    // MG.43d: the commit-MOVING rows. `A` / `a` copy; these four
752    // remove the commit from where it came from.
753    TransientRow {
754        key: "h",
755        label: "harvest",
756        doc: "Move a commit here from another branch, removing it there",
757        action: "action:magit-cherry-harvest",
758        placeholder: "cherry_harvest_op",
759    },
760    TransientRow {
761        key: "d",
762        label: "donate",
763        doc: "Move a commit to another branch, staying on this one",
764        action: "action:magit-cherry-donate",
765        placeholder: "cherry_donate_op",
766    },
767    TransientRow {
768        key: "n",
769        label: "spinout",
770        doc: "Move a commit to a new branch, staying on this one",
771        action: "action:magit-cherry-spinout",
772        placeholder: "cherry_spinout_op",
773    },
774    TransientRow {
775        key: "s",
776        label: "spinoff",
777        doc: "Move a commit to a new branch and check it out",
778        action: "action:magit-cherry-spinoff",
779        placeholder: "cherry_spinoff_op",
780    },
781];
782
783const CHERRY_PICK_SEQUENCE_ROWS: &[TransientRow] = &[
784    TransientRow {
785        key: "A",
786        label: "continue",
787        doc: "Resume the cherry-pick after resolving the conflict",
788        action: "action:magit-global-cherry-pick-continue",
789        placeholder: "cherry_pick_continue_op",
790    },
791    TransientRow {
792        key: "s",
793        label: "skip",
794        doc: "Skip the commit the cherry-pick stopped on",
795        action: "action:magit-global-cherry-pick-skip",
796        placeholder: "cherry_pick_skip_op",
797    },
798    TransientRow {
799        key: "a",
800        label: "abort",
801        doc: "Abandon the cherry-pick, restoring the branch",
802        action: "action:magit-global-cherry-pick-abort",
803        placeholder: "cherry_pick_abort_op",
804    },
805];
806
807const REVERT_ROWS: &[TransientRow] = &[
808    TransientRow {
809        key: "V",
810        label: "revert commit",
811        doc: "Revert a commit, creating an inverse commit",
812        action: "action:magit-revert",
813        placeholder: "revert_op",
814    },
815    // MG.43a: magit's `v` — stage the reversal WITHOUT committing it.
816    TransientRow {
817        key: "v",
818        label: "revert changes",
819        doc: "Apply the inverse of a commit without committing",
820        action: "action:magit-revert-changes",
821        placeholder: "revert_changes_op",
822    },
823];
824
825const REVERT_SEQUENCE_ROWS: &[TransientRow] = &[
826    TransientRow {
827        key: "V",
828        label: "continue",
829        doc: "Resume the revert after resolving the conflict",
830        action: "action:magit-global-revert-continue",
831        placeholder: "revert_continue_op",
832    },
833    TransientRow {
834        key: "s",
835        label: "skip",
836        doc: "Skip the commit the revert stopped on",
837        action: "action:magit-global-revert-skip",
838        placeholder: "revert_skip_op",
839    },
840    TransientRow {
841        key: "a",
842        label: "abort",
843        doc: "Abandon the revert, restoring the branch",
844        action: "action:magit-global-revert-abort",
845        placeholder: "revert_abort_op",
846    },
847];
848
849/// MG.41e: magit's `m` merge submenu.
850const MERGE_ROWS: &[TransientRow] = &[
851    TransientRow {
852        key: "m",
853        label: "merge",
854        doc: "Merge a branch into the current one",
855        action: "action:magit-global-merge",
856        placeholder: "merge_op",
857    },
858    TransientRow {
859        key: "n",
860        label: "merge, don't commit",
861        doc: "Merge but stop before committing, so the result can be inspected first",
862        action: "action:magit-global-merge-no-commit",
863        placeholder: "merge_no_commit_op",
864    },
865    TransientRow {
866        key: "s",
867        label: "squash",
868        doc: "Take the branch's changes as one staged change, with no merge commit",
869        action: "action:magit-global-merge-squash",
870        placeholder: "merge_squash_op",
871    },
872    // MG.42-E1: merge with an authored message.
873    TransientRow {
874        key: "e",
875        label: "merge and edit message",
876        doc: "Merge a branch, writing the merge message yourself",
877        action: "action:magit-global-merge-edit",
878        placeholder: "merge_edit_op",
879    },
880    // MG.43e: preview — shows what merging would bring in, without
881    // merging. Read-only, so it sits with the acting rows but changes
882    // nothing.
883    TransientRow {
884        key: "p",
885        label: "preview",
886        doc: "Show what merging a branch would bring in",
887        action: "action:magit-global-merge-preview",
888        placeholder: "merge_preview_op",
889    },
890    // MG.43e: the mirror of `a` absorb — merge THIS branch into
891    // another and delete this one.
892    TransientRow {
893        key: "i",
894        label: "merge into",
895        doc: "Merge this branch into another, then delete this one",
896        action: "action:magit-global-merge-into",
897        placeholder: "merge_into_op",
898    },
899    // MG.42-E2: merge then delete, as one operation.
900    TransientRow {
901        key: "a",
902        label: "absorb",
903        doc: "Merge a branch and delete it — the delete is refused if the merge did not take",
904        action: "action:magit-global-merge-absorb",
905        placeholder: "merge_absorb_op",
906    },
907];
908
909/// The merge menu while a merge is STOPPED on a conflict.
910///
911/// Peer of `CHERRY_PICK_SEQUENCE_ROWS`, and it exists for the same
912/// reason: every row in `MERGE_ROWS` is a way IN, and git refuses all
913/// of them while `MERGE_HEAD` is present ("you have not concluded your
914/// merge"). An ungated menu therefore showed the user seven rows that
915/// could only fail, and no row at all for the two things they actually
916/// wanted.
917///
918/// No `skip`: that is a sequencer verb, and a merge is a single
919/// operation with nothing to skip to. `--quit` is left out too — it
920/// forgets the merge while keeping the index, which is a recovery tool
921/// rather than a way out, and `q` is the menu's dismiss key anyway.
922///
923/// Keys follow the overload convention the sequencer menus established:
924/// `m` is *merge* when idle and *continue* when stopped, `a` is
925/// *absorb* when idle and *abort* when stopped. Safe only because the
926/// gate never shows both sets at once.
927const MERGE_SEQUENCE_ROWS: &[TransientRow] = &[
928    TransientRow {
929        key: "m",
930        label: "continue",
931        doc: "Conclude the merge after resolving the conflict",
932        action: "action:magit-global-merge-continue",
933        placeholder: "merge_continue_op",
934    },
935    TransientRow {
936        key: "a",
937        label: "abort",
938        doc: "Abandon the merge, restoring the branch as it was",
939        action: "action:magit-global-merge-abort",
940        placeholder: "merge_abort_op",
941    },
942];
943
944/// MG.41e: magit's `t` tag submenu.
945const TAG_ROWS: &[TransientRow] = &[
946    // MG.43e: magit's `r` — an annotated release tag, which is a real
947    // object rather than a pointer.
948    TransientRow {
949        key: "r",
950        label: "release",
951        doc: "Create an annotated release tag (asks name and message)",
952        action: "action:magit-global-tag-release",
953        placeholder: "tag_release_op",
954    },
955    // MG.43e: magit's `p` — drop local tags gone from the remote.
956    TransientRow {
957        key: "p",
958        label: "prune",
959        doc: "Drop local tags that no longer exist on the remote",
960        action: "action:magit-global-tag-prune",
961        placeholder: "tag_prune_op",
962    },
963    TransientRow {
964        key: "t",
965        label: "tag",
966        doc: "Tag HEAD with a name you type",
967        action: "action:magit-global-tag",
968        placeholder: "tag_op",
969    },
970    TransientRow {
971        key: "k",
972        label: "delete",
973        doc: "Delete a local tag — the remote copy is untouched",
974        action: "action:magit-global-tag-delete",
975        placeholder: "tag_delete_op",
976    },
977];
978
979/// MG.41e: the rebase submenu, shown when NO rebase is running.
980const REBASE_START_ROWS: &[TransientRow] = &[
981    // MG.43b: magit's onto-a-target rows. `p` and `u` need no prompt —
982    // git resolves `@{push}` / `@{upstream}` itself.
983    TransientRow {
984        key: "p",
985        label: "onto pushRemote",
986        doc: "Rebase this branch onto its push target",
987        action: "action:magit-global-rebase-onto-push",
988        placeholder: "rebase_onto_push_op",
989    },
990    TransientRow {
991        key: "u",
992        label: "onto @{upstream}",
993        doc: "Rebase this branch onto its upstream",
994        action: "action:magit-global-rebase-onto-upstream",
995        placeholder: "rebase_onto_upstream_op",
996    },
997    TransientRow {
998        key: "e",
999        label: "onto elsewhere",
1000        doc: "Rebase this branch onto a ref you name",
1001        action: "action:magit-global-rebase-onto-elsewhere",
1002        placeholder: "rebase_onto_elsewhere_op",
1003    },
1004    TransientRow {
1005        key: "s",
1006        label: "a subset",
1007        doc: "Replay the commits after one ref onto another",
1008        action: "action:magit-global-rebase-subset",
1009        placeholder: "rebase_subset_op",
1010    },
1011    // MG.43c: the todo-rewriting rows. Each names a commit and changes
1012    // its verb; the verb IS the operation.
1013    TransientRow {
1014        key: "m",
1015        label: "edit a commit",
1016        doc: "Replay history, stopping at a commit so you can change it",
1017        action: "action:magit-rebase-edit-commit",
1018        placeholder: "rebase_edit_commit_op",
1019    },
1020    TransientRow {
1021        key: "w",
1022        label: "reword a commit",
1023        doc: "Change an older commit's message",
1024        action: "action:magit-rebase-reword-commit",
1025        placeholder: "rebase_reword_commit_op",
1026    },
1027    TransientRow {
1028        key: "k",
1029        label: "remove a commit",
1030        doc: "Replay history without a commit",
1031        action: "action:magit-rebase-remove-commit",
1032        placeholder: "rebase_remove_commit_op",
1033    },
1034    TransientRow {
1035        key: "f",
1036        label: "autosquash",
1037        doc: "Replay, folding in fixup! and squash! markers",
1038        action: "action:magit-global-rebase-autosquash",
1039        placeholder: "rebase_autosquash_op",
1040    },
1041    TransientRow {
1042        key: "i",
1043        label: "interactively",
1044        doc: "Start an interactive rebase — pick a base, then edit the todo list",
1045        action: "action:magit-global-rebase",
1046        placeholder: "rebase_op",
1047    },
1048];
1049
1050/// MG.41e: shown INSTEAD when a rebase is stopped.
1051///
1052/// Gated for the same reason bisect / notes-merge / am are: outside a
1053/// rebase these three error, so ungated rows would look actionable and
1054/// fail; inside one, starting another is what you must not do.
1055const REBASE_SEQUENCE_ROWS: &[TransientRow] = &[
1056    TransientRow {
1057        key: "r",
1058        label: "continue",
1059        doc: "Resume after amending or resolving conflicts",
1060        action: "action:magit-global-rebase-continue",
1061        placeholder: "rebase_continue_op",
1062    },
1063    TransientRow {
1064        key: "s",
1065        label: "skip",
1066        doc: "Skip the commit the rebase stopped on",
1067        action: "action:magit-global-rebase-skip",
1068        placeholder: "rebase_skip_op",
1069    },
1070    TransientRow {
1071        key: "a",
1072        label: "abort",
1073        doc: "Abandon the rebase, restoring the branch to where it started",
1074        action: "action:magit-global-rebase-abort",
1075        placeholder: "rebase_abort_op",
1076    },
1077];
1078
1079const JUMP_ROWS: &[TransientRow] = &[
1080    TransientRow {
1081        key: "s",
1082        label: "staged",
1083        doc: "Jump to the staged-changes section",
1084        action: "action:magit-jump-staged",
1085        placeholder: "jump_staged_op",
1086    },
1087    TransientRow {
1088        key: "u",
1089        label: "unstaged",
1090        doc: "Jump to the unstaged-changes section",
1091        action: "action:magit-jump-unstaged",
1092        placeholder: "jump_unstaged_op",
1093    },
1094    TransientRow {
1095        key: "n",
1096        label: "untracked",
1097        doc: "Jump to the untracked-files section",
1098        action: "action:magit-jump-untracked",
1099        placeholder: "jump_untracked_op",
1100    },
1101    TransientRow {
1102        key: "z",
1103        label: "stashes",
1104        doc: "Jump to the stashes section",
1105        action: "action:magit-jump-stashes",
1106        placeholder: "jump_stashes_op",
1107    },
1108    // `m` for "unmerged" — `u` is taken by unstaged, and magit's own
1109    // jump menu keys are per-section initials with the same collisions
1110    // resolved the same way.
1111    TransientRow {
1112        key: "m",
1113        label: "unmerged",
1114        doc: "Jump to the unmerged-into-upstream section",
1115        action: "action:magit-jump-unmerged",
1116        placeholder: "jump_unmerged_op",
1117    },
1118    TransientRow {
1119        key: "c",
1120        label: "commits",
1121        doc: "Jump to the recent-commits section",
1122        action: "action:magit-jump-commits",
1123        placeholder: "jump_commits_op",
1124    },
1125];
1126
1127/// MG.17a: the `Flag` items for a [`RemoteOp`], built from the op's own
1128/// flag table so the menu can't offer a toggle the argv builder ignores.
1129fn flag_items(op: RemoteOp) -> Vec<TransientItem> {
1130    flag_items_from(op.flags)
1131}
1132
1133/// MG.23k: the same translation for any flag table, not only a
1134/// [`RemoteOp`]'s — the view-arguments menu has flags but no operation.
1135fn flag_items_from(flags: &'static [crate::magit_global_mode::RemoteFlag]) -> Vec<TransientItem> {
1136    use crate::magit_global_mode::RemoteArgKind;
1137    flags
1138        .iter()
1139        .map(|f| TransientItem {
1140            key: vec![f.key.to_string()],
1141            label: f.arg.to_string(),
1142            description: f.doc.to_string(),
1143            kind: match f.kind {
1144                RemoteArgKind::Flag => TransientItemKind::Flag {
1145                    name: f.name.to_string(),
1146                    default: false,
1147                },
1148                // MG.17b: a value argument opens a prompt and comes
1149                // back to this menu with the value filled in.
1150                RemoteArgKind::Value { prompt } | RemoteArgKind::ValueJoined { prompt } => {
1151                    TransientItemKind::Argument {
1152                        name: f.name.to_string(),
1153                        default: None,
1154                        prompt: prompt.to_string(),
1155                        // Free text: these are values being *created*
1156                        // (a new remote name, a URL), not names of
1157                        // things that already exist. MG.53's rule —
1158                        // a picker for a new name is worse than
1159                        // useless, because there is nothing to pick.
1160                        source: None,
1161                    }
1162                }
1163            },
1164        })
1165        .collect()
1166}
1167
1168/// MG.17a: the live preview for a [`RemoteOp`] transient — the exact
1169/// git command the current toggles resolve to. Rendered by the same
1170/// `RemoteOp::preview` the argv builder is paired with, so the preview
1171/// cannot claim one command while the run executes another.
1172fn remote_preview(op: RemoteOp) -> Box<dyn Fn(&TransientState) -> String + Send + Sync> {
1173    Box::new(move |state: &TransientState| {
1174        op.preview(&|name| match state.get(name) {
1175            Some(TransientValue::Bool(b)) => Some(b.to_string()),
1176            Some(TransientValue::String(v)) => Some(v.clone()),
1177            None => None,
1178        })
1179    })
1180}
1181
1182/// MG.17a: a sub-transient for one remote operation — its flags, then
1183/// the key that runs it.
1184///
1185/// Flags need a menu that stays open while you toggle them, which the
1186/// flat root dispatch cannot do: pressing `P` there fires immediately.
1187/// So `P` now opens this, and `P` again (or `<CR>`) runs it — one extra
1188/// keystroke, in exchange for the flags being reachable at all.
1189fn remote_op_transient(
1190    title: &str,
1191    op: RemoteOp,
1192    ids: &MagitActionIds,
1193    rows: &'static [TransientRow],
1194    config_rows: &'static [ConfigRow],
1195    workdir: &std::path::Path,
1196) -> TransientSpec {
1197    let mut groups = Vec::new();
1198    let flags = flag_items(op);
1199    if !flags.is_empty() {
1200        groups.push(TransientGroup {
1201            label: "Arguments".into(),
1202            items: flags,
1203        });
1204    }
1205    // MG.41c: several destinations, not one unlabelled run. Magit's
1206    // push menu offers seven; lattice offered `P`.
1207    groups.push(row_group("Destination", ids, rows));
1208    // MG.43g: magit's `C`, reporting the key's current value inline.
1209    groups.extend(config_group(ids, config_rows, workdir));
1210    TransientSpec {
1211        title: title.into(),
1212        groups,
1213        preview: Some(remote_preview(op)),
1214        footer: Some("q dismiss  Esc/BS back".into()),
1215    }
1216}
1217
1218/// The `action:magit-global-*` `CommandId`s [`dispatch_transient`]'s
1219/// items fire, resolved once at `install()` time (all the names it
1220/// needs are registered earlier in the same call, by
1221/// `register_action_commands`) and captured by the
1222/// `TransientSourceRegistry` builder closure — the registry's
1223/// builders take only a [`TransientContext`], so capture is how a
1224/// boot-time-resolved id reaches a spec built long after boot,
1225/// possibly many times (once per `C-c g` press).
1226#[derive(Debug, Clone, Copy, Default)]
1227pub struct DispatchActionIds {
1228    pub status: Option<CommandId>,
1229    pub commit: Option<CommandId>,
1230    pub amend: Option<CommandId>,
1231    pub log: Option<CommandId>,
1232    pub diff: Option<CommandId>,
1233    pub branch: Option<CommandId>,
1234    /// MG.29: the branch submenu's own rows.
1235    pub branch_checkout: Option<CommandId>,
1236    pub branch_create: Option<CommandId>,
1237    /// MG.32: the four rows that completed the submenu against magit's
1238    /// own `magit-branch` transient.
1239    pub branch_checkout_rev: Option<CommandId>,
1240    pub branch_create_no_checkout: Option<CommandId>,
1241    pub branch_rename: Option<CommandId>,
1242    pub branch_delete: Option<CommandId>,
1243    /// MG.21d: `M` — remote management, magit's own key.
1244    pub remote: Option<CommandId>,
1245    /// MG.23k: `D` — re-run this view with different git arguments.
1246    pub view_args: Option<CommandId>,
1247    /// MG.21i: `o` — the submodule list, magit's own key.
1248    pub submodule: Option<CommandId>,
1249    /// MG.35: `y` — the refs buffer, magit's own key.
1250    pub refs: Option<CommandId>,
1251    /// MG.36: `C` — clone a repository, magit's own key.
1252    pub clone: Option<CommandId>,
1253    /// MG.37: the `T` notes submenu's rows, on magit's own keys.
1254    pub note_edit: Option<CommandId>,
1255    pub note_remove: Option<CommandId>,
1256    pub note_prune: Option<CommandId>,
1257    pub note_merge: Option<CommandId>,
1258    /// Shown only while a notes merge is stopped on a conflict — see
1259    /// [`notes_transient`].
1260    pub note_merge_commit: Option<CommandId>,
1261    pub note_merge_abort: Option<CommandId>,
1262    /// MG.38: the `"` subtree submenu's rows.
1263    pub subtree_add: Option<CommandId>,
1264    pub subtree_merge: Option<CommandId>,
1265    pub subtree_pull: Option<CommandId>,
1266    pub subtree_push: Option<CommandId>,
1267    pub subtree_split: Option<CommandId>,
1268    /// MG.39: `w` am / `W` format-patch, and the way out of a stopped
1269    /// `am`.
1270    pub am_apply: Option<CommandId>,
1271    pub am_continue: Option<CommandId>,
1272    pub am_skip: Option<CommandId>,
1273    pub am_abort: Option<CommandId>,
1274    pub format_patch: Option<CommandId>,
1275    /// MG.40: `Y` cherries.
1276    pub cherries: Option<CommandId>,
1277    /// MG.21g: `B` — bisect. Start is shown only when none is running;
1278    /// the marks only when one is.
1279    pub bisect_start: Option<CommandId>,
1280    pub bisect_good: Option<CommandId>,
1281    pub bisect_bad: Option<CommandId>,
1282    pub bisect_skip: Option<CommandId>,
1283    pub bisect_reset: Option<CommandId>,
1284    pub stash: Option<CommandId>,
1285    pub stash_create: Option<CommandId>,
1286    pub rebase: Option<CommandId>,
1287    pub fetch: Option<CommandId>,
1288    pub pull: Option<CommandId>,
1289    pub push: Option<CommandId>,
1290    /// MG.23b: magit's `S` / `U` — repo-wide index operations.
1291    pub stage_all: Option<CommandId>,
1292    pub unstage_all: Option<CommandId>,
1293    /// MG.23c1: prompt-backed rows, on magit's own keys.
1294    pub tag: Option<CommandId>,
1295    pub gitignore: Option<CommandId>,
1296    /// MG.23c2.
1297    pub init: Option<CommandId>,
1298    pub merge: Option<CommandId>,
1299    /// MG.23h: the section-acting rows, shown only inside a magit
1300    /// buffer. `discard` is magit-status's own `x` action, reused
1301    /// rather than duplicated.
1302    pub apply_hunk: Option<CommandId>,
1303    pub reverse_hunk: Option<CommandId>,
1304    pub discard: Option<CommandId>,
1305    /// MG.23j: the commit operations, in magit's ungated group. The
1306    /// same actions the chords fire — they ask for a commit when there
1307    /// is none under the cursor.
1308    pub cherry_pick: Option<CommandId>,
1309    pub revert: Option<CommandId>,
1310    pub reset_soft: Option<CommandId>,
1311    pub reset_mixed: Option<CommandId>,
1312    pub reset_hard: Option<CommandId>,
1313    /// MG.23h: `magit-status-jump`'s rows, one per section we render.
1314    pub jump_staged: Option<CommandId>,
1315    pub jump_unstaged: Option<CommandId>,
1316    pub jump_untracked: Option<CommandId>,
1317    pub jump_stashes: Option<CommandId>,
1318    pub jump_commits: Option<CommandId>,
1319}
1320
1321/// An item that fires `id` if resolved, or falls back to a `Flag`
1322/// placeholder if the action name wasn't found in the registry
1323/// (shouldn't happen in practice — `register_action_commands` always
1324/// runs first — but a missing id silently downgrading to "does
1325/// nothing when toggled" beats a panic or a dangling `CommandId`).
1326fn action_or_placeholder(
1327    id: Option<CommandId>,
1328    key: &str,
1329    label: &str,
1330    description: &str,
1331    placeholder_name: &str,
1332) -> TransientItem {
1333    let kind = match id {
1334        Some(cid) => TransientItemKind::action(cid),
1335        None => TransientItemKind::Flag {
1336            name: placeholder_name.to_string(),
1337            default: false,
1338        },
1339    };
1340    TransientItem {
1341        key: vec![key.to_string()],
1342        label: label.to_string(),
1343        description: description.to_string(),
1344        kind,
1345    }
1346}
1347
1348/// MG.43g: a configure row — magit's `C`.
1349///
1350/// Renders the key's CURRENT value inline and fires an action that
1351/// prompts for a new one. The value comes from the prefetched cache,
1352/// never from a read here: this runs while a menu is being built,
1353/// which is a keystroke path.
1354///
1355/// Falls back to the same inert placeholder every other row uses when
1356/// its action does not resolve, so an unregistered configure action
1357/// renders disabled rather than panicking.
1358fn variable_item(
1359    ids: &MagitActionIds,
1360    workdir: &std::path::Path,
1361    key_chord: &str,
1362    label: &str,
1363    description: &str,
1364    config_key: &str,
1365    action: &str,
1366    placeholder_name: &str,
1367) -> TransientItem {
1368    let Some(cid) = ids.get(action) else {
1369        return action_or_placeholder(None, key_chord, label, description, placeholder_name);
1370    };
1371    TransientItem {
1372        key: vec![key_chord.to_string()],
1373        label: label.to_string(),
1374        description: description.to_string(),
1375        kind: TransientItemKind::Variable {
1376            key: config_key.to_string(),
1377            value: crate::git_config::value_of(workdir, config_key),
1378            action: cid,
1379        },
1380    }
1381}
1382
1383/// MG.43g: the config keys each menu's `C` row reports.
1384///
1385/// Magit's own keys, per menu. Kept as data for the same reason rows
1386/// are: a menu gaining a key is a table entry, and the drift test can
1387/// walk them.
1388pub(crate) struct ConfigRow {
1389    pub key: &'static str,
1390    pub label: &'static str,
1391    pub config_key: &'static str,
1392    /// The action that changes it. Named per row for the same reason
1393    /// `TransientRow::action` is: a `Variable` fires an action and
1394    /// carries no key, so the row must name a handler that knows which
1395    /// key it edits.
1396    pub action: &'static str,
1397}
1398
1399pub(crate) const BRANCH_CONFIG_ROWS: &[ConfigRow] = &[ConfigRow {
1400    key: "C",
1401    label: "rebase on pull",
1402    config_key: "pull.rebase",
1403    action: "action:magit-config-pull-rebase",
1404}];
1405
1406pub(crate) const PUSH_CONFIG_ROWS: &[ConfigRow] = &[ConfigRow {
1407    key: "C",
1408    label: "default push target",
1409    config_key: "remote.pushDefault",
1410    action: "action:magit-config-push-default",
1411}];
1412
1413pub(crate) const PULL_CONFIG_ROWS: &[ConfigRow] = &[ConfigRow {
1414    key: "C",
1415    label: "rebase on pull",
1416    config_key: "pull.rebase",
1417    action: "action:magit-config-pull-rebase",
1418}];
1419
1420pub(crate) const FETCH_CONFIG_ROWS: &[ConfigRow] = &[ConfigRow {
1421    key: "C",
1422    label: "prune on fetch",
1423    config_key: "fetch.prune",
1424    action: "action:magit-config-fetch-prune",
1425}];
1426
1427pub(crate) const TAG_CONFIG_ROWS: &[ConfigRow] = &[ConfigRow {
1428    key: "C",
1429    label: "sign tags",
1430    config_key: "tag.gpgSign",
1431    action: "action:magit-config-tag-sign",
1432}];
1433
1434pub(crate) const NOTES_CONFIG_ROWS: &[ConfigRow] = &[ConfigRow {
1435    key: "C",
1436    label: "notes ref",
1437    config_key: "core.notesRef",
1438    action: "action:magit-config-notes-ref",
1439}];
1440
1441/// Every configure table, for the drift test — the same bookkeeping
1442/// `all_row_tables` keeps for action rows.
1443#[cfg(test)]
1444pub(crate) fn all_config_tables() -> &'static [(&'static str, &'static [ConfigRow])] {
1445    &[
1446        ("branch", BRANCH_CONFIG_ROWS),
1447        ("push", PUSH_CONFIG_ROWS),
1448        ("pull", PULL_CONFIG_ROWS),
1449        ("fetch", FETCH_CONFIG_ROWS),
1450        ("tag", TAG_CONFIG_ROWS),
1451        ("notes", NOTES_CONFIG_ROWS),
1452    ]
1453}
1454
1455/// The `Configure` group a menu appends, or nothing when the table is
1456/// empty.
1457fn config_group(
1458    ids: &MagitActionIds,
1459    rows: &'static [ConfigRow],
1460    workdir: &std::path::Path,
1461) -> Option<TransientGroup> {
1462    if rows.is_empty() {
1463        return None;
1464    }
1465    Some(TransientGroup {
1466        label: "Configure".into(),
1467        items: rows
1468            .iter()
1469            .map(|r| {
1470                variable_item(
1471                    ids,
1472                    workdir,
1473                    r.key,
1474                    r.label,
1475                    "Change this setting for the repository",
1476                    r.config_key,
1477                    r.action,
1478                    "config_op",
1479                )
1480            })
1481            .collect(),
1482    })
1483}
1484
1485/// MG.23h: the "Applying changes" rows, gated per magit's own
1486/// `:if-derived magit-mode` — see the call site for the reasoning and
1487/// for why `s` / `u` are not among them.
1488fn applying_changes_items(ids: &MagitActionIds, ctx: &TransientContext) -> Vec<TransientItem> {
1489    let mut items = Vec::new();
1490    if ctx.has_minor(crate::MagitCoreMode::mode_id().as_str()) {
1491        items.push(action_or_placeholder(
1492            ids.get("action:magit-apply-hunk"),
1493            "a",
1494            "apply",
1495            "Apply the hunk at cursor to the working tree",
1496            "apply_hunk",
1497        ));
1498        items.push(action_or_placeholder(
1499            ids.get("action:magit-reverse-hunk"),
1500            "-",
1501            "reverse",
1502            "Reverse the hunk at cursor out of the working tree",
1503            "reverse_hunk",
1504        ));
1505        items.push(action_or_placeholder(
1506            ids.get("action:magit-discard"),
1507            "x",
1508            "discard",
1509            "Discard the hunk or file at cursor (asks first)",
1510            "discard_at_cursor",
1511        ));
1512    }
1513    items.push(action_or_placeholder(
1514        ids.get("action:magit-global-stage-all"),
1515        "S",
1516        "stage all",
1517        "Stage every tracked modification (git add --update)",
1518        "stage_all_op",
1519    ));
1520    items.push(action_or_placeholder(
1521        ids.get("action:magit-global-unstage-all"),
1522        "U",
1523        "unstage all",
1524        "Unstage everything, keeping your working tree (git reset)",
1525        "unstage_all_op",
1526    ));
1527    items
1528}
1529
1530/// MG.23j: the three resets, on the chords' own `s` / `m` / `h`
1531/// suffixes so `C-c g O h` and the `Oh` chord read the same.
1532///
1533/// A submenu rather than three top-level rows because `O` is one
1534/// concept with three strengths, and because the destructive one wants
1535/// to sit next to the two that are not — seeing `--soft` and `--mixed`
1536/// beside it is what makes "keeps your changes" legible at the moment
1537/// of choosing.
1538fn reset_transient(ids: &MagitActionIds) -> TransientSpec {
1539    TransientSpec {
1540        title: "Reset".into(),
1541        groups: vec![row_group("Reset", ids, RESET_ROWS)],
1542        preview: None,
1543        footer: Some("q dismiss  Esc/BS back".into()),
1544    }
1545}
1546
1547/// MG.29 + MG.32: the `b` branch submenu.
1548///
1549/// `b` used to open the branch **list** straight away. That is one of
1550/// several things you want from "branches", and it made the other ones
1551/// — check one out, start one — reachable only by opening the list
1552/// first and then finding the chord. Magit puts them in a submenu; so
1553/// does this.
1554///
1555/// Every row ASKS rather than reading a cursor, because a menu opened
1556/// from anywhere has none — the same answer MG.23j gave the commit rows,
1557/// and the reason magit's own branch commands sit in an ungated group.
1558///
1559/// ## The keys are magit's, and MG.32 corrected two that were not
1560///
1561/// Pulled from `magit/lisp/magit-branch.el`'s `magit-branch` transient
1562/// with `evil-collection-magit-popup-changes` applied — MG.23's policy
1563/// #1 ("keys follow magit / evil-collection-magit from day one, so a row
1564/// landing later lands in the slot muscle memory already expects").
1565/// MG.29 shipped this submenu without doing that inventory, and two keys
1566/// were wrong as a result:
1567///
1568/// - **`l` was "list"**, but in magit `l` is *checkout local branch*.
1569///   The list is a lattice concept — magit's branch transient has no
1570///   list-buffer row at all — so it had squatted on an occupied key.
1571/// - **`b` was the local-branch picker**, but magit's `b` is
1572///   *branch/revision*: it accepts a tag, a remote ref or a raw SHA,
1573///   which a list of local branches cannot express. What MG.29 built
1574///   was magit's `l` under magit's `b`.
1575///
1576/// So the MG.29 row moved `b` → `l` keeping its action (pinned by a
1577/// test), `b` became the revision prompt it always meant, and the list
1578/// took `L` — free in magit's transient, and capital-as-variant matches
1579/// magit's own `d`/`D`, `l`/`L`, `b`/`B` pairs in file-dispatch.
1580///
1581/// Deferred, with magit's keys reserved so they stay free: `s`/`S`
1582/// spin-off/spin-out, `C` configure (a sub-transient over
1583/// `branch.<name>.*` that likely belongs to `:customize`, not a
1584/// hand-rolled menu), `X` reset (wants MG.23j's commit picker).
1585fn branch_transient(ids: &MagitActionIds, workdir: &std::path::Path) -> TransientSpec {
1586    TransientSpec {
1587        title: "Branch".into(),
1588        groups: vec![
1589            row_group("Checkout", ids, BRANCH_CHECKOUT_ROWS),
1590            row_group("Create", ids, BRANCH_CREATE_ROWS),
1591            row_group("Do", ids, BRANCH_DO_ROWS),
1592        ]
1593        .into_iter()
1594        .chain(config_group(ids, BRANCH_CONFIG_ROWS, workdir))
1595        .collect(),
1596        preview: None,
1597        footer: Some("q dismiss  Esc/BS back".into()),
1598    }
1599}
1600
1601/// MG.37: the `T` notes submenu, gated on whether a notes merge is
1602/// stopped mid-flight.
1603///
1604/// Keys are magit's own (`magit-notes`): `T` edit, `r` remove, `m`
1605/// merge, `p` prune, and — while merging — `c` commit / `a` abort.
1606///
1607/// **Gated for the same reason `B` bisect is** (MG.21g): outside a
1608/// merge, `git notes merge --commit` / `--abort` error, so ungated rows
1609/// would look actionable and fail. Inside one, edit / remove / merge /
1610/// prune are what you must not be doing, so the menu shows only the two
1611/// ways out.
1612///
1613/// **MG.43g added `C`** (`core.notesRef`), the first of magit's four
1614/// configure rows here. `TransientItemKind::Variable` exists now, so
1615/// the remaining three (`c` / `d` / `D`, chiefly `notes.displayRef`)
1616/// are table entries rather than a missing capability. Only outside a
1617/// merge: a stopped notes merge shows the ways out and nothing else.
1618fn notes_transient(
1619    ids: &MagitActionIds,
1620    workdir: &std::path::Path,
1621    merge_in_progress: bool,
1622) -> TransientSpec {
1623    let groups = if merge_in_progress {
1624        vec![TransientGroup {
1625            label: "Notes merge in progress".into(),
1626            items: vec![
1627                action_or_placeholder(
1628                    ids.get("action:magit-global-note-merge-commit"),
1629                    "c",
1630                    "commit merge",
1631                    "Finish the notes merge, keeping the resolved notes",
1632                    "note_merge_commit_op",
1633                ),
1634                action_or_placeholder(
1635                    ids.get("action:magit-global-note-merge-abort"),
1636                    "a",
1637                    "abort merge",
1638                    "Abandon the notes merge, restoring the notes ref",
1639                    "note_merge_abort_op",
1640                ),
1641            ],
1642        }]
1643    } else {
1644        vec![TransientGroup {
1645            label: "Notes".into(),
1646            items: vec![
1647                action_or_placeholder(
1648                    ids.get("action:magit-global-note-edit"),
1649                    "T",
1650                    "edit",
1651                    "Edit the note on a commit — opens an editable buffer",
1652                    "note_edit_op",
1653                ),
1654                action_or_placeholder(
1655                    ids.get("action:magit-global-note-remove"),
1656                    "r",
1657                    "remove",
1658                    "Remove the note from a commit",
1659                    "note_remove_op",
1660                ),
1661                action_or_placeholder(
1662                    ids.get("action:magit-global-note-merge"),
1663                    "m",
1664                    "merge",
1665                    "Merge another notes ref into this one",
1666                    "note_merge_op",
1667                ),
1668                action_or_placeholder(
1669                    ids.get("action:magit-global-note-prune"),
1670                    "p",
1671                    "prune",
1672                    "Drop notes whose commit no longer exists (asks first)",
1673                    "note_prune_op",
1674                ),
1675            ],
1676        }]
1677    };
1678    // MG.43g: `C` only outside a merge — a stopped notes merge shows
1679    // the ways out and nothing else, which is the whole point of the
1680    // gate. Changing the notes ref mid-merge is exactly what the user
1681    // must not be doing.
1682    let groups: Vec<TransientGroup> = if merge_in_progress {
1683        groups
1684    } else {
1685        groups
1686            .into_iter()
1687            .chain(config_group(ids, NOTES_CONFIG_ROWS, workdir))
1688            .collect()
1689    };
1690    TransientSpec {
1691        title: "Notes".into(),
1692        groups,
1693        preview: None,
1694        footer: Some("q dismiss  Esc/BS back".into()),
1695    }
1696}
1697
1698/// MG.38: the `"` subtree submenu.
1699///
1700/// **Magit's key for subtree is `O`, and `O` is not free here** — the
1701/// MG.34–MG.40 scoping note said it was, and that was wrong: `O` is the
1702/// reset submenu, which is evil-collection-magit's remap of magit's `X`.
1703/// evil-collection resolves the collision it created, and this follows
1704/// it verbatim: `(magit-dispatch "O" "\"" magit-subtree)`. So subtree
1705/// takes `"`, which is the reference set the standing rule names.
1706///
1707/// Every row prompts, because every subtree operation needs a
1708/// `--prefix=<dir>` and most need a repository and a ref too — none of
1709/// which a menu can guess.
1710fn subtree_transient(ids: &MagitActionIds) -> TransientSpec {
1711    TransientSpec {
1712        title: "Subtree".into(),
1713        groups: vec![row_group("Actions", ids, SUBTREE_ROWS)],
1714        preview: None,
1715        footer: Some("q dismiss  Esc/BS back".into()),
1716    }
1717}
1718
1719/// MG.39: the `w` patch submenu, gated on whether a `git am` is stopped.
1720///
1721/// Magit splits these across `w` (am) and `W` (patch); one submenu holds
1722/// both because apply-a-patch and create-a-patch are the two halves of
1723/// the same email workflow and there are five rows between them.
1724///
1725/// **Gated like `B` and `T`:** an `am` stops on a patch that will not
1726/// apply, and outside that state `--continue` / `--skip` / `--abort`
1727/// error. Inside it, applying more patches is what you must not do.
1728fn patch_transient(ids: &MagitActionIds, am_in_progress: bool) -> TransientSpec {
1729    let groups = if am_in_progress {
1730        vec![TransientGroup {
1731            label: "Patch application stopped".into(),
1732            items: vec![
1733                action_or_placeholder(
1734                    ids.get("action:magit-global-am-continue"),
1735                    "c",
1736                    "continue",
1737                    "Resume applying after resolving the conflict",
1738                    "am_continue_op",
1739                ),
1740                action_or_placeholder(
1741                    ids.get("action:magit-global-am-skip"),
1742                    "s",
1743                    "skip",
1744                    "Skip the patch that would not apply",
1745                    "am_skip_op",
1746                ),
1747                action_or_placeholder(
1748                    ids.get("action:magit-global-am-abort"),
1749                    "a",
1750                    "abort",
1751                    "Abandon the whole apply, restoring the branch",
1752                    "am_abort_op",
1753                ),
1754            ],
1755        }]
1756    } else {
1757        vec![TransientGroup {
1758            label: "Patches".into(),
1759            items: vec![
1760                action_or_placeholder(
1761                    ids.get("action:magit-global-am-apply"),
1762                    "w",
1763                    "apply patches",
1764                    "Apply a mailbox of patches (git am)",
1765                    "am_apply_op",
1766                ),
1767                action_or_placeholder(
1768                    ids.get("action:magit-global-format-patch"),
1769                    "W",
1770                    "create patches",
1771                    "Write a commit range out as .patch files",
1772                    "format_patch_op",
1773                ),
1774            ],
1775        }]
1776    };
1777    TransientSpec {
1778        title: "Patches".into(),
1779        groups,
1780        preview: None,
1781        footer: Some("q dismiss  Esc/BS back".into()),
1782    }
1783}
1784
1785/// MG.21g: the `B` bisect submenu, gated on whether a bisect is
1786/// running.
1787///
1788/// **Why the menu is gated rather than showing everything.** `good` /
1789/// `bad` / `skip` / `reset` outside a bisect are not merely useless —
1790/// git errors on them, so they would be rows that look actionable and
1791/// produce a log line. `start` *during* a bisect is the same in
1792/// reverse. Magit gates this menu for exactly these reasons, and the
1793/// no-inert-rows policy says the same thing from our side.
1794///
1795/// **The gate is a `stat`, not a git call.** This spec is built when
1796/// `C-c g` is pressed — on the actor thread — so answering "is a
1797/// bisect running" by spawning `git` would be process-spawn latency on
1798/// a keystroke path (paramount goal #1). `Bisect::in_progress` reads
1799/// `.git/BISECT_LOG`, which is the file git itself creates and removes.
1800/// Discovering the repository is the same `magit_workdir` lookup every
1801/// magit chord already does.
1802/// MG.23k: `D` — the arguments the view you are in can be re-run with.
1803///
1804/// One menu, whose *content* is chosen by the major mode, because one
1805/// chord serves what magit splits across `D` (diff) and `L` (log) —
1806/// `L` is the bottom-of-screen motion here and stays off chords. See
1807/// `MagitView::argument_flags`.
1808///
1809/// A buffer with no arguments gets a menu that says so rather than an
1810/// empty one: the chord is bound on `magit-core-mode`, so it fires in
1811/// every magit buffer, and silence would read as a broken key.
1812pub fn view_arguments_transient(ids: &MagitActionIds, ctx: &TransientContext) -> TransientSpec {
1813    let (title, flags) = if ctx.is_major(crate::MagitDiffMode::mode_id().as_str()) {
1814        ("Diff arguments", crate::magit_diff_mode::DIFF_ARGS)
1815    } else if ctx.is_major(crate::MagitLogMode::mode_id().as_str()) {
1816        ("Log arguments", crate::magit_log_mode::LOG_ARGS)
1817    } else {
1818        ("Arguments", &[] as &[crate::magit_global_mode::RemoteFlag])
1819    };
1820
1821    if flags.is_empty() {
1822        return TransientSpec {
1823            title: title.into(),
1824            groups: vec![TransientGroup {
1825                label: "This buffer takes no arguments".into(),
1826                items: Vec::new(),
1827            }],
1828            preview: None,
1829            footer: Some("q dismiss".into()),
1830        };
1831    }
1832
1833    TransientSpec {
1834        title: title.into(),
1835        groups: vec![
1836            TransientGroup {
1837                label: "Arguments".into(),
1838                items: flag_items_from(flags),
1839            },
1840            TransientGroup {
1841                label: "Actions".into(),
1842                items: vec![action_or_placeholder(
1843                    ids.get("action:magit-view-refresh-args"),
1844                    "g",
1845                    "refresh",
1846                    "Re-run with these arguments",
1847                    "view_args_op",
1848                )],
1849            },
1850        ],
1851        preview: None,
1852        footer: Some("q dismiss  Esc/BS back".into()),
1853    }
1854}
1855
1856// MR.4: the seven cwd-based gate readers that stood here are gone.
1857//
1858// Each answered "is a bisect / rebase / merge / … stopped" by probing
1859// `magit_workdir()` — the process's repository — which is the wrong
1860// question once a menu belongs to the buffer it was opened over. The
1861// same seven answers now come from `DispatchGates::probe_in`, which
1862// takes the repository as an argument, so there is nowhere left for a
1863// row to read the working directory from.
1864
1865fn bisect_transient(ids: &MagitActionIds, in_progress: bool) -> TransientSpec {
1866    let items = if in_progress {
1867        vec![
1868            action_or_placeholder(
1869                ids.get("action:magit-global-bisect-good"),
1870                "g",
1871                "good",
1872                "Mark the revision git checked out as good",
1873                "bisect_good_op",
1874            ),
1875            action_or_placeholder(
1876                ids.get("action:magit-global-bisect-bad"),
1877                "b",
1878                "bad",
1879                "Mark the revision git checked out as bad",
1880                "bisect_bad_op",
1881            ),
1882            action_or_placeholder(
1883                ids.get("action:magit-global-bisect-skip"),
1884                "k",
1885                "skip",
1886                "Skip this revision — it cannot be tested",
1887                "bisect_skip_op",
1888            ),
1889            action_or_placeholder(
1890                ids.get("action:magit-global-bisect-reset"),
1891                "r",
1892                "reset",
1893                "End the bisect and return to where it started",
1894                "bisect_reset_op",
1895            ),
1896        ]
1897    } else {
1898        vec![action_or_placeholder(
1899            ids.get("action:magit-global-bisect-start"),
1900            "B",
1901            "start",
1902            "Start a bisect — asks for a bad then a good revision",
1903            "bisect_start_op",
1904        )]
1905    };
1906
1907    TransientSpec {
1908        title: if in_progress {
1909            "Bisect (in progress)".into()
1910        } else {
1911            "Bisect".into()
1912        },
1913        groups: vec![TransientGroup {
1914            label: "Actions".into(),
1915            items,
1916        }],
1917        preview: None,
1918        footer: Some("q dismiss  Esc/BS back".into()),
1919    }
1920}
1921
1922/// MG.23h: the `s` row, which means two different things.
1923///
1924/// In magit-status, "open the status buffer" is a no-op on the buffer
1925/// you are already looking at — so the row becomes the section jump,
1926/// which is the useful thing to want from a menu there. Everywhere
1927/// else it opens the buffer.
1928///
1929/// This is magit's own shape, on magit's own predicate: its dispatch
1930/// carries two `j` rows, `magit-status-jump :if-mode magit-status-mode`
1931/// and `magit-status-quick :if-not-mode magit-status-mode`. Ours keeps
1932/// the key on `s` (magit leaves `s` empty at this level, so there is
1933/// nothing to collide with) and swaps the meaning the same way.
1934fn status_row(ids: &MagitActionIds, ctx: &TransientContext) -> TransientItem {
1935    if ctx.is_major(crate::MagitStatusMode::mode_id().as_str()) {
1936        return TransientItem {
1937            key: vec!["s".into()],
1938            label: "jump".into(),
1939            description: "Jump to a section of this buffer".into(),
1940            kind: TransientItemKind::Submenu(Arc::new(jump_transient(ids))),
1941        };
1942    }
1943    action_or_placeholder(
1944        ids.get("action:magit-global-status"),
1945        "s",
1946        "status",
1947        "Open the status buffer",
1948        "status_op",
1949    )
1950}
1951
1952/// MG.23h: magit's `magit-status-jump`, over the sections we render.
1953///
1954/// Keys are magit's where the sections coincide (`s` staged, `u`
1955/// unstaged, `n` untracked, `z` stashes). Recent commits has no magit
1956/// counterpart — its status buffer reaches unpushed/unpulled instead —
1957/// so `c` is ours, free at this level and mnemonic.
1958/// MG.41e: the `r` submenu, gated on whether a rebase is stopped.
1959/// MG.42-E4: `A`, gated on whether a cherry-pick is stopped.
1960fn cherry_pick_transient(ids: &MagitActionIds, in_progress: bool) -> TransientSpec {
1961    let groups = if in_progress {
1962        vec![row_group(
1963            "Cherry-pick in progress",
1964            ids,
1965            CHERRY_PICK_SEQUENCE_ROWS,
1966        )]
1967    } else {
1968        vec![row_group("Cherry-pick", ids, CHERRY_PICK_ROWS)]
1969    };
1970    TransientSpec {
1971        title: "Cherry-pick".into(),
1972        groups,
1973        preview: None,
1974        footer: Some("q dismiss  Esc/BS back".into()),
1975    }
1976}
1977
1978/// MG.42-E4: `_`, gated the same way.
1979fn revert_transient(ids: &MagitActionIds, in_progress: bool) -> TransientSpec {
1980    let groups = if in_progress {
1981        vec![row_group("Revert in progress", ids, REVERT_SEQUENCE_ROWS)]
1982    } else {
1983        vec![row_group("Revert", ids, REVERT_ROWS)]
1984    };
1985    TransientSpec {
1986        title: "Revert".into(),
1987        groups,
1988        preview: None,
1989        footer: Some("q dismiss  Esc/BS back".into()),
1990    }
1991}
1992
1993fn merge_transient(ids: &MagitActionIds, in_progress: bool) -> TransientSpec {
1994    let groups = if in_progress {
1995        vec![row_group("Merge in progress", ids, MERGE_SEQUENCE_ROWS)]
1996    } else {
1997        vec![row_group("Merge", ids, MERGE_ROWS)]
1998    };
1999    TransientSpec {
2000        title: "Merge".into(),
2001        groups,
2002        preview: None,
2003        footer: Some("q dismiss  Esc/BS back".into()),
2004    }
2005}
2006
2007fn tag_transient(ids: &MagitActionIds, workdir: &std::path::Path) -> TransientSpec {
2008    TransientSpec {
2009        title: "Tag".into(),
2010        groups: vec![row_group("Tag", ids, TAG_ROWS)]
2011            .into_iter()
2012            .chain(config_group(ids, TAG_CONFIG_ROWS, workdir))
2013            .collect(),
2014        preview: None,
2015        footer: Some("q dismiss  Esc/BS back".into()),
2016    }
2017}
2018
2019fn rebase_transient(ids: &MagitActionIds, in_progress: bool) -> TransientSpec {
2020    let groups = if in_progress {
2021        vec![row_group("Rebase in progress", ids, REBASE_SEQUENCE_ROWS)]
2022    } else {
2023        vec![row_group("Rebase", ids, REBASE_START_ROWS)]
2024    };
2025    TransientSpec {
2026        title: "Rebase".into(),
2027        groups,
2028        preview: None,
2029        footer: Some("q dismiss  Esc/BS back".into()),
2030    }
2031}
2032
2033fn jump_transient(ids: &MagitActionIds) -> TransientSpec {
2034    TransientSpec {
2035        title: "Jump to section".into(),
2036        groups: vec![row_group("Sections", ids, JUMP_ROWS)],
2037        preview: None,
2038        footer: Some("q dismiss  Esc/BS back".into()),
2039    }
2040}
2041
2042/// Build the repo-level dispatch transient (`C-c g`).
2043///
2044/// Key assignments follow Emacs magit's own `magit-dispatch` where a
2045/// corresponding lattice capability exists (`s` status, `c` commit,
2046/// `d` diff, `l` log, `b` branch, `z` stash, `r` rebase, `f` fetch,
2047/// `F` pull, `P` push) — muscle memory carries across editors for a
2048/// menu this central, and every one of these keys means the same
2049/// thing in magit. Magit entries with no lattice implementation
2050/// behind them (bisect, submodule, patch) are deliberately ABSENT
2051/// rather than present-and-inert: a menu row that does nothing when
2052/// pressed is worse than a row that isn't there.
2053///
2054/// MG.23h: `ctx` is where the menu was opened from. Two things vary on
2055/// it, both mirroring a predicate magit puts on its own dispatch — the
2056/// `s` row's meaning ([`status_row`]) and the section-acting rows
2057/// ([`applying_changes_items`]).
2058pub fn dispatch_transient(
2059    ids: &MagitActionIds,
2060    ctx: &TransientContext,
2061    workdir: &std::path::Path,
2062) -> TransientSpec {
2063    dispatch_transient_with(ids, ctx, &DispatchGates::probe_in(workdir))
2064}
2065
2066/// One repository question, asked once — the shape every gate above
2067/// shares.
2068fn repo_flag(workdir: &std::path::Path, ask: impl Fn(&lattice_vcs::Repository) -> bool) -> bool {
2069    lattice_vcs::Repository::discover(workdir)
2070        .ok()
2071        .map(|repo| ask(&repo))
2072        .unwrap_or(false)
2073}
2074
2075/// The mid-flight git operations the menu gates rows on.
2076///
2077/// A struct rather than positional `bool`s: they are adjacent, same
2078/// type, and both mean "something is half-done" — exactly the pair that
2079/// transposes silently, and a transposed gate shows the wrong menu with
2080/// no error.
2081#[derive(Debug, Clone, Default, PartialEq, Eq)]
2082pub struct DispatchGates {
2083    /// MG.21g: `git bisect` is running.
2084    pub bisect: bool,
2085    /// MG.37: `git notes merge` stopped on a conflict.
2086    pub notes_merge: bool,
2087    /// MG.39: `git am` stopped on a patch that would not apply.
2088    pub am: bool,
2089    /// MG.41e: a rebase is stopped — mid-conflict or at an `edit` stop.
2090    pub rebase: bool,
2091    /// MG.42-E4: a cherry-pick sequence stopped on a conflict.
2092    pub cherry_pick: bool,
2093    /// MG.42-E4: a revert sequence stopped on a conflict.
2094    pub revert: bool,
2095    /// A merge stopped on a conflict. `MERGE_HEAD` was checked nowhere
2096    /// before this, so the merge menu offered only ways IN while git
2097    /// refused every one of them.
2098    pub merge: bool,
2099    /// MR.6: the repository these gates describe.
2100    ///
2101    /// Carried rather than threaded separately because a menu's rows ask
2102    /// two kinds of question about one repository — "is a rebase
2103    /// stopped" and "what is `pull.rebase` set to" — and answering them
2104    /// about *different* repositories is the bug this whole series is
2105    /// about. One value, probed once, for both.
2106    pub workdir: std::path::PathBuf,
2107}
2108
2109impl DispatchGates {
2110    /// Read the gates from the repository.
2111    ///
2112    /// Every *guard* over this menu passes gates in rather than calling
2113    /// this, deliberately: probing would make a test's row count depend
2114    /// on whether the developer's own checkout happened to be mid-bisect
2115    /// while the suite ran — a flake that reads as a real regression.
2116    /// MR.4: probed in `workdir`, which is the repository of the buffer
2117    /// the menu was opened over — not the process's.
2118    ///
2119    /// This is what the rows are *about*: offering `rebase --continue`
2120    /// because some other checkout is mid-rebase is a row that does
2121    /// nothing here, and hiding it while THIS repository is stopped is
2122    /// worse — the way out of a stopped rebase is missing from the menu
2123    /// whose job is to show it.
2124    pub fn probe_in(workdir: &std::path::Path) -> Self {
2125        let flight = lattice_vcs::Repository::discover(workdir)
2126            .ok()
2127            .and_then(|repo| lattice_vcs::InFlightOp::detect(&repo));
2128        Self {
2129            workdir: workdir.to_path_buf(),
2130            bisect: repo_flag(workdir, lattice_vcs::Bisect::in_progress),
2131            notes_merge: repo_flag(workdir, |repo| {
2132                lattice_vcs::Note::merge_in_progress(repo.gitdir())
2133            }),
2134            am: flight == Some(lattice_vcs::InFlightOp::ApplyPatch),
2135            rebase: flight == Some(lattice_vcs::InFlightOp::Rebase),
2136            cherry_pick: flight == Some(lattice_vcs::InFlightOp::CherryPick),
2137            revert: flight == Some(lattice_vcs::InFlightOp::Revert),
2138            merge: flight == Some(lattice_vcs::InFlightOp::Merge),
2139        }
2140    }
2141}
2142
2143/// [`dispatch_transient`] with the gates supplied rather than probed —
2144/// pure, and the form every guard over this menu uses.
2145/// MG.49: one root menu — reachable from the dispatch **and** from its
2146/// own chord.
2147///
2148/// Emacs binds these on `magit-mode-map`, the parent keymap every
2149/// magit-derived mode inherits, so `z` opens stash from the log buffer
2150/// and the diff buffer as much as from status. `magit-core-mode` is the
2151/// same shape here, which is why the chords live there rather than on
2152/// `magit-status-mode`.
2153///
2154/// Chords follow **evil-collection-magit**, the reference this crate
2155/// already uses for `gr` / `O` / `x` — so push is `p`, not magit's `P`
2156/// (`p` is free in a read-only buffer; `P` is not, being paste).
2157pub struct RootMenu {
2158    /// Registered `TransientSourceRegistry` name, and the string the
2159    /// chord's `Effect::OpenTransient` names.
2160    pub source: &'static str,
2161    /// The chord `magit-core-mode` binds, or `None` when the key is a
2162    /// live vim chord in a read-only buffer and the menu is reachable
2163    /// only through the dispatch.
2164    ///
2165    /// **This is the constraint emacs does not have.** Magit binds all
2166    /// seventeen on `magit-mode-map` because emacs is not modal. Here a
2167    /// minor-mode layer beats the builtin vim layer, so binding `f`
2168    /// would take find-char away inside every magit buffer — and a
2169    /// magit buffer is text you navigate. Vim's grammar IS the public
2170    /// command API (paramount goal #3), so it wins: only keys whose vim
2171    /// meaning is an *editing operator* — inert where nothing is
2172    /// editable — are free to take.
2173    pub chord: Option<&'static str>,
2174    /// The action the chord fires.
2175    pub action: &'static str,
2176    /// Keymap documentation.
2177    pub doc: &'static str,
2178}
2179
2180/// The seventeen root menus, in dispatch order.
2181///
2182/// A table rather than seventeen hand-written registrations + seventeen
2183/// keymap entries + seventeen handlers: those three lists have to agree,
2184/// and three parallel lists that must agree are exactly where a gap goes
2185/// unnoticed — the failure `magit-hunk-mode` was created to end.
2186pub const ROOT_MENUS: &[RootMenu] = &[
2187    RootMenu {
2188        source: "magit-menu-diff",
2189        // `d`: delete operator, but `magit-branch` / `-remote` / `-stash` /
2190        // `-submodule` majors bind `d` for delete-this-row, and a minor
2191        // shadows a major.
2192
2193        // Reachable via the dispatch, which `C-c g` opens.
2194        chord: None,
2195        action: "action:magit-menu-diff",
2196        doc: "Diff menu",
2197    },
2198    RootMenu {
2199        source: "magit-menu-commit",
2200        // `c`: change operator, but `magit-branch` binds `c` (create) and
2201        // `magit-refs` binds `c` (checkout).
2202
2203        // Reachable via the dispatch, which `C-c g` opens.
2204        chord: None,
2205        action: "action:magit-menu-commit",
2206        doc: "Commit menu",
2207    },
2208    RootMenu {
2209        source: "magit-menu-log",
2210        // `l`: right-motion — dispatch only.
2211        chord: None,
2212        action: "action:magit-menu-log",
2213        doc: "Log menu",
2214    },
2215    RootMenu {
2216        source: "magit-menu-cherry-pick",
2217        // `A`: append-EOL operator — inert; was already this mode's chord.
2218        chord: Some("A"),
2219        action: "action:magit-menu-cherry-pick",
2220        doc: "Cherry-pick menu",
2221    },
2222    RootMenu {
2223        source: "magit-menu-revert",
2224        // `_`: documented free by MG.20; was already this mode's chord.
2225        chord: Some("_"),
2226        action: "action:magit-menu-revert",
2227        doc: "Revert menu",
2228    },
2229    RootMenu {
2230        source: "magit-menu-reset",
2231        // `O`: open-line-above operator — inert; was already this mode's chord.
2232        chord: Some("O"),
2233        action: "action:magit-menu-reset",
2234        doc: "Reset menu",
2235    },
2236    RootMenu {
2237        source: "magit-menu-bisect",
2238        // `B`: back-WORD motion (also test-enforced) — dispatch only.
2239        chord: None,
2240        action: "action:magit-menu-bisect",
2241        doc: "Bisect menu",
2242    },
2243    RootMenu {
2244        source: "magit-menu-notes",
2245        // `T`: till-back motion — dispatch only.
2246        chord: None,
2247        action: "action:magit-menu-notes",
2248        doc: "Notes menu",
2249    },
2250    RootMenu {
2251        source: "magit-menu-branch",
2252        // `b`: back-word motion — dispatch only.
2253        chord: None,
2254        action: "action:magit-menu-branch",
2255        doc: "Branch menu",
2256    },
2257    RootMenu {
2258        source: "magit-menu-stash",
2259        // `z`: vim fold prefix (`zf` / `za` / `zo`) — dispatch only.
2260        chord: None,
2261        action: "action:magit-menu-stash",
2262        doc: "Stash menu",
2263    },
2264    RootMenu {
2265        source: "magit-menu-fetch",
2266        // `f`: find-char motion — dispatch only.
2267        chord: None,
2268        action: "action:magit-menu-fetch",
2269        doc: "Fetch menu",
2270    },
2271    RootMenu {
2272        source: "magit-menu-pull",
2273        // `F`: find-char-back motion — dispatch only.
2274        chord: None,
2275        action: "action:magit-menu-pull",
2276        doc: "Pull menu",
2277    },
2278    // evil-collection-magit moves magit's `P` here; `P` is paste.
2279    RootMenu {
2280        source: "magit-menu-push",
2281        // `p`: paste — inert in a read-only buffer, and the key
2282        // evil-collection-magit itself moves push to. `magit-blame-mode`
2283        // overrides it on blob buffers, which the layer order expresses:
2284        // blame activates after the major cascade, so its layer wins.
2285        // `p`: paste is inert, but `magit-remote` (prune) and `magit-stash`
2286        // (pop) bind `p`.
2287
2288        // Reachable via the dispatch, which `C-c g` opens.
2289        chord: None,
2290        action: "action:magit-menu-push",
2291        doc: "Push menu",
2292    },
2293    RootMenu {
2294        source: "magit-menu-patches",
2295        // `w`: word motion — dispatch only.
2296        chord: None,
2297        action: "action:magit-menu-patches",
2298        doc: "Patch (am / format-patch) menu",
2299    },
2300    RootMenu {
2301        source: "magit-menu-rebase",
2302        // `r`: replace operator, but `magit-remote` binds `r` (rename).
2303
2304        // Reachable via the dispatch, which `C-c g` opens.
2305        chord: None,
2306        action: "action:magit-menu-rebase",
2307        doc: "Rebase menu",
2308    },
2309    RootMenu {
2310        source: "magit-menu-tag",
2311        // `t`: till motion — dispatch only.
2312        chord: None,
2313        action: "action:magit-menu-tag",
2314        doc: "Tag menu",
2315    },
2316    RootMenu {
2317        source: "magit-menu-merge",
2318        // `m`: set-mark — dispatch only.
2319        chord: None,
2320        action: "action:magit-menu-merge",
2321        doc: "Merge menu",
2322    },
2323];
2324
2325/// Build one root menu by name — the SAME spec the dispatch nests, so a
2326/// chord and the menu path to it can never disagree.
2327///
2328/// `None` for an unknown name; the caller then leaves the transient
2329/// unopened rather than showing an empty menu.
2330pub fn root_menu_spec(
2331    source: &str,
2332    ids: &MagitActionIds,
2333    ctx: &TransientContext,
2334    gates: &DispatchGates,
2335) -> Option<TransientSpec> {
2336    let spec = match source {
2337        "magit-menu-diff" => view_open_transient(
2338            "Diff",
2339            ids,
2340            crate::magit_diff_mode::DIFF_ARGS,
2341            DIFF_SHOW_ROWS,
2342        ),
2343        "magit-menu-commit" => commit_transient(ids),
2344        "magit-menu-log" => {
2345            view_open_transient("Log", ids, crate::magit_log_mode::LOG_ARGS, LOG_SHOW_ROWS)
2346        }
2347        "magit-menu-cherry-pick" => cherry_pick_transient(ids, gates.cherry_pick),
2348        "magit-menu-revert" => revert_transient(ids, gates.revert),
2349        "magit-menu-reset" => reset_transient(ids),
2350        "magit-menu-bisect" => bisect_transient(ids, gates.bisect),
2351        "magit-menu-notes" => notes_transient(ids, &gates.workdir, gates.notes_merge),
2352        "magit-menu-branch" => branch_transient(ids, &gates.workdir),
2353        "magit-menu-stash" => stash_transient(ids),
2354        "magit-menu-fetch" => remote_op_transient(
2355            "Fetch",
2356            RemoteOp::FETCH,
2357            ids,
2358            FETCH_ROWS,
2359            FETCH_CONFIG_ROWS,
2360            &gates.workdir,
2361        ),
2362        "magit-menu-pull" => remote_op_transient(
2363            "Pull",
2364            RemoteOp::PULL,
2365            ids,
2366            PULL_ROWS,
2367            PULL_CONFIG_ROWS,
2368            &gates.workdir,
2369        ),
2370        "magit-menu-push" => remote_op_transient(
2371            "Push",
2372            RemoteOp::PUSH,
2373            ids,
2374            PUSH_ROWS,
2375            PUSH_CONFIG_ROWS,
2376            &gates.workdir,
2377        ),
2378        "magit-menu-patches" => patch_transient(ids, gates.am),
2379        "magit-menu-rebase" => rebase_transient(ids, gates.rebase),
2380        "magit-menu-tag" => tag_transient(ids, &gates.workdir),
2381        "magit-menu-merge" => merge_transient(ids, gates.merge),
2382        _ => return None,
2383    };
2384    let _ = ctx;
2385    Some(spec)
2386}
2387
2388pub fn dispatch_transient_with(
2389    ids: &MagitActionIds,
2390    ctx: &TransientContext,
2391    gates: &DispatchGates,
2392) -> TransientSpec {
2393    let _bisect_in_progress = gates.bisect;
2394    TransientSpec {
2395        title: "Magit dispatch".into(),
2396        groups: vec![
2397            TransientGroup {
2398                label: "Working tree".into(),
2399                items: vec![
2400                    status_row(ids, ctx),
2401                    // MG.43h: a submenu now. The toggles are consumed
2402                    // — `action:magit-global-diff` declares the schema
2403                    // they project onto, and the values ride to the
2404                    // opened buffer via `ViewArgsRequests`.
2405                    TransientItem {
2406                        key: vec!["d".into()],
2407                        label: "diff".into(),
2408                        description: "Diff the working tree against HEAD".into(),
2409                        kind: TransientItemKind::Submenu(Arc::new(
2410                            root_menu_spec("magit-menu-diff", ids, ctx, gates)
2411                                .expect("`magit-menu-diff` is in ROOT_MENUS"),
2412                        )),
2413                    },
2414                    // PD.6: the editable cross-file diff, promoted to the
2415                    // dispatch's top level.
2416                    //
2417                    // It shipped reachable only as `d` → `e`, one level
2418                    // down inside the Diff menu, next to three rows that
2419                    // open patch text. That put the view people would
2420                    // reach for most often behind the one they would
2421                    // reach for least, and made it look like a variant of
2422                    // the patch views rather than the different surface it
2423                    // is. `e` for edit, matching the row it also keeps
2424                    // inside the Diff menu — the two front-ends emit the
2425                    // same action, so they cannot drift.
2426                    //
2427                    // An Action row, not a Submenu: this opens a view,
2428                    // and there is nothing to choose first.
2429                    action_or_placeholder(
2430                        ids.get("action:magit-project-diff"),
2431                        "e",
2432                        "edit diff",
2433                        "Edit the working-tree diff across every changed file",
2434                        "project_diff",
2435                    ),
2436                    TransientItem {
2437                        key: vec!["c".into()],
2438                        label: "commit".into(),
2439                        description: "Commit changes".into(),
2440                        kind: TransientItemKind::Submenu(Arc::new(
2441                            root_menu_spec("magit-menu-commit", ids, ctx, gates)
2442                                .expect("`magit-menu-commit` is in ROOT_MENUS"),
2443                        )),
2444                    },
2445                ],
2446            },
2447            // MG.23b/MG.23h: magit's own "Applying changes" group.
2448            //
2449            // The two repo-wide rows are unconditional — `add --update`
2450            // and `reset` need no target and work from anywhere, so
2451            // gating them (as magit does) would be strictly less useful.
2452            // The section-acting rows ARE gated, on the same test
2453            // magit's `:if-derived magit-mode` makes: they resolve the
2454            // hunk under the cursor, and outside a magit buffer there is
2455            // no diff text to find one in.
2456            //
2457            // Magit's `s` / `u` rows are deliberately absent. They would
2458            // collide with the `s` row above, and unlike `a` / `-` / `x`
2459            // their chords are the first thing anyone reaches for — a
2460            // menu path to them earns nothing and costs the status key.
2461            TransientGroup {
2462                label: "Applying changes".into(),
2463                items: applying_changes_items(ids, ctx),
2464            },
2465            TransientGroup {
2466                label: "History".into(),
2467                items: vec![
2468                    // MG.43h: the log's peer, same mechanism.
2469                    TransientItem {
2470                        key: vec!["l".into()],
2471                        label: "log".into(),
2472                        description: "Show commit history".into(),
2473                        kind: TransientItemKind::Submenu(Arc::new(
2474                            root_menu_spec("magit-menu-log", ids, ctx, gates)
2475                                .expect("`magit-menu-log` is in ROOT_MENUS"),
2476                        )),
2477                    },
2478                    // MG.23j: magit's own keys, in magit's own ungated
2479                    // group. They need a commit and this menu has no
2480                    // cursor on one — so the action they fire asks,
2481                    // which is exactly what magit's `A` / `V` / `X`
2482                    // transients do and why magit does NOT gate them.
2483                    //
2484                    // The same actions the chords fire: in a magit
2485                    // buffer they take the commit under the cursor, and
2486                    // everywhere else they open the commit picker. One
2487                    // action, both surfaces.
2488                    // MG.42-E4: gated submenus. A stopped sequence
2489                    // needs continue / skip / abort, and offering
2490                    // "pick another commit" mid-conflict is the wrong
2491                    // menu entirely.
2492                    TransientItem {
2493                        key: vec!["A".into()],
2494                        label: "cherry-pick".into(),
2495                        description: "Cherry-pick, or drive a stopped one".into(),
2496                        kind: TransientItemKind::Submenu(Arc::new(
2497                            root_menu_spec("magit-menu-cherry-pick", ids, ctx, gates)
2498                                .expect("`magit-menu-cherry-pick` is in ROOT_MENUS"),
2499                        )),
2500                    },
2501                    TransientItem {
2502                        key: vec!["_".into()],
2503                        label: "revert".into(),
2504                        description: "Revert, or drive a stopped one".into(),
2505                        kind: TransientItemKind::Submenu(Arc::new(
2506                            root_menu_spec("magit-menu-revert", ids, ctx, gates)
2507                                .expect("`magit-menu-revert` is in ROOT_MENUS"),
2508                        )),
2509                    },
2510                    TransientItem {
2511                        key: vec!["O".into()],
2512                        label: "reset".into(),
2513                        description: "Reset this branch to a commit".into(),
2514                        kind: TransientItemKind::Submenu(Arc::new(
2515                            root_menu_spec("magit-menu-reset", ids, ctx, gates)
2516                                .expect("`magit-menu-reset` is in ROOT_MENUS"),
2517                        )),
2518                    },
2519                    // MG.21g: magit's own key, in magit's own group.
2520                    // The submenu's contents depend on whether a
2521                    // bisect is running — see `bisect_transient`.
2522                    TransientItem {
2523                        key: vec!["B".into()],
2524                        label: "bisect".into(),
2525                        description: "Find the commit that introduced a bug".into(),
2526                        kind: TransientItemKind::Submenu(Arc::new(
2527                            root_menu_spec("magit-menu-bisect", ids, ctx, gates)
2528                                .expect("`magit-menu-bisect` is in ROOT_MENUS"),
2529                        )),
2530                    },
2531                    // MG.37: magit's `T`. A submenu rather than a direct
2532                    // action because notes have four operations and two
2533                    // more while a merge is stopped — the same shape `B`
2534                    // has, and gated the same way.
2535                    TransientItem {
2536                        key: vec!["T".into()],
2537                        label: "notes".into(),
2538                        description: "Edit, remove, merge or prune commit notes".into(),
2539                        kind: TransientItemKind::Submenu(Arc::new(
2540                            root_menu_spec("magit-menu-notes", ids, ctx, gates)
2541                                .expect("`magit-menu-notes` is in ROOT_MENUS"),
2542                        )),
2543                    },
2544                ],
2545            },
2546            TransientGroup {
2547                label: "Branches".into(),
2548                items: vec![TransientItem {
2549                    key: vec!["b".into()],
2550                    label: "branch".into(),
2551                    description: "Checkout, create, or list branches".into(),
2552                    kind: TransientItemKind::Submenu(Arc::new(
2553                        root_menu_spec("magit-menu-branch", ids, ctx, gates)
2554                            .expect("`magit-menu-branch` is in ROOT_MENUS"),
2555                    )),
2556                }],
2557            },
2558            TransientGroup {
2559                label: "Stashing".into(),
2560                items: vec![TransientItem {
2561                    key: vec!["z".into()],
2562                    label: "stash".into(),
2563                    description: "Stash operations".into(),
2564                    kind: TransientItemKind::Submenu(Arc::new(
2565                        root_menu_spec("magit-menu-stash", ids, ctx, gates)
2566                            .expect("`magit-menu-stash` is in ROOT_MENUS"),
2567                    )),
2568                }],
2569            },
2570            TransientGroup {
2571                label: "Remotes".into(),
2572                items: vec![
2573                    TransientItem {
2574                        key: vec!["f".into()],
2575                        label: "fetch".into(),
2576                        description: "Fetch from the remote without merging".into(),
2577                        kind: TransientItemKind::Submenu(Arc::new(
2578                            root_menu_spec("magit-menu-fetch", ids, ctx, gates)
2579                                .expect("`magit-menu-fetch` is in ROOT_MENUS"),
2580                        )),
2581                    },
2582                    // MG.41c: pull IS a submenu now. It was a plain row
2583                    // because `--ff-only` was not optional and there was
2584                    // nothing else to show — but magit's pull has three
2585                    // destinations, and `-r` / `-a` are real toggles.
2586                    TransientItem {
2587                        key: vec!["F".into()],
2588                        label: "pull".into(),
2589                        description: "Fetch + integrate from the remote".into(),
2590                        kind: TransientItemKind::Submenu(Arc::new(
2591                            root_menu_spec("magit-menu-pull", ids, ctx, gates)
2592                                .expect("`magit-menu-pull` is in ROOT_MENUS"),
2593                        )),
2594                    },
2595                    TransientItem {
2596                        key: vec!["p".into()],
2597                        label: "push".into(),
2598                        description: "Push to the remote".into(),
2599                        kind: TransientItemKind::Submenu(Arc::new(
2600                            root_menu_spec("magit-menu-push", ids, ctx, gates)
2601                                .expect("`magit-menu-push` is in ROOT_MENUS"),
2602                        )),
2603                    },
2604                    // MG.21d: magit's `M`. Not a submenu — the row
2605                    // opens the remote list buffer, because the URLs
2606                    // are the point and a menu cannot show them. `M`
2607                    // costs nothing here: transient keys do not shadow
2608                    // the vim grammar, which is also why `M` stays
2609                    // unbound as a chord inside magit buffers.
2610                    action_or_placeholder(
2611                        ids.get("action:magit-global-remote"),
2612                        "M",
2613                        "remote",
2614                        "Manage remotes — add, rename, remove, set URL, prune",
2615                        "remote_manage_op",
2616                    ),
2617                    // MG.21i: magit's `o`. Like `M`, a buffer rather
2618                    // than a submenu — and here magit agrees, since
2619                    // `magit-list-submodules` is a buffer there too.
2620                    action_or_placeholder(
2621                        ids.get("action:magit-global-submodule"),
2622                        "o",
2623                        "submodule",
2624                        "Manage submodules — add, update, sync, remove",
2625                        "submodule_op",
2626                    ),
2627                    // MG.40: magit's `Y`. A buffer, like `y` above and
2628                    // for the same reason — the answer is a list.
2629                    action_or_placeholder(
2630                        ids.get("action:magit-global-cherries"),
2631                        "Y",
2632                        "cherries",
2633                        "Which commits are not upstream yet, and which already are",
2634                        "cherries_op",
2635                    ),
2636                    // MG.35: magit's `y`. A buffer for the same reason
2637                    // `M` and `o` are — the answer is a list with a
2638                    // column of object ids, which a menu cannot show.
2639                    action_or_placeholder(
2640                        ids.get("action:magit-global-refs"),
2641                        "y",
2642                        "refs",
2643                        "Show every branch, remote-tracking branch and tag",
2644                        "show_refs",
2645                    ),
2646                    // MG.36: magit's `C`. In the Remotes group because
2647                    // that is what a clone reads from — magit files it
2648                    // under its own dispatch's ungated set for the same
2649                    // reason it needs no repository to be open.
2650                    action_or_placeholder(
2651                        ids.get("action:magit-global-clone"),
2652                        "C",
2653                        "clone",
2654                        "Clone a repository — asks for the URL, then where to put it",
2655                        "clone_op",
2656                    ),
2657                ],
2658            },
2659            TransientGroup {
2660                label: "Misc".into(),
2661                items: vec![
2662                    // MG.38: evil-collection-magit's key for subtree,
2663                    // because magit's own `O` is the reset submenu here.
2664                    TransientItem {
2665                        key: vec!["\"".into()],
2666                        label: "subtree".into(),
2667                        description: "Add, merge, pull, push or split a subtree".into(),
2668                        kind: TransientItemKind::Submenu(Arc::new(subtree_transient(ids))),
2669                    },
2670                    // MG.39: magit's `w`, holding `W`'s rows too.
2671                    TransientItem {
2672                        key: vec!["w".into()],
2673                        label: "patches".into(),
2674                        description: "Apply or create email patches".into(),
2675                        kind: TransientItemKind::Submenu(Arc::new(
2676                            root_menu_spec("magit-menu-patches", ids, ctx, gates)
2677                                .expect("`magit-menu-patches` is in ROOT_MENUS"),
2678                        )),
2679                    },
2680                    // MG.41e: a submenu now, gated like bisect / am.
2681                    // A stopped rebase needs continue / skip / abort,
2682                    // and offering "start an interactive rebase" while
2683                    // one is half-done is the wrong menu entirely.
2684                    TransientItem {
2685                        key: vec!["r".into()],
2686                        label: "rebase".into(),
2687                        description: "Rebase, or drive a stopped one".into(),
2688                        kind: TransientItemKind::Submenu(Arc::new(
2689                            root_menu_spec("magit-menu-rebase", ids, ctx, gates)
2690                                .expect("`magit-menu-rebase` is in ROOT_MENUS"),
2691                        )),
2692                    },
2693                    // MG.23c1: magit's own keys. Both ask for their one
2694                    // value rather than taking it from context — there
2695                    // is nothing at a cursor to read from a menu opened
2696                    // anywhere.
2697                    TransientItem {
2698                        key: vec!["t".into()],
2699                        label: "tag".into(),
2700                        description: "Create or delete a tag".into(),
2701                        kind: TransientItemKind::Submenu(Arc::new(
2702                            root_menu_spec("magit-menu-tag", ids, ctx, gates)
2703                                .expect("`magit-menu-tag` is in ROOT_MENUS"),
2704                        )),
2705                    },
2706                    action_or_placeholder(
2707                        ids.get("action:magit-global-gitignore"),
2708                        "i",
2709                        "gitignore",
2710                        "Add a pattern to .gitignore",
2711                        "gitignore_op",
2712                    ),
2713                    // MG.23c2. `m` is the repo-level convenience for
2714                    // when you know the branch name; picking from a
2715                    // list is already served one level down, by `m` in
2716                    // the branch buffer.
2717                    TransientItem {
2718                        key: vec!["m".into()],
2719                        label: "merge".into(),
2720                        description: "Merge a branch into the current one".into(),
2721                        kind: TransientItemKind::Submenu(Arc::new(
2722                            root_menu_spec("magit-menu-merge", ids, ctx, gates)
2723                                .expect("`magit-menu-merge` is in ROOT_MENUS"),
2724                        )),
2725                    },
2726                    action_or_placeholder(
2727                        ids.get("action:magit-global-init"),
2728                        "I",
2729                        "init",
2730                        "Initialize a git repository",
2731                        "init_op",
2732                    ),
2733                ],
2734            },
2735        ],
2736        preview: None,
2737        footer: Some("q dismiss  Esc/BS back".into()),
2738    }
2739}
2740
2741/// Build a commit sub-transient. `c c` / `c a` mirror magit-status's
2742/// own `cc` / `ca` chords exactly, so the same two keystrokes commit
2743/// and amend whether you're inside the status buffer or reaching for
2744/// the dispatch menu from an ordinary file.
2745fn commit_transient(ids: &MagitActionIds) -> TransientSpec {
2746    TransientSpec {
2747        title: "Commit".into(),
2748        groups: vec![row_group("Actions", ids, COMMIT_ROWS)],
2749        preview: None,
2750        footer: Some("q dismiss  Esc/BS back".into()),
2751    }
2752}
2753
2754/// Build a stash sub-transient. `z l` lists, `z z` pushes a new
2755/// stash — magit uses `z z` for "stash" (push) too, and apply/pop/
2756/// drop live as `a`/`p`/`d` chords inside the stash-list buffer
2757/// itself rather than being duplicated here (they need a stash
2758/// selected, which only the list view provides).
2759fn stash_transient(ids: &MagitActionIds) -> TransientSpec {
2760    TransientSpec {
2761        title: "Stash".into(),
2762        groups: vec![
2763            // MG.17a: `-u` rides here rather than in a further submenu —
2764            // this menu already exists and already stays open, so the
2765            // flag costs no extra keystroke.
2766            TransientGroup {
2767                label: "Arguments".into(),
2768                items: flag_items(RemoteOp::STASH),
2769            },
2770            row_group("Actions", ids, STASH_ROWS),
2771        ],
2772        preview: Some(remote_preview(RemoteOp::STASH)),
2773        footer: Some("q dismiss  Esc/BS back".into()),
2774    }
2775}
2776
2777/// The `action:magit-global-file-*` `CommandId`s
2778/// [`file_dispatch_transient`]'s items fire — resolved once at
2779/// `install()` time, same shape as [`DispatchActionIds`].
2780#[derive(Debug, Clone, Copy, Default)]
2781pub struct FileDispatchActionIds {
2782    pub stage: Option<CommandId>,
2783    pub unstage: Option<CommandId>,
2784    pub discard: Option<CommandId>,
2785    pub diff: Option<CommandId>,
2786    pub log: Option<CommandId>,
2787    pub blame: Option<CommandId>,
2788    /// MG.23f2: reverse blame. Only in `C-c f`, not in the other-file
2789    /// menu — that menu names a target by *path*, and reverse blame
2790    /// needs a revision the path cannot carry.
2791    pub blame_reverse: Option<CommandId>,
2792    /// MG.28: `v` — this file at a revision you name.
2793    pub at_revision: Option<CommandId>,
2794    /// MG.28: `V` — from a blob buffer back to the live file.
2795    pub visit_live: Option<CommandId>,
2796    /// MG.23d: the file operations.
2797    pub untrack: Option<CommandId>,
2798    pub delete: Option<CommandId>,
2799    pub rename: Option<CommandId>,
2800    /// MG.23d2: check the file out from a revision.
2801    pub checkout: Option<CommandId>,
2802    /// MG.34: `M` — the merge that brought a commit into HEAD. Magit's
2803    /// own key for it in `magit-file-dispatch`.
2804    pub log_merged: Option<CommandId>,
2805    /// MG.34: `e` — start a rebase to amend the commit that wrote the
2806    /// line at the cursor. Magit's own key, in its "More actions" group.
2807    pub edit_line_commit: Option<CommandId>,
2808}
2809
2810/// Build the file-level dispatch transient (`C-c f`).
2811///
2812/// Items resolve to real actions that operate on the buffer that was
2813/// active when the transient was opened (`ActionContext::buffer_id`
2814/// at fire time) — see
2815/// `magit_global_mode::global_action_handler_contributions` for the
2816/// file-path resolution. Unlike the root dispatch, there is
2817/// no per-file `SectionIndex`-cursor resolution when opened from
2818/// inside a magit-status buffer; the file is always "whichever real
2819/// buffer was active", which covers the common case (editing a file,
2820/// pressing `C-c f` to stage/diff it) but not "invoke from within
2821/// magit-status, act on the entry at cursor".
2822/// Key assignments follow Emacs magit's own `magit-file-dispatch`
2823/// (`s` stage, `u` unstage, `x` discard, `d` diff, `l` log, `b`
2824/// blame) — the same reasoning as [`dispatch_transient`]'s. Magit
2825/// entries with no lattice implementation (stage-all/unstage-all,
2826/// edit-blob, trace-definition, commit-fixup) are absent rather than
2827/// inert.
2828/// MG.23a: `:magit-other-file-dispatch` — the file menu for a file you
2829/// are **not** visiting.
2830///
2831/// A stand-alone command, tied to no buffer: invoke it from anywhere,
2832/// set the target with `=f`, then act. Deliberately bound to no chord —
2833/// `C-c f` is the common case (act on what you are looking at) and this
2834/// is the occasional one; bind it yourself if you prefer magit's
2835/// always-ask behaviour.
2836///
2837/// Same rows and the same actions as [`file_dispatch_transient`]; the
2838/// only difference is the `file` argument, which those actions read in
2839/// preference to the visited file. With the argument unset every row
2840/// falls back to the visited file, so an unset menu is a superset of
2841/// `C-c f` rather than something that acts wrongly — and the preview
2842/// line always names the target that will be used.
2843///
2844/// **Discard works here as of IX.7.** It is destructive, so it goes
2845/// through §12.13's ask/execute pair — and `Effect::Confirm` opens a
2846/// transient of its own, which replaces this menu and its state. That
2847/// used to lose the target: the execute half found no `file` argument
2848/// and fell back to the visited file, asking about one file and
2849/// deleting another's changes. IX.1 made the confirm carry its target
2850/// and IX.2 made the execute half read it, so the dialog replacing this
2851/// menu no longer matters.
2852pub fn other_file_dispatch_transient(ids: &MagitActionIds) -> TransientSpec {
2853    TransientSpec {
2854        title: "File dispatch (other file)".into(),
2855        groups: vec![
2856            TransientGroup {
2857                label: "Target".into(),
2858                items: vec![TransientItem {
2859                    key: vec!["=f".into()],
2860                    label: "file".into(),
2861                    description: "Repo-relative path to act on".into(),
2862                    kind: TransientItemKind::Argument {
2863                        name: "file".to_string(),
2864                        prompt: "File (repo-relative): ".to_string(),
2865                        default: None,
2866                        // MG.53.e: the file must already exist, so it is
2867                        // picked rather than typed — the rule this whole
2868                        // plan applies. A free-text path is a typo
2869                        // waiting to happen, and git reports it long
2870                        // after the keystroke that caused it, by which
2871                        // point the menu has closed.
2872                        //
2873                        // The listing lives in `lattice-picker`, not
2874                        // here: magit names the source and the walk stays
2875                        // generic, so the next provider that needs
2876                        // "choose a file, then act" declares the same
2877                        // row instead of copying a directory walk into
2878                        // its own crate.
2879                        source: Some(lattice_picker::TransientArgSource::new(
2880                            lattice_picker::FILE_PICK_SOURCE,
2881                        )),
2882                    },
2883                }],
2884            },
2885            TransientGroup {
2886                label: "Stage".into(),
2887                items: vec![
2888                    action_or_placeholder(
2889                        ids.get("action:magit-global-file-stage"),
2890                        "s",
2891                        "stage",
2892                        "Stage the target file",
2893                        "stage_other_file",
2894                    ),
2895                    action_or_placeholder(
2896                        ids.get("action:magit-global-file-unstage"),
2897                        "u",
2898                        "unstage",
2899                        "Unstage the target file",
2900                        "unstage_other_file",
2901                    ),
2902                    // IX.7: destructive, and safe here now. Its confirm
2903                    // carries the target (IX.1/IX.2), so the dialog
2904                    // replacing this menu no longer loses it — before
2905                    // that, the execute half would have fallen back to
2906                    // the visited file and acted on something the prompt
2907                    // never named.
2908                    action_or_placeholder(
2909                        ids.get("action:magit-global-file-discard"),
2910                        "x",
2911                        "discard",
2912                        "Discard the target file's changes (asks first)",
2913                        "discard_other_file",
2914                    ),
2915                ],
2916            },
2917            TransientGroup {
2918                label: "Inspect".into(),
2919                items: vec![
2920                    action_or_placeholder(
2921                        ids.get("action:magit-global-file-diff"),
2922                        "d",
2923                        "diff",
2924                        "Show the target file's diff",
2925                        "diff_other_file",
2926                    ),
2927                    action_or_placeholder(
2928                        ids.get("action:magit-global-file-log"),
2929                        "l",
2930                        "log",
2931                        "Show the target file's history",
2932                        "log_other_file",
2933                    ),
2934                    action_or_placeholder(
2935                        ids.get("action:magit-global-file-blame"),
2936                        "b",
2937                        "blame",
2938                        "Blame the target file",
2939                        "blame_other_file",
2940                    ),
2941                ],
2942            },
2943        ],
2944        // The target is the whole point of this menu, so it is always on
2945        // screen — including when unset, where saying so beats leaving
2946        // the user to guess which file a row will hit.
2947        preview: Some(Box::new(
2948            |state: &lattice_picker::TransientState| match state.get("file") {
2949                Some(lattice_picker::TransientValue::String(p)) if !p.is_empty() => {
2950                    format!("target: {p}")
2951                }
2952                _ => "target: (none set — rows act on the visited file)".to_string(),
2953            },
2954        )),
2955        footer: Some("=f set target  q dismiss".into()),
2956    }
2957}
2958
2959pub fn file_dispatch_transient(ids: &MagitActionIds) -> TransientSpec {
2960    TransientSpec {
2961        title: "File dispatch".into(),
2962        groups: vec![
2963            TransientGroup {
2964                label: "Stage".into(),
2965                items: vec![
2966                    action_or_placeholder(
2967                        ids.get("action:magit-global-file-stage"),
2968                        "s",
2969                        "stage",
2970                        "Stage this file",
2971                        "stage_file",
2972                    ),
2973                    action_or_placeholder(
2974                        ids.get("action:magit-global-file-unstage"),
2975                        "u",
2976                        "unstage",
2977                        "Unstage this file",
2978                        "unstage_file",
2979                    ),
2980                    action_or_placeholder(
2981                        ids.get("action:magit-global-file-discard"),
2982                        "x",
2983                        "discard",
2984                        "Discard this file's working-tree changes (asks first)",
2985                        "discard_file",
2986                    ),
2987                ],
2988            },
2989            // MG.23d. Magit puts these behind a `,` prefix in its own
2990            // file-dispatch, which is a deliberate signal rather than a
2991            // key shortage: they change what the file IS, not just what
2992            // is staged of it. Keeping the prefix keeps that signal —
2993            // and keeps `r` free for the blame-removal row magit also
2994            // has at this level.
2995            TransientGroup {
2996                label: "File".into(),
2997                items: vec![
2998                    action_or_placeholder(
2999                        ids.get("action:magit-global-file-untrack"),
3000                        ",x",
3001                        "untrack",
3002                        "Stop tracking this file, keeping it on disk",
3003                        "untrack_file",
3004                    ),
3005                    action_or_placeholder(
3006                        ids.get("action:magit-global-file-rename"),
3007                        ",r",
3008                        "rename",
3009                        "Rename this file (asks for the new name)",
3010                        "rename_file",
3011                    ),
3012                    action_or_placeholder(
3013                        ids.get("action:magit-global-file-delete"),
3014                        ",k",
3015                        "delete",
3016                        "Delete this file (asks first)",
3017                        "delete_file",
3018                    ),
3019                    action_or_placeholder(
3020                        ids.get("action:magit-global-file-checkout"),
3021                        ",c",
3022                        "checkout",
3023                        "Replace this file with its content at a revision (asks, then confirms)",
3024                        "checkout_file",
3025                    ),
3026                ],
3027            },
3028            TransientGroup {
3029                label: "Inspect".into(),
3030                items: vec![
3031                    action_or_placeholder(
3032                        ids.get("action:magit-global-file-diff"),
3033                        "d",
3034                        "diff",
3035                        "Show diff for this file",
3036                        "diff_file",
3037                    ),
3038                    action_or_placeholder(
3039                        ids.get("action:magit-global-file-log"),
3040                        "l",
3041                        "log",
3042                        "Show commit history for this file",
3043                        "log_file",
3044                    ),
3045                    action_or_placeholder(
3046                        ids.get("action:magit-global-file-blame"),
3047                        "b",
3048                        "blame",
3049                        "Blame this file",
3050                        "blame_file",
3051                    ),
3052                    // MG.23f2, on magit's own key for it (`f`
3053                    // "...reverse" in magit-file-dispatch's Blame
3054                    // group). Only meaningful from a blob buffer; the
3055                    // handler says so rather than the row hiding, since
3056                    // there is no per-context menu content yet
3057                    // (MG.23h).
3058                    // MG.28: magit's own key for this.
3059                    action_or_placeholder(
3060                        ids.get("action:magit-global-file-at-revision"),
3061                        "v",
3062                        "view at revision",
3063                        "Open this file as it was at a revision you name",
3064                        "file_at_revision",
3065                    ),
3066                    action_or_placeholder(
3067                        ids.get("action:magit-global-file-visit-live"),
3068                        "V",
3069                        "back to the live file",
3070                        "From a file-at-revision, open the working-tree copy at the same line",
3071                        "file_visit_live",
3072                    ),
3073                    action_or_placeholder(
3074                        ids.get("action:magit-global-file-blame-reverse"),
3075                        "f",
3076                        "reverse blame",
3077                        "For each line of this revision, the last commit it existed in",
3078                        "blame_reverse_file",
3079                    ),
3080                    // MG.34, on magit's own key for it (`M` "Merged" in
3081                    // magit-file-dispatch). A row rather than a chord:
3082                    // `M` and `gM` are both vim motions, and magit binds
3083                    // this as a transient suffix anyway.
3084                    action_or_placeholder(
3085                        ids.get("action:magit-global-log-merged"),
3086                        "M",
3087                        "merged",
3088                        "Show the merge commit that brought a commit into HEAD",
3089                        "log_merged",
3090                    ),
3091                ],
3092            },
3093            // MG.34: magit's own group name for the row below.
3094            TransientGroup {
3095                label: "More actions".into(),
3096                items: vec![action_or_placeholder(
3097                    ids.get("action:magit-global-edit-line-commit"),
3098                    "e",
3099                    "edit line",
3100                    "Start a rebase to amend the commit that wrote the line at the cursor",
3101                    "edit_line_commit",
3102                )],
3103            },
3104        ],
3105        preview: None,
3106        footer: Some("q dismiss".into()),
3107    }
3108}
3109
3110#[cfg(test)]
3111mod row_table_tests {
3112    use super::*;
3113
3114    /// A `CommandRegistry` with magit's actions registered, as `install`
3115    /// leaves it.
3116    fn registry() -> lattice_grammar::CommandRegistry {
3117        let mut r = lattice_grammar::CommandRegistry::new();
3118        let _ = lattice_grammar::builtins::populate(&mut r);
3119        let _ = lattice_grammar::ex_commands::populate(&mut r);
3120        crate::register_action_commands_for_test(&mut r);
3121        r
3122    }
3123
3124    /// MG.41a: THE test that replaces compile-time field checking.
3125    ///
3126    /// Rows name their command as a string, so a typo or a renamed
3127    /// action no longer fails to compile — it renders a disabled
3128    /// placeholder the user reads as "not implemented yet". This is the
3129    /// same guard `help_prefix_chord_table_resolves_all_commands` gives
3130    /// the `<C-h>` map, and it is why the string-keyed design is safe.
3131    #[test]
3132    fn every_row_action_is_registered() {
3133        let reg = registry();
3134        let ids = MagitActionIds::resolve(&reg);
3135        assert!(!ids.is_empty(), "no magit actions resolved at all");
3136        for (table, rows) in all_row_tables() {
3137            for row in *rows {
3138                assert!(
3139                    ids.get(row.action).is_some(),
3140                    "{table} row `{}` ({}) references unregistered `{}`",
3141                    row.key,
3142                    row.label,
3143                    row.action,
3144                );
3145            }
3146        }
3147    }
3148
3149    /// Two rows in one group cannot share a key — the second would be
3150    /// unreachable, and silently so.
3151    #[test]
3152    fn no_duplicate_keys_within_a_table() {
3153        for (table, rows) in all_row_tables() {
3154            let mut seen = std::collections::HashSet::new();
3155            for row in *rows {
3156                assert!(
3157                    seen.insert(row.key),
3158                    "{table} binds `{}` twice; the second row is unreachable",
3159                    row.key,
3160                );
3161            }
3162        }
3163    }
3164
3165    /// Every row carries a non-empty label and doc — the transient
3166    /// renders both, and a blank one reads as a rendering bug.
3167    #[test]
3168    fn rows_are_fully_described() {
3169        for (table, rows) in all_row_tables() {
3170            for row in *rows {
3171                assert!(!row.key.is_empty(), "{table}: empty key");
3172                assert!(!row.label.is_empty(), "{table} `{}`: empty label", row.key);
3173                assert!(!row.doc.is_empty(), "{table} `{}`: empty doc", row.key);
3174                assert!(
3175                    row.action.starts_with(MagitActionIds::PREFIX),
3176                    "{table} `{}`: `{}` is outside the `{}` namespace, so \
3177                     `MagitActionIds::resolve` will never find it",
3178                    row.key,
3179                    row.action,
3180                    MagitActionIds::PREFIX,
3181                );
3182            }
3183        }
3184    }
3185
3186    /// The resolver picks up magit actions automatically — the property
3187    /// that removes the third enumeration. If this regresses to a
3188    /// hand-kept list, adding an action would silently not be reachable.
3189    #[test]
3190    fn resolver_finds_actions_without_a_hand_kept_list() {
3191        let reg = registry();
3192        let ids = MagitActionIds::resolve(&reg);
3193        let registered = reg
3194            .names()
3195            .filter(|n| n.starts_with(MagitActionIds::PREFIX))
3196            .count();
3197        assert_eq!(
3198            ids.by_name.len(),
3199            registered,
3200            "resolve() must pick up EVERY registered magit action",
3201        );
3202    }
3203}
3204
3205#[cfg(test)]
3206mod background_task_tests {
3207    /// MG.41g: magit publishes completion; it does not post
3208    /// notifications.
3209    ///
3210    /// The decoupling is the point of the slice, so it is worth
3211    /// asserting structurally rather than trusting a grep at review
3212    /// time: if a future spawner reaches for `lattice_notify` again,
3213    /// the coupling this removed is back.
3214    #[test]
3215    fn magit_does_not_depend_on_the_notification_crate() {
3216        let manifest = include_str!("../Cargo.toml");
3217        assert!(
3218            !manifest.contains("lattice-notify"),
3219            "magit must not depend on lattice-notify — completion is \
3220             reported by publishing `BackgroundTaskFinished`, which the \
3221             notification layer subscribes to",
3222        );
3223    }
3224
3225    /// Every git-spawning helper reports completion through
3226    /// `finish_task`, which logs AND publishes in one call.
3227    ///
3228    /// Five of ten spawners previously reported nothing at all — the
3229    /// gap that motivated this slice — and the reason was that
3230    /// notification was an opt-in parameter each one could forget.
3231    /// NC.5: a repository mutation whose result is thrown away is an
3232    /// operation that finishes invisibly — and, publishing nothing,
3233    /// leaves every open magit view stale. `let _ =` on one is the
3234    /// shape every such site had.
3235    #[test]
3236    fn no_repository_call_discards_its_result() {
3237        let sources = [
3238            ("magit_global_mode.rs", include_str!("magit_global_mode.rs")),
3239            ("magit_refs_mode.rs", include_str!("magit_refs_mode.rs")),
3240            ("magit_rebase_mode.rs", include_str!("magit_rebase_mode.rs")),
3241            ("magit_branch_mode.rs", include_str!("magit_branch_mode.rs")),
3242            ("magit_stash_mode.rs", include_str!("magit_stash_mode.rs")),
3243            ("actions.rs", include_str!("actions.rs")),
3244        ];
3245        for (file, src) in sources {
3246            for (n, line) in src.lines().enumerate() {
3247                let code = line.trim_start();
3248                if code.starts_with("//") {
3249                    continue;
3250                }
3251                assert!(
3252                    !code.starts_with("let _ = lattice_vcs::")
3253                        && !code.starts_with("let _ = repo.run_git"),
3254                    "{file}:{}: a repository call's result is discarded",
3255                    n + 1
3256                );
3257            }
3258        }
3259    }
3260
3261    #[test]
3262    fn every_spawner_reports_completion() {
3263        let src = include_str!("magit_global_mode.rs");
3264        // Spawners that delegate to another spawner inherit its
3265        // reporting; the rest must call `finish_task` themselves.
3266        let delegating = ["spawn_note_remove", "spawn_note_prune"];
3267        let mut checked = 0;
3268        for (idx, _) in src.match_indices("fn spawn_") {
3269            let name: String = src[idx + 3..]
3270                .chars()
3271                .take_while(|c| c.is_alphanumeric() || *c == '_')
3272                .collect();
3273            let body_end = src[idx..]
3274                .find("\nfn ")
3275                .map(|e| idx + e)
3276                .unwrap_or(src.len());
3277            let body = &src[idx..body_end];
3278            if delegating.contains(&name.as_str()) {
3279                assert!(
3280                    body.contains("spawn_git(crate::repo_scope::action_workdir(ctx), ")
3281                        || body.contains("spawn_remote_op("),
3282                    "{name} is listed as delegating but calls neither spawner",
3283                );
3284            } else {
3285                assert!(
3286                    body.contains("finish_task"),
3287                    "{name} does not report completion — a background op that \
3288                     finishes invisibly is the bug MG.41g fixed",
3289                );
3290            }
3291            checked += 1;
3292        }
3293        assert!(
3294            checked >= 8,
3295            "expected to inspect every spawner, saw {checked}"
3296        );
3297    }
3298
3299    /// **The same rule, for the spawners that are not in
3300    /// `magit_global_mode.rs`.**
3301    ///
3302    /// `every_spawner_reports_completion` above reads ONE file. That
3303    /// was the whole gap: each major mode grew its own
3304    /// `spawn_mutation_and_refresh` / `spawn_*_mutation`, none of them
3305    /// in that file, so none were ever checked — and those helpers are
3306    /// what the chords a user presses most (`s`, `u`, `x`, branch `d`,
3307    /// stash `p`, remote `a`) actually call. A guard scoped to a file
3308    /// rather than to the rule is a guard with a blind spot the size of
3309    /// the rest of the crate.
3310    ///
3311    /// Refreshers are exempt from reporting SUCCESS — a repopulated
3312    /// buffer is its own report, and a notification per `gr` is noise —
3313    /// but not from reporting failure, which is why they are named
3314    /// individually here rather than matched by a `refresh` substring.
3315    #[test]
3316    fn every_mutation_helper_in_the_crate_reports_completion() {
3317        // (file, helper) pairs that mutate the repository.
3318        const MUTATORS: &[(&str, &str)] = &[
3319            ("actions.rs", "spawn_mutation_and_refresh"),
3320            ("magit_branch_mode.rs", "spawn_mutation_and_refresh"),
3321            ("magit_diff_mode.rs", "spawn_mutation_and_refresh"),
3322            ("magit_stash_mode.rs", "spawn_mutation_and_refresh"),
3323            ("magit_remote_mode.rs", "spawn_remote_mutation"),
3324            ("magit_submodule_mode.rs", "spawn_submodule_mutation"),
3325            ("magit_core_mode.rs", "spawn_patch_discard"),
3326            ("magit_core_mode.rs", "spawn_hunk_apply"),
3327        ];
3328        let sources: &[(&str, &str)] = &[
3329            ("actions.rs", include_str!("actions.rs")),
3330            ("magit_branch_mode.rs", include_str!("magit_branch_mode.rs")),
3331            ("magit_diff_mode.rs", include_str!("magit_diff_mode.rs")),
3332            ("magit_stash_mode.rs", include_str!("magit_stash_mode.rs")),
3333            ("magit_remote_mode.rs", include_str!("magit_remote_mode.rs")),
3334            (
3335                "magit_submodule_mode.rs",
3336                include_str!("magit_submodule_mode.rs"),
3337            ),
3338            ("magit_core_mode.rs", include_str!("magit_core_mode.rs")),
3339        ];
3340
3341        for (file, helper) in MUTATORS {
3342            let src = sources
3343                .iter()
3344                .find(|(f, _)| f == file)
3345                .map(|(_, s)| *s)
3346                .unwrap_or_else(|| panic!("{file} is in the source table"));
3347            let idx = src
3348                .find(&format!("fn {helper}"))
3349                .unwrap_or_else(|| panic!("{file}: `{helper}` not found — renamed?"));
3350            let body_end = src[idx..]
3351                .find("\nfn ")
3352                .map(|e| idx + e)
3353                .unwrap_or(src.len());
3354            let body = &src[idx..body_end];
3355            assert!(
3356                body.contains("finish_task"),
3357                "{file}: `{helper}` mutates the repository and does not \
3358                 report completion. The user presses a key, git runs, and \
3359                 nothing says whether it worked — on failure the buffer \
3360                 just refreshes as though it had.",
3361            );
3362        }
3363    }
3364}
3365
3366#[cfg(test)]
3367mod remote_target_tests {
3368    use crate::magit_global_mode::RemoteTarget;
3369
3370    /// MG.41c: `p` — no destination argument, so git resolves
3371    /// `pushRemote` / `remote.pushDefault` itself. This is what the
3372    /// single unlabelled "push" row did; it is now named.
3373    #[test]
3374    fn configured_adds_nothing() {
3375        assert!(RemoteTarget::Configured.argv(None).is_empty());
3376        // A stray resolved value cannot leak in.
3377        assert!(
3378            RemoteTarget::Configured
3379                .argv(Some("origin main"))
3380                .is_empty()
3381        );
3382    }
3383
3384    #[test]
3385    fn all_remotes_and_all_tags_are_flags() {
3386        assert_eq!(RemoteTarget::AllRemotes.argv(None), vec!["--all"]);
3387        assert_eq!(RemoteTarget::AllTags.argv(None), vec!["--tags"]);
3388    }
3389
3390    /// The upstream pair expands to TWO tokens — `origin main`, not
3391    /// `origin/main`. `git push origin/main` would be read as a single
3392    /// refspec and fail, which is the bug this splitting avoids.
3393    #[test]
3394    fn upstream_expands_to_remote_and_branch() {
3395        assert_eq!(
3396            RemoteTarget::Upstream.argv(Some("origin main")),
3397            vec!["origin", "main"],
3398        );
3399    }
3400
3401    /// An unresolved destination contributes NOTHING rather than an
3402    /// empty argument. `git push ""` is not a no-op — git reads it as a
3403    /// real (empty) refspec and errors.
3404    #[test]
3405    fn an_unresolved_destination_contributes_no_argument() {
3406        for t in [RemoteTarget::Upstream, RemoteTarget::Prompted] {
3407            assert!(t.argv(None).is_empty(), "{t:?} with None");
3408            assert!(t.argv(Some("")).is_empty(), "{t:?} with empty");
3409            assert!(t.argv(Some("   ")).is_empty(), "{t:?} with blank");
3410        }
3411    }
3412
3413    /// A prompted destination may be several tokens (`origin my-branch`)
3414    /// or one (`v1.2.0`); both pass through verbatim.
3415    #[test]
3416    fn prompted_destinations_pass_through() {
3417        assert_eq!(RemoteTarget::Prompted.argv(Some("v1.2.0")), vec!["v1.2.0"]);
3418        assert_eq!(
3419            RemoteTarget::Prompted.argv(Some("origin feature/x")),
3420            vec!["origin", "feature/x"],
3421        );
3422    }
3423}
3424
3425#[cfg(test)]
3426mod remote_flag_tests {
3427    use crate::magit_global_mode::RemoteOp;
3428    use lattice_grammar::{ArgValue, Args};
3429
3430    fn flags(op: RemoteOp, on: &[&str]) -> Vec<String> {
3431        let list: Vec<ArgValue> = op
3432            .flags
3433            .iter()
3434            .map(|f| ArgValue::Bool(on.contains(&f.name)))
3435            .collect();
3436        op.argv(&Args::List(list))
3437    }
3438
3439    /// MG.41c: `--rebase` REPLACES `--ff-only`. Git rejects the pair,
3440    /// so emitting both would make magit's `-r` row fail every time.
3441    #[test]
3442    fn pull_rebase_replaces_ff_only() {
3443        let argv = flags(RemoteOp::PULL, &["rebase"]);
3444        assert!(argv.contains(&"--rebase".to_string()), "{argv:?}");
3445        assert!(
3446            !argv.contains(&"--ff-only".to_string()),
3447            "--ff-only must be dropped when rebasing: {argv:?}",
3448        );
3449    }
3450
3451    /// Without `-r`, the safe default stands: a pull cannot create a
3452    /// merge commit behind your back.
3453    #[test]
3454    fn pull_defaults_to_ff_only() {
3455        let argv = flags(RemoteOp::PULL, &[]);
3456        assert!(argv.contains(&"--ff-only".to_string()), "{argv:?}");
3457        assert!(!argv.contains(&"--rebase".to_string()), "{argv:?}");
3458    }
3459
3460    /// `--autostash` is orthogonal — it must survive alongside either.
3461    #[test]
3462    fn pull_autostash_is_independent_of_rebase() {
3463        let with_rebase = flags(RemoteOp::PULL, &["rebase", "autostash"]);
3464        assert!(with_rebase.contains(&"--autostash".to_string()));
3465        assert!(!with_rebase.contains(&"--ff-only".to_string()));
3466        let without = flags(RemoteOp::PULL, &["autostash"]);
3467        assert!(without.contains(&"--autostash".to_string()));
3468        assert!(without.contains(&"--ff-only".to_string()));
3469    }
3470
3471    /// The flag table is looked up by NAME, so reordering it cannot
3472    /// silently point `--rebase` at another toggle's slot.
3473    #[test]
3474    fn rebase_lookup_survives_table_order() {
3475        let idx = RemoteOp::PULL
3476            .flags
3477            .iter()
3478            .position(|f| f.name == "rebase")
3479            .expect("pull has a rebase flag");
3480        // Only that slot turns it on.
3481        let mut list: Vec<ArgValue> = RemoteOp::PULL
3482            .flags
3483            .iter()
3484            .map(|_| ArgValue::Bool(false))
3485            .collect();
3486        list[idx] = ArgValue::Bool(true);
3487        let argv = RemoteOp::PULL.argv(&Args::List(list));
3488        assert!(!argv.contains(&"--ff-only".to_string()), "{argv:?}");
3489    }
3490
3491    /// MG.41c added magit's remaining push flags — except bare
3492    /// `--force`, which lattice deliberately does not offer.
3493    ///
3494    /// That divergence predates this slice and is pinned separately by
3495    /// `force_push_uses_force_with_lease`: `--force-with-lease` refuses
3496    /// exactly when a bare force would destroy commits you never
3497    /// fetched. "Match magit" governs KEYS inside a transient; it does
3498    /// not extend to re-adding a footgun someone removed on purpose.
3499    #[test]
3500    fn push_offers_magits_flag_set_minus_the_footgun() {
3501        let names: Vec<&str> = RemoteOp::PUSH.flags.iter().map(|f| f.name).collect();
3502        for expected in ["force-with-lease", "set-upstream", "no-verify", "dry-run"] {
3503            assert!(
3504                names.contains(&expected),
3505                "push missing `{expected}`: {names:?}"
3506            );
3507        }
3508        assert!(
3509            !names.contains(&"force"),
3510            "bare --force stays out; see force_push_uses_force_with_lease",
3511        );
3512    }
3513
3514    #[test]
3515    fn fetch_offers_tags_and_prune() {
3516        let names: Vec<&str> = RemoteOp::FETCH.flags.iter().map(|f| f.name).collect();
3517        for expected in ["tags", "prune", "all"] {
3518            assert!(
3519                names.contains(&expected),
3520                "fetch missing `{expected}`: {names:?}"
3521            );
3522        }
3523    }
3524}
3525
3526#[cfg(test)]
3527mod config_row_tests {
3528    use super::*;
3529    use lattice_grammar::CommandRegistry;
3530
3531    /// MG.43g: every configure row's action resolves.
3532    ///
3533    /// The same drift guard `every_row_action_is_registered` gives the
3534    /// action tables. A `Variable` whose action does not resolve falls
3535    /// back to an inert placeholder, so the row would render and do
3536    /// nothing when pressed.
3537    #[test]
3538    fn every_config_row_action_is_registered() {
3539        let mut registry = CommandRegistry::new();
3540        crate::register_action_commands(&mut registry);
3541        for (table, rows) in all_config_tables() {
3542            for row in *rows {
3543                assert!(
3544                    registry.id_by_name(row.action).is_some(),
3545                    "{table} row `{}` ({}) references unregistered `{}`",
3546                    row.key,
3547                    row.label,
3548                    row.action,
3549                );
3550            }
3551        }
3552    }
3553
3554    /// Every configure row names a non-empty git-config key.
3555    ///
3556    /// An empty key would render `" = …"` forever: the cache can never
3557    /// answer for it, so the row would report nothing while looking
3558    /// like it was still loading.
3559    #[test]
3560    fn every_config_row_names_a_key() {
3561        for (table, rows) in all_config_tables() {
3562            for row in *rows {
3563                assert!(
3564                    !row.config_key.is_empty(),
3565                    "{table} row `{}` names no config key",
3566                    row.key,
3567                );
3568                assert!(
3569                    row.config_key.contains('.'),
3570                    "{table} row `{}` names `{}`, which is not a git-config key",
3571                    row.key,
3572                    row.config_key,
3573                );
3574            }
3575        }
3576    }
3577
3578    /// MG.43g: **building a menu reads the cache, never the disk.**
3579    ///
3580    /// This is the paramount-#1 constraint the whole slice is shaped
3581    /// around: a menu is built on a keystroke. With nothing prefetched
3582    /// every row must still build, reporting "not read yet" rather
3583    /// than blocking to find out.
3584    #[test]
3585    fn rows_build_without_a_prefetched_value() {
3586        let mut registry = CommandRegistry::new();
3587        crate::register_action_commands(&mut registry);
3588        let ids = MagitActionIds::resolve(&registry);
3589
3590        let group = config_group(&ids, BRANCH_CONFIG_ROWS, std::path::Path::new("/work/api"))
3591            .expect("branch has a configure row");
3592        let item = &group.items[0];
3593        match &item.kind {
3594            TransientItemKind::Variable { key, value, .. } => {
3595                assert_eq!(key, "pull.rebase");
3596                // `None`, not `Some("")`: nothing has been read, and
3597                // claiming `unset` would state a fact about the user's
3598                // config that was never checked.
3599                assert_eq!(*value, None, "an unread key must not report a value");
3600            }
3601            other => panic!("configure rows must be Variable, got {other:?}"),
3602        }
3603    }
3604
3605    /// The three display states stay distinct.
3606    #[test]
3607    fn unread_and_unset_render_differently() {
3608        assert_eq!(TransientItemKind::variable_display(None), "…");
3609        assert_eq!(TransientItemKind::variable_display(Some("")), "unset");
3610        assert_eq!(TransientItemKind::variable_display(Some("true")), "");
3611    }
3612}
3613
3614#[cfg(test)]
3615mod merge_and_tag_argv_tests {
3616    use crate::magit_global_mode::{
3617        merge_absorb_steps, merge_into_steps, tag_prune_argv, tag_release_argv,
3618    };
3619
3620    /// MG.43e: **`i` merge-into and `a` absorb delete DIFFERENT
3621    /// branches.**
3622    ///
3623    /// They are mirrors: absorb merges another branch into this one
3624    /// and deletes that one; merge-into merges this one into another
3625    /// and deletes this one. Getting the direction backwards deletes
3626    /// the branch the user is standing on and keeps the one they meant
3627    /// to fold in — and both forms are perfectly valid git.
3628    #[test]
3629    fn merge_into_deletes_this_branch_and_absorb_deletes_the_other() {
3630        let into = merge_into_steps("feature", "main");
3631        let deleted = into
3632            .iter()
3633            .find(|s| s.argv.first().map(String::as_str) == Some("branch"))
3634            .expect("merge-into deletes a branch");
3635        assert!(
3636            deleted.argv.contains(&"feature".to_string()),
3637            "merge-into deletes the CURRENT branch: {:?}",
3638            deleted.argv,
3639        );
3640
3641        let absorb = merge_absorb_steps("feature");
3642        let deleted = absorb
3643            .iter()
3644            .find(|s| s.argv.first().map(String::as_str) == Some("branch"))
3645            .expect("absorb deletes a branch");
3646        assert!(
3647            deleted.argv.contains(&"feature".to_string()),
3648            "absorb deletes the OTHER branch: {:?}",
3649            deleted.argv,
3650        );
3651    }
3652
3653    /// Merge-into checks the target out FIRST, then merges. Merging
3654    /// before checking out would merge into the wrong branch.
3655    #[test]
3656    fn merge_into_checks_out_before_merging() {
3657        let steps = merge_into_steps("feature", "main");
3658        assert_eq!(steps[0].argv, vec!["checkout", "main"]);
3659        assert_eq!(steps[1].argv.first().map(String::as_str), Some("merge"));
3660    }
3661
3662    /// Both delete with `-d`, never `-D`: git refuses `-d` on a branch
3663    /// that is not fully merged, so a failed merge leaves it intact.
3664    /// `-D` would destroy it precisely when the merge did not take.
3665    #[test]
3666    fn neither_direction_force_deletes() {
3667        for steps in [
3668            merge_into_steps("feature", "main"),
3669            merge_absorb_steps("feature"),
3670        ] {
3671            for step in steps {
3672                assert!(
3673                    !step.argv.iter().any(|a| a == "-D"),
3674                    "a force delete would destroy the branch on a failed merge: {:?}",
3675                    step.argv,
3676                );
3677            }
3678        }
3679    }
3680
3681    /// A release tag is ANNOTATED. Without `-a` git makes a
3682    /// lightweight tag — a bare pointer with no tagger, date or
3683    /// message, which most release tooling ignores.
3684    #[test]
3685    fn a_release_tag_is_annotated() {
3686        let argv = tag_release_argv("v1.0.0", "first release");
3687        assert_eq!(argv, vec!["tag", "-a", "v1.0.0", "-m", "first release"],);
3688    }
3689
3690    /// `--prune-tags` needs `--prune` AND a remote. Alone it prunes
3691    /// nothing and still reports success, so the row would look like
3692    /// it worked.
3693    #[test]
3694    fn pruning_tags_carries_prune_and_a_remote() {
3695        let argv = tag_prune_argv("origin");
3696        assert!(argv.contains(&"--prune".to_string()), "{argv:?}");
3697        assert!(argv.contains(&"--prune-tags".to_string()), "{argv:?}");
3698        assert_eq!(argv.last().map(String::as_str), Some("origin"), "{argv:?}");
3699    }
3700}
3701
3702#[cfg(test)]
3703mod merge_preview_tests {
3704    /// MG.43e: **preview uses THREE dots, not two.**
3705    ///
3706    /// `HEAD...<branch>` shows what the branch added since the two
3707    /// diverged — what a merge would bring in. `HEAD..<branch>` would
3708    /// additionally report everything HEAD gained in the meantime as
3709    /// though the merge were removing it, which is the opposite of
3710    /// what a preview is for, and both forms are valid git.
3711    #[test]
3712    fn the_preview_range_is_symmetric_difference() {
3713        let argv = crate::magit_diff_mode::merge_preview_argv_for_test("feature");
3714        let range = argv
3715            .iter()
3716            .find(|a| a.contains("HEAD"))
3717            .expect("the range names HEAD");
3718        assert_eq!(range, "HEAD...feature");
3719        assert!(
3720            !range.contains("HEAD..feature"),
3721            "two dots would invert what the preview reports",
3722        );
3723    }
3724}
3725
3726#[cfg(test)]
3727mod rebase_argv_tests {
3728    use crate::magit_global_mode::{
3729        rebase_autosquash_argv, rebase_onto_argv, rebase_subset_argv, resolve_upstream,
3730    };
3731
3732    /// MG.43b: **rebase takes ONE revision, and that is why these rows
3733    /// do not reuse push/pull's upstream resolution.**
3734    ///
3735    /// `resolve_upstream` deliberately produces a two-token
3736    /// `"<remote> <branch>"` pair, because `git push` wants them
3737    /// separate. `git rebase origin main` is not an error — git reads
3738    /// `origin` as the upstream and `main` as the branch to rebase,
3739    /// silently replaying a different range than the row promised.
3740    ///
3741    /// So the rows pass git's own revision syntax straight through.
3742    #[test]
3743    fn rebase_targets_are_a_single_revision() {
3744        assert_eq!(
3745            rebase_onto_argv("@{upstream}"),
3746            vec!["rebase", "@{upstream}"]
3747        );
3748        assert_eq!(rebase_onto_argv("@{push}"), vec!["rebase", "@{push}"]);
3749        for target in ["@{upstream}", "@{push}", "origin/main"] {
3750            assert_eq!(
3751                rebase_onto_argv(target).len(),
3752                2,
3753                "`{target}` must contribute exactly one token after `rebase`",
3754            );
3755        }
3756    }
3757
3758    /// The push-side resolution really does produce two tokens, so the
3759    /// test above is guarding against something real rather than a
3760    /// hypothetical.
3761    ///
3762    /// Run in a temp dir with no upstream: the function returns `None`
3763    /// rather than a bare token, which is itself the property that
3764    /// stops an unresolved destination becoming a bare `git push`.
3765    #[test]
3766    fn the_push_side_resolution_is_not_a_single_token() {
3767        let dir = tempfile::tempdir().expect("temp dir");
3768        assert_eq!(
3769            resolve_upstream(dir.path()),
3770            None,
3771            "no upstream configured must resolve to nothing, never a partial ref",
3772        );
3773    }
3774
3775    /// `--onto <newbase> <upstream>` — the order is not
3776    /// interchangeable, and git will happily run the swapped form,
3777    /// replaying the wrong range onto the wrong base.
3778    #[test]
3779    fn subset_puts_the_new_base_before_the_upstream() {
3780        assert_eq!(
3781            rebase_subset_argv("main", "feature~3"),
3782            vec!["rebase", "--onto", "main", "feature~3"],
3783        );
3784    }
3785
3786    /// `--autosquash` needs `-i`: it only affects the generated todo
3787    /// list, which is an interactive-rebase concept. Without `-i` git
3788    /// accepts the flag and does nothing with it, so the row would
3789    /// look like it worked and fold in nothing.
3790    #[test]
3791    fn autosquash_is_interactive() {
3792        let argv = rebase_autosquash_argv("main");
3793        assert_eq!(argv, vec!["rebase", "-i", "--autosquash", "main"]);
3794        assert!(argv.iter().any(|a| a == "-i"));
3795    }
3796}
3797
3798#[cfg(test)]
3799mod commit_op_argv_tests {
3800    use crate::magit_global_mode::CommitOp;
3801
3802    /// MG.41d: `git reset <commit> --` resets the INDEX without moving
3803    /// HEAD. Drop the trailing `--` and it moves HEAD too — a very
3804    /// different operation, which is why the position is pinned.
3805    #[test]
3806    fn reset_index_puts_the_dashes_after_the_commit() {
3807        assert_eq!(
3808            CommitOp::RESET_INDEX.argv("abc123"),
3809            vec!["reset", "abc123", "--"],
3810        );
3811    }
3812
3813    /// `--keep` refuses rather than discarding, so unlike `--hard` it
3814    /// carries no confirm step.
3815    #[test]
3816    fn reset_keep_needs_no_confirmation() {
3817        assert_eq!(
3818            CommitOp::RESET_KEEP.argv("abc123"),
3819            vec!["reset", "--keep", "abc123"]
3820        );
3821        assert!(CommitOp::RESET_KEEP.confirm_action.is_none());
3822        // The destructive sibling still does.
3823        assert!(CommitOp::RESET_HARD.confirm_action.is_some());
3824    }
3825
3826    /// MG.43a: the `--no-commit` halves stage the change instead of
3827    /// recording it.
3828    ///
3829    /// That single flag is each row's entire reason to exist: `V`/`A`
3830    /// commit, `v`/`a` leave the result staged so it can be edited,
3831    /// split, or combined first. Losing the flag would silently
3832    /// collapse each pair into its sibling.
3833    #[test]
3834    fn the_no_commit_halves_stage_rather_than_commit() {
3835        assert_eq!(
3836            CommitOp::REVERT_CHANGES.argv("abc123"),
3837            vec!["revert", "--no-commit", "abc123"],
3838        );
3839        assert_eq!(
3840            CommitOp::CHERRY_PICK_APPLY.argv("abc123"),
3841            vec!["cherry-pick", "--no-commit", "abc123"],
3842        );
3843    }
3844
3845    /// The committing halves and their `--no-commit` peers are NOT the
3846    /// same operation, and neither pair may collapse into the other.
3847    #[test]
3848    fn each_no_commit_half_differs_from_its_committing_sibling() {
3849        assert_ne!(
3850            CommitOp::REVERT.argv("abc123"),
3851            CommitOp::REVERT_CHANGES.argv("abc123"),
3852        );
3853        assert_ne!(
3854            CommitOp::CHERRY_PICK.argv("abc123"),
3855            CommitOp::CHERRY_PICK_APPLY.argv("abc123"),
3856        );
3857    }
3858
3859    /// `--no-commit` carries no `--no-edit`.
3860    ///
3861    /// `--no-edit` exists on the committing halves to stop git opening
3862    /// `$EDITOR` in a context that cannot answer it. Nothing is
3863    /// committed here, so there is no editor to suppress. Git accepts
3864    /// the pair (verified — it exits 0 rather than erroring), so this
3865    /// is not guarding against a failure; it pins that the flag stays
3866    /// off, because a `--no-edit` here would read as though this row
3867    /// commits something.
3868    #[test]
3869    fn the_no_commit_halves_carry_no_edit_flag() {
3870        for argv in [
3871            CommitOp::REVERT_CHANGES.argv("abc123"),
3872            CommitOp::CHERRY_PICK_APPLY.argv("abc123"),
3873        ] {
3874            assert!(
3875                !argv.iter().any(|a| a == "--no-edit"),
3876                "no editor is opened when nothing is committed: {argv:?}",
3877            );
3878        }
3879    }
3880
3881    /// MG.43f: **reset `w` restores the WORKING TREE only.**
3882    ///
3883    /// `git restore --source <commit> --worktree -- .` leaves HEAD and
3884    /// the index alone. The two obvious alternatives both do more:
3885    /// `reset` moves HEAD, and `checkout <commit> -- .` writes the
3886    /// index too — so a file the user had staged would silently be
3887    /// restaged to the commit's version. Verified against real git.
3888    #[test]
3889    fn reset_worktree_leaves_head_and_the_index_alone() {
3890        let argv = CommitOp::RESET_WORKTREE.argv("abc123");
3891        assert_eq!(
3892            argv,
3893            vec!["restore", "--source", "abc123", "--worktree", "--", "."],
3894        );
3895        assert!(
3896            !argv.iter().any(|a| a == "reset" || a == "checkout"),
3897            "neither `reset` nor `checkout`: both touch more than the worktree",
3898        );
3899        // `--worktree` without `--staged` is the whole point; adding
3900        // `--staged` would make it write the index too.
3901        assert!(!argv.iter().any(|a| a == "--staged"), "{argv:?}");
3902    }
3903
3904    /// It overwrites uncommitted work, so it asks — the same bar
3905    /// `--hard` is held to.
3906    #[test]
3907    fn reset_worktree_asks_first() {
3908        assert!(CommitOp::RESET_WORKTREE.confirm_action.is_some());
3909    }
3910
3911    /// fixup / squash take the target commit LAST, which is what git's
3912    /// `--fixup <commit>` spelling expects.
3913    #[test]
3914    fn fixup_and_squash_target_the_commit() {
3915        assert_eq!(
3916            CommitOp::COMMIT_FIXUP.argv("abc123"),
3917            vec!["commit", "--no-edit", "--fixup", "abc123"],
3918        );
3919        assert_eq!(
3920            CommitOp::COMMIT_SQUASH.argv("abc123"),
3921            vec!["commit", "--no-edit", "--squash", "abc123"],
3922        );
3923    }
3924
3925    /// Ops with no trailing tokens are unchanged by the new field —
3926    /// the shape every pre-MG.41d op relies on.
3927    #[test]
3928    fn ops_without_trailing_tokens_are_unaffected() {
3929        assert_eq!(
3930            CommitOp::RESET_SOFT.argv("abc"),
3931            vec!["reset", "--soft", "abc"]
3932        );
3933        assert!(CommitOp::RESET_SOFT.trailing.is_empty());
3934    }
3935}
3936
3937#[cfg(test)]
3938mod rebase_gate_tests {
3939    use super::*;
3940
3941    fn keys(spec: &TransientSpec) -> Vec<String> {
3942        spec.groups
3943            .iter()
3944            .flat_map(|g| &g.items)
3945            .flat_map(|i| i.key.clone())
3946            .collect()
3947    }
3948
3949    /// MG.41e: outside a rebase the menu offers a way IN; it must not
3950    /// offer continue / skip / abort, which error when nothing is
3951    /// stopped and so would look actionable and fail.
3952    #[test]
3953    fn no_rebase_running_offers_only_the_way_in() {
3954        let spec = rebase_transient(&MagitActionIds::default(), false);
3955        // MG.43b added magit's onto-a-target rows; every one is a way
3956        // IN, so all belong to the idle set.
3957        assert_eq!(
3958            keys(&spec),
3959            vec!["p", "u", "e", "s", "m", "w", "k", "f", "i"]
3960        );
3961    }
3962
3963    /// Inside one the menu offers only the ways OUT — starting another
3964    /// rebase while one is half-done is the wrong menu entirely.
3965    #[test]
3966    fn a_stopped_rebase_offers_only_the_ways_out() {
3967        let spec = rebase_transient(&MagitActionIds::default(), true);
3968        assert_eq!(keys(&spec), vec!["r", "s", "a"]);
3969        assert!(
3970            !keys(&spec).contains(&"i".to_string()),
3971            "must not offer to start a rebase while one is stopped",
3972        );
3973    }
3974
3975    /// The gate is read from the repository, and covers BOTH backends.
3976    /// Git uses `rebase-merge` for the interactive/merge backend and
3977    /// `rebase-apply` for the older am-based one; checking only the
3978    /// first misses a whole class of stopped rebase, and
3979    /// `rebase-apply` is shared with `git am` (distinguished by the
3980    /// `applying` marker).
3981    #[test]
3982    fn the_gate_is_part_of_the_probe() {
3983        let gates = DispatchGates::default();
3984        assert!(!gates.rebase, "default gates report nothing in progress");
3985    }
3986}
3987
3988#[cfg(test)]
3989mod sequencer_gate_tests {
3990    use super::*;
3991
3992    fn keys(spec: &TransientSpec) -> Vec<String> {
3993        spec.groups
3994            .iter()
3995            .flat_map(|g| &g.items)
3996            .flat_map(|i| i.key.clone())
3997            .collect()
3998    }
3999
4000    /// MG.42-E4: idle offers the way IN only. `--continue` / `--skip`
4001    /// / `--abort` error when no sequence is running, so ungated rows
4002    /// would look actionable and fail.
4003    #[test]
4004    fn idle_sequencers_offer_only_the_way_in() {
4005        let ids = MagitActionIds::default();
4006        // MG.43a added the `--no-commit` halves; both are ways IN, so
4007        // both belong to the idle set.
4008        // MG.43d added the commit-MOVING rows; every one is a way IN.
4009        // A stopped merge offers only the ways OUT, and an idle one
4010        // only the ways IN — git refuses every `MERGE_ROWS` verb while
4011        // `MERGE_HEAD` exists, so an ungated menu was seven rows that
4012        // could only fail.
4013        assert_eq!(keys(&merge_transient(&ids, true)), vec!["m", "a"]);
4014        let idle_merge = keys(&merge_transient(&ids, false));
4015        assert!(
4016            idle_merge.len() > 2 && !idle_merge.contains(&"continue".to_string()),
4017            "idle merge offers the ways in: {idle_merge:?}"
4018        );
4019        assert_eq!(
4020            keys(&cherry_pick_transient(&ids, false)),
4021            vec!["A", "a", "h", "d", "n", "s"]
4022        );
4023        assert_eq!(keys(&revert_transient(&ids, false)), vec!["V", "v"]);
4024    }
4025
4026    /// MG.43a: **keys are overloaded across the two states, and the
4027    /// gate is the only thing that makes that safe.**
4028    ///
4029    /// `A` is *pick* when idle and *continue* when stopped; `a` is
4030    /// *apply* when idle and *abort* when stopped. Every one of those
4031    /// is magit's own key, and the pairs are only safe because the two
4032    /// sets are mutually exclusive — a menu showing both would put
4033    /// "apply this commit" one row from "throw the sequence away".
4034    ///
4035    /// So rather than assert particular keys, this asserts the
4036    /// property that makes the overload safe: any key appearing in
4037    /// BOTH states must resolve to a different row in each. A future
4038    /// row that reused a key for the *same* operation in both states
4039    /// would be a gate that stopped doing its job.
4040    #[test]
4041    fn overloaded_keys_resolve_to_different_rows_in_each_state() {
4042        let ids = MagitActionIds::default();
4043        for (name, idle, stopped) in [
4044            (
4045                "cherry-pick",
4046                cherry_pick_transient(&ids, false),
4047                cherry_pick_transient(&ids, true),
4048            ),
4049            (
4050                "revert",
4051                revert_transient(&ids, false),
4052                revert_transient(&ids, true),
4053            ),
4054            (
4055                "merge",
4056                merge_transient(&ids, false),
4057                merge_transient(&ids, true),
4058            ),
4059        ] {
4060            let label_for = |spec: &TransientSpec, key: &str| {
4061                spec.groups
4062                    .iter()
4063                    .flat_map(|g| &g.items)
4064                    .find(|i| i.key.iter().any(|k| k == key))
4065                    .map(|i| i.label.clone())
4066            };
4067            let shared: Vec<String> = keys(&idle)
4068                .into_iter()
4069                .filter(|k| keys(&stopped).contains(k))
4070                .collect();
4071            // Vacuity guard: these menus DO overload keys, and a
4072            // refactor that stopped sharing any would silently make
4073            // the loop below assert nothing.
4074            assert!(
4075                !shared.is_empty(),
4076                "{name}: expected the two states to share at least one key",
4077            );
4078            for key in shared {
4079                assert_ne!(
4080                    label_for(&idle, &key),
4081                    label_for(&stopped, &key),
4082                    "{name}: `{key}` resolves to the same row in both states —                      the gate is no longer distinguishing them",
4083                );
4084            }
4085        }
4086    }
4087
4088    /// Stopped offers the ways OUT only — starting another pick while
4089    /// one is mid-conflict is the wrong menu entirely.
4090    #[test]
4091    fn stopped_sequencers_offer_only_the_ways_out() {
4092        let ids = MagitActionIds::default();
4093        let cp = keys(&cherry_pick_transient(&ids, true));
4094        assert_eq!(cp, vec!["A", "s", "a"]);
4095        let rv = keys(&revert_transient(&ids, true));
4096        assert_eq!(rv, vec!["V", "s", "a"]);
4097    }
4098
4099    /// The two sequences do NOT share their sequencer rows.
4100    ///
4101    /// `git revert --continue` errors during a cherry-pick and vice
4102    /// versa, so a shared row set would fire the wrong command in one
4103    /// of the two menus — the reason these are separate consts rather
4104    /// than one "sequencer" table.
4105    #[test]
4106    fn each_sequence_fires_its_own_commands() {
4107        let cp: Vec<&str> = CHERRY_PICK_SEQUENCE_ROWS.iter().map(|r| r.action).collect();
4108        let rv: Vec<&str> = REVERT_SEQUENCE_ROWS.iter().map(|r| r.action).collect();
4109        assert!(cp.iter().all(|a| a.contains("cherry-pick")), "{cp:?}");
4110        assert!(rv.iter().all(|a| a.contains("revert")), "{rv:?}");
4111        assert!(
4112            cp.iter().all(|a| !rv.contains(a)),
4113            "the two sequences must not share an action: {cp:?} vs {rv:?}",
4114        );
4115    }
4116
4117    /// The gates default to "nothing running", so a developer's own
4118    /// half-finished cherry-pick cannot change a test's row count —
4119    /// the reason `probe()` is separate from the pure builder.
4120    #[test]
4121    fn gates_default_to_nothing_in_progress() {
4122        let g = DispatchGates::default();
4123        assert!(!g.cherry_pick && !g.revert && !g.rebase);
4124    }
4125}
4126
4127#[cfg(test)]
4128mod sequence_step_tests {
4129    use crate::magit_global_mode::{
4130        instant_squash_steps, merge_absorb_steps, stash_snapshot_steps,
4131    };
4132
4133    /// MG.42-E2: a snapshot APPLIES, never pops. A pop would remove the
4134    /// very stack entry the snapshot exists to create, leaving the user
4135    /// with neither a restore point nor a changed tree.
4136    #[test]
4137    fn a_snapshot_applies_rather_than_pops() {
4138        let steps = stash_snapshot_steps(&[]);
4139        assert_eq!(steps.len(), 2);
4140        assert_eq!(steps[0].argv, vec!["stash", "push"]);
4141        assert_eq!(steps[1].argv, vec!["stash", "apply"]);
4142        assert!(
4143            !steps[1].argv.contains(&"pop".to_string()),
4144            "pop would destroy the snapshot it just made",
4145        );
4146    }
4147
4148    /// The variants differ only in the push flags.
4149    #[test]
4150    fn snapshot_variants_pass_their_flags_to_the_push_only() {
4151        for extra in [vec!["--staged"], vec!["--keep-index"]] {
4152            let steps = stash_snapshot_steps(&extra);
4153            assert!(steps[0].argv.contains(&extra[0].to_string()));
4154            assert_eq!(
4155                steps[1].argv,
4156                vec!["stash", "apply"],
4157                "restore is unflagged"
4158            );
4159        }
4160    }
4161
4162    /// The rebase base is `<commit>~1`, not `<commit>`.
4163    ///
4164    /// A fixup must be replayed ALONGSIDE the commit it targets, so the
4165    /// rebase has to start one before it. Rebasing onto the commit
4166    /// itself would leave the fixup unmerged and the operation silently
4167    /// pointless.
4168    #[test]
4169    fn instant_squash_rebases_from_one_before_the_target() {
4170        let steps = instant_squash_steps("fixup", "abc123");
4171        assert_eq!(steps.len(), 2);
4172        assert!(steps[0].argv.contains(&"--fixup".to_string()));
4173        assert!(steps[0].argv.contains(&"abc123".to_string()));
4174        assert!(
4175            steps[1].argv.contains(&"abc123~1".to_string()),
4176            "must rebase from one before the target: {:?}",
4177            steps[1].argv,
4178        );
4179        assert!(
4180            steps[1].argv.contains(&"--autostash".to_string()),
4181            "an instant fixup is reached mid-edit; without autostash it \
4182             fails exactly when it is most wanted",
4183        );
4184    }
4185
4186    #[test]
4187    fn instant_squash_kind_selects_the_marker() {
4188        assert!(
4189            instant_squash_steps("squash", "x")[0]
4190                .argv
4191                .contains(&"--squash".to_string())
4192        );
4193        assert!(
4194            instant_squash_steps("fixup", "x")[0]
4195                .argv
4196                .contains(&"--fixup".to_string())
4197        );
4198    }
4199
4200    /// Absorb deletes with `-d`, never `-D`.
4201    ///
4202    /// Git refuses `-d` on a branch that is not fully merged, so a
4203    /// failed merge leaves the branch intact. `-D` would destroy it in
4204    /// exactly the case where the merge did not take.
4205    #[test]
4206    fn absorb_uses_a_safe_delete() {
4207        let steps = merge_absorb_steps("feature");
4208        assert_eq!(steps.len(), 2);
4209        assert!(steps[1].argv.contains(&"-d".to_string()));
4210        assert!(
4211            !steps[1].argv.contains(&"-D".to_string()),
4212            "a forced delete would destroy the branch when the merge failed",
4213        );
4214    }
4215}
4216
4217#[cfg(test)]
4218mod two_input_argv_tests {
4219    use crate::magit_global_mode::{reset_file_argv, stash_branch_argv};
4220
4221    /// MG.42-E3: "reset a file" is `checkout <commit> -- <path>`, NOT
4222    /// `reset`.
4223    ///
4224    /// `checkout` replaces the file in both index and working tree,
4225    /// which is what the row promises. `reset <commit> -- <path>` moves
4226    /// index entries only and leaves the file on disk untouched — the
4227    /// same words, a different outcome, and the failure would look like
4228    /// the command silently doing nothing.
4229    #[test]
4230    fn resetting_a_file_checks_it_out() {
4231        let argv = reset_file_argv("abc123", "src/main.rs");
4232        assert_eq!(argv, vec!["checkout", "abc123", "--", "src/main.rs"]);
4233        assert_ne!(argv[0], "reset", "reset would not touch the working tree");
4234    }
4235
4236    /// The `--` separator is what stops a path that looks like a ref
4237    /// from being read as one.
4238    #[test]
4239    fn the_path_is_separated_from_the_revision() {
4240        // A file literally named like a branch is the case this guards.
4241        let argv = reset_file_argv("HEAD", "main");
4242        let dashes = argv.iter().position(|a| a == "--").expect("has --");
4243        assert!(
4244            dashes < argv.iter().rposition(|a| a == "main").unwrap(),
4245            "the path must come after `--`: {argv:?}",
4246        );
4247    }
4248
4249    #[test]
4250    fn stash_branch_takes_the_name_then_the_stash() {
4251        assert_eq!(
4252            stash_branch_argv("recover", "stash@{0}"),
4253            vec!["stash", "branch", "recover", "stash@{0}"],
4254        );
4255    }
4256}
4257
4258#[cfg(test)]
4259mod commit_intent_tests {
4260    use crate::magit_commit_mode::CommitIntent;
4261
4262    /// MG.42-E1: the buffer name selects the intent in ONE place.
4263    ///
4264    /// `reword` is tested before `amend` on purpose — order is the
4265    /// difference between the two mapping correctly and one shadowing
4266    /// the other if a name ever changes.
4267    #[test]
4268    fn buffer_names_map_to_intents() {
4269        assert_eq!(
4270            CommitIntent::from_buffer_name("*magit:reword*"),
4271            CommitIntent::Reword
4272        );
4273        assert_eq!(
4274            CommitIntent::from_buffer_name("*magit:amend*"),
4275            CommitIntent::Amend
4276        );
4277        assert_eq!(
4278            CommitIntent::from_buffer_name("*magit:commit*"),
4279            CommitIntent::Create
4280        );
4281    }
4282
4283    /// Both replacing intents open pre-filled; a fresh commit does not.
4284    #[test]
4285    fn replacing_intents_seed_the_prior_message() {
4286        assert!(CommitIntent::Amend.seeds_prior_message());
4287        assert!(CommitIntent::Reword.seeds_prior_message());
4288        assert!(!CommitIntent::Create.seeds_prior_message());
4289    }
4290
4291    /// Reword and amend are NOT the same operation.
4292    ///
4293    /// `amend` sweeps in whatever is staged; `reword` passes `--only`
4294    /// and touches the message alone. Collapsing them would make a row
4295    /// labelled "reword" silently commit staged content.
4296    #[test]
4297    fn reword_is_distinct_from_amend() {
4298        assert_ne!(CommitIntent::Reword, CommitIntent::Amend);
4299    }
4300
4301    /// MG.42-E1: the targeted intents carry their target IN the name.
4302    ///
4303    /// Augment and merge-edit act on something the user picked, and
4304    /// the compose buffer is opened long before the commit runs. The
4305    /// name is the carrier so there is no side-channel to go stale
4306    /// between opening the buffer and confirming it.
4307    #[test]
4308    fn targeted_intents_round_trip_through_the_buffer_name() {
4309        let name = CommitIntent::augment_buffer_name("lattice", "abc123");
4310        assert_eq!(
4311            CommitIntent::from_buffer_name(&name),
4312            CommitIntent::Augment {
4313                target: "abc123".to_string()
4314            }
4315        );
4316
4317        let name = CommitIntent::merge_edit_buffer_name("lattice", "feature/x");
4318        assert_eq!(
4319            CommitIntent::from_buffer_name(&name),
4320            CommitIntent::MergeEdit {
4321                branch: "feature/x".to_string()
4322            }
4323        );
4324    }
4325
4326    /// A target that is empty or absent must NOT produce a targeted
4327    /// intent.
4328    ///
4329    /// `git commit --squash= -m msg` is not a no-op — it is an error
4330    /// git reports at the point the user expected a commit. Falling
4331    /// back to `Create` is wrong too, so the name simply does not
4332    /// match and the buffer composes an ordinary commit.
4333    #[test]
4334    fn an_empty_target_does_not_produce_a_targeted_intent() {
4335        assert_eq!(
4336            CommitIntent::from_buffer_name("*magit:augment:lattice:*"),
4337            CommitIntent::Create
4338        );
4339        assert_eq!(
4340            CommitIntent::from_buffer_name("*magit:merge-edit:lattice:*"),
4341            CommitIntent::Create
4342        );
4343    }
4344
4345    /// A target — or a REPOSITORY — containing "amend" or "reword"
4346    /// stays what the view word says it is.
4347    ///
4348    /// `amend-fixes` is an ordinary branch name and a checkout can be
4349    /// called anything; before MR.3 the intent was chosen by substring
4350    /// (`name.contains("amend")`), so either would have selected the
4351    /// opposite operation — merge-edit records a new merge commit,
4352    /// amend rewrites the last one. The repo here is deliberately named
4353    /// `amend` to pin that the structured parse, not ordering, is what
4354    /// makes this safe.
4355    #[test]
4356    fn a_target_containing_amend_stays_targeted() {
4357        assert_eq!(
4358            CommitIntent::from_buffer_name(&CommitIntent::merge_edit_buffer_name(
4359                "amend",
4360                "amend-fixes"
4361            )),
4362            CommitIntent::MergeEdit {
4363                branch: "amend-fixes".to_string()
4364            }
4365        );
4366    }
4367
4368    /// MG.43c: reword-a-commit is checked BEFORE the bare `reword`
4369    /// test, and the two are different operations.
4370    ///
4371    /// `*magit:reword-commit:<sha>*` contains the substring `reword`,
4372    /// so an ordering slip would make it amend HEAD — rewriting the
4373    /// wrong commit's message, and one the user can see is wrong only
4374    /// after it has happened.
4375    #[test]
4376    fn reword_a_commit_is_not_reword_head() {
4377        let name = CommitIntent::reword_commit_buffer_name("lattice", "abc123");
4378        assert_eq!(
4379            CommitIntent::from_buffer_name(&name),
4380            CommitIntent::RewordCommit {
4381                target: "abc123".to_string()
4382            },
4383        );
4384        assert_ne!(CommitIntent::from_buffer_name(&name), CommitIntent::Reword);
4385    }
4386
4387    /// It seeds from the commit it NAMES, not from HEAD.
4388    ///
4389    /// The buffer's text is what gets written back, so seeding from
4390    /// HEAD would show the wrong message and then apply it to the
4391    /// target — replacing one commit's message with another's.
4392    #[test]
4393    fn reword_a_commit_seeds_from_its_own_target() {
4394        let intent = CommitIntent::RewordCommit {
4395            target: "abc123".to_string(),
4396        };
4397        assert!(intent.seeds_prior_message());
4398        assert_eq!(intent.seed_source(), Some("abc123"));
4399        // The HEAD-acting intents name no source and fall back to HEAD.
4400        assert_eq!(CommitIntent::Reword.seed_source(), None);
4401        assert_eq!(CommitIntent::Amend.seed_source(), None);
4402    }
4403
4404    /// Neither targeted intent pre-fills the buffer.
4405    ///
4406    /// Augment's note is the user's own addition BELOW the generated
4407    /// `squash!` line, and a merge message is written fresh — seeding
4408    /// either with a prior message would put text there the user then
4409    /// has to delete.
4410    #[test]
4411    fn targeted_intents_do_not_seed_a_prior_message() {
4412        assert!(
4413            !CommitIntent::Augment {
4414                target: "abc123".to_string()
4415            }
4416            .seeds_prior_message()
4417        );
4418        assert!(
4419            !CommitIntent::MergeEdit {
4420                branch: "main".to_string()
4421            }
4422            .seeds_prior_message()
4423        );
4424    }
4425}
4426
4427#[cfg(test)]
4428mod root_menu_chord_tests {
4429    use super::*;
4430
4431    /// MG.49: **the keys vim needs stay vim's.**
4432    ///
4433    /// Emacs binds all seventeen root menus on `magit-mode-map` because
4434    /// emacs is not modal. Here `magit-core-mode` is a MINOR layer,
4435    /// which beats the builtin vim layer — so binding `f` would take
4436    /// find-char away inside every magit buffer, and a magit buffer is
4437    /// text you navigate.
4438    ///
4439    /// Vim's grammar IS the public command API (paramount goal #3), so
4440    /// it wins. The rule that falls out: a root menu may take a key
4441    /// whose vim meaning is an *editing operator* (inert where nothing
4442    /// is editable) and may NOT take one whose meaning is a motion.
4443    ///
4444    /// This is a deny-list rather than a "not in the builtin keymap"
4445    /// check, because the keys we DO take (`d`, `c`, `r`, `A`, `O`) are
4446    /// in the builtin keymap too — as operators. Membership is not the
4447    /// question; what the key MEANS is.
4448    #[test]
4449    fn no_root_menu_takes_a_key_vim_uses_as_a_motion() {
4450        // Motion or prefix in Normal mode, and therefore load-bearing
4451        // in a read-only buffer.
4452        const VIM_MOTIONS: &[(&str, &str)] = &[
4453            ("l", "right"),
4454            ("b", "back-word"),
4455            ("B", "back-WORD"),
4456            ("w", "word"),
4457            ("f", "find-char"),
4458            ("F", "find-char-back"),
4459            ("t", "till"),
4460            ("T", "till-back"),
4461            ("m", "set-mark"),
4462            ("z", "fold prefix (`zf` / `za` / `zo`)"),
4463        ];
4464        for menu in ROOT_MENUS {
4465            let Some(chord) = menu.chord else { continue };
4466            if let Some((_, meaning)) = VIM_MOTIONS.iter().find(|(k, _)| *k == chord) {
4467                panic!(
4468                    "`{}` binds `{chord}`, which is vim's {meaning} — a minor \
4469                     layer shadows the builtin one, so this would remove the \
4470                     motion from every magit buffer. Leave it `None`; the menu \
4471                     stays reachable through the dispatch.",
4472                    menu.source,
4473                );
4474            }
4475        }
4476    }
4477
4478    /// The other direction, so the rule above cannot be satisfied by
4479    /// binding nothing at all: the operator keys that ARE safe stay
4480    /// bound, including the three this mode carried before MG.49.
4481    #[test]
4482    fn the_operator_keys_are_actually_taken() {
4483        for (chord, source) in [
4484            ("A", "magit-menu-cherry-pick"),
4485            ("_", "magit-menu-revert"),
4486            ("O", "magit-menu-reset"),
4487        ] {
4488            let menu = ROOT_MENUS
4489                .iter()
4490                .find(|m| m.source == source)
4491                .unwrap_or_else(|| panic!("{source} is in ROOT_MENUS"));
4492            assert_eq!(
4493                menu.chord,
4494                Some(chord),
4495                "`{chord}` is a vim editing operator — inert in a read-only \
4496                 magit buffer — so {source} is free to take it",
4497            );
4498        }
4499    }
4500
4501    /// PD.3: the Diff menu carries four targets, and `e` is the new one.
4502    ///
4503    /// `d` / `f` / `v` are asserted alongside it because they are the
4504    /// regression that matters: they only live in this menu at all
4505    /// because binding `d` to it took their chords (the trie checks a
4506    /// node's own binding before its children), so a row lost here is a
4507    /// chord lost outright, silently.
4508    #[test]
4509    fn the_diff_menu_carries_d_f_v_and_the_new_e_row() {
4510        let by_key: std::collections::HashMap<&str, &str> =
4511            DIFF_SHOW_ROWS.iter().map(|r| (r.key, r.action)).collect();
4512        assert_eq!(by_key.len(), 4, "four targets: {by_key:?}");
4513        assert_eq!(by_key.get("d"), Some(&"action:magit-global-diff"));
4514        assert_eq!(by_key.get("f"), Some(&"action:magit-diff-file"));
4515        assert_eq!(by_key.get("v"), Some(&"action:magit-diff-side-by-side"));
4516        assert_eq!(
4517            by_key.get("e"),
4518            Some(&"action:magit-project-diff"),
4519            "`e` opens the editable cross-file view"
4520        );
4521    }
4522
4523    /// Every entry resolves to a spec, or a chord would open nothing.
4524    #[test]
4525    fn every_root_menu_builds() {
4526        let ids = MagitActionIds::default();
4527        let ctx = TransientContext::default();
4528        for menu in ROOT_MENUS {
4529            assert!(
4530                root_menu_spec(menu.source, &ids, &ctx, &DispatchGates::default()).is_some(),
4531                "{} has no spec — its chord and its dispatch row would both \
4532                 open nothing",
4533                menu.source,
4534            );
4535        }
4536    }
4537}