diff --git a/crates/uv/src/commands/python/install.rs b/crates/uv/src/commands/python/install.rs index e903926c7595..591b8a405118 100644 --- a/crates/uv/src/commands/python/install.rs +++ b/crates/uv/src/commands/python/install.rs @@ -358,32 +358,50 @@ pub(crate) async fn install( target.simplified_display() ); + // Check if the existing link is valid + let valid_link = target + .read_link() + .and_then(|target| target.try_exists()) + .inspect_err(|err| debug!("Failed to inspect executable with error: {err}")) + .unwrap_or(true); + // Figure out what installation it references, if any - let existing = find_matching_bin_link( - installations - .iter() - .copied() - .chain(existing_installations.iter()), - &target, - ); + let existing = valid_link + .then(|| { + find_matching_bin_link( + installations + .iter() + .copied() + .chain(existing_installations.iter()), + &target, + ) + }) + .flatten(); match existing { None => { // There's an existing executable we don't manage, require `--force` - if !force { - errors.push(( - installation.key(), - anyhow::anyhow!( - "Executable already exists at `{}` but is not managed by uv; use `--force` to replace it", - to.simplified_display() - ), - )); - continue; + if valid_link { + if !force { + errors.push(( + installation.key(), + anyhow::anyhow!( + "Executable already exists at `{}` but is not managed by uv; use `--force` to replace it", + to.simplified_display() + ), + )); + continue; + } + debug!( + "Replacing existing executable at `{}` due to `--force`", + target.simplified_display() + ); + } else { + debug!( + "Replacing broken symlink at `{}`", + target.simplified_display() + ); } - debug!( - "Replacing existing executable at `{}` due to `--force`", - target.simplified_display() - ); } Some(existing) if existing == *installation => { // The existing link points to the same installation, so we're done unless diff --git a/crates/uv/tests/it/python_install.rs b/crates/uv/tests/it/python_install.rs index a6e3baf145a1..303e7f01e4e1 100644 --- a/crates/uv/tests/it/python_install.rs +++ b/crates/uv/tests/it/python_install.rs @@ -839,3 +839,40 @@ fn python_install_unknown() { error: `./foo` is not a valid Python download request; see `uv python help` for supported formats and `uv python list --only-downloads` for available versions "###); } + +#[cfg(unix)] +#[test] +fn python_install_preview_broken_link() { + use assert_fs::prelude::PathCreateDir; + use fs_err::os::unix::fs::symlink; + + let context: TestContext = TestContext::new_with_versions(&[]) + .with_filtered_python_keys() + .with_filtered_exe_suffix(); + + let bin_python = context.temp_dir.child("bin").child("python3.13"); + + // Create a broken symlink + context.temp_dir.child("bin").create_dir_all().unwrap(); + symlink(context.temp_dir.join("does-not-exist"), &bin_python).unwrap(); + + // Install + uv_snapshot!(context.filters(), context.python_install().arg("--preview").arg("3.13"), @r###" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + Installed Python 3.13.1 in [TIME] + + cpython-3.13.1-[PLATFORM] (python3.13) + "###); + + // We should replace the broken symlink + insta::with_settings!({ + filters => context.filters(), + }, { + insta::assert_snapshot!( + read_link_path(&bin_python), @"[TEMP_DIR]/managed/cpython-3.13.1-[PLATFORM]/bin/python3.13" + ); + }); +}