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