Skip to main content

wagner/
git.rs

1use std::path::Path;
2use std::process::Command;
3
4#[derive(Debug, Clone)]
5pub struct DiffFile {
6    pub path: String,
7    pub status: char,
8    pub additions: usize,
9    pub deletions: usize,
10}
11
12#[derive(Debug, Clone, Default)]
13pub struct RepoStats {
14    pub additions: usize,
15    pub deletions: usize,
16    pub file_count: usize,
17}
18
19fn run_git(repo_path: &Path, args: &[&str]) -> Option<String> {
20    let output = Command::new("git")
21        .arg("-C")
22        .arg(repo_path)
23        .args(args)
24        .output()
25        .ok()?;
26    output
27        .status
28        .success()
29        .then(|| String::from_utf8_lossy(&output.stdout).into_owned())
30}
31
32fn resolve_base_ref(repo_path: &Path, base: &str) -> String {
33    let check_ref =
34        |r: &str| -> bool { run_git(repo_path, &["rev-parse", "--verify", r]).is_some() };
35
36    if check_ref(base) {
37        return base.to_string();
38    }
39
40    let origin_ref = format!("origin/{}", base);
41    if check_ref(&origin_ref) {
42        return origin_ref;
43    }
44
45    base.to_string()
46}
47
48pub fn get_diff_files(repo_path: &Path, base: &str) -> Vec<DiffFile> {
49    let base_ref = resolve_base_ref(repo_path, base);
50    let range = format!("{}..HEAD", base_ref);
51    let Some(stdout) = run_git(repo_path, &["diff", "--numstat", &range]) else {
52        return Vec::new();
53    };
54
55    stdout
56        .lines()
57        .filter_map(|line| {
58            let parts: Vec<&str> = line.split('\t').collect();
59            if parts.len() >= 3 {
60                let additions = parts[0].parse().unwrap_or(0);
61                let deletions = parts[1].parse().unwrap_or(0);
62                let path = parts[2].to_string();
63                let status = get_file_status(repo_path, &base_ref, &path);
64                Some(DiffFile {
65                    path,
66                    status,
67                    additions,
68                    deletions,
69                })
70            } else {
71                None
72            }
73        })
74        .collect()
75}
76
77fn get_file_status(repo_path: &Path, base_ref: &str, file_path: &str) -> char {
78    let range = format!("{}..HEAD", base_ref);
79    run_git(
80        repo_path,
81        &["diff", "--name-status", &range, "--", file_path],
82    )
83    .and_then(|s| s.chars().next())
84    .unwrap_or('M')
85}
86
87pub fn get_repo_stats(repo_path: &Path, base: &str) -> RepoStats {
88    let base_ref = resolve_base_ref(repo_path, base);
89    let range = format!("{}..HEAD", base_ref);
90    run_git(repo_path, &["diff", "--shortstat", &range])
91        .map(|s| parse_shortstat(&s))
92        .unwrap_or_default()
93}
94
95fn parse_shortstat(s: &str) -> RepoStats {
96    let mut stats = RepoStats::default();
97
98    for part in s.split(',') {
99        let part = part.trim();
100        if part.contains("file") {
101            if let Some(n) = part.split_whitespace().next() {
102                stats.file_count = n.parse().unwrap_or(0);
103            }
104        } else if part.contains("insertion") {
105            if let Some(n) = part.split_whitespace().next() {
106                stats.additions = n.parse().unwrap_or(0);
107            }
108        } else if part.contains("deletion")
109            && let Some(n) = part.split_whitespace().next()
110        {
111            stats.deletions = n.parse().unwrap_or(0);
112        }
113    }
114
115    stats
116}
117
118pub fn get_diff_content(repo_path: &Path, base: &str, file_path: &str) -> Vec<String> {
119    let base_ref = resolve_base_ref(repo_path, base);
120    let range = format!("{}..HEAD", base_ref);
121    run_git(
122        repo_path,
123        &["diff", "--color=always", &range, "--", file_path],
124    )
125    .map(|s| s.lines().map(String::from).collect())
126    .unwrap_or_default()
127}