Skip to main content

sessionwiki/
blame.rs

1//! `blame` core: git-blame porcelain parsing, run grouping, the commit ->
2//! session attribution heuristic, and the hardened git invocation. The parsing
3//! and attribution are pure and unit-testable without git or a database.
4
5use anyhow::{bail, Context, Result};
6use std::path::{Path, PathBuf};
7use std::process::Command;
8
9#[derive(Debug, Clone, PartialEq)]
10pub struct LineBlame {
11    pub line: usize,
12    pub commit: String,
13    pub author_time: i64,
14}
15
16/// Parse `git blame --line-porcelain` output. Every line is preceded by a
17/// `<40-hex-sha> <orig> <final> [count]` header and repeated `key value`
18/// lines; the content line begins with a tab. We capture the final line
19/// number, the commit sha, and `author-time` (epoch seconds).
20pub fn parse_line_porcelain(out: &str) -> Vec<LineBlame> {
21    let mut result = Vec::new();
22    let mut sha = String::new();
23    let mut final_line = 0usize;
24    let mut author_time = 0i64;
25    for raw in out.lines() {
26        if raw.starts_with('\t') {
27            // The source line itself; emit the metadata gathered for it.
28            if !sha.is_empty() {
29                result.push(LineBlame {
30                    line: final_line,
31                    commit: sha.clone(),
32                    author_time,
33                });
34            }
35            continue;
36        }
37        if let Some(rest) = raw.strip_prefix("author-time ") {
38            author_time = rest.trim().parse().unwrap_or(0);
39            continue;
40        }
41        // Header line: "<sha> <orig> <final> [count]" - 40-hex sha + digits.
42        let mut parts = raw.split(' ');
43        if let (Some(maybe_sha), Some(_orig), Some(fin)) =
44            (parts.next(), parts.next(), parts.next())
45        {
46            if maybe_sha.len() == 40 && maybe_sha.bytes().all(|b| b.is_ascii_hexdigit()) {
47                if let Ok(f) = fin.parse::<usize>() {
48                    sha = maybe_sha.to_string();
49                    final_line = f;
50                }
51            }
52        }
53    }
54    result
55}
56
57#[derive(Debug, Clone, PartialEq)]
58pub struct Run {
59    pub start: usize,
60    pub end: usize,
61    pub commit: String,
62    pub author_time: i64,
63}
64
65/// Collapse per-line blame into contiguous runs sharing one commit.
66pub fn group_runs(lines: &[LineBlame]) -> Vec<Run> {
67    let mut runs: Vec<Run> = Vec::new();
68    for lb in lines {
69        if let Some(last) = runs.last_mut() {
70            if last.commit == lb.commit && lb.line == last.end + 1 {
71                last.end = lb.line;
72                continue;
73            }
74        }
75        runs.push(Run {
76            start: lb.line,
77            end: lb.line,
78            commit: lb.commit.clone(),
79            author_time: lb.author_time,
80        });
81    }
82    runs
83}
84
85#[derive(Debug, Clone, PartialEq)]
86pub struct TouchingSession {
87    pub session_id: String,
88    pub tool: String,
89    pub title: String,
90    pub project: String,
91    pub started: Option<i64>,
92    pub ended: Option<i64>,
93    pub archived: bool,
94}
95
96#[derive(Debug, Clone, PartialEq)]
97pub enum Attribution {
98    Confident(TouchingSession),
99    Ambiguous(Vec<TouchingSession>),
100    Unattributed,
101}
102
103/// How long after a session ends a commit may still be attributed to it
104/// (commits often land well after the conversation). Tunable.
105pub const LAG_WINDOW_SECS: i64 = 14 * 24 * 3600;
106
107fn project_is_ancestor(project: &str, repo_path: &str) -> bool {
108    !project.is_empty() && repo_path.starts_with(project)
109}
110
111/// Map a commit's author-time to the session most likely behind it, among the
112/// sessions that touched the file. (a) sessions whose [started,ended] window
113/// contains author_time; (b) else the most recent session that ended at/before
114/// author_time within LAG_WINDOW_SECS; project-ancestor breaks ties. >=2 keep
115/// as ambiguous; none -> unattributed.
116pub fn attribute_commit(
117    author_time: i64,
118    repo_path: &str,
119    candidates: &[TouchingSession],
120) -> Attribution {
121    let containing: Vec<&TouchingSession> = candidates
122        .iter()
123        .filter(|s| {
124            matches!((s.started, s.ended), (Some(a), Some(b)) if a <= author_time && author_time <= b)
125        })
126        .collect();
127    if containing.len() == 1 {
128        return Attribution::Confident(containing[0].clone());
129    }
130    if containing.len() > 1 {
131        return disambiguate(containing, repo_path);
132    }
133    // (b) most-recent-before within the lag window
134    let mut before: Vec<&TouchingSession> = candidates
135        .iter()
136        .filter(|s| matches!(s.ended, Some(e) if e <= author_time && author_time - e <= LAG_WINDOW_SECS))
137        .collect();
138    if before.is_empty() {
139        return Attribution::Unattributed;
140    }
141    let newest = before
142        .iter()
143        .filter_map(|s| s.ended)
144        .max()
145        .unwrap_or(i64::MIN);
146    before.retain(|s| s.ended == Some(newest));
147    if before.len() == 1 {
148        Attribution::Confident(before[0].clone())
149    } else {
150        disambiguate(before, repo_path)
151    }
152}
153
154fn disambiguate(mut tied: Vec<&TouchingSession>, repo_path: &str) -> Attribution {
155    let ancestors: Vec<&TouchingSession> = tied
156        .iter()
157        .copied()
158        .filter(|s| project_is_ancestor(&s.project, repo_path))
159        .collect();
160    if ancestors.len() == 1 {
161        return Attribution::Confident(ancestors[0].clone());
162    }
163    if !ancestors.is_empty() {
164        tied = ancestors;
165    }
166    Attribution::Ambiguous(tied.into_iter().cloned().collect())
167}
168
169/// Cap on git blame output we will buffer, so a huge file can't exhaust memory.
170pub const MAX_BLAME_BYTES: usize = 16 * 1024 * 1024;
171
172/// Spawn git with a hardened, minimal environment: running inside an untrusted
173/// repository must not execute attacker-controlled config (pager / fsmonitor /
174/// hooks), and inherited `GIT_*` env must not influence the child.
175fn git_command(repo: &Path) -> Command {
176    let mut cmd = Command::new("git");
177    cmd.current_dir(repo);
178    for (k, _) in std::env::vars() {
179        if k.starts_with("GIT_") {
180            cmd.env_remove(k);
181        }
182    }
183    cmd.env("GIT_CONFIG_NOSYSTEM", "1")
184        .env("GIT_TERMINAL_PROMPT", "0")
185        .env("GIT_PAGER", "cat")
186        .args([
187            "--no-pager",
188            "-c",
189            "core.fsmonitor=",
190            "-c",
191            "core.hooksPath=/dev/null",
192        ]);
193    cmd
194}
195
196/// Resolve the git repository root that contains `file`.
197pub fn repo_root(file: &Path) -> Result<PathBuf> {
198    let canon =
199        std::fs::canonicalize(file).with_context(|| format!("resolve {}", file.display()))?;
200    let dir = canon.parent().unwrap_or(&canon);
201    let out = git_command(dir)
202        .args(["rev-parse", "--show-toplevel"])
203        .output()
204        .context("run git rev-parse")?;
205    if !out.status.success() {
206        bail!("not inside a git repository");
207    }
208    let root = String::from_utf8_lossy(&out.stdout).trim().to_string();
209    if root.is_empty() {
210        bail!("not inside a git repository");
211    }
212    Ok(PathBuf::from(root))
213}
214
215/// Run `git blame --line-porcelain` on `file` (optionally a -L range). The path
216/// is passed after `--` as a single argv element, so a path starting with `-`
217/// can never be read as a flag. `-L` values are validated integers from the
218/// caller. Output is capped to `MAX_BLAME_BYTES`.
219pub fn run_git_blame(repo: &Path, file: &Path, range: Option<(usize, usize)>) -> Result<String> {
220    let mut cmd = git_command(repo);
221    cmd.args(["blame", "--line-porcelain", "-M", "-C"]);
222    if let Some((s, e)) = range {
223        cmd.arg(format!("-L{s},{e}"));
224    }
225    cmd.arg("--").arg(file);
226    let out = cmd.output().context("run git blame")?;
227    if !out.status.success() {
228        bail!(
229            "git blame failed: {}",
230            String::from_utf8_lossy(&out.stderr).trim()
231        );
232    }
233    if out.stdout.len() > MAX_BLAME_BYTES {
234        bail!("git blame output too large; narrow with -L <start>,<end>");
235    }
236    Ok(String::from_utf8_lossy(&out.stdout).into_owned())
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    fn sess(id: &str, started: i64, ended: i64, project: &str) -> TouchingSession {
244        TouchingSession {
245            session_id: id.into(),
246            tool: "claude-code".into(),
247            title: id.into(),
248            project: project.into(),
249            started: Some(started),
250            ended: Some(ended),
251            archived: false,
252        }
253    }
254
255    #[test]
256    fn confident_when_one_window_contains_the_commit() {
257        let c = vec![sess("s1", 100, 200, "/repo")];
258        match attribute_commit(150, "/repo", &c) {
259            Attribution::Confident(s) => assert_eq!(s.session_id, "s1"),
260            other => panic!("{other:?}"),
261        }
262    }
263
264    #[test]
265    fn ambiguous_when_two_windows_contain_the_commit() {
266        let c = vec![sess("s1", 100, 200, "/repo"), sess("s2", 140, 260, "/repo")];
267        match attribute_commit(150, "/repo", &c) {
268            Attribution::Ambiguous(v) => assert_eq!(v.len(), 2),
269            other => panic!("{other:?}"),
270        }
271    }
272
273    #[test]
274    fn falls_back_to_most_recent_before_within_lag_window() {
275        let c = vec![sess("s1", 100, 200, "/repo"), sess("s2", 900, 990, "/repo")];
276        match attribute_commit(1000, "/repo", &c) {
277            Attribution::Confident(s) => assert_eq!(s.session_id, "s2"),
278            other => panic!("{other:?}"),
279        }
280    }
281
282    #[test]
283    fn unattributed_when_nothing_qualifies() {
284        let c = vec![sess("s1", 100, 200, "/repo")];
285        assert_eq!(
286            attribute_commit(200 + LAG_WINDOW_SECS + 1, "/repo", &c),
287            Attribution::Unattributed
288        );
289    }
290
291    #[test]
292    fn project_ancestor_breaks_a_tie_in_the_lag_window() {
293        let a = sess("a", 100, 500, "/other");
294        let b = sess("b", 100, 500, "/repo");
295        match attribute_commit(1000, "/repo/src", &[a, b]) {
296            Attribution::Confident(s) => assert_eq!(s.session_id, "b"),
297            other => panic!("{other:?}"),
298        }
299    }
300
301    const SAMPLE: &str = "\
302abc123abc123abc123abc123abc123abc123abcd 1 1 2
303author Dev
304author-time 1700000000
305author-tz +0000
306summary first
307filename src/a.rs
308\tline one
309abc123abc123abc123abc123abc123abc123abcd 2 2
310author-time 1700000000
311\tline two
312def456def456def456def456def456def456def4 3 3 1
313author Dev
314author-time 1700000500
315summary second
316filename src/a.rs
317\tline three
318";
319
320    #[test]
321    fn parses_line_to_commit_and_time() {
322        let got = parse_line_porcelain(SAMPLE);
323        assert_eq!(got.len(), 3);
324        assert_eq!(
325            got[0],
326            LineBlame {
327                line: 1,
328                commit: "abc123abc123abc123abc123abc123abc123abcd".into(),
329                author_time: 1_700_000_000
330            }
331        );
332        assert_eq!(
333            got[2],
334            LineBlame {
335                line: 3,
336                commit: "def456def456def456def456def456def456def4".into(),
337                author_time: 1_700_000_500
338            }
339        );
340    }
341
342    #[test]
343    fn groups_consecutive_lines_by_commit() {
344        let lines = vec![
345            LineBlame {
346                line: 1,
347                commit: "a".into(),
348                author_time: 10,
349            },
350            LineBlame {
351                line: 2,
352                commit: "a".into(),
353                author_time: 10,
354            },
355            LineBlame {
356                line: 3,
357                commit: "b".into(),
358                author_time: 20,
359            },
360            LineBlame {
361                line: 4,
362                commit: "a".into(),
363                author_time: 10,
364            },
365        ];
366        let runs = group_runs(&lines);
367        assert_eq!(
368            runs,
369            vec![
370                Run {
371                    start: 1,
372                    end: 2,
373                    commit: "a".into(),
374                    author_time: 10
375                },
376                Run {
377                    start: 3,
378                    end: 3,
379                    commit: "b".into(),
380                    author_time: 20
381                },
382                Run {
383                    start: 4,
384                    end: 4,
385                    commit: "a".into(),
386                    author_time: 10
387                },
388            ]
389        );
390    }
391}