|
4 | 4 | """ |
5 | 5 | from __future__ import annotations |
6 | 6 |
|
| 7 | +import io |
7 | 8 | import os |
| 9 | +from contextlib import redirect_stdout |
| 10 | +from pathlib import Path |
8 | 11 |
|
9 | 12 | from _base import FRAMEWORK_ROOT, InstallerTestCase |
10 | 13 | from installer.errors import IntegrityError |
@@ -104,6 +107,55 @@ def test_broken_framework_symlink_warned_not_added(self) -> None: |
104 | 107 | result = scan_local_exceptions(self.target) |
105 | 108 | self.assertEqual(result, []) # broken framework link is not an exception |
106 | 109 |
|
| 110 | + # -- symlinks leaving the project ------------------------------------ |
| 111 | + # A `!` on one of these invites `git add -A` to commit a path that exists on |
| 112 | + # the author's machine only. Measured on a real project: one `update` added |
| 113 | + # 15 such lines for skills symlinked out of ~/ExternalTools. |
| 114 | + |
| 115 | + def _outbound_symlink(self, name: str) -> Path: |
| 116 | + """Symlink ``name`` at a real directory outside the target project.""" |
| 117 | + outside = self.tmp / "outside" / name |
| 118 | + outside.mkdir(parents=True) |
| 119 | + os.symlink(outside, self.skills / name) |
| 120 | + return outside |
| 121 | + |
| 122 | + def test_outbound_symlink_not_exception(self) -> None: |
| 123 | + self._outbound_symlink("external-skill") |
| 124 | + self.assertEqual(scan_local_exceptions(self.target), []) |
| 125 | + |
| 126 | + def test_outbound_symlink_warned(self) -> None: |
| 127 | + self._outbound_symlink("external-skill") |
| 128 | + with redirect_stdout(io.StringIO()) as out: |
| 129 | + scan_local_exceptions(self.target) |
| 130 | + self.assertIn("external-skill", out.getvalue()) |
| 131 | + self.assertIn("resolves outside the project", out.getvalue()) |
| 132 | + |
| 133 | + def test_inbound_symlink_is_exception(self) -> None: |
| 134 | + # `.claude/skills/x` -> `.agent/skills/x` is relative and in-repo, so a |
| 135 | + # clone resolves it; it stays trackable. |
| 136 | + (self.skills / "owned").mkdir() |
| 137 | + claude_skills = self.target / ".claude" / "skills" |
| 138 | + claude_skills.mkdir(parents=True) |
| 139 | + os.symlink("../../.agent/skills/owned", claude_skills / "owned") |
| 140 | + self.assertEqual( |
| 141 | + scan_local_exceptions(self.target), |
| 142 | + ["!/.agent/skills/owned", "!/.claude/skills/owned"], |
| 143 | + ) |
| 144 | + |
| 145 | + def test_outbound_symlink_stays_out_across_repeated_updates(self) -> None: |
| 146 | + # The block is regenerated on every `update`, so a one-off correct run |
| 147 | + # proves nothing: the defect must not come back on the second pass. |
| 148 | + self._outbound_symlink("external-skill") |
| 149 | + state: dict = {} |
| 150 | + with redirect_stdout(io.StringIO()): |
| 151 | + state["gitignore_block_hash"] = update_gitignore( |
| 152 | + self.target, _CLAUDE, state) |
| 153 | + update_gitignore(self.target, _CLAUDE, state) |
| 154 | + body = extract_block( |
| 155 | + (self.target / ".gitignore").read_text(encoding="utf-8"), |
| 156 | + GITIGNORE_MARKERS) |
| 157 | + self.assertNotIn("external-skill", body) |
| 158 | + |
107 | 159 |
|
108 | 160 | class TestUpdateGitignore(InstallerTestCase): |
109 | 161 |
|
|
0 commit comments