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