use std::collections::{HashMap, HashSet};
use std::fmt::Write as _;
use std::rc::Rc;
use std::sync::Arc;
use crate::hash::{self, Hash};
use crate::object::{EntryMode, Identity, Object};
use crate::store::ObjectStore;
mod move_copy;
mod walk;
use walk::{WalkCtx, attribute_commit, build_file_dag, topo_order};
pub const BLAME_MAX_LINES: usize = 100_000;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BlameLine {
pub line_num: usize,
pub orig_line_num: usize,
pub commit_hash: Hash,
pub author: Identity,
pub timestamp: u64,
pub boundary: bool,
pub source_path: Option<String>,
pub text: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BlameResult {
pub lines: Vec<BlameLine>,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum MoveDetection {
#[default]
Off,
On {
threshold: usize,
},
}
impl MoveDetection {
pub const GIT_DEFAULT: Self = Self::On { threshold: 20 };
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum CopyDetection {
#[default]
Off,
On {
level: u8,
threshold: usize,
},
}
impl CopyDetection {
#[must_use]
pub const fn git_default(level: u8) -> Self {
Self::On {
level,
threshold: 40,
}
}
}
#[derive(Debug, Clone, Default)]
pub struct BlameOptions {
pub ignore_whitespace: bool,
pub moves: MoveDetection,
pub copies: CopyDetection,
pub ignore_revs: Arc<HashSet<Hash>>,
pub ignore_rev_precise: bool,
pub first_parent: bool,
}
impl BlameOptions {
fn effective_move(&self) -> MoveDetection {
match self.moves {
MoveDetection::On { .. } => self.moves,
MoveDetection::Off if matches!(self.copies, CopyDetection::On { .. }) => {
MoveDetection::GIT_DEFAULT
}
MoveDetection::Off => MoveDetection::Off,
}
}
fn detection_enabled(&self) -> bool {
matches!(self.effective_move(), MoveDetection::On { .. })
|| matches!(self.copies, CopyDetection::On { .. })
}
#[must_use]
fn is_ignored(&self, commit: &Hash) -> bool {
self.ignore_revs.contains(commit)
}
}
#[derive(Clone)]
struct Attribution {
commit_hash: Hash,
author: Identity,
timestamp: u64,
orig_line_num: usize,
boundary: bool,
source_path: Option<String>,
}
impl From<BlameLine> for Attribution {
fn from(l: BlameLine) -> Self {
Self {
commit_hash: l.commit_hash,
author: l.author,
timestamp: l.timestamp,
orig_line_num: l.orig_line_num,
boundary: l.boundary,
source_path: l.source_path,
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum BlameError {
#[error("requested object is not a commit")]
NotACommit,
#[error("requested object is not a blob or chunked-blob")]
NotABlob,
#[error("file '{0}' was not found at any commit in history")]
FileNotFound(String),
#[error("reverse blame: '{start}' is not a first-parent ancestor of '{end}'")]
ReverseRange { start: String, end: String },
#[error("file has too many lines for blame ({lines} > {max})", max = BLAME_MAX_LINES)]
FileTooLarge { lines: usize },
#[error(transparent)]
Object(#[from] crate::object::MkitError),
#[error(transparent)]
Store(#[from] crate::store::StoreError),
}
pub type BlameOutcome<T> = Result<T, BlameError>;
pub fn blame_file(
store: &ObjectStore,
head_hash: Hash,
file_path: &str,
) -> BlameOutcome<BlameResult> {
blame_file_with(store, head_hash, file_path, &BlameOptions::default())
}
pub fn blame_file_with(
store: &ObjectStore,
head_hash: Hash,
file_path: &str,
opts: &BlameOptions,
) -> BlameOutcome<BlameResult> {
let Object::Commit(head_commit) = store.read_object(&head_hash)? else {
return Err(BlameError::NotACommit);
};
if find_blob_in_tree(store, head_commit.tree_hash, file_path)?.is_none() {
return Err(BlameError::FileNotFound(file_path.to_string()));
}
let (nodes, children) = build_file_dag(store, head_hash, file_path, opts.first_parent)?;
let order = topo_order(&nodes, head_hash);
let ctx = WalkCtx {
store,
opts,
nodes: &nodes,
file_path,
};
let mut detector = move_copy::Detector::new(store, opts);
let mut memo: HashMap<Hash, Rc<[Attribution]>> = HashMap::with_capacity(nodes.len());
let mut remaining = children;
for &commit in &order {
let attrs = attribute_commit(&ctx, &memo, &mut detector, commit)?;
memo.insert(commit, attrs);
for &parent in &nodes[&commit].parents {
if let Some(left) = remaining.get_mut(&parent) {
*left -= 1;
if *left == 0 {
memo.remove(&parent);
}
}
}
}
let head_attrs = memo
.get(&head_hash)
.expect("head is processed last and is never a parent, so never freed");
let final_lines = load_blob_lines(store, nodes[&head_hash].blob_hash)?;
let mut out = Vec::with_capacity(final_lines.len());
for (i, text) in final_lines.into_iter().enumerate() {
let a = &head_attrs[i];
out.push(BlameLine {
line_num: i + 1,
orig_line_num: a.orig_line_num,
commit_hash: a.commit_hash,
author: a.author.clone(),
timestamp: a.timestamp,
boundary: a.boundary,
source_path: a.source_path.clone(),
text,
});
}
Ok(BlameResult { lines: out })
}
struct ReverseEntry {
commit_hash: Hash,
blob: Option<Hash>,
author: Identity,
timestamp: u64,
}
impl From<&ReverseEntry> for Attribution {
fn from(e: &ReverseEntry) -> Self {
Self {
commit_hash: e.commit_hash,
author: e.author.clone(),
timestamp: e.timestamp,
orig_line_num: 0,
boundary: false,
source_path: None,
}
}
}
fn collect_reverse_chain(
store: &ObjectStore,
start_hash: Hash,
end_hash: Hash,
file_path: &str,
) -> BlameOutcome<Vec<ReverseEntry>> {
let mut chain: Vec<ReverseEntry> = Vec::new();
let mut current = Some(end_hash);
let mut reached_start = false;
while let Some(commit_hash) = current {
let Object::Commit(commit) = store.read_object(&commit_hash)? else {
return Err(BlameError::NotACommit);
};
let blob = find_blob_in_tree(store, commit.tree_hash, file_path)?;
chain.push(ReverseEntry {
commit_hash,
blob,
author: commit.author.clone(),
timestamp: commit.timestamp,
});
if commit_hash == start_hash {
reached_start = true;
break;
}
current = commit.parents.first().copied();
}
if !reached_start {
return Err(BlameError::ReverseRange {
start: hash::to_hex(&start_hash),
end: hash::to_hex(&end_hash),
});
}
chain.reverse();
Ok(chain)
}
pub fn blame_file_reverse(
store: &ObjectStore,
start_hash: Hash,
end_hash: Hash,
file_path: &str,
opts: &BlameOptions,
) -> BlameOutcome<BlameResult> {
let chain = collect_reverse_chain(store, start_hash, end_hash, file_path)?;
let Some(start_blob) = chain[0].blob else {
return Err(BlameError::FileNotFound(file_path.to_string()));
};
let start_lines = load_blob_lines(store, start_blob)?;
check_line_count(start_lines.len())?;
let mut cur_idx: Vec<Option<usize>> = (0..start_lines.len()).map(Some).collect();
let mut attributions: Vec<Attribution> = vec![Attribution::from(&chain[0]); start_lines.len()];
let mut live = start_lines.len();
let mut prev_blob = start_blob;
let mut prev_lines = start_lines.clone();
for entry in &chain[1..] {
if live == 0 {
break;
}
let newer_attr = Attribution::from(entry);
let Some(blob) = entry.blob else {
cur_idx.fill(None);
live = 0;
continue;
};
if blob == prev_blob {
for (j, c) in cur_idx.iter().enumerate() {
if c.is_some() {
attributions[j] = newer_attr.clone();
}
}
continue;
}
let new_lines = load_blob_lines(store, blob)?;
let mapping = match_lines_with_options(&prev_lines, &new_lines, opts)?;
let mut prev_to_new: Vec<Option<usize>> = vec![None; prev_lines.len()];
for (ni, m) in mapping.iter().enumerate() {
if let Some(oi) = *m {
prev_to_new[oi] = Some(ni);
}
}
for (j, c) in cur_idx.iter_mut().enumerate() {
if let Some(p) = *c {
if let Some(q) = prev_to_new.get(p).copied().flatten() {
*c = Some(q);
attributions[j] = newer_attr.clone();
} else {
*c = None;
live -= 1;
}
}
}
prev_blob = blob;
prev_lines = new_lines;
}
let mut out = Vec::with_capacity(start_lines.len());
for (i, text) in start_lines.into_iter().enumerate() {
let a = &attributions[i];
out.push(BlameLine {
line_num: i + 1,
orig_line_num: i + 1,
commit_hash: a.commit_hash,
author: a.author.clone(),
timestamp: a.timestamp,
boundary: false,
source_path: None,
text,
});
}
Ok(BlameResult { lines: out })
}
fn line_key(line: &[u8], ignore_whitespace: bool) -> Vec<u8> {
if ignore_whitespace {
strip_ws(line)
} else {
line.to_vec()
}
}
pub fn find_blob_in_tree(
store: &ObjectStore,
tree_hash: Hash,
path: &str,
) -> BlameOutcome<Option<Hash>> {
let components: Vec<&str> = path.split('/').filter(|c| !c.is_empty()).collect();
if components.is_empty() {
return Ok(None);
}
let mut current_tree = tree_hash;
for (ci, component) in components.iter().enumerate() {
let obj = store.read_object(¤t_tree)?;
let Object::Tree(tree) = obj else {
return Ok(None);
};
let is_last = ci == components.len() - 1;
let mut found_subtree = None;
let mut matched = false;
for entry in &tree.entries {
if entry.name.as_slice() == component.as_bytes() {
matched = true;
if is_last {
return match entry.mode {
EntryMode::Blob | EntryMode::Executable => Ok(Some(entry.object_hash)),
_ => Ok(None),
};
}
if entry.mode == EntryMode::Tree {
found_subtree = Some(entry.object_hash);
break;
}
return Ok(None);
}
}
if !matched {
return Ok(None);
}
if let Some(t) = found_subtree {
current_tree = t;
}
}
Ok(None)
}
fn load_blob_lines(store: &ObjectStore, blob_hash: Hash) -> BlameOutcome<Vec<Vec<u8>>> {
let obj = store.read_object(&blob_hash)?;
let data: Vec<u8> = match obj {
Object::Blob(b) => b.data,
Object::ChunkedBlob(cb) => {
let mut buf: Vec<u8> = Vec::with_capacity(usize::try_from(cb.total_size).unwrap_or(0));
for ch in &cb.chunks {
let chunk_obj = store.read_object(ch)?;
let Object::Blob(b) = chunk_obj else {
return Err(BlameError::NotABlob);
};
buf.extend_from_slice(&b.data);
}
cb.check_reassembled_size(buf.len())?;
buf
}
_ => return Err(BlameError::NotABlob),
};
Ok(split_lines(&data))
}
fn strip_ws(line: &[u8]) -> Vec<u8> {
line.iter()
.copied()
.filter(|b| !(b.is_ascii_whitespace() || *b == 0x0B))
.collect()
}
pub(super) const TRIVIAL_KEY_MIN_LEN: usize = 3;
pub(super) fn is_trivial_key(key: &[u8]) -> bool {
strip_ws(key).len() < TRIVIAL_KEY_MIN_LEN
}
fn split_lines(data: &[u8]) -> Vec<Vec<u8>> {
if data.is_empty() {
return Vec::new();
}
let mut out: Vec<Vec<u8>> = data.split(|b| *b == b'\n').map(<[u8]>::to_vec).collect();
if data.last().copied() == Some(b'\n') && !out.is_empty() {
out.pop();
}
out
}
fn match_lines_with_options(
old_lines: &[Vec<u8>],
new_lines: &[Vec<u8>],
opts: &BlameOptions,
) -> BlameOutcome<Vec<Option<usize>>> {
check_line_count(old_lines.len())?;
check_line_count(new_lines.len())?;
if opts.ignore_whitespace {
let old_keys: Vec<Vec<u8>> = old_lines.iter().map(|l| strip_ws(l)).collect();
let new_keys: Vec<Vec<u8>> = new_lines.iter().map(|l| strip_ws(l)).collect();
Ok(match_lines(&old_keys, &new_keys))
} else {
Ok(match_lines(old_lines, new_lines))
}
}
fn ignore_fallthrough(mapping: &[Option<usize>], old_len: usize) -> Vec<Option<usize>> {
let n = mapping.len();
let mut fall: Vec<Option<usize>> = vec![None; n];
let mut next_old = 0usize;
let mut ni = 0usize;
while ni < n {
let Some(anchor_old) = mapping[ni] else {
let hunk_new_start = ni;
while ni < n && mapping[ni].is_none() {
ni += 1;
}
let hunk_old_end = mapping.get(ni).copied().flatten().unwrap_or(old_len);
let mut oi = next_old;
let mut nj = hunk_new_start;
while nj < ni && oi < hunk_old_end {
fall[nj] = Some(oi);
nj += 1;
oi += 1;
}
next_old = hunk_old_end;
continue;
};
next_old = anchor_old + 1;
ni += 1;
}
fall
}
fn check_line_count(lines: usize) -> BlameOutcome<()> {
if lines > BLAME_MAX_LINES {
return Err(BlameError::FileTooLarge { lines });
}
Ok(())
}
#[must_use]
fn match_lines<T: AsRef<[u8]>>(old_lines: &[T], new_lines: &[T]) -> Vec<Option<usize>> {
let m = old_lines.len();
let n = new_lines.len();
let mut dp = vec![vec![0u32; n + 1]; m + 1];
for i in 1..=m {
for j in 1..=n {
if old_lines[i - 1].as_ref() == new_lines[j - 1].as_ref() {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = dp[i - 1][j].max(dp[i][j - 1]);
}
}
}
let mut mapping: Vec<Option<usize>> = vec![None; n];
let mut i = m;
let mut j = n;
while i > 0 && j > 0 {
if dp[i][j] == dp[i][j - 1] {
j -= 1;
} else if dp[i][j] == dp[i - 1][j] {
i -= 1;
} else {
mapping[j - 1] = Some(i - 1);
i -= 1;
j -= 1;
}
}
mapping
}
#[must_use]
pub fn format_blame_text(result: &BlameResult) -> String {
let mut out = String::new();
for line in &result.lines {
let hex = hash::to_hex(&line.commit_hash);
let short = &hex[..12];
let _ = write!(out, "{}\t{}\t", short, line.line_num);
out.push_str(&String::from_utf8_lossy(&line.text));
out.push('\n');
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::object::{Commit, Identity, Tree, TreeEntry};
use crate::serialize;
use tempfile::TempDir;
fn fresh_store() -> (TempDir, ObjectStore) {
let dir = TempDir::new().unwrap();
let store = ObjectStore::init(&crate::layout::RepoLayout::single(dir.path())).unwrap();
(dir, store)
}
fn put_blob(store: &ObjectStore, data: &[u8]) -> Hash {
let bytes = serialize::serialize(&Object::Blob(crate::object::Blob {
data: data.to_vec(),
}))
.unwrap();
store.write(&bytes).unwrap()
}
fn put_single_file_tree(store: &ObjectStore, name: &str, blob: Hash) -> Hash {
let tree = Object::Tree(Tree {
entries: vec![TreeEntry {
name: name.as_bytes().to_vec(),
mode: EntryMode::Blob,
object_hash: blob,
}],
});
store.write(&serialize::serialize(&tree).unwrap()).unwrap()
}
fn put_file_commit(
store: &ObjectStore,
filename: &str,
content: &[u8],
parents: Vec<Hash>,
author_mid: u64,
ts: u64,
) -> Hash {
let blob = put_blob(store, content);
let tree = put_single_file_tree(store, filename, blob);
let commit = Object::Commit(Commit::new_unannotated(
tree,
parents,
Identity::opaque(author_mid.to_le_bytes()),
[0u8; 32],
b"msg".to_vec(),
ts,
[0u8; 64],
));
store
.write(&serialize::serialize(&commit).unwrap())
.unwrap()
}
fn put_multi_file_commit(
store: &ObjectStore,
files: &[(&str, &[u8])],
parents: Vec<Hash>,
author_mid: u64,
ts: u64,
) -> Hash {
let mut entries: Vec<TreeEntry> = files
.iter()
.map(|(name, content)| TreeEntry {
name: name.as_bytes().to_vec(),
mode: EntryMode::Blob,
object_hash: put_blob(store, content),
})
.collect();
entries.sort_by(|a, b| a.name.cmp(&b.name));
let tree = store
.write(&serialize::serialize(&Object::Tree(Tree { entries })).unwrap())
.unwrap();
let commit = Object::Commit(Commit::new_unannotated(
tree,
parents,
Identity::opaque(author_mid.to_le_bytes()),
[0u8; 32],
b"msg".to_vec(),
ts,
[0u8; 64],
));
store
.write(&serialize::serialize(&commit).unwrap())
.unwrap()
}
const LONG_LINE: &[u8] = b"let quick_brown_fox_total = 1;";
const BLOCK_A: &[u8] = b"fn handler_alpha() { compute(); }";
const BLOCK_B: &[u8] = b"fn handler_bravo() { compute(); }";
#[test]
fn blame_rejects_chunked_total_size_mismatch() {
let (_d, store) = fresh_store();
let chunk = put_blob(&store, b"one line\n");
let cb = Object::ChunkedBlob(crate::object::ChunkedBlob {
total_size: 4096,
chunk_size: 0,
chunks: vec![chunk],
});
let cb_h = store.write(&serialize::serialize(&cb).unwrap()).unwrap();
let tree = put_single_file_tree(&store, "big.bin", cb_h);
let commit = Object::Commit(Commit::new_unannotated(
tree,
vec![],
Identity::opaque(1u64.to_le_bytes()),
[0u8; 32],
b"msg".to_vec(),
100,
[0u8; 64],
));
let head = store
.write(&serialize::serialize(&commit).unwrap())
.unwrap();
let err = blame_file(&store, head, "big.bin").unwrap_err();
assert!(
matches!(
err,
BlameError::Object(crate::object::MkitError::ChunkedBlobSizeMismatch {
expected: 4096,
actual: 9,
})
),
"expected ChunkedBlobSizeMismatch, got {err:?}"
);
}
#[test]
fn blame_m_attributes_within_file_move_to_origin() {
let (_d, store) = fresh_store();
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');
let c_a = put_file_commit(&store, "f.txt", &v1, vec![], 1, 100);
let c_b = put_file_commit(&store, "f.txt", &v2, vec![c_a], 2, 200);
let plain = blame_file(&store, c_b, "f.txt").unwrap();
assert_eq!(plain.lines[2].text, LONG_LINE);
assert_eq!(
plain.lines[2].commit_hash, c_b,
"default: moved line is new"
);
let opts = BlameOptions {
moves: MoveDetection::On { threshold: 20 },
..Default::default()
};
let m = blame_file_with(&store, c_b, "f.txt", &opts).unwrap();
assert_eq!(m.lines[2].text, LONG_LINE);
assert_eq!(
m.lines[2].commit_hash, c_a,
"-M attributes the moved line to its origin"
);
assert!(
m.lines.iter().all(|l| l.commit_hash == c_a),
"every line predates c_b under -M"
);
}
#[test]
fn blame_m_threshold_boundary_is_inclusive() {
let (_d, store) = fresh_store();
let exact: &[u8] = b"abcdefghijklmnopqrst"; let short: &[u8] = b"abcdefghijklmnopqrs"; let v1 = [exact, b"MID", short, b"B", b"C", b""].join(&b'\n');
let v2 = [b"B" as &[u8], b"C", exact, b"NEWX", short, b""].join(&b'\n');
let c_a = put_file_commit(&store, "f.txt", &v1, vec![], 1, 100);
let c_b = put_file_commit(&store, "f.txt", &v2, vec![c_a], 2, 200);
let opts = BlameOptions {
moves: MoveDetection::On { threshold: 20 },
..Default::default()
};
let m = blame_file_with(&store, c_b, "f.txt", &opts).unwrap();
assert_eq!(m.lines[2].text, exact);
assert_eq!(
m.lines[2].commit_hash, c_a,
"exactly-threshold (20) move is detected (>= is inclusive)"
);
assert_eq!(m.lines[4].text, short);
assert_eq!(
m.lines[4].commit_hash, c_b,
"one char short of the threshold stays on the editing commit"
);
}
#[test]
fn blame_m_ignores_moves_below_threshold() {
let (_d, store) = fresh_store();
let c_a = put_file_commit(&store, "f.txt", b"a\nB\nC\n", vec![], 1, 100);
let c_b = put_file_commit(&store, "f.txt", b"B\nC\na\n", vec![c_a], 2, 200);
let opts = BlameOptions {
moves: MoveDetection::On { threshold: 20 },
..Default::default()
};
let m = blame_file_with(&store, c_b, "f.txt", &opts).unwrap();
assert_eq!(m.lines[2].text, b"a");
assert_eq!(
m.lines[2].commit_hash, c_b,
"a sub-threshold move is not detected"
);
}
#[test]
fn blame_m_detects_sub_block_move_adjacent_to_new_line() {
let (_d, store) = fresh_store();
let v1 = [LONG_LINE, BLOCK_A, b"B", b"C", b""].join(&b'\n');
let v2 = [b"B" as &[u8], b"C", b"NEWLINE", LONG_LINE, BLOCK_A, b""].join(&b'\n');
let c_a = put_file_commit(&store, "f.txt", &v1, vec![], 1, 100);
let c_b = put_file_commit(&store, "f.txt", &v2, vec![c_a], 2, 200);
let opts = BlameOptions {
moves: MoveDetection::On { threshold: 20 },
..Default::default()
};
let m = blame_file_with(&store, c_b, "f.txt", &opts).unwrap();
assert_eq!(m.lines[2].text, b"NEWLINE");
assert_eq!(
m.lines[2].commit_hash, c_b,
"the genuinely-new line stays on c_b"
);
assert_eq!(m.lines[3].text, LONG_LINE);
assert_eq!(
m.lines[3].commit_hash, c_a,
"the moved block reverts to c_a"
);
assert_eq!(
m.lines[4].commit_hash, c_a,
"…including the second moved line"
);
}
#[test]
fn blame_w_c_detects_copy_with_whitespace_change() {
let (_d, store) = fresh_store();
let a1 = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
let c_a = put_multi_file_commit(&store, &[("a.txt", &a1)], vec![], 1, 100);
let reindented = {
let mut v = Vec::new();
v.extend_from_slice(b" ");
v.extend_from_slice(BLOCK_A);
v.push(b'\n');
v.extend_from_slice(b" ");
v.extend_from_slice(BLOCK_B);
v.push(b'\n');
v
};
let c_b = put_multi_file_commit(
&store,
&[("a.txt", b"zzz\n"), ("b.txt", &reindented)],
vec![c_a],
2,
200,
);
let plain_c = BlameOptions {
copies: CopyDetection::On {
level: 1,
threshold: 40,
},
..Default::default()
};
let r = blame_file_with(&store, c_b, "b.txt", &plain_c).unwrap();
assert!(
r.lines.iter().all(|l| l.commit_hash == c_b),
"without -w a reindented copy is not detected"
);
let w_c = BlameOptions {
ignore_whitespace: true,
copies: CopyDetection::On {
level: 1,
threshold: 40,
},
..Default::default()
};
let r = blame_file_with(&store, c_b, "b.txt", &w_c).unwrap();
assert!(
r.lines.iter().all(|l| l.commit_hash == c_a),
"-w -C credits a reindented copy to its origin"
);
}
#[test]
fn blame_c_attributes_copy_from_other_file_to_origin() {
let (_d, store) = fresh_store();
let a1 = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
let c_a = put_multi_file_commit(&store, &[("a.txt", &a1)], vec![], 1, 100);
let bfile = [BLOCK_A, BLOCK_B, b""].join(&b'\n');
let c_b = put_multi_file_commit(
&store,
&[("a.txt", b"zzz\n"), ("b.txt", &bfile)],
vec![c_a],
2,
200,
);
let plain = blame_file(&store, c_b, "b.txt").unwrap();
assert_eq!(
plain.lines[0].commit_hash, c_b,
"default: copied block is new"
);
let opts = BlameOptions {
copies: CopyDetection::On {
level: 1,
threshold: 40,
},
..Default::default()
};
let c = blame_file_with(&store, c_b, "b.txt", &opts).unwrap();
assert_eq!(c.lines[0].text, BLOCK_A);
assert_eq!(c.lines[1].text, BLOCK_B);
assert!(
c.lines.iter().all(|l| l.commit_hash == c_a),
"-C credits the copied block to its origin commit"
);
}
#[test]
fn blame_c_level1_skips_unchanged_files_until_level2() {
let (_d, store) = fresh_store();
let block = [BLOCK_A, BLOCK_B, b""].join(&b'\n');
let c_a = put_multi_file_commit(&store, &[("src.txt", &block)], vec![], 1, 100);
let c_b = put_multi_file_commit(
&store,
&[("src.txt", &block), ("dst.txt", &block)],
vec![c_a],
2,
200,
);
let l1 = BlameOptions {
copies: CopyDetection::On {
level: 1,
threshold: 40,
},
..Default::default()
};
let r1 = blame_file_with(&store, c_b, "dst.txt", &l1).unwrap();
assert!(
r1.lines.iter().all(|l| l.commit_hash == c_b),
"-C level 1 ignores the unchanged source file"
);
let l2 = BlameOptions {
copies: CopyDetection::On {
level: 2,
threshold: 40,
},
..Default::default()
};
let r2 = blame_file_with(&store, c_b, "dst.txt", &l2).unwrap();
assert!(
r2.lines.iter().all(|l| l.commit_hash == c_a),
"-C -C searches every parent file and finds the source"
);
}
#[test]
fn blame_w_c_credits_copy_through_prior_whitespace_edit() {
let (_d, store) = fresh_store();
let indented = {
let mut v = Vec::new();
for b in [BLOCK_A, BLOCK_B] {
v.extend_from_slice(b" ");
v.extend_from_slice(b);
v.push(b'\n');
}
v.extend_from_slice(b"zzz\n");
v
};
let d1 = put_multi_file_commit(&store, &[("a.txt", &indented)], vec![], 1, 100);
let dedented = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
let d2 = put_multi_file_commit(&store, &[("a.txt", &dedented)], vec![d1], 2, 200);
let block = [BLOCK_A, BLOCK_B, b""].join(&b'\n');
let d3 = put_multi_file_commit(
&store,
&[("a.txt", b"zzz\n"), ("b.txt", &block)],
vec![d2],
3,
300,
);
let opts = BlameOptions {
ignore_whitespace: true,
copies: CopyDetection::On {
level: 1,
threshold: 40,
},
..Default::default()
};
let r = blame_file_with(&store, d3, "b.txt", &opts).unwrap();
assert!(
r.lines.iter().all(|l| l.commit_hash == d1),
"the source blame keeps -w → credits the original, not the reformat"
);
}
#[test]
fn blame_c_credits_copy_through_prior_same_file_move() {
let (_d, store) = fresh_store();
let v1 = [BLOCK_A, BLOCK_B, b"X", b"Y", b""].join(&b'\n');
let d1 = put_multi_file_commit(&store, &[("a.txt", &v1)], vec![], 1, 100);
let v2 = [b"X" as &[u8], b"Y", BLOCK_A, BLOCK_B, b""].join(&b'\n');
let d2 = put_multi_file_commit(&store, &[("a.txt", &v2)], vec![d1], 2, 200);
let block = [BLOCK_A, BLOCK_B, b""].join(&b'\n');
let d3 = put_multi_file_commit(
&store,
&[("a.txt", b"X\nY\n"), ("b.txt", &block)],
vec![d2],
3,
300,
);
let opts = BlameOptions {
copies: CopyDetection::On {
level: 1,
threshold: 40,
},
..Default::default()
};
let r = blame_file_with(&store, d3, "b.txt", &opts).unwrap();
assert!(
r.lines.iter().all(|l| l.commit_hash == d1),
"the source blame keeps implied -M → credits the original, not the move"
);
}
#[test]
fn blame_c_alone_implies_within_file_m() {
let (_d, store) = fresh_store();
let v1 = [LONG_LINE, BLOCK_A, b"B", b"C", b""].join(&b'\n');
let v2 = [b"B" as &[u8], b"C", LONG_LINE, BLOCK_A, b""].join(&b'\n');
let c_a = put_file_commit(&store, "f.txt", &v1, vec![], 1, 100);
let c_b = put_file_commit(&store, "f.txt", &v2, vec![c_a], 2, 200);
let opts = BlameOptions {
copies: CopyDetection::On {
level: 1,
threshold: 40,
},
..Default::default() };
let m = blame_file_with(&store, c_b, "f.txt", &opts).unwrap();
assert_eq!(m.lines[2].text, LONG_LINE);
assert_eq!(
m.lines[2].commit_hash, c_a,
"-C implies -M, so the within-file move reverts to its origin"
);
}
#[test]
fn blame_m_many_single_line_moves_no_stack_overflow() {
let (_d, store) = fresh_store();
let lines: Vec<String> = (0..400)
.map(|i| format!("let unique_symbol_number_{i:05} = compute({i});"))
.collect();
let mut v1 = lines.join("\n");
v1.push('\n');
let mut rev: Vec<&str> = lines.iter().map(String::as_str).collect();
rev.reverse();
let mut v2 = rev.join("\n");
v2.push('\n');
let c_a = put_file_commit(&store, "f.txt", v1.as_bytes(), vec![], 1, 100);
let c_b = put_file_commit(&store, "f.txt", v2.as_bytes(), vec![c_a], 2, 200);
let opts = BlameOptions {
moves: MoveDetection::On { threshold: 20 },
..Default::default()
};
let m = blame_file_with(&store, c_b, "f.txt", &opts).unwrap();
assert_eq!(m.lines.len(), 400);
assert!(
m.lines.iter().all(|l| l.commit_hash == c_a),
"every reordered long line is a move → all revert to c_a"
);
}
#[test]
fn blame_m_large_new_block_terminates() {
use std::fmt::Write as _;
let (_d, store) = fresh_store();
let c_a = put_file_commit(&store, "f.txt", b"seed\n", vec![], 1, 100);
let mut v2 = String::from("seed\n");
for i in 0..3000 {
let _ = writeln!(v2, "brand_new_distinct_line_number_{i:06}");
}
let c_b = put_file_commit(&store, "f.txt", v2.as_bytes(), vec![c_a], 2, 200);
let opts = BlameOptions {
moves: MoveDetection::On { threshold: 20 },
..Default::default()
};
let m = blame_file_with(&store, c_b, "f.txt", &opts).unwrap();
assert_eq!(m.lines.len(), 3001);
assert_eq!(m.lines[0].commit_hash, c_a, "the seed line is unchanged");
assert!(
m.lines[1..].iter().all(|l| l.commit_hash == c_b),
"the large new block stays on the editing commit"
);
}
#[test]
fn blame_single_commit_attributes_all_lines_to_it() {
let (_d, store) = fresh_store();
let c = put_file_commit(&store, "f.txt", b"l1\nl2\nl3\n", vec![], 42, 1000);
let r = blame_file(&store, c, "f.txt").unwrap();
assert_eq!(r.lines.len(), 3);
for (i, line) in r.lines.iter().enumerate() {
assert_eq!(line.line_num, i + 1);
assert_eq!(line.commit_hash, c);
assert_eq!(line.timestamp, 1000);
assert_eq!(line.author.kind, crate::object::IdentityKind::Opaque);
}
assert_eq!(r.lines[0].text, b"l1");
assert_eq!(r.lines[1].text, b"l2");
assert_eq!(r.lines[2].text, b"l3");
}
#[test]
fn strip_ws_removes_all_whitespace() {
assert_eq!(strip_ws(b" foo(a, b)\t"), b"foo(a,b)".to_vec());
assert_eq!(strip_ws(b"abc"), b"abc".to_vec());
assert_eq!(strip_ws(b" \t "), b"".to_vec());
assert_eq!(strip_ws(b"a\x0Bb\x0Cc"), b"abc".to_vec());
}
#[test]
fn blame_w_ignores_whitespace_only_change() {
let (_d, store) = fresh_store();
let c_a = put_file_commit(&store, "f.txt", b"foo(a, b)\nkeep\n", vec![], 1, 100);
let c_b = put_file_commit(&store, "f.txt", b"foo(a,b)\nkeep\n", vec![c_a], 2, 200);
let plain = blame_file(&store, c_b, "f.txt").unwrap();
assert_eq!(plain.lines[0].commit_hash, c_b);
let opts = BlameOptions {
ignore_whitespace: true,
..Default::default()
};
let w = blame_file_with(&store, c_b, "f.txt", &opts).unwrap();
assert_eq!(
w.lines[0].commit_hash, c_a,
"a whitespace-only change must not steal blame"
);
assert_eq!(
w.lines[0].text, b"foo(a,b)",
"output keeps the current bytes"
);
assert_eq!(w.lines[1].commit_hash, c_a);
}
#[test]
fn blame_w_still_attributes_real_content_change() {
let (_d, store) = fresh_store();
let c_a = put_file_commit(&store, "f.txt", b"a\nb\n", vec![], 1, 100);
let c_b = put_file_commit(&store, "f.txt", b"a\nB CHANGED\n", vec![c_a], 2, 200);
let opts = BlameOptions {
ignore_whitespace: true,
..Default::default()
};
let w = blame_file_with(&store, c_b, "f.txt", &opts).unwrap();
assert_eq!(w.lines[0].commit_hash, c_a);
assert_eq!(
w.lines[1].commit_hash, c_b,
"a non-whitespace change is still attributed normally under -w"
);
}
#[test]
fn blame_w_keeps_position_for_whitespace_equal_duplicate() {
let (_d, store) = fresh_store();
let c_a = put_file_commit(&store, "f.txt", b"ab\n", vec![], 1, 100);
let c_b = put_file_commit(&store, "f.txt", b"ab\na b\n", vec![c_a], 2, 200);
let opts = BlameOptions {
ignore_whitespace: true,
..Default::default()
};
let w = blame_file_with(&store, c_b, "f.txt", &opts).unwrap();
assert_eq!(w.lines.len(), 2);
assert_eq!(w.lines[0].commit_hash, c_a, "unchanged line 1 keeps c_a");
assert_eq!(w.lines[1].commit_hash, c_b, "added line 2 is c_b");
assert_eq!(w.lines[1].text, b"a b", "output keeps the current bytes");
}
#[test]
fn blame_w_blank_line_duplicate_is_position_stable() {
let (_d, store) = fresh_store();
let c_a = put_file_commit(&store, "f.txt", b"x\n\ny\n", vec![], 1, 100);
let c_b = put_file_commit(&store, "f.txt", b"x\n\n\ny\n", vec![c_a], 2, 200);
let opts = BlameOptions {
ignore_whitespace: true,
..Default::default()
};
let w = blame_file_with(&store, c_b, "f.txt", &opts).unwrap();
assert_eq!(w.lines.len(), 4);
assert_eq!(w.lines[0].commit_hash, c_a, "x");
assert_eq!(w.lines[1].commit_hash, c_a, "original blank");
assert_eq!(w.lines[2].commit_hash, c_b, "added blank is new");
assert_eq!(w.lines[3].commit_hash, c_a, "y");
}
#[test]
fn blame_duplicate_line_addition_is_position_stable() {
let (_d, store) = fresh_store();
let c_a = put_file_commit(&store, "f.txt", b"x\n", vec![], 1, 100);
let c_b = put_file_commit(&store, "f.txt", b"x\nx\n", vec![c_a], 2, 200);
let r = blame_file(&store, c_b, "f.txt").unwrap();
assert_eq!(r.lines[0].commit_hash, c_a, "original line keeps c_a");
assert_eq!(r.lines[1].commit_hash, c_b, "appended duplicate is c_b");
}
#[test]
fn blame_two_commits_with_modified_middle() {
let (_d, store) = fresh_store();
let c_a = put_file_commit(&store, "f.txt", b"a\nb\nc\n", vec![], 42, 1000);
let c_b = put_file_commit(&store, "f.txt", b"a\nMOD\nc\n", vec![c_a], 42, 2000);
let r = blame_file(&store, c_b, "f.txt").unwrap();
assert_eq!(r.lines.len(), 3);
assert_eq!(r.lines[0].commit_hash, c_a);
assert_eq!(r.lines[1].commit_hash, c_b);
assert_eq!(r.lines[2].commit_hash, c_a);
assert_eq!(r.lines[1].text, b"MOD");
}
#[test]
fn blame_three_commits_progressive_changes() {
let (_d, store) = fresh_store();
let c_a = put_file_commit(&store, "f.txt", b"a\nb\n", vec![], 1, 100);
let c_b = put_file_commit(&store, "f.txt", b"a\nb\nc\n", vec![c_a], 2, 200);
let c_c = put_file_commit(&store, "f.txt", b"a\nX\nc\n", vec![c_b], 3, 300);
let r = blame_file(&store, c_c, "f.txt").unwrap();
assert_eq!(r.lines.len(), 3);
assert_eq!(r.lines[0].commit_hash, c_a, "a from A");
assert_eq!(r.lines[1].commit_hash, c_c, "X from C");
assert_eq!(r.lines[2].commit_hash, c_b, "c from B");
}
#[test]
fn blame_tracks_additions() {
let (_d, store) = fresh_store();
let c_a = put_file_commit(&store, "f.txt", b"a\nb\n", vec![], 1, 100);
let c_b = put_file_commit(&store, "f.txt", b"a\nNEW\nb\n", vec![c_a], 1, 200);
let r = blame_file(&store, c_b, "f.txt").unwrap();
assert_eq!(r.lines.len(), 3);
assert_eq!(r.lines[0].commit_hash, c_a);
assert_eq!(r.lines[1].commit_hash, c_b);
assert_eq!(r.lines[2].commit_hash, c_a);
}
#[test]
fn blame_tracks_deletions() {
let (_d, store) = fresh_store();
let c_a = put_file_commit(&store, "f.txt", b"a\nb\nc\n", vec![], 1, 100);
let c_b = put_file_commit(&store, "f.txt", b"a\nc\n", vec![c_a], 1, 200);
let r = blame_file(&store, c_b, "f.txt").unwrap();
assert_eq!(r.lines.len(), 2);
assert_eq!(r.lines[0].commit_hash, c_a);
assert_eq!(r.lines[1].commit_hash, c_a);
}
#[test]
fn blame_file_not_found_returns_error() {
let (_d, store) = fresh_store();
let c = put_file_commit(&store, "real.txt", b"x\n", vec![], 1, 100);
let err = blame_file(&store, c, "missing.txt").unwrap_err();
assert!(matches!(err, BlameError::FileNotFound(_)));
}
#[test]
fn lcs_identical_lines() {
let lines: Vec<&[u8]> = vec![b"a", b"b", b"c"];
let m = match_lines(&lines, &lines);
assert_eq!(m, vec![Some(0), Some(1), Some(2)]);
}
#[test]
fn lcs_completely_different() {
let old: Vec<&[u8]> = vec![b"a", b"b", b"c"];
let new: Vec<&[u8]> = vec![b"x", b"y", b"z"];
let m = match_lines(&old, &new);
assert_eq!(m, vec![None, None, None]);
}
#[test]
fn lcs_duplicate_key_matches_earliest_new_line() {
let old: Vec<&[u8]> = vec![b"ab"];
let new: Vec<&[u8]> = vec![b"ab", b"ab"];
assert_eq!(match_lines(&old, &new), vec![Some(0), None]);
}
#[test]
fn split_lines_handles_trailing_newline() {
assert_eq!(
split_lines(b"a\nb\nc\n"),
vec![b"a".to_vec(), b"b".to_vec(), b"c".to_vec()]
);
}
#[test]
fn split_lines_handles_no_trailing_newline() {
assert_eq!(
split_lines(b"a\nb\nc"),
vec![b"a".to_vec(), b"b".to_vec(), b"c".to_vec()]
);
}
#[test]
fn split_lines_empty() {
assert!(split_lines(b"").is_empty());
}
#[test]
fn find_blob_in_nested_tree() {
let (_d, store) = fresh_store();
let blob = put_blob(&store, b"hello\n");
let inner = Object::Tree(Tree {
entries: vec![TreeEntry {
name: b"main.txt".to_vec(),
mode: EntryMode::Blob,
object_hash: blob,
}],
});
let inner_h = store.write(&serialize::serialize(&inner).unwrap()).unwrap();
let outer = Object::Tree(Tree {
entries: vec![TreeEntry {
name: b"src".to_vec(),
mode: EntryMode::Tree,
object_hash: inner_h,
}],
});
let outer_h = store.write(&serialize::serialize(&outer).unwrap()).unwrap();
let found = find_blob_in_tree(&store, outer_h, "src/main.txt").unwrap();
assert_eq!(found, Some(blob));
let missing = find_blob_in_tree(&store, outer_h, "src/none.txt").unwrap();
assert_eq!(missing, None);
}
fn ignoring_precise(revs: &[Hash]) -> BlameOptions {
BlameOptions {
ignore_revs: Arc::new(revs.iter().copied().collect()),
ignore_rev_precise: true,
ignore_whitespace: true,
..Default::default()
}
}
fn ignoring(revs: &[Hash]) -> BlameOptions {
BlameOptions {
ignore_revs: Arc::new(revs.iter().copied().collect()),
..Default::default()
}
}
#[test]
fn blame_ignore_rev_falls_through_reformat() {
let (_d, store) = fresh_store();
let c_a = put_file_commit(&store, "f.txt", b"alpha\nbeta\ngamma\n", vec![], 1, 100);
let c_b = put_file_commit(
&store,
"f.txt",
b"alpha\n beta \ngamma\n",
vec![c_a],
2,
200,
);
let plain = blame_file(&store, c_b, "f.txt").unwrap();
assert_eq!(plain.lines[1].commit_hash, c_b);
let r = blame_file_with(&store, c_b, "f.txt", &ignoring(&[c_b])).unwrap();
assert_eq!(
r.lines[1].commit_hash, c_a,
"ignored reformat falls through to the original commit"
);
assert_eq!(r.lines[1].text, b" beta ", "output keeps current bytes");
assert!(
r.lines.iter().all(|l| l.commit_hash == c_a),
"no line is credited to the ignored commit"
);
}
#[test]
fn blame_ignore_rev_keeps_genuine_insertion() {
let (_d, store) = fresh_store();
let c_a = put_file_commit(&store, "f.txt", b"alpha\nbeta\n", vec![], 1, 100);
let c_b = put_file_commit(
&store,
"f.txt",
b"alpha\nbeta\nBRANDNEW\ngamma\n",
vec![c_a],
2,
200,
);
let r = blame_file_with(&store, c_b, "f.txt", &ignoring(&[c_b])).unwrap();
assert_eq!(r.lines[0].commit_hash, c_a, "alpha");
assert_eq!(r.lines[1].commit_hash, c_a, "beta");
assert_eq!(
r.lines[2].commit_hash, c_b,
"a genuine insertion stays on the ignored commit"
);
assert_eq!(r.lines[3].commit_hash, c_b, "…and the second insertion");
}
#[test]
fn blame_ignore_rev_pairs_changed_lines_to_distinct_origins() {
let (_d, store) = fresh_store();
let c1 = put_file_commit(&store, "f.txt", b"L1\nL2\nL3\nL4\n", vec![], 1, 100);
let c2 = put_file_commit(&store, "f.txt", b"L1\nL2x\nL3\nL4\n", vec![c1], 2, 200);
let c3 = put_file_commit(&store, "f.txt", b"L1\nL2y\nL3y\nL4\n", vec![c2], 3, 300);
let r = blame_file_with(&store, c3, "f.txt", &ignoring(&[c3])).unwrap();
assert_eq!(r.lines[1].commit_hash, c2, "L2y inherits L2x's origin (c2)");
assert_eq!(r.lines[2].commit_hash, c1, "L3y inherits L3's origin (c1)");
assert!(r.lines.iter().all(|l| l.commit_hash != c3));
}
#[test]
fn blame_ignore_rev_unequal_hunk_more_added() {
let (_d, store) = fresh_store();
let c1 = put_file_commit(&store, "f.txt", b"L1\nMID\nL3\n", vec![], 1, 100);
let c2 = put_file_commit(&store, "f.txt", b"L1\nMIDa\nMIDb\nL3\n", vec![c1], 2, 200);
let r = blame_file_with(&store, c2, "f.txt", &ignoring(&[c2])).unwrap();
assert_eq!(r.lines[1].commit_hash, c1, "MIDa falls through to c1");
assert_eq!(r.lines[2].commit_hash, c2, "MIDb has no pair → stays on c2");
}
#[test]
fn blame_ignore_rev_unequal_hunk_more_removed() {
let (_d, store) = fresh_store();
let c1 = put_file_commit(&store, "f.txt", b"L1\nM1\nM2\nL3\n", vec![], 1, 100);
let c2 = put_file_commit(&store, "f.txt", b"L1\nMERGED\nL3\n", vec![c1], 2, 200);
let r = blame_file_with(&store, c2, "f.txt", &ignoring(&[c2])).unwrap();
assert_eq!(r.lines[1].commit_hash, c1, "MERGED falls through to c1");
}
#[test]
fn blame_ignore_root_commit_keeps_its_lines() {
let (_d, store) = fresh_store();
let root = put_file_commit(&store, "f.txt", b"a\nb\n", vec![], 1, 100);
let c2 = put_file_commit(&store, "f.txt", b"a\nb\nc\n", vec![root], 2, 200);
let r = blame_file_with(&store, c2, "f.txt", &ignoring(&[root])).unwrap();
assert_eq!(r.lines[0].commit_hash, root, "a stays on the ignored root");
assert_eq!(r.lines[1].commit_hash, root, "b stays on the ignored root");
assert_eq!(r.lines[2].commit_hash, c2, "c is unaffected");
}
#[test]
fn blame_ignore_multiple_revs_chains_through() {
let (_d, store) = fresh_store();
let c1 = put_file_commit(&store, "f.txt", b"keep\nx\n", vec![], 1, 100);
let c2 = put_file_commit(&store, "f.txt", b"keep\n x \n", vec![c1], 2, 200);
let c3 = put_file_commit(&store, "f.txt", b"keep\n x \n", vec![c2], 3, 300);
let r = blame_file_with(&store, c3, "f.txt", &ignoring(&[c2, c3])).unwrap();
assert_eq!(
r.lines[1].commit_hash, c1,
"line falls through both ignored reformats to c1"
);
}
#[test]
fn blame_ignore_rev_fallthrough_not_overwritten_by_move_detection() {
let (_d, store) = fresh_store();
let dup: &[u8] = b"dupaaaaaaaaaaaaaaaaa"; let mid: &[u8] = b"MIDLINE";
let x_v0: &[u8] = b"exoldbbbbbbbbbbbbbbb";
let x_v1: &[u8] = b"exnewbbbbbbbbbbbbbbb";
let c0 = put_file_commit(
&store,
"f.txt",
&[dup, mid, x_v0, b""].join(&b'\n'),
vec![],
1,
100,
);
let c1 = put_file_commit(
&store,
"f.txt",
&[dup, mid, x_v1, b""].join(&b'\n'),
vec![c0],
2,
200,
);
let c2 = put_file_commit(
&store,
"f.txt",
&[dup, mid, dup, b""].join(&b'\n'),
vec![c1],
3,
300,
);
let opts = BlameOptions {
moves: MoveDetection::On { threshold: 20 },
ignore_revs: Arc::new([c2].into_iter().collect()),
..Default::default()
};
let r = blame_file_with(&store, c2, "f.txt", &opts).unwrap();
assert_eq!(
r.lines[2].commit_hash, c1,
"fallthrough (replaced parent line → c1) wins; -M does not overwrite it to c0"
);
let plain = blame_file_with(&store, c2, "f.txt", &ignoring(&[c2])).unwrap();
assert_eq!(
plain.lines[2].commit_hash, c1,
"-M did not change the result"
);
}
#[test]
fn blame_ignore_rev_precise_reattributes_moved_reindented_lines() {
let (_d, store) = fresh_store();
let c0 = put_file_commit(&store, "f.txt", b"keep\ntail\n", vec![], 1, 100);
let c1 = put_file_commit(&store, "f.txt", b"keep\nXXX\ntail\n", vec![c0], 2, 200);
let c2 = put_file_commit(&store, "f.txt", b"keep\nXXX\nYYY\ntail\n", vec![c1], 3, 300);
let c3 = put_file_commit(
&store,
"f.txt",
b"keep\nXXX\nYYY\nZZZ\ntail\n",
vec![c2],
4,
400,
);
let c4 = put_file_commit(
&store,
"f.txt",
b"keep\n ZZZ\n YYY\n XXX\ntail\n",
vec![c3],
5,
500,
);
let positional_opts = BlameOptions {
ignore_whitespace: true,
ignore_revs: Arc::new([c4].into_iter().collect()),
..Default::default()
};
let positional = blame_file_with(&store, c4, "f.txt", &positional_opts).unwrap();
assert_eq!(
positional.lines[1].commit_hash, c3,
"ZZZ is recognized unchanged by the LCS matcher itself (needs no fall-through)"
);
assert_eq!(
positional.lines[2].commit_hash, c4,
"positional: YYY has no in-hunk counterpart, stays on the ignored commit"
);
assert_eq!(
positional.lines[3].commit_hash, c4,
"positional: XXX has no in-hunk counterpart, stays on the ignored commit"
);
let precise = blame_file_with(&store, c4, "f.txt", &ignoring_precise(&[c4])).unwrap();
assert_eq!(
precise.lines[1].commit_hash, c3,
"ZZZ is unaffected by precise mode (already resolved by plain LCS)"
);
assert_eq!(
precise.lines[2].commit_hash, c2,
"precise: YYY correctly attributed to its true origin"
);
assert_eq!(
precise.lines[3].commit_hash, c1,
"precise: XXX correctly attributed to its true origin"
);
assert_eq!(precise.lines[0].commit_hash, c0, "keep is unaffected");
assert_eq!(precise.lines[4].commit_hash, c0, "tail is unaffected");
}
#[test]
fn blame_ignore_rev_precise_unequal_hunk_surplus_stays_put() {
let (_d, store) = fresh_store();
let c1 = put_file_commit(&store, "f.txt", b"L1\nMID\nL3\n", vec![], 1, 100);
let c2 = put_file_commit(
&store,
"f.txt",
b"L1\nMIDaaa\nMIDbbb\nMIDccc\nL3\n",
vec![c1],
2,
200,
);
let plain = blame_file_with(&store, c2, "f.txt", &ignoring(&[c2])).unwrap();
let precise = blame_file_with(&store, c2, "f.txt", &ignoring_precise(&[c2])).unwrap();
for r in [&plain, &precise] {
assert_eq!(r.lines[1].commit_hash, c1, "MIDaaa falls through to c1");
assert_eq!(
r.lines[2].commit_hash, c2,
"MIDbbb has no pair -> stays on c2"
);
assert_eq!(
r.lines[3].commit_hash, c2,
"MIDccc has no pair -> stays on c2"
);
}
}
#[test]
fn blame_ignore_rev_precise_no_match_falls_back_to_positional() {
let (_d, store) = fresh_store();
let c1 = put_file_commit(&store, "f.txt", b"L1\nA\nL3\n", vec![], 1, 100);
let c2 = put_file_commit(&store, "f.txt", b"L1\nFABRICATED\nL3\n", vec![c1], 2, 200);
let plain = blame_file_with(&store, c2, "f.txt", &ignoring(&[c2])).unwrap();
let precise = blame_file_with(&store, c2, "f.txt", &ignoring_precise(&[c2])).unwrap();
assert_eq!(plain.lines[1].commit_hash, c1);
assert_eq!(
precise.lines[1].commit_hash, plain.lines[1].commit_hash,
"no content candidate exists anywhere in the parent: precise matches positional"
);
}
#[test]
fn precise_overrides_trivial_key_guard() {
let mapping = vec![None, None];
let fall = vec![Some(2), None];
let new_lines = vec![b"ab".to_vec(), b"REALZZZ".to_vec()];
let parent_lines = vec![b"REALZZZ".to_vec(), b"ab".to_vec(), b"FILLER".to_vec()];
let matched = vec![false, false];
let out = super::walk::precise_overrides(&super::walk::PreciseRequest {
mapping: &mapping,
fall: &fall,
new_lines: &new_lines,
parent_lines: &parent_lines,
matched: &matched,
ignore_whitespace: false,
});
assert_eq!(
out[0],
Some(2),
"trivial key 'ab' keeps its positional guess even though a perfect \
unclaimed match ('ab' at old index 1) exists"
);
assert_eq!(
out[1],
Some(0),
"non-trivial key 'REALZZZ' fills its None positional slot from its \
true content match (old index 0)"
);
}
#[test]
fn precise_overrides_never_teleports_edited_line_worse_than_positional() {
let mapping = vec![Some(0), None, Some(2), Some(3), Some(5)];
let fall = vec![None, Some(1), None, None, None];
let new_lines = vec![
b"A".to_vec(),
b"bar".to_vec(),
b"B1".to_vec(),
b"B2".to_vec(),
b"C".to_vec(),
];
let parent_lines = vec![
b"A".to_vec(),
b"foo".to_vec(),
b"B1".to_vec(),
b"B2".to_vec(),
b"bar".to_vec(),
b"C".to_vec(),
];
let matched = vec![false; new_lines.len()];
let out = super::walk::precise_overrides(&super::walk::PreciseRequest {
mapping: &mapping,
fall: &fall,
new_lines: &new_lines,
parent_lines: &parent_lines,
matched: &matched,
ignore_whitespace: false,
});
assert_eq!(
out[1],
Some(1),
"edited `bar` keeps its positional predecessor (foo @ old idx 1)"
);
assert_ne!(
out[1],
Some(4),
"must NOT teleport to the unrelated `bar` @ old idx 4 (commit X)"
);
assert_eq!(
out,
vec![None, Some(1), None, None, None],
"no slot is attributed worse than the positional fall-through"
);
}
#[test]
fn precise_overrides_reindented_brace_does_not_teleport_without_w() {
let mapping = vec![None];
let fall = vec![None];
let new_lines = vec![b" }".to_vec()];
let parent_lines = vec![b" }".to_vec()]; let matched = vec![false];
let out = super::walk::precise_overrides(&super::walk::PreciseRequest {
mapping: &mapping,
fall: &fall,
new_lines: &new_lines,
parent_lines: &parent_lines,
matched: &matched,
ignore_whitespace: false,
});
assert_eq!(
out[0], None,
"an indented brace is trivial once whitespace-stripped; it must not \
teleport to an unrelated brace even without -w"
);
}
#[test]
fn ignore_fallthrough_pairs_per_hunk() {
let mapping = vec![Some(0), None, None, Some(3)];
assert_eq!(
super::ignore_fallthrough(&mapping, 4),
vec![None, Some(1), Some(2), None]
);
let mapping = vec![Some(0), None, None, Some(2)];
assert_eq!(
super::ignore_fallthrough(&mapping, 3),
vec![None, Some(1), None, None]
);
let mapping = vec![Some(0), Some(1), None, None];
assert_eq!(
super::ignore_fallthrough(&mapping, 2),
vec![None, None, None, None]
);
}
#[test]
fn reverse_attributes_each_line_to_last_commit_it_survived() {
let (_d, store) = fresh_store();
let c1 = put_file_commit(&store, "f.txt", b"keep\ndoomed\nalso\n", vec![], 1, 100);
let c2 = put_file_commit(
&store,
"f.txt",
b"keep\ndoomed\nalso\nextra\n",
vec![c1],
2,
200,
);
let c3 = put_file_commit(&store, "f.txt", b"keep\nalso\nextra\n", vec![c2], 3, 300);
let c4 = put_file_commit(&store, "f.txt", b"keep\nalso\nextra2\n", vec![c3], 4, 400);
let r = blame_file_reverse(&store, c1, c4, "f.txt", &BlameOptions::default()).unwrap();
assert_eq!(r.lines.len(), 3, "blames the start (c1) version's 3 lines");
assert_eq!(r.lines[0].text, b"keep");
assert_eq!(r.lines[0].commit_hash, c4, "keep survives to the end");
assert_eq!(r.lines[1].text, b"doomed");
assert_eq!(r.lines[1].commit_hash, c2, "doomed last existed in c2");
assert_eq!(r.lines[2].text, b"also");
assert_eq!(r.lines[2].commit_hash, c4, "also survives to the end");
}
#[test]
fn reverse_line_removed_immediately_stays_on_start() {
let (_d, store) = fresh_store();
let c1 = put_file_commit(&store, "f.txt", b"keep\ngone\n", vec![], 1, 100);
let c2 = put_file_commit(&store, "f.txt", b"keep\n", vec![c1], 2, 200);
let c3 = put_file_commit(&store, "f.txt", b"keep\nnew\n", vec![c2], 3, 300);
let r = blame_file_reverse(&store, c1, c3, "f.txt", &BlameOptions::default()).unwrap();
assert_eq!(r.lines[0].commit_hash, c3, "keep survives to the end");
assert_eq!(
r.lines[1].commit_hash, c1,
"gone never survived a step → stays on start"
);
assert_eq!(r.lines[1].text, b"gone");
}
#[test]
fn reverse_modified_line_freezes_before_the_edit() {
let (_d, store) = fresh_store();
let c1 = put_file_commit(&store, "f.txt", b"a\nMOD\nc\n", vec![], 1, 100);
let c2 = put_file_commit(&store, "f.txt", b"a\nMOD2\nc\n", vec![c1], 2, 200);
let c3 = put_file_commit(&store, "f.txt", b"a\nMOD3\nc\n", vec![c2], 3, 300);
let r = blame_file_reverse(&store, c1, c3, "f.txt", &BlameOptions::default()).unwrap();
assert_eq!(r.lines[0].commit_hash, c3, "a survives");
assert_eq!(r.lines[1].commit_hash, c1, "MOD changed in c2 → last in c1");
assert_eq!(r.lines[2].commit_hash, c3, "c survives");
}
#[test]
fn reverse_unchanged_commit_advances_attribution() {
let (_d, store) = fresh_store();
let c1 = put_file_commit(&store, "f.txt", b"a\n", vec![], 1, 100);
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();
assert_eq!(
r.lines[0].commit_hash, c2,
"a last existed in the unchanged c2, gone by c3"
);
}
#[test]
fn reverse_open_end_walks_to_provided_end() {
let (_d, store) = fresh_store();
let c1 = put_file_commit(&store, "f.txt", b"keep\ndoomed\n", vec![], 1, 100);
let c2 = put_file_commit(&store, "f.txt", b"keep\ndoomed\nx\n", vec![c1], 2, 200);
let _c3 = put_file_commit(&store, "f.txt", b"keep\nx\n", vec![c2], 3, 300);
let r = blame_file_reverse(&store, c1, c2, "f.txt", &BlameOptions::default()).unwrap();
assert!(
r.lines.iter().all(|l| l.commit_hash == c2),
"with the range ending at c2 both start lines last exist in c2"
);
}
#[test]
fn reverse_traces_through_whitespace_edit_under_w() {
let (_d, store) = fresh_store();
let c1 = put_file_commit(&store, "f.txt", b"foo(a, b)\n", vec![], 1, 100);
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);
let plain = blame_file_reverse(&store, c1, c3, "f.txt", &BlameOptions::default()).unwrap();
assert_eq!(
plain.lines[0].commit_hash, c1,
"ws edit ends the line at c1"
);
let w = BlameOptions {
ignore_whitespace: true,
..Default::default()
};
let rw = blame_file_reverse(&store, c1, c3, "f.txt", &w).unwrap();
assert_eq!(
rw.lines[0].commit_hash, c2,
"-w traces the line through the reformat → last exists in c2"
);
}
#[test]
fn reverse_start_not_ancestor_errors() {
let (_d, store) = fresh_store();
let c1 = put_file_commit(&store, "f.txt", b"a\n", vec![], 1, 100);
let other = put_file_commit(&store, "f.txt", b"z\n", vec![], 9, 900);
let err =
blame_file_reverse(&store, other, c1, "f.txt", &BlameOptions::default()).unwrap_err();
assert!(
matches!(err, BlameError::ReverseRange { .. }),
"got {err:?}"
);
}
#[test]
fn reverse_missing_path_in_start_errors() {
let (_d, store) = fresh_store();
let c1 = put_file_commit(&store, "other.txt", b"x\n", vec![], 1, 100);
let c2 = put_file_commit(&store, "f.txt", b"y\n", vec![c1], 2, 200);
let err =
blame_file_reverse(&store, c1, c2, "f.txt", &BlameOptions::default()).unwrap_err();
assert!(matches!(err, BlameError::FileNotFound(_)), "got {err:?}");
}
fn diamond_distinct(store: &ObjectStore) -> (Hash, Hash, Hash, Hash) {
let base = put_file_commit(store, "f.txt", b"base1\nbase2\n", vec![], 1, 100);
let feat = put_file_commit(
store,
"f.txt",
b"base1\nbase2\nfeature\n",
vec![base],
2,
200,
);
let main = put_file_commit(store, "f.txt", b"main\nbase1\nbase2\n", vec![base], 3, 300);
let merge = put_file_commit(
store,
"f.txt",
b"main\nbase1\nbase2\nfeature\n",
vec![main, feat],
4,
400,
);
(base, main, feat, merge)
}
#[test]
fn blame_merge_aware_credits_side_branch_lines() {
let (_d, store) = fresh_store();
let (base, main, feat, merge) = diamond_distinct(&store);
let r = blame_file(&store, merge, "f.txt").unwrap();
assert_eq!(r.lines[0].commit_hash, main, "main-line → main");
assert_eq!(r.lines[1].commit_hash, base, "base1 → base");
assert_eq!(r.lines[2].commit_hash, base, "base2 → base");
assert_eq!(r.lines[3].commit_hash, feat, "feature → feature commit");
assert!(
r.lines.iter().all(|l| l.commit_hash != merge),
"none → merge"
);
}
#[test]
fn blame_first_parent_credits_merge_for_side_branch_line() {
let (_d, store) = fresh_store();
let (base, main, _feat, merge) = diamond_distinct(&store);
let opts = BlameOptions {
first_parent: true,
..Default::default()
};
let r = blame_file_with(&store, merge, "f.txt", &opts).unwrap();
assert_eq!(r.lines[0].commit_hash, main, "main-line → main");
assert_eq!(r.lines[1].commit_hash, base);
assert_eq!(r.lines[3].commit_hash, merge, "feature line → merge");
}
#[test]
fn blame_merge_identical_line_goes_to_first_parent() {
let (_d, store) = fresh_store();
let base = put_file_commit(&store, "f.txt", b"base\n", vec![], 1, 100);
let feat = put_file_commit(&store, "f.txt", b"base\nshared\n", vec![base], 2, 200);
let main = put_file_commit(&store, "f.txt", b"base\nshared\n", vec![base], 3, 300);
let merge = put_file_commit(&store, "f.txt", b"base\nshared\n", vec![main, feat], 4, 400);
let r = blame_file(&store, merge, "f.txt").unwrap();
assert_eq!(r.lines[1].commit_hash, main, "shared line → first parent");
assert!(r.lines.iter().all(|l| l.commit_hash != feat));
}
#[test]
fn blame_evil_merge_attributes_new_line_to_merge() {
let (_d, store) = fresh_store();
let base = put_file_commit(&store, "f.txt", b"base\n", vec![], 1, 100);
let feat = put_file_commit(&store, "f.txt", b"base\nfeat\n", vec![base], 2, 200);
let main = put_file_commit(&store, "f.txt", b"main\nbase\n", vec![base], 3, 300);
let merge = put_file_commit(
&store,
"f.txt",
b"main\nbase\nfeat\nEVIL\n",
vec![main, feat],
4,
400,
);
let r = blame_file(&store, merge, "f.txt").unwrap();
assert_eq!(r.lines[0].commit_hash, main);
assert_eq!(r.lines[1].commit_hash, base);
assert_eq!(r.lines[2].commit_hash, feat);
assert_eq!(r.lines[3].commit_hash, merge, "the evil line → merge");
}
#[test]
fn blame_octopus_merge_credits_each_branch() {
let (_d, store) = fresh_store();
let base = put_file_commit(&store, "f.txt", b"base\n", vec![], 1, 100);
let b1 = put_file_commit(&store, "f.txt", b"base\nb1\n", vec![base], 2, 200);
let b2 = put_file_commit(&store, "f.txt", b"base\nb2\n", vec![base], 3, 300);
let b3 = put_file_commit(&store, "f.txt", b"base\nb3\n", vec![base], 4, 400);
let merge = put_file_commit(
&store,
"f.txt",
b"base\nb1\nb2\nb3\n",
vec![b1, b2, b3],
5,
500,
);
let r = blame_file(&store, merge, "f.txt").unwrap();
assert_eq!(r.lines[0].commit_hash, base);
assert_eq!(r.lines[1].commit_hash, b1);
assert_eq!(r.lines[2].commit_hash, b2);
assert_eq!(r.lines[3].commit_hash, b3);
}
#[test]
fn blame_m_merge_credits_move_from_second_parent() {
let (_d, store) = fresh_store();
let base = put_file_commit(&store, "f.txt", b"X\nY\n", vec![], 1, 100);
let p1 = put_file_commit(&store, "f.txt", b"X\nY\nZ\n", vec![base], 2, 200);
let v2 = [LONG_LINE, b"X", b"Y", b""].join(&b'\n');
let c2 = put_file_commit(&store, "f.txt", &v2, vec![base], 3, 300);
let vm = [b"X" as &[u8], b"Y", b"Z", LONG_LINE, b""].join(&b'\n');
let merge = put_file_commit(&store, "f.txt", &vm, vec![p1, c2], 4, 400);
let opts = BlameOptions {
moves: MoveDetection::On { threshold: 20 },
..Default::default()
};
let r = blame_file_with(&store, merge, "f.txt", &opts).unwrap();
assert_eq!(r.lines[3].text, LONG_LINE);
assert_eq!(
r.lines[3].commit_hash, c2,
"-M credits the move to the 2nd-parent origin, not the merge"
);
assert_eq!(r.lines[2].commit_hash, p1, "Z stays on the first parent");
}
#[test]
fn blame_m_merge_move_prefers_first_parent() {
let (_d, store) = fresh_store();
let base = put_file_commit(&store, "f.txt", b"X\nY\n", vec![], 1, 100);
let v = [LONG_LINE, b"X", b"Y", b""].join(&b'\n');
let p1 = put_file_commit(&store, "f.txt", &v, vec![base], 2, 200);
let c2 = put_file_commit(&store, "f.txt", &v, vec![base], 3, 300);
let vm = [b"X" as &[u8], b"Y", LONG_LINE, b""].join(&b'\n');
let merge = put_file_commit(&store, "f.txt", &vm, vec![p1, c2], 4, 400);
let opts = BlameOptions {
moves: MoveDetection::On { threshold: 20 },
..Default::default()
};
let r = blame_file_with(&store, merge, "f.txt", &opts).unwrap();
assert_eq!(r.lines[2].text, LONG_LINE);
assert_eq!(
r.lines[2].commit_hash, p1,
"-M move at a merge prefers the first parent on a tie"
);
assert!(
r.lines.iter().all(|l| l.commit_hash != c2),
"the second parent never wins the tie"
);
}
#[test]
fn blame_ignore_rev_merge_falls_through_to_second_parent() {
let (_d, store) = fresh_store();
let base = put_file_commit(&store, "f.txt", b"TOP\nMID\nBOT\n", vec![], 1, 100);
let p1 = put_file_commit(&store, "f.txt", b"TOP\nBOT\n", vec![base], 2, 200);
let v2 = [b"TOP" as &[u8], b"REAL_CONTENT_OF_B_LINE", b"BOT", b""].join(&b'\n');
let c2 = put_file_commit(&store, "f.txt", &v2, vec![base], 3, 300);
let vm = [b"TOP" as &[u8], b" REAL_CONTENT_OF_B_LINE X", b"BOT", b""].join(&b'\n');
let merge = put_file_commit(&store, "f.txt", &vm, vec![p1, c2], 4, 400);
let r = blame_file_with(&store, merge, "f.txt", &ignoring(&[merge])).unwrap();
assert_eq!(r.lines[1].text, b" REAL_CONTENT_OF_B_LINE X");
assert_eq!(
r.lines[1].commit_hash, c2,
"ignored merge falls through across to the 2nd parent's origin"
);
}
#[test]
fn blame_ignore_rev_merge_prefers_first_parent_counterpart() {
let (_d, store) = fresh_store();
let base = put_file_commit(&store, "f.txt", b"a\nb\nc\n", vec![], 1, 100);
let p1 = put_file_commit(
&store,
"f.txt",
b"a\nMAIN_B_VERSION\nc\n",
vec![base],
2,
200,
);
let v2 = [b"a" as &[u8], b"REAL_CONTENT_OF_B_LINE", b"c", b""].join(&b'\n');
let c2 = put_file_commit(&store, "f.txt", &v2, vec![base], 3, 300);
let vm = [b"a" as &[u8], b" REAL_CONTENT_OF_B_LINE X", b"c", b""].join(&b'\n');
let merge = put_file_commit(&store, "f.txt", &vm, vec![p1, c2], 4, 400);
let r = blame_file_with(&store, merge, "f.txt", &ignoring(&[merge])).unwrap();
assert_eq!(
r.lines[1].commit_hash, p1,
"fall-through prefers the first parent on a positional tie"
);
assert!(
r.lines.iter().all(|l| l.commit_hash != c2),
"the second parent does not win when the first parent has a counterpart"
);
}
#[test]
fn blame_ignore_rev_precise_merge_second_parent_composition() {
let (_d, store) = fresh_store();
let base = put_file_commit(&store, "f.txt", b"TOP\nBOT\n", vec![], 1, 100);
let p1 = put_file_commit(&store, "f.txt", b"TOP\nBOT\n", vec![base], 2, 200);
let c_x = put_file_commit(&store, "f.txt", b"TOP\nXXX\nBOT\n", vec![base], 3, 300);
let c_y = put_file_commit(&store, "f.txt", b"TOP\nXXX\nYYY\nBOT\n", vec![c_x], 4, 400);
let c_z = put_file_commit(
&store,
"f.txt",
b"TOP\nXXX\nYYY\nZZZ\nBOT\n",
vec![c_y],
5,
500,
);
let merge = put_file_commit(
&store,
"f.txt",
b"TOP\n ZZZ\n YYY\n XXX\nBOT\n",
vec![p1, c_z],
6,
600,
);
let positional_opts = BlameOptions {
ignore_whitespace: true,
ignore_revs: Arc::new([merge].into_iter().collect()),
..Default::default()
};
let positional = blame_file_with(&store, merge, "f.txt", &positional_opts).unwrap();
assert_eq!(
positional.lines[1].commit_hash, c_z,
"ZZZ is recognized unchanged by the LCS matcher itself, against the 2nd parent"
);
assert_eq!(
positional.lines[2].commit_hash, merge,
"positional: YYY has no in-hunk counterpart on either parent, stays on the merge"
);
assert_eq!(
positional.lines[3].commit_hash, merge,
"positional: XXX has no in-hunk counterpart on either parent, stays on the merge"
);
let precise = blame_file_with(&store, merge, "f.txt", &ignoring_precise(&[merge])).unwrap();
assert_eq!(
precise.lines[1].commit_hash, c_z,
"ZZZ is unaffected by precise mode (already resolved by plain LCS)"
);
assert_eq!(
precise.lines[2].commit_hash, c_y,
"precise: YYY correctly attributed to its true origin via the 2nd parent's whole file"
);
assert_eq!(
precise.lines[3].commit_hash, c_x,
"precise: XXX correctly attributed to its true origin via the 2nd parent's whole file"
);
assert!(
positional.lines.iter().all(|l| l.commit_hash != p1)
&& precise.lines.iter().all(|l| l.commit_hash != p1),
"the content-less first parent never wins"
);
}
#[test]
fn blame_c_merge_credits_copy_from_second_parent() {
let (_d, store) = fresh_store();
let base = put_multi_file_commit(&store, &[("b.txt", b"hello\n")], vec![], 1, 100);
let p1 = put_multi_file_commit(
&store,
&[("b.txt", b"hello\n"), ("m.txt", b"main only\n")],
vec![base],
2,
200,
);
let src = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
let c2 = put_multi_file_commit(
&store,
&[("b.txt", b"hello\n"), ("src.txt", &src)],
vec![base],
3,
300,
);
let bmerge = [b"hello" as &[u8], BLOCK_A, BLOCK_B, b""].join(&b'\n');
let merge = put_multi_file_commit(
&store,
&[
("b.txt", &bmerge),
("m.txt", b"main only\n"),
("src.txt", &src),
],
vec![p1, c2],
4,
400,
);
let opts = BlameOptions {
copies: CopyDetection::On {
level: 2,
threshold: 40,
},
..Default::default()
};
let r = blame_file_with(&store, merge, "b.txt", &opts).unwrap();
assert_eq!(r.lines[1].text, BLOCK_A);
assert_eq!(
r.lines[1].commit_hash, c2,
"a modified file's appended block is copied across to the second parent's tree (git credits c2)"
);
assert_eq!(
r.lines[2].commit_hash, c2,
"the whole copied block is credited to c2"
);
assert_ne!(r.lines[1].commit_hash, merge);
}
#[test]
fn blame_c_merge_credits_copy_from_third_octopus_parent() {
let (_d, store) = fresh_store();
let base = put_multi_file_commit(&store, &[("b.txt", b"hello\n")], vec![], 1, 100);
let p1 = put_multi_file_commit(
&store,
&[("b.txt", b"hello\n"), ("x.txt", b"x only\n")],
vec![base],
2,
200,
);
let p2 = put_multi_file_commit(
&store,
&[("b.txt", b"hello\n"), ("y.txt", b"y only\n")],
vec![base],
3,
300,
);
let src = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
let p3 = put_multi_file_commit(
&store,
&[("b.txt", b"hello\n"), ("src.txt", &src)],
vec![base],
4,
400,
);
let bmerge = [b"hello" as &[u8], BLOCK_A, BLOCK_B, b""].join(&b'\n');
let merge = put_multi_file_commit(
&store,
&[
("b.txt", &bmerge),
("x.txt", b"x only\n"),
("y.txt", b"y only\n"),
("src.txt", &src),
],
vec![p1, p2, p3],
5,
500,
);
let opts = BlameOptions {
copies: CopyDetection::On {
level: 2,
threshold: 40,
},
..Default::default()
};
let r = blame_file_with(&store, merge, "b.txt", &opts).unwrap();
assert_eq!(r.lines[1].text, BLOCK_A);
assert_eq!(
r.lines[1].commit_hash, p3,
"the copied block is traced to the third octopus parent's tree"
);
}
#[test]
fn blame_c_merge_source_only_in_merge_tree_credits_merge() {
let (_d, store) = fresh_store();
let base = put_multi_file_commit(&store, &[("b.txt", b"hello\n")], vec![], 1, 100);
let p1 = put_multi_file_commit(
&store,
&[("b.txt", b"hello\n"), ("m.txt", b"main only\n")],
vec![base],
2,
200,
);
let c2 = put_multi_file_commit(
&store,
&[("b.txt", b"hello\n"), ("o.txt", b"other\n")],
vec![base],
3,
300,
);
let src = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
let bmerge = [b"hello" as &[u8], BLOCK_A, BLOCK_B, b""].join(&b'\n');
let merge = put_multi_file_commit(
&store,
&[
("b.txt", &bmerge),
("m.txt", b"main only\n"),
("o.txt", b"other\n"),
("src.txt", &src),
],
vec![p1, c2],
4,
400,
);
let opts = BlameOptions {
copies: CopyDetection::On {
level: 2,
threshold: 40,
},
..Default::default()
};
let r = blame_file_with(&store, merge, "b.txt", &opts).unwrap();
assert_eq!(r.lines[1].text, BLOCK_A);
assert_eq!(
r.lines[1].commit_hash, merge,
"no parent holds the source, so the appended block stays on the merge (git parity)"
);
}
#[test]
fn blame_c_merge_copy_tie_prefers_deduped_second_parent() {
let (_d, store) = fresh_store();
let base = put_multi_file_commit(&store, &[("b.txt", b"hello\n")], vec![], 1, 100);
let src = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
let p1 = put_multi_file_commit(
&store,
&[("b.txt", b"hello\n"), ("s1.txt", &src)],
vec![base],
2,
200,
);
let c2 = put_multi_file_commit(
&store,
&[("b.txt", b"hello\n"), ("s2.txt", &src)],
vec![base],
3,
300,
);
let bmerge = [b"hello" as &[u8], BLOCK_A, BLOCK_B, b""].join(&b'\n');
let merge = put_multi_file_commit(
&store,
&[("b.txt", &bmerge), ("s1.txt", &src), ("s2.txt", &src)],
vec![p1, c2],
4,
400,
);
let opts = BlameOptions {
copies: CopyDetection::On {
level: 2,
threshold: 40,
},
..Default::default()
};
let r = blame_file_with(&store, merge, "b.txt", &opts).unwrap();
assert_eq!(r.lines[1].text, BLOCK_A);
assert_eq!(
r.lines[1].commit_hash, c2,
"a copy tie across parents resolves to the non-first parent (git parity)"
);
assert_eq!(r.lines[2].commit_hash, c2);
assert!(
r.lines.iter().all(|l| l.commit_hash != p1),
"the first parent never wins an interior -C tie"
);
}
#[test]
fn blame_c_merge_copy_tie_octopus_prefers_first_non_first_parent() {
let (_d, store) = fresh_store();
let base = put_multi_file_commit(&store, &[("b.txt", b"hello\n")], vec![], 1, 100);
let p1 = put_multi_file_commit(
&store,
&[("b.txt", b"hello\n"), ("pm.txt", b"p1 only\n")],
vec![base],
2,
200,
);
let src = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
let c2 = put_multi_file_commit(
&store,
&[("b.txt", b"hello\n"), ("s2.txt", &src)],
vec![base],
3,
300,
);
let c3 = put_multi_file_commit(
&store,
&[("b.txt", b"hello\n"), ("s3.txt", &src)],
vec![base],
4,
400,
);
let bmerge = [b"hello" as &[u8], BLOCK_A, BLOCK_B, b""].join(&b'\n');
let merge = put_multi_file_commit(
&store,
&[("b.txt", &bmerge), ("s2.txt", &src), ("s3.txt", &src)],
vec![p1, c2, c3],
5,
500,
);
let opts = BlameOptions {
copies: CopyDetection::On {
level: 2,
threshold: 40,
},
..Default::default()
};
let r = blame_file_with(&store, merge, "b.txt", &opts).unwrap();
assert_eq!(r.lines[1].text, BLOCK_A);
assert_eq!(
r.lines[1].commit_hash, c2,
"the first NON-first parent (in order) wins the octopus tie, not the literal last parent"
);
assert_eq!(r.lines[2].commit_hash, c2);
}
#[test]
fn blame_c_merge_copy_source_only_on_first_parent_stays_on_merge() {
let (_d, store) = fresh_store();
let base = put_multi_file_commit(&store, &[("b.txt", b"hello\n")], vec![], 1, 100);
let src = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
let p1 = put_multi_file_commit(
&store,
&[("b.txt", b"hello\n"), ("s1.txt", &src)],
vec![base],
2,
200,
);
let c2 = put_multi_file_commit(
&store,
&[
("b.txt", b"hello\n"),
("m.txt", b"other unrelated content\n"),
],
vec![base],
3,
300,
);
let bmerge = [b"hello" as &[u8], BLOCK_A, BLOCK_B, b""].join(&b'\n');
let merge = put_multi_file_commit(
&store,
&[
("b.txt", &bmerge),
("s1.txt", &src),
("m.txt", b"other unrelated content\n"),
],
vec![p1, c2],
4,
400,
);
let opts = BlameOptions {
copies: CopyDetection::On {
level: 2,
threshold: 40,
},
..Default::default()
};
let r = blame_file_with(&store, merge, "b.txt", &opts).unwrap();
assert_eq!(r.lines[1].text, BLOCK_A);
assert_eq!(
r.lines[1].commit_hash, merge,
"an uncontested -C source on the first parent is never traced (git parity)"
);
assert_eq!(r.lines[2].commit_hash, merge);
}
#[test]
fn blame_c_merge_unmodified_first_parent_source_with_fileless_second_stays_on_merge() {
let (_d, store) = fresh_store();
let base = put_multi_file_commit(
&store,
&[
("b.txt", b"hello\n"),
("sbase.txt", b"source header line\n"),
],
vec![],
1,
100,
);
let src = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
let p1 = put_multi_file_commit(
&store,
&[
("b.txt", b"hello\n"),
("sbase.txt", b"source header line\n"),
("s1.txt", &src),
],
vec![base],
2,
200,
);
let p2 = put_multi_file_commit(
&store,
&[("sbase.txt", b"source header line\n")],
vec![base],
3,
300,
);
let bmerge = [b"hello" as &[u8], BLOCK_A, BLOCK_B, b""].join(&b'\n');
let merge = put_multi_file_commit(
&store,
&[
("b.txt", &bmerge),
("sbase.txt", b"source header line\n"),
("s1.txt", &src),
],
vec![p1, p2],
4,
400,
);
let opts = BlameOptions {
copies: CopyDetection::On {
level: 2,
threshold: 40,
},
..Default::default()
};
let r = blame_file_with(&store, merge, "b.txt", &opts).unwrap();
assert_eq!(r.lines[1].text, BLOCK_A);
assert_eq!(
r.lines[1].commit_hash, merge,
"a fileless second parent does not make the merge linear: p1's \
unchanged source stays invisible and the block stays on the merge (git parity)"
);
assert_eq!(r.lines[2].commit_hash, merge);
}
#[test]
fn blame_c_merge_file_deleting_parent_supplies_copy_source() {
let (_d, store) = fresh_store();
let base = put_multi_file_commit(
&store,
&[("f.txt", b"hello\n"), ("s.txt", b"source header line\n")],
vec![],
1,
100,
);
let p1 = put_multi_file_commit(
&store,
&[("f.txt", b"hello\n"), ("s.txt", b"source header line\n")],
vec![base],
2,
200,
);
let src = [
b"source header line" as &[u8],
BLOCK_A,
BLOCK_B,
b"zzz",
b"",
]
.join(&b'\n');
let p2 = put_multi_file_commit(&store, &[("s.txt", &src)], vec![base], 3, 300);
let bmerge = [b"hello" as &[u8], BLOCK_A, BLOCK_B, b""].join(&b'\n');
let merge = put_multi_file_commit(
&store,
&[("f.txt", &bmerge), ("s.txt", &src)],
vec![p1, p2],
4,
400,
);
let opts = BlameOptions {
copies: CopyDetection::On {
level: 2,
threshold: 40,
},
..Default::default()
};
let r = blame_file_with(&store, merge, "f.txt", &opts).unwrap();
assert_eq!(r.lines[1].text, BLOCK_A);
assert_eq!(
r.lines[1].commit_hash, p2,
"the parent that deleted the blamed file is whole-tree searched \
and its source claims the block (git parity)"
);
assert_eq!(r.lines[2].commit_hash, p2);
}
#[test]
fn blame_c_merge_fileless_first_parent_unmodified_second_source_stays_on_merge() {
let (_d, store) = fresh_store();
let base = put_multi_file_commit(
&store,
&[("b.txt", b"hello\n"), ("s2.txt", b"source header line\n")],
vec![],
1,
100,
);
let p1 = put_multi_file_commit(
&store,
&[("s2.txt", b"source header line\n")],
vec![base],
2,
200,
);
let src = [
b"source header line" as &[u8],
BLOCK_A,
BLOCK_B,
b"zzz",
b"",
]
.join(&b'\n');
let p2 = put_multi_file_commit(
&store,
&[("b.txt", b"hello\n"), ("s2.txt", &src)],
vec![base],
3,
300,
);
let p3 = put_multi_file_commit(
&store,
&[
("b.txt", b"hello\n"),
("s2.txt", b"source header line\n"),
("o.txt", b"other\n"),
],
vec![base],
4,
400,
);
let bmerge = [b"hello" as &[u8], BLOCK_A, BLOCK_B, b""].join(&b'\n');
let merge = put_multi_file_commit(
&store,
&[("b.txt", &bmerge), ("s2.txt", &src), ("o.txt", b"other\n")],
vec![p1, p2, p3],
5,
500,
);
let opts = BlameOptions {
copies: CopyDetection::On {
level: 2,
threshold: 40,
},
..Default::default()
};
let r = blame_file_with(&store, merge, "b.txt", &opts).unwrap();
assert_eq!(r.lines[1].text, BLOCK_A);
assert_eq!(
r.lines[1].commit_hash, merge,
"the first file-bearing parent keeps its porigin even when the real \
first parent is fileless; its unchanged source stays invisible (git parity)"
);
assert_eq!(r.lines[2].commit_hash, merge);
}
#[test]
fn blame_c_level1_merge_modified_source_credits_first_parent() {
let (_d, store) = fresh_store();
let base = put_multi_file_commit(
&store,
&[("b.txt", b"hello\n"), ("s1.txt", b"source header line\n")],
vec![],
1,
100,
);
let src = [
b"source header line" as &[u8],
BLOCK_A,
BLOCK_B,
b"zzz",
b"",
]
.join(&b'\n');
let p1 = put_multi_file_commit(
&store,
&[("b.txt", b"hello\n"), ("s1.txt", &src)],
vec![base],
2,
200,
);
let p2 = put_multi_file_commit(
&store,
&[
("b.txt", b"hello\n"),
("s1.txt", b"source header line\n"),
("o.txt", b"other\n"),
],
vec![base],
3,
300,
);
let bmerge = [b"hello" as &[u8], BLOCK_A, BLOCK_B, b""].join(&b'\n');
let merge = put_multi_file_commit(
&store,
&[
("b.txt", &bmerge),
("s1.txt", b"source header line\n"),
("o.txt", b"other\n"),
],
vec![p1, p2],
4,
400,
);
for level in [1u8, 2] {
let opts = BlameOptions {
copies: CopyDetection::On {
level,
threshold: 40,
},
..Default::default()
};
let r = blame_file_with(&store, merge, "b.txt", &opts).unwrap();
assert_eq!(r.lines[1].text, BLOCK_A);
assert_eq!(
r.lines[1].commit_hash, p1,
"a source modified between the first parent and the merge is a \
level-{level} candidate and credits the first parent (git parity)"
);
}
}
#[test]
fn blame_c_boundary_first_parent_mode_still_searches_first_parent() {
let (_d, store) = fresh_store();
let base = put_multi_file_commit(&store, &[("x.txt", b"x\n")], vec![], 1, 100);
let src = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
let p1 = put_multi_file_commit(
&store,
&[("x.txt", b"x\n"), ("s1.txt", &src)],
vec![base],
2,
200,
);
let p2 = put_multi_file_commit(
&store,
&[("x.txt", b"x\n"), ("o.txt", b"other\n")],
vec![base],
3,
300,
);
let newf = [BLOCK_A, BLOCK_B, b""].join(&b'\n');
let merge = put_multi_file_commit(
&store,
&[
("x.txt", b"x\n"),
("s1.txt", &src),
("o.txt", b"other\n"),
("n.txt", &newf),
],
vec![p1, p2],
4,
400,
);
let opts = BlameOptions {
copies: CopyDetection::On {
level: 2,
threshold: 40,
},
first_parent: true,
..Default::default()
};
let r = blame_file_with(&store, merge, "n.txt", &opts).unwrap();
assert_eq!(r.lines[0].text, BLOCK_A);
assert_eq!(
r.lines[0].commit_hash, p1,
"--first-parent truncates the boundary search to the real first \
parent, which is still whole-tree searched (git parity)"
);
assert_eq!(r.lines[1].commit_hash, p1);
}
#[test]
fn blame_c_merge_mixed_within_file_move_beats_copy_on_tie() {
let (_d, store) = fresh_store();
let base = put_file_commit(&store, "f.txt", b"X\nY\n", vec![], 1, 100);
let v1 = [LONG_LINE, b"X", b"Y", b""].join(&b'\n');
let p1 = put_file_commit(&store, "f.txt", &v1, vec![base], 2, 200);
let c2 = put_multi_file_commit(
&store,
&[
("f.txt", b"X\nY\n"),
("other.txt", &[LONG_LINE, b"other stuff", b""].join(&b'\n')),
],
vec![base],
3,
300,
);
let vm = [b"X" as &[u8], b"Y", LONG_LINE, b""].join(&b'\n');
let merge = put_multi_file_commit(
&store,
&[
("f.txt", &vm),
("other.txt", &[LONG_LINE, b"other stuff", b""].join(&b'\n')),
],
vec![p1, c2],
4,
400,
);
let opts = BlameOptions {
moves: MoveDetection::On { threshold: 20 },
copies: CopyDetection::On {
level: 2,
threshold: 40,
},
..Default::default()
};
let r = blame_file_with(&store, merge, "f.txt", &opts).unwrap();
assert_eq!(r.lines[2].text, LONG_LINE);
assert_eq!(
r.lines[2].commit_hash, p1,
"-M's within-file move on the first parent still wins the tie over -C's copy on the second"
);
}
#[test]
fn blame_c_merge_boundary_copy_from_second_parent() {
let (_d, store) = fresh_store();
let base = put_multi_file_commit(&store, &[("base.txt", b"base\n")], vec![], 1, 100);
let p1 = put_multi_file_commit(
&store,
&[("base.txt", b"base\n"), ("m.txt", b"main only\n")],
vec![base],
2,
200,
);
let src = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
let c2 = put_multi_file_commit(
&store,
&[("base.txt", b"base\n"), ("src.txt", &src)],
vec![base],
3,
300,
);
let bnew = [BLOCK_A, BLOCK_B, b""].join(&b'\n');
let merge = put_multi_file_commit(
&store,
&[
("base.txt", b"base\n"),
("m.txt", b"main only\n"),
("src.txt", &src),
("b.txt", &bnew),
],
vec![p1, c2],
4,
400,
);
let opts = BlameOptions {
copies: CopyDetection::On {
level: 2,
threshold: 40,
},
..Default::default()
};
let r = blame_file_with(&store, merge, "b.txt", &opts).unwrap();
assert_eq!(r.lines[0].text, BLOCK_A);
assert_eq!(
r.lines[0].commit_hash, c2,
"a boundary -C source on a non-first parent is traced (git parity)"
);
assert_eq!(r.lines[1].commit_hash, c2);
}
#[test]
fn blame_c_merge_boundary_copy_octopus_third_parent() {
let (_d, store) = fresh_store();
let base = put_multi_file_commit(&store, &[("base.txt", b"base\n")], vec![], 1, 100);
let p1 = put_multi_file_commit(
&store,
&[("base.txt", b"base\n"), ("pm.txt", b"p1 only\n")],
vec![base],
2,
200,
);
let c2 = put_multi_file_commit(
&store,
&[("base.txt", b"base\n"), ("cm.txt", b"c2 only\n")],
vec![base],
3,
300,
);
let src = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
let c3 = put_multi_file_commit(
&store,
&[("base.txt", b"base\n"), ("s3.txt", &src)],
vec![base],
4,
400,
);
let bnew = [BLOCK_A, BLOCK_B, b""].join(&b'\n');
let merge = put_multi_file_commit(
&store,
&[
("base.txt", b"base\n"),
("pm.txt", b"p1 only\n"),
("cm.txt", b"c2 only\n"),
("s3.txt", &src),
("b.txt", &bnew),
],
vec![p1, c2, c3],
5,
500,
);
let opts = BlameOptions {
copies: CopyDetection::On {
level: 2,
threshold: 40,
},
..Default::default()
};
let r = blame_file_with(&store, merge, "b.txt", &opts).unwrap();
assert_eq!(r.lines[0].text, BLOCK_A);
assert_eq!(
r.lines[0].commit_hash, c3,
"the boundary search walks every real parent, not just the first two"
);
assert_eq!(r.lines[1].commit_hash, c3);
}
#[test]
fn blame_c_merge_boundary_copy_tie_prefers_first_parent() {
let (_d, store) = fresh_store();
let base = put_multi_file_commit(&store, &[("base.txt", b"base\n")], vec![], 1, 100);
let src = [BLOCK_A, BLOCK_B, b"zzz", b""].join(&b'\n');
let p1 = put_multi_file_commit(
&store,
&[("base.txt", b"base\n"), ("s1.txt", &src)],
vec![base],
2,
200,
);
let c2 = put_multi_file_commit(
&store,
&[("base.txt", b"base\n"), ("s2.txt", &src)],
vec![base],
3,
300,
);
let bnew = [BLOCK_A, BLOCK_B, b""].join(&b'\n');
let merge = put_multi_file_commit(
&store,
&[
("base.txt", b"base\n"),
("s1.txt", &src),
("s2.txt", &src),
("b.txt", &bnew),
],
vec![p1, c2],
4,
400,
);
let opts = BlameOptions {
copies: CopyDetection::On {
level: 2,
threshold: 40,
},
..Default::default()
};
let r = blame_file_with(&store, merge, "b.txt", &opts).unwrap();
assert_eq!(r.lines[0].text, BLOCK_A);
assert_eq!(
r.lines[0].commit_hash, p1,
"the boundary copy tie prefers the first parent (git parity) — unlike the interior tie"
);
}
#[test]
fn match_lines_rejects_oversize_inputs() {
let n = super::BLAME_MAX_LINES + 1;
let opts = BlameOptions::default();
let old: Vec<Vec<u8>> = vec![b"x".to_vec(); n];
let new: Vec<Vec<u8>> = vec![b"y".to_vec(); 1];
let err = super::match_lines_with_options(&old, &new, &opts).unwrap_err();
assert!(
matches!(err, BlameError::FileTooLarge { lines } if lines == n),
"got {err:?}"
);
let old2: Vec<Vec<u8>> = vec![b"a".to_vec(); 1];
let new2: Vec<Vec<u8>> = vec![b"b".to_vec(); n];
let err2 = super::match_lines_with_options(&old2, &new2, &opts).unwrap_err();
assert!(
matches!(err2, BlameError::FileTooLarge { lines } if lines == n),
"got {err2:?}"
);
}
}