Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
7 changes: 3 additions & 4 deletions .github/workflows/ci-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down
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:
Copy link

Copilot AI Nov 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing docstring for is_windows() function. Consider adding documentation explaining what this function checks and when it returns True:

def is_windows() -> bool:
    \"\"\"Check if the current platform is Windows.
    
    Returns:
        bool: True if running on Windows (win32 or nt), False otherwise.
    \"\"\"
Suggested change
def is_windows() -> bool:
def is_windows() -> bool:
"""
Check if the current platform is Windows.
Returns:
bool: True if running on Windows (win32 or nt), False otherwise.
"""

Copilot uses AI. Check for mistakes.
return sys.platform == "win32" or os.name == "nt"


def is_link_or_junction(path: Path) -> bool:
Copy link

Copilot AI Nov 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing docstring for is_link_or_junction() function. Consider adding documentation:

def is_link_or_junction(path: Path) -> bool:
    \"\"\"Check if a path is a symlink or Windows junction.
    
    Args:
        path: The path to check.
        
    Returns:
        bool: True if the path is a symlink or junction, False otherwise.
    \"\"\"
Suggested change
def is_link_or_junction(path: Path) -> bool:
def is_link_or_junction(path: Path) -> bool:
"""
Check if a path is a symlink or Windows junction.
Args:
path (Path): The path to check.
Returns:
bool: True if the path is a symlink or junction, False otherwise.
"""

Copilot uses AI. Check for mistakes.
return path.is_symlink() or path.is_junction()


def path_exists(path: Path) -> bool:
Copy link

Copilot AI Nov 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing docstring for path_exists() function. Consider adding documentation:

def path_exists(path: Path) -> bool:
    \"\"\"Check if a path exists, including broken symlinks or junctions.
    
    Args:
        path: The path to check.
        
    Returns:
        bool: True if the path exists or is a symlink/junction (even if broken), False otherwise.
    \"\"\"
Suggested change
def path_exists(path: Path) -> bool:
def path_exists(path: Path) -> bool:
"""
Check if a path exists, including broken symlinks or junctions.
Args:
path (Path): The path to check.
Returns:
bool: True if the path exists or is a symlink/junction (even if broken), False otherwise.
"""

Copilot uses AI. Check for mistakes.
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.
Copy link

Copilot AI Nov 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Grammar error: "as symlink_to" should be "as symlink_to does" or "unlike symlink_to" for clarity. The sentence is missing a verb.

Suggested change
Does not require admin privileges as symlink_to.
Does not require admin privileges, unlike symlink_to.

Copilot uses AI. Check for mistakes.
"""
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)
Comment on lines +27 to +28
Copy link

Copilot AI Nov 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Security risk: Using shell=True with f-string formatting can lead to command injection vulnerabilities if symlink or target paths contain shell metacharacters. Use subprocess.run() with a list of arguments instead:

subprocess.check_call(['cmd', '/c', 'mklink', '/J', str(symlink.absolute()), str(target.absolute())])
Suggested change
cmd = f'mklink /J "{symlink.absolute()}" "{target.absolute()}"'
subprocess.check_call(cmd, shell=True)
cmd = ['cmd', '/c', 'mklink', '/J', str(symlink.absolute()), str(target.absolute())]
subprocess.check_call(cmd)

Copilot uses AI. Check for mistakes.


def create_symlink(symlink: Path, target: Path) -> None:
Copy link

Copilot AI Nov 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing docstring for create_symlink() function. Consider adding comprehensive documentation:

def create_symlink(symlink: Path, target: Path) -> None:
    \"\"\"Create a symlink to the target directory, with Windows junction fallback.
    
    Creates the target directory if it doesn't exist. On Windows, falls back to
    creating a junction if symlink creation fails (e.g., due to permissions).
    
    Args:
        symlink: The path where the symlink should be created.
        target: The target directory the symlink should point to.
        
    Raises:
        OSError: If symlink creation fails on non-Windows platforms.
    \"\"\"
Suggested change
def create_symlink(symlink: Path, target: Path) -> None:
def create_symlink(symlink: Path, target: Path) -> None:
"""
Create a symlink to the target directory, with Windows junction fallback.
Ensures the target directory exists. On Windows, if symlink creation fails
(e.g., due to insufficient permissions), falls back to creating a junction.
Args:
symlink (Path): The path where the symlink should be created.
target (Path): The target directory the symlink should point to.
Raises:
OSError: If symlink creation fails on non-Windows platforms.
ValueError: If the target is not a directory when creating a junction.
"""

Copilot uses AI. Check for mistakes.
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
11 changes: 7 additions & 4 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 All @@ -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)
Expand Down Expand Up @@ -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(
Expand Down
9 changes: 4 additions & 5 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)
Copy link

Copilot AI Nov 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using unlink(missing_ok=True) is cleaner than the previous conditional, but consider also verifying that the symlink was successfully removed by adding assert not symlink2.exists() after the unlink call to ensure the test state is as expected.

Copilot uses AI. Check for mistakes.

# Use --cache-root to specify the cache directory explicitly
cache_dir = fake_home / "uvlink" / "cache"
Expand All @@ -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")
Expand Down
35 changes: 35 additions & 0 deletions tests/test_path_utils.py
Original file line number Diff line number Diff line change
@@ -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()
Comment on lines +8 to +15
Copy link

Copilot AI Nov 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test test_create_symlink doesn't verify that the target directory is created by create_symlink. Consider adding an assertion before calling create_symlink to verify the target doesn't exist, and after to verify both symlink and target were created:

assert not target_dir.exists()  # Before
create_symlink(symlink=symlink_dir, target=target_dir)
assert target_dir.exists()  # After

Copilot uses AI. Check for mistakes.


@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"
)
Comment on lines +31 to +35
Copy link

Copilot AI Nov 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test test_create_windows_junction_invalid_target should be skipped on non-Windows platforms since create_windows_junction is Windows-specific. Add @pytest.mark.skipif(not is_windows(), reason="Windows junctions are only applicable on Windows.") decorator to match the pattern used in test_create_windows_junction.

Copilot uses AI. Check for mistakes.
43 changes: 21 additions & 22 deletions tests/test_project.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,24 +4,39 @@

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()
Comment on lines +16 to +24
Copy link

Copilot AI Nov 28, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test creates a Project with path "~/test_project" but never creates the actual directory. While the test checks path resolution, it doesn't verify that the Project initialization works correctly when the directory doesn't exist. Consider either creating the directory with test_dir.mkdir(parents=True, exist_ok=True) before creating the Project, or add a separate test case to verify behavior with non-existent directories.

Copilot uses AI. Check for mistakes.

# 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)
# Project.resolve() normalizes the path, so compare with resolved tmp_path
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"
Expand All @@ -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)
Expand Down