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 is_transient_error(err: &RunError) -> bool {
318 match err {
319 RunError::NonZeroExit { stderr, .. } => {
320 stderr.contains("stale") || stderr.contains(".lock")
321 }
322 RunError::Spawn { .. } | RunError::Timeout { .. } => false,
323 }
324}
325
326pub fn binary_available(name: &str) -> bool {
328 Command::new(name)
329 .arg("--version")
330 .stdout(Stdio::null())
331 .stderr(Stdio::null())
332 .status()
333 .is_ok_and(|s| s.success())
334}
335
336pub fn binary_version(name: &str) -> Option<String> {
338 let output = Command::new(name).arg("--version").output().ok()?;
339 if !output.status.success() {
340 return None;
341 }
342 Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
343}
344
345fn check_output(program: &str, args: &[&str], output: Output) -> Result<RunOutput, RunError> {
346 if output.status.success() {
347 Ok(RunOutput {
348 stdout: output.stdout,
349 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
350 })
351 } else {
352 Err(RunError::NonZeroExit {
353 program: program.to_string(),
354 args: args.iter().map(|s| s.to_string()).collect(),
355 status: output.status,
356 stdout: output.stdout,
357 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
358 })
359 }
360}
361
362fn read_to_end<R: Read>(mut reader: R) -> Vec<u8> {
363 let mut buf = Vec::new();
364 let _ = reader.read_to_end(&mut buf);
365 buf
366}
367
368#[cfg(test)]
369mod tests {
370 use super::*;
371
372 fn fake_non_zero(stderr: &str) -> RunError {
375 let status = Command::new("false").status().expect("false");
376 RunError::NonZeroExit {
377 program: "jj".into(),
378 args: vec!["status".into()],
379 status,
380 stdout: Vec::new(),
381 stderr: stderr.to_string(),
382 }
383 }
384
385 fn fake_spawn() -> RunError {
386 RunError::Spawn {
387 program: "jj".into(),
388 source: std::io::Error::new(std::io::ErrorKind::NotFound, "not found"),
389 }
390 }
391
392 fn fake_timeout() -> RunError {
393 RunError::Timeout {
394 program: "git".into(),
395 args: vec!["fetch".into()],
396 elapsed: Duration::from_secs(30),
397 stdout: Vec::new(),
398 stderr: String::new(),
399 }
400 }
401
402 #[test]
403 fn transient_detects_stale() {
404 assert!(is_transient_error(&fake_non_zero("The working copy is stale")));
405 }
406
407 #[test]
408 fn transient_detects_lock() {
409 assert!(is_transient_error(&fake_non_zero(
410 "Unable to create .lock: File exists"
411 )));
412 }
413
414 #[test]
415 fn transient_rejects_config_error() {
416 assert!(!is_transient_error(&fake_non_zero(
417 "Config error: no such revision"
418 )));
419 }
420
421 #[test]
422 fn transient_never_retries_spawn_failure() {
423 assert!(!is_transient_error(&fake_spawn()));
424 }
425
426 #[test]
427 fn transient_never_retries_timeout() {
428 assert!(!is_transient_error(&fake_timeout()));
429 }
430
431 #[test]
434 fn cmd_inherited_succeeds() {
435 run_cmd_inherited("true", &[]).expect("true should succeed");
436 }
437
438 #[test]
439 fn cmd_inherited_fails_on_nonzero() {
440 let err = run_cmd_inherited("false", &[]).expect_err("should fail");
441 assert!(err.is_non_zero_exit());
442 assert_eq!(err.program(), "false");
443 }
444
445 #[test]
446 fn cmd_inherited_fails_on_missing_binary() {
447 let err = run_cmd_inherited("nonexistent_binary_xyz_42", &[]).expect_err("should fail");
448 assert!(err.is_spawn_failure());
449 }
450
451 #[test]
454 fn cmd_captured_succeeds() {
455 let output = run_cmd("echo", &["hello"]).expect("echo should succeed");
456 assert_eq!(output.stdout_lossy().trim(), "hello");
457 }
458
459 #[test]
460 fn cmd_captured_fails_on_nonzero() {
461 let err = run_cmd("false", &[]).expect_err("should fail");
462 assert!(err.is_non_zero_exit());
463 assert!(err.exit_status().is_some());
464 }
465
466 #[test]
467 fn cmd_captured_captures_stderr_on_failure() {
468 let err = run_cmd("sh", &["-c", "echo err >&2; exit 1"]).expect_err("should fail");
469 assert_eq!(err.stderr(), Some("err\n"));
470 }
471
472 #[test]
473 fn cmd_captured_captures_stdout_on_failure() {
474 let err = run_cmd("sh", &["-c", "echo output; exit 1"]).expect_err("should fail");
475 match &err {
476 RunError::NonZeroExit { stdout, .. } => {
477 assert_eq!(String::from_utf8_lossy(stdout).trim(), "output");
478 }
479 _ => panic!("expected NonZeroExit"),
480 }
481 }
482
483 #[test]
484 fn cmd_fails_on_missing_binary() {
485 let err = run_cmd("nonexistent_binary_xyz_42", &[]).expect_err("should fail");
486 assert!(err.is_spawn_failure());
487 }
488
489 #[test]
492 fn cmd_in_runs_in_directory() {
493 let tmp = tempfile::tempdir().expect("tempdir");
494 let output = run_cmd_in(tmp.path(), "pwd", &[]).expect("pwd should work");
495 let pwd = output.stdout_lossy().trim().to_string();
496 let expected = tmp.path().canonicalize().expect("canonicalize");
497 let actual = std::path::Path::new(&pwd).canonicalize().expect("canonicalize pwd");
498 assert_eq!(actual, expected);
499 }
500
501 #[test]
502 fn cmd_in_fails_on_nonzero() {
503 let tmp = tempfile::tempdir().expect("tempdir");
504 let err = run_cmd_in(tmp.path(), "false", &[]).expect_err("should fail");
505 assert!(err.is_non_zero_exit());
506 }
507
508 #[test]
509 fn cmd_in_fails_on_nonexistent_dir() {
510 let err = run_cmd_in(
511 std::path::Path::new("/nonexistent_dir_xyz_42"),
512 "echo",
513 &["hi"],
514 )
515 .expect_err("should fail");
516 assert!(err.is_spawn_failure());
517 }
518
519 #[test]
522 fn cmd_in_with_env_sets_variable() {
523 let tmp = tempfile::tempdir().expect("tempdir");
524 let output = run_cmd_in_with_env(
525 tmp.path(),
526 "sh",
527 &["-c", "echo $TEST_VAR_XYZ"],
528 &[("TEST_VAR_XYZ", "hello_from_env")],
529 )
530 .expect("should succeed");
531 assert_eq!(output.stdout_lossy().trim(), "hello_from_env");
532 }
533
534 #[test]
535 fn cmd_in_with_env_multiple_vars() {
536 let tmp = tempfile::tempdir().expect("tempdir");
537 let output = run_cmd_in_with_env(
538 tmp.path(),
539 "sh",
540 &["-c", "echo ${A}_${B}"],
541 &[("A", "foo"), ("B", "bar")],
542 )
543 .expect("should succeed");
544 assert_eq!(output.stdout_lossy().trim(), "foo_bar");
545 }
546
547 #[test]
548 fn cmd_in_with_env_overrides_existing_var() {
549 let tmp = tempfile::tempdir().expect("tempdir");
550 let output = run_cmd_in_with_env(
551 tmp.path(),
552 "sh",
553 &["-c", "echo $HOME"],
554 &[("HOME", "/fake/home")],
555 )
556 .expect("should succeed");
557 assert_eq!(output.stdout_lossy().trim(), "/fake/home");
558 }
559
560 #[test]
561 fn cmd_in_with_env_fails_on_nonzero() {
562 let tmp = tempfile::tempdir().expect("tempdir");
563 let err = run_cmd_in_with_env(
564 tmp.path(),
565 "sh",
566 &["-c", "exit 1"],
567 &[("IRRELEVANT", "value")],
568 )
569 .expect_err("should fail");
570 assert!(err.is_non_zero_exit());
571 }
572
573 #[test]
576 fn timeout_succeeds_for_fast_command() {
577 let tmp = tempfile::tempdir().expect("tempdir");
578 let output =
579 run_cmd_in_with_timeout(tmp.path(), "echo", &["hello"], Duration::from_secs(5))
580 .expect("should succeed");
581 assert_eq!(output.stdout_lossy().trim(), "hello");
582 }
583
584 #[test]
585 fn timeout_fires_for_slow_command() {
586 let tmp = tempfile::tempdir().expect("tempdir");
587 let wall_start = Instant::now();
588 let err = run_cmd_in_with_timeout(
589 tmp.path(),
590 "sleep",
591 &["10"],
592 Duration::from_millis(200),
593 )
594 .expect_err("should time out");
595 let wall_elapsed = wall_start.elapsed();
596
597 assert!(err.is_timeout());
598 assert!(
600 wall_elapsed < Duration::from_secs(5),
601 "expected quick kill, took {wall_elapsed:?}"
602 );
603 }
604
605 #[test]
606 fn timeout_captures_partial_stderr_before_kill() {
607 let tmp = tempfile::tempdir().expect("tempdir");
608 let err = run_cmd_in_with_timeout(
613 tmp.path(),
614 "sh",
615 &["-c", "echo partial >&2; exec sleep 10"],
616 Duration::from_millis(500),
617 )
618 .expect_err("should time out");
619 assert!(err.is_timeout());
620 let stderr = err.stderr().unwrap_or("");
621 assert!(
622 stderr.contains("partial"),
623 "expected partial stderr, got: {stderr:?}"
624 );
625 }
626
627 #[test]
628 fn timeout_reports_non_zero_exit_when_process_completes() {
629 let tmp = tempfile::tempdir().expect("tempdir");
630 let err = run_cmd_in_with_timeout(
631 tmp.path(),
632 "false",
633 &[],
634 Duration::from_secs(5),
635 )
636 .expect_err("should fail");
637 assert!(err.is_non_zero_exit());
638 }
639
640 #[test]
641 fn timeout_fails_on_missing_binary() {
642 let tmp = tempfile::tempdir().expect("tempdir");
643 let err = run_cmd_in_with_timeout(
644 tmp.path(),
645 "nonexistent_binary_xyz_42",
646 &[],
647 Duration::from_secs(5),
648 )
649 .expect_err("should fail");
650 assert!(err.is_spawn_failure());
651 }
652
653 #[test]
654 fn timeout_does_not_block_on_large_output() {
655 let tmp = tempfile::tempdir().expect("tempdir");
659 let output = run_cmd_in_with_timeout(
660 tmp.path(),
661 "sh",
662 &["-c", "yes | head -c 200000"],
663 Duration::from_secs(5),
664 )
665 .expect("should succeed");
666 assert!(output.stdout.len() >= 200_000);
667 }
668
669 #[test]
672 fn stdout_lossy_valid_utf8() {
673 let output = RunOutput {
674 stdout: b"hello world".to_vec(),
675 stderr: String::new(),
676 };
677 assert_eq!(output.stdout_lossy(), "hello world");
678 }
679
680 #[test]
681 fn stdout_lossy_invalid_utf8() {
682 let output = RunOutput {
683 stdout: vec![0xff, 0xfe, b'a', b'b'],
684 stderr: String::new(),
685 };
686 let s = output.stdout_lossy();
687 assert!(s.contains("ab"));
688 assert!(s.contains('�'));
689 }
690
691 #[test]
692 fn stdout_raw_bytes_preserved() {
693 let bytes: Vec<u8> = (0..=255).collect();
694 let output = RunOutput {
695 stdout: bytes.clone(),
696 stderr: String::new(),
697 };
698 assert_eq!(output.stdout, bytes);
699 }
700
701 #[test]
702 fn run_output_debug_impl() {
703 let output = RunOutput {
704 stdout: b"hello".to_vec(),
705 stderr: "warn".to_string(),
706 };
707 let debug = format!("{output:?}");
708 assert!(debug.contains("warn"));
709 assert!(debug.contains("stdout"));
710 }
711
712 #[test]
715 fn binary_available_true_returns_true() {
716 assert!(binary_available("echo"));
717 }
718
719 #[test]
720 fn binary_available_missing_returns_false() {
721 assert!(!binary_available("nonexistent_binary_xyz_42"));
722 }
723
724 #[test]
725 fn binary_version_missing_returns_none() {
726 assert!(binary_version("nonexistent_binary_xyz_42").is_none());
727 }
728
729 #[test]
732 fn run_jj_version_succeeds() {
733 if !binary_available("jj") {
734 return;
735 }
736 let tmp = tempfile::tempdir().expect("tempdir");
737 let output = run_jj(tmp.path(), &["--version"]).expect("jj --version should work");
738 assert!(output.stdout_lossy().contains("jj"));
739 }
740
741 #[test]
742 fn run_jj_fails_in_non_repo() {
743 if !binary_available("jj") {
744 return;
745 }
746 let tmp = tempfile::tempdir().expect("tempdir");
747 let err = run_jj(tmp.path(), &["status"]).expect_err("should fail");
748 assert!(err.is_non_zero_exit());
749 }
750
751 #[test]
752 fn run_git_version_succeeds() {
753 if !binary_available("git") {
754 return;
755 }
756 let tmp = tempfile::tempdir().expect("tempdir");
757 let output = run_git(tmp.path(), &["--version"]).expect("git --version should work");
758 assert!(output.stdout_lossy().contains("git"));
759 }
760
761 #[test]
762 fn run_git_fails_in_non_repo() {
763 if !binary_available("git") {
764 return;
765 }
766 let tmp = tempfile::tempdir().expect("tempdir");
767 let err = run_git(tmp.path(), &["status"]).expect_err("should fail");
768 assert!(err.is_non_zero_exit());
769 }
770
771 #[test]
772 fn run_jj_with_timeout_succeeds() {
773 if !binary_available("jj") {
774 return;
775 }
776 let tmp = tempfile::tempdir().expect("tempdir");
777 let output =
778 run_jj_with_timeout(tmp.path(), &["--version"], Duration::from_secs(5))
779 .expect("jj --version should work");
780 assert!(output.stdout_lossy().contains("jj"));
781 }
782
783 #[test]
784 fn run_git_with_timeout_succeeds() {
785 if !binary_available("git") {
786 return;
787 }
788 let tmp = tempfile::tempdir().expect("tempdir");
789 let output =
790 run_git_with_timeout(tmp.path(), &["--version"], Duration::from_secs(5))
791 .expect("git --version should work");
792 assert!(output.stdout_lossy().contains("git"));
793 }
794
795 #[test]
798 fn check_output_preserves_stderr_on_success() {
799 let output =
800 run_cmd("sh", &["-c", "echo ok; echo warn >&2"]).expect("should succeed");
801 assert_eq!(output.stdout_lossy().trim(), "ok");
802 assert_eq!(output.stderr.trim(), "warn");
803 }
804
805 #[test]
808 fn retry_accepts_closure_over_run_error() {
809 let captured = "special".to_string();
810 let checker = |err: &RunError| err.stderr().is_some_and(|s| s.contains(captured.as_str()));
811
812 assert!(!checker(&fake_non_zero("other")));
813 assert!(checker(&fake_non_zero("this has special text")));
814 assert!(!checker(&fake_spawn()));
815 }
816}