Skip to main content

lattice_magit/
picker_sources.rs

1//! `:picker magit-branch-pick-base` source generator.
2//!
3//! Lists existing local branches so magit's branch-create wizard
4//! (`c` in `magit-branch-mode`) can let the user pick a base branch
5//! before typing the new branch's name — mirrors Emacs magit's own
6//! two-step "pick base, then type name" flow. Accept emits
7//! `PickerAcceptOutcome::OpenPrompt`, stashing the picked base in the
8//! prompt buffer's synthetic name for
9//! `action:magit-branch-create-finish` (registered in
10//! `magit_global_mode`) to read back.
11
12use std::path::PathBuf;
13use std::sync::Arc;
14
15use lattice_completion::{CandidateKind, RawCandidate};
16use lattice_picker::{
17    PickerAcceptOutcome, PickerContext, PickerInitResult, PickerSourceGenerator, PickerSourceSpec,
18    RoutingPayload, SourceResult,
19};
20use lattice_vcs::{Branch, RefKind, Reference, Remote, Repository};
21
22/// MR.6: which repository a picker is about.
23///
24/// The branch / commit / stash / ref pickers used to list
25/// `Repository::discover(".")` — the process's repository — so opening
26/// one over a file from another checkout offered that checkout's *name*
27/// in the prompt and the wrong repository's branches in the list.
28///
29/// `PickerContext` already carries the active buffer, so the answer is
30/// the same one every other magit surface reaches for; these two handles
31/// are what turn a buffer id into it. They arrive at registration
32/// because a picker source is built at boot and `PickerContext` carries
33/// no service registry — the same reason the ex-commands capture them.
34///
35/// `Default` (no handles) resolves from the active buffer's *path* and
36/// then the working directory, which is what a harness without a buffer
37/// store gets and what magit did everywhere before MR.6.
38#[derive(Clone, Default)]
39pub struct RepoLens {
40    store: Option<lattice_mode::BufferStoreHandle>,
41    scopes: Option<crate::repo_scope::RepoScopesHandle>,
42}
43
44impl RepoLens {
45    pub fn new(
46        store: lattice_mode::BufferStoreHandle,
47        scopes: crate::repo_scope::RepoScopesHandle,
48    ) -> Self {
49        Self {
50            store: Some(store),
51            scopes: Some(scopes),
52        }
53    }
54
55    /// The repository the picker's rows belong to.
56    ///
57    /// Resolved at `init`, before the listing goes off-thread: the
58    /// answer has to be captured into the spawned task, and a task that
59    /// asked afterwards would be asking about whatever buffer is active
60    /// by the time it runs.
61    pub fn workdir(&self, ctx: &PickerContext<'_>) -> std::path::PathBuf {
62        if let (Some(store), Some(scopes)) = (&self.store, &self.scopes) {
63            let buffer = lattice_core::BufferId(ctx.active_buffer.buffer_id);
64            if let Some(workdir) = crate::repo_scope::active_workdir(store, scopes, buffer) {
65                return workdir;
66            }
67        }
68        ctx.active_buffer
69            .path
70            .and_then(|p| crate::workdir::workdir_for_file(p).map(|(workdir, _rel)| workdir))
71            .or_else(crate::workdir::magit_workdir)
72            .unwrap_or_default()
73    }
74}
75
76pub struct BranchPickBaseSource {
77    spec: PickerSourceSpec,
78    repo: RepoLens,
79}
80
81impl BranchPickBaseSource {
82    pub fn new(repo: RepoLens) -> Self {
83        Self {
84            repo,
85            // PP.2: `with_rooted` on every source in this file, for
86            // `takes_ex_command`'s reason — a branch list belongs to ONE
87            // repository, and which one is a live question by design
88            // (`magit-repo-scoping.md` §2 resolves it from the buffer so two
89            // checkouts can be open at once).
90            spec: PickerSourceSpec::no_args(
91                "magit-branch-pick-base",
92                "Pick an existing branch as the base for a new branch (magit branch-create wizard).",
93            ).with_rooted(true).with_help_topic(MAGIT_PICKER_HELP),
94        }
95    }
96}
97
98impl Default for BranchPickBaseSource {
99    fn default() -> Self {
100        Self::new(RepoLens::default())
101    }
102}
103
104impl PickerSourceGenerator for BranchPickBaseSource {
105    fn spec(&self) -> &PickerSourceSpec {
106        &self.spec
107    }
108
109    fn init(&self, ctx: &PickerContext<'_>, _args: &[String]) -> SourceResult<PickerInitResult> {
110        // MR.6: the rows belong to the repository the picker was
111        // opened over, resolved BEFORE the listing goes off-thread.
112        let workdir = self.repo.workdir(ctx);
113        Ok(PickerInitResult::Future(Box::pin(async move {
114            let branches = tokio::task::spawn_blocking(move || {
115                let repo = Repository::discover(&workdir)
116                    .map_err(|e| format!("magit-branch-pick-base: repo discover failed: {e}"))?;
117                Branch::list(&repo).map_err(|e| format!("magit-branch-pick-base: {e}"))
118            })
119            .await
120            .map_err(|e| format!("magit-branch-pick-base: join error: {e}"))??;
121            Ok(branches
122                .into_iter()
123                .map(|name| {
124                    let cand = RawCandidate::plain(name.clone(), CandidateKind::Plain);
125                    (cand, RoutingPayload::BranchBase { name })
126                })
127                .collect())
128        })))
129    }
130
131    fn accept(
132        &self,
133        _ctx: &PickerContext<'_>,
134        routing: &RoutingPayload,
135    ) -> SourceResult<PickerAcceptOutcome> {
136        match routing {
137            RoutingPayload::BranchBase { name } => Ok(branch_create_prompt_outcome(name)),
138            other => Err(format!(
139                "magit-branch-pick-base: unexpected routing payload {other:?}"
140            )),
141        }
142    }
143}
144
145/// MG.29: pick a branch and check it out.
146///
147/// The same listing as [`BranchPickBaseSource`] with a different
148/// `accept` — the branch buffer's `<CR>` needs a cursor, and a menu
149/// opened from anywhere has none. Same reasoning MG.23j applied to
150/// `A` / `_` / `O`: the row asks rather than being gated away.
151pub struct BranchCheckoutSource {
152    spec: PickerSourceSpec,
153    repo: RepoLens,
154}
155
156impl BranchCheckoutSource {
157    pub fn new(repo: RepoLens) -> Self {
158        Self {
159            repo,
160            spec: PickerSourceSpec::no_args(
161                BRANCH_CHECKOUT_SOURCE,
162                "Pick a branch and check it out.",
163            )
164            .with_rooted(true)
165            .with_help_topic(MAGIT_PICKER_HELP),
166        }
167    }
168}
169
170impl Default for BranchCheckoutSource {
171    fn default() -> Self {
172        Self::new(RepoLens::default())
173    }
174}
175
176pub const BRANCH_CHECKOUT_SOURCE: &str = "magit-branch-checkout-pick";
177
178impl PickerSourceGenerator for BranchCheckoutSource {
179    fn spec(&self) -> &PickerSourceSpec {
180        &self.spec
181    }
182
183    fn init(&self, ctx: &PickerContext<'_>, args: &[String]) -> SourceResult<PickerInitResult> {
184        // One listing, one place. A second copy of "enumerate the
185        // branches" would drift from this one the first time either
186        // grows a filter.
187        BranchPickBaseSource::new(self.repo.clone()).init(ctx, args)
188    }
189
190    fn accept(
191        &self,
192        _ctx: &PickerContext<'_>,
193        routing: &RoutingPayload,
194    ) -> SourceResult<PickerAcceptOutcome> {
195        match routing {
196            RoutingPayload::BranchBase { name } => Ok(branch_checkout_outcome(name)),
197            other => Err(format!(
198                "{BRANCH_CHECKOUT_SOURCE}: unexpected routing payload {other:?}"
199            )),
200        }
201    }
202}
203
204/// What accepting a branch in the checkout picker does.
205///
206/// Pure and separate from `accept` for the same reason
207/// [`branch_create_prompt_outcome`] is: `accept`'s signature needs a
208/// `PickerContext` fixture this translation never reads.
209fn branch_checkout_outcome(name: &str) -> PickerAcceptOutcome {
210    PickerAcceptOutcome::InvokeCommand {
211        id: "magit-checkout".to_string(),
212        args: lattice_grammar::Args::String(name.to_string()),
213    }
214}
215
216/// Build the `OpenPrompt` outcome for a picked base branch. Pulled
217/// out of `accept` so it's testable without a full `PickerContext`
218/// fixture (which `accept`'s trait signature requires but this
219/// translation never actually reads).
220fn branch_create_prompt_outcome(base: &str) -> PickerAcceptOutcome {
221    PickerAcceptOutcome::OpenPrompt {
222        prompt: format!("New branch name (from {base}):"),
223        initial: String::new(),
224        on_submit_action: "action:magit-branch-create-finish".to_string(),
225        buffer_name: Some(format!(
226            "{}{base}*",
227            crate::magit_global_mode::BRANCH_CREATE_PROMPT_PREFIX
228        )),
229    }
230}
231
232// ── MG.32: the rest of magit's branch transient ──────────────────
233//
234// Three more sources, all listing the same branches. Each reuses
235// `BranchPickBaseSource::init` rather than re-enumerating: a second
236// copy of "list the branches" drifts the moment either grows a filter,
237// which is the reason `BranchCheckoutSource` was built this way in
238// MG.29 and the reason these follow it.
239
240/// MG.32: `n` — magit's "new branch" *without* checking it out.
241///
242/// Distinct from `c` (`BranchPickBaseSource`) only in the accept: same
243/// listing, same prompt shape, and the finish action passes
244/// `checkout: false` to the same `Branch::create`. Magit keeps both
245/// because "start a branch here" and "start a branch and go there" are
246/// different intents, and the second is not always what you want when
247/// you are mid-edit.
248pub struct BranchCreateNoCheckoutSource {
249    spec: PickerSourceSpec,
250    repo: RepoLens,
251}
252
253impl BranchCreateNoCheckoutSource {
254    pub fn new(repo: RepoLens) -> Self {
255        Self {
256            repo,
257            spec: PickerSourceSpec::no_args(
258                BRANCH_CREATE_NO_CHECKOUT_SOURCE,
259                "Pick a base, then name a new branch — without checking it out.",
260            )
261            .with_rooted(true)
262            .with_help_topic(MAGIT_PICKER_HELP),
263        }
264    }
265}
266
267impl Default for BranchCreateNoCheckoutSource {
268    fn default() -> Self {
269        Self::new(RepoLens::default())
270    }
271}
272
273pub const BRANCH_CREATE_NO_CHECKOUT_SOURCE: &str = "magit-branch-create-no-checkout-pick";
274
275impl PickerSourceGenerator for BranchCreateNoCheckoutSource {
276    fn spec(&self) -> &PickerSourceSpec {
277        &self.spec
278    }
279
280    fn init(&self, ctx: &PickerContext<'_>, args: &[String]) -> SourceResult<PickerInitResult> {
281        BranchPickBaseSource::new(self.repo.clone()).init(ctx, args)
282    }
283
284    fn accept(
285        &self,
286        _ctx: &PickerContext<'_>,
287        routing: &RoutingPayload,
288    ) -> SourceResult<PickerAcceptOutcome> {
289        match routing {
290            RoutingPayload::BranchBase { name } => Ok(branch_create_no_checkout_outcome(name)),
291            other => Err(format!(
292                "{BRANCH_CREATE_NO_CHECKOUT_SOURCE}: unexpected routing payload {other:?}"
293            )),
294        }
295    }
296}
297
298fn branch_create_no_checkout_outcome(base: &str) -> PickerAcceptOutcome {
299    PickerAcceptOutcome::OpenPrompt {
300        prompt: format!("New branch name (from {base}, no checkout):"),
301        initial: String::new(),
302        on_submit_action: "action:magit-branch-create-no-checkout-finish".to_string(),
303        buffer_name: Some(format!(
304            "{}{base}*",
305            crate::magit_global_mode::BRANCH_CREATE_NO_CHECKOUT_PROMPT_PREFIX
306        )),
307    }
308}
309
310/// MG.32: `m` — rename a branch.
311///
312/// Pick the branch to rename, then a prompt asks for its new name.
313/// The old name rides in the prompt buffer's name, the same carry
314/// `c`'s wizard uses for its base — one mechanism, not a second.
315pub struct BranchRenameSource {
316    spec: PickerSourceSpec,
317    repo: RepoLens,
318}
319
320impl BranchRenameSource {
321    pub fn new(repo: RepoLens) -> Self {
322        Self {
323            repo,
324            spec: PickerSourceSpec::no_args(
325                BRANCH_RENAME_SOURCE,
326                "Pick a branch, then type its new name.",
327            )
328            .with_rooted(true)
329            .with_help_topic(MAGIT_PICKER_HELP),
330        }
331    }
332}
333
334impl Default for BranchRenameSource {
335    fn default() -> Self {
336        Self::new(RepoLens::default())
337    }
338}
339
340pub const BRANCH_RENAME_SOURCE: &str = "magit-branch-rename-pick";
341
342impl PickerSourceGenerator for BranchRenameSource {
343    fn spec(&self) -> &PickerSourceSpec {
344        &self.spec
345    }
346
347    fn init(&self, ctx: &PickerContext<'_>, args: &[String]) -> SourceResult<PickerInitResult> {
348        BranchPickBaseSource::new(self.repo.clone()).init(ctx, args)
349    }
350
351    fn accept(
352        &self,
353        _ctx: &PickerContext<'_>,
354        routing: &RoutingPayload,
355    ) -> SourceResult<PickerAcceptOutcome> {
356        match routing {
357            RoutingPayload::BranchBase { name } => Ok(branch_rename_outcome(name)),
358            other => Err(format!(
359                "{BRANCH_RENAME_SOURCE}: unexpected routing payload {other:?}"
360            )),
361        }
362    }
363}
364
365/// The rename prompt is pre-filled with the current name, because a
366/// rename is usually an edit of it (a typo, a prefix) rather than a
367/// fresh name typed from nothing.
368fn branch_rename_outcome(old: &str) -> PickerAcceptOutcome {
369    PickerAcceptOutcome::OpenPrompt {
370        prompt: format!("Rename {old} to:"),
371        initial: old.to_string(),
372        on_submit_action: "action:magit-branch-rename-finish".to_string(),
373        buffer_name: Some(format!(
374            "{}{old}*",
375            crate::magit_global_mode::BRANCH_RENAME_PROMPT_PREFIX
376        )),
377    }
378}
379
380/// MG.32: `x` — delete a branch (magit's `k`, moved by
381/// evil-collection-magit).
382///
383/// Accept routes through the **ex-command**, not straight to a git
384/// call, because deletion must ask first (MG.12) and a picker's accept
385/// cannot raise an `Effect::Confirm` itself. `:magit-branch-delete
386/// <name>` is that ask, and is the scriptable form besides.
387pub struct BranchDeleteSource {
388    spec: PickerSourceSpec,
389    repo: RepoLens,
390}
391
392impl BranchDeleteSource {
393    pub fn new(repo: RepoLens) -> Self {
394        Self {
395            repo,
396            spec: PickerSourceSpec::no_args(
397                BRANCH_DELETE_SOURCE,
398                "Pick a branch to delete — asks before deleting.",
399            )
400            .with_rooted(true)
401            .with_help_topic(MAGIT_PICKER_HELP),
402        }
403    }
404}
405
406impl Default for BranchDeleteSource {
407    fn default() -> Self {
408        Self::new(RepoLens::default())
409    }
410}
411
412pub const BRANCH_DELETE_SOURCE: &str = "magit-branch-delete-pick";
413
414impl PickerSourceGenerator for BranchDeleteSource {
415    fn spec(&self) -> &PickerSourceSpec {
416        &self.spec
417    }
418
419    fn init(&self, ctx: &PickerContext<'_>, args: &[String]) -> SourceResult<PickerInitResult> {
420        BranchPickBaseSource::new(self.repo.clone()).init(ctx, args)
421    }
422
423    fn accept(
424        &self,
425        _ctx: &PickerContext<'_>,
426        routing: &RoutingPayload,
427    ) -> SourceResult<PickerAcceptOutcome> {
428        match routing {
429            RoutingPayload::BranchBase { name } => Ok(branch_delete_outcome(name)),
430            other => Err(format!(
431                "{BRANCH_DELETE_SOURCE}: unexpected routing payload {other:?}"
432            )),
433        }
434    }
435}
436
437fn branch_delete_outcome(name: &str) -> PickerAcceptOutcome {
438    PickerAcceptOutcome::InvokeCommand {
439        id: "magit-branch-delete".to_string(),
440        args: lattice_grammar::Args::String(name.to_string()),
441    }
442}
443
444/// MG.53.c: build the ex line a picked value runs.
445///
446/// `{}` in `command` is replaced by the pick; with no `{}` the pick is
447/// appended. Both magit's picker sources go through this.
448///
449/// The placeholder exists because not every operation takes its
450/// selection LAST. `magit-find-file` is `<rev> <path>` — appending a
451/// revision to `magit-find-file src/main.rs` would produce
452/// `<path> <rev>` and open a file named after a sha. The alternative
453/// was registering order-adapter ex-commands that duplicate an existing
454/// operation purely to move an argument, which is a second
455/// implementation of the same thing.
456pub(crate) fn picked_line(command: &str, value: &str) -> String {
457    match command.find("{}") {
458        Some(_) => command.replacen("{}", value, 1),
459        None => format!("{command} {value}"),
460    }
461}
462
463/// Register this crate's picker sources into the supplied registry.
464/// Called from `lattice-host`'s `editor_boot.rs`, mirroring
465/// `lattice_snippet::picker_sources::register` — the picker registry
466/// is host-owned and populated by name at boot (no generic
467/// `SubsystemBoot` seam for pickers today), so this is the
468/// established shape for a feature crate to contribute a source.
469/// MG.54: `config` reaches the revision source so it can read
470/// `magit.revision-preview` at preview time. Passed in rather than
471/// looked up because the picker registry is populated at boot, before
472/// any service registry the source could consult exists — and because
473/// the option is this crate's, so this crate reads it (the host learns
474/// nothing about magit's options).
475pub fn register(
476    picker_registry: &mut lattice_picker::PickerRegistry,
477    config: Option<Arc<lattice_config::ConfigRegistry>>,
478    repo: RepoLens,
479) {
480    picker_registry.register_generator(Arc::new(BranchPickBaseSource::new(repo.clone())));
481    picker_registry.register_generator(Arc::new(BranchCheckoutSource::new(repo.clone())));
482    // MG.32: the rest of the branch transient's picker-backed rows.
483    picker_registry.register_generator(Arc::new(BranchCreateNoCheckoutSource::new(repo.clone())));
484    picker_registry.register_generator(Arc::new(BranchRenameSource::new(repo.clone())));
485    picker_registry.register_generator(Arc::new(BranchDeleteSource::new(repo.clone())));
486    picker_registry.register_generator(Arc::new(CommitPickSource::new(repo.clone())));
487    // The stash peer: the dispatch menu's apply / pop / drop / show
488    // rows have no cursor to resolve a stash from.
489    picker_registry.register_generator(Arc::new(StashPickSource::new(repo.clone())));
490    // MG.52: the branch peer of `CommitPickSource` — one source for
491    // every "which branch?" question, parameterised by what to do with
492    // the answer.
493    picker_registry.register_generator(Arc::new(BranchPickSource::new(repo.clone())));
494    // MG.53.d/e: tag / remote / ref — one implementation, three scopes.
495    picker_registry.register_generator(Arc::new(RefPickSource::new(repo.clone(), RefScope::Tags)));
496    picker_registry.register_generator(Arc::new(RefPickSource::new(
497        repo.clone(),
498        RefScope::Remotes,
499    )));
500    picker_registry.register_generator(Arc::new(RefPickSource::new(
501        repo.clone(),
502        RefScope::AllRefs,
503    )));
504    // MG.54: the revision scope is the one that previews (`C-c f v`), so
505    // it is the one that needs the config handle.
506    picker_registry.register_generator(Arc::new(RefPickSource::with_config(
507        repo,
508        RefScope::Revisions,
509        config,
510    )));
511}
512
513/// MG.23j: `:picker magit-commit <ex-command>` — pick a commit, then
514/// run that command on it.
515///
516/// The repo-level `A` / `_` / `O` rows need a commit and the root
517/// dispatch has none under a cursor, so magit answers with a prompt
518/// rather than with a predicate — its own `magit-cherry-pick` /
519/// `magit-revert` / `magit-reset` sit in the **ungated** group of
520/// `magit-dispatch` precisely because they are transients that ask.
521/// This is that ask.
522///
523/// **The argument is an ex-command name, not an action name**, and
524/// that is forced rather than chosen. A picked candidate can only
525/// reach an operation through `RoutingPayload::InvokeCommand`, whose
526/// host arm destructures its `args` away
527/// (`InvokeCommand { id, .. }`) and runs `id` as an ex line — so the
528/// commit has to travel *inside* the line, and the thing on the other
529/// end has to be an ex-command. Every [`CommitOp`] carries its own
530/// `ex_command` for exactly this.
531pub struct CommitPickSource {
532    spec: PickerSourceSpec,
533    repo: RepoLens,
534}
535
536/// Spec for a source that **takes the ex-command to run on the pick**.
537///
538/// Every "pick a thing, then act on it" source here shares one shape,
539/// because a picked candidate reaches an operation only through
540/// `RoutingPayload::InvokeCommand` — the host runs its `id` as an ex
541/// line — so the operation has to arrive as an argument.
542///
543/// Declaring that argument is not paperwork. `args_schema` is what
544/// `:picker <id> <Tab>` completes against and `args_hint` is what the
545/// command line shows while you type one; a source that omits them
546/// while its `init` rejects an empty `args` advertises "nothing to
547/// type here" and then refuses to open. `a_source_that_needs_an_ex_command_declares_it`
548/// holds the two together by asking every registered source to `init`
549/// with no arguments and requiring the refusals to be exactly the
550/// declarations.
551/// PH.4: the one help page every magit source declares. They are one family
552/// — the same keys over different ref kinds — so a page per source would be
553/// twelve copies of the same table; `docs/user/picker-magit.md`.
554const MAGIT_PICKER_HELP: &str = "picker-magit";
555
556fn takes_ex_command(id: &'static str, doc: &'static str, noun: &'static str) -> PickerSourceSpec {
557    PickerSourceSpec {
558        create_label: None,
559        id: std::borrow::Cow::Borrowed(id),
560        doc: std::borrow::Cow::Borrowed(doc),
561        args_schema: vec![lattice_grammar::ArgSpec::required(
562            "command",
563            lattice_grammar::ArgKind::String,
564            noun,
565        )],
566        args_hint: std::borrow::Cow::Borrowed("<magit ex-command>"),
567        live: false,
568        // PD.1: nothing here is removable from a picker. A branch, a stash
569        // and a tag are all deletable THINGS, and magit has commands for each
570        // — but deleting one is a git operation with its own confirmation and
571        // its own failure modes, not a list-tidying keystroke. `<C-d>` stays
572        // inert here rather than becoming a second, unconfirmed path to
573        // `branch -D`.
574        delete_command: None,
575        help_topic: Some(std::borrow::Cow::Borrowed(MAGIT_PICKER_HELP)),
576        // PP.2: every magit source lists one REPOSITORY's refs, and the whole
577        // reason `RepoLens` exists is that which repository is a live question
578        // — `magit-repo-scoping.md` §2 resolves it from the buffer, precisely
579        // so two checkouts can be open at once. A branch list with no repo on
580        // it is the case that rule was written for.
581        rooted: true,
582    }
583}
584
585impl CommitPickSource {
586    pub fn new(repo: RepoLens) -> Self {
587        Self {
588            repo,
589            spec: takes_ex_command(
590                COMMIT_PICK_SOURCE,
591                "Pick a commit, then run the named magit ex-command on it.",
592                "magit ex-command to run on the picked commit",
593            ),
594        }
595    }
596}
597
598impl Default for CommitPickSource {
599    fn default() -> Self {
600        Self::new(RepoLens::default())
601    }
602}
603
604/// The source id, shared by the generator and every
605/// `Effect::OpenPicker` that names it — the drift `BranchPickBaseSource`'s
606/// own test exists to catch, avoided here by there being one constant.
607pub const COMMIT_PICK_SOURCE: &str = "magit-commit";
608
609/// How many commits the picker offers. Bounded because this runs
610/// `git log` synchronously on a blocking thread and a repository's
611/// history has no upper size — the list is for picking a recent
612/// commit, and anything older is reachable by typing the sha into the
613/// ex-command directly.
614const COMMIT_PICK_LIMIT: usize = 200;
615
616impl PickerSourceGenerator for CommitPickSource {
617    fn spec(&self) -> &PickerSourceSpec {
618        &self.spec
619    }
620
621    fn init(&self, ctx: &PickerContext<'_>, args: &[String]) -> SourceResult<PickerInitResult> {
622        let workdir = self.repo.workdir(ctx);
623        let command = args
624            .first()
625            .filter(|c| !c.is_empty())
626            .ok_or_else(|| {
627                format!("{COMMIT_PICK_SOURCE}: needs the ex-command to run on the picked commit")
628            })?
629            .clone();
630        Ok(PickerInitResult::Future(Box::pin(async move {
631            let rows = tokio::task::spawn_blocking(move || recent_commits(workdir))
632                .await
633                .map_err(|e| format!("{COMMIT_PICK_SOURCE}: join error: {e}"))??;
634            Ok(rows
635                .into_iter()
636                .map(|(sha, display)| {
637                    let cand = RawCandidate::plain(display, CandidateKind::Plain);
638                    (
639                        cand,
640                        // The full sha, not the abbreviation shown: an
641                        // abbreviation is ambiguous in principle and
642                        // git resolves the ambiguity by refusing, which
643                        // would surface as a picked commit that did
644                        // nothing.
645                        RoutingPayload::InvokeCommand {
646                            id: picked_line(&command, &sha),
647                            args: lattice_grammar::args::Args::None,
648                        },
649                    )
650                })
651                .collect())
652        })))
653    }
654
655    fn accept(
656        &self,
657        _ctx: &PickerContext<'_>,
658        routing: &RoutingPayload,
659    ) -> SourceResult<PickerAcceptOutcome> {
660        match routing {
661            RoutingPayload::InvokeCommand { id, args } => Ok(PickerAcceptOutcome::InvokeCommand {
662                id: id.clone(),
663                args: args.clone(),
664            }),
665            other => Err(format!(
666                "{COMMIT_PICK_SOURCE}: unexpected routing payload {other:?}"
667            )),
668        }
669    }
670}
671
672/// Pick a stash, then run the named magit ex-command on it.
673///
674/// The stash peer of [`CommitPickSource`], and it exists for the same
675/// reason: the stash chords resolve the stash under the cursor, and a
676/// dispatch menu opened from an ordinary file has no cursor to read.
677/// Before this the menu's apply / pop / drop / show rows rendered,
678/// fired, resolved nothing and returned — a visible row that did
679/// nothing, silently, which is the failure `BranchCheckoutSource`'s
680/// note calls out for `<CR>` in the branch buffer.
681///
682/// The pick is the stash's **index**, not its message: `git stash`
683/// addresses entries as `stash@{N}` and messages are neither unique
684/// nor stable. The display carries the message because that is what a
685/// person remembers, exactly as the commit picker shows the subject.
686pub struct StashPickSource {
687    spec: PickerSourceSpec,
688    repo: RepoLens,
689}
690
691impl StashPickSource {
692    pub fn new(repo: RepoLens) -> Self {
693        Self {
694            repo,
695            spec: takes_ex_command(
696                STASH_PICK_SOURCE,
697                "Pick a stash, then run the named magit ex-command on it.",
698                "magit ex-command to run on the picked stash",
699            ),
700        }
701    }
702}
703
704impl Default for StashPickSource {
705    fn default() -> Self {
706        Self::new(RepoLens::default())
707    }
708}
709
710pub const STASH_PICK_SOURCE: &str = "magit-stash-pick";
711
712impl PickerSourceGenerator for StashPickSource {
713    fn spec(&self) -> &PickerSourceSpec {
714        &self.spec
715    }
716
717    fn init(&self, ctx: &PickerContext<'_>, args: &[String]) -> SourceResult<PickerInitResult> {
718        // MR.6: the rows belong to the repository the picker was
719        // opened over, resolved BEFORE the listing goes off-thread.
720        let workdir = self.repo.workdir(ctx);
721        let command = args
722            .first()
723            .filter(|c| !c.is_empty())
724            .ok_or_else(|| {
725                format!("{STASH_PICK_SOURCE}: needs the ex-command to run on the picked stash")
726            })?
727            .clone();
728        Ok(PickerInitResult::Future(Box::pin(async move {
729            let entries = tokio::task::spawn_blocking(move || {
730                let repo = Repository::discover(&workdir)
731                    .map_err(|e| format!("{STASH_PICK_SOURCE}: repo discover failed: {e}"))?;
732                lattice_vcs::Stash::list(&repo).map_err(|e| format!("{STASH_PICK_SOURCE}: {e}"))
733            })
734            .await
735            .map_err(|e| format!("{STASH_PICK_SOURCE}: join error: {e}"))??;
736            Ok(entries
737                .into_iter()
738                .map(|entry| {
739                    let cand = RawCandidate::plain(
740                        format!("stash@{{{}}} {}", entry.index, entry.message),
741                        CandidateKind::Plain,
742                    );
743                    (
744                        cand,
745                        RoutingPayload::InvokeCommand {
746                            id: picked_line(&command, &entry.index.to_string()),
747                            args: lattice_grammar::args::Args::None,
748                        },
749                    )
750                })
751                .collect())
752        })))
753    }
754
755    fn accept(
756        &self,
757        _ctx: &PickerContext<'_>,
758        routing: &RoutingPayload,
759    ) -> SourceResult<PickerAcceptOutcome> {
760        match routing {
761            RoutingPayload::InvokeCommand { id, args } => Ok(PickerAcceptOutcome::InvokeCommand {
762                id: id.clone(),
763                args: args.clone(),
764            }),
765            other => Err(format!(
766                "{STASH_PICK_SOURCE}: unexpected routing payload {other:?}"
767            )),
768        }
769    }
770}
771
772/// `(full-sha, display-row)` for the most recent commits, newest
773/// first.
774///
775/// The display is `<short> <subject>` — what a log row looks like, so
776/// the fuzzy filter matches on the subject, which is what anyone
777/// actually remembers about a commit.
778fn recent_commits(workdir: std::path::PathBuf) -> Result<Vec<(String, String)>, String> {
779    let out = std::process::Command::new("git")
780        .args([
781            "log",
782            &format!("-n{COMMIT_PICK_LIMIT}"),
783            "--format=%H%x00%h %s",
784        ])
785        // MR.6: a `Command` with no `current_dir` inherits the
786        // process's, which is the same bug as `discover(".")` wearing a
787        // shape no grep for the discovery would match.
788        .current_dir(&workdir)
789        .output()
790        .map_err(|e| format!("{COMMIT_PICK_SOURCE}: {e}"))?;
791    if !out.status.success() {
792        return Err(format!("{COMMIT_PICK_SOURCE}: git log failed"));
793    }
794    Ok(parse_commit_rows(&String::from_utf8_lossy(&out.stdout)))
795}
796
797/// Split `git log`'s NUL-separated `(sha, display)` pairs.
798///
799/// Pure so the shape is testable without a repository — and the shape
800/// is what matters, because a row whose sha and display got swapped
801/// would show a readable list that cherry-picks the wrong thing.
802pub(crate) fn parse_commit_rows(raw: &str) -> Vec<(String, String)> {
803    raw.lines()
804        .filter_map(|line| {
805            let (sha, display) = line.split_once('\0')?;
806            (!sha.is_empty() && !display.is_empty()).then(|| (sha.to_string(), display.to_string()))
807        })
808        .collect()
809}
810
811#[cfg(test)]
812mod commit_pick {
813    use super::*;
814
815    #[test]
816    fn spec_id_matches_the_name_every_open_effect_uses() {
817        let source = CommitPickSource::new(RepoLens::default());
818        assert_eq!(source.spec().id, COMMIT_PICK_SOURCE);
819        assert_eq!(
820            source.spec().args_schema.len(),
821            1,
822            "the ex-command to run is the source's one argument"
823        );
824    }
825
826    /// The sha must be the full one and it must reach the ex line.
827    ///
828    /// `InvokeCommand`'s `args` field was a dead end when this was
829    /// written — the host destructured it away — so the value had to be
830    /// *in* the line. That is fixed (2026-08-03), but the line form is
831    /// kept: it is the exact text a user would type.
832    #[test]
833    fn a_picked_commit_becomes_the_ex_line_that_acts_on_it() {
834        let rows = parse_commit_rows("abc123def\0abc123d fix the thing\n");
835        assert_eq!(rows.len(), 1);
836        let (sha, display) = &rows[0];
837        assert_eq!(sha, "abc123def", "the FULL sha, not the abbreviation");
838        assert_eq!(display, "abc123d fix the thing");
839        assert_eq!(
840            format!("magit-cherry-pick {sha}"),
841            "magit-cherry-pick abc123def"
842        );
843    }
844
845    /// Sha and display must not swap: a swapped row renders a
846    /// perfectly readable list that cherry-picks the wrong thing.
847    #[test]
848    fn rows_keep_the_sha_and_the_display_on_their_own_sides() {
849        // `\x00`, not `\0`: the separator is immediately followed by a
850        // digit, which makes `\01` read as an octal escape.
851        let rows = parse_commit_rows("1111111111\x001111111 first\n2222222222\x002222222 second\n");
852        assert_eq!(rows[0].0, "1111111111");
853        assert!(rows[0].1.ends_with("first"));
854        assert_eq!(rows[1].0, "2222222222");
855        assert!(rows[1].1.ends_with("second"));
856    }
857
858    #[test]
859    fn malformed_rows_are_dropped_rather_than_half_parsed() {
860        assert!(parse_commit_rows("no-nul-here\n").is_empty());
861        assert!(parse_commit_rows("\0only-display\n").is_empty());
862        assert!(parse_commit_rows("only-sha\0\n").is_empty());
863    }
864
865    /// Every `CommitOp` names an ex-command, and the picker fires it
866    /// by that name — so an op whose name did not match a registered
867    /// command would produce a picker that picks and does nothing.
868    /// Pinned against the literal strings the registration uses.
869    #[test]
870    fn every_commit_op_names_a_distinct_ex_command() {
871        use crate::magit_global_mode::CommitOp;
872        let ops = [
873            CommitOp::CHERRY_PICK,
874            CommitOp::REVERT,
875            CommitOp::RESET_SOFT,
876            CommitOp::RESET_MIXED,
877            CommitOp::RESET_HARD,
878        ];
879        let names: Vec<&str> = ops.iter().map(|o| o.ex_command).collect();
880        assert_eq!(
881            names,
882            [
883                "magit-cherry-pick",
884                "magit-revert",
885                "magit-reset-soft",
886                "magit-reset-mixed",
887                "magit-reset-hard"
888            ]
889        );
890        let unique: std::collections::HashSet<_> = names.iter().collect();
891        assert_eq!(unique.len(), names.len(), "one command per op");
892    }
893}
894
895#[cfg(test)]
896mod tests {
897    use super::*;
898
899    /// MG.32: **magit owns the inventory of magit's picker sources.**
900    ///
901    /// This assertion used to live in `lattice-ui-tui`'s
902    /// `gen_picker_sources_emits_candidate_per_registered_source`, as
903    /// part of one hardcoded list of every first-party source. That
904    /// list taxed each crate that added a source and could be owned by
905    /// none of them, so it rotted: MG.29 registered
906    /// `magit-branch-checkout-pick` without extending it and left that
907    /// test failing on `main` until MG.32 noticed. Here, adding a
908    /// source and updating its test are the same edit in the same
909    /// crate.
910    ///
911    /// Every id is also pinned against the constant the `Effect::
912    /// OpenPicker` handler names, so a renamed source cannot leave a
913    /// menu row opening a picker that no longer exists.
914    #[test]
915    fn magit_registers_exactly_the_sources_its_rows_open() {
916        let mut registry = lattice_picker::PickerRegistry::new();
917        register(&mut registry, None, RepoLens::default());
918        let mut ids: Vec<&str> = registry.ids().collect();
919        ids.sort_unstable();
920
921        assert_eq!(
922            ids,
923            vec![
924                BRANCH_PICK_SOURCE, // MG.52: `b b`, and every
925                //                                   other "which branch?"
926                BRANCH_CHECKOUT_SOURCE,           // MG.29: `b l`
927                BRANCH_CREATE_NO_CHECKOUT_SOURCE, // MG.32: `b n`
928                BRANCH_DELETE_SOURCE,             // MG.32: `b x`
929                "magit-branch-pick-base",         // `b c`, and `c` in the branch buffer
930                BRANCH_RENAME_SOURCE,             // MG.32: `b m`
931                COMMIT_PICK_SOURCE,               // MG.23j: `A` / `_` / `O`
932                REF_PICK_SOURCE,                  // MG.53.e: any ref
933                REMOTE_PICK_SOURCE,               // MG.53.d: tag prune
934                REVISION_PICK_SOURCE,             // MG.53.g: refs + commits
935                STASH_PICK_SOURCE,                // `z a`/`z p`/`z k`/`z v`
936                TAG_PICK_SOURCE,                  // MG.53.d: tag delete
937            ],
938            "magit's registered picker sources changed — update this list \
939             together with `register`, and check every `Effect::OpenPicker` \
940             that names one"
941        );
942    }
943
944    /// A `PickerContext` with nothing in it.
945    ///
946    /// Every magit source takes `_ctx` — each asks git, not the editor —
947    /// so an empty snapshot exercises them exactly as the real one does.
948    fn empty_picker_ctx(buffer: &lattice_core::Buffer) -> lattice_picker::PickerContext<'_> {
949        lattice_picker::PickerContext {
950            active_buffer: lattice_picker::ActiveBufferSnapshot {
951                buffer_id: 0,
952                path: None,
953                language: None,
954                cursor: lattice_protocol::position::Position::new(0, 0),
955                selection: None,
956                buffer,
957                syntax_symbols: Vec::new(),
958                syntax_highlights: Vec::new(),
959            },
960            workspace_root: std::path::PathBuf::from("."),
961            recent_files: &[],
962            position_history: Vec::new(),
963            buffers: Vec::new(),
964            marks: Vec::new(),
965            registers: Vec::new(),
966            yank_ring: Vec::new(),
967            active_modes: Vec::new(),
968            command_history: Vec::new(),
969            search_history: Vec::new(),
970            pane_buffer_history: Vec::new(),
971        }
972    }
973
974    /// A source that **requires** an ex-command must say so in its spec.
975    ///
976    /// `spec.args_schema` is not decoration: `:picker <id> <Tab>` reads
977    /// it to offer the argument, and `args_hint` is what the command
978    /// line shows while you are typing one. A source whose `init`
979    /// rejects an empty `args` while its spec claims `no_args` tells the
980    /// user there is nothing to type and then refuses the pick — the
981    /// failure is only visible at the moment the picker declines to
982    /// open, which is the worst moment to learn about an argument.
983    ///
984    /// Asserted by *behaviour*, not by reading the schema back: each
985    /// source is asked to `init` with no arguments, and the ones that
986    /// refuse must be exactly the ones declaring a required arg.
987    #[test]
988    fn a_source_that_needs_an_ex_command_declares_it() {
989        let mut registry = lattice_picker::PickerRegistry::new();
990        register(&mut registry, None, RepoLens::default());
991        let buffer = lattice_core::Buffer::empty();
992        let ctx = empty_picker_ctx(&buffer);
993        let ids: Vec<String> = registry.ids().map(str::to_string).collect();
994        for id in ids {
995            let id = id.as_str();
996            let source = registry.generator(id).expect("just enumerated");
997            let declares = source
998                .spec()
999                .args_schema
1000                .first()
1001                .is_some_and(|a| matches!(a.default, lattice_grammar::ArgDefault::Required));
1002            let refuses = source.init(&ctx, &[]).is_err();
1003            assert_eq!(
1004                declares, refuses,
1005                "`{id}`: spec declares a required arg = {declares}, but \
1006                 init with no args refuses = {refuses}. These must agree — \
1007                 a source that needs an ex-command has to advertise it so \
1008                 `:picker {id} <Tab>` can complete one."
1009            );
1010            if declares {
1011                assert!(
1012                    !source.spec().args_hint.is_empty(),
1013                    "`{id}` declares a required arg but shows no args_hint, \
1014                     so the command line has nothing to display while the \
1015                     user types it"
1016                );
1017            }
1018        }
1019    }
1020
1021    /// MG.53.g: **a revision is not only a commit.**
1022    ///
1023    /// `git log` answers "which commit on the branch I am on", which is
1024    /// the wrong question for *view this file as it is on
1025    /// `origin/main`*: a file that lives on another branch is not in
1026    /// this branch's history at all, so no number of commits would
1027    /// surface it. The scopes are what keep those two questions apart.
1028    #[test]
1029    fn the_revision_scope_spans_refs_and_commits_and_the_others_do_not() {
1030        use super::{RefPickSource, RefScope};
1031        // Distinct ids, so a row naming one cannot silently get another.
1032        let ids: Vec<String> = [
1033            RefScope::Tags,
1034            RefScope::Remotes,
1035            RefScope::AllRefs,
1036            RefScope::Revisions,
1037        ]
1038        .into_iter()
1039        .map(|sc| {
1040            RefPickSource::new(RepoLens::default(), sc)
1041                .spec()
1042                .id
1043                .to_string()
1044        })
1045        .collect();
1046        let mut sorted = ids.clone();
1047        sorted.sort_unstable();
1048        sorted.dedup();
1049        assert_eq!(
1050            sorted.len(),
1051            ids.len(),
1052            "each scope registers under its own id: {ids:?}"
1053        );
1054        assert_eq!(
1055            RefPickSource::new(RepoLens::default(), RefScope::Revisions)
1056                .spec()
1057                .id,
1058            super::REVISION_PICK_SOURCE
1059        );
1060        // The commit-only picker is a DIFFERENT source and stays so —
1061        // cherry-pick / revert / reset genuinely want a commit, and
1062        // offering them a branch would be offering the wrong noun.
1063        assert_ne!(super::REVISION_PICK_SOURCE, super::COMMIT_PICK_SOURCE);
1064    }
1065
1066    /// MG.53.c: **the pick does not always go last.**
1067    ///
1068    /// `magit-find-file` is `<rev> <path>`, so appending a revision to
1069    /// `magit-find-file src/main.rs` would produce `<path> <rev>` and
1070    /// open a file named after a sha. The alternative to a placeholder
1071    /// was an order-adapter ex-command duplicating an operation that
1072    /// already exists, purely to move an argument.
1073    #[test]
1074    fn a_placeholder_places_the_pick_and_absence_appends_it() {
1075        assert_eq!(
1076            super::picked_line("magit-find-file {} src/main.rs", "abc123"),
1077            "magit-find-file abc123 src/main.rs",
1078            "the pick goes where the placeholder is"
1079        );
1080        assert_eq!(
1081            super::picked_line("magit-checkout", "main"),
1082            "magit-checkout main",
1083            "no placeholder — appended, which is what every branch row does"
1084        );
1085        // Only the first is substituted: a command mentioning `{}` twice
1086        // wants one value in one slot, not the same value twice.
1087        assert_eq!(
1088            super::picked_line("cmd {} {}", "x"),
1089            "cmd x {}",
1090            "one pick fills one slot"
1091        );
1092    }
1093
1094    /// MG.54: the preview answers `magit-find-file` and nothing else.
1095    ///
1096    /// The same `magit-revision` source fills `magit-checkout` (moves
1097    /// HEAD) and `magit-file-checkout` (overwrites the working tree).
1098    /// Showing a file's content beside either invites reading the pane
1099    /// as "this is what you'll get", when what you get is that content
1100    /// written over uncommitted work. This is the guard, so it is pinned
1101    /// against every line the menu rows actually build.
1102    #[test]
1103    fn only_the_find_file_line_is_previewable() {
1104        use super::find_file_preview_target;
1105        let (rev, path) =
1106            find_file_preview_target("magit-find-file abc123 src/main.rs").expect("previewable");
1107        assert_eq!(rev, "abc123");
1108        assert_eq!(path, std::path::Path::new("src/main.rs"));
1109
1110        // The lines the other two rows build, verbatim from
1111        // `magit_global_mode` (`picked_line` has already substituted).
1112        assert!(
1113            find_file_preview_target("magit-checkout main").is_none(),
1114            "a checkout is an action, not a question about a file"
1115        );
1116        assert!(
1117            find_file_preview_target("magit-file-checkout abc123 src/main.rs").is_none(),
1118            "file-checkout OVERWRITES that path — previewing it reads as a promise"
1119        );
1120        // Malformed / partial lines refuse rather than fetching `HEAD:`.
1121        assert!(find_file_preview_target("magit-find-file abc123").is_none());
1122        assert!(find_file_preview_target("magit-find-file  ").is_none());
1123        assert!(find_file_preview_target("magit-find-files x y").is_none());
1124    }
1125
1126    /// A path with spaces survives: everything after the revision is the
1127    /// path, because splitting on every space would truncate it.
1128    #[test]
1129    fn a_path_with_spaces_is_kept_whole() {
1130        let (rev, path) = super::find_file_preview_target("magit-find-file HEAD my dir/a b.rs")
1131            .expect("previewable");
1132        assert_eq!(rev, "HEAD");
1133        assert_eq!(path, std::path::Path::new("my dir/a b.rs"));
1134    }
1135
1136    /// The window and the fetch are gated by the SAME switch. If they
1137    /// could disagree, turning the option off would still arm a timer on
1138    /// every arrow key (or worse, leave the fetch reachable inline).
1139    #[test]
1140    fn the_option_gates_the_window_and_the_fetch_together() {
1141        use super::{RefPickSource, RefScope};
1142        use lattice_picker::PickerSourceGenerator;
1143
1144        let config = std::sync::Arc::new(lattice_config::ConfigRegistry::new());
1145        // `options! { … }` is a compile-time declaration; this is what
1146        // makes it a runtime fact in a registry.
1147        config.init_from_linkme();
1148        let src = RefPickSource::with_config(
1149            RepoLens::default(),
1150            RefScope::Revisions,
1151            Some(std::sync::Arc::clone(&config)),
1152        );
1153        assert!(
1154            src.preview_debounce().is_some(),
1155            "on by default ⇒ the settle window is declared"
1156        );
1157
1158        config
1159            .set_typed::<crate::options::MagitRevisionPreview>(false)
1160            .expect("option is registered");
1161        assert!(
1162            src.preview_debounce().is_none(),
1163            "off ⇒ no window, so no timer per selection move either"
1164        );
1165    }
1166
1167    /// The other scopes never preview, whatever the option says — they
1168    /// list tags / remotes / refs, and none of those is a file.
1169    #[test]
1170    fn only_the_revision_scope_declares_a_settle_window() {
1171        use super::{RefPickSource, RefScope};
1172        use lattice_picker::PickerSourceGenerator;
1173        for scope in [RefScope::Tags, RefScope::Remotes, RefScope::AllRefs] {
1174            assert!(
1175                RefPickSource::new(RepoLens::default(), scope)
1176                    .preview_debounce()
1177                    .is_none(),
1178                "{scope:?} has nothing to preview"
1179            );
1180        }
1181    }
1182
1183    #[test]
1184    fn spec_id_matches_the_effect_openpicker_source_name() {
1185        // magit_branch_mode's `c` handler hardcodes this string in
1186        // `Effect::OpenPicker { source: "magit-branch-pick-base", .. }`
1187        // — this assertion is the tripwire if the id ever drifts.
1188        let source = BranchPickBaseSource::new(RepoLens::default());
1189        assert_eq!(source.spec().id, "magit-branch-pick-base");
1190        assert!(source.spec().args_schema.is_empty());
1191    }
1192
1193    /// MG.32: every branch flow that stashes its target in a prompt
1194    /// buffer's NAME must be readable back by the finish handler that
1195    /// consumes it.
1196    ///
1197    /// This is the MG.15 failure class, and it is silent: the writer
1198    /// lives here and the reader lives in `magit_global_mode`, so a
1199    /// prefix changed on one side leaves the other returning `None` —
1200    /// the prompt opens, you type a name, and nothing happens. Both
1201    /// sides now spell the prefix through one constant, and this
1202    /// round-trips through the real functions rather than through a
1203    /// literal that could itself drift.
1204    #[test]
1205    fn every_branch_prompt_name_round_trips_to_its_reader() {
1206        use lattice_picker::PickerAcceptOutcome as O;
1207
1208        // Names with a `/` and a `-` — both appear in real branch names
1209        // and both would break a naive split-on-delimiter parser.
1210        for branch in ["feature/foo", "release-1.2", "main"] {
1211            let cases: Vec<(&str, PickerAcceptOutcome, &str)> = vec![
1212                (
1213                    "create",
1214                    branch_create_prompt_outcome(branch),
1215                    crate::magit_global_mode::BRANCH_CREATE_PROMPT_PREFIX,
1216                ),
1217                (
1218                    "create-no-checkout",
1219                    branch_create_no_checkout_outcome(branch),
1220                    crate::magit_global_mode::BRANCH_CREATE_NO_CHECKOUT_PROMPT_PREFIX,
1221                ),
1222                (
1223                    "rename",
1224                    branch_rename_outcome(branch),
1225                    crate::magit_global_mode::BRANCH_RENAME_PROMPT_PREFIX,
1226                ),
1227            ];
1228            for (what, outcome, prefix) in cases {
1229                let O::OpenPrompt { buffer_name, .. } = outcome else {
1230                    panic!("{what} must open a prompt");
1231                };
1232                let name = buffer_name.unwrap_or_else(|| panic!("{what} must name its buffer"));
1233                assert_eq!(
1234                    crate::magit_global_mode::branch_from_prompt_buffer_name_for_test(
1235                        &name, prefix
1236                    ),
1237                    Some(branch.to_string()),
1238                    "{what}'s prompt-buffer name `{name}` must read back as `{branch}`"
1239                );
1240            }
1241        }
1242    }
1243
1244    /// The three prefixes must be mutually non-ambiguous, or a finish
1245    /// handler reads a name a different flow wrote and acts on the
1246    /// wrong branch with the wrong operation.
1247    ///
1248    /// Not hypothetical: `*magit:branch-create-from:` and
1249    /// `*magit:branch-create-nocheckout-from:` are one hyphen apart, and
1250    /// had the second been spelled `…create-from-nocheckout:` the
1251    /// create parser would match it and silently check the branch out.
1252    #[test]
1253    fn the_branch_prompt_prefixes_cannot_match_each_others_names() {
1254        use crate::magit_global_mode as g;
1255        let prefixes = [
1256            g::BRANCH_CREATE_PROMPT_PREFIX,
1257            g::BRANCH_CREATE_NO_CHECKOUT_PROMPT_PREFIX,
1258            g::BRANCH_RENAME_PROMPT_PREFIX,
1259        ];
1260        for writer in prefixes {
1261            let name = format!("{writer}topic*");
1262            for reader in prefixes {
1263                let parsed = g::branch_from_prompt_buffer_name_for_test(&name, reader);
1264                if writer == reader {
1265                    assert_eq!(parsed, Some("topic".to_string()));
1266                } else {
1267                    assert_eq!(
1268                        parsed, None,
1269                        "`{reader}` must not match a name written by `{writer}`"
1270                    );
1271                }
1272            }
1273        }
1274    }
1275
1276    #[test]
1277    fn branch_delete_asks_through_the_ex_command_rather_than_deleting() {
1278        use lattice_picker::PickerAcceptOutcome as O;
1279        let O::InvokeCommand { id, args } = branch_delete_outcome("feature/foo") else {
1280            panic!("delete must route through a command");
1281        };
1282        assert_eq!(
1283            id, "magit-branch-delete",
1284            "the ex-command is what raises the MG.12 confirm; routing anywhere \
1285             else would delete without asking"
1286        );
1287        assert_eq!(args, lattice_grammar::Args::String("feature/foo".into()));
1288    }
1289
1290    /// The rename prompt is pre-filled with the current name — a rename
1291    /// is usually an edit of it, not a fresh name typed from nothing.
1292    #[test]
1293    fn branch_rename_prefills_the_current_name() {
1294        use lattice_picker::PickerAcceptOutcome as O;
1295        let O::OpenPrompt {
1296            prompt, initial, ..
1297        } = branch_rename_outcome("feature/foo")
1298        else {
1299            panic!("rename must open a prompt");
1300        };
1301        assert_eq!(initial, "feature/foo");
1302        assert!(
1303            prompt.contains("feature/foo"),
1304            "the prompt names its target"
1305        );
1306    }
1307
1308    #[test]
1309    fn branch_create_prompt_outcome_names_the_base_in_the_label_and_buffer_name() {
1310        let outcome = branch_create_prompt_outcome("feature/foo");
1311        match outcome {
1312            PickerAcceptOutcome::OpenPrompt {
1313                prompt,
1314                initial,
1315                on_submit_action,
1316                buffer_name,
1317            } => {
1318                assert!(prompt.contains("feature/foo"));
1319                assert_eq!(initial, "");
1320                assert_eq!(on_submit_action, "action:magit-branch-create-finish");
1321                assert_eq!(
1322                    buffer_name.as_deref(),
1323                    Some("*magit:branch-create-from:feature/foo*")
1324                );
1325            }
1326            other => panic!("expected OpenPrompt, got {other:?}"),
1327        }
1328    }
1329}
1330
1331/// MG.52: the **branch picker**, parameterised by the ex-command it
1332/// hands the picked branch to.
1333///
1334/// One source, not one per operation. Checkout, merge, merge-into,
1335/// reset and rebase-onto all ask the same question — *which branch* —
1336/// and differ only in what they do with the answer, which is exactly
1337/// what an argument is for. [`CommitPickSource`] already established
1338/// this shape for commits; this is its peer for branches, down to the
1339/// same constraint on the argument.
1340///
1341/// **Branch selection does not accept free text.** A branch that does
1342/// not exist is not a merge target, a base to branch from, or a reset
1343/// destination — it is a typo, and git's error arrives long after the
1344/// keystroke that caused it. The prompt these replace accepted anything
1345/// and reported the mistake as a failed git call.
1346///
1347/// **The argument is an ex-command name, not an action name**, for the
1348/// reason [`CommitPickSource`] documents: a picked candidate reaches an
1349/// operation only through `RoutingPayload::InvokeCommand`, whose host
1350/// arm runs `id` as an ex line, so the branch has to travel inside that
1351/// line.
1352pub struct BranchPickSource {
1353    spec: PickerSourceSpec,
1354    repo: RepoLens,
1355}
1356
1357pub const BRANCH_PICK_SOURCE: &str = "magit-branch";
1358
1359impl BranchPickSource {
1360    pub fn new(repo: RepoLens) -> Self {
1361        Self {
1362            repo,
1363            spec: takes_ex_command(
1364                BRANCH_PICK_SOURCE,
1365                "Pick a branch, then run the named magit ex-command on it.",
1366                "magit ex-command to run on the picked branch",
1367            ),
1368        }
1369    }
1370}
1371
1372impl Default for BranchPickSource {
1373    fn default() -> Self {
1374        Self::new(RepoLens::default())
1375    }
1376}
1377
1378impl PickerSourceGenerator for BranchPickSource {
1379    fn spec(&self) -> &PickerSourceSpec {
1380        &self.spec
1381    }
1382
1383    fn init(&self, ctx: &PickerContext<'_>, args: &[String]) -> SourceResult<PickerInitResult> {
1384        // MR.6: the rows belong to the repository the picker was
1385        // opened over, resolved BEFORE the listing goes off-thread.
1386        let workdir = self.repo.workdir(ctx);
1387        let command = args
1388            .first()
1389            .filter(|c| !c.is_empty())
1390            .ok_or_else(|| {
1391                format!("{BRANCH_PICK_SOURCE}: needs the ex-command to run on the picked branch")
1392            })?
1393            .clone();
1394        Ok(PickerInitResult::Future(Box::pin(async move {
1395            let branches = tokio::task::spawn_blocking(move || {
1396                let repo = Repository::discover(&workdir)
1397                    .map_err(|e| format!("{BRANCH_PICK_SOURCE}: repo discover failed: {e}"))?;
1398                Branch::list(&repo).map_err(|e| format!("{BRANCH_PICK_SOURCE}: {e}"))
1399            })
1400            .await
1401            .map_err(|e| format!("{BRANCH_PICK_SOURCE}: join error: {e}"))??;
1402            Ok(branches
1403                .into_iter()
1404                .map(|name| {
1405                    let cand = RawCandidate::plain(name.clone(), CandidateKind::Plain);
1406                    (
1407                        cand,
1408                        RoutingPayload::InvokeCommand {
1409                            id: picked_line(&command, &name),
1410                            args: lattice_grammar::Args::None,
1411                        },
1412                    )
1413                })
1414                .collect())
1415        })))
1416    }
1417
1418    fn accept(
1419        &self,
1420        _ctx: &PickerContext<'_>,
1421        routing: &RoutingPayload,
1422    ) -> SourceResult<PickerAcceptOutcome> {
1423        match routing {
1424            RoutingPayload::InvokeCommand { id, args } => Ok(PickerAcceptOutcome::InvokeCommand {
1425                id: id.clone(),
1426                args: args.clone(),
1427            }),
1428            other => Err(format!(
1429                "{BRANCH_PICK_SOURCE}: unexpected routing payload {other:?}"
1430            )),
1431        }
1432    }
1433}
1434
1435/// MG.53.d/e: the **tag**, **remote** and **ref** pickers.
1436///
1437/// Three ids, one implementation, because they differ only in what they
1438/// list. `Reference::list` already returns branches, remotes and tags
1439/// in one `for-each-ref` walk tagged with a [`RefKind`], so a tag
1440/// picker is that walk filtered — building a separate `git tag` call
1441/// beside it would be a second way to ask the same question.
1442///
1443/// Parameterised by the ex-command that receives the pick, exactly as
1444/// [`BranchPickSource`] and [`CommitPickSource`] are, and for the same
1445/// reason: a picked candidate reaches an operation only as an ex line.
1446pub struct RefPickSource {
1447    spec: PickerSourceSpec,
1448    repo: RepoLens,
1449    which: RefScope,
1450    /// MG.54: read at PREVIEW time for `magit.revision-preview`, so
1451    /// `:set` lands on the next selection rather than the next picker.
1452    /// `None` in a stripped harness (no config registry), which resolves
1453    /// to the option's own default — a test rig should behave like a
1454    /// default install.
1455    config: Option<Arc<lattice_config::ConfigRegistry>>,
1456}
1457
1458/// What a [`RefPickSource`] lists.
1459#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1460pub enum RefScope {
1461    /// `refs/tags/*` — `Delete tag`.
1462    Tags,
1463    /// Configured remotes (`git remote`), not `refs/remotes/*`: the
1464    /// operations that take one want `origin`, not `origin/main`.
1465    Remotes,
1466    /// Everything `for-each-ref` returns — branches, remote-tracking
1467    /// refs and tags. What a "ref" prompt means.
1468    AllRefs,
1469    /// MG.53.g: refs **and** recent commits — what "revision" means.
1470    ///
1471    /// `git log` alone answers "which commit on the branch I am on",
1472    /// which is the wrong question for *view this file as it is on
1473    /// `origin/main`*: a file on another branch is not in the current
1474    /// branch's history at all, so no number of commits would surface
1475    /// it. Emacs's `magit-find-file` completes over branches, tags and
1476    /// commits together for the same reason.
1477    ///
1478    /// Refs come first: reaching for another branch is the common ask,
1479    /// and a branch name is the thing a user can recognise.
1480    Revisions,
1481}
1482
1483pub const TAG_PICK_SOURCE: &str = "magit-tag";
1484pub const REMOTE_PICK_SOURCE: &str = "magit-remote";
1485pub const REF_PICK_SOURCE: &str = "magit-ref";
1486pub const REVISION_PICK_SOURCE: &str = "magit-revision";
1487
1488impl RefPickSource {
1489    pub fn new(repo: RepoLens, which: RefScope) -> Self {
1490        Self::with_config(repo, which, None)
1491    }
1492
1493    pub fn with_config(
1494        repo: RepoLens,
1495        which: RefScope,
1496        config: Option<Arc<lattice_config::ConfigRegistry>>,
1497    ) -> Self {
1498        let (id, doc, noun) = match which {
1499            RefScope::Tags => (
1500                TAG_PICK_SOURCE,
1501                "Pick a tag, then run the named magit ex-command on it.",
1502                "magit ex-command to run on the picked tag",
1503            ),
1504            RefScope::Remotes => (
1505                REMOTE_PICK_SOURCE,
1506                "Pick a remote, then run the named magit ex-command on it.",
1507                "magit ex-command to run on the picked remote",
1508            ),
1509            RefScope::AllRefs => (
1510                REF_PICK_SOURCE,
1511                "Pick a ref, then run the named magit ex-command on it.",
1512                "magit ex-command to run on the picked ref",
1513            ),
1514            RefScope::Revisions => (
1515                REVISION_PICK_SOURCE,
1516                "Pick a revision — a branch, tag or recent commit — then run \
1517                 the named magit ex-command on it.",
1518                "magit ex-command to run on the picked revision",
1519            ),
1520        };
1521        Self {
1522            repo,
1523            spec: takes_ex_command(id, doc, noun),
1524            which,
1525            config,
1526        }
1527    }
1528
1529    fn id(&self) -> &'static str {
1530        match self.which {
1531            RefScope::Tags => TAG_PICK_SOURCE,
1532            RefScope::Remotes => REMOTE_PICK_SOURCE,
1533            RefScope::AllRefs => REF_PICK_SOURCE,
1534            RefScope::Revisions => REVISION_PICK_SOURCE,
1535        }
1536    }
1537
1538    /// MG.54: is the revision preview switched on? A missing config
1539    /// registry resolves to the option's default, not `false`.
1540    fn preview_enabled(&self) -> bool {
1541        self.config
1542            .as_ref()
1543            .and_then(|c| c.get_typed::<crate::options::MagitRevisionPreview>())
1544            .map(|v| *v)
1545            .unwrap_or(true)
1546    }
1547}
1548
1549/// MG.54: the ex-line this picker will run, split into the revision and
1550/// the file — but ONLY for `magit-find-file`, the one command whose
1551/// answer is a file's content.
1552///
1553/// The same `magit-revision` source also fills `magit-checkout` and
1554/// `magit-file-checkout`. Those are *actions*: one moves HEAD, the other
1555/// overwrites the working tree. Previewing a checkout would mean showing
1556/// a file the command is about to replace, which invites reading the
1557/// pane as "this is what you'll get" when what you get is the file
1558/// written over your uncommitted work. Matching on the command name is
1559/// what keeps the preview to the question it can actually answer.
1560fn find_file_preview_target(line: &str) -> Option<(String, PathBuf)> {
1561    let rest = line.strip_prefix("magit-find-file ")?;
1562    let (rev, path) = rest.trim().split_once(char::is_whitespace)?;
1563    let path = path.trim();
1564    (!rev.is_empty() && !path.is_empty()).then(|| (rev.to_string(), PathBuf::from(path)))
1565}
1566
1567impl PickerSourceGenerator for RefPickSource {
1568    fn spec(&self) -> &PickerSourceSpec {
1569        &self.spec
1570    }
1571
1572    fn init(&self, ctx: &PickerContext<'_>, args: &[String]) -> SourceResult<PickerInitResult> {
1573        // MR.6: the rows belong to the repository the picker was
1574        // opened over, resolved BEFORE the listing goes off-thread.
1575        let workdir = self.repo.workdir(ctx);
1576        let id = self.id();
1577        let which = self.which;
1578        let command = args
1579            .first()
1580            .filter(|c| !c.is_empty())
1581            .ok_or_else(|| format!("{id}: needs the ex-command to run on the pick"))?
1582            .clone();
1583        Ok(PickerInitResult::Future(Box::pin(async move {
1584            // (display, value): they differ for commits, where the row
1585            // must read as `abbrev subject` while what git receives is
1586            // the full sha — an abbreviation is ambiguous in principle
1587            // and git resolves the ambiguity by refusing.
1588            let names = tokio::task::spawn_blocking(move || {
1589                let repo = Repository::discover(&workdir)
1590                    .map_err(|e| format!("{id}: repo discover failed: {e}"))?;
1591                Ok::<Vec<(String, String)>, String>(match which {
1592                    RefScope::Remotes => Remote::list(&repo)
1593                        .map_err(|e| format!("{id}: {e}"))?
1594                        .into_iter()
1595                        .map(|r| (r.name.clone(), r.name))
1596                        .collect(),
1597                    RefScope::Tags => Reference::list(&repo)
1598                        .map_err(|e| format!("{id}: {e}"))?
1599                        .into_iter()
1600                        .filter(|r| r.kind == RefKind::Tag)
1601                        .map(|r| (r.name.clone(), r.name))
1602                        .collect(),
1603                    RefScope::AllRefs => Reference::list(&repo)
1604                        .map_err(|e| format!("{id}: {e}"))?
1605                        .into_iter()
1606                        .map(|r| (r.name.clone(), r.name))
1607                        .collect(),
1608                    // Refs first, then commits: `origin/main` is what
1609                    // someone reaching for another branch recognises,
1610                    // and a sha they would have to read to identify.
1611                    RefScope::Revisions => {
1612                        let mut out: Vec<(String, String)> = Reference::list(&repo)
1613                            .map_err(|e| format!("{id}: {e}"))?
1614                            .into_iter()
1615                            .map(|r| (r.name.clone(), r.name))
1616                            .collect();
1617                        out.extend(
1618                            recent_commits(workdir.clone())?
1619                                .into_iter()
1620                                .map(|(sha, display)| (display, sha)),
1621                        );
1622                        out
1623                    }
1624                })
1625            })
1626            .await
1627            .map_err(|e| format!("{id}: join error: {e}"))??;
1628            Ok(names
1629                .into_iter()
1630                .map(|(display, value)| {
1631                    let cand = RawCandidate::plain(display, CandidateKind::Plain);
1632                    (
1633                        cand,
1634                        RoutingPayload::InvokeCommand {
1635                            id: picked_line(&command, &value),
1636                            args: lattice_grammar::Args::None,
1637                        },
1638                    )
1639                })
1640                .collect())
1641        })))
1642    }
1643
1644    fn accept(
1645        &self,
1646        _ctx: &PickerContext<'_>,
1647        routing: &RoutingPayload,
1648    ) -> SourceResult<PickerAcceptOutcome> {
1649        match routing {
1650            RoutingPayload::InvokeCommand { id, args } => Ok(PickerAcceptOutcome::InvokeCommand {
1651                id: id.clone(),
1652                args: args.clone(),
1653            }),
1654            other => Err(format!(
1655                "{}: unexpected routing payload {other:?}",
1656                self.id()
1657            )),
1658        }
1659    }
1660
1661    /// MG.54: `C-c f v` used to show nothing until you accepted, so
1662    /// choosing between two revisions meant accepting one, looking,
1663    /// going back, and accepting the other. This answers the question
1664    /// the picker is asking.
1665    ///
1666    /// Synchronous `git show`, which is only sound because the host does
1667    /// not call this while the selection is moving — see
1668    /// [`Self::preview_debounce`]. The blob fetch itself is guarded
1669    /// (size, binary, line count) by `preview_blob`.
1670    ///
1671    /// The preview buffer takes the name `magit-find-file` would give
1672    /// the real one, so what the pane says while you are choosing is
1673    /// what the buffer is called once you accept.
1674    fn preview(
1675        &self,
1676        ctx: &PickerContext<'_>,
1677        routing: &RoutingPayload,
1678    ) -> Option<lattice_picker::PickerPreviewOutcome> {
1679        if self.which != RefScope::Revisions || !self.preview_enabled() {
1680            return None;
1681        }
1682        let RoutingPayload::InvokeCommand { id, .. } = routing else {
1683            return None;
1684        };
1685        let (rev, path) = find_file_preview_target(id)?;
1686        // MR.6: the same repository the rows came from — a preview of
1687        // the file at a revision of some other checkout would be a
1688        // different file entirely.
1689        let workdir = self.repo.workdir(ctx);
1690        let text = crate::magit_file_revision_mode::preview_blob(&workdir, &rev, &path)?;
1691        Some(lattice_picker::PickerPreviewOutcome::Buffer {
1692            name: crate::magit_file_revision_mode::blob_buffer_name(
1693                &crate::workdir::repo_label(&workdir),
1694                &rev,
1695                &path,
1696            ),
1697            text,
1698            syntax_path: Some(path),
1699        })
1700    }
1701
1702    /// MG.54: never while the user is still moving.
1703    ///
1704    /// `git show` on every arrow key would be a subprocess per keystroke
1705    /// on the actor thread. Declaring the window means a scroll through
1706    /// fifty revisions spawns nothing at all, and the one fetch that
1707    /// does run is for the revision the user stopped on — so there is
1708    /// nothing to cancel and no stale result to discard.
1709    ///
1710    /// `None` when the feature is off, so a user who turns it off pays
1711    /// for no timers either. The other scopes never previewed.
1712    fn preview_debounce(&self) -> Option<std::time::Duration> {
1713        (self.which == RefScope::Revisions && self.preview_enabled())
1714            .then(|| std::time::Duration::from_millis(150))
1715    }
1716}