Skip to main content

lattice_magit/
magit_global_mode.rs

1//! MG.1: magit-global universal minor mode — entry-point chords.
2//!
3//! Activates on every buffer (Universal policy) so `C-x g`,
4//! `C-c g`, and `C-c f` work from any buffer kind — document,
5//! help, file tree, oil, terminal, etc.
6//!
7//! Fold audit fix (MG.8): also contributes the `action:magit-global-*`
8//! handlers the root dispatch transient's items fire. These exist
9//! precisely because `TransientItemKind::Action` dispatch resolves
10//! through `ActionHandlerRegistry::lookup` only — never through the
11//! ex-command path — so an item that should "open the log buffer
12//! from wherever the user happens to be" can't just target the
13//! `magit-log` ex-command's `CommandId`; nothing in
14//! `ActionHandlerRegistry` answers for it. Every OTHER
15//! `action:magit-*` handler in this crate is registered per-buffer
16//! from `on_activate` (only live while its owning magit buffer is
17//! the one that activated it) — these are global instead, via
18//! [`Mode::action_handlers`], because this mode's
19//! `ActivationPolicy::Universal` means they're needed everywhen,
20//! matching what a global dispatch menu needs.
21//!
22//! **Bug fix history:** an earlier version registered these from
23//! `on_activate` itself, gated by a `OnceLock` so the
24//! process-lifetime handlers were only installed once despite
25//! `Universal` re-running `on_activate` on every buffer. That was
26//! fundamentally racy: `Mode::on_activate`'s returned future runs
27//! through a "try-sync-then-spawn" cascade
28//! (`lattice_mode::registry::ModeRegistry::spawn_cascade`) shared
29//! with every OTHER mode admitted by the same activation batch — if
30//! any mode ordered earlier in that batch has real async work, the
31//! WHOLE batch (including this mode's own, otherwise-synchronous,
32//! step) defers to a background task with no guarantee it completes
33//! before the user's next keystroke. Symptom: `C-c g`/`C-c f` opened
34//! the transient fine (its `CommandId`s resolve independently, from
35//! `CommandRegistry` at `install()` time), but every item's key just
36//! dismissed the menu and did nothing — `ActionHandlerRegistry::lookup`
37//! returned `None` because the handler registration hadn't run yet,
38//! or (with a separate now-fixed bug where the guard latched on the
39//! FIRST ATTEMPT regardless of success) had permanently failed to
40//! run at all. [`Mode::action_handlers`] sidesteps the whole hazard:
41//! the host's `register_mode_action_handlers` walks every mode's
42//! contributed list in a plain synchronous `for` loop at boot,
43//! strictly after the command registry is frozen — no cascade, no
44//! `Universal`-activation timing dependency, no per-buffer state
45//! needed (these handlers close over nothing but read `ActionContext`
46//! at call time), so no on-activate registration is needed at all.
47
48use std::sync::{Arc, OnceLock};
49
50use lattice_grammar::{EchoLevel, Effect};
51use lattice_mode::{
52    ActionContext, ActionHandlerContribution, ActivationPolicy, BufferStoreHandle, CapabilitySet,
53    Keymap, KeymapEntry, LifecycleFuture, Mode, ModeContext, ModeId, ModeKind, OptionOverrideSet,
54    keymap_entry,
55};
56use lattice_vcs::Repository;
57
58pub struct MagitGlobalMode;
59
60impl MagitGlobalMode {
61    pub fn mode_id() -> ModeId {
62        ModeId::new("magit-global-mode")
63    }
64}
65
66fn magit_global_keymap_entries() -> &'static [KeymapEntry] {
67    static ENTRIES: OnceLock<Vec<KeymapEntry>> = OnceLock::new();
68    ENTRIES.get_or_init(|| {
69        vec![
70            keymap_entry! {
71                mode: Normal, chord: "<C-x>g",
72                doc: "Open magit-status for the current repo",
73               cmd: "magit-status"
74            },
75            keymap_entry! {
76                mode: Normal, chord: "<C-c>g",
77                doc: "Open magit dispatch transient (repo-level)",
78                cmd: "magit-dispatch"
79            },
80            keymap_entry! {
81                mode: Normal, chord: "<C-c>f",
82                doc: "Open magit file-dispatch transient",
83                cmd: "magit-file-dispatch"
84            },
85        ]
86    })
87}
88
89impl Mode for MagitGlobalMode {
90    type Guard = ();
91
92    fn id(&self) -> ModeId {
93        Self::mode_id()
94    }
95
96    fn kind(&self) -> ModeKind {
97        ModeKind::Minor
98    }
99
100    fn activation_policy(&self) -> ActivationPolicy {
101        // Universal: activate on every buffer kind so the entry
102        // chords work from help, file tree, oil, terminal, etc.
103        ActivationPolicy::Universal
104    }
105
106    fn options(&self) -> OptionOverrideSet {
107        OptionOverrideSet::new()
108    }
109
110    fn required_capabilities(&self) -> CapabilitySet {
111        CapabilitySet::empty()
112    }
113
114    fn keymap(&self) -> Keymap {
115        Keymap::from_entries(magit_global_keymap_entries())
116    }
117
118    /// See the module doc for why these are contributed here (a
119    /// plain, synchronous, boot-time list) rather than registered
120    /// from `on_activate`.
121    fn action_handlers(&self) -> Vec<ActionHandlerContribution> {
122        global_action_handler_contributions()
123    }
124
125    fn on_activate(&self, _ctx: ModeContext) -> LifecycleFuture<'_, Self::Guard> {
126        Box::pin(async { Ok(()) })
127    }
128}
129
130/// The `action:magit-global-*` handler contributions the root
131/// dispatch transient's items (and the branch-create wizard's
132/// prompt) fire — each just directly builds the same
133/// `Effect::OpenSyntheticBuffer` its equivalent ex-command returns
134/// (open-status/-commit/-log/-branch/-stash/-rebase), a real remote
135/// git operation (pull/push), a real file-scoped stage/diff, or the
136/// branch-create wizard's finish step. The host resolves
137/// `action_name` -> `CommandId` and performs the actual
138/// `ActionHandlerRegistry::register` call; this function only builds
139/// the declarative list. See [`Mode::action_handlers`]'s doc comment
140/// for why closing over no per-buffer state is what makes this safe.
141/// MR.2: the action-handler half of the repo-scoped trigger.
142///
143/// Reads what an action has that an ex-command does not — the service
144/// registry — and hands both halves to the one shared body. When either
145/// service is missing (a harness that registered neither) the fixed,
146/// unlabelled name is returned: the buffer still opens, on the working
147/// directory's repository, which is what magit did before MR.2.
148pub(crate) fn open_repo_view_from_action(
149    ctx: &ActionContext<'_>,
150    view: &str,
151    mode_id: &str,
152) -> Effect {
153    open_repo_view_from_action_with(ctx, view, mode_id, None)
154}
155
156/// MR.3: the same, for a view whose name also encodes a target — the
157/// commit family's `augment` / `merge-edit` / `reword-commit`.
158///
159/// These fire *inside* a magit buffer, so the repository they resolve to
160/// is that buffer's own (design §2's first question), which is what
161/// makes a `w` pressed in repo B's log compose a message for repo B.
162pub(crate) fn open_repo_view_from_action_with(
163    ctx: &ActionContext<'_>,
164    view: &str,
165    mode_id: &str,
166    rest: Option<&str>,
167) -> Effect {
168    let store = ctx.services.get::<lattice_mode::BufferStoreHandle>();
169    let scopes = ctx.services.get::<crate::repo_scope::RepoScopesHandle>();
170    match (store, scopes) {
171        (Some(store), Some(scopes)) => Effect::OpenSyntheticBuffer {
172            name: crate::repo_scope::repo_view_name_with(
173                view,
174                rest,
175                &store,
176                &scopes,
177                lattice_core::BufferId(ctx.buffer_id.raw() as u32),
178            ),
179            mode_id: mode_id.to_string(),
180            content: None,
181            cursor: None,
182            activate_minor: None,
183        },
184        // Neither service registered (a harness that wired neither):
185        // the unlabelled name, which is what magit opened before MR.2 —
186        // the buffer still appears, on the working directory's repo.
187        _ => Effect::OpenSyntheticBuffer {
188            name: match rest {
189                Some(rest) => crate::workdir::magit_buffer_name_with(view, "", rest),
190                None => crate::workdir::magit_buffer_name(view, ""),
191            },
192            mode_id: mode_id.to_string(),
193            content: None,
194            cursor: None,
195            activate_minor: None,
196        },
197    }
198}
199
200fn global_action_handler_contributions() -> Vec<ActionHandlerContribution> {
201    let mut contributions = Vec::new();
202
203    /// MR.3: a view identified by "this view, this repository" — the
204    /// chord half of `lib.rs`'s `mk`, sharing the same body.
205    macro_rules! open_repo {
206        ($action_name:expr, $view:expr, $mode_id:expr) => {
207            contributions.push(ActionHandlerContribution {
208                action_name: $action_name,
209                handler: Arc::new(|ctx: &ActionContext<'_>| {
210                    Some(open_repo_view_from_action(ctx, $view, $mode_id))
211                }),
212            });
213        };
214    }
215
216    // MR.2: `C-x g` opens the status of the repository the buffer in
217    // front of you belongs to, not the one the editor was started in.
218    // The resolution, the naming and the record all live in
219    // `repo_scope::open_repo_view`, which `:magit-status` calls too —
220    // the two surfaces have one body between them by construction, not
221    // by two closures that happen to agree today.
222    //
223    // A handler with no `BufferStoreHandle` service (a harness that did
224    // not register one) falls back to the fixed name, which is exactly
225    // the pre-MR.2 behaviour: the status buffer still opens, on the cwd
226    // repository.
227    contributions.push(ActionHandlerContribution {
228        action_name: "action:magit-global-status",
229        handler: Arc::new(|ctx: &ActionContext<'_>| {
230            Some(open_repo_view_from_action(
231                ctx,
232                "status",
233                "magit-status-mode",
234            ))
235        }),
236    });
237    // PD.3 (2026-08-12): the Diff transient's `e` row — the editable
238    // cross-file project diff. It cannot use `open!`: the view is a
239    // multibuffer, not a synthetic Document, so it routes through the
240    // generic provider-view seam instead of `OpenSyntheticBuffer`.
241    //
242    // The handler is a pure name + args hand-off; everything the view
243    // does lives in `providers::project_diff`'s registered opener. The
244    // ex-command `:magit-project-diff` returns the identical effect, so
245    // the two front-ends cannot drift apart.
246    contributions.push(ActionHandlerContribution {
247        action_name: "action:magit-project-diff",
248        handler: Arc::new(|ctx: &ActionContext<'_>| {
249            Some(Effect::AppAction(
250                lattice_grammar::app_effect::AppEffect::OpenProviderView {
251                    provider: crate::providers::project_diff::PROVIDER_NAME.to_string(),
252                    args: ctx.args.clone(),
253                },
254            ))
255        }),
256    });
257    open_repo!("action:magit-global-commit", "commit", "magit-commit-mode");
258    // MG.43h: `d` / `l` carry the argument menu's toggles into the
259    // view they open. The values are left under the buffer's name for
260    // the mode to take on activation (`ViewArgsRequests`) — the buffer
261    // does not exist yet, so there is nothing else to hold them.
262    macro_rules! open_view_with_args {
263        ($action_name:expr, $view:expr, $mode_id:expr, $flags:expr) => {
264            contributions.push(ActionHandlerContribution {
265                action_name: $action_name,
266                handler: Arc::new(|ctx: &ActionContext<'_>| {
267                    // MR.3b: the args are keyed by the buffer's name, so
268                    // the name has to be resolved FIRST — keying by the
269                    // old fixed one would leave the toggles under a name
270                    // no buffer has, and the view would open with the
271                    // menu's answers silently dropped.
272                    let effect = open_repo_view_from_action(ctx, $view, $mode_id);
273                    let extra = crate::magit_core_mode::view_argv($flags, &ctx.args);
274                    if !extra.is_empty()
275                        && let Effect::OpenSyntheticBuffer { ref name, .. } = effect
276                        && let Some(reqs) = ctx
277                            .services
278                            .get::<crate::magit_diff_mode::ViewArgsRequestsHandle>()
279                    {
280                        reqs.put(name.clone(), extra);
281                    }
282                    Some(effect)
283                }),
284            });
285        };
286    }
287    open_view_with_args!(
288        "action:magit-global-log",
289        "log",
290        "magit-log-mode",
291        crate::magit_log_mode::LOG_ARGS
292    );
293    open_repo!("action:magit-global-branch", "branch", "magit-branch-mode");
294    open_repo!("action:magit-global-stash", "stash", "magit-stash-mode");
295    // MG.21d: `M` on the root dispatch, magit's own key for remote
296    // management. It opens the remote BUFFER rather than a submenu —
297    // see `magit_remote_mode`'s header for why the list needs a
298    // surface that can show URLs.
299    open_repo!("action:magit-global-remote", "remote", "magit-remote-mode");
300    // MG.21i: `o` on the root dispatch, magit's own key.
301    open_repo!(
302        "action:magit-global-submodule",
303        "submodule",
304        "magit-submodule-mode"
305    );
306    // MG.35: `y` on the root dispatch, magit's own key.
307    open_repo!(
308        "action:magit-global-refs",
309        crate::magit_refs_mode::REFS_VIEW,
310        "magit-refs-mode"
311    );
312    open_repo!("action:magit-global-rebase", "rebase", "magit-rebase-mode");
313    open_repo!("action:magit-global-amend", "amend", "magit-commit-mode");
314    // MG.42-E1: magit's `w`. Same compose buffer, different intent —
315    // the name selects it (see `CommitIntent::from_buffer_name`).
316    open_repo!("action:magit-global-reword", "reword", "magit-commit-mode");
317    open_view_with_args!(
318        "action:magit-global-diff",
319        "diff",
320        "magit-diff-mode",
321        crate::magit_diff_mode::DIFF_ARGS
322    );
323
324    // pull/push — real git operations, run off the actor thread.
325    // `GIT_TERMINAL_PROMPT=0` makes a missing/expired credential fail
326    // fast and cleanly (git errors out immediately) instead of
327    // hanging the background task waiting for interactive input that
328    // can never arrive. Optimistic `Effect::Echo` returns
329    // synchronously; the real outcome lands via `tracing`, same as
330    // every other detached background mutation in this crate — no
331    // synchronous path exists back to the echo area from a task that
332    // outlives the handler call, so success/failure is logged rather
333    // than silently dropped (never both silent AND absent).
334    // MG.16: the body lives in [`spawn_remote_op`], NOT in this
335    // macro. The transient item and the ex-command are two front-ends
336    // over one implementation (the unified-dispatch rule) — a second
337    // copy behind `:magit-push` would be a second place for the
338    // credential handling, the echo text, and the outcome logging to
339    // drift.
340    macro_rules! remote_op {
341        ($action_name:expr, $op:expr) => {
342            contributions.push(ActionHandlerContribution {
343                action_name: $action_name,
344                handler: Arc::new(|ctx: &ActionContext<'_>| {
345                    // MG.41g: no notification handle. The op publishes
346                    // `BackgroundTaskFinished`; the notification layer
347                    // subscribes. magit does not depend on it.
348                    Some(spawn_remote_op(
349                        crate::repo_scope::action_workdir(ctx),
350                        $op,
351                        &ctx.args,
352                    ))
353                }),
354            });
355        };
356    }
357    // MG.41c: one handler per destination. The op is the same; only
358    // the target differs, which is why seven push rows cost one macro
359    // rather than seven functions.
360    macro_rules! remote_op_to {
361        ($action_name:expr, $op:expr, $target:expr) => {
362            contributions.push(ActionHandlerContribution {
363                action_name: $action_name,
364                handler: Arc::new(|ctx: &ActionContext<'_>| {
365                    // A `Prompted` target's value rides in as the
366                    // `dest` arg the transient filled in; the others
367                    // ignore it.
368                    let prompted =
369                        ctx.args
370                            .as_list()
371                            .and_then(|l| l.last())
372                            .and_then(|v| match v {
373                                lattice_grammar::ArgValue::String(s) => Some(s.clone()),
374                                _ => None,
375                            });
376                    Some(spawn_remote_op_to(
377                        crate::repo_scope::action_workdir(ctx),
378                        $op,
379                        &ctx.args,
380                        $target,
381                        prompted,
382                    ))
383                }),
384            });
385        };
386    }
387
388    // Push — magit's seven destinations.
389    remote_op_to!(
390        "action:magit-global-push-configured",
391        RemoteOp::PUSH,
392        RemoteTarget::Configured
393    );
394    remote_op_to!(
395        "action:magit-global-push-upstream",
396        RemoteOp::PUSH,
397        RemoteTarget::Upstream
398    );
399    remote_op_to!(
400        "action:magit-global-push-elsewhere",
401        RemoteOp::PUSH,
402        RemoteTarget::Prompted
403    );
404    remote_op_to!(
405        "action:magit-global-push-other-branch",
406        RemoteOp::PUSH,
407        RemoteTarget::Prompted
408    );
409    remote_op_to!(
410        "action:magit-global-push-refspecs",
411        RemoteOp::PUSH,
412        RemoteTarget::Prompted
413    );
414    remote_op_to!(
415        "action:magit-global-push-tag",
416        RemoteOp::PUSH,
417        RemoteTarget::Prompted
418    );
419    remote_op_to!(
420        "action:magit-global-push-all-tags",
421        RemoteOp::PUSH,
422        RemoteTarget::AllTags
423    );
424
425    // Pull — magit's three.
426    remote_op_to!(
427        "action:magit-global-pull-configured",
428        RemoteOp::PULL,
429        RemoteTarget::Configured
430    );
431    remote_op_to!(
432        "action:magit-global-pull-upstream",
433        RemoteOp::PULL,
434        RemoteTarget::Upstream
435    );
436    remote_op_to!(
437        "action:magit-global-pull-elsewhere",
438        RemoteOp::PULL,
439        RemoteTarget::Prompted
440    );
441
442    // Fetch — magit's six (submodules is deferred; see the slice plan).
443    remote_op_to!(
444        "action:magit-global-fetch-configured",
445        RemoteOp::FETCH,
446        RemoteTarget::Configured
447    );
448    remote_op_to!(
449        "action:magit-global-fetch-upstream",
450        RemoteOp::FETCH,
451        RemoteTarget::Upstream
452    );
453    remote_op_to!(
454        "action:magit-global-fetch-elsewhere",
455        RemoteOp::FETCH,
456        RemoteTarget::Prompted
457    );
458    remote_op_to!(
459        "action:magit-global-fetch-other-branch",
460        RemoteOp::FETCH,
461        RemoteTarget::Prompted
462    );
463    remote_op_to!(
464        "action:magit-global-fetch-refspecs",
465        RemoteOp::FETCH,
466        RemoteTarget::Prompted
467    );
468    remote_op_to!(
469        "action:magit-global-fetch-all-remotes",
470        RemoteOp::FETCH,
471        RemoteTarget::AllRemotes
472    );
473
474    remote_op!("action:magit-global-pull", RemoteOp::PULL);
475    remote_op!("action:magit-global-push", RemoteOp::PUSH);
476    // Fetch is the non-merging half of pull — magit gives it its own
477    // top-level key (`f`) precisely because "see what's upstream
478    // without touching my tree" is a distinct, frequent intent.
479    remote_op!("action:magit-global-fetch", RemoteOp::FETCH);
480    // Stash-push is local, not remote, but `run_remote_op`'s
481    // fail-fast + log-the-outcome shape fits any one-shot git
482    // invocation whose result can't come back synchronously.
483    remote_op!("action:magit-global-stash-create", RemoteOp::STASH);
484    // The merge sequence rows — the two things a stopped merge can do.
485    remote_op!(
486        "action:magit-global-merge-continue",
487        RemoteOp::MERGE_CONTINUE
488    );
489    remote_op!("action:magit-global-merge-abort", RemoteOp::MERGE_ABORT);
490    // MG.41e: the rebase sequencer rows.
491    remote_op!(
492        "action:magit-global-rebase-continue",
493        RemoteOp::REBASE_CONTINUE
494    );
495    remote_op!("action:magit-global-rebase-skip", RemoteOp::REBASE_SKIP);
496    remote_op!("action:magit-global-rebase-abort", RemoteOp::REBASE_ABORT);
497    // MG.42-E4: the sequencer controls.
498    remote_op!(
499        "action:magit-global-cherry-pick-continue",
500        RemoteOp::CHERRY_PICK_CONTINUE
501    );
502    remote_op!(
503        "action:magit-global-cherry-pick-skip",
504        RemoteOp::CHERRY_PICK_SKIP
505    );
506    remote_op!(
507        "action:magit-global-cherry-pick-abort",
508        RemoteOp::CHERRY_PICK_ABORT
509    );
510    remote_op!(
511        "action:magit-global-revert-continue",
512        RemoteOp::REVERT_CONTINUE
513    );
514    remote_op!("action:magit-global-revert-skip", RemoteOp::REVERT_SKIP);
515    remote_op!("action:magit-global-revert-abort", RemoteOp::REVERT_ABORT);
516    // MG.41d: magit's `x` / `i` stash variants — same spawner, different
517    // argv, so they cost a line each rather than a handler each.
518    remote_op!(
519        "action:magit-global-stash-keep-index",
520        RemoteOp::STASH_KEEP_INDEX
521    );
522    remote_op!("action:magit-global-stash-staged", RemoteOp::STASH_STAGED);
523    // MG.42-E2: the snapshots. No input, so they fire directly.
524    macro_rules! snapshot_op {
525        ($action_name:expr, $label:expr, $extra:expr) => {
526            contributions.push(ActionHandlerContribution {
527                action_name: $action_name,
528                handler: Arc::new(|ctx: &ActionContext<'_>| {
529                    Some(spawn_git_sequence(
530                        crate::repo_scope::action_workdir(ctx),
531                        $label,
532                        stash_snapshot_steps($extra),
533                    ))
534                }),
535            });
536        };
537    }
538    snapshot_op!(
539        "action:magit-global-stash-snapshot",
540        "snapshot all changes into a stash",
541        &[]
542    );
543    snapshot_op!(
544        "action:magit-global-stash-snapshot-index",
545        "snapshot staged changes into a stash",
546        &["--staged"]
547    );
548    snapshot_op!(
549        "action:magit-global-stash-snapshot-worktree",
550        "snapshot unstaged changes into a stash",
551        &["--keep-index"]
552    );
553    // MG.23b: the two repo-wide index rows magit puts on `S` / `U`.
554    // Both need no target — they act on the whole index — which is why
555    // they land before the commit-acting rows (`A` / `_` / `O`), whose
556    // root-dispatch entries still want a commit picker.
557    // MG.21g: bisect. Every mark checks out a different commit, so
558    // each of these refreshes EVERY live magit view rather than one —
559    // an open log or diff is just as stale as the status buffer after
560    // a `good`. See `buffer_state::refresh_all_views`.
561    //
562    // Start asks for its two ends. `HEAD` seeds the bad one because
563    // "the bug is here now" is why you are starting a bisect at all;
564    // the good end has no defensible default and is left empty.
565    contributions.push(ActionHandlerContribution {
566        action_name: "action:magit-global-bisect-start",
567        handler: Arc::new(|_ctx: &ActionContext<'_>| {
568            Some(prompt_seeded(
569                "Bisect — known BAD revision: ",
570                "action:magit-global-bisect-start-good",
571                "HEAD".to_string(),
572            ))
573        }),
574    });
575    contributions.push(ActionHandlerContribution {
576        action_name: "action:magit-global-bisect-start-good",
577        handler: Arc::new(|ctx: &ActionContext<'_>| {
578            let bad = ctx.prompt_value?.trim().to_string();
579            if bad.is_empty() {
580                return None;
581            }
582            Some(Effect::OpenPrompt {
583                prompt: format!("Bisect {bad} back to — known GOOD revision: "),
584                initial: String::new(),
585                on_submit_action: "action:magit-global-bisect-start-finish".to_string(),
586                buffer_name: Some(bisect_start_buffer_name(&bad)),
587            })
588        }),
589    });
590    contributions.push(ActionHandlerContribution {
591        action_name: "action:magit-global-bisect-start-finish",
592        handler: Arc::new(|ctx: &ActionContext<'_>| {
593            let good = ctx.prompt_value?.trim().to_string();
594            let bad = ctx
595                .services
596                .get::<BufferStoreHandle>()?
597                .name_for(lattice_core::BufferId(ctx.buffer_id.0 as u32))
598                .and_then(|n| bad_from_bisect_start_buffer_name(&n))?;
599            if good.is_empty() {
600                return None;
601            }
602            spawn_bisect(ctx, "start", move |repo| {
603                lattice_vcs::Bisect::start(repo, Some(&bad), Some(&good)).map(Some)
604            });
605            Some(Effect::Echo {
606                level: EchoLevel::Info,
607                text: "bisecting\u{2026}".to_string(),
608            })
609        }),
610    });
611
612    macro_rules! bisect_mark {
613        ($action_name:expr, $verb:literal, $call:expr) => {
614            contributions.push(ActionHandlerContribution {
615                action_name: $action_name,
616                handler: Arc::new(|ctx: &ActionContext<'_>| {
617                    // `None` = the revision git checked out for you.
618                    // Naming one would mean reading a cursor, and this
619                    // fires from a menu that has none.
620                    spawn_bisect(ctx, $verb, $call);
621                    None
622                }),
623            });
624        };
625    }
626    bisect_mark!("action:magit-global-bisect-good", "good", |repo| {
627        lattice_vcs::Bisect::good(repo, None).map(Some)
628    });
629    bisect_mark!("action:magit-global-bisect-bad", "bad", |repo| {
630        lattice_vcs::Bisect::bad(repo, None).map(Some)
631    });
632    bisect_mark!("action:magit-global-bisect-skip", "skip", |repo| {
633        lattice_vcs::Bisect::skip(repo, None).map(Some)
634    });
635    bisect_mark!("action:magit-global-bisect-reset", "reset", |repo| {
636        lattice_vcs::Bisect::reset(repo).map(|()| None)
637    });
638
639    // MG.23c1: prompt-backed rows. The first action opens the prompt;
640    // the `-finish` half does the work with what was typed.
641    contributions.push(ActionHandlerContribution {
642        action_name: "action:magit-global-tag",
643        handler: Arc::new(|_ctx: &ActionContext<'_>| {
644            Some(prompt_for("Tag name: ", "action:magit-global-tag-finish"))
645        }),
646    });
647    contributions.push(ActionHandlerContribution {
648        action_name: "action:magit-global-tag-finish",
649        handler: Arc::new(|ctx: &ActionContext<'_>| {
650            let name = ctx.prompt_value?.trim();
651            // An empty prompt is a cancel, not a request to tag HEAD
652            // with the empty string (which git would reject anyway,
653            // loudly and confusingly).
654            (!name.is_empty()).then(|| {
655                spawn_git(
656                    crate::repo_scope::action_workdir(ctx),
657                    tag_argv(name),
658                    &format!("tag HEAD as {name}"),
659                )
660            })
661        }),
662    });
663    contributions.push(ActionHandlerContribution {
664        action_name: "action:magit-global-gitignore",
665        handler: Arc::new(|_ctx: &ActionContext<'_>| {
666            Some(prompt_for(
667                "Ignore pattern: ",
668                "action:magit-global-gitignore-finish",
669            ))
670        }),
671    });
672    contributions.push(ActionHandlerContribution {
673        action_name: "action:magit-global-gitignore-finish",
674        handler: Arc::new(|ctx: &ActionContext<'_>| {
675            let pattern = ctx.prompt_value?.trim();
676            (!pattern.is_empty()).then(|| {
677                spawn_gitignore(crate::repo_scope::action_workdir(ctx), pattern.to_string())
678            })
679        }),
680    });
681    // MG.23d: file operations. Each reads `active_target`, so they act
682    // on the visited file from `C-c f` and on the named one from
683    // `:magit-other-file-dispatch`.
684    contributions.push(ActionHandlerContribution {
685        action_name: "action:magit-global-file-untrack",
686        handler: Arc::new(|ctx: &ActionContext<'_>| {
687            let (_workdir, rel) = active_target(ctx)?;
688            // No confirm: the file stays on disk and only leaves the
689            // index, which `git add` puts back. §12.13's bar is work
690            // git cannot hand back.
691            Some(spawn_git(
692                crate::repo_scope::action_workdir(ctx),
693                untrack_argv(&rel.to_string_lossy()),
694                &format!("untrack {}", rel.display()),
695            ))
696        }),
697    });
698    contributions.push(ActionHandlerContribution {
699        action_name: "action:magit-global-file-delete",
700        handler: Arc::new(|ctx: &ActionContext<'_>| {
701            let (_workdir, rel) = active_target(ctx)?;
702            // Carries the path (IX.1): the execute half acts on what
703            // this prompt names, not on wherever the cursor ends up.
704            Some(crate::confirm::ask_target(
705                format!("Delete {}?", rel.display()),
706                "action:magit-global-file-delete-execute",
707                rel.to_string_lossy().into_owned(),
708            ))
709        }),
710    });
711    contributions.push(ActionHandlerContribution {
712        action_name: "action:magit-global-file-delete-execute",
713        handler: Arc::new(|ctx: &ActionContext<'_>| {
714            let path = match crate::confirm::carried_target(ctx) {
715                Some(carried) => carried,
716                None => active_target(ctx)?.1.to_string_lossy().into_owned(),
717            };
718            Some(spawn_git(
719                crate::repo_scope::action_workdir(ctx),
720                delete_argv(&path),
721                &format!("delete {path}"),
722            ))
723        }),
724    });
725    contributions.push(ActionHandlerContribution {
726        action_name: "action:magit-global-file-rename",
727        handler: Arc::new(|ctx: &ActionContext<'_>| {
728            let (_workdir, rel) = active_target(ctx)?;
729            let from = rel.to_string_lossy().into_owned();
730            Some(Effect::OpenPrompt {
731                prompt: "Rename to: ".to_string(),
732                // Seeded with the current path so a rename within the
733                // same directory is an edit rather than a retype.
734                initial: from.clone(),
735                on_submit_action: "action:magit-global-file-rename-finish".to_string(),
736                // The source rides in the buffer name: by submit time
737                // the prompt buffer is the active one, so nothing else
738                // still knows which file this was.
739                buffer_name: Some(format!("*magit:rename:{from}*")),
740            })
741        }),
742    });
743    contributions.push(ActionHandlerContribution {
744        action_name: "action:magit-global-file-rename-finish",
745        handler: Arc::new(|ctx: &ActionContext<'_>| {
746            let to = ctx.prompt_value?.trim().to_string();
747            let buffer_id = lattice_core::BufferId(ctx.buffer_id.0 as u32);
748            let from = ctx
749                .services
750                .get::<BufferStoreHandle>()?
751                .name_for(buffer_id)
752                .and_then(|n| rename_source_from_prompt_buffer_name(&n))?;
753            // Renaming a file to its own name is what submitting the
754            // seeded value unchanged means — a cancel, not a git call
755            // that would fail with "source and destination are the
756            // same".
757            (!to.is_empty() && to != from).then(|| {
758                spawn_git(
759                    crate::repo_scope::action_workdir(ctx),
760                    rename_argv(&from, &to),
761                    &format!("rename {from} to {to}"),
762                )
763            })
764        }),
765    });
766
767    // MG.23d2: `,c` — this file as it was at some revision, written
768    // over the working-tree copy. Prompt for the revision, then confirm,
769    // because the write is over uncommitted work and git keeps no copy
770    // of what it replaced.
771    contributions.push(ActionHandlerContribution {
772        action_name: "action:magit-global-file-checkout",
773        handler: Arc::new(|ctx: &ActionContext<'_>| {
774            let (_workdir, rel) = active_target(ctx)?;
775            let path = rel.to_string_lossy().into_owned();
776            // MG.53.c/g: pick the revision — branch, tag or commit.
777            // `magit-file-checkout` is
778            // `<rev> <path>`, so the pick fills the `{}` — and that
779            // ex-command still runs the same confirm, so reaching this
780            // by picker does not skip the "discards local changes"
781            // guard.
782            Some(Effect::OpenPicker {
783                source: crate::picker_sources::REVISION_PICK_SOURCE.to_string(),
784                args: vec![format!("magit-file-checkout {{}} {path}")],
785                root: None,
786                fill_action: None,
787                query: None,
788            })
789        }),
790    });
791    contributions.push(ActionHandlerContribution {
792        action_name: "action:magit-global-file-checkout-finish",
793        handler: Arc::new(|ctx: &ActionContext<'_>| {
794            let rev = ctx.prompt_value?.trim().to_string();
795            if rev.is_empty() {
796                return None;
797            }
798            let buffer_id = lattice_core::BufferId(ctx.buffer_id.0 as u32);
799            let path = ctx
800                .services
801                .get::<BufferStoreHandle>()?
802                .name_for(buffer_id)
803                .and_then(|n| checkout_target_from_prompt_buffer_name(&n))?;
804            // Carries both halves (IX.1): by execute time the prompt
805            // buffer is gone and the confirm dialog is what is active,
806            // so neither the revision nor the path is re-derivable.
807            Some(crate::confirm::ask_with(
808                format!("Checkout {path} from {rev}, discarding its uncommitted changes?"),
809                "action:magit-global-file-checkout-execute",
810                lattice_grammar::Args::List(vec![
811                    lattice_grammar::ArgValue::String(rev),
812                    lattice_grammar::ArgValue::String(path),
813                ]),
814            ))
815        }),
816    });
817    contributions.push(ActionHandlerContribution {
818        action_name: "action:magit-global-file-checkout-execute",
819        handler: Arc::new(|ctx: &ActionContext<'_>| {
820            // No re-derivation fallback, unlike the other execute
821            // halves: there is no sensible guess for a revision, and
822            // checking out from the wrong one is the exact damage the
823            // confirm exists to prevent. Both slots or nothing.
824            let rev = ctx.arg_str(0)?;
825            let path = ctx.arg_str(1)?;
826            Some(spawn_git(
827                crate::repo_scope::action_workdir(ctx),
828                checkout_file_argv(rev, path),
829                &format!("restore {path} from {}", short_rev(rev)),
830            ))
831        }),
832    });
833
834    // MG.23c2: `I` init and `m` merge, on c1's prompt shape.
835    contributions.push(ActionHandlerContribution {
836        action_name: "action:magit-global-init",
837        handler: Arc::new(|_ctx: &ActionContext<'_>| {
838            // Seeded with the working directory: initialising *here* is
839            // the overwhelmingly common intent, and creating a `.git`
840            // in the wrong place is annoying enough to be worth showing
841            // the path before it happens rather than after.
842            let cwd = std::env::current_dir()
843                .map(|p| p.to_string_lossy().into_owned())
844                .unwrap_or_else(|_| ".".to_string());
845            Some(prompt_seeded(
846                "Initialize repository in: ",
847                "action:magit-global-init-finish",
848                cwd,
849            ))
850        }),
851    });
852    contributions.push(ActionHandlerContribution {
853        action_name: "action:magit-global-init-finish",
854        handler: Arc::new(|ctx: &ActionContext<'_>| {
855            let dir = ctx.prompt_value?.trim();
856            (!dir.is_empty()).then(|| {
857                spawn_git(
858                    crate::repo_scope::action_workdir(ctx),
859                    init_argv(dir),
860                    &format!("create a repository in {dir}"),
861                )
862            })
863        }),
864    });
865    contributions.push(ActionHandlerContribution {
866        action_name: "action:magit-global-merge",
867        handler: Arc::new(|_ctx: &ActionContext<'_>| {
868            // MG.52: a picker. A branch that does not exist is not a
869            // merge target or a reset destination — it is a typo, and
870            // git reports it long after the keystroke that caused it.
871            Some(Effect::OpenPicker {
872                source: crate::picker_sources::BRANCH_PICK_SOURCE.to_string(),
873                args: vec!["magit-merge".to_string()],
874                root: None,
875                fill_action: None,
876                query: None,
877            })
878        }),
879    });
880    // MG.43e: a prompt whose answer names a BUFFER rather than an
881    // argv — for rows that show something instead of changing it.
882    macro_rules! prompted_op_open {
883        ($entry:expr, $prompt:expr, $finish:expr, $view:expr, $name:expr, $mode_id:expr) => {
884            contributions.push(ActionHandlerContribution {
885                action_name: $entry,
886                handler: Arc::new(|_ctx: &ActionContext<'_>| Some(prompt_for($prompt, $finish))),
887            });
888            contributions.push(ActionHandlerContribution {
889                action_name: $finish,
890                handler: Arc::new(|ctx: &ActionContext<'_>| {
891                    let value = ctx.prompt_value?.trim();
892                    if value.is_empty() {
893                        return None;
894                    }
895                    // MR.3b: `$name` builds the view's own `rest`; the
896                    // repository in front of it is resolved from the
897                    // buffer the prompt was answered in.
898                    Some(open_repo_view_from_action_with(
899                        ctx,
900                        $view,
901                        $mode_id,
902                        Some(&($name)(value)),
903                    ))
904                }),
905            });
906        };
907    }
908    // MG.53.a: the picker peer of `prompted_op!`.
909    //
910    // Same two contributions, but the entry opens the branch picker
911    // instead of a prompt and the finish half is gone — the ex-command
912    // named here IS the finish half, because a picked candidate reaches
913    // an operation only as an ex line. The `*_argv` builder moved with
914    // it, so there is still exactly one place that knows each
915    // operation's git arguments.
916    // MG.53.d: the same shape against a source other than branches.
917    macro_rules! picked_from {
918        ($entry:expr, $source:expr, $ex_command:expr) => {
919            contributions.push(ActionHandlerContribution {
920                action_name: $entry,
921                handler: Arc::new(|_ctx: &ActionContext<'_>| {
922                    Some(Effect::OpenPicker {
923                        source: $source.to_string(),
924                        args: vec![$ex_command.to_string()],
925                        root: None,
926                        fill_action: None,
927                        query: None,
928                    })
929                }),
930            });
931        };
932    }
933    macro_rules! picked_op {
934        ($entry:expr, $ex_command:expr) => {
935            contributions.push(ActionHandlerContribution {
936                action_name: $entry,
937                handler: Arc::new(|_ctx: &ActionContext<'_>| {
938                    Some(Effect::OpenPicker {
939                        source: crate::picker_sources::BRANCH_PICK_SOURCE.to_string(),
940                        args: vec![$ex_command.to_string()],
941                        root: None,
942                        fill_action: None,
943                        query: None,
944                    })
945                }),
946            });
947        };
948    }
949    picked_op!(
950        "action:magit-global-merge-no-commit",
951        "magit-merge-no-commit"
952    );
953    picked_op!("action:magit-global-merge-squash", "magit-merge-squash");
954    // MG.43d: magit's branch `s` spin-off and `S` spin-out.
955    //
956    // Both create a branch from the current branch's unpushed commits
957    // and rewind the current branch to its upstream. `checkout` is the
958    // only difference: spin-off leaves you on the new branch, spin-out
959    // leaves you where you were.
960    macro_rules! spinoff_op {
961        ($entry:expr, $finish:expr, $prompt:expr, $checkout:expr, $label:expr) => {
962            contributions.push(ActionHandlerContribution {
963                action_name: $entry,
964                handler: Arc::new(|_ctx: &ActionContext<'_>| Some(prompt_for($prompt, $finish))),
965            });
966            contributions.push(ActionHandlerContribution {
967                action_name: $finish,
968                handler: Arc::new(|ctx: &ActionContext<'_>| {
969                    let branch = ctx.prompt_value?.trim().to_string();
970                    if branch.is_empty() {
971                        return None;
972                    }
973                    let label = format!("{} {branch}", $label);
974                    Some(spawn_computed(
975                        crate::repo_scope::action_workdir(ctx),
976                        label.clone(),
977                        label,
978                        move |wd| crate::cherry_move::branch_spinoff(wd, &branch, $checkout),
979                    ))
980                }),
981            });
982        };
983    }
984    spinoff_op!(
985        "action:magit-global-branch-spinoff",
986        "action:magit-global-branch-spinoff-finish",
987        "Spin off branch: ",
988        true,
989        "spin off new branch"
990    );
991    spinoff_op!(
992        "action:magit-global-branch-spinout",
993        "action:magit-global-branch-spinout-finish",
994        "Spin out branch: ",
995        false,
996        "spin out new branch"
997    );
998
999    // MG.43d: the cherry-move rows. Each resolves a commit first (the
1000    // cursor, or a picker), stashes it, then prompts for the branch —
1001    // the same carry `two_input_op!` uses, and consumed the same way.
1002    macro_rules! cherry_move_finish {
1003        ($finish:expr, $label:literal, $body:expr) => {
1004            contributions.push(ActionHandlerContribution {
1005                action_name: $finish,
1006                handler: Arc::new(|ctx: &ActionContext<'_>| {
1007                    let branch = ctx.prompt_value?.trim().to_string();
1008                    let commit = take_first_input()?;
1009                    if branch.is_empty() {
1010                        return None;
1011                    }
1012                    let label = format!($label, commit = short_rev(&commit), branch = branch);
1013                    Some(spawn_computed(
1014                        crate::repo_scope::action_workdir(ctx),
1015                        label.clone(),
1016                        label,
1017                        move |wd| {
1018                            let f: fn(&std::path::Path, &str, &str) -> Result<(), String> = $body;
1019                            f(wd, &commit, &branch)
1020                        },
1021                    ))
1022                }),
1023            });
1024        };
1025    }
1026    cherry_move_finish!(
1027        "action:magit-cherry-harvest-finish",
1028        "harvest {commit} from {branch}",
1029        |wd, commit, branch| {
1030            // Move it FROM `branch` onto the current one, and stay put.
1031            let current = crate::cherry_move::current_branch_of(wd)
1032                .ok_or_else(|| "not on a branch".to_string())?;
1033            crate::cherry_move::cherry_move(wd, commit, Some(branch), &current, None, true)
1034        }
1035    );
1036    cherry_move_finish!(
1037        "action:magit-cherry-donate-finish",
1038        "donate {commit} to {branch}",
1039        |wd, commit, branch| {
1040            // Move it from the current branch onto `branch`, and stay
1041            // on the current one.
1042            let current = crate::cherry_move::current_branch_of(wd)
1043                .ok_or_else(|| "not on a branch".to_string())?;
1044            crate::cherry_move::cherry_move(wd, commit, Some(&current), branch, None, false)
1045        }
1046    );
1047    cherry_move_finish!(
1048        "action:magit-cherry-spinout-finish",
1049        "spin {commit} out to new branch {branch}",
1050        |wd, commit, branch| {
1051            let current = crate::cherry_move::current_branch_of(wd)
1052                .ok_or_else(|| "not on a branch".to_string())?;
1053            // The new branch starts at the UPSTREAM, not here — see
1054            // `spin_start_point`. Starting at the current branch would
1055            // make the cherry-pick empty, because the commit is
1056            // already there.
1057            let start = crate::cherry_move::spin_start_point(wd, commit);
1058            crate::cherry_move::cherry_move(wd, commit, Some(&current), branch, Some(&start), false)
1059        }
1060    );
1061    cherry_move_finish!(
1062        "action:magit-cherry-spinoff-finish",
1063        "spin {commit} off to new branch {branch}",
1064        |wd, commit, branch| {
1065            let current = crate::cherry_move::current_branch_of(wd)
1066                .ok_or_else(|| "not on a branch".to_string())?;
1067            let start = crate::cherry_move::spin_start_point(wd, commit);
1068            crate::cherry_move::cherry_move(wd, commit, Some(&current), branch, Some(&start), true)
1069        }
1070    );
1071
1072    // MG.43f: magit's fetch `m` — fetch submodules too.
1073    contributions.push(ActionHandlerContribution {
1074        action_name: "action:magit-global-fetch-submodules",
1075        handler: Arc::new(|ctx: &ActionContext<'_>| {
1076            Some(spawn_git(
1077                crate::repo_scope::action_workdir(ctx),
1078                ["fetch", "--recurse-submodules"]
1079                    .iter()
1080                    .map(|s| s.to_string())
1081                    .collect(),
1082                "fetch, including submodules",
1083            ))
1084        }),
1085    });
1086
1087    // MG.43e: merge `p` preview — a read-only diff of what merging
1088    // would bring in. Opens a buffer rather than running anything.
1089    prompted_op_open!(
1090        "action:magit-global-merge-preview",
1091        "Preview merge with branch: ",
1092        "action:magit-global-merge-preview-finish",
1093        "diff",
1094        |branch: &str| crate::magit_diff_mode::diff_view_rest(
1095            &crate::magit_diff_mode::DiffScope::MergePreview(branch.to_string()),
1096            None
1097        ),
1098        "magit-diff-mode"
1099    );
1100
1101    // MG.43e: merge `i` — merge THIS branch into another and delete
1102    // this one. The mirror of `a` absorb; the direction is the whole
1103    // difference, and it deletes a different branch.
1104    picked_op!("action:magit-global-merge-into", "magit-merge-into");
1105    contributions.push(ActionHandlerContribution {
1106        action_name: "action:magit-global-merge-into-finish",
1107        handler: Arc::new(|ctx: &ActionContext<'_>| {
1108            let target = ctx.prompt_value?.trim().to_string();
1109            if target.is_empty() {
1110                return None;
1111            }
1112            // Detached HEAD has no branch to merge or delete, so this
1113            // declines rather than acting on `HEAD`.
1114            let Some(current) = current_branch(&crate::repo_scope::action_workdir(ctx)) else {
1115                return Some(Effect::Echo {
1116                    level: lattice_grammar::EchoLevel::Error,
1117                    text: "magit: not on a branch".to_string(),
1118                });
1119            };
1120            if current == target {
1121                return Some(Effect::Echo {
1122                    level: lattice_grammar::EchoLevel::Error,
1123                    text: "magit: cannot merge a branch into itself".to_string(),
1124                });
1125            }
1126            Some(spawn_git_sequence(
1127                crate::repo_scope::action_workdir(ctx),
1128                format!("merge {current} into {target} and delete it"),
1129                merge_into_steps(&current, &target),
1130            ))
1131        }),
1132    });
1133
1134    // MG.43e: tag `p` prune.
1135    picked_from!(
1136        "action:magit-global-tag-prune",
1137        crate::picker_sources::REMOTE_PICK_SOURCE,
1138        "magit-tag-prune"
1139    );
1140
1141    // MG.43b: rebase `e` elsewhere, and `f` autosquash.
1142    picked_op!(
1143        "action:magit-global-rebase-onto-elsewhere",
1144        "magit-rebase-onto"
1145    );
1146    picked_op!(
1147        "action:magit-global-rebase-autosquash",
1148        "magit-rebase-autosquash"
1149    );
1150    // MG.42-E2: absorb — merge then delete, as one operation.
1151    picked_op!("action:magit-global-merge-absorb", "magit-merge-absorb");
1152    // MG.42-E3: two-input operations. The first prompt's finish opens
1153    // the second; the second builds the argv from both.
1154    macro_rules! two_input_op {
1155        ($entry:expr, $p1:expr, $mid:expr, $p2:expr, $finish:expr, $argv:expr, $label:expr) => {
1156            contributions.push(ActionHandlerContribution {
1157                action_name: $entry,
1158                handler: Arc::new(|_ctx: &ActionContext<'_>| Some(prompt_for($p1, $mid))),
1159            });
1160            contributions.push(ActionHandlerContribution {
1161                action_name: $mid,
1162                handler: Arc::new(|ctx: &ActionContext<'_>| {
1163                    let first = ctx.prompt_value?.trim();
1164                    // Cancelling or clearing the FIRST prompt must run
1165                    // nothing — not a half-applied operation with an
1166                    // empty argument.
1167                    if first.is_empty() {
1168                        return None;
1169                    }
1170                    stash_first_input(first.to_string());
1171                    Some(prompt_for($p2, $finish))
1172                }),
1173            });
1174            contributions.push(ActionHandlerContribution {
1175                action_name: $finish,
1176                handler: Arc::new(|ctx: &ActionContext<'_>| {
1177                    let second = ctx.prompt_value?.trim();
1178                    // Same at the second step, and the pending value is
1179                    // consumed either way so it cannot leak into a later
1180                    // chain.
1181                    let first = take_first_input()?;
1182                    if second.is_empty() {
1183                        return None;
1184                    }
1185                    let label: fn(&str, &str) -> String = $label;
1186                    Some(spawn_git(
1187                        crate::repo_scope::action_workdir(ctx),
1188                        $argv(&first, second),
1189                        &label(&first, second),
1190                    ))
1191                }),
1192            });
1193        };
1194    }
1195    two_input_op!(
1196        "action:magit-global-reset-file",
1197        "Reset file from commit: ",
1198        "action:magit-global-reset-file-path",
1199        "File path: ",
1200        "action:magit-global-reset-file-finish",
1201        reset_file_argv,
1202        |commit, path| format!("restore {path} from {}", short_rev(commit))
1203    );
1204    // MG.43b: rebase `s` — a subset onto a new base. Two refs, and
1205    // the order is load-bearing (see `rebase_subset_argv`).
1206    two_input_op!(
1207        "action:magit-global-rebase-subset",
1208        "Rebase onto (new base): ",
1209        "action:magit-global-rebase-subset-upstream",
1210        "Commits after (upstream): ",
1211        "action:magit-global-rebase-subset-finish",
1212        rebase_subset_argv,
1213        |base, upstream| format!("rebase the commits after {upstream} onto {base}")
1214    );
1215    // MG.43e: tag `r` release — annotated, so two inputs.
1216    two_input_op!(
1217        "action:magit-global-tag-release",
1218        "Release tag name: ",
1219        "action:magit-global-tag-release-message",
1220        "Tag message: ",
1221        "action:magit-global-tag-release-finish",
1222        tag_release_argv,
1223        |name, _message| format!("create release tag {name}")
1224    );
1225    two_input_op!(
1226        "action:magit-global-stash-branch",
1227        "New branch name: ",
1228        "action:magit-global-stash-branch-stash",
1229        "Stash (e.g. stash@{0}): ",
1230        "action:magit-global-stash-branch-finish",
1231        stash_branch_argv,
1232        |branch, stash| format!("create branch {branch} from {stash}")
1233    );
1234
1235    // MG.43g: magit's `C` configure rows. One prompt-then-write pair
1236    // per key, generated from the SAME table the menus render from, so
1237    // a row and its handler cannot drift apart.
1238    //
1239    // The prompt is seeded with the current value, so editing an
1240    // existing setting starts from what it is rather than blank.
1241    macro_rules! config_op {
1242        ($action_name:expr, $finish:expr, $config_key:expr, $label:expr) => {
1243            contributions.push(ActionHandlerContribution {
1244                action_name: $action_name,
1245                handler: Arc::new(|ctx: &ActionContext<'_>| {
1246                    // Seeded with the current value: a configure row
1247                    // edits an EXISTING setting, so starting blank
1248                    // would mean retyping it to change one character,
1249                    // and an accidental `<CR>` would clear it.
1250                    //
1251                    // MR.6: read from the buffer's repository, which is
1252                    // also the one the finish half writes to.
1253                    let current = crate::git_config::value_of(
1254                        &crate::repo_scope::action_workdir(ctx),
1255                        $config_key,
1256                    )
1257                    .unwrap_or_default();
1258                    Some(prompt_seeded(
1259                        concat!($label, " (", $config_key, "): "),
1260                        $finish,
1261                        current,
1262                    ))
1263                }),
1264            });
1265            contributions.push(ActionHandlerContribution {
1266                action_name: $finish,
1267                handler: Arc::new(|ctx: &ActionContext<'_>| {
1268                    // An empty value UNSETS rather than declining: a
1269                    // configure row must be able to clear a setting,
1270                    // and blanking the prompt is how magit does it.
1271                    let value = ctx.prompt_value?.trim().to_string();
1272                    Some(crate::git_config::set(
1273                        crate::repo_scope::action_workdir(ctx),
1274                        $config_key,
1275                        &value,
1276                    ))
1277                }),
1278            });
1279        };
1280    }
1281    config_op!(
1282        "action:magit-config-pull-rebase",
1283        "action:magit-config-pull-rebase-finish",
1284        "pull.rebase",
1285        "Rebase on pull"
1286    );
1287    config_op!(
1288        "action:magit-config-push-default",
1289        "action:magit-config-push-default-finish",
1290        "remote.pushDefault",
1291        "Default push target"
1292    );
1293    config_op!(
1294        "action:magit-config-fetch-prune",
1295        "action:magit-config-fetch-prune-finish",
1296        "fetch.prune",
1297        "Prune on fetch"
1298    );
1299    config_op!(
1300        "action:magit-config-tag-sign",
1301        "action:magit-config-tag-sign-finish",
1302        "tag.gpgSign",
1303        "Sign tags"
1304    );
1305    config_op!(
1306        "action:magit-config-notes-ref",
1307        "action:magit-config-notes-ref-finish",
1308        "core.notesRef",
1309        "Notes ref"
1310    );
1311
1312    // MG.43b: magit's rebase `p` / `u` — onto the configured push
1313    // target or the upstream. Both are plain revisions to git, so
1314    // neither needs the `RemoteTarget` resolution push/pull use.
1315    macro_rules! rebase_onto {
1316        ($action_name:expr, $target:expr, $what:expr) => {
1317            contributions.push(ActionHandlerContribution {
1318                action_name: $action_name,
1319                handler: Arc::new(|ctx: &ActionContext<'_>| {
1320                    Some(spawn_git(
1321                        crate::repo_scope::action_workdir(ctx),
1322                        rebase_onto_argv($target),
1323                        $what,
1324                    ))
1325                }),
1326            });
1327        };
1328    }
1329    rebase_onto!(
1330        "action:magit-global-rebase-onto-push",
1331        "@{push}",
1332        "rebase onto the push branch"
1333    );
1334    rebase_onto!(
1335        "action:magit-global-rebase-onto-upstream",
1336        "@{upstream}",
1337        "rebase onto the upstream"
1338    );
1339
1340    // MG.43a: magit's commit `e` — add what is staged to the last
1341    // commit, keeping its message.
1342    //
1343    // The one commit row that takes NO argument: it always acts on
1344    // HEAD, so there is nothing to resolve and no prompt to answer.
1345    // `--no-edit` is what makes it "extend" rather than "amend" — the
1346    // message is deliberately left alone.
1347    contributions.push(ActionHandlerContribution {
1348        action_name: "action:magit-global-commit-extend",
1349        handler: Arc::new(|ctx: &ActionContext<'_>| {
1350            Some(spawn_git(
1351                crate::repo_scope::action_workdir(ctx),
1352                ["commit", "--amend", "--no-edit"]
1353                    .iter()
1354                    .map(|s| s.to_string())
1355                    .collect(),
1356                "extend the last commit",
1357            ))
1358        }),
1359    });
1360
1361    // MG.43a: magit's branch `x` — reset the current branch to another
1362    // ref.
1363    //
1364    // Destructive in the same way `reset --hard` is, so it asks, and
1365    // the ref the prompt named is CARRIED to the execute half rather
1366    // than re-derived (IX.1) — a background refresh while the dialog
1367    // is open must not change what gets reset.
1368    contributions.push(ActionHandlerContribution {
1369        action_name: "action:magit-global-branch-reset",
1370        handler: Arc::new(|_ctx: &ActionContext<'_>| {
1371            // MG.52: a picker. A branch that does not exist is not a
1372            // merge target or a reset destination — it is a typo, and
1373            // git reports it long after the keystroke that caused it.
1374            Some(Effect::OpenPicker {
1375                source: crate::picker_sources::BRANCH_PICK_SOURCE.to_string(),
1376                args: vec!["magit-branch-reset".to_string()],
1377                root: None,
1378                fill_action: None,
1379                query: None,
1380            })
1381        }),
1382    });
1383    contributions.push(ActionHandlerContribution {
1384        action_name: "action:magit-global-branch-reset-finish",
1385        handler: Arc::new(|ctx: &ActionContext<'_>| {
1386            let target = ctx.prompt_value?.trim();
1387            if target.is_empty() {
1388                return None;
1389            }
1390            Some(crate::confirm::ask_target(
1391                format!("git reset --hard {target} — discard uncommitted changes?"),
1392                "action:magit-global-branch-reset-execute",
1393                target.to_string(),
1394            ))
1395        }),
1396    });
1397    contributions.push(ActionHandlerContribution {
1398        action_name: "action:magit-global-branch-reset-execute",
1399        handler: Arc::new(|ctx: &ActionContext<'_>| {
1400            let target = crate::confirm::carried_target(ctx)?;
1401            Some(spawn_git(
1402                crate::repo_scope::action_workdir(ctx),
1403                vec!["reset".to_string(), "--hard".to_string(), target.clone()],
1404                &format!("hard-reset the branch to {}", short_rev(&target)),
1405            ))
1406        }),
1407    });
1408
1409    // MG.42-E1: merge `e` — prompt for the branch, then compose the
1410    // merge message in a buffer. Genuinely different from the `n`
1411    // don't-commit row: this completes the merge in one step with an
1412    // authored message, rather than leaving a staged merge behind.
1413    picked_op!("action:magit-global-merge-edit", "magit-merge-edit");
1414    contributions.push(ActionHandlerContribution {
1415        action_name: "action:magit-global-merge-edit-finish",
1416        handler: Arc::new(|ctx: &ActionContext<'_>| {
1417            let branch = ctx.prompt_value?.trim();
1418            if branch.is_empty() {
1419                return None;
1420            }
1421            Some(open_repo_view_from_action_with(
1422                ctx,
1423                "merge-edit",
1424                "magit-commit-mode",
1425                Some(branch),
1426            ))
1427        }),
1428    });
1429
1430    picked_from!(
1431        "action:magit-global-tag-delete",
1432        crate::picker_sources::TAG_PICK_SOURCE,
1433        "magit-tag-delete"
1434    );
1435    contributions.push(ActionHandlerContribution {
1436        action_name: "action:magit-global-merge-finish",
1437        handler: Arc::new(|ctx: &ActionContext<'_>| {
1438            let branch = ctx.prompt_value?.trim();
1439            // `--no-edit` for the same reason `revert` passes it: git
1440            // would otherwise open `$EDITOR` for the merge message,
1441            // which inside lattice is a wait on a prompt that never
1442            // appears.
1443            (!branch.is_empty()).then(|| {
1444                spawn_git(
1445                    crate::repo_scope::action_workdir(ctx),
1446                    merge_argv(branch),
1447                    &merge_label("merge", branch),
1448                )
1449            })
1450        }),
1451    });
1452    remote_op!("action:magit-global-stage-all", RemoteOp::STAGE_ALL);
1453    remote_op!("action:magit-global-unstage-all", RemoteOp::UNSTAGE_ALL);
1454
1455    // file-dispatch (`C-c f`) — every item acts on the file in
1456    // whatever buffer was active when the transient was opened,
1457    // resolved through `active_file` below.
1458
1459    /// Open a file-scoped magit buffer named `<prefix><rel-path>*`
1460    /// in `mode_id` — the shape `C-c f`'s diff/log/blame items share.
1461    /// MR.3b: open a view scoped to the active file. The repository
1462    /// comes from the shared trigger body (which resolves it from that
1463    /// same file), so the path lands in `rest` behind it rather than in
1464    /// the repository's slot.
1465    macro_rules! file_open {
1466        ($action_name:expr, $view:expr, $mode_id:expr) => {
1467            contributions.push(ActionHandlerContribution {
1468                action_name: $action_name,
1469                handler: Arc::new(|ctx: &ActionContext<'_>| {
1470                    let (_workdir, rel) = active_target(ctx)?;
1471                    Some(open_repo_view_from_action_with(
1472                        ctx,
1473                        $view,
1474                        $mode_id,
1475                        Some(&rel.display().to_string()),
1476                    ))
1477                }),
1478            });
1479        };
1480    }
1481
1482    /// Run a blocking git mutation against the active file, off the
1483    /// actor thread, echoing optimistically — the same detached
1484    /// shape `remote_op!` uses (no synchronous path back to the echo
1485    /// area from a task that outlives the handler call).
1486    // NC.5: every file mutation reports through `finish_task`. These
1487    // used to discard their result (`let _ =`) and echo the past tense
1488    // when the task was SPAWNED — "staged a.rs" whether or not it was —
1489    // and, publishing nothing, left open magit views stale too.
1490    macro_rules! file_mutate {
1491        ($action_name:expr, $verb:expr, $doing:expr, $body:expr) => {
1492            contributions.push(ActionHandlerContribution {
1493                action_name: $action_name,
1494                handler: Arc::new(|ctx: &ActionContext<'_>| {
1495                    let (workdir, rel) = active_target(ctx)?;
1496                    let shown = rel.display().to_string();
1497                    let label = format!("{} {shown}", $verb);
1498                    tokio::task::spawn(async move {
1499                        let scope_dir = workdir.clone();
1500                        let result = tokio::task::spawn_blocking(move || {
1501                            let repo = Repository::discover(&workdir)
1502                                .map_err(|e| format!("not a git repository: {e}"))?;
1503                            #[allow(clippy::redundant_closure_call)]
1504                            ($body)(&repo, &rel)
1505                        })
1506                        .await
1507                        .unwrap_or_else(|e| Err(e.to_string()));
1508                        finish_task(&scope_dir, &label, result.map(|()| String::new()));
1509                    });
1510                    Some(Effect::Echo {
1511                        level: lattice_grammar::EchoLevel::Info,
1512                        text: format!(concat!("magit: ", $doing, " {}\u{2026}"), shown),
1513                    })
1514                }),
1515            });
1516        };
1517    }
1518
1519    file_mutate!(
1520        "action:magit-global-file-stage",
1521        "stage",
1522        "staging",
1523        |repo: &Repository, rel: &std::path::Path| -> Result<(), String> {
1524            lattice_vcs::Index::stage_path(repo, rel).map_err(|e| e.to_string())
1525        }
1526    );
1527    file_mutate!(
1528        "action:magit-global-file-unstage",
1529        "unstage",
1530        "unstaging",
1531        |repo: &Repository, rel: &std::path::Path| -> Result<(), String> {
1532            lattice_vcs::Index::unstage_path(repo, rel).map_err(|e| e.to_string())
1533        }
1534    );
1535    // Discard is destructive, so it asks first — same `Effect::Confirm`
1536    // → `<action>-execute` two-step magit-status's own `x` uses.
1537    contributions.push(ActionHandlerContribution {
1538        action_name: "action:magit-global-file-discard",
1539        handler: Arc::new(|ctx: &ActionContext<'_>| {
1540            let (_workdir, rel) = active_target(ctx)?;
1541            // IX.1: carry the path the prompt names, so the execute half
1542            // acts on exactly what was confirmed. It needs no change to
1543            // read it — `active_target` already prefers the `file`
1544            // argument over the visited file, which is the same seam
1545            // `:magit-other-file-dispatch` uses.
1546            Some(crate::confirm::ask_with(
1547                format!("Discard changes to {}?", rel.display()),
1548                "action:magit-global-file-discard-execute",
1549                lattice_grammar::Args::List(vec![lattice_grammar::ArgValue::String(
1550                    rel.to_string_lossy().into_owned(),
1551                )]),
1552            ))
1553        }),
1554    });
1555    file_mutate!(
1556        "action:magit-global-file-discard-execute",
1557        "discard changes to",
1558        "discarding changes to",
1559        |repo: &Repository, rel: &std::path::Path| -> Result<(), String> {
1560            repo.run_git(["checkout", "--", &rel.to_string_lossy()])
1561                .map(|_| ())
1562                .map_err(|e| e.to_string())
1563        }
1564    );
1565
1566    file_open!("action:magit-global-file-diff", "diff", "magit-diff-mode");
1567    file_open!("action:magit-global-file-log", "log", "magit-log-mode");
1568    // MG.26b: blame no longer opens a buffer — it activates a minor on
1569    // the buffer you are already reading, so the file keeps its own
1570    // major, its parser and therefore its highlighting.
1571    contributions.push(ActionHandlerContribution {
1572        action_name: "action:magit-global-file-blame",
1573        handler: Arc::new(|_ctx: &ActionContext<'_>| {
1574            Some(Effect::ToggleMode {
1575                mode_name: crate::MagitBlameMode::mode_id().as_str().to_string(),
1576            })
1577        }),
1578    });
1579
1580    // MG.29: the branch submenu's picker-backed rows.
1581    //
1582    // The branch buffer's own `<CR>` / `c` read a cursor, and a menu
1583    // opened from anywhere has none — so these ASK, which is the same
1584    // answer MG.23j gave `A` / `_` / `O` and the reason magit puts its
1585    // branch commands in an ungated group.
1586    contributions.push(ActionHandlerContribution {
1587        action_name: "action:magit-global-branch-checkout",
1588        handler: Arc::new(|_ctx: &ActionContext<'_>| {
1589            Some(Effect::OpenPicker {
1590                source: crate::picker_sources::BRANCH_CHECKOUT_SOURCE.to_string(),
1591                args: Vec::new(),
1592                root: None,
1593                fill_action: None,
1594                query: None,
1595            })
1596        }),
1597    });
1598    contributions.push(ActionHandlerContribution {
1599        action_name: "action:magit-global-branch-create",
1600        handler: Arc::new(|_ctx: &ActionContext<'_>| {
1601            // The same two-step wizard `c` runs in the branch buffer —
1602            // pick a base, then name the branch. Ungated: the buffer
1603            // version refuses outside a branch list, which is right for
1604            // a chord and wrong for a menu row.
1605            Some(Effect::OpenPicker {
1606                source: "magit-branch-pick-base".to_string(),
1607                args: Vec::new(),
1608                root: None,
1609                fill_action: None,
1610                query: None,
1611            })
1612        }),
1613    });
1614
1615    // MG.28: `v` — this file at a revision you name.
1616    //
1617    // The gap it fills: `magit-file-revision-mode` has existed since
1618    // MG.11, but the only ways in were `<CR>` on a file inside a
1619    // revision/diff view and `gj`/`gk` to walk from there. There was no
1620    // way to say "this file, at that revision" directly.
1621    //
1622    // Magit prompts for a revision AND a file. Here only the revision
1623    // is asked, because `C-c f` already means "the file I am visiting"
1624    // (MG.23a) — asking for something the menu already knows is the
1625    // deviation magit's own file-dispatch makes for the same reason.
1626    // `:magit-find-file <rev> [<path>]` is the explicit form.
1627    contributions.push(ActionHandlerContribution {
1628        action_name: "action:magit-global-file-at-revision",
1629        handler: Arc::new(|ctx: &ActionContext<'_>| {
1630            let (_workdir, rel) = active_target(ctx)?;
1631            let path = rel.to_string_lossy().into_owned();
1632            // MG.53.c/g: a picker over REVISIONS — branches, tags and
1633            // recent commits. `magit-find-file`
1634            // is `<rev> <path>`, so the pick goes in the `{}` rather
1635            // than on the end — see `picker_sources::picked_line`.
1636            //
1637            // The picker's first row is HEAD (it is `git log`), which
1638            // preserves what the prompt's `HEAD` default gave: "what did
1639            // this look like before my edits" is still one keystroke,
1640            // and now it shows the subject.
1641            Some(Effect::OpenPicker {
1642                source: crate::picker_sources::REVISION_PICK_SOURCE.to_string(),
1643                args: vec![format!("magit-find-file {{}} {path}")],
1644                root: None,
1645                fill_action: None,
1646                query: None,
1647            })
1648        }),
1649    });
1650    contributions.push(ActionHandlerContribution {
1651        action_name: "action:magit-global-file-at-revision-finish",
1652        handler: Arc::new(|ctx: &ActionContext<'_>| {
1653            let rev = ctx.prompt_value?.trim().to_string();
1654            let buffer_id = lattice_core::BufferId(ctx.buffer_id.0 as u32);
1655            let store = ctx.services.get::<BufferStoreHandle>()?;
1656            let path = store
1657                .name_for(buffer_id)
1658                .and_then(|n| path_from_prompt_buffer_name(&n, "*magit:show-at:"))?;
1659            if rev.is_empty() {
1660                return None;
1661            }
1662            Some(Effect::OpenSyntheticBuffer {
1663                name: crate::magit_file_revision_mode::blob_buffer_name(
1664                    &crate::repo_scope::label_of_buffer(&store, buffer_id),
1665                    &rev,
1666                    std::path::Path::new(&path),
1667                ),
1668                mode_id: crate::magit_file_revision_mode::MagitFileRevisionMode::mode_id()
1669                    .to_string(),
1670                content: None,
1671                cursor: None,
1672                activate_minor: None,
1673            })
1674        }),
1675    });
1676
1677    // MG.28: `V` — from a blob buffer, back to the LIVE file.
1678    //
1679    // The gap: `gj` / `gk` walk a blob's history, and nothing walked
1680    // back out. From `*magit:file:<rev>:<path>*` the only way to the
1681    // working-tree copy was to type `:e <path>` yourself, which means
1682    // knowing the path you are already looking at.
1683    //
1684    // Lands on the SAME LINE, via the atomic open-and-position effect.
1685    // Line numbers drift between revisions, so this is "roughly where
1686    // you were" rather than a promise — but landing at the top of a
1687    // file you were reading the middle of is the worse answer, and the
1688    // alternative (a diff-based line map) is a different feature.
1689    contributions.push(ActionHandlerContribution {
1690        action_name: "action:magit-global-file-visit-live",
1691        handler: Arc::new(|ctx: &ActionContext<'_>| {
1692            let buffer_id = lattice_core::BufferId(ctx.buffer_id.0 as u32);
1693            let parsed = ctx
1694                .services
1695                .get::<BufferStoreHandle>()?
1696                .name_for(buffer_id)
1697                .and_then(|n| crate::magit_file_revision_mode::parse_buffer_name(&n));
1698            Some(match parsed {
1699                Some((_git_ref, path)) => {
1700                    let workdir = crate::repo_scope::action_workdir(ctx);
1701                    let full = workdir.join(&path);
1702                    if full.exists() {
1703                        Effect::OpenBufferAt {
1704                            path: Some(full),
1705                            position: ctx.cursor,
1706                            force: false,
1707                            content: None,
1708                            activate_minor: None,
1709                        }
1710                    } else {
1711                        // The file existed at that revision and does
1712                        // not now. Saying so beats opening an empty
1713                        // buffer named after a deleted path.
1714                        Effect::Echo {
1715                            level: lattice_grammar::EchoLevel::Warn,
1716                            text: format!(
1717                                "magit: {} no longer exists in the working tree",
1718                                path.display()
1719                            ),
1720                        }
1721                    }
1722                }
1723                // Not a file-at-revision buffer.
1724                //
1725                // This used to be an error, and it was wrong. `V` means
1726                // "take me to the live file"; run from an ordinary file
1727                // buffer you are ALREADY there, so the request is
1728                // satisfied, not refused. Erroring told the user they had
1729                // done something wrong when they had asked for a state
1730                // they were already in.
1731                //
1732                // `Effect::None` rather than an echo: there is nothing to
1733                // report. An echo saying "you are already on the live
1734                // file" would be noise on a key whose whole job is to put
1735                // you there.
1736                //
1737                // Contrast reverse blame, which genuinely cannot run
1738                // outside a revision buffer — it needs a revision to
1739                // resolve against, so refusing IS the answer there. The
1740                // two looked alike and are not: one is missing an input,
1741                // the other has already reached its destination.
1742                None => Effect::None,
1743            })
1744        }),
1745    });
1746
1747    // MG.23f2: reverse blame — "when did each of these lines go away",
1748    // the one blame variant magit has that we had no answer for.
1749    //
1750    // It does NOT go through `active_target`, and that is the whole
1751    // shape of it: reverse blame needs a *revision* as well as a path,
1752    // and its output is the file **as it was at that revision**. Run
1753    // from a working-tree file it would replace what you are looking at
1754    // with an older version of it, annotated with shas that mean the
1755    // opposite of the ones next door in a normal blame. So it is
1756    // reachable only from a buffer that is already showing a revision —
1757    // a blob buffer — which is magit's own rule ("Only blob buffers can
1758    // be blamed in reverse") reached from the same reasoning rather
1759    // than copied.
1760    //
1761    // `staged` is refused with it: the index is not a commit, so there
1762    // is no range to walk forward from. Same exclusion `gj`/`gk` make,
1763    // for the same reason.
1764    contributions.push(ActionHandlerContribution {
1765        action_name: "action:magit-global-file-blame-reverse",
1766        handler: Arc::new(|ctx: &ActionContext<'_>| {
1767            let buffer_id = lattice_core::BufferId(ctx.buffer_id.0 as u32);
1768            let store = ctx.services.get::<BufferStoreHandle>()?;
1769            // MR.3b: reverse blame re-opens the SAME blob, so it must
1770            // re-name it in the same repository.
1771            let label = crate::repo_scope::label_of_buffer(&store, buffer_id);
1772            let parsed = store
1773                .name_for(buffer_id)
1774                .and_then(|n| crate::magit_file_revision_mode::parse_buffer_name(&n))
1775                .filter(|(git_ref, _)| git_ref != "staged");
1776            Some(match parsed {
1777                // MG.26b: the blob buffer this was opened from IS the
1778                // content reverse blame wants to annotate, so the mode
1779                // activates on it in place. The direction and revision
1780                // cannot ride on `ToggleMode` — it carries a mode name
1781                // and nothing else, which is right, since the grammar
1782                // crate must not learn what a blame direction is — so
1783                // they are left as a request keyed by the buffer's
1784                // name and consumed by `on_activate`.
1785                Some((git_ref, path)) => {
1786                    let name =
1787                        crate::magit_file_revision_mode::blob_buffer_name(&label, &git_ref, &path);
1788                    if let Some(requests) = ctx
1789                        .services
1790                        .get::<crate::magit_blame_mode::BlameRequestsHandle>()
1791                    {
1792                        requests.put(
1793                            name,
1794                            crate::magit_blame_mode::BlameDirection::Reverse,
1795                            git_ref.clone(),
1796                        );
1797                    }
1798                    Effect::ToggleMode {
1799                        mode_name: crate::MagitBlameMode::mode_id().as_str().to_string(),
1800                    }
1801                }
1802                // Naming what is missing and where to get it beats a
1803                // row that appears to do nothing: the answer is one
1804                // `<CR>` on a log entry away.
1805                None => Effect::Echo {
1806                    level: lattice_grammar::EchoLevel::Error,
1807                    text: "magit: reverse blame needs a revision — open the file at one first \
1808                           (<CR> on a log entry, then gj/gk to walk)"
1809                        .to_string(),
1810                },
1811            })
1812        }),
1813    });
1814
1815    // MG.38 / MG.39: every one of these needs arguments a menu cannot
1816    // guess — a subtree prefix, a mailbox path, a commit range — so each
1817    // row opens a prompt seeded with what IS knowable, and the finish
1818    // handler reads the operation back out of the prompt buffer's name.
1819    // Same wizard shape the clone rows and the branch-create flow use.
1820    macro_rules! prompted {
1821        ($open:expr, $prompt:expr, $initial:expr, $finish:expr, $buffer:expr) => {
1822            contributions.push(ActionHandlerContribution {
1823                action_name: $open,
1824                handler: Arc::new(|_ctx: &ActionContext<'_>| {
1825                    Some(Effect::OpenPrompt {
1826                        prompt: $prompt.to_string(),
1827                        initial: $initial.to_string(),
1828                        on_submit_action: $finish.to_string(),
1829                        buffer_name: Some($buffer.to_string()),
1830                    })
1831                }),
1832            });
1833        };
1834    }
1835
1836    for op in [
1837        SubtreeOp::ADD,
1838        SubtreeOp::MERGE,
1839        SubtreeOp::PULL,
1840        SubtreeOp::PUSH,
1841        SubtreeOp::SPLIT,
1842    ] {
1843        contributions.push(ActionHandlerContribution {
1844            action_name: match op.sub {
1845                "add" => "action:magit-global-subtree-add",
1846                "merge" => "action:magit-global-subtree-merge",
1847                "pull" => "action:magit-global-subtree-pull",
1848                "push" => "action:magit-global-subtree-push",
1849                _ => "action:magit-global-subtree-split",
1850            },
1851            handler: Arc::new(move |_ctx: &ActionContext<'_>| {
1852                Some(Effect::OpenPrompt {
1853                    prompt: format!("{} {}: ", op.what, op.usage()),
1854                    initial: String::new(),
1855                    on_submit_action: "action:magit-global-subtree-finish".to_string(),
1856                    // The operation rides in the prompt buffer's name —
1857                    // one finish handler for all five, rather than five
1858                    // near-identical ones.
1859                    buffer_name: Some(format!("*magit:subtree:{}*", op.sub)),
1860                })
1861            }),
1862        });
1863    }
1864    contributions.push(ActionHandlerContribution {
1865        action_name: "action:magit-global-subtree-finish",
1866        handler: Arc::new(|ctx: &ActionContext<'_>| {
1867            let line = ctx.prompt_value?.trim().to_string();
1868            let buffer_id = lattice_core::BufferId(ctx.buffer_id.0 as u32);
1869            let sub = ctx
1870                .services
1871                .get::<BufferStoreHandle>()?
1872                .name_for(buffer_id)
1873                .and_then(|n| path_from_prompt_buffer_name(&n, "*magit:subtree:"))?;
1874            let op = match sub.as_str() {
1875                "add" => SubtreeOp::ADD,
1876                "merge" => SubtreeOp::MERGE,
1877                "pull" => SubtreeOp::PULL,
1878                "push" => SubtreeOp::PUSH,
1879                "split" => SubtreeOp::SPLIT,
1880                _ => return None,
1881            };
1882            if line.is_empty() {
1883                return None;
1884            }
1885            Some(spawn_subtree_op(
1886                crate::repo_scope::action_workdir(ctx),
1887                op,
1888                &line,
1889            ))
1890        }),
1891    });
1892
1893    prompted!(
1894        "action:magit-global-am-apply",
1895        "Apply patches (paths, add -3 for three-way): ",
1896        "",
1897        "action:magit-global-am-apply-finish",
1898        "*magit:am*"
1899    );
1900    contributions.push(ActionHandlerContribution {
1901        action_name: "action:magit-global-am-apply-finish",
1902        handler: Arc::new(|ctx: &ActionContext<'_>| {
1903            let line = ctx.prompt_value?.trim().to_string();
1904            if line.is_empty() {
1905                return None;
1906            }
1907            let Some(argv) = am_argv(&line, am_wants_three_way(&line)) else {
1908                return Some(Effect::Echo {
1909                    level: lattice_grammar::EchoLevel::Error,
1910                    text: "magit: usage — :magit-am <patch>… [-3]".to_string(),
1911                });
1912            };
1913            Some(spawn_git(
1914                crate::repo_scope::action_workdir(ctx),
1915                argv.clone(),
1916                &am_label(&argv),
1917            ))
1918        }),
1919    });
1920
1921    prompted!(
1922        "action:magit-global-format-patch",
1923        "Create patches for range: ",
1924        "@{upstream}..HEAD",
1925        "action:magit-global-format-patch-finish",
1926        "*magit:format-patch*"
1927    );
1928    contributions.push(ActionHandlerContribution {
1929        action_name: "action:magit-global-format-patch-finish",
1930        handler: Arc::new(|ctx: &ActionContext<'_>| {
1931            let range = ctx.prompt_value?.trim().to_string();
1932            // Written to the repository root rather than the editor's
1933            // process directory: a scatter of `.patch` files somewhere
1934            // unexpected is tedious to undo, and the repo root is the
1935            // one directory the user can predict from here.
1936            let root = Some(crate::repo_scope::action_workdir(ctx))
1937                .filter(|p| !p.as_os_str().is_empty())
1938                .map(|p| p.to_string_lossy().into_owned())
1939                .unwrap_or_default();
1940            let Some(argv) = format_patch_argv(&range, (!root.is_empty()).then_some(root.as_str()))
1941            else {
1942                return Some(Effect::Echo {
1943                    level: lattice_grammar::EchoLevel::Error,
1944                    text: "magit: usage — :magit-format-patch <range>".to_string(),
1945                });
1946            };
1947            Some(spawn_git(
1948                crate::repo_scope::action_workdir(ctx),
1949                argv.clone(),
1950                &format_patch_label(&argv),
1951            ))
1952        }),
1953    });
1954
1955    macro_rules! am_op {
1956        ($action_name:expr, $op:expr) => {
1957            contributions.push(ActionHandlerContribution {
1958                action_name: $action_name,
1959                handler: Arc::new(|ctx: &ActionContext<'_>| {
1960                    Some(spawn_remote_op(
1961                        crate::repo_scope::action_workdir(ctx),
1962                        $op,
1963                        &lattice_grammar::Args::None,
1964                    ))
1965                }),
1966            });
1967        };
1968    }
1969    am_op!("action:magit-global-am-continue", RemoteOp::AM_CONTINUE);
1970    am_op!("action:magit-global-am-skip", RemoteOp::AM_SKIP);
1971    am_op!("action:magit-global-am-abort", RemoteOp::AM_ABORT);
1972
1973    // MG.40: `Y` cherries. The upstream is asked for, seeded with
1974    // `@{upstream}` — the answer in the overwhelmingly common case, and
1975    // the one thing about this question that IS knowable from here.
1976    prompted!(
1977        "action:magit-global-cherries",
1978        "Cherries against upstream: ",
1979        "@{upstream}",
1980        "action:magit-global-cherries-finish",
1981        "*magit:cherries*"
1982    );
1983    contributions.push(ActionHandlerContribution {
1984        action_name: "action:magit-global-cherries-finish",
1985        handler: Arc::new(|ctx: &ActionContext<'_>| {
1986            let upstream = ctx.prompt_value?.trim().to_string();
1987            if upstream.is_empty() {
1988                return None;
1989            }
1990            Some(open_repo_view_from_action_with(
1991                ctx,
1992                crate::magit_cherry_mode::CHERRY_VIEW,
1993                crate::MagitCherryMode::mode_id().as_str(),
1994                Some(&crate::magit_cherry_mode::cherry_view_rest(
1995                    &upstream, "HEAD",
1996                )),
1997            ))
1998        }),
1999    });
2000
2001    // MG.37: the notes submenu's handlers.
2002    //
2003    // Edit and remove need a COMMIT, and this menu has no cursor on one
2004    // when opened outside a magit buffer — so they answer the same two
2005    // ways `A` / `_` / `O` (MG.23j) and `M` (MG.34) do: the commit under
2006    // the cursor when there is one, the commit picker when there is not.
2007    contributions.push(ActionHandlerContribution {
2008        action_name: "action:magit-global-note-edit",
2009        handler: Arc::new(|ctx: &ActionContext<'_>| {
2010            let at_cursor =
2011                crate::buffer_state::view_for(ctx).and_then(|v| v.commit_at_cursor(ctx.cursor));
2012            Some(match at_cursor {
2013                Some(commit) => open_repo_view_from_action_with(
2014                    ctx,
2015                    crate::magit_notes_mode::NOTE_VIEW,
2016                    crate::MagitNotesMode::mode_id().as_str(),
2017                    Some(&commit),
2018                ),
2019                None => Effect::OpenPicker {
2020                    source: crate::picker_sources::COMMIT_PICK_SOURCE.to_string(),
2021                    args: vec!["magit-note-edit".to_string()],
2022                    root: None,
2023                    fill_action: None,
2024                    query: None,
2025                },
2026            })
2027        }),
2028    });
2029    contributions.push(ActionHandlerContribution {
2030        action_name: "action:magit-global-note-remove",
2031        handler: Arc::new(|ctx: &ActionContext<'_>| {
2032            let at_cursor =
2033                crate::buffer_state::view_for(ctx).and_then(|v| v.commit_at_cursor(ctx.cursor));
2034            Some(match at_cursor {
2035                // No confirm: a note is not history, removing one loses
2036                // only the note, and it is one `T` away from being
2037                // retyped. `prune` DOES ask — it can drop many at once
2038                // and names none of them.
2039                Some(commit) => spawn_note_remove(crate::repo_scope::action_workdir(ctx), commit),
2040                None => Effect::OpenPicker {
2041                    source: crate::picker_sources::COMMIT_PICK_SOURCE.to_string(),
2042                    args: vec!["magit-note-remove".to_string()],
2043                    root: None,
2044                    fill_action: None,
2045                    query: None,
2046                },
2047            })
2048        }),
2049    });
2050
2051    // Prune asks, because it removes an unbounded number of notes and
2052    // names none of them — the same bar `x` discard and branch-delete
2053    // are held to (MG.12). The ask half performs no git call, so
2054    // answering `n` cannot mutate.
2055    contributions.push(ActionHandlerContribution {
2056        action_name: "action:magit-global-note-prune",
2057        handler: Arc::new(|_ctx: &ActionContext<'_>| {
2058            Some(crate::confirm::ask(
2059                "Drop every note whose commit no longer exists?".to_string(),
2060                "action:magit-global-note-prune-execute",
2061            ))
2062        }),
2063    });
2064    contributions.push(ActionHandlerContribution {
2065        action_name: "action:magit-global-note-prune-execute",
2066        handler: Arc::new(|ctx: &ActionContext<'_>| {
2067            Some(spawn_note_prune(crate::repo_scope::action_workdir(ctx)))
2068        }),
2069    });
2070
2071    macro_rules! notes_merge_op {
2072        ($action_name:expr, $op:expr) => {
2073            contributions.push(ActionHandlerContribution {
2074                action_name: $action_name,
2075                handler: Arc::new(|ctx: &ActionContext<'_>| {
2076                    Some(spawn_remote_op(
2077                        crate::repo_scope::action_workdir(ctx),
2078                        $op,
2079                        &lattice_grammar::Args::None,
2080                    ))
2081                }),
2082            });
2083        };
2084    }
2085    notes_merge_op!(
2086        "action:magit-global-note-merge-commit",
2087        RemoteOp::NOTES_MERGE_COMMIT
2088    );
2089    notes_merge_op!(
2090        "action:magit-global-note-merge-abort",
2091        RemoteOp::NOTES_MERGE_ABORT
2092    );
2093
2094    picked_from!(
2095        "action:magit-global-note-merge",
2096        crate::picker_sources::REF_PICK_SOURCE,
2097        "magit-note-merge"
2098    );
2099    contributions.push(ActionHandlerContribution {
2100        action_name: "action:magit-global-note-merge-finish",
2101        handler: Arc::new(|ctx: &ActionContext<'_>| {
2102            let spec = ctx.prompt_value?.trim().to_string();
2103            if spec.is_empty() {
2104                return None;
2105            }
2106            Some(spawn_note_merge(
2107                crate::repo_scope::action_workdir(ctx),
2108                &spec,
2109            ))
2110        }),
2111    });
2112
2113    // MG.36: magit's `C` clone — a two-step wizard, the same shape the
2114    // branch-create wizard uses. URL first, then where to put it.
2115    //
2116    // Two prompts rather than one line with both, because the second
2117    // has a *derived default*: `git clone` picks the directory name off
2118    // the URL, and re-typing it is the step everyone skips in a
2119    // terminal. Asking separately is what makes offering that default
2120    // possible.
2121    contributions.push(ActionHandlerContribution {
2122        action_name: "action:magit-global-clone",
2123        handler: Arc::new(|_ctx: &ActionContext<'_>| {
2124            Some(Effect::OpenPrompt {
2125                prompt: "Clone repository: ".to_string(),
2126                initial: String::new(),
2127                on_submit_action: "action:magit-global-clone-dest".to_string(),
2128                buffer_name: Some("*magit:clone*".to_string()),
2129            })
2130        }),
2131    });
2132    contributions.push(ActionHandlerContribution {
2133        action_name: "action:magit-global-clone-dest",
2134        handler: Arc::new(|ctx: &ActionContext<'_>| {
2135            let url = ctx.prompt_value?.trim().to_string();
2136            if url.is_empty() {
2137                return None;
2138            }
2139            // Absolute, so "where did it go" is answered on screen
2140            // before the clone runs rather than after it.
2141            let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
2142            let initial = match default_clone_dest(&url) {
2143                name if name.is_empty() => String::new(),
2144                name => cwd.join(name).to_string_lossy().into_owned(),
2145            };
2146            Some(Effect::OpenPrompt {
2147                prompt: "Clone into: ".to_string(),
2148                initial,
2149                on_submit_action: "action:magit-global-clone-finish".to_string(),
2150                // The URL rides in the prompt buffer's name — the same
2151                // way the branch-create wizard carries its base, and
2152                // magit's blame / rebase / revision modes carry theirs.
2153                buffer_name: Some(format!("*magit:clone-from:{url}*")),
2154            })
2155        }),
2156    });
2157    contributions.push(ActionHandlerContribution {
2158        action_name: "action:magit-global-clone-finish",
2159        handler: Arc::new(|ctx: &ActionContext<'_>| {
2160            let dest = ctx.prompt_value?.trim().to_string();
2161            let buffer_id = lattice_core::BufferId(ctx.buffer_id.0 as u32);
2162            let url = ctx
2163                .services
2164                .get::<BufferStoreHandle>()?
2165                .name_for(buffer_id)
2166                .and_then(|n| path_from_prompt_buffer_name(&n, "*magit:clone-from:"))?;
2167            if dest.is_empty() {
2168                return None;
2169            }
2170            Some(spawn_clone(url, dest))
2171        }),
2172    });
2173
2174    // MG.34: magit's `M` "Merged" — which merge brought a commit into
2175    // HEAD.
2176    //
2177    // One action answers from two places, the same dual shape MG.23j
2178    // gave `A` / `_` / `O`: the commit under the cursor when there is
2179    // one (this row is reachable from a magit log or revision buffer),
2180    // and the commit picker when there is not — which is the ordinary
2181    // case, since magit's own home for this row is *file*-dispatch and a
2182    // file buffer has no commit under the cursor at all.
2183    //
2184    // No chord. `M` is mid-screen and `gM` is go-to-middle-of-line, both
2185    // vim grammar we owe the user; magit binds this as a transient
2186    // suffix rather than a key for its own reasons, and following that
2187    // costs the grammar nothing.
2188    contributions.push(ActionHandlerContribution {
2189        action_name: "action:magit-global-log-merged",
2190        handler: Arc::new(|ctx: &ActionContext<'_>| {
2191            let at_cursor =
2192                crate::buffer_state::view_for(ctx).and_then(|v| v.commit_at_cursor(ctx.cursor));
2193            Some(match at_cursor {
2194                Some(commit) => open_repo_view_from_action_with(
2195                    ctx,
2196                    crate::magit_revision_mode::MERGED_VIEW,
2197                    "magit-revision-mode",
2198                    Some(&commit),
2199                ),
2200                None => Effect::OpenPicker {
2201                    source: crate::picker_sources::COMMIT_PICK_SOURCE.to_string(),
2202                    args: vec!["magit-log-merged".to_string()],
2203                    root: None,
2204                    fill_action: None,
2205                    query: None,
2206                },
2207            })
2208        }),
2209    });
2210
2211    // MG.34: magit's `e` "Edit line" — start a rebase that stops on the
2212    // commit that wrote the line at the cursor, so it can be amended
2213    // instead of fixed up in a follow-on commit.
2214    //
2215    // The cursor line is read here (it is the one thing only this
2216    // context knows) and the blame is left to the rebase mode, because
2217    // finding the commit is a `git` call and this handler runs on the
2218    // actor thread — same split `M` above makes.
2219    contributions.push(ActionHandlerContribution {
2220        action_name: "action:magit-global-edit-line-commit",
2221        handler: Arc::new(|ctx: &ActionContext<'_>| {
2222            let (_workdir, rel) = active_target(ctx)?;
2223            Some(open_repo_view_from_action_with(
2224                ctx,
2225                "rebase-edit",
2226                crate::magit_rebase_mode::MagitRebaseMode::mode_id().as_str(),
2227                // `git blame -L` counts from 1; the cursor from 0.
2228                Some(&crate::magit_rebase_mode::edit_line_rest(
2229                    ctx.cursor.line + 1,
2230                    &rel.to_string_lossy(),
2231                )),
2232            ))
2233        }),
2234    });
2235
2236    // Branch-create wizard's second step — fired by the prompt
2237    // opened after `magit-branch-pick-base`'s accept. `ctx.buffer_id`
2238    // is the PROMPT buffer (see `Editor::do_prompt_line_submit`'s
2239    // doc comment); its synthetic name carries the picked base
2240    // branch, exactly like magit's blame/rebase/revision modes
2241    // encode their target in the buffer name.
2242    contributions.push(ActionHandlerContribution {
2243        action_name: "action:magit-branch-create-finish",
2244        handler: Arc::new(|ctx: &ActionContext<'_>| {
2245            let name = ctx.prompt_value?.trim().to_string();
2246            if name.is_empty() {
2247                return Some(Effect::Echo {
2248                    level: lattice_grammar::EchoLevel::Error,
2249                    text: "magit: branch name is empty".to_string(),
2250                });
2251            }
2252            let buffer_id = lattice_core::BufferId(ctx.buffer_id.0 as u32);
2253            let base = ctx
2254                .services
2255                .get::<BufferStoreHandle>()?
2256                .name_for(buffer_id)
2257                .and_then(|n| base_branch_from_prompt_buffer_name(&n))?;
2258            // MR.5: the branch operation belongs to the buffer's repository.
2259            let workdir = crate::repo_scope::action_workdir(ctx);
2260            let label = format!("create and check out branch {name} from {base}");
2261            let echo = format!("magit: creating branch {name}\u{2026}");
2262            spawn_repo_op(workdir, label, move |repo| {
2263                lattice_vcs::Branch::create(repo, &name, true, Some(&base))
2264            });
2265            Some(Effect::Echo {
2266                level: lattice_grammar::EchoLevel::Info,
2267                text: echo,
2268            })
2269        }),
2270    });
2271
2272    // ── MG.32: the rest of magit's branch transient ──────────────
2273    //
2274    // Keys follow magit with evil-collection-magit's remaps applied
2275    // (`(magit-branch "k" "x" magit-branch-delete)`), so a row lands in
2276    // the slot muscle memory already expects — MG.23's policy #1.
2277    //
2278    // `b` — checkout a branch **or revision**, magit's own wording.
2279    // MG.52: a PICKER, not a prompt.
2280    //
2281    // This asked for free text because it accepts anything `git
2282    // checkout` does — a tag, a remote ref, a raw SHA. But nobody types
2283    // a branch name they are not sure exists: a typo here is reported
2284    // by git long after the keystroke that caused it, and the branch
2285    // the user wanted was on a list the editor could have shown.
2286    //
2287    // The `-finish` handler below is kept and still reachable through
2288    // `:magit-checkout <rev>`, which is the scriptable path.
2289    //
2290    // **The REVISION picker, not the branch one.** MG.52 first pointed
2291    // this at `magit-branch` along with every other branch prompt, and
2292    // that quietly deleted the row: this is magit's `b`
2293    // (branch/revision) and the submenu also has `l` (local branch),
2294    // whose whole difference is that `b` reaches a tag, `origin/main`
2295    // or a SHA and `l` does not. Listing local branches in both made
2296    // them the same row and made checking out anything else from the
2297    // menu unreachable. Nothing errored, because
2298    // `git checkout <local branch>` is a fine command — which is why
2299    // `the_branch_revision_row_is_not_the_local_branch_row` asserts the
2300    // two sources differ rather than checking either row alone.
2301    //
2302    // `magit-revision` (MG.53.g) is refs + recent commits, which is
2303    // exactly "anything git can take" minus the typo.
2304    contributions.push(ActionHandlerContribution {
2305        action_name: "action:magit-global-branch-checkout-rev",
2306        handler: Arc::new(|_ctx: &ActionContext<'_>| {
2307            Some(Effect::OpenPicker {
2308                source: crate::picker_sources::REVISION_PICK_SOURCE.to_string(),
2309                args: vec!["magit-checkout".to_string()],
2310                root: None,
2311                fill_action: None,
2312                query: None,
2313            })
2314        }),
2315    });
2316    contributions.push(ActionHandlerContribution {
2317        action_name: "action:magit-global-branch-checkout-rev-finish",
2318        handler: Arc::new(|ctx: &ActionContext<'_>| {
2319            let rev = ctx.prompt_value?.trim().to_string();
2320            if rev.is_empty() {
2321                return Some(Effect::Echo {
2322                    level: lattice_grammar::EchoLevel::Error,
2323                    text: "magit: revision is empty".to_string(),
2324                });
2325            }
2326            // MR.5: the branch operation belongs to the buffer's repository.
2327            let workdir = crate::repo_scope::action_workdir(ctx);
2328            let label = format!("check out {}", short_rev(&rev));
2329            let echo = format!("magit: checking out {rev}\u{2026}");
2330            spawn_repo_op(workdir, label, move |repo| {
2331                lattice_vcs::Branch::checkout(repo, &rev)
2332            });
2333            Some(Effect::Echo {
2334                level: lattice_grammar::EchoLevel::Info,
2335                text: echo,
2336            })
2337        }),
2338    });
2339
2340    // `n` — new branch, NOT checked out. The picker + prompt are `c`'s;
2341    // only the `checkout` flag differs, so `Branch::create` already
2342    // covers it and no new vcs surface was needed.
2343    contributions.push(ActionHandlerContribution {
2344        action_name: "action:magit-global-branch-create-no-checkout",
2345        handler: Arc::new(|_ctx: &ActionContext<'_>| {
2346            Some(Effect::OpenPicker {
2347                source: crate::picker_sources::BRANCH_CREATE_NO_CHECKOUT_SOURCE.to_string(),
2348                args: Vec::new(),
2349                root: None,
2350                fill_action: None,
2351                query: None,
2352            })
2353        }),
2354    });
2355    contributions.push(ActionHandlerContribution {
2356        action_name: "action:magit-branch-create-no-checkout-finish",
2357        handler: Arc::new(|ctx: &ActionContext<'_>| {
2358            let name = ctx.prompt_value?.trim().to_string();
2359            if name.is_empty() {
2360                return Some(Effect::Echo {
2361                    level: lattice_grammar::EchoLevel::Error,
2362                    text: "magit: branch name is empty".to_string(),
2363                });
2364            }
2365            let buffer_id = lattice_core::BufferId(ctx.buffer_id.0 as u32);
2366            let base = ctx
2367                .services
2368                .get::<BufferStoreHandle>()?
2369                .name_for(buffer_id)
2370                .and_then(|n| {
2371                    branch_from_prompt_buffer_name(&n, BRANCH_CREATE_NO_CHECKOUT_PROMPT_PREFIX)
2372                })?;
2373            // MR.5: the branch operation belongs to the buffer's repository.
2374            let workdir = crate::repo_scope::action_workdir(ctx);
2375            let label = format!("create branch {name} from {base}");
2376            let echo = format!("magit: creating branch {name}\u{2026}");
2377            spawn_repo_op(workdir, label, move |repo| {
2378                lattice_vcs::Branch::create(repo, &name, false, Some(&base))
2379            });
2380            Some(Effect::Echo {
2381                level: lattice_grammar::EchoLevel::Info,
2382                text: echo,
2383            })
2384        }),
2385    });
2386
2387    // `m` — rename. Not destructive in MG.12's sense: nothing is
2388    // discarded, and `Branch::rename` uses `-m` (not `-M`), which
2389    // REFUSES to overwrite an existing name rather than clobbering it.
2390    // So it acts directly, like checkout and merge, rather than asking.
2391    contributions.push(ActionHandlerContribution {
2392        action_name: "action:magit-global-branch-rename",
2393        handler: Arc::new(|_ctx: &ActionContext<'_>| {
2394            Some(Effect::OpenPicker {
2395                source: crate::picker_sources::BRANCH_RENAME_SOURCE.to_string(),
2396                args: Vec::new(),
2397                root: None,
2398                fill_action: None,
2399                query: None,
2400            })
2401        }),
2402    });
2403    contributions.push(ActionHandlerContribution {
2404        action_name: "action:magit-branch-rename-finish",
2405        handler: Arc::new(|ctx: &ActionContext<'_>| {
2406            let new_name = ctx.prompt_value?.trim().to_string();
2407            if new_name.is_empty() {
2408                return Some(Effect::Echo {
2409                    level: lattice_grammar::EchoLevel::Error,
2410                    text: "magit: branch name is empty".to_string(),
2411                });
2412            }
2413            let buffer_id = lattice_core::BufferId(ctx.buffer_id.0 as u32);
2414            let old = ctx
2415                .services
2416                .get::<BufferStoreHandle>()?
2417                .name_for(buffer_id)
2418                .and_then(|n| branch_from_prompt_buffer_name(&n, BRANCH_RENAME_PROMPT_PREFIX))?;
2419            if old == new_name {
2420                // The prompt is pre-filled with the current name, so
2421                // submitting unchanged is the likeliest accident. git
2422                // would error; saying nothing happened is kinder.
2423                return Some(Effect::Echo {
2424                    level: lattice_grammar::EchoLevel::Info,
2425                    text: format!("magit: {old} unchanged"),
2426                });
2427            }
2428            // MR.5: the branch operation belongs to the buffer's repository.
2429            let workdir = crate::repo_scope::action_workdir(ctx);
2430            let label = format!("rename branch {old} to {new_name}");
2431            let echo = format!("magit: renaming branch {old}\u{2026}");
2432            spawn_repo_op(workdir, label, move |repo| {
2433                lattice_vcs::Branch::rename(repo, &old, &new_name)
2434            });
2435            Some(Effect::Echo {
2436                level: lattice_grammar::EchoLevel::Info,
2437                text: echo,
2438            })
2439        }),
2440    });
2441
2442    // `x` — delete (magit's `k`). Opens the picker; the picker's accept
2443    // routes through `:magit-branch-delete <name>`, which raises the
2444    // MG.12 confirm. The git call lives only in the execute half below,
2445    // so answering `n` cannot reach it.
2446    contributions.push(ActionHandlerContribution {
2447        action_name: "action:magit-global-branch-delete",
2448        handler: Arc::new(|_ctx: &ActionContext<'_>| {
2449            Some(Effect::OpenPicker {
2450                source: crate::picker_sources::BRANCH_DELETE_SOURCE.to_string(),
2451                args: Vec::new(),
2452                root: None,
2453                fill_action: None,
2454                query: None,
2455            })
2456        }),
2457    });
2458    contributions.push(ActionHandlerContribution {
2459        action_name: "action:magit-global-branch-delete-execute",
2460        handler: Arc::new(|ctx: &ActionContext<'_>| {
2461            // Always carried: this pair is only ever raised by the
2462            // ex-command, which names its target. There is no cursor to
2463            // fall back to — the menu opens from anywhere.
2464            let name = crate::confirm::carried_target(ctx)?;
2465            // MR.5: the branch operation belongs to the buffer's repository.
2466            let workdir = crate::repo_scope::action_workdir(ctx);
2467            let label = format!("delete branch {name}");
2468            let echo = format!("magit: deleting branch {name}\u{2026}");
2469            spawn_repo_op(workdir, label, move |repo| {
2470                lattice_vcs::Branch::delete(repo, &name)
2471            });
2472            Some(Effect::Echo {
2473                level: lattice_grammar::EchoLevel::Info,
2474                text: echo,
2475            })
2476        }),
2477    });
2478
2479    contributions
2480}
2481
2482/// NC.5: run one `lattice_vcs` repository call off the actor thread and
2483/// report it through [`finish_task`].
2484///
2485/// The branch rows used to spawn the call and log a failure with
2486/// `tracing::error!`, so success was invisible, failure reached only
2487/// `*messages*`, and — publishing nothing — open magit views stayed
2488/// stale until `gr`.
2489fn spawn_repo_op(
2490    workdir: std::path::PathBuf,
2491    label: String,
2492    op: impl FnOnce(&Repository) -> lattice_vcs::Result<()> + Send + 'static,
2493) {
2494    tokio::task::spawn(async move {
2495        let scope_dir = workdir.clone();
2496        let result = tokio::task::spawn_blocking(move || {
2497            let repo =
2498                Repository::discover(&workdir).map_err(|e| format!("not a git repository: {e}"))?;
2499            op(&repo).map(|()| String::new()).map_err(|e| e.to_string())
2500        })
2501        .await
2502        .unwrap_or_else(|e| Err(e.to_string()));
2503        finish_task(&scope_dir, &label, result);
2504    });
2505}
2506
2507/// Resolve the active buffer's file to `(repo-workdir,
2508/// repo-relative-path)` — every `C-c f` item's first step. `None`
2509/// when the active buffer has no backing file (a synthetic magit
2510/// buffer, `*messages*`, a scratch buffer) or isn't inside a git
2511/// repository; the item then silently does nothing, which is the
2512/// right outcome for "stage the file" with no file to stage.
2513/// MG.23a: the file a `C-c f` item acts on — **the argument if one was
2514/// supplied, else the visited file**.
2515///
2516/// `C-c f` supplies none, so it keeps acting on the buffer you were in
2517/// when you opened it: no "which file?" prompt, which is the one
2518/// deliberate deviation from magit (magit prompts, defaulting to the
2519/// current file). `:magit-other-file-dispatch` supplies one, which is
2520/// how a stand-alone invocation names a file it is not visiting.
2521///
2522/// The argument is repo-relative and the repository is discovered from
2523/// the working directory — the same resolution every other repo-level
2524/// magit action uses. An empty argument counts as absent, because a
2525/// transient argument left at its default renders as an empty string.
2526///
2527/// This is also the seam a future universal-prefix would use: it would
2528/// set the same argument rather than needing a mechanism of its own.
2529fn active_target(ctx: &ActionContext<'_>) -> Option<(std::path::PathBuf, std::path::PathBuf)> {
2530    match ctx.arg_str(FILE_TARGET_SLOT) {
2531        Some(rel) => {
2532            // MR.4: a path given as an ARGUMENT (the picker-routed rows)
2533            // is relative to the buffer's repository, not the process's
2534            // — the no-argument branch below already resolved from the
2535            // file itself, and these two must agree.
2536            let workdir = crate::repo_scope::action_workdir(ctx);
2537            (!workdir.as_os_str().is_empty()).then(|| (workdir, std::path::PathBuf::from(rel)))
2538        }
2539        None => active_file(ctx),
2540    }
2541}
2542
2543/// Slot of the optional `file` argument in every `C-c f` action's
2544/// `args_schema`. One constant so the schema and the reader cannot
2545/// disagree about which slot carries the target.
2546pub(crate) const FILE_TARGET_SLOT: usize = 0;
2547
2548fn active_file(ctx: &ActionContext<'_>) -> Option<(std::path::PathBuf, std::path::PathBuf)> {
2549    let buffer_id = lattice_core::BufferId(ctx.buffer_id.0 as u32);
2550    let path = ctx
2551        .services
2552        .get::<BufferStoreHandle>()?
2553        .path_for(buffer_id)?;
2554    // B3: `gix::discover` requires a directory, and `path` is the file
2555    // — `workdir_for_file` is the form that knows that.
2556    crate::workdir::workdir_for_file(&path)
2557}
2558
2559/// Parse the base branch name stashed in a branch-create prompt
2560/// buffer's synthetic name (`*magit:branch-create-from:<base>*`).
2561/// `None` for any other buffer name — the empty-base case
2562/// (`*magit:branch-create-from:*`) is also rejected since an empty
2563/// base is never a valid ref.
2564fn base_branch_from_prompt_buffer_name(buffer_name: &str) -> Option<String> {
2565    branch_from_prompt_buffer_name(buffer_name, BRANCH_CREATE_PROMPT_PREFIX)
2566}
2567
2568/// The prompt-buffer-name prefixes the branch flows carry their target
2569/// in. **Constants rather than literals**, because the writer
2570/// (`picker_sources`) and the reader (the finish handlers) live in
2571/// different files: a name format load-bearing in two places, spelled
2572/// twice, is exactly the writer/reader drift that left every magit-stash
2573/// chord dead until MG.15.
2574pub(crate) const BRANCH_CREATE_PROMPT_PREFIX: &str = "*magit:branch-create-from:";
2575pub(crate) const BRANCH_CREATE_NO_CHECKOUT_PROMPT_PREFIX: &str =
2576    "*magit:branch-create-nocheckout-from:";
2577pub(crate) const BRANCH_RENAME_PROMPT_PREFIX: &str = "*magit:branch-rename:";
2578
2579/// MG.32: one parser for every `*magit:…:<branch>*` prompt-buffer name.
2580///
2581/// Generalised from `base_branch_from_prompt_buffer_name` rather than
2582/// copied per flow — three near-identical strip-prefix/strip-suffix
2583/// parsers is the shape where a fix lands in one and misses the others.
2584fn branch_from_prompt_buffer_name(buffer_name: &str, prefix: &str) -> Option<String> {
2585    let s = buffer_name.strip_prefix(prefix)?;
2586    let s = s.strip_suffix("*")?;
2587    (!s.is_empty()).then(|| s.to_string())
2588}
2589
2590/// Test-only window onto [`branch_from_prompt_buffer_name`] so
2591/// `picker_sources`' round-trip guard exercises the REAL reader.
2592///
2593/// The point of that guard is that the writer and the reader live in
2594/// different modules; asserting against a re-implementation here would
2595/// prove only that the copy agrees with itself.
2596#[cfg(test)]
2597pub(crate) fn branch_from_prompt_buffer_name_for_test(
2598    buffer_name: &str,
2599    prefix: &str,
2600) -> Option<String> {
2601    branch_from_prompt_buffer_name(buffer_name, prefix)
2602}
2603
2604/// MG.17b: what an argument contributes to the command line.
2605#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2606pub enum RemoteArgKind {
2607    /// A toggle: contributes `arg` when on, nothing when off.
2608    Flag,
2609    /// A value: contributes `arg` followed by the typed text (or just
2610    /// the text when `arg` is empty, for a positional like a remote
2611    /// name). Contributes nothing when unset.
2612    Value {
2613        /// Label shown in the minibuffer while typing.
2614        prompt: &'static str,
2615    },
2616    /// MG.23k: a value that must be **joined** to its argument rather
2617    /// than passed as a separate token.
2618    ///
2619    /// Not a stylistic variant — git rejects the separated form for
2620    /// some options and accepts it for others. `git log --author x`
2621    /// works; `git diff -U 3` and `git diff --unified 3` are both
2622    /// errors, because a long option's value needs `=` and `-U`'s
2623    /// needs gluing on. So the argument carries its own joiner
2624    /// (`"--unified="`) and the value is appended to it. Verified
2625    /// against real git rather than assumed — the separated form was
2626    /// tried first and rejected.
2627    ValueJoined {
2628        /// Label shown in the minibuffer while typing.
2629        prompt: &'static str,
2630    },
2631}
2632
2633/// MG.17a: one argument on a [`RemoteOp`].
2634///
2635/// The single definition, read by four consumers that would otherwise
2636/// drift apart: the ex-command's `args_schema`, the transient menu's
2637/// item, the live preview string, and the argv builder. Adding
2638/// `--prune` to fetch means adding one row here.
2639#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2640pub struct RemoteFlag {
2641    /// Schema slot + transient-state key (`"force"`).
2642    pub name: &'static str,
2643    /// The git argument it contributes (`"--force"`), or `""` for a
2644    /// bare positional value.
2645    pub arg: &'static str,
2646    /// Key that selects it in the transient (`"-f"`).
2647    pub key: &'static str,
2648    /// One-line doc, shown in the menu and in `:describe-command`.
2649    pub doc: &'static str,
2650    /// MG.17b: toggle or value.
2651    pub kind: RemoteArgKind,
2652}
2653
2654/// MG.16: one detached git operation, named once.
2655///
2656/// The transient item (`C-c g p`) and the ex-command (`:magit-pull`)
2657/// both resolve to one of these constants and call
2658/// [`spawn_remote_op`] — the operation is defined in exactly one
2659/// place, so the two surfaces cannot drift in argv, in echo text, or
2660/// in how the outcome is reported.
2661///
2662/// MG.17a: `flags` extends that to the optional arguments. The order
2663/// of the slice IS the `args_schema` order, which is what lets a
2664/// transient toggle and a `--force` on the `:` line resolve to the
2665/// same `Args::List` slot.
2666/// MG.41c: where a remote operation sends or takes its refs.
2667///
2668/// Magit's push / pull / fetch menus are **one operation with several
2669/// destinations**, not several operations — which is why a single
2670/// unlabelled "push" row was the wrong shape. Modelling the
2671/// destination as data means push gains six rows and one handler
2672/// rather than six handlers.
2673#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2674pub enum RemoteTarget {
2675    /// Whatever git itself resolves with no destination argument:
2676    /// `branch.<name>.pushRemote`, then `remote.pushDefault`, then
2677    /// `branch.<name>.remote`. Magit's `p`.
2678    Configured,
2679    /// The branch's `@{upstream}`, resolved explicitly. Magit's `u`.
2680    ///
2681    /// Distinct from [`Self::Configured`] whenever `pushRemote` and
2682    /// the upstream differ — the triangular-workflow case the two rows
2683    /// exist to separate. Git has no single-token spelling for it, so
2684    /// this is resolved with a `rev-parse` at run time.
2685    Upstream,
2686    /// Every configured remote (`--all`). Magit's `a`, fetch only.
2687    AllRemotes,
2688    /// Every tag (`--tags`). Magit's `t`, push only.
2689    AllTags,
2690    /// A destination the user types — a remote, a branch, a refspec, a
2691    /// tag. Magit's `e` / `o` / `r` / `T`, which differ only in what
2692    /// they prompt for.
2693    Prompted,
2694}
2695
2696impl RemoteTarget {
2697    /// Extra argv this target contributes, appended after the flags.
2698    ///
2699    /// `resolved` carries the value a [`Self::Prompted`] target asked
2700    /// for, or the `remote branch` pair a [`Self::Upstream`] lookup
2701    /// produced. Returning a `Vec` rather than an `Option<String>` is
2702    /// what lets `Upstream` expand to two tokens.
2703    pub fn argv(self, resolved: Option<&str>) -> Vec<String> {
2704        match self {
2705            Self::Configured => Vec::new(),
2706            Self::AllRemotes => vec!["--all".to_string()],
2707            Self::AllTags => vec!["--tags".to_string()],
2708            // Both carry a caller-resolved value; an empty one
2709            // contributes nothing rather than an empty argument git
2710            // would read as a real (empty) ref.
2711            Self::Upstream | Self::Prompted => resolved
2712                .map(str::trim)
2713                .filter(|v| !v.is_empty())
2714                .map(|v| v.split_whitespace().map(str::to_string).collect())
2715                .unwrap_or_default(),
2716        }
2717    }
2718}
2719
2720/// MG.41c: resolve `@{upstream}` into the `remote branch` pair a push
2721/// destination needs.
2722///
2723/// `git push` has no `@{upstream}` spelling, so magit computes it and
2724/// so must this. Returns `None` when the branch has no upstream — the
2725/// caller reports that rather than pushing somewhere unintended, which
2726/// is the whole risk this row carries.
2727pub fn resolve_upstream(workdir: &std::path::Path) -> Option<String> {
2728    // A query, not an operation: read stdout as a value. NC.3 made
2729    // `run_remote_op`'s output a report (summary line, then detail),
2730    // which is the wrong shape to parse.
2731    let output = std::process::Command::new("git")
2732        .args([
2733            "rev-parse",
2734            "--abbrev-ref",
2735            "--symbolic-full-name",
2736            "@{upstream}",
2737        ])
2738        .env("GIT_TERMINAL_PROMPT", "0")
2739        .current_dir(workdir)
2740        .output()
2741        .ok()
2742        .filter(|o| o.status.success())?;
2743    let full = String::from_utf8_lossy(&output.stdout).into_owned();
2744    // `origin/main` -> `origin main`. A remote name cannot contain `/`,
2745    // but a BRANCH can (`origin/feature/x`), so split once from the
2746    // left and keep the remainder whole.
2747    let (remote, branch) = full.trim().split_once('/')?;
2748    if remote.is_empty() || branch.is_empty() {
2749        return None;
2750    }
2751    Some(format!("{remote} {branch}"))
2752}
2753
2754#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2755pub struct RemoteOp {
2756    /// NC.4: what this does, as an imperative phrase a user reads
2757    /// ("continue rebase", not "rebase --continue"). The notification
2758    /// label is built from it — see [`Self::label`] — and must read
2759    /// correctly before "failed" as well as before a summary.
2760    pub what: &'static str,
2761    /// The same, in progress, for the fire-time echo ("continuing
2762    /// rebase…"). Spelled out rather than derived: appending "ing" to
2763    /// `what` produced "stage alling" and "cherry-pick --continueing".
2764    pub doing: &'static str,
2765    /// Base argv passed to `git`, before flags.
2766    pub args: &'static [&'static str],
2767    /// Toggleable flags, in `args_schema` order.
2768    pub flags: &'static [RemoteFlag],
2769}
2770
2771impl RemoteOp {
2772    pub const PULL: Self = Self {
2773        what: "pull",
2774        doing: "pulling",
2775        // `--ff-only` stays the default: a pull that can create a merge
2776        // commit behind your back is the wrong default. `--rebase`
2777        // REPLACES it (see `argv`) — it solves the same problem a
2778        // different way, and git rejects the two together.
2779        args: &["pull", "--ff-only"],
2780        flags: &[
2781            RemoteFlag {
2782                name: "rebase",
2783                arg: "--rebase",
2784                key: "-r",
2785                doc: "Rebase local commits onto the fetched head instead of merging",
2786                kind: RemoteArgKind::Flag,
2787            },
2788            RemoteFlag {
2789                name: "autostash",
2790                arg: "--autostash",
2791                key: "-a",
2792                doc: "Stash local changes for the pull and reapply them after",
2793                kind: RemoteArgKind::Flag,
2794            },
2795        ],
2796    };
2797    pub const PUSH: Self = Self {
2798        what: "push",
2799        doing: "pushing",
2800        args: &["push"],
2801        flags: &[
2802            RemoteFlag {
2803                name: "force-with-lease",
2804                // `--force-with-lease`, never bare `--force`: it refuses
2805                // when the remote moved since you last fetched, which is
2806                // exactly the case where a bare force silently destroys
2807                // someone else's commits. Magit defaults to the same.
2808                arg: "--force-with-lease",
2809                key: "-f",
2810                doc: "Force-push, but refuse if the remote moved since your last fetch",
2811                kind: RemoteArgKind::Flag,
2812            },
2813            RemoteFlag {
2814                name: "set-upstream",
2815                arg: "--set-upstream",
2816                key: "-u",
2817                doc: "Set the pushed branch's upstream to the remote branch",
2818                kind: RemoteArgKind::Flag,
2819            },
2820            // MG.41c: magit offers bare `--force` on `-F`. Lattice
2821            // deliberately does NOT, and that predates this slice —
2822            // `force_push_uses_force_with_lease` pins its absence
2823            // because the difference is whether a colleague's commits
2824            // survive when the remote moved under you.
2825            // `--force-with-lease` refuses in exactly that case, so the
2826            // menu is strictly safer and loses nothing a user cannot
2827            // still do from a shell. Matching magit key-for-key does
2828            // not extend to re-adding a footgun someone removed on
2829            // purpose.
2830            RemoteFlag {
2831                name: "no-verify",
2832                arg: "--no-verify",
2833                key: "-h",
2834                doc: "Skip the pre-push hook",
2835                kind: RemoteArgKind::Flag,
2836            },
2837            RemoteFlag {
2838                name: "dry-run",
2839                arg: "--dry-run",
2840                key: "-n",
2841                doc: "Show what would be pushed without sending anything",
2842                kind: RemoteArgKind::Flag,
2843            },
2844        ],
2845    };
2846    pub const FETCH: Self = Self {
2847        what: "fetch",
2848        doing: "fetching",
2849        args: &["fetch"],
2850        flags: &[
2851            RemoteFlag {
2852                name: "all",
2853                arg: "--all",
2854                key: "-a",
2855                doc: "Fetch from every remote, not just the default",
2856                kind: RemoteArgKind::Flag,
2857            },
2858            RemoteFlag {
2859                name: "prune",
2860                arg: "--prune",
2861                key: "-p",
2862                doc: "Delete local refs whose remote branch is gone",
2863                kind: RemoteArgKind::Flag,
2864            },
2865            // MG.41c: APPENDED, not inserted. The slice order IS the
2866            // `args_schema` order, so adding a flag mid-table shifts
2867            // every later slot and silently re-points existing `:`-line
2868            // positional args at the wrong toggle.
2869            RemoteFlag {
2870                name: "tags",
2871                arg: "--tags",
2872                key: "-t",
2873                doc: "Fetch all tags as well as the branches being fetched",
2874                kind: RemoteArgKind::Flag,
2875            },
2876        ],
2877    };
2878    /// MG.34: the way out of a rebase that stopped.
2879    ///
2880    /// `magit-edit-line-commit` marks a commit `edit`, which is only
2881    /// useful if the rebase can be resumed afterwards — and until this
2882    /// slice the ONLY sequencer control was `C-c C-k` inside a todo
2883    /// buffer, so a stopped rebase left the repository in a state the
2884    /// editor could not finish. Shipping the `edit` row without these
2885    /// would have been shipping a trap.
2886    ///
2887    /// `GIT_EDITOR` is forced to `true` by [`run_remote_op`] for the
2888    /// same reason `run_rebase` does it: `--continue` opens the commit
2889    /// message in `$EDITOR`, and an editor we cannot drive would hang
2890    /// the task forever.
2891    // MG.42-E4: the cherry-pick / revert sequencer controls. Both
2892    // sequences stop on conflict and need the same three ways out;
2893    // they are separate consts rather than one shared set because
2894    // `git cherry-pick --continue` and `git revert --continue` are
2895    // different commands and each errors during the other's sequence.
2896    pub const CHERRY_PICK_CONTINUE: Self = Self {
2897        what: "continue cherry-pick",
2898        doing: "continuing cherry-pick",
2899        args: &["cherry-pick", "--continue"],
2900        flags: &[],
2901    };
2902    pub const CHERRY_PICK_SKIP: Self = Self {
2903        what: "skip commit in cherry-pick",
2904        doing: "skipping commit in cherry-pick",
2905        args: &["cherry-pick", "--skip"],
2906        flags: &[],
2907    };
2908    pub const CHERRY_PICK_ABORT: Self = Self {
2909        what: "abort cherry-pick",
2910        doing: "aborting cherry-pick",
2911        args: &["cherry-pick", "--abort"],
2912        flags: &[],
2913    };
2914    pub const REVERT_CONTINUE: Self = Self {
2915        what: "continue revert",
2916        doing: "continuing revert",
2917        args: &["revert", "--continue"],
2918        flags: &[],
2919    };
2920    pub const REVERT_SKIP: Self = Self {
2921        what: "skip commit in revert",
2922        doing: "skipping commit in revert",
2923        args: &["revert", "--skip"],
2924        flags: &[],
2925    };
2926    pub const REVERT_ABORT: Self = Self {
2927        what: "abort revert",
2928        doing: "aborting revert",
2929        args: &["revert", "--abort"],
2930        flags: &[],
2931    };
2932    /// Conclude a merge stopped on a conflict. Equivalent to
2933    /// committing the prepared merge message once the index is clean;
2934    /// git refuses it while unmerged paths remain, which is the right
2935    /// answer and is reported rather than swallowed.
2936    pub const MERGE_CONTINUE: Self = Self {
2937        what: "conclude merge",
2938        doing: "concluding merge",
2939        args: &["merge", "--continue"],
2940        flags: &[],
2941    };
2942    /// Throw the merge away and restore the branch.
2943    pub const MERGE_ABORT: Self = Self {
2944        what: "abort merge",
2945        doing: "aborting merge",
2946        args: &["merge", "--abort"],
2947        flags: &[],
2948    };
2949    pub const REBASE_CONTINUE: Self = Self {
2950        what: "continue rebase",
2951        doing: "continuing rebase",
2952        args: &["rebase", "--continue"],
2953        flags: &[],
2954    };
2955    pub const REBASE_SKIP: Self = Self {
2956        what: "skip commit in rebase",
2957        doing: "skipping commit in rebase",
2958        args: &["rebase", "--skip"],
2959        flags: &[],
2960    };
2961    /// The abort `C-c C-k` runs, reachable when there is no todo buffer
2962    /// open — which is the case once the rebase has actually started.
2963    pub const REBASE_ABORT: Self = Self {
2964        what: "abort rebase",
2965        doing: "aborting rebase",
2966        args: &["rebase", "--abort"],
2967        flags: &[],
2968    };
2969
2970    /// MG.37: the notes operations with no argument. `spawn_remote_op`'s
2971    /// shape — one bounded argv, off-thread, notify — fits them for the
2972    /// same reason it fits the rebase sequencer.
2973    pub const NOTES_PRUNE: Self = Self {
2974        what: "prune notes",
2975        doing: "pruning notes",
2976        args: &["notes", "prune"],
2977        flags: &[RemoteFlag {
2978            name: "dry-run",
2979            arg: "--dry-run",
2980            key: "-n",
2981            doc: "Report what would be dropped without dropping it",
2982            kind: RemoteArgKind::Flag,
2983        }],
2984    };
2985    pub const NOTES_MERGE_COMMIT: Self = Self {
2986        what: "conclude notes merge",
2987        doing: "concluding notes merge",
2988        args: &["notes", "merge", "--commit"],
2989        flags: &[],
2990    };
2991    pub const NOTES_MERGE_ABORT: Self = Self {
2992        what: "abort notes merge",
2993        doing: "aborting notes merge",
2994        args: &["notes", "merge", "--abort"],
2995        flags: &[],
2996    };
2997
2998    /// MG.39: the way out of a `git am` that stopped.
2999    ///
3000    /// `am` stops on a patch that does not apply, exactly as `rebase`
3001    /// stops on `edit` — and for the same reason as MG.34's sequencer
3002    /// commands, shipping the apply without these would leave the
3003    /// repository in a state the editor cannot finish.
3004    pub const AM_CONTINUE: Self = Self {
3005        what: "continue applying patches",
3006        doing: "continuing to apply patches",
3007        args: &["am", "--continue"],
3008        flags: &[],
3009    };
3010    pub const AM_SKIP: Self = Self {
3011        what: "skip patch",
3012        doing: "skipping patch",
3013        args: &["am", "--skip"],
3014        flags: &[],
3015    };
3016    pub const AM_ABORT: Self = Self {
3017        what: "abort applying patches",
3018        doing: "aborting patch application",
3019        args: &["am", "--abort"],
3020        flags: &[],
3021    };
3022
3023    /// MG.23b: magit's `S` — stage every tracked modification at once.
3024    ///
3025    /// `add --update`, matching `magit-stage-modified`: tracked changes
3026    /// only. Untracked files are deliberately NOT swept in — "stage
3027    /// everything" quietly adding a file you never told git about is
3028    /// how build artefacts and secrets get committed. Magit reaches
3029    /// that behaviour behind a prefix argument, which is exactly the
3030    /// deferred `<C-u>` work; until then `s` on the Untracked entry in
3031    /// magit-status is the explicit path.
3032    pub const STAGE_ALL: Self = Self {
3033        what: "stage all tracked changes",
3034        doing: "staging all tracked changes",
3035        args: &["add", "--update"],
3036        flags: &[],
3037    };
3038    /// MG.23b: magit's `U` — unstage everything.
3039    ///
3040    /// A bare `git reset`: the index goes back to HEAD and the working
3041    /// tree is untouched, so nothing is lost and every change is still
3042    /// there to re-stage. That is why it does not ask, per §12.13's
3043    /// no-confirm set for index-only work — the blast radius is wider
3044    /// than one file but it is still fully reversible.
3045    pub const UNSTAGE_ALL: Self = Self {
3046        what: "unstage everything",
3047        doing: "unstaging everything",
3048        args: &["reset", "--quiet"],
3049        flags: &[],
3050    };
3051    /// MG.41d: magit's `x` — stash everything but leave the index
3052    /// staged, so a partially-staged commit can be tried in isolation.
3053    pub const STASH_KEEP_INDEX: Self = Self {
3054        what: "stash, keeping the index",
3055        doing: "stashing, keeping the index",
3056        args: &["stash", "push", "--keep-index"],
3057        flags: &[],
3058    };
3059    /// MG.41d: magit's `i` — stash only what is staged.
3060    pub const STASH_STAGED: Self = Self {
3061        what: "stash staged changes",
3062        doing: "stashing staged changes",
3063        args: &["stash", "push", "--staged"],
3064        flags: &[],
3065    };
3066    pub const STASH: Self = Self {
3067        what: "stash changes",
3068        doing: "stashing changes",
3069        args: &["stash", "push"],
3070        flags: &[
3071            RemoteFlag {
3072                name: "include-untracked",
3073                arg: "--include-untracked",
3074                key: "-u",
3075                doc: "Stash untracked files too, not just tracked changes",
3076                kind: RemoteArgKind::Flag,
3077            },
3078            // MG.17b: the first real `Argument` — a stash message. This is
3079            // the reason to have arguments at all: an unlabelled stash is
3080            // findable only by position, and positions renumber.
3081            RemoteFlag {
3082                name: "message",
3083                arg: "-m",
3084                key: "-m",
3085                doc: "Label the stash so you can recognise it later",
3086                kind: RemoteArgKind::Value {
3087                    prompt: "Stash message",
3088                },
3089            },
3090        ],
3091    };
3092
3093    /// Resolve the full argv for this run: the base plus every flag the
3094    /// caller enabled. `args` is positional by `flags` order — see
3095    /// [`RemoteOp::flags`].
3096    pub fn argv(&self, args: &lattice_grammar::Args) -> Vec<String> {
3097        let mut argv: Vec<String> = self.args.iter().map(|s| (*s).to_string()).collect();
3098        // MG.41c: `--rebase` REPLACES the default `--ff-only` rather
3099        // than joining it — git rejects the pair outright, so leaving
3100        // both in would make the `-r` row fail every time instead of
3101        // doing the obvious thing. Rebase serves the same purpose the
3102        // default was protecting (no surprise merge commit), so
3103        // dropping it here loses no safety.
3104        if self.rebase_selected(args) {
3105            argv.retain(|a| a != "--ff-only");
3106        }
3107        for (i, flag) in self.flags.iter().enumerate() {
3108            let slot = args.as_list().and_then(|l| l.get(i));
3109            match flag.kind {
3110                RemoteArgKind::Flag => {
3111                    if matches!(slot, Some(lattice_grammar::ArgValue::Bool(true))) {
3112                        argv.push(flag.arg.to_string());
3113                    }
3114                }
3115                // An unset value contributes nothing at all — not an
3116                // empty string, which git would read as a real (empty)
3117                // argument.
3118                RemoteArgKind::Value { .. } => {
3119                    if let Some(lattice_grammar::ArgValue::String(v)) = slot
3120                        && !v.is_empty()
3121                    {
3122                        if !flag.arg.is_empty() {
3123                            argv.push(flag.arg.to_string());
3124                        }
3125                        argv.push(v.clone());
3126                    }
3127                }
3128                RemoteArgKind::ValueJoined { .. } => {
3129                    if let Some(lattice_grammar::ArgValue::String(v)) = slot
3130                        && !v.is_empty()
3131                    {
3132                        argv.push(format!("{}{v}", flag.arg));
3133                    }
3134                }
3135            }
3136        }
3137        argv
3138    }
3139
3140    /// NC.4: the notification label for one run, naming what the
3141    /// resolved argv acts on.
3142    ///
3143    /// Built from the argv rather than the transient state, so it
3144    /// describes what actually ran — including a destination resolved
3145    /// at run time. A force-push, a dry run and a push of tags each
3146    /// read differently from a plain push; before this they all read
3147    /// "push".
3148    pub fn label(&self, argv: &[String]) -> String {
3149        let has = |flag: &str| argv.iter().any(|a| a == flag);
3150        // Subcommand words (`stash push`) are not destinations.
3151        let words = self.args.iter().take_while(|a| !a.starts_with('-')).count();
3152        let mut rest: Vec<&str> = Vec::new();
3153        let mut message = None;
3154        let mut tail = argv.iter().skip(words);
3155        while let Some(a) = tail.next() {
3156            if a == "-m" {
3157                message = tail.next().map(String::as_str);
3158            } else if !a.starts_with('-') {
3159                rest.push(a);
3160            }
3161        }
3162        // `origin main` is how an upstream resolves; `origin/main` is
3163        // how people say it.
3164        let dest = match rest.as_slice() {
3165            [remote, branch] if !remote.contains(['/', ':']) && !branch.contains(':') => {
3166                format!("{remote}/{branch}")
3167            }
3168            other => other.join(" "),
3169        };
3170        let mut label = match self.args.first().copied() {
3171            Some("push") => {
3172                let verb = if has("--force-with-lease") {
3173                    "force-push"
3174                } else {
3175                    "push"
3176                };
3177                let mut s = if has("--tags") {
3178                    format!("{verb} tags")
3179                } else {
3180                    verb.to_string()
3181                };
3182                if !dest.is_empty() {
3183                    s.push_str(&format!(" to {dest}"));
3184                }
3185                s
3186            }
3187            Some("fetch") if has("--all") => "fetch all remotes".to_string(),
3188            Some("fetch") | Some("pull") if !dest.is_empty() => format!("{} {dest}", self.what),
3189            Some("pull") if has("--rebase") => "pull and rebase".to_string(),
3190            _ => self.what.to_string(),
3191        };
3192        if let Some(message) = message.filter(|m| !m.is_empty()) {
3193            label.push_str(&format!(" \u{201c}{message}\u{201d}"));
3194        }
3195        if has("--dry-run") {
3196            label.push_str(" (dry run)");
3197        }
3198        label
3199    }
3200
3201    /// Is the `--rebase` toggle on? Looked up by NAME rather than by
3202    /// slot index so it stays correct if the flag table is reordered.
3203    fn rebase_selected(&self, args: &lattice_grammar::Args) -> bool {
3204        self.flags
3205            .iter()
3206            .position(|f| f.name == "rebase")
3207            .and_then(|i| args.as_list().and_then(|l| l.get(i)))
3208            .is_some_and(|v| matches!(v, lattice_grammar::ArgValue::Bool(true)))
3209    }
3210
3211    /// The command line this run would execute, for the transient's
3212    /// live preview. Renders what `argv` will actually pass, so the
3213    /// preview cannot claim one thing and the run do another.
3214    pub fn preview(&self, value_of: &dyn Fn(&str) -> Option<String>) -> String {
3215        let mut out = String::from("git");
3216        for a in self.args {
3217            out.push(' ');
3218            out.push_str(a);
3219        }
3220        for flag in self.flags {
3221            let Some(v) = value_of(flag.name) else {
3222                continue;
3223            };
3224            match flag.kind {
3225                RemoteArgKind::Flag => {
3226                    if v == "true" {
3227                        out.push(' ');
3228                        out.push_str(flag.arg);
3229                    }
3230                }
3231                RemoteArgKind::Value { .. } => {
3232                    if !v.is_empty() {
3233                        if !flag.arg.is_empty() {
3234                            out.push(' ');
3235                            out.push_str(flag.arg);
3236                        }
3237                        // Quoted: a stash message has spaces, and an
3238                        // unquoted preview would read as several args.
3239                        out.push_str(&format!(" {v:?}"));
3240                    }
3241                }
3242                RemoteArgKind::ValueJoined { .. } => {
3243                    if !v.is_empty() {
3244                        out.push(' ');
3245                        out.push_str(flag.arg);
3246                        out.push_str(&v);
3247                    }
3248                }
3249            }
3250        }
3251        out
3252    }
3253
3254    /// `args_schema` for this operation's ex-command — one optional
3255    /// bool per flag, in slice order.
3256    pub fn arg_specs(&self) -> Vec<lattice_grammar::ArgSpec> {
3257        self.flags
3258            .iter()
3259            .map(|f| {
3260                let kind = match f.kind {
3261                    RemoteArgKind::Flag => lattice_grammar::ArgKind::Bool,
3262                    RemoteArgKind::Value { .. } | RemoteArgKind::ValueJoined { .. } => {
3263                        lattice_grammar::ArgKind::String
3264                    }
3265                };
3266                lattice_grammar::ArgSpec::optional(f.name, kind, f.doc)
3267            })
3268            .collect()
3269    }
3270}
3271
3272/// MG.23c1: a repo-level operation whose single argument the user
3273/// types.
3274///
3275/// The shape is the branch-create wizard's, generalised: the menu row
3276/// (or chord) returns [`Effect::OpenPrompt`], and the `-finish` action
3277/// named as its submit target does the work with `ctx.prompt_value`.
3278/// Two actions per operation, no transient state, and the ex-command
3279/// form skips the prompt entirely by taking the value as an argument —
3280/// which is what makes these scriptable rather than menu-only.
3281/// MG.42-E3: the first value of a two-input operation, waiting for the
3282/// second prompt to be answered.
3283///
3284/// A single slot is sufficient and cannot mis-pair. The second prompt
3285/// is only ever opened BY the first's finish handler, so a read is
3286/// always preceded by the matching write; and the second finish
3287/// `take()`s, so a value is consumed once. A cancelled second prompt
3288/// leaves a stale value, which the next chain's first step overwrites
3289/// before anything reads it.
3290static PENDING_FIRST_INPUT: std::sync::Mutex<Option<String>> = std::sync::Mutex::new(None);
3291
3292/// MG.43d: the cherry-move rows carry their resolved commit across
3293/// the branch prompt through the same slot `two_input_op!` uses.
3294pub(crate) fn stash_pending_commit(value: String) {
3295    stash_first_input(value);
3296}
3297
3298/// `prompt_for`, for the half of a cherry-move row that lives in
3299/// `magit_core_mode`.
3300pub(crate) fn prompt_for_pub(prompt: &str, finish_action: &str) -> Effect {
3301    prompt_for(prompt, finish_action)
3302}
3303
3304fn stash_first_input(value: String) {
3305    if let Ok(mut slot) = PENDING_FIRST_INPUT.lock() {
3306        *slot = Some(value);
3307    }
3308}
3309
3310fn take_first_input() -> Option<String> {
3311    PENDING_FIRST_INPUT.lock().ok().and_then(|mut s| s.take())
3312}
3313
3314fn prompt_for(prompt: &str, finish_action: &str) -> Effect {
3315    prompt_seeded(prompt, finish_action, String::new())
3316}
3317
3318/// [`prompt_for`] with the input pre-filled — for a value that has an
3319/// obvious default the user will usually accept and occasionally edit,
3320/// which is what magit does for `init`'s directory.
3321fn prompt_seeded(prompt: &str, finish_action: &str, initial: String) -> Effect {
3322    Effect::OpenPrompt {
3323        prompt: prompt.to_string(),
3324        initial,
3325        on_submit_action: finish_action.to_string(),
3326        buffer_name: None,
3327    }
3328}
3329
3330/// Run a one-shot git command off the actor thread, echoing what was
3331/// asked for and logging what happened.
3332///
3333/// The dynamic-argv peer of [`spawn_remote_op`], whose `RemoteOp` argv
3334/// is static. Same discipline: the echo returns synchronously and the
3335/// real outcome lands in `*messages*` via `tracing`, because there is
3336/// no synchronous path back from a detached task.
3337/// MG.41g: the event bus, captured at install so a spawned task can
3338/// report completion without a handle threaded through every caller.
3339///
3340/// A `OnceLock` rather than a parameter deliberately. `spawn_git` has
3341/// no `ActionContext` to thread anything through, which is precisely
3342/// why the previous `Option<NotificationStoreHandle>` parameter was
3343/// forgotten in five of ten spawners. Set once, at install.
3344static EVENT_BUS: std::sync::OnceLock<std::sync::Arc<lattice_runtime::EventBus>> =
3345    std::sync::OnceLock::new();
3346
3347pub(crate) fn set_event_bus(bus: std::sync::Arc<lattice_runtime::EventBus>) {
3348    let _ = EVENT_BUS.set(bus);
3349}
3350
3351/// The bus `finish_task` publishes on, for modes that need to LISTEN
3352/// to it. `None` in a harness that never installed one.
3353pub(crate) fn event_bus() -> Option<std::sync::Arc<lattice_runtime::EventBus>> {
3354    EVENT_BUS.get().cloned()
3355}
3356
3357/// Does this event mean a magit view is now stale?
3358///
3359/// Only magit's own work does. An LSP request or a compilation
3360/// finishing is also a `BackgroundTaskFinished`, and neither says
3361/// anything about the repository — refreshing on those would run
3362/// `git status` every time a build ended.
3363///
3364/// Deliberately keyed on the event's `source` rather than its `label`:
3365/// labels are human sentences that change with wording, and every
3366/// magit spawner already stamps the same source.
3367pub(crate) fn invalidates_a_magit_view(event: &lattice_protocol::event::Event) -> bool {
3368    matches!(
3369        event,
3370        lattice_protocol::event::Event::BackgroundTaskFinished { source, .. }
3371            if source == "magit"
3372    )
3373}
3374
3375/// NC.2: how a magit operation ended.
3376///
3377/// Three outcomes, not `Result`'s two: a conflicted merge or a rebase
3378/// paused on `edit` has not failed and is not done, and saying either
3379/// sends the user the wrong way. `From<Result<..>>` recognises those
3380/// from git's own output (`git_report::stopped_reason`), so every
3381/// producer gets it without deciding anything.
3382#[derive(Debug, Clone, PartialEq, Eq)]
3383pub(crate) enum TaskResult {
3384    Done(String),
3385    /// The line to show, then git's output.
3386    Stopped(String),
3387    Failed(String),
3388}
3389
3390impl From<Result<String, String>> for TaskResult {
3391    fn from(result: Result<String, String>) -> Self {
3392        if let Some(reason) = crate::git_report::stopped_reason(&result) {
3393            let detail = match result {
3394                Ok(out) | Err(out) => out,
3395            };
3396            return TaskResult::Stopped(format!("{reason}\n{detail}"));
3397        }
3398        match result {
3399            Ok(out) => TaskResult::Done(out),
3400            Err(err) => TaskResult::Failed(err),
3401        }
3402    }
3403}
3404
3405/// NC.2: the name a notification shows for `workdir`'s repository.
3406///
3407/// The basename, until a second repository with the same basename
3408/// reports in this session — from then on both are qualified with their
3409/// parent (`work/api`, `oss/api`). Two notifications that differ only
3410/// in a repository nobody can tell apart are the ambiguity this field
3411/// exists to remove. Learnt from what has reported, because a task has
3412/// no handle on the set of open repositories, and the first report
3413/// from each checkout is the moment that set grows.
3414pub(crate) fn task_scope(workdir: &std::path::Path) -> Option<String> {
3415    use std::collections::{HashMap, HashSet};
3416    use std::sync::{Mutex, OnceLock};
3417    static SEEN: OnceLock<Mutex<HashMap<String, HashSet<std::path::PathBuf>>>> = OnceLock::new();
3418
3419    if workdir.as_os_str().is_empty() {
3420        return None;
3421    }
3422    let base = crate::workdir::repo_label(workdir);
3423    let shared = SEEN
3424        .get_or_init(Default::default)
3425        .lock()
3426        .map(|mut seen| {
3427            let dirs = seen.entry(base.clone()).or_default();
3428            dirs.insert(workdir.to_path_buf());
3429            dirs.len() > 1
3430        })
3431        .unwrap_or(false);
3432    Some(if shared {
3433        crate::workdir::qualified_repo_label(workdir)
3434    } else {
3435        base
3436    })
3437}
3438
3439/// MG.41g: report a finished background operation — log **and**
3440/// publish, in one call.
3441///
3442/// The two are deliberately not separable: a spawner that logged but
3443/// did not publish is exactly the silent-completion bug this replaces,
3444/// and making them one call means the failure mode requires actively
3445/// skipping the helper rather than merely forgetting an argument.
3446///
3447/// NC.2: `workdir` is required for the same reason — the repository
3448/// is what tells two notifications apart, and an optional argument is
3449/// one a spawner forgets.
3450///
3451/// magit never mentions notifications. The notification layer is one
3452/// subscriber on `BackgroundTaskFinished`; LSP, compilation, or a
3453/// plugin get the same treatment by publishing the same event.
3454pub(crate) fn finish_task(workdir: &std::path::Path, label: &str, result: impl Into<TaskResult>) {
3455    use lattice_protocol::event::{Event, TaskOutcome};
3456    let outcome = match result.into() {
3457        TaskResult::Done(out) => {
3458            // `debug!`, not `info!`: the notification tees itself to
3459            // `*messages*`, and two lines saying the same thing is the
3460            // flooding the diagnostic-logging rule warns about.
3461            tracing::debug!(target: "lattice_magit", "magit: {label} succeeded: {out}");
3462            TaskOutcome::Succeeded {
3463                summary: first_line(&out),
3464            }
3465        }
3466        TaskResult::Stopped(why) => {
3467            // `warn!`-worthy, but the notification tees at Warn
3468            // already; the full output belongs in the debug record.
3469            tracing::debug!(target: "lattice_magit", "magit: {label} stopped: {why}");
3470            TaskOutcome::Stopped {
3471                message: first_line(&why),
3472            }
3473        }
3474        TaskResult::Failed(err) => {
3475            // `error!` keeps git's FULL stderr; the published message
3476            // is the truncated one-liner a notification can show.
3477            tracing::error!(target: "lattice_magit", "magit: {label} failed: {err}");
3478            TaskOutcome::Failed {
3479                message: first_line(&err),
3480            }
3481        }
3482    };
3483    if let Some(bus) = EVENT_BUS.get() {
3484        bus.publish(Event::BackgroundTaskFinished {
3485            source: "magit".to_string(),
3486            scope: task_scope(workdir),
3487            label: label.to_string(),
3488            outcome,
3489        });
3490    }
3491}
3492
3493/// Git output is often many lines; a notification shows one.
3494fn first_line(text: &str) -> String {
3495    text.lines().next().unwrap_or_default().trim().to_string()
3496}
3497
3498/// MG.42-E2: one step of a composite operation.
3499pub struct GitStep {
3500    /// Names this step in a failure report — "fixup commit", not the
3501    /// whole argv, which is too long for a notification.
3502    pub name: &'static str,
3503    pub argv: Vec<String>,
3504}
3505
3506/// MG.42-E2: run several git invocations in order as ONE operation.
3507///
3508/// Two properties, both load-bearing:
3509///
3510/// 1. **Aborts on the first failure.** Several magit operations are
3511///    compositions where a half-done result is worse than none — a
3512///    snapshot whose `stash apply` never ran is silently just a stash,
3513///    and the user's working tree is empty when they expected it
3514///    restored. Continuing past a failed step manufactures exactly
3515///    that.
3516/// 2. **Reports once.** One logical operation produces one
3517///    `BackgroundTaskFinished`, naming the step that failed rather
3518///    than the whole sequence. Per-step reporting would turn a
3519///    two-command operation into two notifications, which is how a
3520///    notification surface becomes noise.
3521pub fn spawn_git_sequence(
3522    workdir: std::path::PathBuf,
3523    label: impl Into<String>,
3524    steps: Vec<GitStep>,
3525) -> Effect {
3526    let label = label.into();
3527    let shown = steps
3528        .first()
3529        .map(|s| format!("git {}", s.argv.join(" ")))
3530        .unwrap_or_else(|| label.clone());
3531    let scope_dir = workdir.clone();
3532    tokio::task::spawn(async move {
3533        let result = tokio::task::spawn_blocking(move || {
3534            let mut last = String::new();
3535            for step in steps {
3536                match run_remote_op(&workdir, &step.argv) {
3537                    Ok(out) => last = out,
3538                    // The step name, not the argv: a notification is one
3539                    // line and "fixup commit failed" localises the
3540                    // problem better than a truncated command.
3541                    Err(e) => return Err(format!("{}: {e}", step.name)),
3542                }
3543            }
3544            Ok(last)
3545        })
3546        .await
3547        .unwrap_or_else(|e| Err(e.to_string()));
3548        finish_task(&scope_dir, &label, result);
3549    });
3550    Effect::Echo {
3551        level: lattice_grammar::EchoLevel::Info,
3552        text: format!("magit: {shown}"),
3553    }
3554}
3555
3556/// MG.43c: run a one-commit interactive rebase off the actor thread.
3557///
3558/// `message` is `Some` only for `reword`, where it is what git's
3559/// reword step writes — see `run_rebase_with_message`.
3560pub fn spawn_rebase_verb(workdir: std::path::PathBuf, verb: &'static str, commit: &str) -> Effect {
3561    spawn_rebase_verb_with(workdir, verb, commit, None)
3562}
3563
3564/// NC.4: what a one-commit rebase does, naming the commit.
3565pub(crate) fn rebase_verb_label(verb: &str, commit: &str) -> String {
3566    let commit = short_rev(commit);
3567    match verb {
3568        "edit" => format!("rebase to edit {commit}"),
3569        "drop" => format!("drop {commit} from history"),
3570        "reword" => format!("reword {commit}"),
3571        other => format!("{other} {commit} in a rebase"),
3572    }
3573}
3574
3575pub fn spawn_rebase_verb_with(
3576    workdir: std::path::PathBuf,
3577    verb: &'static str,
3578    commit: &str,
3579    message: Option<String>,
3580) -> Effect {
3581    let label = rebase_verb_label(verb, commit);
3582    let commit = commit.to_string();
3583    let shown = format!("git rebase ({verb} {commit})");
3584    let scope_dir = workdir.clone();
3585    tokio::task::spawn(async move {
3586        let result = tokio::task::spawn_blocking(move || {
3587            crate::magit_rebase_mode::rebase_one_commit(&workdir, &commit, verb, message.as_deref())
3588                .map(|()| String::new())
3589        })
3590        .await
3591        .unwrap_or_else(|e| Err(e.to_string()));
3592        // NC.4: an `edit` that worked has STOPPED, by design — the user
3593        // is meant to amend and continue. "Done" would say otherwise.
3594        let result = match result {
3595            Ok(_) if verb == "edit" => {
3596                TaskResult::Stopped("amend, then continue the rebase".to_string())
3597            }
3598            other => other.into(),
3599        };
3600        finish_task(&scope_dir, &label, result);
3601    });
3602    Effect::Echo {
3603        level: lattice_grammar::EchoLevel::Info,
3604        text: format!("magit: {shown}"),
3605    }
3606}
3607
3608/// MG.43d: run a multi-step operation whose LATER steps depend on
3609/// state only discoverable part-way through.
3610///
3611/// `spawn_git_sequence` takes its steps up front, which cannot express
3612/// "create the branch only if it is missing" or "compare-and-swap the
3613/// ref only if the commit was at the tip". Computing those on the
3614/// actor thread would be git I/O on a keystroke, so the whole
3615/// operation runs inside one `spawn_blocking` and reports once,
3616/// exactly like a sequence does.
3617pub fn spawn_computed<F>(workdir: std::path::PathBuf, label: String, shown: String, f: F) -> Effect
3618where
3619    F: FnOnce(&std::path::Path) -> Result<(), String> + Send + 'static,
3620{
3621    let scope_dir = workdir.clone();
3622    tokio::task::spawn(async move {
3623        let result = tokio::task::spawn_blocking(move || f(&workdir).map(|()| String::new()))
3624            .await
3625            .unwrap_or_else(|e| Err(e.to_string()));
3626        finish_task(&scope_dir, &label, result);
3627    });
3628    Effect::Echo {
3629        level: lattice_grammar::EchoLevel::Info,
3630        text: format!("magit: {shown}"),
3631    }
3632}
3633
3634pub fn spawn_git(workdir: std::path::PathBuf, argv: Vec<String>, what: &str) -> Effect {
3635    let shown = format!("git {}", argv.join(" "));
3636    let logged = shown.clone();
3637    let what = what.to_string();
3638    let scope_dir = workdir.clone();
3639    tokio::task::spawn(async move {
3640        let result = tokio::task::spawn_blocking(move || run_remote_op(&workdir, &argv))
3641            .await
3642            .unwrap_or_else(|e| Err(e.to_string()));
3643        // MG.41g: was log-only, so every `spawn_git` caller finished
3644        // invisibly.
3645        let _ = logged;
3646        finish_task(&scope_dir, &what, result);
3647    });
3648    Effect::Echo {
3649        level: lattice_grammar::EchoLevel::Info,
3650        text: format!("magit: {shown}"),
3651    }
3652}
3653
3654// ── MG.39: `w` am / `W` format-patch ────────────────────────────────
3655
3656/// `git format-patch <range>` — magit's `W` "Create patches".
3657///
3658/// Pure. `-o` defaults to the repository root rather than being left to
3659/// git's cwd: the editor's process directory is not necessarily where
3660/// the user thinks they are, and a scatter of `.patch` files in an
3661/// unexpected directory is tedious to undo.
3662pub(crate) fn format_patch_argv(range: &str, output_dir: Option<&str>) -> Option<Vec<String>> {
3663    let range = range.trim();
3664    if range.is_empty() {
3665        return None;
3666    }
3667    let mut argv = vec!["format-patch".to_string()];
3668    if let Some(dir) = output_dir {
3669        argv.push("-o".to_string());
3670        argv.push(dir.to_string());
3671    }
3672    argv.push(range.to_string());
3673    Some(argv)
3674}
3675
3676/// `git am <mbox>…` — magit's `w` "Apply patches".
3677///
3678/// `--3way` is NOT default. It is magit's `-3` flag, and it changes what
3679/// happens on a conflict: git falls back to a three-way merge and leaves
3680/// conflict markers rather than refusing. That is a better outcome only
3681/// when you expected the patch not to apply cleanly, so it is opt-in —
3682/// the same judgement `--force-with-lease`-not-`--force` makes on push.
3683pub(crate) fn am_argv(files: &str, three_way: bool) -> Option<Vec<String>> {
3684    let files: Vec<&str> = files
3685        .split_whitespace()
3686        .filter(|f| !f.starts_with("--"))
3687        .collect();
3688    if files.is_empty() {
3689        return None;
3690    }
3691    let mut argv = vec!["am".to_string()];
3692    if three_way {
3693        argv.push("--3way".to_string());
3694    }
3695    argv.extend(files.iter().map(|f| f.to_string()));
3696    Some(argv)
3697}
3698
3699/// Does the line ask for a three-way apply? Accepts magit's short flag
3700/// and git's long one, anywhere in the line.
3701pub(crate) fn am_wants_three_way(line: &str) -> bool {
3702    line.split_whitespace().any(|w| w == "--3way" || w == "-3")
3703}
3704
3705// ── MG.38: `git subtree` ────────────────────────────────────────────
3706
3707/// One `git subtree` operation.
3708///
3709/// Peer of [`CommitOp`] / [`RemoteOp`]: the argv template plus what the
3710/// operation needs from the user, so the menu row, the ex-command and
3711/// the prompt all read one definition.
3712#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3713pub struct SubtreeOp {
3714    /// Verb for the echo and the notification.
3715    pub what: &'static str,
3716    /// The `git subtree` subcommand.
3717    pub sub: &'static str,
3718    /// Ex-command name, without the `:`.
3719    pub ex_command: &'static str,
3720    /// Does it take a `<repository>` as well as a `<ref>`? `merge` and
3721    /// `split` do not; `add` / `pull` / `push` do.
3722    pub takes_repo: bool,
3723    /// Does it take a `<ref>` at all? `split` does not.
3724    pub takes_ref: bool,
3725}
3726
3727impl SubtreeOp {
3728    pub const ADD: Self = Self {
3729        what: "subtree add",
3730        sub: "add",
3731        ex_command: "magit-subtree-add",
3732        takes_repo: true,
3733        takes_ref: true,
3734    };
3735    pub const MERGE: Self = Self {
3736        what: "subtree merge",
3737        sub: "merge",
3738        ex_command: "magit-subtree-merge",
3739        takes_repo: false,
3740        takes_ref: true,
3741    };
3742    pub const PULL: Self = Self {
3743        what: "subtree pull",
3744        sub: "pull",
3745        ex_command: "magit-subtree-pull",
3746        takes_repo: true,
3747        takes_ref: true,
3748    };
3749    pub const PUSH: Self = Self {
3750        what: "subtree push",
3751        sub: "push",
3752        ex_command: "magit-subtree-push",
3753        takes_repo: true,
3754        takes_ref: true,
3755    };
3756    pub const SPLIT: Self = Self {
3757        what: "subtree split",
3758        sub: "split",
3759        ex_command: "magit-subtree-split",
3760        takes_repo: false,
3761        takes_ref: false,
3762    };
3763
3764    /// What the prompt asks for, in the order the operation reads them.
3765    pub fn usage(&self) -> &'static str {
3766        match (self.takes_repo, self.takes_ref) {
3767            (true, true) => "<prefix> <repository> <ref>",
3768            (false, true) => "<prefix> <ref>",
3769            _ => "<prefix>",
3770        }
3771    }
3772}
3773
3774/// Build the argv for `op` from one whitespace-separated line.
3775///
3776/// Pure, so the flag shape is pinned without running a subtree — and
3777/// `--prefix=` in particular, which is the one argument every subtree
3778/// operation requires and the one git errors on last, after doing work.
3779///
3780/// `--squash` is accepted as a trailing word on `add` and `pull`, which
3781/// is where magit offers it. Returns `None` when the line does not carry
3782/// what the operation needs, so the caller can print `op.usage()`
3783/// instead of letting git fail with its own less specific message.
3784pub(crate) fn subtree_argv(op: SubtreeOp, line: &str) -> Option<Vec<String>> {
3785    let mut words: Vec<&str> = line.split_whitespace().collect();
3786    let squash = matches!(words.last(), Some(&"--squash") | Some(&"squash"))
3787        && matches!(op.sub, "add" | "pull");
3788    if squash {
3789        words.pop();
3790    }
3791    let wanted = 1 + usize::from(op.takes_repo) + usize::from(op.takes_ref);
3792    if words.len() != wanted {
3793        return None;
3794    }
3795    let mut argv = vec![
3796        "subtree".to_string(),
3797        op.sub.to_string(),
3798        format!("--prefix={}", words[0]),
3799    ];
3800    if squash {
3801        argv.push("--squash".to_string());
3802    }
3803    argv.extend(words[1..].iter().map(|w| w.to_string()));
3804    Some(argv)
3805}
3806
3807/// Run a subtree operation off the actor thread, reporting completion.
3808///
3809/// A notification rather than `spawn_git`'s fire-time echo: every
3810/// subtree operation rewrites history or touches a remote, so "did it
3811/// work" is the question, and an echo written before it starts cannot
3812/// answer it.
3813pub fn spawn_subtree_op(workdir: std::path::PathBuf, op: SubtreeOp, line: &str) -> Effect {
3814    let Some(argv) = subtree_argv(op, line) else {
3815        return Effect::Echo {
3816            level: lattice_grammar::EchoLevel::Error,
3817            text: format!("magit: usage — :{} {}", op.ex_command, op.usage()),
3818        };
3819    };
3820    let what = op.what;
3821    // NC.4: name the subtree — `--prefix=<dir>` is always argv[2].
3822    let prefix = argv
3823        .get(2)
3824        .and_then(|a| a.strip_prefix("--prefix="))
3825        .unwrap_or_default();
3826    let label = format!("{} subtree {prefix}", op.sub);
3827    let scope_dir = workdir.clone();
3828    tokio::task::spawn(async move {
3829        let result = tokio::task::spawn_blocking(move || run_remote_op(&workdir, &argv))
3830            .await
3831            .unwrap_or_else(|e| Err(e.to_string()));
3832        // MG.41g: publish rather than hold a notification handle.
3833        finish_task(&scope_dir, &label, result);
3834    });
3835    Effect::Echo {
3836        level: lattice_grammar::EchoLevel::Info,
3837        text: format!("magit: {what}…"),
3838    }
3839}
3840
3841// ── MG.37: the notes operations that are not a buffer ───────────────
3842
3843/// `git notes merge <ref> [--strategy=<s>]`, parsed from one line.
3844///
3845/// Pure, so the flag shape is pinned without running a merge — the same
3846/// reason `clone_argv` / `tag_argv` / `log_merged_argv` are.
3847///
3848/// The strategy is optional and trailing (`<ref> ours`). An unrecognised
3849/// second word is an ERROR rather than a ref with a typo'd strategy
3850/// silently merged manually — `NoteMergeStrategy::parse` refuses, and
3851/// this returns `None` so the caller can say so.
3852pub(crate) fn note_merge_argv(spec: &str) -> Option<Vec<String>> {
3853    let spec = spec.trim();
3854    let (git_ref, strategy) = match spec.split_once(char::is_whitespace) {
3855        Some((r, s)) => (r, lattice_vcs::NoteMergeStrategy::parse(s)?),
3856        None => (spec, lattice_vcs::NoteMergeStrategy::Manual),
3857    };
3858    if git_ref.is_empty() {
3859        return None;
3860    }
3861    Some(vec![
3862        "notes".to_string(),
3863        "merge".to_string(),
3864        format!("--strategy={}", strategy.as_str()),
3865        git_ref.to_string(),
3866    ])
3867}
3868
3869/// Remove one commit's note, off the actor thread.
3870pub(crate) fn spawn_note_remove(workdir: std::path::PathBuf, commit: String) -> Effect {
3871    spawn_git(
3872        workdir,
3873        vec![
3874            "notes".to_string(),
3875            "remove".to_string(),
3876            "--ignore-missing".to_string(),
3877            commit.clone(),
3878        ],
3879        &format!("remove the note on {}", short_rev(&commit)),
3880    )
3881}
3882
3883/// Prune unreachable notes, reporting the outcome.
3884///
3885/// A notification rather than `spawn_git`'s echo: prune's whole result
3886/// is *what it removed*, and an echo written at fire time cannot carry
3887/// it.
3888pub(crate) fn spawn_note_prune(workdir: std::path::PathBuf) -> Effect {
3889    spawn_remote_op(workdir, RemoteOp::NOTES_PRUNE, &lattice_grammar::Args::None)
3890}
3891
3892/// Merge a notes ref into the current one.
3893pub(crate) fn spawn_note_merge(workdir: std::path::PathBuf, spec: &str) -> Effect {
3894    let label = format!(
3895        "merge notes from {}",
3896        spec.split_whitespace().next().unwrap_or(spec)
3897    );
3898    let Some(argv) = note_merge_argv(spec) else {
3899        return Effect::Echo {
3900            level: lattice_grammar::EchoLevel::Error,
3901            text: "magit: usage — <notes-ref> [manual|ours|theirs|union|cat_sort_uniq]".to_string(),
3902        };
3903    };
3904    let scope_dir = workdir.clone();
3905    tokio::task::spawn(async move {
3906        let result = tokio::task::spawn_blocking(move || run_remote_op(&workdir, &argv))
3907            .await
3908            .unwrap_or_else(|e| Err(e.to_string()));
3909        // MG.41g: publish rather than post.
3910        finish_task(&scope_dir, &label, result);
3911    });
3912    Effect::Echo {
3913        level: lattice_grammar::EchoLevel::Info,
3914        text: format!("magit: merging notes from {spec}…"),
3915    }
3916}
3917
3918// ── MG.36: `C` clone ────────────────────────────────────────────────
3919
3920/// The argv for cloning `url` into `dest`.
3921///
3922/// Pure, so the flags are pinned without running a clone — the same
3923/// shape `tag_argv` / `merge_argv` / `log_merged_argv` have.
3924///
3925/// `--` before the operands: a URL or a destination beginning with `-`
3926/// would otherwise be read as an option. That is not hypothetical for
3927/// the destination, which the user types.
3928pub(crate) fn clone_argv(url: &str, dest: &str) -> Vec<String> {
3929    vec![
3930        "clone".to_string(),
3931        "--".to_string(),
3932        url.to_string(),
3933        dest.to_string(),
3934    ]
3935}
3936
3937/// The directory name `git clone <url>` would pick on its own.
3938///
3939/// Both URL shapes have to work, because both are what people paste:
3940/// `https://host/owner/repo.git` splits on `/`, and
3941/// `git@host:owner/repo.git` puts the interesting part after a `:`.
3942/// Trailing slashes are common in copied URLs and `.git` is usual on
3943/// the SSH form; neither belongs in a directory name.
3944///
3945/// Empty when nothing usable is left — the caller then asks rather than
3946/// pre-filling a prompt with a name it invented.
3947pub(crate) fn default_clone_dest(url: &str) -> String {
3948    let trimmed = url.trim().trim_end_matches('/');
3949    let last = trimmed.rsplit(['/', ':']).next().unwrap_or_default().trim();
3950    last.strip_suffix(".git").unwrap_or(last).to_string()
3951}
3952
3953/// Clone `url` into `dest`, off the actor thread.
3954///
3955/// Not [`spawn_git`], which runs in `magit_workdir()` — that would put
3956/// the clone *inside* the repository you are already in, which is
3957/// almost never what anyone means, and is silent when it happens.
3958/// `dest` is resolved against the process's own directory instead, and
3959/// the prompt pre-fills it absolute so the answer to "where did it go"
3960/// is on screen before the clone starts.
3961///
3962/// **The notification says what the clone does NOT do.** Magit shows the
3963/// new repository's status buffer afterwards; that is not reachable here
3964/// (`magit_workdir` is process-wide, and there is no `:cd`), so the
3965/// magit buffers keep pointing at the repository the editor was launched
3966/// in. Saying so once, at the moment it matters, is the difference
3967/// between a documented limit and a user concluding the clone failed.
3968pub fn spawn_clone(url: String, dest: String) -> Effect {
3969    let argv = clone_argv(&url, &dest);
3970    let label = format!("clone into {dest}");
3971    let scope_dir = std::path::PathBuf::from(&dest);
3972    tokio::task::spawn(async move {
3973        let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from("."));
3974        let result = tokio::task::spawn_blocking(move || run_remote_op(&cwd, &argv))
3975            .await
3976            .unwrap_or_else(|e| Err(e.to_string()));
3977        // MG.41g: publish rather than post.
3978        finish_task(&scope_dir, &label, result);
3979    });
3980    Effect::Echo {
3981        level: lattice_grammar::EchoLevel::Info,
3982        text: format!("magit: cloning into {dest}…"),
3983    }
3984}
3985
3986/// MG.23c1: append `pattern` to the repository's `.gitignore`.
3987///
3988/// Not a git command — git has no "add to gitignore" subcommand, so
3989/// this is a file append, done on `spawn_blocking` like every other
3990/// blocking call in this crate.
3991///
3992/// **Skips a pattern already present**, comparing whole trimmed lines.
3993/// Ignoring the same path twice is harmless to git but grows the file
3994/// and makes it harder to read, and the user pressing `i` twice on the
3995/// same build artefact is an ordinary mistake rather than an intent to
3996/// duplicate.
3997pub fn spawn_gitignore(workdir: std::path::PathBuf, pattern: String) -> Effect {
3998    let shown = pattern.clone();
3999    let label = format!("ignore {pattern}");
4000    let scope_dir = workdir.clone();
4001    tokio::task::spawn(async move {
4002        let result = tokio::task::spawn_blocking(move || -> Result<String, String> {
4003            // MR.4: `.gitignore` is written in the repository the buffer
4004            // belongs to. This discovered from `"."` — a cwd site the
4005            // `magit_workdir()` sweep did not catch, because it spelled
4006            // the discovery out rather than calling the helper.
4007            if workdir.as_os_str().is_empty() {
4008                return Err("not a git repository".to_string());
4009            }
4010            let path = workdir.join(".gitignore");
4011            let existing = std::fs::read_to_string(&path).unwrap_or_default();
4012            let Some(out) = gitignore_append(&existing, &pattern) else {
4013                return Ok("it was already ignored".to_string());
4014            };
4015            std::fs::write(&path, out).map_err(|e| e.to_string())?;
4016            Ok(String::new())
4017        })
4018        .await
4019        .unwrap_or_else(|e| Err(e.to_string()));
4020        // MG.41g: was log-only.
4021        finish_task(&scope_dir, &label, result);
4022    });
4023    Effect::Echo {
4024        level: lattice_grammar::EchoLevel::Info,
4025        text: format!("magit: ignoring {shown}"),
4026    }
4027}
4028
4029/// MG.23d: the argv for each file operation.
4030///
4031/// Pure and shared, for the reason the MG.23c builders are — the flags
4032/// are what matters and a test must not have to run git in the
4033/// repository it lives in.
4034///
4035/// `untrack` is `rm --cached`: the file **stays on disk** and only
4036/// leaves the index, which is why it does not ask. `delete` is a plain
4037/// `rm` with no `-f`, so git itself refuses to remove a file with
4038/// uncommitted changes — the confirm is the second line of defence, not
4039/// the only one.
4040pub(crate) fn untrack_argv(path: &str) -> Vec<String> {
4041    vec![
4042        "rm".into(),
4043        "--cached".into(),
4044        "--".into(),
4045        path.to_string(),
4046    ]
4047}
4048
4049pub(crate) fn delete_argv(path: &str) -> Vec<String> {
4050    vec!["rm".into(), "--".into(), path.to_string()]
4051}
4052
4053pub(crate) fn rename_argv(from: &str, to: &str) -> Vec<String> {
4054    vec!["mv".into(), "--".into(), from.to_string(), to.to_string()]
4055}
4056
4057/// MG.23d2: `git checkout <rev> -- <path>` — the file as it was at
4058/// `rev`, written over the working-tree copy.
4059///
4060/// `--` is load-bearing rather than decorative: without it a path that
4061/// happens to match a ref name is ambiguous, and git resolves the
4062/// ambiguity by checking out the *branch*.
4063pub(crate) fn checkout_file_argv(rev: &str, path: &str) -> Vec<String> {
4064    vec![
4065        "checkout".into(),
4066        rev.to_string(),
4067        "--".into(),
4068        path.to_string(),
4069    ]
4070}
4071
4072/// The path a rename prompt is carrying, from its buffer name.
4073///
4074/// The prompt buffer is the active one by the time the user submits, so
4075/// `active_target` would resolve *it* rather than the file being
4076/// renamed. The name is the carrier — the same trick the branch-create
4077/// wizard uses for its base branch.
4078pub(crate) fn rename_source_from_prompt_buffer_name(buffer_name: &str) -> Option<String> {
4079    path_from_prompt_buffer_name(buffer_name, "*magit:rename:")
4080}
4081
4082/// MG.23d2: the same carrier, for the checkout prompt.
4083pub(crate) fn checkout_target_from_prompt_buffer_name(buffer_name: &str) -> Option<String> {
4084    path_from_prompt_buffer_name(buffer_name, "*magit:checkout:")
4085}
4086
4087/// MG.21g: the bad end of a bisect, carried from the first prompt to
4088/// the second. Same carrier as rename/checkout — by submit time the
4089/// prompt buffer is the active one, so nothing else still knows it.
4090pub(crate) const BISECT_START_PREFIX: &str = "*magit:bisect-start:";
4091
4092pub(crate) fn bisect_start_buffer_name(bad: &str) -> String {
4093    format!("{BISECT_START_PREFIX}{bad}*")
4094}
4095
4096pub(crate) fn bad_from_bisect_start_buffer_name(buffer_name: &str) -> Option<String> {
4097    path_from_prompt_buffer_name(buffer_name, BISECT_START_PREFIX)
4098}
4099
4100/// Run a bisect operation off the actor thread, then refresh every
4101/// live magit view.
4102///
4103/// A bisect mark moves HEAD, so nothing that reads HEAD is still
4104/// accurate — the status buffer, an open log, an open diff. There is no
4105/// synchronous path back from the detached task (§4.6), so the log is
4106/// the report and the refresh is what the user sees.
4107fn spawn_bisect(
4108    ctx: &ActionContext<'_>,
4109    what: &'static str,
4110    op: impl FnOnce(&lattice_vcs::Repository) -> lattice_vcs::Result<Option<lattice_vcs::BisectStep>>
4111    + Send
4112    + 'static,
4113) {
4114    let Some(views) = ctx.services.get::<crate::buffer_state::MagitViewsHandle>() else {
4115        return;
4116    };
4117    let workdir = crate::repo_scope::action_workdir(ctx);
4118    tokio::task::spawn(async move {
4119        let wd = workdir.clone();
4120        let outcome =
4121            tokio::task::spawn_blocking(move || match lattice_vcs::Repository::discover(&wd) {
4122                Ok(repo) => op(&repo),
4123                Err(e) => Err(lattice_vcs::VcsError::Bisect(format!(
4124                    "no repository at {}: {e}",
4125                    wd.display()
4126                ))),
4127            })
4128            .await;
4129        // MG.41g: bisect marks check out a different commit — a
4130        // completion the user very much wants to see. NC.6: git's own
4131        // report says which, or that the search is over.
4132        let label = bisect_label(what);
4133        let result = match outcome {
4134            Ok(Ok(step)) => Ok(step.map(|s| bisect_summary(&s)).unwrap_or_default()),
4135            Ok(Err(e)) => Err(e.to_string()),
4136            Err(e) => Err(format!("panicked: {e}")),
4137        };
4138        finish_task(&workdir, &label, result);
4139        for view in views.all() {
4140            let _ = view.refresh();
4141        }
4142    });
4143}
4144
4145/// NC.4: what each bisect step does, as a user says it.
4146fn bisect_label(what: &str) -> String {
4147    match what {
4148        "start" => "start bisecting".to_string(),
4149        "reset" => "end the bisect".to_string(),
4150        mark => format!("mark the bisect commit {mark}"),
4151    }
4152}
4153
4154/// NC.6: what a bisect step did, in one line.
4155///
4156/// The culprit is the message a bisect exists to produce, so it leads;
4157/// an in-progress step names the commit to test next and how far is
4158/// left, the same numbers `git bisect` prints.
4159fn bisect_summary(step: &lattice_vcs::BisectStep) -> String {
4160    use lattice_vcs::BisectStep;
4161    match step {
4162        BisectStep::Found { commit, subject } => {
4163            format!("first bad commit is {} {subject}", short_rev(commit))
4164        }
4165        BisectStep::Testing {
4166            commit,
4167            subject,
4168            revisions_left,
4169            steps,
4170        } => {
4171            let mut s = format!("now testing {} {subject}", short_rev(commit));
4172            if let Some(left) = revisions_left {
4173                s.push_str(&format!(", {left} left"));
4174            }
4175            if let Some(steps) = steps {
4176                s.push_str(&format!(" (about {steps} more)"));
4177            }
4178            s
4179        }
4180        BisectStep::Other(line) => line.clone(),
4181    }
4182}
4183
4184fn path_from_prompt_buffer_name(buffer_name: &str, prefix: &str) -> Option<String> {
4185    let s = buffer_name.strip_prefix(prefix)?;
4186    let s = s.strip_suffix('*')?;
4187    (!s.is_empty()).then(|| s.to_string())
4188}
4189
4190/// MG.23c: the argv each prompt-backed operation runs.
4191///
4192/// Pure, and separate from [`spawn_git`], for two reasons. The flags
4193/// are the part worth testing — `--no-edit` on merge is what stops git
4194/// opening an `$EDITOR` that never appears — and a test that reached
4195/// them through the spawning path would run **real git against the
4196/// repository the tests live in**. It would also be the only copy: the
4197/// action handler and the ex-command both build their argv here rather
4198/// than each inline.
4199pub(crate) fn tag_argv(name: &str) -> Vec<String> {
4200    vec!["tag".into(), name.to_string()]
4201}
4202
4203pub(crate) fn init_argv(dir: &str) -> Vec<String> {
4204    vec!["init".into(), dir.to_string()]
4205}
4206
4207/// `--no-edit` for the same reason `revert` passes it: git would open
4208/// `$EDITOR` for the merge message, and inside lattice that is a wait
4209/// on a prompt that never appears — a hang `Command::output()` cannot
4210/// recover from.
4211/// NC.4: the label for each merge flavour, naming the branch. The
4212/// flavours leave the repository in different states, so they must not
4213/// read alike.
4214pub(crate) fn merge_label(kind: &str, branch: &str) -> String {
4215    match kind {
4216        "no-commit" => format!("merge {branch} without committing"),
4217        "squash" => format!("squash {branch} into the index"),
4218        _ => format!("merge {branch}"),
4219    }
4220}
4221
4222/// NC.4: `am`'s label, from its argv — the patches it was given.
4223pub(crate) fn am_label(argv: &[String]) -> String {
4224    let files: Vec<&str> = argv
4225        .iter()
4226        .skip(1)
4227        .map(String::as_str)
4228        .filter(|a| !a.starts_with('-'))
4229        .collect();
4230    match files.as_slice() {
4231        [one] => format!("apply patch {one}"),
4232        many => format!("apply {} patches", many.len()),
4233    }
4234}
4235
4236/// NC.4: `format-patch`'s label, from its argv — the range written.
4237pub(crate) fn format_patch_label(argv: &[String]) -> String {
4238    let range = argv.last().map(String::as_str).unwrap_or_default();
4239    format!("write patches for {range}")
4240}
4241
4242pub(crate) fn merge_argv(branch: &str) -> Vec<String> {
4243    vec!["merge".into(), "--no-edit".into(), branch.to_string()]
4244}
4245
4246/// MG.41e: magit's `n` — merge but stop before committing, so the
4247/// result can be inspected or amended first.
4248///
4249/// No `--no-edit`: nothing is committed, so git never opens an editor
4250/// and the hang that flag exists to avoid cannot happen here.
4251pub(crate) fn merge_no_commit_argv(branch: &str) -> Vec<String> {
4252    vec!["merge".into(), "--no-commit".into(), branch.to_string()]
4253}
4254
4255/// MG.41e: magit's `s` — take the branch's changes as ONE staged
4256/// change with no merge commit and no second parent.
4257pub(crate) fn merge_squash_argv(branch: &str) -> Vec<String> {
4258    vec!["merge".into(), "--squash".into(), branch.to_string()]
4259}
4260
4261/// MG.42-E3: magit's reset `f` — restore ONE path from a commit.
4262///
4263/// `checkout <commit> -- <path>`, not `reset`: this replaces the file's
4264/// content in both index and working tree, which is what "reset a file
4265/// to that commit" means. A `reset` would move index entries only and
4266/// leave the file on disk untouched — the same words, a different
4267/// outcome.
4268pub(crate) fn reset_file_argv(commit: &str, path: &str) -> Vec<String> {
4269    vec![
4270        "checkout".into(),
4271        commit.to_string(),
4272        "--".into(),
4273        path.to_string(),
4274    ]
4275}
4276
4277/// MG.43e: magit's tag `r` — an ANNOTATED release tag.
4278///
4279/// `-a` (with `-m`) is what separates this from the plain `t` row: a
4280/// release tag carries a tagger, a date and a message, and is a real
4281/// object rather than a pointer. Dropping `-a` would silently produce
4282/// a lightweight tag that most release tooling ignores.
4283pub(crate) fn tag_release_argv(name: &str, message: &str) -> Vec<String> {
4284    vec![
4285        "tag".into(),
4286        "-a".into(),
4287        name.to_string(),
4288        "-m".into(),
4289        message.to_string(),
4290    ]
4291}
4292
4293/// MG.43e: magit's tag `p` — drop local tags that are gone from the
4294/// remote.
4295///
4296/// `--prune-tags` implies nothing on its own: it needs `--prune` AND a
4297/// remote, or git prunes nothing and reports success. Both are
4298/// therefore explicit here rather than left to config.
4299pub(crate) fn tag_prune_argv(remote: &str) -> Vec<String> {
4300    vec![
4301        "fetch".into(),
4302        "--prune".into(),
4303        "--prune-tags".into(),
4304        remote.to_string(),
4305    ]
4306}
4307
4308/// MG.43e: magit's merge `i` — merge the CURRENT branch into another,
4309/// then delete the current one.
4310///
4311/// The mirror of `a` absorb, which merges another branch into this one
4312/// and deletes that one. The direction is the whole difference, and
4313/// getting it backwards deletes the wrong branch — so the steps are
4314/// spelled out rather than shared with absorb's builder.
4315///
4316/// Deletes with `-d`, never `-D`, for absorb's reason: git refuses
4317/// `-d` on a branch that is not fully merged, so a failed merge leaves
4318/// the branch intact.
4319pub(crate) fn merge_into_steps(current: &str, target: &str) -> Vec<GitStep> {
4320    vec![
4321        GitStep {
4322            name: "checkout the target branch",
4323            argv: vec!["checkout".into(), target.to_string()],
4324        },
4325        GitStep {
4326            name: "merge",
4327            argv: merge_argv(current),
4328        },
4329        GitStep {
4330            name: "delete the merged branch",
4331            argv: vec!["branch".into(), "-d".into(), current.to_string()],
4332        },
4333    ]
4334}
4335
4336/// The branch `HEAD` is on, for operations that act on "this branch".
4337pub(crate) fn current_branch(workdir: &std::path::Path) -> Option<String> {
4338    let out = std::process::Command::new("git")
4339        .args(["rev-parse", "--abbrev-ref", "HEAD"])
4340        .current_dir(workdir)
4341        .output()
4342        .ok()?;
4343    if !out.status.success() {
4344        return None;
4345    }
4346    let branch = String::from_utf8(out.stdout).ok()?.trim().to_string();
4347    // Detached HEAD reports `HEAD`, which is not a branch anyone can
4348    // merge or delete — the caller must decline rather than act on it.
4349    (!branch.is_empty() && branch != "HEAD").then_some(branch)
4350}
4351
4352/// MG.43b: magit's rebase `p` / `u` / `e` — rebase onto a ref.
4353///
4354/// **The target is a single revision, not a `remote branch` pair.**
4355/// `RemoteTarget::Upstream` expands `@{upstream}` to two tokens
4356/// because `git push` wants `<remote> <branch>`; `git rebase` wants
4357/// one revision, and would read a second token as the *upstream*
4358/// argument — silently rebasing a different range. Git resolves
4359/// `@{upstream}` and `@{push}` natively as revisions, so these rows
4360/// pass them through untouched rather than reusing that resolution.
4361pub(crate) fn rebase_onto_argv(target: &str) -> Vec<String> {
4362    vec!["rebase".into(), target.to_string()]
4363}
4364
4365/// MG.43b: magit's rebase `s` — rebase a SUBSET of commits elsewhere.
4366///
4367/// `--onto <newbase> <upstream>` replays the commits after `upstream`
4368/// onto `newbase`. Both arguments are required and the order is not
4369/// interchangeable: swapping them replays the wrong range onto the
4370/// wrong base, and git will happily do it.
4371pub(crate) fn rebase_subset_argv(newbase: &str, upstream: &str) -> Vec<String> {
4372    vec![
4373        "rebase".into(),
4374        "--onto".into(),
4375        newbase.to_string(),
4376        upstream.to_string(),
4377    ]
4378}
4379
4380/// MG.43b: magit's rebase `f` — replay, folding in `fixup!` /
4381/// `squash!` markers.
4382///
4383/// `-i` is required even though nothing is edited interactively:
4384/// `--autosquash` only applies to the generated todo list, which is an
4385/// interactive-rebase concept. `run_remote_op`'s
4386/// `GIT_SEQUENCE_EDITOR=true` accepts that list unchanged, which IS
4387/// autosquash — git has already ordered the lines.
4388pub(crate) fn rebase_autosquash_argv(upstream: &str) -> Vec<String> {
4389    vec![
4390        "rebase".into(),
4391        "-i".into(),
4392        "--autosquash".into(),
4393        upstream.to_string(),
4394    ]
4395}
4396
4397/// MG.42-E3: magit's stash `b` — start a branch from a stash.
4398///
4399/// `git stash branch` checks out a new branch at the commit the stash
4400/// was made from, applies the stash, and drops it on success. Useful
4401/// exactly when a stash no longer applies to the current HEAD.
4402pub(crate) fn stash_branch_argv(branch: &str, stash: &str) -> Vec<String> {
4403    vec![
4404        "stash".into(),
4405        "branch".into(),
4406        branch.to_string(),
4407        stash.to_string(),
4408    ]
4409}
4410
4411/// MG.42-E2: magit's `Z` / `I` / `W` snapshots — stash, then put it
4412/// straight back.
4413///
4414/// The point is a restore point that costs nothing: the stack gets an
4415/// entry, the working tree is untouched. `apply`, never `pop` — a pop
4416/// would remove the very entry the snapshot exists to create.
4417pub(crate) fn stash_snapshot_steps(extra: &[&str]) -> Vec<GitStep> {
4418    let mut push: Vec<String> = vec!["stash".into(), "push".into()];
4419    push.extend(extra.iter().map(|s| (*s).to_string()));
4420    vec![
4421        GitStep {
4422            name: "stash",
4423            argv: push,
4424        },
4425        GitStep {
4426            name: "restore working tree",
4427            argv: vec!["stash".into(), "apply".into()],
4428        },
4429    ]
4430}
4431
4432/// MG.42-E2: magit's `F` / `S` — record a `fixup!` / `squash!` commit
4433/// and immediately fold it in.
4434///
4435/// The rebase base is `<commit>~1`: the fixup has to be replayed
4436/// alongside the commit it targets, so the rebase must start one
4437/// before it. `--autostash` because the user is very often
4438/// mid-edit — without it an instant fixup fails on a dirty tree,
4439/// which is precisely when people reach for it.
4440pub(crate) fn instant_squash_steps(kind: &'static str, commit: &str) -> Vec<GitStep> {
4441    vec![
4442        GitStep {
4443            name: "record the marker commit",
4444            argv: vec![
4445                "commit".into(),
4446                "--no-edit".into(),
4447                format!("--{kind}"),
4448                commit.to_string(),
4449            ],
4450        },
4451        GitStep {
4452            name: "autosquash",
4453            argv: vec![
4454                "rebase".into(),
4455                "-i".into(),
4456                "--autosquash".into(),
4457                "--autostash".into(),
4458                format!("{commit}~1"),
4459            ],
4460        },
4461    ]
4462}
4463
4464/// MG.42-E2: magit's `a` absorb — merge a branch, then delete it.
4465///
4466/// `-d`, never `-D`: git refuses to delete a branch that is not fully
4467/// merged, so if the merge did not actually take, the branch survives.
4468/// A forced delete here would destroy the branch precisely in the case
4469/// where the merge failed.
4470pub(crate) fn merge_absorb_steps(branch: &str) -> Vec<GitStep> {
4471    vec![
4472        GitStep {
4473            name: "merge",
4474            argv: merge_argv(branch),
4475        },
4476        GitStep {
4477            name: "delete the merged branch",
4478            argv: vec!["branch".into(), "-d".into(), branch.to_string()],
4479        },
4480    ]
4481}
4482
4483/// MG.41e: magit's `k` — delete a tag.
4484///
4485/// Local only. Deleting the remote copy is `push --delete`, a
4486/// different and far more consequential operation that magit also
4487/// keeps separate.
4488pub(crate) fn tag_delete_argv(name: &str) -> Vec<String> {
4489    vec!["tag".into(), "-d".into(), name.to_string()]
4490}
4491
4492/// MG.23c: every prompt-backed operation, as (row, finish) pairs.
4493///
4494/// **Hand-kept, and honest about it.** A row added here is checked
4495/// against production — its prompt must target the finish action named,
4496/// and that action must exist — but a row added to production and *not*
4497/// here is simply unchecked. Deriving it would mean invoking every
4498/// contributed handler to see which return `OpenPrompt`, and some of
4499/// those handlers spawn git; a test that called them would run real
4500/// commands against the repository it lives in.
4501///
4502/// The pairing check below is the compensation: it is what catches the
4503/// failure that is otherwise silent from the code's side, where a
4504/// prompt accepts input and does nothing with it.
4505#[cfg(test)]
4506pub(crate) const PROMPTED_OPS: &[(&str, &str)] = &[
4507    ("action:magit-global-tag", "action:magit-global-tag-finish"),
4508    (
4509        "action:magit-global-gitignore",
4510        "action:magit-global-gitignore-finish",
4511    ),
4512    (
4513        "action:magit-global-init",
4514        "action:magit-global-init-finish",
4515    ),
4516];
4517
4518/// MG.52: rows that open a BRANCH PICKER, paired with the ex-command the
4519/// picked branch is handed to.
4520///
4521/// The peer of [`PROMPTED_OPS`], and the reason it exists: these used to
4522/// be prompts and moving them left `each_prompt_row_targets_a_finish_action_that_exists`
4523/// with nothing to say about them. A row that silently stopped opening
4524/// its picker — or opened one naming a command nobody registered — would
4525/// otherwise be invisible to the suite.
4526#[cfg(test)]
4527pub(crate) const PICKED_BRANCH_OPS: &[(&str, &str)] = &[
4528    ("action:magit-global-merge", "magit-merge"),
4529    ("action:magit-global-branch-reset", "magit-branch-reset"),
4530    // NOT `branch-checkout-rev` — that row is magit's `b`
4531    // (branch/revision) and takes the REVISION picker, which is the
4532    // only thing that keeps it distinct from `l`. See
4533    // `the_branch_revision_row_is_not_the_local_branch_row`.
4534    // MG.53.a
4535    (
4536        "action:magit-global-merge-no-commit",
4537        "magit-merge-no-commit",
4538    ),
4539    ("action:magit-global-merge-squash", "magit-merge-squash"),
4540    (
4541        "action:magit-global-rebase-onto-elsewhere",
4542        "magit-rebase-onto",
4543    ),
4544    (
4545        "action:magit-global-rebase-autosquash",
4546        "magit-rebase-autosquash",
4547    ),
4548    // MG.53.b — the three whose ex-command is not one git call.
4549    ("action:magit-global-merge-absorb", "magit-merge-absorb"),
4550    ("action:magit-global-merge-edit", "magit-merge-edit"),
4551    ("action:magit-global-merge-into", "magit-merge-into"),
4552];
4553
4554/// MG.53.d: rows backed by a picker that is NOT the branch one, as
4555/// `(action, picker source, ex-command)`.
4556///
4557/// Kept apart from [`PICKED_BRANCH_OPS`] because the source is the
4558/// thing worth asserting here — `t p` prunes tags but takes a REMOTE,
4559/// and pointing it at the tag picker would list the wrong nouns while
4560/// still running a real command. Only checking "opens some picker"
4561/// would miss that.
4562#[cfg(test)]
4563pub(crate) const PICKED_OTHER_OPS: &[(&str, &str, &str)] = &[
4564    (
4565        "action:magit-global-tag-delete",
4566        crate::picker_sources::TAG_PICK_SOURCE,
4567        "magit-tag-delete",
4568    ),
4569    (
4570        "action:magit-global-tag-prune",
4571        crate::picker_sources::REMOTE_PICK_SOURCE,
4572        "magit-tag-prune",
4573    ),
4574    // MG.53.e
4575    (
4576        "action:magit-global-note-merge",
4577        crate::picker_sources::REF_PICK_SOURCE,
4578        "magit-note-merge",
4579    ),
4580];
4581
4582/// The `.gitignore` content after adding `pattern`, or `None` when it
4583/// is already ignored.
4584///
4585/// Split from [`spawn_gitignore`] so the rule is testable without a
4586/// repository or a runtime — the same split `classify_line` /
4587/// `classify_line_text` uses, and the reason the test exercises this
4588/// rather than a copy of it.
4589pub(crate) fn gitignore_append(existing: &str, pattern: &str) -> Option<String> {
4590    if existing.lines().any(|l| l.trim() == pattern.trim()) {
4591        return None;
4592    }
4593    let mut out = existing.to_string();
4594    // A file not ending in a newline would otherwise glue the new
4595    // pattern onto the last one, silently ignoring neither.
4596    if !out.is_empty() && !out.ends_with('\n') {
4597        out.push('\n');
4598    }
4599    out.push_str(pattern.trim());
4600    out.push('\n');
4601    Some(out)
4602}
4603
4604/// Run `op` off the actor thread and return the optimistic echo.
4605///
4606/// `GIT_TERMINAL_PROMPT=0` (in [`run_remote_op`]) makes a missing or
4607/// expired credential fail fast and cleanly instead of hanging the
4608/// background task on interactive input that can never arrive. The
4609/// echo returns synchronously; the real outcome lands via `tracing`,
4610/// same as every other detached background mutation in this crate —
4611/// no synchronous path exists back to the echo area from a task that
4612/// outlives the call, so success and failure are logged rather than
4613/// silently dropped (never both silent AND absent).
4614/// MG.41c: run a remote op against an explicit [`RemoteTarget`].
4615///
4616/// One handler for every destination row. `Upstream` resolves
4617/// `@{upstream}` on the blocking pool before running — it needs a git
4618/// call, and doing it here rather than at menu-build time keeps the
4619/// menu free of I/O.
4620///
4621/// A destination that cannot be resolved **aborts rather than falling
4622/// back** to a bare push. Falling back would silently send refs
4623/// somewhere the user did not choose, which is the one failure this
4624/// row must not have.
4625pub fn spawn_remote_op_to(
4626    workdir: std::path::PathBuf,
4627    op: RemoteOp,
4628    args: &lattice_grammar::Args,
4629    target: RemoteTarget,
4630    prompted: Option<String>,
4631) -> Effect {
4632    let base = op.argv(args);
4633    let shown = base.join(" ");
4634    let scope_dir = workdir.clone();
4635    tokio::task::spawn(async move {
4636        // The label names the resolved destination, so it is built
4637        // where the destination resolves; a resolution failure is
4638        // reported under the base label.
4639        let fallback = op.label(&base);
4640        let (label, result) = tokio::task::spawn_blocking(move || {
4641            let resolved = match target {
4642                RemoteTarget::Upstream => match resolve_upstream(&workdir) {
4643                    Some(v) => Some(v),
4644                    None => {
4645                        return (
4646                            op.label(&base),
4647                            Err("no upstream configured for this branch — set one, \
4648                                 or pick a destination explicitly"
4649                                .to_string()),
4650                        );
4651                    }
4652                },
4653                RemoteTarget::Prompted => match prompted.as_deref() {
4654                    Some(v) if !v.trim().is_empty() => Some(v.to_string()),
4655                    _ => return (op.label(&base), Err("no destination given".to_string())),
4656                },
4657                _ => None,
4658            };
4659            let mut argv = base;
4660            argv.extend(target.argv(resolved.as_deref()));
4661            (op.label(&argv), run_remote_op(&workdir, &argv))
4662        })
4663        .await
4664        .unwrap_or_else(|e| (fallback, Err(e.to_string())));
4665        finish_task(&scope_dir, &label, result);
4666    });
4667    Effect::Echo {
4668        level: lattice_grammar::EchoLevel::Info,
4669        text: format!("magit: {shown}"),
4670    }
4671}
4672
4673pub fn spawn_remote_op(
4674    workdir: std::path::PathBuf,
4675    op: RemoteOp,
4676    args: &lattice_grammar::Args,
4677) -> Effect {
4678    let argv = op.argv(args);
4679    let shown = argv.join(" ");
4680    let label = op.label(&argv);
4681    let scope_dir = workdir.clone();
4682    tokio::task::spawn(async move {
4683        let result = tokio::task::spawn_blocking(move || run_remote_op(&workdir, &argv))
4684            .await
4685            .unwrap_or_else(|e| Err(e.to_string()));
4686        // NOTIF.1d / MG.41g: the completion the echo could never
4687        // carry — the echo is written at *fire* time, so before this
4688        // the operation succeeded invisibly and failed only into
4689        // `*messages*`.
4690        //
4691        // Publishes rather than posting: magit no longer knows the
4692        // notification subsystem exists, and `finish_task` keeps the
4693        // `debug!`-on-success / `error!`-on-failure split (the failure
4694        // log carries git's FULL stderr; the published message is the
4695        // one line a notification can show).
4696        finish_task(&scope_dir, &label, result);
4697    });
4698    Effect::Echo {
4699        level: lattice_grammar::EchoLevel::Info,
4700        // Naming the flags in the echo matters: a force-push and an
4701        // ordinary push are the same word otherwise, and the echo is
4702        // the only synchronous feedback this path produces.
4703        text: format!("magit: {}… (git {shown})", op.doing),
4704    }
4705}
4706
4707/// MG.20: an operation that acts on ONE commit.
4708///
4709/// Reset, revert and cherry-pick share a shape the [`RemoteOp`]s above
4710/// do not: they need a target — the commit under the cursor — so they
4711/// cannot be fired from a context-free global handler. The target is
4712/// resolved through [`crate::buffer_state::MagitView::commit_at_cursor`],
4713/// which every view answers for its own row format.
4714#[derive(Debug, Clone, Copy, PartialEq, Eq)]
4715pub struct CommitOp {
4716    /// The git spelling, for the echo, the confirm prompt and the
4717    /// ex-command's doc line — places that show the command.
4718    pub what: &'static str,
4719    /// NC.4: what it does, for the notification, completed by the
4720    /// commit it acted on ("hard-reset to 3f2a1c0"). The argv it used to
4721    /// show there named a full sha and several flags, and read the same
4722    /// for every op in a burst.
4723    pub label: &'static str,
4724    /// MG.41d: tokens appended AFTER the commit (see [`Self::argv`]).
4725    /// Empty for every op that takes none.
4726    pub trailing: &'static [&'static str],
4727    /// MG.23j: this op's ex-command name, without the `:`.
4728    ///
4729    /// Load-bearing in two places beyond documentation. It is the
4730    /// scriptable surface (`:magit-cherry-pick <sha>`), and it is what
4731    /// the commit picker fires: a picked candidate resolves to the ex
4732    /// line `"<ex_command> <sha>"`.
4733    ///
4734    /// **That shape was once the ONLY route** — the host's
4735    /// `InvokeCommand` arm destructured `args` away, so a value not
4736    /// baked into the line was lost. Fixed 2026-08-03: the arm now
4737    /// renders `args` onto the line, and both forms work. This one is
4738    /// kept because it is also the exact text a user would type, which
4739    /// keeps the picker a front-end to the command rather than a second
4740    /// way in.
4741    pub ex_command: &'static str,
4742    /// argv template; the resolved commit is appended.
4743    pub args: &'static [&'static str],
4744    /// When set, the operation asks before running and this is the
4745    /// `-execute` half it fires on "yes". Reset --hard is the only one
4746    /// today: it discards working-tree changes irrecoverably, which is
4747    /// the same bar `x` / branch-delete / stash-drop are held to.
4748    pub confirm_action: Option<&'static str>,
4749}
4750
4751impl CommitOp {
4752    pub const REVERT: Self = Self {
4753        what: "revert",
4754        label: "revert",
4755        trailing: &[],
4756        ex_command: "magit-revert",
4757        // `--no-edit` keeps the generated message: lattice has no
4758        // commit-message UI wired into this path, so opening $EDITOR
4759        // inside the editor would hang the operation on a prompt the
4760        // user cannot answer.
4761        args: &["revert", "--no-edit"],
4762        confirm_action: None,
4763    };
4764    pub const CHERRY_PICK: Self = Self {
4765        what: "cherry-pick",
4766        label: "cherry-pick",
4767        trailing: &[],
4768        ex_command: "magit-cherry-pick",
4769        args: &["cherry-pick"],
4770        confirm_action: None,
4771    };
4772    /// MG.43f: magit's reset `w` — reset the WORKING TREE to a
4773    /// commit, leaving HEAD and the index alone.
4774    ///
4775    /// `git restore --source <commit> --worktree` is exactly this, and
4776    /// is why the row needs no plumbing: `reset` moves HEAD, and
4777    /// `checkout <commit> -- .` writes the index too. Verified against
4778    /// real git — a file staged before the restore is still staged
4779    /// after it.
4780    ///
4781    /// The commit sits between `--source` and the rest, which is what
4782    /// `trailing` is for.
4783    pub const RESET_WORKTREE: Self = Self {
4784        what: "restore worktree",
4785        label: "restore the worktree from",
4786        trailing: &["--worktree", "--", "."],
4787        ex_command: "magit-reset-worktree",
4788        args: &["restore", "--source"],
4789        // Overwrites uncommitted working-tree changes, the same bar
4790        // `--hard` is held to.
4791        confirm_action: Some("action:magit-reset-worktree-execute"),
4792    };
4793
4794    /// MG.43a: magit's revert `v` — apply the inverse to the working
4795    /// tree and index WITHOUT committing.
4796    ///
4797    /// `--no-commit` is the whole row: `V` records a revert commit,
4798    /// `v` leaves the reversal staged so it can be edited, split, or
4799    /// combined before committing. No `--no-edit` here because nothing
4800    /// is committed, so git never opens an editor to hang on.
4801    pub const REVERT_CHANGES: Self = Self {
4802        what: "revert --no-commit",
4803        label: "apply the revert of",
4804        trailing: &[],
4805        ex_command: "magit-revert-changes",
4806        args: &["revert", "--no-commit"],
4807        confirm_action: None,
4808    };
4809    /// MG.43a: magit's cherry-pick `a` — apply the commit's changes
4810    /// without recording a commit. Peer of [`Self::REVERT_CHANGES`].
4811    pub const CHERRY_PICK_APPLY: Self = Self {
4812        what: "cherry-pick --no-commit",
4813        label: "apply the changes of",
4814        trailing: &[],
4815        ex_command: "magit-cherry-pick-apply",
4816        args: &["cherry-pick", "--no-commit"],
4817        confirm_action: None,
4818    };
4819    pub const RESET_SOFT: Self = Self {
4820        what: "reset --soft",
4821        label: "soft-reset to",
4822        trailing: &[],
4823        ex_command: "magit-reset-soft",
4824        args: &["reset", "--soft"],
4825        confirm_action: None,
4826    };
4827    pub const RESET_MIXED: Self = Self {
4828        what: "reset --mixed",
4829        label: "reset to",
4830        trailing: &[],
4831        ex_command: "magit-reset-mixed",
4832        args: &["reset", "--mixed"],
4833        confirm_action: None,
4834    };
4835    pub const RESET_HARD: Self = Self {
4836        what: "reset --hard",
4837        label: "hard-reset to",
4838        trailing: &[],
4839        ex_command: "magit-reset-hard",
4840        args: &["reset", "--hard"],
4841        // The only one that destroys uncommitted work.
4842        confirm_action: Some("action:magit-reset-hard-execute"),
4843    };
4844
4845    /// MG.41d: magit's `k` — move HEAD but refuse if that would
4846    /// discard uncommitted work, unlike `--hard` which discards it
4847    /// silently. No confirm precisely because git itself declines
4848    /// rather than destroying anything.
4849    pub const RESET_KEEP: Self = Self {
4850        what: "reset --keep",
4851        label: "reset (keeping changes) to",
4852        trailing: &[],
4853        ex_command: "magit-reset-keep",
4854        args: &["reset", "--keep"],
4855        confirm_action: None,
4856    };
4857    /// MG.41d: magit's `i` — set the index to `commit` WITHOUT moving
4858    /// HEAD. The trailing `--` is what makes it index-only; the same
4859    /// command without it moves HEAD too.
4860    pub const RESET_INDEX: Self = Self {
4861        what: "reset index",
4862        label: "reset the index to",
4863        trailing: &["--"],
4864        ex_command: "magit-reset-index",
4865        args: &["reset"],
4866        confirm_action: None,
4867    };
4868    /// MG.41d: magit's `f` fixup — record a `fixup!` commit that a
4869    /// later `rebase --autosquash` folds into `commit`.
4870    pub const COMMIT_FIXUP: Self = Self {
4871        what: "commit --fixup",
4872        label: "record a fixup for",
4873        trailing: &[],
4874        ex_command: "magit-commit-fixup",
4875        args: &["commit", "--no-edit", "--fixup"],
4876        confirm_action: None,
4877    };
4878    /// MG.41d: magit's `s` squash — like fixup, but the message is
4879    /// kept for editing when the autosquash runs.
4880    pub const COMMIT_SQUASH: Self = Self {
4881        what: "commit --squash",
4882        label: "record a squash for",
4883        trailing: &[],
4884        ex_command: "magit-commit-squash",
4885        args: &["commit", "--no-edit", "--squash"],
4886        confirm_action: None,
4887    };
4888
4889    /// Full argv for `commit`.
4890    pub fn argv(&self, commit: &str) -> Vec<String> {
4891        let mut argv: Vec<String> = self.args.iter().map(|s| (*s).to_string()).collect();
4892        argv.push(commit.to_string());
4893        // MG.41d: tokens that must follow the commit. `git reset
4894        // <commit> --` resets the index WITHOUT moving HEAD; the same
4895        // words before the commit mean something else entirely, so the
4896        // position is load-bearing rather than cosmetic.
4897        argv.extend(self.trailing.iter().map(|s| (*s).to_string()));
4898        argv
4899    }
4900}
4901
4902/// Run a [`CommitOp`] against `commit`, off the actor thread.
4903///
4904/// Same detached shape as [`spawn_remote_op`]: optimistic echo now,
4905/// real outcome through `tracing`, because nothing can carry a result
4906/// back to an echo area from a task that outlives the handler call.
4907pub fn spawn_commit_op(op: CommitOp, workdir: std::path::PathBuf, commit: &str) -> Effect {
4908    let argv = op.argv(commit);
4909    let shown = argv.join(" ");
4910    let label = format!("{} {}", op.label, short_rev(commit));
4911    let scope_dir = workdir.clone();
4912    tokio::task::spawn(async move {
4913        let result = tokio::task::spawn_blocking(move || run_remote_op(&workdir, &argv))
4914            .await
4915            .unwrap_or_else(|e| Err(e.to_string()));
4916        // MG.41g: was log-only.
4917        finish_task(&scope_dir, &label, result);
4918    });
4919    Effect::Echo {
4920        level: lattice_grammar::EchoLevel::Info,
4921        // Name the commit: these operations are indistinguishable from
4922        // each other in the echo area otherwise, and two of them
4923        // rewrite history.
4924        text: format!("magit: git {shown}"),
4925    }
4926}
4927
4928/// NC.4: a revision as a notification names it — a full sha cut to
4929/// the seven characters git itself abbreviates to; anything else (a
4930/// branch, `HEAD~2`) as given.
4931pub(crate) fn short_rev(rev: &str) -> &str {
4932    let rev = rev.trim();
4933    if rev.len() > 7 && rev.bytes().all(|b| b.is_ascii_hexdigit()) {
4934        &rev[..7]
4935    } else {
4936        rev
4937    }
4938}
4939
4940/// Run a git subcommand for a remote operation (pull/push), with
4941/// `GIT_TERMINAL_PROMPT=0` so a missing credential fails fast instead
4942/// of hanging. `git push`'s human-readable progress goes to stderr
4943/// even on success, so both streams are checked.
4944fn run_remote_op(workdir: &std::path::Path, args: &[String]) -> Result<String, String> {
4945    let output = std::process::Command::new("git")
4946        .args(args)
4947        .env("GIT_TERMINAL_PROMPT", "0")
4948        // MG.34: `rebase --continue` opens `$EDITOR` on the commit
4949        // message. There is no editor here that git can drive, so
4950        // without this the child blocks forever holding a blocking-pool
4951        // thread and the operation never reports either way. `true`
4952        // accepts the message unchanged — the same limitation, and the
4953        // same reason, as `run_rebase`'s `GIT_EDITOR`.
4954        .env("GIT_EDITOR", "true")
4955        // MG.42-E2: the todo-list editor, distinct from `GIT_EDITOR`.
4956        // `rebase -i --autosquash` opens the generated todo list; `true`
4957        // accepts it unchanged, which is exactly what autosquash means
4958        // — git has already ordered the fixup!/squash! lines. Without
4959        // this an instant-fixup hangs the same way `--continue` would
4960        // without `GIT_EDITOR`.
4961        .env("GIT_SEQUENCE_EDITOR", "true")
4962        .current_dir(workdir)
4963        .output();
4964    // NC.3: the line that says what happened goes FIRST, git's full
4965    // output after it — `finish_task` publishes the first line and
4966    // logs the rest. Taking stdout-then-stderr as it came made a push
4967    // read `To <url>` and a conflicted merge read nothing at all.
4968    match output {
4969        Ok(o) => {
4970            let out = String::from_utf8_lossy(&o.stdout);
4971            let err = String::from_utf8_lossy(&o.stderr);
4972            if o.status.success() {
4973                Ok(crate::git_report::success_report(args, &out, &err))
4974            } else {
4975                Err(crate::git_report::failure_report(
4976                    &out,
4977                    &err,
4978                    o.status.code(),
4979                ))
4980            }
4981        }
4982        Err(e) => Err(e.to_string()),
4983    }
4984}
4985
4986#[cfg(test)]
4987mod tests {
4988    use super::*;
4989
4990    /// MG.23d — untrack keeps the file, delete does not force.
4991    ///
4992    /// The distinction is the whole reason untrack does not ask and
4993    /// delete does. `--cached` leaves the file on disk; a plain `rm`
4994    /// with no `-f` makes git itself refuse to remove a file with
4995    /// uncommitted changes, so the confirm is the second line of
4996    /// defence rather than the only one.
4997    #[test]
4998    fn untrack_keeps_the_file_and_delete_never_forces() {
4999        let untrack = untrack_argv("src/main.rs");
5000        assert!(
5001            untrack.contains(&"--cached".to_string()),
5002            "untrack must leave the file on disk — without `--cached` it \
5003             would delete it, and it does not ask: {untrack:?}"
5004        );
5005        let delete = delete_argv("src/main.rs");
5006        assert!(
5007            !delete.iter().any(|a| a == "-f" || a == "--force"),
5008            "delete must let git refuse a modified file rather than \
5009             overriding that refusal: {delete:?}"
5010        );
5011        // `--` before the path, or a file named like a flag is read as
5012        // one.
5013        for argv in [untrack, delete, rename_argv("a", "b")] {
5014            assert!(
5015                argv.contains(&"--".to_string()),
5016                "paths must be separated from flags: {argv:?}"
5017            );
5018        }
5019    }
5020
5021    /// MG.23d — the rename prompt carries its source in the buffer
5022    /// name, because by submit time the prompt buffer is the active one
5023    /// and nothing else still knows which file this was.
5024    #[test]
5025    fn a_rename_prompt_carries_its_source_in_its_buffer_name() {
5026        assert_eq!(
5027            rename_source_from_prompt_buffer_name("*magit:rename:src/main.rs*").as_deref(),
5028            Some("src/main.rs")
5029        );
5030        // Another buffer's name must not be read as a rename source —
5031        // the finish handler would otherwise rename something arbitrary.
5032        assert!(rename_source_from_prompt_buffer_name("*magit:status*").is_none());
5033        assert!(
5034            rename_source_from_prompt_buffer_name("*magit:rename:*").is_none(),
5035            "an empty source is not a path"
5036        );
5037    }
5038
5039    /// MG.23d2 — the checkout argv, where `--` is not cosmetic.
5040    ///
5041    /// `git checkout <rev> <path>` without it is ambiguous when the path
5042    /// matches a ref name, and git resolves the ambiguity by checking
5043    /// out the *branch* — a wrong action, not an error.
5044    #[test]
5045    fn checkout_file_names_the_revision_then_separates_the_path() {
5046        let argv = checkout_file_argv("HEAD~2", "main");
5047        assert_eq!(argv, vec!["checkout", "HEAD~2", "--", "main"]);
5048        let sep = argv.iter().position(|a| a == "--").expect("a separator");
5049        assert!(
5050            sep < argv.len() - 1 && argv.iter().position(|a| a == "HEAD~2").unwrap() < sep,
5051            "the revision goes before the separator and the path after: {argv:?}"
5052        );
5053    }
5054
5055    /// MG.23d2 — the checkout prompt carries its path the same way the
5056    /// rename prompt does, and the two carriers must not read each
5057    /// other's buffers: a checkout finish that accepted a rename prompt's
5058    /// name would overwrite a file the user was only renaming.
5059    #[test]
5060    fn the_checkout_prompt_carries_its_path_and_only_its_own() {
5061        assert_eq!(
5062            checkout_target_from_prompt_buffer_name("*magit:checkout:src/main.rs*").as_deref(),
5063            Some("src/main.rs")
5064        );
5065        assert!(checkout_target_from_prompt_buffer_name("*magit:rename:src/main.rs*").is_none());
5066        assert!(rename_source_from_prompt_buffer_name("*magit:checkout:src/main.rs*").is_none());
5067        assert!(
5068            checkout_target_from_prompt_buffer_name("*magit:checkout:*").is_none(),
5069            "an empty target is not a path"
5070        );
5071    }
5072
5073    /// MG.23d2 — the execute half acts on both carried slots or on
5074    /// nothing.
5075    ///
5076    /// The other execute halves fall back to re-deriving their target,
5077    /// which is safe because the fallback is "the file you are looking
5078    /// at". There is no such guess for a revision, and checking out from
5079    /// the wrong one is exactly the damage the confirm exists to
5080    /// prevent — so a missing slot must produce no git call at all.
5081    #[test]
5082    fn checkout_execute_declines_when_a_slot_is_missing() {
5083        use lattice_mode::Mode as _;
5084
5085        let handlers = MagitGlobalMode.action_handlers();
5086        let handler = handlers
5087            .iter()
5088            .find(|c| c.action_name == "action:magit-global-file-checkout-execute")
5089            .expect("contributed")
5090            .handler
5091            .clone();
5092        let services = lattice_mode::ServiceRegistry::new();
5093        let events = lattice_runtime::EventBus::new();
5094
5095        for args in [
5096            lattice_grammar::Args::None,
5097            // A revision and no path — the shape a confirm raised by a
5098            // path that carries less than it should would produce.
5099            lattice_grammar::Args::List(vec![lattice_grammar::ArgValue::String(
5100                "HEAD".to_string(),
5101            )]),
5102        ] {
5103            let ctx = ActionContext {
5104                buffer_id: lattice_protocol::ids::BufferId::new(1),
5105                cursor: lattice_protocol::position::Position::new(0, 0),
5106                selection: None,
5107                services: &services,
5108                events: &events,
5109                prompt_value: None,
5110                args,
5111                buffer_locals: None,
5112            };
5113            assert!(
5114                handler(&ctx).is_none(),
5115                "a half-carried confirm must run no git command"
5116            );
5117        }
5118    }
5119
5120    /// MG.23c2 — merge passes `--no-edit`, and nothing else opens an
5121    /// editor either.
5122    ///
5123    /// Asserted on the argv rather than by running it: git would
5124    /// otherwise open `$EDITOR` for the merge message and hang on a
5125    /// prompt that never appears — and a test that spawned it would run
5126    /// real git against the repository the tests live in.
5127    #[test]
5128    fn no_prompted_operation_can_open_an_editor() {
5129        assert_eq!(
5130            merge_argv("feature/x"),
5131            vec!["merge", "--no-edit", "feature/x"],
5132            "merge must never be able to open an editor"
5133        );
5134        // The other two have no editor-spawning form, but pin their
5135        // argv so a later flag cannot introduce one unnoticed.
5136        assert_eq!(tag_argv("v1.2.0"), vec!["tag", "v1.2.0"]);
5137        assert_eq!(init_argv("/tmp/x"), vec!["init", "/tmp/x"]);
5138        for argv in [merge_argv("b"), tag_argv("t"), init_argv("d")] {
5139            assert!(
5140                !argv.iter().any(|a| a == "--edit" || a == "-e"),
5141                "no prompted operation may request an editor: {argv:?}"
5142            );
5143        }
5144    }
5145
5146    // ── MG.38: subtree ──────────────────────────────────────────────
5147
5148    /// `--prefix=` is the argument every subtree operation requires, and
5149    /// the one git checks LAST — after it has already done work. Pinned
5150    /// so a refactor cannot drop it silently.
5151    #[test]
5152    fn every_subtree_op_carries_its_prefix() {
5153        for (op, line) in [
5154            (SubtreeOp::ADD, "vendor/lib https://host/lib.git main"),
5155            (SubtreeOp::MERGE, "vendor/lib main"),
5156            (SubtreeOp::PULL, "vendor/lib https://host/lib.git main"),
5157            (SubtreeOp::PUSH, "vendor/lib https://host/lib.git main"),
5158            (SubtreeOp::SPLIT, "vendor/lib"),
5159        ] {
5160            let argv = subtree_argv(op, line).expect("well-formed");
5161            assert_eq!(argv[0], "subtree");
5162            assert_eq!(argv[1], op.sub);
5163            assert_eq!(
5164                argv[2], "--prefix=vendor/lib",
5165                "{} lost its prefix: {argv:?}",
5166                op.sub
5167            );
5168        }
5169    }
5170
5171    /// The wrong number of words is refused rather than passed to git.
5172    ///
5173    /// `subtree add <prefix> <repo> <ref>` with the ref missing is
5174    /// `subtree add <prefix> <repo>` — which git accepts as a DIFFERENT
5175    /// form, so a silent pass-through would do something the user did
5176    /// not ask for instead of erroring.
5177    #[test]
5178    fn the_wrong_argument_count_is_refused() {
5179        assert_eq!(
5180            subtree_argv(SubtreeOp::ADD, "vendor/lib https://host/lib.git"),
5181            None
5182        );
5183        assert_eq!(subtree_argv(SubtreeOp::ADD, "vendor/lib"), None);
5184        assert_eq!(subtree_argv(SubtreeOp::SPLIT, "vendor/lib extra"), None);
5185        assert_eq!(subtree_argv(SubtreeOp::MERGE, ""), None);
5186    }
5187
5188    /// `--squash` is offered where magit offers it and nowhere else:
5189    /// `git subtree push --squash` is not a thing, and accepting it
5190    /// would build an argv git rejects.
5191    #[test]
5192    fn squash_is_accepted_only_where_it_means_something() {
5193        let added = subtree_argv(
5194            SubtreeOp::ADD,
5195            "vendor/lib https://host/lib.git main --squash",
5196        )
5197        .expect("valid");
5198        assert!(added.contains(&"--squash".to_string()), "{added:?}");
5199        // On push the trailing word is not a flag, so the count check
5200        // rejects the line rather than silently dropping the word.
5201        assert_eq!(
5202            subtree_argv(
5203                SubtreeOp::PUSH,
5204                "vendor/lib https://host/lib.git main --squash"
5205            ),
5206            None,
5207            "push has no --squash; the line must not be silently truncated"
5208        );
5209    }
5210
5211    // ── MG.39: am / format-patch ────────────────────────────────────
5212
5213    /// `--3way` is opt-in. It changes what a failed apply DOES — falling
5214    /// back to a three-way merge with conflict markers rather than
5215    /// refusing — so it must not be on by default, the same judgement
5216    /// `--force-with-lease`-not-`--force` makes on push.
5217    #[test]
5218    fn three_way_apply_is_opt_in_and_reachable_by_both_spellings() {
5219        assert_eq!(
5220            am_argv("0001.patch", false),
5221            Some(vec!["am".to_string(), "0001.patch".to_string()]),
5222            "no flag unless asked"
5223        );
5224        assert!(am_wants_three_way("0001.patch -3"));
5225        assert!(am_wants_three_way("--3way 0001.patch"));
5226        assert!(!am_wants_three_way("0001.patch"));
5227        let three = am_argv("0001.patch", true).expect("valid");
5228        assert_eq!(three[1], "--3way");
5229    }
5230
5231    /// The flag words must not be mistaken for patch files, or `git am`
5232    /// would be handed `-3` as a path and fail on a file that does not
5233    /// exist.
5234    #[test]
5235    fn flag_words_are_not_treated_as_patch_files() {
5236        assert_eq!(
5237            am_argv("--3way a.patch b.patch", true),
5238            Some(vec![
5239                "am".to_string(),
5240                "--3way".to_string(),
5241                "a.patch".to_string(),
5242                "b.patch".to_string(),
5243            ])
5244        );
5245        assert_eq!(am_argv("--3way", true), None, "flags alone are not patches");
5246        assert_eq!(am_argv("", false), None);
5247    }
5248
5249    /// The output directory is explicit. `format-patch` with none writes
5250    /// into the process's current directory, which is not necessarily
5251    /// where the user thinks they are — and a scatter of `.patch` files
5252    /// somewhere unexpected is tedious to undo.
5253    #[test]
5254    fn format_patch_names_its_output_directory() {
5255        let argv = format_patch_argv("@{upstream}..HEAD", Some("/repo")).expect("valid");
5256        assert_eq!(
5257            argv,
5258            vec!["format-patch", "-o", "/repo", "@{upstream}..HEAD"]
5259        );
5260        assert_eq!(
5261            format_patch_argv("HEAD~3..HEAD", None).expect("valid"),
5262            vec!["format-patch", "HEAD~3..HEAD"]
5263        );
5264        assert_eq!(
5265            format_patch_argv("  ", Some("/repo")),
5266            None,
5267            "a range is required"
5268        );
5269    }
5270
5271    // ── MG.37: notes ────────────────────────────────────────────────
5272
5273    /// A bare ref merges with git's own default strategy, and the
5274    /// strategy is a trailing word when given.
5275    #[test]
5276    fn note_merge_argv_defaults_to_manual_and_accepts_a_strategy() {
5277        assert_eq!(
5278            note_merge_argv("refs/notes/other"),
5279            Some(vec![
5280                "notes".to_string(),
5281                "merge".to_string(),
5282                "--strategy=manual".to_string(),
5283                "refs/notes/other".to_string(),
5284            ]),
5285            "no strategy given ⇒ git's default, stated explicitly"
5286        );
5287        assert_eq!(
5288            note_merge_argv("refs/notes/other theirs")
5289                .expect("valid")
5290                .get(2)
5291                .map(String::as_str),
5292            Some("--strategy=theirs")
5293        );
5294    }
5295
5296    /// A misspelled strategy is REFUSED, not silently merged manually.
5297    ///
5298    /// This is the case worth pinning: `ours` and `theirs` resolve a
5299    /// conflict in opposite directions, and falling back to `manual` on
5300    /// a typo would stop the merge rather than resolve it — leaving the
5301    /// user in a state they did not ask for with no sign why.
5302    #[test]
5303    fn a_misspelled_strategy_is_refused_rather_than_defaulted() {
5304        for bad in ["ref our", "ref Theirs", "ref cat-sort-uniq", "ref x y"] {
5305            assert_eq!(
5306                note_merge_argv(bad),
5307                None,
5308                "{bad:?} must be refused, not merged with the default"
5309            );
5310        }
5311        assert_eq!(note_merge_argv(""), None, "no ref at all");
5312        assert_eq!(note_merge_argv("   "), None);
5313    }
5314
5315    // ── MG.36: `C` clone ────────────────────────────────────────────
5316
5317    /// Both URL shapes people actually paste. The SSH form is the one a
5318    /// naive `rsplit('/')` gets wrong for a single-segment path, and
5319    /// `.git` is usual on it — a destination directory literally named
5320    /// `repo.git` is not what anyone means.
5321    #[test]
5322    fn the_default_destination_is_the_name_git_would_have_picked() {
5323        for (url, expected) in [
5324            ("https://github.com/owner/repo.git", "repo"),
5325            ("https://github.com/owner/repo", "repo"),
5326            ("https://github.com/owner/repo/", "repo"),
5327            ("git@github.com:owner/repo.git", "repo"),
5328            ("git@github.com:repo.git", "repo"),
5329            ("ssh://git@host:22/owner/repo.git", "repo"),
5330            ("/srv/git/bare-repo.git", "bare-repo"),
5331            ("  https://host/owner/repo.git  ", "repo"),
5332        ] {
5333            assert_eq!(
5334                default_clone_dest(url),
5335                expected,
5336                "the directory `git clone {url}` would create"
5337            );
5338        }
5339    }
5340
5341    /// Nothing usable left means the caller must ask rather than invent
5342    /// a name — an empty string is the signal, and both the ex-command
5343    /// and the prompt check for it.
5344    #[test]
5345    fn a_url_with_no_usable_last_segment_yields_nothing() {
5346        for url in ["", "   ", "/", "https://", ".git"] {
5347            assert_eq!(
5348                default_clone_dest(url),
5349                "",
5350                "{url:?} names no directory, so none may be guessed"
5351            );
5352        }
5353    }
5354
5355    /// `--` before the operands. A destination beginning with `-` is
5356    /// something a user can type, and without the separator git would
5357    /// read it as an option — silently, and with a different effect.
5358    #[test]
5359    fn clone_argv_separates_operands_from_options() {
5360        assert_eq!(
5361            clone_argv("https://host/o/r.git", "/tmp/r"),
5362            vec!["clone", "--", "https://host/o/r.git", "/tmp/r"]
5363        );
5364        let hostile = clone_argv("https://host/o/r.git", "--upload-pack=evil");
5365        assert_eq!(
5366            hostile.iter().position(|a| a == "--"),
5367            Some(1),
5368            "the separator must precede both operands: {hostile:?}"
5369        );
5370    }
5371
5372    /// MG.23c1 — the `.gitignore` append, against a real file.
5373    ///
5374    /// Not a git subcommand (git has none for this), so the file
5375    /// handling is ours to get right and worth testing directly.
5376    #[test]
5377    fn appending_to_gitignore_is_idempotent_and_newline_safe() {
5378        use super::gitignore_append as append;
5379
5380        // A file with no trailing newline would otherwise glue the new
5381        // pattern onto the last one, ignoring neither.
5382        assert_eq!(
5383            append("target", "*.log").as_deref(),
5384            Some("target\n*.log\n"),
5385            "a missing trailing newline must not fuse two patterns"
5386        );
5387        assert_eq!(append("", "*.log").as_deref(), Some("*.log\n"));
5388        assert_eq!(
5389            append("target\n", "*.log").as_deref(),
5390            Some("target\n*.log\n")
5391        );
5392        // Pressing `i` twice on the same artefact is an ordinary
5393        // mistake, not a request for two identical lines.
5394        assert!(
5395            append("target\n*.log\n", "*.log").is_none(),
5396            "an already-ignored pattern is skipped"
5397        );
5398        assert!(
5399            append("target\n", " target ").is_none(),
5400            "compared as trimmed whole lines"
5401        );
5402    }
5403
5404    /// MG.23c1 — an empty prompt is a cancel, not an empty-named tag.
5405    ///
5406    /// Submitting nothing is how you back out of a prompt, and `git tag
5407    /// ""` would fail with a message about refs rather than about what
5408    /// the user did.
5409    #[test]
5410    fn an_empty_prompt_cancels_rather_than_running_the_operation() {
5411        use lattice_mode::Mode as _;
5412
5413        let handlers = MagitGlobalMode.action_handlers();
5414        // Alternating blank shapes, so neither "empty" nor "whitespace"
5415        // is the only one ever exercised.
5416        for (i, (_, action)) in PROMPTED_OPS.iter().enumerate() {
5417            let blank = if i % 2 == 0 { "   " } else { "" };
5418            let handler = handlers
5419                .iter()
5420                .find(|c| c.action_name == *action)
5421                .unwrap_or_else(|| panic!("`{action}` is contributed"))
5422                .handler
5423                .clone();
5424            let services = lattice_mode::ServiceRegistry::new();
5425            let events = lattice_runtime::EventBus::new();
5426            let ctx = ActionContext {
5427                buffer_id: lattice_protocol::ids::BufferId::new(1),
5428                cursor: lattice_protocol::position::Position::new(0, 0),
5429                selection: None,
5430                services: &services,
5431                events: &events,
5432                prompt_value: Some(blank),
5433                args: lattice_grammar::Args::None,
5434                buffer_locals: None,
5435            };
5436            assert!(
5437                handler(&ctx).is_none(),
5438                "`{action}` must decline a blank submission rather than run \
5439                 git with an empty argument"
5440            );
5441        }
5442    }
5443
5444    /// The prompt row and its finish half must name each other, or
5445    /// pressing the key opens a prompt whose submit goes nowhere.
5446    #[test]
5447    fn each_prompt_row_targets_a_finish_action_that_exists() {
5448        use lattice_mode::Mode as _;
5449
5450        let handlers = MagitGlobalMode.action_handlers();
5451        let names: Vec<&str> = handlers.iter().map(|c| c.action_name).collect();
5452        let services = lattice_mode::ServiceRegistry::new();
5453        let events = lattice_runtime::EventBus::new();
5454
5455        for (action, expected_finish) in PROMPTED_OPS {
5456            let handler = handlers
5457                .iter()
5458                .find(|c| c.action_name == *action)
5459                .unwrap_or_else(|| panic!("`{action}` is contributed"))
5460                .handler
5461                .clone();
5462            let ctx = ActionContext {
5463                buffer_id: lattice_protocol::ids::BufferId::new(1),
5464                cursor: lattice_protocol::position::Position::new(0, 0),
5465                selection: None,
5466                services: &services,
5467                events: &events,
5468                prompt_value: None,
5469                args: lattice_grammar::Args::None,
5470                buffer_locals: None,
5471            };
5472            match handler(&ctx) {
5473                Some(Effect::OpenPrompt {
5474                    on_submit_action, ..
5475                }) => {
5476                    assert_eq!(
5477                        &on_submit_action.as_str(),
5478                        expected_finish,
5479                        "`{action}` must submit to its own finish half"
5480                    );
5481                    assert!(
5482                        names.contains(&on_submit_action.as_str()),
5483                        "`{action}` opens a prompt submitting to \
5484                         `{on_submit_action}`, which no mode contributes — the \
5485                         prompt would accept input and do nothing with it"
5486                    );
5487                }
5488                other => panic!("`{action}` should open a prompt, got {other:?}"),
5489            }
5490        }
5491    }
5492
5493    /// MG.52: **a branch row opens the branch picker, naming a real
5494    /// ex-command.**
5495    ///
5496    /// Two ways these rot, and the suite could see neither before: the
5497    /// row quietly goes back to a prompt (which is the thing being
5498    /// removed — a branch that does not exist is a typo, and git reports
5499    /// it long after the keystroke), or it opens a picker naming a
5500    /// command nobody registered, in which case picking does nothing.
5501    #[test]
5502    fn each_branch_row_opens_the_picker_with_a_registered_command() {
5503        use lattice_mode::Mode as _;
5504
5505        let handlers = MagitGlobalMode.action_handlers();
5506        let services = lattice_mode::ServiceRegistry::new();
5507        let events = lattice_runtime::EventBus::new();
5508
5509        // The ex-commands the picker can hand a branch to.
5510        let mut registry = lattice_grammar::CommandRegistry::new();
5511        crate::register_action_commands_for_test(&mut registry);
5512        crate::register_ex_commands_for_test(&mut registry);
5513
5514        for (action, ex_command) in PICKED_BRANCH_OPS {
5515            let handler = handlers
5516                .iter()
5517                .find(|c| c.action_name == *action)
5518                .unwrap_or_else(|| panic!("`{action}` is contributed"))
5519                .handler
5520                .clone();
5521            let ctx = ActionContext {
5522                buffer_id: lattice_protocol::ids::BufferId::new(1),
5523                cursor: lattice_protocol::position::Position::new(0, 0),
5524                selection: None,
5525                services: &services,
5526                events: &events,
5527                prompt_value: None,
5528                args: lattice_grammar::Args::None,
5529                buffer_locals: None,
5530            };
5531            match handler(&ctx) {
5532                Some(Effect::OpenPicker { source, args, .. }) => {
5533                    assert_eq!(
5534                        source,
5535                        crate::picker_sources::BRANCH_PICK_SOURCE,
5536                        "`{action}` must open the branch picker"
5537                    );
5538                    assert_eq!(
5539                        args.first().map(String::as_str),
5540                        Some(*ex_command),
5541                        "`{action}` must hand the picked branch to `{ex_command}`"
5542                    );
5543                    assert!(
5544                        registry.id_by_name(ex_command).is_some(),
5545                        "`{action}` picks a branch and runs `{ex_command}`, which is \
5546                         not a registered ex-command — picking would do nothing"
5547                    );
5548                }
5549                other => panic!("`{action}` should open the branch picker, got {other:?}"),
5550            }
5551        }
5552    }
5553
5554    /// The branch submenu's `b` and `l` must not list the same thing.
5555    ///
5556    /// Magit has both because they answer different questions, and the
5557    /// difference is the *listing*, not the operation — both end in
5558    /// `git checkout`:
5559    ///
5560    /// - `l` **local branch** — your local branches, nothing else.
5561    /// - `b` **branch/revision** — anything `git checkout` accepts: a
5562    ///   branch, a tag, `origin/main`, a raw SHA.
5563    ///
5564    /// MG.52 converted every free-text branch prompt to a picker and
5565    /// swept `b` up with the rest, pointing it at the local-branch
5566    /// picker. Nothing failed — `git checkout <local branch>` is a
5567    /// perfectly good command — so the loss was silent: `b` and `l`
5568    /// became the same row, and checking out `origin/main` or a tag
5569    /// from the menu stopped being reachable at all. The regression is
5570    /// only visible as *two menu rows that do the same thing*, which no
5571    /// assertion about either row alone can see. Hence a test about the
5572    /// pair.
5573    ///
5574    /// MG.53.g built the source that resolves it: `magit-revision`
5575    /// lists refs **and** recent commits, so `b` covers everything it
5576    /// used to accept as free text without accepting a typo.
5577    #[test]
5578    fn the_branch_revision_row_is_not_the_local_branch_row() {
5579        use lattice_mode::Mode as _;
5580
5581        let handlers = MagitGlobalMode.action_handlers();
5582        let services = lattice_mode::ServiceRegistry::new();
5583        let events = lattice_runtime::EventBus::new();
5584        let source_of = |action: &str| -> String {
5585            let handler = handlers
5586                .iter()
5587                .find(|c| c.action_name == action)
5588                .unwrap_or_else(|| panic!("`{action}` is contributed"))
5589                .handler
5590                .clone();
5591            let ctx = ActionContext {
5592                buffer_id: lattice_protocol::ids::BufferId::new(1),
5593                cursor: lattice_protocol::position::Position::new(0, 0),
5594                selection: None,
5595                services: &services,
5596                events: &events,
5597                prompt_value: None,
5598                args: lattice_grammar::Args::None,
5599                buffer_locals: None,
5600            };
5601            match handler(&ctx) {
5602                Some(Effect::OpenPicker { source, .. }) => source,
5603                other => panic!("`{action}` should open a picker, got {other:?}"),
5604            }
5605        };
5606
5607        let rev = source_of("action:magit-global-branch-checkout-rev");
5608        let local = source_of("action:magit-global-branch-checkout");
5609        assert_eq!(
5610            rev,
5611            crate::picker_sources::REVISION_PICK_SOURCE,
5612            "`b` is magit's branch/revision row — it must offer tags, \
5613             remote-tracking refs and commits, not just local branches, \
5614             or it is `l` with a different key"
5615        );
5616        assert_eq!(
5617            local,
5618            crate::picker_sources::BRANCH_CHECKOUT_SOURCE,
5619            "`l` is the local-branch row and stays local"
5620        );
5621        assert_ne!(
5622            rev, local,
5623            "`b` and `l` listing the same set makes one of the two rows dead"
5624        );
5625    }
5626
5627    /// MG.53.d: **a non-branch picker row names the right SOURCE.**
5628    ///
5629    /// `t p` ("Prune tags gone from remote") takes a remote, not a tag —
5630    /// its label reads like a tag operation and its argv builder says
5631    /// otherwise. Pointed at the tag picker it would list tags, hand one
5632    /// to `fetch --prune-tags`, and fail against a remote that does not
5633    /// exist. Asserting only "opens a picker" would not catch it.
5634    #[test]
5635    fn each_non_branch_row_opens_the_right_picker() {
5636        use lattice_mode::Mode as _;
5637
5638        let handlers = MagitGlobalMode.action_handlers();
5639        let services = lattice_mode::ServiceRegistry::new();
5640        let events = lattice_runtime::EventBus::new();
5641        let mut registry = lattice_grammar::CommandRegistry::new();
5642        crate::register_action_commands_for_test(&mut registry);
5643        crate::register_ex_commands_for_test(&mut registry);
5644
5645        for (action, expected_source, ex_command) in PICKED_OTHER_OPS {
5646            let handler = handlers
5647                .iter()
5648                .find(|c| c.action_name == *action)
5649                .unwrap_or_else(|| panic!("`{action}` is contributed"))
5650                .handler
5651                .clone();
5652            let ctx = ActionContext {
5653                buffer_id: lattice_protocol::ids::BufferId::new(1),
5654                cursor: lattice_protocol::position::Position::new(0, 0),
5655                selection: None,
5656                services: &services,
5657                events: &events,
5658                prompt_value: None,
5659                args: lattice_grammar::Args::None,
5660                buffer_locals: None,
5661            };
5662            match handler(&ctx) {
5663                Some(Effect::OpenPicker { source, args, .. }) => {
5664                    assert_eq!(
5665                        source, *expected_source,
5666                        "`{action}` must list the nouns it operates on"
5667                    );
5668                    assert_eq!(args.first().map(String::as_str), Some(*ex_command));
5669                    assert!(
5670                        registry.id_by_name(ex_command).is_some(),
5671                        "`{action}` runs `{ex_command}`, which is not registered"
5672                    );
5673                }
5674                other => panic!("`{action}` should open a picker, got {other:?}"),
5675            }
5676        }
5677    }
5678
5679    /// MG.53.c: **the two file/revision rows open the commit picker,
5680    /// with the path in the right slot.**
5681    ///
5682    /// Not covered by `PICKED_OTHER_OPS`, because these take their
5683    /// target from `ctx.args` rather than being context-free — which is
5684    /// exactly why they needed their own guard rather than being left
5685    /// out. A gap here was invisible: `C-c f v` reverting to a prompt
5686    /// would look like nothing had changed.
5687    ///
5688    /// The placeholder is the part worth asserting. `magit-find-file`
5689    /// is `<rev> <path>`, so a command built without `{}` would append
5690    /// the revision and open a file named after a sha — a plausible
5691    /// command that does the wrong thing.
5692    #[test]
5693    fn the_file_revision_rows_open_the_commit_picker_with_a_placeholder() {
5694        use lattice_mode::Mode as _;
5695
5696        let handlers = MagitGlobalMode.action_handlers();
5697        let services = lattice_mode::ServiceRegistry::new();
5698        let events = lattice_runtime::EventBus::new();
5699
5700        for (action, ex_command) in [
5701            ("action:magit-global-file-at-revision", "magit-find-file"),
5702            ("action:magit-global-file-checkout", "magit-file-checkout"),
5703        ] {
5704            let handler = handlers
5705                .iter()
5706                .find(|c| c.action_name == action)
5707                .unwrap_or_else(|| panic!("`{action}` is contributed"))
5708                .handler
5709                .clone();
5710            // The target comes from the args slot, the same way the
5711            // file dispatch supplies it.
5712            let args = lattice_grammar::Args::List(vec![lattice_grammar::ArgValue::String(
5713                "src/main.rs".to_string(),
5714            )]);
5715            let ctx = ActionContext {
5716                buffer_id: lattice_protocol::ids::BufferId::new(1),
5717                cursor: lattice_protocol::position::Position::new(0, 0),
5718                selection: None,
5719                services: &services,
5720                events: &events,
5721                prompt_value: None,
5722                args,
5723                buffer_locals: None,
5724            };
5725            match handler(&ctx) {
5726                Some(Effect::OpenPicker { source, args, .. }) => {
5727                    assert_eq!(
5728                        source,
5729                        crate::picker_sources::REVISION_PICK_SOURCE,
5730                        "`{action}` picks a REVISION — a branch or tag as much \
5731                         as a commit. The commit-only picker cannot reach a \
5732                         file that lives on another branch, because it is not \
5733                         in this branch's history at all."
5734                    );
5735                    let line = args.first().map(String::as_str).unwrap_or("");
5736                    assert!(
5737                        line.starts_with(ex_command),
5738                        "`{action}` must run `{ex_command}`, got {line:?}"
5739                    );
5740                    assert!(
5741                        line.contains("{}"),
5742                        "`{action}` builds `{line}` — without a `{{}}` the \
5743                         revision is appended after the path, which opens a \
5744                         file named after a sha"
5745                    );
5746                    assert!(
5747                        line.contains("src/main.rs"),
5748                        "`{action}` must carry the target path: {line:?}"
5749                    );
5750                }
5751                other => panic!("`{action}` should open the commit picker, got {other:?}"),
5752            }
5753        }
5754    }
5755
5756    /// MG.23b — `S` stages tracked modifications ONLY.
5757    ///
5758    /// `add --update` and not `add --all`: "stage everything" quietly
5759    /// adding a file git was never told about is how build artefacts and
5760    /// secrets get committed. Magit reaches the include-untracked
5761    /// behaviour behind a prefix argument; until that exists the
5762    /// explicit path is `s` on the Untracked entry.
5763    #[test]
5764    fn stage_all_stages_tracked_modifications_not_untracked_files() {
5765        let argv = RemoteOp::STAGE_ALL.argv(&lattice_grammar::Args::None);
5766        assert_eq!(argv, vec!["add", "--update"]);
5767        assert!(
5768            !argv.iter().any(|a| a == "--all" || a == "-A"),
5769            "an untracked sweep must be opt-in, never the default: {argv:?}"
5770        );
5771    }
5772
5773    /// `U` is a bare `git reset`: index back to HEAD, working tree
5774    /// untouched. That is what makes it safe to fire without asking —
5775    /// nothing is lost and every change is still there to re-stage.
5776    #[test]
5777    fn unstage_all_resets_the_index_and_leaves_the_working_tree() {
5778        let argv = RemoteOp::UNSTAGE_ALL.argv(&lattice_grammar::Args::None);
5779        assert_eq!(argv, vec!["reset", "--quiet"]);
5780        assert!(
5781            !argv.iter().any(|a| a == "--hard" || a == "--merge"),
5782            "a reset that touches the working tree would need MG.12's \
5783             confirm, and would not belong on an unprompted key: {argv:?}"
5784        );
5785    }
5786
5787    /// Neither takes a target, which is precisely why they could land
5788    /// ahead of `A` / `_` / `O` — those act on the commit at the cursor,
5789    /// and the root dispatch has no cursor context.
5790    #[test]
5791    fn the_repo_wide_index_ops_take_no_arguments() {
5792        assert!(RemoteOp::STAGE_ALL.arg_specs().is_empty());
5793        assert!(RemoteOp::UNSTAGE_ALL.arg_specs().is_empty());
5794    }
5795
5796    /// MG.20 — a commit operation appends its target.
5797    #[test]
5798    fn a_commit_op_appends_the_commit_to_its_argv() {
5799        assert_eq!(
5800            CommitOp::CHERRY_PICK.argv("a1b2c3d"),
5801            vec!["cherry-pick", "a1b2c3d"]
5802        );
5803        assert_eq!(
5804            CommitOp::RESET_HARD.argv("a1b2c3d"),
5805            vec!["reset", "--hard", "a1b2c3d"]
5806        );
5807    }
5808
5809    /// Only `--hard` asks. `--soft` and `--mixed` keep your changes —
5810    /// a prompt on those would be noise that trains you to dismiss the
5811    /// one that matters.
5812    #[test]
5813    fn only_the_destructive_reset_asks_first() {
5814        assert!(CommitOp::RESET_HARD.confirm_action.is_some());
5815        assert!(CommitOp::RESET_SOFT.confirm_action.is_none());
5816        assert!(CommitOp::RESET_MIXED.confirm_action.is_none());
5817        assert!(CommitOp::REVERT.confirm_action.is_none());
5818        assert!(CommitOp::CHERRY_PICK.confirm_action.is_none());
5819    }
5820
5821    /// Revert passes `--no-edit`. Without it git opens `$EDITOR` for
5822    /// the message, which inside lattice means the operation hangs on
5823    /// a prompt the user has no way to answer.
5824    #[test]
5825    fn revert_does_not_open_an_editor() {
5826        assert!(
5827            CommitOp::REVERT.args.contains(&"--no-edit"),
5828            "revert must not block on $EDITOR"
5829        );
5830    }
5831
5832    /// Every destructive commit op's confirm target must be a real
5833    /// registered `-execute` action — `confirm::ask` debug-asserts the
5834    /// pairing, so a typo here fails loudly for the author instead of
5835    /// quietly for the user.
5836    #[test]
5837    fn the_hard_reset_confirm_targets_its_execute_half() {
5838        assert_eq!(
5839            CommitOp::RESET_HARD.confirm_action,
5840            Some("action:magit-reset-hard-execute")
5841        );
5842    }
5843
5844    /// MG.17a — the flag table drives argv, and only when enabled.
5845    #[test]
5846    fn argv_appends_exactly_the_enabled_flags_in_schema_order() {
5847        use lattice_grammar::{ArgValue, Args};
5848        let op = RemoteOp::PUSH;
5849        assert_eq!(op.argv(&Args::None), vec!["push"], "no args = bare push");
5850        assert_eq!(
5851            op.argv(&Args::List(vec![
5852                ArgValue::Bool(false),
5853                ArgValue::Bool(false)
5854            ])),
5855            vec!["push"]
5856        );
5857        assert_eq!(
5858            op.argv(&Args::List(vec![
5859                ArgValue::Bool(true),
5860                ArgValue::Bool(false)
5861            ])),
5862            vec!["push", "--force-with-lease"]
5863        );
5864        assert_eq!(
5865            op.argv(&Args::List(vec![
5866                ArgValue::Bool(true),
5867                ArgValue::Bool(true)
5868            ])),
5869            vec!["push", "--force-with-lease", "--set-upstream"]
5870        );
5871    }
5872
5873    /// A bare chord press (no transient, no args) must behave exactly
5874    /// as it did before flags existed — this is the regression that
5875    /// would break every existing `C-c g F` muscle memory.
5876    #[test]
5877    fn an_argless_invocation_runs_the_unflagged_command() {
5878        use lattice_grammar::Args;
5879        for op in [
5880            RemoteOp::PULL,
5881            RemoteOp::PUSH,
5882            RemoteOp::FETCH,
5883            RemoteOp::STASH,
5884        ] {
5885            assert_eq!(
5886                op.argv(&Args::None),
5887                op.args.iter().map(|s| s.to_string()).collect::<Vec<_>>(),
5888                "`{}` with no args must be the bare command",
5889                op.what
5890            );
5891        }
5892    }
5893
5894    /// The preview and the run must agree. A preview that renders a
5895    /// different command than the one that executes is worse than no
5896    /// preview — it actively misleads before a push.
5897    #[test]
5898    fn the_preview_string_matches_the_argv_that_will_run() {
5899        use lattice_grammar::{ArgValue, Args};
5900        let op = RemoteOp::FETCH;
5901        for (all, prune) in [(false, false), (true, false), (false, true), (true, true)] {
5902            let args = Args::List(vec![ArgValue::Bool(all), ArgValue::Bool(prune)]);
5903            let preview = op.preview(&|name| match name {
5904                "all" => Some(all.to_string()),
5905                "prune" => Some(prune.to_string()),
5906                _ => None,
5907            });
5908            let from_argv = format!("git {}", op.argv(&args).join(" "));
5909            assert_eq!(preview, from_argv, "preview must render what runs");
5910        }
5911    }
5912
5913    /// Push force-pushes with `--force-with-lease`, never bare
5914    /// `--force`. Pinned deliberately: the difference is whether a
5915    /// colleague's commits survive when the remote moved under you.
5916    #[test]
5917    fn force_push_uses_force_with_lease() {
5918        let force = RemoteOp::PUSH.flags[0];
5919        assert_eq!(force.arg, "--force-with-lease");
5920        assert!(
5921            !RemoteOp::PUSH.flags.iter().any(|f| f.arg == "--force"),
5922            "bare --force must not be offered"
5923        );
5924    }
5925
5926    /// MG.17b — a value argument contributes `-m <text>`, and only
5927    /// when actually set. An unset value must contribute NOTHING, not
5928    /// an empty string: `git stash push -m ""` labels the stash with
5929    /// an empty message, which is worse than no label.
5930    #[test]
5931    fn a_value_argument_contributes_only_when_set() {
5932        use lattice_grammar::{ArgValue, Args};
5933        let op = RemoteOp::STASH;
5934        // slots: [include-untracked: Bool, message: String]
5935        assert_eq!(
5936            op.argv(&Args::List(vec![
5937                ArgValue::Bool(false),
5938                ArgValue::String(String::new())
5939            ])),
5940            vec!["stash", "push"],
5941            "an empty message must not reach git at all"
5942        );
5943        assert_eq!(
5944            op.argv(&Args::List(vec![
5945                ArgValue::Bool(false),
5946                ArgValue::String("wip: parser".into())
5947            ])),
5948            vec!["stash", "push", "-m", "wip: parser"],
5949            "the message is one argv entry, spaces and all"
5950        );
5951        assert_eq!(
5952            op.argv(&Args::List(vec![
5953                ArgValue::Bool(true),
5954                ArgValue::String("wip".into())
5955            ])),
5956            vec!["stash", "push", "--include-untracked", "-m", "wip"],
5957            "a flag and a value compose in schema order"
5958        );
5959    }
5960
5961    /// A message with spaces must survive as ONE argument. Passing it
5962    /// unsplit is the whole reason argv is a `Vec<String>` rather than
5963    /// a formatted string.
5964    #[test]
5965    fn a_multi_word_message_stays_a_single_argv_entry() {
5966        use lattice_grammar::{ArgValue, Args};
5967        let argv = RemoteOp::STASH.argv(&Args::List(vec![
5968            ArgValue::Bool(false),
5969            ArgValue::String("refactor the parser and fix tests".into()),
5970        ]));
5971        assert_eq!(argv.last().unwrap(), "refactor the parser and fix tests");
5972        assert_eq!(argv.len(), 4, "push + -m + the message, not one word each");
5973    }
5974
5975    /// The preview quotes a value so a multi-word message doesn't read
5976    /// as several arguments in the menu.
5977    #[test]
5978    fn the_preview_quotes_a_value_argument() {
5979        let preview = RemoteOp::STASH.preview(&|name| match name {
5980            "message" => Some("wip: two words".to_string()),
5981            _ => None,
5982        });
5983        assert_eq!(preview, r#"git stash push -m "wip: two words""#);
5984    }
5985
5986    /// `arg_specs` is the ex-command's schema and must line up 1:1 with
5987    /// the flag table the transient and argv builder read — the whole
5988    /// point of one definition.
5989    #[test]
5990    fn arg_specs_mirror_the_flag_table_positionally() {
5991        for op in [
5992            RemoteOp::PULL,
5993            RemoteOp::PUSH,
5994            RemoteOp::FETCH,
5995            RemoteOp::STASH,
5996        ] {
5997            let specs = op.arg_specs();
5998            assert_eq!(specs.len(), op.flags.len(), "`{}` schema length", op.what);
5999            for (spec, flag) in specs.iter().zip(op.flags) {
6000                assert_eq!(spec.name.as_ref(), flag.name);
6001                let expected = match flag.kind {
6002                    RemoteArgKind::Flag => lattice_grammar::ArgKind::Bool,
6003                    RemoteArgKind::Value { .. } | RemoteArgKind::ValueJoined { .. } => {
6004                        lattice_grammar::ArgKind::String
6005                    }
6006                };
6007                assert_eq!(spec.kind, expected, "`{}` slot `{}`", op.what, flag.name);
6008            }
6009        }
6010    }
6011
6012    #[test]
6013    fn base_branch_from_prompt_buffer_name_extracts_the_stashed_base() {
6014        assert_eq!(
6015            base_branch_from_prompt_buffer_name("*magit:branch-create-from:feature/foo*"),
6016            Some("feature/foo".to_string())
6017        );
6018    }
6019
6020    #[test]
6021    fn base_branch_from_prompt_buffer_name_rejects_an_empty_base() {
6022        assert_eq!(
6023            base_branch_from_prompt_buffer_name("*magit:branch-create-from:*"),
6024            None
6025        );
6026    }
6027
6028    #[test]
6029    fn base_branch_from_prompt_buffer_name_rejects_unrelated_names() {
6030        assert_eq!(base_branch_from_prompt_buffer_name("*magit:status*"), None);
6031        assert_eq!(base_branch_from_prompt_buffer_name("*prompt*"), None);
6032    }
6033}
6034
6035#[cfg(test)]
6036mod task_labels {
6037    use super::{CommitOp, RemoteOp, short_rev};
6038
6039    fn argv(words: &[&str]) -> Vec<String> {
6040        words.iter().map(|w| w.to_string()).collect()
6041    }
6042
6043    /// NC.4: a burst of pushes must say which is which.
6044    #[test]
6045    fn a_push_label_names_its_destination_and_its_kind() {
6046        let p = RemoteOp::PUSH;
6047        assert_eq!(p.label(&argv(&["push"])), "push");
6048        assert_eq!(
6049            p.label(&argv(&["push", "origin", "main"])),
6050            "push to origin/main"
6051        );
6052        assert_eq!(
6053            p.label(&argv(&["push", "--force-with-lease", "origin", "main"])),
6054            "force-push to origin/main"
6055        );
6056        assert_eq!(p.label(&argv(&["push", "--tags"])), "push tags");
6057        assert_eq!(p.label(&argv(&["push", "--dry-run"])), "push (dry run)");
6058        assert_eq!(
6059            p.label(&argv(&["push", "origin", "HEAD:refs/for/main"])),
6060            "push to origin HEAD:refs/for/main",
6061            "a refspec is shown as typed, not joined into a path"
6062        );
6063    }
6064
6065    #[test]
6066    fn fetch_and_pull_labels_name_their_source() {
6067        assert_eq!(
6068            RemoteOp::FETCH.label(&argv(&["fetch", "--all", "--prune"])),
6069            "fetch all remotes"
6070        );
6071        assert_eq!(
6072            RemoteOp::FETCH.label(&argv(&["fetch", "upstream"])),
6073            "fetch upstream"
6074        );
6075        assert_eq!(
6076            RemoteOp::PULL.label(&argv(&["pull", "--rebase"])),
6077            "pull and rebase"
6078        );
6079        assert_eq!(
6080            RemoteOp::PULL.label(&argv(&["pull", "--ff-only", "origin", "dev"])),
6081            "pull origin/dev"
6082        );
6083    }
6084
6085    /// The subcommand word of `stash push` is not a destination, and the
6086    /// message is how a stash is recognised later.
6087    #[test]
6088    fn a_stash_label_carries_its_message() {
6089        assert_eq!(
6090            RemoteOp::STASH.label(&argv(&["stash", "push"])),
6091            "stash changes"
6092        );
6093        assert_eq!(
6094            RemoteOp::STASH.label(&argv(&["stash", "push", "-u", "-m", "wip"])),
6095            "stash changes \u{201c}wip\u{201d}"
6096        );
6097    }
6098
6099    /// No label is raw git syntax, and none is shared: a label that
6100    /// reads like a flag, or two operations that read the same, is the
6101    /// ambiguity NC.4 removes.
6102    #[test]
6103    fn remote_op_labels_are_plain_and_distinct() {
6104        let ops = [
6105            RemoteOp::PULL,
6106            RemoteOp::PUSH,
6107            RemoteOp::FETCH,
6108            RemoteOp::CHERRY_PICK_CONTINUE,
6109            RemoteOp::CHERRY_PICK_SKIP,
6110            RemoteOp::CHERRY_PICK_ABORT,
6111            RemoteOp::REVERT_CONTINUE,
6112            RemoteOp::REVERT_SKIP,
6113            RemoteOp::REVERT_ABORT,
6114            RemoteOp::MERGE_CONTINUE,
6115            RemoteOp::MERGE_ABORT,
6116            RemoteOp::REBASE_CONTINUE,
6117            RemoteOp::REBASE_SKIP,
6118            RemoteOp::REBASE_ABORT,
6119            RemoteOp::NOTES_PRUNE,
6120            RemoteOp::NOTES_MERGE_COMMIT,
6121            RemoteOp::NOTES_MERGE_ABORT,
6122            RemoteOp::AM_CONTINUE,
6123            RemoteOp::AM_SKIP,
6124            RemoteOp::AM_ABORT,
6125            RemoteOp::STAGE_ALL,
6126            RemoteOp::UNSTAGE_ALL,
6127            RemoteOp::STASH_KEEP_INDEX,
6128            RemoteOp::STASH_STAGED,
6129            RemoteOp::STASH,
6130        ];
6131        let mut seen = std::collections::HashSet::new();
6132        for op in ops {
6133            assert!(!op.what.contains("--"), "`{}` reads like a flag", op.what);
6134            assert!(!op.doing.contains("--"), "`{}` reads like a flag", op.doing);
6135            assert!(seen.insert(op.what), "`{}` is shared", op.what);
6136        }
6137    }
6138
6139    #[test]
6140    fn commit_op_labels_are_plain_and_distinct() {
6141        let ops = [
6142            CommitOp::REVERT,
6143            CommitOp::CHERRY_PICK,
6144            CommitOp::RESET_WORKTREE,
6145            CommitOp::REVERT_CHANGES,
6146            CommitOp::CHERRY_PICK_APPLY,
6147            CommitOp::RESET_SOFT,
6148            CommitOp::RESET_MIXED,
6149            CommitOp::RESET_HARD,
6150            CommitOp::RESET_KEEP,
6151            CommitOp::RESET_INDEX,
6152            CommitOp::COMMIT_FIXUP,
6153            CommitOp::COMMIT_SQUASH,
6154        ];
6155        let mut seen = std::collections::HashSet::new();
6156        for op in ops {
6157            assert!(!op.label.contains("--"), "`{}` reads like a flag", op.label);
6158            assert!(seen.insert(op.label), "`{}` is shared", op.label);
6159        }
6160    }
6161
6162    /// NC.4b: the merge flavours leave the repository in different
6163    /// states, so each names the branch and none reads like another.
6164    #[test]
6165    fn merge_flavours_read_differently() {
6166        use super::merge_label;
6167        let labels = [
6168            merge_label("merge", "feature"),
6169            merge_label("no-commit", "feature"),
6170            merge_label("squash", "feature"),
6171        ];
6172        assert!(labels.iter().all(|l| l.contains("feature")), "{labels:?}");
6173        assert_eq!(
6174            labels
6175                .iter()
6176                .collect::<std::collections::HashSet<_>>()
6177                .len(),
6178            3
6179        );
6180    }
6181
6182    #[test]
6183    fn patch_labels_name_what_they_touch() {
6184        use super::{am_label, format_patch_label};
6185        assert_eq!(
6186            am_label(&argv(&["am", "--3way", "0001-fix.patch"])),
6187            "apply patch 0001-fix.patch"
6188        );
6189        assert_eq!(
6190            am_label(&argv(&["am", "a.patch", "b.patch"])),
6191            "apply 2 patches"
6192        );
6193        assert_eq!(
6194            format_patch_label(&argv(&["format-patch", "-o", "/r", "main..feature"])),
6195            "write patches for main..feature"
6196        );
6197    }
6198
6199    #[test]
6200    fn a_one_commit_rebase_names_the_commit_and_the_verb() {
6201        use super::rebase_verb_label;
6202        let sha = "3f2a1c09b8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3";
6203        assert_eq!(rebase_verb_label("edit", sha), "rebase to edit 3f2a1c0");
6204        assert_eq!(rebase_verb_label("drop", sha), "drop 3f2a1c0 from history");
6205    }
6206
6207    /// NC.6: the culprit is named; an ongoing step names the next
6208    /// commit and how far is left.
6209    #[test]
6210    fn a_bisect_summary_names_the_commit() {
6211        use super::bisect_summary;
6212        use lattice_vcs::BisectStep;
6213        let sha = "4408d81987933a1275554215cacb0eb9b26df0ce".to_string();
6214        assert_eq!(
6215            bisect_summary(&BisectStep::Found {
6216                commit: sha.clone(),
6217                subject: "c5".into()
6218            }),
6219            "first bad commit is 4408d81 c5"
6220        );
6221        assert_eq!(
6222            bisect_summary(&BisectStep::Testing {
6223                commit: sha,
6224                subject: "c6".into(),
6225                revisions_left: Some(1),
6226                steps: Some(1),
6227            }),
6228            "now testing 4408d81 c6, 1 left (about 1 more)"
6229        );
6230    }
6231
6232    #[test]
6233    fn bisect_steps_read_as_steps() {
6234        use super::bisect_label;
6235        assert_eq!(bisect_label("good"), "mark the bisect commit good");
6236        assert_eq!(bisect_label("start"), "start bisecting");
6237        assert_eq!(bisect_label("reset"), "end the bisect");
6238    }
6239
6240    #[test]
6241    fn a_full_sha_is_shortened_and_a_ref_is_not() {
6242        assert_eq!(
6243            short_rev("3f2a1c09b8d7e6f5a4b3c2d1e0f9a8b7c6d5e4f3"),
6244            "3f2a1c0"
6245        );
6246        assert_eq!(
6247            short_rev("feature/very-long-name"),
6248            "feature/very-long-name"
6249        );
6250        assert_eq!(short_rev("HEAD~2"), "HEAD~2");
6251    }
6252}
6253
6254#[cfg(test)]
6255mod run_remote_op_reports {
6256    use super::{resolve_upstream, run_remote_op};
6257    use std::path::Path;
6258    use std::process::Command;
6259
6260    fn git(dir: &Path, args: &[&str]) {
6261        let ok = Command::new("git")
6262            .args(args)
6263            .current_dir(dir)
6264            .env("GIT_AUTHOR_NAME", "t")
6265            .env("GIT_AUTHOR_EMAIL", "t@t")
6266            .env("GIT_COMMITTER_NAME", "t")
6267            .env("GIT_COMMITTER_EMAIL", "t@t")
6268            .status()
6269            .map(|s| s.success())
6270            .unwrap_or(false);
6271        assert!(ok, "git {args:?}");
6272    }
6273
6274    fn argv(words: &[&str]) -> Vec<String> {
6275        words.iter().map(|w| w.to_string()).collect()
6276    }
6277
6278    /// A repository where merging `other` into `main` conflicts on a.txt.
6279    fn conflicted() -> tempfile::TempDir {
6280        let dir = tempfile::tempdir().expect("temp dir");
6281        let d = dir.path();
6282        git(d, &["init", "-q", "-b", "main"]);
6283        // In the repo's own config, not only the helper's environment: the
6284        // merge under test is run by `run_remote_op`, which does not carry
6285        // the `GIT_*` variables, and a machine with no global identity (a CI
6286        // runner) answers "Please tell me who you are" instead of CONFLICT.
6287        git(d, &["config", "user.name", "t"]);
6288        git(d, &["config", "user.email", "t@t"]);
6289        git(d, &["config", "commit.gpgsign", "false"]);
6290        std::fs::write(d.join("a.txt"), "base\n").expect("write");
6291        git(d, &["add", "a.txt"]);
6292        git(d, &["commit", "-q", "-m", "base"]);
6293        git(d, &["checkout", "-q", "-b", "other"]);
6294        std::fs::write(d.join("a.txt"), "other\n").expect("write");
6295        git(d, &["commit", "-q", "-am", "other"]);
6296        git(d, &["checkout", "-q", "main"]);
6297        std::fs::write(d.join("a.txt"), "main\n").expect("write");
6298        git(d, &["commit", "-q", "-am", "main"]);
6299        dir
6300    }
6301
6302    /// NC.3, against real git: the conflict is on stdout and stderr is
6303    /// empty, which is what made this read `merge failed: ` before.
6304    #[test]
6305    fn a_real_merge_conflict_reports_the_conflict() {
6306        let dir = conflicted();
6307        let err = run_remote_op(dir.path(), &argv(&["merge", "--no-edit", "other"]))
6308            .expect_err("the merge conflicts");
6309        let first = err.lines().next().unwrap_or_default();
6310        assert!(first.starts_with("CONFLICT"), "{err}");
6311        // NC.4d: and it is reported as stopped, not failed — the merge
6312        // is in progress, waiting for the user.
6313        let outcome = super::TaskResult::from(Err(err));
6314        assert!(
6315            matches!(&outcome, super::TaskResult::Stopped(why) if why.starts_with("CONFLICT")),
6316            "a conflict must be a stop, got {outcome:?}"
6317        );
6318    }
6319
6320    /// `resolve_upstream` reads its own stdout: the report shape
6321    /// `run_remote_op` now returns would have split into garbage.
6322    #[test]
6323    fn upstream_resolution_still_reads_a_plain_value() {
6324        let origin = conflicted();
6325        let clone = tempfile::tempdir().expect("temp dir");
6326        git(
6327            clone.path(),
6328            &["clone", "-q", "--", &origin.path().to_string_lossy(), "c"],
6329        );
6330        assert_eq!(
6331            resolve_upstream(&clone.path().join("c")).as_deref(),
6332            Some("origin main")
6333        );
6334    }
6335}
6336
6337#[cfg(test)]
6338mod task_scope_tests {
6339    use super::task_scope;
6340    use std::path::Path;
6341
6342    /// The static set is shared by every test in the binary, so each
6343    /// test invents basenames nobody else uses.
6344    #[test]
6345    fn a_lone_repository_is_named_by_its_basename() {
6346        assert_eq!(
6347            task_scope(Path::new("/tmp/nc2-lone/nc2-solo")).as_deref(),
6348            Some("nc2-solo")
6349        );
6350    }
6351
6352    /// Two checkouts sharing a basename would make two notifications
6353    /// read the same, so once both have reported, both are qualified.
6354    #[test]
6355    fn a_shared_basename_is_qualified_by_its_parent() {
6356        let work = Path::new("/tmp/nc2-work/nc2-api");
6357        let oss = Path::new("/tmp/nc2-oss/nc2-api");
6358        assert_eq!(task_scope(work).as_deref(), Some("nc2-api"));
6359        assert_eq!(task_scope(oss).as_deref(), Some("nc2-oss/nc2-api"));
6360        assert_eq!(
6361            task_scope(work).as_deref(),
6362            Some("nc2-work/nc2-api"),
6363            "and the first one stops using the bare name too"
6364        );
6365    }
6366
6367    #[test]
6368    fn an_empty_workdir_has_no_scope() {
6369        assert_eq!(task_scope(Path::new("")), None);
6370    }
6371}
6372
6373#[cfg(test)]
6374mod reactive_refresh {
6375    use super::invalidates_a_magit_view;
6376    use lattice_protocol::event::{Event, TaskOutcome};
6377
6378    fn finished(source: &str) -> Event {
6379        Event::BackgroundTaskFinished {
6380            source: source.to_string(),
6381            scope: None,
6382            label: "push".to_string(),
6383            outcome: TaskOutcome::Succeeded {
6384                summary: "done".to_string(),
6385            },
6386        }
6387    }
6388
6389    /// The whole point: a magit mutation reported by ANY surface — the
6390    /// branch view, a transient row, an ex-command, another pane —
6391    /// invalidates this buffer, so it refreshes without the user
6392    /// pressing `gr`.
6393    #[test]
6394    fn a_magit_task_invalidates_the_view() {
6395        assert!(invalidates_a_magit_view(&finished("magit")));
6396    }
6397
6398    /// And nothing else does. An LSP request or a compilation
6399    /// finishing publishes the same event KIND and says nothing about
6400    /// the repository; refreshing on those would run `git status`
6401    /// every time a build ended.
6402    #[test]
6403    fn another_subsystems_task_does_not() {
6404        for source in ["lsp", "compilation", "plugin", ""] {
6405            assert!(
6406                !invalidates_a_magit_view(&finished(source)),
6407                "{source:?} must not trigger a magit refresh"
6408            );
6409        }
6410    }
6411}