1use std::path::Path;
8use std::process::Stdio;
9use tokio::process::Command;
10
11pub async fn is_git_repo(cwd: &Path) -> bool {
13 Command::new("git")
14 .args(["rev-parse", "--is-inside-work-tree"])
15 .current_dir(cwd)
16 .stdout(Stdio::null())
17 .stderr(Stdio::null())
18 .status()
19 .await
20 .map(|s| s.success())
21 .unwrap_or(false)
22}
23
24pub async fn repo_root(cwd: &Path) -> Option<String> {
26 let output = Command::new("git")
27 .args(["rev-parse", "--show-toplevel"])
28 .current_dir(cwd)
29 .output()
30 .await
31 .ok()?;
32
33 if output.status.success() {
34 Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
35 } else {
36 None
37 }
38}
39
40pub async fn canonical_root(cwd: &Path) -> Option<String> {
45 let output = Command::new("git")
47 .args(["rev-parse", "--git-common-dir"])
48 .current_dir(cwd)
49 .output()
50 .await
51 .ok()?;
52
53 if !output.status.success() {
54 return repo_root(cwd).await;
55 }
56
57 let common_dir = String::from_utf8_lossy(&output.stdout).trim().to_string();
58
59 if common_dir.ends_with("/.git") || common_dir.ends_with("\\.git") {
61 let root = common_dir
62 .strip_suffix("/.git")
63 .or_else(|| common_dir.strip_suffix("\\.git"))
64 .unwrap_or(&common_dir);
65 Some(root.to_string())
66 } else if common_dir == ".git" {
67 repo_root(cwd).await
69 } else {
70 let path = std::path::Path::new(&common_dir);
72 path.parent().map(|p| p.display().to_string())
73 }
74}
75
76pub async fn is_shallow(cwd: &Path) -> bool {
78 Command::new("git")
79 .args(["rev-parse", "--is-shallow-repository"])
80 .current_dir(cwd)
81 .output()
82 .await
83 .map(|o| {
84 String::from_utf8_lossy(&o.stdout)
85 .trim()
86 .eq_ignore_ascii_case("true")
87 })
88 .unwrap_or(false)
89}
90
91pub async fn is_worktree(cwd: &Path) -> bool {
93 let toplevel = repo_root(cwd).await;
94 let canonical = canonical_root(cwd).await;
95 match (toplevel, canonical) {
96 (Some(t), Some(c)) => t != c,
97 _ => false,
98 }
99}
100
101pub async fn current_branch(cwd: &Path) -> Option<String> {
103 let output = Command::new("git")
104 .args(["branch", "--show-current"])
105 .current_dir(cwd)
106 .output()
107 .await
108 .ok()?;
109
110 if output.status.success() {
111 let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
112 if branch.is_empty() {
113 None
114 } else {
115 Some(branch)
116 }
117 } else {
118 None
119 }
120}
121
122pub async fn default_branch(cwd: &Path) -> String {
124 for name in &["main", "master"] {
126 let output = Command::new("git")
127 .args(["rev-parse", "--verify", &format!("refs/heads/{name}")])
128 .current_dir(cwd)
129 .stdout(Stdio::null())
130 .stderr(Stdio::null())
131 .status()
132 .await;
133
134 if output.map(|s| s.success()).unwrap_or(false) {
135 return name.to_string();
136 }
137 }
138 "main".to_string()
139}
140
141pub async fn status(cwd: &Path) -> Result<String, String> {
143 run_git(cwd, &["status", "--short"]).await
144}
145
146pub async fn diff(cwd: &Path) -> Result<String, String> {
148 let staged = run_git(cwd, &["diff", "--cached"])
149 .await
150 .unwrap_or_default();
151 let unstaged = run_git(cwd, &["diff"]).await.unwrap_or_default();
152
153 let mut result = String::new();
154 if !staged.is_empty() {
155 result.push_str("=== Staged changes ===\n");
156 result.push_str(&staged);
157 }
158 if !unstaged.is_empty() {
159 if !result.is_empty() {
160 result.push('\n');
161 }
162 result.push_str("=== Unstaged changes ===\n");
163 result.push_str(&unstaged);
164 }
165 if result.is_empty() {
166 result = "(no changes)".to_string();
167 }
168 Ok(result)
169}
170
171pub async fn log(cwd: &Path, count: usize) -> Result<String, String> {
173 run_git(cwd, &["log", "--oneline", &format!("-{count}")]).await
174}
175
176pub async fn blame(cwd: &Path, file: &str) -> Result<String, String> {
178 run_git(cwd, &["blame", "--line-porcelain", file]).await
179}
180
181pub async fn diff_from_base(cwd: &Path) -> Result<String, String> {
183 let base = default_branch(cwd).await;
184 run_git(cwd, &["diff", &format!("{base}...HEAD")]).await
185}
186
187pub fn parse_diff(diff_text: &str) -> Vec<DiffFile> {
189 let mut files = Vec::new();
190 let mut current_file: Option<DiffFile> = None;
191 let mut current_hunk: Option<DiffHunk> = None;
192
193 for line in diff_text.lines() {
194 if line.starts_with("diff --git") {
195 if let Some(mut file) = current_file.take() {
197 if let Some(hunk) = current_hunk.take() {
198 file.hunks.push(hunk);
199 }
200 files.push(file);
201 }
202
203 let path = line.split(" b/").nth(1).unwrap_or("unknown").to_string();
205
206 current_file = Some(DiffFile {
207 path,
208 hunks: Vec::new(),
209 });
210 } else if line.starts_with("@@") {
211 if let Some(ref mut file) = current_file
212 && let Some(hunk) = current_hunk.take()
213 {
214 file.hunks.push(hunk);
215 }
216 current_hunk = Some(DiffHunk {
217 header: line.to_string(),
218 lines: Vec::new(),
219 });
220 } else if let Some(ref mut hunk) = current_hunk {
221 let kind = match line.chars().next() {
222 Some('+') => DiffLineKind::Added,
223 Some('-') => DiffLineKind::Removed,
224 _ => DiffLineKind::Context,
225 };
226 hunk.lines.push(DiffLine {
227 kind,
228 content: line.to_string(),
229 });
230 }
231 }
232
233 if let Some(mut file) = current_file {
235 if let Some(hunk) = current_hunk {
236 file.hunks.push(hunk);
237 }
238 files.push(file);
239 }
240
241 files
242}
243
244#[derive(Debug, Clone)]
246pub struct DiffFile {
247 pub path: String,
248 pub hunks: Vec<DiffHunk>,
249}
250
251#[derive(Debug, Clone)]
253pub struct DiffHunk {
254 pub header: String,
255 pub lines: Vec<DiffLine>,
256}
257
258#[derive(Debug, Clone)]
260pub struct DiffLine {
261 pub kind: DiffLineKind,
262 pub content: String,
263}
264
265#[derive(Debug, Clone, Copy, PartialEq, Eq)]
266pub enum DiffLineKind {
267 Added,
268 Removed,
269 Context,
270}
271
272impl DiffFile {
273 pub fn stats(&self) -> (usize, usize) {
275 let mut added = 0;
276 let mut removed = 0;
277 for hunk in &self.hunks {
278 for line in &hunk.lines {
279 match line.kind {
280 DiffLineKind::Added => added += 1,
281 DiffLineKind::Removed => removed += 1,
282 DiffLineKind::Context => {}
283 }
284 }
285 }
286 (added, removed)
287 }
288}
289
290async fn run_git(cwd: &Path, args: &[&str]) -> Result<String, String> {
292 let output = Command::new("git")
293 .args(args)
294 .current_dir(cwd)
295 .output()
296 .await
297 .map_err(|e| format!("git command failed: {e}"))?;
298
299 if output.status.success() {
300 Ok(String::from_utf8_lossy(&output.stdout).to_string())
301 } else {
302 let stderr = String::from_utf8_lossy(&output.stderr);
303 Err(format!("git error: {stderr}"))
304 }
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310
311 #[test]
312 fn test_parse_diff() {
313 let diff = "\
314diff --git a/src/main.rs b/src/main.rs
315index abc..def 100644
316--- a/src/main.rs
317+++ b/src/main.rs
318@@ -1,3 +1,4 @@
319 fn main() {
320- println!(\"old\");
321+ println!(\"new\");
322+ println!(\"added\");
323 }
324";
325 let files = parse_diff(diff);
326 assert_eq!(files.len(), 1);
327 assert_eq!(files[0].path, "src/main.rs");
328 assert_eq!(files[0].hunks.len(), 1);
329
330 let (added, removed) = files[0].stats();
331 assert_eq!(added, 2);
332 assert_eq!(removed, 1);
333 }
334
335 #[test]
336 fn test_parse_diff_multiple_files() {
337 let diff = "\
338diff --git a/a.rs b/a.rs
339--- a/a.rs
340+++ b/a.rs
341@@ -1,1 +1,1 @@
342-old
343+new
344diff --git a/b.rs b/b.rs
345--- a/b.rs
346+++ b/b.rs
347@@ -1,1 +1,2 @@
348 keep
349+added
350";
351 let files = parse_diff(diff);
352 assert_eq!(files.len(), 2);
353 assert_eq!(files[0].path, "a.rs");
354 assert_eq!(files[1].path, "b.rs");
355 }
356
357 #[test]
358 fn test_parse_diff_empty() {
359 let files = parse_diff("");
360 assert!(files.is_empty());
361 }
362
363 #[test]
364 fn test_diff_line_kinds() {
365 assert!(matches!(DiffLineKind::Added, DiffLineKind::Added));
366 assert!(matches!(DiffLineKind::Removed, DiffLineKind::Removed));
367 assert!(matches!(DiffLineKind::Context, DiffLineKind::Context));
368 }
369
370 #[tokio::test]
371 async fn test_is_git_repo_in_repo() {
372 let dir = tempfile::tempdir().unwrap();
375 Command::new("git")
376 .args(["init", "-q"])
377 .current_dir(dir.path())
378 .output()
379 .await
380 .unwrap();
381 assert!(is_git_repo(dir.path()).await);
382 }
383
384 #[tokio::test]
385 async fn test_is_git_repo_not_repo() {
386 let dir = tempfile::tempdir().unwrap();
387 assert!(!is_git_repo(dir.path()).await);
388 }
389
390 #[tokio::test]
391 async fn test_repo_root() {
392 let dir = tempfile::tempdir().unwrap();
393 Command::new("git")
394 .args(["init", "-q"])
395 .current_dir(dir.path())
396 .output()
397 .await
398 .unwrap();
399 let root = repo_root(dir.path()).await;
400 assert!(root.is_some());
401 }
402
403 #[tokio::test]
404 async fn test_current_branch_new_repo() {
405 let dir = tempfile::tempdir().unwrap();
406 Command::new("git")
407 .args(["init", "-q"])
408 .current_dir(dir.path())
409 .output()
410 .await
411 .unwrap();
412 let _branch = current_branch(dir.path()).await;
414 }
416
417 #[tokio::test]
418 async fn test_current_branch_with_commit() {
419 let dir = tempfile::tempdir().unwrap();
420 Command::new("git")
421 .args(["init", "-q"])
422 .current_dir(dir.path())
423 .output()
424 .await
425 .unwrap();
426 Command::new("git")
427 .args(["config", "user.email", "test@test.com"])
428 .current_dir(dir.path())
429 .output()
430 .await
431 .unwrap();
432 Command::new("git")
433 .args(["config", "user.name", "Test"])
434 .current_dir(dir.path())
435 .output()
436 .await
437 .unwrap();
438 std::fs::write(dir.path().join("f.txt"), "hi").unwrap();
439 Command::new("git")
440 .args(["add", "."])
441 .current_dir(dir.path())
442 .output()
443 .await
444 .unwrap();
445 Command::new("git")
446 .args(["commit", "-q", "-m", "init"])
447 .current_dir(dir.path())
448 .output()
449 .await
450 .unwrap();
451
452 let branch = current_branch(dir.path()).await;
453 assert!(branch.is_some());
454 }
455
456 #[tokio::test]
457 async fn test_status_and_diff() {
458 let dir = tempfile::tempdir().unwrap();
459 Command::new("git")
460 .args(["init", "-q"])
461 .current_dir(dir.path())
462 .output()
463 .await
464 .unwrap();
465 Command::new("git")
466 .args(["config", "user.email", "t@t.com"])
467 .current_dir(dir.path())
468 .output()
469 .await
470 .unwrap();
471 Command::new("git")
472 .args(["config", "user.name", "T"])
473 .current_dir(dir.path())
474 .output()
475 .await
476 .unwrap();
477 std::fs::write(dir.path().join("f.txt"), "v1").unwrap();
478 Command::new("git")
479 .args(["add", "."])
480 .current_dir(dir.path())
481 .output()
482 .await
483 .unwrap();
484 Command::new("git")
485 .args(["commit", "-q", "-m", "init"])
486 .current_dir(dir.path())
487 .output()
488 .await
489 .unwrap();
490
491 std::fs::write(dir.path().join("f.txt"), "v2").unwrap();
493
494 let st = status(dir.path()).await.unwrap();
495 assert!(st.contains("f.txt"));
496
497 let d = diff(dir.path()).await.unwrap();
498 assert!(d.contains("v1") || d.contains("v2"));
499 }
500
501 #[tokio::test]
502 async fn test_is_shallow_and_worktree() {
503 let dir = tempfile::tempdir().unwrap();
504 Command::new("git")
505 .args(["init", "-q"])
506 .current_dir(dir.path())
507 .output()
508 .await
509 .unwrap();
510 assert!(!is_shallow(dir.path()).await);
512 assert!(!is_worktree(dir.path()).await);
513 }
514}