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
73pub 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
84pub 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 #[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 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 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}