1use std::borrow::Cow;
2use std::io::Read;
3use std::path::Path;
4use std::process::{Command, Output, Stdio};
5use std::thread;
6use std::time::{Duration, Instant};
7
8use backon::{BlockingRetryable, ExponentialBuilder};
9use wait_timeout::ChildExt;
10
11use crate::error::RunError;
12
13#[derive(Debug, Clone)]
19pub struct RunOutput {
20 pub stdout: Vec<u8>,
21 pub stderr: String,
22}
23
24impl RunOutput {
25 pub fn stdout_lossy(&self) -> Cow<'_, str> {
30 String::from_utf8_lossy(&self.stdout)
31 }
32}
33
34pub fn run_cmd_inherited(program: &str, args: &[&str]) -> Result<(), RunError> {
42 let status = Command::new(program).args(args).status().map_err(|source| {
43 RunError::Spawn {
44 program: program.to_string(),
45 source,
46 }
47 })?;
48
49 if status.success() {
50 Ok(())
51 } else {
52 Err(RunError::NonZeroExit {
53 program: program.to_string(),
54 args: args.iter().map(|s| s.to_string()).collect(),
55 status,
56 stdout: Vec::new(),
57 stderr: String::new(),
58 })
59 }
60}
61
62pub fn run_cmd(program: &str, args: &[&str]) -> Result<RunOutput, RunError> {
64 let output = Command::new(program).args(args).output().map_err(|source| {
65 RunError::Spawn {
66 program: program.to_string(),
67 source,
68 }
69 })?;
70
71 check_output(program, args, output)
72}
73
74pub fn run_cmd_in(dir: &Path, program: &str, args: &[&str]) -> Result<RunOutput, RunError> {
78 run_cmd_in_with_env(dir, program, args, &[])
79}
80
81pub fn run_cmd_in_with_env(
97 dir: &Path,
98 program: &str,
99 args: &[&str],
100 env: &[(&str, &str)],
101) -> Result<RunOutput, RunError> {
102 let mut cmd = Command::new(program);
103 cmd.args(args).current_dir(dir);
104 for &(key, val) in env {
105 cmd.env(key, val);
106 }
107 let output = cmd.output().map_err(|source| RunError::Spawn {
108 program: program.to_string(),
109 source,
110 })?;
111
112 check_output(program, args, output)
113}
114
115pub fn run_cmd_in_with_timeout(
150 dir: &Path,
151 program: &str,
152 args: &[&str],
153 timeout: Duration,
154) -> Result<RunOutput, RunError> {
155 let mut child = Command::new(program)
156 .args(args)
157 .current_dir(dir)
158 .stdout(Stdio::piped())
159 .stderr(Stdio::piped())
160 .spawn()
161 .map_err(|source| RunError::Spawn {
162 program: program.to_string(),
163 source,
164 })?;
165
166 let stdout = child.stdout.take().expect("stdout piped");
168 let stderr = child.stderr.take().expect("stderr piped");
169 let stdout_handle = thread::spawn(move || read_to_end(stdout));
170 let stderr_handle = thread::spawn(move || read_to_end(stderr));
171
172 let start = Instant::now();
173 let wait_result = child.wait_timeout(timeout);
174
175 let outcome = match wait_result {
178 Ok(Some(status)) => Outcome::Exited(status),
179 Ok(None) => {
180 let _ = child.kill();
181 let _ = child.wait();
182 Outcome::TimedOut(start.elapsed())
183 }
184 Err(source) => {
185 let _ = child.kill();
186 let _ = child.wait();
187 Outcome::WaitFailed(source)
188 }
189 };
190
191 let stdout_bytes = stdout_handle.join().unwrap_or_default();
193 let stderr_bytes = stderr_handle.join().unwrap_or_default();
194 let stderr_str = String::from_utf8_lossy(&stderr_bytes).into_owned();
195
196 match outcome {
197 Outcome::Exited(status) => {
198 if status.success() {
199 Ok(RunOutput {
200 stdout: stdout_bytes,
201 stderr: stderr_str,
202 })
203 } else {
204 Err(RunError::NonZeroExit {
205 program: program.to_string(),
206 args: args.iter().map(|s| s.to_string()).collect(),
207 status,
208 stdout: stdout_bytes,
209 stderr: stderr_str,
210 })
211 }
212 }
213 Outcome::TimedOut(elapsed) => Err(RunError::Timeout {
214 program: program.to_string(),
215 args: args.iter().map(|s| s.to_string()).collect(),
216 elapsed,
217 stdout: stdout_bytes,
218 stderr: stderr_str,
219 }),
220 Outcome::WaitFailed(source) => Err(RunError::Spawn {
221 program: program.to_string(),
222 source,
223 }),
224 }
225}
226
227enum Outcome {
228 Exited(std::process::ExitStatus),
229 TimedOut(Duration),
230 WaitFailed(std::io::Error),
231}
232
233pub fn run_jj(repo_path: &Path, args: &[&str]) -> Result<RunOutput, RunError> {
235 run_cmd_in(repo_path, "jj", args)
236}
237
238pub fn run_git(repo_path: &Path, args: &[&str]) -> Result<RunOutput, RunError> {
240 run_cmd_in(repo_path, "git", args)
241}
242
243pub fn run_jj_with_timeout(
247 repo_path: &Path,
248 args: &[&str],
249 timeout: Duration,
250) -> Result<RunOutput, RunError> {
251 run_cmd_in_with_timeout(repo_path, "jj", args, timeout)
252}
253
254pub fn run_git_with_timeout(
258 repo_path: &Path,
259 args: &[&str],
260 timeout: Duration,
261) -> Result<RunOutput, RunError> {
262 run_cmd_in_with_timeout(repo_path, "git", args, timeout)
263}
264
265pub fn run_with_retry(
270 repo_path: &Path,
271 program: &str,
272 args: &[&str],
273 is_transient: impl Fn(&RunError) -> bool,
274) -> Result<RunOutput, RunError> {
275 let args_owned: Vec<String> = args.iter().map(|s| s.to_string()).collect();
276
277 let op = || {
278 let str_args: Vec<&str> = args_owned.iter().map(|s| s.as_str()).collect();
279 run_cmd_in(repo_path, program, &str_args)
280 };
281
282 op.retry(
283 ExponentialBuilder::default()
284 .with_factor(2.0)
285 .with_min_delay(Duration::from_millis(100))
286 .with_max_times(3),
287 )
288 .when(is_transient)
289 .call()
290}
291
292pub fn run_jj_with_retry(
294 repo_path: &Path,
295 args: &[&str],
296 is_transient: impl Fn(&RunError) -> bool,
297) -> Result<RunOutput, RunError> {
298 run_with_retry(repo_path, "jj", args, is_transient)
299}
300
301pub fn run_git_with_retry(
303 repo_path: &Path,
304 args: &[&str],
305 is_transient: impl Fn(&RunError) -> bool,
306) -> Result<RunOutput, RunError> {
307 run_with_retry(repo_path, "git", args, is_transient)
308}
309
310pub fn jj_merge_base(
324 repo_path: &Path,
325 a: &str,
326 b: &str,
327) -> Result<Option<String>, RunError> {
328 let revset = format!("latest(::({a}) & ::({b}))");
329 let output = run_jj(
330 repo_path,
331 &[
332 "log", "-r", &revset, "--no-graph", "--limit", "1", "-T", "commit_id",
333 ],
334 )?;
335 let id = output.stdout_lossy().trim().to_string();
336 Ok(if id.is_empty() { None } else { Some(id) })
337}
338
339pub fn git_merge_base(
352 repo_path: &Path,
353 a: &str,
354 b: &str,
355) -> Result<Option<String>, RunError> {
356 match run_git(repo_path, &["merge-base", a, b]) {
357 Ok(output) => {
358 let id = output.stdout_lossy().trim().to_string();
359 Ok(if id.is_empty() { None } else { Some(id) })
360 }
361 Err(RunError::NonZeroExit { status, .. }) if status.code() == Some(1) => Ok(None),
363 Err(e) => Err(e),
364 }
365}
366
367pub fn is_transient_error(err: &RunError) -> bool {
375 match err {
376 RunError::NonZeroExit { stderr, .. } => {
377 stderr.contains("stale") || stderr.contains(".lock")
378 }
379 RunError::Spawn { .. } | RunError::Timeout { .. } => false,
380 }
381}
382
383pub fn binary_available(name: &str) -> bool {
385 Command::new(name)
386 .arg("--version")
387 .stdout(Stdio::null())
388 .stderr(Stdio::null())
389 .status()
390 .is_ok_and(|s| s.success())
391}
392
393pub fn binary_version(name: &str) -> Option<String> {
395 let output = Command::new(name).arg("--version").output().ok()?;
396 if !output.status.success() {
397 return None;
398 }
399 Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
400}
401
402fn check_output(program: &str, args: &[&str], output: Output) -> Result<RunOutput, RunError> {
403 if output.status.success() {
404 Ok(RunOutput {
405 stdout: output.stdout,
406 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
407 })
408 } else {
409 Err(RunError::NonZeroExit {
410 program: program.to_string(),
411 args: args.iter().map(|s| s.to_string()).collect(),
412 status: output.status,
413 stdout: output.stdout,
414 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
415 })
416 }
417}
418
419fn read_to_end<R: Read>(mut reader: R) -> Vec<u8> {
420 let mut buf = Vec::new();
421 let _ = reader.read_to_end(&mut buf);
422 buf
423}
424
425#[cfg(test)]
426mod tests {
427 use super::*;
428
429 fn fake_non_zero(stderr: &str) -> RunError {
432 let status = Command::new("false").status().expect("false");
433 RunError::NonZeroExit {
434 program: "jj".into(),
435 args: vec!["status".into()],
436 status,
437 stdout: Vec::new(),
438 stderr: stderr.to_string(),
439 }
440 }
441
442 fn fake_spawn() -> RunError {
443 RunError::Spawn {
444 program: "jj".into(),
445 source: std::io::Error::new(std::io::ErrorKind::NotFound, "not found"),
446 }
447 }
448
449 fn fake_timeout() -> RunError {
450 RunError::Timeout {
451 program: "git".into(),
452 args: vec!["fetch".into()],
453 elapsed: Duration::from_secs(30),
454 stdout: Vec::new(),
455 stderr: String::new(),
456 }
457 }
458
459 #[test]
460 fn transient_detects_stale() {
461 assert!(is_transient_error(&fake_non_zero("The working copy is stale")));
462 }
463
464 #[test]
465 fn transient_detects_lock() {
466 assert!(is_transient_error(&fake_non_zero(
467 "Unable to create .lock: File exists"
468 )));
469 }
470
471 #[test]
472 fn transient_rejects_config_error() {
473 assert!(!is_transient_error(&fake_non_zero(
474 "Config error: no such revision"
475 )));
476 }
477
478 #[test]
479 fn transient_never_retries_spawn_failure() {
480 assert!(!is_transient_error(&fake_spawn()));
481 }
482
483 #[test]
484 fn transient_never_retries_timeout() {
485 assert!(!is_transient_error(&fake_timeout()));
486 }
487
488 #[test]
491 fn cmd_inherited_succeeds() {
492 run_cmd_inherited("true", &[]).expect("true should succeed");
493 }
494
495 #[test]
496 fn cmd_inherited_fails_on_nonzero() {
497 let err = run_cmd_inherited("false", &[]).expect_err("should fail");
498 assert!(err.is_non_zero_exit());
499 assert_eq!(err.program(), "false");
500 }
501
502 #[test]
503 fn cmd_inherited_fails_on_missing_binary() {
504 let err = run_cmd_inherited("nonexistent_binary_xyz_42", &[]).expect_err("should fail");
505 assert!(err.is_spawn_failure());
506 }
507
508 #[test]
511 fn cmd_captured_succeeds() {
512 let output = run_cmd("echo", &["hello"]).expect("echo should succeed");
513 assert_eq!(output.stdout_lossy().trim(), "hello");
514 }
515
516 #[test]
517 fn cmd_captured_fails_on_nonzero() {
518 let err = run_cmd("false", &[]).expect_err("should fail");
519 assert!(err.is_non_zero_exit());
520 assert!(err.exit_status().is_some());
521 }
522
523 #[test]
524 fn cmd_captured_captures_stderr_on_failure() {
525 let err = run_cmd("sh", &["-c", "echo err >&2; exit 1"]).expect_err("should fail");
526 assert_eq!(err.stderr(), Some("err\n"));
527 }
528
529 #[test]
530 fn cmd_captured_captures_stdout_on_failure() {
531 let err = run_cmd("sh", &["-c", "echo output; exit 1"]).expect_err("should fail");
532 match &err {
533 RunError::NonZeroExit { stdout, .. } => {
534 assert_eq!(String::from_utf8_lossy(stdout).trim(), "output");
535 }
536 _ => panic!("expected NonZeroExit"),
537 }
538 }
539
540 #[test]
541 fn cmd_fails_on_missing_binary() {
542 let err = run_cmd("nonexistent_binary_xyz_42", &[]).expect_err("should fail");
543 assert!(err.is_spawn_failure());
544 }
545
546 #[test]
549 fn cmd_in_runs_in_directory() {
550 let tmp = tempfile::tempdir().expect("tempdir");
551 let output = run_cmd_in(tmp.path(), "pwd", &[]).expect("pwd should work");
552 let pwd = output.stdout_lossy().trim().to_string();
553 let expected = tmp.path().canonicalize().expect("canonicalize");
554 let actual = std::path::Path::new(&pwd).canonicalize().expect("canonicalize pwd");
555 assert_eq!(actual, expected);
556 }
557
558 #[test]
559 fn cmd_in_fails_on_nonzero() {
560 let tmp = tempfile::tempdir().expect("tempdir");
561 let err = run_cmd_in(tmp.path(), "false", &[]).expect_err("should fail");
562 assert!(err.is_non_zero_exit());
563 }
564
565 #[test]
566 fn cmd_in_fails_on_nonexistent_dir() {
567 let err = run_cmd_in(
568 std::path::Path::new("/nonexistent_dir_xyz_42"),
569 "echo",
570 &["hi"],
571 )
572 .expect_err("should fail");
573 assert!(err.is_spawn_failure());
574 }
575
576 #[test]
579 fn cmd_in_with_env_sets_variable() {
580 let tmp = tempfile::tempdir().expect("tempdir");
581 let output = run_cmd_in_with_env(
582 tmp.path(),
583 "sh",
584 &["-c", "echo $TEST_VAR_XYZ"],
585 &[("TEST_VAR_XYZ", "hello_from_env")],
586 )
587 .expect("should succeed");
588 assert_eq!(output.stdout_lossy().trim(), "hello_from_env");
589 }
590
591 #[test]
592 fn cmd_in_with_env_multiple_vars() {
593 let tmp = tempfile::tempdir().expect("tempdir");
594 let output = run_cmd_in_with_env(
595 tmp.path(),
596 "sh",
597 &["-c", "echo ${A}_${B}"],
598 &[("A", "foo"), ("B", "bar")],
599 )
600 .expect("should succeed");
601 assert_eq!(output.stdout_lossy().trim(), "foo_bar");
602 }
603
604 #[test]
605 fn cmd_in_with_env_overrides_existing_var() {
606 let tmp = tempfile::tempdir().expect("tempdir");
607 let output = run_cmd_in_with_env(
608 tmp.path(),
609 "sh",
610 &["-c", "echo $HOME"],
611 &[("HOME", "/fake/home")],
612 )
613 .expect("should succeed");
614 assert_eq!(output.stdout_lossy().trim(), "/fake/home");
615 }
616
617 #[test]
618 fn cmd_in_with_env_fails_on_nonzero() {
619 let tmp = tempfile::tempdir().expect("tempdir");
620 let err = run_cmd_in_with_env(
621 tmp.path(),
622 "sh",
623 &["-c", "exit 1"],
624 &[("IRRELEVANT", "value")],
625 )
626 .expect_err("should fail");
627 assert!(err.is_non_zero_exit());
628 }
629
630 #[test]
633 fn timeout_succeeds_for_fast_command() {
634 let tmp = tempfile::tempdir().expect("tempdir");
635 let output =
636 run_cmd_in_with_timeout(tmp.path(), "echo", &["hello"], Duration::from_secs(5))
637 .expect("should succeed");
638 assert_eq!(output.stdout_lossy().trim(), "hello");
639 }
640
641 #[test]
642 fn timeout_fires_for_slow_command() {
643 let tmp = tempfile::tempdir().expect("tempdir");
644 let wall_start = Instant::now();
645 let err = run_cmd_in_with_timeout(
646 tmp.path(),
647 "sleep",
648 &["10"],
649 Duration::from_millis(200),
650 )
651 .expect_err("should time out");
652 let wall_elapsed = wall_start.elapsed();
653
654 assert!(err.is_timeout());
655 assert!(
657 wall_elapsed < Duration::from_secs(5),
658 "expected quick kill, took {wall_elapsed:?}"
659 );
660 }
661
662 #[test]
663 fn timeout_captures_partial_stderr_before_kill() {
664 let tmp = tempfile::tempdir().expect("tempdir");
665 let err = run_cmd_in_with_timeout(
670 tmp.path(),
671 "sh",
672 &["-c", "echo partial >&2; exec sleep 10"],
673 Duration::from_millis(500),
674 )
675 .expect_err("should time out");
676 assert!(err.is_timeout());
677 let stderr = err.stderr().unwrap_or("");
678 assert!(
679 stderr.contains("partial"),
680 "expected partial stderr, got: {stderr:?}"
681 );
682 }
683
684 #[test]
685 fn timeout_reports_non_zero_exit_when_process_completes() {
686 let tmp = tempfile::tempdir().expect("tempdir");
687 let err = run_cmd_in_with_timeout(
688 tmp.path(),
689 "false",
690 &[],
691 Duration::from_secs(5),
692 )
693 .expect_err("should fail");
694 assert!(err.is_non_zero_exit());
695 }
696
697 #[test]
698 fn timeout_fails_on_missing_binary() {
699 let tmp = tempfile::tempdir().expect("tempdir");
700 let err = run_cmd_in_with_timeout(
701 tmp.path(),
702 "nonexistent_binary_xyz_42",
703 &[],
704 Duration::from_secs(5),
705 )
706 .expect_err("should fail");
707 assert!(err.is_spawn_failure());
708 }
709
710 #[test]
711 fn timeout_does_not_block_on_large_output() {
712 let tmp = tempfile::tempdir().expect("tempdir");
716 let output = run_cmd_in_with_timeout(
717 tmp.path(),
718 "sh",
719 &["-c", "yes | head -c 200000"],
720 Duration::from_secs(5),
721 )
722 .expect("should succeed");
723 assert!(output.stdout.len() >= 200_000);
724 }
725
726 #[test]
729 fn stdout_lossy_valid_utf8() {
730 let output = RunOutput {
731 stdout: b"hello world".to_vec(),
732 stderr: String::new(),
733 };
734 assert_eq!(output.stdout_lossy(), "hello world");
735 }
736
737 #[test]
738 fn stdout_lossy_invalid_utf8() {
739 let output = RunOutput {
740 stdout: vec![0xff, 0xfe, b'a', b'b'],
741 stderr: String::new(),
742 };
743 let s = output.stdout_lossy();
744 assert!(s.contains("ab"));
745 assert!(s.contains('�'));
746 }
747
748 #[test]
749 fn stdout_raw_bytes_preserved() {
750 let bytes: Vec<u8> = (0..=255).collect();
751 let output = RunOutput {
752 stdout: bytes.clone(),
753 stderr: String::new(),
754 };
755 assert_eq!(output.stdout, bytes);
756 }
757
758 #[test]
759 fn run_output_debug_impl() {
760 let output = RunOutput {
761 stdout: b"hello".to_vec(),
762 stderr: "warn".to_string(),
763 };
764 let debug = format!("{output:?}");
765 assert!(debug.contains("warn"));
766 assert!(debug.contains("stdout"));
767 }
768
769 #[test]
772 fn binary_available_true_returns_true() {
773 assert!(binary_available("echo"));
774 }
775
776 #[test]
777 fn binary_available_missing_returns_false() {
778 assert!(!binary_available("nonexistent_binary_xyz_42"));
779 }
780
781 #[test]
782 fn binary_version_missing_returns_none() {
783 assert!(binary_version("nonexistent_binary_xyz_42").is_none());
784 }
785
786 #[test]
789 fn run_jj_version_succeeds() {
790 if !binary_available("jj") {
791 return;
792 }
793 let tmp = tempfile::tempdir().expect("tempdir");
794 let output = run_jj(tmp.path(), &["--version"]).expect("jj --version should work");
795 assert!(output.stdout_lossy().contains("jj"));
796 }
797
798 #[test]
799 fn run_jj_fails_in_non_repo() {
800 if !binary_available("jj") {
801 return;
802 }
803 let tmp = tempfile::tempdir().expect("tempdir");
804 let err = run_jj(tmp.path(), &["status"]).expect_err("should fail");
805 assert!(err.is_non_zero_exit());
806 }
807
808 #[test]
809 fn run_git_version_succeeds() {
810 if !binary_available("git") {
811 return;
812 }
813 let tmp = tempfile::tempdir().expect("tempdir");
814 let output = run_git(tmp.path(), &["--version"]).expect("git --version should work");
815 assert!(output.stdout_lossy().contains("git"));
816 }
817
818 #[test]
819 fn run_git_fails_in_non_repo() {
820 if !binary_available("git") {
821 return;
822 }
823 let tmp = tempfile::tempdir().expect("tempdir");
824 let err = run_git(tmp.path(), &["status"]).expect_err("should fail");
825 assert!(err.is_non_zero_exit());
826 }
827
828 #[test]
829 fn run_jj_with_timeout_succeeds() {
830 if !binary_available("jj") {
831 return;
832 }
833 let tmp = tempfile::tempdir().expect("tempdir");
834 let output =
835 run_jj_with_timeout(tmp.path(), &["--version"], Duration::from_secs(5))
836 .expect("jj --version should work");
837 assert!(output.stdout_lossy().contains("jj"));
838 }
839
840 #[test]
841 fn run_git_with_timeout_succeeds() {
842 if !binary_available("git") {
843 return;
844 }
845 let tmp = tempfile::tempdir().expect("tempdir");
846 let output =
847 run_git_with_timeout(tmp.path(), &["--version"], Duration::from_secs(5))
848 .expect("git --version should work");
849 assert!(output.stdout_lossy().contains("git"));
850 }
851
852 #[test]
855 fn check_output_preserves_stderr_on_success() {
856 let output =
857 run_cmd("sh", &["-c", "echo ok; echo warn >&2"]).expect("should succeed");
858 assert_eq!(output.stdout_lossy().trim(), "ok");
859 assert_eq!(output.stderr.trim(), "warn");
860 }
861
862 #[test]
865 fn retry_accepts_closure_over_run_error() {
866 let captured = "special".to_string();
867 let checker = |err: &RunError| err.stderr().is_some_and(|s| s.contains(captured.as_str()));
868
869 assert!(!checker(&fake_non_zero("other")));
870 assert!(checker(&fake_non_zero("this has special text")));
871 assert!(!checker(&fake_spawn()));
872 }
873
874 #[test]
877 fn git_merge_base_finds_common_ancestor() {
878 if !binary_available("git") {
879 return;
880 }
881 let tmp = tempfile::tempdir().expect("tempdir");
882 let repo = tmp.path();
883
884 let _ = Command::new("git")
886 .args(["init", "-b", "main"])
887 .current_dir(repo)
888 .output();
889 let _ = Command::new("git")
890 .args(["config", "user.email", "t@t"])
891 .current_dir(repo)
892 .output();
893 let _ = Command::new("git")
894 .args(["config", "user.name", "t"])
895 .current_dir(repo)
896 .output();
897 std::fs::write(repo.join("a.txt"), "a").expect("write test file");
898 let _ = Command::new("git")
899 .args(["add", "."])
900 .current_dir(repo)
901 .output();
902 let _ = Command::new("git")
903 .args(["commit", "-m", "initial"])
904 .current_dir(repo)
905 .output();
906
907 let base = git_merge_base(repo, "HEAD", "HEAD").expect("should succeed");
909 assert!(base.is_some());
910 assert_eq!(base.as_deref().map(str::len), Some(40));
911 }
912
913 #[test]
914 fn git_merge_base_unrelated_histories_returns_none() {
915 if !binary_available("git") {
916 return;
917 }
918 let tmp = tempfile::tempdir().expect("tempdir");
919 let repo = tmp.path();
920
921 let _ = Command::new("git")
924 .args(["init", "-b", "main"])
925 .current_dir(repo)
926 .output();
927 let _ = Command::new("git")
928 .args(["config", "user.email", "t@t"])
929 .current_dir(repo)
930 .output();
931 let _ = Command::new("git")
932 .args(["config", "user.name", "t"])
933 .current_dir(repo)
934 .output();
935 std::fs::write(repo.join("a.txt"), "a").expect("write test file");
936 let _ = Command::new("git")
937 .args(["add", "."])
938 .current_dir(repo)
939 .output();
940 let _ = Command::new("git")
941 .args(["commit", "-m", "main-1"])
942 .current_dir(repo)
943 .output();
944 let _ = Command::new("git")
945 .args(["checkout", "--orphan", "alt"])
946 .current_dir(repo)
947 .output();
948 let _ = Command::new("git")
949 .args(["rm", "-rf", "."])
950 .current_dir(repo)
951 .output();
952 std::fs::write(repo.join("b.txt"), "b").expect("write test file");
953 let _ = Command::new("git")
954 .args(["add", "."])
955 .current_dir(repo)
956 .output();
957 let _ = Command::new("git")
958 .args(["commit", "-m", "alt-1"])
959 .current_dir(repo)
960 .output();
961
962 let result = git_merge_base(repo, "alt", "main");
964 assert!(matches!(result, Ok(None)));
965 }
966
967 #[test]
968 fn git_merge_base_invalid_ref_returns_err() {
969 if !binary_available("git") {
970 return;
971 }
972 let tmp = tempfile::tempdir().expect("tempdir");
973 let _ = Command::new("git")
974 .args(["init"])
975 .current_dir(tmp.path())
976 .output();
977 let result = git_merge_base(tmp.path(), "nonexistent-ref-xyz", "HEAD");
978 assert!(result.is_err());
980 }
981
982 #[test]
983 fn jj_merge_base_same_rev_returns_self() {
984 if !binary_available("jj") {
985 return;
986 }
987 let tmp = tempfile::tempdir().expect("tempdir");
988 let repo = tmp.path();
989 let _ = Command::new("jj")
990 .args(["git", "init"])
991 .current_dir(repo)
992 .output();
993
994 let base = jj_merge_base(repo, "@", "@");
996 assert!(base.is_ok());
999 }
1000}