Skip to main content

batty_cli/team/
git_cmd.rs

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