Skip to content

Commit 804523d

Browse files
committed
feat: use junction instead of symlink for Windows
1 parent 89754c9 commit 804523d

5 files changed

Lines changed: 70 additions & 28 deletions

File tree

src/uvlink/cli.py

Lines changed: 18 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from rich.table import Table
1111

1212
from uvlink import __version__
13+
from uvlink.path_utils import execute_symlink, path_exists
1314
from uvlink.project import Project, Projects, get_uvlink_dir, rm_rf
1415

1516
app = typer.Typer(
@@ -102,25 +103,23 @@ def link(
102103
else:
103104
if not proj.project_dir.is_dir():
104105
raise NotADirectoryError(f"{proj.project_dir} is not a directory")
105-
else:
106-
if venv.exists() or venv.is_symlink():
107-
if typer.confirm(f"'{venv}' already exist, remove?", default=True):
108-
typer.echo("Removing...")
109-
rm_rf(venv.parent)
110-
else:
111-
typer.echo(f"Keep current {venv}")
112-
if symlink.exists() or symlink.is_symlink():
113-
if typer.confirm(
114-
f"'{symlink}' already exist, overwrite?", default=True
115-
):
116-
rm_rf(symlink)
117-
else:
118-
typer.echo("Cancelled.")
119-
raise typer.Abort()
120-
venv.mkdir(parents=True, exist_ok=True)
121-
symlink.symlink_to(venv)
122-
proj.save_json_metadata_file()
123-
typer.echo(f"symlink created: {symlink} -> {venv}")
106+
107+
if path_exists(venv):
108+
if typer.confirm(f"'{venv}' already exists, remove?", default=True):
109+
typer.echo("Removing...")
110+
rm_rf(venv.parent)
111+
else:
112+
typer.echo(f"Keep current {venv}")
113+
if path_exists(symlink):
114+
if typer.confirm(f"'{symlink}' already exists, overwrite?", default=True):
115+
rm_rf(symlink)
116+
else:
117+
typer.echo("Cancelled.")
118+
raise typer.Abort()
119+
120+
execute_symlink(symlink, venv)
121+
proj.save_json_metadata_file()
122+
typer.echo(f"symlink created: {symlink} -> {venv}")
124123

125124

126125
@app.command("ls")

src/uvlink/path_utils.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import os
2+
import subprocess
3+
import sys
4+
from pathlib import Path
5+
6+
7+
def is_windows() -> bool:
8+
return sys.platform == "win32" or os.name == "nt"
9+
10+
11+
def is_link_or_junction(path: Path) -> bool:
12+
return path.is_symlink() or path.is_junction()
13+
14+
15+
def path_exists(path: Path) -> bool:
16+
return path.exists() or is_link_or_junction(path)
17+
18+
19+
def windows_junction(symlink: Path, target: Path) -> None:
20+
"""
21+
Windows junctions are similar to symlinks but specifically for directories.
22+
Does not require admin privileges as symlink_to.
23+
"""
24+
if not target.is_dir():
25+
raise ValueError("Source must be an existing directory for Windows junction.")
26+
27+
cmd = f'mklink /J "{symlink.as_posix()}" "{target.as_posix()}"'
28+
try:
29+
subprocess.check_call(cmd, shell=True)
30+
except subprocess.CalledProcessError as e:
31+
print(f"Failed to create junction. Return code: {e.returncode}")
32+
33+
34+
def execute_symlink(symlink: Path, target: Path) -> None:
35+
target.mkdir(parents=True, exist_ok=True)
36+
try:
37+
symlink.symlink_to(target, target_is_directory=target.is_dir())
38+
except OSError as e:
39+
print(f"Failed to create symlink. Error: {e}")
40+
if is_windows():
41+
windows_junction(symlink, target)

src/uvlink/project.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
from datetime import datetime
88
from pathlib import Path
99

10+
from uvlink.path_utils import is_link_or_junction
11+
1012
from . import __version__
1113

1214

@@ -16,11 +18,10 @@ def rm_rf(path: Path) -> None:
1618
Args:
1719
path (Path): Filesystem target to delete recursively.
1820
"""
19-
20-
if path.is_symlink() or path.is_file():
21-
path.unlink()
22-
else:
21+
if path.is_dir():
2322
shutil.rmtree(path)
23+
else:
24+
path.unlink(missing_ok=True)
2425

2526

2627
def get_uvlink_dir(*subpaths: str | Path) -> Path:
@@ -215,7 +216,8 @@ def get_list(self) -> list[ProjectLinkInfo]:
215216
for p in self:
216217
symlink = p.project_dir / p.venv_type
217218
is_linked = (
218-
symlink.is_symlink() and symlink.resolve().parent == p.project_cache_dir
219+
is_link_or_junction(symlink)
220+
and symlink.resolve().parent == p.project_cache_dir
219221
)
220222
linked.append(
221223
ProjectLinkInfo(

tests/test_cli.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ def test_link_dry_run(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
4444
# Verify that no symlink was actually created (dry-run should not create anything)
4545
assert not expected_symlink.exists()
4646
assert not expected_symlink.is_symlink()
47+
assert not expected_symlink.is_junction()
4748

4849

4950
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:
6768
assert expected_output in result.stdout
6869

6970
# Verify symlink exists
70-
assert expected_symlink.is_symlink()
71+
assert expected_symlink.is_symlink() or expected_symlink.is_junction()
7172

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

105105
# Use --cache-root to specify the cache directory explicitly
106106
cache_dir = fake_home / "uvlink" / "cache"

tests/test_project.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ def test_project_init_path_resolution(
5757
target_dir = tmp_path / "target"
5858
target_dir.mkdir()
5959
symlink_path = tmp_path / "symlink"
60-
symlink_path.symlink_to(target_dir)
60+
execute_symlink(symlink_path, target_dir)
6161

6262
# Project should resolve the symlink to the actual target
6363
p4 = Project(project_dir=symlink_path)

0 commit comments

Comments
 (0)