#![cfg_attr(docsrs, feature(doc_cfg))]
#![deny(rustdoc::broken_intra_doc_links)]
use std::time::Duration;
use processkit::{Error, Result};
pub fn reject_flag_like(program: &str, what: &str, value: &str) -> Result<()> {
if value.trim().is_empty() || value.starts_with('-') {
return Err(Error::Spawn {
program: program.to_string(),
source: std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"{what} {value:?} would be parsed as a flag (or is empty) — \
refusing to pass it as a positional argument"
),
),
});
}
Ok(())
}
pub const FETCH_ATTEMPTS: u32 = 3;
pub const FETCH_BACKOFF: Duration = Duration::from_millis(500);
const CONFLICT_MARKERS: &[&str] = &["conflict (", "automatic merge failed"];
const NOTHING_TO_COMMIT_MARKERS: &[&str] = &["nothing to commit", "nothing added to commit"];
const TRANSIENT_FETCH_MARKERS: &[&str] = &[
"could not resolve host",
"couldn't resolve host",
"temporary failure in name resolution",
"connection timed out",
"connection refused",
"operation timed out",
"timed out",
"network is unreachable",
"failed to connect",
"could not read from remote repository",
"the remote end hung up",
"early eof",
"rpc failed",
];
fn exit_output_matches(err: &Error, markers: &[&str]) -> bool {
let Error::Exit { stdout, stderr, .. } = err else {
return false;
};
let out = stdout.to_ascii_lowercase();
let errt = stderr.to_ascii_lowercase();
markers.iter().any(|m| out.contains(m) || errt.contains(m))
}
pub fn is_merge_conflict(err: &Error) -> bool {
exit_output_matches(err, CONFLICT_MARKERS)
}
pub fn is_nothing_to_commit(err: &Error) -> bool {
exit_output_matches(err, NOTHING_TO_COMMIT_MARKERS)
}
pub fn is_transient_fetch_error(err: &Error) -> bool {
matches!(err, Error::Timeout { .. }) || exit_output_matches(err, TRANSIENT_FETCH_MARKERS)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_empty_and_leading_dash() {
assert!(reject_flag_like("git", "branch name", "-evil").is_err());
assert!(reject_flag_like("git", "branch name", "").is_err());
assert!(reject_flag_like("git", "branch name", " ").is_err());
assert!(reject_flag_like("git", "branch name", "\t").is_err());
assert!(reject_flag_like("git", "branch name", "feature").is_ok());
let err = reject_flag_like("jj", "revset", "--remote").unwrap_err();
assert!(matches!(err, Error::Spawn { program, .. } if program == "jj"));
}
#[test]
fn classifies_merge_conflict() {
let on_stdout = Error::Exit {
program: "git".into(),
code: 1,
stdout: "CONFLICT (content): Merge conflict in a.rs".into(),
stderr: String::new(),
};
let on_stderr = Error::Exit {
program: "git".into(),
code: 1,
stdout: String::new(),
stderr: "Automatic merge failed; fix conflicts and then commit".into(),
};
let unrelated = Error::Exit {
program: "git".into(),
code: 128,
stdout: String::new(),
stderr: "fatal: not a git repository".into(),
};
assert!(is_merge_conflict(&on_stdout));
assert!(is_merge_conflict(&on_stderr));
assert!(!is_merge_conflict(&unrelated));
assert!(!is_nothing_to_commit(&on_stdout));
}
#[test]
fn classifies_nothing_to_commit_and_transient_fetch() {
let nothing = Error::Exit {
program: "git".into(),
code: 1,
stdout: "nothing to commit, working tree clean".into(),
stderr: String::new(),
};
assert!(is_nothing_to_commit(¬hing));
let dns = Error::Exit {
program: "git".into(),
code: 128,
stdout: String::new(),
stderr: "fatal: unable to access 'https://x/': Could not resolve host: x".into(),
};
assert!(is_transient_fetch_error(&dns));
assert!(!is_transient_fetch_error(¬hing));
let timeout = Error::Timeout {
program: "git".into(),
timeout: Duration::from_secs(10),
};
assert!(is_transient_fetch_error(&timeout));
}
#[test]
fn unfamiliar_error_variants_are_not_classified() {
let not_ready = Error::NotReady {
program: "git".into(),
timeout: Duration::from_secs(5),
};
let unsupported = Error::Unsupported {
operation: "suspend".into(),
};
for err in [¬_ready, &unsupported] {
assert!(!is_merge_conflict(err));
assert!(!is_nothing_to_commit(err));
assert!(!is_transient_fetch_error(err));
}
}
#[cfg(feature = "cancellation")]
#[test]
fn cancelled_is_not_transient_or_otherwise_classified() {
let cancelled = Error::Cancelled {
program: "git".into(),
};
assert!(!is_transient_fetch_error(&cancelled));
assert!(!is_merge_conflict(&cancelled));
assert!(!is_nothing_to_commit(&cancelled));
}
}