1use crate::errors::GitError;
7use crate::git::traits::{CloneOptions, FetchResult, GitOperations, PullResult, RepoStatus};
8use std::path::Path;
9use std::process::{Command, Output};
10use tracing::{debug, trace};
11
12#[derive(Debug, Clone, Copy, Default)]
16pub struct ShellGit;
17
18impl ShellGit {
19 pub fn new() -> Self {
21 Self
22 }
23
24 fn run_git(&self, args: &[&str], cwd: Option<&Path>) -> Result<Output, GitError> {
26 let mut cmd = Command::new("git");
27 cmd.args(args);
28
29 if let Some(dir) = cwd {
30 cmd.current_dir(dir);
31 }
32
33 cmd.env("GIT_TERMINAL_PROMPT", "0");
35
36 cmd.output().map_err(|e| {
37 GitError::command_failed(
38 format!("git {}", args.join(" ")),
39 format!("Failed to execute: {}", e),
40 )
41 })
42 }
43
44 fn run_git_output(&self, args: &[&str], cwd: Option<&Path>) -> Result<String, GitError> {
46 let output = self.run_git(args, cwd)?;
47
48 if output.status.success() {
49 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
50 } else {
51 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
52 Err(GitError::command_failed(
53 format!("git {}", args.join(" ")),
54 stderr,
55 ))
56 }
57 }
58
59 fn run_git_check(&self, args: &[&str], cwd: Option<&Path>) -> bool {
61 self.run_git(args, cwd)
62 .map(|o| o.status.success())
63 .unwrap_or(false)
64 }
65
66 fn parse_status_output(&self, output: &str, branch_output: &str) -> RepoStatus {
68 let mut staged_count: usize = 0;
69 let mut unstaged_count: usize = 0;
70 let mut untracked_count: usize = 0;
71
72 for line in output.lines() {
73 if line.len() < 2 {
74 continue;
75 }
76 let bytes = line.as_bytes();
77 let x = bytes[0]; let y = bytes[1]; if x == b'?' && y == b'?' {
81 untracked_count += 1;
82 } else {
83 if x != b' ' && x != b'?' {
84 staged_count += 1;
85 }
86 if y != b' ' && y != b'?' {
87 unstaged_count += 1;
88 }
89 }
90 }
91 let is_uncommitted = staged_count > 0 || unstaged_count > 0;
92 let has_untracked = untracked_count > 0;
93
94 let (branch, ahead, behind) = self.parse_branch_info(branch_output);
97
98 RepoStatus {
99 branch,
100 is_uncommitted,
101 ahead,
102 behind,
103 has_untracked,
104 staged_count,
105 unstaged_count,
106 untracked_count,
107 }
108 }
109
110 fn parse_branch_info(&self, output: &str) -> (String, u32, u32) {
112 let first_line = output.lines().next().unwrap_or("");
113
114 let line = first_line.strip_prefix("## ").unwrap_or(first_line);
116
117 let (branch_part, info_part): (&str, Option<&str>) = if let Some(idx) = line.find("...") {
119 (&line[..idx], Some(&line[idx + 3..]))
120 } else {
121 if let Some(bracket_idx) = line.find('[') {
124 (line[..bracket_idx].trim_end(), Some(&line[bracket_idx..]))
125 } else {
126 let branch = line.split_whitespace().next().unwrap_or("HEAD");
127 (branch, None)
128 }
129 };
130
131 let branch = branch_part.to_string();
132 let mut ahead = 0;
133 let mut behind = 0;
134
135 if let Some(info) = info_part {
138 if let Some(start) = info.find('[') {
139 if let Some(end) = info.find(']') {
140 let bracket_content = &info[start + 1..end];
141 for part in bracket_content.split(", ") {
142 if let Some(n) = part.strip_prefix("ahead ") {
143 ahead = n.parse().unwrap_or(0);
144 } else if let Some(n) = part.strip_prefix("behind ") {
145 behind = n.parse().unwrap_or(0);
146 }
147 }
148 }
149 }
150 }
151
152 (branch, ahead, behind)
153 }
154}
155
156impl GitOperations for ShellGit {
157 fn clone_repo(&self, url: &str, target: &Path, options: &CloneOptions) -> Result<(), GitError> {
158 debug!(
159 url,
160 target = %target.display(),
161 depth = options.depth,
162 branch = options.branch.as_deref().unwrap_or("default"),
163 recurse_submodules = options.recurse_submodules,
164 "Starting git clone"
165 );
166
167 let mut args = vec!["clone"];
168
169 let depth_str;
171 if options.depth > 0 {
172 depth_str = options.depth.to_string();
173 args.push("--depth");
174 args.push(&depth_str);
175 }
176
177 if let Some(ref branch) = options.branch {
179 args.push("--branch");
180 args.push(branch);
181 }
182
183 if options.recurse_submodules {
185 args.push("--recurse-submodules");
186 }
187
188 args.push(url);
190 let target_str = target.to_string_lossy();
191 args.push(&target_str);
192
193 trace!(args = ?args, "Executing git command");
194 let output = self.run_git(&args, None)?;
195
196 if output.status.success() {
197 debug!(url, target = %target.display(), "Clone completed successfully");
198 Ok(())
199 } else {
200 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
201 debug!(url, error = %stderr, "Clone failed");
202 Err(GitError::clone_failed(url, stderr))
203 }
204 }
205
206 fn fetch(&self, repo_path: &Path) -> Result<FetchResult, GitError> {
207 debug!(repo = %repo_path.display(), "Starting git fetch");
208
209 let tracking_branch = self
211 .run_git_output(
212 &["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"],
213 Some(repo_path),
214 )
215 .ok();
216 let before_upstream = tracking_branch.as_ref().and_then(|tracking| {
217 self.run_git_output(&["rev-parse", tracking], Some(repo_path))
218 .ok()
219 });
220
221 trace!(repo = %repo_path.display(), "Executing fetch --all --prune");
223 let output = self.run_git(&["fetch", "--all", "--prune"], Some(repo_path))?;
224
225 if !output.status.success() {
226 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
227 debug!(repo = %repo_path.display(), error = %stderr, "Fetch failed");
228 return Err(GitError::fetch_failed(repo_path, stderr));
229 }
230
231 let updated = if let (Some(before_ref), Some(tracking)) =
233 (before_upstream, tracking_branch.as_deref())
234 {
235 let after = self
236 .run_git_output(&["rev-parse", tracking], Some(repo_path))
237 .ok();
238 after.map(|a| a != before_ref).unwrap_or(false)
239 } else {
240 false
241 };
242
243 let new_commits = if updated {
245 self.run_git_output(&["rev-list", "--count", "HEAD..@{u}"], Some(repo_path))
246 .ok()
247 .and_then(|s| s.parse().ok())
248 } else {
249 Some(0)
250 };
251
252 debug!(
253 repo = %repo_path.display(),
254 updated,
255 new_commits = new_commits.unwrap_or(0),
256 "Fetch completed"
257 );
258
259 Ok(FetchResult {
260 updated,
261 new_commits,
262 })
263 }
264
265 fn pull(&self, repo_path: &Path) -> Result<PullResult, GitError> {
266 debug!(repo = %repo_path.display(), "Starting git pull");
267
268 let status = self.status(repo_path)?;
270
271 if status.is_uncommitted {
272 debug!(repo = %repo_path.display(), "Skipping pull: uncommitted changes");
273 return Ok(PullResult {
274 success: false,
275 updated: false,
276 fast_forward: false,
277 error: Some("Working tree has uncommitted changes".to_string()),
278 });
279 }
280
281 trace!(repo = %repo_path.display(), "Executing pull --ff-only");
283 let output = self.run_git(&["pull", "--ff-only"], Some(repo_path))?;
284
285 if output.status.success() {
286 let stdout = String::from_utf8_lossy(&output.stdout);
287 let updated = !stdout.contains("Already up to date");
288 let fast_forward =
289 stdout.contains("Fast-forward") || stdout.contains("Already up to date");
290
291 debug!(
292 repo = %repo_path.display(),
293 updated,
294 fast_forward,
295 "Pull completed successfully"
296 );
297
298 Ok(PullResult {
299 success: true,
300 updated,
301 fast_forward,
302 error: None,
303 })
304 } else {
305 let stderr = String::from_utf8_lossy(&output.stderr).to_string();
306
307 if stderr.contains("Not possible to fast-forward") {
309 debug!(repo = %repo_path.display(), "Pull failed: branch has diverged");
310 Ok(PullResult {
311 success: false,
312 updated: false,
313 fast_forward: false,
314 error: Some("Cannot fast-forward, local branch has diverged".to_string()),
315 })
316 } else {
317 debug!(repo = %repo_path.display(), error = %stderr, "Pull failed");
318 Err(GitError::pull_failed(repo_path, stderr))
319 }
320 }
321 }
322
323 fn status(&self, repo_path: &Path) -> Result<RepoStatus, GitError> {
324 let branch_output =
326 self.run_git_output(&["status", "-b", "--porcelain"], Some(repo_path))?;
327
328 let status_output = self.run_git_output(&["status", "--porcelain"], Some(repo_path))?;
330
331 Ok(self.parse_status_output(&status_output, &branch_output))
332 }
333
334 fn is_repo(&self, path: &Path) -> bool {
335 if !path.exists() {
336 return false;
337 }
338
339 self.run_git_check(&["rev-parse", "--git-dir"], Some(path))
340 }
341
342 fn current_branch(&self, repo_path: &Path) -> Result<String, GitError> {
343 self.run_git_output(&["rev-parse", "--abbrev-ref", "HEAD"], Some(repo_path))
344 }
345
346 fn remote_url(&self, repo_path: &Path, remote: &str) -> Result<String, GitError> {
347 self.run_git_output(&["remote", "get-url", remote], Some(repo_path))
348 }
349
350 fn recent_commits(&self, repo_path: &Path, limit: usize) -> Result<Vec<String>, GitError> {
351 let limit_arg = format!("-{}", limit);
352 let output = self.run_git_output(&["log", "--oneline", &limit_arg], Some(repo_path))?;
353 if output.is_empty() {
354 return Ok(Vec::new());
355 }
356 Ok(output.lines().map(|l| l.to_string()).collect())
357 }
358}
359
360#[cfg(test)]
361#[path = "shell_tests.rs"]
362mod tests;