Skip to content
37 changes: 18 additions & 19 deletions src/uvlink/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from rich.table import Table

from uvlink import __version__
from uvlink.path_utils import create_symlink, path_exists
from uvlink.project import Project, Projects, get_uvlink_dir, rm_rf

app = typer.Typer(
Expand Down Expand Up @@ -102,25 +103,23 @@ def link(
else:
if not proj.project_dir.is_dir():
raise NotADirectoryError(f"{proj.project_dir} is not a directory")
else:
if venv.exists() or venv.is_symlink():
if typer.confirm(f"'{venv}' already exist, remove?", default=True):
typer.echo("Removing...")
rm_rf(venv.parent)
else:
typer.echo(f"Keep current {venv}")
if symlink.exists() or symlink.is_symlink():
if typer.confirm(
f"'{symlink}' already exist, overwrite?", default=True
):
rm_rf(symlink)
else:
typer.echo("Cancelled.")
raise typer.Abort()
venv.mkdir(parents=True, exist_ok=True)
symlink.symlink_to(venv)
proj.save_json_metadata_file()
typer.echo(f"symlink created: {symlink} -> {venv}")

if path_exists(venv):
if typer.confirm(f"'{venv}' already exists, remove?", default=True):
typer.echo("Removing...")
rm_rf(venv.parent)
else:
typer.echo(f"Keep current {venv}")
if path_exists(symlink):
if typer.confirm(f"'{symlink}' already exists, overwrite?", default=True):
rm_rf(symlink)
else:
typer.echo("Cancelled.")
raise typer.Abort()

create_symlink(symlink, venv)
proj.save_json_metadata_file()
typer.echo(f"symlink created: {symlink} -> {venv}")


@app.command("ls")
Expand Down
39 changes: 39 additions & 0 deletions src/uvlink/path_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import os
import subprocess
import sys
from pathlib import Path


def is_windows() -> bool:
return sys.platform == "win32" or os.name == "nt"


def is_link_or_junction(path: Path) -> bool:
return path.is_symlink() or path.is_junction()


def path_exists(path: Path) -> bool:
return path.exists() or is_link_or_junction(path)


def create_windows_junction(symlink: Path, target: Path) -> None:
"""
Windows junctions are similar to symlinks but specifically for directories.
Does not require admin privileges as symlink_to.
"""
if not target.is_dir():
raise ValueError("Target must be an existing directory for Windows junction.")

cmd = f'mklink /J "{symlink.absolute()}" "{target.absolute()}"'
subprocess.check_call(cmd, shell=True)


def create_symlink(symlink: Path, target: Path) -> None:
target.mkdir(parents=True, exist_ok=True)
try:
symlink.symlink_to(target, target_is_directory=target.is_dir())
except OSError:
if is_windows():
create_windows_junction(symlink, target)
else:
raise
8 changes: 5 additions & 3 deletions src/uvlink/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
from datetime import datetime
from pathlib import Path

from uvlink.path_utils import is_link_or_junction

from . import __version__


Expand All @@ -16,8 +18,7 @@ def rm_rf(path: Path) -> None:
Args:
path (Path): Filesystem target to delete recursively.
"""

if path.is_symlink() or path.is_file():
if is_link_or_junction(path) or path.is_file():
path.unlink()
else:
shutil.rmtree(path)
Expand Down Expand Up @@ -215,7 +216,8 @@ def get_list(self) -> list[ProjectLinkInfo]:
for p in self:
symlink = p.project_dir / p.venv_type
is_linked = (
symlink.is_symlink() and symlink.resolve().parent == p.project_cache_dir
is_link_or_junction(symlink)
and symlink.resolve().parent == p.project_cache_dir
)
linked.append(
ProjectLinkInfo(
Expand Down
6 changes: 3 additions & 3 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ def test_link_dry_run(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
# Verify that no symlink was actually created (dry-run should not create anything)
assert not expected_symlink.exists()
assert not expected_symlink.is_symlink()
assert not expected_symlink.is_junction()


def test_link_creation(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
Expand All @@ -67,7 +68,7 @@ def test_link_creation(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
assert expected_output in result.stdout

# Verify symlink exists
assert expected_symlink.is_symlink()
assert expected_symlink.is_symlink() or expected_symlink.is_junction()

# Verify the symlink actually points to the expected cache directory
assert expected_symlink.resolve() == expected_venv.resolve()
Expand Down Expand Up @@ -99,8 +100,7 @@ def test_ls(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
p2 = Project(project_dir=project2_dir)
# Remove the symlink to make it unlinked
symlink2 = project2_dir / ".venv"
if symlink2.exists() or symlink2.is_symlink():
symlink2.unlink()
symlink2.unlink(missing_ok=True)

# Use --cache-root to specify the cache directory explicitly
cache_dir = fake_home / "uvlink" / "cache"
Expand Down
3 changes: 2 additions & 1 deletion tests/test_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import pytest

from uvlink.path_utils import create_symlink
from uvlink.project import Project


Expand Down Expand Up @@ -57,7 +58,7 @@ def test_project_init_path_resolution(
target_dir = tmp_path / "target"
target_dir.mkdir()
symlink_path = tmp_path / "symlink"
symlink_path.symlink_to(target_dir)
create_symlink(symlink_path, target_dir)

# Project should resolve the symlink to the actual target
p4 = Project(project_dir=symlink_path)
Expand Down
Loading