Skip to main content

lattice_magit/
magit_branch_mode.rs

1//! MG.9: magit-branch major mode.
2//!
3//! Lists local branches with checkout/create/delete/merge operations.
4
5use std::sync::{Arc, Mutex, OnceLock};
6
7use lattice_config;
8use lattice_grammar::Effect;
9use lattice_mode::{
10    ActionContext, ActionHandlerContribution, BufferStoreHandle, CapabilitySet, Keymap,
11    KeymapEntry, LifecycleFuture, Mode, ModeContext, ModeId, ModeKind, OptionOverrideSet,
12    keymap_entry,
13};
14use lattice_protocol::position::Position;
15use lattice_vcs::{Branch, Repository};
16
17use crate::buffer_state::{BufferStateGuard, BufferStates, MagitView, MagitViewsHandle};
18use crate::headerline::{self, Field, MagitHeaderlineHandle};
19
20pub struct MagitBranchMode;
21
22impl MagitBranchMode {
23    pub fn mode_id() -> ModeId {
24        ModeId::new("magit-branch-mode")
25    }
26}
27
28fn magit_branch_keymap_entries() -> &'static [KeymapEntry] {
29    static ENTRIES: OnceLock<Vec<KeymapEntry>> = OnceLock::new();
30    ENTRIES.get_or_init(|| {
31        vec![
32            keymap_entry! { mode: Normal, chord: "<CR>", doc: "Checkout branch", cmd: "action:magit-branch-checkout" },
33            keymap_entry! { mode: Normal, chord: "c", doc: "Create branch", cmd: "action:magit-branch-create" },
34            keymap_entry! { mode: Normal, chord: "d", doc: "Delete branch", cmd: "action:magit-branch-delete" },
35            keymap_entry! { mode: Normal, chord: "m", doc: "Merge branch", cmd: "action:magit-branch-merge" },
36        ]
37    })
38}
39
40pub struct BranchState {
41    buffer_id: lattice_core::BufferId,
42    store: Arc<BufferStoreHandle>,
43    workdir: std::path::PathBuf,
44    pending_highlights: Option<lattice_mode::PendingSyntheticHighlightsHandle>,
45    /// MG.14: the buffer's headerline — current branch + total,
46    /// re-set from the same `build_branch_list` call that produced
47    /// the list itself.
48    headerline: Option<MagitHeaderlineHandle>,
49}
50
51/// MG.13: service alias for this mode's per-buffer state. Register
52/// and look up through this exact type — `ServiceRegistry` keys on
53/// `TypeId`, so `Arc<BufferStates<BranchState>>` and
54/// `BufferStates<BranchState>` are different slots
55/// (`feedback_servicesregistry_arc_typeid`).
56pub type BranchStatesHandle = Arc<BufferStates<BranchState>>;
57
58/// Resolve this mode's state for the buffer an action fired in.
59/// `None` means no magit-branch buffer is live there — the handler
60/// no-ops, exactly as it did when it wasn't registered at all.
61fn state(ctx: &ActionContext<'_>) -> Option<Arc<Mutex<BranchState>>> {
62    crate::buffer_state::state_for::<BranchState>(ctx)
63}
64
65/// `gr` for a branch buffer. `magit-core-mode` owns the chord and the
66/// single boot-registered handler; this supplies the body for buffers
67/// this mode owns (see [`MagitView`] for why the shared action cannot
68/// be registered per mode).
69struct BranchView(Arc<Mutex<BranchState>>);
70
71impl MagitView for BranchView {
72    fn refresh(&self) -> Option<Effect> {
73        refresh(self.0.clone())
74    }
75}
76
77impl Mode for MagitBranchMode {
78    type Guard = BufferStateGuard<BranchState>;
79
80    fn id(&self) -> ModeId {
81        Self::mode_id()
82    }
83    fn kind(&self) -> ModeKind {
84        ModeKind::Major
85    }
86    fn target_buffer_kind(&self) -> Option<lattice_core::BufferKind> {
87        None
88    }
89
90    fn options(&self) -> OptionOverrideSet {
91        lattice_config::overrides! {
92            lattice_config::ReadOnly = true,
93            lattice_config::NoFile = true,
94        }
95    }
96
97    /// MG.RO: `read-only-mode` is where the gate actually is.
98    ///
99    /// `ReadOnly = true` above stops TYPING and nothing else. It is read by
100    /// `read_only_edit_rejected`, which guards the insert-mode char path;
101    /// operators never reach it, because a `Document`'s grammar dispatch
102    /// applies its own edits and hands the host an already-applied
103    /// `Effect::Edits`. `x` deleted a character out of `*magit:status*` while
104    /// the buffer reported itself read-only — worse than not gating at all,
105    /// because it looks protected.
106    ///
107    /// `read-only-mode` carries the option AND the `invocation_runner`
108    /// (`Editor::run_read_only_motion`) that refuses mutating operators while
109    /// letting motions, `:` and `/` through.
110    ///
111    /// Declared per MAJOR rather than once on `magit-core-mode`: an implied
112    /// mode is followed from the mode being ACTIVATED, and the majors are what
113    /// the host activates. Putting it on the shared minor looked right and was
114    /// verified not to fire.
115    fn implies(&self) -> &[lattice_mode::ModeId] {
116        static IMPLIED: std::sync::OnceLock<Vec<lattice_mode::ModeId>> = std::sync::OnceLock::new();
117        IMPLIED.get_or_init(|| vec![lattice_mode::modes::ReadOnlyMode::mode_id()])
118    }
119
120    fn required_capabilities(&self) -> CapabilitySet {
121        CapabilitySet::empty()
122    }
123    fn keymap(&self) -> Keymap {
124        Keymap::from_entries(magit_branch_keymap_entries())
125    }
126
127    /// MG.13: registered once at boot, not per activation. Each body
128    /// resolves its per-buffer state through [`state`] at call time,
129    /// so there is no window in which the chord resolves but no
130    /// handler exists. See `buffer_state`'s module docs.
131    fn action_handlers(&self) -> Vec<ActionHandlerContribution> {
132        vec![
133            // checkout (<CR>)
134            ActionHandlerContribution {
135                action_name: "action:magit-branch-checkout",
136                handler: Arc::new(|ctx: &ActionContext<'_>| {
137                    let s = state(ctx)?;
138                    let (name, workdir) = {
139                        let g = s.lock().ok()?;
140                        (branch_name_at_cursor(&g, ctx.cursor)?, g.workdir.clone())
141                    };
142                    spawn_mutation_and_refresh(s, format!("check out {name}"), move || {
143                        let repo = Repository::discover(&workdir)
144                            .map_err(|e| format!("not a git repository: {e}"))?;
145                        Branch::checkout(&repo, &name)
146                            .map(|_| String::new())
147                            .map_err(|e| e.to_string())
148                    })
149                }),
150            },
151            // delete (d) — MG.12: `Branch::delete` is a force delete
152            // (`-D`), which silently discards unmerged commits, so it
153            // asks first. This half does no git call at all; answering
154            // `n` simply never reaches the execute half below.
155            ActionHandlerContribution {
156                action_name: "action:magit-branch-delete",
157                handler: Arc::new(|ctx: &ActionContext<'_>| {
158                    let s = state(ctx)?;
159                    let name = {
160                        let g = s.lock().ok()?;
161                        branch_name_at_cursor(&g, ctx.cursor)?
162                    };
163                    Some(delete_branch_confirm(&name))
164                }),
165            },
166            // delete, after confirmation. Re-reads the branch at the
167            // cursor rather than carrying it through the prompt: the
168            // confirm transient owns every keystroke while it is open,
169            // so the cursor cannot have moved (`do_transient_trigger`
170            // hands the yes-action the *document* cursor). Same shape
171            // as magit-status's `magit-discard-execute`.
172            ActionHandlerContribution {
173                action_name: "action:magit-branch-delete-execute",
174                handler: Arc::new(|ctx: &ActionContext<'_>| {
175                    let s = state(ctx)?;
176                    // IX.2: act on the branch the prompt named. The
177                    // cursor is only consulted when nothing was carried
178                    // — a refresh can rebuild the list while the dialog
179                    // is open, and then the row means a different branch.
180                    let (name, workdir) = {
181                        let g = s.lock().ok()?;
182                        let name = match crate::confirm::carried_target(ctx) {
183                            Some(carried) => carried,
184                            None => branch_name_at_cursor(&g, ctx.cursor)?,
185                        };
186                        (name, g.workdir.clone())
187                    };
188                    spawn_mutation_and_refresh(s, format!("delete branch {name}"), move || {
189                        let repo = Repository::discover(&workdir)
190                            .map_err(|e| format!("not a git repository: {e}"))?;
191                        Branch::delete(&repo, &name)
192                            .map(|_| String::new())
193                            .map_err(|e| e.to_string())
194                    })
195                }),
196            },
197            // merge (m)
198            ActionHandlerContribution {
199                action_name: "action:magit-branch-merge",
200                handler: Arc::new(|ctx: &ActionContext<'_>| {
201                    let s = state(ctx)?;
202                    let (name, workdir) = {
203                        let g = s.lock().ok()?;
204                        (branch_name_at_cursor(&g, ctx.cursor)?, g.workdir.clone())
205                    };
206                    spawn_mutation_and_refresh(s, format!("merge {name}"), move || {
207                        let repo = Repository::discover(&workdir)
208                            .map_err(|e| format!("not a git repository: {e}"))?;
209                        repo.run_git(["merge", &name])
210                            .map(|_| String::new())
211                            .map_err(|e| e.to_string())
212                    })
213                }),
214            },
215            // create (c) — Emacs-magit-style two-step wizard: pick an
216            // existing branch as the base via the picker, then a
217            // follow-up prompt asks for the new branch's name (see
218            // `picker_sources::BranchPickBaseSource` +
219            // `action:magit-branch-create-finish` in
220            // `magit_global_mode`). The direct `:magit-branch-create
221            // <name>` ex-command (creates from HEAD, no base choice)
222            // stays available for the scriptable/quick path.
223            //
224            // State-free, but still gated: `state(ctx)?` keeps it from
225            // firing in a buffer that is not a magit-branch buffer.
226            ActionHandlerContribution {
227                action_name: "action:magit-branch-create",
228                handler: Arc::new(|ctx: &ActionContext<'_>| {
229                    let _ = state(ctx)?;
230                    Some(Effect::OpenPicker {
231                        source: "magit-branch-pick-base".to_string(),
232                        args: Vec::new(),
233                        root: None,
234                        fill_action: None,
235                        query: None,
236                    })
237                }),
238            },
239        ]
240    }
241
242    fn on_activate(&self, ctx: ModeContext) -> LifecycleFuture<'_, Self::Guard> {
243        Box::pin(async move {
244            let buffer_id = lattice_core::BufferId(ctx.buffer_id().0 as u32);
245            // Nothing to publish without a store or a live handle —
246            // hand back a guard over an orphan registry so the
247            // lifecycle contract (always a fresh Guard) still holds.
248            let orphan = || BufferStateGuard::new(Arc::new(BufferStates::default()), buffer_id);
249            let Some(store) = ctx.service::<BufferStoreHandle>() else {
250                return Ok(orphan());
251            };
252            let Some(handle) = store.handle_for(buffer_id) else {
253                return Ok(orphan());
254            };
255            // MR.3: the repository the trigger resolved for THIS
256            // buffer, not the one the editor was started in.
257            let workdir =
258                crate::repo_scope::view_workdir(&ctx, buffer_id, &handle).unwrap_or_default();
259            let pending_highlights = ctx.service::<lattice_mode::PendingSyntheticHighlights>();
260
261            // MG.14: install the headerline in the same synchronous
262            // prefix as the state publish; it stays hidden until the
263            // branch list below lands.
264            let (hl, hl_registration) =
265                match headerline::install(&ctx, buffer_id, Self::mode_id().as_str()) {
266                    Some((h, reg)) => (Some(h), Some(reg)),
267                    None => (None, None),
268                };
269
270            // MG.13: publish BEFORE the first `.await`. `spawn_cascade`
271            // polls this future once synchronously on the App thread
272            // before spawning it, so everything above the first await
273            // has run by the time `activate_major` returns — which is
274            // what makes the boot-registered handlers above able to
275            // find their state on the very next keystroke. Moving any
276            // `.await` above this line reopens the dead-chord window.
277            let Some(states) = ctx.service::<BranchStatesHandle>() else {
278                return Ok(orphan());
279            };
280            let state = states.publish(
281                buffer_id,
282                BranchState {
283                    buffer_id,
284                    store: store.clone(),
285                    workdir: workdir.clone(),
286                    pending_highlights: pending_highlights.clone(),
287                    headerline: hl.clone(),
288                },
289            );
290            let mut guard = BufferStateGuard::new((*states).clone(), buffer_id)
291                .with_headerline(hl_registration);
292            if let Some(views) = ctx.service::<MagitViewsHandle>() {
293                views.publish(buffer_id, Arc::new(BranchView(state.clone())));
294                guard = guard.with_views((*views).clone());
295            }
296
297            // Populate branch list: blocking I/O on spawn_blocking, then
298            // apply edit on the current task (no Runtime::new()).
299            let wd = workdir.clone();
300            let (text, header) = tokio::task::spawn_blocking(move || build_branch_list(&wd))
301                .await
302                .unwrap();
303            headerline::publish(&hl, header);
304            let spans = crate::highlight::branch_styled_spans(&text);
305            crate::buffer_io::replace_buffer_text(&handle, text).await;
306            if let Some(ref ph) = pending_highlights {
307                ph.store_and_wake(buffer_id, spans);
308            }
309
310            Ok(guard)
311        })
312    }
313}
314
315/// `gr` — re-list branches without a prior mutation.
316fn refresh(s: Arc<Mutex<BranchState>>) -> Option<Effect> {
317    let (handle, wd, pending, buffer_id, hl) = {
318        let g = s.lock().ok()?;
319        (
320            g.store.handle_for(g.buffer_id)?,
321            g.workdir.clone(),
322            g.pending_highlights.clone(),
323            g.buffer_id,
324            g.headerline.clone(),
325        )
326    };
327    // MG.27: the row says "refreshing" from here until the
328    // guard drops — including on every early exit inside the
329    // task, which is why it is a guard and not a matching pair.
330    let busy = headerline::busy(&hl);
331    tokio::task::spawn(async move {
332        let _busy = busy;
333        let (text, header) = tokio::task::spawn_blocking(move || build_branch_list(&wd))
334            .await
335            .unwrap_or_default();
336        headerline::publish(&hl, header);
337        let spans = crate::highlight::branch_styled_spans(&text);
338        crate::buffer_io::replace_buffer_text(&handle, text).await;
339        if let Some(ph) = pending {
340            ph.store_and_wake(buffer_id, spans);
341        }
342    });
343    None
344}
345
346/// Run `mutate` (a blocking git call) on `spawn_blocking`, off the
347/// actor thread, then re-list branches — the shape every mutating
348/// handler above uses instead of calling git synchronously inline.
349/// Run a repository mutation off-thread, report it, then refresh.
350///
351/// MG.54: `mutate` returns a `Result` so the outcome can be published.
352/// It used to be `impl FnOnce()`, which meant every caller discarded
353/// its git result — the operation finished in silence, and a FAILED
354/// one finished in the same silence with the buffer refreshing as
355/// though it had worked.
356fn spawn_mutation_and_refresh(
357    s: Arc<Mutex<BranchState>>,
358    label: String,
359    mutate: impl FnOnce() -> Result<String, String> + Send + 'static,
360) -> Option<Effect> {
361    let (handle, wd, pending, buffer_id, hl) = {
362        let g = s.lock().ok()?;
363        (
364            g.store.handle_for(g.buffer_id)?,
365            g.workdir.clone(),
366            g.pending_highlights.clone(),
367            g.buffer_id,
368            g.headerline.clone(),
369        )
370    };
371    // MG.27: the row says "refreshing" from here until the
372    // guard drops — including on every early exit inside the
373    // task, which is why it is a guard and not a matching pair.
374    let busy = headerline::busy(&hl);
375    tokio::task::spawn(async move {
376        let _busy = busy;
377        let result = tokio::task::spawn_blocking(mutate)
378            .await
379            .unwrap_or_else(|e| Err(e.to_string()));
380        crate::magit_global_mode::finish_task(&wd, &label, result);
381        let (text, header) = tokio::task::spawn_blocking(move || build_branch_list(&wd))
382            .await
383            .unwrap_or_default();
384        headerline::publish(&hl, header);
385        let spans = crate::highlight::branch_styled_spans(&text);
386        crate::buffer_io::replace_buffer_text(&handle, text).await;
387        if let Some(ph) = pending {
388            ph.store_and_wake(buffer_id, spans);
389        }
390    });
391    None
392}
393
394/// MG.12: the ask half of `d`. Names the branch so the question is
395/// answerable while the confirm transient covers the branch list.
396fn delete_branch_confirm(name: &str) -> Effect {
397    crate::confirm::ask_target(
398        format!("Delete branch {name}?"),
399        "action:magit-branch-delete-execute",
400        name,
401    )
402}
403
404fn branch_name_at_cursor(state: &BranchState, cursor: Position) -> Option<String> {
405    let handle = state.store.handle_for(state.buffer_id)?;
406    let snap = handle.snapshot();
407    let line = snap.buffer.line(cursor.line)?;
408    // Format: "  branch-name" or "* branch-name (current)"
409    let name = line
410        .trim()
411        .trim_start_matches("* ")
412        .split_whitespace()
413        .next()?;
414    Some(name.to_string())
415}
416
417/// Build the branch list AND its MG.14 header fields. One pass: the
418/// header's "current branch, N branches" comes from the same
419/// `Branch::list` + `rev-parse` this call already made, so the row
420/// costs no git of its own.
421fn build_branch_list(workdir: &std::path::Path) -> (String, Vec<Field>) {
422    let repo = match Repository::discover(workdir) {
423        Ok(r) => r,
424        Err(_) => return ("Not a git repository.\n".to_string(), Vec::new()),
425    };
426    let branches = Branch::list(&repo).unwrap_or_default();
427
428    // Determine current branch
429    let current = repo
430        .run_git_str(["rev-parse", "--abbrev-ref", "HEAD"])
431        .map(|s| s.trim().to_string())
432        .unwrap_or_default();
433    let header = headerline::branch_fields(&current, branches.len());
434
435    if branches.is_empty() {
436        return ("No branches.\n".to_string(), header);
437    }
438
439    let mut out = format!("Branches ({})\n", branches.len());
440    for b in &branches {
441        let marker = if *b == current { "* " } else { "  " };
442        out.push_str(&format!("{}{}\n", marker, b));
443    }
444    out.push('\n');
445    (out, header)
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451
452    /// MG.12: `d` used to call `Branch::delete` — a force delete —
453    /// straight from the chord. It must now produce nothing but a
454    /// question; the git call moved behind the yes-action.
455    #[test]
456    fn delete_asks_before_deleting_and_names_the_branch() {
457        match delete_branch_confirm("feature/foo") {
458            Effect::Confirm {
459                prompt,
460                yes_action,
461                args: _,
462            } => {
463                assert_eq!(prompt, "Delete branch feature/foo?");
464                assert_eq!(yes_action, "action:magit-branch-delete-execute");
465            }
466            other => panic!("expected a confirm before a force delete, got {other:?}"),
467        }
468    }
469
470    /// The prompt has to survive branch names with slashes and dots
471    /// intact — a truncated name makes the question unanswerable.
472    #[test]
473    fn delete_prompt_preserves_the_full_branch_name() {
474        match delete_branch_confirm("release/v1.2.3-rc.1") {
475            Effect::Confirm { prompt, .. } => {
476                assert!(
477                    prompt.contains("release/v1.2.3-rc.1"),
478                    "prompt lost the branch name: {prompt}"
479                );
480            }
481            other => panic!("expected Confirm, got {other:?}"),
482        }
483    }
484}