Skip to main content

batty_cli/team/
git_cmd.rs

1#![allow(dead_code)]
2
3use std::path::{Path, PathBuf};
4use std::process::Command;
5
6pub use super::errors::GitError;
7
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct GitOutput {
10    pub stdout: String,
11    pub stderr: String,
12}
13
14fn classify_error(stderr: &str) -> GitError {
15    let message = stderr.trim().to_string();
16    let lowered = stderr.to_ascii_lowercase();
17
18    if lowered.contains("lock")
19        || lowered.contains("index.lock")
20        || lowered.contains("unable to create")
21        || lowered.contains("connection refused")
22        || lowered.contains("timeout")
23        || lowered.contains("could not read")
24        || lowered.contains("resource temporarily unavailable")
25    {
26        GitError::Transient {
27            message,
28            stderr: stderr.to_string(),
29        }
30    } else {
31        GitError::Permanent {
32            message,
33            stderr: stderr.to_string(),
34        }
35    }
36}
37
38fn format_git_command(repo_dir: &Path, args: &[&str]) -> String {
39    let mut parts = vec![
40        "git".to_string(),
41        "-C".to_string(),
42        repo_dir.display().to_string(),
43    ];
44    parts.extend(args.iter().map(|arg| arg.to_string()));
45    parts.join(" ")
46}
47
48fn git_program() -> &'static str {
49    for program in ["git", "/usr/bin/git", "/opt/homebrew/bin/git"] {
50        if std::process::Command::new(program)
51            .arg("--version")
52            .output()
53            .is_ok()
54        {
55            return program;
56        }
57    }
58    "git"
59}
60
61fn run_git_with_status(repo_dir: &Path, args: &[&str]) -> Result<std::process::Output, GitError> {
62    Command::new(git_program())
63        .arg("-C")
64        .arg(repo_dir)
65        .args(args)
66        .output()
67        .map_err(|source| GitError::Exec {
68            command: format_git_command(repo_dir, args),
69            source,
70        })
71}
72
73/// Check whether `path` is inside a git work tree.
74pub fn is_git_repo(path: &Path) -> bool {
75    Command::new(git_program())
76        .arg("-C")
77        .arg(path)
78        .args(["rev-parse", "--is-inside-work-tree"])
79        .output()
80        .map(|o| o.status.success())
81        .unwrap_or(false)
82}
83
84/// Discover immediate child directories that are git repositories.
85/// Returns an empty vec if `path` is itself a git repo or has no git children.
86pub fn discover_sub_repos(path: &Path) -> Vec<PathBuf> {
87    let entries = match std::fs::read_dir(path) {
88        Ok(e) => e,
89        Err(_) => return Vec::new(),
90    };
91    let mut repos: Vec<PathBuf> = entries
92        .filter_map(|e| e.ok())
93        .filter(|e| e.file_type().map(|ft| ft.is_dir()).unwrap_or(false))
94        .map(|e| e.path())
95        .filter(|p| {
96            let name = p.file_name().unwrap_or_default().to_string_lossy();
97            !name.starts_with('.') && is_git_repo(p)
98        })
99        .collect();
100    repos.sort();
101    repos
102}
103
104pub fn run_git(repo_dir: &Path, args: &[&str]) -> Result<GitOutput, GitError> {
105    let output = run_git_with_status(repo_dir, args)?;
106    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
107    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
108
109    if output.status.success() {
110        Ok(GitOutput { stdout, stderr })
111    } else {
112        Err(classify_error(&stderr))
113    }
114}
115
116pub fn worktree_add(
117    repo: &Path,
118    path: &Path,
119    branch: &str,
120    start: &str,
121) -> Result<GitOutput, GitError> {
122    let path = path.to_string_lossy();
123    run_git(
124        repo,
125        &["worktree", "add", "-b", branch, path.as_ref(), start],
126    )
127}
128
129pub fn worktree_remove(repo: &Path, path: &Path, force: bool) -> Result<(), GitError> {
130    let path = path.to_string_lossy();
131    if force {
132        run_git(repo, &["worktree", "remove", "--force", path.as_ref()])?;
133    } else {
134        run_git(repo, &["worktree", "remove", path.as_ref()])?;
135    }
136    Ok(())
137}
138
139pub fn worktree_list(repo: &Path) -> Result<String, GitError> {
140    Ok(run_git(repo, &["worktree", "list", "--porcelain"])?.stdout)
141}
142
143pub fn rebase(repo: &Path, onto: &str) -> Result<(), GitError> {
144    run_git(repo, &["rebase", onto])
145        .map(|_| ())
146        .map_err(|error| match error {
147            GitError::Transient { .. } | GitError::Exec { .. } => error,
148            GitError::Permanent { stderr, .. } => GitError::RebaseFailed {
149                branch: onto.to_string(),
150                stderr,
151            },
152            GitError::RebaseFailed { .. }
153            | GitError::MergeFailed { .. }
154            | GitError::RevParseFailed { .. }
155            | GitError::InvalidRevListCount { .. } => error,
156        })
157}
158
159pub fn rebase_abort(repo: &Path) -> Result<(), GitError> {
160    run_git(repo, &["rebase", "--abort"])?;
161    Ok(())
162}
163
164pub fn merge(repo: &Path, branch: &str) -> Result<(), GitError> {
165    run_git(repo, &["merge", branch, "--no-edit"])
166        .map(|_| ())
167        .map_err(|error| match error {
168            GitError::Transient { .. } | GitError::Exec { .. } => error,
169            GitError::Permanent { stderr, .. } => GitError::MergeFailed {
170                branch: branch.to_string(),
171                stderr,
172            },
173            GitError::RebaseFailed { .. }
174            | GitError::MergeFailed { .. }
175            | GitError::RevParseFailed { .. }
176            | GitError::InvalidRevListCount { .. } => error,
177        })
178}
179
180pub fn merge_base_is_ancestor(repo: &Path, commit: &str, base: &str) -> Result<bool, GitError> {
181    let output = run_git_with_status(repo, &["merge-base", "--is-ancestor", commit, base])?;
182    match output.status.code() {
183        Some(0) => Ok(true),
184        Some(1) => Ok(false),
185        _ => Err(classify_error(&String::from_utf8_lossy(&output.stderr))),
186    }
187}
188
189pub fn rev_parse_branch(repo: &Path) -> Result<String, GitError> {
190    run_git(repo, &["rev-parse", "--abbrev-ref", "HEAD"])
191        .map(|output| output.stdout.trim().to_string())
192        .map_err(|error| match error {
193            GitError::Transient { .. } | GitError::Exec { .. } => error,
194            GitError::Permanent { stderr, .. } => GitError::RevParseFailed {
195                spec: "--abbrev-ref HEAD".to_string(),
196                stderr,
197            },
198            GitError::RebaseFailed { .. }
199            | GitError::MergeFailed { .. }
200            | GitError::RevParseFailed { .. }
201            | GitError::InvalidRevListCount { .. } => error,
202        })
203}
204
205pub fn rev_parse_toplevel(repo: &Path) -> Result<PathBuf, GitError> {
206    Ok(PathBuf::from(
207        run_git(repo, &["rev-parse", "--show-toplevel"])?
208            .stdout
209            .trim(),
210    ))
211}
212
213pub fn status_porcelain(repo: &Path) -> Result<String, GitError> {
214    Ok(run_git(repo, &["status", "--porcelain"])?.stdout)
215}
216
217pub fn checkout_new_branch(repo: &Path, branch: &str, start: &str) -> Result<(), GitError> {
218    run_git(repo, &["checkout", "-B", branch, start])?;
219    Ok(())
220}
221
222pub fn show_ref_exists(repo: &Path, branch: &str) -> Result<bool, GitError> {
223    let ref_name = format!("refs/heads/{branch}");
224    let output = run_git_with_status(repo, &["show-ref", "--verify", "--quiet", &ref_name])?;
225    match output.status.code() {
226        Some(0) => Ok(true),
227        Some(1) => Ok(false),
228        _ => Err(classify_error(&String::from_utf8_lossy(&output.stderr))),
229    }
230}
231
232pub fn branch_delete(repo: &Path, branch: &str) -> Result<(), GitError> {
233    run_git(repo, &["branch", "-D", branch])?;
234    Ok(())
235}
236
237pub fn branch_rename(repo: &Path, old: &str, new: &str) -> Result<(), GitError> {
238    run_git(repo, &["branch", "-m", old, new])?;
239    Ok(())
240}
241
242pub fn rev_list_count(repo: &Path, range: &str) -> Result<u32, GitError> {
243    let output = run_git(repo, &["rev-list", "--count", range])?;
244    let count = output
245        .stdout
246        .trim()
247        .parse()
248        .map_err(|_| GitError::InvalidRevListCount {
249            range: range.to_string(),
250            output: output.stdout.trim().to_string(),
251        })?;
252    Ok(count)
253}
254
255pub fn for_each_ref_branches(repo: &Path) -> Result<Vec<String>, GitError> {
256    Ok(run_git(
257        repo,
258        &["for-each-ref", "--format=%(refname:short)", "refs/heads"],
259    )?
260    .stdout
261    .lines()
262    .map(str::trim)
263    .filter(|line| !line.is_empty())
264    .map(ToOwned::to_owned)
265    .collect())
266}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271    use tempfile::TempDir;
272
273    fn git_ok(repo: &Path, args: &[&str]) {
274        let output = Command::new("git")
275            .arg("-C")
276            .arg(repo)
277            .args(args)
278            .output()
279            .unwrap_or_else(|error| panic!("git {:?} failed to run: {error}", args));
280        assert!(
281            output.status.success(),
282            "git {:?} failed: stdout={} stderr={}",
283            args,
284            String::from_utf8_lossy(&output.stdout),
285            String::from_utf8_lossy(&output.stderr)
286        );
287    }
288
289    fn init_repo() -> TempDir {
290        let tmp = tempfile::tempdir().unwrap();
291        let repo = tmp.path();
292        git_ok(repo, &["init", "-b", "main"]);
293        git_ok(repo, &["config", "user.email", "batty-test@example.com"]);
294        git_ok(repo, &["config", "user.name", "Batty Test"]);
295        std::fs::write(repo.join("README.md"), "hello\n").unwrap();
296        git_ok(repo, &["add", "README.md"]);
297        git_ok(repo, &["commit", "-m", "initial"]);
298        tmp
299    }
300
301    #[test]
302    fn classify_error_marks_transient_stderr() {
303        let error = classify_error("Unable to create '/tmp/repo/.git/index.lock': File exists");
304        assert!(matches!(error, GitError::Transient { .. }));
305        assert!(error.is_transient());
306    }
307
308    #[test]
309    fn classify_error_marks_permanent_stderr() {
310        let error = classify_error("fatal: not a git repository");
311        assert!(matches!(error, GitError::Permanent { .. }));
312        assert!(!error.is_transient());
313    }
314
315    #[test]
316    fn run_git_succeeds_for_valid_command() {
317        let tmp = init_repo();
318        let output = run_git(tmp.path(), &["rev-parse", "--show-toplevel"]).unwrap();
319        let actual = PathBuf::from(output.stdout.trim()).canonicalize().unwrap();
320        let expected = tmp.path().canonicalize().unwrap();
321        assert_eq!(actual, expected);
322        assert!(output.stderr.is_empty());
323    }
324
325    #[test]
326    fn run_git_invalid_args_return_permanent_error() {
327        let tmp = init_repo();
328        let error = run_git(tmp.path(), &["not-a-real-subcommand"]).unwrap_err();
329        assert!(matches!(error, GitError::Permanent { .. }));
330        assert!(!error.is_transient());
331    }
332
333    #[test]
334    fn is_transient_matches_variants() {
335        let transient = GitError::Transient {
336            message: "temporary lock".to_string(),
337            stderr: "index.lock".to_string(),
338        };
339        let permanent = GitError::Permanent {
340            message: "bad ref".to_string(),
341            stderr: "fatal: bad revision".to_string(),
342        };
343        let exec = GitError::Exec {
344            command: "git status --porcelain".to_string(),
345            source: std::io::Error::other("missing git"),
346        };
347
348        assert!(transient.is_transient());
349        assert!(!permanent.is_transient());
350        assert!(!exec.is_transient());
351        assert!(exec.to_string().contains("git status --porcelain"));
352    }
353
354    #[test]
355    fn non_git_dir_returns_false() {
356        let tmp = tempfile::tempdir().unwrap();
357        assert!(!is_git_repo(tmp.path()));
358    }
359
360    #[test]
361    fn git_initialized_dir_returns_true() {
362        let tmp = tempfile::tempdir().unwrap();
363        std::process::Command::new("git")
364            .args(["init"])
365            .current_dir(tmp.path())
366            .output()
367            .unwrap();
368        assert!(is_git_repo(tmp.path()));
369    }
370
371    // --- Error path and recovery tests (Task #265) ---
372
373    #[test]
374    fn classify_error_connection_refused_is_transient() {
375        let error = classify_error("fatal: unable to access: Connection refused");
376        assert!(matches!(error, GitError::Transient { .. }));
377    }
378
379    #[test]
380    fn classify_error_timeout_is_transient() {
381        let error = classify_error("fatal: unable to access: Timeout was reached");
382        assert!(matches!(error, GitError::Transient { .. }));
383    }
384
385    #[test]
386    fn classify_error_resource_unavailable_is_transient() {
387        let error = classify_error("error: resource temporarily unavailable");
388        assert!(matches!(error, GitError::Transient { .. }));
389    }
390
391    #[test]
392    fn classify_error_could_not_read_is_transient() {
393        let error = classify_error("fatal: could not read from remote repository");
394        assert!(matches!(error, GitError::Transient { .. }));
395    }
396
397    #[test]
398    fn run_git_on_nonexistent_dir_returns_error() {
399        let error = run_git(Path::new("/tmp/__batty_nonexistent_dir__"), &["status"]).unwrap_err();
400        // Git fails with a permanent error when the dir doesn't exist
401        assert!(!error.is_transient());
402    }
403
404    #[test]
405    fn rev_parse_branch_on_non_git_dir_returns_error() {
406        let tmp = tempfile::tempdir().unwrap();
407        let error = rev_parse_branch(tmp.path()).unwrap_err();
408        assert!(matches!(
409            error,
410            GitError::Permanent { .. } | GitError::RevParseFailed { .. }
411        ));
412    }
413
414    #[test]
415    fn rev_parse_toplevel_on_non_git_dir_returns_error() {
416        let tmp = tempfile::tempdir().unwrap();
417        let error = rev_parse_toplevel(tmp.path()).unwrap_err();
418        assert!(!error.is_transient());
419    }
420
421    #[test]
422    fn status_porcelain_on_non_git_dir_returns_error() {
423        let tmp = tempfile::tempdir().unwrap();
424        let error = status_porcelain(tmp.path()).unwrap_err();
425        assert!(!error.is_transient());
426    }
427
428    #[test]
429    fn rebase_on_nonexistent_branch_returns_error() {
430        let tmp = init_repo();
431        let error = rebase(tmp.path(), "nonexistent-branch-xyz").unwrap_err();
432        assert!(matches!(
433            error,
434            GitError::RebaseFailed { .. } | GitError::Permanent { .. }
435        ));
436    }
437
438    #[test]
439    fn merge_nonexistent_branch_returns_merge_failed() {
440        let tmp = init_repo();
441        let error = merge(tmp.path(), "nonexistent-branch-xyz").unwrap_err();
442        assert!(matches!(
443            error,
444            GitError::MergeFailed { .. } | GitError::Permanent { .. }
445        ));
446    }
447
448    #[test]
449    fn checkout_new_branch_invalid_start_returns_error() {
450        let tmp = init_repo();
451        let error = checkout_new_branch(tmp.path(), "test-branch", "nonexistent-ref").unwrap_err();
452        assert!(!error.is_transient());
453    }
454
455    #[test]
456    fn rev_list_count_invalid_range_returns_error() {
457        let tmp = init_repo();
458        let error = rev_list_count(tmp.path(), "nonexistent..also-nonexistent").unwrap_err();
459        assert!(!error.is_transient());
460    }
461
462    #[test]
463    fn worktree_add_duplicate_branch_returns_error() {
464        let tmp = init_repo();
465        let wt_path = tmp.path().join("worktree1");
466        // "main" branch already exists — worktree add with -b main should fail
467        let error = worktree_add(tmp.path(), &wt_path, "main", "HEAD").unwrap_err();
468        assert!(!error.is_transient());
469    }
470
471    #[test]
472    fn worktree_remove_nonexistent_path_returns_error() {
473        let tmp = init_repo();
474        let error =
475            worktree_remove(tmp.path(), Path::new("/tmp/__batty_no_wt__"), false).unwrap_err();
476        assert!(!error.is_transient());
477    }
478
479    #[test]
480    fn show_ref_exists_on_non_git_dir_returns_error() {
481        let tmp = tempfile::tempdir().unwrap();
482        let error = show_ref_exists(tmp.path(), "main").unwrap_err();
483        assert!(!error.is_transient());
484    }
485
486    #[test]
487    fn branch_delete_nonexistent_returns_error() {
488        let tmp = init_repo();
489        let error = branch_delete(tmp.path(), "nonexistent-branch-xyz").unwrap_err();
490        assert!(!error.is_transient());
491    }
492
493    #[test]
494    fn for_each_ref_branches_on_non_git_dir_returns_error() {
495        let tmp = tempfile::tempdir().unwrap();
496        let error = for_each_ref_branches(tmp.path()).unwrap_err();
497        assert!(!error.is_transient());
498    }
499
500    #[test]
501    fn rebase_abort_without_active_rebase_returns_error() {
502        let tmp = init_repo();
503        let error = rebase_abort(tmp.path()).unwrap_err();
504        assert!(!error.is_transient());
505    }
506
507    #[test]
508    fn merge_base_is_ancestor_invalid_commit_returns_error() {
509        let tmp = init_repo();
510        let error =
511            merge_base_is_ancestor(tmp.path(), "nonexistent-ref", "also-nonexistent").unwrap_err();
512        assert!(!error.is_transient());
513    }
514
515    #[test]
516    fn format_git_command_includes_repo_dir_and_args() {
517        let cmd = format_git_command(Path::new("/my/repo"), &["status", "--porcelain"]);
518        assert_eq!(cmd, "git -C /my/repo status --porcelain");
519    }
520
521    #[test]
522    fn worktree_list_on_non_git_dir_returns_error() {
523        let tmp = tempfile::tempdir().unwrap();
524        let error = worktree_list(tmp.path()).unwrap_err();
525        assert!(!error.is_transient());
526    }
527}