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)
236 .with_context(|| format!("{} is not an object id", head.oid))?;
237
238 blame_file(git, hash, tip, path)
239}
240
241pub fn blame_at(
243 data_root: &std::path::Path,
244 repo: &gunnar_proto::RepoPath,
245 rev: &str,
246 path: &str,
247) -> Result<Vec<Blamed>> {
248 let stores = std::sync::Arc::new(ServerStores::open(data_root)?);
249 let plane = crate::control_plane::CoreControlPlane::new(std::sync::Arc::clone(&stores));
250 blame(&stores, &plane, repo, rev, path)
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256 use crate::testgit::{Repo, TREE_MODE_BLOB};
257
258 #[test]
269 fn blame_names_the_commit_that_wrote_each_line_not_the_tip() {
270 let repo = Repo::new();
271 let blob_a = repo.blob(b"line one\nline two\nline three\n");
272 let tree_a = repo.tree(&[(TREE_MODE_BLOB, "src.txt", blob_a)]);
273 let commit_a = repo.commit(tree_a, &[], "Ada", "commit A: write the file");
274
275 let blob_b = repo.blob(b"line one\nLINE TWO CHANGED\nline three\n");
276 let tree_b = repo.tree(&[(TREE_MODE_BLOB, "src.txt", blob_b)]);
277 let commit_b = repo.commit(tree_b, &[commit_a], "Bo", "commit B: rewrite line 2");
278
279 let blamed =
280 blame_file(repo.serve(), repo.hash_kind(), commit_b, "src.txt").expect("blame");
281
282 assert_eq!(blamed.len(), 3, "the file at the tip has three lines");
283 assert_eq!(
284 blamed[0].commit, commit_a,
285 "line 1 was untouched since A and must be attributed to A, not the tip"
286 );
287 assert_eq!(
288 blamed[1].commit, commit_b,
289 "line 2 was rewritten by B and must be attributed to B"
290 );
291 assert_eq!(
292 blamed[2].commit, commit_a,
293 "line 3 was untouched since A and must be attributed to A, not the tip"
294 );
295 assert_eq!(blamed[0].author, "Ada");
297 assert_eq!(blamed[1].author, "Bo");
298 assert_eq!(blamed[1].line, b"LINE TWO CHANGED");
299 }
300
301 #[test]
306 fn an_inserted_line_does_not_reblame_the_lines_it_pushed_down() {
307 let repo = Repo::new();
308 let blob_a = repo.blob(b"alpha\nbeta\ngamma\n");
309 let tree_a = repo.tree(&[(TREE_MODE_BLOB, "f", blob_a)]);
310 let commit_a = repo.commit(tree_a, &[], "Ada", "A");
311
312 let blob_b = repo.blob(b"alpha\nINSERTED\nbeta\ngamma\n");
313 let tree_b = repo.tree(&[(TREE_MODE_BLOB, "f", blob_b)]);
314 let commit_b = repo.commit(tree_b, &[commit_a], "Bo", "B");
315
316 let blamed = blame_file(repo.serve(), repo.hash_kind(), commit_b, "f").expect("blame");
317 let names: Vec<_> = blamed.iter().map(|b| b.commit).collect();
318 assert_eq!(
319 names,
320 vec![commit_a, commit_b, commit_a, commit_a],
321 "only the inserted line is B's; alpha/beta/gamma stay A's"
322 );
323 }
324}