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 author_time: Option<i64>,
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%at%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(format!("--pretty=format:{format}"))
246 .arg("--shortstat")
247 .output()?;
248
249 if !output.status.success() {
250 return Err(GitError::CommandFailed(
251 String::from_utf8_lossy(&output.stderr).to_string(),
252 ));
253 }
254
255 let mut commits = Vec::new();
256 let mut last_idx: Option<usize> = None;
257
258 for line in String::from_utf8_lossy(&output.stdout).lines() {
259 let line = line.trim();
260 if line.is_empty() {
261 continue;
262 }
263 if line.contains('\u{1f}') {
264 let parts: Vec<&str> = line.split('\u{1f}').collect();
265 if parts.len() < 6 {
266 continue;
267 }
268 let parents = if parts[2].trim().is_empty() {
269 Vec::new()
270 } else {
271 parts[2].split_whitespace().map(|s| s.to_string()).collect()
272 };
273 let author_time = parts[4].trim().parse::<i64>().ok();
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 author_time,
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_file_at_commit_bytes(
316 repo_path: &Path,
317 commit: &str,
318 file: &Path,
319) -> Result<Vec<u8>, GitError> {
320 let output = Command::new("git")
321 .arg("-C")
322 .arg(repo_path)
323 .arg("show")
324 .arg(format!("{}:{}", commit, file.display()))
325 .output()?;
326
327 if !output.status.success() {
328 return Err(GitError::CommandFailed(
329 String::from_utf8_lossy(&output.stderr).to_string(),
330 ));
331 }
332
333 Ok(output.stdout)
334}
335
336pub fn get_file_at_commit_size(repo_path: &Path, commit: &str, file: &Path) -> Option<u64> {
337 let output = Command::new("git")
338 .arg("-C")
339 .arg(repo_path)
340 .arg("cat-file")
341 .arg("-s")
342 .arg(format!("{}:{}", commit, file.display()))
343 .output()
344 .ok()?;
345
346 if !output.status.success() {
347 return None;
348 }
349
350 String::from_utf8_lossy(&output.stdout).trim().parse().ok()
351}
352
353pub fn get_staged_content(repo_path: &Path, file: &Path) -> Result<String, GitError> {
355 let output = Command::new("git")
356 .arg("-C")
357 .arg(repo_path)
358 .arg("show")
359 .arg(format!(":{}", file.display()))
360 .output()?;
361
362 if !output.status.success() {
363 return get_file_at_commit(repo_path, "HEAD", file);
365 }
366
367 Ok(String::from_utf8_lossy(&output.stdout).to_string())
368}
369
370pub fn get_staged_content_bytes(repo_path: &Path, file: &Path) -> Result<Vec<u8>, GitError> {
371 let output = Command::new("git")
372 .arg("-C")
373 .arg(repo_path)
374 .arg("show")
375 .arg(format!(":{}", file.display()))
376 .output()?;
377
378 if !output.status.success() {
379 return get_file_at_commit_bytes(repo_path, "HEAD", file);
380 }
381
382 Ok(output.stdout)
383}
384
385pub fn get_staged_content_size(repo_path: &Path, file: &Path) -> Option<u64> {
386 let output = Command::new("git")
387 .arg("-C")
388 .arg(repo_path)
389 .arg("cat-file")
390 .arg("-s")
391 .arg(format!(":{}", file.display()))
392 .output()
393 .ok()?;
394
395 if !output.status.success() {
396 return None;
397 }
398
399 String::from_utf8_lossy(&output.stdout).trim().parse().ok()
400}
401
402pub fn get_head_content_bytes(repo_path: &Path, file: &Path) -> Result<Vec<u8>, GitError> {
403 get_file_at_commit_bytes(repo_path, "HEAD", file)
404}
405
406pub fn get_head_content(repo_path: &Path, file: &Path) -> Result<String, GitError> {
408 get_file_at_commit(repo_path, "HEAD", file)
409}
410
411fn parse_name_status(output: &str, changes: &mut Vec<ChangedFile>) {
412 for line in output.lines() {
413 let line = line.trim();
414 if line.is_empty() {
415 continue;
416 }
417
418 let parts: Vec<&str> = line.split('\t').collect();
419 if parts.is_empty() {
420 continue;
421 }
422
423 let status_char = parts[0].chars().next().unwrap_or(' ');
424 let status = match status_char {
425 'M' => FileStatus::Modified,
426 'A' => FileStatus::Added,
427 'D' => FileStatus::Deleted,
428 'R' => FileStatus::Renamed,
429 _ => continue,
430 };
431
432 if parts.len() >= 2 {
433 let path = PathBuf::from(parts.last().unwrap());
434 let old_path = if status == FileStatus::Renamed && parts.len() >= 3 {
435 Some(PathBuf::from(parts[1]))
436 } else {
437 None
438 };
439
440 changes.push(ChangedFile {
441 path,
442 status,
443 old_path,
444 });
445 }
446 }
447}
448
449fn parse_shortstat(line: &str) -> Option<CommitStats> {
450 if !line.contains("file changed") && !line.contains("files changed") {
451 return None;
452 }
453
454 let mut files_changed = 0usize;
455 let mut insertions = 0usize;
456 let mut deletions = 0usize;
457
458 for part in line.split(',') {
459 let part = part.trim();
460 let count = part
461 .split_whitespace()
462 .next()
463 .and_then(|s| s.parse::<usize>().ok())
464 .unwrap_or(0);
465 if part.contains("file changed") || part.contains("files changed") {
466 files_changed = count;
467 } else if part.contains("insertion") {
468 insertions = count;
469 } else if part.contains("deletion") {
470 deletions = count;
471 }
472 }
473
474 Some(CommitStats {
475 files_changed,
476 insertions,
477 deletions,
478 })
479}
480
481#[cfg(test)]
482mod tests {
483 use super::*;
484
485 #[test]
486 fn test_parse_name_status() {
487 let output = "M\tsrc/main.rs\nA\tsrc/new.rs\nD\tsrc/old.rs\n";
488 let mut changes = Vec::new();
489 parse_name_status(output, &mut changes);
490
491 assert_eq!(changes.len(), 3);
492 assert_eq!(changes[0].status, FileStatus::Modified);
493 assert_eq!(changes[1].status, FileStatus::Added);
494 assert_eq!(changes[2].status, FileStatus::Deleted);
495 }
496}