1use std::path::{Path, PathBuf};
9
10use tracing::debug;
11
12use crate::CoreError;
13
14pub trait GitOps: Send + Sync {
20 fn worktree_add(&self, path: &Path, branch: &str, base: &str) -> Result<(), CoreError>;
28
29 fn worktree_remove(&self, path: &Path, force: bool) -> Result<(), CoreError>;
37
38 fn worktree_prune(&self) -> Result<(), CoreError>;
46
47 fn branch_delete(&self, branch: &str) -> Result<(), CoreError>;
55
56 fn add(&self, cwd: &Path, paths: &[&str]) -> Result<(), CoreError>;
64
65 fn has_staged_changes(&self, cwd: &Path) -> bool;
69
70 fn commit(&self, cwd: &Path, message: &str) -> Result<(), CoreError>;
78
79 fn diff(&self, cwd: &Path, base: &str) -> Result<String, CoreError>;
87
88 fn log_oneline(&self, cwd: &Path, range: &str) -> Result<String, CoreError>;
96
97 fn push(&self, cwd: &Path, branch: &str) -> Result<(), CoreError>;
105
106 fn detect_default_branch(&self) -> String;
111}
112
113#[derive(Debug)]
115pub struct DefaultGitOps {
116 project_root: PathBuf,
117}
118
119impl DefaultGitOps {
120 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
214fn 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}