vcs_cli_support/lib.rs
1#![cfg_attr(docsrs, feature(doc_cfg))]
2#![deny(rustdoc::broken_intra_doc_links)]
3//! `vcs-cli-support` — the [`processkit`]-coupled plumbing the CLI wrappers reuse.
4//!
5//! `vcs-git` / `vcs-jj` / `vcs-github` all drive a CLI through [`processkit`], so
6//! they share three concerns that *touch* [`processkit::Error`]: an argv injection
7//! guard, a fetch-retry policy, and a set of [`Error`] classifiers. Extracting them
8//! here keeps the std-only `vcs-diff` clean of the `processkit` dependency, and —
9//! more to the point — keeps the marker lists and classifier logic from drifting
10//! between backends. The wrapper crates re-export these items (so you reach them
11//! as `vcs_git::is_merge_conflict`, not via this crate's name) and rarely name
12//! `vcs-cli-support` directly.
13//!
14//! # The surface
15//!
16//! - **[`reject_flag_like`]** — the injection guard for bare positional argv slots.
17//! A caller value that is empty/whitespace, or starts with `-`, is refused before
18//! spawning (the CLI would parse it as a flag); flag-*value* slots (`-m <msg>`)
19//! are consumed verbatim and skip the check. Wrappers call it with their own
20//! binary name so the surfaced [`Error::Spawn`] names the right `program`.
21//! - **[`FETCH_ATTEMPTS`] / [`FETCH_BACKOFF`]** — the shared transient-retry policy
22//! for `fetch` (one try plus two retries, fixed backoff between them).
23//! - **[`is_merge_conflict`] / [`is_nothing_to_commit`] / [`is_transient_fetch_error`]**
24//! — classify a returned [`Error`] so callers branch on *intent* ("conflict,
25//! resolve it"; "nothing to commit, no-op"; "transient, retry") instead of
26//! matching on error internals. They inspect captured [`Error::Exit`] output
27//! against fixed marker lists (and treat a [`processkit`] [`Error::Timeout`] as
28//! transient); any unfamiliar `#[non_exhaustive]` variant falls through to "no".
29//!
30//! # Recipes
31//!
32//! Classify a failed `fetch` to drive a retry decision — branch on intent, not on
33//! the error's internals:
34//!
35//! ```no_run
36//! use vcs_cli_support::{is_transient_fetch_error, FETCH_ATTEMPTS, FETCH_BACKOFF};
37//! # fn run() -> Result<(), processkit::Error> { todo!() }
38//! # fn demo() -> Result<(), processkit::Error> {
39//! for attempt in 1..=FETCH_ATTEMPTS {
40//! match run() {
41//! Ok(()) => break,
42//! Err(e) if is_transient_fetch_error(&e) && attempt < FETCH_ATTEMPTS => {
43//! std::thread::sleep(FETCH_BACKOFF); // DNS/timeout — worth a retry
44//! }
45//! Err(e) => return Err(e), // anything else: give up
46//! }
47//! }
48//! # Ok(()) }
49//! ```
50
51use std::time::Duration;
52
53use processkit::{Error, Result};
54
55/// Injection guard for bare positional argv slots: a caller-supplied value with a
56/// leading `-` would be parsed by the CLI as a *flag* (verified: `git checkout
57/// -evil` → "unknown switch"; jj likewise), and an empty (or whitespace-only)
58/// value silently changes most commands' meaning. Refuse both before anything
59/// spawns, surfacing an [`Error::Spawn`] naming `program`. Flag-VALUE positions
60/// (`-m <msg>`, `--branch <b>`) don't need this — the CLI consumes the next
61/// token verbatim there.
62pub fn reject_flag_like(program: &str, what: &str, value: &str) -> Result<()> {
63 if value.trim().is_empty() || value.starts_with('-') {
64 return Err(Error::Spawn {
65 program: program.to_string(),
66 source: std::io::Error::new(
67 std::io::ErrorKind::InvalidInput,
68 format!(
69 "{what} {value:?} would be parsed as a flag (or is empty) — \
70 refusing to pass it as a positional argument"
71 ),
72 ),
73 });
74 }
75 Ok(())
76}
77
78/// Total attempts for a transient-retried `fetch` (1 try + 2 retries).
79pub const FETCH_ATTEMPTS: u32 = 3;
80/// Fixed backoff between fetch retries.
81pub const FETCH_BACKOFF: Duration = Duration::from_millis(500);
82
83/// Lower-case substrings marking a merge that stopped on conflicts.
84const CONFLICT_MARKERS: &[&str] = &["conflict (", "automatic merge failed"];
85/// Lower-case substrings marking a commit that found nothing to record.
86const NOTHING_TO_COMMIT_MARKERS: &[&str] = &["nothing to commit", "nothing added to commit"];
87/// Lower-case substrings marking a transient (retryable) network/fetch failure.
88const TRANSIENT_FETCH_MARKERS: &[&str] = &[
89 "could not resolve host",
90 "couldn't resolve host",
91 "temporary failure in name resolution",
92 "connection timed out",
93 "connection refused",
94 "operation timed out",
95 "timed out",
96 "network is unreachable",
97 "failed to connect",
98 "could not read from remote repository",
99 "the remote end hung up",
100 "early eof",
101 "rpc failed",
102];
103
104/// Whether `err` is an [`Error::Exit`] whose captured output contains any marker.
105fn exit_output_matches(err: &Error, markers: &[&str]) -> bool {
106 let Error::Exit { stdout, stderr, .. } = err else {
107 return false;
108 };
109 let out = stdout.to_ascii_lowercase();
110 let errt = stderr.to_ascii_lowercase();
111 markers.iter().any(|m| out.contains(m) || errt.contains(m))
112}
113
114/// Whether a failed `merge`/`merge_commit` stopped on a merge conflict. (jj
115/// surfaces conflicts as state rather than as errors, so this only fires on git
116/// output — see `vcs_core::Error::is_merge_conflict`.)
117pub fn is_merge_conflict(err: &Error) -> bool {
118 exit_output_matches(err, CONFLICT_MARKERS)
119}
120
121/// Whether a failed `commit`/`commit_paths` reported nothing to commit (a clean
122/// tree), as opposed to a real error.
123pub fn is_nothing_to_commit(err: &Error) -> bool {
124 exit_output_matches(err, NOTHING_TO_COMMIT_MARKERS)
125}
126
127/// Whether a failed `fetch`/`fetch_remote_branch`/`remote_branch_exists` looks
128/// transient (DNS, timeout, dropped connection) and is worth retrying.
129pub fn is_transient_fetch_error(err: &Error) -> bool {
130 // A processkit-level timeout (a `.timeout()`-bounded run that expired) carries
131 // no captured output but is inherently transient; treat it as retryable too.
132 matches!(err, Error::Timeout { .. }) || exit_output_matches(err, TRANSIENT_FETCH_MARKERS)
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138
139 #[test]
140 fn rejects_empty_and_leading_dash() {
141 assert!(reject_flag_like("git", "branch name", "-evil").is_err());
142 assert!(reject_flag_like("git", "branch name", "").is_err());
143 // Whitespace-only is as meaning-changing as empty — refuse it too.
144 assert!(reject_flag_like("git", "branch name", " ").is_err());
145 assert!(reject_flag_like("git", "branch name", "\t").is_err());
146 assert!(reject_flag_like("git", "branch name", "feature").is_ok());
147 // The error names the program and surfaces as a spawn-side refusal.
148 let err = reject_flag_like("jj", "revset", "--remote").unwrap_err();
149 assert!(matches!(err, Error::Spawn { program, .. } if program == "jj"));
150 }
151
152 #[test]
153 fn classifies_merge_conflict() {
154 let on_stdout = Error::Exit {
155 program: "git".into(),
156 code: 1,
157 stdout: "CONFLICT (content): Merge conflict in a.rs".into(),
158 stderr: String::new(),
159 };
160 let on_stderr = Error::Exit {
161 program: "git".into(),
162 code: 1,
163 stdout: String::new(),
164 stderr: "Automatic merge failed; fix conflicts and then commit".into(),
165 };
166 let unrelated = Error::Exit {
167 program: "git".into(),
168 code: 128,
169 stdout: String::new(),
170 stderr: "fatal: not a git repository".into(),
171 };
172 assert!(is_merge_conflict(&on_stdout));
173 assert!(is_merge_conflict(&on_stderr));
174 assert!(!is_merge_conflict(&unrelated));
175 assert!(!is_nothing_to_commit(&on_stdout));
176 }
177
178 #[test]
179 fn classifies_nothing_to_commit_and_transient_fetch() {
180 let nothing = Error::Exit {
181 program: "git".into(),
182 code: 1,
183 stdout: "nothing to commit, working tree clean".into(),
184 stderr: String::new(),
185 };
186 assert!(is_nothing_to_commit(¬hing));
187
188 let dns = Error::Exit {
189 program: "git".into(),
190 code: 128,
191 stdout: String::new(),
192 stderr: "fatal: unable to access 'https://x/': Could not resolve host: x".into(),
193 };
194 assert!(is_transient_fetch_error(&dns));
195 assert!(!is_transient_fetch_error(¬hing));
196
197 // A processkit timeout (no captured output) is transient too.
198 let timeout = Error::Timeout {
199 program: "git".into(),
200 timeout: Duration::from_secs(10),
201 };
202 assert!(is_transient_fetch_error(&timeout));
203 }
204
205 // processkit 0.7's `Error` is `#[non_exhaustive]` and grows variants over
206 // time (`NotReady`/`Unsupported` now; `Cancelled`/`ResourceLimit` behind
207 // features). Unfamiliar variants must fall through every classifier to
208 // "no" — a cancelled or unsupported run is neither a conflict, nor a clean
209 // tree, nor worth a fetch retry.
210 #[test]
211 fn unfamiliar_error_variants_are_not_classified() {
212 let not_ready = Error::NotReady {
213 program: "git".into(),
214 timeout: Duration::from_secs(5),
215 };
216 let unsupported = Error::Unsupported {
217 operation: "suspend".into(),
218 };
219 for err in [¬_ready, &unsupported] {
220 assert!(!is_merge_conflict(err));
221 assert!(!is_nothing_to_commit(err));
222 assert!(!is_transient_fetch_error(err));
223 }
224 }
225
226 // processkit 0.8's `cancellation` feature makes `Error::Cancelled` reachable
227 // (a client-level `default_cancel_on` killing an in-flight run). It must fall
228 // through every classifier to "no" — a cancelled fetch was *deliberately*
229 // stopped, so replaying it would fight the cancellation. (Behaviour already
230 // held via the `#[non_exhaustive]` fall-through above; this pins it as a
231 // first-class assertion now that the variant can be constructed.)
232 #[cfg(feature = "cancellation")]
233 #[test]
234 fn cancelled_is_not_transient_or_otherwise_classified() {
235 let cancelled = Error::Cancelled {
236 program: "git".into(),
237 };
238 assert!(!is_transient_fetch_error(&cancelled));
239 assert!(!is_merge_conflict(&cancelled));
240 assert!(!is_nothing_to_commit(&cancelled));
241 }
242}