Skip to main content

lattice_magit/
magit_cherry_mode.rs

1//! MG.40: `magit-cherry-mode` — magit's `Y` cherries.
2//!
3//! "Which of my commits are not upstream yet, and which already are —
4//! possibly under a different SHA?" `git cherry` answers exactly that,
5//! and the second half is what makes it more than `git log
6//! upstream..HEAD`: a commit that was cherry-picked or rebased upstream
7//! has a *different* SHA there, so a range walk still lists it as
8//! missing. `git cherry` compares patch-ids instead, and marks it `-`.
9//!
10//! ```text
11//! Cherries  main vs origin/main
12//!
13//! + a1b2c3d  not upstream yet
14//! - e4f5g6h  already upstream, under another sha
15//! ```
16//!
17//! `<CR>` opens the commit, and `magit-core-mode`'s `A` / `_` / `O` act
18//! on it — a `+` row is exactly the thing you reach for cherry-pick
19//! with, which is where the command's name comes from.
20
21use std::sync::{Arc, Mutex, OnceLock};
22
23use lattice_cells::{Style, StyledSpan};
24use lattice_config;
25use lattice_grammar::Effect;
26use lattice_mode::{
27    ActionContext, ActionHandlerContribution, BufferStoreHandle, CapabilitySet, Keymap,
28    KeymapEntry, LifecycleFuture, Mode, ModeContext, ModeId, ModeKind, OptionOverrideSet,
29    keymap_entry,
30};
31use lattice_protocol::position::Position;
32use lattice_vcs::Repository;
33
34use crate::buffer_state::{BufferStateGuard, BufferStates, MagitView, MagitViewsHandle};
35use crate::headerline::{self, Field, MagitHeaderlineHandle};
36
37pub struct MagitCherryMode;
38
39impl MagitCherryMode {
40    pub fn mode_id() -> ModeId {
41        ModeId::new("magit-cherry-mode")
42    }
43}
44
45/// `*magit:cherry:<upstream>..<head>*`.
46///
47/// Both ends are in the name because "cherries" is meaningless without
48/// them — the same buffer text describes a completely different question
49/// against a different upstream.
50pub(crate) const CHERRY_VIEW: &str = "cherry";
51
52/// MR.3b: this view's `rest` — `<upstream>..<head>`, git's own range
53/// spelling, which is why `..` rather than a further `:` separates them.
54pub(crate) fn cherry_view_rest(upstream: &str, head: &str) -> String {
55    format!("{upstream}..{head}")
56}
57
58/// The `(upstream, head)` a cherry buffer's name carries.
59fn parse_name(name: &str) -> Option<(String, String)> {
60    let parsed = crate::workdir::parse_magit_name(name)?;
61    (parsed.view == CHERRY_VIEW).then_some(())?;
62    let (upstream, head) = parsed.rest?.split_once("..")?;
63    (!upstream.is_empty() && !head.is_empty()).then(|| (upstream.to_string(), head.to_string()))
64}
65
66fn magit_cherry_keymap_entries() -> &'static [KeymapEntry] {
67    static ENTRIES: OnceLock<Vec<KeymapEntry>> = OnceLock::new();
68    ENTRIES.get_or_init(|| {
69        vec![
70            keymap_entry! { mode: Normal, chord: "<CR>", doc: "Show the commit at cursor", cmd: "action:magit-cherry-show" },
71        ]
72    })
73}
74
75pub struct CherryState {
76    buffer_id: lattice_core::BufferId,
77    store: Arc<BufferStoreHandle>,
78    workdir: std::path::PathBuf,
79    pending_highlights: Option<lattice_mode::PendingSyntheticHighlightsHandle>,
80    headerline: Option<MagitHeaderlineHandle>,
81    upstream: String,
82    head: String,
83    /// One slot per rendered line: the full sha that line names, or
84    /// `None` for a heading or blank. Built in the same pass that
85    /// renders, for the reason `magit-refs-mode` does it — the row is
86    /// padded, so scraping it back would tie the parser to the
87    /// formatter forever.
88    rows: Vec<Option<String>>,
89}
90
91/// MG.13: service alias — register and look up through this exact type.
92pub type CherryStatesHandle = Arc<BufferStates<CherryState>>;
93
94fn state(ctx: &ActionContext<'_>) -> Option<Arc<Mutex<CherryState>>> {
95    crate::buffer_state::state_for::<CherryState>(ctx)
96}
97
98struct CherryView(Arc<Mutex<CherryState>>);
99
100impl MagitView for CherryView {
101    fn refresh(&self) -> Option<Effect> {
102        refresh(self.0.clone())
103    }
104
105    /// MG.24c: `A` / `_` / `O` act on the commit under the cursor. This
106    /// is the buffer those chords matter most in — a `+` row is a commit
107    /// that is not upstream, which is the thing you cherry-pick.
108    fn commit_at_cursor(&self, cursor: Position) -> Option<String> {
109        let g = self.0.lock().ok()?;
110        g.rows.get(cursor.line as usize).cloned().flatten()
111    }
112
113    fn workdir(&self) -> Option<std::path::PathBuf> {
114        Some(self.0.lock().ok()?.workdir.clone())
115    }
116}
117
118impl Mode for MagitCherryMode {
119    type Guard = BufferStateGuard<CherryState>;
120
121    fn id(&self) -> ModeId {
122        Self::mode_id()
123    }
124    fn kind(&self) -> ModeKind {
125        ModeKind::Major
126    }
127    fn target_buffer_kind(&self) -> Option<lattice_core::BufferKind> {
128        None
129    }
130
131    fn options(&self) -> OptionOverrideSet {
132        lattice_config::overrides! {
133            lattice_config::ReadOnly = true,
134            lattice_config::NoFile = true,
135        }
136    }
137
138    /// MG.RO: `read-only-mode` is where the gate actually is.
139    ///
140    /// `ReadOnly = true` above stops TYPING and nothing else. It is read by
141    /// `read_only_edit_rejected`, which guards the insert-mode char path;
142    /// operators never reach it, because a `Document`'s grammar dispatch
143    /// applies its own edits and hands the host an already-applied
144    /// `Effect::Edits`. `x` deleted a character out of `*magit:status*` while
145    /// the buffer reported itself read-only — worse than not gating at all,
146    /// because it looks protected.
147    ///
148    /// `read-only-mode` carries the option AND the `invocation_runner`
149    /// (`Editor::run_read_only_motion`) that refuses mutating operators while
150    /// letting motions, `:` and `/` through.
151    ///
152    /// Declared per MAJOR rather than once on `magit-core-mode`: an implied
153    /// mode is followed from the mode being ACTIVATED, and the majors are what
154    /// the host activates. Putting it on the shared minor looked right and was
155    /// verified not to fire.
156    fn implies(&self) -> &[lattice_mode::ModeId] {
157        static IMPLIED: std::sync::OnceLock<Vec<lattice_mode::ModeId>> = std::sync::OnceLock::new();
158        IMPLIED.get_or_init(|| vec![lattice_mode::modes::ReadOnlyMode::mode_id()])
159    }
160
161    fn required_capabilities(&self) -> CapabilitySet {
162        CapabilitySet::empty()
163    }
164
165    fn keymap(&self) -> Keymap {
166        Keymap::from_entries(magit_cherry_keymap_entries())
167    }
168
169    fn action_handlers(&self) -> Vec<ActionHandlerContribution> {
170        vec![ActionHandlerContribution {
171            action_name: "action:magit-cherry-show",
172            handler: Arc::new(|ctx: &ActionContext<'_>| {
173                let s = state(ctx)?;
174                let sha = {
175                    let g = s.lock().ok()?;
176                    g.rows.get(ctx.cursor.line as usize).cloned().flatten()?
177                };
178                Some(crate::magit_global_mode::open_repo_view_from_action_with(
179                    ctx,
180                    crate::magit_revision_mode::SHOW_VIEW,
181                    "magit-revision-mode",
182                    Some(&sha),
183                ))
184            }),
185        }]
186    }
187
188    fn on_activate(&self, ctx: ModeContext) -> LifecycleFuture<'_, Self::Guard> {
189        Box::pin(async move {
190            let buffer_id = lattice_core::BufferId(ctx.buffer_id().0 as u32);
191            let orphan = || BufferStateGuard::new(Arc::new(BufferStates::default()), buffer_id);
192            let Some(store) = ctx.service::<BufferStoreHandle>() else {
193                return Ok(orphan());
194            };
195            let Some(handle) = store.handle_for(buffer_id) else {
196                return Ok(orphan());
197            };
198            // MR.3: the repository the trigger resolved for THIS
199            // buffer, not the one the editor was started in.
200            let workdir =
201                crate::repo_scope::view_workdir(&ctx, buffer_id, &handle).unwrap_or_default();
202            let pending_highlights = ctx.service::<lattice_mode::PendingSyntheticHighlights>();
203            let (upstream, head) = store
204                .name_for(buffer_id)
205                .as_deref()
206                .and_then(parse_name)
207                .unwrap_or_default();
208
209            let (hl, hl_registration) =
210                match headerline::install(&ctx, buffer_id, Self::mode_id().as_str()) {
211                    Some((h, reg)) => (Some(h), Some(reg)),
212                    None => (None, None),
213                };
214
215            // MG.13: publish BEFORE the first `.await`. `rows` starts
216            // empty, so `<CR>` and `A` / `_` / `O` decline in the window
217            // before the walk lands rather than acting on a stale row.
218            let Some(states) = ctx.service::<CherryStatesHandle>() else {
219                return Ok(orphan());
220            };
221            let state = states.publish(
222                buffer_id,
223                CherryState {
224                    buffer_id,
225                    store: store.clone(),
226                    workdir: workdir.clone(),
227                    pending_highlights: pending_highlights.clone(),
228                    headerline: hl.clone(),
229                    upstream: upstream.clone(),
230                    head: head.clone(),
231                    rows: Vec::new(),
232                },
233            );
234            let mut guard = BufferStateGuard::new((*states).clone(), buffer_id)
235                .with_headerline(hl_registration);
236            if let Some(views) = ctx.service::<MagitViewsHandle>() {
237                views.publish(buffer_id, Arc::new(CherryView(state.clone())));
238                guard = guard.with_views((*views).clone());
239            }
240
241            let wd = workdir.clone();
242            let (u, h) = (upstream.clone(), head.clone());
243            let built = tokio::task::spawn_blocking(move || build_cherry_buffer(&wd, &u, &h))
244                .await
245                .unwrap_or_default();
246            headerline::publish(&hl, built.header.clone());
247            if let Ok(mut g) = state.lock() {
248                g.rows = built.rows;
249            }
250            crate::buffer_io::replace_buffer_text(&handle, built.text).await;
251            if let Some(ref ph) = pending_highlights {
252                ph.store_and_wake(buffer_id, built.spans);
253            }
254
255            Ok(guard)
256        })
257    }
258}
259
260/// `gr` — re-run the walk.
261fn refresh(s: Arc<Mutex<CherryState>>) -> Option<Effect> {
262    let (handle, wd, pending, buffer_id, hl, upstream, head) = {
263        let g = s.lock().ok()?;
264        (
265            g.store.handle_for(g.buffer_id)?,
266            g.workdir.clone(),
267            g.pending_highlights.clone(),
268            g.buffer_id,
269            g.headerline.clone(),
270            g.upstream.clone(),
271            g.head.clone(),
272        )
273    };
274    let busy = headerline::busy(&hl);
275    tokio::task::spawn(async move {
276        let _busy = busy;
277        let built = tokio::task::spawn_blocking(move || build_cherry_buffer(&wd, &upstream, &head))
278            .await
279            .unwrap_or_default();
280        headerline::publish(&hl, built.header.clone());
281        if let Ok(mut g) = s.lock() {
282            g.rows = built.rows;
283        }
284        crate::buffer_io::replace_buffer_text(&handle, built.text).await;
285        if let Some(ph) = pending {
286            ph.store_and_wake(buffer_id, built.spans);
287        }
288    });
289    None
290}
291
292/// The rendered buffer, its header, its line→sha index and its spans —
293/// one walk, because they must agree.
294#[derive(Default)]
295pub(crate) struct CherryBuffer {
296    pub(crate) text: String,
297    pub(crate) header: Vec<Field>,
298    pub(crate) rows: Vec<Option<String>>,
299    pub(crate) spans: Vec<Vec<StyledSpan>>,
300}
301
302fn build_cherry_buffer(workdir: &std::path::Path, upstream: &str, head: &str) -> CherryBuffer {
303    if upstream.is_empty() {
304        return CherryBuffer {
305            text: "magit: cherries needs an upstream to compare against.\n".to_string(),
306            rows: vec![None],
307            spans: vec![Vec::new()],
308            ..Default::default()
309        };
310    }
311    let Ok(repo) = Repository::discover(workdir) else {
312        return CherryBuffer {
313            text: "Not a git repository.\n".to_string(),
314            rows: vec![None],
315            spans: vec![Vec::new()],
316            ..Default::default()
317        };
318    };
319    // `-v` adds the subject; without it the rows are bare shas and the
320    // buffer is unreadable. `%H`-style full shas are not available from
321    // `git cherry`, so `rev-parse` is not needed: it prints full shas
322    // already, which is what `<CR>` and `A` hand on.
323    let raw = repo
324        .run_git_str(["cherry", "-v", upstream, head])
325        .unwrap_or_default();
326    render_cherries(&raw, upstream, head)
327}
328
329/// Turn `git cherry -v` output into the buffer.
330///
331/// Split out pure so the layout and the index are testable without a
332/// repository — the same shape `render_refs` has.
333pub(crate) fn render_cherries(raw: &str, upstream: &str, head: &str) -> CherryBuffer {
334    let mut text = format!("Cherries  {head} vs {upstream}\n\n");
335    let mut rows: Vec<Option<String>> = vec![None, None];
336    let mut spans: Vec<Vec<StyledSpan>> = vec![Vec::new(), Vec::new()];
337    let (mut ahead, mut equivalent) = (0usize, 0usize);
338
339    for line in raw.lines() {
340        // `<+|-> <sha> <subject>`.
341        let Some((mark, rest)) = line.split_once(' ') else {
342            continue;
343        };
344        let (sha, subject) = match rest.split_once(' ') {
345            Some((s, subj)) => (s, subj),
346            None => (rest, ""),
347        };
348        if sha.is_empty() || !sha.chars().all(|c| c.is_ascii_hexdigit()) {
349            continue;
350        }
351        match mark {
352            "+" => ahead += 1,
353            "-" => equivalent += 1,
354            _ => continue,
355        }
356        let short: String = sha.chars().take(7).collect();
357        let row = if subject.is_empty() {
358            format!("{mark} {short}")
359        } else {
360            format!("{mark} {short}  {subject}")
361        };
362        // The sha's span, measured from the row just built rather than
363        // assumed — the mark is one char plus a space, but saying so
364        // twice is how the two drift.
365        let sha_start = mark.len() + 1;
366        spans.push(vec![StyledSpan {
367            start: sha_start,
368            end: sha_start + short.len(),
369            style: Style::MagitSha,
370        }]);
371        text.push_str(&row);
372        text.push('\n');
373        rows.push(Some(sha.to_string()));
374    }
375
376    if ahead == 0 && equivalent == 0 {
377        text.push_str("Nothing to compare — no commits on either side.\n");
378        rows.push(None);
379        spans.push(Vec::new());
380    }
381    CherryBuffer {
382        text,
383        header: headerline::cherry_fields(upstream, head, ahead, equivalent),
384        rows,
385        spans,
386    }
387}
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392
393    const SAMPLE: &str = "+ a1b2c3d4e5f6 not upstream yet\n\
394                          - e4f5a6b7c8d9 already upstream under another sha\n\
395                          + 0123456789ab another one\n";
396
397    #[test]
398    fn the_name_carries_both_ends_and_round_trips() {
399        let name = crate::workdir::magit_buffer_name_with(
400            CHERRY_VIEW,
401            "lattice",
402            &cherry_view_rest("origin/main", "main"),
403        );
404        assert_eq!(
405            parse_name(&name),
406            Some(("origin/main".to_string(), "main".to_string()))
407        );
408    }
409
410    /// Both ends are required. A cherry buffer with half a comparison
411    /// describes a different question than the one asked.
412    #[test]
413    fn names_missing_an_end_resolve_to_nothing() {
414        for name in [
415            "*magit:cherry:..main*",
416            "*magit:cherry:origin/main..*",
417            "*magit:cherry:origin/main*",
418            "*magit:commit:abc*",
419        ] {
420            assert_eq!(parse_name(name), None, "{name:?}");
421        }
422    }
423
424    /// The index must name the commit on the line it was written to —
425    /// `<CR>`, `A`, `_` and `O` all read through it, so an off-by-one
426    /// acts on the wrong commit while looking right.
427    #[test]
428    fn every_row_index_points_at_the_commit_on_that_line() {
429        let built = render_cherries(SAMPLE, "origin/main", "main");
430        let lines: Vec<&str> = built.text.lines().collect();
431        assert_eq!(built.rows.len(), lines.len(), "one slot per line");
432        assert_eq!(built.spans.len(), lines.len(), "one span slot per line");
433        for (i, slot) in built.rows.iter().enumerate() {
434            if let Some(sha) = slot {
435                assert!(
436                    lines[i].contains(&sha[..7]),
437                    "line {i} ({:?}) must show {sha}",
438                    lines[i]
439                );
440            }
441        }
442    }
443
444    /// The FULL sha is what the index carries, not the seven characters
445    /// on screen: an abbreviation is ambiguous in principle and git
446    /// resolves the ambiguity by refusing, which would surface as a
447    /// `<CR>` that opened nothing.
448    #[test]
449    fn the_index_carries_full_shas_even_though_rows_show_short_ones() {
450        let built = render_cherries(SAMPLE, "origin/main", "main");
451        let shas: Vec<&String> = built.rows.iter().flatten().collect();
452        assert_eq!(shas.len(), 3);
453        assert_eq!(shas[0], "a1b2c3d4e5f6");
454        assert!(
455            built.text.contains("+ a1b2c3d"),
456            "but the row shows the short one: {}",
457            built.text
458        );
459    }
460
461    /// `-` rows are the whole point of using `git cherry` over a range
462    /// walk: a commit already upstream under a different sha. Both marks
463    /// must survive into the buffer and be counted separately.
464    #[test]
465    fn both_marks_survive_and_are_counted_apart() {
466        let built = render_cherries(SAMPLE, "origin/main", "main");
467        assert!(built.text.starts_with("Cherries"));
468        assert_eq!(
469            built.text.lines().filter(|l| l.starts_with("+ ")).count(),
470            2,
471            "two not-upstream commits"
472        );
473        assert_eq!(
474            built.text.lines().filter(|l| l.starts_with("- ")).count(),
475            1,
476            "one already-upstream commit"
477        );
478    }
479
480    /// Garbage lines are skipped rather than rendered as a row with no
481    /// commit behind it — the same judgement `parse_for_each_ref` makes.
482    #[test]
483    fn unparseable_lines_are_skipped_not_rendered() {
484        let built = render_cherries("garbage\n* wrongmark abc subject\n+ \n", "u", "h");
485        assert!(
486            built.rows.iter().flatten().next().is_none(),
487            "no row survived: {:?}",
488            built.text
489        );
490        assert!(built.text.contains("Nothing to compare"));
491    }
492
493    /// Every span must cover the sha it claims to.
494    #[test]
495    fn the_sha_span_covers_the_sha() {
496        let built = render_cherries(SAMPLE, "origin/main", "main");
497        let lines: Vec<&str> = built.text.lines().collect();
498        for (i, spans) in built.spans.iter().enumerate() {
499            for s in spans {
500                assert_eq!(
501                    &lines[i][s.start..s.end],
502                    &built.rows[i].as_ref().expect("a row")[..7],
503                    "span on line {i} must cover the short sha"
504                );
505            }
506        }
507    }
508}