Skip to main content

git_same/git/
shell.rs

1//! Shell-based git command implementation.
2//!
3//! This module provides the real implementation of git operations
4//! by invoking git commands through the shell.
5
6use 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/// Shell-based git operations.
13///
14/// This implementation executes git commands via the shell and parses their output.
15#[derive(Debug, Clone, Copy, Default)]
16pub struct ShellGit;
17
18impl ShellGit {
19    /// Creates a new ShellGit instance.
20    pub fn new() -> Self {
21        Self
22    }
23
24    /// Runs a git command and returns the output.
25    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        // Prevent git from prompting for credentials
34        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    /// Runs a git command and returns stdout as a string.
45    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    /// Checks if a git command succeeds.
60    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    /// Parses the porcelain status output.
67    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]; // index (staged) status
78            let y = bytes[1]; // working tree (unstaged) status
79
80            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        // Parse branch info from `git status -b --porcelain`
95        // Format: "## main...origin/main [ahead 1, behind 2]" or "## main"
96        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    /// Parses branch info from git status -b --porcelain output.
111    fn parse_branch_info(&self, output: &str) -> (String, u32, u32) {
112        let first_line = output.lines().next().unwrap_or("");
113
114        // Remove the "## " prefix
115        let line = first_line.strip_prefix("## ").unwrap_or(first_line);
116
117        // Split on "..." to get branch name and tracking info
118        let (branch_part, info_part): (&str, Option<&str>) = if let Some(idx) = line.find("...") {
119            (&line[..idx], Some(&line[idx + 3..]))
120        } else {
121            // No tracking branch, but might have [ahead X, behind Y] directly
122            // e.g., "## feature [ahead 1, behind 2]"
123            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        // Parse ahead/behind from info part
136        // Format: "origin/main [ahead 1, behind 2]" or "[ahead 1]" or "origin/main [ahead 1]"
137        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        // Add depth if specified
170        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        // Add branch if specified
178        if let Some(ref branch) = options.branch {
179            args.push("--branch");
180            args.push(branch);
181        }
182
183        // Add submodule recursion if requested
184        if options.recurse_submodules {
185            args.push("--recurse-submodules");
186        }
187
188        // Add URL and target
189        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        // Resolve upstream tracking ref and snapshot its commit before fetch.
210        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        // Run fetch
222        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        // Compare upstream commit before/after fetch to determine whether remote changed.
232        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        // Count new commits if updated
244        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        // First check status
269        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        // Try fast-forward only pull
282        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            // Check if it's a non-fast-forward situation
308            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        // Get status with branch info
325        let branch_output =
326            self.run_git_output(&["status", "-b", "--porcelain"], Some(repo_path))?;
327
328        // Get just the file status
329        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;