Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion crates/cargo-util/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

pub use self::read2::read2;
pub use du::du;
pub use process_builder::ProcessBuilder;
pub use process_builder::{ProcessBuilder, Stdio};

@weihanglo weihanglo Aug 27, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: I thought you liked one import per line.

View changes since the review

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file did compound so I did.

Tempted to use nightly rustfmt...

pub use process_error::{ProcessError, exit_status_to_string, is_simple_exit_code};
pub use sha256::Sha256;

Expand Down
53 changes: 52 additions & 1 deletion crates/cargo-util/src/process_builder.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -43,6 +43,8 @@ pub struct ProcessBuilder {
retry_with_argfile: bool,
/// Data to write to stdin.
stdin: Option<Vec<u8>>,
stdout: Option<Stdio>,
stderr: Option<Stdio>,
}

impl fmt::Display for ProcessBuilder {
Expand Down Expand Up @@ -86,6 +88,8 @@ impl ProcessBuilder {
display_env_vars: false,
retry_with_argfile: false,
stdin: None,
stdout: None,
stderr: None,
}
}

Expand Down Expand Up @@ -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<T: Into<Stdio>>(&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<T: Into<Stdio>>(&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)
Expand Down Expand Up @@ -243,13 +263,25 @@ impl ProcessBuilder {
fn _status(&self) -> io::Result<ExitStatus> {
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),
Ok(mut child) => return child.wait(),
}
}
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
Expand Down Expand Up @@ -557,6 +589,23 @@ impl ProcessBuilder {
}
}

#[derive(Clone, Debug)]
pub enum Stdio {

@epage epage Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needed a clonable Stdio, so had to make my own

View changes since the review

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.
Expand All @@ -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 {
Expand Down
206 changes: 204 additions & 2 deletions src/sources/git/utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -1131,6 +1131,104 @@ fn has_shallow_lock_file(err: &crate::sources::git::fetch::Error) -> bool {
)
}

fn git_version() -> Option<GitVersion> {
#[tracing::instrument(skip_all)]
fn git_version() -> Option<GitVersion> {
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<Option<GitVersion>> = 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<Self, anyhow::Error> {
// 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<Self, Self::Err> {
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.
///
Expand All @@ -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");
Expand Down Expand Up @@ -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`

@joshtriplett joshtriplett Aug 27, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And we also don't want these anyway, typically, even if the user has enabled them. They just clone extra data we don't need.

View changes since the review

.arg(url)
.args(refspecs)
// If cargo is run by git (for example, the `exec` command in `git
Expand Down Expand Up @@ -1873,3 +1992,86 @@ pub(super) fn rev_to_oid(rev: &str) -> Option<Oid> {
.ok()
.filter(|oid| oid.as_bytes().len() * 2 == rev.len())
}

#[cfg(test)]
mod test {
Comment thread
epage marked this conversation as resolved.
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);
}
}