diff --git a/.github/workflows/ci-checks.yml b/.github/workflows/ci-checks.yml index 899b730..8d5c79c 100644 --- a/.github/workflows/ci-checks.yml +++ b/.github/workflows/ci-checks.yml @@ -2,9 +2,9 @@ name: CI Checks on: push: - branches: [main] + branches: [main, support-windows] pull_request: - branches: [main] + branches: [main, support-windows] jobs: checks: diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 525b7f9..1d81b21 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -2,9 +2,9 @@ name: CI Tests on: push: - branches: [main] + branches: [main, support-windows] pull_request: - branches: [main] + branches: [main, support-windows] jobs: test: @@ -13,8 +13,7 @@ jobs: strategy: fail-fast: false matrix: - # os: [ubuntu-latest, macos-latest, windows-latest] - os: [ubuntu-latest, macos-latest] + os: [ubuntu-latest, macos-latest, windows-latest] python-version: ["3.12", "3.13", "3.14"] steps: 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..d251b88 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) @@ -32,7 +33,8 @@ def get_uvlink_dir(*subpaths: str | Path) -> Path: Returns: Path: Absolute path to the uvlink data directory or provided subpath. """ - base = Path(os.environ.get("XDG_DATA_HOME", "~/.local/share")).expanduser() + default_path = Path.home() / ".local" / "share" + base = Path(os.environ.get("XDG_DATA_HOME", default_path)).expanduser() root = base / "uvlink" for sp in subpaths: root /= Path(sp) @@ -215,7 +217,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..a08c8fb 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" @@ -113,8 +113,7 @@ def test_ls(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: assert "Is Linked" in result.stdout # Verify cache location message - assert "Cache Location:" in result.stdout - assert "uvlink/cache" in result.stdout + assert f"Cache Location: {cache_dir}" in result.stdout # Verify both projects appear and have correct linked status output_lines = result.stdout.split("\n") diff --git a/tests/test_path_utils.py b/tests/test_path_utils.py new file mode 100644 index 0000000..fa64980 --- /dev/null +++ b/tests/test_path_utils.py @@ -0,0 +1,35 @@ +from pathlib import Path + +import pytest + +from uvlink.path_utils import create_symlink, create_windows_junction, is_windows + + +def test_create_symlink(tmp_path: Path): + target_dir = tmp_path / "target" + symlink_dir = tmp_path / "symlink" + create_symlink(symlink=symlink_dir, target=target_dir) + + assert symlink_dir.exists() + assert symlink_dir.is_symlink() or (is_windows() and symlink_dir.is_junction()) + assert symlink_dir.resolve() == target_dir.resolve() + + +@pytest.mark.skipif( + not is_windows(), reason="Windows junctions are only applicable on Windows." +) +def test_create_windows_junction(tmp_path: Path): + target_dir = tmp_path + symlink_dir = tmp_path / "symlink" + create_windows_junction(symlink=symlink_dir, target=target_dir) + + assert symlink_dir.exists() + assert symlink_dir.is_junction() + assert symlink_dir.resolve() == target_dir.resolve() + + +def test_create_windows_junction_invalid_target(tmp_path: Path): + with pytest.raises(ValueError): + create_windows_junction( + symlink=tmp_path / "any", target=tmp_path / "nonexistent" + ) diff --git a/tests/test_project.py b/tests/test_project.py index 776faa5..9a86390 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -4,12 +4,29 @@ import pytest +from uvlink.path_utils import create_symlink, is_windows from uvlink.project import Project class TestProject: def test_hash_path(self): - assert Project.hash_path("/") == "8a5edab28263" + expected_hash = "d0023e7cb6a9" if is_windows() else "8a5edab28263" + assert Project.hash_path("/") == expected_hash + + def test_project_init_path_resolution_with_tilde(self) -> None: + """Test with tilde expansion and parent directory (e.g., "~/../xxx").""" + user_home_path = Path.home() + test_dir = user_home_path / "test_project" + + # Test ~/test_project resolves correctly + p2 = Project(project_dir="~/test_project") + assert p2.project_dir == test_dir.resolve() + assert p2.project_dir.is_absolute() + + # Test ~/.. resolves to parent of HOME + p3 = Project(project_dir="~/..") + assert p3.project_dir == user_home_path.parent.resolve() + assert p3.project_dir.is_absolute() def test_project_init(self, tmp_path: Path) -> None: p = Project(project_dir=tmp_path) @@ -17,11 +34,9 @@ def test_project_init(self, tmp_path: Path) -> None: assert p.project_dir == tmp_path.resolve() assert p.project_name == tmp_path.name assert p.venv_type == ".venv" - assert "uvlink/cache" in str(p.project_cache_dir) + assert "uvlink/cache" in p.project_cache_dir.as_posix() - def test_project_init_path_resolution( - self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch - ) -> None: + def test_project_init_path_resolution(self, tmp_path: Path) -> None: """Test that Project resolves relative paths and parent directory references.""" # Create a subdirectory to test relative paths with parent directory references subdir = tmp_path / "subdir" / "nested" @@ -36,28 +51,12 @@ def test_project_init_path_resolution( assert p1.project_dir.is_absolute() assert p1.project_name == tmp_path.name - # Test with tilde expansion and parent directory (e.g., "~/../xxx") - # Mock HOME to be tmp_path for reliable testing - monkeypatch.setenv("HOME", str(tmp_path)) - test_dir = tmp_path / "test_project" - test_dir.mkdir(exist_ok=True) - - # Test ~/test_project resolves correctly - p2 = Project(project_dir="~/test_project") - assert p2.project_dir == test_dir.resolve() - assert p2.project_dir.is_absolute() - - # Test ~/.. resolves to parent of HOME - p3 = Project(project_dir="~/..") - assert p3.project_dir == tmp_path.parent.resolve() - assert p3.project_dir.is_absolute() - # Test symlink resolution # Create a target directory and a symlink pointing to it 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)