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
114 .lines()
115 .filter_map(|line| {
116 let parts: Vec<&str> = line.splitn(5, '|').collect();
117 if parts.len() < 5 {
118 return None;
119 }
120 Some(CommitInfo {
121 hash: parts[0].to_string(),
122 author: parts[1].to_string(),
123 email: parts[2].to_string(),
124 timestamp: parts[3].parse().unwrap_or(0),
125 subject: parts[4].to_string(),
126 })
127 })
128 .collect();
129
130 Ok(commits)
131}
132
133fn compute_contributors(commits: &[CommitInfo]) -> Vec<Contributor> {
135 let mut by_name: HashMap<String, (String, usize)> = HashMap::new();
136 for commit in commits {
137 let entry = by_name
138 .entry(commit.author.clone())
139 .or_insert_with(|| (commit.email.clone(), 0));
140 entry.1 += 1;
141 }
142
143 let mut contributors: Vec<Contributor> = by_name
144 .into_iter()
145 .map(|(name, (email, count))| Contributor {
146 name,
147 email,
148 commit_count: count,
149 })
150 .collect();
151
152 contributors.sort_by(|a, b| b.commit_count.cmp(&a.commit_count));
153 contributors
154}
155
156fn extract_file_churn(root: &Path) -> Result<Vec<FileChurn>> {
158 let output = Command::new("git")
160 .arg("-C")
161 .arg(root)
162 .args(["log", "--format=%an", "--name-only", "--since=6 months ago"])
163 .output()
164 .context("Failed to run git log --name-only")?;
165
166 if !output.status.success() {
167 return Ok(vec![]);
168 }
169
170 let stdout = String::from_utf8_lossy(&output.stdout);
171 let mut file_counts: HashMap<String, usize> = HashMap::new();
172 let mut file_authors: HashMap<String, HashMap<String, usize>> = HashMap::new();
173 let mut current_author = String::new();
174
175 for line in stdout.lines() {
176 let trimmed = line.trim();
177 if trimmed.is_empty() {
178 continue;
179 }
180
181 if !trimmed.contains('/') && !trimmed.contains('.') && !trimmed.starts_with(' ') {
184 current_author = trimmed.to_string();
185 } else if !current_author.is_empty() {
186 *file_counts.entry(trimmed.to_string()).or_default() += 1;
187 *file_authors
188 .entry(trimmed.to_string())
189 .or_default()
190 .entry(current_author.clone())
191 .or_default() += 1;
192 }
193 }
194
195 let mut churn: Vec<FileChurn> = file_counts
196 .into_iter()
197 .map(|(path, count)| {
198 let primary = file_authors
199 .get(&path)
200 .and_then(|authors| authors.iter().max_by_key(|(_, c)| *c))
201 .map(|(name, _)| name.clone())
202 .unwrap_or_default();
203 FileChurn {
204 path,
205 change_count: count,
206 primary_author: primary,
207 }
208 })
209 .collect();
210
211 churn.sort_by(|a, b| b.change_count.cmp(&a.change_count));
212 churn.truncate(50); Ok(churn)
215}
216
217fn compute_weekly_summaries(root: &Path, commits: &[CommitInfo]) -> Result<Vec<WeekSummary>> {
219 if commits.is_empty() {
220 return Ok(vec![]);
221 }
222
223 let mut weeks: HashMap<String, (usize, Vec<String>, HashMap<String, usize>)> = HashMap::new();
225
226 for commit in commits {
227 let ts = commit.timestamp;
229 let days_since_epoch = ts / 86400;
231 let week_day = ((days_since_epoch + 3) % 7) as i64; let monday = days_since_epoch - week_day;
234 let week_key = format!("{}", monday); let entry = weeks
237 .entry(week_key)
238 .or_insert_with(|| (0, vec![], HashMap::new()));
239 entry.0 += 1;
240 if !entry.1.contains(&commit.author) {
241 entry.1.push(commit.author.clone());
242 }
243 }
244
245 let output = Command::new("git")
248 .arg("-C")
249 .arg(root)
250 .args(["log", "--format=%at", "--name-only", "--since=6 months ago"])
251 .output();
252
253 let mut week_files: HashMap<String, HashMap<String, bool>> = HashMap::new();
254
255 if let Ok(output) = output {
256 if output.status.success() {
257 let stdout = String::from_utf8_lossy(&output.stdout);
258 let mut current_ts: i64 = 0;
259 for line in stdout.lines() {
260 let trimmed = line.trim();
261 if trimmed.is_empty() {
262 continue;
263 }
264 if let Ok(ts) = trimmed.parse::<i64>() {
265 current_ts = ts;
266 } else if current_ts > 0 {
267 let days = current_ts / 86400;
268 let week_day = ((days + 3) % 7) as i64;
269 let monday = days - week_day;
270 let week_key = format!("{}", monday);
271 week_files
272 .entry(week_key)
273 .or_default()
274 .insert(trimmed.to_string(), true);
275 }
276 }
277 }
278 }
279
280 let mut summaries: Vec<WeekSummary> = weeks
282 .into_iter()
283 .map(|(week_key, (count, contributors, _))| {
284 let files_changed = week_files.get(&week_key).map(|f| f.len()).unwrap_or(0);
285
286 let mut module_counts: HashMap<String, usize> = HashMap::new();
288 if let Some(files) = week_files.get(&week_key) {
289 for file in files.keys() {
290 if let Some(module) = file.split('/').next() {
291 *module_counts.entry(module.to_string()).or_default() += 1;
292 }
293 }
294 }
295 let mut top_modules: Vec<(String, usize)> = module_counts.into_iter().collect();
296 top_modules.sort_by(|a, b| b.1.cmp(&a.1));
297 let top_modules: Vec<String> =
298 top_modules.into_iter().take(3).map(|(m, _)| m).collect();
299
300 let monday_days: i64 = week_key.parse().unwrap_or(0);
302 let monday_ts = monday_days * 86400;
303 let week_start = epoch_to_date_string(monday_ts);
304
305 WeekSummary {
306 week_start,
307 commit_count: count,
308 files_changed,
309 contributors,
310 top_modules,
311 }
312 })
313 .collect();
314
315 summaries.sort_by(|a, b| b.week_start.cmp(&a.week_start));
316 summaries.truncate(12); Ok(summaries)
319}
320
321fn compute_module_activity(churn: &[FileChurn]) -> Vec<ModuleActivity> {
323 let mut by_module: HashMap<String, (usize, usize, HashMap<String, usize>)> = HashMap::new();
324
325 for file in churn {
326 let module = file.path.split('/').next().unwrap_or("root").to_string();
327 let entry = by_module.entry(module).or_default();
328 entry.0 += file.change_count;
329 entry.1 += 1;
330 *entry.2.entry(file.primary_author.clone()).or_default() += file.change_count;
331 }
332
333 let mut activity: Vec<ModuleActivity> = by_module
334 .into_iter()
335 .map(|(module, (commits, files, authors))| {
336 let primary = authors
337 .into_iter()
338 .max_by_key(|(_, c)| *c)
339 .map(|(name, _)| name)
340 .unwrap_or_default();
341 ModuleActivity {
342 module_path: module,
343 commit_count: commits,
344 files_changed: files,
345 primary_contributor: primary,
346 }
347 })
348 .collect();
349
350 activity.sort_by(|a, b| b.commit_count.cmp(&a.commit_count));
351 activity
352}
353
354pub fn epoch_to_date_string(epoch_secs: i64) -> String {
356 let days = epoch_secs / 86400;
358 let (year, month, day) = days_to_ymd(days);
359 format!("{:04}-{:02}-{:02}", year, month, day)
360}
361
362pub fn days_to_ymd(days: i64) -> (i64, u32, u32) {
364 let z = days + 719468;
366 let era = if z >= 0 { z } else { z - 146096 } / 146097;
367 let doe = (z - era * 146097) as u32;
368 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
369 let y = yoe as i64 + era * 400;
370 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
371 let mp = (5 * doy + 2) / 153;
372 let d = doy - (153 * mp + 2) / 5 + 1;
373 let m = if mp < 10 { mp + 3 } else { mp - 9 };
374 let y = if m <= 2 { y + 1 } else { y };
375 (y, m, d)
376}
377
378pub fn build_timeline_context(data: &GitIntel) -> String {
380 let mut ctx = String::new();
381
382 ctx.push_str(&format!(
383 "Total commits (last 6 months): {}\n",
384 data.commits.len()
385 ));
386 ctx.push_str(&format!("Contributors: {}\n\n", data.contributors.len()));
387
388 ctx.push_str("Top contributors:\n");
390 for c in data.contributors.iter().take(10) {
391 ctx.push_str(&format!("- {} ({} commits)\n", c.name, c.commit_count));
392 }
393 ctx.push('\n');
394
395 ctx.push_str("Most-changed files:\n");
397 for f in data.churn.iter().take(15) {
398 ctx.push_str(&format!(
399 "- {} ({} changes, primarily by {})\n",
400 f.path, f.change_count, f.primary_author
401 ));
402 }
403 ctx.push('\n');
404
405 ctx.push_str("Module activity:\n");
407 for m in data.module_activity.iter().take(10) {
408 ctx.push_str(&format!(
409 "- {} ({} changes across {} files, led by {})\n",
410 m.module_path, m.commit_count, m.files_changed, m.primary_contributor
411 ));
412 }
413 ctx.push('\n');
414
415 ctx.push_str("Recent weekly activity:\n");
417 for w in data.weekly_summaries.iter().take(4) {
418 ctx.push_str(&format!(
419 "- Week of {}: {} commits, {} files changed by {} contributors\n",
420 w.week_start,
421 w.commit_count,
422 w.files_changed,
423 w.contributors.len()
424 ));
425 if !w.top_modules.is_empty() {
426 ctx.push_str(&format!(" Most active: {}\n", w.top_modules.join(", ")));
427 }
428 }
429
430 ctx
431}
432
433pub fn render_timeline_markdown(data: &GitIntel) -> String {
435 let mut md = String::new();
436
437 if data.commits.is_empty() {
438 md.push_str("*No git history available.*\n");
439 return md;
440 }
441
442 if let Some(ref narration) = data.narration {
444 md.push_str(narration);
445 md.push_str("\n\n");
446 }
447
448 if !data.weekly_summaries.is_empty() {
450 md.push_str("## Weekly Activity\n\n");
451 let weeks: Vec<&WeekSummary> = data.weekly_summaries.iter().rev().collect();
452 let max_commits = weeks
453 .iter()
454 .map(|w| w.commit_count)
455 .max()
456 .unwrap_or(1)
457 .max(1);
458 const BAR_WIDTH: usize = 24;
459 for w in &weeks {
460 let label = if w.week_start.len() >= 10 {
461 &w.week_start[5..10]
462 } else {
463 &w.week_start
464 };
465 let bar_len = if w.commit_count == 0 {
466 0
467 } else {
468 (w.commit_count * BAR_WIDTH / max_commits).max(1)
469 };
470 let bar = "█".repeat(bar_len);
471 md.push_str(&format!("{} {:>3} {}\n", label, w.commit_count, bar));
472 }
473 md.push('\n');
474 }
475
476 if !data.contributors.is_empty() {
478 md.push_str("## Contributors\n\n");
479 md.push_str("| Author | Commits |\n|---|---|\n");
480 for c in data.contributors.iter().take(15) {
481 md.push_str(&format!("| {} | {} |\n", c.name, c.commit_count));
482 }
483 md.push('\n');
484 }
485
486 if !data.churn.is_empty() {
488 md.push_str("## Most-Changed Files\n\n");
489 md.push_str("| File | Changes | Primary Author |\n|---|---|---|\n");
490 for f in data.churn.iter().take(20) {
491 md.push_str(&format!(
492 "| `{}` | {} | {} |\n",
493 f.path, f.change_count, f.primary_author
494 ));
495 }
496 md.push('\n');
497 }
498
499 if !data.module_activity.is_empty() {
501 md.push_str("## Module Activity\n\n");
502 md.push_str("| Module | Changes | Files | Primary Contributor |\n|---|---|---|---|\n");
503 for m in &data.module_activity {
504 md.push_str(&format!(
505 "| `{}` | {} | {} | {} |\n",
506 m.module_path, m.commit_count, m.files_changed, m.primary_contributor
507 ));
508 }
509 md.push('\n');
510 }
511
512 md
513}
514
515#[cfg(test)]
516mod tests {
517 use super::*;
518
519 #[test]
520 fn test_epoch_to_date_string() {
521 assert_eq!(epoch_to_date_string(1704067200), "2024-01-01");
523 }
524
525 #[test]
526 fn test_days_to_ymd() {
527 let (y, m, d) = days_to_ymd(0); assert_eq!((y, m, d), (1970, 1, 1));
529 }
530
531 #[test]
532 fn test_compute_contributors() {
533 let commits = vec![
534 CommitInfo {
535 hash: "a".into(),
536 author: "Alice".into(),
537 email: "a@x.com".into(),
538 timestamp: 1,
539 subject: "test".into(),
540 },
541 CommitInfo {
542 hash: "b".into(),
543 author: "Alice".into(),
544 email: "a@x.com".into(),
545 timestamp: 2,
546 subject: "test2".into(),
547 },
548 CommitInfo {
549 hash: "c".into(),
550 author: "Bob".into(),
551 email: "b@x.com".into(),
552 timestamp: 3,
553 subject: "test3".into(),
554 },
555 ];
556 let contributors = compute_contributors(&commits);
557 assert_eq!(contributors.len(), 2);
558 assert_eq!(contributors[0].name, "Alice");
559 assert_eq!(contributors[0].commit_count, 2);
560 }
561
562 #[test]
563 fn test_render_empty_timeline() {
564 let data = GitIntel {
565 commits: vec![],
566 contributors: vec![],
567 churn: vec![],
568 weekly_summaries: vec![],
569 module_activity: vec![],
570 narration: None,
571 };
572 let md = render_timeline_markdown(&data);
573 assert!(md.contains("No git history"));
574 }
575}