Skip to main content

lattice_magit/
magit_blame_mode.rs

1//! MG.26b: `magit-blame-mode` — a **minor** mode that annotates the
2//! buffer you are already looking at.
3//!
4//! **Why this is not a buffer.** The shape this replaces
5//! (`*magit:blame:<path>*`) rendered the file as *text*, one
6//! `<sha> <author>  <code>` row per line. That is why blame lost
7//! syntax highlighting: the buffer stopped being the file, so there
8//! was no language and no parser, and `blame_styled_spans` returned
9//! nothing for the code column *by construction*. Every other editor
10//! checked — magit, fugitive, Zed, GitLens, JetBrains — annotates the
11//! real buffer, and highlighting survives because the file was never
12//! replaced. Design:
13//! [`../../../docs/dev/architecture/magit-blame.md`].
14//!
15//! **What it adds:** one virtual row above each chunk of lines sharing
16//! a commit, carrying `<sha> <author> <date> <summary>` — magit's
17//! `headings` style. Vertical cost instead of the horizontal cost a
18//! per-line column would impose, so the code stays exactly where the
19//! eye expects it and the commit is read once per chunk rather than
20//! truncated onto every line.
21//!
22//! **The buffer goes read-only while blaming**, which is what re-frees
23//! `<CR>` and `p` for blame use. A minor on an editable file buffer
24//! cannot take grammar keys; magit resolves this the same way.
25
26use std::sync::atomic::{AtomicU64, Ordering};
27use std::sync::{Arc, Mutex, OnceLock, RwLock};
28
29use lattice_cells::{
30    AnchorPosition, Cell, ProviderId, VirtualRow, VirtualRowKind, VirtualRowProvider,
31};
32use lattice_config;
33use lattice_grammar::Effect;
34use lattice_mode::{
35    ActionContext, ActionHandlerContribution, ActivationPolicy, BufferStoreHandle, CapabilitySet,
36    Keymap, KeymapEntry, LifecycleFuture, Mode, ModeContext, ModeId, ModeKind, OptionOverrideSet,
37    VirtualRowRegistrar, keymap_entry,
38};
39use lattice_theme::{ElementId, ThemeRegistryHandle};
40use lattice_vcs::Repository;
41
42use crate::blame::{
43    BlameChunk, Removal, RemovalCommit, heading_text, is_uncommitted, parse_blame_chunks,
44};
45use crate::buffer_state::{BufferStateGuard, BufferStates};
46
47/// Provider id for the chunk-heading lane. One per buffer scope — a
48/// buffer has at most one blame running on it.
49pub const MAGIT_BLAME_PROVIDER_ID: ProviderId = 0x6d61_6769_745f_626c; // "magit_bl"
50
51pub struct MagitBlameMode;
52
53impl MagitBlameMode {
54    pub fn mode_id() -> ModeId {
55        ModeId::new("magit-blame-mode")
56    }
57}
58
59fn magit_blame_keymap_entries() -> &'static [KeymapEntry] {
60    static ENTRIES: OnceLock<Vec<KeymapEntry>> = OnceLock::new();
61    ENTRIES.get_or_init(|| {
62        vec![
63            keymap_entry! { mode: Normal, chord: "<CR>", doc: "Show the commit for the chunk at cursor", cmd: "action:magit-blame-show-commit" },
64            keymap_entry! { mode: Normal, chord: "p", doc: "Blame back one commit", cmd: "action:magit-blame-parent" },
65            // `gq`, not magit's bare `q`. This mode can be active on a
66            // *blob* buffer, where `magit-core-mode` is also active and
67            // already binds `q` to close the buffer — two minors
68            // binding one chord on one buffer resolves by registration
69            // order, which is not a contract anyone should depend on.
70            // `gq` DOES shadow something as of RF.2 — it is the reflow
71            // operator now, where when this was written it was
72            // unimplemented. The shadow is still the right call and is
73            // now deliberate rather than incidental: a MajorMode layer
74            // resolves before Builtin, and a blame buffer is read-only,
75            // so the operator has nothing to do here anyway.
76            //
77            // It stays clean only while the Builtin layer leaves
78            // `[g, q]` an internal node (no depth-2 terminal), which
79            // `gq_and_gw_stay_internal_nodes_so_their_longer_chords_survive`
80            // pins from the host side. `gq` also sits beside `gr` in the
81            // same namespace.
82            keymap_entry! { mode: Normal, chord: "gq", doc: "Stop blaming (the buffer becomes editable again)", cmd: "action:magit-blame-quit" },
83        ]
84    })
85}
86
87/// MG.23f2: which question this blame answers.
88///
89/// Two directions, not two modes: the chunking, the headings and the
90/// chords are identical and only the argv differs. **Mode state now,
91/// not a buffer name** — a buffer was the only carrier the old shape
92/// had, and it forced `p` in a reverse view to open a *new* buffer
93/// rather than walk in place.
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
95pub enum BlameDirection {
96    /// `git blame <rev> -- <path>` — for each line, the commit that
97    /// **introduced** it.
98    #[default]
99    Addition,
100    /// `git blame --reverse <rev>..HEAD -- <path>` — for each line, the
101    /// last commit in which it **still existed**. Lines annotated with
102    /// something other than HEAD are the ones that have since gone
103    /// away.
104    Reverse,
105}
106
107/// The `git` argv for one blame run.
108///
109/// Pure and shared by the handler and the tests, so what the tests pin
110/// is what runs.
111///
112/// `--` before the path is load-bearing rather than tidy: a path that
113/// looks like a rev is otherwise ambiguous, and git resolves the
114/// ambiguity in favour of the rev.
115pub(crate) fn blame_argv(direction: BlameDirection, rev: &str, path: &str) -> Vec<String> {
116    let mut argv = vec!["blame".to_string(), "--line-porcelain".to_string()];
117    match direction {
118        BlameDirection::Addition => argv.push(rev.to_string()),
119        BlameDirection::Reverse => {
120            argv.push("--reverse".to_string());
121            // git accepts a bare `--reverse <rev>` and reads it as
122            // `<rev>..HEAD`; spelling the range out says which end is
123            // which at the call site and in the test.
124            argv.push(format!("{rev}..HEAD"));
125        }
126    }
127    argv.push("--".to_string());
128    argv.push(path.to_string());
129    argv
130}
131
132// ── The chunk-heading provider ───────────────────────────────────────
133
134/// One virtual row above each blame chunk.
135///
136/// **No work per frame.** `collect` hands back rows built once, when a
137/// blame run landed — the cells worker calls it on every rebuild, and
138/// re-chunking or re-formatting there would be exactly the UI-thread
139/// work paramount goal #1 forbids. Colours *are* resolved in `collect`,
140/// which is one read-lock plus an `ArcSwap` load and is what
141/// `MagitHeaderline::render` already does, so a `:colorscheme` repaints
142/// the headings instead of leaving them on the old palette.
143pub struct BlameProvider {
144    chunks: RwLock<Arc<[BlameChunk]>>,
145    /// Bumped when the chunks change. Folded with the theme's version
146    /// in [`VirtualRowProvider::version`] so a palette swap repaints.
147    version: AtomicU64,
148    /// `None` in a harness with no theme registry — the fallback
149    /// colours are used instead.
150    theme: Option<ThemeRegistryHandle>,
151    sha_element: Option<ElementId>,
152    label_element: Option<ElementId>,
153    /// Frozen once per blame run rather than read per frame: a heading
154    /// that said "2 minutes ago" one frame and "3 minutes ago" the
155    /// next would repaint the whole lane for nothing.
156    now_secs: AtomicU64,
157}
158
159/// `ThemeRegistryHandle` is not `Debug`, and the provider trait wants
160/// it — so the interesting state is printed and the handle is not.
161impl std::fmt::Debug for BlameProvider {
162    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
163        f.debug_struct("BlameProvider")
164            .field("version", &self.version.load(Ordering::Acquire))
165            .field("chunks", &self.chunks.read().map(|c| c.len()).unwrap_or(0))
166            .finish()
167    }
168}
169
170impl BlameProvider {
171    fn new(theme: Option<ThemeRegistryHandle>, mode_id: &str) -> Arc<Self> {
172        let (sha_element, label_element) = match &theme {
173            Some(t) => {
174                let (s, l) = crate::headerline::intern_blame_heading_elements(t, mode_id);
175                (Some(s), Some(l))
176            }
177            None => (None, None),
178        };
179        Arc::new(Self {
180            chunks: RwLock::new(Arc::from(Vec::new())),
181            version: AtomicU64::new(0),
182            theme,
183            sha_element,
184            label_element,
185            now_secs: AtomicU64::new(0),
186        })
187    }
188
189    /// Replace the chunks and repaint. `now_secs` is passed in rather
190    /// than read here so the relative dates are testable.
191    fn set_chunks(&self, chunks: Vec<BlameChunk>, now_secs: i64) {
192        if let Ok(mut slot) = self.chunks.write() {
193            *slot = Arc::from(chunks);
194        }
195        self.now_secs
196            .store(now_secs.max(0) as u64, Ordering::Release);
197        self.version.fetch_add(1, Ordering::Release);
198    }
199
200    fn colours(&self) -> (u32, u32) {
201        let (fallback_sha, fallback_label) = crate::headerline::blame_heading_fallback();
202        let Some(theme) = &self.theme else {
203            return (fallback_sha, fallback_label);
204        };
205        let table = theme.resolved();
206        let pick = |id: Option<ElementId>, fallback: u32| {
207            id.and_then(|i| table.get(i).fg)
208                .map(|c| c.to_rgb_u32(0))
209                .unwrap_or(fallback)
210        };
211        (
212            pick(self.sha_element, fallback_sha),
213            pick(self.label_element, fallback_label),
214        )
215    }
216
217    /// The chunk covering `line`, for the chunk-at-cursor chords.
218    fn chunk_at(&self, line: u32) -> Option<BlameChunk> {
219        let chunks = self.chunks.read().ok()?;
220        chunks.iter().find(|c| c.contains(line)).cloned()
221    }
222}
223
224impl VirtualRowProvider for BlameProvider {
225    fn id(&self) -> ProviderId {
226        MAGIT_BLAME_PROVIDER_ID
227    }
228
229    fn version(&self) -> u64 {
230        let theme_version = self
231            .theme
232            .as_ref()
233            .map(|t| t.resolved().version())
234            .unwrap_or(0);
235        self.version
236            .load(Ordering::Acquire)
237            .wrapping_add(theme_version)
238    }
239
240    fn collect(&self) -> Vec<VirtualRow> {
241        let Ok(chunks) = self.chunks.read() else {
242            return Vec::new();
243        };
244        let (sha_fg, label_fg) = self.colours();
245        let now = self.now_secs.load(Ordering::Acquire) as i64;
246        chunks
247            .iter()
248            .map(|chunk| {
249                let text = heading_text(chunk, now);
250                // The sha is its own colour so chunk boundaries are
251                // scannable without reading the whole heading; an
252                // uncommitted chunk has no sha to colour.
253                let sha_len = if is_uncommitted(&chunk.sha) {
254                    0
255                } else {
256                    text.chars().take_while(|c| !c.is_whitespace()).count()
257                };
258                let cells: Vec<Cell> = text
259                    .chars()
260                    .enumerate()
261                    .map(|(i, c)| {
262                        let fg = if i < sha_len { sha_fg } else { label_fg };
263                        Cell::new(c as u32, fg, 0, 0)
264                    })
265                    .collect();
266                VirtualRow {
267                    media: None,
268                    anchor_line: chunk.start_line,
269                    position: AnchorPosition::Above,
270                    cells: Arc::from(cells),
271                    height: 1,
272                    kind: VirtualRowKind::Annotation,
273                    bg: None,
274                    scales: None,
275                    gutter_line: None,
276                    gutter_fg: None,
277                }
278            })
279            .collect()
280    }
281}
282
283/// Drops the provider registration when the mode deactivates — the
284/// headings must go away with the blame, not outlive it.
285pub struct BlameRegistration {
286    registrar: Arc<dyn VirtualRowRegistrar>,
287    buffer: lattice_core::BufferId,
288}
289
290impl Drop for BlameRegistration {
291    fn drop(&mut self) {
292        self.registrar
293            .unregister(self.buffer, MAGIT_BLAME_PROVIDER_ID);
294    }
295}
296
297// ── Per-buffer state ─────────────────────────────────────────────────
298
299pub struct BlameState {
300    workdir: std::path::PathBuf,
301    /// Repo-relative path being blamed.
302    path: String,
303    /// The revision currently blamed — `p` walks this back to its
304    /// parent **in place**, which is what direction-as-state buys.
305    rev: String,
306    direction: BlameDirection,
307    provider: Arc<BlameProvider>,
308    /// How a finished blame reaches the screen.
309    ///
310    /// **Bumping the provider's version is NOT a wake**, which is what
311    /// the first version of this mode assumed. The cells worker
312    /// re-reads providers when the editor is already redrawing; nothing
313    /// *starts* a redraw. So the headings sat there until the user
314    /// happened to press a key — "it works, but only after I hit
315    /// something", the exact symptom `CLAUDE.md` describes and which a
316    /// user reported here. `wake()` fires the waker without storing
317    /// anything, which is the idiom for precisely this.
318    pending_highlights: Option<lattice_mode::PendingSyntheticHighlightsHandle>,
319    /// Kept so the guard's drop tears the heading lane down.
320    _registration: Option<BlameRegistration>,
321}
322
323pub type BlameStatesHandle = Arc<BufferStates<BlameState>>;
324
325fn state(ctx: &ActionContext<'_>) -> Option<Arc<Mutex<BlameState>>> {
326    crate::buffer_state::state_for::<BlameState>(ctx)
327}
328
329/// MG.26b: what a *pending* blame should be, keyed by the buffer name
330/// it will land on.
331///
332/// `Effect::ToggleMode` carries only a mode name, which is right — the
333/// grammar crate must not learn about blame directions. So an action
334/// that wants a non-default blame (reverse, or a specific revision)
335/// leaves the request here first, and `on_activate` consumes it. Keyed
336/// by *name* rather than `BufferId` because the buffer may not exist
337/// yet: the reverse path opens a blob buffer and activates the mode on
338/// it in one `Effect::Many`.
339#[derive(Default)]
340pub struct BlameRequests {
341    map: Mutex<std::collections::HashMap<String, (BlameDirection, String)>>,
342}
343
344pub type BlameRequestsHandle = Arc<BlameRequests>;
345
346impl BlameRequests {
347    pub fn put(&self, buffer_name: String, direction: BlameDirection, rev: String) {
348        if let Ok(mut m) = self.map.lock() {
349            m.insert(buffer_name, (direction, rev));
350        }
351    }
352
353    /// Read and remove — a request is for one activation. Leaving it
354    /// would make the *next* plain `:magit-blame` on the same buffer
355    /// silently reverse.
356    pub fn take(&self, buffer_name: &str) -> Option<(BlameDirection, String)> {
357        self.map.lock().ok()?.remove(buffer_name)
358    }
359}
360
361impl Mode for MagitBlameMode {
362    type Guard = BufferStateGuard<BlameState>;
363
364    fn id(&self) -> ModeId {
365        Self::mode_id()
366    }
367    fn kind(&self) -> ModeKind {
368        ModeKind::Minor
369    }
370
371    /// Never automatic: blame is something you ask for on a buffer you
372    /// are already reading. `Effect::ToggleMode` is the seam.
373    fn activation_policy(&self) -> ActivationPolicy {
374        ActivationPolicy::Manual
375    }
376
377    fn target_buffer_kind(&self) -> Option<lattice_core::BufferKind> {
378        None
379    }
380
381    /// Read-only for the duration, which is what re-frees `<CR>`, `p`
382    /// and `q` — a minor on an editable buffer cannot take grammar
383    /// keys. The override reverts when the mode deactivates, so the
384    /// file is editable again the moment blame stops.
385    fn options(&self) -> OptionOverrideSet {
386        lattice_config::overrides! {
387            lattice_config::ReadOnly = true,
388        }
389    }
390
391    fn required_capabilities(&self) -> CapabilitySet {
392        CapabilitySet::empty()
393    }
394    fn keymap(&self) -> Keymap {
395        Keymap::from_entries(magit_blame_keymap_entries())
396    }
397
398    fn action_handlers(&self) -> Vec<ActionHandlerContribution> {
399        vec![
400            // <CR> — the commit for the chunk under the cursor.
401            //
402            // Resolved from the stored chunks, not by reading the
403            // buffer: the buffer is the user's own code now, and it
404            // carries no sha to parse. That is the whole point.
405            ActionHandlerContribution {
406                action_name: "action:magit-blame-show-commit",
407                handler: Arc::new(|ctx: &ActionContext<'_>| {
408                    let s = state(ctx)?;
409                    let g = s.lock().ok()?;
410                    let chunk = g.provider.chunk_at(ctx.cursor.line)?;
411                    if is_uncommitted(&chunk.sha) {
412                        return Some(Effect::Echo {
413                            level: lattice_grammar::EchoLevel::Info,
414                            text: "magit: this line is not committed yet".to_string(),
415                        });
416                    }
417                    Some(crate::magit_global_mode::open_repo_view_from_action_with(
418                        ctx,
419                        crate::magit_revision_mode::SHOW_VIEW,
420                        "magit-revision-mode",
421                        Some(&chunk.sha),
422                    ))
423                }),
424            },
425            // gq — stop blaming. Deactivating the mode is what removes
426            // the headings and gives the buffer back its editability;
427            // there is no buffer to close any more.
428            ActionHandlerContribution {
429                action_name: "action:magit-blame-quit",
430                handler: Arc::new(|ctx: &ActionContext<'_>| {
431                    let _ = state(ctx)?;
432                    Some(Effect::ToggleMode {
433                        mode_name: MagitBlameMode::mode_id().as_str().to_string(),
434                    })
435                }),
436            },
437            // p — re-blame at the parent of the revision currently
438            // blamed, IN PLACE. The old shape had to open a new buffer
439            // in the reverse direction because the direction lived in
440            // the buffer's name; state has no such constraint.
441            ActionHandlerContribution {
442                action_name: "action:magit-blame-parent",
443                handler: Arc::new(|ctx: &ActionContext<'_>| {
444                    let s = state(ctx)?;
445                    let (wd, rev) = {
446                        let g = s.lock().ok()?;
447                        (g.workdir.clone(), g.rev.clone())
448                    };
449                    let s2 = s.clone();
450                    tokio::task::spawn(async move {
451                        let wd2 = wd.clone();
452                        let rev2 = rev.clone();
453                        let parent =
454                            tokio::task::spawn_blocking(move || resolve_parent(&wd2, &rev2))
455                                .await
456                                .ok()
457                                .flatten();
458                        let Some(parent) = parent else {
459                            tracing::debug!(
460                                target: "lattice_magit",
461                                "blame: {rev} has no parent — already at the root commit",
462                            );
463                            return;
464                        };
465                        if let Ok(mut g) = s2.lock() {
466                            g.rev = parent;
467                        }
468                        rerun_blame(s2).await;
469                    });
470                    None
471                }),
472            },
473        ]
474    }
475
476    fn on_activate(&self, ctx: ModeContext) -> LifecycleFuture<'_, Self::Guard> {
477        Box::pin(async move {
478            let buffer_id = lattice_core::BufferId(ctx.buffer_id().0 as u32);
479            let orphan = || BufferStateGuard::new(Arc::new(BufferStates::default()), buffer_id);
480            let Some(store) = ctx.service::<BufferStoreHandle>() else {
481                return Ok(orphan());
482            };
483
484            // What to blame: the buffer's own file, or — for the blob
485            // buffer the reverse path opens — the path in its name.
486            let buffer_name = store.name_for(buffer_id).unwrap_or_default();
487            let scopes = ctx.service::<crate::repo_scope::RepoScopesHandle>();
488            let Some((workdir, path)) = blame_target(
489                &store,
490                scopes.as_deref().map(|s| &**s),
491                buffer_id,
492                &buffer_name,
493            ) else {
494                // A scratch buffer, or a file outside any repository.
495                // Nothing to annotate; the mode activates as a no-op
496                // rather than failing, and `q` turns it back off.
497                return Ok(orphan());
498            };
499
500            // A pending request (reverse, or a specific revision) wins
501            // over the defaults; a plain toggle has none.
502            let (direction, rev) = ctx
503                .service::<BlameRequestsHandle>()
504                .and_then(|r| r.take(&buffer_name))
505                .unwrap_or((BlameDirection::Addition, "HEAD".to_string()));
506
507            let theme = ctx
508                .service::<ThemeRegistryHandle>()
509                .map(|outer| (*outer).clone());
510            let provider = BlameProvider::new(theme, Self::mode_id().as_str());
511
512            let registration = ctx
513                .service::<Arc<dyn VirtualRowRegistrar>>()
514                .map(|outer| (*outer).clone())
515                .map(|registrar| {
516                    // `register` refuses to replace a live id, so clear
517                    // whatever a previous activation left behind.
518                    registrar.unregister(buffer_id, MAGIT_BLAME_PROVIDER_ID);
519                    registrar.register(buffer_id, provider.clone() as Arc<dyn VirtualRowProvider>);
520                    BlameRegistration {
521                        registrar,
522                        buffer: buffer_id,
523                    }
524                });
525
526            // MG.13: publish BEFORE the first `.await`.
527            let Some(states) = ctx.service::<BlameStatesHandle>() else {
528                return Ok(orphan());
529            };
530            let state = states.publish(
531                buffer_id,
532                BlameState {
533                    workdir: workdir.clone(),
534                    path: path.clone(),
535                    rev: rev.clone(),
536                    direction,
537                    provider: provider.clone(),
538                    pending_highlights: ctx.service::<lattice_mode::PendingSyntheticHighlights>(),
539                    _registration: registration,
540                },
541            );
542            let guard = BufferStateGuard::new((*states).clone(), buffer_id);
543
544            rerun_blame(state).await;
545            Ok(guard)
546        })
547    }
548}
549
550/// Where the file being blamed lives, as `(workdir, repo-relative path)`.
551///
552/// Two sources, in order. A real file buffer has a path. A blob buffer
553/// (`*magit:file:<rev>:<path>*`) has none — it is synthetic — but
554/// carries its path in its name, which is the same trick
555/// `magit-file-revision-mode` and `lattice-multibuffer` use.
556fn blame_target(
557    store: &BufferStoreHandle,
558    scopes: Option<&crate::repo_scope::RepoScopes>,
559    buffer_id: lattice_core::BufferId,
560    buffer_name: &str,
561) -> Option<(std::path::PathBuf, String)> {
562    if let Some(p) = store.path_for(buffer_id) {
563        let (workdir, rel) = crate::workdir::workdir_for_file(&p)?;
564        return Some((workdir, rel.to_string_lossy().into_owned()));
565    }
566    // MR.4: a blob buffer has no file on disk, so its repository comes
567    // from the name it was opened under rather than from the process.
568    let rel = crate::magit_file_revision_mode::parse_buffer_name(buffer_name)?.1;
569    let workdir = scopes
570        .and_then(|s| {
571            s.workdir_for(buffer_name).or_else(|| {
572                crate::workdir::parse_magit_name(buffer_name)
573                    .and_then(|n| n.repo)
574                    .and_then(|label| s.workdir_for_label(label))
575            })
576        })
577        .or_else(crate::workdir::magit_workdir)?;
578    Some((workdir, rel.to_string_lossy().into_owned()))
579}
580
581/// Run the blame this state describes and hand the chunks to its
582/// provider.
583///
584/// Off the actor thread on `spawn_blocking`, and the result reaches the
585/// screen with no keypress — but **only because of the explicit wake at
586/// the end**. Bumping the provider's version is not a wake: the cells
587/// worker re-reads providers when a redraw is already happening, and
588/// nothing starts one. Nothing here touches the buffer's text.
589async fn rerun_blame(s: Arc<Mutex<BlameState>>) {
590    let Some((wd, direction, rev, path, provider, wake)) = ({
591        let g = s.lock().ok();
592        g.map(|g| {
593            (
594                g.workdir.clone(),
595                g.direction,
596                g.rev.clone(),
597                g.path.clone(),
598                g.provider.clone(),
599                g.pending_highlights.clone(),
600            )
601        })
602    }) else {
603        return;
604    };
605    // MG.33: the removal walk runs in the SAME `spawn_blocking` as the
606    // blame. Splitting it would publish headings once without the
607    // answer and again with it — a visible relabel of rows the user did
608    // not touch, which the keystroke UX contract forbids.
609    let chunks = tokio::task::spawn_blocking(move || {
610        let mut chunks = parse_blame_chunks(&run_blame(&wd, direction, &rev, &path));
611        if direction == BlameDirection::Reverse {
612            resolve_removals(&wd, &path, &mut chunks);
613        }
614        chunks
615    })
616    .await
617    .unwrap_or_default();
618    let now = std::time::SystemTime::now()
619        .duration_since(std::time::UNIX_EPOCH)
620        .map(|d| d.as_secs() as i64)
621        .unwrap_or(0);
622    provider.set_chunks(chunks, now);
623    // The headings exist now; ask for a frame. Without this they appear
624    // on the next keystroke instead of when the blame lands.
625    if let Some(wake) = wake {
626        wake.wake();
627    }
628}
629
630/// MG.33: resolve what removed the lines a reverse-blame chunk covers.
631///
632/// **Blocking — call on `spawn_blocking`.** Up to two `git` invocations
633/// per distinct blamed SHA, so callers dedupe by SHA first
634/// ([`resolve_removals`]).
635///
636/// The walk is
637/// `git rev-list --ancestry-path --reverse <sha>..HEAD -- <path>`:
638/// every commit that is a descendant of `sha`, an ancestor of HEAD, and
639/// touched the file, oldest first. The oldest is the one that removed
640/// the lines.
641///
642/// - **Empty output** — nothing after `sha` touched the file on the way
643///   to HEAD, so the lines are still there.
644/// - **Two or more, and the second is not a descendant of the first** —
645///   history forked at `sha` and parallel branches touched the file, so
646///   several commits qualify. [`Removal::Ambiguous`] rather than a
647///   guess: naming the wrong commit in a blame heading is worse than
648///   naming none.
649///
650/// The `merge-base` check is the only reason for a second invocation
651/// and it runs only when the list has more than one entry, so the
652/// linear case — overwhelmingly the common one — costs one call.
653fn resolve_removal(workdir: &std::path::Path, sha: &str, path: &str) -> Removal {
654    let candidates = git_lines(
655        workdir,
656        &[
657            "rev-list",
658            "--ancestry-path",
659            "--reverse",
660            &format!("{sha}..HEAD"),
661            "--",
662            path,
663        ],
664    );
665    let Some(first) = candidates.first() else {
666        return Removal::StillPresent;
667    };
668    if let Some(second) = candidates.get(1) {
669        // `--is-ancestor` exits 0 when it is one. If the second commit
670        // does not descend from the first, they are on parallel
671        // branches and "the first" is an artefact of traversal order.
672        let linear = std::process::Command::new("git")
673            .args(["merge-base", "--is-ancestor", first, second])
674            .current_dir(workdir)
675            .status()
676            .map(|s| s.success())
677            .unwrap_or(false);
678        if !linear {
679            return Removal::Ambiguous;
680        }
681    }
682    match commit_meta(workdir, first) {
683        Some(c) => Removal::By(c),
684        // The rev-list named it, so a failure to describe it is a git
685        // problem rather than an absent commit. Declining beats
686        // rendering a bare SHA with empty columns.
687        None => Removal::Ambiguous,
688    }
689}
690
691/// `author-name`, `author-time` and `subject` for one commit.
692fn commit_meta(workdir: &std::path::Path, sha: &str) -> Option<RemovalCommit> {
693    // `%x1f` is the ASCII unit separator: it cannot occur in an author
694    // name or a subject, where a `|` or a tab easily can.
695    let out = git_lines(workdir, &["show", "-s", "--format=%an%x1f%at%x1f%s", sha]);
696    let line = out.first()?;
697    let mut parts = line.split('\u{1f}');
698    let author = parts.next()?.to_string();
699    let time = parts.next()?.parse::<i64>().ok()?;
700    let summary = parts.next().unwrap_or_default().to_string();
701    Some(RemovalCommit {
702        sha: sha.to_string(),
703        author,
704        time,
705        summary,
706    })
707}
708
709/// Run `git` and return stdout's non-empty lines, or nothing on
710/// failure. Blame must degrade to fewer annotations, never to an error.
711fn git_lines(workdir: &std::path::Path, args: &[&str]) -> Vec<String> {
712    match std::process::Command::new("git")
713        .args(args)
714        .current_dir(workdir)
715        .output()
716    {
717        Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
718            .lines()
719            .filter(|l| !l.trim().is_empty())
720            .map(str::to_string)
721            .collect(),
722        Ok(o) => {
723            tracing::debug!(
724                target: "lattice_magit",
725                "git {args:?}: {}",
726                String::from_utf8_lossy(&o.stderr).trim(),
727            );
728            Vec::new()
729        }
730        Err(e) => {
731            tracing::debug!(target: "lattice_magit", "git {args:?}: {e}");
732            Vec::new()
733        }
734    }
735}
736
737/// MG.33: fill in `removal` for every chunk of a reverse blame.
738///
739/// **Deduped by SHA**, which is what keeps the cost sane: a file with
740/// 200 chunks usually has far fewer distinct commits, and adjacent runs
741/// of the same commit resolve once. Uncommitted lines are skipped —
742/// they have no history to walk.
743///
744/// Blocking; runs inside the same `spawn_blocking` as the blame itself.
745fn resolve_removals(workdir: &std::path::Path, path: &str, chunks: &mut [BlameChunk]) {
746    let mut seen: std::collections::HashMap<String, Removal> = std::collections::HashMap::new();
747    for chunk in chunks.iter_mut() {
748        if is_uncommitted(&chunk.sha) {
749            continue;
750        }
751        let removal = match seen.get(&chunk.sha) {
752            Some(r) => r.clone(),
753            None => {
754                let r = resolve_removal(workdir, &chunk.sha, path);
755                seen.insert(chunk.sha.clone(), r.clone());
756                r
757            }
758        };
759        chunk.removal = Some(removal);
760    }
761}
762
763/// `git blame --line-porcelain`'s raw output, or empty on failure.
764///
765/// Returns the porcelain rather than formatted rows — the formatting
766/// this used to do is what made blame a buffer instead of an
767/// annotation.
768fn run_blame(
769    workdir: &std::path::Path,
770    direction: BlameDirection,
771    rev: &str,
772    path: &str,
773) -> String {
774    if path.is_empty() || path == "." {
775        return String::new();
776    }
777    match std::process::Command::new("git")
778        .args(blame_argv(direction, rev, path))
779        .current_dir(workdir)
780        .output()
781    {
782        Ok(o) if o.status.success() => String::from_utf8(o.stdout).unwrap_or_default(),
783        Ok(o) => {
784            tracing::debug!(
785                target: "lattice_magit",
786                "blame {path}: {}",
787                String::from_utf8_lossy(&o.stderr).trim(),
788            );
789            String::new()
790        }
791        Err(e) => {
792            tracing::error!(target: "lattice_magit", "blame {path}: {e}");
793            String::new()
794        }
795    }
796}
797
798/// Resolve `<rev>^`'s commit sha — `None` if `rev` has no parent (the
799/// root commit) or resolution otherwise fails.
800fn resolve_parent(workdir: &std::path::Path, rev: &str) -> Option<String> {
801    let repo = Repository::discover(workdir).ok()?;
802    repo.run_git_str(["rev-parse", &format!("{rev}^")])
803        .ok()
804        .map(|s| s.trim().to_string())
805        .filter(|s| !s.is_empty())
806}
807
808#[cfg(test)]
809mod tests {
810    use super::*;
811
812    #[test]
813    fn forward_blame_names_the_revision_and_separates_the_path() {
814        assert_eq!(
815            blame_argv(BlameDirection::Addition, "HEAD", "src/main.rs"),
816            vec!["blame", "--line-porcelain", "HEAD", "--", "src/main.rs"]
817        );
818    }
819
820    /// The range is spelled out rather than relying on git reading a
821    /// bare `--reverse <rev>` as `<rev>..HEAD`.
822    #[test]
823    fn reverse_blame_spells_out_the_range() {
824        assert_eq!(
825            blame_argv(BlameDirection::Reverse, "a1b2c3d", "src/main.rs"),
826            vec![
827                "blame",
828                "--line-porcelain",
829                "--reverse",
830                "a1b2c3d..HEAD",
831                "--",
832                "src/main.rs"
833            ]
834        );
835    }
836
837    /// `--` is load-bearing: a path that looks like a rev is otherwise
838    /// ambiguous and git resolves it in favour of the rev.
839    #[test]
840    fn a_path_that_looks_like_a_rev_is_still_a_path() {
841        let argv = blame_argv(BlameDirection::Addition, "HEAD", "HEAD");
842        let sep = argv.iter().position(|a| a == "--").expect("a separator");
843        assert_eq!(argv[sep + 1], "HEAD", "the path sits after `--`: {argv:?}");
844    }
845
846    fn chunk(sha: &str, start: u32, count: u32) -> BlameChunk {
847        BlameChunk {
848            sha: sha.into(),
849            author: "Jane Doe".into(),
850            time: 1_700_000_000,
851            summary: "do the thing".into(),
852            start_line: start,
853            line_count: count,
854            removal: None,
855        }
856    }
857
858    #[test]
859    fn one_heading_is_emitted_above_each_chunk() {
860        let p = BlameProvider::new(None, "magit-blame-mode");
861        p.set_chunks(
862            vec![chunk("aaaa1111", 0, 3), chunk("bbbb2222", 3, 2)],
863            1_700_000_000,
864        );
865        let rows = p.collect();
866        assert_eq!(rows.len(), 2);
867        assert_eq!(rows[0].anchor_line, 0);
868        assert_eq!(
869            rows[1].anchor_line, 3,
870            "the chunk's FIRST line, not its last"
871        );
872        for row in &rows {
873            assert_eq!(
874                row.position,
875                AnchorPosition::Above,
876                "a heading introduces its chunk"
877            );
878            assert_eq!(row.height, 1);
879        }
880    }
881
882    #[test]
883    fn a_heading_carries_the_commit_text() {
884        let p = BlameProvider::new(None, "magit-blame-mode");
885        p.set_chunks(vec![chunk("aaaa1111", 0, 1)], 1_700_000_000);
886        let row = &p.collect()[0];
887        let text: String = row
888            .cells
889            .iter()
890            .map(|c| char::from_u32(c.codepoint).unwrap_or(' '))
891            .collect();
892        assert!(text.starts_with("aaaa1111"), "{text}");
893        assert!(text.contains("Jane Doe"), "{text}");
894        assert!(text.contains("do the thing"), "{text}");
895    }
896
897    /// The sha gets its own colour so chunk boundaries are scannable
898    /// without reading the heading.
899    #[test]
900    fn the_sha_is_coloured_apart_from_the_rest_of_the_heading() {
901        let p = BlameProvider::new(None, "magit-blame-mode");
902        p.set_chunks(vec![chunk("aaaa1111", 0, 1)], 1_700_000_000);
903        let row = &p.collect()[0];
904        let (sha_fg, label_fg) = crate::headerline::blame_heading_fallback();
905        assert_ne!(sha_fg, label_fg, "the two roles must be distinguishable");
906        assert_eq!(row.cells[0].fg, sha_fg);
907        assert_eq!(row.cells[7].fg, sha_fg, "…through the last sha character");
908        assert_eq!(row.cells[8].fg, label_fg, "…and not past it");
909    }
910
911    /// MG.33: on a removed chunk the leading sha is the **removing**
912    /// commit's, and it must be the one that gets the sha colour.
913    ///
914    /// This works because `sha_len` is measured from the rendered
915    /// text's leading token rather than from `chunk.sha` — which reads
916    /// like an implementation detail and is in fact the thing that
917    /// keeps the two in step. Pinned so a later "tidy-up" to
918    /// `chunk.sha.len()` (equal here only by coincidence of both shas
919    /// being full-length) is caught.
920    #[test]
921    fn a_removed_headings_sha_colour_covers_the_removing_commit() {
922        let p = BlameProvider::new(None, "magit-blame-mode");
923        let mut c = chunk("aaaa1111", 0, 1);
924        c.removal = Some(Removal::By(RemovalCommit {
925            sha: "dead9999beef".into(),
926            author: "Sam Patel".into(),
927            time: 1_700_000_000,
928            summary: "drop it".into(),
929        }));
930        p.set_chunks(vec![c], 1_700_000_000);
931        let row = &p.collect()[0];
932        let (sha_fg, label_fg) = crate::headerline::blame_heading_fallback();
933
934        let text: String = row
935            .cells
936            .iter()
937            .filter_map(|c| char::from_u32(c.codepoint))
938            .collect();
939        assert!(text.starts_with("dead9999"), "{text}");
940        for i in 0..8 {
941            assert_eq!(row.cells[i].fg, sha_fg, "char {i} of the removing sha");
942        }
943        assert_eq!(row.cells[8].fg, label_fg, "…and not past it");
944    }
945
946    /// An uncommitted chunk has no sha to colour, so the whole row is
947    /// the label colour rather than eight characters of "sha" that is
948    /// really the word "Uncommi".
949    #[test]
950    fn an_uncommitted_heading_colours_no_sha() {
951        let p = BlameProvider::new(None, "magit-blame-mode");
952        p.set_chunks(vec![chunk(&"0".repeat(40), 0, 1)], 1_700_000_000);
953        let row = &p.collect()[0];
954        let (_, label_fg) = crate::headerline::blame_heading_fallback();
955        assert!(row.cells.iter().all(|c| c.fg == label_fg));
956    }
957
958    #[test]
959    fn a_provider_with_no_blame_yet_emits_no_rows() {
960        let p = BlameProvider::new(None, "magit-blame-mode");
961        assert!(
962            p.collect().is_empty(),
963            "no headings until a blame lands — an empty lane, not a blank row"
964        );
965    }
966
967    /// The worker short-circuits on the version fingerprint, so a new
968    /// blame that did not bump it would never reach the screen.
969    #[test]
970    fn new_chunks_bump_the_version() {
971        let p = BlameProvider::new(None, "magit-blame-mode");
972        let before = p.version();
973        p.set_chunks(vec![chunk("aaaa1111", 0, 1)], 1_700_000_000);
974        assert!(p.version() > before);
975    }
976
977    #[test]
978    fn the_chunk_at_the_cursor_is_the_one_covering_that_line() {
979        let p = BlameProvider::new(None, "magit-blame-mode");
980        p.set_chunks(
981            vec![chunk("aaaa1111", 0, 3), chunk("bbbb2222", 3, 2)],
982            1_700_000_000,
983        );
984        assert_eq!(p.chunk_at(0).unwrap().sha, "aaaa1111");
985        assert_eq!(p.chunk_at(2).unwrap().sha, "aaaa1111");
986        assert_eq!(p.chunk_at(3).unwrap().sha, "bbbb2222");
987        assert!(p.chunk_at(99).is_none(), "past the end belongs to nothing");
988    }
989
990    /// A request is for one activation. Leaving it behind would make
991    /// the next plain `:magit-blame` on the same buffer silently
992    /// reverse.
993    #[test]
994    fn a_blame_request_is_consumed_by_the_activation_that_reads_it() {
995        let r = BlameRequests::default();
996        r.put(
997            "*magit:file:a1b2c3d:src/main.rs*".into(),
998            BlameDirection::Reverse,
999            "a1b2c3d".into(),
1000        );
1001        assert_eq!(
1002            r.take("*magit:file:a1b2c3d:src/main.rs*"),
1003            Some((BlameDirection::Reverse, "a1b2c3d".into()))
1004        );
1005        assert_eq!(r.take("*magit:file:a1b2c3d:src/main.rs*"), None);
1006    }
1007
1008    #[test]
1009    fn an_unrequested_buffer_takes_nothing() {
1010        let r = BlameRequests::default();
1011        assert_eq!(r.take("src/main.rs"), None);
1012    }
1013
1014    /// `gq` turns blame off, and it must stay `g`-prefixed.
1015    ///
1016    /// Bare `q` is `magit-core-mode`'s (close the buffer), and this
1017    /// mode can be active on a blob buffer where that mode is also
1018    /// active — two minors on one chord resolves by registration order,
1019    /// which is not a contract.
1020    #[test]
1021    fn quitting_blame_is_g_prefixed_and_shares_no_chord_with_magit_core() {
1022        use lattice_mode::Mode;
1023        let chords: Vec<&str> = MagitBlameMode
1024            .keymap()
1025            .entries
1026            .iter()
1027            .map(|e| e.chord)
1028            .collect();
1029        assert!(chords.contains(&"gq"), "{chords:?}");
1030        assert!(
1031            !chords.contains(&"q"),
1032            "bare `q` belongs to magit-core-mode: {chords:?}"
1033        );
1034        let core: Vec<&str> = crate::MagitCoreMode
1035            .keymap()
1036            .entries
1037            .iter()
1038            .map(|e| e.chord)
1039            .collect();
1040        for c in &chords {
1041            assert!(
1042                !core.contains(c),
1043                "`{c}` is bound by both magit-blame-mode and magit-core-mode"
1044            );
1045        }
1046    }
1047
1048    /// The regression a user reported: the headings appeared only after
1049    /// an unrelated redraw.
1050    ///
1051    /// Bumping the provider's version is NOT a wake — the cells worker
1052    /// re-reads providers when a redraw is already happening, and
1053    /// nothing starts one. `rerun_blame` must therefore fire the waker
1054    /// itself, and this pins that the state carries the handle it needs
1055    /// to. A `None` here is exactly the shipped bug.
1056    #[test]
1057    fn the_state_carries_the_handle_the_finished_blame_wakes_with() {
1058        // A structural pin: the field exists and is the wake-capable
1059        // handle, not a bare marker. The behavioural half lives in the
1060        // host's async-wake tests, which need a live editor.
1061        fn assert_wakeable(_: &Option<lattice_mode::PendingSyntheticHighlightsHandle>) {}
1062        let s = BlameState {
1063            workdir: std::path::PathBuf::new(),
1064            path: String::new(),
1065            rev: String::new(),
1066            direction: BlameDirection::Addition,
1067            provider: BlameProvider::new(None, "magit-blame-mode"),
1068            pending_highlights: None,
1069            _registration: None,
1070        };
1071        assert_wakeable(&s.pending_highlights);
1072    }
1073
1074    /// The mode must stay Manual: an auto-activating blame would make
1075    /// every file read-only on open.
1076    #[test]
1077    fn blame_never_activates_on_its_own() {
1078        assert!(matches!(
1079            MagitBlameMode.activation_policy(),
1080            ActivationPolicy::Manual
1081        ));
1082        assert_eq!(MagitBlameMode.kind(), ModeKind::Minor);
1083    }
1084}
1085
1086/// MG.33: resolving what removed a run of lines, against real git.
1087///
1088/// These drive actual repositories because the question is about
1089/// history topology — `--ancestry-path` behaviour across a merge is
1090/// exactly the thing a hand-built fixture would get wrong in the same
1091/// way the implementation might.
1092#[cfg(test)]
1093mod removal_resolution {
1094    use super::*;
1095    use std::path::Path;
1096    use std::process::Command;
1097
1098    fn git(dir: &Path, args: &[&str]) -> String {
1099        let out = Command::new("git")
1100            .args(args)
1101            .current_dir(dir)
1102            .output()
1103            .expect("git");
1104        assert!(
1105            out.status.success(),
1106            "git {args:?} failed: {}",
1107            String::from_utf8_lossy(&out.stderr)
1108        );
1109        String::from_utf8_lossy(&out.stdout).trim().to_string()
1110    }
1111
1112    fn init(dir: &Path) {
1113        git(dir, &["init", "-b", "main"]);
1114        git(dir, &["config", "user.email", "t@lattice.dev"]);
1115        git(dir, &["config", "user.name", "lattice-test"]);
1116    }
1117
1118    fn commit(dir: &Path, path: &str, body: &str, msg: &str) -> String {
1119        std::fs::write(dir.join(path), body).expect("write");
1120        git(dir, &["add", path]);
1121        git(dir, &["commit", "-m", msg]);
1122        git(dir, &["rev-parse", "HEAD"])
1123    }
1124
1125    /// The case the feature is for: a line present at `base` and gone
1126    /// by HEAD resolves to the commit that took it out — not to `base`,
1127    /// which is what the heading showed before MG.33.
1128    #[test]
1129    fn a_removed_line_resolves_to_the_commit_that_removed_it() {
1130        let dir = tempfile::tempdir().expect("tempdir");
1131        let p = dir.path();
1132        init(p);
1133        let base = commit(p, "a.txt", "keep\ndoomed\n", "base");
1134        let remover = commit(p, "a.txt", "keep\n", "drop the doomed line");
1135
1136        let Removal::By(c) = resolve_removal(p, &base, "a.txt") else {
1137            panic!("a line removed before HEAD must resolve to its remover");
1138        };
1139        assert_eq!(c.sha, remover, "must name the REMOVING commit, not `base`");
1140        assert_ne!(c.sha, base, "naming the last-containing commit is the bug");
1141        assert_eq!(c.summary, "drop the doomed line");
1142        assert_eq!(c.author, "lattice-test");
1143        assert!(c.time > 0, "the heading shows a relative date");
1144    }
1145
1146    /// Lines still in the file at HEAD have no removing commit, and
1147    /// saying so is the point — inventing one would be the worst
1148    /// outcome of the three.
1149    #[test]
1150    fn a_surviving_line_resolves_to_still_present() {
1151        let dir = tempfile::tempdir().expect("tempdir");
1152        let p = dir.path();
1153        init(p);
1154        let base = commit(p, "a.txt", "keep\n", "base");
1155        // A later commit that does NOT touch the blamed file.
1156        commit(p, "b.txt", "other\n", "unrelated");
1157
1158        assert_eq!(resolve_removal(p, &base, "a.txt"), Removal::StillPresent);
1159    }
1160
1161    /// History forks at `base`, both branches touch the file, and both
1162    /// merge into HEAD. Two commits qualify and neither descends from
1163    /// the other, so we decline rather than pick by traversal order.
1164    ///
1165    /// This is the case the "within reason" clause of the UX rule is
1166    /// about: a confidently wrong attribution is worse than the honest
1167    /// "last contained here" it replaces.
1168    #[test]
1169    fn a_fork_that_both_branches_touched_is_ambiguous() {
1170        let dir = tempfile::tempdir().expect("tempdir");
1171        let p = dir.path();
1172        init(p);
1173        let base = commit(p, "a.txt", "one\ntwo\nthree\n", "base");
1174
1175        git(p, &["checkout", "-b", "side"]);
1176        commit(p, "a.txt", "one\ntwo\n", "side drops three");
1177        git(p, &["checkout", "main"]);
1178        commit(p, "a.txt", "two\nthree\n", "main drops one");
1179        // Merge side in, resolving to something that keeps neither.
1180        let out = Command::new("git")
1181            .args(["merge", "side", "-m", "merge"])
1182            .current_dir(p)
1183            .output()
1184            .expect("git");
1185        if !out.status.success() {
1186            std::fs::write(p.join("a.txt"), "two\n").expect("write");
1187            git(p, &["add", "a.txt"]);
1188            git(p, &["commit", "-m", "merge"]);
1189        }
1190
1191        assert_eq!(
1192            resolve_removal(p, &base, "a.txt"),
1193            Removal::Ambiguous,
1194            "two parallel commits touched the file after `base`; naming one \
1195             would be a guess presented as a fact"
1196        );
1197    }
1198
1199    /// Uncommitted lines have no history to walk, so they are skipped
1200    /// rather than handed a bogus range (`0000000..HEAD` is not a rev).
1201    #[test]
1202    fn resolve_removals_skips_uncommitted_chunks_and_dedupes_by_sha() {
1203        let dir = tempfile::tempdir().expect("tempdir");
1204        let p = dir.path();
1205        init(p);
1206        let base = commit(p, "a.txt", "keep\ndoomed\n", "base");
1207        commit(p, "a.txt", "keep\n", "drop it");
1208
1209        let mk = |sha: &str, start: u32| BlameChunk {
1210            sha: sha.into(),
1211            author: String::new(),
1212            time: 0,
1213            summary: String::new(),
1214            start_line: start,
1215            line_count: 1,
1216            removal: None,
1217        };
1218        // Two chunks share `base` — the dedupe path — plus an
1219        // uncommitted one that must be left alone.
1220        let mut chunks = vec![mk(&base, 0), mk(&"0".repeat(40), 1), mk(&base, 2)];
1221        resolve_removals(p, "a.txt", &mut chunks);
1222
1223        assert!(matches!(chunks[0].removal, Some(Removal::By(_))));
1224        assert_eq!(
1225            chunks[0].removal, chunks[2].removal,
1226            "the same sha must resolve to the same answer, from one walk"
1227        );
1228        assert_eq!(
1229            chunks[1].removal, None,
1230            "an uncommitted chunk has no history to walk"
1231        );
1232    }
1233}