Make uv init resilient against broken git (#12895)
Some checks are pending
CI / Determine changes (push) Waiting to run
CI / cargo clippy | ubuntu (push) Blocked by required conditions
CI / cargo clippy | windows (push) Blocked by required conditions
CI / cargo dev generate-all (push) Blocked by required conditions
CI / cargo shear (push) Waiting to run
CI / cargo test | ubuntu (push) Blocked by required conditions
CI / cargo test | macos (push) Blocked by required conditions
CI / cargo test | windows (push) Blocked by required conditions
CI / check windows trampoline | aarch64 (push) Blocked by required conditions
CI / check windows trampoline | i686 (push) Blocked by required conditions
CI / build binary | windows aarch64 (push) Blocked by required conditions
CI / check windows trampoline | x86_64 (push) Blocked by required conditions
CI / test windows trampoline | i686 (push) Blocked by required conditions
CI / test windows trampoline | x86_64 (push) Blocked by required conditions
CI / typos (push) Waiting to run
CI / mkdocs (push) Waiting to run
CI / check system | python on macos x86-64 (push) Blocked by required conditions
CI / build binary | linux libc (push) Blocked by required conditions
CI / build binary | linux musl (push) Blocked by required conditions
CI / lint (push) Waiting to run
CI / build binary | macos aarch64 (push) Blocked by required conditions
CI / build binary | macos x86_64 (push) Blocked by required conditions
CI / build binary | windows x86_64 (push) Blocked by required conditions
CI / cargo build (msrv) (push) Blocked by required conditions
CI / build binary | freebsd (push) Blocked by required conditions
CI / ecosystem test | pydantic/pydantic-core (push) Blocked by required conditions
CI / ecosystem test | prefecthq/prefect (push) Blocked by required conditions
CI / integration test | pypy on ubuntu (push) Blocked by required conditions
CI / ecosystem test | pallets/flask (push) Blocked by required conditions
CI / smoke test | linux (push) Blocked by required conditions
CI / check system | alpine (push) Blocked by required conditions
CI / smoke test | macos (push) Blocked by required conditions
CI / smoke test | windows x86_64 (push) Blocked by required conditions
CI / smoke test | windows aarch64 (push) Blocked by required conditions
CI / integration test | conda on ubuntu (push) Blocked by required conditions
CI / integration test | deadsnakes python3.9 on ubuntu (push) Blocked by required conditions
CI / integration test | free-threaded on linux (push) Blocked by required conditions
CI / integration test | free-threaded on windows (push) Blocked by required conditions
CI / integration test | pypy on windows (push) Blocked by required conditions
CI / integration test | graalpy on ubuntu (push) Blocked by required conditions
CI / integration test | graalpy on windows (push) Blocked by required conditions
CI / integration test | github actions (push) Blocked by required conditions
CI / integration test | free-threaded python on github actions (push) Blocked by required conditions
CI / integration test | determine publish changes (push) Blocked by required conditions
CI / integration test | uv publish (push) Blocked by required conditions
CI / integration test | uv_build (push) Blocked by required conditions
CI / check cache | ubuntu (push) Blocked by required conditions
CI / check cache | macos aarch64 (push) Blocked by required conditions
CI / check system | python on debian (push) Blocked by required conditions
CI / check system | python on fedora (push) Blocked by required conditions
CI / check system | python on ubuntu (push) Blocked by required conditions
CI / check system | python on opensuse (push) Blocked by required conditions
CI / check system | python on rocky linux 8 (push) Blocked by required conditions
CI / check system | python on rocky linux 9 (push) Blocked by required conditions
CI / check system | pypy on ubuntu (push) Blocked by required conditions
CI / check system | pyston (push) Blocked by required conditions
CI / check system | python on macos aarch64 (push) Blocked by required conditions
CI / check system | homebrew python on macos aarch64 (push) Blocked by required conditions
CI / check system | python3.10 on windows x86-64 (push) Blocked by required conditions
CI / check system | python3.10 on windows x86 (push) Blocked by required conditions
CI / check system | python3.13 on windows x86-64 (push) Blocked by required conditions
CI / check system | x86-64 python3.13 on windows aarch64 (push) Blocked by required conditions
CI / check system | windows registry (push) Blocked by required conditions
CI / check system | python3.12 via chocolatey (push) Blocked by required conditions
CI / check system | python3.9 via pyenv (push) Blocked by required conditions
CI / check system | python3.13 (push) Blocked by required conditions
CI / check system | conda3.11 on macos aarch64 (push) Blocked by required conditions
CI / check system | conda3.8 on macos aarch64 (push) Blocked by required conditions
CI / check system | conda3.11 on linux x86-64 (push) Blocked by required conditions
CI / check system | conda3.8 on linux x86-64 (push) Blocked by required conditions
CI / check system | conda3.11 on windows x86-64 (push) Blocked by required conditions
CI / check system | conda3.8 on windows x86-64 (push) Blocked by required conditions
CI / check system | amazonlinux (push) Blocked by required conditions
CI / check system | embedded python3.10 on windows x86-64 (push) Blocked by required conditions
CI / benchmarks (push) Blocked by required conditions

Currently, `uv init` works without a `git` executable, and with a
working `git` executable, but not with a broken `git`, be it from GitHub
Action's Windows CI or from the shim we insert.

`uv init` calls git twice: Once `git rev-parse` to check whether a git
repo already exists, and then `git init` (if there is no git repository
yet and no `--vcs none`).

By separately handling the cases where git failed during `git rev-parse`
doesn't work vs. where the is no repository when checking for an
existing repo work tree, we can avoid calling `git init` for broken git
and erroring. We have to hardcode the expected git command outputs to be
able to check.
This commit is contained in:
konsti 2025-04-17 17:45:25 +02:00 committed by GitHub
parent 211e712b83
commit 37a71fd26a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 162 additions and 79 deletions

View file

@ -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<Self> {
// 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 {

View file

@ -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.
#[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,
}
/// 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<VersionControlSystem>) -> 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}`");
}
// Otherwise, ignore the specified VCS, since it's already in use.
VersionControlSystem::None
}
// 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.

View file

@ -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";

View file

@ -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());
}