From b656a7c610a873ce7eac4c5a6989a919ff955c35 Mon Sep 17 00:00:00 2001 From: Ed Page Date: Thu, 27 Aug 2026 16:13:07 -0500 Subject: [PATCH 1/5] fix(git): Improve the error message when git isn't present Really, this is just a smaller, incremental step towards - detecting git is present for an "auto" default - version detecton for using different git features --- src/sources/git/utils.rs | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/sources/git/utils.rs b/src/sources/git/utils.rs index 21996626098..f74db425ad9 100644 --- a/src/sources/git/utils.rs +++ b/src/sources/git/utils.rs @@ -1131,6 +1131,24 @@ fn has_shallow_lock_file(err: &crate::sources::git::fetch::Error) -> bool { ) } +fn is_git_cli_present() -> bool { + #[tracing::instrument(skip_all)] + fn is_git_cli_present() -> bool { + use std::process::Stdio; + + std::process::Command::new("git") + .arg("--version") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok() + } + + static CACHE: std::sync::OnceLock = std::sync::OnceLock::new(); + *CACHE.get_or_init(is_git_cli_present) +} + /// Attempts to use `git` CLI installed on the system to fetch a repository, /// when the config value [`net.git-fetch-with-cli`][1] is set. /// @@ -1153,6 +1171,15 @@ fn fetch_with_cli( ) -> CargoResult<()> { debug!(target: "git-fetch", backend = "git-cli"); + if !is_git_cli_present() { + anyhow::bail!( + "`git` is not available +help: to still use `git` for fetching, please install it +help: to use Cargo's native git support, re-try with `net.git-fetch-with-cli = false` +https://doc.rust-lang.org/cargo/reference/config.html#netgit-fetch-with-cli" + ); + } + let mut cmd = ProcessBuilder::new("git"); // Avoid potential for unused work that may also hang (#15775) cmd.arg("-c").arg("core.fsmonitor=false"); From db50a976af9df347fd67f8ad10b07e2868c74609 Mon Sep 17 00:00:00 2001 From: Ed Page Date: Thu, 27 Aug 2026 19:41:46 -0500 Subject: [PATCH 2/5] refactor(git): Call 'git --version' rather than 'git' --- src/sources/git/utils.rs | 183 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 173 insertions(+), 10 deletions(-) diff --git a/src/sources/git/utils.rs b/src/sources/git/utils.rs index f74db425ad9..6160303bcf5 100644 --- a/src/sources/git/utils.rs +++ b/src/sources/git/utils.rs @@ -1131,22 +1131,102 @@ fn has_shallow_lock_file(err: &crate::sources::git::fetch::Error) -> bool { ) } -fn is_git_cli_present() -> bool { +fn git_version() -> Option { #[tracing::instrument(skip_all)] - fn is_git_cli_present() -> bool { + fn git_version() -> Option { use std::process::Stdio; - std::process::Command::new("git") + let output = std::process::Command::new("git") .arg("--version") .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .is_ok() + .output() + .ok()?; + if !output.status.success() { + return None; + } + let Ok(stdout) = String::from_utf8(output.stdout) else { + return None; + }; + // All else fails, we can at least report the lowest common denominator of what we support + let default_version = GitVersion { + major: 2, + minor: 0, + patch: 0, + }; + Some(GitVersion::from_version_stdout(&stdout).unwrap_or(default_version)) } - static CACHE: std::sync::OnceLock = std::sync::OnceLock::new(); - *CACHE.get_or_init(is_git_cli_present) + static CACHE: std::sync::OnceLock> = std::sync::OnceLock::new(); + *CACHE.get_or_init(git_version) +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +#[allow(unused)] +struct GitVersion { + major: usize, + minor: usize, + patch: usize, +} + +impl GitVersion { + fn from_version_stdout(stdout: &str) -> Result { + // See https://github.com/git/git/blob/f78ce2f7b6df702f93d40b85d6bda92a3f65da79/help.c#L779-L784 + let Some(version) = stdout.strip_prefix("git version ") else { + anyhow::bail!("unrecognized `git --version` output: {stdout}") + }; + let (version, _) = version.split_once(" ").unwrap_or((version, "")); + let (version, _) = version.split_once("\n").unwrap_or((version, "")); + version.parse() + } +} + +impl std::str::FromStr for GitVersion { + type Err = anyhow::Error; + + fn from_str(version: &str) -> Result { + let unreleased = "GIT"; + + let s = version; + let (major, s) = s.split_once(".").unwrap_or((s, "")); + let mut major: usize = major.parse().map_err(|_err| { + anyhow::format_err!("unrecognized major version `{major}` in `{version}`") + })?; + let (minor, s) = s.split_once(".").unwrap_or((s, "")); + let mut minor: usize = if minor == unreleased { + 0 + } else { + minor.parse().map_err(|_err| { + anyhow::format_err!("unrecognized minor version `{minor}` in `{version}`") + })? + }; + let (patch, s) = s.split_once(".").unwrap_or((s, "")); + let mut patch: usize = if patch == unreleased { + 0 + } else { + patch.parse().map_err(|_err| { + anyhow::format_err!("unrecognized patch version `{patch}` in `{version}`") + })? + }; + + let more = s; + if !more.is_empty() { + // `more` can be either pre-release or post-release. + // The difference should be minimal, so be conservative and assume they are pre-release + if let Some(sub) = patch.checked_sub(1) { + patch = sub; + } else if let Some(sub) = minor.checked_sub(1) { + minor = sub; + } else if let Some(sub) = major.checked_sub(1) { + major = sub; + } + } + + Ok(Self { + major, + minor, + patch, + }) + } } /// Attempts to use `git` CLI installed on the system to fetch a repository, @@ -1171,7 +1251,7 @@ fn fetch_with_cli( ) -> CargoResult<()> { debug!(target: "git-fetch", backend = "git-cli"); - if !is_git_cli_present() { + if git_version().is_some() { anyhow::bail!( "`git` is not available help: to still use `git` for fetching, please install it @@ -1900,3 +1980,86 @@ pub(super) fn rev_to_oid(rev: &str) -> Option { .ok() .filter(|oid| oid.as_bytes().len() * 2 == rev.len()) } + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn git_version_empty() { + let input = ""; + GitVersion::from_version_stdout(input).unwrap_err(); + } + + #[test] + fn git_version_version() { + let input = "git version 2.19.0"; + let actual = GitVersion::from_version_stdout(input).unwrap(); + let expected = GitVersion { + major: 2, + minor: 19, + patch: 0, + }; + assert_eq!(actual, expected); + } + + #[test] + fn git_version_version_nl() { + let input = "git version 2.19.0\n"; + let actual = GitVersion::from_version_stdout(input).unwrap(); + let expected = GitVersion { + major: 2, + minor: 19, + patch: 0, + }; + assert_eq!(actual, expected); + } + + #[test] + fn git_version_version_platform() { + let input = "git version 2.19.0 (Apple Git-117)"; + let actual = GitVersion::from_version_stdout(input).unwrap(); + let expected = GitVersion { + major: 2, + minor: 19, + patch: 0, + }; + assert_eq!(actual, expected); + } + + #[test] + fn git_version_version_vendor() { + let input = "git version 2.19.0.windows.1"; + let actual = GitVersion::from_version_stdout(input).unwrap(); + let expected = GitVersion { + major: 2, + minor: 18, + patch: 0, + }; + assert_eq!(actual, expected); + } + + #[test] + fn git_version_version_rc() { + let input = "git version 2.19.0.rc0"; + let actual = GitVersion::from_version_stdout(input).unwrap(); + let expected = GitVersion { + major: 2, + minor: 18, + patch: 0, + }; + assert_eq!(actual, expected); + } + + #[test] + fn git_version_version_unreleased() { + let input = "git version 2.55.GIT"; + let actual = GitVersion::from_version_stdout(input).unwrap(); + let expected = GitVersion { + major: 2, + minor: 55, + patch: 0, + }; + assert_eq!(actual, expected); + } +} From 0160eb959687b59766fd588f5ed99e3c4b5385ff Mon Sep 17 00:00:00 2001 From: Ed Page Date: Thu, 27 Aug 2026 13:12:12 -0500 Subject: [PATCH 3/5] refactor(util): Make room for our own Stdio --- crates/cargo-util/src/process_builder.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/cargo-util/src/process_builder.rs b/crates/cargo-util/src/process_builder.rs index 2b929cd0b24..456052ba250 100644 --- a/crates/cargo-util/src/process_builder.rs +++ b/crates/cargo-util/src/process_builder.rs @@ -13,7 +13,7 @@ use std::fmt; use std::io::{self, Write}; use std::iter::once; use std::path::Path; -use std::process::{Command, ExitStatus, Output, Stdio}; +use std::process::{Command, ExitStatus, Output}; /// A builder object for an external process, similar to [`std::process::Command`]. #[derive(Clone, Debug)] @@ -566,6 +566,8 @@ fn debug_force_argfile(retry_enabled: bool) -> bool { /// Creates new pipes for stderr, stdout, and optionally stdin. fn piped(cmd: &mut Command, pipe_stdin: bool) -> &mut Command { + use std::process::Stdio; + cmd.stdout(Stdio::piped()) .stderr(Stdio::piped()) .stdin(if pipe_stdin { From 7d471e2db2862c935333266a0f8eedf7fa76b951 Mon Sep 17 00:00:00 2001 From: Ed Page Date: Thu, 27 Aug 2026 13:20:57 -0500 Subject: [PATCH 4/5] fix(git): Remove ref status update when showing progress Since progress does not have end-to-end tests, I ran ``` cargo new git-dep cd git-dep cargo add cargo --git https://github.com/rust-lang/cargo/ rm -rf ~/.cargo/git CARGO_NET_GIT_FETCH_WITH_CLI=true nargo check ``` --- crates/cargo-util/src/lib.rs | 2 +- crates/cargo-util/src/process_builder.rs | 49 ++++++++++++++++++++++++ src/sources/git/utils.rs | 21 +++++++--- 3 files changed, 66 insertions(+), 6 deletions(-) diff --git a/crates/cargo-util/src/lib.rs b/crates/cargo-util/src/lib.rs index 09944645a4b..7ffe195207f 100644 --- a/crates/cargo-util/src/lib.rs +++ b/crates/cargo-util/src/lib.rs @@ -8,7 +8,7 @@ pub use self::read2::read2; pub use du::du; -pub use process_builder::ProcessBuilder; +pub use process_builder::{ProcessBuilder, Stdio}; pub use process_error::{ProcessError, exit_status_to_string, is_simple_exit_code}; pub use sha256::Sha256; diff --git a/crates/cargo-util/src/process_builder.rs b/crates/cargo-util/src/process_builder.rs index 456052ba250..b952f24c511 100644 --- a/crates/cargo-util/src/process_builder.rs +++ b/crates/cargo-util/src/process_builder.rs @@ -43,6 +43,8 @@ pub struct ProcessBuilder { retry_with_argfile: bool, /// Data to write to stdin. stdin: Option>, + stdout: Option, + stderr: Option, } impl fmt::Display for ProcessBuilder { @@ -86,6 +88,8 @@ impl ProcessBuilder { display_env_vars: false, retry_with_argfile: false, stdin: None, + stdout: None, + stderr: None, } } @@ -146,6 +150,22 @@ impl ProcessBuilder { self } + /// (chainable) Configure the process's stdout handle + /// + /// Only applies when used with [`Self::status`] and [`Self::exec`] + pub fn stdout>(&mut self, cfg: T) -> &mut ProcessBuilder { + self.stdout = Some(cfg.into()); + self + } + + /// (chainable) Configure the process's stderr handle + /// + /// Only applies when used with [`Self::status`] and [`Self::exec`] + pub fn stderr>(&mut self, cfg: T) -> &mut ProcessBuilder { + self.stderr = Some(cfg.into()); + self + } + /// Gets the executable name. pub fn get_program(&self) -> &OsString { self.wrappers.last().unwrap_or(&self.program) @@ -243,6 +263,12 @@ impl ProcessBuilder { fn _status(&self) -> io::Result { if !debug_force_argfile(self.retry_with_argfile) { let mut cmd = self.build_command(); + if let Some(stdout) = &self.stdout { + cmd.stdout(stdout.to_std()); + } + if let Some(stderr) = &self.stderr { + cmd.stderr(stderr.to_std()); + } match cmd.spawn() { Err(ref e) if self.should_retry_with_argfile(e) => {} Err(e) => return Err(e), @@ -250,6 +276,12 @@ impl ProcessBuilder { } } let (mut cmd, argfile) = self.build_command_with_argfile()?; + if let Some(stdout) = &self.stdout { + cmd.stdout(stdout.to_std()); + } + if let Some(stderr) = &self.stderr { + cmd.stderr(stderr.to_std()); + } let status = cmd.spawn()?.wait(); close_tempfile_and_log_error(argfile); status @@ -557,6 +589,23 @@ impl ProcessBuilder { } } +#[derive(Clone, Debug)] +pub enum Stdio { + Piped, + Inherit, + Null, +} + +impl Stdio { + fn to_std(&self) -> std::process::Stdio { + match self { + Self::Piped => std::process::Stdio::piped(), + Self::Inherit => std::process::Stdio::inherit(), + Self::Null => std::process::Stdio::null(), + } + } +} + /// Forces the command to use `@path` argfile. /// /// You should set `__CARGO_TEST_FORCE_ARGFILE` to enable this. diff --git a/src/sources/git/utils.rs b/src/sources/git/utils.rs index 6160303bcf5..287df2d7182 100644 --- a/src/sources/git/utils.rs +++ b/src/sources/git/utils.rs @@ -15,7 +15,7 @@ use crate::util::{GlobalContext, IntoUrl, MetricsCounter, Progress, network}; use crate::workspace::{GitReference, SourceId}; use anyhow::{Context as _, anyhow}; -use cargo_util::{ProcessBuilder, paths}; +use cargo_util::{ProcessBuilder, Stdio, paths}; use cargo_util_terminal::Verbosity; use git2::{ErrorClass, ObjectType, Oid}; use http::{Request, StatusCode}; @@ -1160,7 +1160,7 @@ fn git_version() -> Option { *CACHE.get_or_init(git_version) } -#[derive(Copy, Clone, Debug, PartialEq, Eq)] +#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] #[allow(unused)] struct GitVersion { major: usize, @@ -1251,14 +1251,14 @@ fn fetch_with_cli( ) -> CargoResult<()> { debug!(target: "git-fetch", backend = "git-cli"); - if git_version().is_some() { + let Some(git_version) = git_version() else { anyhow::bail!( "`git` is not available help: to still use `git` for fetching, please install it help: to use Cargo's native git support, re-try with `net.git-fetch-with-cli = false` https://doc.rust-lang.org/cargo/reference/config.html#netgit-fetch-with-cli" ); - } + }; let mut cmd = ProcessBuilder::new("git"); // Avoid potential for unused work that may also hang (#15775) @@ -1289,7 +1289,18 @@ https://doc.rust-lang.org/cargo/reference/config.html#netgit-fetch-with-cli" }; if gctx.shell().verbosity() == Verbosity::Verbose { cmd.arg("--verbose"); - } else if !progress { + } else if progress { + cmd.arg("--progress"); + let min_porcelain_version = GitVersion { + major: 2, + minor: 41, + patch: 0, + }; + if min_porcelain_version <= git_version { + // Move ref update status to `stdout` and silence it + cmd.arg("--porcelain").stdout(Stdio::Null); + } + } else { cmd.arg("--quiet"); } From 5f3b4bde81e638e5fbf0a78c752c611c117f58e1 Mon Sep 17 00:00:00 2001 From: Ed Page Date: Thu, 27 Aug 2026 13:30:38 -0500 Subject: [PATCH 5/5] fix(git): Override submodule update user config --- src/sources/git/utils.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src/sources/git/utils.rs b/src/sources/git/utils.rs index 287df2d7182..91693ff3701 100644 --- a/src/sources/git/utils.rs +++ b/src/sources/git/utils.rs @@ -1306,6 +1306,7 @@ https://doc.rust-lang.org/cargo/reference/config.html#netgit-fetch-with-cli" cmd.arg("--force") // handle force pushes .arg("--update-head-ok") // see discussion in #2078 + .arg("--recurse-submodules=no") // we handle this, incompatible with `--porcelain` .arg(url) .args(refspecs) // If cargo is run by git (for example, the `exec` command in `git