Skip to main content

gunnar_server/
blame.rs

1//! **`gunnar blame` — who wrote each line, walked backwards.**
2//!
3//! `verify --line <path>:<n>` already answers link 1 for ONE line: *which
4//! commit put this line here* (see [`crate::verify`]). Blame is that answer for
5//! **every** line of a file at a revision.
6//!
7//! # It reuses link 1's writers, it does not twin them (one implementation)
8//!
9//! The single-line walk in `verify` rests on three things: the tree/blob reader
10//! [`crate::verify::blob_at`], the parent reader [`crate::verify::parents`], and
11//! the diff engine [`crate::diff`] (`trace_line` there is `diff_ops` there). A
12//! whole-file blame rests on the **same three**. What is genuinely new is the
13//! shape of the walk: `verify` follows one line down one lineage, and blame
14//! follows a *set* of lines, each down the first parent that still holds it.
15//!
16//! That set-walk is not a second copy of link 1 — it is link 1 done once for a
17//! whole file instead of once per line. Per-line iteration would re-diff the
18//! file at every history step for every line (`O(lines · depth)` diffs); the
19//! set-walk diffs each revision once (`O(depth)` diffs) and moves every line
20//! that survived a revision back through the same script. Both attribute a line
21//! to the first parent that contains it and, failing that, to the commit in
22//! hand — so a line blamed by this module is the line `verify --line` would
23//! blame, by construction.
24//!
25//! # The follow-up this is not
26//!
27//! A blame *cache* — precomputed per-line attribution kept beside the repo so a
28//! blame is a lookup rather than a walk — is a separate, larger piece of work
29//! (it has to be invalidated on every push and stored somewhere). This walks
30//! live, bounded by [`WALK_BUDGET`], which is the honest first version.
31
32use std::collections::HashMap;
33
34use anyhow::{Context, Result};
35use gunnar_store::git_storage_trait::GitServe;
36
37use crate::stores::ServerStores;
38
39/// How many history steps a blame will take before it refuses.
40///
41/// A bound rather than a promise, exactly as [`crate::verify::BLAME_BUDGET`] is
42/// for one line — but this one counts *commits visited*, not lines, because a
43/// whole-file blame legitimately steps once per revision that touched the file.
44/// A file with more than a million revisions in its past is a question this
45/// command refuses rather than answers in an hour.
46pub const WALK_BUDGET: usize = 1 << 20;
47
48/// One line's attribution.
49#[derive(Debug, Clone)]
50pub struct Blamed {
51    /// The commit that last touched this line.
52    pub commit: gix_hash::ObjectId,
53    /// The author of that commit, lossily decoded — git does not promise a name
54    /// is UTF-8 and this is a display string, so the conversion is lossy at this
55    /// one boundary.
56    pub author: String,
57    /// The line's bytes at the revision blamed, without its trailing newline.
58    pub line: Vec<u8>,
59}
60
61/// **Blame every line of `path` at `tip`.**
62///
63/// The testable core: it needs only a [`GitServe`] and the tip commit, so a
64/// test can build a commit graph whose oids it chose and assert the exact
65/// commit each line is attributed to — not merely that an answer came back.
66///
67/// The returned vector has one entry per line of the file at `tip`, in file
68/// order.
69pub 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    // The file at the tip is the set of lines that must be attributed.
76    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    // `assigned[i]` is the commit that last touched original line `i`.
85    let mut assigned: Vec<Option<gix_hash::ObjectId>> = vec![None; n];
86
87    // The frontier: `(commit, care)` where `care` maps a line index *in that
88    // commit's version of the file* to the original line index it descends to.
89    //
90    // A line is passed to the FIRST parent that still holds it and to no other,
91    // so each original line follows exactly one lineage — a merge never splits
92    // it in two — and the walk cannot fan out exponentially over a diamond.
93    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        // `handed[c]` — this care entry has been passed to some parent.
113        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                // This parent does not carry the file; the line cannot have come
119                // through it. `verify`'s link-1 skips it the same way.
120                continue;
121            };
122            let mut moved: Vec<(usize, usize)> = Vec::new();
123            if parent_blob == blob {
124                // Identical blobs: every still-unhanded line survives at the
125                // same index, and diffing them would be work for a known answer.
126                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                // Where each surviving line of `commit` sat in `parent`.
135                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        // A line no parent held was written here. `assigned[orig].is_none()`
159        // guards the one case a diamond could reach the same commit twice: the
160        // first attribution wins, and it is the same commit either way.
161        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    // Turn the per-line commit into the printable row, reading each author once.
170    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
192/// The author name of `commit`, lossily decoded for display.
193fn 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
206/// **Blame `path` at `rev` in `repo`**, resolving the revision the way
207/// `verify` and `Refs.Log` do so `HEAD`, a branch and a hex oid all mean here
208/// what they mean there.
209pub 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
240/// [`blame`] over a data root, opening the stores itself — the CLI verb's entry.
241pub 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    /// **Commit A writes lines 1-3; commit B rewrites line 2. Blame names B for
258    /// line 2 and A for lines 1 and 3 — by their exact oids.**
259    ///
260    /// The oids are the ones the test *chose* for each commit, so this asserts
261    /// the attribution and not merely that a vector came back. The red it is
262    /// written against is the naive blame that answers "the tip" for every line:
263    /// that would put commit B on lines 1 and 3, and the two `assert_eq!`s on
264    /// `commit_a` below would fail. Proven red on 2026-08-31 by stubbing
265    /// `blame_file` to return `tip` for every line — lines 1 and 3 came back as
266    /// B and the test failed exactly there.
267    #[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        // The author rode along with the commit.
294        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    /// An inserted line is attributed to the commit that inserted it, and the
300    /// lines that merely shifted down keep their original author. This is the
301    /// case a blame that keyed on line *number* rather than line *content* would
302    /// get wrong — it would blame the whole tail on B.
303    #[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}