Skip to content

Commit afa609a

Browse files
dsarnoclaude
andcommitted
verify-worktree: don't accept a stale real dir as a valid link on POSIX
Invariant-2's `-d` fallback was meant only for Windows directory junctions (which appear as plain dirs to bash). On macOS/Linux it also matched a stale real directory at test_project/addons/godot_ai — a full copy of an old plugin left by a self-update smoke test or botched checkout. The script saw plugin.gd inside it, printed [ok], and exited 0 without repairing, so the developer/agent silently tested OUTDATED plugin source. Gate the `-d` fallback to Windows only (reusing the existing ${OS}/${OSTYPE} detection). On POSIX a non-symlink directory now falls through to the existing rm -rf + recreate-symlink path. Windows junction support is unchanged. Add tests/unit/test_verify_worktree_link.py: drives the script against a throwaway sandbox repo (real worktree untouched) covering stale-real-dir repair, healthy-symlink pass-through, missing-link creation, and wrong-target repair. POSIX-only. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent a29bd4a commit afa609a

2 files changed

Lines changed: 139 additions & 3 deletions

File tree

script/verify-worktree

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,16 +38,29 @@ fi
3838
link="test_project/addons/godot_ai"
3939
expected_target_abs="$repo_root/plugin/addons/godot_ai"
4040

