Skip to main content

reposcry_git/
lib.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3use std::process::Command;
4
5use anyhow::{Context, Result};
6use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct GitChange {
10    pub path: String,
11    pub status: String,
12    pub lines_added: i64,
13    pub lines_deleted: i64,
14}
15
16#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct GitCommit {
18    pub hash: String,
19    pub author: String,
20    pub date: String,
21    pub message: String,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct GitBlameLine {
26    pub line: u32,
27    pub commit_hash: String,
28    pub author: String,
29    pub date: String,
30}
31
32pub struct GitIntegration {
33    repo_root: PathBuf,
34}
35
36impl GitIntegration {
37    pub fn new(repo_root: &Path) -> Self {
38        Self {
39            repo_root: repo_root.to_path_buf(),
40        }
41    }
42
43    fn run_git(&self, args: &[&str]) -> Result<String> {
44        let output = Command::new("git")
45            .current_dir(&self.repo_root)
46            .args(args)
47            .output()
48            .context("Failed to execute git command")?;
49        if !output.status.success() {
50            let stderr = String::from_utf8_lossy(&output.stderr);
51            return Err(anyhow::anyhow!("Git error: {}", stderr));
52        }
53        Ok(String::from_utf8_lossy(&output.stdout).to_string())
54    }
55
56    pub fn diff_files(&self, base: &str, head: &str) -> Result<Vec<GitChange>> {
57        let output = self.run_git(&["diff", "--name-status", &format!("{}...{}", base, head)])?;
58        let mut changes = Vec::new();
59        for line in output.lines() {
60            if line.is_empty() {
61                continue;
62            }
63            let parts: Vec<&str> = line.split('\t').collect();
64            if parts.len() >= 2 {
65                changes.push(GitChange {
66                    status: parts[0].to_string(),
67                    path: parts[1].to_string(),
68                    lines_added: 0,
69                    lines_deleted: 0,
70                });
71            }
72        }
73        let numstat = self.run_git(&["diff", "--numstat", &format!("{}...{}", base, head)])?;
74        for line in numstat.lines() {
75            if line.is_empty() {
76                continue;
77            }
78            let parts: Vec<&str> = line.split('\t').collect();
79            if parts.len() >= 3 {
80                let added: i64 = parts[0].parse().unwrap_or(0);
81                let deleted: i64 = parts[1].parse().unwrap_or(0);
82                let path = parts[2];
83                if let Some(change) = changes.iter_mut().find(|c| c.path == path) {
84                    change.lines_added = added;
85                    change.lines_deleted = deleted;
86                }
87            }
88        }
89        Ok(changes)
90    }
91
92    pub fn log(&self, since: &str, max_count: u32) -> Result<Vec<GitCommit>> {
93        let output = self.run_git(&[
94            "log",
95            &format!("--since={}", since),
96            &format!("--max-count={}", max_count),
97            "--format=%H|%an|%ai|%s",
98        ])?;
99        let mut commits = Vec::new();
100        for line in output.lines() {
101            if line.is_empty() {
102                continue;
103            }
104            let parts: Vec<&str> = line.splitn(4, '|').collect();
105            if parts.len() >= 4 {
106                commits.push(GitCommit {
107                    hash: parts[0].to_string(),
108                    author: parts[1].to_string(),
109                    date: parts[2].to_string(),
110                    message: parts[3].to_string(),
111                });
112            }
113        }
114        Ok(commits)
115    }
116
117    pub fn blame(&self, path: &str) -> Result<Vec<GitBlameLine>> {
118        let simple = self.run_git(&["blame", "--porcelain", path])?;
119        let mut blame_lines = Vec::new();
120        let mut current_hash = String::new();
121        let mut current_author = String::new();
122        let mut current_date = String::new();
123        let mut line_num: u32 = 0;
124        for line in simple.lines() {
125            if line.starts_with('\t') {
126                line_num += 1;
127                blame_lines.push(GitBlameLine {
128                    line: line_num,
129                    commit_hash: current_hash.clone(),
130                    author: current_author.clone(),
131                    date: current_date.clone(),
132                });
133            } else if line.len() >= 40 && line.chars().take(40).all(|c| c.is_ascii_hexdigit()) {
134                current_hash = line.split(' ').next().unwrap_or("").to_string();
135            } else if let Some(author) = line.strip_prefix("author ") {
136                current_author = author.to_string();
137            } else if let Some(date) = line.strip_prefix("author-time ") {
138                current_date = date.to_string();
139            }
140        }
141        Ok(blame_lines)
142    }
143
144    pub fn changed_files_since(&self, base: &str) -> Result<Vec<String>> {
145        let output = self.run_git(&["diff", "--name-only", &format!("{}...HEAD", base)])?;
146        Ok(output
147            .lines()
148            .map(|l| l.to_string())
149            .filter(|l| !l.is_empty())
150            .collect())
151    }
152
153    pub fn churn_since(&self, since: &str) -> Result<HashMap<String, u32>> {
154        let output = self.run_git(&[
155            "log",
156            &format!("--since={}", since),
157            "--name-only",
158            "--format=",
159            "--diff-filter=AM",
160        ])?;
161        let mut churn: HashMap<String, u32> = HashMap::new();
162        for line in output.lines() {
163            if !line.is_empty() {
164                *churn.entry(line.to_string()).or_insert(0) += 1;
165            }
166        }
167        Ok(churn)
168    }
169
170    pub fn file_owner(&self, path: &str) -> Result<String> {
171        let output = self.run_git(&["shortlog", "-sn", "--", path])?;
172        output
173            .lines()
174            .next()
175            .map(|l| {
176                let parts: Vec<&str> = l.split('\t').collect();
177                if parts.len() > 1 {
178                    parts[1].to_string()
179                } else {
180                    "unknown".to_string()
181                }
182            })
183            .ok_or_else(|| anyhow::anyhow!("No owner found for {}", path))
184    }
185
186    pub fn is_git_repo(&self) -> bool {
187        self.repo_root.join(".git").exists()
188    }
189}