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}