Skip to main content

lattice_magit/
magit_revision_mode.rs

1//! Fold audit fix (MG.6/MG.7): `*magit:commit:<sha>*` revision view.
2//!
3//! A read-only `git show` of one commit. Previously, magit-log's
4//! `<CR>` wrote the same content to an uncleaned temp file in the
5//! repo workdir and opened it via a plain `Effect::OpenBuffer` —
6//! this is the real synthetic buffer the design always specified,
7//! shared by magit-log's `<CR>` and magit-blame's `<CR>`.
8//!
9//! `<CR>` on a file line here (the `--stat` summary or a `diff --git`
10//! header) opens that file's content AS OF THIS COMMIT
11//! (`magit-file-revision-mode`), not the live working-tree file —
12//! see `magit_file_revision_mode`'s doc comment for why.
13
14use std::path::PathBuf;
15use std::sync::{Arc, Mutex};
16
17use lattice_config;
18use lattice_grammar::Effect;
19use lattice_mode::{
20    BufferStoreHandle, CapabilitySet, Keymap, LifecycleFuture, Mode, ModeContext, ModeId, ModeKind,
21    OptionOverrideSet,
22};
23
24use crate::buffer_state::{BufferStateGuard, BufferStates};
25use crate::headerline;
26
27pub struct MagitRevisionMode;
28
29impl MagitRevisionMode {
30    pub fn mode_id() -> ModeId {
31        ModeId::new("magit-revision-mode")
32    }
33}
34
35pub struct RevisionState {
36    sha: String,
37    /// MR.3b: the repository label this buffer's own name carries.
38    ///
39    /// Held rather than re-derived from `workdir`: on a basename
40    /// collision the trigger *qualified* the label (`work/api`), and
41    /// `repo_label(workdir)` would hand back the unqualified form — a
42    /// name pointing at the other checkout's buffer.
43    repo: String,
44    /// MG.23g: where `a` / `-` apply the hunk under the cursor. Read
45    /// from the repository at activation, because a `git apply` needs a
46    /// directory and this buffer has no file of its own.
47    workdir: PathBuf,
48}
49
50/// MG.23g: this buffer's [`MagitView`], so `a` / `-` can act on a hunk
51/// of the commit it shows.
52///
53/// The view exists for `diff_source` and `workdir`; the rest of the
54/// trait declines. Publishing it is what turns `magit-core-mode`'s
55/// generic hunk resolution loose in here — nothing about `a` / `-` is
56/// specific to this mode, which is exactly why the handler is not.
57struct RevisionView(Arc<Mutex<RevisionState>>);
58
59impl crate::buffer_state::MagitView for RevisionView {
60    /// This buffer's content is a unified diff, so "a file" is a
61    /// `diff --git` header — not the generic indented-row scan, which
62    /// here matches every indented CONTEXT line and would walk `]f`
63    /// through arbitrary code.
64    fn file_lines(
65        &self,
66        store: &lattice_mode::BufferStoreHandle,
67        buffer: lattice_core::BufferId,
68    ) -> Option<Vec<u32>> {
69        Some(crate::magit_core_mode::diff_file_lines(store, buffer))
70    }
71
72    /// A fixed sha's `git show` cannot change, so `gr` has nothing to
73    /// rebuild. `None` rather than a re-run: repainting identical text
74    /// would move the cursor for no reason.
75    ///
76    /// This is also what `a` / `-` get after applying — correctly. The
77    /// commit is unchanged by putting one of its hunks in the working
78    /// tree; what changed is the tree, which this buffer does not show.
79    fn refresh(&self) -> Option<Effect> {
80        None
81    }
82
83    /// Everything here came out of a commit, so a hunk under the cursor
84    /// is history — `a` applies it to the working tree, `-` reverses it
85    /// back out, and `s` / `u` are refused with a sentence saying so.
86    fn diff_source(
87        &self,
88        _cursor: lattice_protocol::position::Position,
89    ) -> Option<crate::buffer_state::DiffSource> {
90        Some(crate::buffer_state::DiffSource::Committed)
91    }
92
93    /// MG.22: this commit's version of the file.
94    fn diff_target(
95        &self,
96        path: &std::path::Path,
97        _cursor: lattice_protocol::position::Position,
98    ) -> Option<Effect> {
99        let (sha, label) = {
100            let g = self.0.lock().ok()?;
101            (g.sha.clone(), g.repo.clone())
102        };
103        (!sha.is_empty()).then(|| Effect::OpenSyntheticBuffer {
104            name: crate::magit_file_revision_mode::blob_buffer_name(&label, &sha, path),
105            mode_id: "magit-file-revision-mode".to_string(),
106            content: None,
107            cursor: None,
108            activate_minor: None,
109        })
110    }
111
112    /// MG.24c: this buffer IS one commit, so the answer does not depend
113    /// on the cursor — every line of a `git show` belongs to the sha in
114    /// the buffer's name.
115    ///
116    /// `magit-core-mode.md` has claimed since MG.20 that `A` / `_` /
117    /// `O` work in "the revision view". They did not: this view was
118    /// added by MG.23g for `a` / `-` and never overrode
119    /// `commit_at_cursor`, so the trait default returned `None` and the
120    /// chords were consumed dead keys. Reading a sha off the line under
121    /// the cursor would have been the wrong fix — the `--stat` rows and
122    /// the diff body carry no sha at all, so it would work on the
123    /// header lines and nowhere else.
124    fn commit_at_cursor(&self, _cursor: lattice_protocol::position::Position) -> Option<String> {
125        let sha = self.0.lock().ok()?.sha.clone();
126        (!sha.is_empty()).then_some(sha)
127    }
128
129    fn workdir(&self) -> Option<PathBuf> {
130        self.0.lock().ok().map(|g| g.workdir.clone())
131    }
132}
133
134/// MG.13: service alias for this mode's per-buffer state
135/// (`feedback_servicesregistry_arc_typeid`).
136pub type RevisionStatesHandle = Arc<BufferStates<RevisionState>>;
137
138/// MG.34: the two buffer-name forms this mode answers to.
139///
140/// The second exists because magit's `M` "Merged" asks a question whose
141/// answer is *a different commit from the one you named*, and finding it
142/// costs a `git log` walk. The handler that fires the chord is
143/// synchronous and must not run `git` on the actor thread (MG.31), so it
144/// cannot resolve the merge and put the answer in the buffer name.
145/// Encoding the *question* in the name instead lets this mode resolve it
146/// inside the `spawn_blocking` it already runs for `git show` — no new
147/// async seam, and one buffer open rather than two.
148#[derive(Debug, Clone, PartialEq, Eq)]
149enum RevisionTarget {
150    /// `*magit:show:<repo>:<sha>*` — show this commit.
151    Commit(String),
152    /// `*magit:merged:<repo>:<sha>*` — show the merge that brought `<sha>` into
153    /// HEAD. The sha in the name is the **source**; the commit shown is
154    /// derived from it.
155    Merged(String),
156}
157
158/// MR.3b: the view word this mode's commit buffers use.
159///
160/// **Not `commit`.** That word belongs to the compose buffer
161/// (`*magit:commit:<repo>*`, `magit-commit-mode`), and once MR.3a put
162/// the repository in segment 2 the two shapes became the same string:
163/// showing commit `abc123` and composing a commit in a checkout called
164/// `abc123` would have been one buffer, with whichever mode got there
165/// first. `show` says what the buffer does and cannot collide.
166pub(crate) const SHOW_VIEW: &str = "show";
167/// MG.34: the view that asks "which merge brought `sha` in?".
168pub(crate) const MERGED_VIEW: &str = "merged";
169
170/// Which question a buffer name asks. `None` for a name this mode does
171/// not own — the caller shows the same "no commit sha given" text it
172/// showed before MG.34, rather than guessing.
173fn parse_target(name: &str) -> Option<RevisionTarget> {
174    let parsed = crate::workdir::parse_magit_name(name)?;
175    let sha = parsed.rest?;
176    match parsed.view {
177        SHOW_VIEW => Some(RevisionTarget::Commit(sha.to_string())),
178        MERGED_VIEW => Some(RevisionTarget::Merged(sha.to_string())),
179        _ => None,
180    }
181}
182
183/// MG.34: what a `*magit:merged:*` buffer says when nothing merged the
184/// commit in.
185///
186/// Not an error, and worded so it does not read as one: a commit made
187/// straight onto the branch you are on has no merge, which is the
188/// ordinary case for most of a repository's history. Showing an empty
189/// buffer would leave the reader unable to tell that from a failure.
190fn not_merged_text(sha: &str) -> String {
191    format!(
192        "{sha} was not merged into HEAD.\n\
193         \n\
194         No merge commit lies on the ancestry path from it to HEAD, so it\n\
195         reached this branch by a direct commit or a fast-forward rather\n\
196         than by a merge. There is nothing to show.\n"
197    )
198}
199
200impl Mode for MagitRevisionMode {
201    type Guard = BufferStateGuard<RevisionState>;
202
203    fn id(&self) -> ModeId {
204        Self::mode_id()
205    }
206    fn kind(&self) -> ModeKind {
207        ModeKind::Major
208    }
209    fn target_buffer_kind(&self) -> Option<lattice_core::BufferKind> {
210        None
211    }
212
213    fn options(&self) -> OptionOverrideSet {
214        lattice_config::overrides! {
215            lattice_config::ReadOnly = true,
216            lattice_config::NoFile = true,
217            lattice_config::Number = false,
218        }
219    }
220
221    /// MG.RO: `read-only-mode` is where the gate actually is.
222    ///
223    /// `ReadOnly = true` above stops TYPING and nothing else. It is read by
224    /// `read_only_edit_rejected`, which guards the insert-mode char path;
225    /// operators never reach it, because a `Document`'s grammar dispatch
226    /// applies its own edits and hands the host an already-applied
227    /// `Effect::Edits`. `x` deleted a character out of `*magit:status*` while
228    /// the buffer reported itself read-only — worse than not gating at all,
229    /// because it looks protected.
230    ///
231    /// `read-only-mode` carries the option AND the `invocation_runner`
232    /// (`Editor::run_read_only_motion`) that refuses mutating operators while
233    /// letting motions, `:` and `/` through.
234    ///
235    /// Declared per MAJOR rather than once on `magit-core-mode`: an implied
236    /// mode is followed from the mode being ACTIVATED, and the majors are what
237    /// the host activates. Putting it on the shared minor looked right and was
238    /// verified not to fire.
239    fn implies(&self) -> &[lattice_mode::ModeId] {
240        static IMPLIED: std::sync::OnceLock<Vec<lattice_mode::ModeId>> = std::sync::OnceLock::new();
241        IMPLIED.get_or_init(|| vec![lattice_mode::modes::ReadOnlyMode::mode_id()])
242    }
243
244    fn required_capabilities(&self) -> CapabilitySet {
245        CapabilitySet::empty()
246    }
247    /// MG.22: no chords of its own any more. `q` / `gr` / navigation
248    /// come from `magit-core-mode`, and `<CR>` / `s` / `u` / `a` / `-`
249    /// from `magit-hunk-mode` — this buffer is entirely diff content,
250    /// so everything that acts on it belongs to the mode that owns
251    /// diff content. What stays here is the `MagitView` telling those
252    /// modes *which commit* they are looking at.
253    fn keymap(&self) -> Keymap {
254        Keymap::default()
255    }
256
257    fn on_activate(&self, ctx: ModeContext) -> LifecycleFuture<'_, Self::Guard> {
258        Box::pin(async move {
259            let buffer_id = lattice_core::BufferId(ctx.buffer_id().0 as u32);
260            let orphan = || BufferStateGuard::new(Arc::new(BufferStates::default()), buffer_id);
261            let Some(store) = ctx.service::<BufferStoreHandle>() else {
262                return Ok(orphan());
263            };
264            let Some(handle) = store.handle_for(buffer_id) else {
265                return Ok(orphan());
266            };
267            // MR.3: the repository the trigger resolved for THIS
268            // buffer, not the one the editor was started in.
269            let workdir =
270                crate::repo_scope::view_workdir(&ctx, buffer_id, &handle).unwrap_or_default();
271
272            // MG.34: which question the buffer name asks — a commit
273            // directly, or the merge that brought one in.
274            let target = store.name_for(buffer_id).as_deref().and_then(parse_target);
275            // The sha the state starts with. For `Merged` it is not
276            // known yet (that is the whole question), so the state
277            // starts empty and is filled in below once the walk has
278            // run — the same late-resolve `magit-rebase-mode` does for
279            // its upstream.
280            let sha = match &target {
281                Some(RevisionTarget::Commit(sha)) => sha.clone(),
282                _ => String::new(),
283            };
284
285            // MG.14: the commit's identity (author, date, subject) is
286            // not in the buffer name, so the header is filled in below
287            // from the same `spawn_blocking` that runs `git show`.
288            let (hl, hl_registration) =
289                match headerline::install(&ctx, buffer_id, Self::mode_id().as_str()) {
290                    Some((h, reg)) => (Some(h), Some(reg)),
291                    None => (None, None),
292                };
293
294            // MG.13: publish BEFORE the first `.await` — see the note
295            // in `magit_branch_mode::on_activate`.
296            let Some(states) = ctx.service::<RevisionStatesHandle>() else {
297                return Ok(orphan());
298            };
299            let state = states.publish(
300                buffer_id,
301                RevisionState {
302                    sha: sha.clone(),
303                    repo: crate::repo_scope::label_of_buffer(&store, buffer_id),
304                    workdir: workdir.clone(),
305                },
306            );
307            let mut guard = BufferStateGuard::new((*states).clone(), buffer_id)
308                .with_headerline(hl_registration);
309            // MG.23g: publish the view, or `a` / `-` have nothing to
310            // ask about this buffer and refuse in it.
311            if let Some(views) = ctx.service::<crate::buffer_state::MagitViewsHandle>() {
312                views.publish(buffer_id, Arc::new(RevisionView(state.clone())));
313                guard = guard.with_views((*views).clone());
314            }
315
316            let wd = workdir.clone();
317            let context = crate::actions::context_lines(
318                &ctx.service::<std::sync::Arc<lattice_config::ConfigRegistry>>()
319                    .map(|outer| (*outer).clone()),
320            );
321            // MG.34: the merge walk runs here, inside the
322            // `spawn_blocking` that was already fetching `git show` —
323            // so the answer and the patch land in one paint. Splitting
324            // them would show the buffer, then relabel it, which the
325            // keystroke UX contract forbids.
326            let (resolved, text, meta) = tokio::task::spawn_blocking(move || {
327                let shown = match target {
328                    Some(RevisionTarget::Commit(sha)) => Some(sha),
329                    Some(RevisionTarget::Merged(source)) => {
330                        match crate::magit_core_mode::resolve_merge_commit(&wd, &source) {
331                            Some(merge) => Some(merge),
332                            // Ordinary answer, not a failure — say so
333                            // and stop, rather than `git show ""`.
334                            None => {
335                                return (
336                                    String::new(),
337                                    not_merged_text(&source),
338                                    headerline::RevisionMeta::default(),
339                                );
340                            }
341                        }
342                    }
343                    None => None,
344                };
345                let shown = shown.unwrap_or_default();
346                let text = run_show(&wd, &shown, context);
347                let meta = commit_meta(&wd, &shown);
348                (shown, text, meta)
349            })
350            .await
351            .unwrap_or_default();
352            headerline::publish(&hl, headerline::revision_fields(&meta));
353            // `git show --stat -p` is header lines (commit/author/date/
354            // message/stat-summary) followed by a unified diff — none
355            // of the header lines start with `+`/`-`/`@@`/`diff --git`/
356            // `---`/`+++`, so the plain whole-buffer diff styler is
357            // safe to apply directly (same reuse `magit-diff-mode`
358            // makes for its own `git diff` output).
359            // DS.4: the header lines carry no `+`/`-` marker, so the
360            // layered path classifies them as context and leaves them
361            // to the (absent) syntax layer — byte-identical to before.
362            // Only the diff region below gains syntax.
363            let spans = crate::hunk_syntax::diff_spans(
364                &text,
365                crate::hunk_syntax::syntax_registry(
366                    ctx.service::<std::sync::Arc<lattice_syntax::LangRegistry>>()
367                        .map(|outer| (*outer).clone()),
368                    ctx.service::<std::sync::Arc<lattice_config::ConfigRegistry>>()
369                        .map(|outer| (*outer).clone())
370                        .as_ref(),
371                )
372                .as_ref(),
373            );
374            crate::buffer_io::replace_buffer_text(&handle, text).await;
375            if let Some(ph) = ctx.service::<lattice_mode::PendingSyntheticHighlights>() {
376                ph.store_and_wake(buffer_id, spans);
377            }
378
379            // MG.34: late-resolved, now the walk has run. Without this
380            // the merge view's `A` / `_` / `O` / `<CR>` would act on
381            // the *source* commit the name carries rather than on the
382            // merge the buffer is showing — the same commit under two
383            // names, which is the failure mode this whole slice exists
384            // to avoid. Empty (unmerged) leaves them declining, which
385            // is right: there is no commit on screen to act on.
386            if let Ok(mut g) = state.lock() {
387                g.sha = resolved;
388            }
389
390            Ok(guard)
391        })
392    }
393}
394
395/// MG.14 header data: the commit's short sha, author, relative date
396/// and subject. A separate `git show -s --format=…` rather than
397/// scraping `run_show`'s header, because that output is locale- and
398/// config-dependent (`log.date`, `i18n.logOutputEncoding`) while
399/// `--format` is not. `-s` suppresses the diff, so this is a
400/// metadata-only read next to the patch `run_show` already fetches.
401pub(crate) fn commit_meta(workdir: &std::path::Path, sha: &str) -> headerline::RevisionMeta {
402    if sha.is_empty() {
403        return headerline::RevisionMeta::default();
404    }
405    let raw = std::process::Command::new("git")
406        .args(["show", "-s", "--format=%h%x00%an%x00%ar%x00%s", sha])
407        .current_dir(workdir)
408        .output()
409        .ok()
410        .filter(|o| o.status.success())
411        .and_then(|o| String::from_utf8(o.stdout).ok())
412        .unwrap_or_default();
413    headerline::parse_revision_meta(&raw)
414}
415
416fn run_show(workdir: &std::path::Path, sha: &str, context: i64) -> String {
417    if sha.is_empty() {
418        return "No commit sha given.\n".to_string();
419    }
420    std::process::Command::new("git")
421        .args(["show", "--stat", "-p", &format!("--unified={context}"), sha])
422        .current_dir(workdir)
423        .output()
424        .ok()
425        .filter(|o| o.status.success())
426        .and_then(|o| String::from_utf8(o.stdout).ok())
427        .unwrap_or_else(|| format!("Could not show commit {sha}\n"))
428}
429
430// ── MG.34: the `*magit:merged:*` name form ──────────────────────────
431#[cfg(test)]
432mod merged_target {
433    use super::*;
434
435    /// The two forms, and that they stay apart. Both live under
436    /// `*magit:` and both carry a bare sha, so a parser that checked one
437    /// prefix loosely would show the *source* commit where the merge was
438    /// asked for — the same commit under two names, which is exactly the
439    /// confusion this form exists to avoid.
440    #[test]
441    fn the_two_name_forms_stay_distinct() {
442        let name = |view| crate::workdir::magit_buffer_name_with(view, "lattice", "abc123");
443        assert_eq!(
444            parse_target(&name(SHOW_VIEW)),
445            Some(RevisionTarget::Commit("abc123".into()))
446        );
447        assert_eq!(
448            parse_target(&name(MERGED_VIEW)),
449            Some(RevisionTarget::Merged("abc123".into()))
450        );
451    }
452
453    /// MR.3b: `commit` is the COMPOSE buffer's view word, and this mode
454    /// must not answer to it.
455    ///
456    /// Once the repository took segment 2, `*magit:commit:<repo>*` (the
457    /// message you are writing) and `*magit:commit:<sha>*` (the commit
458    /// you are reading) became the same shape — one buffer, in a
459    /// checkout named like a sha, with whichever mode reached it first.
460    /// Renaming this view to `show` is what keeps them apart.
461    #[test]
462    fn the_compose_buffers_view_word_is_not_ours() {
463        assert_eq!(
464            parse_target(&crate::workdir::magit_buffer_name("commit", "lattice")),
465            None
466        );
467        assert_eq!(
468            parse_target(&crate::workdir::magit_buffer_name_with(
469                "commit", "lattice", "abc123"
470            )),
471            None
472        );
473    }
474
475    /// A name this mode does not own, and the two empty-sha forms. An
476    /// empty sha would reach `git show ""`, whose failure text names no
477    /// commit and reads like a bug in the editor.
478    #[test]
479    fn names_without_a_sha_are_not_targets() {
480        assert_eq!(parse_target("*magit:show:lattice:*"), None);
481        assert_eq!(parse_target("*magit:merged:lattice:*"), None);
482        assert_eq!(parse_target("*magit:show:lattice*"), None);
483        assert_eq!(parse_target("*magit:log:lattice:main*"), None);
484        assert_eq!(parse_target("a.txt"), None);
485    }
486
487    #[test]
488    fn the_builder_and_the_parser_agree() {
489        assert_eq!(
490            parse_target(&crate::workdir::magit_buffer_name_with(
491                MERGED_VIEW,
492                "lattice",
493                "deadbeef"
494            )),
495            Some(RevisionTarget::Merged("deadbeef".into()))
496        );
497    }
498
499    /// `None` from the walk is the ordinary answer for a commit made
500    /// straight onto the branch, so the buffer has to say that rather
501    /// than being empty — an empty buffer is indistinguishable from a
502    /// failure. The sha is named so the reader knows which commit was
503    /// asked about.
504    #[test]
505    fn the_unmerged_message_names_the_commit_and_does_not_read_as_an_error() {
506        let text = not_merged_text("abc123");
507        assert!(text.contains("abc123"), "must name the commit: {text}");
508        assert!(
509            !text.to_lowercase().contains("error")
510                && !text.to_lowercase().contains("failed")
511                && !text.to_lowercase().contains("could not"),
512            "a commit that was never merged is not a failure: {text}"
513        );
514    }
515}
516
517// MG.22: this mode's `parse_stat_line` / `file_at_cursor` tests moved
518// with the functions, to `hunk::path_at_cursor_tests`. They gained a
519// case in the move — the one that matters here, and the one this
520// module's copy could never have caught, because it tested the stat
521// parser in isolation rather than in the order the caller used it: a
522// diff body line containing ` | ` used to resolve to the text left of
523// the pipe, because the stat check ran first.