1use std::path::{Path, PathBuf};
4use std::process::Command;
5use thiserror::Error;
6
7#[derive(Error, Debug)]
8pub enum GitError {
9 #[error("Not a git repository")]
10 NotARepo,
11 #[error("Git command failed: {0}")]
12 CommandFailed(String),
13 #[error("IO error: {0}")]
14 Io(#[from] std::io::Error),
15}
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
19pub enum FileStatus {
20 Modified,
21 Added,
22 Deleted,
23 Renamed,
24 Untracked,
25}
26
27#[derive(Debug, Clone)]
29pub struct ChangedFile {
30 pub path: PathBuf,
31 pub status: FileStatus,
32 pub old_path: Option<PathBuf>,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub struct CommitStats {
39 pub files_changed: usize,
40 pub insertions: usize,
41 pub deletions: usize,
42}
43
44#[derive(Debug, Clone)]
46pub struct CommitEntry {
47 pub id: String,
48 pub short_id: String,
49 pub parents: Vec<String>,
50 pub author: String,
51 pub date: String,
52 pub summary: String,
53 pub stats: Option<CommitStats>,
54}
55
56pub fn is_git_repo(path: &Path) -> bool {
58 Command::new("git")
59 .arg("-C")
60 .arg(path)
61 .arg("rev-parse")
62 .arg("--git-dir")
63 .output()
64 .map(|o| o.status.success())
65 .unwrap_or(false)
66}
67
68pub fn get_current_branch(path: &Path) -> Result<String, GitError> {
70 let output = Command::new("git")
71 .arg("-C")
72 .arg(path)
73 .arg("rev-parse")
74 .arg("--abbrev-ref")
75 .arg("HEAD")
76 .output()?;
77
78 if !output.status.success() {
79 return Err(GitError::NotARepo);
80 }
81
82 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
83}
84
85pub fn get_repo_root(path: &Path) -> Result<PathBuf, GitError> {
87 let output = Command::new("git")
88 .arg("-C")
89 .arg(path)
90 .arg("rev-parse")
91 .arg("--show-toplevel")
92 .output()?;
93
94 if !output.status.success() {
95 return Err(GitError::NotARepo);
96 }
97
98 let root = String::from_utf8_lossy(&output.stdout).trim().to_string();
99 Ok(PathBuf::from(root))
100}
101
102pub fn get_uncommitted_changes(repo_path: &Path) -> Result<Vec<ChangedFile>, GitError> {
104 let mut changes = Vec::new();
105
106 let staged = Command::new("git")
108 .arg("-C")
109 .arg(repo_path)
110 .arg("diff")
111 .arg("--cached")
112 .arg("--name-status")
113 .output()?;
114
115 if staged.status.success() {
116 parse_name_status(&String::from_utf8_lossy(&staged.stdout), &mut changes);
117 }
118
119 let unstaged = Command::new("git")
121 .arg("-C")
122 .arg(repo_path)
123 .arg("diff")
124 .arg("--name-status")
125 .output()?;
126
127 if unstaged.status.success() {
128 parse_name_status(&String::from_utf8_lossy(&unstaged.stdout), &mut changes);
129 }
130
131 let untracked = Command::new("git")
133 .arg("-C")
134 .arg(repo_path)
135 .arg("ls-files")
136 .arg("--others")
137 .arg("--exclude-standard")
138 .output()?;
139
140 if untracked.status.success() {
141 for line in String::from_utf8_lossy(&untracked.stdout).lines() {
142 let line = line.trim();
143 if !line.is_empty() {
144 changes.push(ChangedFile {
145 path: PathBuf::from(line),
146 status: FileStatus::Untracked,
147 old_path: None,
148 });
149 }
150 }
151 }
152
153 changes.sort_by(|a, b| a.path.cmp(&b.path));
155 changes.dedup_by(|a, b| a.path == b.path);
156
157 Ok(changes)
158}
159
160pub fn get_staged_changes(repo_path: &Path) -> Result<Vec<ChangedFile>, GitError> {
162 let output = Command::new("git")
163 .arg("-C")
164 .arg(repo_path)
165 .arg("diff")
166 .arg("--cached")
167 .arg("--name-status")
168 .output()?;
169
170 if !output.status.success() {
171 return Err(GitError::CommandFailed(
172 String::from_utf8_lossy(&output.stderr).to_string(),
173 ));
174 }
175
176 let mut changes = Vec::new();
177 parse_name_status(&String::from_utf8_lossy(&output.stdout), &mut changes);
178 Ok(changes)
179}
180
181pub fn get_changes_between(
183 repo_path: &Path,
184 from: &str,
185 to: &str,
186) -> Result<Vec<ChangedFile>, GitError> {
187 let output = Command::new("git")
188 .arg("-C")
189 .arg(repo_path)
190 .arg("diff")
191 .arg("--name-status")
192 .arg(format!("{}..{}", from, to))
193 .output()?;
194
195 if !output.status.success() {
196 return Err(GitError::CommandFailed(
197 String::from_utf8_lossy(&output.stderr).to_string(),
198 ));
199 }
200
201 let mut changes = Vec::new();
202 parse_name_status(&String::from_utf8_lossy(&output.stdout), &mut changes);
203 Ok(changes)
204}
205
206pub fn get_changes_between_index(
208 repo_path: &Path,
209 from: &str,
210 reverse: bool,
211) -> Result<Vec<ChangedFile>, GitError> {
212 let mut cmd = Command::new("git");
213 cmd.arg("-C")
214 .arg(repo_path)
215 .arg("diff")
216 .arg("--cached")
217 .arg("--name-status");
218 if reverse {
219 cmd.arg("-R");
220 }
221 cmd.arg(from);
222
223 let output = cmd.output()?;
224
225 if !output.status.success() {
226 return Err(GitError::CommandFailed(
227 String::from_utf8_lossy(&output.stderr).to_string(),
228 ));
229 }
230
231 let mut changes = Vec::new();
232 parse_name_status(&String::from_utf8_lossy(&output.stdout), &mut changes);
233 Ok(changes)
234}
235
236pub fn get_recent_commits(repo_path: &Path, limit: usize) -> Result<Vec<CommitEntry>, GitError> {
238 let format = "%H%x1f%h%x1f%P%x1f%an%x1f%ad%x1f%s";
239 let output = Command::new("git")
240 .arg("-C")
241 .arg(repo_path)
242 .arg("log")
243 .arg("-n")
244 .arg(limit.to_string())
245 .arg("--date=format:%Y-%m-%d %H:%M")
246 .arg(format!("--pretty=format:{format}"))
247 .arg("--shortstat")
248 .output()?;
249
250 if !output.status.success() {
251 return Err(GitError::CommandFailed(
252 String::from_utf8_lossy(&output.stderr).to_string(),
253 ));
254 }
255
256 let mut commits = Vec::new();
257 let mut last_idx: Option<usize> = None;
258
259 for line in String::from_utf8_lossy(&output.stdout).lines() {
260 let line = line.trim();
261 if line.is_empty() {
262 continue;
263 }
264 if line.contains('\u{1f}') {
265 let parts: Vec<&str> = line.split('\u{1f}').collect();
266 if parts.len() < 6 {
267 continue;
268 }
269 let parents = if parts[2].trim().is_empty() {
270 Vec::new()
271 } else {
272 parts[2].split_whitespace().map(|s| s.to_string()).collect()
273 };
274 commits.push(CommitEntry {
275 id: parts[0].to_string(),
276 short_id: parts[1].to_string(),
277 parents,
278 author: parts[3].to_string(),
279 date: parts[4].to_string(),
280 summary: parts[5].to_string(),
281 stats: None,
282 });
283 last_idx = Some(commits.len() - 1);
284 continue;
285 }
286
287 if let Some(stats) = parse_shortstat(line) {
288 if let Some(idx) = last_idx {
289 commits[idx].stats = Some(stats);
290 }
291 }
292 }
293
294 Ok(commits)
295}
296
297pub fn get_file_at_commit(repo_path: &Path, commit: &str, file: &Path) -> Result<String, GitError> {
299 let output = Command::new("git")
300 .arg("-C")
301 .arg(repo_path)
302 .arg("show")
303 .arg(format!("{}:{}", commit, file.display()))
304 .output()?;
305
306 if !output.status.success() {
307 return Err(GitError::CommandFailed(
308 String::from_utf8_lossy(&output.stderr).to_string(),
309 ));
310 }
311
312 Ok(String::from_utf8_lossy(&output.stdout).to_string())
313}
314
315pub fn get_staged_content(repo_path: &Path, file: &Path) -> Result<String, GitError> {
317 let output = Command::new("git")
318 .arg("-C")
319 .arg(repo_path)
320 .arg("show")
321 .arg(format!(":{}", file.display()))
322 .output()?;
323
324 if !output.status.success() {
325 return get_file_at_commit(repo_path, "HEAD", file);
327 }
328
329 Ok(String::from_utf8_lossy(&output.stdout).to_string())
330}
331
332pub fn get_head_content(repo_path: &Path, file: &Path) -> Result<String, GitError> {
334 get_file_at_commit(repo_path, "HEAD", file)
335}
336
337fn parse_name_status(output: &str, changes: &mut Vec<ChangedFile>) {
338 for line in output.lines() {
339 let line = line.trim();
340 if line.is_empty() {
341 continue;
342 }
343
344 let parts: Vec<&str> = line.split('\t').collect();
345 if parts.is_empty() {
346 continue;
347 }
348
349 let status_char = parts[0].chars().next().unwrap_or(' ');
350 let status = match status_char {
351 'M' => FileStatus::Modified,
352 'A' => FileStatus::Added,
353 'D' => FileStatus::Deleted,
354 'R' => FileStatus::Renamed,
355 _ => continue,
356 };
357
358 if parts.len() >= 2 {
359 let path = PathBuf::from(parts.last().unwrap());
360 let old_path = if status == FileStatus::Renamed && parts.len() >= 3 {
361 Some(PathBuf::from(parts[1]))
362 } else {
363 None
364 };
365
366 changes.push(ChangedFile {
367 path,
368 status,
369 old_path,
370 });
371 }
372 }
373}
374
375fn parse_shortstat(line: &str) -> Option<CommitStats> {
376 if !line.contains("file changed") && !line.contains("files changed") {
377 return None;
378 }
379
380 let mut files_changed = 0usize;
381 let mut insertions = 0usize;
382 let mut deletions = 0usize;
383
384 for part in line.split(',') {
385 let part = part.trim();
386 let count = part
387 .split_whitespace()
388 .next()
389 .and_then(|s| s.parse::<usize>().ok())
390 .unwrap_or(0);
391 if part.contains("file changed") || part.contains("files changed") {
392 files_changed = count;
393 } else if part.contains("insertion") {
394 insertions = count;
395 } else if part.contains("deletion") {
396 deletions = count;
397 }
398 }
399
400 Some(CommitStats {
401 files_changed,
402 insertions,
403 deletions,
404 })
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410
411 #[test]
412 fn test_parse_name_status() {
413 let output = "M\tsrc/main.rs\nA\tsrc/new.rs\nD\tsrc/old.rs\n";
414 let mut changes = Vec::new();
415 parse_name_status(output, &mut changes);
416
417 assert_eq!(changes.len(), 3);
418 assert_eq!(changes[0].status, FileStatus::Modified);
419 assert_eq!(changes[1].status, FileStatus::Added);
420 assert_eq!(changes[2].status, FileStatus::Deleted);
421 }
422}