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 2b929cd0b24..b952f24c511 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)] @@ -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. @@ -566,6 +615,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 { diff --git a/src/sources/git/utils.rs b/src/sources/git/utils.rs index 21996626098..91693ff3701 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}; @@ -1131,6 +1131,104 @@ fn has_shallow_lock_file(err: &crate::sources::git::fetch::Error) -> bool { ) } +fn git_version() -> Option { + #[tracing::instrument(skip_all)] + fn git_version() -> Option { + use std::process::Stdio; + + let output = std::process::Command::new("git") + .arg("--version") + .stdin(Stdio::null()) + .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(git_version) +} + +#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +#[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, /// when the config value [`net.git-fetch-with-cli`][1] is set. /// @@ -1153,6 +1251,15 @@ fn fetch_with_cli( ) -> CargoResult<()> { debug!(target: "git-fetch", backend = "git-cli"); + 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) cmd.arg("-c").arg("core.fsmonitor=false"); @@ -1182,12 +1289,24 @@ fn 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"); } 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 @@ -1873,3 +1992,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); + } +}