1use std::borrow::Cow;
2use std::path::Path;
3use std::process::{Command, Output, Stdio};
4
5use anyhow::{Context, Result, bail};
6use backon::{BlockingRetryable, ExponentialBuilder};
7
8#[derive(Debug, Clone)]
14pub struct RunOutput {
15 pub stdout: Vec<u8>,
16 pub stderr: String,
17}
18
19impl RunOutput {
20 pub fn stdout_lossy(&self) -> Cow<'_, str> {
25 String::from_utf8_lossy(&self.stdout)
26 }
27}
28
29pub fn run_cmd_inherited(program: &str, args: &[&str]) -> Result<()> {
34 let status = Command::new(program)
35 .args(args)
36 .status()
37 .with_context(|| format!("failed to run {program}"))?;
38 if !status.success() {
39 bail!("{program} exited with status {status}");
40 }
41 Ok(())
42}
43
44pub fn run_cmd(program: &str, args: &[&str]) -> Result<RunOutput> {
48 let output = Command::new(program)
49 .args(args)
50 .output()
51 .with_context(|| format!("failed to run {program}"))?;
52
53 check_output(program, args, output)
54}
55
56pub fn run_cmd_in(dir: &Path, program: &str, args: &[&str]) -> Result<RunOutput> {
60 run_cmd_in_with_env(dir, program, args, &[])
61}
62
63pub fn run_cmd_in_with_env(
79 dir: &Path,
80 program: &str,
81 args: &[&str],
82 env: &[(&str, &str)],
83) -> Result<RunOutput> {
84 let mut cmd = Command::new(program);
85 cmd.args(args).current_dir(dir);
86 for &(key, val) in env {
87 cmd.env(key, val);
88 }
89 let output = cmd
90 .output()
91 .with_context(|| format!("failed to run {program} in {}", dir.display()))?;
92
93 check_output(program, args, output)
94}
95
96pub fn run_jj(repo_path: &Path, args: &[&str]) -> Result<RunOutput> {
98 run_cmd_in(repo_path, "jj", args)
99}
100
101pub fn run_git(repo_path: &Path, args: &[&str]) -> Result<RunOutput> {
103 run_cmd_in(repo_path, "git", args)
104}
105
106pub fn run_with_retry(
115 repo_path: &Path,
116 program: &str,
117 args: &[&str],
118 is_transient: impl Fn(&str) -> bool,
119) -> Result<RunOutput> {
120 let args_owned: Vec<String> = args.iter().map(|s| s.to_string()).collect();
121
122 let op = || {
123 let str_args: Vec<&str> = args_owned.iter().map(|s| s.as_str()).collect();
124 run_cmd_in(repo_path, program, &str_args)
125 };
126
127 op.retry(
128 ExponentialBuilder::default()
129 .with_factor(2.0)
130 .with_min_delay(std::time::Duration::from_millis(100))
131 .with_max_times(3),
132 )
133 .when(|e| is_transient(&e.to_string()))
134 .call()
135}
136
137pub fn run_jj_with_retry(
141 repo_path: &Path,
142 args: &[&str],
143 is_transient: impl Fn(&str) -> bool,
144) -> Result<RunOutput> {
145 run_with_retry(repo_path, "jj", args, is_transient)
146}
147
148pub fn run_git_with_retry(
152 repo_path: &Path,
153 args: &[&str],
154 is_transient: impl Fn(&str) -> bool,
155) -> Result<RunOutput> {
156 run_with_retry(repo_path, "git", args, is_transient)
157}
158
159pub fn is_transient_error(error_msg: &str) -> bool {
165 error_msg.contains("stale") || error_msg.contains(".lock")
166}
167
168pub fn binary_available(name: &str) -> bool {
170 Command::new(name)
171 .arg("--version")
172 .stdout(Stdio::null())
173 .stderr(Stdio::null())
174 .status()
175 .is_ok_and(|s| s.success())
176}
177
178pub fn binary_version(name: &str) -> Option<String> {
180 let output = Command::new(name)
181 .arg("--version")
182 .output()
183 .ok()?;
184 if !output.status.success() {
185 return None;
186 }
187 Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
188}
189
190fn check_output(program: &str, args: &[&str], output: Output) -> Result<RunOutput> {
191 if output.status.success() {
192 Ok(RunOutput {
193 stdout: output.stdout,
194 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
195 })
196 } else {
197 let stderr = String::from_utf8_lossy(&output.stderr);
198 bail!("{program} {} failed: {}", args.join(" "), stderr.trim())
199 }
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205
206 #[test]
209 fn transient_detects_stale() {
210 assert!(is_transient_error("The working copy is stale"));
211 }
212
213 #[test]
214 fn transient_detects_stale_in_context() {
215 assert!(is_transient_error(
216 "jj diff failed: Error: The working copy is stale (not updated since op abc)"
217 ));
218 }
219
220 #[test]
221 fn transient_detects_lock() {
222 assert!(is_transient_error("Unable to create .lock: File exists"));
223 }
224
225 #[test]
226 fn transient_detects_git_index_lock() {
227 assert!(is_transient_error("fatal: Unable to create '/repo/.git/index.lock'"));
228 }
229
230 #[test]
231 fn transient_rejects_config_error() {
232 assert!(!is_transient_error("Config error: no such revision"));
233 }
234
235 #[test]
236 fn transient_rejects_empty() {
237 assert!(!is_transient_error(""));
238 }
239
240 #[test]
241 fn transient_rejects_not_found() {
242 assert!(!is_transient_error("jj not found"));
243 }
244
245 #[test]
248 fn cmd_inherited_succeeds() {
249 run_cmd_inherited("true", &[]).expect("true should succeed");
250 }
251
252 #[test]
253 fn cmd_inherited_fails_on_nonzero() {
254 let result = run_cmd_inherited("false", &[]);
255 assert!(result.is_err());
256 let msg = result.expect_err("should fail").to_string();
257 assert!(msg.contains("false"), "error should name the program");
258 }
259
260 #[test]
261 fn cmd_inherited_fails_on_missing_binary() {
262 let result = run_cmd_inherited("nonexistent_binary_xyz_42", &[]);
263 assert!(result.is_err());
264 }
265
266 #[test]
269 fn cmd_captured_succeeds() {
270 let output = run_cmd("echo", &["hello"]).expect("echo should succeed");
271 assert_eq!(output.stdout_lossy().trim(), "hello");
272 }
273
274 #[test]
275 fn cmd_captured_fails_on_nonzero() {
276 let result = run_cmd("false", &[]);
277 assert!(result.is_err());
278 }
279
280 #[test]
281 fn cmd_captured_captures_stderr() {
282 let result = run_cmd("sh", &["-c", "echo err >&2; exit 1"]);
283 let msg = result.expect_err("should fail").to_string();
284 assert!(msg.contains("err"), "error should include stderr content");
285 }
286
287 #[test]
290 fn cmd_in_runs_in_directory() {
291 let tmp = tempfile::tempdir().expect("tempdir");
292 let output = run_cmd_in(tmp.path(), "pwd", &[]).expect("pwd should work");
293 let pwd = output.stdout_lossy().trim().to_string();
294 let expected = tmp.path().canonicalize().expect("canonicalize");
295 let actual = std::path::Path::new(&pwd).canonicalize().expect("canonicalize pwd");
296 assert_eq!(actual, expected);
297 }
298
299 #[test]
300 fn cmd_in_fails_on_nonzero() {
301 let tmp = tempfile::tempdir().expect("tempdir");
302 let result = run_cmd_in(tmp.path(), "false", &[]);
303 assert!(result.is_err());
304 }
305
306 #[test]
307 fn cmd_in_fails_on_nonexistent_dir() {
308 let result = run_cmd_in(std::path::Path::new("/nonexistent_dir_xyz_42"), "echo", &["hi"]);
309 assert!(result.is_err());
310 }
311
312 #[test]
315 fn cmd_in_with_env_sets_variable() {
316 let tmp = tempfile::tempdir().expect("tempdir");
317 let output = run_cmd_in_with_env(
318 tmp.path(),
319 "sh",
320 &["-c", "echo $TEST_VAR_XYZ"],
321 &[("TEST_VAR_XYZ", "hello_from_env")],
322 )
323 .expect("should succeed");
324 assert_eq!(output.stdout_lossy().trim(), "hello_from_env");
325 }
326
327 #[test]
328 fn cmd_in_with_env_multiple_vars() {
329 let tmp = tempfile::tempdir().expect("tempdir");
330 let output = run_cmd_in_with_env(
331 tmp.path(),
332 "sh",
333 &["-c", "echo ${A}_${B}"],
334 &[("A", "foo"), ("B", "bar")],
335 )
336 .expect("should succeed");
337 assert_eq!(output.stdout_lossy().trim(), "foo_bar");
338 }
339
340 #[test]
341 fn cmd_in_with_env_empty_env_same_as_cmd_in() {
342 let tmp = tempfile::tempdir().expect("tempdir");
343 let output = run_cmd_in_with_env(tmp.path(), "pwd", &[], &[])
344 .expect("should succeed");
345 let pwd = output.stdout_lossy().trim().to_string();
346 let expected = tmp.path().canonicalize().expect("canonicalize");
347 let actual = std::path::Path::new(&pwd).canonicalize().expect("canonicalize pwd");
348 assert_eq!(actual, expected);
349 }
350
351 #[test]
352 fn cmd_in_with_env_overrides_existing_var() {
353 let tmp = tempfile::tempdir().expect("tempdir");
354 let output = run_cmd_in_with_env(
355 tmp.path(),
356 "sh",
357 &["-c", "echo $HOME"],
358 &[("HOME", "/fake/home")],
359 )
360 .expect("should succeed");
361 assert_eq!(output.stdout_lossy().trim(), "/fake/home");
362 }
363
364 #[test]
365 fn cmd_in_with_env_fails_on_nonzero() {
366 let tmp = tempfile::tempdir().expect("tempdir");
367 let result = run_cmd_in_with_env(
368 tmp.path(),
369 "sh",
370 &["-c", "exit 1"],
371 &[("IRRELEVANT", "value")],
372 );
373 assert!(result.is_err());
374 }
375
376 #[test]
379 fn stdout_lossy_valid_utf8() {
380 let output = RunOutput {
381 stdout: b"hello world".to_vec(),
382 stderr: String::new(),
383 };
384 assert_eq!(output.stdout_lossy(), "hello world");
385 }
386
387 #[test]
388 fn stdout_lossy_invalid_utf8() {
389 let output = RunOutput {
390 stdout: vec![0xff, 0xfe, b'a', b'b'],
391 stderr: String::new(),
392 };
393 let s = output.stdout_lossy();
394 assert!(s.contains("ab"), "valid bytes should be preserved");
395 assert!(s.contains('�'), "invalid bytes should become replacement char");
396 }
397
398 #[test]
399 fn stdout_raw_bytes_preserved() {
400 let bytes: Vec<u8> = (0..=255).collect();
401 let output = RunOutput {
402 stdout: bytes.clone(),
403 stderr: String::new(),
404 };
405 assert_eq!(output.stdout, bytes);
406 }
407
408 #[test]
409 fn run_output_debug_impl() {
410 let output = RunOutput {
411 stdout: b"hello".to_vec(),
412 stderr: "warn".to_string(),
413 };
414 let debug = format!("{output:?}");
415 assert!(debug.contains("warn"));
416 assert!(debug.contains("stdout"));
417 }
418
419 #[test]
422 fn binary_available_true_returns_true() {
423 assert!(binary_available("echo"));
424 }
425
426 #[test]
427 fn binary_available_missing_returns_false() {
428 assert!(!binary_available("nonexistent_binary_xyz_42"));
429 }
430
431 #[test]
432 fn binary_version_missing_returns_none() {
433 assert!(binary_version("nonexistent_binary_xyz_42").is_none());
434 }
435
436 #[test]
439 fn run_jj_version_succeeds() {
440 if !binary_available("jj") {
441 return;
442 }
443 let tmp = tempfile::tempdir().expect("tempdir");
444 let output = run_jj(tmp.path(), &["--version"]).expect("jj --version should work");
445 assert!(output.stdout_lossy().contains("jj"));
446 }
447
448 #[test]
449 fn run_jj_fails_in_non_repo() {
450 if !binary_available("jj") {
451 return;
452 }
453 let tmp = tempfile::tempdir().expect("tempdir");
454 assert!(run_jj(tmp.path(), &["status"]).is_err());
455 }
456
457 #[test]
460 fn run_git_version_succeeds() {
461 if !binary_available("git") {
462 return;
463 }
464 let tmp = tempfile::tempdir().expect("tempdir");
465 let output = run_git(tmp.path(), &["--version"]).expect("git --version should work");
466 assert!(output.stdout_lossy().contains("git"));
467 }
468
469 #[test]
470 fn run_git_fails_in_non_repo() {
471 if !binary_available("git") {
472 return;
473 }
474 let tmp = tempfile::tempdir().expect("tempdir");
475 assert!(run_git(tmp.path(), &["status"]).is_err());
476 }
477
478 #[test]
481 fn check_output_preserves_stderr_on_success() {
482 let output = run_cmd("sh", &["-c", "echo ok; echo warn >&2"])
483 .expect("should succeed");
484 assert_eq!(output.stdout_lossy().trim(), "ok");
485 assert_eq!(output.stderr.trim(), "warn");
486 }
487
488 #[test]
491 fn retry_accepts_closure() {
492 let custom_keyword = "custom_transient".to_string();
493 let checker = |err: &str| err.contains(custom_keyword.as_str());
494 assert!(!checker("some other error"));
495 assert!(checker("this is custom_transient error"));
496 }
497}