diff --git a/src/uvlink/cli.py b/src/uvlink/cli.py index 29cd345..2afecdc 100644 --- a/src/uvlink/cli.py +++ b/src/uvlink/cli.py @@ -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( @@ -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") diff --git a/src/uvlink/path_utils.py b/src/uvlink/path_utils.py new file mode 100644 index 0000000..591bfb6 --- /dev/null +++ b/src/uvlink/path_utils.py @@ -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 diff --git a/src/uvlink/project.py b/src/uvlink/project.py index 18c96bc..b7a38bd 100644 --- a/src/uvlink/project.py +++ b/src/uvlink/project.py @@ -7,6 +7,8 @@ from datetime import datetime from pathlib import Path +from uvlink.path_utils import is_link_or_junction + from . import __version__ @@ -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) @@ -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( diff --git a/tests/test_cli.py b/tests/test_cli.py index 94344e4..8dcf9f9 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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: @@ -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() @@ -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" diff --git a/tests/test_project.py b/tests/test_project.py index 776faa5..d5ddb21 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -4,6 +4,7 @@ import pytest +from uvlink.path_utils import create_symlink from uvlink.project import Project @@ -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)