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