diff --git a/Cargo.lock b/Cargo.lock index 4989afbf6..470b54b7f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4680,6 +4680,7 @@ dependencies = [ "uv-cache-key", "uv-fs", "uv-static", + "which", ] [[package]] diff --git a/crates/uv-git/Cargo.toml b/crates/uv-git/Cargo.toml index 82b85f660..e7e20de8f 100644 --- a/crates/uv-git/Cargo.toml +++ b/crates/uv-git/Cargo.toml @@ -32,3 +32,4 @@ thiserror = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } url = { workspace = true } +which = { workspace = true } diff --git a/crates/uv-git/src/git.rs b/crates/uv-git/src/git.rs index d0c5d033a..49a04af6e 100644 --- a/crates/uv-git/src/git.rs +++ b/crates/uv-git/src/git.rs @@ -4,6 +4,7 @@ use std::fmt::Display; use std::path::{Path, PathBuf}; use std::str::{self, FromStr}; +use std::sync::LazyLock; use crate::sha::GitOid; use crate::GitSha; @@ -11,6 +12,7 @@ use anyhow::{anyhow, Context, Result}; use cargo_util::{paths, ProcessBuilder}; use reqwest::StatusCode; use reqwest_middleware::ClientWithMiddleware; + use tracing::debug; use url::Url; use uv_fs::Simplified; @@ -20,6 +22,9 @@ use uv_static::EnvVars; /// checkout is ready to go. See [`GitCheckout::reset`] for why we need this. const CHECKOUT_READY_LOCK: &str = ".ok"; +/// A global cache of the result of `which git`. +pub static GIT: LazyLock> = LazyLock::new(|| which::which("git")); + /// A reference to commit or commit-ish. #[derive( Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord, serde::Serialize, serde::Deserialize, @@ -158,7 +163,7 @@ impl GitRepository { /// Opens an existing Git repository at `path`. pub(crate) fn open(path: &Path) -> Result { // Make sure there is a Git repository at the specified path. - ProcessBuilder::new("git") + ProcessBuilder::new(GIT.as_ref()?) .arg("rev-parse") .cwd(path) .exec_with_output()?; @@ -177,7 +182,7 @@ impl GitRepository { // opts.external_template(false); // Initialize the repository. - ProcessBuilder::new("git") + ProcessBuilder::new(GIT.as_ref()?) .arg("init") .cwd(path) .exec_with_output()?; @@ -189,7 +194,7 @@ impl GitRepository { /// Parses the object ID of the given `refname`. fn rev_parse(&self, refname: &str) -> Result { - let result = ProcessBuilder::new("git") + let result = ProcessBuilder::new(GIT.as_ref()?) .arg("rev-parse") .arg(refname) .cwd(&self.path) @@ -295,7 +300,7 @@ impl GitDatabase { /// Get a short OID for a `revision`, usually 7 chars or more if ambiguous. pub(crate) fn to_short_id(&self, revision: GitOid) -> Result { - let output = ProcessBuilder::new("git") + let output = ProcessBuilder::new(GIT.as_ref()?) .arg("rev-parse") .arg("--short") .arg(revision.as_str()) @@ -372,7 +377,7 @@ impl GitCheckout { // Perform a local clone of the repository, which will attempt to use // hardlinks to set up the repository. This should speed up the clone operation // quite a bit if it works. - ProcessBuilder::new("git") + ProcessBuilder::new(GIT.as_ref()?) .arg("clone") .arg("--local") // Make sure to pass the local file path and not a file://... url. If given a url, @@ -418,7 +423,7 @@ impl GitCheckout { debug!("reset {} to {}", self.repo.path.display(), self.revision); // Perform the hard reset. - ProcessBuilder::new("git") + ProcessBuilder::new(GIT.as_ref()?) .arg("reset") .arg("--hard") .arg(self.revision.as_str()) @@ -426,7 +431,7 @@ impl GitCheckout { .exec_with_output()?; // Update submodules (`git submodule update --recursive`). - ProcessBuilder::new("git") + ProcessBuilder::new(GIT.as_ref()?) .arg("submodule") .arg("update") .arg("--recursive") @@ -592,7 +597,7 @@ fn fetch_with_cli( refspecs: &[String], tags: bool, ) -> Result<()> { - let mut cmd = ProcessBuilder::new("git"); + let mut cmd = ProcessBuilder::new(GIT.as_ref()?); cmd.arg("fetch"); if tags { cmd.arg("--tags"); diff --git a/crates/uv-git/src/lib.rs b/crates/uv-git/src/lib.rs index 2ba6bca83..77b85ce99 100644 --- a/crates/uv-git/src/lib.rs +++ b/crates/uv-git/src/lib.rs @@ -1,7 +1,7 @@ use url::Url; pub use crate::credentials::{store_credentials_from_url, GIT_STORE}; -pub use crate::git::GitReference; +pub use crate::git::{GitReference, GIT}; pub use crate::resolver::{ GitResolver, GitResolverError, RepositoryReference, ResolvedRepositoryReference, }; diff --git a/crates/uv/src/commands/project/init.rs b/crates/uv/src/commands/project/init.rs index 3f933a3d0..4e2b8fc81 100644 --- a/crates/uv/src/commands/project/init.rs +++ b/crates/uv/src/commands/project/init.rs @@ -11,6 +11,7 @@ use uv_cli::AuthorFrom; use uv_client::{BaseClientBuilder, Connectivity}; use uv_configuration::{VersionControlError, VersionControlSystem}; use uv_fs::{Simplified, CWD}; +use uv_git::GIT; use uv_pep440::Version; use uv_pep508::PackageName; use uv_python::{ @@ -896,14 +897,14 @@ fn get_author_info(path: &Path, author_from: AuthorFrom) -> Option { /// Fetch the default author from git configuration. fn get_author_from_git(path: &Path) -> Result { - let Ok(git) = which::which("git") else { + let Ok(git) = GIT.as_ref() else { anyhow::bail!("`git` not found in PATH") }; let mut name = None; let mut email = None; - let output = Command::new(&git) + let output = Command::new(git) .arg("config") .arg("--get") .arg("user.name") @@ -915,7 +916,7 @@ fn get_author_from_git(path: &Path) -> Result { name = Some(String::from_utf8_lossy(&output.stdout).trim().to_string()); } - let output = Command::new(&git) + let output = Command::new(git) .arg("config") .arg("--get") .arg("user.email") diff --git a/crates/uv/tests/it/pip_compile.rs b/crates/uv/tests/it/pip_compile.rs index a5070e262..a7ebc4c68 100644 --- a/crates/uv/tests/it/pip_compile.rs +++ b/crates/uv/tests/it/pip_compile.rs @@ -11397,6 +11397,9 @@ fn git_source_refs() -> Result<()> { fn git_source_missing_tag() -> Result<()> { let context = TestContext::new("3.12"); + let mut filters = context.filters(); + filters.push(("`.*/git fetch (.*)`", "`git fetch $1`")); + let pyproject_toml = context.temp_dir.child("pyproject.toml"); pyproject_toml.write_str(indoc! {r#" [project] @@ -11410,7 +11413,7 @@ fn git_source_missing_tag() -> Result<()> { uv-public-pypackage = { git = "https://github.com/astral-test/uv-public-pypackage", tag = "missing" } "#})?; - uv_snapshot!(context.filters(), context.pip_compile() + uv_snapshot!(filters, context.pip_compile() .arg("pyproject.toml"), @r###" success: false exit_code: 2 diff --git a/crates/uv/tests/it/pip_install.rs b/crates/uv/tests/it/pip_install.rs index 4b0c8cfca..aa21a5b5e 100644 --- a/crates/uv/tests/it/pip_install.rs +++ b/crates/uv/tests/it/pip_install.rs @@ -1594,7 +1594,7 @@ fn install_git_public_https_missing_branch_or_tag() { let mut filters = context.filters(); // Windows does not style the command the same as Unix, so we must omit it from the snapshot - filters.push(("`git fetch .*`", "`git fetch [...]`")); + filters.push(("`.*/git(.exe)? fetch .*`", "`git fetch [...]`")); filters.push(("exit status", "exit code")); uv_snapshot!(filters, context.pip_install() @@ -1624,7 +1624,7 @@ fn install_git_public_https_missing_commit() { let mut filters = context.filters(); // Windows does not style the command the same as Unix, so we must omit it from the snapshot - filters.push(("`git fetch .*`", "`git fetch [...]`")); + filters.push(("`.*/git(.exe)? fetch .*`", "`git fetch [...]`")); filters.push(("exit status", "exit code")); // There are flakes on Windows where this irrelevant error is appended @@ -1842,6 +1842,7 @@ fn install_git_private_https_pat_not_authorized() { let mut filters = context.filters(); filters.insert(0, (token, "***")); + filters.push(("`.*/git fetch (.*)`", "`git fetch $1`")); // We provide a username otherwise (since the token is invalid), the git cli will prompt for a password // and hang the test