1use std::collections::{HashMap, HashSet};
22use std::fmt::Write as _;
23use std::rc::Rc;
24use std::sync::Arc;
25
26use crate::hash::{self, Hash};
27use crate::object::{EntryMode, Identity, Object};
28use crate::store::ObjectStore;
29
30mod move_copy;
31mod walk;
32
33use walk::{WalkCtx, attribute_commit, build_file_dag, topo_order};
34
35pub const BLAME_MAX_LINES: usize = 100_000;
40
41#[derive(Debug, Clone, PartialEq, Eq)]
43pub struct BlameLine {
44 pub line_num: usize,
46 pub orig_line_num: usize,
52 pub commit_hash: Hash,
54 pub author: Identity,
57 pub timestamp: u64,
59 pub boundary: bool,
62 pub source_path: Option<String>,
66 pub text: Vec<u8>,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq)]
72pub struct BlameResult {
73 pub lines: Vec<BlameLine>,
74}
75
76#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
82pub enum MoveDetection {
83 #[default]
85 Off,
86 On {
89 threshold: usize,
91 },
92}
93
94impl MoveDetection {
95 pub const GIT_DEFAULT: Self = Self::On { threshold: 20 };
97}
98
99#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
105pub enum CopyDetection {
106 #[default]
108 Off,
109 On {
112 level: u8,
115 threshold: usize,
117 },
118}
119
120impl CopyDetection {
121 #[must_use]
123 pub const fn git_default(level: u8) -> Self {
124 Self::On {
125 level,
126 threshold: 40,
127 }
128 }
129}
130
131#[derive(Debug, Clone, Default)]
141pub struct BlameOptions {
142 pub ignore_whitespace: bool,
147 pub moves: MoveDetection,
149 pub copies: CopyDetection,
153 pub ignore_revs: Arc<HashSet<Hash>>,
165 pub ignore_rev_precise: bool,
194 pub first_parent: bool,
203}
204
205impl BlameOptions {
206 fn effective_move(&self) -> MoveDetection {
210 match self.moves {
211 MoveDetection::On { .. } => self.moves,
212 MoveDetection::Off if matches!(self.copies, CopyDetection::On { .. }) => {
213 MoveDetection::GIT_DEFAULT
214 }
215 MoveDetection::Off => MoveDetection::Off,
216 }
217 }
218
219 fn detection_enabled(&self) -> bool {
221 matches!(self.effective_move(), MoveDetection::On { .. })
222 || matches!(self.copies, CopyDetection::On { .. })
223 }
224
225 #[must_use]
227 fn is_ignored(&self, commit: &Hash) -> bool {
228 self.ignore_revs.contains(commit)
229 }
230}
231
232#[derive(Clone)]
235struct Attribution {
236 commit_hash: Hash,
237 author: Identity,
238 timestamp: u64,
239 orig_line_num: usize,
243 boundary: bool,
245 source_path: Option<String>,
248}
249
250impl From<BlameLine> for Attribution {
251 fn from(l: BlameLine) -> Self {
252 Self {
253 commit_hash: l.commit_hash,
254 author: l.author,
255 timestamp: l.timestamp,
256 orig_line_num: l.orig_line_num,
257 boundary: l.boundary,
258 source_path: l.source_path,
259 }
260 }
261}
262
263#[derive(Debug, thiserror::Error)]
265pub enum BlameError {
266 #[error("requested object is not a commit")]
267 NotACommit,
268 #[error("requested object is not a blob or chunked-blob")]
269 NotABlob,
270 #[error("file '{0}' was not found at any commit in history")]
271 FileNotFound(String),
272 #[error("reverse blame: '{start}' is not a first-parent ancestor of '{end}'")]
275 ReverseRange { start: String, end: String },
276 #[error("file has too many lines for blame ({lines} > {max})", max = BLAME_MAX_LINES)]
280 FileTooLarge { lines: usize },
281 #[error(transparent)]
282 Object(#[from] crate::object::MkitError),
283 #[error(transparent)]
284 Store(#[from] crate::store::StoreError),
285}
286
287pub type BlameOutcome<T> = Result<T, BlameError>;
289
290pub fn blame_file(
296 store: &ObjectStore,
297 head_hash: Hash,
298 file_path: &str,
299) -> BlameOutcome<BlameResult> {
300 blame_file_with(store, head_hash, file_path, &BlameOptions::default())
301}
302
303pub fn blame_file_with(
332 store: &ObjectStore,
333 head_hash: Hash,
334 file_path: &str,
335 opts: &BlameOptions,
336) -> BlameOutcome<BlameResult> {
337 let Object::Commit(head_commit) = store.read_object(&head_hash)? else {
339 return Err(BlameError::NotACommit);
340 };
341 if find_blob_in_tree(store, head_commit.tree_hash, file_path)?.is_none() {
342 return Err(BlameError::FileNotFound(file_path.to_string()));
343 }
344
345 let (nodes, children) = build_file_dag(store, head_hash, file_path, opts.first_parent)?;
346 let order = topo_order(&nodes, head_hash);
347
348 let ctx = WalkCtx {
349 store,
350 opts,
351 nodes: &nodes,
352 file_path,
353 };
354 let mut detector = move_copy::Detector::new(store, opts);
356 let mut memo: HashMap<Hash, Rc<[Attribution]>> = HashMap::with_capacity(nodes.len());
357 let mut remaining = children;
358
359 for &commit in &order {
360 let attrs = attribute_commit(&ctx, &memo, &mut detector, commit)?;
361 memo.insert(commit, attrs);
362 for &parent in &nodes[&commit].parents {
364 if let Some(left) = remaining.get_mut(&parent) {
365 *left -= 1;
366 if *left == 0 {
367 memo.remove(&parent);
368 }
369 }
370 }
371 }
372
373 let head_attrs = memo
374 .get(&head_hash)
375 .expect("head is processed last and is never a parent, so never freed");
376 let final_lines = load_blob_lines(store, nodes[&head_hash].blob_hash)?;
377 let mut out = Vec::with_capacity(final_lines.len());
378 for (i, text) in final_lines.into_iter().enumerate() {
379 let a = &head_attrs[i];
380 out.push(BlameLine {
381 line_num: i + 1,
382 orig_line_num: a.orig_line_num,
383 commit_hash: a.commit_hash,
384 author: a.author.clone(),
385 timestamp: a.timestamp,
386 boundary: a.boundary,
387 source_path: a.source_path.clone(),
388 text,
389 });
390 }
391 Ok(BlameResult { lines: out })
392}
393
394struct ReverseEntry {
397 commit_hash: Hash,
398 blob: Option<Hash>,
399 author: Identity,
400 timestamp: u64,
401}
402
403impl From<&ReverseEntry> for Attribution {
404 fn from(e: &ReverseEntry) -> Self {
405 Self {
406 commit_hash: e.commit_hash,
407 author: e.author.clone(),
408 timestamp: e.timestamp,
409 orig_line_num: 0,
413 boundary: false,
414 source_path: None,
415 }
416 }
417}
418
419fn collect_reverse_chain(
428 store: &ObjectStore,
429 start_hash: Hash,
430 end_hash: Hash,
431 file_path: &str,
432) -> BlameOutcome<Vec<ReverseEntry>> {
433 let mut chain: Vec<ReverseEntry> = Vec::new();
434 let mut current = Some(end_hash);
435 let mut reached_start = false;
436 while let Some(commit_hash) = current {
437 let Object::Commit(commit) = store.read_object(&commit_hash)? else {
438 return Err(BlameError::NotACommit);
439 };
440 let blob = find_blob_in_tree(store, commit.tree_hash, file_path)?;
441 chain.push(ReverseEntry {
442 commit_hash,
443 blob,
444 author: commit.author.clone(),
445 timestamp: commit.timestamp,
446 });
447 if commit_hash == start_hash {
448 reached_start = true;
449 break;
450 }
451 current = commit.parents.first().copied();
452 }
453 if !reached_start {
454 return Err(BlameError::ReverseRange {
455 start: hash::to_hex(&start_hash),
456 end: hash::to_hex(&end_hash),
457 });
458 }
459 chain.reverse();
461 Ok(chain)
462}
463
464pub fn blame_file_reverse(
497 store: &ObjectStore,
498 start_hash: Hash,
499 end_hash: Hash,
500 file_path: &str,
501 opts: &BlameOptions,
502) -> BlameOutcome<BlameResult> {
503 let chain = collect_reverse_chain(store, start_hash, end_hash, file_path)?;
504
505 let Some(start_blob) = chain[0].blob else {
507 return Err(BlameError::FileNotFound(file_path.to_string()));
508 };
509 let start_lines = load_blob_lines(store, start_blob)?;
510 check_line_count(start_lines.len())?;
511
512 let mut cur_idx: Vec<Option<usize>> = (0..start_lines.len()).map(Some).collect();
516 let mut attributions: Vec<Attribution> = vec![Attribution::from(&chain[0]); start_lines.len()];
517 let mut live = start_lines.len();
520
521 let mut prev_blob = start_blob;
522 let mut prev_lines = start_lines.clone();
523 for entry in &chain[1..] {
524 if live == 0 {
527 break;
528 }
529 let newer_attr = Attribution::from(entry);
530 let Some(blob) = entry.blob else {
531 cur_idx.fill(None);
534 live = 0;
535 continue;
536 };
537 if blob == prev_blob {
538 for (j, c) in cur_idx.iter().enumerate() {
540 if c.is_some() {
541 attributions[j] = newer_attr.clone();
542 }
543 }
544 continue;
545 }
546 let new_lines = load_blob_lines(store, blob)?;
547 let mapping = match_lines_with_options(&prev_lines, &new_lines, opts)?;
550 let mut prev_to_new: Vec<Option<usize>> = vec![None; prev_lines.len()];
551 for (ni, m) in mapping.iter().enumerate() {
552 if let Some(oi) = *m {
553 prev_to_new[oi] = Some(ni);
554 }
555 }
556 for (j, c) in cur_idx.iter_mut().enumerate() {
557 if let Some(p) = *c {
558 if let Some(q) = prev_to_new.get(p).copied().flatten() {
559 *c = Some(q);
561 attributions[j] = newer_attr.clone();
562 } else {
563 *c = None;
566 live -= 1;
567 }
568 }
569 }
570 prev_blob = blob;
571 prev_lines = new_lines;
572 }
573
574 let mut out = Vec::with_capacity(start_lines.len());
575 for (i, text) in start_lines.into_iter().enumerate() {
576 let a = &attributions[i];
577 out.push(BlameLine {
578 line_num: i + 1,
579 orig_line_num: i + 1,
582 commit_hash: a.commit_hash,
583 author: a.author.clone(),
584 timestamp: a.timestamp,
585 boundary: false,
586 source_path: None,
587 text,
588 });
589 }
590 Ok(BlameResult { lines: out })
591}
592
593fn line_key(line: &[u8], ignore_whitespace: bool) -> Vec<u8> {
597 if ignore_whitespace {
598 strip_ws(line)
599 } else {
600 line.to_vec()
601 }
602}
603
604pub fn find_blob_in_tree(
610 store: &ObjectStore,
611 tree_hash: Hash,
612 path: &str,
613) -> BlameOutcome<Option<Hash>> {
614 let components: Vec<&str> = path.split('/').filter(|c| !c.is_empty()).collect();
615 if components.is_empty() {
616 return Ok(None);
617 }
618 let mut current_tree = tree_hash;
619 for (ci, component) in components.iter().enumerate() {
620 let obj = store.read_object(¤t_tree)?;
621 let Object::Tree(tree) = obj else {
622 return Ok(None);
623 };
624 let is_last = ci == components.len() - 1;
625 let mut found_subtree = None;
626 let mut matched = false;
627 for entry in &tree.entries {
628 if entry.name.as_slice() == component.as_bytes() {
629 matched = true;
630 if is_last {
631 return match entry.mode {
632 EntryMode::Blob | EntryMode::Executable => Ok(Some(entry.object_hash)),
633 _ => Ok(None),
634 };
635 }
636 if entry.mode == EntryMode::Tree {
637 found_subtree = Some(entry.object_hash);
638 break;
639 }
640 return Ok(None);
641 }
642 }
643 if !matched {
644 return Ok(None);
645 }
646 if let Some(t) = found_subtree {
647 current_tree = t;
648 }
649 }
650 Ok(None)
651}
652
653fn load_blob_lines(store: &ObjectStore, blob_hash: Hash) -> BlameOutcome<Vec<Vec<u8>>> {
656 let obj = store.read_object(&blob_hash)?;
657 let data: Vec<u8> = match obj {
658 Object::Blob(b) => b.data,
659 Object::ChunkedBlob(cb) => {
660 let mut buf: Vec<u8> = Vec::with_capacity(usize::try_from(cb.total_size).unwrap_or(0));
661 for ch in &cb.chunks {
662 let chunk_obj = store.read_object(ch)?;
663 let Object::Blob(b) = chunk_obj else {
664 return Err(BlameError::NotABlob);
665 };
666 buf.extend_from_slice(&b.data);
667 }
668 cb.check_reassembled_size(buf.len())?;
669 buf
670 }
671 _ => return Err(BlameError::NotABlob),
672 };
673 Ok(split_lines(&data))
674}
675
676fn strip_ws(line: &[u8]) -> Vec<u8> {
681 line.iter()
686 .copied()
687 .filter(|b| !(b.is_ascii_whitespace() || *b == 0x0B))
688 .collect()
689}
690
691pub(super) const TRIVIAL_KEY_MIN_LEN: usize = 3;
696
697pub(super) fn is_trivial_key(key: &[u8]) -> bool {
706 strip_ws(key).len() < TRIVIAL_KEY_MIN_LEN
707}
708
709fn split_lines(data: &[u8]) -> Vec<Vec<u8>> {
710 if data.is_empty() {
711 return Vec::new();
712 }
713 let mut out: Vec<Vec<u8>> = data.split(|b| *b == b'\n').map(<[u8]>::to_vec).collect();
714 if data.last().copied() == Some(b'\n') && !out.is_empty() {
715 out.pop();
716 }
717 out
718}
719
720fn match_lines_with_options(
734 old_lines: &[Vec<u8>],
735 new_lines: &[Vec<u8>],
736 opts: &BlameOptions,
737) -> BlameOutcome<Vec<Option<usize>>> {
738 check_line_count(old_lines.len())?;
741 check_line_count(new_lines.len())?;
742 if opts.ignore_whitespace {
743 let old_keys: Vec<Vec<u8>> = old_lines.iter().map(|l| strip_ws(l)).collect();
746 let new_keys: Vec<Vec<u8>> = new_lines.iter().map(|l| strip_ws(l)).collect();
747 Ok(match_lines(&old_keys, &new_keys))
748 } else {
749 Ok(match_lines(old_lines, new_lines))
750 }
751}
752
753fn ignore_fallthrough(mapping: &[Option<usize>], old_len: usize) -> Vec<Option<usize>> {
767 let n = mapping.len();
768 let mut fall: Vec<Option<usize>> = vec![None; n];
769 let mut next_old = 0usize;
774 let mut ni = 0usize;
775 while ni < n {
776 let Some(anchor_old) = mapping[ni] else {
777 let hunk_new_start = ni;
779 while ni < n && mapping[ni].is_none() {
780 ni += 1;
781 }
782 let hunk_old_end = mapping.get(ni).copied().flatten().unwrap_or(old_len);
787 let mut oi = next_old;
790 let mut nj = hunk_new_start;
791 while nj < ni && oi < hunk_old_end {
792 fall[nj] = Some(oi);
793 nj += 1;
794 oi += 1;
795 }
796 next_old = hunk_old_end;
797 continue;
798 };
799 next_old = anchor_old + 1;
802 ni += 1;
803 }
804 fall
805}
806
807fn check_line_count(lines: usize) -> BlameOutcome<()> {
811 if lines > BLAME_MAX_LINES {
812 return Err(BlameError::FileTooLarge { lines });
813 }
814 Ok(())
815}
816
817#[must_use]
824fn match_lines<T: AsRef<[u8]>>(old_lines: &[T], new_lines: &[T]) -> Vec<Option<usize>> {
825 let m = old_lines.len();
826 let n = new_lines.len();
827 let mut dp = vec![vec![0u32; n + 1]; m + 1];
829 for i in 1..=m {
830 for j in 1..=n {
831 if old_lines[i - 1].as_ref() == new_lines[j - 1].as_ref() {
832 dp[i][j] = dp[i - 1][j - 1] + 1;
833 } else {
834 dp[i][j] = dp[i - 1][j].max(dp[i][j - 1]);
835 }
836 }
837 }
838 let mut mapping: Vec<Option<usize>> = vec![None; n];
839 let mut i = m;
840 let mut j = n;
841 while i > 0 && j > 0 {
851 if dp[i][j] == dp[i][j - 1] {
852 j -= 1;
853 } else if dp[i][j] == dp[i - 1][j] {
854 i -= 1;
855 } else {
856 mapping[j - 1] = Some(i - 1);
857 i -= 1;
858 j -= 1;
859 }
860 }
861 mapping
862}
863
864#[must_use]
872pub fn format_blame_text(result: &BlameResult) -> String {
873 let mut out = String::new();
874 for line in &result.lines {
875 let hex = hash::to_hex(&line.commit_hash);
876 let short = &hex[..12];
877 let _ = write!(out, "{}\t{}\t", short, line.line_num);
878 out.push_str(&String::from_utf8_lossy(&line.text));
879 out.push('\n');
880 }
881 out
882}
883
884#[cfg(test)]
885mod tests {
886 use super::*;
887 use crate::object::{Commit, Identity, Tree, TreeEntry};
888 use crate::serialize;
889 use tempfile::TempDir;
890
891 fn fresh_store() -> (TempDir, ObjectStore) {
892 let dir = TempDir::new().unwrap();
893 let store = ObjectStore::init(&crate::layout::RepoLayout::single(dir.path())).unwrap();
894 (dir, store)
895 }
896
897 fn put_blob(store: &ObjectStore, data: &[u8]) -> Hash {
898 let bytes = serialize::serialize(&Object::Blob(crate::object::Blob {
899 data: data.to_vec(),
900 }))
901 .unwrap();
902 store.write(&bytes).unwrap()
903 }
904
905 fn put_single_file_tree(store: &ObjectStore, name: &str, blob: Hash) -> Hash {
906 let tree = Object::Tree(Tree {
907 entries: vec![TreeEntry {
908 name: name.as_bytes().to_vec(),
909 mode: EntryMode::Blob,
910 object_hash: blob,
911 }],
912 });
913 store.write(&serialize::serialize(&tree).unwrap()).unwrap()
914 }
915
916 fn put_file_commit(
917 store: &ObjectStore,
918 filename: &str,
919 content: &[u8],
920 parents: Vec<Hash>,
921 author_mid: u64,
922 ts: u64,
923 ) -> Hash {
924 let blob = put_blob(store, content);
925 let tree = put_single_file_tree(store, filename, blob);
926 let commit = Object::Commit(Commit::new_unannotated(
927 tree,
928 parents,
929 Identity::opaque(author_mid.to_le_bytes()),
930 [0u8; 32],
931 b"msg".to_vec(),
932 ts,
933 [0u8; 64],
934 ));
935 store
936 .write(&serialize::serialize(&commit).unwrap())
937 .unwrap()
938 }
939
940 fn put_multi_file_commit(
942 store: &ObjectStore,
943 files: &[(&str, &[u8])],
944 parents: Vec<Hash>,
945 author_mid: u64,
946 ts: u64,
947 ) -> Hash {
948 let mut entries: Vec<TreeEntry> = files
949 .iter()
950 .map(|(name, content)| TreeEntry {
951 name: name.as_bytes().to_vec(),
952 mode: EntryMode::Blob,
953 object_hash: put_blob(store, content),
954 })
955 .collect();
956 entries.sort_by(|a, b| a.name.cmp(&b.name));
959 let tree = store
960 .write(&serialize::serialize(&Object::Tree(Tree { entries })).unwrap())
961 .unwrap();
962 let commit = Object::Commit(Commit::new_unannotated(
963 tree,
964 parents,
965 Identity::opaque(author_mid.to_le_bytes()),
966 [0u8; 32],
967 b"msg".to_vec(),
968 ts,
969 [0u8; 64],
970 ));
971 store
972 .write(&serialize::serialize(&commit).unwrap())
973 .unwrap()
974 }
975
976 const LONG_LINE: &[u8] = b"let quick_brown_fox_total = 1;";
979 const BLOCK_A: &[u8] = b"fn handler_alpha() { compute(); }";
982 const BLOCK_B: &[u8] = b"fn handler_bravo() { compute(); }";
983
984 #[test]
989 fn blame_rejects_chunked_total_size_mismatch() {
990 let (_d, store) = fresh_store();
991 let chunk = put_blob(&store, b"one line\n");
992 let cb = Object::ChunkedBlob(crate::object::ChunkedBlob {
993 total_size: 4096,
994 chunk_size: 0,
995 chunks: vec![chunk],
996 });
997 let cb_h = store.write(&serialize::serialize(&cb).unwrap()).unwrap();
998 let tree = put_single_file_tree(&store, "big.bin", cb_h);
999 let commit = Object::Commit(Commit::new_unannotated(
1000 tree,
1001 vec![],
1002 Identity::opaque(1u64.to_le_bytes()),
1003 [0u8; 32],
1004 b"msg".to_vec(),
1005 100,
1006 [0u8; 64],
1007 ));
1008 let head = store
1009 .write(&serialize::serialize(&commit).unwrap())
1010 .unwrap();
1011 let err = blame_file(&store, head, "big.bin").unwrap_err();
1012 assert!(
1013 matches!(
1014 err,
1015 BlameError::Object(crate::object::MkitError::ChunkedBlobSizeMismatch {
1016 expected: 4096,
1017 actual: 9,
1018 })
1019 ),
1020 "expected ChunkedBlobSizeMismatch, got {err:?}"
1021 );
1022 }
1023
1024 #[test]
1025 fn blame_m_attributes_within_file_move_to_origin() {
1026 let (_d, store) = fresh_store();
1030 let v1 = [LONG_LINE, b"B", b"C", b""].join(&b'\n'); let v2 = [b"B" as &[u8], b"C", LONG_LINE, b""].join(&b'\n');
1032 let c_a = put_file_commit(&store, "f.txt", &v1, vec![], 1, 100);
1033 let c_b = put_file_commit(&store, "f.txt", &v2, vec![c_a], 2, 200);
1034
1035 let plain = blame_file(&store, c_b, "f.txt").unwrap();
1036 assert_eq!(plain.lines[2].text, LONG_LINE);
1037 assert_eq!(
1038 plain.lines[2].commit_hash, c_b,
1039 "default: moved line is new"
1040 );
1041
1042 let opts = BlameOptions {
1043 moves: MoveDetection::On { threshold: 20 },
1044 ..Default::default()
1045 };
1046 let m = blame_file_with(&store, c_b, "f.txt", &opts).unwrap();
1047 assert_eq!(m.lines[2].text, LONG_LINE);
1048 assert_eq!(
1049 m.lines[2].commit_hash, c_a,
1050 "-M attributes the moved line to its origin"
1051 );
1052 assert!(
1053 m.lines.iter().all(|l| l.commit_hash == c_a),
1054 "every line predates c_b under -M"
1055 );
1056 }
1057
1058 #[test]
1059 fn blame_m_threshold_boundary_is_inclusive() {
1060 let (_d, store) = fresh_store();
1067 let exact: &[u8] = b"abcdefghijklmnopqrst"; let short: &[u8] = b"abcdefghijklmnopqrs"; let v1 = [exact, b"MID", short, b"B", b"C", b""].join(&b'\n');
1070 let v2 = [b"B" as &[u8], b"C", exact, b"NEWX", short, b""].join(&b'\n');
1071 let c_a = put_file_commit(&store, "f.txt", &v1, vec![], 1, 100);
1072 let c_b = put_file_commit(&store, "f.txt", &v2, vec![c_a], 2, 200);
1073
1074 let opts = BlameOptions {
1075 moves: MoveDetection::On { threshold: 20 },
1076 ..Default::default()
1077 };
1078 let m = blame_file_with(&store, c_b, "f.txt", &opts).unwrap();
1079 assert_eq!(m.lines[2].text, exact);
1081 assert_eq!(
1082 m.lines[2].commit_hash, c_a,
1083 "exactly-threshold (20) move is detected (>= is inclusive)"
1084 );
1085 assert_eq!(m.lines[4].text, short);
1086 assert_eq!(
1087 m.lines[4].commit_hash, c_b,
1088 "one char short of the threshold stays on the editing commit"
1089 );
1090 }
1091
1092 #[test]
1093 fn blame_m_ignores_moves_below_threshold() {
1094 let (_d, store) = fresh_store();
1098 let c_a = put_file_commit(&store, "f.txt", b"a\nB\nC\n", vec![], 1, 100);
1099 let c_b = put_file_commit(&store, "f.txt", b"B\nC\na\n", vec![c_a], 2, 200);
1100 let opts = BlameOptions {
1101 moves: MoveDetection::On { threshold: 20 },
1102 ..Default::default()
1103 };
1104 let m = blame_file_with(&store, c_b, "f.txt", &opts).unwrap();
1105 assert_eq!(m.lines[2].text, b"a");
1106 assert_eq!(
1107 m.lines[2].commit_hash, c_b,
1108 "a sub-threshold move is not detected"
1109 );
1110 }
1111
1112 #[test]
1113 fn blame_m_detects_sub_block_move_adjacent_to_new_line() {
1114 let (_d, store) = fresh_store();
1120 let v1 = [LONG_LINE, BLOCK_A, b"B", b"C", b""].join(&b'\n');
1121 let v2 = [b"B" as &[u8], b"C", b"NEWLINE", LONG_LINE, BLOCK_A, b""].join(&b'\n');
1122 let c_a = put_file_commit(&store, "f.txt", &v1, vec![], 1, 100);
1123 let c_b = put_file_commit(&store, "f.txt", &v2, vec![c_a], 2, 200);
1124
1125 let opts = BlameOptions {
1126 moves: MoveDetection::On { threshold: 20 },
1127 ..Default::default()
1128 };
1129 let m = blame_file_with(&store, c_b, "f.txt", &opts).unwrap();
1130 assert_eq!(m.lines[2].text, b"NEWLINE");
1132 assert_eq!(
1133 m.lines[2].commit_hash, c_b,
1134 "the genuinely-new line stays on c_b"
1135 );
1136 assert_eq!(m.lines[3].text, LONG_LINE);
1137 assert_eq!(
1138 m.lines[3].commit_hash, c_a,
1139 "the moved block reverts to c_a"
1140 );
1141 assert_eq!(
1142 m.lines[4].commit_hash, c_a,
1143 "…including the second moved line"
1144 );
1145 }
1146
1147 #[test]
1148 fn blame_w_c_detects_copy_with_whitespace_change() {
1149 let (_d, store) = fresh_store();
1154 let a1 = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
1155 let c_a = put_multi_file_commit(&store, &[("a.txt", &a1)], vec![], 1, 100);
1156 let reindented = {
1158 let mut v = Vec::new();
1159 v.extend_from_slice(b" ");
1160 v.extend_from_slice(BLOCK_A);
1161 v.push(b'\n');
1162 v.extend_from_slice(b" ");
1163 v.extend_from_slice(BLOCK_B);
1164 v.push(b'\n');
1165 v
1166 };
1167 let c_b = put_multi_file_commit(
1168 &store,
1169 &[("a.txt", b"zzz\n"), ("b.txt", &reindented)],
1170 vec![c_a],
1171 2,
1172 200,
1173 );
1174
1175 let plain_c = BlameOptions {
1177 copies: CopyDetection::On {
1178 level: 1,
1179 threshold: 40,
1180 },
1181 ..Default::default()
1182 };
1183 let r = blame_file_with(&store, c_b, "b.txt", &plain_c).unwrap();
1184 assert!(
1185 r.lines.iter().all(|l| l.commit_hash == c_b),
1186 "without -w a reindented copy is not detected"
1187 );
1188
1189 let w_c = BlameOptions {
1191 ignore_whitespace: true,
1192 copies: CopyDetection::On {
1193 level: 1,
1194 threshold: 40,
1195 },
1196 ..Default::default()
1197 };
1198 let r = blame_file_with(&store, c_b, "b.txt", &w_c).unwrap();
1199 assert!(
1200 r.lines.iter().all(|l| l.commit_hash == c_a),
1201 "-w -C credits a reindented copy to its origin"
1202 );
1203 }
1204
1205 #[test]
1206 fn blame_c_attributes_copy_from_other_file_to_origin() {
1207 let (_d, store) = fresh_store();
1212 let a1 = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
1213 let c_a = put_multi_file_commit(&store, &[("a.txt", &a1)], vec![], 1, 100);
1214 let bfile = [BLOCK_A, BLOCK_B, b""].join(&b'\n');
1215 let c_b = put_multi_file_commit(
1216 &store,
1217 &[("a.txt", b"zzz\n"), ("b.txt", &bfile)],
1218 vec![c_a],
1219 2,
1220 200,
1221 );
1222
1223 let plain = blame_file(&store, c_b, "b.txt").unwrap();
1224 assert_eq!(
1225 plain.lines[0].commit_hash, c_b,
1226 "default: copied block is new"
1227 );
1228
1229 let opts = BlameOptions {
1230 copies: CopyDetection::On {
1231 level: 1,
1232 threshold: 40,
1233 },
1234 ..Default::default()
1235 };
1236 let c = blame_file_with(&store, c_b, "b.txt", &opts).unwrap();
1237 assert_eq!(c.lines[0].text, BLOCK_A);
1238 assert_eq!(c.lines[1].text, BLOCK_B);
1239 assert!(
1240 c.lines.iter().all(|l| l.commit_hash == c_a),
1241 "-C credits the copied block to its origin commit"
1242 );
1243 }
1244
1245 #[test]
1246 fn blame_c_level1_skips_unchanged_files_until_level2() {
1247 let (_d, store) = fresh_store();
1252 let block = [BLOCK_A, BLOCK_B, b""].join(&b'\n');
1253 let c_a = put_multi_file_commit(&store, &[("src.txt", &block)], vec![], 1, 100);
1254 let c_b = put_multi_file_commit(
1255 &store,
1256 &[("src.txt", &block), ("dst.txt", &block)],
1257 vec![c_a],
1258 2,
1259 200,
1260 );
1261
1262 let l1 = BlameOptions {
1263 copies: CopyDetection::On {
1264 level: 1,
1265 threshold: 40,
1266 },
1267 ..Default::default()
1268 };
1269 let r1 = blame_file_with(&store, c_b, "dst.txt", &l1).unwrap();
1270 assert!(
1271 r1.lines.iter().all(|l| l.commit_hash == c_b),
1272 "-C level 1 ignores the unchanged source file"
1273 );
1274
1275 let l2 = BlameOptions {
1276 copies: CopyDetection::On {
1277 level: 2,
1278 threshold: 40,
1279 },
1280 ..Default::default()
1281 };
1282 let r2 = blame_file_with(&store, c_b, "dst.txt", &l2).unwrap();
1283 assert!(
1284 r2.lines.iter().all(|l| l.commit_hash == c_a),
1285 "-C -C searches every parent file and finds the source"
1286 );
1287 }
1288
1289 #[test]
1290 fn blame_w_c_credits_copy_through_prior_whitespace_edit() {
1291 let (_d, store) = fresh_store();
1298 let indented = {
1299 let mut v = Vec::new();
1300 for b in [BLOCK_A, BLOCK_B] {
1301 v.extend_from_slice(b" ");
1302 v.extend_from_slice(b);
1303 v.push(b'\n');
1304 }
1305 v.extend_from_slice(b"zzz\n");
1306 v
1307 };
1308 let d1 = put_multi_file_commit(&store, &[("a.txt", &indented)], vec![], 1, 100);
1309 let dedented = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
1310 let d2 = put_multi_file_commit(&store, &[("a.txt", &dedented)], vec![d1], 2, 200);
1311 let block = [BLOCK_A, BLOCK_B, b""].join(&b'\n');
1312 let d3 = put_multi_file_commit(
1313 &store,
1314 &[("a.txt", b"zzz\n"), ("b.txt", &block)],
1315 vec![d2],
1316 3,
1317 300,
1318 );
1319
1320 let opts = BlameOptions {
1321 ignore_whitespace: true,
1322 copies: CopyDetection::On {
1323 level: 1,
1324 threshold: 40,
1325 },
1326 ..Default::default()
1327 };
1328 let r = blame_file_with(&store, d3, "b.txt", &opts).unwrap();
1329 assert!(
1330 r.lines.iter().all(|l| l.commit_hash == d1),
1331 "the source blame keeps -w → credits the original, not the reformat"
1332 );
1333 }
1334
1335 #[test]
1336 fn blame_c_credits_copy_through_prior_same_file_move() {
1337 let (_d, store) = fresh_store();
1344 let v1 = [BLOCK_A, BLOCK_B, b"X", b"Y", b""].join(&b'\n');
1345 let d1 = put_multi_file_commit(&store, &[("a.txt", &v1)], vec![], 1, 100);
1346 let v2 = [b"X" as &[u8], b"Y", BLOCK_A, BLOCK_B, b""].join(&b'\n');
1347 let d2 = put_multi_file_commit(&store, &[("a.txt", &v2)], vec![d1], 2, 200);
1348 let block = [BLOCK_A, BLOCK_B, b""].join(&b'\n');
1349 let d3 = put_multi_file_commit(
1350 &store,
1351 &[("a.txt", b"X\nY\n"), ("b.txt", &block)],
1352 vec![d2],
1353 3,
1354 300,
1355 );
1356
1357 let opts = BlameOptions {
1358 copies: CopyDetection::On {
1359 level: 1,
1360 threshold: 40,
1361 },
1362 ..Default::default()
1363 };
1364 let r = blame_file_with(&store, d3, "b.txt", &opts).unwrap();
1365 assert!(
1366 r.lines.iter().all(|l| l.commit_hash == d1),
1367 "the source blame keeps implied -M → credits the original, not the move"
1368 );
1369 }
1370
1371 #[test]
1372 fn blame_c_alone_implies_within_file_m() {
1373 let (_d, store) = fresh_store();
1378 let v1 = [LONG_LINE, BLOCK_A, b"B", b"C", b""].join(&b'\n');
1379 let v2 = [b"B" as &[u8], b"C", LONG_LINE, BLOCK_A, b""].join(&b'\n');
1380 let c_a = put_file_commit(&store, "f.txt", &v1, vec![], 1, 100);
1381 let c_b = put_file_commit(&store, "f.txt", &v2, vec![c_a], 2, 200);
1382
1383 let opts = BlameOptions {
1384 copies: CopyDetection::On {
1385 level: 1,
1386 threshold: 40,
1387 },
1388 ..Default::default() };
1390 let m = blame_file_with(&store, c_b, "f.txt", &opts).unwrap();
1391 assert_eq!(m.lines[2].text, LONG_LINE);
1392 assert_eq!(
1393 m.lines[2].commit_hash, c_a,
1394 "-C implies -M, so the within-file move reverts to its origin"
1395 );
1396 }
1397
1398 #[test]
1399 fn blame_m_many_single_line_moves_no_stack_overflow() {
1400 let (_d, store) = fresh_store();
1404 let lines: Vec<String> = (0..400)
1405 .map(|i| format!("let unique_symbol_number_{i:05} = compute({i});"))
1406 .collect();
1407 let mut v1 = lines.join("\n");
1408 v1.push('\n');
1409 let mut rev: Vec<&str> = lines.iter().map(String::as_str).collect();
1410 rev.reverse();
1411 let mut v2 = rev.join("\n");
1412 v2.push('\n');
1413 let c_a = put_file_commit(&store, "f.txt", v1.as_bytes(), vec![], 1, 100);
1414 let c_b = put_file_commit(&store, "f.txt", v2.as_bytes(), vec![c_a], 2, 200);
1415
1416 let opts = BlameOptions {
1417 moves: MoveDetection::On { threshold: 20 },
1418 ..Default::default()
1419 };
1420 let m = blame_file_with(&store, c_b, "f.txt", &opts).unwrap();
1421 assert_eq!(m.lines.len(), 400);
1422 assert!(
1423 m.lines.iter().all(|l| l.commit_hash == c_a),
1424 "every reordered long line is a move → all revert to c_a"
1425 );
1426 }
1427
1428 #[test]
1429 fn blame_m_large_new_block_terminates() {
1430 use std::fmt::Write as _;
1435 let (_d, store) = fresh_store();
1436 let c_a = put_file_commit(&store, "f.txt", b"seed\n", vec![], 1, 100);
1437 let mut v2 = String::from("seed\n");
1438 for i in 0..3000 {
1439 let _ = writeln!(v2, "brand_new_distinct_line_number_{i:06}");
1440 }
1441 let c_b = put_file_commit(&store, "f.txt", v2.as_bytes(), vec![c_a], 2, 200);
1442
1443 let opts = BlameOptions {
1444 moves: MoveDetection::On { threshold: 20 },
1445 ..Default::default()
1446 };
1447 let m = blame_file_with(&store, c_b, "f.txt", &opts).unwrap();
1448 assert_eq!(m.lines.len(), 3001);
1449 assert_eq!(m.lines[0].commit_hash, c_a, "the seed line is unchanged");
1450 assert!(
1451 m.lines[1..].iter().all(|l| l.commit_hash == c_b),
1452 "the large new block stays on the editing commit"
1453 );
1454 }
1455
1456 #[test]
1457 fn blame_single_commit_attributes_all_lines_to_it() {
1458 let (_d, store) = fresh_store();
1459 let c = put_file_commit(&store, "f.txt", b"l1\nl2\nl3\n", vec![], 42, 1000);
1460 let r = blame_file(&store, c, "f.txt").unwrap();
1461 assert_eq!(r.lines.len(), 3);
1462 for (i, line) in r.lines.iter().enumerate() {
1463 assert_eq!(line.line_num, i + 1);
1464 assert_eq!(line.commit_hash, c);
1465 assert_eq!(line.timestamp, 1000);
1466 assert_eq!(line.author.kind, crate::object::IdentityKind::Opaque);
1467 }
1468 assert_eq!(r.lines[0].text, b"l1");
1469 assert_eq!(r.lines[1].text, b"l2");
1470 assert_eq!(r.lines[2].text, b"l3");
1471 }
1472
1473 #[test]
1474 fn strip_ws_removes_all_whitespace() {
1475 assert_eq!(strip_ws(b" foo(a, b)\t"), b"foo(a,b)".to_vec());
1476 assert_eq!(strip_ws(b"abc"), b"abc".to_vec());
1477 assert_eq!(strip_ws(b" \t "), b"".to_vec());
1478 assert_eq!(strip_ws(b"a\x0Bb\x0Cc"), b"abc".to_vec());
1481 }
1482
1483 #[test]
1484 fn blame_w_ignores_whitespace_only_change() {
1485 let (_d, store) = fresh_store();
1486 let c_a = put_file_commit(&store, "f.txt", b"foo(a, b)\nkeep\n", vec![], 1, 100);
1487 let c_b = put_file_commit(&store, "f.txt", b"foo(a,b)\nkeep\n", vec![c_a], 2, 200);
1488
1489 let plain = blame_file(&store, c_b, "f.txt").unwrap();
1491 assert_eq!(plain.lines[0].commit_hash, c_b);
1492
1493 let opts = BlameOptions {
1495 ignore_whitespace: true,
1496 ..Default::default()
1497 };
1498 let w = blame_file_with(&store, c_b, "f.txt", &opts).unwrap();
1499 assert_eq!(
1500 w.lines[0].commit_hash, c_a,
1501 "a whitespace-only change must not steal blame"
1502 );
1503 assert_eq!(
1504 w.lines[0].text, b"foo(a,b)",
1505 "output keeps the current bytes"
1506 );
1507 assert_eq!(w.lines[1].commit_hash, c_a);
1508 }
1509
1510 #[test]
1511 fn blame_w_still_attributes_real_content_change() {
1512 let (_d, store) = fresh_store();
1513 let c_a = put_file_commit(&store, "f.txt", b"a\nb\n", vec![], 1, 100);
1514 let c_b = put_file_commit(&store, "f.txt", b"a\nB CHANGED\n", vec![c_a], 2, 200);
1515 let opts = BlameOptions {
1516 ignore_whitespace: true,
1517 ..Default::default()
1518 };
1519 let w = blame_file_with(&store, c_b, "f.txt", &opts).unwrap();
1520 assert_eq!(w.lines[0].commit_hash, c_a);
1521 assert_eq!(
1522 w.lines[1].commit_hash, c_b,
1523 "a non-whitespace change is still attributed normally under -w"
1524 );
1525 }
1526
1527 #[test]
1528 fn blame_w_keeps_position_for_whitespace_equal_duplicate() {
1529 let (_d, store) = fresh_store();
1536 let c_a = put_file_commit(&store, "f.txt", b"ab\n", vec![], 1, 100);
1537 let c_b = put_file_commit(&store, "f.txt", b"ab\na b\n", vec![c_a], 2, 200);
1538 let opts = BlameOptions {
1539 ignore_whitespace: true,
1540 ..Default::default()
1541 };
1542 let w = blame_file_with(&store, c_b, "f.txt", &opts).unwrap();
1543 assert_eq!(w.lines.len(), 2);
1544 assert_eq!(w.lines[0].commit_hash, c_a, "unchanged line 1 keeps c_a");
1545 assert_eq!(w.lines[1].commit_hash, c_b, "added line 2 is c_b");
1546 assert_eq!(w.lines[1].text, b"a b", "output keeps the current bytes");
1547 }
1548
1549 #[test]
1550 fn blame_w_blank_line_duplicate_is_position_stable() {
1551 let (_d, store) = fresh_store();
1556 let c_a = put_file_commit(&store, "f.txt", b"x\n\ny\n", vec![], 1, 100);
1557 let c_b = put_file_commit(&store, "f.txt", b"x\n\n\ny\n", vec![c_a], 2, 200);
1558 let opts = BlameOptions {
1559 ignore_whitespace: true,
1560 ..Default::default()
1561 };
1562 let w = blame_file_with(&store, c_b, "f.txt", &opts).unwrap();
1563 assert_eq!(w.lines.len(), 4);
1564 assert_eq!(w.lines[0].commit_hash, c_a, "x");
1565 assert_eq!(w.lines[1].commit_hash, c_a, "original blank");
1566 assert_eq!(w.lines[2].commit_hash, c_b, "added blank is new");
1567 assert_eq!(w.lines[3].commit_hash, c_a, "y");
1568 }
1569
1570 #[test]
1571 fn blame_duplicate_line_addition_is_position_stable() {
1572 let (_d, store) = fresh_store();
1576 let c_a = put_file_commit(&store, "f.txt", b"x\n", vec![], 1, 100);
1577 let c_b = put_file_commit(&store, "f.txt", b"x\nx\n", vec![c_a], 2, 200);
1578 let r = blame_file(&store, c_b, "f.txt").unwrap();
1579 assert_eq!(r.lines[0].commit_hash, c_a, "original line keeps c_a");
1580 assert_eq!(r.lines[1].commit_hash, c_b, "appended duplicate is c_b");
1581 }
1582
1583 #[test]
1584 fn blame_two_commits_with_modified_middle() {
1585 let (_d, store) = fresh_store();
1586 let c_a = put_file_commit(&store, "f.txt", b"a\nb\nc\n", vec![], 42, 1000);
1587 let c_b = put_file_commit(&store, "f.txt", b"a\nMOD\nc\n", vec![c_a], 42, 2000);
1588 let r = blame_file(&store, c_b, "f.txt").unwrap();
1589 assert_eq!(r.lines.len(), 3);
1590 assert_eq!(r.lines[0].commit_hash, c_a);
1591 assert_eq!(r.lines[1].commit_hash, c_b);
1592 assert_eq!(r.lines[2].commit_hash, c_a);
1593 assert_eq!(r.lines[1].text, b"MOD");
1594 }
1595
1596 #[test]
1597 fn blame_three_commits_progressive_changes() {
1598 let (_d, store) = fresh_store();
1599 let c_a = put_file_commit(&store, "f.txt", b"a\nb\n", vec![], 1, 100);
1600 let c_b = put_file_commit(&store, "f.txt", b"a\nb\nc\n", vec![c_a], 2, 200);
1601 let c_c = put_file_commit(&store, "f.txt", b"a\nX\nc\n", vec![c_b], 3, 300);
1602 let r = blame_file(&store, c_c, "f.txt").unwrap();
1603 assert_eq!(r.lines.len(), 3);
1604 assert_eq!(r.lines[0].commit_hash, c_a, "a from A");
1605 assert_eq!(r.lines[1].commit_hash, c_c, "X from C");
1606 assert_eq!(r.lines[2].commit_hash, c_b, "c from B");
1607 }
1608
1609 #[test]
1610 fn blame_tracks_additions() {
1611 let (_d, store) = fresh_store();
1612 let c_a = put_file_commit(&store, "f.txt", b"a\nb\n", vec![], 1, 100);
1613 let c_b = put_file_commit(&store, "f.txt", b"a\nNEW\nb\n", vec![c_a], 1, 200);
1614 let r = blame_file(&store, c_b, "f.txt").unwrap();
1615 assert_eq!(r.lines.len(), 3);
1616 assert_eq!(r.lines[0].commit_hash, c_a);
1617 assert_eq!(r.lines[1].commit_hash, c_b);
1618 assert_eq!(r.lines[2].commit_hash, c_a);
1619 }
1620
1621 #[test]
1622 fn blame_tracks_deletions() {
1623 let (_d, store) = fresh_store();
1624 let c_a = put_file_commit(&store, "f.txt", b"a\nb\nc\n", vec![], 1, 100);
1625 let c_b = put_file_commit(&store, "f.txt", b"a\nc\n", vec![c_a], 1, 200);
1626 let r = blame_file(&store, c_b, "f.txt").unwrap();
1627 assert_eq!(r.lines.len(), 2);
1628 assert_eq!(r.lines[0].commit_hash, c_a);
1629 assert_eq!(r.lines[1].commit_hash, c_a);
1630 }
1631
1632 #[test]
1633 fn blame_file_not_found_returns_error() {
1634 let (_d, store) = fresh_store();
1635 let c = put_file_commit(&store, "real.txt", b"x\n", vec![], 1, 100);
1636 let err = blame_file(&store, c, "missing.txt").unwrap_err();
1637 assert!(matches!(err, BlameError::FileNotFound(_)));
1638 }
1639
1640 #[test]
1641 fn lcs_identical_lines() {
1642 let lines: Vec<&[u8]> = vec![b"a", b"b", b"c"];
1643 let m = match_lines(&lines, &lines);
1644 assert_eq!(m, vec![Some(0), Some(1), Some(2)]);
1645 }
1646
1647 #[test]
1648 fn lcs_completely_different() {
1649 let old: Vec<&[u8]> = vec![b"a", b"b", b"c"];
1650 let new: Vec<&[u8]> = vec![b"x", b"y", b"z"];
1651 let m = match_lines(&old, &new);
1652 assert_eq!(m, vec![None, None, None]);
1653 }
1654
1655 #[test]
1656 fn lcs_duplicate_key_matches_earliest_new_line() {
1657 let old: Vec<&[u8]> = vec![b"ab"];
1662 let new: Vec<&[u8]> = vec![b"ab", b"ab"];
1663 assert_eq!(match_lines(&old, &new), vec![Some(0), None]);
1664 }
1665
1666 #[test]
1667 fn split_lines_handles_trailing_newline() {
1668 assert_eq!(
1669 split_lines(b"a\nb\nc\n"),
1670 vec![b"a".to_vec(), b"b".to_vec(), b"c".to_vec()]
1671 );
1672 }
1673
1674 #[test]
1675 fn split_lines_handles_no_trailing_newline() {
1676 assert_eq!(
1677 split_lines(b"a\nb\nc"),
1678 vec![b"a".to_vec(), b"b".to_vec(), b"c".to_vec()]
1679 );
1680 }
1681
1682 #[test]
1683 fn split_lines_empty() {
1684 assert!(split_lines(b"").is_empty());
1685 }
1686
1687 #[test]
1688 fn find_blob_in_nested_tree() {
1689 let (_d, store) = fresh_store();
1690 let blob = put_blob(&store, b"hello\n");
1691 let inner = Object::Tree(Tree {
1692 entries: vec![TreeEntry {
1693 name: b"main.txt".to_vec(),
1694 mode: EntryMode::Blob,
1695 object_hash: blob,
1696 }],
1697 });
1698 let inner_h = store.write(&serialize::serialize(&inner).unwrap()).unwrap();
1699 let outer = Object::Tree(Tree {
1700 entries: vec![TreeEntry {
1701 name: b"src".to_vec(),
1702 mode: EntryMode::Tree,
1703 object_hash: inner_h,
1704 }],
1705 });
1706 let outer_h = store.write(&serialize::serialize(&outer).unwrap()).unwrap();
1707 let found = find_blob_in_tree(&store, outer_h, "src/main.txt").unwrap();
1708 assert_eq!(found, Some(blob));
1709 let missing = find_blob_in_tree(&store, outer_h, "src/none.txt").unwrap();
1710 assert_eq!(missing, None);
1711 }
1712
1713 fn ignoring_precise(revs: &[Hash]) -> BlameOptions {
1717 BlameOptions {
1718 ignore_revs: Arc::new(revs.iter().copied().collect()),
1719 ignore_rev_precise: true,
1720 ignore_whitespace: true,
1721 ..Default::default()
1722 }
1723 }
1724
1725 fn ignoring(revs: &[Hash]) -> BlameOptions {
1727 BlameOptions {
1728 ignore_revs: Arc::new(revs.iter().copied().collect()),
1729 ..Default::default()
1730 }
1731 }
1732
1733 #[test]
1734 fn blame_ignore_rev_falls_through_reformat() {
1735 let (_d, store) = fresh_store();
1740 let c_a = put_file_commit(&store, "f.txt", b"alpha\nbeta\ngamma\n", vec![], 1, 100);
1741 let c_b = put_file_commit(
1742 &store,
1743 "f.txt",
1744 b"alpha\n beta \ngamma\n",
1745 vec![c_a],
1746 2,
1747 200,
1748 );
1749
1750 let plain = blame_file(&store, c_b, "f.txt").unwrap();
1752 assert_eq!(plain.lines[1].commit_hash, c_b);
1753
1754 let r = blame_file_with(&store, c_b, "f.txt", &ignoring(&[c_b])).unwrap();
1755 assert_eq!(
1756 r.lines[1].commit_hash, c_a,
1757 "ignored reformat falls through to the original commit"
1758 );
1759 assert_eq!(r.lines[1].text, b" beta ", "output keeps current bytes");
1760 assert!(
1761 r.lines.iter().all(|l| l.commit_hash == c_a),
1762 "no line is credited to the ignored commit"
1763 );
1764 }
1765
1766 #[test]
1767 fn blame_ignore_rev_keeps_genuine_insertion() {
1768 let (_d, store) = fresh_store();
1773 let c_a = put_file_commit(&store, "f.txt", b"alpha\nbeta\n", vec![], 1, 100);
1774 let c_b = put_file_commit(
1775 &store,
1776 "f.txt",
1777 b"alpha\nbeta\nBRANDNEW\ngamma\n",
1778 vec![c_a],
1779 2,
1780 200,
1781 );
1782
1783 let r = blame_file_with(&store, c_b, "f.txt", &ignoring(&[c_b])).unwrap();
1784 assert_eq!(r.lines[0].commit_hash, c_a, "alpha");
1785 assert_eq!(r.lines[1].commit_hash, c_a, "beta");
1786 assert_eq!(
1787 r.lines[2].commit_hash, c_b,
1788 "a genuine insertion stays on the ignored commit"
1789 );
1790 assert_eq!(r.lines[3].commit_hash, c_b, "…and the second insertion");
1791 }
1792
1793 #[test]
1794 fn blame_ignore_rev_pairs_changed_lines_to_distinct_origins() {
1795 let (_d, store) = fresh_store();
1800 let c1 = put_file_commit(&store, "f.txt", b"L1\nL2\nL3\nL4\n", vec![], 1, 100);
1801 let c2 = put_file_commit(&store, "f.txt", b"L1\nL2x\nL3\nL4\n", vec![c1], 2, 200);
1802 let c3 = put_file_commit(&store, "f.txt", b"L1\nL2y\nL3y\nL4\n", vec![c2], 3, 300);
1803
1804 let r = blame_file_with(&store, c3, "f.txt", &ignoring(&[c3])).unwrap();
1805 assert_eq!(r.lines[1].commit_hash, c2, "L2y inherits L2x's origin (c2)");
1806 assert_eq!(r.lines[2].commit_hash, c1, "L3y inherits L3's origin (c1)");
1807 assert!(r.lines.iter().all(|l| l.commit_hash != c3));
1808 }
1809
1810 #[test]
1811 fn blame_ignore_rev_unequal_hunk_more_added() {
1812 let (_d, store) = fresh_store();
1816 let c1 = put_file_commit(&store, "f.txt", b"L1\nMID\nL3\n", vec![], 1, 100);
1817 let c2 = put_file_commit(&store, "f.txt", b"L1\nMIDa\nMIDb\nL3\n", vec![c1], 2, 200);
1818
1819 let r = blame_file_with(&store, c2, "f.txt", &ignoring(&[c2])).unwrap();
1820 assert_eq!(r.lines[1].commit_hash, c1, "MIDa falls through to c1");
1821 assert_eq!(r.lines[2].commit_hash, c2, "MIDb has no pair → stays on c2");
1822 }
1823
1824 #[test]
1825 fn blame_ignore_rev_unequal_hunk_more_removed() {
1826 let (_d, store) = fresh_store();
1829 let c1 = put_file_commit(&store, "f.txt", b"L1\nM1\nM2\nL3\n", vec![], 1, 100);
1830 let c2 = put_file_commit(&store, "f.txt", b"L1\nMERGED\nL3\n", vec![c1], 2, 200);
1831
1832 let r = blame_file_with(&store, c2, "f.txt", &ignoring(&[c2])).unwrap();
1833 assert_eq!(r.lines[1].commit_hash, c1, "MERGED falls through to c1");
1834 }
1835
1836 #[test]
1837 fn blame_ignore_root_commit_keeps_its_lines() {
1838 let (_d, store) = fresh_store();
1842 let root = put_file_commit(&store, "f.txt", b"a\nb\n", vec![], 1, 100);
1843 let c2 = put_file_commit(&store, "f.txt", b"a\nb\nc\n", vec![root], 2, 200);
1844
1845 let r = blame_file_with(&store, c2, "f.txt", &ignoring(&[root])).unwrap();
1846 assert_eq!(r.lines[0].commit_hash, root, "a stays on the ignored root");
1847 assert_eq!(r.lines[1].commit_hash, root, "b stays on the ignored root");
1848 assert_eq!(r.lines[2].commit_hash, c2, "c is unaffected");
1849 }
1850
1851 #[test]
1852 fn blame_ignore_multiple_revs_chains_through() {
1853 let (_d, store) = fresh_store();
1856 let c1 = put_file_commit(&store, "f.txt", b"keep\nx\n", vec![], 1, 100);
1857 let c2 = put_file_commit(&store, "f.txt", b"keep\n x \n", vec![c1], 2, 200);
1858 let c3 = put_file_commit(&store, "f.txt", b"keep\n x \n", vec![c2], 3, 300);
1859
1860 let r = blame_file_with(&store, c3, "f.txt", &ignoring(&[c2, c3])).unwrap();
1861 assert_eq!(
1862 r.lines[1].commit_hash, c1,
1863 "line falls through both ignored reformats to c1"
1864 );
1865 }
1866
1867 #[test]
1868 fn blame_ignore_rev_fallthrough_not_overwritten_by_move_detection() {
1869 let (_d, store) = fresh_store();
1878 let dup: &[u8] = b"dupaaaaaaaaaaaaaaaaa"; let mid: &[u8] = b"MIDLINE";
1880 let x_v0: &[u8] = b"exoldbbbbbbbbbbbbbbb";
1881 let x_v1: &[u8] = b"exnewbbbbbbbbbbbbbbb";
1882 let c0 = put_file_commit(
1883 &store,
1884 "f.txt",
1885 &[dup, mid, x_v0, b""].join(&b'\n'),
1886 vec![],
1887 1,
1888 100,
1889 );
1890 let c1 = put_file_commit(
1892 &store,
1893 "f.txt",
1894 &[dup, mid, x_v1, b""].join(&b'\n'),
1895 vec![c0],
1896 2,
1897 200,
1898 );
1899 let c2 = put_file_commit(
1901 &store,
1902 "f.txt",
1903 &[dup, mid, dup, b""].join(&b'\n'),
1904 vec![c1],
1905 3,
1906 300,
1907 );
1908
1909 let opts = BlameOptions {
1910 moves: MoveDetection::On { threshold: 20 },
1911 ignore_revs: Arc::new([c2].into_iter().collect()),
1912 ..Default::default()
1913 };
1914 let r = blame_file_with(&store, c2, "f.txt", &opts).unwrap();
1915 assert_eq!(
1916 r.lines[2].commit_hash, c1,
1917 "fallthrough (replaced parent line → c1) wins; -M does not overwrite it to c0"
1918 );
1919 let plain = blame_file_with(&store, c2, "f.txt", &ignoring(&[c2])).unwrap();
1921 assert_eq!(
1922 plain.lines[2].commit_hash, c1,
1923 "-M did not change the result"
1924 );
1925 }
1926
1927 #[test]
1930 fn blame_ignore_rev_precise_reattributes_moved_reindented_lines() {
1931 let (_d, store) = fresh_store();
1945 let c0 = put_file_commit(&store, "f.txt", b"keep\ntail\n", vec![], 1, 100);
1946 let c1 = put_file_commit(&store, "f.txt", b"keep\nXXX\ntail\n", vec![c0], 2, 200);
1947 let c2 = put_file_commit(&store, "f.txt", b"keep\nXXX\nYYY\ntail\n", vec![c1], 3, 300);
1948 let c3 = put_file_commit(
1949 &store,
1950 "f.txt",
1951 b"keep\nXXX\nYYY\nZZZ\ntail\n",
1952 vec![c2],
1953 4,
1954 400,
1955 );
1956 let c4 = put_file_commit(
1957 &store,
1958 "f.txt",
1959 b"keep\n ZZZ\n YYY\n XXX\ntail\n",
1960 vec![c3],
1961 5,
1962 500,
1963 );
1964
1965 let positional_opts = BlameOptions {
1970 ignore_whitespace: true,
1971 ignore_revs: Arc::new([c4].into_iter().collect()),
1972 ..Default::default()
1973 };
1974 let positional = blame_file_with(&store, c4, "f.txt", &positional_opts).unwrap();
1975 assert_eq!(
1976 positional.lines[1].commit_hash, c3,
1977 "ZZZ is recognized unchanged by the LCS matcher itself (needs no fall-through)"
1978 );
1979 assert_eq!(
1980 positional.lines[2].commit_hash, c4,
1981 "positional: YYY has no in-hunk counterpart, stays on the ignored commit"
1982 );
1983 assert_eq!(
1984 positional.lines[3].commit_hash, c4,
1985 "positional: XXX has no in-hunk counterpart, stays on the ignored commit"
1986 );
1987
1988 let precise = blame_file_with(&store, c4, "f.txt", &ignoring_precise(&[c4])).unwrap();
1992 assert_eq!(
1993 precise.lines[1].commit_hash, c3,
1994 "ZZZ is unaffected by precise mode (already resolved by plain LCS)"
1995 );
1996 assert_eq!(
1997 precise.lines[2].commit_hash, c2,
1998 "precise: YYY correctly attributed to its true origin"
1999 );
2000 assert_eq!(
2001 precise.lines[3].commit_hash, c1,
2002 "precise: XXX correctly attributed to its true origin"
2003 );
2004 assert_eq!(precise.lines[0].commit_hash, c0, "keep is unaffected");
2005 assert_eq!(precise.lines[4].commit_hash, c0, "tail is unaffected");
2006 }
2007
2008 #[test]
2009 fn blame_ignore_rev_precise_unequal_hunk_surplus_stays_put() {
2010 let (_d, store) = fresh_store();
2016 let c1 = put_file_commit(&store, "f.txt", b"L1\nMID\nL3\n", vec![], 1, 100);
2017 let c2 = put_file_commit(
2018 &store,
2019 "f.txt",
2020 b"L1\nMIDaaa\nMIDbbb\nMIDccc\nL3\n",
2021 vec![c1],
2022 2,
2023 200,
2024 );
2025
2026 let plain = blame_file_with(&store, c2, "f.txt", &ignoring(&[c2])).unwrap();
2027 let precise = blame_file_with(&store, c2, "f.txt", &ignoring_precise(&[c2])).unwrap();
2028 for r in [&plain, &precise] {
2029 assert_eq!(r.lines[1].commit_hash, c1, "MIDaaa falls through to c1");
2030 assert_eq!(
2031 r.lines[2].commit_hash, c2,
2032 "MIDbbb has no pair -> stays on c2"
2033 );
2034 assert_eq!(
2035 r.lines[3].commit_hash, c2,
2036 "MIDccc has no pair -> stays on c2"
2037 );
2038 }
2039 }
2040
2041 #[test]
2042 fn blame_ignore_rev_precise_no_match_falls_back_to_positional() {
2043 let (_d, store) = fresh_store();
2047 let c1 = put_file_commit(&store, "f.txt", b"L1\nA\nL3\n", vec![], 1, 100);
2048 let c2 = put_file_commit(&store, "f.txt", b"L1\nFABRICATED\nL3\n", vec![c1], 2, 200);
2049
2050 let plain = blame_file_with(&store, c2, "f.txt", &ignoring(&[c2])).unwrap();
2051 let precise = blame_file_with(&store, c2, "f.txt", &ignoring_precise(&[c2])).unwrap();
2052 assert_eq!(plain.lines[1].commit_hash, c1);
2053 assert_eq!(
2054 precise.lines[1].commit_hash, plain.lines[1].commit_hash,
2055 "no content candidate exists anywhere in the parent: precise matches positional"
2056 );
2057 }
2058
2059 #[test]
2060 fn precise_overrides_trivial_key_guard() {
2061 let mapping = vec![None, None];
2074 let fall = vec![Some(2), None];
2075 let new_lines = vec![b"ab".to_vec(), b"REALZZZ".to_vec()];
2076 let parent_lines = vec![b"REALZZZ".to_vec(), b"ab".to_vec(), b"FILLER".to_vec()];
2077 let matched = vec![false, false];
2078
2079 let out = super::walk::precise_overrides(&super::walk::PreciseRequest {
2080 mapping: &mapping,
2081 fall: &fall,
2082 new_lines: &new_lines,
2083 parent_lines: &parent_lines,
2084 matched: &matched,
2085 ignore_whitespace: false,
2086 });
2087 assert_eq!(
2088 out[0],
2089 Some(2),
2090 "trivial key 'ab' keeps its positional guess even though a perfect \
2091 unclaimed match ('ab' at old index 1) exists"
2092 );
2093 assert_eq!(
2094 out[1],
2095 Some(0),
2096 "non-trivial key 'REALZZZ' fills its None positional slot from its \
2097 true content match (old index 0)"
2098 );
2099 }
2100
2101 #[test]
2102 fn precise_overrides_never_teleports_edited_line_worse_than_positional() {
2103 let mapping = vec![Some(0), None, Some(2), Some(3), Some(5)];
2123 let fall = vec![None, Some(1), None, None, None];
2124 let new_lines = vec![
2125 b"A".to_vec(),
2126 b"bar".to_vec(),
2127 b"B1".to_vec(),
2128 b"B2".to_vec(),
2129 b"C".to_vec(),
2130 ];
2131 let parent_lines = vec![
2132 b"A".to_vec(),
2133 b"foo".to_vec(),
2134 b"B1".to_vec(),
2135 b"B2".to_vec(),
2136 b"bar".to_vec(),
2137 b"C".to_vec(),
2138 ];
2139 let matched = vec![false; new_lines.len()];
2140
2141 let out = super::walk::precise_overrides(&super::walk::PreciseRequest {
2142 mapping: &mapping,
2143 fall: &fall,
2144 new_lines: &new_lines,
2145 parent_lines: &parent_lines,
2146 matched: &matched,
2147 ignore_whitespace: false,
2148 });
2149 assert_eq!(
2150 out[1],
2151 Some(1),
2152 "edited `bar` keeps its positional predecessor (foo @ old idx 1)"
2153 );
2154 assert_ne!(
2155 out[1],
2156 Some(4),
2157 "must NOT teleport to the unrelated `bar` @ old idx 4 (commit X)"
2158 );
2159 assert_eq!(
2160 out,
2161 vec![None, Some(1), None, None, None],
2162 "no slot is attributed worse than the positional fall-through"
2163 );
2164 }
2165
2166 #[test]
2167 fn precise_overrides_reindented_brace_does_not_teleport_without_w() {
2168 let mapping = vec![None];
2180 let fall = vec![None];
2181 let new_lines = vec![b" }".to_vec()];
2182 let parent_lines = vec![b" }".to_vec()]; let matched = vec![false];
2184
2185 let out = super::walk::precise_overrides(&super::walk::PreciseRequest {
2186 mapping: &mapping,
2187 fall: &fall,
2188 new_lines: &new_lines,
2189 parent_lines: &parent_lines,
2190 matched: &matched,
2191 ignore_whitespace: false,
2192 });
2193 assert_eq!(
2194 out[0], None,
2195 "an indented brace is trivial once whitespace-stripped; it must not \
2196 teleport to an unrelated brace even without -w"
2197 );
2198 }
2199
2200 #[test]
2201 fn ignore_fallthrough_pairs_per_hunk() {
2202 let mapping = vec![Some(0), None, None, Some(3)];
2207 assert_eq!(
2208 super::ignore_fallthrough(&mapping, 4),
2209 vec![None, Some(1), Some(2), None]
2210 );
2211 let mapping = vec![Some(0), None, None, Some(2)];
2214 assert_eq!(
2215 super::ignore_fallthrough(&mapping, 3),
2216 vec![None, Some(1), None, None]
2217 );
2218 let mapping = vec![Some(0), Some(1), None, None];
2220 assert_eq!(
2221 super::ignore_fallthrough(&mapping, 2),
2222 vec![None, None, None, None]
2223 );
2224 }
2225
2226 #[test]
2227 fn reverse_attributes_each_line_to_last_commit_it_survived() {
2228 let (_d, store) = fresh_store();
2232 let c1 = put_file_commit(&store, "f.txt", b"keep\ndoomed\nalso\n", vec![], 1, 100);
2233 let c2 = put_file_commit(
2234 &store,
2235 "f.txt",
2236 b"keep\ndoomed\nalso\nextra\n",
2237 vec![c1],
2238 2,
2239 200,
2240 );
2241 let c3 = put_file_commit(&store, "f.txt", b"keep\nalso\nextra\n", vec![c2], 3, 300);
2242 let c4 = put_file_commit(&store, "f.txt", b"keep\nalso\nextra2\n", vec![c3], 4, 400);
2243
2244 let r = blame_file_reverse(&store, c1, c4, "f.txt", &BlameOptions::default()).unwrap();
2245 assert_eq!(r.lines.len(), 3, "blames the start (c1) version's 3 lines");
2246 assert_eq!(r.lines[0].text, b"keep");
2247 assert_eq!(r.lines[0].commit_hash, c4, "keep survives to the end");
2248 assert_eq!(r.lines[1].text, b"doomed");
2249 assert_eq!(r.lines[1].commit_hash, c2, "doomed last existed in c2");
2250 assert_eq!(r.lines[2].text, b"also");
2251 assert_eq!(r.lines[2].commit_hash, c4, "also survives to the end");
2252 }
2253
2254 #[test]
2255 fn reverse_line_removed_immediately_stays_on_start() {
2256 let (_d, store) = fresh_store();
2260 let c1 = put_file_commit(&store, "f.txt", b"keep\ngone\n", vec![], 1, 100);
2261 let c2 = put_file_commit(&store, "f.txt", b"keep\n", vec![c1], 2, 200);
2262 let c3 = put_file_commit(&store, "f.txt", b"keep\nnew\n", vec![c2], 3, 300);
2263
2264 let r = blame_file_reverse(&store, c1, c3, "f.txt", &BlameOptions::default()).unwrap();
2265 assert_eq!(r.lines[0].commit_hash, c3, "keep survives to the end");
2266 assert_eq!(
2267 r.lines[1].commit_hash, c1,
2268 "gone never survived a step → stays on start"
2269 );
2270 assert_eq!(r.lines[1].text, b"gone");
2271 }
2272
2273 #[test]
2274 fn reverse_modified_line_freezes_before_the_edit() {
2275 let (_d, store) = fresh_store();
2278 let c1 = put_file_commit(&store, "f.txt", b"a\nMOD\nc\n", vec![], 1, 100);
2279 let c2 = put_file_commit(&store, "f.txt", b"a\nMOD2\nc\n", vec![c1], 2, 200);
2280 let c3 = put_file_commit(&store, "f.txt", b"a\nMOD3\nc\n", vec![c2], 3, 300);
2281
2282 let r = blame_file_reverse(&store, c1, c3, "f.txt", &BlameOptions::default()).unwrap();
2283 assert_eq!(r.lines[0].commit_hash, c3, "a survives");
2284 assert_eq!(r.lines[1].commit_hash, c1, "MOD changed in c2 → last in c1");
2285 assert_eq!(r.lines[2].commit_hash, c3, "c survives");
2286 }
2287
2288 #[test]
2289 fn reverse_unchanged_commit_advances_attribution() {
2290 let (_d, store) = fresh_store();
2293 let c1 = put_file_commit(&store, "f.txt", b"a\n", vec![], 1, 100);
2294 let c2 = put_file_commit(&store, "f.txt", b"a\n", vec![c1], 2, 200); let c3 = put_file_commit(&store, "f.txt", b"b\n", vec![c2], 3, 300); let r = blame_file_reverse(&store, c1, c3, "f.txt", &BlameOptions::default()).unwrap();
2298 assert_eq!(
2299 r.lines[0].commit_hash, c2,
2300 "a last existed in the unchanged c2, gone by c3"
2301 );
2302 }
2303
2304 #[test]
2305 fn reverse_open_end_walks_to_provided_end() {
2306 let (_d, store) = fresh_store();
2309 let c1 = put_file_commit(&store, "f.txt", b"keep\ndoomed\n", vec![], 1, 100);
2310 let c2 = put_file_commit(&store, "f.txt", b"keep\ndoomed\nx\n", vec![c1], 2, 200);
2311 let _c3 = put_file_commit(&store, "f.txt", b"keep\nx\n", vec![c2], 3, 300);
2312
2313 let r = blame_file_reverse(&store, c1, c2, "f.txt", &BlameOptions::default()).unwrap();
2314 assert!(
2315 r.lines.iter().all(|l| l.commit_hash == c2),
2316 "with the range ending at c2 both start lines last exist in c2"
2317 );
2318 }
2319
2320 #[test]
2321 fn reverse_traces_through_whitespace_edit_under_w() {
2322 let (_d, store) = fresh_store();
2325 let c1 = put_file_commit(&store, "f.txt", b"foo(a, b)\n", vec![], 1, 100);
2326 let c2 = put_file_commit(&store, "f.txt", b"foo(a,b)\n", vec![c1], 2, 200); let c3 = put_file_commit(&store, "f.txt", b"changed\n", vec![c2], 3, 300);
2328
2329 let plain = blame_file_reverse(&store, c1, c3, "f.txt", &BlameOptions::default()).unwrap();
2331 assert_eq!(
2332 plain.lines[0].commit_hash, c1,
2333 "ws edit ends the line at c1"
2334 );
2335
2336 let w = BlameOptions {
2337 ignore_whitespace: true,
2338 ..Default::default()
2339 };
2340 let rw = blame_file_reverse(&store, c1, c3, "f.txt", &w).unwrap();
2341 assert_eq!(
2342 rw.lines[0].commit_hash, c2,
2343 "-w traces the line through the reformat → last exists in c2"
2344 );
2345 }
2346
2347 #[test]
2348 fn reverse_start_not_ancestor_errors() {
2349 let (_d, store) = fresh_store();
2350 let c1 = put_file_commit(&store, "f.txt", b"a\n", vec![], 1, 100);
2351 let other = put_file_commit(&store, "f.txt", b"z\n", vec![], 9, 900);
2353 let err =
2354 blame_file_reverse(&store, other, c1, "f.txt", &BlameOptions::default()).unwrap_err();
2355 assert!(
2356 matches!(err, BlameError::ReverseRange { .. }),
2357 "got {err:?}"
2358 );
2359 }
2360
2361 #[test]
2362 fn reverse_missing_path_in_start_errors() {
2363 let (_d, store) = fresh_store();
2364 let c1 = put_file_commit(&store, "other.txt", b"x\n", vec![], 1, 100);
2365 let c2 = put_file_commit(&store, "f.txt", b"y\n", vec![c1], 2, 200);
2366 let err =
2367 blame_file_reverse(&store, c1, c2, "f.txt", &BlameOptions::default()).unwrap_err();
2368 assert!(matches!(err, BlameError::FileNotFound(_)), "got {err:?}");
2369 }
2370
2371 fn diamond_distinct(store: &ObjectStore) -> (Hash, Hash, Hash, Hash) {
2378 let base = put_file_commit(store, "f.txt", b"base1\nbase2\n", vec![], 1, 100);
2379 let feat = put_file_commit(
2380 store,
2381 "f.txt",
2382 b"base1\nbase2\nfeature\n",
2383 vec![base],
2384 2,
2385 200,
2386 );
2387 let main = put_file_commit(store, "f.txt", b"main\nbase1\nbase2\n", vec![base], 3, 300);
2388 let merge = put_file_commit(
2389 store,
2390 "f.txt",
2391 b"main\nbase1\nbase2\nfeature\n",
2392 vec![main, feat],
2393 4,
2394 400,
2395 );
2396 (base, main, feat, merge)
2397 }
2398
2399 #[test]
2400 fn blame_merge_aware_credits_side_branch_lines() {
2401 let (_d, store) = fresh_store();
2404 let (base, main, feat, merge) = diamond_distinct(&store);
2405 let r = blame_file(&store, merge, "f.txt").unwrap();
2406 assert_eq!(r.lines[0].commit_hash, main, "main-line → main");
2407 assert_eq!(r.lines[1].commit_hash, base, "base1 → base");
2408 assert_eq!(r.lines[2].commit_hash, base, "base2 → base");
2409 assert_eq!(r.lines[3].commit_hash, feat, "feature → feature commit");
2410 assert!(
2411 r.lines.iter().all(|l| l.commit_hash != merge),
2412 "none → merge"
2413 );
2414 }
2415
2416 #[test]
2417 fn blame_first_parent_credits_merge_for_side_branch_line() {
2418 let (_d, store) = fresh_store();
2421 let (base, main, _feat, merge) = diamond_distinct(&store);
2422 let opts = BlameOptions {
2423 first_parent: true,
2424 ..Default::default()
2425 };
2426 let r = blame_file_with(&store, merge, "f.txt", &opts).unwrap();
2427 assert_eq!(r.lines[0].commit_hash, main, "main-line → main");
2428 assert_eq!(r.lines[1].commit_hash, base);
2429 assert_eq!(r.lines[3].commit_hash, merge, "feature line → merge");
2430 }
2431
2432 #[test]
2433 fn blame_merge_identical_line_goes_to_first_parent() {
2434 let (_d, store) = fresh_store();
2436 let base = put_file_commit(&store, "f.txt", b"base\n", vec![], 1, 100);
2437 let feat = put_file_commit(&store, "f.txt", b"base\nshared\n", vec![base], 2, 200);
2438 let main = put_file_commit(&store, "f.txt", b"base\nshared\n", vec![base], 3, 300);
2439 let merge = put_file_commit(&store, "f.txt", b"base\nshared\n", vec![main, feat], 4, 400);
2440 let r = blame_file(&store, merge, "f.txt").unwrap();
2441 assert_eq!(r.lines[1].commit_hash, main, "shared line → first parent");
2442 assert!(r.lines.iter().all(|l| l.commit_hash != feat));
2443 }
2444
2445 #[test]
2446 fn blame_evil_merge_attributes_new_line_to_merge() {
2447 let (_d, store) = fresh_store();
2450 let base = put_file_commit(&store, "f.txt", b"base\n", vec![], 1, 100);
2451 let feat = put_file_commit(&store, "f.txt", b"base\nfeat\n", vec![base], 2, 200);
2452 let main = put_file_commit(&store, "f.txt", b"main\nbase\n", vec![base], 3, 300);
2453 let merge = put_file_commit(
2454 &store,
2455 "f.txt",
2456 b"main\nbase\nfeat\nEVIL\n",
2457 vec![main, feat],
2458 4,
2459 400,
2460 );
2461 let r = blame_file(&store, merge, "f.txt").unwrap();
2462 assert_eq!(r.lines[0].commit_hash, main);
2463 assert_eq!(r.lines[1].commit_hash, base);
2464 assert_eq!(r.lines[2].commit_hash, feat);
2465 assert_eq!(r.lines[3].commit_hash, merge, "the evil line → merge");
2466 }
2467
2468 #[test]
2469 fn blame_octopus_merge_credits_each_branch() {
2470 let (_d, store) = fresh_store();
2472 let base = put_file_commit(&store, "f.txt", b"base\n", vec![], 1, 100);
2473 let b1 = put_file_commit(&store, "f.txt", b"base\nb1\n", vec![base], 2, 200);
2474 let b2 = put_file_commit(&store, "f.txt", b"base\nb2\n", vec![base], 3, 300);
2475 let b3 = put_file_commit(&store, "f.txt", b"base\nb3\n", vec![base], 4, 400);
2476 let merge = put_file_commit(
2477 &store,
2478 "f.txt",
2479 b"base\nb1\nb2\nb3\n",
2480 vec![b1, b2, b3],
2481 5,
2482 500,
2483 );
2484 let r = blame_file(&store, merge, "f.txt").unwrap();
2485 assert_eq!(r.lines[0].commit_hash, base);
2486 assert_eq!(r.lines[1].commit_hash, b1);
2487 assert_eq!(r.lines[2].commit_hash, b2);
2488 assert_eq!(r.lines[3].commit_hash, b3);
2489 }
2490
2491 #[test]
2492 fn blame_m_merge_credits_move_from_second_parent() {
2493 let (_d, store) = fresh_store();
2499 let base = put_file_commit(&store, "f.txt", b"X\nY\n", vec![], 1, 100);
2500 let p1 = put_file_commit(&store, "f.txt", b"X\nY\nZ\n", vec![base], 2, 200);
2502 let v2 = [LONG_LINE, b"X", b"Y", b""].join(&b'\n');
2504 let c2 = put_file_commit(&store, "f.txt", &v2, vec![base], 3, 300);
2505 let vm = [b"X" as &[u8], b"Y", b"Z", LONG_LINE, b""].join(&b'\n');
2507 let merge = put_file_commit(&store, "f.txt", &vm, vec![p1, c2], 4, 400);
2508
2509 let opts = BlameOptions {
2510 moves: MoveDetection::On { threshold: 20 },
2511 ..Default::default()
2512 };
2513 let r = blame_file_with(&store, merge, "f.txt", &opts).unwrap();
2514 assert_eq!(r.lines[3].text, LONG_LINE);
2516 assert_eq!(
2517 r.lines[3].commit_hash, c2,
2518 "-M credits the move to the 2nd-parent origin, not the merge"
2519 );
2520 assert_eq!(r.lines[2].commit_hash, p1, "Z stays on the first parent");
2521 }
2522
2523 #[test]
2524 fn blame_m_merge_move_prefers_first_parent() {
2525 let (_d, store) = fresh_store();
2531 let base = put_file_commit(&store, "f.txt", b"X\nY\n", vec![], 1, 100);
2532 let v = [LONG_LINE, b"X", b"Y", b""].join(&b'\n');
2533 let p1 = put_file_commit(&store, "f.txt", &v, vec![base], 2, 200);
2534 let c2 = put_file_commit(&store, "f.txt", &v, vec![base], 3, 300);
2535 let vm = [b"X" as &[u8], b"Y", LONG_LINE, b""].join(&b'\n');
2536 let merge = put_file_commit(&store, "f.txt", &vm, vec![p1, c2], 4, 400);
2537
2538 let opts = BlameOptions {
2539 moves: MoveDetection::On { threshold: 20 },
2540 ..Default::default()
2541 };
2542 let r = blame_file_with(&store, merge, "f.txt", &opts).unwrap();
2543 assert_eq!(r.lines[2].text, LONG_LINE);
2545 assert_eq!(
2546 r.lines[2].commit_hash, p1,
2547 "-M move at a merge prefers the first parent on a tie"
2548 );
2549 assert!(
2550 r.lines.iter().all(|l| l.commit_hash != c2),
2551 "the second parent never wins the tie"
2552 );
2553 }
2554
2555 #[test]
2556 fn blame_ignore_rev_merge_falls_through_to_second_parent() {
2557 let (_d, store) = fresh_store();
2564 let base = put_file_commit(&store, "f.txt", b"TOP\nMID\nBOT\n", vec![], 1, 100);
2565 let p1 = put_file_commit(&store, "f.txt", b"TOP\nBOT\n", vec![base], 2, 200);
2567 let v2 = [b"TOP" as &[u8], b"REAL_CONTENT_OF_B_LINE", b"BOT", b""].join(&b'\n');
2569 let c2 = put_file_commit(&store, "f.txt", &v2, vec![base], 3, 300);
2570 let vm = [b"TOP" as &[u8], b" REAL_CONTENT_OF_B_LINE X", b"BOT", b""].join(&b'\n');
2572 let merge = put_file_commit(&store, "f.txt", &vm, vec![p1, c2], 4, 400);
2573
2574 let r = blame_file_with(&store, merge, "f.txt", &ignoring(&[merge])).unwrap();
2575 assert_eq!(r.lines[1].text, b" REAL_CONTENT_OF_B_LINE X");
2576 assert_eq!(
2577 r.lines[1].commit_hash, c2,
2578 "ignored merge falls through across to the 2nd parent's origin"
2579 );
2580 }
2581
2582 #[test]
2583 fn blame_ignore_rev_merge_prefers_first_parent_counterpart() {
2584 let (_d, store) = fresh_store();
2589 let base = put_file_commit(&store, "f.txt", b"a\nb\nc\n", vec![], 1, 100);
2590 let p1 = put_file_commit(
2591 &store,
2592 "f.txt",
2593 b"a\nMAIN_B_VERSION\nc\n",
2594 vec![base],
2595 2,
2596 200,
2597 );
2598 let v2 = [b"a" as &[u8], b"REAL_CONTENT_OF_B_LINE", b"c", b""].join(&b'\n');
2599 let c2 = put_file_commit(&store, "f.txt", &v2, vec![base], 3, 300);
2600 let vm = [b"a" as &[u8], b" REAL_CONTENT_OF_B_LINE X", b"c", b""].join(&b'\n');
2601 let merge = put_file_commit(&store, "f.txt", &vm, vec![p1, c2], 4, 400);
2602
2603 let r = blame_file_with(&store, merge, "f.txt", &ignoring(&[merge])).unwrap();
2604 assert_eq!(
2605 r.lines[1].commit_hash, p1,
2606 "fall-through prefers the first parent on a positional tie"
2607 );
2608 assert!(
2609 r.lines.iter().all(|l| l.commit_hash != c2),
2610 "the second parent does not win when the first parent has a counterpart"
2611 );
2612 }
2613
2614 #[test]
2615 fn blame_ignore_rev_precise_merge_second_parent_composition() {
2616 let (_d, store) = fresh_store();
2631 let base = put_file_commit(&store, "f.txt", b"TOP\nBOT\n", vec![], 1, 100);
2632 let p1 = put_file_commit(&store, "f.txt", b"TOP\nBOT\n", vec![base], 2, 200);
2634 let c_x = put_file_commit(&store, "f.txt", b"TOP\nXXX\nBOT\n", vec![base], 3, 300);
2636 let c_y = put_file_commit(&store, "f.txt", b"TOP\nXXX\nYYY\nBOT\n", vec![c_x], 4, 400);
2637 let c_z = put_file_commit(
2638 &store,
2639 "f.txt",
2640 b"TOP\nXXX\nYYY\nZZZ\nBOT\n",
2641 vec![c_y],
2642 5,
2643 500,
2644 );
2645 let merge = put_file_commit(
2648 &store,
2649 "f.txt",
2650 b"TOP\n ZZZ\n YYY\n XXX\nBOT\n",
2651 vec![p1, c_z],
2652 6,
2653 600,
2654 );
2655
2656 let positional_opts = BlameOptions {
2657 ignore_whitespace: true,
2658 ignore_revs: Arc::new([merge].into_iter().collect()),
2659 ..Default::default()
2660 };
2661 let positional = blame_file_with(&store, merge, "f.txt", &positional_opts).unwrap();
2662 assert_eq!(
2663 positional.lines[1].commit_hash, c_z,
2664 "ZZZ is recognized unchanged by the LCS matcher itself, against the 2nd parent"
2665 );
2666 assert_eq!(
2667 positional.lines[2].commit_hash, merge,
2668 "positional: YYY has no in-hunk counterpart on either parent, stays on the merge"
2669 );
2670 assert_eq!(
2671 positional.lines[3].commit_hash, merge,
2672 "positional: XXX has no in-hunk counterpart on either parent, stays on the merge"
2673 );
2674
2675 let precise = blame_file_with(&store, merge, "f.txt", &ignoring_precise(&[merge])).unwrap();
2676 assert_eq!(
2677 precise.lines[1].commit_hash, c_z,
2678 "ZZZ is unaffected by precise mode (already resolved by plain LCS)"
2679 );
2680 assert_eq!(
2681 precise.lines[2].commit_hash, c_y,
2682 "precise: YYY correctly attributed to its true origin via the 2nd parent's whole file"
2683 );
2684 assert_eq!(
2685 precise.lines[3].commit_hash, c_x,
2686 "precise: XXX correctly attributed to its true origin via the 2nd parent's whole file"
2687 );
2688 assert!(
2689 positional.lines.iter().all(|l| l.commit_hash != p1)
2690 && precise.lines.iter().all(|l| l.commit_hash != p1),
2691 "the content-less first parent never wins"
2692 );
2693 }
2694
2695 #[test]
2696 fn blame_c_merge_credits_copy_from_second_parent() {
2697 let (_d, store) = fresh_store();
2706 let base = put_multi_file_commit(&store, &[("b.txt", b"hello\n")], vec![], 1, 100);
2707 let p1 = put_multi_file_commit(
2708 &store,
2709 &[("b.txt", b"hello\n"), ("m.txt", b"main only\n")],
2710 vec![base],
2711 2,
2712 200,
2713 );
2714 let src = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
2715 let c2 = put_multi_file_commit(
2716 &store,
2717 &[("b.txt", b"hello\n"), ("src.txt", &src)],
2718 vec![base],
2719 3,
2720 300,
2721 );
2722 let bmerge = [b"hello" as &[u8], BLOCK_A, BLOCK_B, b""].join(&b'\n');
2723 let merge = put_multi_file_commit(
2724 &store,
2725 &[
2726 ("b.txt", &bmerge),
2727 ("m.txt", b"main only\n"),
2728 ("src.txt", &src),
2729 ],
2730 vec![p1, c2],
2731 4,
2732 400,
2733 );
2734
2735 let opts = BlameOptions {
2736 copies: CopyDetection::On {
2737 level: 2,
2738 threshold: 40,
2739 },
2740 ..Default::default()
2741 };
2742 let r = blame_file_with(&store, merge, "b.txt", &opts).unwrap();
2743 assert_eq!(r.lines[1].text, BLOCK_A);
2745 assert_eq!(
2746 r.lines[1].commit_hash, c2,
2747 "a modified file's appended block is copied across to the second parent's tree (git credits c2)"
2748 );
2749 assert_eq!(
2750 r.lines[2].commit_hash, c2,
2751 "the whole copied block is credited to c2"
2752 );
2753 assert_ne!(r.lines[1].commit_hash, merge);
2755 }
2756
2757 #[test]
2758 fn blame_c_merge_credits_copy_from_third_octopus_parent() {
2759 let (_d, store) = fresh_store();
2765 let base = put_multi_file_commit(&store, &[("b.txt", b"hello\n")], vec![], 1, 100);
2766 let p1 = put_multi_file_commit(
2767 &store,
2768 &[("b.txt", b"hello\n"), ("x.txt", b"x only\n")],
2769 vec![base],
2770 2,
2771 200,
2772 );
2773 let p2 = put_multi_file_commit(
2774 &store,
2775 &[("b.txt", b"hello\n"), ("y.txt", b"y only\n")],
2776 vec![base],
2777 3,
2778 300,
2779 );
2780 let src = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
2781 let p3 = put_multi_file_commit(
2782 &store,
2783 &[("b.txt", b"hello\n"), ("src.txt", &src)],
2784 vec![base],
2785 4,
2786 400,
2787 );
2788 let bmerge = [b"hello" as &[u8], BLOCK_A, BLOCK_B, b""].join(&b'\n');
2789 let merge = put_multi_file_commit(
2790 &store,
2791 &[
2792 ("b.txt", &bmerge),
2793 ("x.txt", b"x only\n"),
2794 ("y.txt", b"y only\n"),
2795 ("src.txt", &src),
2796 ],
2797 vec![p1, p2, p3],
2798 5,
2799 500,
2800 );
2801
2802 let opts = BlameOptions {
2803 copies: CopyDetection::On {
2804 level: 2,
2805 threshold: 40,
2806 },
2807 ..Default::default()
2808 };
2809 let r = blame_file_with(&store, merge, "b.txt", &opts).unwrap();
2810 assert_eq!(r.lines[1].text, BLOCK_A);
2811 assert_eq!(
2812 r.lines[1].commit_hash, p3,
2813 "the copied block is traced to the third octopus parent's tree"
2814 );
2815 }
2816
2817 #[test]
2818 fn blame_c_merge_source_only_in_merge_tree_credits_merge() {
2819 let (_d, store) = fresh_store();
2824 let base = put_multi_file_commit(&store, &[("b.txt", b"hello\n")], vec![], 1, 100);
2825 let p1 = put_multi_file_commit(
2826 &store,
2827 &[("b.txt", b"hello\n"), ("m.txt", b"main only\n")],
2828 vec![base],
2829 2,
2830 200,
2831 );
2832 let c2 = put_multi_file_commit(
2833 &store,
2834 &[("b.txt", b"hello\n"), ("o.txt", b"other\n")],
2835 vec![base],
2836 3,
2837 300,
2838 );
2839 let src = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
2840 let bmerge = [b"hello" as &[u8], BLOCK_A, BLOCK_B, b""].join(&b'\n');
2841 let merge = put_multi_file_commit(
2843 &store,
2844 &[
2845 ("b.txt", &bmerge),
2846 ("m.txt", b"main only\n"),
2847 ("o.txt", b"other\n"),
2848 ("src.txt", &src),
2849 ],
2850 vec![p1, c2],
2851 4,
2852 400,
2853 );
2854
2855 let opts = BlameOptions {
2856 copies: CopyDetection::On {
2857 level: 2,
2858 threshold: 40,
2859 },
2860 ..Default::default()
2861 };
2862 let r = blame_file_with(&store, merge, "b.txt", &opts).unwrap();
2863 assert_eq!(r.lines[1].text, BLOCK_A);
2864 assert_eq!(
2865 r.lines[1].commit_hash, merge,
2866 "no parent holds the source, so the appended block stays on the merge (git parity)"
2867 );
2868 }
2869
2870 #[test]
2871 fn blame_c_merge_copy_tie_prefers_deduped_second_parent() {
2872 let (_d, store) = fresh_store();
2886 let base = put_multi_file_commit(&store, &[("b.txt", b"hello\n")], vec![], 1, 100);
2887 let src = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
2888 let p1 = put_multi_file_commit(
2889 &store,
2890 &[("b.txt", b"hello\n"), ("s1.txt", &src)],
2891 vec![base],
2892 2,
2893 200,
2894 );
2895 let c2 = put_multi_file_commit(
2896 &store,
2897 &[("b.txt", b"hello\n"), ("s2.txt", &src)],
2898 vec![base],
2899 3,
2900 300,
2901 );
2902 let bmerge = [b"hello" as &[u8], BLOCK_A, BLOCK_B, b""].join(&b'\n');
2903 let merge = put_multi_file_commit(
2904 &store,
2905 &[("b.txt", &bmerge), ("s1.txt", &src), ("s2.txt", &src)],
2906 vec![p1, c2],
2907 4,
2908 400,
2909 );
2910
2911 let opts = BlameOptions {
2912 copies: CopyDetection::On {
2913 level: 2,
2914 threshold: 40,
2915 },
2916 ..Default::default()
2917 };
2918 let r = blame_file_with(&store, merge, "b.txt", &opts).unwrap();
2919 assert_eq!(r.lines[1].text, BLOCK_A);
2920 assert_eq!(
2921 r.lines[1].commit_hash, c2,
2922 "a copy tie across parents resolves to the non-first parent (git parity)"
2923 );
2924 assert_eq!(r.lines[2].commit_hash, c2);
2925 assert!(
2926 r.lines.iter().all(|l| l.commit_hash != p1),
2927 "the first parent never wins an interior -C tie"
2928 );
2929 }
2930
2931 #[test]
2932 fn blame_c_merge_copy_tie_octopus_prefers_first_non_first_parent() {
2933 let (_d, store) = fresh_store();
2948 let base = put_multi_file_commit(&store, &[("b.txt", b"hello\n")], vec![], 1, 100);
2949 let p1 = put_multi_file_commit(
2950 &store,
2951 &[("b.txt", b"hello\n"), ("pm.txt", b"p1 only\n")],
2952 vec![base],
2953 2,
2954 200,
2955 );
2956 let src = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
2957 let c2 = put_multi_file_commit(
2958 &store,
2959 &[("b.txt", b"hello\n"), ("s2.txt", &src)],
2960 vec![base],
2961 3,
2962 300,
2963 );
2964 let c3 = put_multi_file_commit(
2965 &store,
2966 &[("b.txt", b"hello\n"), ("s3.txt", &src)],
2967 vec![base],
2968 4,
2969 400,
2970 );
2971 let bmerge = [b"hello" as &[u8], BLOCK_A, BLOCK_B, b""].join(&b'\n');
2972 let merge = put_multi_file_commit(
2973 &store,
2974 &[("b.txt", &bmerge), ("s2.txt", &src), ("s3.txt", &src)],
2975 vec![p1, c2, c3],
2976 5,
2977 500,
2978 );
2979
2980 let opts = BlameOptions {
2981 copies: CopyDetection::On {
2982 level: 2,
2983 threshold: 40,
2984 },
2985 ..Default::default()
2986 };
2987 let r = blame_file_with(&store, merge, "b.txt", &opts).unwrap();
2988 assert_eq!(r.lines[1].text, BLOCK_A);
2989 assert_eq!(
2990 r.lines[1].commit_hash, c2,
2991 "the first NON-first parent (in order) wins the octopus tie, not the literal last parent"
2992 );
2993 assert_eq!(r.lines[2].commit_hash, c2);
2994 }
2995
2996 #[test]
2997 fn blame_c_merge_copy_source_only_on_first_parent_stays_on_merge() {
2998 let (_d, store) = fresh_store();
3016 let base = put_multi_file_commit(&store, &[("b.txt", b"hello\n")], vec![], 1, 100);
3017 let src = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
3018 let p1 = put_multi_file_commit(
3019 &store,
3020 &[("b.txt", b"hello\n"), ("s1.txt", &src)],
3021 vec![base],
3022 2,
3023 200,
3024 );
3025 let c2 = put_multi_file_commit(
3026 &store,
3027 &[
3028 ("b.txt", b"hello\n"),
3029 ("m.txt", b"other unrelated content\n"),
3030 ],
3031 vec![base],
3032 3,
3033 300,
3034 );
3035 let bmerge = [b"hello" as &[u8], BLOCK_A, BLOCK_B, b""].join(&b'\n');
3036 let merge = put_multi_file_commit(
3037 &store,
3038 &[
3039 ("b.txt", &bmerge),
3040 ("s1.txt", &src),
3041 ("m.txt", b"other unrelated content\n"),
3042 ],
3043 vec![p1, c2],
3044 4,
3045 400,
3046 );
3047
3048 let opts = BlameOptions {
3049 copies: CopyDetection::On {
3050 level: 2,
3051 threshold: 40,
3052 },
3053 ..Default::default()
3054 };
3055 let r = blame_file_with(&store, merge, "b.txt", &opts).unwrap();
3056 assert_eq!(r.lines[1].text, BLOCK_A);
3057 assert_eq!(
3058 r.lines[1].commit_hash, merge,
3059 "an uncontested -C source on the first parent is never traced (git parity)"
3060 );
3061 assert_eq!(r.lines[2].commit_hash, merge);
3062 }
3063
3064 #[test]
3065 fn blame_c_merge_unmodified_first_parent_source_with_fileless_second_stays_on_merge() {
3066 let (_d, store) = fresh_store();
3079 let base = put_multi_file_commit(
3080 &store,
3081 &[
3082 ("b.txt", b"hello\n"),
3083 ("sbase.txt", b"source header line\n"),
3084 ],
3085 vec![],
3086 1,
3087 100,
3088 );
3089 let src = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
3090 let p1 = put_multi_file_commit(
3091 &store,
3092 &[
3093 ("b.txt", b"hello\n"),
3094 ("sbase.txt", b"source header line\n"),
3095 ("s1.txt", &src),
3096 ],
3097 vec![base],
3098 2,
3099 200,
3100 );
3101 let p2 = put_multi_file_commit(
3102 &store,
3103 &[("sbase.txt", b"source header line\n")],
3104 vec![base],
3105 3,
3106 300,
3107 );
3108 let bmerge = [b"hello" as &[u8], BLOCK_A, BLOCK_B, b""].join(&b'\n');
3109 let merge = put_multi_file_commit(
3110 &store,
3111 &[
3112 ("b.txt", &bmerge),
3113 ("sbase.txt", b"source header line\n"),
3114 ("s1.txt", &src),
3115 ],
3116 vec![p1, p2],
3117 4,
3118 400,
3119 );
3120
3121 let opts = BlameOptions {
3122 copies: CopyDetection::On {
3123 level: 2,
3124 threshold: 40,
3125 },
3126 ..Default::default()
3127 };
3128 let r = blame_file_with(&store, merge, "b.txt", &opts).unwrap();
3129 assert_eq!(r.lines[1].text, BLOCK_A);
3130 assert_eq!(
3131 r.lines[1].commit_hash, merge,
3132 "a fileless second parent does not make the merge linear: p1's \
3133 unchanged source stays invisible and the block stays on the merge (git parity)"
3134 );
3135 assert_eq!(r.lines[2].commit_hash, merge);
3136 }
3137
3138 #[test]
3139 fn blame_c_merge_file_deleting_parent_supplies_copy_source() {
3140 let (_d, store) = fresh_store();
3151 let base = put_multi_file_commit(
3152 &store,
3153 &[("f.txt", b"hello\n"), ("s.txt", b"source header line\n")],
3154 vec![],
3155 1,
3156 100,
3157 );
3158 let p1 = put_multi_file_commit(
3159 &store,
3160 &[("f.txt", b"hello\n"), ("s.txt", b"source header line\n")],
3161 vec![base],
3162 2,
3163 200,
3164 );
3165 let src = [
3166 b"source header line" as &[u8],
3167 BLOCK_A,
3168 BLOCK_B,
3169 b"zzz",
3170 b"",
3171 ]
3172 .join(&b'\n');
3173 let p2 = put_multi_file_commit(&store, &[("s.txt", &src)], vec![base], 3, 300);
3174 let bmerge = [b"hello" as &[u8], BLOCK_A, BLOCK_B, b""].join(&b'\n');
3175 let merge = put_multi_file_commit(
3176 &store,
3177 &[("f.txt", &bmerge), ("s.txt", &src)],
3178 vec![p1, p2],
3179 4,
3180 400,
3181 );
3182
3183 let opts = BlameOptions {
3184 copies: CopyDetection::On {
3185 level: 2,
3186 threshold: 40,
3187 },
3188 ..Default::default()
3189 };
3190 let r = blame_file_with(&store, merge, "f.txt", &opts).unwrap();
3191 assert_eq!(r.lines[1].text, BLOCK_A);
3192 assert_eq!(
3193 r.lines[1].commit_hash, p2,
3194 "the parent that deleted the blamed file is whole-tree searched \
3195 and its source claims the block (git parity)"
3196 );
3197 assert_eq!(r.lines[2].commit_hash, p2);
3198 }
3199
3200 #[test]
3201 fn blame_c_merge_fileless_first_parent_unmodified_second_source_stays_on_merge() {
3202 let (_d, store) = fresh_store();
3219 let base = put_multi_file_commit(
3220 &store,
3221 &[("b.txt", b"hello\n"), ("s2.txt", b"source header line\n")],
3222 vec![],
3223 1,
3224 100,
3225 );
3226 let p1 = put_multi_file_commit(
3227 &store,
3228 &[("s2.txt", b"source header line\n")],
3229 vec![base],
3230 2,
3231 200,
3232 );
3233 let src = [
3234 b"source header line" as &[u8],
3235 BLOCK_A,
3236 BLOCK_B,
3237 b"zzz",
3238 b"",
3239 ]
3240 .join(&b'\n');
3241 let p2 = put_multi_file_commit(
3242 &store,
3243 &[("b.txt", b"hello\n"), ("s2.txt", &src)],
3244 vec![base],
3245 3,
3246 300,
3247 );
3248 let p3 = put_multi_file_commit(
3249 &store,
3250 &[
3251 ("b.txt", b"hello\n"),
3252 ("s2.txt", b"source header line\n"),
3253 ("o.txt", b"other\n"),
3254 ],
3255 vec![base],
3256 4,
3257 400,
3258 );
3259 let bmerge = [b"hello" as &[u8], BLOCK_A, BLOCK_B, b""].join(&b'\n');
3260 let merge = put_multi_file_commit(
3261 &store,
3262 &[("b.txt", &bmerge), ("s2.txt", &src), ("o.txt", b"other\n")],
3263 vec![p1, p2, p3],
3264 5,
3265 500,
3266 );
3267
3268 let opts = BlameOptions {
3269 copies: CopyDetection::On {
3270 level: 2,
3271 threshold: 40,
3272 },
3273 ..Default::default()
3274 };
3275 let r = blame_file_with(&store, merge, "b.txt", &opts).unwrap();
3276 assert_eq!(r.lines[1].text, BLOCK_A);
3277 assert_eq!(
3278 r.lines[1].commit_hash, merge,
3279 "the first file-bearing parent keeps its porigin even when the real \
3280 first parent is fileless; its unchanged source stays invisible (git parity)"
3281 );
3282 assert_eq!(r.lines[2].commit_hash, merge);
3283 }
3284
3285 #[test]
3286 fn blame_c_level1_merge_modified_source_credits_first_parent() {
3287 let (_d, store) = fresh_store();
3301 let base = put_multi_file_commit(
3302 &store,
3303 &[("b.txt", b"hello\n"), ("s1.txt", b"source header line\n")],
3304 vec![],
3305 1,
3306 100,
3307 );
3308 let src = [
3309 b"source header line" as &[u8],
3310 BLOCK_A,
3311 BLOCK_B,
3312 b"zzz",
3313 b"",
3314 ]
3315 .join(&b'\n');
3316 let p1 = put_multi_file_commit(
3317 &store,
3318 &[("b.txt", b"hello\n"), ("s1.txt", &src)],
3319 vec![base],
3320 2,
3321 200,
3322 );
3323 let p2 = put_multi_file_commit(
3324 &store,
3325 &[
3326 ("b.txt", b"hello\n"),
3327 ("s1.txt", b"source header line\n"),
3328 ("o.txt", b"other\n"),
3329 ],
3330 vec![base],
3331 3,
3332 300,
3333 );
3334 let bmerge = [b"hello" as &[u8], BLOCK_A, BLOCK_B, b""].join(&b'\n');
3335 let merge = put_multi_file_commit(
3336 &store,
3337 &[
3338 ("b.txt", &bmerge),
3339 ("s1.txt", b"source header line\n"),
3340 ("o.txt", b"other\n"),
3341 ],
3342 vec![p1, p2],
3343 4,
3344 400,
3345 );
3346
3347 for level in [1u8, 2] {
3348 let opts = BlameOptions {
3349 copies: CopyDetection::On {
3350 level,
3351 threshold: 40,
3352 },
3353 ..Default::default()
3354 };
3355 let r = blame_file_with(&store, merge, "b.txt", &opts).unwrap();
3356 assert_eq!(r.lines[1].text, BLOCK_A);
3357 assert_eq!(
3358 r.lines[1].commit_hash, p1,
3359 "a source modified between the first parent and the merge is a \
3360 level-{level} candidate and credits the first parent (git parity)"
3361 );
3362 }
3363 }
3364
3365 #[test]
3366 fn blame_c_boundary_first_parent_mode_still_searches_first_parent() {
3367 let (_d, store) = fresh_store();
3378 let base = put_multi_file_commit(&store, &[("x.txt", b"x\n")], vec![], 1, 100);
3379 let src = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
3380 let p1 = put_multi_file_commit(
3381 &store,
3382 &[("x.txt", b"x\n"), ("s1.txt", &src)],
3383 vec![base],
3384 2,
3385 200,
3386 );
3387 let p2 = put_multi_file_commit(
3388 &store,
3389 &[("x.txt", b"x\n"), ("o.txt", b"other\n")],
3390 vec![base],
3391 3,
3392 300,
3393 );
3394 let newf = [BLOCK_A, BLOCK_B, b""].join(&b'\n');
3395 let merge = put_multi_file_commit(
3396 &store,
3397 &[
3398 ("x.txt", b"x\n"),
3399 ("s1.txt", &src),
3400 ("o.txt", b"other\n"),
3401 ("n.txt", &newf),
3402 ],
3403 vec![p1, p2],
3404 4,
3405 400,
3406 );
3407
3408 let opts = BlameOptions {
3409 copies: CopyDetection::On {
3410 level: 2,
3411 threshold: 40,
3412 },
3413 first_parent: true,
3414 ..Default::default()
3415 };
3416 let r = blame_file_with(&store, merge, "n.txt", &opts).unwrap();
3417 assert_eq!(r.lines[0].text, BLOCK_A);
3418 assert_eq!(
3419 r.lines[0].commit_hash, p1,
3420 "--first-parent truncates the boundary search to the real first \
3421 parent, which is still whole-tree searched (git parity)"
3422 );
3423 assert_eq!(r.lines[1].commit_hash, p1);
3424 }
3425
3426 #[test]
3427 fn blame_c_merge_mixed_within_file_move_beats_copy_on_tie() {
3428 let (_d, store) = fresh_store();
3439 let base = put_file_commit(&store, "f.txt", b"X\nY\n", vec![], 1, 100);
3440 let v1 = [LONG_LINE, b"X", b"Y", b""].join(&b'\n');
3441 let p1 = put_file_commit(&store, "f.txt", &v1, vec![base], 2, 200);
3442 let c2 = put_multi_file_commit(
3443 &store,
3444 &[
3445 ("f.txt", b"X\nY\n"),
3446 ("other.txt", &[LONG_LINE, b"other stuff", b""].join(&b'\n')),
3447 ],
3448 vec![base],
3449 3,
3450 300,
3451 );
3452 let vm = [b"X" as &[u8], b"Y", LONG_LINE, b""].join(&b'\n');
3453 let merge = put_multi_file_commit(
3454 &store,
3455 &[
3456 ("f.txt", &vm),
3457 ("other.txt", &[LONG_LINE, b"other stuff", b""].join(&b'\n')),
3458 ],
3459 vec![p1, c2],
3460 4,
3461 400,
3462 );
3463
3464 let opts = BlameOptions {
3465 moves: MoveDetection::On { threshold: 20 },
3466 copies: CopyDetection::On {
3467 level: 2,
3468 threshold: 40,
3469 },
3470 ..Default::default()
3471 };
3472 let r = blame_file_with(&store, merge, "f.txt", &opts).unwrap();
3473 assert_eq!(r.lines[2].text, LONG_LINE);
3474 assert_eq!(
3475 r.lines[2].commit_hash, p1,
3476 "-M's within-file move on the first parent still wins the tie over -C's copy on the second"
3477 );
3478 }
3479
3480 #[test]
3481 fn blame_c_merge_boundary_copy_from_second_parent() {
3482 let (_d, store) = fresh_store();
3491 let base = put_multi_file_commit(&store, &[("base.txt", b"base\n")], vec![], 1, 100);
3492 let p1 = put_multi_file_commit(
3493 &store,
3494 &[("base.txt", b"base\n"), ("m.txt", b"main only\n")],
3495 vec![base],
3496 2,
3497 200,
3498 );
3499 let src = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
3500 let c2 = put_multi_file_commit(
3501 &store,
3502 &[("base.txt", b"base\n"), ("src.txt", &src)],
3503 vec![base],
3504 3,
3505 300,
3506 );
3507 let bnew = [BLOCK_A, BLOCK_B, b""].join(&b'\n');
3508 let merge = put_multi_file_commit(
3509 &store,
3510 &[
3511 ("base.txt", b"base\n"),
3512 ("m.txt", b"main only\n"),
3513 ("src.txt", &src),
3514 ("b.txt", &bnew),
3515 ],
3516 vec![p1, c2],
3517 4,
3518 400,
3519 );
3520
3521 let opts = BlameOptions {
3522 copies: CopyDetection::On {
3523 level: 2,
3524 threshold: 40,
3525 },
3526 ..Default::default()
3527 };
3528 let r = blame_file_with(&store, merge, "b.txt", &opts).unwrap();
3529 assert_eq!(r.lines[0].text, BLOCK_A);
3530 assert_eq!(
3531 r.lines[0].commit_hash, c2,
3532 "a boundary -C source on a non-first parent is traced (git parity)"
3533 );
3534 assert_eq!(r.lines[1].commit_hash, c2);
3535 }
3536
3537 #[test]
3538 fn blame_c_merge_boundary_copy_octopus_third_parent() {
3539 let (_d, store) = fresh_store();
3549 let base = put_multi_file_commit(&store, &[("base.txt", b"base\n")], vec![], 1, 100);
3550 let p1 = put_multi_file_commit(
3551 &store,
3552 &[("base.txt", b"base\n"), ("pm.txt", b"p1 only\n")],
3553 vec![base],
3554 2,
3555 200,
3556 );
3557 let c2 = put_multi_file_commit(
3558 &store,
3559 &[("base.txt", b"base\n"), ("cm.txt", b"c2 only\n")],
3560 vec![base],
3561 3,
3562 300,
3563 );
3564 let src = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
3565 let c3 = put_multi_file_commit(
3566 &store,
3567 &[("base.txt", b"base\n"), ("s3.txt", &src)],
3568 vec![base],
3569 4,
3570 400,
3571 );
3572 let bnew = [BLOCK_A, BLOCK_B, b""].join(&b'\n');
3573 let merge = put_multi_file_commit(
3574 &store,
3575 &[
3576 ("base.txt", b"base\n"),
3577 ("pm.txt", b"p1 only\n"),
3578 ("cm.txt", b"c2 only\n"),
3579 ("s3.txt", &src),
3580 ("b.txt", &bnew),
3581 ],
3582 vec![p1, c2, c3],
3583 5,
3584 500,
3585 );
3586
3587 let opts = BlameOptions {
3588 copies: CopyDetection::On {
3589 level: 2,
3590 threshold: 40,
3591 },
3592 ..Default::default()
3593 };
3594 let r = blame_file_with(&store, merge, "b.txt", &opts).unwrap();
3595 assert_eq!(r.lines[0].text, BLOCK_A);
3596 assert_eq!(
3597 r.lines[0].commit_hash, c3,
3598 "the boundary search walks every real parent, not just the first two"
3599 );
3600 assert_eq!(r.lines[1].commit_hash, c3);
3601 }
3602
3603 #[test]
3604 fn blame_c_merge_boundary_copy_tie_prefers_first_parent() {
3605 let (_d, store) = fresh_store();
3617 let base = put_multi_file_commit(&store, &[("base.txt", b"base\n")], vec![], 1, 100);
3618 let src = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
3619 let p1 = put_multi_file_commit(
3620 &store,
3621 &[("base.txt", b"base\n"), ("s1.txt", &src)],
3622 vec![base],
3623 2,
3624 200,
3625 );
3626 let c2 = put_multi_file_commit(
3627 &store,
3628 &[("base.txt", b"base\n"), ("s2.txt", &src)],
3629 vec![base],
3630 3,
3631 300,
3632 );
3633 let bnew = [BLOCK_A, BLOCK_B, b""].join(&b'\n');
3634 let merge = put_multi_file_commit(
3635 &store,
3636 &[
3637 ("base.txt", b"base\n"),
3638 ("s1.txt", &src),
3639 ("s2.txt", &src),
3640 ("b.txt", &bnew),
3641 ],
3642 vec![p1, c2],
3643 4,
3644 400,
3645 );
3646
3647 let opts = BlameOptions {
3648 copies: CopyDetection::On {
3649 level: 2,
3650 threshold: 40,
3651 },
3652 ..Default::default()
3653 };
3654 let r = blame_file_with(&store, merge, "b.txt", &opts).unwrap();
3655 assert_eq!(r.lines[0].text, BLOCK_A);
3656 assert_eq!(
3657 r.lines[0].commit_hash, p1,
3658 "the boundary copy tie prefers the first parent (git parity) — unlike the interior tie"
3659 );
3660 }
3661
3662 #[test]
3663 fn match_lines_rejects_oversize_inputs() {
3664 let n = super::BLAME_MAX_LINES + 1;
3669 let opts = BlameOptions::default();
3670 let old: Vec<Vec<u8>> = vec![b"x".to_vec(); n];
3671 let new: Vec<Vec<u8>> = vec![b"y".to_vec(); 1];
3672 let err = super::match_lines_with_options(&old, &new, &opts).unwrap_err();
3673 assert!(
3674 matches!(err, BlameError::FileTooLarge { lines } if lines == n),
3675 "got {err:?}"
3676 );
3677
3678 let old2: Vec<Vec<u8>> = vec![b"a".to_vec(); 1];
3679 let new2: Vec<Vec<u8>> = vec![b"b".to_vec(); n];
3680 let err2 = super::match_lines_with_options(&old2, &new2, &opts).unwrap_err();
3681 assert!(
3682 matches!(err2, BlameError::FileTooLarge { lines } if lines == n),
3683 "got {err2:?}"
3684 );
3685 }
3686}