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