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