Skip to main content

tokmd_git/
lib.rs

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