Skip to main content

lattice_magit/
lib.rs

1//! Magit — git porcelain as a core plugin.
2//!
3//! Feature-buffer crate inverted out of `lattice-host`. Owns every
4//! magit buffer view's mode, keymap, action handler, and synthetic-
5//! buffer provisioning. Installs through the `SubsystemBoot` seam —
6//! one line in `editor_boot.rs`, zero `Editor::do_magit_*` methods.
7//!
8//! See [`docs/dev/architecture/magit.md`] and
9//! [`docs/dev/operations/slice-plans/magit.md`].
10
11pub mod actions;
12pub mod blame;
13pub mod buffer_io;
14pub mod buffer_state;
15mod cherry_move;
16mod confirm;
17mod git_config;
18mod git_report;
19mod hunk_fold_source;
20// MG.18d: `pub` because `MagitView::refresh_restoring` (a public
21// trait) names `HunkRestore` in its signature.
22pub mod cursor_restore;
23pub mod fold_source;
24pub mod headerline;
25mod highlight;
26pub mod options;
27// MG.18c: public so the bench can measure the parser directly — the
28// accessor shape it pins (read the hunk, not the document) is the
29// paramount-#1 claim staging rests on, and MG.22 relocates this module
30// into `magit-hunk-mode` as the owner of diff content.
31pub mod hunk;
32/// DS.1: syntax spans for the code inside a diff. Owned by
33/// `magit-hunk-mode` — see `docs/dev/architecture/span-layering.md`.
34mod hunk_syntax;
35pub mod magit_blame_mode;
36pub mod magit_branch_mode;
37pub mod magit_cherry_mode;
38pub mod magit_commit_mode;
39pub mod magit_core_mode;
40pub mod magit_diff_mode;
41pub mod magit_file_revision_mode;
42pub mod magit_global_mode;
43pub mod magit_hunk_mode;
44pub mod magit_log_mode;
45pub mod magit_nav_mode;
46pub mod magit_notes_mode;
47pub mod magit_rebase_mode;
48pub mod magit_refs_mode;
49pub mod magit_remote_mode;
50pub mod magit_revision_mode;
51pub mod magit_stash_mode;
52pub mod magit_stash_show_mode;
53pub mod magit_status_mode;
54pub mod magit_submodule_mode;
55pub mod picker_sources;
56pub mod providers;
57pub mod refresh;
58pub mod repo_scope;
59pub mod sections;
60pub mod transients;
61pub mod workdir;
62
63use std::sync::Arc;
64
65use lattice_grammar::{
66    ActionSpec, ArgSpec, Args, Effect, ExCommandSpec, GrammarResult, LatencyClass, SurfaceForm,
67    registry::CommandRegistry,
68};
69use lattice_mode::SubsystemBoot;
70
71use magit_blame_mode::MagitBlameMode;
72use magit_branch_mode::MagitBranchMode;
73use magit_cherry_mode::MagitCherryMode;
74use magit_commit_mode::MagitCommitMode;
75use magit_core_mode::MagitCoreMode;
76use magit_diff_mode::MagitDiffMode;
77use magit_file_revision_mode::MagitFileRevisionMode;
78use magit_global_mode::MagitGlobalMode;
79use magit_log_mode::MagitLogMode;
80use magit_notes_mode::MagitNotesMode;
81use magit_rebase_mode::MagitRebaseMode;
82use magit_refs_mode::MagitRefsMode;
83use magit_remote_mode::MagitRemoteMode;
84use magit_revision_mode::MagitRevisionMode;
85use magit_stash_mode::MagitStashMode;
86use magit_status_mode::MagitStatusMode;
87use magit_submodule_mode::MagitSubmoduleMode;
88
89/// Register all magit modes, commands, and keymaps via the generic
90/// `SubsystemBoot` seam. Called once from `editor_boot.rs` during
91/// the Phase-B subsystem install pass.
92pub fn install(boot: &mut impl SubsystemBoot) {
93    // MG.41g: capture the bus so spawned git tasks can report
94    // completion. magit publishes `BackgroundTaskFinished`; the
95    // notification layer subscribes. No dependency either way.
96    magit_global_mode::set_event_bus(boot.event_bus().clone());
97
98    // ── Modes ──────────────────────────────────────────────
99
100    boot.modes_mut()
101        .register(MagitGlobalMode)
102        .expect("magit-global-mode registers without conflict");
103
104    boot.modes_mut()
105        .register(MagitCoreMode)
106        .expect("magit-core-mode registers without conflict");
107
108    // PD.1 (2026-08-12): the project-diff view's identity marker. It
109    // declares no chords — `implies` pulls in `magit-core-mode`, so it
110    // inherits `gr` / `q` / `]]` / `[[` by joining the family rather
111    // than by copying them.
112    crate::providers::project_diff::register_project_diff_mode(boot.modes_mut());
113
114    // PD.1: per-view state (workdir + which comparison), plus the
115    // DocumentId index the DocumentClosed cleanup keys on.
116    boot.register_service::<crate::providers::project_diff::ProjectDiffServiceHandle>(
117        std::sync::Arc::new(crate::providers::project_diff::ProjectDiffService::new()),
118    );
119
120    // PD.3: the view opener, on the generic provider-view seam. With
121    // the ex-command and the transient row (both registered below),
122    // this is the ENTIRE trigger — no `Editor::` method, no host
123    // `Action` variant, no dispatch arm. That is the acid test a
124    // provider crate is meant to pass, and it is the reason PV.1 built
125    // the seam instead of spending a third `AppEffect` variant here.
126    crate::providers::project_diff::register_project_diff_provider(boot.services_mut());
127
128    // MG.24a: the second shared minor. `magit-core-mode` is every magit
129    // buffer; this one is every magit buffer that renders a diff.
130    boot.modes_mut()
131        .register(magit_hunk_mode::MagitHunkMode)
132        .expect("magit-hunk-mode registers without conflict");
133
134    boot.modes_mut()
135        .register(MagitStatusMode)
136        .expect("magit-status-mode registers without conflict");
137
138    boot.modes_mut()
139        .register(MagitCommitMode)
140        .expect("magit-commit-mode registers without conflict");
141
142    boot.modes_mut()
143        .register(magit_nav_mode::MagitNavMode)
144        .expect("magit-nav-mode registers without conflict");
145
146    boot.modes_mut()
147        .register(MagitDiffMode)
148        .expect("magit-diff-mode registers without conflict");
149
150    boot.modes_mut()
151        .register(MagitLogMode)
152        .expect("magit-log-mode registers without conflict");
153
154    boot.modes_mut()
155        .register(MagitBlameMode)
156        .expect("magit-blame-mode registers without conflict");
157
158    boot.modes_mut()
159        .register(MagitStashMode)
160        .expect("magit-stash-mode registers without conflict");
161
162    boot.modes_mut()
163        .register(MagitBranchMode)
164        .expect("magit-branch-mode registers without conflict");
165
166    boot.modes_mut()
167        .register(MagitRemoteMode)
168        .expect("magit-remote-mode registers without conflict");
169
170    // MG.35
171    boot.modes_mut()
172        .register(MagitRefsMode)
173        .expect("magit-refs-mode registers without conflict");
174
175    // MG.37
176    boot.modes_mut()
177        .register(MagitNotesMode)
178        .expect("magit-notes-mode registers without conflict");
179
180    // MG.40
181    boot.modes_mut()
182        .register(MagitCherryMode)
183        .expect("magit-cherry-mode registers without conflict");
184
185    boot.modes_mut()
186        .register(MagitSubmoduleMode)
187        .expect("magit-submodule-mode registers without conflict");
188
189    boot.modes_mut()
190        .register(MagitRebaseMode)
191        .expect("magit-rebase-mode registers without conflict");
192
193    boot.modes_mut()
194        .register(MagitRevisionMode)
195        .expect("magit-revision-mode registers without conflict");
196
197    boot.modes_mut()
198        .register(MagitFileRevisionMode)
199        .expect("magit-file-revision-mode registers without conflict");
200
201    // MG.15
202    boot.modes_mut()
203        .register(magit_stash_show_mode::MagitStashShowMode)
204        .expect("magit-stash-show-mode registers without conflict");
205
206    // ── Per-buffer mode state (MG.13) ──────────────────────
207    //
208    // Each per-buffer mode's action handlers are registered once at
209    // boot via `Mode::action_handlers()` and resolve their state
210    // through these services at call time, keyed by `BufferId`. That
211    // removes the window in which a magit chord resolved but found no
212    // handler because `on_activate` had not finished. Register and
213    // look up through the `…StatesHandle` aliases — `ServiceRegistry`
214    // keys on `TypeId` (`feedback_servicesregistry_arc_typeid`).
215    // See `buffer_state`'s module docs.
216    register_buffer_state_services(boot);
217
218    // ── MG.18d: the cursor's way back after an async refresh ───
219    cursor_restore::install_cursor_bus(boot);
220
221    // ── Ex-commands ────────────────────────────────────────
222
223    let blame_requests: magit_blame_mode::BlameRequestsHandle =
224        Arc::new(magit_blame_mode::BlameRequests::default());
225    boot.register_service::<magit_blame_mode::BlameRequestsHandle>(blame_requests.clone());
226    // MG.43h: where the `d` / `l` argument menus leave their toggles
227    // for the view they are about to open.
228    boot.register_service::<magit_diff_mode::ViewArgsRequestsHandle>(
229        magit_diff_mode::ViewArgsRequestsHandle::default(),
230    );
231    // NOTIF.1d: the same handle the action handlers get as a service —
232    // MG.41g: no notification handle is captured any more — the git
233    // ops publish `BackgroundTaskFinished` and the notification layer
234    // subscribes, so magit has no dependency on it at all.
235    // MR.2: which repository each magit buffer acts on. Registered as a
236    // service (the modes read it at activation) AND captured by the
237    // ex-command closures below, which have no service registry to
238    // reach — one map, both surfaces.
239    let repo_scopes: repo_scope::RepoScopesHandle = Arc::new(repo_scope::RepoScopes::default());
240    // PR.5: hand magit the project resolver so its step-3 fallback
241    // discovers from a `:cd`-aware directory instead of the process's
242    // working directory. Registered ahead of every subsystem install in
243    // `editor_boot`, so this lookup is reliable rather than hopeful.
244    if let Some(resolver) = boot.service::<lattice_core::ProjectResolverHandle>() {
245        repo_scopes.set_resolver((*resolver).clone());
246    } else {
247        tracing::debug!("no project resolver at magit install; `C-x g` falls back to process cwd");
248    }
249    boot.register_service::<repo_scope::RepoScopesHandle>(repo_scopes.clone());
250    // PR.6: magit's buffers have no path, so the editor's project resolution
251    // had only the process working directory left to answer with — `:files`
252    // in a status buffer for one checkout listed whichever tree the editor
253    // was launched in. `RepoScopes` already knows, keyed by the name the host
254    // creates the buffer under, so registering it as a generic
255    // `BufferScopeSource` is the whole fix and covers every magit view at
256    // once. RCU into the wait-free registry like every other producer seam.
257    if let Some(scope_sources) = boot.service::<lattice_mode::BufferScopeSourceRegistryHandle>() {
258        let source: Arc<dyn lattice_mode::BufferScopeSource> = repo_scopes.clone();
259        scope_sources.rcu(|current| {
260            let mut next = (**current).clone();
261            next.register(source.clone());
262            Arc::new(next)
263        });
264    } else {
265        tracing::debug!(
266            "no buffer-scope registry at magit install; `:files` in a magit buffer \
267             will resolve against the working directory"
268        );
269    }
270    // The store handle exists on `boot` from Phase A; the *service*
271    // entry for it is registered after this install runs, so the
272    // ex-commands capture the handle rather than looking it up.
273    let store = boot.buffer_store().clone();
274    install_repo_scope_cleanup(boot, repo_scopes.clone());
275    register_ex_commands(
276        boot.commands_mut(),
277        blame_requests,
278        store,
279        repo_scopes.clone(),
280    );
281
282    // ── Action commands (keymap resolution targets) ──────
283
284    register_action_commands(boot.commands_mut());
285
286    // ── Transient menus (magit-dispatch / magit-file-dispatch) ──
287
288    // Fold audit fix: resolve the root dispatch's action ids now,
289    // while `boot.commands_mut()` still gives direct access to the
290    // registry `register_action_commands` just populated above —
291    // `TransientSourceRegistry`'s builders receive only a
292    // `TransientContext` (see its doc comment for why
293    // `Effect::OpenTransient` can only carry a name, not a
294    // `TransientSpec`), so this is captured by value rather than
295    // looked up again on every press.
296    // MG.41a: ONE resolver for every transient. It scans the registry
297    // for `action:magit-` names rather than reading a hand-kept struct,
298    // so registering an action is the only step needed before a row can
299    // reference it — the four-place enumeration this replaces
300    // (`reg` / struct field / `id_by_name` line / builder) is down to
301    // two, and neither of the removed two can drift silently.
302    let dispatch_ids = transients::MagitActionIds::resolve(boot.commands_mut());
303    let file_dispatch_ids = dispatch_ids.clone();
304    let other_file_dispatch_ids = dispatch_ids.clone();
305    let view_args_ids_src = dispatch_ids.clone();
306    let root_menu_ids = dispatch_ids.clone();
307    // MR.4: a menu's ROWS depend on what the buffer's repository has
308    // half-done, so the builders need the same two handles every trigger
309    // uses. Captured here for the same reason the ex-commands capture
310    // them: a transient builder receives a `TransientContext`, not a
311    // service registry.
312    let menu_store = boot.buffer_store().clone();
313    let menu_scopes = repo_scopes.clone();
314    let dispatch_store = menu_store.clone();
315    let dispatch_scopes = menu_scopes.clone();
316    // TR.1: the registry is the EDITOR's now (`editor_boot`, beside the picker
317    // registry), not magit's. Magit was its first user and had been its owner
318    // by accident, which made every transient menu conditional on magit having
319    // loaded. Look it up and contribute sources; a missing service means a
320    // harness that wired no picker layer, so degrade to a local registry whose
321    // sources simply go nowhere rather than panicking mid-boot.
322    // Looked up under the HANDLE type, matching how it is registered — the
323    // `ServiceRegistry` keys on `TypeId::of::<T>()`, so registering an
324    // `Arc<X>` as `XHandle` and looking up `X` silently answers `None`.
325    let transient_registry: lattice_picker::TransientSourceRegistryHandle = boot
326        .service::<lattice_picker::TransientSourceRegistryHandle>()
327        .map(|h| (*h).clone())
328        .unwrap_or_else(|| std::sync::Arc::new(lattice_picker::TransientSourceRegistry::new()));
329    // MG.49: each root menu is reachable BOTH from the dispatch and from
330    // its own chord, and both go through `root_menu_spec` — so the menu
331    // `C-c g z` nests and the menu `z` opens are the same object, not two
332    // that have to be kept in step.
333    //
334    // Registered from `ROOT_MENUS` rather than seventeen hand-written
335    // lines, for the same reason `magit-hunk-mode` exists: a set that has
336    // to be enumerated in more than one place grows a silent gap.
337    for menu in transients::ROOT_MENUS {
338        let ids = root_menu_ids.clone();
339        let source = menu.source;
340        let store = menu_store.clone();
341        let scopes = menu_scopes.clone();
342        transient_registry.register(source, move |ctx| {
343            // Gates are probed per open, exactly as the dispatch does:
344            // whether a rebase / bisect / cherry-pick is stopped decides
345            // which rows the menu should show, and that can change
346            // between two presses of the same chord.
347            //
348            // MR.4: probed in the repository of the buffer the menu was
349            // opened over. A menu built from the process's repository
350            // offers the way out of someone else's stopped rebase.
351            let workdir = menu_workdir(&store, &scopes, ctx);
352            transients::root_menu_spec(
353                source,
354                &ids,
355                ctx,
356                &transients::DispatchGates::probe_in(&workdir),
357            )
358            .expect("every ROOT_MENUS source has a spec")
359        });
360    }
361    // MG.23h: the root dispatch varies with where it was opened — see
362    // `transients::dispatch_transient`. The file dispatch does not: its
363    // rows all act on the visited file, which is the same question
364    // wherever you press `C-c f`.
365    transient_registry.register("magit-dispatch", move |ctx| {
366        // MG.43g: kick the git-config prefetch off HERE, not inside
367        // the builder's row construction.
368        //
369        // Every submenu is built eagerly when the dispatch opens, so
370        // this is the one point that runs before all of them, and it
371        // is fire-and-forget: the builder must not wait for I/O. A
372        // refresh that lands after this menu was built shows up the
373        // next time it opens, which is why an unread value renders
374        // `…` rather than blocking.
375        let workdir = menu_workdir(&dispatch_store, &dispatch_scopes, ctx);
376        git_config::refresh(workdir.clone());
377        transients::dispatch_transient(&dispatch_ids, ctx, &workdir)
378    });
379    transient_registry.register("magit-file-dispatch", move |_| {
380        transients::file_dispatch_transient(&file_dispatch_ids)
381    });
382    // MG.23a: the same rows for a file you are not visiting. Registered
383    // as a source + an ex-command and bound to NO chord — `C-c f` is the
384    // common case; a user who wants magit's always-ask behaviour binds
385    // this instead.
386    transient_registry.register("magit-other-file-dispatch", move |_| {
387        transients::other_file_dispatch_transient(&other_file_dispatch_ids)
388    });
389    // MG.23k: `D`. The rows depend on which magit view you are in, so
390    // this is the second context-varying source after the root
391    // dispatch — the builder reads `ctx.major_mode`.
392    let view_args_ids = view_args_ids_src;
393    transient_registry.register("magit-view-arguments", move |ctx| {
394        transients::view_arguments_transient(&view_args_ids, ctx)
395    });
396}
397
398/// MG.17a: parse a remote operation's flags off the `:` line into the
399/// positional `Args::List` its `args_schema` declares.
400///
401/// Accepts each flag's full git spelling (`--force-with-lease`) and
402/// nothing else — no abbreviations. The transient shows the same
403/// strings, so what you learn in one surface types correctly in the
404/// other, and an unrecognised token is silently ignored rather than
405/// failing the command: the flags are additive, so the worst case is
406/// an operation that does slightly less than you asked, never
407/// something you didn't ask for.
408fn parse_remote_flags(op: magit_global_mode::RemoteOp, line: &str) -> Args {
409    use magit_global_mode::RemoteArgKind;
410    if op.flags.is_empty() {
411        return Args::None;
412    }
413    let given: Vec<&str> = line.split_whitespace().collect();
414    Args::List(
415        op.flags
416            .iter()
417            .map(|f| match f.kind {
418                RemoteArgKind::Flag => lattice_grammar::ArgValue::Bool(given.contains(&f.arg)),
419                // MG.17b: `-m some message` — everything after the
420                // marker to the end of the line, so a stash message
421                // does not have to be quoted. That means a value
422                // argument must come last on the line, which is stated
423                // in the ex-command's doc; the transient has no such
424                // constraint.
425                // MG.23k: the joined form is one token, `--unified=3`,
426                // so it is found by prefix and the value is what
427                // follows. No "must come last" constraint, because
428                // there is nothing after it to swallow.
429                RemoteArgKind::ValueJoined { .. } => lattice_grammar::ArgValue::String(
430                    given
431                        .iter()
432                        .find_map(|t| t.strip_prefix(f.arg))
433                        .unwrap_or_default()
434                        .to_string(),
435                ),
436                RemoteArgKind::Value { .. } => lattice_grammar::ArgValue::String(
437                    given
438                        .iter()
439                        .position(|t| *t == f.arg)
440                        .map(|i| given[i + 1..].join(" "))
441                        .unwrap_or_default(),
442                ),
443            })
444            .collect(),
445    )
446}
447
448/// MG.13: register one `BufferStates<S>` service per per-buffer mode.
449///
450/// Factored out of [`install`] so the test below can assert that every
451/// migrated mode has its slot — a mode whose service is missing has
452/// handlers that silently resolve `None` and no-op, which from the
453/// user's side is indistinguishable from the dead-chord bug this slice
454/// exists to remove.
455fn register_buffer_state_services(boot: &mut impl SubsystemBoot) {
456    boot.register_service::<magit_branch_mode::BranchStatesHandle>(Arc::new(
457        buffer_state::BufferStates::default(),
458    ));
459    boot.register_service::<magit_stash_mode::StashStatesHandle>(Arc::new(
460        buffer_state::BufferStates::default(),
461    ));
462    boot.register_service::<magit_revision_mode::RevisionStatesHandle>(Arc::new(
463        buffer_state::BufferStates::default(),
464    ));
465    boot.register_service::<magit_blame_mode::BlameStatesHandle>(Arc::new(
466        buffer_state::BufferStates::default(),
467    ));
468    boot.register_service::<magit_commit_mode::CommitStatesHandle>(Arc::new(
469        buffer_state::BufferStates::default(),
470    ));
471    boot.register_service::<magit_rebase_mode::RebaseStatesHandle>(Arc::new(
472        buffer_state::BufferStates::default(),
473    ));
474    // MG.21c
475    boot.register_service::<magit_remote_mode::RemoteStatesHandle>(Arc::new(
476        buffer_state::BufferStates::default(),
477    ));
478    // MG.35
479    boot.register_service::<magit_refs_mode::RefsStatesHandle>(Arc::new(
480        buffer_state::BufferStates::default(),
481    ));
482    // MG.37
483    boot.register_service::<magit_notes_mode::NoteStatesHandle>(Arc::new(
484        buffer_state::BufferStates::default(),
485    ));
486    // MG.40
487    boot.register_service::<magit_cherry_mode::CherryStatesHandle>(Arc::new(
488        buffer_state::BufferStates::default(),
489    ));
490    // MG.21i
491    boot.register_service::<magit_submodule_mode::SubmoduleStatesHandle>(Arc::new(
492        buffer_state::BufferStates::default(),
493    ));
494    boot.register_service::<magit_log_mode::LogStatesHandle>(Arc::new(
495        buffer_state::BufferStates::default(),
496    ));
497    boot.register_service::<magit_diff_mode::DiffStatesHandle>(Arc::new(
498        buffer_state::BufferStates::default(),
499    ));
500    boot.register_service::<actions::StatusStatesHandle>(Arc::new(
501        buffer_state::BufferStates::default(),
502    ));
503    // MG.23g: stash-show gained per-buffer state when `a` / `-` needed
504    // somewhere to read its workdir from.
505    boot.register_service::<magit_stash_show_mode::StashShowStatesHandle>(Arc::new(
506        buffer_state::BufferStates::default(),
507    ));
508    // Shared-action dispatch: `gr` is bound by `magit-core-mode` and
509    // registered exactly once at boot; each view publishes its own
510    // refresh body here. See `buffer_state::MagitView` for why a
511    // per-mode registration of a shared action id is unsafe.
512    let views: buffer_state::MagitViewsHandle = Arc::new(buffer_state::MagitViews::default());
513    boot.register_service::<buffer_state::MagitViewsHandle>(views.clone());
514
515    // Reactive refresh: a magit mutation invalidates EVERY magit view,
516    // not the one the chord fired in.
517    //
518    // MG.21g found this shape for bisect — "a bisect mark checks out a
519    // different commit, so the status buffer, any open log, and any
520    // open diff are all stale at once, and refreshing only the buffer
521    // the chord fired in would leave the others confidently showing
522    // the previous HEAD". Every repo mutation has that property. This
523    // is that observation generalised from one operation to all of
524    // them, driven by the event every mutation already publishes.
525    //
526    // ONE subscription for the subsystem rather than one per mode.
527    // The split is deliberate: each view still owns its own refresh
528    // body (`MagitView::refresh`, which is also what `gr` runs), so
529    // the mode-owns-its-surface rule is satisfied where the behaviour
530    // lives. Only the invalidation SIGNAL is shared — and it is shared
531    // because it is one fact about the repository, not eight. Copying
532    // the subscription into every view mode would be the duplication
533    // `prefer-minor-modes-over-duplication` warns about, in the shape
534    // where a missing copy is silent: the view that forgot it would
535    // simply go stale.
536    subscribe_view_invalidation(boot, views);
537}
538
539/// Refresh every live magit view whenever a magit mutation reports
540/// itself finished.
541///
542/// Split out of [`install`] so the wiring reads as one thing rather
543/// than nine lines at the end of a 300-line function.
544fn subscribe_view_invalidation(
545    boot: &mut impl SubsystemBoot,
546    views: buffer_state::MagitViewsHandle,
547) {
548    let bus = boot.event_bus().clone();
549    let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
550    bus.subscribe(
551        lattice_runtime::EventFilter::kind(
552            lattice_protocol::event::EventKind::BackgroundTaskFinished,
553        ),
554        lattice_runtime::SubscriptionTarget::Channel(tx),
555    );
556    // Not unsubscribed: this lives for the editor's lifetime, like the
557    // service registration above it. There is no teardown point — the
558    // subsystem outlives every buffer it serves.
559    boot.runtime_handle().spawn(async move {
560        while let Some(event) = rx.recv().await {
561            if !magit_global_mode::invalidates_a_magit_view(&event) {
562                continue;
563            }
564            for view in views.all() {
565                // Each `refresh` spawns its own task and returns
566                // `None`; the effect channel is not how these land.
567                // The body is the one `gr` runs, so it carries the
568                // view's cursor-restore state and wakes the screen.
569                let _ = view.refresh();
570            }
571        }
572    });
573}
574
575/// IX.2: the execute half of each destructive pair, and the slots its
576/// confirmation carries.
577///
578/// Every slot is optional: a confirm raised by a path that carries
579/// nothing leaves them unset and the handler re-derives, which is the
580/// pre-IX.1 behaviour. Names matter — the host projects the dialog's
581/// state onto this schema **by name**, so a rename here without one at
582/// the `ask` site silently reverts that action to re-deriving.
583///
584/// `magit-rebase-abort-execute` is absent deliberately: it aborts *the*
585/// in-progress rebase, of which there is exactly one, so it has no
586/// target to carry and nothing to get wrong.
587/// How many argument slots `name` declares, or `None` if it is not a
588/// confirm-target action.
589///
590/// Exists so a producer can be pinned against the DECLARATION rather than
591/// against a literal count: `Effect::Confirm` zips this schema with the
592/// carried values, so a producer emitting more than is declared has those
593/// extras dropped silently between the ask and the act.
594#[cfg(test)]
595pub(crate) fn confirm_target_slots(name: &str) -> Option<usize> {
596    CONFIRM_TARGET_ACTIONS
597        .iter()
598        .find(|(n, _, _)| *n == name)
599        .map(|(_, _, slots)| slots.len())
600}
601
602const CONFIRM_TARGET_ACTIONS: &[(&str, &str, &[(&str, &str)])] = &[
603    (
604        "action:magit-discard-execute",
605        "Execute the discard after confirmation",
606        &[
607            ("path", "Repo-relative path, for a file-level discard"),
608            ("patch", "The synthesized patch, for a hunk or region"),
609            ("workdir", "Repository the patch applies in"),
610        ],
611    ),
612    (
613        "action:magit-discard-untracked-execute",
614        "Delete the untracked file after confirmation",
615        // Only a path: an untracked file has no hunks to synthesize a
616        // patch from, which is the same reason it cannot be restored
617        // with `git checkout`.
618        &[("path", "Repo-relative path of the untracked file to delete")],
619    ),
620    (
621        "action:magit-discard-batch-execute",
622        "Discard every file in the confirmed selection",
623        // ONE slot holding the whole batch, NUL-joined, each entry
624        // `<t|u><path>` so the tracked flag travels with its path. That flag
625        // decides `git checkout` versus `git clean`, and losing it would
626        // either fail on an untracked path or DELETE a tracked one.
627        //
628        // This comment used to read "a variadic list, not named slots", and
629        // the machinery has no such thing: `seed_transient_state` zips this
630        // schema with the carried values and `TransientValue` is
631        // `Bool | String`, so a slot cannot hold a list and everything past
632        // the first value was dropped between the ask and the act. The
633        // declared intent and the mechanism disagreed, silently, and
634        // `x` over three files discarded one.
635        &[(
636            "files",
637            "Every selected file as NUL-joined `<t|u><path>` entries",
638        )],
639    ),
640    (
641        "action:magit-global-file-delete-execute",
642        "Delete the file after confirmation",
643        &[("file", "Repo-relative path the prompt named")],
644    ),
645    (
646        "action:magit-global-file-checkout-execute",
647        "Check the file out from the named revision after confirmation",
648        &[
649            ("rev", "Revision the prompt named"),
650            ("file", "Repo-relative path the prompt named"),
651        ],
652    ),
653    (
654        "action:magit-branch-delete-execute",
655        "Delete the branch after confirmation",
656        &[("branch", "Branch the prompt named")],
657    ),
658    // MG.43a: the branch submenu's `x` reset. Like the delete pair
659    // below it, the menu opens from anywhere, so the carried ref is
660    // the only source of the target.
661    (
662        "action:magit-reset-worktree-execute",
663        "Reset the working tree after confirmation",
664        &[("commit", "Commit the prompt named")],
665    ),
666    (
667        "action:magit-global-branch-reset-execute",
668        "Reset the current branch after confirmation",
669        &[("ref", "Ref the prompt named")],
670    ),
671    // MG.32: the branch submenu's `x`. Unlike the chord's execute half
672    // above there is NO cursor to fall back on — the menu opens from
673    // anywhere — so the carried slot is the only source of the target.
674    (
675        "action:magit-global-branch-delete-execute",
676        "Delete the branch the menu named, after confirmation",
677        &[("branch", "Branch the prompt named")],
678    ),
679    // MG.21i. The slot is what makes the answer act on the submodule
680    // the QUESTION named rather than whatever is under the cursor when
681    // it is answered — a refresh landing while the dialog is open would
682    // otherwise re-point a working-tree deletion at a different one.
683    (
684        "action:magit-submodule-remove-execute",
685        "Remove the submodule after confirmation",
686        &[("path", "Submodule path the prompt named")],
687    ),
688    (
689        "action:magit-stash-drop-execute",
690        "Drop the stash after confirmation",
691        &[("stash", "Stash index the prompt named")],
692    ),
693    (
694        "action:magit-reset-hard-execute",
695        "Reset --hard after confirmation",
696        &[("commit", "Commit the prompt named")],
697    ),
698];
699
700/// MG.23a: the actions that take an optional `file` target — every
701/// `C-c f` row. Listed once so the schema pass, the
702/// `magit-other-file-dispatch` rows and the tests cannot drift apart.
703///
704/// `…-discard-execute` is deliberately NOT here: see
705/// [`transients::other_file_dispatch_transient`] for why an
706/// explicit target cannot survive a confirm today.
707const FILE_TARGET_ACTIONS: &[(&str, &str)] = &[
708    (
709        "action:magit-global-file-stage",
710        "Stage the file in the current buffer",
711    ),
712    (
713        "action:magit-global-file-unstage",
714        "Unstage the file in the current buffer",
715    ),
716    (
717        "action:magit-global-file-discard",
718        "Discard changes to the file in the current buffer",
719    ),
720    (
721        "action:magit-global-file-diff",
722        "Show diff for the file in the current buffer",
723    ),
724    (
725        "action:magit-global-file-log",
726        "Show commit history for the file in the current buffer",
727    ),
728    (
729        "action:magit-global-file-blame",
730        "Blame the file in the current buffer",
731    ),
732    // The execute half needs the slot too, not just the ask half that
733    // fills it: the host projects the confirm dialog's state onto THIS
734    // action's schema, so without a `file` slot the carried path lands
735    // nowhere and the handler silently falls back to the visited file.
736    // IX.1 migrated the ask half and missed this; the destructive-pair
737    // guard is what found it.
738    (
739        "action:magit-global-file-discard-execute",
740        "Execute the file discard after confirmation",
741    ),
742    // MG.23d
743    (
744        "action:magit-global-file-untrack",
745        "Stop tracking the file, keeping it on disk",
746    ),
747    (
748        "action:magit-global-file-delete",
749        "Delete the file (asks first)",
750    ),
751    (
752        "action:magit-global-file-rename",
753        "Rename the file (asks for the new name)",
754    ),
755    // MG.23d2
756    (
757        "action:magit-global-file-checkout",
758        "Check the file out from a revision (asks for it, then confirms)",
759    ),
760];
761
762/// MG.28: what `:magit-find-file` says when it is not given both
763/// halves. Both are required — there is no defensible default file,
764/// and a default revision would silently show you HEAD when you asked
765/// for something else.
766fn find_file_usage() -> Effect {
767    Effect::Echo {
768        level: lattice_grammar::EchoLevel::Error,
769        text: "magit: usage — :magit-find-file <rev> <path> \
770               (or `C-c f v` for the file you are visiting)"
771            .to_string(),
772    }
773}
774
775/// MG.39: what `:magit-am` says with no patch to apply.
776fn am_usage() -> Effect {
777    Effect::Echo {
778        level: lattice_grammar::EchoLevel::Error,
779        text: "magit: usage — :magit-am <patch>… [-3]".to_string(),
780    }
781}
782
783/// MG.39: what `:magit-format-patch` says with no range. No default:
784/// `format-patch` with none writes a patch per commit since the root,
785/// which is never what anyone meant.
786fn format_patch_usage() -> Effect {
787    Effect::Echo {
788        level: lattice_grammar::EchoLevel::Error,
789        text: "magit: usage — :magit-format-patch <range>  (e.g. @{upstream}..HEAD)".to_string(),
790    }
791}
792
793/// MG.40: what `:magit-cherries` says with no upstream. "Not upstream
794/// yet" has no meaning without naming the upstream.
795fn cherries_usage() -> Effect {
796    Effect::Echo {
797        level: lattice_grammar::EchoLevel::Error,
798        text: "magit: usage — :magit-cherries <upstream> [<head>]".to_string(),
799    }
800}
801
802/// MG.37: what the note ex-commands say with no commit. No default —
803/// defaulting to HEAD would edit or remove a note on a commit the user
804/// never named.
805fn note_usage(cmd: &str) -> Effect {
806    Effect::Echo {
807        level: lattice_grammar::EchoLevel::Error,
808        text: format!("magit: usage — :{cmd} <commit>  (or `C-c g T` to pick one)"),
809    }
810}
811
812/// MG.37: merge takes a ref and an optional strategy, not a commit.
813fn note_merge_usage() -> Effect {
814    Effect::Echo {
815        level: lattice_grammar::EchoLevel::Error,
816        text: "magit: usage — :magit-note-merge <notes-ref> \
817               [manual|ours|theirs|union|cat_sort_uniq]"
818            .to_string(),
819    }
820}
821
822/// MG.36: what `:magit-clone` says with nothing usable. The
823/// destination is optional and derived; the URL is not, and there is no
824/// defensible guess for it.
825fn clone_usage() -> Effect {
826    Effect::Echo {
827        level: lattice_grammar::EchoLevel::Error,
828        text: "magit: usage — :magit-clone <url> [<destination>] \
829               (or `C` in the dispatch)"
830            .to_string(),
831    }
832}
833
834/// MG.34: what `:magit-log-merged` says with no commit. No default —
835/// "the merge that brought HEAD in" is not a question with an answer,
836/// and guessing one would show a buffer the user did not ask for.
837fn log_merged_usage() -> Effect {
838    Effect::Echo {
839        level: lattice_grammar::EchoLevel::Error,
840        text: "magit: usage — :magit-log-merged <commit> \
841               (or `C-c f M` to pick one)"
842            .to_string(),
843    }
844}
845
846/// MG.23f2: what `:magit-blame-reverse` says when it is not given both
847/// halves. An error rather than a best guess — see the registration for
848/// why there is no defensible default revision.
849fn reverse_blame_usage() -> Effect {
850    Effect::Echo {
851        level: lattice_grammar::EchoLevel::Error,
852        text: "magit: usage — :magit-blame-reverse <rev> <path>".to_string(),
853    }
854}
855
856/// MR.4: the repository a transient menu's rows are about.
857///
858/// The menu is built over a buffer, and `TransientContext::buffer` is
859/// how the host says which. Falls back to the working directory when
860/// the context has none (mid-boot), which is the same direction the
861/// mode-gated rows degrade in.
862fn menu_workdir(
863    store: &lattice_mode::BufferStoreHandle,
864    scopes: &repo_scope::RepoScopes,
865    ctx: &lattice_picker::TransientContext,
866) -> std::path::PathBuf {
867    ctx.buffer
868        .map(|buffer| repo_scope::workdir_or_cwd(store, scopes, buffer))
869        .or_else(|| {
870            // PR.5: same `:cd`-aware start as every other fallback.
871            workdir::magit_workdir_from(
872                scopes
873                    .discovery_start()
874                    .as_deref()
875                    .unwrap_or(std::path::Path::new(".")),
876            )
877        })
878        .unwrap_or_default()
879}
880
881/// MR.2: drop a magit buffer's recorded repository when the buffer
882/// closes.
883///
884/// Same shape as `lattice-lsp`'s references cleanup, including the
885/// runtime guard: an `Editor` built outside a tokio runtime (the host
886/// lib tests) skips the subscriber rather than panicking, and leaks at
887/// most one small entry per magit buffer in a short-lived process.
888fn install_repo_scope_cleanup(boot: &mut impl SubsystemBoot, scopes: repo_scope::RepoScopesHandle) {
889    let Ok(handle) = tokio::runtime::Handle::try_current() else {
890        tracing::debug!(
891            "magit: no tokio runtime in scope; skipping DocumentClosed \
892             repo-scope cleanup (expected in test paths)"
893        );
894        return;
895    };
896    let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<lattice_protocol::Event>();
897    boot.event_bus().subscribe(
898        lattice_runtime::EventFilter::kind(lattice_protocol::EventKind::DocumentClosed),
899        lattice_runtime::SubscriptionTarget::Channel(tx),
900    );
901    handle.spawn(async move {
902        while let Some(event) = rx.recv().await {
903            if let lattice_protocol::Event::DocumentClosed { id } = event {
904                scopes.forget_by_document_id(id);
905            }
906        }
907    });
908}
909
910/// Register all magit ex-commands in the command registry.
911///
912/// MG.26b: `blame_requests` is threaded in rather than looked up,
913/// because an ex-command's `apply` receives `lattice_grammar`'s
914/// `ExCommandContext`, which carries no service registry — by design,
915/// the grammar crate knows nothing about magit's services. Capturing the
916/// same `Arc` the mode's handlers get as a service means both surfaces
917/// write to one map instead of two.
918///
919/// MR.2 threads `store` + `scopes` in for the same reason and through
920/// the same door. `store` comes from `SubsystemBoot::buffer_store`,
921/// which magit holds at install time — the service-registry entry does
922/// not exist yet at that point, so a lookup would silently find nothing.
923fn register_ex_commands(
924    registry: &mut CommandRegistry,
925    blame_requests: magit_blame_mode::BlameRequestsHandle,
926    store: lattice_mode::BufferStoreHandle,
927    scopes: repo_scope::RepoScopesHandle,
928) {
929    // MR.2: `:magit-status` resolves its repository from the buffer the
930    // `:` line was submitted from, exactly as `C-x g` does — both go
931    // through `repo_scope::open_repo_view` and neither has a path of its
932    // own. The remaining views keep `mk` (fixed name, cwd resolution)
933    // until MR.3 converts them; this is the whole reason MR.2 is
934    // status-only.
935    //
936    // Registered before `mk` is built, not for style: `mk` borrows
937    // `registry` mutably for its whole life, so anything else touching
938    // the registry has to happen first.
939    // PC.3: an optional PATH argument — the explicit form
940    // `magit-repo-scoping.md` deferred rather than rejected ("Rejected as the
941    // *primary* mechanism … Worth having later as an explicit form"). Bare
942    // `:magit-status` is unchanged and still resolves from the buffer, which is
943    // what keeps this complementary: making the common case (working across two
944    // checkouts) the one that needs an argument would be backwards. What needs
945    // an argument is a repository chosen from somewhere else — a project
946    // picker, which already knows which one it means.
947    registry.register_ex_command(
948        "magit-status",
949        "Open the Magit status buffer. With no argument, for the repository of \
950         the current buffer; with a path, for the repository containing it.",
951        ExCommandSpec {
952            latency_class: LatencyClass::Reflex,
953            accepts_bang: false,
954            accepts_range: false,
955            parse_args: Arc::new(|line: &str, _bang: bool| {
956                let rest = line.trim();
957                Ok(if rest.is_empty() {
958                    Args::None
959                } else {
960                    Args::String(rest.to_string())
961                })
962            }),
963            apply: {
964                let store = store.clone();
965                let scopes = scopes.clone();
966                Arc::new(move |ctx| {
967                    let at = match &ctx.args {
968                        Args::String(p) if !p.trim().is_empty() => Some(std::path::PathBuf::from(
969                            lattice_core::home::expand_tilde(p.trim()),
970                        )),
971                        _ => None,
972                    };
973                    Ok(repo_scope::open_repo_view_at(
974                        "status",
975                        "magit-status-mode",
976                        &store,
977                        &scopes,
978                        ctx.buffer_id,
979                        at.as_deref(),
980                    ))
981                })
982            },
983            args_schema: vec![lattice_grammar::ArgSpec {
984                name: "path".into(),
985                kind: lattice_grammar::ArgKind::String,
986                doc: "a path inside the repository; defaults to the current buffer's".into(),
987                prompt: "Repository: ".into(),
988                default: lattice_grammar::ArgDefault::None,
989                completion: None,
990                picker: None,
991            }],
992            surface_form: SurfaceForm::Keyword,
993        },
994    );
995
996    // MR.3: a view whose whole identity is "this view, this repository".
997    // The view word goes in; the repository is resolved from the buffer
998    // the `:` line was submitted from and the name is composed from
999    // both. Same body as `C-x g`'s handler and `:magit-status`.
1000    let mut mk =
1001        |name: &'static str, doc: &'static str, view: &'static str, mode_id: &'static str| {
1002            let store = store.clone();
1003            let scopes = scopes.clone();
1004            registry.register_ex_command(
1005                name,
1006                doc,
1007                ExCommandSpec {
1008                    latency_class: LatencyClass::Reflex,
1009                    accepts_bang: false,
1010                    accepts_range: false,
1011                    parse_args: Arc::new(|_line: &str, _bang: bool| Ok(Args::None)),
1012                    apply: Arc::new(move |ctx| {
1013                        Ok(repo_scope::open_repo_view(
1014                            view,
1015                            mode_id,
1016                            &store,
1017                            &scopes,
1018                            ctx.buffer_id,
1019                        ))
1020                    }),
1021                    args_schema: Vec::new(),
1022                    surface_form: SurfaceForm::Keyword,
1023                },
1024            );
1025        };
1026
1027    mk(
1028        "magit-commit",
1029        "Open the Magit commit buffer for the repository of the current buffer.",
1030        "commit",
1031        "magit-commit-mode",
1032    );
1033    mk(
1034        "magit-branch",
1035        "Open the Magit branch list for the repository of the current buffer.",
1036        "branch",
1037        "magit-branch-mode",
1038    );
1039    // MG.21c
1040    mk(
1041        "magit-remote",
1042        "Open the Magit remote list for the repository of the current buffer.",
1043        "remote",
1044        "magit-remote-mode",
1045    );
1046    // MG.21i
1047    mk(
1048        "magit-submodule",
1049        "Open the Magit submodule list for the repository of the current buffer.",
1050        "submodule",
1051        "magit-submodule-mode",
1052    );
1053    // MG.35: magit's `y` show-refs. Named for what it lists rather than
1054    // for magit's key, per the dashed-namespaced ex-command rule.
1055    mk(
1056        "magit-refs",
1057        "Open the Magit refs buffer — every branch, remote-tracking branch and tag.",
1058        magit_refs_mode::REFS_VIEW,
1059        "magit-refs-mode",
1060    );
1061    // MR.3b: these three also encode parameters of their own
1062    // (`*magit:diff:<repo>:staged:<path>*`, `*magit:log:<repo>:<path>*`,
1063    // `*magit:stash:<repo>:<n>*`). Their bare ex-command forms are
1064    // ordinary repo-scoped views; the parameterised forms are produced
1065    // by the handlers that know the parameter.
1066    mk(
1067        "magit-diff",
1068        "Diff the repository of the current buffer against HEAD.",
1069        "diff",
1070        "magit-diff-mode",
1071    );
1072    mk(
1073        "magit-log",
1074        "Open the commit history of the repository of the current buffer.",
1075        "log",
1076        "magit-log-mode",
1077    );
1078    mk(
1079        "magit-stash-list",
1080        "Open the stash list of the repository of the current buffer.",
1081        "stash",
1082        "magit-stash-mode",
1083    );
1084
1085    // PD.3 (2026-08-12): the project-diff view — every changed file at
1086    // once, as editable source. Not `mk`-able: it opens a multibuffer,
1087    // not a synthetic Document, so it routes through the generic
1088    // provider-view seam (`AppEffect::OpenProviderView`) rather than
1089    // `Effect::OpenSyntheticBuffer`. The opener itself lives in
1090    // `providers::project_diff`; this is one of its two front-ends
1091    // (the other is the Diff transient's `e` row), and both name the
1092    // same registered provider so they cannot drift.
1093    //
1094    // One dashed namespaced alias, per the ex-command naming rule. No
1095    // collapsed spelling, and no new 1–2 letter short.
1096    registry.register_ex_command(
1097        "magit-project-diff",
1098        "Open every changed file in the working tree as one editable diff view. \
1099         Pass `staged` to see the index against HEAD instead (read-only).",
1100        ExCommandSpec {
1101            latency_class: LatencyClass::Reflex,
1102            accepts_bang: false,
1103            accepts_range: false,
1104            // The comparison rides as a plain string so the opener owns
1105            // the parse — the ex-command must not learn the provider's
1106            // vocabulary, or adding a comparison would mean editing two
1107            // places that then have to agree.
1108            parse_args: Arc::new(|line: &str, _bang: bool| {
1109                let trimmed = line.trim();
1110                Ok(if trimmed.is_empty() {
1111                    Args::None
1112                } else {
1113                    Args::String(trimmed.to_string())
1114                })
1115            }),
1116            apply: Arc::new(|ctx| {
1117                Ok(Effect::AppAction(
1118                    lattice_grammar::app_effect::AppEffect::OpenProviderView {
1119                        provider: crate::providers::project_diff::PROVIDER_NAME.to_string(),
1120                        args: ctx.args.clone(),
1121                    },
1122                ))
1123            }),
1124            args_schema: vec![lattice_grammar::args::ArgSpec::optional(
1125                "comparison",
1126                lattice_grammar::args::ArgKind::String,
1127                "`staged` for the index against HEAD; omitted for the working tree",
1128            )],
1129            surface_form: SurfaceForm::Keyword,
1130        },
1131    );
1132    // Fold audit fix: `magit-dispatch` / `magit-file-dispatch` open
1133    // their OWN named transients (registered into
1134    // `TransientSourceRegistry` by `install`, below) instead of
1135    // aliasing `magit-status` — each returns `Effect::OpenTransient`
1136    // rather than `Effect::OpenSyntheticBuffer`.
1137    let mut mk_transient = |name: &'static str, doc: &'static str, source: &'static str| {
1138        registry.register_ex_command(
1139            name,
1140            doc,
1141            ExCommandSpec {
1142                latency_class: LatencyClass::Reflex,
1143                accepts_bang: false,
1144                accepts_range: false,
1145                parse_args: Arc::new(|_line: &str, _bang: bool| Ok(Args::None)),
1146                apply: Arc::new(move |_ctx| {
1147                    Ok(Effect::OpenTransient {
1148                        source: source.to_string(),
1149                        // TR.3a: a plain open — every native menu is opened for
1150                        // itself rather than for a subject.
1151                        args: lattice_grammar::Args::None,
1152                    })
1153                }),
1154                args_schema: Vec::new(),
1155                surface_form: SurfaceForm::Keyword,
1156            },
1157        );
1158    };
1159    mk_transient(
1160        "magit-dispatch",
1161        "Open the Magit repo-level dispatch transient.",
1162        "magit-dispatch",
1163    );
1164    mk_transient(
1165        "magit-file-dispatch",
1166        "Open the Magit file-level dispatch transient.",
1167        "magit-file-dispatch",
1168    );
1169    mk_transient(
1170        "magit-other-file-dispatch",
1171        "Open the Magit file-level dispatch transient for a file you name, \
1172         rather than the one you are visiting.",
1173        "magit-other-file-dispatch",
1174    );
1175
1176    // MG.16: the remote/stash operations were reachable from `C-c g`
1177    // and nowhere else. Ex-commands are the scriptable surface and the
1178    // `:` discovery path, so a transient-only operation is invisible
1179    // to both — you cannot bind it, cannot script it, and cannot find
1180    // it by typing `:magit-<Tab>`.
1181    //
1182    // These are front-ends, not reimplementations: each resolves the
1183    // same `RemoteOp` constant its transient item fires and calls the
1184    // same `spawn_remote_op` body (the unified-dispatch rule). Names
1185    // are dashed + namespaced per the standing ex-command rule; no new
1186    // 1-2 letter shorts.
1187    let mut mk_op = |name: &'static str, doc: &'static str, op: magit_global_mode::RemoteOp| {
1188        registry.register_ex_command(
1189            name,
1190            doc,
1191            ExCommandSpec {
1192                latency_class: LatencyClass::Reflex,
1193                accepts_bang: false,
1194                accepts_range: false,
1195                // MG.17a: `--force-with-lease`, `--prune`, … parsed off
1196                // the `:` line into the SAME positional `Args::List` the
1197                // transient's flag toggles produce. `RemoteOp::flags` is
1198                // the one definition both read, so a flag added there
1199                // appears on both surfaces at once.
1200                parse_args: Arc::new(move |line: &str, _bang: bool| {
1201                    Ok(parse_remote_flags(op, line))
1202                }),
1203                apply: {
1204                    let store = store.clone();
1205                    let scopes = scopes.clone();
1206                    Arc::new(move |ctx| {
1207                        Ok(magit_global_mode::spawn_remote_op(
1208                            repo_scope::workdir_or_cwd(&store, &scopes, ctx.buffer_id),
1209                            op,
1210                            &ctx.args,
1211                        ))
1212                    })
1213                },
1214                args_schema: op.arg_specs(),
1215                surface_form: SurfaceForm::Keyword,
1216            },
1217        );
1218    };
1219    mk_op(
1220        "magit-fetch",
1221        "Fetch from the default remote without merging.",
1222        magit_global_mode::RemoteOp::FETCH,
1223    );
1224    mk_op(
1225        "magit-pull",
1226        "Pull from the upstream branch (fast-forward only).",
1227        magit_global_mode::RemoteOp::PULL,
1228    );
1229    mk_op(
1230        "magit-push",
1231        "Push the current branch to its upstream.",
1232        magit_global_mode::RemoteOp::PUSH,
1233    );
1234    // `:magit-stash` creates a stash; `:magit-stash-list` opens the
1235    // list buffer. The pair mirrors Emacs magit's own `z z` / `z l`,
1236    // where the bare stash key is the create.
1237    mk_op(
1238        "magit-stash",
1239        "Stash the working tree's changes.",
1240        magit_global_mode::RemoteOp::STASH,
1241    );
1242    // MG.34: sequencer controls. Not remote operations, but the same
1243    // shape — one bounded `git` argv, run off the actor thread, result
1244    // reported by notification — so they reuse the mechanism rather
1245    // than growing a parallel one. `:magit-stash` set that precedent.
1246    //
1247    // These exist because `C-c f e` marks a commit `edit`: a rebase that
1248    // stops needs a way forward, and before this slice the only
1249    // sequencer control was `C-c C-k` in a todo buffer, which is gone by
1250    // the time the rebase is actually running.
1251    mk_op(
1252        "magit-rebase-continue",
1253        "Resume a rebase that stopped (after amending, or resolving conflicts).",
1254        magit_global_mode::RemoteOp::REBASE_CONTINUE,
1255    );
1256    mk_op(
1257        "magit-rebase-skip",
1258        "Skip the commit a stopped rebase is sitting on.",
1259        magit_global_mode::RemoteOp::REBASE_SKIP,
1260    );
1261    mk_op(
1262        "magit-rebase-abort",
1263        "Abandon a rebase in progress, restoring the branch to where it started.",
1264        magit_global_mode::RemoteOp::REBASE_ABORT,
1265    );
1266    // MG.23c1: the scriptable half of the prompt-backed operations.
1267    // With an argument they act directly; without one they open the
1268    // same prompt the menu row does, so `:magit-tag` and `C-c g t` are
1269    // the same operation reached two ways rather than two operations.
1270    {
1271        let mut mk_prompted =
1272            |name: &'static str,
1273             doc: &'static str,
1274             arg: &'static str,
1275             arg_doc: &'static str,
1276             prompt_action: &'static str,
1277             run: fn(std::path::PathBuf, String) -> Effect| {
1278                // MR.4: the operation runs in the repository of the buffer
1279                // the `:` line came from, so the handles are captured here
1280                // and the workdir resolved at call time.
1281                let store = store.clone();
1282                let scopes = scopes.clone();
1283                registry.register_ex_command(
1284                    name,
1285                    doc,
1286                    ExCommandSpec {
1287                        latency_class: LatencyClass::Reflex,
1288                        accepts_bang: false,
1289                        accepts_range: false,
1290                        parse_args: Arc::new(|line: &str, _bang: bool| {
1291                            let trimmed = line.trim();
1292                            if trimmed.is_empty() {
1293                                Ok(Args::None)
1294                            } else {
1295                                Ok(Args::String(trimmed.to_string()))
1296                            }
1297                        }),
1298                        apply: Arc::new(move |ctx| {
1299                            Ok(match ctx.args {
1300                                Args::String(ref v) if !v.trim().is_empty() => run(
1301                                    repo_scope::active_workdir(&store, &scopes, ctx.buffer_id)
1302                                        .unwrap_or_default(),
1303                                    v.trim().to_string(),
1304                                ),
1305                                // No argument: ask, through the same action
1306                                // the menu row fires, so there is one prompt
1307                                // and one finish handler for both surfaces.
1308                                _ => Effect::OpenPrompt {
1309                                    prompt: format!("{arg_doc}: "),
1310                                    initial: String::new(),
1311                                    on_submit_action: prompt_action.to_string(),
1312                                    buffer_name: None,
1313                                },
1314                            })
1315                        }),
1316                        args_schema: vec![ArgSpec::optional(
1317                            arg,
1318                            lattice_grammar::ArgKind::String,
1319                            arg_doc,
1320                        )],
1321                        surface_form: SurfaceForm::Keyword,
1322                    },
1323                );
1324            };
1325        mk_prompted(
1326            "magit-tag",
1327            "Tag HEAD. With arg: the tag name; without, asks for it.",
1328            "name",
1329            "Tag name",
1330            "action:magit-global-tag-finish",
1331            |wd, name| {
1332                magit_global_mode::spawn_git(
1333                    wd,
1334                    magit_global_mode::tag_argv(&name),
1335                    &format!("tag HEAD as {name}"),
1336                )
1337            },
1338        );
1339        mk_prompted(
1340            "magit-merge",
1341            "Merge a branch into the current one. With arg: the branch; without, asks.",
1342            "branch",
1343            "Merge branch",
1344            "action:magit-global-merge-finish",
1345            |wd, branch| {
1346                magit_global_mode::spawn_git(
1347                    wd,
1348                    magit_global_mode::merge_argv(&branch),
1349                    &magit_global_mode::merge_label("merge", &branch),
1350                )
1351            },
1352        );
1353        // MG.41e: merge / tag variants. Same prompt-then-finish shape
1354        // as their siblings above; only the argv differs.
1355        mk_prompted(
1356            "magit-merge-no-commit",
1357            "Merge a branch but stop before committing. With arg: the branch; without, asks.",
1358            "branch",
1359            "Merge branch (no commit)",
1360            "action:magit-global-merge-no-commit-finish",
1361            |wd, branch| {
1362                magit_global_mode::spawn_git(
1363                    wd,
1364                    magit_global_mode::merge_no_commit_argv(&branch),
1365                    &magit_global_mode::merge_label("no-commit", &branch),
1366                )
1367            },
1368        );
1369        mk_prompted(
1370            "magit-merge-squash",
1371            "Squash a branch's changes into the index. With arg: the branch; without, asks.",
1372            "branch",
1373            "Squash branch",
1374            "action:magit-global-merge-squash-finish",
1375            |wd, branch| {
1376                magit_global_mode::spawn_git(
1377                    wd,
1378                    magit_global_mode::merge_squash_argv(&branch),
1379                    &magit_global_mode::merge_label("squash", &branch),
1380                )
1381            },
1382        );
1383        mk_prompted(
1384            "magit-tag-delete",
1385            "Delete a local tag. With arg: the tag; without, asks.",
1386            "name",
1387            "Delete tag",
1388            "action:magit-global-tag-delete-finish",
1389            |wd, name| {
1390                magit_global_mode::spawn_git(
1391                    wd,
1392                    magit_global_mode::tag_delete_argv(&name),
1393                    &format!("delete tag {name}"),
1394                )
1395            },
1396        );
1397        mk_prompted(
1398            "magit-init",
1399            "Initialize a git repository. With arg: the directory; without, asks.",
1400            "directory",
1401            "Initialize repository in",
1402            "action:magit-global-init-finish",
1403            |wd, dir| {
1404                magit_global_mode::spawn_git(
1405                    wd,
1406                    magit_global_mode::init_argv(&dir),
1407                    &format!("create a repository in {dir}"),
1408                )
1409            },
1410        );
1411        mk_prompted(
1412            "magit-gitignore",
1413            "Add a pattern to .gitignore. With arg: the pattern; without, asks for it.",
1414            "pattern",
1415            "Ignore pattern",
1416            "action:magit-global-gitignore-finish",
1417            magit_global_mode::spawn_gitignore,
1418        );
1419    }
1420
1421    {
1422        registry.register_ex_command(
1423            "magit-blame",
1424            "Open git blame annotations for a file. With arg: specifies the file path.",
1425            ExCommandSpec {
1426                latency_class: LatencyClass::Reflex,
1427                accepts_bang: false,
1428                accepts_range: false,
1429                parse_args: Arc::new(|line: &str, _bang: bool| {
1430                    let trimmed = line.trim();
1431                    if trimmed.is_empty() {
1432                        Ok(Args::None)
1433                    } else {
1434                        Ok(Args::String(trimmed.to_string()))
1435                    }
1436                }),
1437                // MG.26b: blame annotates the buffer you are reading
1438                // rather than opening one of its own. With no argument
1439                // that is a plain toggle; with a path, open the file
1440                // first and toggle on it — the same composition `dv`
1441                // uses, and the reason the argument stays useful.
1442                apply: Arc::new(|ctx| {
1443                    let toggle = Effect::ToggleMode {
1444                        mode_name: "magit-blame-mode".to_string(),
1445                    };
1446                    Ok(match ctx.args {
1447                        Args::String(ref path) if !path.trim().is_empty() => Effect::Many(vec![
1448                            Effect::OpenBuffer {
1449                                path: Some(std::path::PathBuf::from(path.trim())),
1450                                force: false,
1451                            },
1452                            toggle,
1453                        ]),
1454                        _ => toggle,
1455                    })
1456                }),
1457                args_schema: vec![ArgSpec::optional(
1458                    "file",
1459                    lattice_grammar::ArgKind::String,
1460                    "file path to blame",
1461                )],
1462                surface_form: SurfaceForm::Keyword,
1463            },
1464        );
1465    }
1466    {
1467        // MG.23j: the scriptable surface for MG.20's three operations,
1468        // which shipped as chords and nothing else.
1469        //
1470        // Two ways in, the shape MG.23c1 established: `:magit-revert
1471        // <sha>` acts immediately; bare `:magit-revert` opens the
1472        // commit picker, which then fires *this same command* with the
1473        // picked sha appended. That round trip is why the picker takes
1474        // an ex-command name rather than an action name — see
1475        // `picker_sources::CommitPickSource`.
1476        //
1477        // `reset --hard` returns its confirm here exactly as the chord
1478        // does: `spawn_commit_op` is not reached until the `-execute`
1479        // half runs, so answering `n` performs no git call (§12.13).
1480        for op in [
1481            magit_global_mode::CommitOp::CHERRY_PICK,
1482            magit_global_mode::CommitOp::REVERT,
1483            magit_global_mode::CommitOp::RESET_SOFT,
1484            magit_global_mode::CommitOp::RESET_MIXED,
1485            magit_global_mode::CommitOp::RESET_HARD,
1486            // MG.41d: magit's remaining reset modes + the autosquash
1487            // pair. Each is data — same handler, different argv.
1488            magit_global_mode::CommitOp::RESET_KEEP,
1489            magit_global_mode::CommitOp::RESET_INDEX,
1490            magit_global_mode::CommitOp::COMMIT_FIXUP,
1491            magit_global_mode::CommitOp::COMMIT_SQUASH,
1492            // MG.43a: revert `v` and cherry-pick `a`.
1493            magit_global_mode::CommitOp::REVERT_CHANGES,
1494            magit_global_mode::CommitOp::CHERRY_PICK_APPLY,
1495            // MG.43f: reset `w`.
1496            magit_global_mode::CommitOp::RESET_WORKTREE,
1497        ] {
1498            // Cloned per iteration: each closure outlives the loop body.
1499            let store = store.clone();
1500            let scopes = scopes.clone();
1501            registry.register_ex_command(
1502                op.ex_command,
1503                // Leaked once at boot, from a `&'static` table — the
1504                // registry wants `&'static str` docs and these are
1505                // per-op. Five allocations for the process lifetime.
1506                Box::leak(
1507                    format!(
1508                        "git {} the named commit. With no argument: pick one.",
1509                        op.what
1510                    )
1511                    .into_boxed_str(),
1512                ),
1513                ExCommandSpec {
1514                    latency_class: LatencyClass::Reflex,
1515                    accepts_bang: false,
1516                    accepts_range: false,
1517                    parse_args: Arc::new(|line: &str, _bang: bool| {
1518                        Ok(Args::String(line.trim().to_string()))
1519                    }),
1520                    apply: Arc::new(move |ctx| {
1521                        let commit = match ctx.args {
1522                            Args::String(ref s) if !s.trim().is_empty() => s.trim().to_string(),
1523                            _ => {
1524                                return Ok(Effect::OpenPicker {
1525                                    source: picker_sources::COMMIT_PICK_SOURCE.to_string(),
1526                                    args: vec![op.ex_command.to_string()],
1527                                    root: None,
1528                                    fill_action: None,
1529                                    query: None,
1530                                });
1531                            }
1532                        };
1533                        Ok(match op.confirm_action {
1534                            Some(yes) => confirm::ask_target(
1535                                format!("git {} {commit} — discard uncommitted changes?", op.what),
1536                                yes,
1537                                commit,
1538                            ),
1539                            None => magit_global_mode::spawn_commit_op(
1540                                op,
1541                                repo_scope::workdir_or_cwd(&store, &scopes, ctx.buffer_id),
1542                                &commit,
1543                            ),
1544                        })
1545                    }),
1546                    args_schema: vec![ArgSpec::optional(
1547                        "commit",
1548                        lattice_grammar::ArgKind::String,
1549                        "commit to act on; omit to pick one",
1550                    )],
1551                    surface_form: SurfaceForm::Keyword,
1552                },
1553            );
1554        }
1555    }
1556    {
1557        // The stash operations' scriptable halves, and the targets of
1558        // the stash picker's accept (`<ex-command> <index>`).
1559        //
1560        // Same two-ways-in shape MG.23c1 established for the commit
1561        // ops: `:magit-stash-pop 2` acts immediately, bare
1562        // `:magit-stash-pop` opens the stash picker, which fires this
1563        // same command with the picked index appended. That round trip
1564        // is why `StashPickSource` takes an ex-command name rather than
1565        // an action name.
1566        //
1567        // The argument is the INDEX, not `stash@{N}`: `git stash`
1568        // addresses entries by index and the picker routes one, so
1569        // accepting anything else would mean two spellings of the same
1570        // argument. `parse_index` handles the `stash@{N}` form for
1571        // buffer rows, which is a different reader.
1572        for (name, verb, doc) in [
1573            (
1574                "magit-stash-apply",
1575                "apply",
1576                "Apply the named stash, keeping it on the stack. With no argument: pick one.",
1577            ),
1578            (
1579                "magit-stash-pop",
1580                "pop",
1581                "Apply the named stash and drop it. With no argument: pick one.",
1582            ),
1583            (
1584                "magit-stash-drop",
1585                "drop",
1586                "Delete the named stash without applying it — asks first. With no argument: pick one.",
1587            ),
1588        ] {
1589            registry.register_ex_command(
1590                name,
1591                doc,
1592                ExCommandSpec {
1593                    latency_class: LatencyClass::Reflex,
1594                    accepts_bang: false,
1595                    accepts_range: false,
1596                    parse_args: Arc::new(|line: &str, _bang: bool| {
1597                        Ok(Args::String(line.trim().to_string()))
1598                    }),
1599                    apply: {
1600                        let store = store.clone();
1601                        let scopes = scopes.clone();
1602                        Arc::new(move |ctx| {
1603                            let idx = match ctx.args {
1604                                Args::String(ref s) if !s.trim().is_empty() => {
1605                                    match s.trim().parse::<usize>() {
1606                                        Ok(i) => i,
1607                                        Err(_) => {
1608                                            return Err(lattice_grammar::CommandError::BadArgs(
1609                                                format!(
1610                                                    "{name}: expected a stash index, got {s:?}"
1611                                                ),
1612                                            ));
1613                                        }
1614                                    }
1615                                }
1616                                _ => {
1617                                    return Ok(Effect::OpenPicker {
1618                                        source: picker_sources::STASH_PICK_SOURCE.to_string(),
1619                                        args: vec![name.to_string()],
1620                                        root: None,
1621                                        fill_action: None,
1622                                        query: None,
1623                                    });
1624                                }
1625                            };
1626                            // MG.12: dropping is the one that cannot be
1627                            // undone, so it asks — and the ask carries the
1628                            // index, because a refresh between question and
1629                            // answer renumbers every later stash.
1630                            if verb == "drop" {
1631                                return Ok(confirm::ask_target(
1632                                    format!("Drop stash@{{{idx}}}?"),
1633                                    "action:magit-stash-drop-execute",
1634                                    idx.to_string(),
1635                                ));
1636                            }
1637                            Ok(magit_global_mode::spawn_git(
1638                                repo_scope::workdir_or_cwd(&store, &scopes, ctx.buffer_id),
1639                                vec![
1640                                    "stash".to_string(),
1641                                    verb.to_string(),
1642                                    format!("stash@{{{idx}}}"),
1643                                ],
1644                                &format!("{verb} stash@{{{idx}}}"),
1645                            ))
1646                        })
1647                    },
1648                    args_schema: vec![ArgSpec::optional(
1649                        "stash",
1650                        lattice_grammar::ArgKind::String,
1651                        "stash index to act on; omit to pick one",
1652                    )],
1653                    surface_form: SurfaceForm::Keyword,
1654                },
1655            );
1656        }
1657        // `show` opens a buffer rather than mutating, so it is not part
1658        // of the loop above — but it takes the same argument and the
1659        // same picker fallback, which is what keeps `hzv` working from
1660        // anywhere.
1661        registry.register_ex_command(
1662            "magit-stash-show",
1663            "Show the named stash's patch. With no argument: pick one.",
1664            ExCommandSpec {
1665                latency_class: LatencyClass::Reflex,
1666                accepts_bang: false,
1667                accepts_range: false,
1668                parse_args: Arc::new(|line: &str, _bang: bool| {
1669                    Ok(Args::String(line.trim().to_string()))
1670                }),
1671                apply: {
1672                    let store = store.clone();
1673                    let scopes = scopes.clone();
1674                    Arc::new(move |ctx| {
1675                        let idx = match ctx.args {
1676                            Args::String(ref s) if !s.trim().is_empty() => {
1677                                s.trim().parse::<usize>().map_err(|_| {
1678                                    lattice_grammar::CommandError::BadArgs(format!(
1679                                        "magit-stash-show: expected a stash index, got {s:?}"
1680                                    ))
1681                                })?
1682                            }
1683                            _ => {
1684                                return Ok(Effect::OpenPicker {
1685                                    source: picker_sources::STASH_PICK_SOURCE.to_string(),
1686                                    args: vec!["magit-stash-show".to_string()],
1687                                    root: None,
1688                                    fill_action: None,
1689                                    query: None,
1690                                });
1691                            }
1692                        };
1693                        Ok(Effect::OpenSyntheticBuffer {
1694                            name: repo_scope::repo_view_name_with(
1695                                "stash",
1696                                Some(&magit_stash_show_mode::stash_view_rest(idx)),
1697                                &store,
1698                                &scopes,
1699                                ctx.buffer_id,
1700                            ),
1701                            mode_id: "magit-stash-show-mode".to_string(),
1702                            content: None,
1703                            cursor: None,
1704                            activate_minor: None,
1705                        })
1706                    })
1707                },
1708                args_schema: vec![ArgSpec::optional(
1709                    "stash",
1710                    lattice_grammar::ArgKind::String,
1711                    "stash index to show; omit to pick one",
1712                )],
1713                surface_form: SurfaceForm::Keyword,
1714            },
1715        );
1716    }
1717    {
1718        // MG.43c: the rebase todo rows' scriptable halves, and the
1719        // targets of their picker fallbacks (`<ex-command> <sha>`).
1720        for (name, verb, doc) in [
1721            (
1722                "magit-rebase-edit-commit",
1723                "edit",
1724                "Replay history, stopping at the named commit. With no argument: pick one.",
1725            ),
1726            (
1727                "magit-rebase-remove-commit",
1728                "drop",
1729                "Replay history without the named commit. With no argument: pick one.",
1730            ),
1731        ] {
1732            registry.register_ex_command(
1733                name,
1734                doc,
1735                ExCommandSpec {
1736                    latency_class: LatencyClass::Reflex,
1737                    accepts_bang: false,
1738                    accepts_range: false,
1739                    parse_args: Arc::new(|line: &str, _bang: bool| {
1740                        Ok(Args::String(line.trim().to_string()))
1741                    }),
1742                    apply: {
1743                        let store = store.clone();
1744                        let scopes = scopes.clone();
1745                        Arc::new(move |ctx| {
1746                            let commit = match ctx.args {
1747                                Args::String(ref s) if !s.trim().is_empty() => s.trim().to_string(),
1748                                _ => {
1749                                    return Ok(Effect::OpenPicker {
1750                                        source: picker_sources::COMMIT_PICK_SOURCE.to_string(),
1751                                        args: vec![name.to_string()],
1752                                        root: None,
1753                                        fill_action: None,
1754                                        query: None,
1755                                    });
1756                                }
1757                            };
1758                            Ok(magit_global_mode::spawn_rebase_verb(
1759                                repo_scope::workdir_or_cwd(&store, &scopes, ctx.buffer_id),
1760                                verb,
1761                                &commit,
1762                            ))
1763                        })
1764                    },
1765                    args_schema: vec![ArgSpec::optional(
1766                        "commit",
1767                        lattice_grammar::ArgKind::String,
1768                        "commit to act on; omit to pick one",
1769                    )],
1770                    surface_form: SurfaceForm::Keyword,
1771                },
1772            );
1773        }
1774        // `w` reword opens the compose buffer rather than spawning —
1775        // it needs a message before anything runs.
1776        registry.register_ex_command(
1777            "magit-rebase-reword-commit",
1778            "Change the named commit's message. With no argument: pick one.",
1779            ExCommandSpec {
1780                latency_class: LatencyClass::Reflex,
1781                accepts_bang: false,
1782                accepts_range: false,
1783                parse_args: Arc::new(|line: &str, _bang: bool| {
1784                    Ok(Args::String(line.trim().to_string()))
1785                }),
1786                apply: {
1787                    let store = store.clone();
1788                    let scopes = scopes.clone();
1789                    Arc::new(move |ctx| {
1790                        let commit = match ctx.args {
1791                            Args::String(ref s) if !s.trim().is_empty() => s.trim().to_string(),
1792                            _ => {
1793                                return Ok(Effect::OpenPicker {
1794                                    source: picker_sources::COMMIT_PICK_SOURCE.to_string(),
1795                                    args: vec!["magit-rebase-reword-commit".to_string()],
1796                                    root: None,
1797                                    fill_action: None,
1798                                    query: None,
1799                                });
1800                            }
1801                        };
1802                        // MR.3: the compose buffer belongs to the
1803                        // repository the `:` line came from, and records it
1804                        // — `magit-commit-mode`'s activation reads it back.
1805                        Ok(repo_scope::open_repo_view_with(
1806                            "reword-commit",
1807                            "magit-commit-mode",
1808                            &commit,
1809                            &store,
1810                            &scopes,
1811                            ctx.buffer_id,
1812                        ))
1813                    })
1814                },
1815                args_schema: vec![ArgSpec::optional(
1816                    "commit",
1817                    lattice_grammar::ArgKind::String,
1818                    "commit to reword; omit to pick one",
1819                )],
1820                surface_form: SurfaceForm::Keyword,
1821            },
1822        );
1823    }
1824    {
1825        // MG.42-E1: augment's scriptable half — and the target of its
1826        // picker fallback, which invokes `<ex-command> <sha>`. The
1827        // commit rides IN the compose buffer's name, so the buffer
1828        // itself records which commit it is about to squash into.
1829        registry.register_ex_command(
1830            "magit-augment",
1831            "Record a squash! for the named commit, with a note you write. \
1832             With no argument: pick one.",
1833            ExCommandSpec {
1834                latency_class: LatencyClass::Reflex,
1835                accepts_bang: false,
1836                accepts_range: false,
1837                parse_args: Arc::new(|line: &str, _bang: bool| {
1838                    Ok(Args::String(line.trim().to_string()))
1839                }),
1840                apply: {
1841                    let store = store.clone();
1842                    let scopes = scopes.clone();
1843                    Arc::new(move |ctx| {
1844                        let commit = match ctx.args {
1845                            Args::String(ref s) if !s.trim().is_empty() => s.trim().to_string(),
1846                            _ => {
1847                                return Ok(Effect::OpenPicker {
1848                                    source: picker_sources::COMMIT_PICK_SOURCE.to_string(),
1849                                    args: vec!["magit-augment".to_string()],
1850                                    root: None,
1851                                    fill_action: None,
1852                                    query: None,
1853                                });
1854                            }
1855                        };
1856                        Ok(repo_scope::open_repo_view_with(
1857                            "augment",
1858                            "magit-commit-mode",
1859                            &commit,
1860                            &store,
1861                            &scopes,
1862                            ctx.buffer_id,
1863                        ))
1864                    })
1865                },
1866                args_schema: vec![ArgSpec::optional(
1867                    "commit",
1868                    lattice_grammar::ArgKind::String,
1869                    "commit to augment; omit to pick one",
1870                )],
1871                surface_form: SurfaceForm::Keyword,
1872            },
1873        );
1874    }
1875    {
1876        // MG.23f2: the scriptable half of reverse blame. `C-c f`'s `f`
1877        // takes both arguments from the blob buffer it is pressed in;
1878        // this one is told them, which is also the only way to reverse
1879        // blame a file you are not currently reading at a revision.
1880        //
1881        // Both arguments are required — a default revision is exactly
1882        // what reverse blame cannot have. `HEAD` would make the range
1883        // `HEAD..HEAD`, i.e. empty, and report every line as still
1884        // present: a plausible-looking answer that says nothing.
1885        // MG.29: what the branch-checkout picker invokes. Also the
1886        // scriptable form — `:magit-checkout <branch>`.
1887        registry.register_ex_command(
1888            "magit-checkout",
1889            "Check out a branch: `<branch>`.",
1890            ExCommandSpec {
1891                latency_class: LatencyClass::Reflex,
1892                accepts_bang: false,
1893                accepts_range: false,
1894                parse_args: Arc::new(|line: &str, _bang: bool| {
1895                    Ok(Args::String(line.trim().to_string()))
1896                }),
1897                apply: {
1898                    let store = store.clone();
1899                    let scopes = scopes.clone();
1900                    Arc::new(move |ctx| {
1901                        let Args::String(ref name) = ctx.args else {
1902                            return Ok(Effect::Echo {
1903                                level: lattice_grammar::EchoLevel::Error,
1904                                text: "magit: usage — :magit-checkout <branch>".to_string(),
1905                            });
1906                        };
1907                        let name = name.trim().to_string();
1908                        if name.is_empty() {
1909                            return Ok(Effect::Echo {
1910                                level: lattice_grammar::EchoLevel::Error,
1911                                text: "magit: usage — :magit-checkout <branch>".to_string(),
1912                            });
1913                        }
1914                        Ok(magit_global_mode::spawn_git(
1915                            repo_scope::workdir_or_cwd(&store, &scopes, ctx.buffer_id),
1916                            vec!["checkout".to_string(), name.clone()],
1917                            &format!("check out {name}"),
1918                        ))
1919                    })
1920                },
1921                args_schema: vec![
1922                    ArgSpec::required(
1923                        "branch",
1924                        lattice_grammar::ArgKind::String,
1925                        "the branch to check out",
1926                    )
1927                    .with_picker(picker_sources::BRANCH_PICK_SOURCE),
1928                ],
1929                surface_form: SurfaceForm::Keyword,
1930            },
1931        );
1932
1933        // MG.52 / MG.53.a: what the branch picker invokes, and the
1934        // scriptable forms besides.
1935        //
1936        // Registered for the reason `magit-checkout` is: a picked
1937        // candidate reaches an operation only as an ex line, so every
1938        // picker-backed branch row needs an ex-command to name. A table
1939        // rather than one block each — they differ only in the argv
1940        // they build, which is what a table column is for.
1941        //
1942        // Reset is the one that asks first: it discards uncommitted
1943        // work. The rest either stop on conflict (merge) or rewrite
1944        // only committed history (rebase).
1945        type BranchArgv = fn(&str) -> Vec<String>;
1946        const BRANCH_EX_COMMANDS: &[(&str, &str, BranchArgv, &str, bool)] = &[
1947            (
1948                "magit-merge",
1949                "Merge a branch into the current one: `<branch>`.",
1950                magit_global_mode::merge_argv,
1951                "merge {}",
1952                false,
1953            ),
1954            (
1955                "magit-merge-no-commit",
1956                "Merge a branch but stop before committing: `<branch>`.",
1957                magit_global_mode::merge_no_commit_argv,
1958                "merge {} without committing",
1959                false,
1960            ),
1961            (
1962                "magit-merge-squash",
1963                "Squash a branch's changes into the working tree: `<branch>`.",
1964                magit_global_mode::merge_squash_argv,
1965                "squash {} into the index",
1966                false,
1967            ),
1968            (
1969                "magit-rebase-onto",
1970                "Rebase the current branch onto another: `<branch>`.",
1971                magit_global_mode::rebase_onto_argv,
1972                "rebase onto {}",
1973                false,
1974            ),
1975            (
1976                "magit-rebase-autosquash",
1977                "Rebase interactively with --autosquash onto: `<branch>`.",
1978                magit_global_mode::rebase_autosquash_argv,
1979                "autosquash the commits after {}",
1980                false,
1981            ),
1982            (
1983                "magit-branch-reset",
1984                "Reset the current branch to another: `<branch>` (hard).",
1985                magit_global_mode::merge_argv, // unused — `confirms` diverts
1986                "hard-reset the branch to {}",
1987                true,
1988            ),
1989        ];
1990        for (name, doc, argv, what, confirms) in BRANCH_EX_COMMANDS {
1991            let (name, argv, what, confirms) = (*name, *argv, *what, *confirms);
1992            registry.register_ex_command(
1993                name,
1994                doc,
1995                ExCommandSpec {
1996                    latency_class: LatencyClass::Reflex,
1997                    accepts_bang: false,
1998                    accepts_range: false,
1999                    parse_args: Arc::new(|line: &str, _bang: bool| {
2000                        Ok(Args::String(line.trim().to_string()))
2001                    }),
2002                    apply: {
2003                        let store = store.clone();
2004                        let scopes = scopes.clone();
2005                        Arc::new(move |ctx| {
2006                            let Args::String(ref b) = ctx.args else {
2007                                return Ok(Effect::Echo {
2008                                    level: lattice_grammar::EchoLevel::Error,
2009                                    text: format!("magit: usage — :{name} <branch>"),
2010                                });
2011                            };
2012                            let b = b.trim().to_string();
2013                            if b.is_empty() {
2014                                return Ok(Effect::Echo {
2015                                    level: lattice_grammar::EchoLevel::Error,
2016                                    text: format!("magit: usage — :{name} <branch>"),
2017                                });
2018                            }
2019                            Ok(if confirms {
2020                                crate::confirm::ask_target(
2021                                    format!("git reset --hard {b} — discard uncommitted changes?"),
2022                                    "action:magit-global-branch-reset-execute",
2023                                    b,
2024                                )
2025                            } else {
2026                                magit_global_mode::spawn_git(
2027                                    repo_scope::workdir_or_cwd(&store, &scopes, ctx.buffer_id),
2028                                    argv(&b),
2029                                    // NC.4: a template naming the branch.
2030                                    &what.replace("{}", &b),
2031                                )
2032                            })
2033                        })
2034                    },
2035                    args_schema: vec![
2036                        ArgSpec::required(
2037                            "branch",
2038                            lattice_grammar::ArgKind::String,
2039                            "the branch to operate on",
2040                        )
2041                        .with_picker(picker_sources::BRANCH_PICK_SOURCE),
2042                    ],
2043                    surface_form: SurfaceForm::Keyword,
2044                },
2045            );
2046        }
2047
2048        // MG.53.b: the branch operations that are NOT one git call, so
2049        // they cannot join the table above.
2050        //
2051        // Each is a different shape, which is why they are three blocks
2052        // and not three rows: absorb runs a step sequence, merge-edit
2053        // spawns no git at all (it opens a commit buffer), and
2054        // merge-into needs the current branch and refuses without one.
2055        // A table column cannot express "and also decline on detached
2056        // HEAD".
2057        for (name, doc) in [
2058            (
2059                "magit-merge-absorb",
2060                "Merge a branch and delete it: `<branch>`.",
2061            ),
2062            (
2063                "magit-merge-edit",
2064                "Merge a branch, editing the merge message: `<branch>`.",
2065            ),
2066            (
2067                "magit-merge-into",
2068                "Merge the current branch INTO another: `<branch>`.",
2069            ),
2070        ] {
2071            // Cloned per iteration: the closure outlives the loop body,
2072            // so it cannot borrow the shared handles.
2073            let store = store.clone();
2074            let scopes = scopes.clone();
2075            registry.register_ex_command(
2076                name,
2077                doc,
2078                ExCommandSpec {
2079                    latency_class: LatencyClass::Reflex,
2080                    accepts_bang: false,
2081                    accepts_range: false,
2082                    parse_args: Arc::new(|line: &str, _bang: bool| {
2083                        Ok(Args::String(line.trim().to_string()))
2084                    }),
2085                    apply: {
2086                        let store = store.clone();
2087                        let scopes = scopes.clone();
2088                        Arc::new(move |ctx| {
2089                            let Args::String(ref b) = ctx.args else {
2090                                return Ok(Effect::Echo {
2091                                    level: lattice_grammar::EchoLevel::Error,
2092                                    text: format!("magit: usage — :{name} <branch>"),
2093                                });
2094                            };
2095                            let b = b.trim().to_string();
2096                            if b.is_empty() {
2097                                return Ok(Effect::Echo {
2098                                    level: lattice_grammar::EchoLevel::Error,
2099                                    text: format!("magit: usage — :{name} <branch>"),
2100                                });
2101                            }
2102                            Ok(match name {
2103                                "magit-merge-absorb" => magit_global_mode::spawn_git_sequence(
2104                                    repo_scope::workdir_or_cwd(&store, &scopes, ctx.buffer_id),
2105                                    format!("merge {b} and delete it"),
2106                                    magit_global_mode::merge_absorb_steps(&b),
2107                                ),
2108                                // No git call: the merge runs when the
2109                                // commit buffer is confirmed, which is the
2110                                // whole point of the "edit message" variant.
2111                                "magit-merge-edit" => repo_scope::open_repo_view_with(
2112                                    "merge-edit",
2113                                    "magit-commit-mode",
2114                                    &b,
2115                                    &store,
2116                                    &scopes,
2117                                    ctx.buffer_id,
2118                                ),
2119                                // Merging the CURRENT branch into another
2120                                // needs to know which one that is, and
2121                                // detached HEAD has no answer. Declines
2122                                // rather than acting on `HEAD`.
2123                                _ => match magit_global_mode::current_branch(
2124                                    &repo_scope::workdir_or_cwd(&store, &scopes, ctx.buffer_id),
2125                                ) {
2126                                    None => Effect::Echo {
2127                                        level: lattice_grammar::EchoLevel::Error,
2128                                        text: "magit: not on a branch".to_string(),
2129                                    },
2130                                    Some(current) if current == b => Effect::Echo {
2131                                        level: lattice_grammar::EchoLevel::Error,
2132                                        text: "magit: cannot merge a branch into itself"
2133                                            .to_string(),
2134                                    },
2135                                    Some(current) => magit_global_mode::spawn_git_sequence(
2136                                        repo_scope::workdir_or_cwd(&store, &scopes, ctx.buffer_id),
2137                                        format!("merge {current} into {b} and delete it"),
2138                                        magit_global_mode::merge_into_steps(&current, &b),
2139                                    ),
2140                                },
2141                            })
2142                        })
2143                    },
2144                    args_schema: vec![
2145                        ArgSpec::required(
2146                            "branch",
2147                            lattice_grammar::ArgKind::String,
2148                            "the branch to operate on",
2149                        )
2150                        .with_picker(picker_sources::BRANCH_PICK_SOURCE),
2151                    ],
2152                    surface_form: SurfaceForm::Keyword,
2153                },
2154            );
2155        }
2156
2157        // MG.53.d/e: what the tag / remote / ref pickers invoke, and the
2158        // scriptable forms. Same table shape as the branch ones —
2159        // single argument, one git call.
2160        type RefArgv = fn(&str) -> Vec<String>;
2161        const REF_EX_COMMANDS: &[(&str, &str, RefArgv, &str)] = &[
2162            (
2163                "magit-tag-delete",
2164                "Delete a tag: `<tag>`.",
2165                magit_global_mode::tag_delete_argv,
2166                "delete tag {}",
2167            ),
2168            (
2169                "magit-tag-prune",
2170                "Prune tags gone from a remote: `<remote>`.",
2171                magit_global_mode::tag_prune_argv,
2172                "prune tags gone from {}",
2173            ),
2174        ];
2175        for (name, doc, argv, what) in REF_EX_COMMANDS {
2176            let (name, argv, what) = (*name, *argv, *what);
2177            registry.register_ex_command(
2178                name,
2179                doc,
2180                ExCommandSpec {
2181                    latency_class: LatencyClass::Reflex,
2182                    accepts_bang: false,
2183                    accepts_range: false,
2184                    parse_args: Arc::new(|line: &str, _bang: bool| {
2185                        Ok(Args::String(line.trim().to_string()))
2186                    }),
2187                    apply: {
2188                        let store = store.clone();
2189                        let scopes = scopes.clone();
2190                        Arc::new(move |ctx| {
2191                            let Args::String(ref v) = ctx.args else {
2192                                return Ok(Effect::Echo {
2193                                    level: lattice_grammar::EchoLevel::Error,
2194                                    text: format!("magit: usage — :{name} <name>"),
2195                                });
2196                            };
2197                            let v = v.trim().to_string();
2198                            if v.is_empty() {
2199                                return Ok(Effect::Echo {
2200                                    level: lattice_grammar::EchoLevel::Error,
2201                                    text: format!("magit: usage — :{name} <name>"),
2202                                });
2203                            }
2204                            Ok(magit_global_mode::spawn_git(
2205                                repo_scope::workdir_or_cwd(&store, &scopes, ctx.buffer_id),
2206                                argv(&v),
2207                                &what.replace("{}", &v),
2208                            ))
2209                        })
2210                    },
2211                    args_schema: vec![ArgSpec::required(
2212                        "name",
2213                        lattice_grammar::ArgKind::String,
2214                        "the tag or remote to operate on",
2215                    )],
2216                    surface_form: SurfaceForm::Keyword,
2217                },
2218            );
2219        }
2220
2221        // MG.53.c: `git checkout <rev> -- <path>`, which the revision
2222        // picker invokes and which is also the scriptable form.
2223        //
2224        // Confirms, and reuses the SAME confirm/execute pair the chord
2225        // path uses rather than spawning directly: checking out a file
2226        // discards its uncommitted changes, and doing that without
2227        // asking because the caller happened to be a picker would make
2228        // the guard depend on how the operation was reached.
2229        registry.register_ex_command(
2230            "magit-file-checkout",
2231            "Check out a file from a revision: `<rev> <path>` (discards local changes).",
2232            ExCommandSpec {
2233                latency_class: LatencyClass::Reflex,
2234                accepts_bang: false,
2235                accepts_range: false,
2236                parse_args: Arc::new(|line: &str, _bang: bool| {
2237                    Ok(Args::String(line.trim().to_string()))
2238                }),
2239                apply: Arc::new(|ctx| {
2240                    let usage = || Effect::Echo {
2241                        level: lattice_grammar::EchoLevel::Error,
2242                        text: "magit: usage — :magit-file-checkout <rev> <path>".to_string(),
2243                    };
2244                    let Args::String(ref line) = ctx.args else {
2245                        return Ok(usage());
2246                    };
2247                    let mut it = line.trim().splitn(2, char::is_whitespace);
2248                    let (Some(rev), Some(path)) = (it.next(), it.next()) else {
2249                        return Ok(usage());
2250                    };
2251                    let (rev, path) = (rev.trim(), path.trim());
2252                    if rev.is_empty() || path.is_empty() {
2253                        return Ok(usage());
2254                    }
2255                    Ok(crate::confirm::ask_with(
2256                        format!("Checkout {path} from {rev}, discarding its uncommitted changes?"),
2257                        "action:magit-global-file-checkout-execute",
2258                        lattice_grammar::Args::List(vec![
2259                            lattice_grammar::ArgValue::String(rev.to_string()),
2260                            lattice_grammar::ArgValue::String(path.to_string()),
2261                        ]),
2262                    ))
2263                }),
2264                args_schema: vec![
2265                    ArgSpec::required("rev", lattice_grammar::ArgKind::String, "the revision")
2266                        .with_picker(picker_sources::REVISION_PICK_SOURCE),
2267                    ArgSpec::required("path", lattice_grammar::ArgKind::String, "the file"),
2268                ],
2269                surface_form: SurfaceForm::Keyword,
2270            },
2271        );
2272
2273        // MG.53.e: notes-merge takes a REF, so it gets the ref picker
2274        // (branches, remote-tracking refs and tags — everything
2275        // `for-each-ref` returns) rather than the branch one. A notes
2276        // ref is commonly `refs/notes/*`, which is neither a branch nor
2277        // a tag; the picker offers what git can resolve and
2278        // `:magit-note-merge <ref>` still takes anything else.
2279        registry.register_ex_command(
2280            "magit-note-merge",
2281            "Merge a notes ref into the current notes: `<ref>`.",
2282            ExCommandSpec {
2283                latency_class: LatencyClass::Reflex,
2284                accepts_bang: false,
2285                accepts_range: false,
2286                parse_args: Arc::new(|line: &str, _bang: bool| {
2287                    Ok(Args::String(line.trim().to_string()))
2288                }),
2289                apply: {
2290                    let store = store.clone();
2291                    let scopes = scopes.clone();
2292                    Arc::new(move |ctx| {
2293                        let Args::String(ref r) = ctx.args else {
2294                            return Ok(Effect::Echo {
2295                                level: lattice_grammar::EchoLevel::Error,
2296                                text: "magit: usage — :magit-note-merge <ref>".to_string(),
2297                            });
2298                        };
2299                        let r = r.trim().to_string();
2300                        if r.is_empty() {
2301                            return Ok(Effect::Echo {
2302                                level: lattice_grammar::EchoLevel::Error,
2303                                text: "magit: usage — :magit-note-merge <ref>".to_string(),
2304                            });
2305                        }
2306                        Ok(magit_global_mode::spawn_note_merge(
2307                            repo_scope::workdir_or_cwd(&store, &scopes, ctx.buffer_id),
2308                            &r,
2309                        ))
2310                    })
2311                },
2312                args_schema: vec![
2313                    ArgSpec::required(
2314                        "ref",
2315                        lattice_grammar::ArgKind::String,
2316                        "the notes ref to merge",
2317                    )
2318                    .with_picker(picker_sources::REF_PICK_SOURCE),
2319                ],
2320                surface_form: SurfaceForm::Keyword,
2321            },
2322        );
2323
2324        // MG.28: the explicit form of `C-c f v` — a file you are not
2325        // visiting. `<rev>` alone shows the file you ARE visiting,
2326        // which is what the chord does.
2327        registry.register_ex_command(
2328            "magit-find-file",
2329            "Open a file as it was at a revision: `<rev> <path>`.",
2330            ExCommandSpec {
2331                latency_class: LatencyClass::Reflex,
2332                accepts_bang: false,
2333                accepts_range: false,
2334                parse_args: Arc::new(|line: &str, _bang: bool| {
2335                    Ok(Args::String(line.trim().to_string()))
2336                }),
2337                apply: {
2338                    let store = store.clone();
2339                    let scopes = scopes.clone();
2340                    Arc::new(move |ctx| {
2341                        let Args::String(ref spec) = ctx.args else {
2342                            return Ok(find_file_usage());
2343                        };
2344                        match spec.split_once(char::is_whitespace) {
2345                            Some((rev, path)) if !rev.is_empty() && !path.trim().is_empty() => {
2346                                Ok(Effect::OpenSyntheticBuffer {
2347                                    name: repo_scope::repo_view_name_with(
2348                                        magit_file_revision_mode::FILE_VIEW,
2349                                        Some(&magit_file_revision_mode::file_view_rest(
2350                                            rev,
2351                                            std::path::Path::new(path.trim()),
2352                                        )),
2353                                        &store,
2354                                        &scopes,
2355                                        ctx.buffer_id,
2356                                    ),
2357                                    mode_id: "magit-file-revision-mode".to_string(),
2358                                    content: None,
2359                                    cursor: None,
2360                                    activate_minor: None,
2361                                })
2362                            }
2363                            _ => Ok(find_file_usage()),
2364                        }
2365                    })
2366                },
2367                args_schema: vec![ArgSpec::required(
2368                    "spec",
2369                    lattice_grammar::ArgKind::String,
2370                    "<rev> <path> — the revision, and the file to show at it",
2371                )],
2372                surface_form: SurfaceForm::Keyword,
2373            },
2374        );
2375        // MG.38 / MG.39 / MG.40: the scriptable halves. Each takes the
2376        // same line the menu's prompt takes, so a user who learned one
2377        // surface can use the other without re-learning the argument
2378        // order.
2379        {
2380            let mut mk_subtree = |op: magit_global_mode::SubtreeOp, doc: &'static str| {
2381                let store = store.clone();
2382                let scopes = scopes.clone();
2383                registry.register_ex_command(
2384                    op.ex_command,
2385                    doc,
2386                    ExCommandSpec {
2387                        latency_class: LatencyClass::Reflex,
2388                        accepts_bang: false,
2389                        accepts_range: false,
2390                        parse_args: Arc::new(|line: &str, _bang: bool| {
2391                            Ok(Args::String(line.trim().to_string()))
2392                        }),
2393                        apply: Arc::new(move |ctx| {
2394                            let line = match ctx.args {
2395                                Args::String(ref l) => l.clone(),
2396                                _ => String::new(),
2397                            };
2398                            Ok(magit_global_mode::spawn_subtree_op(
2399                                repo_scope::workdir_or_cwd(&store, &scopes, ctx.buffer_id),
2400                                op,
2401                                &line,
2402                            ))
2403                        }),
2404                        args_schema: vec![ArgSpec::required(
2405                            "spec",
2406                            lattice_grammar::ArgKind::String,
2407                            op.usage(),
2408                        )],
2409                        surface_form: SurfaceForm::Keyword,
2410                    },
2411                );
2412            };
2413            mk_subtree(
2414                magit_global_mode::SubtreeOp::ADD,
2415                "Add a repository as a subtree: `<prefix> <repository> <ref> [--squash]`.",
2416            );
2417            mk_subtree(
2418                magit_global_mode::SubtreeOp::MERGE,
2419                "Merge a ref into an existing subtree: `<prefix> <ref>`.",
2420            );
2421            mk_subtree(
2422                magit_global_mode::SubtreeOp::PULL,
2423                "Fetch and merge a subtree's upstream: `<prefix> <repository> <ref> [--squash]`.",
2424            );
2425            mk_subtree(
2426                magit_global_mode::SubtreeOp::PUSH,
2427                "Push a subtree's history to its own repository: `<prefix> <repository> <ref>`.",
2428            );
2429            mk_subtree(
2430                magit_global_mode::SubtreeOp::SPLIT,
2431                "Extract a subtree's history into its own branch: `<prefix>`.",
2432            );
2433        }
2434
2435        registry.register_ex_command(
2436            "magit-am",
2437            "Apply a mailbox of patches: `<patch>… [-3]`.",
2438            ExCommandSpec {
2439                latency_class: LatencyClass::Reflex,
2440                accepts_bang: false,
2441                accepts_range: false,
2442                parse_args: Arc::new(|line: &str, _bang: bool| {
2443                    Ok(Args::String(line.trim().to_string()))
2444                }),
2445                apply: {
2446                    let store = store.clone();
2447                    let scopes = scopes.clone();
2448                    Arc::new(move |ctx| {
2449                        let Args::String(ref line) = ctx.args else {
2450                            return Ok(am_usage());
2451                        };
2452                        match magit_global_mode::am_argv(
2453                            line,
2454                            magit_global_mode::am_wants_three_way(line),
2455                        ) {
2456                            Some(argv) => Ok(magit_global_mode::spawn_git(
2457                                repo_scope::workdir_or_cwd(&store, &scopes, ctx.buffer_id),
2458                                argv.clone(),
2459                                &magit_global_mode::am_label(&argv),
2460                            )),
2461                            None => Ok(am_usage()),
2462                        }
2463                    })
2464                },
2465                args_schema: vec![ArgSpec::required(
2466                    "patches",
2467                    lattice_grammar::ArgKind::String,
2468                    "<patch>… [-3]",
2469                )],
2470                surface_form: SurfaceForm::Keyword,
2471            },
2472        );
2473        registry.register_ex_command(
2474            "magit-format-patch",
2475            "Write a commit range out as .patch files in the repository root.",
2476            ExCommandSpec {
2477                latency_class: LatencyClass::Reflex,
2478                accepts_bang: false,
2479                accepts_range: false,
2480                parse_args: Arc::new(|line: &str, _bang: bool| {
2481                    Ok(Args::String(line.trim().to_string()))
2482                }),
2483                apply: {
2484                    let store = store.clone();
2485                    let scopes = scopes.clone();
2486                    Arc::new(move |ctx| {
2487                        let Args::String(ref range) = ctx.args else {
2488                            return Ok(format_patch_usage());
2489                        };
2490                        // MR.4: patches are written to the repository
2491                        // root of the buffer the command came from.
2492                        let root = repo_scope::workdir_or_cwd(&store, &scopes, ctx.buffer_id)
2493                            .to_string_lossy()
2494                            .into_owned();
2495                        match magit_global_mode::format_patch_argv(
2496                            range,
2497                            (!root.is_empty()).then_some(root.as_str()),
2498                        ) {
2499                            Some(argv) => Ok(magit_global_mode::spawn_git(
2500                                repo_scope::workdir_or_cwd(&store, &scopes, ctx.buffer_id),
2501                                argv.clone(),
2502                                &magit_global_mode::format_patch_label(&argv),
2503                            )),
2504                            None => Ok(format_patch_usage()),
2505                        }
2506                    })
2507                },
2508                args_schema: vec![ArgSpec::required(
2509                    "range",
2510                    lattice_grammar::ArgKind::String,
2511                    "the commit range to turn into patches",
2512                )],
2513                surface_form: SurfaceForm::Keyword,
2514            },
2515        );
2516        registry.register_ex_command(
2517            "magit-cherries",
2518            "Show which commits are not upstream yet: `<upstream> [<head>]`.",
2519            ExCommandSpec {
2520                latency_class: LatencyClass::Reflex,
2521                accepts_bang: false,
2522                accepts_range: false,
2523                parse_args: Arc::new(|line: &str, _bang: bool| {
2524                    Ok(Args::String(line.trim().to_string()))
2525                }),
2526                apply: {
2527                    let store = store.clone();
2528                    let scopes = scopes.clone();
2529                    Arc::new(move |ctx| {
2530                        let Args::String(ref spec) = ctx.args else {
2531                            return Ok(cherries_usage());
2532                        };
2533                        let spec = spec.trim();
2534                        if spec.is_empty() {
2535                            return Ok(cherries_usage());
2536                        }
2537                        let (upstream, head) = match spec.split_once(char::is_whitespace) {
2538                            Some((u, h)) if !h.trim().is_empty() => (u, h.trim()),
2539                            _ => (spec, "HEAD"),
2540                        };
2541                        Ok(Effect::OpenSyntheticBuffer {
2542                            name: repo_scope::repo_view_name_with(
2543                                magit_cherry_mode::CHERRY_VIEW,
2544                                Some(&magit_cherry_mode::cherry_view_rest(upstream, head)),
2545                                &store,
2546                                &scopes,
2547                                ctx.buffer_id,
2548                            ),
2549                            mode_id: "magit-cherry-mode".to_string(),
2550                            content: None,
2551                            cursor: None,
2552                            activate_minor: None,
2553                        })
2554                    })
2555                },
2556                args_schema: vec![ArgSpec::required(
2557                    "spec",
2558                    lattice_grammar::ArgKind::String,
2559                    "<upstream> [<head>] — what to compare against, and what to compare",
2560                )],
2561                surface_form: SurfaceForm::Keyword,
2562            },
2563        );
2564
2565        // MG.37: the scriptable halves of the notes submenu, and what
2566        // the commit picker routes to when the menu was opened without
2567        // a commit under the cursor.
2568        registry.register_ex_command(
2569            "magit-note-edit",
2570            "Edit the note on a commit — opens an editable buffer.",
2571            ExCommandSpec {
2572                latency_class: LatencyClass::Reflex,
2573                accepts_bang: false,
2574                accepts_range: false,
2575                parse_args: Arc::new(|line: &str, _bang: bool| {
2576                    Ok(Args::String(line.trim().to_string()))
2577                }),
2578                apply: {
2579                    let store = store.clone();
2580                    let scopes = scopes.clone();
2581                    Arc::new(move |ctx| {
2582                        let Args::String(ref commit) = ctx.args else {
2583                            return Ok(note_usage("magit-note-edit"));
2584                        };
2585                        let commit = commit.trim();
2586                        if commit.is_empty() {
2587                            return Ok(note_usage("magit-note-edit"));
2588                        }
2589                        Ok(Effect::OpenSyntheticBuffer {
2590                            name: repo_scope::repo_view_name_with(
2591                                magit_notes_mode::NOTE_VIEW,
2592                                Some(commit),
2593                                &store,
2594                                &scopes,
2595                                ctx.buffer_id,
2596                            ),
2597                            mode_id: "magit-notes-mode".to_string(),
2598                            content: None,
2599                            cursor: None,
2600                            activate_minor: None,
2601                        })
2602                    })
2603                },
2604                args_schema: vec![
2605                    ArgSpec::required(
2606                        "commit",
2607                        lattice_grammar::ArgKind::String,
2608                        "the commit whose note to edit",
2609                    )
2610                    .with_picker(picker_sources::COMMIT_PICK_SOURCE),
2611                ],
2612                surface_form: SurfaceForm::Keyword,
2613            },
2614        );
2615        registry.register_ex_command(
2616            "magit-note-remove",
2617            "Remove the note from a commit.",
2618            ExCommandSpec {
2619                latency_class: LatencyClass::Reflex,
2620                accepts_bang: false,
2621                accepts_range: false,
2622                parse_args: Arc::new(|line: &str, _bang: bool| {
2623                    Ok(Args::String(line.trim().to_string()))
2624                }),
2625                apply: {
2626                    let store = store.clone();
2627                    let scopes = scopes.clone();
2628                    Arc::new(move |ctx| {
2629                        let Args::String(ref commit) = ctx.args else {
2630                            return Ok(note_usage("magit-note-remove"));
2631                        };
2632                        let commit = commit.trim();
2633                        if commit.is_empty() {
2634                            return Ok(note_usage("magit-note-remove"));
2635                        }
2636                        Ok(magit_global_mode::spawn_note_remove(
2637                            repo_scope::workdir_or_cwd(&store, &scopes, ctx.buffer_id),
2638                            commit.to_string(),
2639                        ))
2640                    })
2641                },
2642                args_schema: vec![
2643                    ArgSpec::required(
2644                        "commit",
2645                        lattice_grammar::ArgKind::String,
2646                        "the commit whose note to remove",
2647                    )
2648                    .with_picker(picker_sources::COMMIT_PICK_SOURCE),
2649                ],
2650                surface_form: SurfaceForm::Keyword,
2651            },
2652        );
2653        registry.register_ex_command(
2654            "magit-note-merge",
2655            "Merge a notes ref into this one: `<ref> [manual|ours|theirs|union|cat_sort_uniq]`.",
2656            ExCommandSpec {
2657                latency_class: LatencyClass::Reflex,
2658                accepts_bang: false,
2659                accepts_range: false,
2660                parse_args: Arc::new(|line: &str, _bang: bool| {
2661                    Ok(Args::String(line.trim().to_string()))
2662                }),
2663                apply: {
2664                    let store = store.clone();
2665                    let scopes = scopes.clone();
2666                    Arc::new(move |ctx| {
2667                        let Args::String(ref spec) = ctx.args else {
2668                            return Ok(note_merge_usage());
2669                        };
2670                        if spec.trim().is_empty() {
2671                            return Ok(note_merge_usage());
2672                        }
2673                        Ok(magit_global_mode::spawn_note_merge(
2674                            repo_scope::workdir_or_cwd(&store, &scopes, ctx.buffer_id),
2675                            spec,
2676                        ))
2677                    })
2678                },
2679                args_schema: vec![ArgSpec::required(
2680                    "spec",
2681                    lattice_grammar::ArgKind::String,
2682                    "<notes-ref> [strategy]",
2683                )],
2684                surface_form: SurfaceForm::Keyword,
2685            },
2686        );
2687
2688        // MG.36: the scriptable half of `C`. With both arguments it
2689        // clones directly; with one it derives the destination the way
2690        // `git clone` itself would, so `:magit-clone <url>` behaves like
2691        // the terminal command people already know.
2692        registry.register_ex_command(
2693            "magit-clone",
2694            "Clone a repository: `<url> [<destination>]`.",
2695            ExCommandSpec {
2696                latency_class: LatencyClass::Reflex,
2697                accepts_bang: false,
2698                accepts_range: false,
2699                parse_args: Arc::new(|line: &str, _bang: bool| {
2700                    Ok(Args::String(line.trim().to_string()))
2701                }),
2702                apply: {
2703                    Arc::new(move |ctx| {
2704                        let Args::String(ref spec) = ctx.args else {
2705                            return Ok(clone_usage());
2706                        };
2707                        let spec = spec.trim();
2708                        if spec.is_empty() {
2709                            return Ok(clone_usage());
2710                        }
2711                        let (url, dest) = match spec.split_once(char::is_whitespace) {
2712                            Some((url, dest)) if !dest.trim().is_empty() => {
2713                                (url, dest.trim().to_string())
2714                            }
2715                            _ => (spec, magit_global_mode::default_clone_dest(spec)),
2716                        };
2717                        if dest.is_empty() {
2718                            // Nothing usable in the URL to name a
2719                            // directory after, and git would refuse for
2720                            // the same reason — say so here instead.
2721                            return Ok(clone_usage());
2722                        }
2723                        Ok(magit_global_mode::spawn_clone(url.to_string(), dest))
2724                    })
2725                },
2726                args_schema: vec![ArgSpec::required(
2727                    "spec",
2728                    lattice_grammar::ArgKind::String,
2729                    "<url> [<destination>] — what to clone, and where",
2730                )],
2731                surface_form: SurfaceForm::Keyword,
2732            },
2733        );
2734
2735        // MG.34: magit's `M` "Merged" (magit-file-dispatch, level 7).
2736        //
2737        // The commit you name is the *question*, not the answer: the
2738        // buffer shows the merge that brought it into HEAD, which is a
2739        // different commit and costs a `git log` walk to find. That walk
2740        // happens in `magit-revision-mode`'s activation, which is why
2741        // this hands over a `*magit:merged:*` name rather than a
2742        // resolved sha — see that module for the reasoning.
2743        registry.register_ex_command(
2744            "magit-log-merged",
2745            "Show the merge commit that brought <commit> into HEAD.",
2746            ExCommandSpec {
2747                latency_class: LatencyClass::Reflex,
2748                accepts_bang: false,
2749                accepts_range: false,
2750                parse_args: Arc::new(|line: &str, _bang: bool| {
2751                    Ok(Args::String(line.trim().to_string()))
2752                }),
2753                apply: {
2754                    let store = store.clone();
2755                    let scopes = scopes.clone();
2756                    Arc::new(move |ctx| {
2757                        let Args::String(ref commit) = ctx.args else {
2758                            return Ok(log_merged_usage());
2759                        };
2760                        let commit = commit.trim();
2761                        if commit.is_empty() {
2762                            return Ok(log_merged_usage());
2763                        }
2764                        Ok(Effect::OpenSyntheticBuffer {
2765                            name: repo_scope::repo_view_name_with(
2766                                magit_revision_mode::MERGED_VIEW,
2767                                Some(commit),
2768                                &store,
2769                                &scopes,
2770                                ctx.buffer_id,
2771                            ),
2772                            mode_id: "magit-revision-mode".to_string(),
2773                            content: None,
2774                            cursor: None,
2775                            activate_minor: None,
2776                        })
2777                    })
2778                },
2779                args_schema: vec![
2780                    ArgSpec::required(
2781                        "commit",
2782                        lattice_grammar::ArgKind::String,
2783                        "the commit whose merge to find",
2784                    )
2785                    .with_picker(picker_sources::COMMIT_PICK_SOURCE),
2786                ],
2787                surface_form: SurfaceForm::Keyword,
2788            },
2789        );
2790        registry.register_ex_command(
2791            "magit-blame-reverse",
2792            "Reverse-blame a file: for each line as of <rev>, the last commit it existed in. \
2793             Takes `<rev> <path>`.",
2794            ExCommandSpec {
2795                latency_class: LatencyClass::Reflex,
2796                accepts_bang: false,
2797                accepts_range: false,
2798                parse_args: Arc::new(|line: &str, _bang: bool| {
2799                    Ok(Args::String(line.trim().to_string()))
2800                }),
2801                // MG.26b: reverse blame annotates the blob buffer —
2802                // the file at that revision, which is the content
2803                // reverse blame is about. The direction and revision
2804                // are left as a request keyed by that buffer's name,
2805                // because `ToggleMode` carries a mode name and nothing
2806                // else.
2807                apply: {
2808                    let requests = blame_requests.clone();
2809                    let store = store.clone();
2810                    let scopes = scopes.clone();
2811                    Arc::new(move |ctx| {
2812                        let Args::String(ref spec) = ctx.args else {
2813                            return Ok(reverse_blame_usage());
2814                        };
2815                        match spec.split_once(char::is_whitespace) {
2816                            Some((rev, path)) if !rev.is_empty() && !path.trim().is_empty() => {
2817                                // MR.3b: resolved FIRST — the blame
2818                                // request is keyed by the buffer's name,
2819                                // so a name computed any other way would
2820                                // leave the request unfindable and the
2821                                // buffer would forward-blame instead.
2822                                let name = repo_scope::repo_view_name_with(
2823                                    magit_file_revision_mode::FILE_VIEW,
2824                                    Some(&magit_file_revision_mode::file_view_rest(
2825                                        rev,
2826                                        std::path::Path::new(path.trim()),
2827                                    )),
2828                                    &store,
2829                                    &scopes,
2830                                    ctx.buffer_id,
2831                                );
2832                                requests.put(
2833                                    name.clone(),
2834                                    magit_blame_mode::BlameDirection::Reverse,
2835                                    rev.to_string(),
2836                                );
2837                                Ok(Effect::Many(vec![
2838                                    Effect::OpenSyntheticBuffer {
2839                                        name,
2840                                        mode_id: "magit-file-revision-mode".to_string(),
2841                                        content: None,
2842                                        cursor: None,
2843                                        activate_minor: None,
2844                                    },
2845                                    Effect::ToggleMode {
2846                                        mode_name: "magit-blame-mode".to_string(),
2847                                    },
2848                                ]))
2849                            }
2850                            _ => Ok(reverse_blame_usage()),
2851                        }
2852                    })
2853                },
2854                args_schema: vec![ArgSpec::required(
2855                    "spec",
2856                    lattice_grammar::ArgKind::String,
2857                    "<rev> <path> — the revision to walk forward from, and the file",
2858                )],
2859                surface_form: SurfaceForm::Keyword,
2860            },
2861        );
2862    }
2863    {
2864        // Fold audit fix: the upstream to rebase onto is encoded into
2865        // the buffer name (`*magit:rebase:<upstream>*`), mirroring
2866        // `magit-blame`'s file-in-buffer-name pattern — `on_activate`
2867        // extracts it the same way. No arg falls back to
2868        // `*magit:rebase*`, and the mode resolves `@{upstream}` itself.
2869        registry.register_ex_command(
2870            "magit-rebase",
2871            "Start an interactive rebase. With arg: the upstream ref to rebase onto \
2872             (default: the branch's configured upstream).",
2873            ExCommandSpec {
2874                latency_class: LatencyClass::Reflex,
2875                accepts_bang: false,
2876                accepts_range: false,
2877                parse_args: Arc::new(|line: &str, _bang: bool| {
2878                    let trimmed = line.trim();
2879                    if trimmed.is_empty() {
2880                        Ok(Args::None)
2881                    } else {
2882                        Ok(Args::String(trimmed.to_string()))
2883                    }
2884                }),
2885                apply: {
2886                    let store = store.clone();
2887                    let scopes = scopes.clone();
2888                    Arc::new(move |ctx| {
2889                        // MR.3b: the upstream is this view's `rest`, and
2890                        // it now sits BEHIND the repository rather than
2891                        // where the repository goes.
2892                        let upstream = match ctx.args {
2893                            Args::String(ref u) if !u.trim().is_empty() => Some(u.trim()),
2894                            _ => None,
2895                        };
2896                        Ok(Effect::OpenSyntheticBuffer {
2897                            name: repo_scope::repo_view_name_with(
2898                                "rebase",
2899                                upstream,
2900                                &store,
2901                                &scopes,
2902                                ctx.buffer_id,
2903                            ),
2904                            mode_id: "magit-rebase-mode".to_string(),
2905                            content: None,
2906                            cursor: None,
2907                            activate_minor: None,
2908                        })
2909                    })
2910                },
2911                args_schema: vec![ArgSpec::optional(
2912                    "upstream",
2913                    lattice_grammar::ArgKind::String,
2914                    "ref to rebase onto",
2915                )],
2916                surface_form: SurfaceForm::Keyword,
2917            },
2918        );
2919    }
2920    {
2921        // Fold audit fix: magit-branch's `c` (create) chord was an
2922        // explicit stub ("needs minibuffer prompt"). This codebase
2923        // has no generic single-line-prompt-with-callback mechanism
2924        // yet (`:lsp-rename <new>` takes its arg the same way — typed
2925        // on the `:` line, not an interactive prompt buffer); `c`
2926        // points the user here instead of pretending to prompt.
2927        registry.register_ex_command(
2928            "magit-branch-create",
2929            "Create a new branch from HEAD and check it out.",
2930            ExCommandSpec {
2931                latency_class: LatencyClass::Reflex,
2932                accepts_bang: false,
2933                accepts_range: false,
2934                parse_args: Arc::new(|line: &str, _bang: bool| {
2935                    let trimmed = line.trim();
2936                    if trimmed.is_empty() {
2937                        Err(lattice_grammar::error::CommandError::BadArgs(
2938                            "magit-branch-create: branch name required".to_string(),
2939                        ))
2940                    } else {
2941                        Ok(Args::String(trimmed.to_string()))
2942                    }
2943                }),
2944                apply: {
2945                    let store = store.clone();
2946                    let scopes = scopes.clone();
2947                    Arc::new(move |ctx| {
2948                    let Args::String(ref name) = ctx.args else {
2949                        return Ok(Effect::Echo {
2950                            level: lattice_grammar::EchoLevel::Error,
2951                            text: "magit-branch-create: branch name required".to_string(),
2952                        });
2953                    };
2954                    let name = name.clone();
2955                    // MR.5: the branch is created in the repository the
2956                    // command came from, not the process's.
2957                    let workdir = repo_scope::workdir_or_cwd(&store, &scopes, ctx.buffer_id);
2958                    tokio::task::spawn(tokio::task::spawn_blocking(move || {
2959                        let Ok(repo) = lattice_vcs::Repository::discover(&workdir) else {
2960                            tracing::error!(target: "lattice_magit", "branch create: repo discover failed");
2961                            return;
2962                        };
2963                        if let Err(e) = lattice_vcs::Branch::create(&repo, &name, true, None) {
2964                            tracing::error!(target: "lattice_magit", "branch create {name}: {e}");
2965                        }
2966                    }));
2967                    Ok(Effect::Echo {
2968                        level: lattice_grammar::EchoLevel::Info,
2969                        text: "magit: creating branch…".to_string(),
2970                    })
2971                })
2972                },
2973                args_schema: vec![ArgSpec::required(
2974                    "name",
2975                    lattice_grammar::ArgKind::String,
2976                    "new branch name",
2977                )],
2978                surface_form: SurfaceForm::Keyword,
2979            },
2980        );
2981    }
2982    {
2983        // MG.32: `:magit-branch-delete <name>` — the ask half of the
2984        // branch submenu's `x`, and the scriptable form besides.
2985        //
2986        // **This is an ex-command rather than an action because a
2987        // picker's accept can only reach an operation through
2988        // `InvokeCommand`**, which dispatches ex-commands — the same
2989        // constraint that shaped MG.23j's commit picker. It does no git
2990        // call at all: it raises the MG.12 confirm carrying the name,
2991        // and only `action:magit-global-branch-delete-execute` deletes,
2992        // so answering `n` cannot mutate anything.
2993        registry.register_ex_command(
2994            "magit-branch-delete",
2995            "Delete a branch by name — asks first (force delete).",
2996            ExCommandSpec {
2997                latency_class: LatencyClass::Reflex,
2998                accepts_bang: false,
2999                accepts_range: false,
3000                parse_args: Arc::new(|line: &str, _bang: bool| {
3001                    let trimmed = line.trim();
3002                    if trimmed.is_empty() {
3003                        Err(lattice_grammar::error::CommandError::BadArgs(
3004                            "magit-branch-delete: branch name required".to_string(),
3005                        ))
3006                    } else {
3007                        Ok(Args::String(trimmed.to_string()))
3008                    }
3009                }),
3010                apply: Arc::new(|ctx| {
3011                    let Args::String(ref name) = ctx.args else {
3012                        return Ok(Effect::Echo {
3013                            level: lattice_grammar::EchoLevel::Error,
3014                            text: "magit-branch-delete: branch name required".to_string(),
3015                        });
3016                    };
3017                    Ok(confirm::ask_target(
3018                        format!("Delete branch {name}?"),
3019                        "action:magit-global-branch-delete-execute",
3020                        name.clone(),
3021                    ))
3022                }),
3023                args_schema: vec![ArgSpec::required(
3024                    "name",
3025                    lattice_grammar::ArgKind::String,
3026                    "branch to delete",
3027                )],
3028                surface_form: SurfaceForm::Keyword,
3029            },
3030        );
3031    }
3032}
3033
3034/// Register every `action:magit-*` command so that mode keymap
3035/// entries resolve against the registry. Each action is a dead
3036/// marker returning `Effect::None` — the real handler is registered
3037/// per-buffer via `ActionHandlerRegistry` in `on_activate`.
3038/// MG.41a: test-only door onto [`register_action_commands`], so the
3039/// row-table drift tests can build the same registry `install` does
3040/// without standing up a whole boot.
3041#[cfg(test)]
3042pub(crate) fn register_action_commands_for_test(registry: &mut CommandRegistry) {
3043    register_action_commands(registry);
3044}
3045
3046/// MG.52: test door onto [`register_ex_commands`], so a guard can ask
3047/// whether the ex-command a picker hands its pick to actually exists.
3048/// Without it a row could open a picker naming a command nobody
3049/// registered, and picking would silently do nothing.
3050#[cfg(test)]
3051pub(crate) fn register_ex_commands_for_test(registry: &mut CommandRegistry) {
3052    register_ex_commands(
3053        registry,
3054        Default::default(),
3055        crate::repo_scope::test_support::empty_store(),
3056        Default::default(),
3057    );
3058}
3059
3060fn register_action_commands(registry: &mut CommandRegistry) {
3061    let none = Some(Arc::new(
3062        |_: &lattice_grammar::ActionContext| -> GrammarResult<Effect> { Ok(Effect::None) },
3063    )
3064        as Arc<
3065            dyn Fn(&lattice_grammar::ActionContext) -> GrammarResult<Effect> + Send + Sync,
3066        >);
3067
3068    let mut reg = |name: &str, doc: &str| {
3069        registry.register_action(
3070            name,
3071            doc,
3072            ActionSpec {
3073                apply: none.clone().unwrap(),
3074                args_schema: Vec::new(),
3075            },
3076        );
3077    };
3078
3079    // magit-status-mode
3080    reg("action:magit-stage", "Stage the hunk or file at cursor");
3081    reg("action:magit-unstage", "Unstage the hunk or file at cursor");
3082    reg("action:magit-discard", "Discard the hunk or file at cursor");
3083    reg(
3084        "action:magit-discard-execute",
3085        "Execute the discard after confirmation",
3086    );
3087    reg(
3088        "action:magit-discard-untracked-execute",
3089        "Delete the untracked file after confirmation",
3090    );
3091    reg(
3092        "action:magit-discard-batch-execute",
3093        "Discard every file in the confirmed selection",
3094    );
3095    reg("action:magit-commit", "Open the commit buffer");
3096    reg("action:magit-commit-amend", "Amend the previous commit");
3097    reg("action:magit-toggle-diff", "Toggle inline diff at cursor");
3098    reg(
3099        "action:magit-diff-file",
3100        "Open file diff in a dedicated buffer",
3101    );
3102    // PD.3: the Diff transient's `e` row. `MagitActionIds::resolve`
3103    // picks it up from the `action:magit-` prefix, so the row wires
3104    // itself once the name is registered here.
3105    reg(
3106        "action:magit-project-diff",
3107        "Open every changed file as one editable diff view",
3108    );
3109    reg(
3110        "action:magit-stage-patch",
3111        "Stage hunk interactively (git add -p)",
3112    );
3113    reg("action:magit-visit", "Context-aware open/visit at cursor");
3114    // MG.22: magit-hunk-mode's `<CR>` — one action for the five buffers
3115    // that render a diff, replacing magit-diff / magit-commit /
3116    // magit-revision's own visit actions, each of which carried its own
3117    // copy of the diff-path parser.
3118    reg(
3119        "action:magit-visit-diff-target",
3120        "Visit the file at cursor, in the version this view describes",
3121    );
3122    // MG.23g: owned by magit-core-mode, so they work in every view that
3123    // shows a committed patch (revision, stash detail).
3124    reg(
3125        "action:magit-apply-hunk",
3126        "Apply the hunk at cursor to the working tree",
3127    );
3128    reg(
3129        "action:magit-reverse-hunk",
3130        "Reverse the hunk at cursor out of the working tree",
3131    );
3132    // MG.23h: magit's `magit-status-jump`, one action per section we
3133    // render. Owned by magit-status-mode — it is the only view with
3134    // sections to jump between.
3135    reg(
3136        "action:magit-jump-staged",
3137        "Jump to the Staged changes section",
3138    );
3139    reg(
3140        "action:magit-jump-unstaged",
3141        "Jump to the Unstaged changes section",
3142    );
3143    reg(
3144        "action:magit-jump-untracked",
3145        "Jump to the Untracked files section",
3146    );
3147    reg("action:magit-jump-stashes", "Jump to the Stashes section");
3148    reg(
3149        "action:magit-jump-unmerged",
3150        "Jump to the Unmerged into <upstream> section",
3151    );
3152    reg(
3153        "action:magit-jump-commits",
3154        "Jump to the Recent commits section",
3155    );
3156
3157    // magit-file-revision-mode
3158    reg(
3159        "action:magit-blob-previous",
3160        "Visit this file at the previous revision",
3161    );
3162    reg(
3163        "action:magit-blob-next",
3164        "Visit this file at the next revision",
3165    );
3166
3167    // magit-project-diff-mode (PD.7c). Its own action, not
3168    // `action:magit-refresh`: that one dispatches through the
3169    // `MagitView` trait to a per-buffer published view, and the
3170    // project-diff multibuffer is a provider view instead — refreshed
3171    // by re-running its provider.
3172    reg(
3173        crate::providers::project_diff::REFRESH_ACTION,
3174        "Re-scan the project diff (the same comparison it already shows)",
3175    );
3176
3177    // magit-core-mode
3178    reg("action:magit-refresh", "Refresh the current magit buffer");
3179    reg("action:magit-close", "Close the magit buffer (bury)");
3180    reg(
3181        "action:magit-next-section",
3182        "Jump to the next top-level section",
3183    );
3184    reg(
3185        "action:magit-prev-section",
3186        "Jump to the previous top-level section",
3187    );
3188    reg(
3189        "action:magit-next-file",
3190        "Jump to the next file/entry in the current section",
3191    );
3192    reg(
3193        "action:magit-prev-file",
3194        "Jump to the previous file/entry in the current section",
3195    );
3196    reg("action:magit-next-hunk", "Jump to the next hunk");
3197    reg("action:magit-prev-hunk", "Jump to the previous hunk");
3198    reg(
3199        "action:magit-toggle-fold",
3200        "Toggle section/hunk fold at cursor",
3201    );
3202    reg("action:magit-cycle-sections", "Cycle section visibility");
3203
3204    // magit-commit-mode
3205    reg(
3206        "action:magit-commit-confirm",
3207        "Create the commit with the entered message",
3208    );
3209    reg("action:magit-commit-abort", "Abort the commit");
3210
3211    // magit-log-mode
3212    reg(
3213        "action:magit-log-show-commit",
3214        "Show the commit detail at cursor",
3215    );
3216
3217    // magit-blame-mode
3218    reg(
3219        "action:magit-blame-show-commit",
3220        "Show the commit for the blamed line",
3221    );
3222    reg("action:magit-blame-parent", "Re-blame at the parent commit");
3223    reg(
3224        "action:magit-blame-quit",
3225        "Stop blaming — the buffer becomes editable again",
3226    );
3227    // MG.23f2. Deliberately NOT in `FILE_TARGET_ACTIONS`: it needs a
3228    // revision as well as a path, and takes both from the blob buffer
3229    // it is invoked in — a `file` argument alone could not say which
3230    // revision to walk forward from. See its handler for why that
3231    // restricts it to blob buffers.
3232    reg(
3233        "action:magit-global-file-blame-reverse",
3234        "For each line of this revision of the file, the last commit it existed in",
3235    );
3236
3237    // MG.34: magit-file-dispatch's `M` and `e`.
3238    //
3239    // Neither is in `FILE_TARGET_ACTIONS`. `M` takes a *commit*, not a
3240    // path — it is only nominally file-scoped, and magit files it under
3241    // file-dispatch because that is where you are when you wonder how a
3242    // commit got here. `e` takes a path AND a cursor line, and a `file`
3243    // argument alone cannot carry the line, so a target-file form would
3244    // silently blame line 1 of whatever was named.
3245    reg(
3246        "action:magit-global-log-merged",
3247        "Show the merge commit that brought a commit into HEAD",
3248    );
3249    reg(
3250        "action:magit-global-edit-line-commit",
3251        "Start a rebase that stops on the commit that wrote the line at the cursor",
3252    );
3253
3254    // MG.35: magit-refs-mode
3255    reg(
3256        "action:magit-refs-show",
3257        "Show the commit the ref at cursor points at",
3258    );
3259    reg(
3260        "action:magit-refs-checkout",
3261        "Check out the ref at cursor (refuses on a tag or remote-tracking branch)",
3262    );
3263
3264    // magit-stash-mode
3265    reg("action:magit-stash-apply", "Apply the stash at cursor");
3266    reg("action:magit-stash-pop", "Pop the stash at cursor");
3267    reg("action:magit-stash-drop", "Drop the stash at cursor");
3268    // MG.12: the git call lives here, behind `magit-stash-drop`'s
3269    // `Effect::Confirm`. See `confirm::DESTRUCTIVE_ACTIONS`.
3270    reg(
3271        "action:magit-stash-drop-execute",
3272        "Execute the stash drop after confirmation",
3273    );
3274    reg("action:magit-stash-create", "Create a new stash");
3275    // MG.15
3276    reg(
3277        "action:magit-stash-show",
3278        "Show the patch of the stash at cursor",
3279    );
3280
3281    // MG.20: operations on the commit under the cursor. Owned by
3282    // magit-core-mode, so they work in every view that shows a commit
3283    // (log, status's Recent commits, revision, rebase todo).
3284    reg(
3285        "action:magit-cherry-pick",
3286        "Cherry-pick the commit at cursor onto the current branch",
3287    );
3288    reg(
3289        "action:magit-revert",
3290        "Revert the commit at cursor (creates an inverse commit)",
3291    );
3292    // MG.43a: the `--no-commit` halves — stage the change without
3293    // recording it, so it can be edited before committing.
3294    reg(
3295        "action:magit-revert-changes",
3296        "Apply the inverse of the commit at cursor without committing",
3297    );
3298    reg(
3299        "action:magit-cherry-pick-apply",
3300        "Apply the commit at cursor's changes without committing",
3301    );
3302    reg(
3303        "action:magit-reset-soft",
3304        "Reset --soft to the commit at cursor (keeps index + working tree)",
3305    );
3306    reg(
3307        "action:magit-reset-mixed",
3308        "Reset --mixed to the commit at cursor (keeps working tree)",
3309    );
3310    reg(
3311        "action:magit-reset-hard",
3312        "Reset --hard to the commit at cursor (DISCARDS working tree; asks first)",
3313    );
3314    reg(
3315        "action:magit-reset-keep",
3316        "Reset --keep to the commit at cursor (refuses rather than discarding your work)",
3317    );
3318    reg(
3319        "action:magit-reset-index",
3320        "Reset the index to the commit at cursor, leaving HEAD and the working tree alone",
3321    );
3322    reg(
3323        "action:magit-commit-fixup",
3324        "Record a fixup! commit for the commit at cursor (folded by rebase --autosquash)",
3325    );
3326    reg(
3327        "action:magit-commit-squash",
3328        "Record a squash! commit for the commit at cursor (folded by rebase --autosquash)",
3329    );
3330    reg(
3331        "action:magit-reset-hard-execute",
3332        "Execute the hard reset after confirmation",
3333    );
3334
3335    // magit-branch-mode
3336    reg(
3337        "action:magit-branch-checkout",
3338        "Check out the branch at cursor",
3339    );
3340    reg("action:magit-branch-create", "Create a new branch");
3341    reg("action:magit-branch-delete", "Delete the branch at cursor");
3342    // MG.12: `Branch::delete` is a force delete (`-D`), so it drops
3343    // unmerged commits — the git call lives here, behind
3344    // `magit-branch-delete`'s `Effect::Confirm`.
3345    reg(
3346        "action:magit-branch-delete-execute",
3347        "Execute the branch delete after confirmation",
3348    );
3349    reg(
3350        "action:magit-branch-merge",
3351        "Merge the branch at cursor into current",
3352    );
3353
3354    // MG.21c: magit-remote-mode. The `-url` / `-finish` halves are
3355    // fired by a prompt submit, never by a chord, but they are real
3356    // registered actions all the same — `do_prompt_line_submit`
3357    // resolves `on_submit_action` through the command registry and
3358    // reports "unknown action" if it is missing.
3359    reg(
3360        "action:magit-global-remote",
3361        "Open the Magit remote list buffer",
3362    );
3363    // MG.29: the branch submenu's picker-backed rows.
3364    reg(
3365        "action:magit-global-branch-checkout",
3366        "Pick a branch and check it out",
3367    );
3368    reg(
3369        "action:magit-global-branch-create",
3370        "Pick a base, then name a new branch",
3371    );
3372    // MG.21g: bisect. The `-start-good` / `-start-finish` halves are
3373    // fired by a prompt submit rather than a chord, but must still be
3374    // registered — `do_prompt_line_submit` resolves `on_submit_action`
3375    // through the command registry.
3376    reg(
3377        "action:magit-global-bisect-start",
3378        "Start a bisect (asks for a bad then a good revision)",
3379    );
3380    reg(
3381        "action:magit-global-bisect-start-good",
3382        "Ask for the good revision after the bad one",
3383    );
3384    reg(
3385        "action:magit-global-bisect-start-finish",
3386        "Start the bisect once both ends are known",
3387    );
3388    reg(
3389        "action:magit-global-bisect-good",
3390        "Mark the revision git checked out as good",
3391    );
3392    reg(
3393        "action:magit-global-bisect-bad",
3394        "Mark the revision git checked out as bad",
3395    );
3396    reg(
3397        "action:magit-global-bisect-skip",
3398        "Skip a revision that cannot be tested",
3399    );
3400    reg(
3401        "action:magit-global-bisect-reset",
3402        "End the bisect and return to where it started",
3403    );
3404    // MG.21i: magit-submodule-mode.
3405    reg("action:magit-submodule-add", "Add a submodule");
3406    reg(
3407        "action:magit-submodule-add-path",
3408        "Ask where to put the submodule after its URL",
3409    );
3410    reg(
3411        "action:magit-submodule-add-finish",
3412        "Add the submodule once its URL and path are known",
3413    );
3414    reg(
3415        "action:magit-submodule-update",
3416        "Initialise and check out the submodule at cursor",
3417    );
3418    reg(
3419        "action:magit-submodule-sync",
3420        "Re-copy the configured URL into the submodule at cursor",
3421    );
3422    reg(
3423        "action:magit-submodule-remove",
3424        "Remove the submodule at cursor (asks first)",
3425    );
3426    reg(
3427        "action:magit-global-submodule",
3428        "Open the Magit submodule list buffer",
3429    );
3430    // MG.35
3431    reg(
3432        "action:magit-global-refs",
3433        "Open the Magit refs buffer — every branch, remote-tracking branch and tag",
3434    );
3435    // MG.37: magit-notes-mode's own chords.
3436    reg(
3437        "action:magit-cherry-show",
3438        "Show the commit at cursor in the cherry list",
3439    );
3440    reg("action:magit-note-confirm", "Save this note");
3441    reg(
3442        "action:magit-note-abort",
3443        "Close the note buffer without saving",
3444    );
3445
3446    // MG.38 / MG.39 / MG.40.
3447    for (name, doc) in [
3448        (
3449            "action:magit-global-subtree-add",
3450            "Add a repository as a subtree",
3451        ),
3452        (
3453            "action:magit-global-subtree-merge",
3454            "Merge a ref into a subtree",
3455        ),
3456        (
3457            "action:magit-global-subtree-pull",
3458            "Fetch and merge a subtree's upstream",
3459        ),
3460        (
3461            "action:magit-global-subtree-push",
3462            "Push a subtree's history to its repository",
3463        ),
3464        (
3465            "action:magit-global-subtree-split",
3466            "Extract a subtree's history",
3467        ),
3468        (
3469            "action:magit-global-subtree-finish",
3470            "Run the subtree operation once its arguments are known",
3471        ),
3472        ("action:magit-global-am-apply", "Apply a mailbox of patches"),
3473        (
3474            "action:magit-global-am-apply-finish",
3475            "Run the patch apply once the files are known",
3476        ),
3477        (
3478            "action:magit-global-am-continue",
3479            "Resume a stopped patch apply",
3480        ),
3481        (
3482            "action:magit-global-am-skip",
3483            "Skip the patch that would not apply",
3484        ),
3485        (
3486            "action:magit-global-am-abort",
3487            "Abandon a stopped patch apply",
3488        ),
3489        (
3490            "action:magit-global-format-patch",
3491            "Write a commit range out as .patch files",
3492        ),
3493        (
3494            "action:magit-global-format-patch-finish",
3495            "Run format-patch once the range is known",
3496        ),
3497        (
3498            "action:magit-global-cherries",
3499            "Show which commits are not upstream yet",
3500        ),
3501        (
3502            "action:magit-global-cherries-finish",
3503            "Open the cherry list once the upstream is known",
3504        ),
3505    ] {
3506        reg(name, doc);
3507    }
3508
3509    // MG.37: the notes submenu.
3510    reg(
3511        "action:magit-global-note-edit",
3512        "Edit the note on a commit — opens an editable buffer",
3513    );
3514    reg(
3515        "action:magit-global-note-remove",
3516        "Remove the note from a commit",
3517    );
3518    reg(
3519        "action:magit-global-note-prune",
3520        "Drop notes whose commit no longer exists (asks first)",
3521    );
3522    reg(
3523        "action:magit-global-note-prune-execute",
3524        "Execute the notes prune after confirmation",
3525    );
3526    reg(
3527        "action:magit-global-note-merge",
3528        "Merge another notes ref into this one",
3529    );
3530    reg(
3531        "action:magit-global-note-merge-finish",
3532        "Run the notes merge once the ref is known",
3533    );
3534    reg(
3535        "action:magit-global-note-merge-commit",
3536        "Finish a notes merge that stopped on a conflict",
3537    );
3538    reg(
3539        "action:magit-global-note-merge-abort",
3540        "Abandon a notes merge that stopped on a conflict",
3541    );
3542
3543    // MG.36: the clone wizard's three steps.
3544    reg(
3545        "action:magit-global-clone",
3546        "Clone a repository — asks for the URL",
3547    );
3548    reg(
3549        "action:magit-global-clone-dest",
3550        "Second step of the clone wizard — asks where to put it",
3551    );
3552    reg(
3553        "action:magit-global-clone-finish",
3554        "Run the clone once the URL and destination are known",
3555    );
3556
3557    reg(
3558        "action:magit-view-arguments",
3559        "Re-run this view with different git arguments",
3560    );
3561    // MG.49: one action per root menu — ALL of them, not only the three
3562    // that get a default chord.
3563    //
3564    // The chord set is constrained by vim, not by the menus: `f` / `t` /
3565    // `b` / `w` / `m` / `z` are live motions inside a read-only magit
3566    // buffer and a minor-mode layer would shadow them. The actions
3567    // themselves are unambiguous, so they are registered regardless —
3568    // which is what lets a user bind `<leader>z` to the stash menu in
3569    // their own config. Registering only the bound three would make the
3570    // other fourteen unreachable by name as well as by key.
3571    for menu in transients::ROOT_MENUS {
3572        reg(menu.action, menu.doc);
3573    }
3574    // MG.19: `dv`. The session it opens is `lattice-diff`'s, so
3575    // `do` / `dp` / `]c` / `[c` and the scroll binding are that
3576    // subsystem's — nothing is reimplemented here.
3577    reg(
3578        "action:magit-diff-side-by-side",
3579        "Open the file at cursor side-by-side against its baseline",
3580    );
3581    reg("action:magit-remote-add", "Add a remote");
3582    reg(
3583        "action:magit-remote-add-url",
3584        "Ask for the new remote's URL after its name",
3585    );
3586    reg(
3587        "action:magit-remote-add-finish",
3588        "Add the remote once its name and URL are known",
3589    );
3590    reg("action:magit-remote-rename", "Rename the remote at cursor");
3591    reg(
3592        "action:magit-remote-rename-finish",
3593        "Rename the remote to the typed name",
3594    );
3595    reg("action:magit-remote-remove", "Remove the remote at cursor");
3596    reg(
3597        "action:magit-remote-set-url",
3598        "Set the URL of the remote at cursor",
3599    );
3600    reg(
3601        "action:magit-remote-set-url-finish",
3602        "Point the remote at the typed URL",
3603    );
3604    reg(
3605        "action:magit-remote-prune",
3606        "Delete local refs whose branch is gone from the remote at cursor",
3607    );
3608
3609    // magit-rebase-mode
3610    reg("action:magit-rebase-confirm", "Execute the rebase");
3611    reg("action:magit-rebase-abort", "Abort the rebase");
3612    // MG.12: only fired when a rebase is actually in progress — see
3613    // `magit_rebase_mode`'s abort handler, which closes the pane
3614    // outright when there is nothing to throw away.
3615    reg(
3616        "action:magit-rebase-abort-execute",
3617        "Execute the rebase abort after confirmation",
3618    );
3619    reg(
3620        "action:magit-rebase-show-commit",
3621        "Show the commit detail at cursor",
3622    );
3623
3624    // magit-global-mode (Universal — always active, unlike every
3625    // action above which only has a live handler while its owning
3626    // buffer is open). Backs the `magit-dispatch` root transient's
3627    // items so pressing a key inside it works from ANY buffer, not
3628    // just from within the matching magit buffer kind.
3629    reg("action:magit-global-status", "Open the status buffer");
3630    reg("action:magit-global-commit", "Open the commit buffer");
3631    reg("action:magit-global-amend", "Amend the previous commit");
3632    reg("action:magit-global-log", "Open the log buffer");
3633    reg("action:magit-global-diff", "Open the diff buffer");
3634    reg("action:magit-global-branch", "Open the branch list");
3635    reg("action:magit-global-stash", "Open the stash list");
3636    reg(
3637        "action:magit-global-stash-create",
3638        "Stash the working tree (git stash push)",
3639    );
3640    reg("action:magit-global-rebase", "Start an interactive rebase");
3641    // MG.41e: the rebase submenu's sequence rows. The ex-commands
3642    // already existed (`:magit-rebase-continue` etc.); these are the
3643    // action names the menu rows fire.
3644    reg(
3645        "action:magit-global-rebase-continue",
3646        "Resume a rebase that stopped, after amending or resolving conflicts",
3647    );
3648    reg(
3649        "action:magit-global-rebase-skip",
3650        "Skip the commit a stopped rebase is sitting on",
3651    );
3652    reg(
3653        "action:magit-global-merge-continue",
3654        "Conclude a merge that stopped on a conflict, once the resolution is staged",
3655    );
3656    reg(
3657        "action:magit-global-merge-abort",
3658        "Abandon a merge in progress, restoring the branch",
3659    );
3660    reg(
3661        "action:magit-global-cherry-pick-continue",
3662        "Resume a cherry-pick that stopped on a conflict",
3663    );
3664    reg(
3665        "action:magit-global-cherry-pick-skip",
3666        "Skip the commit a stopped cherry-pick is sitting on",
3667    );
3668    reg(
3669        "action:magit-global-cherry-pick-abort",
3670        "Abandon a cherry-pick in progress, restoring the branch",
3671    );
3672    reg(
3673        "action:magit-global-revert-continue",
3674        "Resume a revert that stopped on a conflict",
3675    );
3676    reg(
3677        "action:magit-global-revert-skip",
3678        "Skip the commit a stopped revert is sitting on",
3679    );
3680    reg(
3681        "action:magit-global-revert-abort",
3682        "Abandon a revert in progress, restoring the branch",
3683    );
3684    reg(
3685        "action:magit-global-rebase-abort",
3686        "Abandon a rebase in progress, restoring the branch to where it started",
3687    );
3688    reg(
3689        "action:magit-global-fetch",
3690        "Fetch from the remote without merging",
3691    );
3692    reg(
3693        "action:magit-global-pull",
3694        "Fetch + fast-forward merge from the remote",
3695    );
3696    reg("action:magit-global-push", "Push to the remote");
3697    reg(
3698        "action:magit-global-stash-keep-index",
3699        "Stash everything but leave the index staged",
3700    );
3701    reg(
3702        "action:magit-global-stash-staged",
3703        "Stash only the staged changes",
3704    );
3705    // MG.42-E2: composite operations.
3706    reg(
3707        "action:magit-global-stash-snapshot",
3708        "Stash everything and put it straight back — a restore point that costs nothing",
3709    );
3710    reg(
3711        "action:magit-global-stash-snapshot-index",
3712        "Snapshot the staged changes without disturbing the working tree",
3713    );
3714    reg(
3715        "action:magit-global-stash-snapshot-worktree",
3716        "Snapshot the working tree without disturbing the index",
3717    );
3718    reg(
3719        "action:magit-global-branch-reset-finish",
3720        "Ask before resetting the current branch to the named ref",
3721    );
3722    // MG.43d: the commit-moving rows.
3723    for (name, doc) in [
3724        (
3725            "action:magit-cherry-harvest",
3726            "Move a commit here from another branch, removing it there",
3727        ),
3728        (
3729            "action:magit-cherry-donate",
3730            "Move a commit to another branch, staying on this one",
3731        ),
3732        (
3733            "action:magit-cherry-spinout",
3734            "Move a commit to a new branch, staying on this one",
3735        ),
3736        (
3737            "action:magit-cherry-spinoff",
3738            "Move a commit to a new branch and check it out",
3739        ),
3740    ] {
3741        reg(name, doc);
3742        reg(
3743            Box::leak(format!("{name}-finish").into_boxed_str()),
3744            "Run the cherry move once the branch is named",
3745        );
3746    }
3747    for (name, doc) in [
3748        (
3749            "action:magit-global-branch-spinoff",
3750            "Branch the unpushed commits and check it out",
3751        ),
3752        (
3753            "action:magit-global-branch-spinout",
3754            "Branch the unpushed commits, staying on this branch",
3755        ),
3756    ] {
3757        reg(name, doc);
3758        reg(
3759            Box::leak(format!("{name}-finish").into_boxed_str()),
3760            "Create the branch once it is named",
3761        );
3762    }
3763    // MG.43f: reset the worktree, fetch submodules.
3764    reg(
3765        "action:magit-reset-worktree",
3766        "Reset the working tree to the commit at cursor, keeping HEAD and the index",
3767    );
3768    reg(
3769        "action:magit-global-fetch-submodules",
3770        "Fetch the superproject and its submodules",
3771    );
3772    // MG.43e: merge preview / merge-into, tag release / prune.
3773    reg(
3774        "action:magit-global-merge-preview",
3775        "Show what merging a branch would bring in (asks which)",
3776    );
3777    reg(
3778        "action:magit-global-merge-into",
3779        "Merge this branch into another, then delete this one (asks which)",
3780    );
3781    reg(
3782        "action:magit-global-tag-release",
3783        "Create an annotated release tag (asks name and message)",
3784    );
3785    reg(
3786        "action:magit-global-tag-prune",
3787        "Drop local tags that no longer exist on the remote (asks the remote)",
3788    );
3789    // MG.43g: the `C` configure rows' actions, one pair per key.
3790    for (name, doc) in [
3791        (
3792            "action:magit-config-pull-rebase",
3793            "Set pull.rebase for this repository",
3794        ),
3795        (
3796            "action:magit-config-push-default",
3797            "Set remote.pushDefault for this repository",
3798        ),
3799        (
3800            "action:magit-config-fetch-prune",
3801            "Set fetch.prune for this repository",
3802        ),
3803        (
3804            "action:magit-config-tag-sign",
3805            "Set tag.gpgSign for this repository",
3806        ),
3807        (
3808            "action:magit-config-notes-ref",
3809            "Set core.notesRef for this repository",
3810        ),
3811    ] {
3812        reg(name, doc);
3813        // The finish half is what the prompt submits to; unregistered,
3814        // the prompt would accept a value and route nowhere.
3815        reg(
3816            Box::leak(format!("{name}-finish").into_boxed_str()),
3817            "Write the configure row's new value",
3818        );
3819    }
3820    // MG.43c: rebase's todo-rewriting rows.
3821    reg(
3822        "action:magit-rebase-edit-commit",
3823        "Replay history, stopping at a commit so you can change it",
3824    );
3825    reg(
3826        "action:magit-rebase-reword-commit",
3827        "Change an older commit's message",
3828    );
3829    reg(
3830        "action:magit-rebase-remove-commit",
3831        "Replay history without a commit",
3832    );
3833    // MG.43b: rebase's onto-a-target rows.
3834    reg(
3835        "action:magit-global-rebase-onto-push",
3836        "Rebase this branch onto its push target",
3837    );
3838    reg(
3839        "action:magit-global-rebase-onto-upstream",
3840        "Rebase this branch onto its upstream",
3841    );
3842    reg(
3843        "action:magit-global-rebase-onto-elsewhere",
3844        "Rebase this branch onto a ref you name (asks which)",
3845    );
3846    reg(
3847        "action:magit-global-rebase-subset",
3848        "Replay the commits after one ref onto another (asks both)",
3849    );
3850    reg(
3851        "action:magit-global-rebase-autosquash",
3852        "Replay, folding in fixup! and squash! markers (asks the base)",
3853    );
3854    reg(
3855        "action:magit-global-commit-extend",
3856        "Add staged changes to the last commit, keeping its message",
3857    );
3858    reg(
3859        "action:magit-global-branch-reset",
3860        "Reset the current branch to another ref (asks first)",
3861    );
3862    reg(
3863        "action:magit-commit-augment",
3864        "Record a squash! for a commit, carrying a note you write",
3865    );
3866    reg(
3867        "action:magit-global-merge-edit",
3868        "Merge a branch with a message you write (asks which)",
3869    );
3870    reg(
3871        "action:magit-global-reword",
3872        "Reword the last commit — its message only, leaving the index alone",
3873    );
3874    reg(
3875        "action:magit-global-reset-file",
3876        "Restore one file from a commit (asks for the commit, then the path)",
3877    );
3878    reg(
3879        "action:magit-global-stash-branch",
3880        "Start a branch from a stash (asks for the name, then the stash)",
3881    );
3882    reg(
3883        "action:magit-global-merge-absorb",
3884        "Merge a branch and delete it (asks which)",
3885    );
3886    reg(
3887        "action:magit-commit-instant-fixup",
3888        "Record a fixup! for a commit and fold it in immediately",
3889    );
3890    reg(
3891        "action:magit-commit-instant-squash",
3892        "Record a squash! for a commit and fold it in immediately",
3893    );
3894
3895    // MG.41c: magit's destination rows. The op is the same each time —
3896    // only where it sends or takes refs differs — so these share one
3897    // handler shape (`spawn_remote_op_to`) rather than one function
3898    // each.
3899    reg(
3900        "action:magit-global-push-configured",
3901        "Push to the configured push-remote (git resolves pushRemote / pushDefault)",
3902    );
3903    reg(
3904        "action:magit-global-push-upstream",
3905        "Push to this branch's @{upstream} — differs from the push-remote in a triangular workflow",
3906    );
3907    reg(
3908        "action:magit-global-push-elsewhere",
3909        "Push to a remote you name",
3910    );
3911    reg(
3912        "action:magit-global-push-other-branch",
3913        "Push a branch other than HEAD",
3914    );
3915    reg(
3916        "action:magit-global-push-refspecs",
3917        "Push explicit refspecs",
3918    );
3919    reg("action:magit-global-push-tag", "Push a single tag");
3920    reg("action:magit-global-push-all-tags", "Push every tag");
3921
3922    reg(
3923        "action:magit-global-pull-configured",
3924        "Pull from the configured remote",
3925    );
3926    reg(
3927        "action:magit-global-pull-upstream",
3928        "Pull from this branch's @{upstream}",
3929    );
3930    reg(
3931        "action:magit-global-pull-elsewhere",
3932        "Pull from a remote you name",
3933    );
3934
3935    reg(
3936        "action:magit-global-fetch-configured",
3937        "Fetch from the configured remote",
3938    );
3939    reg(
3940        "action:magit-global-fetch-upstream",
3941        "Fetch this branch's @{upstream}",
3942    );
3943    reg(
3944        "action:magit-global-fetch-elsewhere",
3945        "Fetch from a remote you name",
3946    );
3947    reg(
3948        "action:magit-global-fetch-other-branch",
3949        "Fetch a branch you name",
3950    );
3951    reg(
3952        "action:magit-global-fetch-refspecs",
3953        "Fetch explicit refspecs",
3954    );
3955    reg(
3956        "action:magit-global-fetch-all-remotes",
3957        "Fetch from every configured remote",
3958    );
3959
3960    // MG.23a: the six file-dispatch actions declare an optional
3961    // `file` argument. `C-c f` leaves it unset and they act on the
3962    // visited file; `:magit-other-file-dispatch` sets it, which is how
3963    // a stand-alone invocation names a file it is not visiting. The
3964    // name must match the transient `Argument`'s name — the host maps
3965    // transient state onto the schema BY NAME
3966    // (`project_transient_state`), so a typo here degrades silently to
3967    // "always the current file".
3968    // MG.23c1: prompt-backed repo operations.
3969    reg("action:magit-global-tag", "Tag HEAD (asks for the name)");
3970    reg(
3971        "action:magit-global-tag-finish",
3972        "Create the tag with the typed name",
3973    );
3974    reg(
3975        "action:magit-global-gitignore",
3976        "Add a pattern to .gitignore (asks for it)",
3977    );
3978    reg(
3979        "action:magit-global-gitignore-finish",
3980        "Append the typed pattern to .gitignore",
3981    );
3982
3983    // MG.23d: file operations.
3984    reg(
3985        "action:magit-global-file-untrack",
3986        "Stop tracking the file, keeping it on disk",
3987    );
3988    reg(
3989        "action:magit-global-file-delete",
3990        "Delete the file (asks first)",
3991    );
3992    reg(
3993        "action:magit-global-file-delete-execute",
3994        "Delete the file after confirmation",
3995    );
3996    reg(
3997        "action:magit-global-file-rename",
3998        "Rename the file (asks for the new name)",
3999    );
4000    // MG.23d2
4001    reg(
4002        "action:magit-global-file-checkout",
4003        "Check the file out from a revision (asks for it, then confirms)",
4004    );
4005    reg(
4006        "action:magit-global-file-checkout-finish",
4007        "Confirm checking the file out from the typed revision",
4008    );
4009    reg(
4010        "action:magit-global-file-rename-finish",
4011        "Rename the file to the typed name",
4012    );
4013
4014    // MG.23c2
4015    reg(
4016        "action:magit-global-init",
4017        "Initialize a git repository (asks for the directory)",
4018    );
4019    reg(
4020        "action:magit-global-init-finish",
4021        "Run git init in the typed directory",
4022    );
4023    reg("action:magit-global-merge", "Merge a branch (asks which)");
4024    // MG.41e: the merge / tag submenu rows.
4025    reg(
4026        "action:magit-global-merge-no-commit",
4027        "Merge a branch but stop before committing (asks which)",
4028    );
4029    reg(
4030        "action:magit-global-merge-squash",
4031        "Squash a branch's changes into the index without a merge commit (asks which)",
4032    );
4033    reg(
4034        "action:magit-global-tag-delete",
4035        "Delete a local tag (asks which)",
4036    );
4037    reg(
4038        "action:magit-global-merge-finish",
4039        "Merge the typed branch into the current one",
4040    );
4041
4042    // MG.23b: repo-wide index operations (magit's `S` / `U`).
4043    reg(
4044        "action:magit-global-stage-all",
4045        "Stage every tracked modification",
4046    );
4047    reg("action:magit-global-unstage-all", "Unstage everything");
4048
4049    // File-dispatch (`C-c f`) — file-level operations scoped to the
4050    // buffer active when the transient was opened.
4051    reg(
4052        "action:magit-global-file-stage",
4053        "Stage the file in the current buffer",
4054    );
4055    reg(
4056        "action:magit-global-file-unstage",
4057        "Unstage the file in the current buffer",
4058    );
4059    reg(
4060        "action:magit-global-file-discard",
4061        "Discard changes to the file in the current buffer",
4062    );
4063    reg(
4064        "action:magit-global-file-discard-execute",
4065        "Execute the file discard after confirmation",
4066    );
4067    reg(
4068        "action:magit-global-file-diff",
4069        "Show diff for the file in the current buffer",
4070    );
4071    reg(
4072        "action:magit-global-file-log",
4073        "Show commit history for the file in the current buffer",
4074    );
4075    // MG.28: `v` on the file dispatch — the direct way into
4076    // `magit-file-revision-mode`, which until now was reachable only by
4077    // `<CR>` inside a revision view and `gj`/`gk` from there.
4078    reg(
4079        "action:magit-global-file-at-revision",
4080        "Open the current file as it was at a revision you name",
4081    );
4082    reg(
4083        "action:magit-global-file-at-revision-finish",
4084        "Open the file once the revision is known",
4085    );
4086    reg(
4087        "action:magit-global-file-visit-live",
4088        "From a file-at-revision, open the working-tree copy at the same line",
4089    );
4090    reg(
4091        "action:magit-global-file-blame",
4092        "Blame the file in the current buffer",
4093    );
4094
4095    // Branch-create wizard (`c` in magit-branch-mode): fired by the
4096    // prompt opened after picking a base branch via
4097    // `magit-branch-pick-base`. Global like the others above — the
4098    // prompt buffer isn't a magit-status/-branch buffer, so this
4099    // can't be a per-buffer handler.
4100    reg(
4101        "action:magit-branch-create-finish",
4102        "Create the new branch (from the picked base) with the typed name",
4103    );
4104
4105    // MG.32: the rest of magit's branch transient. Each row is an
4106    // ask-half (opens a picker or a prompt) plus, where the flow needs
4107    // one, a finish-half fired by the prompt it opened.
4108    reg(
4109        "action:magit-global-branch-checkout-rev",
4110        "Check out a branch or revision you name",
4111    );
4112    reg(
4113        "action:magit-global-branch-checkout-rev-finish",
4114        "Check out the typed branch or revision",
4115    );
4116    reg(
4117        "action:magit-global-branch-create-no-checkout",
4118        "Create a branch without checking it out",
4119    );
4120    reg(
4121        "action:magit-branch-create-no-checkout-finish",
4122        "Create the new branch (from the picked base) without checking it out",
4123    );
4124    reg("action:magit-global-branch-rename", "Rename a branch");
4125    reg(
4126        "action:magit-branch-rename-finish",
4127        "Rename the picked branch to the typed name",
4128    );
4129    reg(
4130        "action:magit-global-branch-delete",
4131        "Delete a branch — asks first",
4132    );
4133
4134    // MG.23k: `D` — re-run the current view with different git
4135    // arguments. ONE action for both flag tables: the schema is their
4136    // union (the names are disjoint), and the argv is built from the
4137    // VIEW's own table, so a diff buffer can never be handed a log
4138    // flag. `project_transient_state` matches by name, so a slot the
4139    // open menu did not offer simply stays unset.
4140    registry.register_action(
4141        "action:magit-view-refresh-args",
4142        "Re-run the current magit view with the chosen git arguments",
4143        ActionSpec {
4144            apply: none.clone().unwrap(),
4145            args_schema: magit_diff_mode::DIFF_ARGS
4146                .iter()
4147                .chain(magit_log_mode::LOG_ARGS.iter())
4148                .map(|f| {
4149                    let kind = match f.kind {
4150                        magit_global_mode::RemoteArgKind::Flag => lattice_grammar::ArgKind::Bool,
4151                        _ => lattice_grammar::ArgKind::String,
4152                    };
4153                    lattice_grammar::ArgSpec::optional(f.name, kind, f.doc)
4154                })
4155                .collect(),
4156        },
4157    );
4158
4159    // MG.43h: the dispatch `d` / `l` rows became argument menus, so
4160    // their OPEN actions must declare a schema for the toggles to
4161    // project onto. MG.41f called this blocked and inferred an
4162    // operation change; the projection was already generic and only
4163    // the empty schema was missing.
4164    //
4165    // The schema is the UNION, as `-refresh-args` uses, because
4166    // `view_argv` resolves each flag by its position there. Declaring
4167    // only a view's OWN table works for diff by coincidence (it is
4168    // first) and breaks log silently: `slot_of` would return an index
4169    // past the end of log's own argument list, so every log toggle
4170    // would be collected and then read as unset.
4171    //
4172    // A diff still cannot be handed a log flag — that comes from
4173    // `view_argv(DIFF_ARGS, ..)` iterating only DIFF_ARGS.
4174    for (name, doc) in [
4175        (
4176            "action:magit-global-diff",
4177            "Open the diff view, with the chosen git arguments",
4178        ),
4179        (
4180            "action:magit-global-log",
4181            "Open the log view, with the chosen git arguments",
4182        ),
4183    ] {
4184        registry.register_action(
4185            name,
4186            doc,
4187            ActionSpec {
4188                apply: none.clone().unwrap(),
4189                args_schema: magit_core_mode::VIEW_ARG_TABLES
4190                    .iter()
4191                    .flat_map(|t| t.iter())
4192                    .map(|f| {
4193                        let kind = match f.kind {
4194                            magit_global_mode::RemoteArgKind::Flag => {
4195                                lattice_grammar::ArgKind::Bool
4196                            }
4197                            _ => lattice_grammar::ArgKind::String,
4198                        };
4199                        lattice_grammar::ArgSpec::optional(f.name, kind, f.doc)
4200                    })
4201                    .collect(),
4202            },
4203        );
4204    }
4205
4206    // MG.23a: the six file-dispatch actions gain an optional `file`
4207    // argument, re-registered here (rather than at their `reg` above)
4208    // because `reg` holds `registry` for its own lifetime and two
4209    // closures cannot both borrow it.
4210    //
4211    // `C-c f` leaves the argument unset and the action falls back to the
4212    // visited file — the one deliberate deviation from magit, which
4213    // prompts. `:magit-other-file-dispatch` sets it, which is how a
4214    // stand-alone invocation names a file it is not visiting.
4215    //
4216    // The name must match the transient `Argument`'s name: the host maps
4217    // transient state onto the schema BY NAME
4218    // (`project_transient_state`), so a mismatch degrades silently to
4219    // "always the current file" rather than failing.
4220    // IX.2: the execute half of every destructive pair declares the
4221    // slots its ask half carries, so the confirm dialog's state
4222    // projects onto them by name.
4223    for (name, doc, slots) in CONFIRM_TARGET_ACTIONS {
4224        registry.register_action(
4225            name,
4226            doc,
4227            ActionSpec {
4228                apply: none.clone().unwrap(),
4229                args_schema: slots
4230                    .iter()
4231                    .map(|(slot, slot_doc)| {
4232                        ArgSpec::optional(*slot, lattice_grammar::ArgKind::String, *slot_doc)
4233                    })
4234                    .collect(),
4235            },
4236        );
4237    }
4238
4239    for (name, doc) in FILE_TARGET_ACTIONS {
4240        registry.register_action(
4241            name,
4242            doc,
4243            ActionSpec {
4244                apply: none.clone().unwrap(),
4245                args_schema: vec![ArgSpec::optional(
4246                    "file",
4247                    lattice_grammar::ArgKind::String,
4248                    "Repo-relative path to act on; the visited file when unset",
4249                )],
4250            },
4251        );
4252    }
4253}
4254
4255#[cfg(test)]
4256mod tests {
4257    use super::*;
4258
4259    /// Collect every leaf item in `spec` — RECURSING through
4260    /// submenus — that did NOT resolve to a real `Action`.
4261    /// `action_or_placeholder` silently downgrades an unresolved id
4262    /// to an inert `Flag` (see its doc comment), which from the
4263    /// user's side looks EXACTLY like "pressing the key does
4264    /// nothing": no error, no effect, transient stays open.
4265    /// Recursion matters because the root dispatch's `c` (commit)
4266    /// and `z` (stash) items are submenus whose own leaves would
4267    /// otherwise go unchecked.
4268    ///
4269    /// Returns findings rather than panicking so the vacuity test
4270    /// below can assert the walker actually FINDS inert items on a
4271    /// deliberately-unresolved spec (`TransientSpec` holds a
4272    /// `Box<dyn Fn>` preview, so it isn't `UnwindSafe` and can't be
4273    /// probed via `catch_unwind`).
4274    ///
4275    /// **MG.17a:** `Flag` stopped being a reliable inert-marker when
4276    /// real flags landed (`--force-with-lease`, `--prune`, …). A Flag
4277    /// whose name appears in some `RemoteOp::flags` table is a genuine
4278    /// toggle; anything else is still the placeholder fallback. That
4279    /// keeps the guard precise without hand-listing exceptions — adding
4280    /// a flag to a `RemoteOp` makes it legitimate automatically, and a
4281    /// placeholder can never match because placeholders are named after
4282    /// the action they failed to resolve.
4283    fn declared_flag_names() -> std::collections::HashSet<&'static str> {
4284        use magit_global_mode::RemoteOp;
4285        [
4286            RemoteOp::PULL,
4287            RemoteOp::PUSH,
4288            RemoteOp::FETCH,
4289            RemoteOp::STASH,
4290        ]
4291        .iter()
4292        .flat_map(|op| op.flags.iter().map(|f| f.name))
4293        .chain(
4294            // MG.43h: NOT a loosening. The diff / log open actions now
4295            // declare these names, which is what makes the toggles
4296            // consumed rather than discarded — the condition MG.41f
4297            // found missing. The test below pins that premise so this
4298            // list cannot go stale into vacuity.
4299            magit_core_mode::VIEW_ARG_TABLES
4300                .iter()
4301                .flat_map(|t| t.iter().map(|f| f.name)),
4302        )
4303        .collect()
4304    }
4305
4306    fn inert_items(spec: &lattice_picker::TransientSpec, path: &str) -> Vec<String> {
4307        let mut found = Vec::new();
4308        for group in &spec.groups {
4309            for item in &group.items {
4310                let where_ = format!("{path}{} / {}", group.label, item.label);
4311                match &item.kind {
4312                    lattice_picker::TransientItemKind::Action { .. } => {}
4313                    lattice_picker::TransientItemKind::Submenu(sub) => {
4314                        found.extend(inert_items(sub, &format!("{where_} > ")));
4315                    }
4316                    lattice_picker::TransientItemKind::Flag { name, .. } => {
4317                        if !declared_flag_names().contains(name.as_str()) {
4318                            found.push(format!(
4319                                "'{where_}' fell back to an inert Flag placeholder \
4320                                 named '{name}' — its action id failed to resolve"
4321                            ));
4322                        }
4323                    }
4324                    // MG.17b: `Argument` became a real item kind. Same
4325                    // rule as `Flag` — legitimate when the name is
4326                    // declared in a `RemoteOp` table, suspect otherwise.
4327                    lattice_picker::TransientItemKind::Argument { name, .. } => {
4328                        if !declared_flag_names().contains(name.as_str()) {
4329                            found.push(format!(
4330                                "'{where_}' is an Argument named '{name}' that no \
4331                                 RemoteOp declares — nothing will consume its value"
4332                            ));
4333                        }
4334                    }
4335                    // MG.43g: a `Variable` carries a resolved
4336                    // `CommandId`, so it is a real leaf — the inert
4337                    // case is the `Flag` placeholder `variable_item`
4338                    // falls back to, which the arm above already
4339                    // catches. What IS worth catching here is a row
4340                    // naming no config key: it would render `" = …"`
4341                    // and report nothing.
4342                    lattice_picker::TransientItemKind::Variable { key, .. } => {
4343                        if key.is_empty() {
4344                            found.push(format!(
4345                                "'{where_}' is a Variable with no config key —                                  it would render a value it cannot read"
4346                            ));
4347                        }
4348                    }
4349                    other => found.push(format!("unexpected item kind for '{where_}': {other:?}")),
4350                }
4351            }
4352        }
4353        found
4354    }
4355
4356    /// MG.23h: `C-c g` pressed in an ordinary file buffer.
4357    /// The repository the suite is running in — what these tests probed
4358    /// implicitly before MR.4 made the workdir explicit.
4359    fn probe_here() -> std::path::PathBuf {
4360        workdir::magit_workdir().unwrap_or_default()
4361    }
4362
4363    fn outside_magit() -> lattice_picker::TransientContext {
4364        lattice_picker::TransientContext::default()
4365    }
4366
4367    /// `C-c g` pressed in the status buffer — both predicates true.
4368    fn in_magit_status() -> lattice_picker::TransientContext {
4369        lattice_picker::TransientContext {
4370            major_mode: Some(MagitStatusMode::mode_id().as_str().to_string()),
4371            minor_modes: vec![MagitCoreMode::mode_id().as_str().to_string()],
4372            buffer: None,
4373            args: Default::default(),
4374        }
4375    }
4376
4377    /// `C-c g` pressed in a magit buffer that is NOT the status buffer
4378    /// — the family predicate true, the exact-major one false.
4379    fn in_magit_log() -> lattice_picker::TransientContext {
4380        lattice_picker::TransientContext {
4381            major_mode: Some(MagitLogMode::mode_id().as_str().to_string()),
4382            minor_modes: vec![MagitCoreMode::mode_id().as_str().to_string()],
4383            buffer: None,
4384            args: Default::default(),
4385        }
4386    }
4387
4388    /// Every key at the top level of `spec`, in order.
4389    fn top_level_keys(spec: &lattice_picker::TransientSpec) -> Vec<String> {
4390        spec.groups
4391            .iter()
4392            .flat_map(|g| &g.items)
4393            .flat_map(|i| i.key.clone())
4394            .collect()
4395    }
4396
4397    fn assert_no_inert_items(spec: &lattice_picker::TransientSpec) {
4398        let found = inert_items(spec, "");
4399        assert!(
4400            found.is_empty(),
4401            "inert transient items:\n  {}",
4402            found.join("\n  ")
4403        );
4404    }
4405
4406    /// MG.13 guard for the shared-action collision class.
4407    ///
4408    /// `Mode::action_handlers()` contributions are registered at boot
4409    /// into a map keyed by `CommandId` — `register` *inserts*, so two
4410    /// modes contributing the same `action_name` means the second
4411    /// silently replaces the first and one of them is dead. Worse,
4412    /// dropping either registration unregisters *by action id*, taking
4413    /// the survivor with it.
4414    ///
4415    /// `action:magit-refresh` (`gr`) is the live example: five modes
4416    /// bound it. It is now registered once by `magit-core-mode` and
4417    /// dispatched per-buffer through `buffer_state::MagitView`. This
4418    /// test fails if any future mode re-adds a duplicate contribution
4419    /// rather than publishing a view.
4420    #[test]
4421    fn no_two_modes_contribute_the_same_boot_action_handler() {
4422        use lattice_mode::Mode;
4423        let mut seen: Vec<(&'static str, &'static str)> = Vec::new();
4424        let mut collisions: Vec<String> = Vec::new();
4425
4426        macro_rules! collect {
4427            ($mode:expr, $label:literal) => {
4428                for c in $mode.action_handlers() {
4429                    if let Some((prior, _)) = seen.iter().find(|(n, _)| *n == c.action_name) {
4430                        let _ = prior;
4431                        let owner = seen
4432                            .iter()
4433                            .find(|(n, _)| *n == c.action_name)
4434                            .map(|(_, o)| *o)
4435                            .unwrap_or("?");
4436                        collisions.push(format!(
4437                            "`{}` contributed by both `{}` and `{}` — the second \
4438                             replaces the first at boot and dropping either kills \
4439                             both; publish a `MagitView` instead",
4440                            c.action_name, owner, $label
4441                        ));
4442                    } else {
4443                        seen.push((c.action_name, $label));
4444                    }
4445                }
4446            };
4447        }
4448
4449        collect!(MagitGlobalMode, "magit-global-mode");
4450        collect!(MagitCoreMode, "magit-core-mode");
4451        collect!(MagitStatusMode, "magit-status-mode");
4452        collect!(MagitCommitMode, "magit-commit-mode");
4453        collect!(MagitDiffMode, "magit-diff-mode");
4454        collect!(MagitLogMode, "magit-log-mode");
4455        collect!(MagitBlameMode, "magit-blame-mode");
4456        collect!(MagitStashMode, "magit-stash-mode");
4457        collect!(MagitBranchMode, "magit-branch-mode");
4458        collect!(MagitRemoteMode, "magit-remote-mode");
4459        collect!(MagitRefsMode, "magit-refs-mode");
4460        collect!(MagitNotesMode, "magit-notes-mode");
4461        collect!(MagitCherryMode, "magit-cherry-mode");
4462        collect!(MagitSubmoduleMode, "magit-submodule-mode");
4463        collect!(MagitRebaseMode, "magit-rebase-mode");
4464        collect!(MagitRevisionMode, "magit-revision-mode");
4465        collect!(MagitFileRevisionMode, "magit-file-revision-mode");
4466
4467        assert!(
4468            collisions.is_empty(),
4469            "duplicate boot action handlers:\n  {}",
4470            collisions.join("\n  ")
4471        );
4472    }
4473
4474    /// IX.2 — every destructive pair's execute half declares the slots
4475    /// its ask half carries.
4476    ///
4477    /// The projection is **by name**, so an ask that carries `"branch"`
4478    /// against an execute declaring `"ref"` does not fail — the value
4479    /// lands nowhere and the handler silently falls back to re-deriving
4480    /// from the cursor, which is precisely the bug IX.1 removed. This
4481    /// pins the two halves to one table.
4482    #[test]
4483    fn every_destructive_execute_declares_the_slots_its_confirm_carries() {
4484        let mut registry = CommandRegistry::new();
4485        register_action_commands(&mut registry);
4486        for (name, _, slots) in CONFIRM_TARGET_ACTIONS {
4487            let spec = registry
4488                .lookup_by_name(name)
4489                .unwrap_or_else(|| panic!("`{name}` is registered"));
4490            let declared: Vec<&str> = spec.args_schema.iter().map(|a| a.name.as_ref()).collect();
4491            let expected: Vec<&str> = slots.iter().map(|(s, _)| *s).collect();
4492            assert_eq!(
4493                declared, expected,
4494                "`{name}`'s schema must match the slots its confirm carries, \
4495                 in order — the host projects positionally into these names"
4496            );
4497        }
4498    }
4499
4500    /// MG.42-E1: **every ex-command a commit-picker is opened WITH is
4501    /// actually registered.**
4502    ///
4503    /// `COMMIT_PICK_SOURCE` builds `"<arg> <sha>"` and invokes it as a
4504    /// command id. The arg is a plain string, so nothing catches a
4505    /// typo or a rename at compile time — and the failure mode is the
4506    /// worst kind: the picker opens, lists commits, the user chooses
4507    /// one, and *nothing happens*. It reads as a broken commit rather
4508    /// than a missing command.
4509    ///
4510    /// This asserts the other direction from
4511    /// `each_op_declares_its_own_ex_command`: that one pins the names
4512    /// the ops carry, this one pins that those names resolve.
4513    #[test]
4514    fn every_commit_picker_arg_names_a_registered_ex_command() {
4515        let mut registry = CommandRegistry::new();
4516        register_ex_commands(
4517            &mut registry,
4518            Default::default(),
4519            crate::repo_scope::test_support::empty_store(),
4520            Default::default(),
4521        );
4522
4523        // Every ex-command any handler passes as the commit picker's
4524        // single arg. Kept explicit rather than scraped: a scrape that
4525        // found nothing would pass vacuously.
4526        let picker_args = [
4527            magit_global_mode::CommitOp::CHERRY_PICK.ex_command,
4528            magit_global_mode::CommitOp::REVERT.ex_command,
4529            magit_global_mode::CommitOp::RESET_SOFT.ex_command,
4530            magit_global_mode::CommitOp::RESET_MIXED.ex_command,
4531            magit_global_mode::CommitOp::RESET_HARD.ex_command,
4532            magit_global_mode::CommitOp::RESET_KEEP.ex_command,
4533            magit_global_mode::CommitOp::RESET_INDEX.ex_command,
4534            magit_global_mode::CommitOp::COMMIT_FIXUP.ex_command,
4535            magit_global_mode::CommitOp::COMMIT_SQUASH.ex_command,
4536            // MG.42-E1: augment's fallback, which is a bare string in
4537            // both its action handler and its own ex-command.
4538            "magit-augment",
4539            // MG.43c: the rebase todo rows' fallbacks.
4540            "magit-rebase-edit-commit",
4541            "magit-rebase-remove-commit",
4542            "magit-rebase-reword-commit",
4543        ];
4544
4545        for name in picker_args {
4546            assert!(
4547                registry.lookup_by_name(name).is_some(),
4548                "`{name}` is opened as a commit-picker arg but is not a                  registered ex-command — picking a commit would do nothing"
4549            );
4550        }
4551    }
4552
4553    /// MG.43h: **the premise behind whitelisting the view flags.**
4554    ///
4555    /// `declared_flag_names` treats a diff/log flag as consumed. That
4556    /// holds only because each open action declares a schema those
4557    /// names project onto — remove it and they go back to being
4558    /// silently discarded, the bug MG.41f caught and reverted for.
4559    ///
4560    /// The schema must be the UNION in union order, because
4561    /// `view_argv` resolves each flag by its position there.
4562    #[test]
4563    fn the_view_open_actions_declare_the_union_their_menus_project_onto() {
4564        let mut registry = CommandRegistry::new();
4565        register_action_commands(&mut registry);
4566        let union: Vec<&str> = magit_core_mode::VIEW_ARG_TABLES
4567            .iter()
4568            .flat_map(|t| t.iter().map(|f| f.name))
4569            .collect();
4570        for action in ["action:magit-global-diff", "action:magit-global-log"] {
4571            let id = registry
4572                .id_by_name(action)
4573                .unwrap_or_else(|| panic!("`{action}` is registered"));
4574            let spec = registry.lookup(id).expect("spec");
4575            let declared: Vec<&str> = spec.args_schema.iter().map(|a| a.name.as_ref()).collect();
4576            assert_eq!(
4577                declared, union,
4578                "`{action}` must declare the union, in union order — \
4579                 `view_argv` indexes by position in it",
4580            );
4581        }
4582    }
4583
4584    /// MG.43h: **a log toggle actually reaches the log argv.**
4585    ///
4586    /// The end-to-end check the schema assertion cannot make alone. An
4587    /// own-table schema would put `count` at index 0 while `view_argv`
4588    /// looks for it at its union position, past the end of the list —
4589    /// so every log toggle would read as unset. Total, and silent.
4590    #[test]
4591    fn a_log_toggle_survives_the_round_trip_to_argv() {
4592        use lattice_grammar::{ArgValue, Args};
4593        let union: Vec<&str> = magit_core_mode::VIEW_ARG_TABLES
4594            .iter()
4595            .flat_map(|t| t.iter().map(|f| f.name))
4596            .collect();
4597        let slot = union
4598            .iter()
4599            .position(|n| *n == "count")
4600            .expect("the log table offers `-n`");
4601
4602        // The positional list the projection produces for the union.
4603        let mut list = vec![ArgValue::Bool(false); union.len()];
4604        list[slot] = ArgValue::String("5".to_string());
4605        let args = Args::List(list);
4606
4607        let argv = magit_core_mode::view_argv(magit_log_mode::LOG_ARGS, &args);
4608        assert!(
4609            argv.iter().any(|a| a == "5"),
4610            "the log's `-n 5` must reach the argv, got {argv:?}",
4611        );
4612        // The diff view, handed the same args, emits nothing: the flag
4613        // belongs to the other table.
4614        let diff_argv = magit_core_mode::view_argv(magit_diff_mode::DIFF_ARGS, &args);
4615        assert!(
4616            !diff_argv.iter().any(|a| a == "5"),
4617            "a log flag must not reach a diff argv: {diff_argv:?}",
4618        );
4619    }
4620
4621    /// Every execute half in the destructive table is one, and every
4622    /// destructive pair that *can* carry a target does.
4623    ///
4624    /// `magit-rebase-abort-execute` is the deliberate exception: there
4625    /// is exactly one in-progress rebase, so it has no target to name.
4626    #[test]
4627    fn every_destructive_pair_carries_a_target_except_the_one_with_none() {
4628        let mut registry = CommandRegistry::new();
4629        register_action_commands(&mut registry);
4630        for (_, execute) in confirm::DESTRUCTIVE_ACTIONS {
4631            if *execute == "action:magit-rebase-abort-execute" {
4632                continue;
4633            }
4634            // Checked against the registry rather than one table: an
4635            // execute half may declare its slots via
4636            // `CONFIRM_TARGET_ACTIONS` or, for the `C-c f` family, via
4637            // `FILE_TARGET_ACTIONS`. What matters is that it declares
4638            // somewhere to *receive* a target — an empty schema means
4639            // the carried value has nowhere to land and the handler
4640            // silently re-derives.
4641            let spec = registry
4642                .lookup_by_name(execute)
4643                .unwrap_or_else(|| panic!("`{execute}` is registered"));
4644            assert!(
4645                !spec.args_schema.is_empty(),
4646                "`{execute}` is destructive but declares no argument slot — a \
4647                 carried target would have nowhere to land, so it would \
4648                 re-derive at answer time, and a refresh landing while the \
4649                 dialog is open makes that a different target"
4650            );
4651        }
4652    }
4653
4654    // ── MG.23h: the menu varies with where it was opened ──
4655
4656    /// The section-acting rows appear in any magit buffer and nowhere
4657    /// else — the `:if-derived magit-mode` half.
4658    ///
4659    /// They resolve the hunk under the cursor, so outside a magit
4660    /// buffer there is no diff text for them to find one in and the row
4661    /// would be a key that explains why it did nothing. Both directions
4662    /// are asserted: a gate that never opens and a gate that never
4663    /// closes both pass a one-sided test.
4664    #[test]
4665    fn the_section_acting_rows_appear_only_inside_a_magit_buffer() {
4666        let mut registry = CommandRegistry::new();
4667        register_action_commands(&mut registry);
4668        let ids = transients::MagitActionIds::resolve(&registry);
4669
4670        for ctx in [in_magit_status(), in_magit_log()] {
4671            let keys = top_level_keys(&transients::dispatch_transient(&ids, &ctx, &probe_here()));
4672            for k in ["a", "-", "x"] {
4673                assert!(
4674                    keys.contains(&k.to_string()),
4675                    "`{k}` must be offered in a magit buffer: {keys:?}"
4676                );
4677            }
4678        }
4679
4680        let keys = top_level_keys(&transients::dispatch_transient(
4681            &ids,
4682            &outside_magit(),
4683            &probe_here(),
4684        ));
4685        for k in ["a", "-", "x"] {
4686            assert!(
4687                !keys.contains(&k.to_string()),
4688                "`{k}` acts on the hunk at cursor — it must not appear \
4689                 outside a magit buffer: {keys:?}"
4690            );
4691        }
4692        // ...while the repo-wide pair is there in every context, which
4693        // is where we are deliberately more permissive than magit.
4694        for ctx in [in_magit_status(), in_magit_log(), outside_magit()] {
4695            let keys = top_level_keys(&transients::dispatch_transient(&ids, &ctx, &probe_here()));
4696            assert!(keys.contains(&"S".to_string()) && keys.contains(&"U".to_string()));
4697        }
4698    }
4699
4700    /// MG.21d: `M` opens remote management, in every context.
4701    ///
4702    /// It reads nothing from the cursor — the buffer it opens lists the
4703    /// remotes itself — so unlike the section-acting rows above it must
4704    /// NOT be gated on being inside a magit buffer. And it must be a
4705    /// real `Action`: an `M` that fell back to a `Flag` would look
4706    /// present and do nothing, which is the failure the no-inert-rows
4707    /// policy exists to stop.
4708    #[test]
4709    fn remote_management_is_offered_everywhere_and_is_not_an_inert_row() {
4710        use lattice_picker::TransientItemKind;
4711
4712        let mut registry = CommandRegistry::new();
4713        register_action_commands(&mut registry);
4714        let ids = transients::MagitActionIds::resolve(&registry);
4715
4716        for ctx in [in_magit_status(), in_magit_log(), outside_magit()] {
4717            let item = transients::dispatch_transient(&ids, &ctx, &probe_here())
4718                .groups
4719                .iter()
4720                .flat_map(|g| &g.items)
4721                .find(|i| i.key.iter().any(|k| k == "M"))
4722                .cloned()
4723                .expect("the dispatch offers `M` in every context");
4724            assert!(
4725                matches!(item.kind, TransientItemKind::Action { .. }),
4726                "`M` resolved to {:?}, not a real action",
4727                item.label
4728            );
4729        }
4730    }
4731
4732    /// MG.23k: the union schema and the tables it is built from must
4733    /// stay in lockstep.
4734    ///
4735    /// The action receives a POSITIONAL list, so a slot that shifts
4736    /// means a toggle lands in a neighbour's slot and the wrong git
4737    /// flag runs — silently, with a diff that looks merely surprising.
4738    #[test]
4739    fn the_view_argument_schema_matches_the_tables_it_is_built_from() {
4740        let mut registry = CommandRegistry::new();
4741        register_action_commands(&mut registry);
4742        let spec = registry
4743            .lookup_by_name("action:magit-view-refresh-args")
4744            .expect("registered");
4745        let declared: Vec<&str> = spec.args_schema.iter().map(|a| a.name.as_ref()).collect();
4746        let expected: Vec<&str> = magit_core_mode::VIEW_ARG_TABLES
4747            .iter()
4748            .flat_map(|t| t.iter())
4749            .map(|f| f.name)
4750            .collect();
4751        assert_eq!(declared, expected);
4752    }
4753
4754    /// Flag names must be unique across the tables, or `view_argv`'s
4755    /// position lookup resolves the wrong slot.
4756    #[test]
4757    fn no_two_view_arguments_share_a_name() {
4758        let mut seen = std::collections::HashSet::new();
4759        for f in magit_core_mode::VIEW_ARG_TABLES
4760            .iter()
4761            .flat_map(|t| t.iter())
4762        {
4763            assert!(
4764                seen.insert(f.name),
4765                "`{}` appears in more than one view-argument table — \
4766                 `view_argv` resolves slots by name",
4767                f.name
4768            );
4769        }
4770    }
4771
4772    /// A view only ever gets its OWN arguments, even though the action
4773    /// carries the union of both tables.
4774    #[test]
4775    fn a_view_never_receives_the_other_views_arguments() {
4776        use lattice_grammar::{ArgValue, Args};
4777        // Every slot set: diff's three, then log's three.
4778        let all_set = Args::List(vec![
4779            ArgValue::Bool(true),              // ignore-space
4780            ArgValue::Bool(true),              // stat
4781            ArgValue::String("3".into()),      // unified
4782            ArgValue::Bool(true),              // all
4783            ArgValue::String("200".into()),    // count
4784            ArgValue::String("dhruva".into()), // author
4785        ]);
4786
4787        let diff = magit_core_mode::view_argv(magit_diff_mode::DIFF_ARGS, &all_set);
4788        assert_eq!(diff, vec!["-w", "--stat", "--unified=3"]);
4789        assert!(
4790            !diff.iter().any(|a| a.contains("author") || a == "--all"),
4791            "a diff must not receive log arguments: {diff:?}"
4792        );
4793
4794        let log = magit_core_mode::view_argv(magit_log_mode::LOG_ARGS, &all_set);
4795        assert_eq!(log, vec!["--all", "-n", "200", "--author", "dhruva"]);
4796        assert!(
4797            !log.iter().any(|a| a == "-w" || a.starts_with("--unified")),
4798            "a log must not receive diff arguments: {log:?}"
4799        );
4800    }
4801
4802    /// The joined form is not cosmetic: `git diff -U 3` and
4803    /// `--unified 3` are both errors, so the value has to arrive glued
4804    /// to its argument as a single token.
4805    #[test]
4806    fn the_context_argument_is_one_joined_token() {
4807        use lattice_grammar::{ArgValue, Args};
4808        let args = Args::List(vec![
4809            ArgValue::Bool(false),
4810            ArgValue::Bool(false),
4811            ArgValue::String("5".into()),
4812        ]);
4813        assert_eq!(
4814            magit_core_mode::view_argv(magit_diff_mode::DIFF_ARGS, &args),
4815            vec!["--unified=5"]
4816        );
4817    }
4818
4819    /// An unset value contributes nothing — not an empty string, which
4820    /// git reads as a real (empty) argument and rejects.
4821    #[test]
4822    fn unset_view_arguments_contribute_nothing() {
4823        use lattice_grammar::{ArgValue, Args};
4824        let none = Args::List(vec![
4825            ArgValue::Bool(false),
4826            ArgValue::Bool(false),
4827            ArgValue::String(String::new()),
4828        ]);
4829        assert!(magit_core_mode::view_argv(magit_diff_mode::DIFF_ARGS, &none).is_empty());
4830        assert!(
4831            magit_core_mode::view_argv(magit_diff_mode::DIFF_ARGS, &Args::None).is_empty(),
4832            "no state at all is the same as nothing set"
4833        );
4834    }
4835
4836    /// `D`'s menu shows the arguments of the view it was opened in,
4837    /// and says so plainly where there are none — the chord is on
4838    /// `magit-core-mode`, so it fires in every magit buffer.
4839    #[test]
4840    fn the_argument_menu_follows_the_view_it_was_opened_in() {
4841        let mut registry = CommandRegistry::new();
4842        register_action_commands(&mut registry);
4843        let ids = transients::MagitActionIds::resolve(&registry);
4844
4845        let keys = |ctx: &lattice_picker::TransientContext| -> Vec<String> {
4846            transients::view_arguments_transient(&ids, ctx)
4847                .groups
4848                .iter()
4849                .flat_map(|g| &g.items)
4850                .flat_map(|i| i.key.clone())
4851                .collect()
4852        };
4853
4854        let in_diff = lattice_picker::TransientContext {
4855            major_mode: Some("magit-diff-mode".into()),
4856            minor_modes: vec!["magit-core-mode".into()],
4857            buffer: None,
4858            args: Default::default(),
4859        };
4860        let diff_keys = keys(&in_diff);
4861        for k in ["-w", "-s", "-U", "g"] {
4862            assert!(diff_keys.contains(&k.to_string()), "diff: {diff_keys:?}");
4863        }
4864        assert!(
4865            !diff_keys.contains(&"-A".to_string()),
4866            "diff: {diff_keys:?}"
4867        );
4868
4869        let in_log = lattice_picker::TransientContext {
4870            major_mode: Some("magit-log-mode".into()),
4871            minor_modes: vec!["magit-core-mode".into()],
4872            buffer: None,
4873            args: Default::default(),
4874        };
4875        let log_keys = keys(&in_log);
4876        for k in ["-a", "-n", "-A", "g"] {
4877            assert!(log_keys.contains(&k.to_string()), "log: {log_keys:?}");
4878        }
4879        assert!(!log_keys.contains(&"-w".to_string()), "log: {log_keys:?}");
4880
4881        // A view with no arguments: a menu that says so, not an empty
4882        // one and not a missing key.
4883        let elsewhere = in_magit_status();
4884        assert!(keys(&elsewhere).is_empty());
4885        assert!(
4886            transients::view_arguments_transient(&ids, &elsewhere).groups[0]
4887                .label
4888                .contains("no arguments"),
4889            "a view without arguments must say so"
4890        );
4891    }
4892
4893    /// MG.26b: two minors that can be active on the SAME buffer must
4894    /// not bind the same chord.
4895    ///
4896    /// `magit-blame-mode` annotates blob buffers, where
4897    /// `magit-core-mode` is also active. Both binding `q` — which the
4898    /// first draft did, since `q` is magit's own key for stopping a
4899    /// blame — resolves by registration order, which is not a contract
4900    /// anything should depend on. The chord guard would not have caught
4901    /// it: both chords reach a registered action and a handler.
4902    #[test]
4903    fn the_blame_minor_shares_no_chord_with_magit_core() {
4904        use lattice_mode::Mode;
4905
4906        // MG.49b: the overlap is gone again. `magit-core-mode` no longer
4907        // binds `p` — one chord (`h`) opens the dispatch instead of
4908        // seventeen chords opening seventeen menus — so blame keeps `p`
4909        // outright and there is nothing to declare.
4910        //
4911        // Worth recording why the strict form is right after all: a
4912        // later-activated minor DOES win deterministically
4913        // (`ActiveModes::minors` is activation-ordered and
4914        // `lookup_with_context` folds in that order), so an override is
4915        // expressible. But relying on it means the reader of either mode
4916        // has to know the other exists. Not overlapping is cheaper.
4917        let core: Vec<&str> = MagitCoreMode
4918            .keymap()
4919            .entries
4920            .iter()
4921            .map(|e| e.chord)
4922            .collect();
4923        for entry in MagitBlameMode.keymap().entries {
4924            assert!(
4925                !core.contains(&entry.chord),
4926                "`{}` is bound by BOTH magit-blame-mode and magit-core-mode, \
4927                 and both are active on a blob buffer",
4928                entry.chord
4929            );
4930        }
4931    }
4932
4933    /// `magit-core-mode` activates by major, so naming a *minor* in
4934    /// its allowlist is an entry that can never match — dead config
4935    /// that reads as intent.
4936    #[test]
4937    fn magit_core_activates_only_on_real_majors() {
4938        use lattice_mode::{ActivationPolicy, Mode};
4939        let ActivationPolicy::Majors(majors) = MagitCoreMode.activation_policy() else {
4940            panic!("magit-core-mode activates by major");
4941        };
4942        assert!(
4943            !majors.contains(&MagitBlameMode::mode_id()),
4944            "magit-blame-mode is a minor — it can never be an active MAJOR, so \
4945             this entry never matches"
4946        );
4947        assert_eq!(MagitBlameMode.kind(), lattice_mode::ModeKind::Minor);
4948    }
4949
4950    /// MG.28: `v` on the file dispatch, and `:magit-find-file`, are the
4951    /// direct ways into `magit-file-revision-mode`.
4952    ///
4953    /// The mode has existed since MG.11 with no direct entry point —
4954    /// reachable only by `<CR>` inside a revision view and `gj`/`gk`
4955    /// from there — so "show me this file at that revision" had no
4956    /// answer. Both must resolve, or the row is inert and the command
4957    /// is missing.
4958    #[test]
4959    fn a_file_at_a_revision_is_reachable_directly() {
4960        use lattice_picker::TransientItemKind;
4961
4962        let mut actions = CommandRegistry::new();
4963        register_action_commands(&mut actions);
4964        let ids = transients::MagitActionIds::resolve(&actions);
4965        let row = transients::file_dispatch_transient(&ids)
4966            .groups
4967            .iter()
4968            .flat_map(|g| &g.items)
4969            .find(|i| i.key.iter().any(|k| k == "v"))
4970            .cloned()
4971            .expect("`C-c f v` must exist");
4972        assert!(
4973            matches!(row.kind, TransientItemKind::Action { .. }),
4974            "`v` resolved to {:?}, not a real action",
4975            row.label
4976        );
4977
4978        let mut ex = CommandRegistry::new();
4979        register_ex_commands(
4980            &mut ex,
4981            Default::default(),
4982            crate::repo_scope::test_support::empty_store(),
4983            Default::default(),
4984        );
4985        let id = ex
4986            .id_by_name("magit-find-file")
4987            .expect("`:magit-find-file` must exist");
4988        assert!(
4989            ex.ex_command_spec(id).is_some(),
4990            "`:magit-find-file` must be an EX command"
4991        );
4992    }
4993
4994    /// MG.28: `V` is the way back out. `gj` / `gk` walk a blob's
4995    /// history and nothing walked back to the working-tree copy — you
4996    /// had to type `:e <path>` for a path you were already looking at.
4997    ///
4998    /// `v` and `V` must be distinct rows: one goes in, the other comes
4999    /// out, and a single key doing both by context would be the
5000    /// mislabelled-chord problem `]f` had.
5001    #[test]
5002    fn the_way_into_a_revision_and_the_way_back_are_separate_rows() {
5003        use lattice_picker::TransientItemKind;
5004
5005        let mut actions = CommandRegistry::new();
5006        register_action_commands(&mut actions);
5007        let ids = transients::MagitActionIds::resolve(&actions);
5008        let spec = transients::file_dispatch_transient(&ids);
5009
5010        let row = |key: &str| {
5011            spec.groups
5012                .iter()
5013                .flat_map(|g| &g.items)
5014                .find(|i| i.key.iter().any(|k| k == key))
5015                .cloned()
5016                .unwrap_or_else(|| panic!("`C-c f {key}` must exist"))
5017        };
5018        for key in ["v", "V"] {
5019            assert!(
5020                matches!(row(key).kind, TransientItemKind::Action { .. }),
5021                "`{key}` must be a real action"
5022            );
5023        }
5024        assert_ne!(
5025            transients::MagitActionIds::resolve(&actions)
5026                .get("action:magit-global-file-at-revision"),
5027            transients::MagitActionIds::resolve(&actions)
5028                .get("action:magit-global-file-visit-live"),
5029            "in and out are different actions, not one key guessing"
5030        );
5031    }
5032
5033    /// MG.29: `b` is a submenu, and the list it used to open directly
5034    /// is still reachable inside it.
5035    ///
5036    /// The regression this guards is a real one to make: moving `b` to
5037    /// a submenu and forgetting to carry the list row would silently
5038    /// remove the only way to the branch buffer from the menu.
5039    #[test]
5040    fn the_branch_submenu_keeps_the_list_it_replaced() {
5041        use lattice_picker::{TransientItemKind, TransientSpec};
5042
5043        let mut registry = CommandRegistry::new();
5044        register_action_commands(&mut registry);
5045        let ids = transients::MagitActionIds::resolve(&registry);
5046
5047        let item = transients::dispatch_transient(&ids, &outside_magit(), &probe_here())
5048            .groups
5049            .iter()
5050            .flat_map(|g| &g.items)
5051            .find(|i| i.key.iter().any(|k| k == "b"))
5052            .cloned()
5053            .expect("`b` must exist on the dispatch");
5054        let TransientItemKind::Submenu(spec) = &item.kind else {
5055            panic!("`b` must open a submenu, got {:?}", item.label);
5056        };
5057        let spec: &TransientSpec = spec;
5058        let rows: Vec<(String, bool)> = spec
5059            .groups
5060            .iter()
5061            .flat_map(|g| &g.items)
5062            .flat_map(|i| {
5063                let real = matches!(i.kind, TransientItemKind::Action { .. });
5064                i.key.iter().map(move |k| (k.clone(), real))
5065            })
5066            .collect();
5067
5068        // MG.32: the full set magit's own branch transient shows, minus
5069        // the still-deferred `s` / `S` / `C`.
5070        //
5071        // MG.41a moved delete from `x` to magit's own `k`. Inside a
5072        // transient the menu owns every keystroke, so there is no vim
5073        // grammar to dodge and no reason to diverge — and the old `x`
5074        // put DELETE where a magit user reaches for reset.
5075        //
5076        // MG.43a landed reset on the `x` that move freed up, so the
5077        // reservation below became a binding: `x` is reset, `k` is
5078        // delete, and the two must not swap back. That pair is the
5079        // whole reason the keys moved — a user reaching for magit's
5080        // reset must never hit delete.
5081        for key in ["b", "l", "c", "n", "m", "k", "L", "x"] {
5082            let (_, real) = rows
5083                .iter()
5084                .find(|(k, _)| k == key)
5085                .unwrap_or_else(|| panic!("`b {key}` must exist: {rows:?}"));
5086            assert!(real, "`b {key}` must be a real action");
5087        }
5088        let key_for = |action: &str| {
5089            spec.groups
5090                .iter()
5091                .flat_map(|g| &g.items)
5092                .find(|i| {
5093                    matches!(&i.kind, TransientItemKind::Action { command, .. }
5094                        if Some(*command) == registry.id_by_name(action))
5095                })
5096                .and_then(|i| i.key.first().cloned())
5097        };
5098        assert_eq!(
5099            key_for("action:magit-global-branch-reset").as_deref(),
5100            Some("x"),
5101            "reset must be on magit's `x`: {rows:?}"
5102        );
5103        assert_eq!(
5104            key_for("action:magit-global-branch-delete").as_deref(),
5105            Some("k"),
5106            "delete must be on magit's `k`, NOT the `x` reset now owns: {rows:?}"
5107        );
5108        assert_eq!(
5109            transients::MagitActionIds::resolve(&registry).get("action:magit-global-branch"),
5110            registry.id_by_name("action:magit-global-branch"),
5111            "`b L` fires the SAME action `b` used to — the list did not \
5112             disappear when MG.32 moved it off `l`"
5113        );
5114    }
5115
5116    /// MG.32: the two keys MG.29 got wrong, pinned so they cannot drift
5117    /// back.
5118    ///
5119    /// Both were found by inventorying magit's own `magit-branch`
5120    /// transient (with `evil-collection-magit-popup-changes` applied) —
5121    /// the step MG.29 skipped:
5122    ///
5123    /// - **`l` is checkout-local-branch in magit**, so the list buffer
5124    ///   (a lattice concept magit has no row for) had squatted on an
5125    ///   occupied key. The list moved to `L`.
5126    /// - **`b` is branch/*revision* in magit** — it takes a tag, a
5127    ///   remote ref or a raw SHA. MG.29's `b` offered a list of local
5128    ///   branches, which cannot express any of those; that row *was*
5129    ///   magit's `l`, and is now bound as such.
5130    ///
5131    /// A test on the mapping rather than on mere presence, because both
5132    /// bugs were "the row exists, under the wrong letter" — the
5133    /// presence check above passed throughout.
5134    #[test]
5135    fn the_branch_submenu_keys_mean_what_magit_means_by_them() {
5136        use lattice_picker::{TransientItemKind, TransientSpec};
5137
5138        let mut registry = CommandRegistry::new();
5139        register_action_commands(&mut registry);
5140        let ids = transients::MagitActionIds::resolve(&registry);
5141
5142        let item = transients::dispatch_transient(&ids, &outside_magit(), &probe_here())
5143            .groups
5144            .iter()
5145            .flat_map(|g| &g.items)
5146            .find(|i| i.key.iter().any(|k| k == "b"))
5147            .cloned()
5148            .expect("`b` must exist on the dispatch");
5149        let TransientItemKind::Submenu(spec) = &item.kind else {
5150            panic!("`b` must open a submenu");
5151        };
5152        let spec: &TransientSpec = spec;
5153
5154        let action_for = |key: &str| -> Option<lattice_grammar::CommandId> {
5155            spec.groups
5156                .iter()
5157                .flat_map(|g| &g.items)
5158                .find(|i| i.key.iter().any(|k| k == key))
5159                .and_then(|i| match i.kind {
5160                    TransientItemKind::Action { command, .. } => Some(command),
5161                    _ => None,
5162                })
5163        };
5164
5165        // Both sides of every comparison below are `Option`, so
5166        // `None == None` would pass vacuously — an unregistered action
5167        // and an absent row would agree with each other. Pin that these
5168        // resolve before comparing them.
5169        for key in ["b", "l", "L"] {
5170            assert!(
5171                action_for(key).is_some(),
5172                "`b {key}` must resolve to a real action, or the assertions \
5173                 below compare None to None and prove nothing"
5174            );
5175        }
5176
5177        assert_eq!(
5178            action_for("l"),
5179            registry.id_by_name("action:magit-global-branch-checkout"),
5180            "`l` is magit's checkout-LOCAL-branch, and that is exactly the \
5181             picker MG.29 had built — it only sat on the wrong key"
5182        );
5183        assert_eq!(
5184            action_for("b"),
5185            registry.id_by_name("action:magit-global-branch-checkout-rev"),
5186            "`b` is magit's branch/REVISION: it must reach the prompt that \
5187             accepts a tag / remote ref / SHA, not the local-branch list"
5188        );
5189        assert_eq!(
5190            action_for("L"),
5191            registry.id_by_name("action:magit-global-branch"),
5192            "the list buffer has no magit counterpart, so it takes `L` — \
5193             capital-as-variant, and a key magit's transient leaves free"
5194        );
5195        assert_ne!(
5196            action_for("b"),
5197            action_for("l"),
5198            "branch/revision and local-branch are different operations; one \
5199             of them pointing at the other is the MG.29 bug returning"
5200        );
5201    }
5202
5203    /// MG.32: the keys magit uses for the four deferred rows stay FREE.
5204    ///
5205    /// The no-inert-rows policy says a row appears only once its
5206    /// operation exists — but MG.23's policy #1 also says a row landing
5207    /// later must land where muscle memory expects. Both hold only if
5208    /// nothing else claims `s` / `S` / `C` / `X` in the meantime, which
5209    /// is the kind of thing a later slice does without noticing.
5210    #[test]
5211    fn the_deferred_branch_rows_keep_their_magit_keys_free() {
5212        use lattice_picker::{TransientItemKind, TransientSpec};
5213
5214        let mut registry = CommandRegistry::new();
5215        register_action_commands(&mut registry);
5216        let ids = transients::MagitActionIds::resolve(&registry);
5217
5218        let item = transients::dispatch_transient(&ids, &outside_magit(), &probe_here())
5219            .groups
5220            .iter()
5221            .flat_map(|g| &g.items)
5222            .find(|i| i.key.iter().any(|k| k == "b"))
5223            .cloned()
5224            .expect("`b` must exist on the dispatch");
5225        let TransientItemKind::Submenu(spec) = &item.kind else {
5226            panic!("`b` must open a submenu");
5227        };
5228        let spec: &TransientSpec = spec;
5229        let taken: Vec<String> = spec
5230            .groups
5231            .iter()
5232            .flat_map(|g| &g.items)
5233            .flat_map(|i| i.key.iter().cloned())
5234            .collect();
5235
5236        // MG.43d: nothing is deferred here any more. `s` / `S`
5237        // landed in the slots that were being held for them, as `C`
5238        // and `X` did before — so this asserts what OCCUPIES the keys
5239        // rather than that they are free. A reservation that is never
5240        // converted is how a placeholder test quietly stops testing.
5241        let key_of = |action: &str| {
5242            spec.groups
5243                .iter()
5244                .flat_map(|g| &g.items)
5245                .find(|i| {
5246                    matches!(&i.kind, TransientItemKind::Action { command, .. }
5247                        if Some(*command) == registry.id_by_name(action))
5248                })
5249                .and_then(|i| i.key.first().cloned())
5250        };
5251        assert_eq!(
5252            key_of("action:magit-global-branch-spinoff").as_deref(),
5253            Some("s"),
5254            "spin-off must be on magit's `s`: {taken:?}"
5255        );
5256        assert_eq!(
5257            key_of("action:magit-global-branch-spinout").as_deref(),
5258            Some("S"),
5259            "spin-out must be on magit's `S`: {taken:?}"
5260        );
5261
5262        // MG.43g: `C` is now the configure row, and it must be a
5263        // `Variable` — an ordinary action there would be a `C` that
5264        // does not report the current value, which is the whole reason
5265        // magit puts a variable row in that slot.
5266        let configure = spec
5267            .groups
5268            .iter()
5269            .flat_map(|g| &g.items)
5270            .find(|i| i.key.iter().any(|k| k == "C"))
5271            .expect("`C` must be the configure row");
5272        assert!(
5273            matches!(configure.kind, TransientItemKind::Variable { .. }),
5274            "`C` must report its value inline, got {:?}",
5275            configure.kind
5276        );
5277    }
5278
5279    /// Every submenu tells the user `Esc` goes back, now that it does.
5280    /// A footer still saying only `BS` would be documenting behaviour
5281    /// the editor no longer has.
5282    #[test]
5283    fn submenu_footers_offer_esc_as_back() {
5284        use lattice_picker::{TransientItemKind, TransientSpec};
5285
5286        let mut registry = CommandRegistry::new();
5287        register_action_commands(&mut registry);
5288        let ids = transients::MagitActionIds::resolve(&registry);
5289        let root = transients::dispatch_transient(&ids, &outside_magit(), &probe_here());
5290
5291        let mut checked = 0;
5292        for item in root.groups.iter().flat_map(|g| &g.items) {
5293            if let TransientItemKind::Submenu(spec) = &item.kind {
5294                let spec: &TransientSpec = spec;
5295                let footer = spec.footer.clone().unwrap_or_default();
5296                assert!(
5297                    footer.contains("Esc"),
5298                    "submenu {:?} does not offer Esc as back: {footer:?}",
5299                    spec.title
5300                );
5301                checked += 1;
5302            }
5303        }
5304        assert!(checked >= 4, "expected several submenus, checked {checked}");
5305    }
5306
5307    /// MG.21i: `o` opens the submodule list, in every context.
5308    ///
5309    /// Same claim as `M`'s, for the same reason — the buffer lists the
5310    /// submodules itself, so there is nothing to read from a cursor
5311    /// and nothing to gate on.
5312    #[test]
5313    fn submodule_management_is_offered_everywhere_and_is_not_an_inert_row() {
5314        use lattice_picker::TransientItemKind;
5315
5316        let mut registry = CommandRegistry::new();
5317        register_action_commands(&mut registry);
5318        let ids = transients::MagitActionIds::resolve(&registry);
5319
5320        for ctx in [in_magit_status(), in_magit_log(), outside_magit()] {
5321            let item = transients::dispatch_transient(&ids, &ctx, &probe_here())
5322                .groups
5323                .iter()
5324                .flat_map(|g| &g.items)
5325                .find(|i| i.key.iter().any(|k| k == "o"))
5326                .cloned()
5327                .expect("the dispatch offers `o` in every context");
5328            assert!(
5329                matches!(item.kind, TransientItemKind::Action { .. }),
5330                "`o` resolved to {:?}, not a real action",
5331                item.label
5332            );
5333        }
5334    }
5335
5336    /// Both list buffers are reachable two ways, and the two must name
5337    /// the same mode — a drift is silent, since the buffer would open
5338    /// with no mode and every chord on it would be inert.
5339    #[test]
5340    fn the_submodule_buffer_is_reachable_by_ex_command_and_by_action() {
5341        let mut registry = CommandRegistry::new();
5342        register_ex_commands(
5343            &mut registry,
5344            Default::default(),
5345            crate::repo_scope::test_support::empty_store(),
5346            Default::default(),
5347        );
5348        let id = registry
5349            .id_by_name("magit-submodule")
5350            .expect("`:magit-submodule` must exist");
5351        assert!(
5352            registry.ex_command_spec(id).is_some(),
5353            "`:magit-submodule` must be an EX command, not an action of the same name"
5354        );
5355
5356        let mut actions = CommandRegistry::new();
5357        register_action_commands(&mut actions);
5358        assert!(
5359            actions
5360                .id_by_name("action:magit-global-submodule")
5361                .is_some(),
5362            "`o` fires `action:magit-global-submodule` — it must be registered"
5363        );
5364        assert_eq!(
5365            MagitSubmoduleMode::mode_id().as_str(),
5366            "magit-submodule-mode",
5367            "the mode id both open paths hardcode"
5368        );
5369    }
5370
5371    /// MG.21g: the bisect menu shows the operations that can actually
5372    /// run, and only those.
5373    ///
5374    /// Outside a bisect, `good` / `bad` / `skip` / `reset` are not
5375    /// merely useless — git errors on them, so they would be rows that
5376    /// look actionable and produce a log line. `start` during a bisect
5377    /// is the same in reverse. Both directions are asserted: a gate
5378    /// that never opens and a gate that never closes both pass a
5379    /// one-sided test.
5380    #[test]
5381    fn the_bisect_menu_offers_start_or_the_marks_but_never_both() {
5382        use lattice_picker::{TransientItemKind, TransientSpec};
5383
5384        let mut registry = CommandRegistry::new();
5385        register_action_commands(&mut registry);
5386        let ids = transients::MagitActionIds::resolve(&registry);
5387
5388        let bisect_keys = |in_progress: bool| -> Vec<String> {
5389            let root = transients::dispatch_transient_with(
5390                &ids,
5391                &outside_magit(),
5392                &transients::DispatchGates {
5393                    workdir: Default::default(),
5394                    merge: false,
5395                    bisect: in_progress,
5396                    notes_merge: false,
5397                    am: false,
5398                    rebase: false,
5399                    cherry_pick: false,
5400                    revert: false,
5401                },
5402            );
5403            let item = root
5404                .groups
5405                .iter()
5406                .flat_map(|g| &g.items)
5407                .find(|i| i.key.iter().any(|k| k == "B"))
5408                .expect("the dispatch offers `B`");
5409            let TransientItemKind::Submenu(spec) = &item.kind else {
5410                panic!("`B` must open a submenu, got {:?}", item.label);
5411            };
5412            let spec: &TransientSpec = spec;
5413            spec.groups
5414                .iter()
5415                .flat_map(|g| &g.items)
5416                .flat_map(|i| i.key.clone())
5417                .collect()
5418        };
5419
5420        let idle = bisect_keys(false);
5421        assert_eq!(idle, vec!["B"], "idle offers only start: {idle:?}");
5422
5423        let running = bisect_keys(true);
5424        for k in ["g", "b", "k", "r"] {
5425            assert!(
5426                running.contains(&k.to_string()),
5427                "`{k}` must be offered during a bisect: {running:?}"
5428            );
5429        }
5430        assert!(
5431            !running.contains(&"B".to_string()),
5432            "start must NOT be offered during a bisect: {running:?}"
5433        );
5434    }
5435
5436    /// Every bisect row must resolve to a real action in both states —
5437    /// an inert `Flag` here would be a row that looks like it marks a
5438    /// revision and does nothing.
5439    #[test]
5440    fn every_bisect_row_resolves_to_a_real_action() {
5441        use lattice_picker::{TransientItemKind, TransientSpec};
5442
5443        let mut registry = CommandRegistry::new();
5444        register_action_commands(&mut registry);
5445        let ids = transients::MagitActionIds::resolve(&registry);
5446
5447        for in_progress in [false, true] {
5448            let root = transients::dispatch_transient_with(
5449                &ids,
5450                &outside_magit(),
5451                &transients::DispatchGates {
5452                    workdir: Default::default(),
5453                    merge: false,
5454                    bisect: in_progress,
5455                    notes_merge: false,
5456                    am: false,
5457                    rebase: false,
5458                    cherry_pick: false,
5459                    revert: false,
5460                },
5461            );
5462            let item = root
5463                .groups
5464                .iter()
5465                .flat_map(|g| &g.items)
5466                .find(|i| i.key.iter().any(|k| k == "B"))
5467                .expect("`B` row");
5468            let TransientItemKind::Submenu(spec) = &item.kind else {
5469                panic!("`B` must open a submenu");
5470            };
5471            let spec: &TransientSpec = spec;
5472            for row in spec.groups.iter().flat_map(|g| &g.items) {
5473                assert!(
5474                    matches!(row.kind, TransientItemKind::Action { .. }),
5475                    "bisect row {:?} is inert (in_progress={in_progress})",
5476                    row.label
5477                );
5478            }
5479        }
5480    }
5481
5482    /// The chord half of the same claim, from the other direction: `M`
5483    /// (remote) and `B` (bisect) are *transient* keys only. Binding
5484    /// either as a chord inside a magit buffer would shadow a vim
5485    /// motion — middle-of-screen and back-WORD — which is the same
5486    /// reasoning that keeps `V` free
5487    /// (`feedback_magit_keys_follow_evil_magit`). Magit binds both in
5488    /// its own buffers; it can, because it is not modal.
5489    #[test]
5490    fn no_magit_mode_binds_m_or_b_as_a_chord() {
5491        use lattice_mode::Mode;
5492        macro_rules! check {
5493            ($($mode:expr => $label:literal),* $(,)?) => {
5494                $(for entry in $mode.keymap().entries {
5495                    for taken in ["M", "B"] {
5496                        assert!(
5497                            entry.chord != taken,
5498                            "`{}` binds `{taken}`, shadowing the vim motion — \
5499                             put it on the dispatch transient instead", $label
5500                        );
5501                    }
5502                })*
5503            };
5504        }
5505        check!(
5506            MagitCoreMode => "magit-core-mode",
5507            MagitStatusMode => "magit-status-mode",
5508            MagitBranchMode => "magit-branch-mode",
5509            MagitRemoteMode => "magit-remote-mode",
5510            MagitRefsMode => "magit-refs-mode",
5511            MagitNotesMode => "magit-notes-mode",
5512            MagitCherryMode => "magit-cherry-mode",
5513            MagitSubmoduleMode => "magit-submodule-mode",
5514            MagitStashMode => "magit-stash-mode",
5515            MagitLogMode => "magit-log-mode",
5516            MagitDiffMode => "magit-diff-mode",
5517            MagitBlameMode => "magit-blame-mode",
5518            MagitRebaseMode => "magit-rebase-mode",
5519            MagitRevisionMode => "magit-revision-mode",
5520            MagitFileRevisionMode => "magit-file-revision-mode",
5521            magit_stash_show_mode::MagitStashShowMode => "magit-stash-show-mode",
5522            magit_hunk_mode::MagitHunkMode => "magit-hunk-mode",
5523            // PD.9: the navigation chords `magit-core-mode` used to own.
5524            magit_nav_mode::MagitNavMode => "magit-nav-mode",
5525        );
5526    }
5527
5528    /// The `s` row swaps meaning in magit-status and only there — the
5529    /// `:if-mode` half, which is a different predicate from the one
5530    /// above and would be indistinguishable from it if only the status
5531    /// buffer were tested.
5532    #[test]
5533    fn the_status_row_becomes_a_section_jump_only_in_the_status_buffer() {
5534        let mut registry = CommandRegistry::new();
5535        register_action_commands(&mut registry);
5536        let ids = transients::MagitActionIds::resolve(&registry);
5537
5538        let row = |ctx: &lattice_picker::TransientContext| {
5539            transients::dispatch_transient(&ids, ctx, &probe_here())
5540                .groups
5541                .iter()
5542                .flat_map(|g| &g.items)
5543                .find(|i| i.key.iter().any(|k| k == "s"))
5544                .map(|i| {
5545                    (
5546                        i.label.clone(),
5547                        matches!(i.kind, lattice_picker::TransientItemKind::Submenu(_)),
5548                    )
5549                })
5550                .expect("`s` is always offered")
5551        };
5552
5553        assert_eq!(
5554            row(&in_magit_status()),
5555            ("jump".to_string(), true),
5556            "in the status buffer, `s` must be the section-jump submenu \
5557             — opening the buffer you are already in is a no-op"
5558        );
5559        for ctx in [in_magit_log(), outside_magit()] {
5560            let (label, is_submenu) = row(&ctx);
5561            assert_eq!(label, "status");
5562            assert!(
5563                !is_submenu,
5564                "outside the status buffer, `s` opens it — a magit-log \
5565                 buffer has no sections to jump between"
5566            );
5567        }
5568    }
5569
5570    /// Whatever the context, no two rows at one level share a key.
5571    ///
5572    /// This is the guard the gating actually needs: the added rows land
5573    /// in an existing menu, and `-`/`x`/`a` colliding with something
5574    /// already there would make one of them unreachable with no error.
5575    #[test]
5576    fn no_context_produces_a_duplicate_key_in_the_dispatch() {
5577        let mut registry = CommandRegistry::new();
5578        register_action_commands(&mut registry);
5579        let ids = transients::MagitActionIds::resolve(&registry);
5580        for ctx in [in_magit_status(), in_magit_log(), outside_magit()] {
5581            let keys = top_level_keys(&transients::dispatch_transient(&ids, &ctx, &probe_here()));
5582            let mut seen = std::collections::HashSet::new();
5583            for k in &keys {
5584                assert!(seen.insert(k.clone()), "duplicate key `{k}` in {keys:?}");
5585            }
5586        }
5587    }
5588
5589    /// Every jump row resolves, and every section we render has one.
5590    ///
5591    /// The prefixes the handlers scan for are the same constants that
5592    /// render the headers, so this pins that the submenu covers all of
5593    /// them rather than whichever the author remembered.
5594    #[test]
5595    fn the_jump_submenu_covers_every_section_we_render() {
5596        let mut registry = CommandRegistry::new();
5597        register_action_commands(&mut registry);
5598        let ids = transients::MagitActionIds::resolve(&registry);
5599        let spec = transients::dispatch_transient(&ids, &in_magit_status(), &probe_here());
5600        let jump = spec
5601            .groups
5602            .iter()
5603            .flat_map(|g| &g.items)
5604            .find_map(|i| match &i.kind {
5605                lattice_picker::TransientItemKind::Submenu(sub) if i.label == "jump" => {
5606                    Some(std::sync::Arc::clone(sub))
5607                }
5608                _ => None,
5609            })
5610            .expect("the jump submenu");
5611        assert_eq!(
5612            jump.selectable_count(),
5613            sections::SECTION_HEADER_PREFIXES.len(),
5614            "one row per rendered section, no more and no fewer"
5615        );
5616        assert_no_inert_items(&jump);
5617    }
5618
5619    /// MG.24c — every view the docs say answers "what commit is under
5620    /// the cursor" actually overrides the method that answers it.
5621    ///
5622    /// `magit-core-mode.md` names four views for `A` / `_` / `O`. Two
5623    /// of them — the revision view and the rebase todo — never
5624    /// implemented `commit_at_cursor`, so the trait default returned
5625    /// `None` and the chords were consumed dead keys for two of the
5626    /// four documented cases. Nothing failed loudly; the doc simply
5627    /// described behaviour no code provided.
5628    ///
5629    /// Asserted structurally rather than by driving the chords: what
5630    /// went wrong was a *missing override*, and an override that exists
5631    /// but returns `None` for a given buffer is a different (and
5632    /// legitimate) thing. This catches the class that actually bit.
5633    #[test]
5634    fn every_commit_showing_view_overrides_commit_at_cursor() {
5635        use crate::buffer_state::MagitView;
5636        use lattice_protocol::position::Position;
5637
5638        // A view whose `commit_at_cursor` is the trait DEFAULT answers
5639        // `None` for every cursor. That is what the revision and rebase
5640        // views did before this slice.
5641        struct DefaultOnly;
5642        impl MagitView for DefaultOnly {
5643            fn refresh(&self) -> Option<Effect> {
5644                None
5645            }
5646        }
5647        assert!(
5648            DefaultOnly.commit_at_cursor(Position::new(0, 0)).is_none(),
5649            "the trait default must answer None — this test's premise"
5650        );
5651
5652        // The guard: the source files for the views the docs name must
5653        // each carry an override. A structural check, because
5654        // constructing these views needs a live buffer store and a
5655        // published state, which is a fixture per mode rather than a
5656        // fact about the code.
5657        for (module, file) in [
5658            ("magit-status", include_str!("actions.rs")),
5659            ("magit-log", include_str!("magit_log_mode.rs")),
5660            ("magit-revision", include_str!("magit_revision_mode.rs")),
5661            ("magit-rebase", include_str!("magit_rebase_mode.rs")),
5662        ] {
5663            assert!(
5664                file.contains("fn commit_at_cursor"),
5665                "`{module}` is named in magit-core-mode.md as a view where \
5666                 `A` / `_` / `O` act on the commit at the cursor, so its \
5667                 MagitView must override `commit_at_cursor` — without it \
5668                 the trait default answers None and the chords are dead"
5669            );
5670        }
5671    }
5672
5673    /// MG.23j — every commit op is reachable by its ex-command name,
5674    /// which is what the picker fires.
5675    ///
5676    /// The picker builds the ex line `"<ex_command> <sha>"` and hands
5677    /// it to the host, which runs it as typed. A name that no command
5678    /// answers produces a picker that lists commits, accepts one, and
5679    /// does nothing — with no error, because an unknown ex-command
5680    /// inside an accept path is not the same as one typed on the `:`
5681    /// line.
5682    #[test]
5683    fn every_commit_ops_ex_command_is_registered() {
5684        let mut registry = CommandRegistry::new();
5685        register_ex_commands(
5686            &mut registry,
5687            Default::default(),
5688            crate::repo_scope::test_support::empty_store(),
5689            Default::default(),
5690        );
5691        for op in [
5692            magit_global_mode::CommitOp::CHERRY_PICK,
5693            magit_global_mode::CommitOp::REVERT,
5694            magit_global_mode::CommitOp::RESET_SOFT,
5695            magit_global_mode::CommitOp::RESET_MIXED,
5696            magit_global_mode::CommitOp::RESET_HARD,
5697            // MG.41d: magit's remaining reset modes + the autosquash
5698            // pair. Each is data — same handler, different argv.
5699            magit_global_mode::CommitOp::RESET_KEEP,
5700            magit_global_mode::CommitOp::RESET_INDEX,
5701            magit_global_mode::CommitOp::COMMIT_FIXUP,
5702            magit_global_mode::CommitOp::COMMIT_SQUASH,
5703        ] {
5704            let id = registry.id_by_name(op.ex_command).unwrap_or_else(|| {
5705                panic!(
5706                    "`:{}` must exist — the commit picker fires it by name",
5707                    op.ex_command
5708                )
5709            });
5710            assert!(
5711                registry.ex_command_spec(id).is_some(),
5712                "`:{}` must be an EX command, not an action of the same name",
5713                op.ex_command
5714            );
5715        }
5716    }
5717
5718    /// MG.21c: `:magit-remote` exists and is an ex-command.
5719    ///
5720    /// Two independent registrations open `*magit:remote*` with
5721    /// `magit-remote-mode` — this one and `M`'s
5722    /// `action:magit-global-remote`. Both are asserted here, because a
5723    /// drift between them is silent: the buffer would open with no
5724    /// mode, so every chord on it would be inert while the buffer
5725    /// itself looked fine.
5726    #[test]
5727    fn the_remote_buffer_is_reachable_by_ex_command_and_by_action() {
5728        let mut registry = CommandRegistry::new();
5729        register_ex_commands(
5730            &mut registry,
5731            Default::default(),
5732            crate::repo_scope::test_support::empty_store(),
5733            Default::default(),
5734        );
5735        let id = registry
5736            .id_by_name("magit-remote")
5737            .expect("`:magit-remote` must exist");
5738        assert!(
5739            registry.ex_command_spec(id).is_some(),
5740            "`:magit-remote` must be an EX command, not an action of the same name"
5741        );
5742
5743        let mut actions = CommandRegistry::new();
5744        register_action_commands(&mut actions);
5745        assert!(
5746            actions.id_by_name("action:magit-global-remote").is_some(),
5747            "`M` fires `action:magit-global-remote` — it must be registered"
5748        );
5749
5750        // The mode both paths name has to be the one that is actually
5751        // installed, or the buffer opens without its keymap.
5752        assert_eq!(
5753            MagitRemoteMode::mode_id().as_str(),
5754            "magit-remote-mode",
5755            "the mode id both open paths hardcode"
5756        );
5757    }
5758
5759    /// The repo-level rows fire the SAME actions the chords fire, so a
5760    /// row cannot drift onto a second handler with its own idea of the
5761    /// confirm contract.
5762    #[test]
5763    fn the_commit_rows_reuse_the_chords_actions() {
5764        // MG.41a: this property is now STRUCTURAL. Rows name their
5765        // command directly, so a row cannot drift onto a twin handler —
5766        // there is no second place to keep in sync. What is still worth
5767        // asserting is that the tables reference the *chord* actions
5768        // (`action:magit-reset-soft`) and not invented `-global-`
5769        // variants; getting exactly that wrong is what
5770        // `every_row_action_is_registered` caught while this slice was
5771        // being written.
5772        let mut registry = CommandRegistry::new();
5773        register_action_commands(&mut registry);
5774        let ids = transients::MagitActionIds::resolve(&registry);
5775        for action in [
5776            "action:magit-cherry-pick",
5777            "action:magit-revert",
5778            "action:magit-reset-soft",
5779            "action:magit-reset-mixed",
5780            "action:magit-reset-hard",
5781        ] {
5782            assert_eq!(
5783                ids.get(action),
5784                registry.id_by_name(action),
5785                "the `{action}` row must fire that action, not a twin"
5786            );
5787            assert!(
5788                ids.get(action).is_some(),
5789                "`{action}` must stay registered — a row references it"
5790            );
5791        }
5792    }
5793
5794    /// MG.23a — every `C-c f` action declares the optional `file`
5795    /// target, and declares it under the name the transient uses.
5796    ///
5797    /// The host maps transient state onto an action's schema **by name**
5798    /// (`project_transient_state`), so a mismatch here does not fail —
5799    /// it silently degrades `:magit-other-file-dispatch` to "always the
5800    /// visited file", which looks like the feature working on the wrong
5801    /// file rather than like a bug.
5802    #[test]
5803    fn every_file_dispatch_action_takes_the_file_target_under_that_name() {
5804        let mut registry = CommandRegistry::new();
5805        register_action_commands(&mut registry);
5806        for (name, _) in FILE_TARGET_ACTIONS {
5807            let spec = registry
5808                .lookup_by_name(name)
5809                .unwrap_or_else(|| panic!("`{name}` is registered"));
5810            let schema = &spec.args_schema;
5811            assert_eq!(
5812                schema.len(),
5813                1,
5814                "`{name}` should declare exactly the file target, got {schema:?}"
5815            );
5816            assert_eq!(
5817                schema[0].name.as_ref(),
5818                "file",
5819                "`{name}`'s target arg must be named `file` — the transient \
5820                 Argument is matched by name, and a mismatch silently means \
5821                 'always the visited file'"
5822            );
5823        }
5824    }
5825
5826    /// The target argument the menu offers must be the one the actions
5827    /// read. Both halves are checked against the literal `"file"` above
5828    /// and here, so neither can be renamed alone.
5829    #[test]
5830    fn the_other_file_menu_offers_the_file_argument_the_actions_read() {
5831        let spec = transients::other_file_dispatch_transient(&Default::default());
5832        let named_file = spec.groups.iter().flat_map(|g| &g.items).any(|item| {
5833            matches!(
5834                &item.kind,
5835                lattice_picker::TransientItemKind::Argument { name, .. } if name == "file"
5836            )
5837        });
5838        assert!(
5839            named_file,
5840            "the menu must expose an `Argument` named `file`, or no row can \
5841             ever act on anything but the visited file"
5842        );
5843    }
5844
5845    /// PD.6: the project diff is reachable from the dispatch's top level,
5846    /// not only from `d` → `e`. Asserts BOTH, because the point of the
5847    /// promotion is an extra route rather than a moved one — losing the
5848    /// Diff-menu row would break the muscle memory of anyone who learned
5849    /// it there.
5850    #[test]
5851    fn the_project_diff_is_reachable_from_the_dispatch_and_the_diff_menu() {
5852        let ids = transients::MagitActionIds::default();
5853        let ctx = lattice_picker::TransientContext::default();
5854        let spec = transients::dispatch_transient(&ids, &ctx, &probe_here());
5855        let top_level = spec
5856            .groups
5857            .iter()
5858            .flat_map(|g| &g.items)
5859            .any(|i| i.key.iter().any(|k| k == "e"));
5860        assert!(
5861            top_level,
5862            "the dispatch must offer `e` at its top level; keys were {:?}",
5863            spec.groups
5864                .iter()
5865                .flat_map(|g| &g.items)
5866                .flat_map(|i| i.key.clone())
5867                .collect::<Vec<_>>()
5868        );
5869
5870        // ...and the Diff menu keeps its own row. Read through the
5871        // drift table rather than the private const, which is the same
5872        // list `menu_rows_for_drift_check` guards.
5873        let in_diff_menu = transients::all_row_tables()
5874            .iter()
5875            .filter(|(name, _)| *name == "diff/show")
5876            .flat_map(|(_, rows)| rows.iter())
5877            .any(|r| r.key == "e" && r.action == "action:magit-project-diff");
5878        assert!(
5879            in_diff_menu,
5880            "the Diff menu must keep its `e` row — the promotion adds a route"
5881        );
5882    }
5883
5884    /// The ex-command answers to the mode's own name. `magit-project-diff-mode`
5885    /// is the mode, `*magit:project-diff*` the buffer, `magit-project-diff.md`
5886    /// the design — the command was the one surface spelling it the other way
5887    /// round, which is exactly the split HD.1's rule exists to remove.
5888    #[test]
5889    fn the_ex_command_matches_the_mode_name() {
5890        let mut registry = lattice_grammar::CommandRegistry::new();
5891        crate::register_ex_commands_for_test(&mut registry);
5892        assert!(
5893            registry.id_by_name("magit-project-diff").is_some(),
5894            "`:magit-project-diff` must resolve"
5895        );
5896        assert!(
5897            registry.id_by_name("magit-diff-project").is_none(),
5898            "the old spelling must not linger — one alias per command"
5899        );
5900    }
5901
5902    /// PD.9: nothing editable may reach `magit-core-mode`.
5903    ///
5904    /// Its bare letters (`i`, `C`, `D`, `S`, `U`, `q`, `yr`) are only
5905    /// legitimate because every major it attaches to is a read-only list —
5906    /// that is the rule its own `ActivationPolicy::Majors` doc states, and
5907    /// why `magit-commit-mode` is excluded by name.
5908    ///
5909    /// `ActivationPolicy::Majors` enforces it for majors. **Nothing
5910    /// enforced it for `Mode::implies`**, which is how the editable
5911    /// project diff acquired the whole set and lost `i`. This is that
5912    /// missing half: no mode may imply `magit-core-mode` unless it is
5913    /// itself read-only.
5914    #[test]
5915    fn no_editable_mode_implies_magit_core() {
5916        use lattice_mode::Mode as _;
5917
5918        // The editable magit views, and what each must imply INSTEAD.
5919        // A new editable view added without a line here is the gap this
5920        // test exists to make loud.
5921        let project_diff = providers::project_diff::MagitProjectDiffMode.implies();
5922        assert!(
5923            !project_diff.contains(&magit_core_mode::MagitCoreMode::mode_id()),
5924            "the project diff is EDITABLE — implying `magit-core-mode` gives it \
5925             `i`, `C`, `D`, `S`, `U`, `q`, `yr` and makes the buffer untypeable"
5926        );
5927        assert!(
5928            project_diff.contains(&magit_nav_mode::MagitNavMode::mode_id()),
5929            "...but it still wants section navigation and folding"
5930        );
5931        assert!(
5932            project_diff.contains(&lattice_mode::RefreshableViewMode::mode_id()),
5933            "...and `gr`, which comes from refreshable-view-mode rather than magit-core"
5934        );
5935    }
5936
5937    /// The read-only views are unchanged: `magit-core-mode` implies the
5938    /// navigation mode, so nothing they had was taken away by the split.
5939    #[test]
5940    fn magit_core_still_supplies_navigation_to_read_only_views() {
5941        use lattice_mode::Mode as _;
5942        assert!(
5943            magit_core_mode::MagitCoreMode
5944                .implies()
5945                .contains(&magit_nav_mode::MagitNavMode::mode_id()),
5946            "read-only magit buffers must keep `]]` / `[[` / `<Tab>` / `<S-Tab>`"
5947        );
5948    }
5949
5950    /// The split must not have left a copy behind. Two modes binding the
5951    /// same chord is the duplication the standing rule forbids, and it is
5952    /// silent — both work until one is changed.
5953    #[test]
5954    fn the_navigation_chords_live_in_exactly_one_mode() {
5955        use lattice_mode::Mode as _;
5956        let core = magit_core_mode::MagitCoreMode.keymap();
5957        for chord in ["]]", "[[", "<Tab>", "<S-Tab>"] {
5958            assert!(
5959                !core.entries.iter().any(|e| e.chord == chord),
5960                "`{chord}` still bound in magit-core-mode as well as magit-nav-mode"
5961            );
5962        }
5963    }
5964
5965    /// `V` on an ordinary file buffer is a NO-OP, not an error.
5966    ///
5967    /// It used to echo "open a file-at-revision first", which told the
5968    /// user they had done something wrong when they had asked for a state
5969    /// they were already in: `V` means "take me to the live file", and
5970    /// from a live file that request is already satisfied.
5971    ///
5972    /// `Effect::None` rather than an informational echo — "you are
5973    /// already on the live file" is noise on a key whose entire job is to
5974    /// put you there.
5975    ///
5976    /// Distinguished from reverse blame, which genuinely cannot run
5977    /// outside a revision buffer because it needs a revision to resolve
5978    /// against. Refusing is right there and wrong here; the two looked
5979    /// alike, which is how this got the wrong one.
5980    #[test]
5981    fn visiting_the_live_file_from_a_live_file_does_nothing() {
5982        use lattice_mode::Mode as _;
5983        let handler = magit_global_mode::MagitGlobalMode
5984            .action_handlers()
5985            .into_iter()
5986            .find(|c| c.action_name == "action:magit-global-file-visit-live")
5987            .expect("the handler is contributed")
5988            .handler;
5989
5990        // No `BufferStoreHandle`, so the buffer resolves to no
5991        // file-at-revision name — the ordinary-file case.
5992        let services = lattice_mode::ServiceRegistry::new();
5993        let events = lattice_runtime::EventBus::new();
5994        let ctx = lattice_mode::ActionContext {
5995            buffer_id: lattice_protocol::ids::BufferId::new(1),
5996            cursor: lattice_protocol::position::Position::new(0, 0),
5997            selection: None,
5998            services: &services,
5999            events: &events,
6000            prompt_value: None,
6001            args: lattice_grammar::Args::None,
6002            buffer_locals: None,
6003        };
6004
6005        match handler(&ctx) {
6006            None | Some(lattice_grammar::Effect::None) => {}
6007            Some(lattice_grammar::Effect::Echo { level, text }) => {
6008                panic!("`V` on a live file must not report anything; got {level:?}: {text}")
6009            }
6010            Some(other) => panic!("expected a no-op; got {other:?}"),
6011        }
6012    }
6013
6014    /// MG.53.e: that argument names an existing file, so it is picked,
6015    /// not typed. Asserts the wiring rather than the intent — a
6016    /// declaration that merely *says* "repo-relative" in its prompt is
6017    /// what it was before, and it typed fine while accepting paths that
6018    /// git would reject minutes later.
6019    ///
6020    /// The source id is checked against the picker crate's constant, so
6021    /// renaming the source cannot leave this row pointing at nothing:
6022    /// an unknown source id fails at open time with an echo, which is a
6023    /// menu row that looks alive and is not.
6024    #[test]
6025    fn the_other_file_menu_picks_its_file_rather_than_prompting_for_it() {
6026        let spec = transients::other_file_dispatch_transient(&Default::default());
6027        let source = spec
6028            .groups
6029            .iter()
6030            .flat_map(|g| &g.items)
6031            .find_map(|item| match &item.kind {
6032                lattice_picker::TransientItemKind::Argument { name, source, .. }
6033                    if name == "file" =>
6034                {
6035                    Some(source.clone())
6036                }
6037                _ => None,
6038            })
6039            .expect("the `file` argument exists");
6040        let source = source.expect(
6041            "the `file` argument must be picker-backed — a free-text path for a \
6042             file that must already exist is the typo this plan exists to remove",
6043        );
6044        assert_eq!(source.id, lattice_picker::FILE_PICK_SOURCE);
6045    }
6046
6047    /// The generic counterpart, and the one that would catch a rename on
6048    /// either side: whatever source the menu names has to actually be
6049    /// registered. Checked against the real first-party generator list.
6050    #[test]
6051    fn the_file_pick_source_the_menu_names_is_registered() {
6052        let ids: Vec<String> = lattice_picker::picker_sources::first_party_generators(
6053            std::sync::Arc::new(arc_swap::ArcSwap::from_pointee(
6054                lattice_grammar::CommandRegistry::new(),
6055            )),
6056            std::sync::Arc::new(lattice_config::ConfigRegistry::new()),
6057            std::sync::Arc::new(NoKeybindings),
6058            None,
6059        )
6060        .iter()
6061        .map(|g| g.spec().id.to_string())
6062        .collect();
6063        assert!(
6064            ids.iter().any(|id| id == lattice_picker::FILE_PICK_SOURCE),
6065            "`{}` must be a registered first-party source; known: {ids:?}",
6066            lattice_picker::FILE_PICK_SOURCE
6067        );
6068    }
6069
6070    /// Minimal stand-in for the keybinding reverse lookup the commands
6071    /// source needs; this test only reads source ids.
6072    struct NoKeybindings;
6073    impl lattice_completion::KeymapReverseLookup for NoKeybindings {
6074        fn chords_for(&self, _name: &str) -> Vec<lattice_protocol::chord::KeyChord> {
6075            Vec::new()
6076        }
6077    }
6078
6079    /// IX.7 — the other-file menu's destructive row carries its target.
6080    ///
6081    /// Replaces MG.23a's "no destructive row here" guard, which existed
6082    /// because `Effect::Confirm` opened a transient of its own and lost
6083    /// the target with it: the execute half fell back to the visited
6084    /// file and acted on something the prompt never named. IX.1/IX.2
6085    /// made the confirm carry its target, so the row is safe — and this
6086    /// test is what keeps it safe, by asserting the carrying rather than
6087    /// the absence.
6088    ///
6089    /// Exercises the whole chain: an argument on the context reaches the
6090    /// ask half, which puts it in the `Confirm` the host will seed the
6091    /// dialog from.
6092    #[test]
6093    fn the_other_file_menus_discard_carries_the_file_it_names() {
6094        use lattice_mode::Mode;
6095
6096        let handler = MagitGlobalMode
6097            .action_handlers()
6098            .into_iter()
6099            .find(|c| c.action_name == "action:magit-global-file-discard")
6100            .expect("the ask half is contributed")
6101            .handler;
6102
6103        let services = lattice_mode::ServiceRegistry::new();
6104        let events = lattice_runtime::EventBus::new();
6105        let ctx = lattice_mode::ActionContext {
6106            buffer_id: lattice_protocol::ids::BufferId::new(1),
6107            cursor: lattice_protocol::position::Position::new(0, 0),
6108            selection: None,
6109            services: &services,
6110            events: &events,
6111            prompt_value: None,
6112            // What `:magit-other-file-dispatch`'s `=f` row supplies.
6113            args: lattice_grammar::Args::List(vec![lattice_grammar::ArgValue::String(
6114                "Cargo.toml".to_string(),
6115            )]),
6116            buffer_locals: None,
6117        };
6118
6119        match handler(&ctx) {
6120            Some(lattice_grammar::Effect::Confirm { prompt, args, .. }) => {
6121                assert!(
6122                    prompt.contains("Cargo.toml"),
6123                    "the prompt names the target it will act on: {prompt}"
6124                );
6125                let carried = args.as_list().expect("the target is carried");
6126                assert!(
6127                    matches!(
6128                        &carried[0],
6129                        lattice_grammar::ArgValue::String(p) if p == "Cargo.toml"
6130                    ),
6131                    "and the execute half receives that same target, rather \
6132                     than re-deriving the visited file: {carried:?}"
6133                );
6134            }
6135            other => panic!("expected a Confirm carrying its target, got {other:?}"),
6136        }
6137    }
6138
6139    /// MG.23f2 — a `BufferStore` that knows one thing: what a buffer is
6140    /// called. That is the only method reverse blame reads, and stubbing
6141    /// the rest keeps the test about the resolution rather than about
6142    /// standing up a registry.
6143    struct NamedBuffer(&'static str);
6144
6145    impl lattice_mode::BufferStore for NamedBuffer {
6146        fn find_by_name(&self, _name: &str) -> Option<lattice_core::BufferId> {
6147            None
6148        }
6149        fn handle_for(
6150            &self,
6151            _id: lattice_core::BufferId,
6152        ) -> Option<std::sync::Arc<dyn lattice_runtime::Document>> {
6153            None
6154        }
6155        fn name_for(&self, _id: lattice_core::BufferId) -> Option<String> {
6156            Some(self.0.to_string())
6157        }
6158        fn insert_document_buffer(
6159            &self,
6160            _id: lattice_core::BufferId,
6161            _kind: lattice_core::BufferKind,
6162            _handle: std::sync::Arc<dyn lattice_runtime::Document>,
6163            _flags: lattice_core::BufferFlags,
6164            _name: Option<String>,
6165        ) {
6166        }
6167    }
6168
6169    /// Fire `action:magit-global-file-blame-reverse` as if `C-c f`'s
6170    /// `f` were pressed in a buffer called `buffer_name`.
6171    fn fire_reverse_blame_in(buffer_name: &'static str) -> Option<Effect> {
6172        use lattice_mode::Mode;
6173
6174        let handler = MagitGlobalMode
6175            .action_handlers()
6176            .into_iter()
6177            .find(|c| c.action_name == "action:magit-global-file-blame-reverse")
6178            .expect("reverse blame is contributed")
6179            .handler;
6180
6181        let mut services = lattice_mode::ServiceRegistry::new();
6182        // Registered as `BufferStoreHandle`, NOT `Arc<BufferStoreHandle>`
6183        // — `register` keys on `TypeId::of::<T>()` and the handler looks
6184        // up `get::<BufferStoreHandle>()`, so the wrapped form would be
6185        // filed under a type nobody asks for and every case would come
6186        // back as the refusal (`feedback_servicesregistry_arc_typeid`).
6187        services.register(lattice_mode::BufferStoreHandle::new(std::sync::Arc::new(
6188            NamedBuffer(buffer_name),
6189        )));
6190        let events = lattice_runtime::EventBus::new();
6191        handler(&lattice_mode::ActionContext {
6192            buffer_id: lattice_protocol::ids::BufferId::new(1),
6193            cursor: lattice_protocol::position::Position::new(0, 0),
6194            selection: None,
6195            services: &services,
6196            events: &events,
6197            prompt_value: None,
6198            args: lattice_grammar::Args::None,
6199            buffer_locals: None,
6200        })
6201    }
6202
6203    /// MG.26b — reverse blame annotates the blob buffer it was run in,
6204    /// rather than opening a buffer of its own. Both halves still come
6205    /// out of that buffer's name; they now go into the request map,
6206    /// because `ToggleMode` carries only a mode name.
6207    #[test]
6208    fn reverse_blame_toggles_the_minor_on_the_blob_buffer_it_runs_in() {
6209        match fire_reverse_blame_in("*magit:file:lattice:a1b2c3d:src/main.rs*") {
6210            Some(Effect::ToggleMode { mode_name }) => {
6211                assert_eq!(mode_name, "magit-blame-mode");
6212            }
6213            other => panic!("expected the blame minor to be toggled, got {other:?}"),
6214        }
6215    }
6216
6217    /// Refusals are echoed, never silent. A handler returning `None`
6218    /// here would leave the menu row looking like a key that does
6219    /// nothing — the exact failure the no-inert-rows policy exists to
6220    /// prevent, arrived at from the other direction.
6221    ///
6222    /// `staged` is in the list deliberately: the index is not a commit,
6223    /// so there is no range to walk forward from — the same exclusion
6224    /// `gj`/`gk` make.
6225    #[test]
6226    fn reverse_blame_says_why_when_there_is_no_revision_to_walk_from() {
6227        for name in [
6228            "*magit:file:lattice:staged:src/main.rs*",
6229            "*magit:status*",
6230            "src/main.rs",
6231        ] {
6232            match fire_reverse_blame_in(name) {
6233                Some(Effect::Echo { level, text }) => {
6234                    assert_eq!(level, lattice_grammar::EchoLevel::Error);
6235                    assert!(
6236                        text.contains("revision"),
6237                        "the message must name what is missing: {text}"
6238                    );
6239                }
6240                other => panic!("expected an explained refusal in {name}, got {other:?}"),
6241            }
6242        }
6243    }
6244
6245    /// No magit chord may shadow a Visual-mode entry key.
6246    ///
6247    /// Region staging needs a selection, so `v` / `V` / `C-v` have to
6248    /// keep meaning what vim says they mean in every magit buffer. A
6249    /// mode action that binds one of them takes it *unconditionally* —
6250    /// the chord is consumed even when the action has no target, because
6251    /// a handler returning `None` counts as handled. That is how revert
6252    /// on `V` made region staging unreachable before it moved to `_`
6253    /// (evil-collection-magit's key).
6254    ///
6255    /// The failure is silent from the code's side: the binding looks
6256    /// fine, the action works on the rows it applies to, and only the
6257    /// selection gesture quietly stops existing.
6258    #[test]
6259    fn no_magit_mode_binds_a_visual_entry_key() {
6260        use lattice_mode::Mode;
6261
6262        const VISUAL_ENTRY: &[&str] = &["v", "V", "<C-v>"];
6263        let mut stolen: Vec<String> = Vec::new();
6264        macro_rules! check {
6265            ($($mode:expr => $label:literal),* $(,)?) => {
6266                $(for entry in $mode.keymap().entries {
6267                    if VISUAL_ENTRY.contains(&entry.chord)
6268                        && entry.modes.contains(&lattice_keymap::BindingMode::Normal)
6269                    {
6270                        stolen.push(format!(
6271                            "{}: binds `{}`, which is how you ENTER Visual mode — \
6272                             region staging becomes unreachable in this buffer",
6273                            $label, entry.chord
6274                        ));
6275                    }
6276                })*
6277            };
6278        }
6279        check!(
6280            // Its chords are all `ex:` (`<C-x>g`, `<C-c>g`, `<C-c>f`),
6281            // so the `action:` filter below skips every one — included
6282            // anyway so the count check covers all fourteen registered
6283            // modes rather than thirteen plus an exception.
6284            MagitGlobalMode => "magit-global-mode",
6285            MagitCoreMode => "magit-core-mode",
6286            MagitGlobalMode => "magit-global-mode",
6287            MagitStatusMode => "magit-status-mode",
6288            MagitCommitMode => "magit-commit-mode",
6289            MagitDiffMode => "magit-diff-mode",
6290            MagitLogMode => "magit-log-mode",
6291            MagitBlameMode => "magit-blame-mode",
6292            MagitStashMode => "magit-stash-mode",
6293            MagitBranchMode => "magit-branch-mode",
6294            MagitRemoteMode => "magit-remote-mode",
6295            MagitRefsMode => "magit-refs-mode",
6296            MagitNotesMode => "magit-notes-mode",
6297            MagitCherryMode => "magit-cherry-mode",
6298            MagitSubmoduleMode => "magit-submodule-mode",
6299            MagitRebaseMode => "magit-rebase-mode",
6300            MagitRevisionMode => "magit-revision-mode",
6301            MagitFileRevisionMode => "magit-file-revision-mode",
6302            magit_stash_show_mode::MagitStashShowMode => "magit-stash-show-mode",
6303        );
6304        assert!(stolen.is_empty(), "{}", stolen.join("\n"));
6305    }
6306
6307    /// MG.18e — a view that stages in Normal mode must also stage in
6308    /// Visual mode.
6309    ///
6310    /// The two halves are independent keymap rows, so dropping the
6311    /// Visual one is a silent regression: `s` over a selection would
6312    /// fall through to vim's substitute, hit the read-only gate, and
6313    /// report "buffer is read-only" — which reads as a bug in staging
6314    /// rather than a missing binding. Pinning the pairing means the next
6315    /// view that gains `s` cannot ship half of it.
6316    #[test]
6317    fn every_view_that_stages_in_normal_mode_also_stages_over_a_selection() {
6318        use lattice_mode::Mode;
6319
6320        // MG.22: `s`/`u`/`x` moved to `magit-hunk-mode`, so checking
6321        // the majors here would pass vacuously — they bind none of
6322        // them now. The pairing claim moved with the chords.
6323        for (label, keymap) in [("magit-hunk-mode", magit_hunk_mode::MagitHunkMode.keymap())] {
6324            let bound = |mode: lattice_keymap::BindingMode, chord: &str| -> Option<&'static str> {
6325                keymap
6326                    .entries
6327                    .iter()
6328                    .find(|e| e.modes.contains(&mode) && e.chord == chord)
6329                    .and_then(|e| e.command)
6330            };
6331            for chord in ["s", "u", "x"] {
6332                let Some(normal) = bound(lattice_keymap::BindingMode::Normal, chord) else {
6333                    continue; // this view does not offer the chord at all
6334                };
6335                assert_eq!(
6336                    bound(lattice_keymap::BindingMode::Visual, chord),
6337                    Some(normal),
6338                    "{label}: `{chord}` acts in Normal mode but not over a \
6339                     selection — region staging is unreachable there"
6340                );
6341            }
6342        }
6343    }
6344
6345    /// MG.15 — every chord every magit mode binds must reach a real
6346    /// handler. Three links, each of which has broken in production:
6347    ///
6348    /// 1. the keymap's `cmd:` names an action registered in the
6349    ///    command registry (an unregistered name resolves to nothing —
6350    ///    the key is silently inert, the MG.8 failure);
6351    /// 2. some mode contributes a boot handler for that action (a
6352    ///    registered command with no handler is equally inert, the
6353    ///    MG.13 failure);
6354    /// 3. and the mode binding it is the mode owning it, or reaches it
6355    ///    through `magit-core-mode` (the shared-action collision).
6356    ///
6357    /// Every prior slice bolted a bespoke test onto one of these after
6358    /// a bug shipped through it. This walks all three for every chord
6359    /// at once, so the next chord added is covered by construction.
6360    #[test]
6361    fn every_chord_every_mode_binds_reaches_a_registered_action_and_a_handler() {
6362        use lattice_mode::Mode;
6363
6364        let mut registry = CommandRegistry::new();
6365        register_action_commands(&mut registry);
6366
6367        // The union of every boot-registered handler, from every mode.
6368        let mut handled: Vec<&'static str> = Vec::new();
6369        macro_rules! handlers {
6370            ($($mode:expr),* $(,)?) => {
6371                $(for c in $mode.action_handlers() { handled.push(c.action_name); })*
6372            };
6373        }
6374        handlers!(
6375            MagitGlobalMode,
6376            MagitCoreMode,
6377            MagitStatusMode,
6378            MagitCommitMode,
6379            MagitDiffMode,
6380            MagitLogMode,
6381            MagitBlameMode,
6382            MagitStashMode,
6383            MagitBranchMode,
6384            MagitRemoteMode,
6385            MagitRefsMode,
6386            MagitNotesMode,
6387            MagitCherryMode,
6388            MagitSubmoduleMode,
6389            MagitRebaseMode,
6390            MagitRevisionMode,
6391            MagitFileRevisionMode,
6392            magit_stash_show_mode::MagitStashShowMode,
6393            magit_hunk_mode::MagitHunkMode,
6394        );
6395
6396        let mut dead: Vec<String> = Vec::new();
6397        macro_rules! check {
6398            ($($mode:expr => $label:literal),* $(,)?) => {
6399                $(for entry in $mode.keymap().entries {
6400                    // `None` = a synthetic action with no registered
6401                    // command (`PushDigit` and peers); magit binds none
6402                    // today, but skipping keeps this honest if it does.
6403                    let Some(cmd) = entry.command else { continue };
6404                    // Only `action:` chords are this test's business;
6405                    // `ex:` chords route through the ex-command table.
6406                    if !cmd.starts_with("action:") {
6407                        continue;
6408                    }
6409                    if registry.lookup_by_name(cmd).is_none() {
6410                        dead.push(format!(
6411                            "{}: chord `{}` → `{cmd}`, which is NOT a registered \
6412                             action command — the key is silently inert",
6413                            $label, entry.chord
6414                        ));
6415                    } else if !handled.contains(&cmd) {
6416                        dead.push(format!(
6417                            "{}: chord `{}` → `{cmd}`, registered but NO mode \
6418                             contributes a handler — the key does nothing",
6419                            $label, entry.chord
6420                        ));
6421                    }
6422                })*
6423            };
6424        }
6425        check!(
6426            MagitCoreMode => "magit-core-mode",
6427            MagitStatusMode => "magit-status-mode",
6428            MagitCommitMode => "magit-commit-mode",
6429            MagitDiffMode => "magit-diff-mode",
6430            MagitLogMode => "magit-log-mode",
6431            MagitBlameMode => "magit-blame-mode",
6432            MagitStashMode => "magit-stash-mode",
6433            MagitBranchMode => "magit-branch-mode",
6434            MagitRemoteMode => "magit-remote-mode",
6435            MagitRefsMode => "magit-refs-mode",
6436            MagitNotesMode => "magit-notes-mode",
6437            MagitCherryMode => "magit-cherry-mode",
6438            MagitSubmoduleMode => "magit-submodule-mode",
6439            MagitRebaseMode => "magit-rebase-mode",
6440            MagitRevisionMode => "magit-revision-mode",
6441            MagitFileRevisionMode => "magit-file-revision-mode",
6442            magit_stash_show_mode::MagitStashShowMode => "magit-stash-show-mode",
6443            magit_hunk_mode::MagitHunkMode => "magit-hunk-mode",
6444            // PD.9: the navigation chords `magit-core-mode` used to own.
6445            magit_nav_mode::MagitNavMode => "magit-nav-mode",
6446        );
6447
6448        // MG.22: the two lists above are HAND-KEPT, and a mode missing
6449        // from them is not covered — which is not hypothetical. This
6450        // slice added `magit-hunk-mode`, bound `<CR>` on it, and forgot
6451        // to register the action; the guard said nothing, because the
6452        // mode was in neither list. `<CR>` would have been inert in all
6453        // five diff buffers.
6454        //
6455        // Cross-checked against `install`'s own registrations so the
6456        // omission cannot recur silently: every `.register(` there must
6457        // appear here.
6458        let installed = include_str!("lib.rs")
6459            .lines()
6460            .filter_map(|l| l.trim().strip_prefix(".register("))
6461            .filter_map(|l| l.strip_suffix(")"))
6462            .filter(|m| m.contains("Magit"))
6463            .count();
6464        assert_eq!(
6465            installed, 20,
6466            "`install` registers {installed} magit modes but this guard \
6467             checks 20 — a mode registered at boot and absent from the \
6468             lists above has its chords unverified"
6469        );
6470
6471        assert!(
6472            dead.is_empty(),
6473            "chords that cannot reach a handler:\n  {}",
6474            dead.join("\n  ")
6475        );
6476    }
6477
6478    /// MG.17a — the two front-ends resolve a flag to the SAME `Args`.
6479    ///
6480    /// This is the claim the whole slice rests on: `:magit-push
6481    /// --force-with-lease` and toggling `-f` in the transient must
6482    /// reach `spawn_remote_op` with identical arguments, so there is
6483    /// one body and one behaviour rather than two that agree today and
6484    /// drift tomorrow.
6485    ///
6486    /// The transient half is simulated the way the host builds it —
6487    /// project the toggled state onto `arg_specs()` in order — because
6488    /// `Editor::transient_args_for` lives in `lattice-host` and can't
6489    /// be reached from here.
6490    #[test]
6491    fn the_cmdline_and_the_transient_resolve_a_flag_to_the_same_args() {
6492        use lattice_grammar::{ArgValue, Args};
6493        use magit_global_mode::RemoteOp;
6494
6495        let op = RemoteOp::PUSH;
6496
6497        // Front-end 1: the `:` line.
6498        let from_cmdline = parse_remote_flags(op, "--force-with-lease");
6499
6500        // Front-end 2: the transient, `-f` toggled on.
6501        let toggled: std::collections::HashMap<&str, bool> =
6502            [("force-with-lease", true)].into_iter().collect();
6503        let from_transient = Args::List(
6504            op.arg_specs()
6505                .iter()
6506                .map(|spec| {
6507                    ArgValue::Bool(toggled.get(spec.name.as_ref()).copied().unwrap_or(false))
6508                })
6509                .collect(),
6510        );
6511
6512        assert_eq!(from_cmdline, from_transient);
6513        assert_eq!(
6514            op.argv(&from_cmdline),
6515            vec!["push", "--force-with-lease"],
6516            "and both must produce the force-with-lease push"
6517        );
6518    }
6519
6520    /// An unknown token on the `:` line is ignored rather than failing
6521    /// the command. The flags are additive, so the worst outcome is an
6522    /// operation that does slightly less than asked — never one that
6523    /// does something unasked.
6524    #[test]
6525    fn an_unrecognised_flag_on_the_cmdline_is_ignored_not_fatal() {
6526        use lattice_grammar::{ArgValue, Args};
6527        use magit_global_mode::RemoteOp;
6528        assert_eq!(
6529            parse_remote_flags(RemoteOp::PUSH, "--frce-with-lease --set-upstream"),
6530            // One slot per flag, in table order. MG.41c appended
6531            // `--no-verify` / `--dry-run`, so the list grew — built
6532            // from the table rather than hard-coded so the next
6533            // addition does not fail this test for the wrong reason.
6534            Args::List(
6535                RemoteOp::PUSH
6536                    .flags
6537                    .iter()
6538                    .map(|f| ArgValue::Bool(f.name == "set-upstream"))
6539                    .collect()
6540            ),
6541            "the typo drops out; the flag that parsed still applies"
6542        );
6543    }
6544
6545    /// An operation with no flags keeps `Args::None`, so its handler
6546    /// sees exactly what it saw before MG.17a.
6547    ///
6548    /// MG.41c: this used `PULL`, which now carries `-r` / `-a`. The
6549    /// property is about flagless ops, not about pull, so it moves to
6550    /// one that still is — asserted rather than assumed, so the test
6551    /// cannot quietly stop testing anything if that op gains flags too.
6552    #[test]
6553    fn a_flagless_operation_parses_to_no_args() {
6554        use lattice_grammar::Args;
6555        use magit_global_mode::RemoteOp;
6556        assert!(
6557            RemoteOp::REBASE_CONTINUE.flags.is_empty(),
6558            "this test needs a genuinely flagless op",
6559        );
6560        assert_eq!(
6561            parse_remote_flags(RemoteOp::REBASE_CONTINUE, "--force"),
6562            Args::None
6563        );
6564    }
6565
6566    /// MG.16 — the remote/stash operations exist on both surfaces.
6567    ///
6568    /// They were transient-only: reachable from `C-c g` and nowhere
6569    /// else, so they could not be scripted, could not be rebound, and
6570    /// did not appear under `:magit-<Tab>`. Each ex-command must be
6571    /// registered AND resolve to the same `RemoteOp` its transient item
6572    /// fires — two front-ends, one body.
6573    #[test]
6574    fn every_remote_operation_has_both_a_transient_item_and_an_ex_command() {
6575        use lattice_mode::Mode;
6576
6577        let mut registry = CommandRegistry::new();
6578        register_ex_commands(
6579            &mut registry,
6580            Default::default(),
6581            crate::repo_scope::test_support::empty_store(),
6582            Default::default(),
6583        );
6584        register_action_commands(&mut registry);
6585        let handlers = MagitGlobalMode.action_handlers();
6586
6587        for (ex_name, action_name) in [
6588            ("magit-fetch", "action:magit-global-fetch"),
6589            ("magit-pull", "action:magit-global-pull"),
6590            ("magit-push", "action:magit-global-push"),
6591            ("magit-stash", "action:magit-global-stash-create"),
6592        ] {
6593            assert!(
6594                registry.lookup_by_name(ex_name).is_some(),
6595                "`:{ex_name}` must exist — an operation reachable only from a \
6596                 transient is invisible to `:` and unscriptable"
6597            );
6598            assert!(
6599                handlers.iter().any(|c| c.action_name == action_name),
6600                "`{action_name}` must still have its transient handler — the \
6601                 ex-command is a second front-end, not a replacement"
6602            );
6603        }
6604    }
6605
6606    /// The four `RemoteOp` constants are the single definition of what
6607    /// each operation runs. If a fifth operation is added without a
6608    /// distinct argv this catches the copy-paste.
6609    #[test]
6610    fn each_remote_op_names_a_distinct_git_invocation() {
6611        use magit_global_mode::RemoteOp;
6612        let ops = [
6613            RemoteOp::FETCH,
6614            RemoteOp::PULL,
6615            RemoteOp::PUSH,
6616            RemoteOp::STASH,
6617        ];
6618        for (i, a) in ops.iter().enumerate() {
6619            assert!(!a.args.is_empty(), "`{}` has no argv", a.what);
6620            assert!(
6621                a.what.contains(a.args[0]),
6622                "`{}` must name the operation its argv runs (`{}`)",
6623                a.what,
6624                a.args[0]
6625            );
6626            for b in &ops[i + 1..] {
6627                assert_ne!(
6628                    a.args, b.args,
6629                    "`{}` and `{}` run the same git",
6630                    a.what, b.what
6631                );
6632            }
6633        }
6634    }
6635
6636    /// `:magit-stash` (create) and `:magit-stash-list` (open the list)
6637    /// are distinct commands where one name is a strict prefix of the
6638    /// other. Both must resolve to themselves — a lookup that fell
6639    /// through to prefix matching would make `:magit-stash` open the
6640    /// list instead of stashing, which is a silent wrong action rather
6641    /// than an error.
6642    #[test]
6643    fn magit_stash_and_magit_stash_list_are_distinct_commands() {
6644        let mut registry = CommandRegistry::new();
6645        register_ex_commands(
6646            &mut registry,
6647            Default::default(),
6648            crate::repo_scope::test_support::empty_store(),
6649            Default::default(),
6650        );
6651        let create = registry
6652            .lookup_by_name("magit-stash")
6653            .expect("`:magit-stash` registered");
6654        let list = registry
6655            .lookup_by_name("magit-stash-list")
6656            .expect("`:magit-stash-list` registered");
6657        assert_eq!(create.name, "magit-stash");
6658        assert_eq!(list.name, "magit-stash-list");
6659        assert_ne!(create.id, list.id, "a prefix collision would alias the two");
6660    }
6661
6662    /// Every shared action has exactly one owner, and it is
6663    /// `magit-core-mode`. Pins the arrangement the test above protects.
6664    ///
6665    /// `gr` is bound by core itself; `s` / `u` are bound by
6666    /// `magit-status-mode` and `magit-diff-mode` — the *binding* stays
6667    /// with whichever mode offers the chord, but the *handler* must
6668    /// exist once, so it lives on core and dispatches through
6669    /// `MagitView`.
6670    #[test]
6671    fn shared_actions_are_owned_solely_by_magit_core_mode() {
6672        use lattice_mode::Mode;
6673        const SHARED: &[&str] = &[
6674            "action:magit-refresh",
6675            "action:magit-stage",
6676            "action:magit-unstage",
6677        ];
6678        for name in SHARED {
6679            assert!(
6680                MagitCoreMode
6681                    .action_handlers()
6682                    .iter()
6683                    .any(|c| c.action_name == *name),
6684                "`{name}` is reachable from more than one magit view, so \
6685                 magit-core-mode must own its single handler"
6686            );
6687        }
6688        for (label, contributions) in [
6689            ("magit-branch-mode", MagitBranchMode.action_handlers()),
6690            ("magit-remote-mode", MagitRemoteMode.action_handlers()),
6691            ("magit-refs-mode", MagitRefsMode.action_handlers()),
6692            ("magit-notes-mode", MagitNotesMode.action_handlers()),
6693            ("magit-cherry-mode", MagitCherryMode.action_handlers()),
6694            ("magit-submodule-mode", MagitSubmoduleMode.action_handlers()),
6695            ("magit-stash-mode", MagitStashMode.action_handlers()),
6696            ("magit-diff-mode", MagitDiffMode.action_handlers()),
6697            ("magit-log-mode", MagitLogMode.action_handlers()),
6698            ("magit-status-mode", MagitStatusMode.action_handlers()),
6699        ] {
6700            for c in contributions {
6701                assert!(
6702                    !SHARED.contains(&c.action_name),
6703                    "`{label}` contributes shared action `{}` — it must reach \
6704                     it through its MagitView instead",
6705                    c.action_name
6706                );
6707            }
6708        }
6709    }
6710
6711    /// Regression test for a live-reported bug: `C-c g` then `l`
6712    /// (log) / `b` (branch) did nothing. Mirrors `install()`'s exact
6713    /// `register_action_commands` → resolve-`DispatchActionIds`
6714    /// sequence — every root dispatch item, at every submenu depth,
6715    /// must resolve to a real `Action`, not `Flag`.
6716    #[test]
6717    fn every_root_dispatch_item_resolves_to_a_real_action_not_a_flag_fallback() {
6718        let mut registry = CommandRegistry::new();
6719        register_action_commands(&mut registry);
6720        // Resolved through the SAME function `install` uses, so a
6721        // field added later is covered automatically rather than
6722        // needing this test to be remembered and updated.
6723        let ids = transients::MagitActionIds::resolve(&registry);
6724        // MG.23h: BOTH shapes the menu can take. The gated rows only
6725        // exist in the magit-buffer one, so checking a single context
6726        // would leave whichever rows the other adds unverified.
6727        for ctx in [&outside_magit(), &in_magit_status()] {
6728            assert_no_inert_items(&transients::dispatch_transient(&ids, ctx, &probe_here()));
6729        }
6730    }
6731
6732    #[test]
6733    fn file_dispatch_items_resolve_to_real_actions() {
6734        let mut registry = CommandRegistry::new();
6735        register_action_commands(&mut registry);
6736        let ids = transients::MagitActionIds::resolve(&registry);
6737        let spec = transients::file_dispatch_transient(&ids);
6738        assert_no_inert_items(&spec);
6739    }
6740
6741    /// Guards against a subtler variant of the same bug: two items
6742    /// in one menu level sharing a key means the second is
6743    /// unreachable — pressing the key always fires the first. Not
6744    /// caught by the inert-Flag check (both resolve fine); it only
6745    /// shows up as "this menu entry does nothing".
6746    #[test]
6747    fn no_duplicate_keys_within_any_transient_menu_level() {
6748        fn check(spec: &lattice_picker::TransientSpec, path: &str) {
6749            let mut seen: Vec<(String, String)> = Vec::new();
6750            for group in &spec.groups {
6751                for item in &group.items {
6752                    for key in &item.key {
6753                        if let Some((_, prior)) = seen.iter().find(|(k, _)| k == key) {
6754                            panic!(
6755                                "key '{key}' in menu '{path}' is bound twice: \
6756                                 '{prior}' and '{}' — the second is unreachable",
6757                                item.label
6758                            );
6759                        }
6760                        seen.push((key.clone(), item.label.clone()));
6761                    }
6762                    if let lattice_picker::TransientItemKind::Submenu(sub) = &item.kind {
6763                        check(sub, &format!("{path}{} > ", item.label));
6764                    }
6765                }
6766            }
6767        }
6768        let mut registry = CommandRegistry::new();
6769        register_action_commands(&mut registry);
6770        check(
6771            &transients::dispatch_transient(
6772                &transients::MagitActionIds::resolve(&registry),
6773                &in_magit_status(),
6774                &probe_here(),
6775            ),
6776            "dispatch",
6777        );
6778        check(
6779            &transients::file_dispatch_transient(&transients::MagitActionIds::resolve(&registry)),
6780            "file-dispatch",
6781        );
6782    }
6783
6784    /// The inverse guard: with NO ids resolved, EVERY leaf must be
6785    /// reported inert — proving the walker actually visits leaves
6786    /// and detects the failure it claims to. A walker that silently
6787    /// visited nothing (wrong field, empty groups, no recursion)
6788    /// would pass the two tests above vacuously.
6789    #[test]
6790    fn unresolved_ids_do_produce_inert_items_so_the_guard_is_not_vacuous() {
6791        // 15 file-dispatch items: stage/unstage/discard,
6792        // diff/log/blame, MG.23f2's reverse blame, MG.23d's
6793        // untrack/rename/delete, MG.23d2's checkout, MG.28's
6794        // at-revision/visit-live, and MG.34's merged/edit-line.
6795        let file = inert_items(
6796            &transients::file_dispatch_transient(&Default::default()),
6797            "",
6798        );
6799        assert_eq!(
6800            file.len(),
6801            15,
6802            "expected every file-dispatch leaf to report inert, got: {file:?}"
6803        );
6804        // Root dispatch: 18 ACTION leaves — status, diff, log,
6805        // branch, pull, rebase directly, MG.23b's stage-all /
6806        // unstage-all, MG.23c1's tag / gitignore, MG.23c2's merge /
6807        // init, plus the commit
6808        // submenu's 2 (c/a), the stash submenu's 2 (z/l), and one each
6809        // inside the fetch and push submenus MG.17a introduced to hold
6810        // their flags. Recursion is
6811        // what makes the submenu leaves visible. The flag items
6812        // themselves are NOT counted — they are real toggles, not
6813        // placeholders; see `declared_flag_names`.
6814        //
6815        // This count is deliberately hardcoded: a row added without a
6816        // resolvable action id would otherwise slip in as a
6817        // permanently-inert placeholder, which is the "menu row that
6818        // does nothing" the no-inert-rows policy forbids. Bump it only
6819        // together with a real action.
6820        // MG.21g: the gate is passed in, not probed. Probing would make
6821        // this count depend on whether the developer's own checkout is
6822        // mid-bisect while the suite runs — a flake, and one that would
6823        // have looked like a real regression.
6824        let root = inert_items(
6825            &transients::dispatch_transient_with(
6826                &Default::default(),
6827                &outside_magit(),
6828                &transients::DispatchGates::default(),
6829            ),
6830            "",
6831        );
6832        assert_eq!(
6833            root.len(),
6834            // MG.41c: push/pull/fetch each replaced ONE run row with
6835            // destination rows — 7, 3 and 6 — so 46 + 6 + 2 + 5 = 59.
6836            // MG.41d: +2 reset modes, +2 commit autosquash rows.
6837            // MG.42-E1: +commit `A` augment, +merge `e` edit.
6838            // MG.43a: +commit `e`, +revert `v`, +cherry-pick `a`,
6839            // +branch `x` reset. MG.43b: +5 rebase onto-target rows;
6840            // MG.43c: +3 todo-rewriting rows; MG.43g: +6 `C` rows;
6841            // MG.43e: +merge `p`/`i`, +tag `r`/`p`;
6842            // MG.43f: +reset `w`, +fetch `m`.
6843            //
6844            // MG.43h adds none: `d` / `l` became submenus whose show
6845            // row replaces the old direct row 1:1, and their argument
6846            // toggles are declared (so not inert).
6847            // MG.43d: +4 cherry-move rows, +2 branch spin rows.
6848            // MG.49: +2 diff rows (`f` file, `v` side-by-side). Binding
6849            // `d` to the Diff menu takes both chords — the trie checks a
6850            // node's own binding before its children, so a bound `d`
6851            // makes `dv` unreachable and `d` itself was `diff-file`.
6852            // The rows are where those two keep working.
6853            // PD.3: +1 diff row (`e` edit) — the editable cross-file
6854            // project diff, a peer of `d` rather than a replacement.
6855            // MG-unmerged adds NO leaf here: the jump submenu is not
6856            // rendered in the `outside_magit()` context this guard
6857            // walks, so its `m` row never reaches this count. The
6858            // one-row-per-section invariant is pinned separately, by
6859            // the jump-submenu count test.
6860            // PD.6: +1 — the project diff promoted to the dispatch's own
6861            // top level as `e`. It is an ADDITIONAL leaf, not a moved
6862            // one: the Diff menu keeps its `e` row, so both routes stay
6863            // and both are counted.
6864            117,
6865            "expected every root-dispatch leaf (incl. both submenus') to \
6866             report inert, got: {root:?}"
6867        );
6868
6869        // The in-progress branch of the bisect menu is a different set
6870        // of rows, and an unresolved id there would be just as inert.
6871        let bisecting = inert_items(
6872            &transients::dispatch_transient_with(
6873                &Default::default(),
6874                &outside_magit(),
6875                &transients::DispatchGates {
6876                    workdir: Default::default(),
6877                    merge: false,
6878                    bisect: true,
6879                    notes_merge: false,
6880                    am: false,
6881                    rebase: false,
6882                    cherry_pick: false,
6883                    revert: false,
6884                },
6885            ),
6886            "",
6887        );
6888        assert_eq!(
6889            bisecting.len(),
6890            // MG.41c: +13 destination rows; MG.41d: +4 more;
6891            // MG.42-E1: +2 (augment, merge-edit); MG.43a: +4;
6892            // MG.43b: +5; MG.43c: +3; MG.43g: +6; MG.43e: +4;
6893            // MG.43f: +2; MG.43h: none; MG.43d: +6.
6894            // MG.49: +2, the same two Diff rows as the guard above.
6895            // PD.3: +1, the same `e` Diff row as the guard above.
6896            // MG-unmerged: no change here either, for the same reason.
6897            // PD.6: +1 — the dispatch's top-level `e` row, present in
6898            // this context too.
6899            120,
6900            "the in-progress bisect menu trades `start` for good/bad/skip/reset: {bisecting:?}"
6901        );
6902    }
6903}
6904
6905#[cfg(test)]
6906mod same_buffer_chord_tests {
6907    use lattice_mode::Mode;
6908
6909    /// The five majors `magit-hunk-mode` activates on.
6910    const HUNK_MAJORS: &[&str] = &[
6911        "magit-status-mode",
6912        "magit-diff-mode",
6913        "magit-commit-mode",
6914        "magit-revision-mode",
6915        "magit-stash-show-mode",
6916    ];
6917
6918    fn chords(m: &dyn Fn() -> Vec<&'static str>) -> Vec<&'static str> {
6919        m()
6920    }
6921
6922    /// MG.49c: **no chord may be eaten on a buffer where both binders
6923    /// are live.**
6924    ///
6925    /// Two ways that happens, and the second is the one that bit us:
6926    ///
6927    /// 1. Two live modes bind the same chord — resolution is layer
6928    ///    order, which the reader of either mode cannot see.
6929    /// 2. One live mode binds a chord that is a strict PREFIX of
6930    ///    another live mode's chord. `KeymapTrie::lookup` checks a
6931    ///    node's own binding before descending, so the short one wins
6932    ///    and the long one is unreachable — not shadowed, *dead*.
6933    ///
6934    /// (2) is why magit-status's plain `d` silently killed
6935    /// `magit-hunk-mode`'s `dv`, in that buffer and no other: elsewhere
6936    /// the node keeps vim's `d` OPERATOR, which does not terminate.
6937    /// `dv_is_bound_...` could not catch it — it reads the entry list,
6938    /// not what the merged trie resolves.
6939    #[test]
6940    fn no_magit_chord_is_unreachable_on_a_buffer_that_binds_both() {
6941        fn tokens(c: &str) -> Vec<String> {
6942            let mut out = Vec::new();
6943            let mut it = c.chars().peekable();
6944            while let Some(ch) = it.next() {
6945                if ch == '<' {
6946                    let mut t = String::from('<');
6947                    for c2 in it.by_ref() {
6948                        t.push(c2);
6949                        if c2 == '>' {
6950                            break;
6951                        }
6952                    }
6953                    out.push(t);
6954                } else {
6955                    out.push(ch.to_string());
6956                }
6957            }
6958            out
6959        }
6960
6961        // Deduped per mode: one chord bound in Normal AND Visual by the
6962        // same mode is one binding, not a collision with itself.
6963        fn uniq<'a>(
6964            entries: impl IntoIterator<Item = &'a lattice_keymap::KeymapEntry>,
6965        ) -> Vec<&'static str> {
6966            let mut v: Vec<&'static str> = Vec::new();
6967            for e in entries {
6968                if !v.contains(&e.chord) {
6969                    v.push(e.chord);
6970                }
6971            }
6972            v
6973        }
6974        let core = uniq(crate::MagitCoreMode.keymap().entries);
6975        let hunk = uniq(crate::magit_hunk_mode::MagitHunkMode.keymap().entries);
6976
6977        // Majors, paired with the minors live alongside them.
6978        let majors: Vec<(&str, Vec<&str>)> = vec![
6979            (
6980                "magit-status-mode",
6981                uniq(crate::MagitStatusMode.keymap().entries),
6982            ),
6983            (
6984                "magit-branch-mode",
6985                uniq(crate::MagitBranchMode.keymap().entries),
6986            ),
6987            (
6988                "magit-remote-mode",
6989                uniq(crate::MagitRemoteMode.keymap().entries),
6990            ),
6991            (
6992                "magit-stash-mode",
6993                uniq(crate::MagitStashMode.keymap().entries),
6994            ),
6995            (
6996                "magit-submodule-mode",
6997                uniq(crate::MagitSubmoduleMode.keymap().entries),
6998            ),
6999            (
7000                "magit-refs-mode",
7001                uniq(crate::MagitRefsMode.keymap().entries),
7002            ),
7003        ];
7004
7005        for (major, major_chords) in &majors {
7006            let mut live: Vec<(&str, &str)> = Vec::new();
7007            for c in major_chords {
7008                live.push((c, major));
7009            }
7010            for c in &core {
7011                live.push((c, "magit-core-mode"));
7012            }
7013            if HUNK_MAJORS.contains(major) {
7014                for c in &hunk {
7015                    live.push((c, "magit-hunk-mode"));
7016                }
7017            }
7018
7019            for (i, (a, owner_a)) in live.iter().enumerate() {
7020                for (b, owner_b) in live.iter().skip(i + 1) {
7021                    assert_ne!(
7022                        a, b,
7023                        "on a {major} buffer, `{a}` is bound by BOTH \
7024                         {owner_a} and {owner_b} — which wins is layer \
7025                         order, not something either mode's reader sees",
7026                    );
7027                }
7028            }
7029            for (a, owner_a) in &live {
7030                // No OPERATORS exemption here. `d` / `y` / `c` short-
7031                // circuit only as VIM's operators; the moment a magit
7032                // mode binds one as a plain action it terminates like any
7033                // other, which is precisely how magit-status's `d` killed
7034                // `dv`. Every chord in `live` is a magit binding.
7035                let ta = tokens(a);
7036                for (b, owner_b) in &live {
7037                    if a == b {
7038                        continue;
7039                    }
7040                    let tb = tokens(b);
7041                    assert!(
7042                        !(tb.len() > ta.len() && tb[..ta.len()] == ta[..]),
7043                        "on a {major} buffer, `{a}` ({owner_a}) terminates \
7044                         and is a prefix of `{b}` ({owner_b}) — the trie \
7045                         checks a node's binding before its children, so \
7046                         `{b}` is unreachable there",
7047                    );
7048                }
7049            }
7050        }
7051        let _ = chords;
7052    }
7053}