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
11type WeekData = (usize, Vec<String>, HashMap<String, usize>);
12
13/// Complete git intelligence data
14#[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/// A single commit
25#[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/// Contributor stats
35#[derive(Debug, Clone)]
36pub struct Contributor {
37    pub name: String,
38    pub email: String,
39    pub commit_count: usize,
40}
41
42/// File churn: how often a file changes
43#[derive(Debug, Clone)]
44pub struct FileChurn {
45    pub path: String,
46    pub change_count: usize,
47    pub primary_author: String,
48}
49
50/// Weekly activity summary
51#[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/// Per-module activity
61#[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
69/// Extract git log data for the last 6 months
70pub fn extract_git_intel(root: impl AsRef<Path>) -> Result<GitIntel> {
71    let root = root.as_ref();
72
73    // Check if this is a git repo
74    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
101/// Parse git log into commits
102fn 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
135/// Compute contributor stats from commits (deduplicated by name)
136fn 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
158/// Extract file change frequency using git log --name-only
159fn extract_file_churn(root: &Path) -> Result<Vec<FileChurn>> {
160    // Get file change counts with author info
161    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        // Lines without path separators and not starting with spaces are author names
184        // (from the %an format), file paths contain / or .
185        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); // Top 50 most-changed files
215
216    Ok(churn)
217}
218
219/// Compute weekly summaries from commits and file changes
220fn compute_weekly_summaries(root: &Path, commits: &[CommitInfo]) -> Result<Vec<WeekSummary>> {
221    if commits.is_empty() {
222        return Ok(vec![]);
223    }
224
225    // Group commits by ISO week
226    let mut weeks: HashMap<String, WeekData> = HashMap::new();
227
228    for commit in commits {
229        // Convert timestamp to week start date (Monday)
230        let ts = commit.timestamp;
231        // Simple week computation: round down to nearest Monday
232        let days_since_epoch = ts / 86400;
233        // 1970-01-01 was a Thursday (day 4), so Monday of that week = day -3
234        let week_day = (days_since_epoch + 3) % 7; // 0=Monday
235        let monday = days_since_epoch - week_day;
236        let week_key = format!("{}", monday); // Use epoch-day of Monday as key
237
238        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    // Get file changes per week (using git log with date ranges)
248    // Instead of running git for each week, use the commit data we already have
249    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    // Build summaries
283    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            // Compute top modules from changed files
289            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            // Convert monday epoch-days back to date string
303            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); // Last 12 weeks
319
320    Ok(summaries)
321}
322
323/// Compute per-module activity from file churn
324fn 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
356/// Convert epoch seconds to YYYY-MM-DD string
357pub fn epoch_to_date_string(epoch_secs: i64) -> String {
358    // Simple date calculation without external deps
359    let days = epoch_secs / 86400;
360    let (year, month, day) = days_to_ymd(days);
361    format!("{:04}-{:02}-{:02}", year, month, day)
362}
363
364/// Convert days since epoch to (year, month, day)
365pub fn days_to_ymd(days: i64) -> (i64, u32, u32) {
366    // Algorithm from http://howardhinnant.github.io/date_algorithms.html
367    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
380/// Build structural context for LLM narration
381pub 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    // Top contributors
391    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    // Hottest files
398    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    // Module activity
408    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    // Recent weeks
418    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
435/// Render timeline data as markdown
436pub 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    // Narration
445    if let Some(ref narration) = data.narration {
446        md.push_str(narration);
447        md.push_str("\n\n");
448    }
449
450    // Activity chart — plain ASCII bar chart (terminal-safe, no Zola template syntax)
451    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    // Contributors table
479    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    // Hot files
489    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    // Module activity
502    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        // 2024-01-01 00:00:00 UTC = 1704067200
524        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); // 1970-01-01
530        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}