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.