Skip to main content

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(&nothing));
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(&nothing));
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 [&not_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}