diff --git a/crates/uv-configuration/src/vcs.rs b/crates/uv-configuration/src/vcs.rs index 4c93dba8cc55f..13689de113e68 100644 --- a/crates/uv-configuration/src/vcs.rs +++ b/crates/uv-configuration/src/vcs.rs @@ -3,7 +3,6 @@ use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use serde::Deserialize; -use tracing::debug; use uv_git::GIT; #[derive(Debug, thiserror::Error)] @@ -40,25 +39,21 @@ impl VersionControlSystem { return Err(VersionControlError::GitNotInstalled); }; - if path.join(".git").try_exists()? { - debug!("Git repository already exists at: `{}`", path.display()); - } else { - let output = Command::new(git) - .arg("init") - .current_dir(path) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .output() - .map_err(VersionControlError::GitCommand)?; - if !output.status.success() { - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(VersionControlError::GitInit( - path.to_path_buf(), - stdout.to_string(), - stderr.to_string(), - )); - } + let output = Command::new(git) + .arg("init") + .current_dir(path) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .map_err(VersionControlError::GitCommand)?; + if !output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(VersionControlError::GitInit( + path.to_path_buf(), + stdout.to_string(), + stderr.to_string(), + )); } // Create the `.gitignore`, if it doesn't exist. @@ -77,25 +72,6 @@ impl VersionControlSystem { Self::None => Ok(()), } } - - /// Detects the VCS system based on the provided path. - pub fn detect(path: &Path) -> Option { - // Determine whether the path is inside a Git work tree. - let git = GIT.as_ref().ok()?; - let exit_status = Command::new(git) - .arg("rev-parse") - .arg("--is-inside-work-tree") - .current_dir(path) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - .ok()?; - if exit_status.success() { - return Some(Self::Git); - } - - None - } } impl std::fmt::Display for VersionControlSystem { diff --git a/crates/uv/src/commands/project/init.rs b/crates/uv/src/commands/project/init.rs index 5d8904249cbfd..a51f5489e423d 100644 --- a/crates/uv/src/commands/project/init.rs +++ b/crates/uv/src/commands/project/init.rs @@ -5,7 +5,7 @@ use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; use std::str::FromStr; -use tracing::{debug, warn}; +use tracing::{debug, trace, warn}; use uv_cache::Cache; use uv_cli::AuthorFrom; use uv_client::BaseClientBuilder; @@ -1184,52 +1184,98 @@ fn generate_package_scripts( Ok(()) } -/// Initialize the version control system at the given path. -fn init_vcs(path: &Path, vcs: Option) -> Result<()> { - // Detect any existing version control system. - let existing = VersionControlSystem::detect(path); - - let implicit = vcs.is_none(); - - let vcs = match (vcs, existing) { - // If no version control system was specified, and none was detected, default to Git. - (None, None) => VersionControlSystem::default(), - // If no version control system was specified, but a VCS was detected, leave it as-is. - (None, Some(existing)) => { - debug!("Detected existing version control system: {existing}"); - VersionControlSystem::None - } - // If the user provides an explicit `--vcs none`, - (Some(VersionControlSystem::None), _) => VersionControlSystem::None, - // If a version control system was specified, use it. - (Some(vcs), None) => vcs, - // If a version control system was specified, but a VCS was detected... - (Some(vcs), Some(existing)) => { - // If they differ, raise an error. - if vcs != existing { - anyhow::bail!("The project is already in a version control system (`{existing}`); cannot initialize with `--vcs {vcs}`"); - } +#[derive(Debug, Clone)] +enum GitDiscoveryResult { + /// Git is initialized at the path. + Repository, + /// Git is not initialized at the path. + NoRepository, + /// There is no `git[.exe]` binary in PATH. + NoGit, + /// There is a `git[.exe]` binary in PATH, but it returned an unexpected output. + BrokenGit, +} - // Otherwise, ignore the specified VCS, since it's already in use. - VersionControlSystem::None +/// Checks if there is a Git work tree at the given path. +fn detect_git_repository(path: &Path) -> GitDiscoveryResult { + // Determine whether the path is inside a Git work tree. + let Ok(git) = GIT.as_ref() else { + return GitDiscoveryResult::NoGit; + }; + let Ok(output) = Command::new(git) + .arg("rev-parse") + .arg("--is-inside-work-tree") + .current_dir(path) + .output() + else { + debug!( + "`git rev-parse --is-inside-work-tree` failed to launch for `{}`", + path.display() + ); + return GitDiscoveryResult::BrokenGit; + }; + if output.status.success() { + if std::str::from_utf8(&output.stdout).map(str::trim) == Ok("true") { + debug!("Found a Git repository for `{}`", path.display()); + GitDiscoveryResult::Repository + } else { + debug!( + "`git rev-parse --is-inside-work-tree` succeeded but didn't return `true` for `{}`", + path.display() + ); + trace!( + "`git rev-parse --is-inside-work-tree` stdout: {:?}", + String::from_utf8_lossy(&output.stdout) + ); + GitDiscoveryResult::BrokenGit + } + } else { + if std::str::from_utf8(&output.stderr).is_ok_and(|err| err.contains("not a git repository")) + { + debug!("Not a Git repository `{}`", path.display()); + GitDiscoveryResult::NoRepository + } else { + debug!( + "`git rev-parse --is-inside-work-tree` failed but didn't contain `not a git repository` in stderr for `{}`", + path.display() + ); + GitDiscoveryResult::BrokenGit } + } +} + +/// Initialize the version control system at the given path, if applicable. +fn init_vcs(path: &Path, vcs: Option) -> Result<()> { + // vcs is None for an existing repository because we don't want to initialize again. + let (vcs, implicit) = match vcs { + None => match detect_git_repository(path) { + GitDiscoveryResult::NoRepository => (VersionControlSystem::Git, true), + GitDiscoveryResult::Repository + | GitDiscoveryResult::NoGit + | GitDiscoveryResult::BrokenGit => (VersionControlSystem::None, false), + }, + Some(VersionControlSystem::None) => (VersionControlSystem::None, false), + // The user requested Git explicitly, so the only reason not to invoke it is that Git is + // already initialized. In case of an error (broken git), we will raise the real error + // when trying to initialize, which should give us a better error message. + Some(VersionControlSystem::Git) => match detect_git_repository(path) { + GitDiscoveryResult::NoRepository + | GitDiscoveryResult::BrokenGit + | GitDiscoveryResult::NoGit => (VersionControlSystem::Git, false), + GitDiscoveryResult::Repository => (VersionControlSystem::None, false), + }, }; // Attempt to initialize the VCS. match vcs.init(path) { - Ok(()) => (), + Ok(()) => Ok(()), // If the VCS isn't installed, only raise an error if a VCS was explicitly specified. - Err(err @ VersionControlError::GitNotInstalled) => { - if implicit { - debug!("Failed to initialize version control: {err}"); - } else { - return Err(err.into()); - } + Err(err @ VersionControlError::GitNotInstalled) if implicit => { + debug!("Failed to initialize version control: {err}"); + Ok(()) } - Err(err) => return Err(err.into()), + Err(err) => Err(err.into()), } - - Ok(()) } /// Try to get the author information. diff --git a/crates/uv/tests/it/common/mod.rs b/crates/uv/tests/it/common/mod.rs index 58fc2dd17b1bb..a73965e4e4e58 100644 --- a/crates/uv/tests/it/common/mod.rs +++ b/crates/uv/tests/it/common/mod.rs @@ -619,7 +619,7 @@ impl TestContext { command } - fn disallow_git_cli(bin_dir: &Path) -> std::io::Result<()> { + pub fn disallow_git_cli(bin_dir: &Path) -> std::io::Result<()> { let contents = r"#!/bin/sh echo 'error: `git` operations are not allowed — are you missing a cfg for the `git` feature?' >&2 exit 127"; diff --git a/crates/uv/tests/it/init.rs b/crates/uv/tests/it/init.rs index 539b1dae2d114..ccb397490f3d7 100644 --- a/crates/uv/tests/it/init.rs +++ b/crates/uv/tests/it/init.rs @@ -3732,3 +3732,64 @@ fn init_python_variant() -> Result<()> { Ok(()) } + +/// Check how `uv init` reacts to working and broken git with different `--vcs` options. +#[test] +fn git_states() { + let context = TestContext::new("3.12"); + + // First, with working git. + + context.init().arg("working").assert().success(); + assert!(context.temp_dir.child("working/.git").is_dir()); + + context + .init() + .arg("working-no-git") + .arg("--vcs") + .arg("none") + .assert() + .success(); + assert!(!context.temp_dir.child("working-no-git/.git").is_dir()); + + context + .init() + .arg("working-git") + .arg("--vcs") + .arg("git") + .assert() + .success(); + assert!(context.temp_dir.child("working-git/.git").is_dir()); + + // The same tests again, but with broken git. + TestContext::disallow_git_cli(&context.bin_dir) + .expect("Failed to setup disallowed `git` command"); + + context.init().arg("broken").assert().success(); + assert!(!context.temp_dir.child("broken/.git").is_dir()); + + context + .init() + .arg("broken-no-git") + .arg("--vcs") + .arg("none") + .assert() + .success(); + assert!(!context.temp_dir.child("broken-no-git/.git").is_dir()); + + uv_snapshot!(context.filters(), context + .init() + .arg("broken-git") + .arg("--vcs") + .arg("git"), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Failed to initialize Git repository at `[TEMP_DIR]/broken-git` + stdout: + stderr: error: `git` operations are not allowed — are you missing a cfg for the `git` feature? + "); + assert!(!context.temp_dir.child("broken-git/.git").is_dir()); +}