1use 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
16pub 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 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 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
65pub 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
103pub 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
111pub 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 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
169pub const MAX_BLAME_BYTES: usize = 16 * 1024 * 1024;
171
172fn 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
196pub 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
215pub 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}