mirror of
				https://github.com/astral-sh/uv.git
				synced 2025-11-03 21:23:54 +00:00 
			
		
		
		
	Fix symlink preservation in virtual environment creation (#14933)
## Summary
  Fixes inconsistent symlink handling in `uv venv` command (#14670).
## Problem
00efde06b6/crates/uv-virtualenv/src/virtualenv.rs (L81)
The original code used `Path::metadata()` which automatically follows
symlinks, causing the system to treat symlinked virtual environment
paths as regular directories. When a user runs uv venv on an existing
symlinked virtual environment `(.venv -> foo)`, the code incorrectly
treats the symlink as a regular directory because `location.metadata()`
automatically follows the symlink and returns metadata for the target
directory `foo/`. This causes the removal logic to delete the symlink
itself and permanently breaking the symlink relationship and replacing
it with a standard directory structure.
 
## Solution
- Use canonicalize() to resolve symlinks only when removing and
recreating virtual
  environments
- This ensures operations target the actual directory while preserving
the symlink
  structure
- Minimal change that fixes the core issue without complex path
management
## Test Plan
```bash
➜  test-env alias uv-dev='/Users/wingmunfung/workspace/uv/target/debug/uv'
➜  test-env ln -s dummy foo
➜  test-env ln -s foo .venv
➜  test-env ls -lah        
total 0
drwxr-xr-x   4 wingmunfung  staff   128B Jul 30 10:39 .
drwxr-xr-x  48 wingmunfung  staff   1.5K Jul 29 17:08 ..
lrwxr-xr-x   1 wingmunfung  staff     3B Jul 30 10:39 .venv -> foo
lrwxr-xr-x   1 wingmunfung  staff     5B Jul 30 10:39 foo -> dummy
➜  test-env uv-dev venv
Using CPython 3.13.2
Creating virtual environment at: .venv
error: Failed to create virtual environment
  Caused by: failed to create directory `.venv`: File exists (os error 17)
➜  test-env mkdir dummy
➜  test-env uv-dev venv
Using CPython 3.13.2
Creating virtual environment at: .venv
Activate with: source .venv/bin/activate
➜  test-env ls -lah
total 0
drwxr-xr-x   5 wingmunfung  staff   160B Jul 30 10:39 .
drwxr-xr-x  48 wingmunfung  staff   1.5K Jul 29 17:08 ..
lrwxr-xr-x   1 wingmunfung  staff     3B Jul 30 10:39 .venv -> foo
drwxr-xr-x   7 wingmunfung  staff   224B Jul 30 10:39 dummy
lrwxr-xr-x   1 wingmunfung  staff     5B Jul 30 10:39 foo -> dummy
➜  test-env uv-dev venv
Using CPython 3.13.2
Creating virtual environment at: .venv
✔ A virtual environment already exists at `.venv`. Do you want to replace it? · yes
Activate with: source .venv/bin/activate
➜  test-env ls -lah
total 0
drwxr-xr-x   5 wingmunfung  staff   160B Jul 30 10:39 .
drwxr-xr-x  48 wingmunfung  staff   1.5K Jul 29 17:08 ..
lrwxr-xr-x   1 wingmunfung  staff     3B Jul 30 10:39 .venv -> foo
drwxr-xr-x@  7 wingmunfung  staff   224B Jul 30 10:39 dummy
lrwxr-xr-x   1 wingmunfung  staff     5B Jul 30 10:39 foo -> dummy
### the symlink still exists
```
---------
Co-authored-by: Zanie Blue <contact@zanie.dev>
			
			
This commit is contained in:
		
							parent
							
								
									c4aaae39bc
								
							
						
					
					
						commit
						538ebe6fcf
					
				
					 2 changed files with 200 additions and 4 deletions
				
			
		| 
						 | 
					@ -109,15 +109,27 @@ pub(crate) fn create(
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
                OnExisting::Remove => {
 | 
					                OnExisting::Remove => {
 | 
				
			||||||
                    debug!("Removing existing {name} due to `--clear`");
 | 
					                    debug!("Removing existing {name} due to `--clear`");
 | 
				
			||||||
                    remove_virtualenv(location)?;
 | 
					                    // Before removing the virtual environment, we need to canonicalize the path
 | 
				
			||||||
                    fs::create_dir_all(location)?;
 | 
					                    // 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());
 | 
				
			||||||
 | 
					                    remove_virtualenv(&location)?;
 | 
				
			||||||
 | 
					                    fs::create_dir_all(&location)?;
 | 
				
			||||||
                }
 | 
					                }
 | 
				
			||||||
                OnExisting::Fail => {
 | 
					                OnExisting::Fail => {
 | 
				
			||||||
                    match confirm_clear(location, name)? {
 | 
					                    match confirm_clear(location, name)? {
 | 
				
			||||||
                        Some(true) => {
 | 
					                        Some(true) => {
 | 
				
			||||||
                            debug!("Removing existing {name} due to confirmation");
 | 
					                            debug!("Removing existing {name} due to confirmation");
 | 
				
			||||||
                            remove_virtualenv(location)?;
 | 
					                            // Before removing the virtual environment, we need to canonicalize the
 | 
				
			||||||
                            fs::create_dir_all(location)?;
 | 
					                            // 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());
 | 
				
			||||||
 | 
					                            remove_virtualenv(&location)?;
 | 
				
			||||||
 | 
					                            fs::create_dir_all(&location)?;
 | 
				
			||||||
                        }
 | 
					                        }
 | 
				
			||||||
                        Some(false) => {
 | 
					                        Some(false) => {
 | 
				
			||||||
                            let hint = format!(
 | 
					                            let hint = format!(
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -6,6 +6,9 @@ use predicates::prelude::*;
 | 
				
			||||||
use uv_python::{PYTHON_VERSION_FILENAME, PYTHON_VERSIONS_FILENAME};
 | 
					use uv_python::{PYTHON_VERSION_FILENAME, PYTHON_VERSIONS_FILENAME};
 | 
				
			||||||
use uv_static::EnvVars;
 | 
					use uv_static::EnvVars;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#[cfg(unix)]
 | 
				
			||||||
 | 
					use fs_err::os::unix::fs::symlink;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
use crate::common::{TestContext, uv_snapshot};
 | 
					use crate::common::{TestContext, uv_snapshot};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
#[test]
 | 
					#[test]
 | 
				
			||||||
| 
						 | 
					@ -1388,3 +1391,184 @@ fn venv_python_preference() {
 | 
				
			||||||
    Activate with: source .venv/[BIN]/activate
 | 
					    Activate with: source .venv/[BIN]/activate
 | 
				
			||||||
    ");
 | 
					    ");
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					#[test]
 | 
				
			||||||
 | 
					#[cfg(unix)]
 | 
				
			||||||
 | 
					fn create_venv_symlink_clear_preservation() -> Result<()> {
 | 
				
			||||||
 | 
					    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<()> {
 | 
				
			||||||
 | 
					    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<()> {
 | 
				
			||||||
 | 
					    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(())
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue