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
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
133/// Compute contributor stats from commits (deduplicated by name)
134fn 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
156/// Extract file change frequency using git log --name-only
157fn extract_file_churn(root: &Path) -> Result<Vec<FileChurn>> {
158    // Get file change counts with author info
159    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        // Lines without path separators and not starting with spaces are author names
182        // (from the %an format), file paths contain / or .
183        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); // Top 50 most-changed files
213
214    Ok(churn)
215}
216
217/// Compute weekly summaries from commits and file changes
218fn compute_weekly_summaries(root: &Path, commits: &[CommitInfo]) -> Result<Vec<WeekSummary>> {
219    if commits.is_empty() {
220        return Ok(vec![]);
221    }
222
223    // Group commits by ISO week
224    let mut weeks: HashMap<String, (usize, Vec<String>, HashMap<String, usize>)> = HashMap::new();
225
226    for commit in commits {
227        // Convert timestamp to week start date (Monday)
228        let ts = commit.timestamp;
229        // Simple week computation: round down to nearest Monday
230        let days_since_epoch = ts / 86400;
231        // 1970-01-01 was a Thursday (day 4), so Monday of that week = day -3
232        let week_day = ((days_since_epoch + 3) % 7) as i64; // 0=Monday
233        let monday = days_since_epoch - week_day;
234        let week_key = format!("{}", monday); // Use epoch-day of Monday as key
235
236        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    // Get file changes per week (using git log with date ranges)
246    // Instead of running git for each week, use the commit data we already have
247    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    // Build summaries
281    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            // Compute top modules from changed files
287            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            // Convert monday epoch-days back to date string
301            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); // Last 12 weeks
317
318    Ok(summaries)
319}
320
321/// Compute per-module activity from file churn
322fn 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
354/// Convert epoch seconds to YYYY-MM-DD string
355pub fn epoch_to_date_string(epoch_secs: i64) -> String {
356    // Simple date calculation without external deps
357    let days = epoch_secs / 86400;
358    let (year, month, day) = days_to_ymd(days);
359    format!("{:04}-{:02}-{:02}", year, month, day)
360}
361
362/// Convert days since epoch to (year, month, day)
363pub fn days_to_ymd(days: i64) -> (i64, u32, u32) {
364    // Algorithm from http://howardhinnant.github.io/date_algorithms.html
365    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
378/// Build structural context for LLM narration
379pub 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    // Top contributors
389    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    // Hottest files
396    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    // Module activity
406    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    // Recent weeks
416    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
433/// Render timeline data as markdown
434pub 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    // Narration
443    if let Some(ref narration) = data.narration {
444        md.push_str(narration);
445        md.push_str("\n\n");
446    }
447
448    // Activity chart — plain ASCII bar chart (terminal-safe, no Zola template syntax)
449    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    // Contributors table
477    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    // Hot files
487    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    // Module activity
500    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        // 2024-01-01 00:00:00 UTC = 1704067200
522        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); // 1970-01-01
528        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}