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<()> {
813 if lines > BLAME_MAX_LINES {
814 return Err(BlameError::FileTooLarge { lines });
815 }
816 Ok(())
817}
818
819#[must_use]
858fn match_lines<T: AsRef<[u8]>>(old_lines: &[T], new_lines: &[T]) -> Vec<Option<usize>> {
859 let m = old_lines.len();
860 let n = new_lines.len();
861
862 let max_prefix = m.min(n);
863 let mut prefix = 0;
864 while prefix < max_prefix && old_lines[prefix].as_ref() == new_lines[prefix].as_ref() {
865 prefix += 1;
866 }
867
868 let mid_mapping = match_lines_core(&old_lines[prefix..], &new_lines[prefix..]);
869
870 let mut mapping = Vec::with_capacity(n);
871 mapping.extend((0..prefix).map(Some));
872 mapping.extend(mid_mapping.into_iter().map(|o| o.map(|i| i + prefix)));
873 mapping
874}
875
876#[must_use]
879fn match_lines_core<T: AsRef<[u8]>>(old_lines: &[T], new_lines: &[T]) -> Vec<Option<usize>> {
880 let m = old_lines.len();
881 let n = new_lines.len();
882 let mut dp = vec![vec![0u32; n + 1]; m + 1];
884 for i in 1..=m {
885 for j in 1..=n {
886 if old_lines[i - 1].as_ref() == new_lines[j - 1].as_ref() {
887 dp[i][j] = dp[i - 1][j - 1] + 1;
888 } else {
889 dp[i][j] = dp[i - 1][j].max(dp[i][j - 1]);
890 }
891 }
892 }
893 let mut mapping: Vec<Option<usize>> = vec![None; n];
894 let mut i = m;
895 let mut j = n;
896 while i > 0 && j > 0 {
906 if dp[i][j] == dp[i][j - 1] {
907 j -= 1;
908 } else if dp[i][j] == dp[i - 1][j] {
909 i -= 1;
910 } else {
911 mapping[j - 1] = Some(i - 1);
912 i -= 1;
913 j -= 1;
914 }
915 }
916 mapping
917}
918
919#[must_use]
927pub fn format_blame_text(result: &BlameResult) -> String {
928 let mut out = String::new();
929 for line in &result.lines {
930 let hex = hash::to_hex(&line.commit_hash);
931 let short = &hex[..12];
932 let _ = write!(out, "{}\t{}\t", short, line.line_num);
933 out.push_str(&String::from_utf8_lossy(&line.text));
934 out.push('\n');
935 }
936 out
937}
938
939#[cfg(test)]
940mod tests {
941 use super::*;
942 use crate::object::{Commit, Identity, Tree, TreeEntry};
943 use crate::serialize;
944 use tempfile::TempDir;
945
946 fn fresh_store() -> (TempDir, ObjectStore) {
947 let dir = TempDir::new().unwrap();
948 let store = ObjectStore::init(&crate::layout::RepoLayout::single(dir.path())).unwrap();
949 (dir, store)
950 }
951
952 fn put_blob(store: &ObjectStore, data: &[u8]) -> Hash {
953 let bytes = serialize::serialize(&Object::Blob(crate::object::Blob {
954 data: data.to_vec(),
955 }))
956 .unwrap();
957 store.write(&bytes).unwrap()
958 }
959
960 fn put_single_file_tree(store: &ObjectStore, name: &str, blob: Hash) -> Hash {
961 let tree = Object::Tree(Tree {
962 entries: vec![TreeEntry {
963 name: name.as_bytes().to_vec(),
964 mode: EntryMode::Blob,
965 object_hash: blob,
966 }],
967 });
968 store.write(&serialize::serialize(&tree).unwrap()).unwrap()
969 }
970
971 fn put_file_commit(
972 store: &ObjectStore,
973 filename: &str,
974 content: &[u8],
975 parents: Vec<Hash>,
976 author_mid: u64,
977 ts: u64,
978 ) -> Hash {
979 let blob = put_blob(store, content);
980 let tree = put_single_file_tree(store, filename, blob);
981 let commit = Object::Commit(Commit::new_unannotated(
982 tree,
983 parents,
984 Identity::opaque(author_mid.to_le_bytes()),
985 [0u8; 32],
986 b"msg".to_vec(),
987 ts,
988 [0u8; 64],
989 ));
990 store
991 .write(&serialize::serialize(&commit).unwrap())
992 .unwrap()
993 }
994
995 fn put_multi_file_commit(
997 store: &ObjectStore,
998 files: &[(&str, &[u8])],
999 parents: Vec<Hash>,
1000 author_mid: u64,
1001 ts: u64,
1002 ) -> Hash {
1003 let mut entries: Vec<TreeEntry> = files
1004 .iter()
1005 .map(|(name, content)| TreeEntry {
1006 name: name.as_bytes().to_vec(),
1007 mode: EntryMode::Blob,
1008 object_hash: put_blob(store, content),
1009 })
1010 .collect();
1011 entries.sort_by(|a, b| a.name.cmp(&b.name));
1014 let tree = store
1015 .write(&serialize::serialize(&Object::Tree(Tree { entries })).unwrap())
1016 .unwrap();
1017 let commit = Object::Commit(Commit::new_unannotated(
1018 tree,
1019 parents,
1020 Identity::opaque(author_mid.to_le_bytes()),
1021 [0u8; 32],
1022 b"msg".to_vec(),
1023 ts,
1024 [0u8; 64],
1025 ));
1026 store
1027 .write(&serialize::serialize(&commit).unwrap())
1028 .unwrap()
1029 }
1030
1031 const LONG_LINE: &[u8] = b"let quick_brown_fox_total = 1;";
1034 const BLOCK_A: &[u8] = b"fn handler_alpha() { compute(); }";
1037 const BLOCK_B: &[u8] = b"fn handler_bravo() { compute(); }";
1038
1039 #[test]
1044 fn blame_rejects_chunked_total_size_mismatch() {
1045 let (_d, store) = fresh_store();
1046 let chunk = put_blob(&store, b"one line\n");
1047 let cb = Object::ChunkedBlob(crate::object::ChunkedBlob {
1048 total_size: 4096,
1049 chunk_size: 0,
1050 chunks: vec![chunk],
1051 });
1052 let cb_h = store.write(&serialize::serialize(&cb).unwrap()).unwrap();
1053 let tree = put_single_file_tree(&store, "big.bin", cb_h);
1054 let commit = Object::Commit(Commit::new_unannotated(
1055 tree,
1056 vec![],
1057 Identity::opaque(1u64.to_le_bytes()),
1058 [0u8; 32],
1059 b"msg".to_vec(),
1060 100,
1061 [0u8; 64],
1062 ));
1063 let head = store
1064 .write(&serialize::serialize(&commit).unwrap())
1065 .unwrap();
1066 let err = blame_file(&store, head, "big.bin").unwrap_err();
1067 assert!(
1068 matches!(
1069 err,
1070 BlameError::Object(crate::object::MkitError::ChunkedBlobSizeMismatch {
1071 expected: 4096,
1072 actual: 9,
1073 })
1074 ),
1075 "expected ChunkedBlobSizeMismatch, got {err:?}"
1076 );
1077 }
1078
1079 #[test]
1080 fn blame_m_attributes_within_file_move_to_origin() {
1081 let (_d, store) = fresh_store();
1085 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');
1087 let c_a = put_file_commit(&store, "f.txt", &v1, vec![], 1, 100);
1088 let c_b = put_file_commit(&store, "f.txt", &v2, vec![c_a], 2, 200);
1089
1090 let plain = blame_file(&store, c_b, "f.txt").unwrap();
1091 assert_eq!(plain.lines[2].text, LONG_LINE);
1092 assert_eq!(
1093 plain.lines[2].commit_hash, c_b,
1094 "default: moved line is new"
1095 );
1096
1097 let opts = BlameOptions {
1098 moves: MoveDetection::On { threshold: 20 },
1099 ..Default::default()
1100 };
1101 let m = blame_file_with(&store, c_b, "f.txt", &opts).unwrap();
1102 assert_eq!(m.lines[2].text, LONG_LINE);
1103 assert_eq!(
1104 m.lines[2].commit_hash, c_a,
1105 "-M attributes the moved line to its origin"
1106 );
1107 assert!(
1108 m.lines.iter().all(|l| l.commit_hash == c_a),
1109 "every line predates c_b under -M"
1110 );
1111 }
1112
1113 #[test]
1114 fn blame_m_threshold_boundary_is_inclusive() {
1115 let (_d, store) = fresh_store();
1122 let exact: &[u8] = b"abcdefghijklmnopqrst"; let short: &[u8] = b"abcdefghijklmnopqrs"; let v1 = [exact, b"MID", short, b"B", b"C", b""].join(&b'\n');
1125 let v2 = [b"B" as &[u8], b"C", exact, b"NEWX", short, b""].join(&b'\n');
1126 let c_a = put_file_commit(&store, "f.txt", &v1, vec![], 1, 100);
1127 let c_b = put_file_commit(&store, "f.txt", &v2, vec![c_a], 2, 200);
1128
1129 let opts = BlameOptions {
1130 moves: MoveDetection::On { threshold: 20 },
1131 ..Default::default()
1132 };
1133 let m = blame_file_with(&store, c_b, "f.txt", &opts).unwrap();
1134 assert_eq!(m.lines[2].text, exact);
1136 assert_eq!(
1137 m.lines[2].commit_hash, c_a,
1138 "exactly-threshold (20) move is detected (>= is inclusive)"
1139 );
1140 assert_eq!(m.lines[4].text, short);
1141 assert_eq!(
1142 m.lines[4].commit_hash, c_b,
1143 "one char short of the threshold stays on the editing commit"
1144 );
1145 }
1146
1147 #[test]
1148 fn blame_m_ignores_moves_below_threshold() {
1149 let (_d, store) = fresh_store();
1153 let c_a = put_file_commit(&store, "f.txt", b"a\nB\nC\n", vec![], 1, 100);
1154 let c_b = put_file_commit(&store, "f.txt", b"B\nC\na\n", vec![c_a], 2, 200);
1155 let opts = BlameOptions {
1156 moves: MoveDetection::On { threshold: 20 },
1157 ..Default::default()
1158 };
1159 let m = blame_file_with(&store, c_b, "f.txt", &opts).unwrap();
1160 assert_eq!(m.lines[2].text, b"a");
1161 assert_eq!(
1162 m.lines[2].commit_hash, c_b,
1163 "a sub-threshold move is not detected"
1164 );
1165 }
1166
1167 #[test]
1168 fn blame_m_detects_sub_block_move_adjacent_to_new_line() {
1169 let (_d, store) = fresh_store();
1175 let v1 = [LONG_LINE, BLOCK_A, b"B", b"C", b""].join(&b'\n');
1176 let v2 = [b"B" as &[u8], b"C", b"NEWLINE", LONG_LINE, BLOCK_A, b""].join(&b'\n');
1177 let c_a = put_file_commit(&store, "f.txt", &v1, vec![], 1, 100);
1178 let c_b = put_file_commit(&store, "f.txt", &v2, vec![c_a], 2, 200);
1179
1180 let opts = BlameOptions {
1181 moves: MoveDetection::On { threshold: 20 },
1182 ..Default::default()
1183 };
1184 let m = blame_file_with(&store, c_b, "f.txt", &opts).unwrap();
1185 assert_eq!(m.lines[2].text, b"NEWLINE");
1187 assert_eq!(
1188 m.lines[2].commit_hash, c_b,
1189 "the genuinely-new line stays on c_b"
1190 );
1191 assert_eq!(m.lines[3].text, LONG_LINE);
1192 assert_eq!(
1193 m.lines[3].commit_hash, c_a,
1194 "the moved block reverts to c_a"
1195 );
1196 assert_eq!(
1197 m.lines[4].commit_hash, c_a,
1198 "…including the second moved line"
1199 );
1200 }
1201
1202 #[test]
1203 fn blame_w_c_detects_copy_with_whitespace_change() {
1204 let (_d, store) = fresh_store();
1209 let a1 = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
1210 let c_a = put_multi_file_commit(&store, &[("a.txt", &a1)], vec![], 1, 100);
1211 let reindented = {
1213 let mut v = Vec::new();
1214 v.extend_from_slice(b" ");
1215 v.extend_from_slice(BLOCK_A);
1216 v.push(b'\n');
1217 v.extend_from_slice(b" ");
1218 v.extend_from_slice(BLOCK_B);
1219 v.push(b'\n');
1220 v
1221 };
1222 let c_b = put_multi_file_commit(
1223 &store,
1224 &[("a.txt", b"zzz\n"), ("b.txt", &reindented)],
1225 vec![c_a],
1226 2,
1227 200,
1228 );
1229
1230 let plain_c = BlameOptions {
1232 copies: CopyDetection::On {
1233 level: 1,
1234 threshold: 40,
1235 },
1236 ..Default::default()
1237 };
1238 let r = blame_file_with(&store, c_b, "b.txt", &plain_c).unwrap();
1239 assert!(
1240 r.lines.iter().all(|l| l.commit_hash == c_b),
1241 "without -w a reindented copy is not detected"
1242 );
1243
1244 let w_c = BlameOptions {
1246 ignore_whitespace: true,
1247 copies: CopyDetection::On {
1248 level: 1,
1249 threshold: 40,
1250 },
1251 ..Default::default()
1252 };
1253 let r = blame_file_with(&store, c_b, "b.txt", &w_c).unwrap();
1254 assert!(
1255 r.lines.iter().all(|l| l.commit_hash == c_a),
1256 "-w -C credits a reindented copy to its origin"
1257 );
1258 }
1259
1260 #[test]
1261 fn blame_c_attributes_copy_from_other_file_to_origin() {
1262 let (_d, store) = fresh_store();
1267 let a1 = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
1268 let c_a = put_multi_file_commit(&store, &[("a.txt", &a1)], vec![], 1, 100);
1269 let bfile = [BLOCK_A, BLOCK_B, b""].join(&b'\n');
1270 let c_b = put_multi_file_commit(
1271 &store,
1272 &[("a.txt", b"zzz\n"), ("b.txt", &bfile)],
1273 vec![c_a],
1274 2,
1275 200,
1276 );
1277
1278 let plain = blame_file(&store, c_b, "b.txt").unwrap();
1279 assert_eq!(
1280 plain.lines[0].commit_hash, c_b,
1281 "default: copied block is new"
1282 );
1283
1284 let opts = BlameOptions {
1285 copies: CopyDetection::On {
1286 level: 1,
1287 threshold: 40,
1288 },
1289 ..Default::default()
1290 };
1291 let c = blame_file_with(&store, c_b, "b.txt", &opts).unwrap();
1292 assert_eq!(c.lines[0].text, BLOCK_A);
1293 assert_eq!(c.lines[1].text, BLOCK_B);
1294 assert!(
1295 c.lines.iter().all(|l| l.commit_hash == c_a),
1296 "-C credits the copied block to its origin commit"
1297 );
1298 }
1299
1300 #[test]
1301 fn blame_c_level1_skips_unchanged_files_until_level2() {
1302 let (_d, store) = fresh_store();
1307 let block = [BLOCK_A, BLOCK_B, b""].join(&b'\n');
1308 let c_a = put_multi_file_commit(&store, &[("src.txt", &block)], vec![], 1, 100);
1309 let c_b = put_multi_file_commit(
1310 &store,
1311 &[("src.txt", &block), ("dst.txt", &block)],
1312 vec![c_a],
1313 2,
1314 200,
1315 );
1316
1317 let l1 = BlameOptions {
1318 copies: CopyDetection::On {
1319 level: 1,
1320 threshold: 40,
1321 },
1322 ..Default::default()
1323 };
1324 let r1 = blame_file_with(&store, c_b, "dst.txt", &l1).unwrap();
1325 assert!(
1326 r1.lines.iter().all(|l| l.commit_hash == c_b),
1327 "-C level 1 ignores the unchanged source file"
1328 );
1329
1330 let l2 = BlameOptions {
1331 copies: CopyDetection::On {
1332 level: 2,
1333 threshold: 40,
1334 },
1335 ..Default::default()
1336 };
1337 let r2 = blame_file_with(&store, c_b, "dst.txt", &l2).unwrap();
1338 assert!(
1339 r2.lines.iter().all(|l| l.commit_hash == c_a),
1340 "-C -C searches every parent file and finds the source"
1341 );
1342 }
1343
1344 #[test]
1345 fn blame_w_c_credits_copy_through_prior_whitespace_edit() {
1346 let (_d, store) = fresh_store();
1353 let indented = {
1354 let mut v = Vec::new();
1355 for b in [BLOCK_A, BLOCK_B] {
1356 v.extend_from_slice(b" ");
1357 v.extend_from_slice(b);
1358 v.push(b'\n');
1359 }
1360 v.extend_from_slice(b"zzz\n");
1361 v
1362 };
1363 let d1 = put_multi_file_commit(&store, &[("a.txt", &indented)], vec![], 1, 100);
1364 let dedented = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
1365 let d2 = put_multi_file_commit(&store, &[("a.txt", &dedented)], vec![d1], 2, 200);
1366 let block = [BLOCK_A, BLOCK_B, b""].join(&b'\n');
1367 let d3 = put_multi_file_commit(
1368 &store,
1369 &[("a.txt", b"zzz\n"), ("b.txt", &block)],
1370 vec![d2],
1371 3,
1372 300,
1373 );
1374
1375 let opts = BlameOptions {
1376 ignore_whitespace: true,
1377 copies: CopyDetection::On {
1378 level: 1,
1379 threshold: 40,
1380 },
1381 ..Default::default()
1382 };
1383 let r = blame_file_with(&store, d3, "b.txt", &opts).unwrap();
1384 assert!(
1385 r.lines.iter().all(|l| l.commit_hash == d1),
1386 "the source blame keeps -w → credits the original, not the reformat"
1387 );
1388 }
1389
1390 #[test]
1391 fn blame_c_credits_copy_through_prior_same_file_move() {
1392 let (_d, store) = fresh_store();
1399 let v1 = [BLOCK_A, BLOCK_B, b"X", b"Y", b""].join(&b'\n');
1400 let d1 = put_multi_file_commit(&store, &[("a.txt", &v1)], vec![], 1, 100);
1401 let v2 = [b"X" as &[u8], b"Y", BLOCK_A, BLOCK_B, b""].join(&b'\n');
1402 let d2 = put_multi_file_commit(&store, &[("a.txt", &v2)], vec![d1], 2, 200);
1403 let block = [BLOCK_A, BLOCK_B, b""].join(&b'\n');
1404 let d3 = put_multi_file_commit(
1405 &store,
1406 &[("a.txt", b"X\nY\n"), ("b.txt", &block)],
1407 vec![d2],
1408 3,
1409 300,
1410 );
1411
1412 let opts = BlameOptions {
1413 copies: CopyDetection::On {
1414 level: 1,
1415 threshold: 40,
1416 },
1417 ..Default::default()
1418 };
1419 let r = blame_file_with(&store, d3, "b.txt", &opts).unwrap();
1420 assert!(
1421 r.lines.iter().all(|l| l.commit_hash == d1),
1422 "the source blame keeps implied -M → credits the original, not the move"
1423 );
1424 }
1425
1426 #[test]
1427 fn blame_c_alone_implies_within_file_m() {
1428 let (_d, store) = fresh_store();
1433 let v1 = [LONG_LINE, BLOCK_A, b"B", b"C", b""].join(&b'\n');
1434 let v2 = [b"B" as &[u8], b"C", LONG_LINE, BLOCK_A, b""].join(&b'\n');
1435 let c_a = put_file_commit(&store, "f.txt", &v1, vec![], 1, 100);
1436 let c_b = put_file_commit(&store, "f.txt", &v2, vec![c_a], 2, 200);
1437
1438 let opts = BlameOptions {
1439 copies: CopyDetection::On {
1440 level: 1,
1441 threshold: 40,
1442 },
1443 ..Default::default() };
1445 let m = blame_file_with(&store, c_b, "f.txt", &opts).unwrap();
1446 assert_eq!(m.lines[2].text, LONG_LINE);
1447 assert_eq!(
1448 m.lines[2].commit_hash, c_a,
1449 "-C implies -M, so the within-file move reverts to its origin"
1450 );
1451 }
1452
1453 #[test]
1454 fn blame_m_many_single_line_moves_no_stack_overflow() {
1455 let (_d, store) = fresh_store();
1459 let lines: Vec<String> = (0..400)
1460 .map(|i| format!("let unique_symbol_number_{i:05} = compute({i});"))
1461 .collect();
1462 let mut v1 = lines.join("\n");
1463 v1.push('\n');
1464 let mut rev: Vec<&str> = lines.iter().map(String::as_str).collect();
1465 rev.reverse();
1466 let mut v2 = rev.join("\n");
1467 v2.push('\n');
1468 let c_a = put_file_commit(&store, "f.txt", v1.as_bytes(), vec![], 1, 100);
1469 let c_b = put_file_commit(&store, "f.txt", v2.as_bytes(), vec![c_a], 2, 200);
1470
1471 let opts = BlameOptions {
1472 moves: MoveDetection::On { threshold: 20 },
1473 ..Default::default()
1474 };
1475 let m = blame_file_with(&store, c_b, "f.txt", &opts).unwrap();
1476 assert_eq!(m.lines.len(), 400);
1477 assert!(
1478 m.lines.iter().all(|l| l.commit_hash == c_a),
1479 "every reordered long line is a move → all revert to c_a"
1480 );
1481 }
1482
1483 #[test]
1484 fn blame_m_large_new_block_terminates() {
1485 use std::fmt::Write as _;
1490 let (_d, store) = fresh_store();
1491 let c_a = put_file_commit(&store, "f.txt", b"seed\n", vec![], 1, 100);
1492 let mut v2 = String::from("seed\n");
1493 for i in 0..3000 {
1494 let _ = writeln!(v2, "brand_new_distinct_line_number_{i:06}");
1495 }
1496 let c_b = put_file_commit(&store, "f.txt", v2.as_bytes(), vec![c_a], 2, 200);
1497
1498 let opts = BlameOptions {
1499 moves: MoveDetection::On { threshold: 20 },
1500 ..Default::default()
1501 };
1502 let m = blame_file_with(&store, c_b, "f.txt", &opts).unwrap();
1503 assert_eq!(m.lines.len(), 3001);
1504 assert_eq!(m.lines[0].commit_hash, c_a, "the seed line is unchanged");
1505 assert!(
1506 m.lines[1..].iter().all(|l| l.commit_hash == c_b),
1507 "the large new block stays on the editing commit"
1508 );
1509 }
1510
1511 #[test]
1512 fn blame_single_commit_attributes_all_lines_to_it() {
1513 let (_d, store) = fresh_store();
1514 let c = put_file_commit(&store, "f.txt", b"l1\nl2\nl3\n", vec![], 42, 1000);
1515 let r = blame_file(&store, c, "f.txt").unwrap();
1516 assert_eq!(r.lines.len(), 3);
1517 for (i, line) in r.lines.iter().enumerate() {
1518 assert_eq!(line.line_num, i + 1);
1519 assert_eq!(line.commit_hash, c);
1520 assert_eq!(line.timestamp, 1000);
1521 assert_eq!(line.author.kind, crate::object::IdentityKind::Opaque);
1522 }
1523 assert_eq!(r.lines[0].text, b"l1");
1524 assert_eq!(r.lines[1].text, b"l2");
1525 assert_eq!(r.lines[2].text, b"l3");
1526 }
1527
1528 #[test]
1529 fn strip_ws_removes_all_whitespace() {
1530 assert_eq!(strip_ws(b" foo(a, b)\t"), b"foo(a,b)".to_vec());
1531 assert_eq!(strip_ws(b"abc"), b"abc".to_vec());
1532 assert_eq!(strip_ws(b" \t "), b"".to_vec());
1533 assert_eq!(strip_ws(b"a\x0Bb\x0Cc"), b"abc".to_vec());
1536 }
1537
1538 #[test]
1539 fn blame_w_ignores_whitespace_only_change() {
1540 let (_d, store) = fresh_store();
1541 let c_a = put_file_commit(&store, "f.txt", b"foo(a, b)\nkeep\n", vec![], 1, 100);
1542 let c_b = put_file_commit(&store, "f.txt", b"foo(a,b)\nkeep\n", vec![c_a], 2, 200);
1543
1544 let plain = blame_file(&store, c_b, "f.txt").unwrap();
1546 assert_eq!(plain.lines[0].commit_hash, c_b);
1547
1548 let opts = BlameOptions {
1550 ignore_whitespace: true,
1551 ..Default::default()
1552 };
1553 let w = blame_file_with(&store, c_b, "f.txt", &opts).unwrap();
1554 assert_eq!(
1555 w.lines[0].commit_hash, c_a,
1556 "a whitespace-only change must not steal blame"
1557 );
1558 assert_eq!(
1559 w.lines[0].text, b"foo(a,b)",
1560 "output keeps the current bytes"
1561 );
1562 assert_eq!(w.lines[1].commit_hash, c_a);
1563 }
1564
1565 #[test]
1566 fn blame_w_still_attributes_real_content_change() {
1567 let (_d, store) = fresh_store();
1568 let c_a = put_file_commit(&store, "f.txt", b"a\nb\n", vec![], 1, 100);
1569 let c_b = put_file_commit(&store, "f.txt", b"a\nB CHANGED\n", vec![c_a], 2, 200);
1570 let opts = BlameOptions {
1571 ignore_whitespace: true,
1572 ..Default::default()
1573 };
1574 let w = blame_file_with(&store, c_b, "f.txt", &opts).unwrap();
1575 assert_eq!(w.lines[0].commit_hash, c_a);
1576 assert_eq!(
1577 w.lines[1].commit_hash, c_b,
1578 "a non-whitespace change is still attributed normally under -w"
1579 );
1580 }
1581
1582 #[test]
1583 fn blame_w_keeps_position_for_whitespace_equal_duplicate() {
1584 let (_d, store) = fresh_store();
1591 let c_a = put_file_commit(&store, "f.txt", b"ab\n", vec![], 1, 100);
1592 let c_b = put_file_commit(&store, "f.txt", b"ab\na b\n", vec![c_a], 2, 200);
1593 let opts = BlameOptions {
1594 ignore_whitespace: true,
1595 ..Default::default()
1596 };
1597 let w = blame_file_with(&store, c_b, "f.txt", &opts).unwrap();
1598 assert_eq!(w.lines.len(), 2);
1599 assert_eq!(w.lines[0].commit_hash, c_a, "unchanged line 1 keeps c_a");
1600 assert_eq!(w.lines[1].commit_hash, c_b, "added line 2 is c_b");
1601 assert_eq!(w.lines[1].text, b"a b", "output keeps the current bytes");
1602 }
1603
1604 #[test]
1605 fn blame_w_blank_line_duplicate_is_position_stable() {
1606 let (_d, store) = fresh_store();
1611 let c_a = put_file_commit(&store, "f.txt", b"x\n\ny\n", vec![], 1, 100);
1612 let c_b = put_file_commit(&store, "f.txt", b"x\n\n\ny\n", vec![c_a], 2, 200);
1613 let opts = BlameOptions {
1614 ignore_whitespace: true,
1615 ..Default::default()
1616 };
1617 let w = blame_file_with(&store, c_b, "f.txt", &opts).unwrap();
1618 assert_eq!(w.lines.len(), 4);
1619 assert_eq!(w.lines[0].commit_hash, c_a, "x");
1620 assert_eq!(w.lines[1].commit_hash, c_a, "original blank");
1621 assert_eq!(w.lines[2].commit_hash, c_b, "added blank is new");
1622 assert_eq!(w.lines[3].commit_hash, c_a, "y");
1623 }
1624
1625 #[test]
1626 fn blame_duplicate_line_addition_is_position_stable() {
1627 let (_d, store) = fresh_store();
1631 let c_a = put_file_commit(&store, "f.txt", b"x\n", vec![], 1, 100);
1632 let c_b = put_file_commit(&store, "f.txt", b"x\nx\n", vec![c_a], 2, 200);
1633 let r = blame_file(&store, c_b, "f.txt").unwrap();
1634 assert_eq!(r.lines[0].commit_hash, c_a, "original line keeps c_a");
1635 assert_eq!(r.lines[1].commit_hash, c_b, "appended duplicate is c_b");
1636 }
1637
1638 #[test]
1639 fn blame_two_commits_with_modified_middle() {
1640 let (_d, store) = fresh_store();
1641 let c_a = put_file_commit(&store, "f.txt", b"a\nb\nc\n", vec![], 42, 1000);
1642 let c_b = put_file_commit(&store, "f.txt", b"a\nMOD\nc\n", vec![c_a], 42, 2000);
1643 let r = blame_file(&store, c_b, "f.txt").unwrap();
1644 assert_eq!(r.lines.len(), 3);
1645 assert_eq!(r.lines[0].commit_hash, c_a);
1646 assert_eq!(r.lines[1].commit_hash, c_b);
1647 assert_eq!(r.lines[2].commit_hash, c_a);
1648 assert_eq!(r.lines[1].text, b"MOD");
1649 }
1650
1651 #[test]
1652 fn blame_three_commits_progressive_changes() {
1653 let (_d, store) = fresh_store();
1654 let c_a = put_file_commit(&store, "f.txt", b"a\nb\n", vec![], 1, 100);
1655 let c_b = put_file_commit(&store, "f.txt", b"a\nb\nc\n", vec![c_a], 2, 200);
1656 let c_c = put_file_commit(&store, "f.txt", b"a\nX\nc\n", vec![c_b], 3, 300);
1657 let r = blame_file(&store, c_c, "f.txt").unwrap();
1658 assert_eq!(r.lines.len(), 3);
1659 assert_eq!(r.lines[0].commit_hash, c_a, "a from A");
1660 assert_eq!(r.lines[1].commit_hash, c_c, "X from C");
1661 assert_eq!(r.lines[2].commit_hash, c_b, "c from B");
1662 }
1663
1664 #[test]
1665 fn blame_tracks_additions() {
1666 let (_d, store) = fresh_store();
1667 let c_a = put_file_commit(&store, "f.txt", b"a\nb\n", vec![], 1, 100);
1668 let c_b = put_file_commit(&store, "f.txt", b"a\nNEW\nb\n", vec![c_a], 1, 200);
1669 let r = blame_file(&store, c_b, "f.txt").unwrap();
1670 assert_eq!(r.lines.len(), 3);
1671 assert_eq!(r.lines[0].commit_hash, c_a);
1672 assert_eq!(r.lines[1].commit_hash, c_b);
1673 assert_eq!(r.lines[2].commit_hash, c_a);
1674 }
1675
1676 #[test]
1677 fn blame_tracks_deletions() {
1678 let (_d, store) = fresh_store();
1679 let c_a = put_file_commit(&store, "f.txt", b"a\nb\nc\n", vec![], 1, 100);
1680 let c_b = put_file_commit(&store, "f.txt", b"a\nc\n", vec![c_a], 1, 200);
1681 let r = blame_file(&store, c_b, "f.txt").unwrap();
1682 assert_eq!(r.lines.len(), 2);
1683 assert_eq!(r.lines[0].commit_hash, c_a);
1684 assert_eq!(r.lines[1].commit_hash, c_a);
1685 }
1686
1687 #[test]
1688 fn blame_file_not_found_returns_error() {
1689 let (_d, store) = fresh_store();
1690 let c = put_file_commit(&store, "real.txt", b"x\n", vec![], 1, 100);
1691 let err = blame_file(&store, c, "missing.txt").unwrap_err();
1692 assert!(matches!(err, BlameError::FileNotFound(_)));
1693 }
1694
1695 #[test]
1696 fn lcs_identical_lines() {
1697 let lines: Vec<&[u8]> = vec![b"a", b"b", b"c"];
1698 let m = match_lines(&lines, &lines);
1699 assert_eq!(m, vec![Some(0), Some(1), Some(2)]);
1700 }
1701
1702 #[test]
1703 fn lcs_completely_different() {
1704 let old: Vec<&[u8]> = vec![b"a", b"b", b"c"];
1705 let new: Vec<&[u8]> = vec![b"x", b"y", b"z"];
1706 let m = match_lines(&old, &new);
1707 assert_eq!(m, vec![None, None, None]);
1708 }
1709
1710 #[test]
1711 fn lcs_duplicate_key_matches_earliest_new_line() {
1712 let old: Vec<&[u8]> = vec![b"ab"];
1717 let new: Vec<&[u8]> = vec![b"ab", b"ab"];
1718 assert_eq!(match_lines(&old, &new), vec![Some(0), None]);
1719 }
1720
1721 #[test]
1722 fn lcs_trailing_run_elision_would_be_unsound() {
1723 let old: Vec<&[u8]> = vec![b"a"];
1728 let new: Vec<&[u8]> = vec![b"b", b"a", b"a"];
1729 assert_eq!(match_lines(&old, &new), vec![None, Some(0), None]);
1730 }
1731
1732 proptest::proptest! {
1733 #[test]
1743 fn proptest_match_lines_matches_unelided_core(
1744 old in proptest::collection::vec(0u8..3, 0..12),
1745 new in proptest::collection::vec(0u8..3, 0..12),
1746 ) {
1747 let old_lines: Vec<[u8; 1]> = old.iter().map(|b| [*b]).collect();
1748 let new_lines: Vec<[u8; 1]> = new.iter().map(|b| [*b]).collect();
1749 let elided = match_lines(&old_lines, &new_lines);
1750 let reference = match_lines_core(&old_lines, &new_lines);
1751 proptest::prop_assert_eq!(elided, reference);
1752 }
1753 }
1754
1755 #[test]
1756 fn split_lines_handles_trailing_newline() {
1757 assert_eq!(
1758 split_lines(b"a\nb\nc\n"),
1759 vec![b"a".to_vec(), b"b".to_vec(), b"c".to_vec()]
1760 );
1761 }
1762
1763 #[test]
1764 fn split_lines_handles_no_trailing_newline() {
1765 assert_eq!(
1766 split_lines(b"a\nb\nc"),
1767 vec![b"a".to_vec(), b"b".to_vec(), b"c".to_vec()]
1768 );
1769 }
1770
1771 #[test]
1772 fn split_lines_empty() {
1773 assert!(split_lines(b"").is_empty());
1774 }
1775
1776 #[test]
1777 fn find_blob_in_nested_tree() {
1778 let (_d, store) = fresh_store();
1779 let blob = put_blob(&store, b"hello\n");
1780 let inner = Object::Tree(Tree {
1781 entries: vec![TreeEntry {
1782 name: b"main.txt".to_vec(),
1783 mode: EntryMode::Blob,
1784 object_hash: blob,
1785 }],
1786 });
1787 let inner_h = store.write(&serialize::serialize(&inner).unwrap()).unwrap();
1788 let outer = Object::Tree(Tree {
1789 entries: vec![TreeEntry {
1790 name: b"src".to_vec(),
1791 mode: EntryMode::Tree,
1792 object_hash: inner_h,
1793 }],
1794 });
1795 let outer_h = store.write(&serialize::serialize(&outer).unwrap()).unwrap();
1796 let found = find_blob_in_tree(&store, outer_h, "src/main.txt").unwrap();
1797 assert_eq!(found, Some(blob));
1798 let missing = find_blob_in_tree(&store, outer_h, "src/none.txt").unwrap();
1799 assert_eq!(missing, None);
1800 }
1801
1802 fn ignoring_precise(revs: &[Hash]) -> BlameOptions {
1806 BlameOptions {
1807 ignore_revs: Arc::new(revs.iter().copied().collect()),
1808 ignore_rev_precise: true,
1809 ignore_whitespace: true,
1810 ..Default::default()
1811 }
1812 }
1813
1814 fn ignoring(revs: &[Hash]) -> BlameOptions {
1816 BlameOptions {
1817 ignore_revs: Arc::new(revs.iter().copied().collect()),
1818 ..Default::default()
1819 }
1820 }
1821
1822 #[test]
1823 fn blame_ignore_rev_falls_through_reformat() {
1824 let (_d, store) = fresh_store();
1829 let c_a = put_file_commit(&store, "f.txt", b"alpha\nbeta\ngamma\n", vec![], 1, 100);
1830 let c_b = put_file_commit(
1831 &store,
1832 "f.txt",
1833 b"alpha\n beta \ngamma\n",
1834 vec![c_a],
1835 2,
1836 200,
1837 );
1838
1839 let plain = blame_file(&store, c_b, "f.txt").unwrap();
1841 assert_eq!(plain.lines[1].commit_hash, c_b);
1842
1843 let r = blame_file_with(&store, c_b, "f.txt", &ignoring(&[c_b])).unwrap();
1844 assert_eq!(
1845 r.lines[1].commit_hash, c_a,
1846 "ignored reformat falls through to the original commit"
1847 );
1848 assert_eq!(r.lines[1].text, b" beta ", "output keeps current bytes");
1849 assert!(
1850 r.lines.iter().all(|l| l.commit_hash == c_a),
1851 "no line is credited to the ignored commit"
1852 );
1853 }
1854
1855 #[test]
1856 fn blame_ignore_rev_keeps_genuine_insertion() {
1857 let (_d, store) = fresh_store();
1862 let c_a = put_file_commit(&store, "f.txt", b"alpha\nbeta\n", vec![], 1, 100);
1863 let c_b = put_file_commit(
1864 &store,
1865 "f.txt",
1866 b"alpha\nbeta\nBRANDNEW\ngamma\n",
1867 vec![c_a],
1868 2,
1869 200,
1870 );
1871
1872 let r = blame_file_with(&store, c_b, "f.txt", &ignoring(&[c_b])).unwrap();
1873 assert_eq!(r.lines[0].commit_hash, c_a, "alpha");
1874 assert_eq!(r.lines[1].commit_hash, c_a, "beta");
1875 assert_eq!(
1876 r.lines[2].commit_hash, c_b,
1877 "a genuine insertion stays on the ignored commit"
1878 );
1879 assert_eq!(r.lines[3].commit_hash, c_b, "…and the second insertion");
1880 }
1881
1882 #[test]
1883 fn blame_ignore_rev_pairs_changed_lines_to_distinct_origins() {
1884 let (_d, store) = fresh_store();
1889 let c1 = put_file_commit(&store, "f.txt", b"L1\nL2\nL3\nL4\n", vec![], 1, 100);
1890 let c2 = put_file_commit(&store, "f.txt", b"L1\nL2x\nL3\nL4\n", vec![c1], 2, 200);
1891 let c3 = put_file_commit(&store, "f.txt", b"L1\nL2y\nL3y\nL4\n", vec![c2], 3, 300);
1892
1893 let r = blame_file_with(&store, c3, "f.txt", &ignoring(&[c3])).unwrap();
1894 assert_eq!(r.lines[1].commit_hash, c2, "L2y inherits L2x's origin (c2)");
1895 assert_eq!(r.lines[2].commit_hash, c1, "L3y inherits L3's origin (c1)");
1896 assert!(r.lines.iter().all(|l| l.commit_hash != c3));
1897 }
1898
1899 #[test]
1900 fn blame_ignore_rev_unequal_hunk_more_added() {
1901 let (_d, store) = fresh_store();
1905 let c1 = put_file_commit(&store, "f.txt", b"L1\nMID\nL3\n", vec![], 1, 100);
1906 let c2 = put_file_commit(&store, "f.txt", b"L1\nMIDa\nMIDb\nL3\n", vec![c1], 2, 200);
1907
1908 let r = blame_file_with(&store, c2, "f.txt", &ignoring(&[c2])).unwrap();
1909 assert_eq!(r.lines[1].commit_hash, c1, "MIDa falls through to c1");
1910 assert_eq!(r.lines[2].commit_hash, c2, "MIDb has no pair → stays on c2");
1911 }
1912
1913 #[test]
1914 fn blame_ignore_rev_unequal_hunk_more_removed() {
1915 let (_d, store) = fresh_store();
1918 let c1 = put_file_commit(&store, "f.txt", b"L1\nM1\nM2\nL3\n", vec![], 1, 100);
1919 let c2 = put_file_commit(&store, "f.txt", b"L1\nMERGED\nL3\n", vec![c1], 2, 200);
1920
1921 let r = blame_file_with(&store, c2, "f.txt", &ignoring(&[c2])).unwrap();
1922 assert_eq!(r.lines[1].commit_hash, c1, "MERGED falls through to c1");
1923 }
1924
1925 #[test]
1926 fn blame_ignore_root_commit_keeps_its_lines() {
1927 let (_d, store) = fresh_store();
1931 let root = put_file_commit(&store, "f.txt", b"a\nb\n", vec![], 1, 100);
1932 let c2 = put_file_commit(&store, "f.txt", b"a\nb\nc\n", vec![root], 2, 200);
1933
1934 let r = blame_file_with(&store, c2, "f.txt", &ignoring(&[root])).unwrap();
1935 assert_eq!(r.lines[0].commit_hash, root, "a stays on the ignored root");
1936 assert_eq!(r.lines[1].commit_hash, root, "b stays on the ignored root");
1937 assert_eq!(r.lines[2].commit_hash, c2, "c is unaffected");
1938 }
1939
1940 #[test]
1941 fn blame_ignore_multiple_revs_chains_through() {
1942 let (_d, store) = fresh_store();
1945 let c1 = put_file_commit(&store, "f.txt", b"keep\nx\n", vec![], 1, 100);
1946 let c2 = put_file_commit(&store, "f.txt", b"keep\n x \n", vec![c1], 2, 200);
1947 let c3 = put_file_commit(&store, "f.txt", b"keep\n x \n", vec![c2], 3, 300);
1948
1949 let r = blame_file_with(&store, c3, "f.txt", &ignoring(&[c2, c3])).unwrap();
1950 assert_eq!(
1951 r.lines[1].commit_hash, c1,
1952 "line falls through both ignored reformats to c1"
1953 );
1954 }
1955
1956 #[test]
1957 fn blame_ignore_rev_fallthrough_not_overwritten_by_move_detection() {
1958 let (_d, store) = fresh_store();
1967 let dup: &[u8] = b"dupaaaaaaaaaaaaaaaaa"; let mid: &[u8] = b"MIDLINE";
1969 let x_v0: &[u8] = b"exoldbbbbbbbbbbbbbbb";
1970 let x_v1: &[u8] = b"exnewbbbbbbbbbbbbbbb";
1971 let c0 = put_file_commit(
1972 &store,
1973 "f.txt",
1974 &[dup, mid, x_v0, b""].join(&b'\n'),
1975 vec![],
1976 1,
1977 100,
1978 );
1979 let c1 = put_file_commit(
1981 &store,
1982 "f.txt",
1983 &[dup, mid, x_v1, b""].join(&b'\n'),
1984 vec![c0],
1985 2,
1986 200,
1987 );
1988 let c2 = put_file_commit(
1990 &store,
1991 "f.txt",
1992 &[dup, mid, dup, b""].join(&b'\n'),
1993 vec![c1],
1994 3,
1995 300,
1996 );
1997
1998 let opts = BlameOptions {
1999 moves: MoveDetection::On { threshold: 20 },
2000 ignore_revs: Arc::new([c2].into_iter().collect()),
2001 ..Default::default()
2002 };
2003 let r = blame_file_with(&store, c2, "f.txt", &opts).unwrap();
2004 assert_eq!(
2005 r.lines[2].commit_hash, c1,
2006 "fallthrough (replaced parent line → c1) wins; -M does not overwrite it to c0"
2007 );
2008 let plain = blame_file_with(&store, c2, "f.txt", &ignoring(&[c2])).unwrap();
2010 assert_eq!(
2011 plain.lines[2].commit_hash, c1,
2012 "-M did not change the result"
2013 );
2014 }
2015
2016 #[test]
2019 fn blame_ignore_rev_precise_reattributes_moved_reindented_lines() {
2020 let (_d, store) = fresh_store();
2034 let c0 = put_file_commit(&store, "f.txt", b"keep\ntail\n", vec![], 1, 100);
2035 let c1 = put_file_commit(&store, "f.txt", b"keep\nXXX\ntail\n", vec![c0], 2, 200);
2036 let c2 = put_file_commit(&store, "f.txt", b"keep\nXXX\nYYY\ntail\n", vec![c1], 3, 300);
2037 let c3 = put_file_commit(
2038 &store,
2039 "f.txt",
2040 b"keep\nXXX\nYYY\nZZZ\ntail\n",
2041 vec![c2],
2042 4,
2043 400,
2044 );
2045 let c4 = put_file_commit(
2046 &store,
2047 "f.txt",
2048 b"keep\n ZZZ\n YYY\n XXX\ntail\n",
2049 vec![c3],
2050 5,
2051 500,
2052 );
2053
2054 let positional_opts = BlameOptions {
2059 ignore_whitespace: true,
2060 ignore_revs: Arc::new([c4].into_iter().collect()),
2061 ..Default::default()
2062 };
2063 let positional = blame_file_with(&store, c4, "f.txt", &positional_opts).unwrap();
2064 assert_eq!(
2065 positional.lines[1].commit_hash, c3,
2066 "ZZZ is recognized unchanged by the LCS matcher itself (needs no fall-through)"
2067 );
2068 assert_eq!(
2069 positional.lines[2].commit_hash, c4,
2070 "positional: YYY has no in-hunk counterpart, stays on the ignored commit"
2071 );
2072 assert_eq!(
2073 positional.lines[3].commit_hash, c4,
2074 "positional: XXX has no in-hunk counterpart, stays on the ignored commit"
2075 );
2076
2077 let precise = blame_file_with(&store, c4, "f.txt", &ignoring_precise(&[c4])).unwrap();
2081 assert_eq!(
2082 precise.lines[1].commit_hash, c3,
2083 "ZZZ is unaffected by precise mode (already resolved by plain LCS)"
2084 );
2085 assert_eq!(
2086 precise.lines[2].commit_hash, c2,
2087 "precise: YYY correctly attributed to its true origin"
2088 );
2089 assert_eq!(
2090 precise.lines[3].commit_hash, c1,
2091 "precise: XXX correctly attributed to its true origin"
2092 );
2093 assert_eq!(precise.lines[0].commit_hash, c0, "keep is unaffected");
2094 assert_eq!(precise.lines[4].commit_hash, c0, "tail is unaffected");
2095 }
2096
2097 #[test]
2098 fn blame_ignore_rev_precise_unequal_hunk_surplus_stays_put() {
2099 let (_d, store) = fresh_store();
2105 let c1 = put_file_commit(&store, "f.txt", b"L1\nMID\nL3\n", vec![], 1, 100);
2106 let c2 = put_file_commit(
2107 &store,
2108 "f.txt",
2109 b"L1\nMIDaaa\nMIDbbb\nMIDccc\nL3\n",
2110 vec![c1],
2111 2,
2112 200,
2113 );
2114
2115 let plain = blame_file_with(&store, c2, "f.txt", &ignoring(&[c2])).unwrap();
2116 let precise = blame_file_with(&store, c2, "f.txt", &ignoring_precise(&[c2])).unwrap();
2117 for r in [&plain, &precise] {
2118 assert_eq!(r.lines[1].commit_hash, c1, "MIDaaa falls through to c1");
2119 assert_eq!(
2120 r.lines[2].commit_hash, c2,
2121 "MIDbbb has no pair -> stays on c2"
2122 );
2123 assert_eq!(
2124 r.lines[3].commit_hash, c2,
2125 "MIDccc has no pair -> stays on c2"
2126 );
2127 }
2128 }
2129
2130 #[test]
2131 fn blame_ignore_rev_precise_no_match_falls_back_to_positional() {
2132 let (_d, store) = fresh_store();
2136 let c1 = put_file_commit(&store, "f.txt", b"L1\nA\nL3\n", vec![], 1, 100);
2137 let c2 = put_file_commit(&store, "f.txt", b"L1\nFABRICATED\nL3\n", vec![c1], 2, 200);
2138
2139 let plain = blame_file_with(&store, c2, "f.txt", &ignoring(&[c2])).unwrap();
2140 let precise = blame_file_with(&store, c2, "f.txt", &ignoring_precise(&[c2])).unwrap();
2141 assert_eq!(plain.lines[1].commit_hash, c1);
2142 assert_eq!(
2143 precise.lines[1].commit_hash, plain.lines[1].commit_hash,
2144 "no content candidate exists anywhere in the parent: precise matches positional"
2145 );
2146 }
2147
2148 #[test]
2149 fn precise_overrides_trivial_key_guard() {
2150 let mapping = vec![None, None];
2163 let fall = vec![Some(2), None];
2164 let new_lines = vec![b"ab".to_vec(), b"REALZZZ".to_vec()];
2165 let parent_lines = vec![b"REALZZZ".to_vec(), b"ab".to_vec(), b"FILLER".to_vec()];
2166 let matched = vec![false, false];
2167
2168 let out = super::walk::precise_overrides(&super::walk::PreciseRequest {
2169 mapping: &mapping,
2170 fall: &fall,
2171 new_lines: &new_lines,
2172 parent_lines: &parent_lines,
2173 matched: &matched,
2174 ignore_whitespace: false,
2175 });
2176 assert_eq!(
2177 out[0],
2178 Some(2),
2179 "trivial key 'ab' keeps its positional guess even though a perfect \
2180 unclaimed match ('ab' at old index 1) exists"
2181 );
2182 assert_eq!(
2183 out[1],
2184 Some(0),
2185 "non-trivial key 'REALZZZ' fills its None positional slot from its \
2186 true content match (old index 0)"
2187 );
2188 }
2189
2190 #[test]
2191 fn precise_overrides_never_teleports_edited_line_worse_than_positional() {
2192 let mapping = vec![Some(0), None, Some(2), Some(3), Some(5)];
2212 let fall = vec![None, Some(1), None, None, None];
2213 let new_lines = vec![
2214 b"A".to_vec(),
2215 b"bar".to_vec(),
2216 b"B1".to_vec(),
2217 b"B2".to_vec(),
2218 b"C".to_vec(),
2219 ];
2220 let parent_lines = vec![
2221 b"A".to_vec(),
2222 b"foo".to_vec(),
2223 b"B1".to_vec(),
2224 b"B2".to_vec(),
2225 b"bar".to_vec(),
2226 b"C".to_vec(),
2227 ];
2228 let matched = vec![false; new_lines.len()];
2229
2230 let out = super::walk::precise_overrides(&super::walk::PreciseRequest {
2231 mapping: &mapping,
2232 fall: &fall,
2233 new_lines: &new_lines,
2234 parent_lines: &parent_lines,
2235 matched: &matched,
2236 ignore_whitespace: false,
2237 });
2238 assert_eq!(
2239 out[1],
2240 Some(1),
2241 "edited `bar` keeps its positional predecessor (foo @ old idx 1)"
2242 );
2243 assert_ne!(
2244 out[1],
2245 Some(4),
2246 "must NOT teleport to the unrelated `bar` @ old idx 4 (commit X)"
2247 );
2248 assert_eq!(
2249 out,
2250 vec![None, Some(1), None, None, None],
2251 "no slot is attributed worse than the positional fall-through"
2252 );
2253 }
2254
2255 #[test]
2256 fn precise_overrides_reindented_brace_does_not_teleport_without_w() {
2257 let mapping = vec![None];
2269 let fall = vec![None];
2270 let new_lines = vec![b" }".to_vec()];
2271 let parent_lines = vec![b" }".to_vec()]; let matched = vec![false];
2273
2274 let out = super::walk::precise_overrides(&super::walk::PreciseRequest {
2275 mapping: &mapping,
2276 fall: &fall,
2277 new_lines: &new_lines,
2278 parent_lines: &parent_lines,
2279 matched: &matched,
2280 ignore_whitespace: false,
2281 });
2282 assert_eq!(
2283 out[0], None,
2284 "an indented brace is trivial once whitespace-stripped; it must not \
2285 teleport to an unrelated brace even without -w"
2286 );
2287 }
2288
2289 #[test]
2290 fn ignore_fallthrough_pairs_per_hunk() {
2291 let mapping = vec![Some(0), None, None, Some(3)];
2296 assert_eq!(
2297 super::ignore_fallthrough(&mapping, 4),
2298 vec![None, Some(1), Some(2), None]
2299 );
2300 let mapping = vec![Some(0), None, None, Some(2)];
2303 assert_eq!(
2304 super::ignore_fallthrough(&mapping, 3),
2305 vec![None, Some(1), None, None]
2306 );
2307 let mapping = vec![Some(0), Some(1), None, None];
2309 assert_eq!(
2310 super::ignore_fallthrough(&mapping, 2),
2311 vec![None, None, None, None]
2312 );
2313 }
2314
2315 #[test]
2316 fn reverse_attributes_each_line_to_last_commit_it_survived() {
2317 let (_d, store) = fresh_store();
2321 let c1 = put_file_commit(&store, "f.txt", b"keep\ndoomed\nalso\n", vec![], 1, 100);
2322 let c2 = put_file_commit(
2323 &store,
2324 "f.txt",
2325 b"keep\ndoomed\nalso\nextra\n",
2326 vec![c1],
2327 2,
2328 200,
2329 );
2330 let c3 = put_file_commit(&store, "f.txt", b"keep\nalso\nextra\n", vec![c2], 3, 300);
2331 let c4 = put_file_commit(&store, "f.txt", b"keep\nalso\nextra2\n", vec![c3], 4, 400);
2332
2333 let r = blame_file_reverse(&store, c1, c4, "f.txt", &BlameOptions::default()).unwrap();
2334 assert_eq!(r.lines.len(), 3, "blames the start (c1) version's 3 lines");
2335 assert_eq!(r.lines[0].text, b"keep");
2336 assert_eq!(r.lines[0].commit_hash, c4, "keep survives to the end");
2337 assert_eq!(r.lines[1].text, b"doomed");
2338 assert_eq!(r.lines[1].commit_hash, c2, "doomed last existed in c2");
2339 assert_eq!(r.lines[2].text, b"also");
2340 assert_eq!(r.lines[2].commit_hash, c4, "also survives to the end");
2341 }
2342
2343 #[test]
2344 fn reverse_line_removed_immediately_stays_on_start() {
2345 let (_d, store) = fresh_store();
2349 let c1 = put_file_commit(&store, "f.txt", b"keep\ngone\n", vec![], 1, 100);
2350 let c2 = put_file_commit(&store, "f.txt", b"keep\n", vec![c1], 2, 200);
2351 let c3 = put_file_commit(&store, "f.txt", b"keep\nnew\n", vec![c2], 3, 300);
2352
2353 let r = blame_file_reverse(&store, c1, c3, "f.txt", &BlameOptions::default()).unwrap();
2354 assert_eq!(r.lines[0].commit_hash, c3, "keep survives to the end");
2355 assert_eq!(
2356 r.lines[1].commit_hash, c1,
2357 "gone never survived a step → stays on start"
2358 );
2359 assert_eq!(r.lines[1].text, b"gone");
2360 }
2361
2362 #[test]
2363 fn reverse_modified_line_freezes_before_the_edit() {
2364 let (_d, store) = fresh_store();
2367 let c1 = put_file_commit(&store, "f.txt", b"a\nMOD\nc\n", vec![], 1, 100);
2368 let c2 = put_file_commit(&store, "f.txt", b"a\nMOD2\nc\n", vec![c1], 2, 200);
2369 let c3 = put_file_commit(&store, "f.txt", b"a\nMOD3\nc\n", vec![c2], 3, 300);
2370
2371 let r = blame_file_reverse(&store, c1, c3, "f.txt", &BlameOptions::default()).unwrap();
2372 assert_eq!(r.lines[0].commit_hash, c3, "a survives");
2373 assert_eq!(r.lines[1].commit_hash, c1, "MOD changed in c2 → last in c1");
2374 assert_eq!(r.lines[2].commit_hash, c3, "c survives");
2375 }
2376
2377 #[test]
2378 fn reverse_unchanged_commit_advances_attribution() {
2379 let (_d, store) = fresh_store();
2382 let c1 = put_file_commit(&store, "f.txt", b"a\n", vec![], 1, 100);
2383 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();
2387 assert_eq!(
2388 r.lines[0].commit_hash, c2,
2389 "a last existed in the unchanged c2, gone by c3"
2390 );
2391 }
2392
2393 #[test]
2394 fn reverse_open_end_walks_to_provided_end() {
2395 let (_d, store) = fresh_store();
2398 let c1 = put_file_commit(&store, "f.txt", b"keep\ndoomed\n", vec![], 1, 100);
2399 let c2 = put_file_commit(&store, "f.txt", b"keep\ndoomed\nx\n", vec![c1], 2, 200);
2400 let _c3 = put_file_commit(&store, "f.txt", b"keep\nx\n", vec![c2], 3, 300);
2401
2402 let r = blame_file_reverse(&store, c1, c2, "f.txt", &BlameOptions::default()).unwrap();
2403 assert!(
2404 r.lines.iter().all(|l| l.commit_hash == c2),
2405 "with the range ending at c2 both start lines last exist in c2"
2406 );
2407 }
2408
2409 #[test]
2410 fn reverse_traces_through_whitespace_edit_under_w() {
2411 let (_d, store) = fresh_store();
2414 let c1 = put_file_commit(&store, "f.txt", b"foo(a, b)\n", vec![], 1, 100);
2415 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);
2417
2418 let plain = blame_file_reverse(&store, c1, c3, "f.txt", &BlameOptions::default()).unwrap();
2420 assert_eq!(
2421 plain.lines[0].commit_hash, c1,
2422 "ws edit ends the line at c1"
2423 );
2424
2425 let w = BlameOptions {
2426 ignore_whitespace: true,
2427 ..Default::default()
2428 };
2429 let rw = blame_file_reverse(&store, c1, c3, "f.txt", &w).unwrap();
2430 assert_eq!(
2431 rw.lines[0].commit_hash, c2,
2432 "-w traces the line through the reformat → last exists in c2"
2433 );
2434 }
2435
2436 #[test]
2437 fn reverse_start_not_ancestor_errors() {
2438 let (_d, store) = fresh_store();
2439 let c1 = put_file_commit(&store, "f.txt", b"a\n", vec![], 1, 100);
2440 let other = put_file_commit(&store, "f.txt", b"z\n", vec![], 9, 900);
2442 let err =
2443 blame_file_reverse(&store, other, c1, "f.txt", &BlameOptions::default()).unwrap_err();
2444 assert!(
2445 matches!(err, BlameError::ReverseRange { .. }),
2446 "got {err:?}"
2447 );
2448 }
2449
2450 #[test]
2451 fn reverse_missing_path_in_start_errors() {
2452 let (_d, store) = fresh_store();
2453 let c1 = put_file_commit(&store, "other.txt", b"x\n", vec![], 1, 100);
2454 let c2 = put_file_commit(&store, "f.txt", b"y\n", vec![c1], 2, 200);
2455 let err =
2456 blame_file_reverse(&store, c1, c2, "f.txt", &BlameOptions::default()).unwrap_err();
2457 assert!(matches!(err, BlameError::FileNotFound(_)), "got {err:?}");
2458 }
2459
2460 fn diamond_distinct(store: &ObjectStore) -> (Hash, Hash, Hash, Hash) {
2467 let base = put_file_commit(store, "f.txt", b"base1\nbase2\n", vec![], 1, 100);
2468 let feat = put_file_commit(
2469 store,
2470 "f.txt",
2471 b"base1\nbase2\nfeature\n",
2472 vec![base],
2473 2,
2474 200,
2475 );
2476 let main = put_file_commit(store, "f.txt", b"main\nbase1\nbase2\n", vec![base], 3, 300);
2477 let merge = put_file_commit(
2478 store,
2479 "f.txt",
2480 b"main\nbase1\nbase2\nfeature\n",
2481 vec![main, feat],
2482 4,
2483 400,
2484 );
2485 (base, main, feat, merge)
2486 }
2487
2488 #[test]
2489 fn blame_merge_aware_credits_side_branch_lines() {
2490 let (_d, store) = fresh_store();
2493 let (base, main, feat, merge) = diamond_distinct(&store);
2494 let r = blame_file(&store, merge, "f.txt").unwrap();
2495 assert_eq!(r.lines[0].commit_hash, main, "main-line → main");
2496 assert_eq!(r.lines[1].commit_hash, base, "base1 → base");
2497 assert_eq!(r.lines[2].commit_hash, base, "base2 → base");
2498 assert_eq!(r.lines[3].commit_hash, feat, "feature → feature commit");
2499 assert!(
2500 r.lines.iter().all(|l| l.commit_hash != merge),
2501 "none → merge"
2502 );
2503 }
2504
2505 #[test]
2506 fn blame_first_parent_credits_merge_for_side_branch_line() {
2507 let (_d, store) = fresh_store();
2510 let (base, main, _feat, merge) = diamond_distinct(&store);
2511 let opts = BlameOptions {
2512 first_parent: true,
2513 ..Default::default()
2514 };
2515 let r = blame_file_with(&store, merge, "f.txt", &opts).unwrap();
2516 assert_eq!(r.lines[0].commit_hash, main, "main-line → main");
2517 assert_eq!(r.lines[1].commit_hash, base);
2518 assert_eq!(r.lines[3].commit_hash, merge, "feature line → merge");
2519 }
2520
2521 #[test]
2522 fn blame_merge_identical_line_goes_to_first_parent() {
2523 let (_d, store) = fresh_store();
2525 let base = put_file_commit(&store, "f.txt", b"base\n", vec![], 1, 100);
2526 let feat = put_file_commit(&store, "f.txt", b"base\nshared\n", vec![base], 2, 200);
2527 let main = put_file_commit(&store, "f.txt", b"base\nshared\n", vec![base], 3, 300);
2528 let merge = put_file_commit(&store, "f.txt", b"base\nshared\n", vec![main, feat], 4, 400);
2529 let r = blame_file(&store, merge, "f.txt").unwrap();
2530 assert_eq!(r.lines[1].commit_hash, main, "shared line → first parent");
2531 assert!(r.lines.iter().all(|l| l.commit_hash != feat));
2532 }
2533
2534 #[test]
2535 fn blame_evil_merge_attributes_new_line_to_merge() {
2536 let (_d, store) = fresh_store();
2539 let base = put_file_commit(&store, "f.txt", b"base\n", vec![], 1, 100);
2540 let feat = put_file_commit(&store, "f.txt", b"base\nfeat\n", vec![base], 2, 200);
2541 let main = put_file_commit(&store, "f.txt", b"main\nbase\n", vec![base], 3, 300);
2542 let merge = put_file_commit(
2543 &store,
2544 "f.txt",
2545 b"main\nbase\nfeat\nEVIL\n",
2546 vec![main, feat],
2547 4,
2548 400,
2549 );
2550 let r = blame_file(&store, merge, "f.txt").unwrap();
2551 assert_eq!(r.lines[0].commit_hash, main);
2552 assert_eq!(r.lines[1].commit_hash, base);
2553 assert_eq!(r.lines[2].commit_hash, feat);
2554 assert_eq!(r.lines[3].commit_hash, merge, "the evil line → merge");
2555 }
2556
2557 #[test]
2558 fn blame_octopus_merge_credits_each_branch() {
2559 let (_d, store) = fresh_store();
2561 let base = put_file_commit(&store, "f.txt", b"base\n", vec![], 1, 100);
2562 let b1 = put_file_commit(&store, "f.txt", b"base\nb1\n", vec![base], 2, 200);
2563 let b2 = put_file_commit(&store, "f.txt", b"base\nb2\n", vec![base], 3, 300);
2564 let b3 = put_file_commit(&store, "f.txt", b"base\nb3\n", vec![base], 4, 400);
2565 let merge = put_file_commit(
2566 &store,
2567 "f.txt",
2568 b"base\nb1\nb2\nb3\n",
2569 vec![b1, b2, b3],
2570 5,
2571 500,
2572 );
2573 let r = blame_file(&store, merge, "f.txt").unwrap();
2574 assert_eq!(r.lines[0].commit_hash, base);
2575 assert_eq!(r.lines[1].commit_hash, b1);
2576 assert_eq!(r.lines[2].commit_hash, b2);
2577 assert_eq!(r.lines[3].commit_hash, b3);
2578 }
2579
2580 #[test]
2581 fn blame_m_merge_credits_move_from_second_parent() {
2582 let (_d, store) = fresh_store();
2588 let base = put_file_commit(&store, "f.txt", b"X\nY\n", vec![], 1, 100);
2589 let p1 = put_file_commit(&store, "f.txt", b"X\nY\nZ\n", vec![base], 2, 200);
2591 let v2 = [LONG_LINE, b"X", b"Y", b""].join(&b'\n');
2593 let c2 = put_file_commit(&store, "f.txt", &v2, vec![base], 3, 300);
2594 let vm = [b"X" as &[u8], b"Y", b"Z", LONG_LINE, b""].join(&b'\n');
2596 let merge = put_file_commit(&store, "f.txt", &vm, vec![p1, c2], 4, 400);
2597
2598 let opts = BlameOptions {
2599 moves: MoveDetection::On { threshold: 20 },
2600 ..Default::default()
2601 };
2602 let r = blame_file_with(&store, merge, "f.txt", &opts).unwrap();
2603 assert_eq!(r.lines[3].text, LONG_LINE);
2605 assert_eq!(
2606 r.lines[3].commit_hash, c2,
2607 "-M credits the move to the 2nd-parent origin, not the merge"
2608 );
2609 assert_eq!(r.lines[2].commit_hash, p1, "Z stays on the first parent");
2610 }
2611
2612 #[test]
2613 fn blame_m_merge_move_prefers_first_parent() {
2614 let (_d, store) = fresh_store();
2620 let base = put_file_commit(&store, "f.txt", b"X\nY\n", vec![], 1, 100);
2621 let v = [LONG_LINE, b"X", b"Y", b""].join(&b'\n');
2622 let p1 = put_file_commit(&store, "f.txt", &v, vec![base], 2, 200);
2623 let c2 = put_file_commit(&store, "f.txt", &v, vec![base], 3, 300);
2624 let vm = [b"X" as &[u8], b"Y", LONG_LINE, b""].join(&b'\n');
2625 let merge = put_file_commit(&store, "f.txt", &vm, vec![p1, c2], 4, 400);
2626
2627 let opts = BlameOptions {
2628 moves: MoveDetection::On { threshold: 20 },
2629 ..Default::default()
2630 };
2631 let r = blame_file_with(&store, merge, "f.txt", &opts).unwrap();
2632 assert_eq!(r.lines[2].text, LONG_LINE);
2634 assert_eq!(
2635 r.lines[2].commit_hash, p1,
2636 "-M move at a merge prefers the first parent on a tie"
2637 );
2638 assert!(
2639 r.lines.iter().all(|l| l.commit_hash != c2),
2640 "the second parent never wins the tie"
2641 );
2642 }
2643
2644 #[test]
2645 fn blame_ignore_rev_merge_falls_through_to_second_parent() {
2646 let (_d, store) = fresh_store();
2653 let base = put_file_commit(&store, "f.txt", b"TOP\nMID\nBOT\n", vec![], 1, 100);
2654 let p1 = put_file_commit(&store, "f.txt", b"TOP\nBOT\n", vec![base], 2, 200);
2656 let v2 = [b"TOP" as &[u8], b"REAL_CONTENT_OF_B_LINE", b"BOT", b""].join(&b'\n');
2658 let c2 = put_file_commit(&store, "f.txt", &v2, vec![base], 3, 300);
2659 let vm = [b"TOP" as &[u8], b" REAL_CONTENT_OF_B_LINE X", b"BOT", b""].join(&b'\n');
2661 let merge = put_file_commit(&store, "f.txt", &vm, vec![p1, c2], 4, 400);
2662
2663 let r = blame_file_with(&store, merge, "f.txt", &ignoring(&[merge])).unwrap();
2664 assert_eq!(r.lines[1].text, b" REAL_CONTENT_OF_B_LINE X");
2665 assert_eq!(
2666 r.lines[1].commit_hash, c2,
2667 "ignored merge falls through across to the 2nd parent's origin"
2668 );
2669 }
2670
2671 #[test]
2672 fn blame_ignore_rev_merge_prefers_first_parent_counterpart() {
2673 let (_d, store) = fresh_store();
2678 let base = put_file_commit(&store, "f.txt", b"a\nb\nc\n", vec![], 1, 100);
2679 let p1 = put_file_commit(
2680 &store,
2681 "f.txt",
2682 b"a\nMAIN_B_VERSION\nc\n",
2683 vec![base],
2684 2,
2685 200,
2686 );
2687 let v2 = [b"a" as &[u8], b"REAL_CONTENT_OF_B_LINE", b"c", b""].join(&b'\n');
2688 let c2 = put_file_commit(&store, "f.txt", &v2, vec![base], 3, 300);
2689 let vm = [b"a" as &[u8], b" REAL_CONTENT_OF_B_LINE X", b"c", b""].join(&b'\n');
2690 let merge = put_file_commit(&store, "f.txt", &vm, vec![p1, c2], 4, 400);
2691
2692 let r = blame_file_with(&store, merge, "f.txt", &ignoring(&[merge])).unwrap();
2693 assert_eq!(
2694 r.lines[1].commit_hash, p1,
2695 "fall-through prefers the first parent on a positional tie"
2696 );
2697 assert!(
2698 r.lines.iter().all(|l| l.commit_hash != c2),
2699 "the second parent does not win when the first parent has a counterpart"
2700 );
2701 }
2702
2703 #[test]
2704 fn blame_ignore_rev_precise_merge_second_parent_composition() {
2705 let (_d, store) = fresh_store();
2720 let base = put_file_commit(&store, "f.txt", b"TOP\nBOT\n", vec![], 1, 100);
2721 let p1 = put_file_commit(&store, "f.txt", b"TOP\nBOT\n", vec![base], 2, 200);
2723 let c_x = put_file_commit(&store, "f.txt", b"TOP\nXXX\nBOT\n", vec![base], 3, 300);
2725 let c_y = put_file_commit(&store, "f.txt", b"TOP\nXXX\nYYY\nBOT\n", vec![c_x], 4, 400);
2726 let c_z = put_file_commit(
2727 &store,
2728 "f.txt",
2729 b"TOP\nXXX\nYYY\nZZZ\nBOT\n",
2730 vec![c_y],
2731 5,
2732 500,
2733 );
2734 let merge = put_file_commit(
2737 &store,
2738 "f.txt",
2739 b"TOP\n ZZZ\n YYY\n XXX\nBOT\n",
2740 vec![p1, c_z],
2741 6,
2742 600,
2743 );
2744
2745 let positional_opts = BlameOptions {
2746 ignore_whitespace: true,
2747 ignore_revs: Arc::new([merge].into_iter().collect()),
2748 ..Default::default()
2749 };
2750 let positional = blame_file_with(&store, merge, "f.txt", &positional_opts).unwrap();
2751 assert_eq!(
2752 positional.lines[1].commit_hash, c_z,
2753 "ZZZ is recognized unchanged by the LCS matcher itself, against the 2nd parent"
2754 );
2755 assert_eq!(
2756 positional.lines[2].commit_hash, merge,
2757 "positional: YYY has no in-hunk counterpart on either parent, stays on the merge"
2758 );
2759 assert_eq!(
2760 positional.lines[3].commit_hash, merge,
2761 "positional: XXX has no in-hunk counterpart on either parent, stays on the merge"
2762 );
2763
2764 let precise = blame_file_with(&store, merge, "f.txt", &ignoring_precise(&[merge])).unwrap();
2765 assert_eq!(
2766 precise.lines[1].commit_hash, c_z,
2767 "ZZZ is unaffected by precise mode (already resolved by plain LCS)"
2768 );
2769 assert_eq!(
2770 precise.lines[2].commit_hash, c_y,
2771 "precise: YYY correctly attributed to its true origin via the 2nd parent's whole file"
2772 );
2773 assert_eq!(
2774 precise.lines[3].commit_hash, c_x,
2775 "precise: XXX correctly attributed to its true origin via the 2nd parent's whole file"
2776 );
2777 assert!(
2778 positional.lines.iter().all(|l| l.commit_hash != p1)
2779 && precise.lines.iter().all(|l| l.commit_hash != p1),
2780 "the content-less first parent never wins"
2781 );
2782 }
2783
2784 #[test]
2785 fn blame_c_merge_credits_copy_from_second_parent() {
2786 let (_d, store) = fresh_store();
2795 let base = put_multi_file_commit(&store, &[("b.txt", b"hello\n")], vec![], 1, 100);
2796 let p1 = put_multi_file_commit(
2797 &store,
2798 &[("b.txt", b"hello\n"), ("m.txt", b"main only\n")],
2799 vec![base],
2800 2,
2801 200,
2802 );
2803 let src = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
2804 let c2 = put_multi_file_commit(
2805 &store,
2806 &[("b.txt", b"hello\n"), ("src.txt", &src)],
2807 vec![base],
2808 3,
2809 300,
2810 );
2811 let bmerge = [b"hello" as &[u8], BLOCK_A, BLOCK_B, b""].join(&b'\n');
2812 let merge = put_multi_file_commit(
2813 &store,
2814 &[
2815 ("b.txt", &bmerge),
2816 ("m.txt", b"main only\n"),
2817 ("src.txt", &src),
2818 ],
2819 vec![p1, c2],
2820 4,
2821 400,
2822 );
2823
2824 let opts = BlameOptions {
2825 copies: CopyDetection::On {
2826 level: 2,
2827 threshold: 40,
2828 },
2829 ..Default::default()
2830 };
2831 let r = blame_file_with(&store, merge, "b.txt", &opts).unwrap();
2832 assert_eq!(r.lines[1].text, BLOCK_A);
2834 assert_eq!(
2835 r.lines[1].commit_hash, c2,
2836 "a modified file's appended block is copied across to the second parent's tree (git credits c2)"
2837 );
2838 assert_eq!(
2839 r.lines[2].commit_hash, c2,
2840 "the whole copied block is credited to c2"
2841 );
2842 assert_ne!(r.lines[1].commit_hash, merge);
2844 }
2845
2846 #[test]
2847 fn blame_c_merge_credits_copy_from_third_octopus_parent() {
2848 let (_d, store) = fresh_store();
2854 let base = put_multi_file_commit(&store, &[("b.txt", b"hello\n")], vec![], 1, 100);
2855 let p1 = put_multi_file_commit(
2856 &store,
2857 &[("b.txt", b"hello\n"), ("x.txt", b"x only\n")],
2858 vec![base],
2859 2,
2860 200,
2861 );
2862 let p2 = put_multi_file_commit(
2863 &store,
2864 &[("b.txt", b"hello\n"), ("y.txt", b"y only\n")],
2865 vec![base],
2866 3,
2867 300,
2868 );
2869 let src = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
2870 let p3 = put_multi_file_commit(
2871 &store,
2872 &[("b.txt", b"hello\n"), ("src.txt", &src)],
2873 vec![base],
2874 4,
2875 400,
2876 );
2877 let bmerge = [b"hello" as &[u8], BLOCK_A, BLOCK_B, b""].join(&b'\n');
2878 let merge = put_multi_file_commit(
2879 &store,
2880 &[
2881 ("b.txt", &bmerge),
2882 ("x.txt", b"x only\n"),
2883 ("y.txt", b"y only\n"),
2884 ("src.txt", &src),
2885 ],
2886 vec![p1, p2, p3],
2887 5,
2888 500,
2889 );
2890
2891 let opts = BlameOptions {
2892 copies: CopyDetection::On {
2893 level: 2,
2894 threshold: 40,
2895 },
2896 ..Default::default()
2897 };
2898 let r = blame_file_with(&store, merge, "b.txt", &opts).unwrap();
2899 assert_eq!(r.lines[1].text, BLOCK_A);
2900 assert_eq!(
2901 r.lines[1].commit_hash, p3,
2902 "the copied block is traced to the third octopus parent's tree"
2903 );
2904 }
2905
2906 #[test]
2907 fn blame_c_merge_source_only_in_merge_tree_credits_merge() {
2908 let (_d, store) = fresh_store();
2913 let base = put_multi_file_commit(&store, &[("b.txt", b"hello\n")], vec![], 1, 100);
2914 let p1 = put_multi_file_commit(
2915 &store,
2916 &[("b.txt", b"hello\n"), ("m.txt", b"main only\n")],
2917 vec![base],
2918 2,
2919 200,
2920 );
2921 let c2 = put_multi_file_commit(
2922 &store,
2923 &[("b.txt", b"hello\n"), ("o.txt", b"other\n")],
2924 vec![base],
2925 3,
2926 300,
2927 );
2928 let src = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
2929 let bmerge = [b"hello" as &[u8], BLOCK_A, BLOCK_B, b""].join(&b'\n');
2930 let merge = put_multi_file_commit(
2932 &store,
2933 &[
2934 ("b.txt", &bmerge),
2935 ("m.txt", b"main only\n"),
2936 ("o.txt", b"other\n"),
2937 ("src.txt", &src),
2938 ],
2939 vec![p1, c2],
2940 4,
2941 400,
2942 );
2943
2944 let opts = BlameOptions {
2945 copies: CopyDetection::On {
2946 level: 2,
2947 threshold: 40,
2948 },
2949 ..Default::default()
2950 };
2951 let r = blame_file_with(&store, merge, "b.txt", &opts).unwrap();
2952 assert_eq!(r.lines[1].text, BLOCK_A);
2953 assert_eq!(
2954 r.lines[1].commit_hash, merge,
2955 "no parent holds the source, so the appended block stays on the merge (git parity)"
2956 );
2957 }
2958
2959 #[test]
2960 fn blame_c_merge_copy_tie_prefers_deduped_second_parent() {
2961 let (_d, store) = fresh_store();
2975 let base = put_multi_file_commit(&store, &[("b.txt", b"hello\n")], vec![], 1, 100);
2976 let src = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
2977 let p1 = put_multi_file_commit(
2978 &store,
2979 &[("b.txt", b"hello\n"), ("s1.txt", &src)],
2980 vec![base],
2981 2,
2982 200,
2983 );
2984 let c2 = put_multi_file_commit(
2985 &store,
2986 &[("b.txt", b"hello\n"), ("s2.txt", &src)],
2987 vec![base],
2988 3,
2989 300,
2990 );
2991 let bmerge = [b"hello" as &[u8], BLOCK_A, BLOCK_B, b""].join(&b'\n');
2992 let merge = put_multi_file_commit(
2993 &store,
2994 &[("b.txt", &bmerge), ("s1.txt", &src), ("s2.txt", &src)],
2995 vec![p1, c2],
2996 4,
2997 400,
2998 );
2999
3000 let opts = BlameOptions {
3001 copies: CopyDetection::On {
3002 level: 2,
3003 threshold: 40,
3004 },
3005 ..Default::default()
3006 };
3007 let r = blame_file_with(&store, merge, "b.txt", &opts).unwrap();
3008 assert_eq!(r.lines[1].text, BLOCK_A);
3009 assert_eq!(
3010 r.lines[1].commit_hash, c2,
3011 "a copy tie across parents resolves to the non-first parent (git parity)"
3012 );
3013 assert_eq!(r.lines[2].commit_hash, c2);
3014 assert!(
3015 r.lines.iter().all(|l| l.commit_hash != p1),
3016 "the first parent never wins an interior -C tie"
3017 );
3018 }
3019
3020 #[test]
3021 fn blame_c_merge_copy_tie_octopus_prefers_first_non_first_parent() {
3022 let (_d, store) = fresh_store();
3037 let base = put_multi_file_commit(&store, &[("b.txt", b"hello\n")], vec![], 1, 100);
3038 let p1 = put_multi_file_commit(
3039 &store,
3040 &[("b.txt", b"hello\n"), ("pm.txt", b"p1 only\n")],
3041 vec![base],
3042 2,
3043 200,
3044 );
3045 let src = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
3046 let c2 = put_multi_file_commit(
3047 &store,
3048 &[("b.txt", b"hello\n"), ("s2.txt", &src)],
3049 vec![base],
3050 3,
3051 300,
3052 );
3053 let c3 = put_multi_file_commit(
3054 &store,
3055 &[("b.txt", b"hello\n"), ("s3.txt", &src)],
3056 vec![base],
3057 4,
3058 400,
3059 );
3060 let bmerge = [b"hello" as &[u8], BLOCK_A, BLOCK_B, b""].join(&b'\n');
3061 let merge = put_multi_file_commit(
3062 &store,
3063 &[("b.txt", &bmerge), ("s2.txt", &src), ("s3.txt", &src)],
3064 vec![p1, c2, c3],
3065 5,
3066 500,
3067 );
3068
3069 let opts = BlameOptions {
3070 copies: CopyDetection::On {
3071 level: 2,
3072 threshold: 40,
3073 },
3074 ..Default::default()
3075 };
3076 let r = blame_file_with(&store, merge, "b.txt", &opts).unwrap();
3077 assert_eq!(r.lines[1].text, BLOCK_A);
3078 assert_eq!(
3079 r.lines[1].commit_hash, c2,
3080 "the first NON-first parent (in order) wins the octopus tie, not the literal last parent"
3081 );
3082 assert_eq!(r.lines[2].commit_hash, c2);
3083 }
3084
3085 #[test]
3086 fn blame_c_merge_copy_source_only_on_first_parent_stays_on_merge() {
3087 let (_d, store) = fresh_store();
3105 let base = put_multi_file_commit(&store, &[("b.txt", b"hello\n")], vec![], 1, 100);
3106 let src = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
3107 let p1 = put_multi_file_commit(
3108 &store,
3109 &[("b.txt", b"hello\n"), ("s1.txt", &src)],
3110 vec![base],
3111 2,
3112 200,
3113 );
3114 let c2 = put_multi_file_commit(
3115 &store,
3116 &[
3117 ("b.txt", b"hello\n"),
3118 ("m.txt", b"other unrelated content\n"),
3119 ],
3120 vec![base],
3121 3,
3122 300,
3123 );
3124 let bmerge = [b"hello" as &[u8], BLOCK_A, BLOCK_B, b""].join(&b'\n');
3125 let merge = put_multi_file_commit(
3126 &store,
3127 &[
3128 ("b.txt", &bmerge),
3129 ("s1.txt", &src),
3130 ("m.txt", b"other unrelated content\n"),
3131 ],
3132 vec![p1, c2],
3133 4,
3134 400,
3135 );
3136
3137 let opts = BlameOptions {
3138 copies: CopyDetection::On {
3139 level: 2,
3140 threshold: 40,
3141 },
3142 ..Default::default()
3143 };
3144 let r = blame_file_with(&store, merge, "b.txt", &opts).unwrap();
3145 assert_eq!(r.lines[1].text, BLOCK_A);
3146 assert_eq!(
3147 r.lines[1].commit_hash, merge,
3148 "an uncontested -C source on the first parent is never traced (git parity)"
3149 );
3150 assert_eq!(r.lines[2].commit_hash, merge);
3151 }
3152
3153 #[test]
3154 fn blame_c_merge_unmodified_first_parent_source_with_fileless_second_stays_on_merge() {
3155 let (_d, store) = fresh_store();
3168 let base = put_multi_file_commit(
3169 &store,
3170 &[
3171 ("b.txt", b"hello\n"),
3172 ("sbase.txt", b"source header line\n"),
3173 ],
3174 vec![],
3175 1,
3176 100,
3177 );
3178 let src = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
3179 let p1 = put_multi_file_commit(
3180 &store,
3181 &[
3182 ("b.txt", b"hello\n"),
3183 ("sbase.txt", b"source header line\n"),
3184 ("s1.txt", &src),
3185 ],
3186 vec![base],
3187 2,
3188 200,
3189 );
3190 let p2 = put_multi_file_commit(
3191 &store,
3192 &[("sbase.txt", b"source header line\n")],
3193 vec![base],
3194 3,
3195 300,
3196 );
3197 let bmerge = [b"hello" as &[u8], BLOCK_A, BLOCK_B, b""].join(&b'\n');
3198 let merge = put_multi_file_commit(
3199 &store,
3200 &[
3201 ("b.txt", &bmerge),
3202 ("sbase.txt", b"source header line\n"),
3203 ("s1.txt", &src),
3204 ],
3205 vec![p1, p2],
3206 4,
3207 400,
3208 );
3209
3210 let opts = BlameOptions {
3211 copies: CopyDetection::On {
3212 level: 2,
3213 threshold: 40,
3214 },
3215 ..Default::default()
3216 };
3217 let r = blame_file_with(&store, merge, "b.txt", &opts).unwrap();
3218 assert_eq!(r.lines[1].text, BLOCK_A);
3219 assert_eq!(
3220 r.lines[1].commit_hash, merge,
3221 "a fileless second parent does not make the merge linear: p1's \
3222 unchanged source stays invisible and the block stays on the merge (git parity)"
3223 );
3224 assert_eq!(r.lines[2].commit_hash, merge);
3225 }
3226
3227 #[test]
3228 fn blame_c_merge_file_deleting_parent_supplies_copy_source() {
3229 let (_d, store) = fresh_store();
3240 let base = put_multi_file_commit(
3241 &store,
3242 &[("f.txt", b"hello\n"), ("s.txt", b"source header line\n")],
3243 vec![],
3244 1,
3245 100,
3246 );
3247 let p1 = put_multi_file_commit(
3248 &store,
3249 &[("f.txt", b"hello\n"), ("s.txt", b"source header line\n")],
3250 vec![base],
3251 2,
3252 200,
3253 );
3254 let src = [
3255 b"source header line" as &[u8],
3256 BLOCK_A,
3257 BLOCK_B,
3258 b"zzz",
3259 b"",
3260 ]
3261 .join(&b'\n');
3262 let p2 = put_multi_file_commit(&store, &[("s.txt", &src)], vec![base], 3, 300);
3263 let bmerge = [b"hello" as &[u8], BLOCK_A, BLOCK_B, b""].join(&b'\n');
3264 let merge = put_multi_file_commit(
3265 &store,
3266 &[("f.txt", &bmerge), ("s.txt", &src)],
3267 vec![p1, p2],
3268 4,
3269 400,
3270 );
3271
3272 let opts = BlameOptions {
3273 copies: CopyDetection::On {
3274 level: 2,
3275 threshold: 40,
3276 },
3277 ..Default::default()
3278 };
3279 let r = blame_file_with(&store, merge, "f.txt", &opts).unwrap();
3280 assert_eq!(r.lines[1].text, BLOCK_A);
3281 assert_eq!(
3282 r.lines[1].commit_hash, p2,
3283 "the parent that deleted the blamed file is whole-tree searched \
3284 and its source claims the block (git parity)"
3285 );
3286 assert_eq!(r.lines[2].commit_hash, p2);
3287 }
3288
3289 #[test]
3290 fn blame_c_merge_fileless_first_parent_unmodified_second_source_stays_on_merge() {
3291 let (_d, store) = fresh_store();
3308 let base = put_multi_file_commit(
3309 &store,
3310 &[("b.txt", b"hello\n"), ("s2.txt", b"source header line\n")],
3311 vec![],
3312 1,
3313 100,
3314 );
3315 let p1 = put_multi_file_commit(
3316 &store,
3317 &[("s2.txt", b"source header line\n")],
3318 vec![base],
3319 2,
3320 200,
3321 );
3322 let src = [
3323 b"source header line" as &[u8],
3324 BLOCK_A,
3325 BLOCK_B,
3326 b"zzz",
3327 b"",
3328 ]
3329 .join(&b'\n');
3330 let p2 = put_multi_file_commit(
3331 &store,
3332 &[("b.txt", b"hello\n"), ("s2.txt", &src)],
3333 vec![base],
3334 3,
3335 300,
3336 );
3337 let p3 = put_multi_file_commit(
3338 &store,
3339 &[
3340 ("b.txt", b"hello\n"),
3341 ("s2.txt", b"source header line\n"),
3342 ("o.txt", b"other\n"),
3343 ],
3344 vec![base],
3345 4,
3346 400,
3347 );
3348 let bmerge = [b"hello" as &[u8], BLOCK_A, BLOCK_B, b""].join(&b'\n');
3349 let merge = put_multi_file_commit(
3350 &store,
3351 &[("b.txt", &bmerge), ("s2.txt", &src), ("o.txt", b"other\n")],
3352 vec![p1, p2, p3],
3353 5,
3354 500,
3355 );
3356
3357 let opts = BlameOptions {
3358 copies: CopyDetection::On {
3359 level: 2,
3360 threshold: 40,
3361 },
3362 ..Default::default()
3363 };
3364 let r = blame_file_with(&store, merge, "b.txt", &opts).unwrap();
3365 assert_eq!(r.lines[1].text, BLOCK_A);
3366 assert_eq!(
3367 r.lines[1].commit_hash, merge,
3368 "the first file-bearing parent keeps its porigin even when the real \
3369 first parent is fileless; its unchanged source stays invisible (git parity)"
3370 );
3371 assert_eq!(r.lines[2].commit_hash, merge);
3372 }
3373
3374 #[test]
3375 fn blame_c_level1_merge_modified_source_credits_first_parent() {
3376 let (_d, store) = fresh_store();
3390 let base = put_multi_file_commit(
3391 &store,
3392 &[("b.txt", b"hello\n"), ("s1.txt", b"source header line\n")],
3393 vec![],
3394 1,
3395 100,
3396 );
3397 let src = [
3398 b"source header line" as &[u8],
3399 BLOCK_A,
3400 BLOCK_B,
3401 b"zzz",
3402 b"",
3403 ]
3404 .join(&b'\n');
3405 let p1 = put_multi_file_commit(
3406 &store,
3407 &[("b.txt", b"hello\n"), ("s1.txt", &src)],
3408 vec![base],
3409 2,
3410 200,
3411 );
3412 let p2 = put_multi_file_commit(
3413 &store,
3414 &[
3415 ("b.txt", b"hello\n"),
3416 ("s1.txt", b"source header line\n"),
3417 ("o.txt", b"other\n"),
3418 ],
3419 vec![base],
3420 3,
3421 300,
3422 );
3423 let bmerge = [b"hello" as &[u8], BLOCK_A, BLOCK_B, b""].join(&b'\n');
3424 let merge = put_multi_file_commit(
3425 &store,
3426 &[
3427 ("b.txt", &bmerge),
3428 ("s1.txt", b"source header line\n"),
3429 ("o.txt", b"other\n"),
3430 ],
3431 vec![p1, p2],
3432 4,
3433 400,
3434 );
3435
3436 for level in [1u8, 2] {
3437 let opts = BlameOptions {
3438 copies: CopyDetection::On {
3439 level,
3440 threshold: 40,
3441 },
3442 ..Default::default()
3443 };
3444 let r = blame_file_with(&store, merge, "b.txt", &opts).unwrap();
3445 assert_eq!(r.lines[1].text, BLOCK_A);
3446 assert_eq!(
3447 r.lines[1].commit_hash, p1,
3448 "a source modified between the first parent and the merge is a \
3449 level-{level} candidate and credits the first parent (git parity)"
3450 );
3451 }
3452 }
3453
3454 #[test]
3455 fn blame_c_boundary_first_parent_mode_still_searches_first_parent() {
3456 let (_d, store) = fresh_store();
3467 let base = put_multi_file_commit(&store, &[("x.txt", b"x\n")], vec![], 1, 100);
3468 let src = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
3469 let p1 = put_multi_file_commit(
3470 &store,
3471 &[("x.txt", b"x\n"), ("s1.txt", &src)],
3472 vec![base],
3473 2,
3474 200,
3475 );
3476 let p2 = put_multi_file_commit(
3477 &store,
3478 &[("x.txt", b"x\n"), ("o.txt", b"other\n")],
3479 vec![base],
3480 3,
3481 300,
3482 );
3483 let newf = [BLOCK_A, BLOCK_B, b""].join(&b'\n');
3484 let merge = put_multi_file_commit(
3485 &store,
3486 &[
3487 ("x.txt", b"x\n"),
3488 ("s1.txt", &src),
3489 ("o.txt", b"other\n"),
3490 ("n.txt", &newf),
3491 ],
3492 vec![p1, p2],
3493 4,
3494 400,
3495 );
3496
3497 let opts = BlameOptions {
3498 copies: CopyDetection::On {
3499 level: 2,
3500 threshold: 40,
3501 },
3502 first_parent: true,
3503 ..Default::default()
3504 };
3505 let r = blame_file_with(&store, merge, "n.txt", &opts).unwrap();
3506 assert_eq!(r.lines[0].text, BLOCK_A);
3507 assert_eq!(
3508 r.lines[0].commit_hash, p1,
3509 "--first-parent truncates the boundary search to the real first \
3510 parent, which is still whole-tree searched (git parity)"
3511 );
3512 assert_eq!(r.lines[1].commit_hash, p1);
3513 }
3514
3515 #[test]
3516 fn blame_c_merge_mixed_within_file_move_beats_copy_on_tie() {
3517 let (_d, store) = fresh_store();
3528 let base = put_file_commit(&store, "f.txt", b"X\nY\n", vec![], 1, 100);
3529 let v1 = [LONG_LINE, b"X", b"Y", b""].join(&b'\n');
3530 let p1 = put_file_commit(&store, "f.txt", &v1, vec![base], 2, 200);
3531 let c2 = put_multi_file_commit(
3532 &store,
3533 &[
3534 ("f.txt", b"X\nY\n"),
3535 ("other.txt", &[LONG_LINE, b"other stuff", b""].join(&b'\n')),
3536 ],
3537 vec![base],
3538 3,
3539 300,
3540 );
3541 let vm = [b"X" as &[u8], b"Y", LONG_LINE, b""].join(&b'\n');
3542 let merge = put_multi_file_commit(
3543 &store,
3544 &[
3545 ("f.txt", &vm),
3546 ("other.txt", &[LONG_LINE, b"other stuff", b""].join(&b'\n')),
3547 ],
3548 vec![p1, c2],
3549 4,
3550 400,
3551 );
3552
3553 let opts = BlameOptions {
3554 moves: MoveDetection::On { threshold: 20 },
3555 copies: CopyDetection::On {
3556 level: 2,
3557 threshold: 40,
3558 },
3559 ..Default::default()
3560 };
3561 let r = blame_file_with(&store, merge, "f.txt", &opts).unwrap();
3562 assert_eq!(r.lines[2].text, LONG_LINE);
3563 assert_eq!(
3564 r.lines[2].commit_hash, p1,
3565 "-M's within-file move on the first parent still wins the tie over -C's copy on the second"
3566 );
3567 }
3568
3569 #[test]
3570 fn blame_c_merge_boundary_copy_from_second_parent() {
3571 let (_d, store) = fresh_store();
3580 let base = put_multi_file_commit(&store, &[("base.txt", b"base\n")], vec![], 1, 100);
3581 let p1 = put_multi_file_commit(
3582 &store,
3583 &[("base.txt", b"base\n"), ("m.txt", b"main only\n")],
3584 vec![base],
3585 2,
3586 200,
3587 );
3588 let src = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
3589 let c2 = put_multi_file_commit(
3590 &store,
3591 &[("base.txt", b"base\n"), ("src.txt", &src)],
3592 vec![base],
3593 3,
3594 300,
3595 );
3596 let bnew = [BLOCK_A, BLOCK_B, b""].join(&b'\n');
3597 let merge = put_multi_file_commit(
3598 &store,
3599 &[
3600 ("base.txt", b"base\n"),
3601 ("m.txt", b"main only\n"),
3602 ("src.txt", &src),
3603 ("b.txt", &bnew),
3604 ],
3605 vec![p1, c2],
3606 4,
3607 400,
3608 );
3609
3610 let opts = BlameOptions {
3611 copies: CopyDetection::On {
3612 level: 2,
3613 threshold: 40,
3614 },
3615 ..Default::default()
3616 };
3617 let r = blame_file_with(&store, merge, "b.txt", &opts).unwrap();
3618 assert_eq!(r.lines[0].text, BLOCK_A);
3619 assert_eq!(
3620 r.lines[0].commit_hash, c2,
3621 "a boundary -C source on a non-first parent is traced (git parity)"
3622 );
3623 assert_eq!(r.lines[1].commit_hash, c2);
3624 }
3625
3626 #[test]
3627 fn blame_c_merge_boundary_copy_octopus_third_parent() {
3628 let (_d, store) = fresh_store();
3638 let base = put_multi_file_commit(&store, &[("base.txt", b"base\n")], vec![], 1, 100);
3639 let p1 = put_multi_file_commit(
3640 &store,
3641 &[("base.txt", b"base\n"), ("pm.txt", b"p1 only\n")],
3642 vec![base],
3643 2,
3644 200,
3645 );
3646 let c2 = put_multi_file_commit(
3647 &store,
3648 &[("base.txt", b"base\n"), ("cm.txt", b"c2 only\n")],
3649 vec![base],
3650 3,
3651 300,
3652 );
3653 let src = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
3654 let c3 = put_multi_file_commit(
3655 &store,
3656 &[("base.txt", b"base\n"), ("s3.txt", &src)],
3657 vec![base],
3658 4,
3659 400,
3660 );
3661 let bnew = [BLOCK_A, BLOCK_B, b""].join(&b'\n');
3662 let merge = put_multi_file_commit(
3663 &store,
3664 &[
3665 ("base.txt", b"base\n"),
3666 ("pm.txt", b"p1 only\n"),
3667 ("cm.txt", b"c2 only\n"),
3668 ("s3.txt", &src),
3669 ("b.txt", &bnew),
3670 ],
3671 vec![p1, c2, c3],
3672 5,
3673 500,
3674 );
3675
3676 let opts = BlameOptions {
3677 copies: CopyDetection::On {
3678 level: 2,
3679 threshold: 40,
3680 },
3681 ..Default::default()
3682 };
3683 let r = blame_file_with(&store, merge, "b.txt", &opts).unwrap();
3684 assert_eq!(r.lines[0].text, BLOCK_A);
3685 assert_eq!(
3686 r.lines[0].commit_hash, c3,
3687 "the boundary search walks every real parent, not just the first two"
3688 );
3689 assert_eq!(r.lines[1].commit_hash, c3);
3690 }
3691
3692 #[test]
3693 fn blame_c_merge_boundary_copy_tie_prefers_first_parent() {
3694 let (_d, store) = fresh_store();
3706 let base = put_multi_file_commit(&store, &[("base.txt", b"base\n")], vec![], 1, 100);
3707 let src = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
3708 let p1 = put_multi_file_commit(
3709 &store,
3710 &[("base.txt", b"base\n"), ("s1.txt", &src)],
3711 vec![base],
3712 2,
3713 200,
3714 );
3715 let c2 = put_multi_file_commit(
3716 &store,
3717 &[("base.txt", b"base\n"), ("s2.txt", &src)],
3718 vec![base],
3719 3,
3720 300,
3721 );
3722 let bnew = [BLOCK_A, BLOCK_B, b""].join(&b'\n');
3723 let merge = put_multi_file_commit(
3724 &store,
3725 &[
3726 ("base.txt", b"base\n"),
3727 ("s1.txt", &src),
3728 ("s2.txt", &src),
3729 ("b.txt", &bnew),
3730 ],
3731 vec![p1, c2],
3732 4,
3733 400,
3734 );
3735
3736 let opts = BlameOptions {
3737 copies: CopyDetection::On {
3738 level: 2,
3739 threshold: 40,
3740 },
3741 ..Default::default()
3742 };
3743 let r = blame_file_with(&store, merge, "b.txt", &opts).unwrap();
3744 assert_eq!(r.lines[0].text, BLOCK_A);
3745 assert_eq!(
3746 r.lines[0].commit_hash, p1,
3747 "the boundary copy tie prefers the first parent (git parity) — unlike the interior tie"
3748 );
3749 }
3750
3751 #[test]
3752 fn match_lines_rejects_oversize_inputs() {
3753 let n = super::BLAME_MAX_LINES + 1;
3758 let opts = BlameOptions::default();
3759 let old: Vec<Vec<u8>> = vec![b"x".to_vec(); n];
3760 let new: Vec<Vec<u8>> = vec![b"y".to_vec(); 1];
3761 let err = super::match_lines_with_options(&old, &new, &opts).unwrap_err();
3762 assert!(
3763 matches!(err, BlameError::FileTooLarge { lines } if lines == n),
3764 "got {err:?}"
3765 );
3766
3767 let old2: Vec<Vec<u8>> = vec![b"a".to_vec(); 1];
3768 let new2: Vec<Vec<u8>> = vec![b"b".to_vec(); n];
3769 let err2 = super::match_lines_with_options(&old2, &new2, &opts).unwrap_err();
3770 assert!(
3771 matches!(err2, BlameError::FileTooLarge { lines } if lines == n),
3772 "got {err2:?}"
3773 );
3774 }
3775}