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
54 changes: 15 additions & 39 deletions crates/uv-configuration/src/vcs.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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.
Expand All @@ -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 {
Expand Down
122 changes: 84 additions & 38 deletions crates/uv/src/commands/project/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<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}`");
}
#[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 `{}`",
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
"`git rev-parse --is-inside-work-tree` failed to launch for `{}`",
"`git rev-parse --is-inside-work-tree` failed for `{}`",

Copy link
Member

Choose a reason for hiding this comment

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

Actually, I might prefer it as-is 🤔 do what you prefer!

Copy link
Member Author

Choose a reason for hiding this comment

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

I like the current versions for pointing out that this isn't case of a non-successful exist status, but one where git failed to spawn as a subprocess.

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<()> {
// 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.
Expand Down
2 changes: 1 addition & 1 deletion crates/uv/tests/it/common/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
61 changes: 61 additions & 0 deletions crates/uv/tests/it/init.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Comment on lines +3771 to +3778
Copy link
Member

@zanieb zanieb Apr 16, 2025

Choose a reason for hiding this comment

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

What's the behavior if without --vcs?

Copy link
Member Author

Choose a reason for hiding this comment

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

That's tested above with context.init().arg("working").assert().success();


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