Skip to main content

agent_code_lib/services/
git.rs

1//! Git integration utilities.
2//!
3//! Helpers for interacting with git repositories — status, diff,
4//! log, blame, and branch operations. All operations shell out to
5//! the git CLI for maximum compatibility.
6
7use std::path::Path;
8use std::process::Stdio;
9use tokio::process::Command;
10
11/// Check if the given directory is inside a git repository.
12pub 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
24/// Get the root of the current git repository.
25pub 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
40/// Resolve the canonical repository root, following worktree links.
41///
42/// If the current directory is inside a worktree, this returns the
43/// path to the main repository (not the worktree checkout).
44pub async fn canonical_root(cwd: &Path) -> Option<String> {
45    // git rev-parse --git-common-dir gives the shared .git directory.
46    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 the common dir ends with "/.git", the parent is the canonical root.
60    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        // Not a worktree — use regular root.
68        repo_root(cwd).await
69    } else {
70        // Absolute path to shared git dir.
71        let path = std::path::Path::new(&common_dir);
72        path.parent().map(|p| p.display().to_string())
73    }
74}
75
76/// Check if the repository is a shallow clone.
77pub 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
91/// Check if the current directory is inside a worktree (not the main checkout).
92pub 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
101/// Get the current branch name.
102pub 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
122/// Get the default/main branch name.
123pub async fn default_branch(cwd: &Path) -> String {
124    // Try common conventions.
125    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
141/// Get git status (short format).
142pub async fn status(cwd: &Path) -> Result<String, String> {
143    run_git(cwd, &["status", "--short"]).await
144}
145
146/// Get staged and unstaged diff.
147pub 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
171/// Get recent commit log.
172pub async fn log(cwd: &Path, count: usize) -> Result<String, String> {
173    run_git(cwd, &["log", "--oneline", &format!("-{count}")]).await
174}
175
176/// Get blame for a file (abbreviated).
177pub async fn blame(cwd: &Path, file: &str) -> Result<String, String> {
178    run_git(cwd, &["blame", "--line-porcelain", file]).await
179}
180
181/// Get the diff between the current branch and the default branch.
182pub 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
187/// Parse a unified diff into structured hunks.
188pub 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            // Save previous file.
196            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            // Extract file path from "diff --git a/path b/path".
204            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    // Save last file.
234    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/// A file in a parsed diff.
245#[derive(Debug, Clone)]
246pub struct DiffFile {
247    pub path: String,
248    pub hunks: Vec<DiffHunk>,
249}
250
251/// A hunk within a diff file.
252#[derive(Debug, Clone)]
253pub struct DiffHunk {
254    pub header: String,
255    pub lines: Vec<DiffLine>,
256}
257
258/// A single line in a diff hunk.
259#[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    /// Count added and removed lines.
274    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
290/// Run a git command and return stdout.
291async 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        // This test runs inside the agent-code repo itself.
373        // Create a directory that IS a git repo.
374        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        // New repo with no commits may not have a branch.
413        let _branch = current_branch(dir.path()).await;
414        // Just ensure it doesn't panic.
415    }
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        // Modify file.
492        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        // Fresh init is not shallow and not a worktree.
511        assert!(!is_shallow(dir.path()).await);
512        assert!(!is_worktree(dir.path()).await);
513    }
514}