1use anyhow::{Context, Result};
7use std::collections::HashMap;
8use std::path::Path;
9use std::process::Command;
10
11#[derive(Debug, Clone)]
13pub struct GitIntel {
14 pub commits: Vec<CommitInfo>,
15 pub contributors: Vec<Contributor>,
16 pub churn: Vec<FileChurn>,
17 pub weekly_summaries: Vec<WeekSummary>,
18 pub module_activity: Vec<ModuleActivity>,
19 pub narration: Option<String>,
20}
21
22#[derive(Debug, Clone)]
24pub struct CommitInfo {
25 pub hash: String,
26 pub author: String,
27 pub email: String,
28 pub timestamp: i64,
29 pub subject: String,
30}
31
32#[derive(Debug, Clone)]
34pub struct Contributor {
35 pub name: String,
36 pub email: String,
37 pub commit_count: usize,
38}
39
40#[derive(Debug, Clone)]
42pub struct FileChurn {
43 pub path: String,
44 pub change_count: usize,
45 pub primary_author: String,
46}
47
48#[derive(Debug, Clone)]
50pub struct WeekSummary {
51 pub week_start: String,
52 pub commit_count: usize,
53 pub files_changed: usize,
54 pub contributors: Vec<String>,
55 pub top_modules: Vec<String>,
56}
57
58#[derive(Debug, Clone)]
60pub struct ModuleActivity {
61 pub module_path: String,
62 pub commit_count: usize,
63 pub files_changed: usize,
64 pub primary_contributor: String,
65}
66
67pub fn extract_git_intel(root: impl AsRef<Path>) -> Result<GitIntel> {
69 let root = root.as_ref();
70
71 if !root.join(".git").exists() {
73 return Ok(GitIntel {
74 commits: vec![],
75 contributors: vec![],
76 churn: vec![],
77 weekly_summaries: vec![],
78 module_activity: vec![],
79 narration: None,
80 });
81 }
82
83 let commits = extract_commits(root)?;
84 let contributors = compute_contributors(&commits);
85 let churn = extract_file_churn(root)?;
86 let weekly_summaries = compute_weekly_summaries(root, &commits)?;
87 let module_activity = compute_module_activity(&churn);
88
89 Ok(GitIntel {
90 commits,
91 contributors,
92 churn,
93 weekly_summaries,
94 module_activity,
95 narration: None,
96 })
97}
98
99fn extract_commits(root: &Path) -> Result<Vec<CommitInfo>> {
101 let output = Command::new("git")
102 .arg("-C")
103 .arg(root)
104 .args(["log", "--format=%H|%an|%ae|%at|%s", "--since=6 months ago"])
105 .output()
106 .context("Failed to run git log")?;
107
108 if !output.status.success() {
109 return Ok(vec![]);
110 }
111
112 let stdout = String::from_utf8_lossy(&output.stdout);
113 let commits: Vec<CommitInfo> = stdout.lines()
114 .filter_map(|line| {
115 let parts: Vec<&str> = line.splitn(5, '|').collect();
116 if parts.len() < 5 { return None; }
117 Some(CommitInfo {
118 hash: parts[0].to_string(),
119 author: parts[1].to_string(),
120 email: parts[2].to_string(),
121 timestamp: parts[3].parse().unwrap_or(0),
122 subject: parts[4].to_string(),
123 })
124 })
125 .collect();
126
127 Ok(commits)
128}
129
130fn compute_contributors(commits: &[CommitInfo]) -> Vec<Contributor> {
132 let mut by_name: HashMap<String, (String, usize)> = HashMap::new();
133 for commit in commits {
134 let entry = by_name.entry(commit.author.clone())
135 .or_insert_with(|| (commit.email.clone(), 0));
136 entry.1 += 1;
137 }
138
139 let mut contributors: Vec<Contributor> = by_name.into_iter()
140 .map(|(name, (email, count))| Contributor {
141 name,
142 email,
143 commit_count: count,
144 })
145 .collect();
146
147 contributors.sort_by(|a, b| b.commit_count.cmp(&a.commit_count));
148 contributors
149}
150
151fn extract_file_churn(root: &Path) -> Result<Vec<FileChurn>> {
153 let output = Command::new("git")
155 .arg("-C")
156 .arg(root)
157 .args(["log", "--format=%an", "--name-only", "--since=6 months ago"])
158 .output()
159 .context("Failed to run git log --name-only")?;
160
161 if !output.status.success() {
162 return Ok(vec![]);
163 }
164
165 let stdout = String::from_utf8_lossy(&output.stdout);
166 let mut file_counts: HashMap<String, usize> = HashMap::new();
167 let mut file_authors: HashMap<String, HashMap<String, usize>> = HashMap::new();
168 let mut current_author = String::new();
169
170 for line in stdout.lines() {
171 let trimmed = line.trim();
172 if trimmed.is_empty() {
173 continue;
174 }
175
176 if !trimmed.contains('/') && !trimmed.contains('.') && !trimmed.starts_with(' ') {
179 current_author = trimmed.to_string();
180 } else if !current_author.is_empty() {
181 *file_counts.entry(trimmed.to_string()).or_default() += 1;
182 *file_authors.entry(trimmed.to_string())
183 .or_default()
184 .entry(current_author.clone())
185 .or_default() += 1;
186 }
187 }
188
189 let mut churn: Vec<FileChurn> = file_counts.into_iter()
190 .map(|(path, count)| {
191 let primary = file_authors.get(&path)
192 .and_then(|authors| authors.iter().max_by_key(|(_, c)| *c))
193 .map(|(name, _)| name.clone())
194 .unwrap_or_default();
195 FileChurn {
196 path,
197 change_count: count,
198 primary_author: primary,
199 }
200 })
201 .collect();
202
203 churn.sort_by(|a, b| b.change_count.cmp(&a.change_count));
204 churn.truncate(50); Ok(churn)
207}
208
209fn compute_weekly_summaries(root: &Path, commits: &[CommitInfo]) -> Result<Vec<WeekSummary>> {
211 if commits.is_empty() {
212 return Ok(vec![]);
213 }
214
215 let mut weeks: HashMap<String, (usize, Vec<String>, HashMap<String, usize>)> = HashMap::new();
217
218 for commit in commits {
219 let ts = commit.timestamp;
221 let days_since_epoch = ts / 86400;
223 let week_day = ((days_since_epoch + 3) % 7) as i64; let monday = days_since_epoch - week_day;
226 let week_key = format!("{}", monday); let entry = weeks.entry(week_key).or_insert_with(|| (0, vec![], HashMap::new()));
229 entry.0 += 1;
230 if !entry.1.contains(&commit.author) {
231 entry.1.push(commit.author.clone());
232 }
233 }
234
235 let output = Command::new("git")
238 .arg("-C")
239 .arg(root)
240 .args(["log", "--format=%at", "--name-only", "--since=6 months ago"])
241 .output();
242
243 let mut week_files: HashMap<String, HashMap<String, bool>> = HashMap::new();
244
245 if let Ok(output) = output {
246 if output.status.success() {
247 let stdout = String::from_utf8_lossy(&output.stdout);
248 let mut current_ts: i64 = 0;
249 for line in stdout.lines() {
250 let trimmed = line.trim();
251 if trimmed.is_empty() { continue; }
252 if let Ok(ts) = trimmed.parse::<i64>() {
253 current_ts = ts;
254 } else if current_ts > 0 {
255 let days = current_ts / 86400;
256 let week_day = ((days + 3) % 7) as i64;
257 let monday = days - week_day;
258 let week_key = format!("{}", monday);
259 week_files.entry(week_key)
260 .or_default()
261 .insert(trimmed.to_string(), true);
262 }
263 }
264 }
265 }
266
267 let mut summaries: Vec<WeekSummary> = weeks.into_iter()
269 .map(|(week_key, (count, contributors, _))| {
270 let files_changed = week_files.get(&week_key).map(|f| f.len()).unwrap_or(0);
271
272 let mut module_counts: HashMap<String, usize> = HashMap::new();
274 if let Some(files) = week_files.get(&week_key) {
275 for file in files.keys() {
276 if let Some(module) = file.split('/').next() {
277 *module_counts.entry(module.to_string()).or_default() += 1;
278 }
279 }
280 }
281 let mut top_modules: Vec<(String, usize)> = module_counts.into_iter().collect();
282 top_modules.sort_by(|a, b| b.1.cmp(&a.1));
283 let top_modules: Vec<String> = top_modules.into_iter().take(3).map(|(m, _)| m).collect();
284
285 let monday_days: i64 = week_key.parse().unwrap_or(0);
287 let monday_ts = monday_days * 86400;
288 let week_start = epoch_to_date_string(monday_ts);
289
290 WeekSummary {
291 week_start,
292 commit_count: count,
293 files_changed,
294 contributors,
295 top_modules,
296 }
297 })
298 .collect();
299
300 summaries.sort_by(|a, b| b.week_start.cmp(&a.week_start));
301 summaries.truncate(12); Ok(summaries)
304}
305
306fn compute_module_activity(churn: &[FileChurn]) -> Vec<ModuleActivity> {
308 let mut by_module: HashMap<String, (usize, usize, HashMap<String, usize>)> = HashMap::new();
309
310 for file in churn {
311 let module = file.path.split('/').next().unwrap_or("root").to_string();
312 let entry = by_module.entry(module).or_default();
313 entry.0 += file.change_count;
314 entry.1 += 1;
315 *entry.2.entry(file.primary_author.clone()).or_default() += file.change_count;
316 }
317
318 let mut activity: Vec<ModuleActivity> = by_module.into_iter()
319 .map(|(module, (commits, files, authors))| {
320 let primary = authors.into_iter()
321 .max_by_key(|(_, c)| *c)
322 .map(|(name, _)| name)
323 .unwrap_or_default();
324 ModuleActivity {
325 module_path: module,
326 commit_count: commits,
327 files_changed: files,
328 primary_contributor: primary,
329 }
330 })
331 .collect();
332
333 activity.sort_by(|a, b| b.commit_count.cmp(&a.commit_count));
334 activity
335}
336
337pub fn epoch_to_date_string(epoch_secs: i64) -> String {
339 let days = epoch_secs / 86400;
341 let (year, month, day) = days_to_ymd(days);
342 format!("{:04}-{:02}-{:02}", year, month, day)
343}
344
345pub fn days_to_ymd(days: i64) -> (i64, u32, u32) {
347 let z = days + 719468;
349 let era = if z >= 0 { z } else { z - 146096 } / 146097;
350 let doe = (z - era * 146097) as u32;
351 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
352 let y = yoe as i64 + era * 400;
353 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
354 let mp = (5 * doy + 2) / 153;
355 let d = doy - (153 * mp + 2) / 5 + 1;
356 let m = if mp < 10 { mp + 3 } else { mp - 9 };
357 let y = if m <= 2 { y + 1 } else { y };
358 (y, m, d)
359}
360
361pub fn build_timeline_context(data: &GitIntel) -> String {
363 let mut ctx = String::new();
364
365 ctx.push_str(&format!("Total commits (last 6 months): {}\n", data.commits.len()));
366 ctx.push_str(&format!("Contributors: {}\n\n", data.contributors.len()));
367
368 ctx.push_str("Top contributors:\n");
370 for c in data.contributors.iter().take(10) {
371 ctx.push_str(&format!("- {} ({} commits)\n", c.name, c.commit_count));
372 }
373 ctx.push('\n');
374
375 ctx.push_str("Most-changed files:\n");
377 for f in data.churn.iter().take(15) {
378 ctx.push_str(&format!("- {} ({} changes, primarily by {})\n", f.path, f.change_count, f.primary_author));
379 }
380 ctx.push('\n');
381
382 ctx.push_str("Module activity:\n");
384 for m in data.module_activity.iter().take(10) {
385 ctx.push_str(&format!("- {} ({} changes across {} files, led by {})\n",
386 m.module_path, m.commit_count, m.files_changed, m.primary_contributor));
387 }
388 ctx.push('\n');
389
390 ctx.push_str("Recent weekly activity:\n");
392 for w in data.weekly_summaries.iter().take(4) {
393 ctx.push_str(&format!("- Week of {}: {} commits, {} files changed by {} contributors\n",
394 w.week_start, w.commit_count, w.files_changed, w.contributors.len()));
395 if !w.top_modules.is_empty() {
396 ctx.push_str(&format!(" Most active: {}\n", w.top_modules.join(", ")));
397 }
398 }
399
400 ctx
401}
402
403pub fn render_timeline_markdown(data: &GitIntel) -> String {
405 let mut md = String::new();
406
407 if data.commits.is_empty() {
408 md.push_str("*No git history available.*\n");
409 return md;
410 }
411
412 if let Some(ref narration) = data.narration {
414 md.push_str(narration);
415 md.push_str("\n\n");
416 }
417
418 if !data.weekly_summaries.is_empty() {
420 md.push_str("## Weekly Activity\n\n");
421 md.push_str("{% mermaid() %}\nxychart-beta\n");
422 md.push_str(" title \"Commits per Week\"\n");
423 md.push_str(" x-axis [");
424 let weeks: Vec<&WeekSummary> = data.weekly_summaries.iter().rev().collect();
425 let labels: Vec<String> = weeks.iter()
426 .map(|w| {
427 if w.week_start.len() >= 10 {
429 format!("\"{}\"", &w.week_start[5..10])
430 } else {
431 format!("\"{}\"", w.week_start)
432 }
433 })
434 .collect();
435 md.push_str(&labels.join(", "));
436 md.push_str("]\n");
437 md.push_str(" y-axis \"Commits\"\n");
438 md.push_str(" bar [");
439 let counts: Vec<String> = weeks.iter().map(|w| w.commit_count.to_string()).collect();
440 md.push_str(&counts.join(", "));
441 md.push_str("]\n");
442 md.push_str("{% end %}\n\n");
443 }
444
445 if !data.contributors.is_empty() {
447 md.push_str("## Contributors\n\n");
448 md.push_str("| Author | Commits |\n|---|---|\n");
449 for c in data.contributors.iter().take(15) {
450 md.push_str(&format!("| {} | {} |\n", c.name, c.commit_count));
451 }
452 md.push('\n');
453 }
454
455 if !data.churn.is_empty() {
457 md.push_str("## Most-Changed Files\n\n");
458 md.push_str("| File | Changes | Primary Author |\n|---|---|---|\n");
459 for f in data.churn.iter().take(20) {
460 md.push_str(&format!("| `{}` | {} | {} |\n", f.path, f.change_count, f.primary_author));
461 }
462 md.push('\n');
463 }
464
465 if !data.module_activity.is_empty() {
467 md.push_str("## Module Activity\n\n");
468 md.push_str("| Module | Changes | Files | Primary Contributor |\n|---|---|---|---|\n");
469 for m in &data.module_activity {
470 md.push_str(&format!("| `{}` | {} | {} | {} |\n",
471 m.module_path, m.commit_count, m.files_changed, m.primary_contributor));
472 }
473 md.push('\n');
474 }
475
476 md
477}
478
479#[cfg(test)]
480mod tests {
481 use super::*;
482
483 #[test]
484 fn test_epoch_to_date_string() {
485 assert_eq!(epoch_to_date_string(1704067200), "2024-01-01");
487 }
488
489 #[test]
490 fn test_days_to_ymd() {
491 let (y, m, d) = days_to_ymd(0); assert_eq!((y, m, d), (1970, 1, 1));
493 }
494
495 #[test]
496 fn test_compute_contributors() {
497 let commits = vec![
498 CommitInfo { hash: "a".into(), author: "Alice".into(), email: "a@x.com".into(), timestamp: 1, subject: "test".into() },
499 CommitInfo { hash: "b".into(), author: "Alice".into(), email: "a@x.com".into(), timestamp: 2, subject: "test2".into() },
500 CommitInfo { hash: "c".into(), author: "Bob".into(), email: "b@x.com".into(), timestamp: 3, subject: "test3".into() },
501 ];
502 let contributors = compute_contributors(&commits);
503 assert_eq!(contributors.len(), 2);
504 assert_eq!(contributors[0].name, "Alice");
505 assert_eq!(contributors[0].commit_count, 2);
506 }
507
508 #[test]
509 fn test_render_empty_timeline() {
510 let data = GitIntel {
511 commits: vec![],
512 contributors: vec![],
513 churn: vec![],
514 weekly_summaries: vec![],
515 module_activity: vec![],
516 narration: None,
517 };
518 let md = render_timeline_markdown(&data);
519 assert!(md.contains("No git history"));
520 }
521}