Skip to main content

xianyun_backends/git/
mod.rs

1//! Git repository operations.
2//!
3//! Ported from `internal/backends/git/{interface,client,errors}.go`.
4//! `state.rs` carries the rest of `Client`'s methods (ported from
5//! `state.go`), split the same way the Go package splits
6//! `client.go`/`state.go`.
7
8pub mod state;
9
10use std::path::Path;
11use std::sync::Arc;
12
13use xianyun_executor::exec::{CommandRunner, ExecError};
14
15/// Ported from `Status` in `interface.go`.
16#[derive(Debug, Default, Clone, PartialEq, Eq)]
17pub struct Status {
18    pub branch: String,
19    pub revision: String,
20    pub has_uncommitted_changes: bool,
21    pub remote_url: String,
22}
23
24/// Ported from `RepositoryState` in `interface.go`.
25#[derive(Debug, Default)]
26pub struct RepositoryState {
27    pub exists: bool,
28    pub is_git_repo: bool,
29    /// Empty if detached.
30    pub current_branch: String,
31    pub is_detached: bool,
32    pub has_uncommitted: bool,
33    pub has_unpushed: bool,
34    /// e.g. "origin/main".
35    pub tracking_branch: String,
36    /// e.g. "main".
37    pub default_branch: String,
38    pub commits_behind: i64,
39    pub commits_ahead: i64,
40    pub remote_url: String,
41    pub remote_matches: bool,
42    pub last_error: Option<GitError>,
43}
44
45/// Ported from `errors.go`'s sentinel errors, plus the exec-layer error
46/// this crate's `Client` methods wrap. Go's `%w` wrapping of a sentinel
47/// alongside arbitrary formatted context becomes an explicit enum
48/// variant per call site; see individual `Client` methods in Phase 4.
49#[derive(Debug, thiserror::Error)]
50pub enum GitError {
51    /// Ported from `ErrInvalidCloneStrategy`.
52    #[error("invalid clone strategy: {0}")]
53    InvalidCloneStrategy(String),
54
55    /// Ported from `ErrDirectoryNotGitRepo`.
56    #[error("directory exists but is not a git repository: {0}")]
57    DirectoryNotGitRepo(String),
58
59    /// Ported from `ErrNoGitOutput`.
60    #[error("no output from git command")]
61    NoGitOutput,
62
63    /// Ported from wrapped `%w` execution errors (`result.Error`), plus
64    /// `result.CombinedOutput()` -- Go's error message is
65    /// `fmt.Errorf("...: %w: %s", result.Error, result.CombinedOutput())`,
66    /// so the combined stdout/stderr is part of the message a caller sees,
67    /// not just the exec-layer error. Empty when the command produced no
68    /// output before failing (e.g. the binary itself was missing).
69    #[error("{0}: {1}")]
70    Exec(ExecError, String),
71
72    /// Ported from ad hoc `fmt.Errorf` wraps with no sentinel (e.g.
73    /// "creating parent directory: %w" over an `os.MkdirAll` I/O error).
74    #[error("{0}")]
75    Other(String),
76}
77
78/// For call sites with only an `ExecError` and no `ExecResult` to pull
79/// combined output from (e.g. `CommandRunner::check`, which returns a
80/// plain `bool`/`ExecError` with no captured stdout/stderr) -- the
81/// `?`-operator path `is_git_repository` uses. `run_result_to_git_error`
82/// is the richer conversion for callers that do have an `ExecResult`.
83impl From<ExecError> for GitError {
84    fn from(err: ExecError) -> Self {
85        GitError::Exec(err, String::new())
86    }
87}
88
89/// Ported from the `Backend` interface in `interface.go`.
90pub trait Backend {
91    /// Clones the repository to `directory`. If the directory already
92    /// exists and contains a valid clone, this is a no-op.
93    fn clone(&self, repo_url: &str, directory: &str) -> Result<(), GitError>;
94
95    /// Fetches and merges the latest changes from the remote.
96    fn pull(&self, directory: &str) -> Result<(), GitError>;
97
98    /// Returns the current git revision (commit SHA).
99    fn get_revision(&self, directory: &str) -> Result<String, GitError>;
100
101    /// Returns the current git status.
102    fn get_status(&self, directory: &str) -> Result<Status, GitError>;
103
104    /// Ensures the repository is cloned based on the clone strategy
105    /// (`"always"` or `"on-demand"`/empty).
106    fn ensure_cloned(
107        &self,
108        repo_url: &str,
109        directory: &str,
110        clone_strategy: &str,
111    ) -> Result<(), GitError>;
112
113    /// Checks if the directory is a valid git repository.
114    fn is_git_repository(&self, directory: &str) -> Result<bool, GitError>;
115
116    /// Returns the current branch name, or empty if detached.
117    fn get_current_branch(&self, directory: &str) -> Result<String, GitError>;
118
119    /// Checks if HEAD is detached.
120    fn is_detached(&self, directory: &str) -> Result<bool, GitError>;
121
122    /// Returns the upstream tracking branch (e.g. "origin/main"), or
123    /// empty if none is set.
124    fn get_tracking_branch(&self, directory: &str) -> Result<String, GitError>;
125
126    /// Detects the default branch from the remote or `origin/HEAD`.
127    fn get_default_branch(&self, directory: &str, repo_url: &str) -> Result<String, GitError>;
128
129    /// Returns `(behind, ahead)` counts vs the tracking branch.
130    fn get_commit_counts(&self, directory: &str) -> Result<(i64, i64), GitError>;
131
132    /// Detects the complete state of a repository. Fail-safe: continues
133    /// collecting state even if some checks fail.
134    fn detect_state(
135        &self,
136        directory: &str,
137        configured_url: &str,
138    ) -> Result<RepositoryState, GitError>;
139
140    /// Fast-forwards the current branch to its upstream; callers must
141    /// fetch first.
142    fn merge_ff_only(&self, directory: &str) -> Result<(), GitError>;
143
144    /// Fetches a single ref from the given remote without merging; an
145    /// empty ref fetches all branches.
146    fn fetch(&self, directory: &str, remote: &str, ref_: &str) -> Result<(), GitError>;
147
148    /// Checks out the given ref (commit SHA, tag, or branch).
149    fn checkout(&self, directory: &str, ref_: &str) -> Result<(), GitError>;
150
151    /// Stashes uncommitted changes with an optional message.
152    fn stash_push(&self, directory: &str, message: &str) -> Result<(), GitError>;
153}
154
155/// Ported from `Client` in `client.go`.
156pub struct Client {
157    pub(crate) executor: Arc<dyn CommandRunner + Send + Sync>,
158}
159
160impl Client {
161    /// Ported from `NewClient`.
162    pub fn new(executor: Arc<dyn CommandRunner + Send + Sync>) -> Self {
163        Self { executor }
164    }
165}
166
167/// Maps a `Run`'s `ExecResult::error` (if present) into `GitError::Exec`,
168/// carrying `result.combined_output()` alongside it -- matching Go's
169/// `fmt.Errorf("...: %w: %s", result.Error, result.CombinedOutput())`.
170fn run_result_to_git_error(result: xianyun_executor::exec::ExecResult) -> Result<(), GitError> {
171    let output = result.combined_output();
172    match result.error {
173        Some(err) => Err(GitError::Exec(err, output)),
174        None => Ok(()),
175    }
176}
177
178impl Backend for Client {
179    fn clone(&self, repo_url: &str, directory: &str) -> Result<(), GitError> {
180        if Path::new(directory).exists() {
181            if self.is_git_repository(directory)? {
182                return Ok(());
183            }
184
185            return Err(GitError::DirectoryNotGitRepo(directory.to_string()));
186        }
187
188        if let Some(parent) = Path::new(directory).parent() {
189            std::fs::create_dir_all(parent)
190                .map_err(|err| GitError::Other(format!("creating parent directory: {err}")))?;
191        }
192
193        let result = self.executor.run(
194            "git",
195            &[
196                "clone".to_string(),
197                repo_url.to_string(),
198                directory.to_string(),
199            ],
200            None,
201        );
202
203        run_result_to_git_error(result)
204    }
205
206    fn pull(&self, directory: &str) -> Result<(), GitError> {
207        let result = self.executor.run(
208            "git",
209            &["-C".to_string(), directory.to_string(), "pull".to_string()],
210            None,
211        );
212
213        run_result_to_git_error(result)
214    }
215
216    fn get_revision(&self, directory: &str) -> Result<String, GitError> {
217        let out = self.executor.read_stdout(
218            "git",
219            &[
220                "-C".to_string(),
221                directory.to_string(),
222                "rev-parse".to_string(),
223                "HEAD".to_string(),
224            ],
225        )?;
226
227        let trimmed = out.trim().to_string();
228        if trimmed.is_empty() {
229            return Err(GitError::NoGitOutput);
230        }
231
232        Ok(trimmed)
233    }
234
235    fn get_status(&self, directory: &str) -> Result<Status, GitError> {
236        let mut status = Status::default();
237
238        if let Ok(branch) = self.executor.read_stdout(
239            "git",
240            &[
241                "-C".to_string(),
242                directory.to_string(),
243                "rev-parse".to_string(),
244                "--abbrev-ref".to_string(),
245                "HEAD".to_string(),
246            ],
247        ) && !branch.is_empty()
248        {
249            status.branch = branch.trim().to_string();
250        }
251
252        if let Ok(revision) = self.get_revision(directory) {
253            status.revision = revision;
254        }
255
256        if let Ok(remote) = self.executor.read_stdout(
257            "git",
258            &[
259                "-C".to_string(),
260                directory.to_string(),
261                "remote".to_string(),
262                "get-url".to_string(),
263                "origin".to_string(),
264            ],
265        ) && !remote.is_empty()
266        {
267            status.remote_url = remote.trim().to_string();
268        }
269
270        if let Ok(porcelain) = self.executor.read_stdout(
271            "git",
272            &[
273                "-C".to_string(),
274                directory.to_string(),
275                "status".to_string(),
276                "--porcelain".to_string(),
277            ],
278        ) {
279            status.has_uncommitted_changes = !porcelain.is_empty();
280        }
281
282        Ok(status)
283    }
284
285    fn ensure_cloned(
286        &self,
287        repo_url: &str,
288        directory: &str,
289        clone_strategy: &str,
290    ) -> Result<(), GitError> {
291        match clone_strategy {
292            "always" => {
293                if Path::new(directory).exists() {
294                    std::fs::remove_dir_all(directory).map_err(|err| {
295                        GitError::Other(format!("removing existing directory: {err}"))
296                    })?;
297                }
298
299                self.clone(repo_url, directory)
300            }
301            "on-demand" | "" => self.clone(repo_url, directory),
302            other => Err(GitError::InvalidCloneStrategy(other.to_string())),
303        }
304    }
305
306    fn is_git_repository(&self, directory: &str) -> Result<bool, GitError> {
307        Ok(self.executor.check(
308            "git",
309            &[
310                "-C".to_string(),
311                directory.to_string(),
312                "rev-parse".to_string(),
313                "--git-dir".to_string(),
314            ],
315        )?)
316    }
317
318    fn get_current_branch(&self, directory: &str) -> Result<String, GitError> {
319        // Soft probe: an exec error means detached HEAD, not a hard
320        // failure.
321        Ok(self
322            .executor
323            .read_stdout(
324                "git",
325                &[
326                    "-C".to_string(),
327                    directory.to_string(),
328                    "symbolic-ref".to_string(),
329                    "--short".to_string(),
330                    "HEAD".to_string(),
331                ],
332            )
333            .map(|out| out.trim().to_string())
334            .unwrap_or_default())
335    }
336
337    fn is_detached(&self, directory: &str) -> Result<bool, GitError> {
338        // Fail-safe: an exec error is reported as "not detached" rather
339        // than propagated.
340        let on_branch = self
341            .executor
342            .check(
343                "git",
344                &[
345                    "-C".to_string(),
346                    directory.to_string(),
347                    "symbolic-ref".to_string(),
348                    "-q".to_string(),
349                    "HEAD".to_string(),
350                ],
351            )
352            .unwrap_or(true);
353
354        Ok(!on_branch)
355    }
356
357    fn get_tracking_branch(&self, directory: &str) -> Result<String, GitError> {
358        // Soft probe: an exec error means no tracking branch is set.
359        Ok(self
360            .executor
361            .read_stdout(
362                "git",
363                &[
364                    "-C".to_string(),
365                    directory.to_string(),
366                    "rev-parse".to_string(),
367                    "--abbrev-ref".to_string(),
368                    "--symbolic-full-name".to_string(),
369                    "@{u}".to_string(),
370                ],
371            )
372            .map(|out| out.trim().to_string())
373            .unwrap_or_default())
374    }
375
376    fn get_default_branch(&self, directory: &str, repo_url: &str) -> Result<String, GitError> {
377        if let Some(branch) = self.default_branch_from_local(directory) {
378            return Ok(branch);
379        }
380
381        if !repo_url.is_empty()
382            && let Some(branch) = self.default_branch_from_remote(repo_url)
383        {
384            return Ok(branch);
385        }
386
387        Ok("main".to_string())
388    }
389
390    fn get_commit_counts(&self, directory: &str) -> Result<(i64, i64), GitError> {
391        let Ok(out) = self.executor.read_stdout(
392            "git",
393            &[
394                "-C".to_string(),
395                directory.to_string(),
396                "rev-list".to_string(),
397                "--count".to_string(),
398                "--left-right".to_string(),
399                "@{u}...HEAD".to_string(),
400            ],
401        ) else {
402            // Soft probe: no tracking branch means zero counts.
403            return Ok((0, 0));
404        };
405
406        let parts: Vec<&str> = out.split_whitespace().collect();
407        if parts.len() != 2 {
408            return Ok((0, 0));
409        }
410
411        let behind = parts[0]
412            .parse::<i64>()
413            .map_err(|err| GitError::Other(format!("parsing behind count: {err}")))?;
414        let ahead = parts[1]
415            .parse::<i64>()
416            .map_err(|err| GitError::Other(format!("parsing ahead count: {err}")))?;
417
418        Ok((behind, ahead))
419    }
420
421    fn detect_state(
422        &self,
423        directory: &str,
424        configured_url: &str,
425    ) -> Result<RepositoryState, GitError> {
426        let mut state = RepositoryState::default();
427
428        match std::fs::metadata(directory) {
429            Ok(_) => {}
430            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
431                state.exists = false;
432                return Ok(state);
433            }
434            Err(err) => {
435                let err = GitError::Other(format!("checking directory: {err}"));
436                state.last_error = Some(err);
437                // Fail-safe: return the partial state, not the error.
438                return Ok(state);
439            }
440        }
441
442        state.exists = true;
443
444        let is_git_repo = match self.is_git_repository(directory) {
445            Ok(is_git_repo) => is_git_repo,
446            Err(err) => {
447                state.last_error = Some(err);
448                return Ok(state);
449            }
450        };
451
452        state.is_git_repo = is_git_repo;
453
454        if !is_git_repo {
455            return Ok(state);
456        }
457
458        let status = match self.get_status(directory) {
459            Ok(status) => status,
460            Err(err) => {
461                state.last_error = Some(err);
462                return Ok(state);
463            }
464        };
465
466        state.remote_url = status.remote_url.clone();
467        state.remote_matches =
468            state::normalize_git_url(&state.remote_url) == state::normalize_git_url(configured_url);
469
470        if let Ok(is_detached) = self.is_detached(directory) {
471            state.is_detached = is_detached;
472        }
473
474        state.current_branch = status.branch.clone();
475        if state.current_branch == "HEAD" || state.is_detached {
476            state.current_branch = String::new();
477            state.is_detached = true;
478        }
479
480        state.has_uncommitted = status.has_uncommitted_changes;
481
482        if let Ok(tracking_branch) = self.get_tracking_branch(directory) {
483            state.tracking_branch = tracking_branch;
484        }
485
486        if let Ok((behind, ahead)) = self.get_commit_counts(directory) {
487            state.commits_behind = behind;
488            state.commits_ahead = ahead;
489            state.has_unpushed = ahead > 0;
490        }
491
492        if let Ok(default_branch) = self.get_default_branch(directory, configured_url) {
493            state.default_branch = default_branch;
494        }
495
496        Ok(state)
497    }
498
499    fn merge_ff_only(&self, directory: &str) -> Result<(), GitError> {
500        let result = self.executor.run(
501            "git",
502            &[
503                "-C".to_string(),
504                directory.to_string(),
505                "merge".to_string(),
506                "--ff-only".to_string(),
507                "@{u}".to_string(),
508            ],
509            None,
510        );
511
512        run_result_to_git_error(result)
513    }
514
515    fn fetch(&self, directory: &str, remote: &str, ref_: &str) -> Result<(), GitError> {
516        let mut args = vec![
517            "-C".to_string(),
518            directory.to_string(),
519            "fetch".to_string(),
520            "--no-tags".to_string(),
521            remote.to_string(),
522        ];
523        if !ref_.is_empty() {
524            args.push(ref_.to_string());
525        }
526
527        let result = self.executor.run("git", &args, None);
528
529        run_result_to_git_error(result)
530    }
531
532    fn checkout(&self, directory: &str, ref_: &str) -> Result<(), GitError> {
533        let result = self.executor.run(
534            "git",
535            &[
536                "-C".to_string(),
537                directory.to_string(),
538                "checkout".to_string(),
539                ref_.to_string(),
540            ],
541            None,
542        );
543
544        run_result_to_git_error(result)
545    }
546
547    fn stash_push(&self, directory: &str, message: &str) -> Result<(), GitError> {
548        let mut args = vec![
549            "-C".to_string(),
550            directory.to_string(),
551            "stash".to_string(),
552            "push".to_string(),
553        ];
554        if !message.is_empty() {
555            args.push("-m".to_string());
556            args.push(message.to_string());
557        }
558
559        let result = self.executor.run("git", &args, None);
560
561        run_result_to_git_error(result)
562    }
563}
564
565impl Client {
566    /// Ported from `getDefaultBranchFromLocal`.
567    fn default_branch_from_local(&self, directory: &str) -> Option<String> {
568        let out = self
569            .executor
570            .read_stdout(
571                "git",
572                &[
573                    "-C".to_string(),
574                    directory.to_string(),
575                    "symbolic-ref".to_string(),
576                    "refs/remotes/origin/HEAD".to_string(),
577                    "--short".to_string(),
578                ],
579            )
580            .ok()?;
581
582        let full_ref = out.trim();
583        let parts: Vec<&str> = full_ref.split('/').collect();
584        if parts.len() >= 2 {
585            return Some((*parts.last().unwrap()).to_string());
586        }
587
588        None
589    }
590
591    /// Ported from `getDefaultBranchFromRemote`.
592    fn default_branch_from_remote(&self, repo_url: &str) -> Option<String> {
593        let out = self
594            .executor
595            .read_stdout(
596                "git",
597                &[
598                    "ls-remote".to_string(),
599                    "--symref".to_string(),
600                    repo_url.to_string(),
601                    "HEAD".to_string(),
602                ],
603            )
604            .ok()?;
605
606        let first_line = out.lines().next()?.trim();
607        if !first_line.starts_with("ref:") {
608            return None;
609        }
610
611        let parts: Vec<&str> = first_line.split_whitespace().collect();
612        if parts.len() < 2 {
613            return None;
614        }
615
616        let ref_parts: Vec<&str> = parts[1].split('/').collect();
617        if ref_parts.len() >= 3 {
618            return Some((*ref_parts.last().unwrap()).to_string());
619        }
620
621        None
622    }
623}
624
625#[cfg(test)]
626mod tests {
627    use super::*;
628    use xianyun_executor::exec::ExecResult;
629    use xianyun_testutil::MockCommandRunner;
630
631    // --- Ported from client_test.go ---
632
633    #[test]
634    fn is_git_repository_true() {
635        let mock = MockCommandRunner::new();
636        mock.expect_check(
637            "git",
638            &["-C", "/some/dir", "rev-parse", "--git-dir"],
639            Ok(true),
640        );
641
642        let client = Client::new(Arc::new(mock));
643
644        let is_repo = client.is_git_repository("/some/dir").unwrap();
645        assert!(is_repo);
646    }
647
648    #[test]
649    fn is_git_repository_false() {
650        let mock = MockCommandRunner::new();
651        mock.expect_check(
652            "git",
653            &["-C", "/some/dir", "rev-parse", "--git-dir"],
654            Ok(false),
655        );
656
657        let client = Client::new(Arc::new(mock));
658
659        let is_repo = client.is_git_repository("/some/dir").unwrap();
660        assert!(!is_repo);
661    }
662
663    #[test]
664    fn is_git_repository_exec_error() {
665        let mock = MockCommandRunner::new();
666        mock.expect_check(
667            "git",
668            &["-C", "/some/dir", "rev-parse", "--git-dir"],
669            Err(ExecError::RunFailed("binary missing".to_string())),
670        );
671
672        let client = Client::new(Arc::new(mock));
673
674        let err = client.is_git_repository("/some/dir").unwrap_err();
675        assert!(matches!(err, GitError::Exec(ExecError::RunFailed(_), _)));
676    }
677
678    #[test]
679    fn is_detached_on_branch() {
680        let mock = MockCommandRunner::new();
681        mock.expect_check(
682            "git",
683            &["-C", "/some/dir", "symbolic-ref", "-q", "HEAD"],
684            Ok(true),
685        );
686
687        let client = Client::new(Arc::new(mock));
688
689        let detached = client.is_detached("/some/dir").unwrap();
690        assert!(!detached);
691    }
692
693    #[test]
694    fn is_detached_detached() {
695        let mock = MockCommandRunner::new();
696        mock.expect_check(
697            "git",
698            &["-C", "/some/dir", "symbolic-ref", "-q", "HEAD"],
699            Ok(false),
700        );
701
702        let client = Client::new(Arc::new(mock));
703
704        let detached = client.is_detached("/some/dir").unwrap();
705        assert!(detached);
706    }
707
708    #[test]
709    fn is_detached_exec_error_is_fail_safe() {
710        let mock = MockCommandRunner::new();
711        mock.expect_check(
712            "git",
713            &["-C", "/some/dir", "symbolic-ref", "-q", "HEAD"],
714            Err(ExecError::RunFailed("binary missing".to_string())),
715        );
716
717        let client = Client::new(Arc::new(mock));
718
719        // Soft probe: an exec error is swallowed and reported as "not detached".
720        let detached = client.is_detached("/some/dir").unwrap();
721        assert!(!detached);
722    }
723
724    #[test]
725    fn checkout_success() {
726        let mock = MockCommandRunner::new();
727        mock.expect_run(
728            "git",
729            &["-C", "/some/dir", "checkout", "abc123"],
730            ExecResult::default(),
731        );
732
733        let client = Client::new(Arc::new(mock));
734
735        client.checkout("/some/dir", "abc123").unwrap();
736    }
737
738    #[test]
739    fn checkout_error() {
740        let mock = MockCommandRunner::new();
741        mock.expect_run(
742            "git",
743            &["-C", "/some/dir", "checkout", "abc123"],
744            ExecResult {
745                error: Some(ExecError::RunFailed("binary missing".to_string())),
746                ..Default::default()
747            },
748        );
749
750        let client = Client::new(Arc::new(mock));
751
752        let err = client.checkout("/some/dir", "abc123").unwrap_err();
753        assert!(matches!(err, GitError::Exec(ExecError::RunFailed(_), _)));
754    }
755
756    /// New coverage: ported from Go's `fmt.Errorf("...: %w: %s",
757    /// result.Error, result.CombinedOutput())` -- the combined stdout/
758    /// stderr git itself printed before failing must be part of the
759    /// error message a caller sees, not just the exec-layer error type.
760    /// No direct Go test asserts the message text (Go tests mostly check
761    /// `errors.Is`/`require.ErrorContains` on the sentinel, not the
762    /// appended output), but the behavior itself is real Go behavior this
763    /// port was silently missing until now.
764    #[test]
765    fn checkout_error_includes_combined_output_in_the_message() {
766        let mock = MockCommandRunner::new();
767        mock.expect_run(
768            "git",
769            &["-C", "/some/dir", "checkout", "abc123"],
770            ExecResult {
771                error: Some(ExecError::RunFailed("exit status 1".to_string())),
772                stdout: vec!["Switched to branch 'abc123'".to_string()],
773                stderr: vec!["error: pathspec 'abc123' did not match".to_string()],
774                ..Default::default()
775            },
776        );
777
778        let client = Client::new(Arc::new(mock));
779
780        let err = client.checkout("/some/dir", "abc123").unwrap_err();
781        let message = err.to_string();
782        assert!(
783            message.contains("pathspec 'abc123' did not match"),
784            "expected the combined output in the error message, got: {message:?}"
785        );
786        assert!(
787            message.contains("Switched to branch"),
788            "expected stdout to also be included: {message:?}"
789        );
790    }
791
792    /// New coverage: when the command never produced any output (e.g. the
793    /// `git` binary itself is missing), the message degrades to just the
794    /// exec-layer error, matching Go's `CombinedOutput()` returning an
795    /// empty string in that case.
796    #[test]
797    fn checkout_error_with_no_output_omits_trailing_colon_text() {
798        let mock = MockCommandRunner::new();
799        mock.expect_run(
800            "git",
801            &["-C", "/some/dir", "checkout", "abc123"],
802            ExecResult {
803                error: Some(ExecError::RunFailed("binary missing".to_string())),
804                ..Default::default()
805            },
806        );
807
808        let client = Client::new(Arc::new(mock));
809
810        let err = client.checkout("/some/dir", "abc123").unwrap_err();
811        assert_eq!(err.to_string(), "running command: binary missing: ");
812    }
813
814    struct FetchCase {
815        name: &'static str,
816        remote: &'static str,
817        ref_: &'static str,
818        want_args: &'static [&'static str],
819        exec_err: bool,
820    }
821
822    #[test]
823    fn fetch_cases() {
824        let cases = [
825            FetchCase {
826                name: "scoped ref",
827                remote: "origin",
828                ref_: "main",
829                want_args: &["-C", "/some/dir", "fetch", "--no-tags", "origin", "main"],
830                exec_err: false,
831            },
832            FetchCase {
833                name: "empty ref fetches all branches",
834                remote: "origin",
835                ref_: "",
836                want_args: &["-C", "/some/dir", "fetch", "--no-tags", "origin"],
837                exec_err: false,
838            },
839            FetchCase {
840                name: "non-origin remote",
841                remote: "upstream",
842                ref_: "main",
843                want_args: &["-C", "/some/dir", "fetch", "--no-tags", "upstream", "main"],
844                exec_err: false,
845            },
846            FetchCase {
847                name: "error propagated",
848                remote: "origin",
849                ref_: "main",
850                want_args: &["-C", "/some/dir", "fetch", "--no-tags", "origin", "main"],
851                exec_err: true,
852            },
853        ];
854
855        for case in cases {
856            let mock = MockCommandRunner::new();
857            mock.expect_run(
858                "git",
859                case.want_args,
860                ExecResult {
861                    error: case
862                        .exec_err
863                        .then(|| ExecError::RunFailed("binary missing".to_string())),
864                    ..Default::default()
865                },
866            );
867
868            let client = Client::new(Arc::new(mock));
869
870            let result = client.fetch("/some/dir", case.remote, case.ref_);
871            if case.exec_err {
872                assert!(result.is_err(), "{}", case.name);
873            } else {
874                assert!(result.is_ok(), "{}", case.name);
875            }
876        }
877    }
878
879    #[test]
880    fn merge_ff_only_success() {
881        let mock = MockCommandRunner::new();
882        mock.expect_run(
883            "git",
884            &["-C", "/some/dir", "merge", "--ff-only", "@{u}"],
885            ExecResult::default(),
886        );
887
888        let client = Client::new(Arc::new(mock));
889
890        client.merge_ff_only("/some/dir").unwrap();
891    }
892
893    #[test]
894    fn merge_ff_only_error() {
895        let mock = MockCommandRunner::new();
896        mock.expect_run(
897            "git",
898            &["-C", "/some/dir", "merge", "--ff-only", "@{u}"],
899            ExecResult {
900                error: Some(ExecError::RunFailed("binary missing".to_string())),
901                ..Default::default()
902            },
903        );
904
905        let client = Client::new(Arc::new(mock));
906
907        let err = client.merge_ff_only("/some/dir").unwrap_err();
908        assert!(matches!(err, GitError::Exec(ExecError::RunFailed(_), _)));
909    }
910}