Skip to main content

reflex/pulse/
git_intel.rs

1//! Git Intelligence: Timeline page with development activity history
2//!
3//! Extracts git log data to show recent activity, contributor patterns,
4//! file churn, and weekly summaries.
5
6use anyhow::{Context, Result};
7use std::collections::HashMap;
8use std::path::Path;
9use std::process::Command;
10
11/// Complete git intelligence data
12#[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/// A single commit
23#[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/// Contributor stats
33#[derive(Debug, Clone)]
34pub struct Contributor {
35    pub name: String,
36    pub email: String,
37    pub commit_count: usize,
38}
39
40/// File churn: how often a file changes
41#[derive(Debug, Clone)]
42pub struct FileChurn {
43    pub path: String,
44    pub change_count: usize,
45    pub primary_author: String,
46}
47
48/// Weekly activity summary
49#[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/// Per-module activity
59#[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
67/// Extract git log data for the last 6 months
68pub fn extract_git_intel(root: impl AsRef<Path>) -> Result<GitIntel> {
69    let root = root.as_ref();
70
71    // Check if this is a git repo
72    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
99/// Parse git log into commits
100fn 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
130/// Compute contributor stats from commits (deduplicated by name)
131fn 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
151/// Extract file change frequency using git log --name-only
152fn extract_file_churn(root: &Path) -> Result<Vec<FileChurn>> {
153    // Get file change counts with author info
154    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        // Lines without path separators and not starting with spaces are author names
177        // (from the %an format), file paths contain / or .
178        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); // Top 50 most-changed files
205
206    Ok(churn)
207}
208
209/// Compute weekly summaries from commits and file changes
210fn compute_weekly_summaries(root: &Path, commits: &[CommitInfo]) -> Result<Vec<WeekSummary>> {
211    if commits.is_empty() {
212        return Ok(vec![]);
213    }
214
215    // Group commits by ISO week
216    let mut weeks: HashMap<String, (usize, Vec<String>, HashMap<String, usize>)> = HashMap::new();
217
218    for commit in commits {
219        // Convert timestamp to week start date (Monday)
220        let ts = commit.timestamp;
221        // Simple week computation: round down to nearest Monday
222        let days_since_epoch = ts / 86400;
223        // 1970-01-01 was a Thursday (day 4), so Monday of that week = day -3
224        let week_day = ((days_since_epoch + 3) % 7) as i64; // 0=Monday
225        let monday = days_since_epoch - week_day;
226        let week_key = format!("{}", monday); // Use epoch-day of Monday as key
227
228        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    // Get file changes per week (using git log with date ranges)
236    // Instead of running git for each week, use the commit data we already have
237    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    // Build summaries
268    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            // Compute top modules from changed files
273            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            // Convert monday epoch-days back to date string
286            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); // Last 12 weeks
302
303    Ok(summaries)
304}
305
306/// Compute per-module activity from file churn
307fn 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
337/// Convert epoch seconds to YYYY-MM-DD string
338pub fn epoch_to_date_string(epoch_secs: i64) -> String {
339    // Simple date calculation without external deps
340    let days = epoch_secs / 86400;
341    let (year, month, day) = days_to_ymd(days);
342    format!("{:04}-{:02}-{:02}", year, month, day)
343}
344
345/// Convert days since epoch to (year, month, day)
346pub fn days_to_ymd(days: i64) -> (i64, u32, u32) {
347    // Algorithm from http://howardhinnant.github.io/date_algorithms.html
348    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
361/// Build structural context for LLM narration
362pub 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    // Top contributors
369    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    // Hottest files
376    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    // Module activity
383    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    // Recent weeks
391    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
403/// Render timeline data as markdown
404pub 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    // Narration
413    if let Some(ref narration) = data.narration {
414        md.push_str(narration);
415        md.push_str("\n\n");
416    }
417
418    // Activity chart (simple text-based bar chart using mermaid)
419    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                // Just use MM-DD for compact labels
428                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    // Contributors table
446    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    // Hot files
456    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    // Module activity
466    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        // 2024-01-01 00:00:00 UTC = 1704067200
486        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); // 1970-01-01
492        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}