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 let output = Command::new(program)
61 .args(args)
62 .current_dir(dir)
63 .output()
64 .with_context(|| format!("failed to run {program} in {}", dir.display()))?;
65
66 check_output(program, args, output)
67}
68
69pub fn run_jj(repo_path: &Path, args: &[&str]) -> Result<RunOutput> {
71 run_cmd_in(repo_path, "jj", args)
72}
73
74pub fn run_git(repo_path: &Path, args: &[&str]) -> Result<RunOutput> {
76 run_cmd_in(repo_path, "git", args)
77}
78
79pub fn run_with_retry(
88 repo_path: &Path,
89 program: &str,
90 args: &[&str],
91 is_transient: impl Fn(&str) -> bool,
92) -> Result<RunOutput> {
93 let args_owned: Vec<String> = args.iter().map(|s| s.to_string()).collect();
94
95 let op = || {
96 let str_args: Vec<&str> = args_owned.iter().map(|s| s.as_str()).collect();
97 run_cmd_in(repo_path, program, &str_args)
98 };
99
100 op.retry(
101 ExponentialBuilder::default()
102 .with_factor(2.0)
103 .with_min_delay(std::time::Duration::from_millis(100))
104 .with_max_times(3),
105 )
106 .when(|e| is_transient(&e.to_string()))
107 .call()
108}
109
110pub fn run_jj_with_retry(
114 repo_path: &Path,
115 args: &[&str],
116 is_transient: impl Fn(&str) -> bool,
117) -> Result<RunOutput> {
118 run_with_retry(repo_path, "jj", args, is_transient)
119}
120
121pub fn run_git_with_retry(
125 repo_path: &Path,
126 args: &[&str],
127 is_transient: impl Fn(&str) -> bool,
128) -> Result<RunOutput> {
129 run_with_retry(repo_path, "git", args, is_transient)
130}
131
132pub fn is_transient_error(error_msg: &str) -> bool {
138 error_msg.contains("stale") || error_msg.contains(".lock")
139}
140
141pub fn binary_available(name: &str) -> bool {
143 Command::new(name)
144 .arg("--version")
145 .stdout(Stdio::null())
146 .stderr(Stdio::null())
147 .status()
148 .is_ok_and(|s| s.success())
149}
150
151pub fn binary_version(name: &str) -> Option<String> {
153 let output = Command::new(name)
154 .arg("--version")
155 .output()
156 .ok()?;
157 if !output.status.success() {
158 return None;
159 }
160 Some(String::from_utf8_lossy(&output.stdout).trim().to_string())
161}
162
163fn check_output(program: &str, args: &[&str], output: Output) -> Result<RunOutput> {
164 if output.status.success() {
165 Ok(RunOutput {
166 stdout: output.stdout,
167 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
168 })
169 } else {
170 let stderr = String::from_utf8_lossy(&output.stderr);
171 bail!("{program} {} failed: {}", args.join(" "), stderr.trim())
172 }
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178
179 #[test]
182 fn transient_detects_stale() {
183 assert!(is_transient_error("The working copy is stale"));
184 }
185
186 #[test]
187 fn transient_detects_stale_in_context() {
188 assert!(is_transient_error(
189 "jj diff failed: Error: The working copy is stale (not updated since op abc)"
190 ));
191 }
192
193 #[test]
194 fn transient_detects_lock() {
195 assert!(is_transient_error("Unable to create .lock: File exists"));
196 }
197
198 #[test]
199 fn transient_detects_git_index_lock() {
200 assert!(is_transient_error("fatal: Unable to create '/repo/.git/index.lock'"));
201 }
202
203 #[test]
204 fn transient_rejects_config_error() {
205 assert!(!is_transient_error("Config error: no such revision"));
206 }
207
208 #[test]
209 fn transient_rejects_empty() {
210 assert!(!is_transient_error(""));
211 }
212
213 #[test]
214 fn transient_rejects_not_found() {
215 assert!(!is_transient_error("jj not found"));
216 }
217
218 #[test]
221 fn cmd_inherited_succeeds() {
222 run_cmd_inherited("true", &[]).expect("true should succeed");
223 }
224
225 #[test]
226 fn cmd_inherited_fails_on_nonzero() {
227 let result = run_cmd_inherited("false", &[]);
228 assert!(result.is_err());
229 let msg = result.expect_err("should fail").to_string();
230 assert!(msg.contains("false"), "error should name the program");
231 }
232
233 #[test]
234 fn cmd_inherited_fails_on_missing_binary() {
235 let result = run_cmd_inherited("nonexistent_binary_xyz_42", &[]);
236 assert!(result.is_err());
237 }
238
239 #[test]
242 fn cmd_captured_succeeds() {
243 let output = run_cmd("echo", &["hello"]).expect("echo should succeed");
244 assert_eq!(output.stdout_lossy().trim(), "hello");
245 }
246
247 #[test]
248 fn cmd_captured_fails_on_nonzero() {
249 let result = run_cmd("false", &[]);
250 assert!(result.is_err());
251 }
252
253 #[test]
254 fn cmd_captured_captures_stderr() {
255 let result = run_cmd("sh", &["-c", "echo err >&2; exit 1"]);
256 let msg = result.expect_err("should fail").to_string();
257 assert!(msg.contains("err"), "error should include stderr content");
258 }
259
260 #[test]
263 fn cmd_in_runs_in_directory() {
264 let tmp = tempfile::tempdir().expect("tempdir");
265 let output = run_cmd_in(tmp.path(), "pwd", &[]).expect("pwd should work");
266 let pwd = output.stdout_lossy().trim().to_string();
267 let expected = tmp.path().canonicalize().expect("canonicalize");
268 let actual = std::path::Path::new(&pwd).canonicalize().expect("canonicalize pwd");
269 assert_eq!(actual, expected);
270 }
271
272 #[test]
273 fn cmd_in_fails_on_nonzero() {
274 let tmp = tempfile::tempdir().expect("tempdir");
275 let result = run_cmd_in(tmp.path(), "false", &[]);
276 assert!(result.is_err());
277 }
278
279 #[test]
280 fn cmd_in_fails_on_nonexistent_dir() {
281 let result = run_cmd_in(std::path::Path::new("/nonexistent_dir_xyz_42"), "echo", &["hi"]);
282 assert!(result.is_err());
283 }
284
285 #[test]
288 fn stdout_lossy_valid_utf8() {
289 let output = RunOutput {
290 stdout: b"hello world".to_vec(),
291 stderr: String::new(),
292 };
293 assert_eq!(output.stdout_lossy(), "hello world");
294 }
295
296 #[test]
297 fn stdout_lossy_invalid_utf8() {
298 let output = RunOutput {
299 stdout: vec![0xff, 0xfe, b'a', b'b'],
300 stderr: String::new(),
301 };
302 let s = output.stdout_lossy();
303 assert!(s.contains("ab"), "valid bytes should be preserved");
304 assert!(s.contains('�'), "invalid bytes should become replacement char");
305 }
306
307 #[test]
308 fn stdout_raw_bytes_preserved() {
309 let bytes: Vec<u8> = (0..=255).collect();
310 let output = RunOutput {
311 stdout: bytes.clone(),
312 stderr: String::new(),
313 };
314 assert_eq!(output.stdout, bytes);
315 }
316
317 #[test]
318 fn run_output_debug_impl() {
319 let output = RunOutput {
320 stdout: b"hello".to_vec(),
321 stderr: "warn".to_string(),
322 };
323 let debug = format!("{output:?}");
324 assert!(debug.contains("warn"));
325 assert!(debug.contains("stdout"));
326 }
327
328 #[test]
331 fn binary_available_true_returns_true() {
332 assert!(binary_available("echo"));
333 }
334
335 #[test]
336 fn binary_available_missing_returns_false() {
337 assert!(!binary_available("nonexistent_binary_xyz_42"));
338 }
339
340 #[test]
341 fn binary_version_missing_returns_none() {
342 assert!(binary_version("nonexistent_binary_xyz_42").is_none());
343 }
344
345 #[test]
348 fn run_jj_version_succeeds() {
349 if !binary_available("jj") {
350 return;
351 }
352 let tmp = tempfile::tempdir().expect("tempdir");
353 let output = run_jj(tmp.path(), &["--version"]).expect("jj --version should work");
354 assert!(output.stdout_lossy().contains("jj"));
355 }
356
357 #[test]
358 fn run_jj_fails_in_non_repo() {
359 if !binary_available("jj") {
360 return;
361 }
362 let tmp = tempfile::tempdir().expect("tempdir");
363 assert!(run_jj(tmp.path(), &["status"]).is_err());
364 }
365
366 #[test]
369 fn run_git_version_succeeds() {
370 if !binary_available("git") {
371 return;
372 }
373 let tmp = tempfile::tempdir().expect("tempdir");
374 let output = run_git(tmp.path(), &["--version"]).expect("git --version should work");
375 assert!(output.stdout_lossy().contains("git"));
376 }
377
378 #[test]
379 fn run_git_fails_in_non_repo() {
380 if !binary_available("git") {
381 return;
382 }
383 let tmp = tempfile::tempdir().expect("tempdir");
384 assert!(run_git(tmp.path(), &["status"]).is_err());
385 }
386
387 #[test]
390 fn check_output_preserves_stderr_on_success() {
391 let output = run_cmd("sh", &["-c", "echo ok; echo warn >&2"])
392 .expect("should succeed");
393 assert_eq!(output.stdout_lossy().trim(), "ok");
394 assert_eq!(output.stderr.trim(), "warn");
395 }
396
397 #[test]
400 fn retry_accepts_closure() {
401 let custom_keyword = "custom_transient".to_string();
402 let checker = |err: &str| err.contains(custom_keyword.as_str());
403 assert!(!checker("some other error"));
404 assert!(checker("this is custom_transient error"));
405 }
406}