1use std::collections::HashMap;
33
34use anyhow::{Context, Result};
35use gunnar_store::git_storage_trait::GitServe;
36
37use crate::stores::ServerStores;
38
39pub const WALK_BUDGET: usize = 1 << 20;
47
48#[derive(Debug, Clone)]
50pub struct Blamed {
51 pub commit: gix_hash::ObjectId,
53 pub author: String,
57 pub line: Vec<u8>,
59}
60
61pub fn blame_file(
70 git: &dyn GitServe,
71 hash: gix_hash::Kind,
72 tip: gix_hash::ObjectId,
73 path: &str,
74) -> Result<Vec<Blamed>> {
75 let (_, tip_blob) = crate::verify::blob_at(git, hash, tip, path)?
77 .with_context(|| format!("{path} is not a file at {tip}"))?;
78 let tip_lines: Vec<Vec<u8>> = crate::diff::split_lines(&tip_blob)
79 .into_iter()
80 .map(<[u8]>::to_vec)
81 .collect();
82 let n = tip_lines.len();
83
84 let mut assigned: Vec<Option<gix_hash::ObjectId>> = vec![None; n];
86
87 let mut frontier: Vec<(gix_hash::ObjectId, Vec<(usize, usize)>)> =
94 vec![(tip, (0..n).map(|i| (i, i)).collect())];
95
96 let mut steps = 0usize;
97 while let Some((commit, care)) = frontier.pop() {
98 if care.is_empty() {
99 continue;
100 }
101 steps += 1;
102 anyhow::ensure!(
103 steps <= WALK_BUDGET,
104 "blame walked {WALK_BUDGET} history steps without finishing; that is a bound, not \
105 an answer"
106 );
107
108 let (_, blob) = crate::verify::blob_at(git, hash, commit, path)?
109 .with_context(|| format!("{path} left {commit} in the middle of a blame walk"))?;
110 let lines = crate::diff::split_lines(&blob);
111
112 let mut handed = vec![false; care.len()];
114 let mut handoffs: Vec<(gix_hash::ObjectId, Vec<(usize, usize)>)> = Vec::new();
115
116 for parent in crate::verify::parents(git, hash, commit)? {
117 let Some((_, parent_blob)) = crate::verify::blob_at(git, hash, parent, path)? else {
118 continue;
121 };
122 let mut moved: Vec<(usize, usize)> = Vec::new();
123 if parent_blob == blob {
124 for (c, &(line_idx, orig)) in care.iter().enumerate() {
127 if !handed[c] {
128 moved.push((line_idx, orig));
129 handed[c] = true;
130 }
131 }
132 } else {
133 let older = crate::diff::split_lines(&parent_blob);
134 let mut new_to_old: HashMap<usize, usize> = HashMap::new();
136 for op in crate::diff::diff_ops(&older, &lines) {
137 if let crate::diff::Op::Equal { old, new, count } = op {
138 for k in 0..count {
139 new_to_old.insert(new + k, old + k);
140 }
141 }
142 }
143 for (c, &(line_idx, orig)) in care.iter().enumerate() {
144 if handed[c] {
145 continue;
146 }
147 if let Some(&old) = new_to_old.get(&line_idx) {
148 moved.push((old, orig));
149 handed[c] = true;
150 }
151 }
152 }
153 if !moved.is_empty() {
154 handoffs.push((parent, moved));
155 }
156 }
157
158 for (c, &(_, orig)) in care.iter().enumerate() {
162 if !handed[c] && assigned[orig].is_none() {
163 assigned[orig] = Some(commit);
164 }
165 }
166 frontier.extend(handoffs);
167 }
168
169 let mut author_of: HashMap<gix_hash::ObjectId, String> = HashMap::new();
171 let mut out = Vec::with_capacity(n);
172 for (i, line) in tip_lines.into_iter().enumerate() {
173 let commit = assigned[i]
174 .with_context(|| format!("line {} was never attributed to a commit", i + 1))?;
175 let author = match author_of.get(&commit) {
176 Some(name) => name.clone(),
177 None => {
178 let name = author_name(git, hash, commit)?;
179 author_of.insert(commit, name.clone());
180 name
181 }
182 };
183 out.push(Blamed {
184 commit,
185 author,
186 line,
187 });
188 }
189 Ok(out)
190}
191
192fn author_name(
194 git: &dyn GitServe,
195 hash: gix_hash::Kind,
196 commit: gix_hash::ObjectId,
197) -> Result<String> {
198 let (_, bytes) = git
199 .read(commit.as_bytes())?
200 .with_context(|| format!("commit {commit} is not in this repository"))?;
201 let decoded = gunnar_store::object::commit(&bytes, hash)
202 .with_context(|| format!("decoding commit {commit}"))?;
203 Ok(String::from_utf8_lossy(&decoded.author.name).into_owned())
204}
205
206pub fn blame(
210 stores: &ServerStores,
211 plane: &crate::control_plane::CoreControlPlane,
212 repo: &gunnar_proto::RepoPath,
213 rev: &str,
214 path: &str,
215) -> Result<Vec<Blamed>> {
216 use gunnar_grpc::ControlPlane as _;
217
218 let repo_ref = gunnar_core::RepoRef::new(&repo.namespace, &repo.repo)
219 .context("the repository path is not a legal one")?;
220 let entry = stores
221 .catalog()
222 .get(&repo_ref)?
223 .with_context(|| format!("no such repository: {repo_ref}"))?;
224 let store = stores.repo_store(&entry)?;
225 let git = store.serve();
226 let hash = store.hash_kind();
227
228 let commits = plane
229 .log(repo, rev, 1, "")
230 .map_err(|err| anyhow::anyhow!("{err}"))?;
231 let head = commits
232 .first()
233 .with_context(|| format!("{rev} names no commit in {repo_ref}"))?;
234 let tip = gix_hash::ObjectId::from_hex(head.oid.as_bytes())
235 .map_err(gix_error::Error::from).with_context(|| format!("{} is not an object id", head.oid))?;
236
237 blame_file(git, hash, tip, path)
238}
239
240pub fn blame_at(
242 data_root: &std::path::Path,
243 repo: &gunnar_proto::RepoPath,
244 rev: &str,
245 path: &str,
246) -> Result<Vec<Blamed>> {
247 let stores = std::sync::Arc::new(ServerStores::open(data_root)?);
248 let plane = crate::control_plane::CoreControlPlane::new(std::sync::Arc::clone(&stores));
249 blame(&stores, &plane, repo, rev, path)
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255 use crate::testgit::{Repo, TREE_MODE_BLOB};
256
257 #[test]
268 fn blame_names_the_commit_that_wrote_each_line_not_the_tip() {
269 let repo = Repo::new();
270 let blob_a = repo.blob(b"line one\nline two\nline three\n");
271 let tree_a = repo.tree(&[(TREE_MODE_BLOB, "src.txt", blob_a)]);
272 let commit_a = repo.commit(tree_a, &[], "Ada", "commit A: write the file");
273
274 let blob_b = repo.blob(b"line one\nLINE TWO CHANGED\nline three\n");
275 let tree_b = repo.tree(&[(TREE_MODE_BLOB, "src.txt", blob_b)]);
276 let commit_b = repo.commit(tree_b, &[commit_a], "Bo", "commit B: rewrite line 2");
277
278 let blamed = blame_file(repo.serve(), repo.hash_kind(), commit_b, "src.txt").expect("blame");
279
280 assert_eq!(blamed.len(), 3, "the file at the tip has three lines");
281 assert_eq!(
282 blamed[0].commit, commit_a,
283 "line 1 was untouched since A and must be attributed to A, not the tip"
284 );
285 assert_eq!(
286 blamed[1].commit, commit_b,
287 "line 2 was rewritten by B and must be attributed to B"
288 );
289 assert_eq!(
290 blamed[2].commit, commit_a,
291 "line 3 was untouched since A and must be attributed to A, not the tip"
292 );
293 assert_eq!(blamed[0].author, "Ada");
295 assert_eq!(blamed[1].author, "Bo");
296 assert_eq!(blamed[1].line, b"LINE TWO CHANGED");
297 }
298
299 #[test]
304 fn an_inserted_line_does_not_reblame_the_lines_it_pushed_down() {
305 let repo = Repo::new();
306 let blob_a = repo.blob(b"alpha\nbeta\ngamma\n");
307 let tree_a = repo.tree(&[(TREE_MODE_BLOB, "f", blob_a)]);
308 let commit_a = repo.commit(tree_a, &[], "Ada", "A");
309
310 let blob_b = repo.blob(b"alpha\nINSERTED\nbeta\ngamma\n");
311 let tree_b = repo.tree(&[(TREE_MODE_BLOB, "f", blob_b)]);
312 let commit_b = repo.commit(tree_b, &[commit_a], "Bo", "B");
313
314 let blamed = blame_file(repo.serve(), repo.hash_kind(), commit_b, "f").expect("blame");
315 let names: Vec<_> = blamed.iter().map(|b| b.commit).collect();
316 assert_eq!(
317 names,
318 vec![commit_a, commit_b, commit_a, commit_a],
319 "only the inserted line is B's; alpha/beta/gamma stay A's"
320 );
321 }
322}