From 2a4ff5e86940d714555c8b6d02c96c51aba4d085 Mon Sep 17 00:00:00 2001 From: shikinamiasuka Date: Wed, 30 Jul 2025 10:45:08 +0800 Subject: [PATCH 1/5] Fix symlink preservation in virtual environment creation Fixes issue where `uv venv` would preserve symlinks on the first run but replace them with regular directories. The issue occurred because `Path::metadata()` automatically follows symlinks, causing the removal logic to delete the symlink itself rather than just clearing the target directory contents. Solution: Use `canonicalize()` to resolve symlinks only when removing and recreating virtual environments, ensuring operations target the actual directory while preserving the symlink structure. --- crates/uv-virtualenv/src/virtualenv.rs | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/crates/uv-virtualenv/src/virtualenv.rs b/crates/uv-virtualenv/src/virtualenv.rs index 7c65ec1bf083c..78726d7d58cc1 100644 --- a/crates/uv-virtualenv/src/virtualenv.rs +++ b/crates/uv-virtualenv/src/virtualenv.rs @@ -109,15 +109,21 @@ pub(crate) fn create( } OnExisting::Remove => { debug!("Removing existing {name} due to `--clear`"); - remove_virtualenv(location)?; - fs::create_dir_all(location)?; + let location = location + .canonicalize() + .unwrap_or_else(|_| location.to_path_buf()); + remove_virtualenv(&location)?; + fs::create_dir_all(&location)?; } OnExisting::Fail => { match confirm_clear(location, name)? { Some(true) => { debug!("Removing existing {name} due to confirmation"); - remove_virtualenv(location)?; - fs::create_dir_all(location)?; + let location = location + .canonicalize() + .unwrap_or_else(|_| location.to_path_buf()); + remove_virtualenv(&location)?; + fs::create_dir_all(&location)?; } Some(false) => { let hint = format!( From c31606e0a97fe11bbd50e79654d02ef680a32c06 Mon Sep 17 00:00:00 2001 From: shikinamiasuka Date: Thu, 31 Jul 2025 10:33:50 +0800 Subject: [PATCH 2/5] Add tests for symlink preservation in virtual environment creation --- crates/uv/tests/it/venv.rs | 187 +++++++++++++++++++++++++++++++++++++ 1 file changed, 187 insertions(+) diff --git a/crates/uv/tests/it/venv.rs b/crates/uv/tests/it/venv.rs index 726d1731bfd0d..51d12e1809ac5 100644 --- a/crates/uv/tests/it/venv.rs +++ b/crates/uv/tests/it/venv.rs @@ -1388,3 +1388,190 @@ fn venv_python_preference() { Activate with: source .venv/[BIN]/activate "); } + +#[test] +#[cfg(unix)] +fn create_venv_symlink_clear_preservation() -> Result<()> { + use std::os::unix::fs::symlink; + + let context = TestContext::new_with_versions(&["3.12"]); + + // Create a target directory + let target_dir = context.temp_dir.child("target"); + target_dir.create_dir_all()?; + + // Create a symlink pointing to the target directory + let symlink_path = context.temp_dir.child(".venv"); + symlink(&target_dir, &symlink_path)?; + + // Verify symlink exists + assert!(symlink_path.path().is_symlink()); + + // Create virtual environment at symlink location + uv_snapshot!(context.filters(), context.venv() + .arg(symlink_path.as_os_str()) + .arg("--python") + .arg("3.12"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Creating virtual environment at: .venv + Activate with: source .venv/[BIN]/activate + "### + ); + + // Verify symlink is still preserved after creation + assert!(symlink_path.path().is_symlink()); + + // Run uv venv with --clear to test symlink preservation during clear + uv_snapshot!(context.filters(), context.venv() + .arg(symlink_path.as_os_str()) + .arg("--clear") + .arg("--python") + .arg("3.12"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Creating virtual environment at: .venv + Activate with: source .venv/[BIN]/activate + "### + ); + + // Verify symlink is STILL preserved after --clear + assert!(symlink_path.path().is_symlink()); + + Ok(()) +} + +#[test] +#[cfg(unix)] +fn create_venv_symlink_recreate_preservation() -> Result<()> { + use std::os::unix::fs::symlink; + + let context = TestContext::new_with_versions(&["3.12"]); + + // Create a target directory + let target_dir = context.temp_dir.child("target"); + target_dir.create_dir_all()?; + + // Create a symlink pointing to the target directory + let symlink_path = context.temp_dir.child(".venv"); + symlink(&target_dir, &symlink_path)?; + + // Verify symlink exists + assert!(symlink_path.path().is_symlink()); + + // Create virtual environment at symlink location + uv_snapshot!(context.filters(), context.venv() + .arg(symlink_path.as_os_str()) + .arg("--python") + .arg("3.12"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Creating virtual environment at: .venv + Activate with: source .venv/[BIN]/activate + "### + ); + + // Verify symlink is preserved after first creation + assert!(symlink_path.path().is_symlink()); + + // Run uv venv again WITHOUT --clear to test recreation behavior + uv_snapshot!(context.filters(), context.venv() + .arg(symlink_path.as_os_str()) + .arg("--python") + .arg("3.12"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Creating virtual environment at: .venv + warning: A virtual environment already exists at `.venv`. In the future, uv will require `--clear` to replace it + Activate with: source .venv/[BIN]/activate + "### + ); + + // Verify symlink is STILL preserved after recreation + assert!(symlink_path.path().is_symlink()); + + Ok(()) +} + +#[test] +#[cfg(unix)] +fn create_venv_nested_symlink_preservation() -> Result<()> { + use std::os::unix::fs::symlink; + + let context = TestContext::new_with_versions(&["3.12"]); + + // Create a target directory + let target_dir = context.temp_dir.child("target"); + target_dir.create_dir_all()?; + + // Create first symlink level: intermediate -> target + let intermediate_link = context.temp_dir.child("intermediate"); + symlink(&target_dir, &intermediate_link)?; + + // Create second symlink level: .venv -> intermediate (nested symlink) + let symlink_path = context.temp_dir.child(".venv"); + symlink(&intermediate_link, &symlink_path)?; + + // Verify nested symlink exists + assert!(symlink_path.path().is_symlink()); + assert!(intermediate_link.path().is_symlink()); + + // Create virtual environment at nested symlink location + uv_snapshot!(context.filters(), context.venv() + .arg(symlink_path.as_os_str()) + .arg("--python") + .arg("3.12"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Creating virtual environment at: .venv + Activate with: source .venv/[BIN]/activate + "### + ); + + // Verify both symlinks are preserved + assert!(symlink_path.path().is_symlink()); + assert!(intermediate_link.path().is_symlink()); + + // Run uv venv again to test nested symlink preservation during recreation + uv_snapshot!(context.filters(), context.venv() + .arg(symlink_path.as_os_str()) + .arg("--python") + .arg("3.12"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Using CPython 3.12.[X] interpreter at: [PYTHON-3.12] + Creating virtual environment at: .venv + warning: A virtual environment already exists at `.venv`. In the future, uv will require `--clear` to replace it + Activate with: source .venv/[BIN]/activate + "### + ); + + // Verify nested symlinks are STILL preserved + assert!(symlink_path.path().is_symlink()); + assert!(intermediate_link.path().is_symlink()); + + Ok(()) +} From 66def58b9d9ff84900c17a26b7763df3dbf45f17 Mon Sep 17 00:00:00 2001 From: shikinamiasuka Date: Thu, 31 Jul 2025 10:40:21 +0800 Subject: [PATCH 3/5] Refactor symlink usage in virtual environment tests --- crates/uv/tests/it/venv.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/uv/tests/it/venv.rs b/crates/uv/tests/it/venv.rs index 51d12e1809ac5..f6254c7707e6d 100644 --- a/crates/uv/tests/it/venv.rs +++ b/crates/uv/tests/it/venv.rs @@ -1392,7 +1392,7 @@ fn venv_python_preference() { #[test] #[cfg(unix)] fn create_venv_symlink_clear_preservation() -> Result<()> { - use std::os::unix::fs::symlink; + use fs_err::os::unix::fs::symlink; let context = TestContext::new_with_versions(&["3.12"]); @@ -1452,7 +1452,7 @@ fn create_venv_symlink_clear_preservation() -> Result<()> { #[test] #[cfg(unix)] fn create_venv_symlink_recreate_preservation() -> Result<()> { - use std::os::unix::fs::symlink; + use fs_err::os::unix::fs::symlink; let context = TestContext::new_with_versions(&["3.12"]); @@ -1512,7 +1512,7 @@ fn create_venv_symlink_recreate_preservation() -> Result<()> { #[test] #[cfg(unix)] fn create_venv_nested_symlink_preservation() -> Result<()> { - use std::os::unix::fs::symlink; + use fs_err::os::unix::fs::symlink; let context = TestContext::new_with_versions(&["3.12"]); From 29e3eaf88f821e77c170f25ade36a7a76e3e4271 Mon Sep 17 00:00:00 2001 From: shikinamiasuka Date: Thu, 31 Jul 2025 10:58:05 +0800 Subject: [PATCH 4/5] Refactor symlink imports in virtual environment tests for clarity and consistency --- crates/uv/tests/it/venv.rs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/crates/uv/tests/it/venv.rs b/crates/uv/tests/it/venv.rs index f6254c7707e6d..b972996259d95 100644 --- a/crates/uv/tests/it/venv.rs +++ b/crates/uv/tests/it/venv.rs @@ -6,6 +6,9 @@ use predicates::prelude::*; use uv_python::{PYTHON_VERSION_FILENAME, PYTHON_VERSIONS_FILENAME}; use uv_static::EnvVars; +#[cfg(unix)] +use fs_err::os::unix::fs::symlink; + use crate::common::{TestContext, uv_snapshot}; #[test] @@ -1392,8 +1395,6 @@ fn venv_python_preference() { #[test] #[cfg(unix)] fn create_venv_symlink_clear_preservation() -> Result<()> { - use fs_err::os::unix::fs::symlink; - let context = TestContext::new_with_versions(&["3.12"]); // Create a target directory @@ -1452,8 +1453,6 @@ fn create_venv_symlink_clear_preservation() -> Result<()> { #[test] #[cfg(unix)] fn create_venv_symlink_recreate_preservation() -> Result<()> { - use fs_err::os::unix::fs::symlink; - let context = TestContext::new_with_versions(&["3.12"]); // Create a target directory @@ -1512,8 +1511,6 @@ fn create_venv_symlink_recreate_preservation() -> Result<()> { #[test] #[cfg(unix)] fn create_venv_nested_symlink_preservation() -> Result<()> { - use fs_err::os::unix::fs::symlink; - let context = TestContext::new_with_versions(&["3.12"]); // Create a target directory From abf02fa4ae4d46ff30a4d7c5c2cb008fa058a56c Mon Sep 17 00:00:00 2001 From: Zanie Blue Date: Thu, 31 Jul 2025 06:47:04 -0500 Subject: [PATCH 5/5] Add a comment --- crates/uv-virtualenv/src/virtualenv.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/crates/uv-virtualenv/src/virtualenv.rs b/crates/uv-virtualenv/src/virtualenv.rs index 78726d7d58cc1..dceb1c5a65d7e 100644 --- a/crates/uv-virtualenv/src/virtualenv.rs +++ b/crates/uv-virtualenv/src/virtualenv.rs @@ -109,6 +109,9 @@ pub(crate) fn create( } OnExisting::Remove => { debug!("Removing existing {name} due to `--clear`"); + // Before removing the virtual environment, we need to canonicalize the path + // because `Path::metadata` will follow the symlink but we're still operating on + // the unresolved path and will remove the symlink itself. let location = location .canonicalize() .unwrap_or_else(|_| location.to_path_buf()); @@ -119,6 +122,9 @@ pub(crate) fn create( match confirm_clear(location, name)? { Some(true) => { debug!("Removing existing {name} due to confirmation"); + // Before removing the virtual environment, we need to canonicalize the + // path because `Path::metadata` will follow the symlink but we're still + // operating on the unresolved path and will remove the symlink itself. let location = location .canonicalize() .unwrap_or_else(|_| location.to_path_buf());