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