diff --git a/crates/uv/src/commands/project/mod.rs b/crates/uv/src/commands/project/mod.rs index 56907f74e83fd..8297d2e8991d2 100644 --- a/crates/uv/src/commands/project/mod.rs +++ b/crates/uv/src/commands/project/mod.rs @@ -120,6 +120,9 @@ pub(crate) enum ProjectError { #[error("Environment marker is empty")] EmptyEnvironment, + #[error("Project virtual environment directory `{0}` cannot be used because it has existing, non-virtual environment content")] + InvalidProjectEnvironmentDir(PathBuf), + #[error("Failed to parse `pyproject.toml`")] TomlParse(#[source] toml::de::Error), @@ -488,6 +491,14 @@ pub(crate) async fn get_or_init_environment( FoundInterpreter::Interpreter(interpreter) => { let venv = workspace.venv(); + // Before deleting the target directory, we confirm that it is either (1) a virtual + // environment or (2) an empty directory. + if PythonEnvironment::from_root(&venv, cache).is_err() + && fs_err::read_dir(&venv).is_ok_and(|mut dir| dir.next().is_some()) + { + return Err(ProjectError::InvalidProjectEnvironmentDir(venv)); + } + // Remove the existing virtual environment if it doesn't meet the requirements. match fs_err::remove_dir_all(&venv) { Ok(()) => { diff --git a/crates/uv/tests/sync.rs b/crates/uv/tests/sync.rs index 7fcc80c3d8bb4..ab31ecb58fb75 100644 --- a/crates/uv/tests/sync.rs +++ b/crates/uv/tests/sync.rs @@ -1613,7 +1613,7 @@ fn convert_to_package() -> Result<()> { #[test] fn sync_custom_environment_path() -> Result<()> { - let mut context = TestContext::new("3.12"); + let mut context = TestContext::new_with_versions(&["3.11", "3.12"]); let pyproject_toml = context.temp_dir.child("pyproject.toml"); pyproject_toml.write_str( @@ -1633,6 +1633,8 @@ fn sync_custom_environment_path() -> Result<()> { ----- stdout ----- ----- stderr ----- + Using Python 3.12.[X] interpreter at: [PYTHON-3.12] + Creating virtualenv at: .venv Resolved 2 packages in [TIME] Prepared 1 package in [TIME] Installed 1 package in [TIME] @@ -1733,6 +1735,52 @@ fn sync_custom_environment_path() -> Result<()> { .child(".venv") .assert(predicate::path::is_dir()); + // If the directory already exists and is not a virtual environment we should fail with an error + fs_err::remove_dir_all(context.temp_dir.join("foo"))?; + fs_err::create_dir(context.temp_dir.join("foo"))?; + fs_err::write(context.temp_dir.join("foo").join("file"), b"")?; + uv_snapshot!(context.filters(), context.sync().env("UV_PROJECT_ENVIRONMENT", "foo"), @r###" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + warning: Ignoring existing virtual environment linked to non-existent Python interpreter: foo/bin/python3 + Using Python 3.12.[X] interpreter at: [PYTHON-3.12] + error: Project virtual environment directory `[TEMP_DIR]/foo` cannot be used because it has existing, non-virtual environment content + "###); + + // But if it's just an incompatible virtual environment... + fs_err::remove_dir_all(context.temp_dir.join("foo"))?; + uv_snapshot!(context.filters(), context.venv().arg("foo").arg("--python").arg("3.11"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using Python 3.11.[X] interpreter at: [PYTHON-3.11] + Creating virtualenv at: foo + Activate with: source foo/bin/activate + "###); + + // Even with some extraneous content... + fs_err::write(context.temp_dir.join("foo").join("file"), b"")?; + + // We can delete and use it + uv_snapshot!(context.filters(), context.sync().env("UV_PROJECT_ENVIRONMENT", "foo"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using Python 3.12.[X] interpreter at: [PYTHON-3.12] + Removed virtual environment at: foo + Creating virtualenv at: foo + Resolved 2 packages in [TIME] + Installed 1 package in [TIME] + + iniconfig==2.0.0 + "###); + Ok(()) }