use std::collections::HashMap;
use anyhow::{Context, Result};
use gunnar_store::git_storage_trait::GitServe;
use crate::stores::ServerStores;
pub const WALK_BUDGET: usize = 1 << 20;
#[derive(Debug, Clone)]
pub struct Blamed {
pub commit: gix_hash::ObjectId,
pub author: String,
pub line: Vec<u8>,
}
pub fn blame_file(
git: &dyn GitServe,
hash: gix_hash::Kind,
tip: gix_hash::ObjectId,
path: &str,
) -> Result<Vec<Blamed>> {
let (_, tip_blob) = crate::verify::blob_at(git, hash, tip, path)?
.with_context(|| format!("{path} is not a file at {tip}"))?;
let tip_lines: Vec<Vec<u8>> = crate::diff::split_lines(&tip_blob)
.into_iter()
.map(<[u8]>::to_vec)
.collect();
let n = tip_lines.len();
let mut assigned: Vec<Option<gix_hash::ObjectId>> = vec![None; n];
let mut frontier: Vec<(gix_hash::ObjectId, Vec<(usize, usize)>)> =
vec![(tip, (0..n).map(|i| (i, i)).collect())];
let mut steps = 0usize;
while let Some((commit, care)) = frontier.pop() {
if care.is_empty() {
continue;
}
steps += 1;
anyhow::ensure!(
steps <= WALK_BUDGET,
"blame walked {WALK_BUDGET} history steps without finishing; that is a bound, not \
an answer"
);
let (_, blob) = crate::verify::blob_at(git, hash, commit, path)?
.with_context(|| format!("{path} left {commit} in the middle of a blame walk"))?;
let lines = crate::diff::split_lines(&blob);
let mut handed = vec![false; care.len()];
let mut handoffs: Vec<(gix_hash::ObjectId, Vec<(usize, usize)>)> = Vec::new();
for parent in crate::verify::parents(git, hash, commit)? {
let Some((_, parent_blob)) = crate::verify::blob_at(git, hash, parent, path)? else {
continue;
};
let mut moved: Vec<(usize, usize)> = Vec::new();
if parent_blob == blob {
for (c, &(line_idx, orig)) in care.iter().enumerate() {
if !handed[c] {
moved.push((line_idx, orig));
handed[c] = true;
}
}
} else {
let older = crate::diff::split_lines(&parent_blob);
let mut new_to_old: HashMap<usize, usize> = HashMap::new();
for op in crate::diff::diff_ops(&older, &lines) {
if let crate::diff::Op::Equal { old, new, count } = op {
for k in 0..count {
new_to_old.insert(new + k, old + k);
}
}
}
for (c, &(line_idx, orig)) in care.iter().enumerate() {
if handed[c] {
continue;
}
if let Some(&old) = new_to_old.get(&line_idx) {
moved.push((old, orig));
handed[c] = true;
}
}
}
if !moved.is_empty() {
handoffs.push((parent, moved));
}
}
for (c, &(_, orig)) in care.iter().enumerate() {
if !handed[c] && assigned[orig].is_none() {
assigned[orig] = Some(commit);
}
}
frontier.extend(handoffs);
}
let mut author_of: HashMap<gix_hash::ObjectId, String> = HashMap::new();
let mut out = Vec::with_capacity(n);
for (i, line) in tip_lines.into_iter().enumerate() {
let commit = assigned[i]
.with_context(|| format!("line {} was never attributed to a commit", i + 1))?;
let author = match author_of.get(&commit) {
Some(name) => name.clone(),
None => {
let name = author_name(git, hash, commit)?;
author_of.insert(commit, name.clone());
name
}
};
out.push(Blamed {
commit,
author,
line,
});
}
Ok(out)
}
fn author_name(
git: &dyn GitServe,
hash: gix_hash::Kind,
commit: gix_hash::ObjectId,
) -> Result<String> {
let (_, bytes) = git
.read(commit.as_bytes())?
.with_context(|| format!("commit {commit} is not in this repository"))?;
let decoded = gunnar_store::object::commit(&bytes, hash)
.with_context(|| format!("decoding commit {commit}"))?;
Ok(String::from_utf8_lossy(&decoded.author.name).into_owned())
}
pub fn blame(
stores: &ServerStores,
plane: &crate::control_plane::CoreControlPlane,
repo: &gunnar_proto::RepoPath,
rev: &str,
path: &str,
) -> Result<Vec<Blamed>> {
use gunnar_grpc::ControlPlane as _;
let repo_ref = gunnar_core::RepoRef::new(&repo.namespace, &repo.repo)
.context("the repository path is not a legal one")?;
let entry = stores
.catalog()
.get(&repo_ref)?
.with_context(|| format!("no such repository: {repo_ref}"))?;
let store = stores.repo_store(&entry)?;
let git = store.serve();
let hash = store.hash_kind();
let commits = plane
.log(repo, rev, 1, "")
.map_err(|err| anyhow::anyhow!("{err}"))?;
let head = commits
.first()
.with_context(|| format!("{rev} names no commit in {repo_ref}"))?;
let tip = gix_hash::ObjectId::from_hex(head.oid.as_bytes())
.map_err(gix_error::Error::from).with_context(|| format!("{} is not an object id", head.oid))?;
blame_file(git, hash, tip, path)
}
pub fn blame_at(
data_root: &std::path::Path,
repo: &gunnar_proto::RepoPath,
rev: &str,
path: &str,
) -> Result<Vec<Blamed>> {
let stores = std::sync::Arc::new(ServerStores::open(data_root)?);
let plane = crate::control_plane::CoreControlPlane::new(std::sync::Arc::clone(&stores));
blame(&stores, &plane, repo, rev, path)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testgit::{Repo, TREE_MODE_BLOB};
#[test]
fn blame_names_the_commit_that_wrote_each_line_not_the_tip() {
let repo = Repo::new();
let blob_a = repo.blob(b"line one\nline two\nline three\n");
let tree_a = repo.tree(&[(TREE_MODE_BLOB, "src.txt", blob_a)]);
let commit_a = repo.commit(tree_a, &[], "Ada", "commit A: write the file");
let blob_b = repo.blob(b"line one\nLINE TWO CHANGED\nline three\n");
let tree_b = repo.tree(&[(TREE_MODE_BLOB, "src.txt", blob_b)]);
let commit_b = repo.commit(tree_b, &[commit_a], "Bo", "commit B: rewrite line 2");
let blamed = blame_file(repo.serve(), repo.hash_kind(), commit_b, "src.txt").expect("blame");
assert_eq!(blamed.len(), 3, "the file at the tip has three lines");
assert_eq!(
blamed[0].commit, commit_a,
"line 1 was untouched since A and must be attributed to A, not the tip"
);
assert_eq!(
blamed[1].commit, commit_b,
"line 2 was rewritten by B and must be attributed to B"
);
assert_eq!(
blamed[2].commit, commit_a,
"line 3 was untouched since A and must be attributed to A, not the tip"
);
assert_eq!(blamed[0].author, "Ada");
assert_eq!(blamed[1].author, "Bo");
assert_eq!(blamed[1].line, b"LINE TWO CHANGED");
}
#[test]
fn an_inserted_line_does_not_reblame_the_lines_it_pushed_down() {
let repo = Repo::new();
let blob_a = repo.blob(b"alpha\nbeta\ngamma\n");
let tree_a = repo.tree(&[(TREE_MODE_BLOB, "f", blob_a)]);
let commit_a = repo.commit(tree_a, &[], "Ada", "A");
let blob_b = repo.blob(b"alpha\nINSERTED\nbeta\ngamma\n");
let tree_b = repo.tree(&[(TREE_MODE_BLOB, "f", blob_b)]);
let commit_b = repo.commit(tree_b, &[commit_a], "Bo", "B");
let blamed = blame_file(repo.serve(), repo.hash_kind(), commit_b, "f").expect("blame");
let names: Vec<_> = blamed.iter().map(|b| b.commit).collect();
assert_eq!(
names,
vec![commit_a, commit_b, commit_a, commit_a],
"only the inserted line is B's; alpha/beta/gamma stay A's"
);
}
}