Skip to main content

tokmd_git/
lib.rs

1//! # tokmd-git
2//!
3//! **Tier 2 (Utilities)**
4//!
5//! Streaming git log adapter for tokmd analysis. Collects commit history
6//! without loading the entire history into memory.
7//!
8//! ## What belongs here
9//! * Git history collection
10//! * Commit parsing (timestamp, author, affected files)
11//! * Streaming interface
12//!
13//! ## What does NOT belong here
14//! * Analysis computation (use tokmd-analysis)
15//! * Git history modification
16//! * Complex git operations (use git2 crate directly if needed)
17
18use std::io::{BufRead, BufReader};
19use std::path::{Path, PathBuf};
20use std::process::{Command, Stdio};
21
22use anyhow::{Context, Result};
23
24#[derive(Debug, Clone)]
25pub struct GitCommit {
26    pub timestamp: i64,
27    pub author: String,
28    pub files: Vec<String>,
29}
30
31pub fn git_available() -> bool {
32    Command::new("git")
33        .arg("--version")
34        .stdout(Stdio::null())
35        .stderr(Stdio::null())
36        .status()
37        .map(|s| s.success())
38        .unwrap_or(false)
39}
40
41pub fn repo_root(path: &Path) -> Option<PathBuf> {
42    let output = Command::new("git")
43        .arg("-C")
44        .arg(path)
45        .arg("rev-parse")
46        .arg("--show-toplevel")
47        .output()
48        .ok()?;
49    if !output.status.success() {
50        return None;
51    }
52    let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
53    if root.is_empty() {
54        None
55    } else {
56        Some(PathBuf::from(root))
57    }
58}
59
60pub fn collect_history(
61    repo_root: &Path,
62    max_commits: Option<usize>,
63    max_commit_files: Option<usize>,
64) -> Result<Vec<GitCommit>> {
65    let mut child = Command::new("git")
66        .arg("-C")
67        .arg(repo_root)
68        .arg("log")
69        .arg("--name-only")
70        .arg("--pretty=format:%ct|%ae")
71        .stdout(Stdio::piped())
72        .stderr(Stdio::null())
73        .spawn()
74        .context("Failed to spawn git log")?;
75
76    let stdout = child.stdout.take().context("Missing git log stdout")?;
77    let reader = BufReader::new(stdout);
78
79    let mut commits: Vec<GitCommit> = Vec::new();
80    let mut current: Option<GitCommit> = None;
81
82    for line in reader.lines() {
83        let line = line?;
84        if line.trim().is_empty() {
85            if let Some(commit) = current.take() {
86                commits.push(commit);
87                if max_commits.is_some_and(|limit| commits.len() >= limit) {
88                    break;
89                }
90            }
91            continue;
92        }
93
94        if current.is_none() {
95            let mut parts = line.splitn(2, '|');
96            let ts = parts.next().unwrap_or("0").parse::<i64>().unwrap_or(0);
97            let author = parts.next().unwrap_or("").to_string();
98            current = Some(GitCommit {
99                timestamp: ts,
100                author,
101                files: Vec::new(),
102            });
103            continue;
104        }
105
106        if let Some(commit) = current.as_mut()
107            && max_commit_files
108                .map(|limit| commit.files.len() < limit)
109                .unwrap_or(true)
110        {
111            commit.files.push(line.trim().to_string());
112        }
113    }
114
115    if let Some(commit) = current.take() {
116        commits.push(commit);
117    }
118
119    let status = child.wait()?;
120    if !status.success() {
121        return Err(anyhow::anyhow!("git log failed"));
122    }
123
124    Ok(commits)
125}