1use std::borrow::Cow;
2use std::path::Path;
3use std::process::{Command, Output, Stdio};
4
5use backon::{BlockingRetryable, ExponentialBuilder};
6
7use crate::error::RunError;
8
9#[derive(Debug, Clone)]
15pub struct RunOutput {
16 pub stdout: Vec<u8>,
17 pub stderr: String,
18}
19
20impl RunOutput {
21 pub fn stdout_lossy(&self) -> Cow<'_, str> {
26 String::from_utf8_lossy(&self.stdout)
27 }
28}
29
30pub fn run_cmd_inherited(program: &str, args: &[&str]) -> Result<(), RunError> {
38 let status = Command::new(program).args(args).status().map_err(|source| {
39 RunError::Spawn {
40 program: program.to_string(),
41 source,
42 }
43 })?;
44
45 if status.success() {
46 Ok(())
47 } else {
48 Err(RunError::NonZeroExit {
49 program: program.to_string(),
50 args: args.iter().map(|s| s.to_string()).collect(),
51 status,
52 stdout: Vec::new(),
53 stderr: String::new(),
54 })
55 }
56}
57
58pub fn run_cmd(program: &str, args: &[&str]) -> Result<RunOutput, RunError> {
63 let output = Command::new(program).args(args).output().map_err(|source| {
64 RunError::Spawn {
65 program: program.to_string(),
66 source,
67 }
68 })?;
69
70 check_output(program, args, output)
71}
72
73pub fn run_cmd_in(dir: &Path, program: &str, args: &[&str]) -> Result<RunOutput, RunError> {
77 run_cmd_in_with_env(dir, program, args, &[])
78}
79
80pub fn run_cmd_in_with_env(
96 dir: &Path,
97 program: &str,
98 args: &[&str],
99 env: &[(&str, &str)],
100) -> Result<RunOutput, RunError> {
101 let mut cmd = Command::new(program);
102 cmd.args(args).current_dir(dir);
103 for &(key, val) in env {
104 cmd.env(key, val);
105 }
106 let output = cmd.output().map_err(|source| RunError::Spawn {
107 program: program.to_string(),
108 source,
109 })?;
110
111 check_output(program, args, output)
112}
113
114pub fn run_jj(repo_path: &Path, args: &[&str]) -> Result<RunOutput, RunError> {
116 run_cmd_in(repo_path, "jj", args)
117}
118
119pub fn run_git(repo_path: &Path, args: &[&str]) -> Result<RunOutput, RunError> {
121 run_cmd_in(repo_path, "git", args)
122}
123
124pub fn run_with_retry(
132 repo_path: &Path,
133 program: &str,
134 args: &[&str],
135 is_transient: impl Fn(&RunError) -> bool,
136) -> Result<RunOutput, RunError> {
137 let args_owned: Vec<String> = args.iter().map(|s| s.to_string()).collect();
138
139 let op = || {
140 let str_args: Vec<&str> = args_owned.iter().map(|s| s.as_str()).collect();
141 run_cmd_in(repo_path, program, &str_args)
142 };
143
144 op.retry(
145 ExponentialBuilder::default()
146 .with_factor(2.0)
147 .with_min_delay(std::time::Duration::from_millis(100))
148 .with_max_times(3),
149 )
150 .when(is_transient)
151 .call()
152}
153
154pub fn run_jj_with_retry(
158 repo_path: &Path,
159 args: &[&str],
160 is_transient: impl Fn(&RunError) -> bool,
161) -> Result<RunOutput, RunError> {
162 run_with_retry(repo_path, "jj", args, is_transient)
163}
164
165pub fn run_git_with_retry(
169 repo_path: &Path,
170 args: &[&str],
171 is_transient: impl Fn(&RunError) -> bool,
172) -> Result<RunOutput, RunError> {
173 run_with_retry(repo_path, "git", args, is_transient)
174}
175
176pub fn is_transient_error(err: &RunError) -> bool {
184 match err {
185 RunError::NonZeroExit { stderr, .. } => {
186 stderr.contains("stale") || stderr.contains(".lock")
187 }
188 RunError::Spawn { .. } => false,
189 }
190}
191
192pub fn binary_available(name: &str) -> bool {
194 Command::new(name)
195 .arg("--version")
196 .stdout(Stdio::null())
197 .stderr(Stdio::null())
198 .status()
199 .is_ok_and(|s| s.success())
200}
201
202pub fn binary_version(name: &str) -> Option<String> {
204 let output = Command::new(name).arg("--version").output().ok()?;
205 if !output.status.success() {
206 return None;
207 }
208 Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
209}
210
211fn check_output(program: &str, args: &[&str], output: Output) -> Result<RunOutput, RunError> {
212 if output.status.success() {
213 Ok(RunOutput {
214 stdout: output.stdout,
215 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
216 })
217 } else {
218 Err(RunError::NonZeroExit {
219 program: program.to_string(),
220 args: args.iter().map(|s| s.to_string()).collect(),
221 status: output.status,
222 stdout: output.stdout,
223 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
224 })
225 }
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231
232 fn fake_non_zero(stderr: &str) -> RunError {
235 let status = Command::new("false").status().expect("false");
236 RunError::NonZeroExit {
237 program: "jj".into(),
238 args: vec!["status".into()],
239 status,
240 stdout: Vec::new(),
241 stderr: stderr.to_string(),
242 }
243 }
244
245 fn fake_spawn() -> RunError {
246 RunError::Spawn {
247 program: "jj".into(),
248 source: std::io::Error::new(std::io::ErrorKind::NotFound, "not found"),
249 }
250 }
251
252 #[test]
253 fn transient_detects_stale() {
254 assert!(is_transient_error(&fake_non_zero("The working copy is stale")));
255 }
256
257 #[test]
258 fn transient_detects_stale_in_context() {
259 assert!(is_transient_error(&fake_non_zero(
260 "Error: The working copy is stale (not updated since op abc)"
261 )));
262 }
263
264 #[test]
265 fn transient_detects_lock() {
266 assert!(is_transient_error(&fake_non_zero(
267 "Unable to create .lock: File exists"
268 )));
269 }
270
271 #[test]
272 fn transient_detects_git_index_lock() {
273 assert!(is_transient_error(&fake_non_zero(
274 "fatal: Unable to create '/repo/.git/index.lock'"
275 )));
276 }
277
278 #[test]
279 fn transient_rejects_config_error() {
280 assert!(!is_transient_error(&fake_non_zero(
281 "Config error: no such revision"
282 )));
283 }
284
285 #[test]
286 fn transient_rejects_empty() {
287 assert!(!is_transient_error(&fake_non_zero("")));
288 }
289
290 #[test]
291 fn transient_never_retries_spawn_failure() {
292 assert!(!is_transient_error(&fake_spawn()));
293 }
294
295 #[test]
298 fn cmd_inherited_succeeds() {
299 run_cmd_inherited("true", &[]).expect("true should succeed");
300 }
301
302 #[test]
303 fn cmd_inherited_fails_on_nonzero() {
304 let result = run_cmd_inherited("false", &[]);
305 let err = result.expect_err("should fail");
306 assert!(err.is_non_zero_exit());
307 assert_eq!(err.program(), "false");
308 }
309
310 #[test]
311 fn cmd_inherited_fails_on_missing_binary() {
312 let result = run_cmd_inherited("nonexistent_binary_xyz_42", &[]);
313 let err = result.expect_err("should fail");
314 assert!(err.is_spawn_failure());
315 }
316
317 #[test]
320 fn cmd_captured_succeeds() {
321 let output = run_cmd("echo", &["hello"]).expect("echo should succeed");
322 assert_eq!(output.stdout_lossy().trim(), "hello");
323 }
324
325 #[test]
326 fn cmd_captured_fails_on_nonzero() {
327 let err = run_cmd("false", &[]).expect_err("should fail");
328 assert!(err.is_non_zero_exit());
329 assert!(err.exit_status().is_some());
330 }
331
332 #[test]
333 fn cmd_captured_captures_stderr_on_failure() {
334 let err = run_cmd("sh", &["-c", "echo err >&2; exit 1"]).expect_err("should fail");
335 assert_eq!(err.stderr(), Some("err\n"));
336 }
337
338 #[test]
339 fn cmd_captured_captures_stdout_on_failure() {
340 let err = run_cmd("sh", &["-c", "echo output; exit 1"]).expect_err("should fail");
341 match &err {
342 RunError::NonZeroExit { stdout, .. } => {
343 assert_eq!(String::from_utf8_lossy(stdout).trim(), "output");
344 }
345 _ => panic!("expected NonZeroExit"),
346 }
347 }
348
349 #[test]
350 fn cmd_fails_on_missing_binary() {
351 let err = run_cmd("nonexistent_binary_xyz_42", &[]).expect_err("should fail");
352 assert!(err.is_spawn_failure());
353 }
354
355 #[test]
358 fn cmd_in_runs_in_directory() {
359 let tmp = tempfile::tempdir().expect("tempdir");
360 let output = run_cmd_in(tmp.path(), "pwd", &[]).expect("pwd should work");
361 let pwd = output.stdout_lossy().trim().to_string();
362 let expected = tmp.path().canonicalize().expect("canonicalize");
363 let actual = std::path::Path::new(&pwd).canonicalize().expect("canonicalize pwd");
364 assert_eq!(actual, expected);
365 }
366
367 #[test]
368 fn cmd_in_fails_on_nonzero() {
369 let tmp = tempfile::tempdir().expect("tempdir");
370 let err = run_cmd_in(tmp.path(), "false", &[]).expect_err("should fail");
371 assert!(err.is_non_zero_exit());
372 }
373
374 #[test]
375 fn cmd_in_fails_on_nonexistent_dir() {
376 let err = run_cmd_in(
377 std::path::Path::new("/nonexistent_dir_xyz_42"),
378 "echo",
379 &["hi"],
380 )
381 .expect_err("should fail");
382 assert!(err.is_spawn_failure());
383 }
384
385 #[test]
388 fn cmd_in_with_env_sets_variable() {
389 let tmp = tempfile::tempdir().expect("tempdir");
390 let output = run_cmd_in_with_env(
391 tmp.path(),
392 "sh",
393 &["-c", "echo $TEST_VAR_XYZ"],
394 &[("TEST_VAR_XYZ", "hello_from_env")],
395 )
396 .expect("should succeed");
397 assert_eq!(output.stdout_lossy().trim(), "hello_from_env");
398 }
399
400 #[test]
401 fn cmd_in_with_env_multiple_vars() {
402 let tmp = tempfile::tempdir().expect("tempdir");
403 let output = run_cmd_in_with_env(
404 tmp.path(),
405 "sh",
406 &["-c", "echo ${A}_${B}"],
407 &[("A", "foo"), ("B", "bar")],
408 )
409 .expect("should succeed");
410 assert_eq!(output.stdout_lossy().trim(), "foo_bar");
411 }
412
413 #[test]
414 fn cmd_in_with_env_empty_env_same_as_cmd_in() {
415 let tmp = tempfile::tempdir().expect("tempdir");
416 let output =
417 run_cmd_in_with_env(tmp.path(), "pwd", &[], &[]).expect("should succeed");
418 let pwd = output.stdout_lossy().trim().to_string();
419 let expected = tmp.path().canonicalize().expect("canonicalize");
420 let actual = std::path::Path::new(&pwd).canonicalize().expect("canonicalize pwd");
421 assert_eq!(actual, expected);
422 }
423
424 #[test]
425 fn cmd_in_with_env_overrides_existing_var() {
426 let tmp = tempfile::tempdir().expect("tempdir");
427 let output = run_cmd_in_with_env(
428 tmp.path(),
429 "sh",
430 &["-c", "echo $HOME"],
431 &[("HOME", "/fake/home")],
432 )
433 .expect("should succeed");
434 assert_eq!(output.stdout_lossy().trim(), "/fake/home");
435 }
436
437 #[test]
438 fn cmd_in_with_env_fails_on_nonzero() {
439 let tmp = tempfile::tempdir().expect("tempdir");
440 let err = run_cmd_in_with_env(
441 tmp.path(),
442 "sh",
443 &["-c", "exit 1"],
444 &[("IRRELEVANT", "value")],
445 )
446 .expect_err("should fail");
447 assert!(err.is_non_zero_exit());
448 }
449
450 #[test]
453 fn stdout_lossy_valid_utf8() {
454 let output = RunOutput {
455 stdout: b"hello world".to_vec(),
456 stderr: String::new(),
457 };
458 assert_eq!(output.stdout_lossy(), "hello world");
459 }
460
461 #[test]
462 fn stdout_lossy_invalid_utf8() {
463 let output = RunOutput {
464 stdout: vec![0xff, 0xfe, b'a', b'b'],
465 stderr: String::new(),
466 };
467 let s = output.stdout_lossy();
468 assert!(s.contains("ab"));
469 assert!(s.contains('�'));
470 }
471
472 #[test]
473 fn stdout_raw_bytes_preserved() {
474 let bytes: Vec<u8> = (0..=255).collect();
475 let output = RunOutput {
476 stdout: bytes.clone(),
477 stderr: String::new(),
478 };
479 assert_eq!(output.stdout, bytes);
480 }
481
482 #[test]
483 fn run_output_debug_impl() {
484 let output = RunOutput {
485 stdout: b"hello".to_vec(),
486 stderr: "warn".to_string(),
487 };
488 let debug = format!("{output:?}");
489 assert!(debug.contains("warn"));
490 assert!(debug.contains("stdout"));
491 }
492
493 #[test]
496 fn binary_available_true_returns_true() {
497 assert!(binary_available("echo"));
498 }
499
500 #[test]
501 fn binary_available_missing_returns_false() {
502 assert!(!binary_available("nonexistent_binary_xyz_42"));
503 }
504
505 #[test]
506 fn binary_version_missing_returns_none() {
507 assert!(binary_version("nonexistent_binary_xyz_42").is_none());
508 }
509
510 #[test]
513 fn run_jj_version_succeeds() {
514 if !binary_available("jj") {
515 return;
516 }
517 let tmp = tempfile::tempdir().expect("tempdir");
518 let output = run_jj(tmp.path(), &["--version"]).expect("jj --version should work");
519 assert!(output.stdout_lossy().contains("jj"));
520 }
521
522 #[test]
523 fn run_jj_fails_in_non_repo() {
524 if !binary_available("jj") {
525 return;
526 }
527 let tmp = tempfile::tempdir().expect("tempdir");
528 let err = run_jj(tmp.path(), &["status"]).expect_err("should fail");
529 assert!(err.is_non_zero_exit());
530 }
531
532 #[test]
533 fn run_git_version_succeeds() {
534 if !binary_available("git") {
535 return;
536 }
537 let tmp = tempfile::tempdir().expect("tempdir");
538 let output = run_git(tmp.path(), &["--version"]).expect("git --version should work");
539 assert!(output.stdout_lossy().contains("git"));
540 }
541
542 #[test]
543 fn run_git_fails_in_non_repo() {
544 if !binary_available("git") {
545 return;
546 }
547 let tmp = tempfile::tempdir().expect("tempdir");
548 let err = run_git(tmp.path(), &["status"]).expect_err("should fail");
549 assert!(err.is_non_zero_exit());
550 }
551
552 #[test]
555 fn check_output_preserves_stderr_on_success() {
556 let output =
557 run_cmd("sh", &["-c", "echo ok; echo warn >&2"]).expect("should succeed");
558 assert_eq!(output.stdout_lossy().trim(), "ok");
559 assert_eq!(output.stderr.trim(), "warn");
560 }
561
562 #[test]
565 fn retry_accepts_closure_over_run_error() {
566 let captured = "special".to_string();
567 let checker = |err: &RunError| err.stderr().is_some_and(|s| s.contains(captured.as_str()));
568
569 assert!(!checker(&fake_non_zero("other")));
570 assert!(checker(&fake_non_zero("this has special text")));
571 assert!(!checker(&fake_spawn()));
572 }
573}