41+
# Are we on Windows? Junctions appear as plain directories to bash, so the
42+
# "is it a directory?" fallback below is only meaningful there. On macOS/Linux
43+
# a non-symlink directory at $link is a stale real copy (e.g. left by a
44+
# self-update smoke test or botched checkout) holding OUTDATED plugin code —
45+
# it must be treated as broken and replaced with the symlink, never accepted.
46+
is_windows=0
47+
case "${OS:-}${OSTYPE:-}" in
48+
*Windows_NT*|*msys*|*cygwin*) is_windows=1 ;;
49+
esac
50+
4151
link_ok=0
4252
if [ -L "$link" ]; then
4353
# POSIX symlink — resolve and compare
4454
resolved="$(cd "$(dirname "$link")" && cd "$(readlink "$(basename "$link")")" 2>/dev/null && pwd || true)"
4555
if [ "$resolved" = "$expected_target_abs" ]; then
4656
link_ok=1
4757
fi
48-
elif [ -d "$link" ]; then
49-
# Could be a Windows junction. Compare contents via plugin.gd presence.
50-
# On Windows, junctions appear as directories to bash.
58+
elif [ "$is_windows" = "1" ] && [ -d "$link" ]; then
59+
# On Windows, directory junctions appear as plain directories to bash and
60+
# can't be told apart from real dirs the way `-L` distinguishes symlinks.
61+
# Accept a junction-shaped dir via plugin.gd presence. (Not reachable on
62+
# macOS/Linux: there, a real dir here is a stale copy and falls through to
63+
# the rm -rf + recreate path below.)
5164
if [ -f "$link/plugin.gd" ]; then
5265
link_ok=1
5366
fi
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
"""Behavioral test for ``script/verify-worktree`` Invariant-2 link repair.
2+
3+
The ``test_project/addons/godot_ai`` link is supposed to be a symlink into this
4+
worktree's ``plugin/addons/godot_ai`` (or, on Windows, a directory junction).
5+
6+
Regression guarded here: on macOS/Linux a *stale real directory* at the link
7+
path (a full copy of an old plugin, e.g. left behind by a self-update smoke
8+
test or a botched checkout) used to satisfy the script's ``-d`` +
9+
``plugin.gd``-exists fallback — that branch is only meant to accept genuine
10+
Windows directory junctions, which appear as plain dirs to bash. The script
11+
printed ``[ok]`` and exited 0 without repairing, so a developer/agent silently
12+
tested OUTDATED plugin source. The fix gates the ``-d`` fallback to Windows
13+
only; on POSIX a non-symlink directory is treated as broken and replaced with
14+
the symlink.
15+
16+
Driven against a throwaway sandbox repo (a copy of the script + a minimal
17+
``plugin/`` tree) so the real worktree's link is never touched. POSIX-only —
18+
the bug and its fix are about the POSIX path; the Windows junction branch can't
19+
be exercised from bash on this platform.
20+
"""
21+
22+
from __future__ import annotations
23+
24+
import os
25+
import shutil
26+
import subprocess
27+
from pathlib import Path
28+
29+
import pytest
30+
31+
REPO_ROOT = Path(__file__).resolve().parents[2]
32+
VERIFY_SCRIPT = REPO_ROOT / "script" / "verify-worktree"
33+
34+
pytestmark = pytest.mark.skipif(
35+
os.name == "nt",
36+
reason="POSIX symlink-repair path; Windows uses the junction branch.",
37+
)
38+
39+
40+
def _make_sandbox(tmp_path: Path) -> Path:
41+
"""Build a minimal repo where verify-worktree can run self-contained.
42+
43+
The script does ``cd "$(dirname "$0")/.."`` then operates on
44+
``plugin/`` and ``test_project/addons/godot_ai`` relative to that root.
45+
"""
46+
root = tmp_path / "sandbox"
47+
(root / "script").mkdir(parents=True)
48+
(root / "plugin" / "addons" / "godot_ai").mkdir(parents=True)
49+
(root / "plugin" / "addons" / "godot_ai" / "plugin.gd").write_text(
50+
"# canonical plugin source\n"
51+
)
52+
(root / "test_project" / "addons").mkdir(parents=True)
53+
54+
script_copy = root / "script" / "verify-worktree"
55+
shutil.copy2(VERIFY_SCRIPT, script_copy)
56+
script_copy.chmod(0o755)
57+
return root
58+
59+
60+
def _run(root: Path) -> subprocess.CompletedProcess:
61+
return subprocess.run(
62+
["bash", str(root / "script" / "verify-worktree")],
63+
capture_output=True,
64+
text=True,
65+
)
66+
67+
68+
def test_stale_real_dir_is_repaired_to_symlink(tmp_path: Path) -> None:
69+
"""A stale real directory (not a symlink) must be replaced, not accepted."""
70+
root = _make_sandbox(tmp_path)
71+
link = root / "test_project" / "addons" / "godot_ai"
72+
73+
# Stale real-dir copy holding OUTDATED plugin code.
74+
link.mkdir()
75+
(link / "plugin.gd").write_text("# OUTDATED stale copy\n")
76+
77+
result = _run(root)
78+
79+
assert result.returncode == 0, result.stderr
80+
assert link.is_symlink(), "stale real dir should have been replaced with a symlink"
81+
assert link.resolve() == (root / "plugin" / "addons" / "godot_ai").resolve()
82+
# And it now resolves to the canonical (not the stale) plugin.gd.
83+
assert (link / "plugin.gd").read_text() == "# canonical plugin source\n"
84+
85+
86+
def test_healthy_symlink_is_left_intact(tmp_path: Path) -> None:
87+
"""An already-correct symlink passes without being recreated."""
88+
root = _make_sandbox(tmp_path)
89+
link = root / "test_project" / "addons" / "godot_ai"
90+
link.symlink_to(Path("../../plugin/addons/godot_ai"))
91+
92+
result = _run(root)
93+
94+
assert result.returncode == 0, result.stderr
95+
assert "[ok]" in result.stdout
96+
assert link.is_symlink()
97+
assert link.resolve() == (root / "plugin" / "addons" / "godot_ai").resolve()
98+
99+
100+
def test_missing_link_is_created(tmp_path: Path) -> None:
101+
"""No link at all → create the symlink."""
102+
root = _make_sandbox(tmp_path)
103+
link = root / "test_project" / "addons" / "godot_ai"
104+
105+
result = _run(root)
106+
107+
assert result.returncode == 0, result.stderr
108+
assert link.is_symlink()
109+
assert link.resolve() == (root / "plugin" / "addons" / "godot_ai").resolve()
110+
111+
112+
def test_wrong_symlink_target_is_repaired(tmp_path: Path) -> None:
113+
"""A symlink pointing somewhere else is repaired to the canonical target."""
114+
root = _make_sandbox(tmp_path)
115+
link = root / "test_project" / "addons" / "godot_ai"
116+
(root / "elsewhere").mkdir()
117+
link.symlink_to(Path("../../elsewhere"))
118+
119+
result = _run(root)
120+
121+
assert result.returncode == 0, result.stderr
122+
assert link.is_symlink()
123+
assert link.resolve() == (root / "plugin" / "addons" / "godot_ai").resolve()

0 commit comments

Comments
 (0)