Skip to main content

coda_core/
git.rs

1//! Git operations abstraction.
2//!
3//! Defines the [`GitOps`] trait for all git CLI interactions and provides
4//! [`DefaultGitOps`], the production implementation that shells out to `git`.
5//! This abstraction enables unit-testing modules that depend on git without
6//! requiring an actual repository.
7
8use std::path::{Path, PathBuf};
9
10use tracing::debug;
11
12use crate::CoreError;
13
14/// Abstraction over git CLI operations.
15///
16/// All path-taking methods expect **absolute** paths unless noted otherwise.
17/// Implementations must be `Send + Sync` so they can be shared across async
18/// tasks.
19pub trait GitOps: Send + Sync {
20    /// Creates a worktree at `path` on a new branch forked from `base`.
21    ///
22    /// Equivalent to `git worktree add <path> -b <branch> <base>`.
23    ///
24    /// # Errors
25    ///
26    /// Returns `CoreError::GitError` if the command fails.
27    fn worktree_add(&self, path: &Path, branch: &str, base: &str) -> Result<(), CoreError>;
28
29    /// Removes a worktree.
30    ///
31    /// Equivalent to `git worktree remove <path> [--force]`.
32    ///
33    /// # Errors
34    ///
35    /// Returns `CoreError::GitError` if the command fails.
36    fn worktree_remove(&self, path: &Path, force: bool) -> Result<(), CoreError>;
37
38    /// Prunes stale worktree bookkeeping entries.
39    ///
40    /// Equivalent to `git worktree prune`.
41    ///
42    /// # Errors
43    ///
44    /// Returns `CoreError::GitError` if the command fails.
45    fn worktree_prune(&self) -> Result<(), CoreError>;
46
47    /// Deletes a local branch.
48    ///
49    /// Equivalent to `git branch -D <branch>`.
50    ///
51    /// # Errors
52    ///
53    /// Returns `CoreError::GitError` if the command fails.
54    fn branch_delete(&self, branch: &str) -> Result<(), CoreError>;
55
56    /// Stages files.
57    ///
58    /// Equivalent to `git add <paths...>` run inside `cwd`.
59    ///
60    /// # Errors
61    ///
62    /// Returns `CoreError::GitError` if the command fails.
63    fn add(&self, cwd: &Path, paths: &[&str]) -> Result<(), CoreError>;
64
65    /// Returns whether the staging area has changes.
66    ///
67    /// Equivalent to `git diff --cached --quiet` (returns `true` when dirty).
68    fn has_staged_changes(&self, cwd: &Path) -> bool;
69
70    /// Creates a commit with the given message.
71    ///
72    /// Equivalent to `git commit -m <message>` run inside `cwd`.
73    ///
74    /// # Errors
75    ///
76    /// Returns `CoreError::GitError` if the command fails.
77    fn commit(&self, cwd: &Path, message: &str) -> Result<(), CoreError>;
78
79    /// Returns the diff between `base` and HEAD.
80    ///
81    /// Equivalent to `git diff <base> HEAD` run inside `cwd`.
82    ///
83    /// # Errors
84    ///
85    /// Returns `CoreError::GitError` if the command fails.
86    fn diff(&self, cwd: &Path, base: &str) -> Result<String, CoreError>;
87
88    /// Returns one-line log entries for commits in `range`.
89    ///
90    /// Equivalent to `git log <range> --oneline --no-decorate` inside `cwd`.
91    ///
92    /// # Errors
93    ///
94    /// Returns `CoreError::GitError` if the command fails.
95    fn log_oneline(&self, cwd: &Path, range: &str) -> Result<String, CoreError>;
96
97    /// Pushes the current branch to origin.
98    ///
99    /// Equivalent to `git push origin <branch>` run inside `cwd`.
100    ///
101    /// # Errors
102    ///
103    /// Returns `CoreError::GitError` if the command fails.
104    fn push(&self, cwd: &Path, branch: &str) -> Result<(), CoreError>;
105
106    /// Detects the repository's default branch.
107    ///
108    /// Queries `git symbolic-ref refs/remotes/origin/HEAD --short` and
109    /// strips the `origin/` prefix. Falls back to `"main"`.
110    fn detect_default_branch(&self) -> String;
111}
112
113/// Production [`GitOps`] implementation that shells out to the `git` binary.
114#[derive(Debug)]
115pub struct DefaultGitOps {
116    project_root: PathBuf,
117}
118
119impl DefaultGitOps {
120    /// Creates a new instance rooted at `project_root`.
121    pub fn new(project_root: PathBuf) -> Self {
122        Self { project_root }
123    }
124}
125
126impl GitOps for DefaultGitOps {
127    fn worktree_add(&self, path: &Path, branch: &str, base: &str) -> Result<(), CoreError> {
128        if let Some(parent) = path.parent() {
129            std::fs::create_dir_all(parent).map_err(CoreError::IoError)?;
130        }
131        run_git(
132            &self.project_root,
133            &[
134                "worktree",
135                "add",
136                &path.display().to_string(),
137                "-b",
138                branch,
139                base,
140            ],
141        )?;
142        Ok(())
143    }
144
145    fn worktree_remove(&self, path: &Path, force: bool) -> Result<(), CoreError> {
146        let path_str = path.display().to_string();
147        let mut args = vec!["worktree", "remove", &path_str];
148        if force {
149            args.push("--force");
150        }
151        run_git(&self.project_root, &args)?;
152        Ok(())
153    }
154
155    fn worktree_prune(&self) -> Result<(), CoreError> {
156        run_git(&self.project_root, &["worktree", "prune"])?;
157        Ok(())
158    }
159
160    fn branch_delete(&self, branch: &str) -> Result<(), CoreError> {
161        run_git(&self.project_root, &["branch", "-D", branch])?;
162        Ok(())
163    }
164
165    fn add(&self, cwd: &Path, paths: &[&str]) -> Result<(), CoreError> {
166        let mut args = vec!["add"];
167        args.extend(paths);
168        run_git(cwd, &args)?;
169        Ok(())
170    }
171
172    fn has_staged_changes(&self, cwd: &Path) -> bool {
173        run_git(cwd, &["diff", "--cached", "--quiet"]).is_err()
174    }
175
176    fn commit(&self, cwd: &Path, message: &str) -> Result<(), CoreError> {
177        run_git(cwd, &["commit", "-m", message])?;
178        Ok(())
179    }
180
181    fn diff(&self, cwd: &Path, base: &str) -> Result<String, CoreError> {
182        run_git(cwd, &["diff", base, "HEAD"])
183    }
184
185    fn log_oneline(&self, cwd: &Path, range: &str) -> Result<String, CoreError> {
186        run_git(cwd, &["log", range, "--oneline", "--no-decorate"])
187    }
188
189    fn push(&self, cwd: &Path, branch: &str) -> Result<(), CoreError> {
190        run_git(cwd, &["push", "origin", branch])?;
191        Ok(())
192    }
193
194    fn detect_default_branch(&self) -> String {
195        let output = std::process::Command::new("git")
196            .args(["symbolic-ref", "refs/remotes/origin/HEAD", "--short"])
197            .current_dir(&self.project_root)
198            .output();
199
200        if let Ok(output) = output
201            && output.status.success()
202        {
203            let branch = String::from_utf8_lossy(&output.stdout).trim().to_string();
204            if let Some(name) = branch.strip_prefix("origin/") {
205                return name.to_string();
206            }
207            return branch;
208        }
209
210        "main".to_string()
211    }
212}
213
214/// Runs a git command and returns its stdout.
215fn run_git(cwd: &Path, args: &[&str]) -> Result<String, CoreError> {
216    debug!(cwd = %cwd.display(), args = ?args, "git");
217    let output = std::process::Command::new("git")
218        .args(args)
219        .current_dir(cwd)
220        .output()
221        .map_err(CoreError::IoError)?;
222
223    if !output.status.success() {
224        let stderr = String::from_utf8_lossy(&output.stderr);
225        return Err(CoreError::GitError(format!(
226            "git {} failed: {stderr}",
227            args.join(" "),
228        )));
229    }
230
231    Ok(String::from_utf8_lossy(&output.stdout).to_string())
232}