1use 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
47pub const MAGIT_BLAME_PROVIDER_ID: ProviderId = 0x6d61_6769_745f_626c; pub 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 keymap_entry! { mode: Normal, chord: "gq", doc: "Stop blaming (the buffer becomes editable again)", cmd: "action:magit-blame-quit" },
83 ]
84 })
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
95pub enum BlameDirection {
96 #[default]
99 Addition,
100 Reverse,
105}
106
107pub(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 argv.push(format!("{rev}..HEAD"));
125 }
126 }
127 argv.push("--".to_string());
128 argv.push(path.to_string());
129 argv
130}
131
132pub struct BlameProvider {
144 chunks: RwLock<Arc<[BlameChunk]>>,
145 version: AtomicU64,
148 theme: Option<ThemeRegistryHandle>,
151 sha_element: Option<ElementId>,
152 label_element: Option<ElementId>,
153 now_secs: AtomicU64,
157}
158
159impl 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 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 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 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
283pub 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
297pub struct BlameState {
300 workdir: std::path::PathBuf,
301 path: String,
303 rev: String,
306 direction: BlameDirection,
307 provider: Arc<BlameProvider>,
308 pending_highlights: Option<lattice_mode::PendingSyntheticHighlightsHandle>,
319 _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#[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 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 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 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 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 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 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 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 return Ok(orphan());
498 };
499
500 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 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 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
550fn 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 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
581async 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 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 if let Some(wake) = wake {
626 wake.wake();
627 }
628}
629
630fn 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 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 None => Removal::Ambiguous,
688 }
689}
690
691fn commit_meta(workdir: &std::path::Path, sha: &str) -> Option<RemovalCommit> {
693 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
709fn 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
737fn 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
763fn 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
798fn 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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[test]
1057 fn the_state_carries_the_handle_the_finished_blame_wakes_with() {
1058 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 #[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#[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 #[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 #[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 commit(p, "b.txt", "other\n", "unrelated");
1157
1158 assert_eq!(resolve_removal(p, &base, "a.txt"), Removal::StillPresent);
1159 }
1160
1161 #[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 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 #[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 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}