diff options
| author | Carlos Fernandez <carlosfsanz@meta.com> | 2026-05-07 09:00:33 -0700 |
|---|---|---|
| committer | gerrit-scoped@luci-project-accounts.iam.gserviceaccount.com <gerrit-scoped@luci-project-accounts.iam.gserviceaccount.com> | 2026-05-12 11:10:43 -0700 |
| commit | 5534f164d6aff567f7ea0b5b41e2e5bb35a0212c (patch) | |
| tree | c5cfaedb059c82a61bf0972b743cd5470fdc7fb9 | |
| parent | 67e52a120b210f70b7a5de06996d9036f9f68011 (diff) | |
| download | git-repo-5534f164d6aff567f7ea0b5b41e2e5bb35a0212c.tar.gz | |
linkfile: Handle directory-to-symlink transitions safely
When a manifest changes from individual linkfiles inside a directory
(e.g. dest=".llms/rules", dest=".llms/skills") to a single linkfile
for the whole directory (e.g. dest=".llms", src="dot-llms"), two
things need to happen:
1. __linkIt must replace a real directory with a symlink. Use
os.rmdir() instead of platform_utils.remove() for real directories.
rmdir only removes empty directories, so user-created content is
never deleted.
2. UpdateCopyLinkfileList must handle the cleanup correctly:
- Use os.rmdir() for directories (safe for non-empty)
- Remove empty parent directories after cleaning old dests
- Retry _CopyAndLinkFiles for all projects, since in interleaved
sync mode _CopyAndLinkFiles runs before cleanup and may have
failed because the directory was not yet empty
Change-Id: I0437b80beab98bce064cea81c11c47d699be91aa
Reviewed-on: https://gerrit-review.googlesource.com/c/git-repo/+/569243
Tested-by: Carlos Fernandez <carlosfsanz@meta.com>
Reviewed-by: Mike Frysinger <vapier@google.com>
Reviewed-by: Gavin Mak <gavinmak@google.com>
Commit-Queue: Carlos Fernandez <carlosfsanz@meta.com>
| -rw-r--r-- | platform_utils.py | 37 | ||||
| -rw-r--r-- | project.py | 8 | ||||
| -rw-r--r-- | subcmds/sync.py | 34 | ||||
| -rw-r--r-- | tests/test_platform_utils.py | 68 | ||||
| -rw-r--r-- | tests/test_project.py | 41 | ||||
| -rw-r--r-- | tests/test_subcmds_sync.py | 141 |
6 files changed, 320 insertions, 9 deletions
diff --git a/platform_utils.py b/platform_utils.py index 45ffec78c..58df6a9e2 100644 --- a/platform_utils.py +++ b/platform_utils.py | |||
| @@ -222,6 +222,43 @@ def rmdir(path): | |||
| 222 | os.rmdir(_makelongpath(path)) | 222 | os.rmdir(_makelongpath(path)) |
| 223 | 223 | ||
| 224 | 224 | ||
| 225 | def removedirs(path: str) -> None: | ||
| 226 | """Remove a directory tree of symlinks and empty directories. | ||
| 227 | |||
| 228 | Walks |path| bottom-up without following symlinks. Removes symlinks | ||
| 229 | and empty directories. Stops at (and preserves) regular files and | ||
| 230 | non-empty directories. Silently succeeds if |path| does not exist. | ||
| 231 | |||
| 232 | Availability: Unix, Windows. | ||
| 233 | """ | ||
| 234 | if islink(path): | ||
| 235 | remove(path) | ||
| 236 | return | ||
| 237 | |||
| 238 | if not isdir(path): | ||
| 239 | return | ||
| 240 | |||
| 241 | for dirpath, dirnames, filenames in walk(path, topdown=False): | ||
| 242 | for name in dirnames: | ||
| 243 | entry = os.path.join(dirpath, name) | ||
| 244 | if islink(entry): | ||
| 245 | remove(entry) | ||
| 246 | else: | ||
| 247 | try: | ||
| 248 | rmdir(entry) | ||
| 249 | except OSError: | ||
| 250 | pass | ||
| 251 | for name in filenames: | ||
| 252 | entry = os.path.join(dirpath, name) | ||
| 253 | if islink(entry): | ||
| 254 | remove(entry) | ||
| 255 | |||
| 256 | try: | ||
| 257 | rmdir(path) | ||
| 258 | except OSError: | ||
| 259 | pass | ||
| 260 | |||
| 261 | |||
| 225 | def isdir(path): | 262 | def isdir(path): |
| 226 | """os.path.isdir(path) wrapper with support for long paths on Windows. | 263 | """os.path.isdir(path) wrapper with support for long paths on Windows. |
| 227 | 264 | ||
diff --git a/project.py b/project.py index f2fe7bc2a..5f2a98e18 100644 --- a/project.py +++ b/project.py | |||
| @@ -453,9 +453,13 @@ class _LinkFile(NamedTuple): | |||
| 453 | platform_utils.readlink(absDest) != relSrc | 453 | platform_utils.readlink(absDest) != relSrc |
| 454 | ): | 454 | ): |
| 455 | try: | 455 | try: |
| 456 | # Remove existing file first, since it might be read-only. | 456 | # Remove existing path first, since it might be read-only. |
| 457 | if os.path.lexists(absDest): | 457 | if os.path.lexists(absDest): |
| 458 | platform_utils.remove(absDest) | 458 | # removedirs handles symlinks, empty dirs, and nested |
| 459 | # trees of stale linkfile dests without deleting user | ||
| 460 | # content. Falls through to symlink() if the path was | ||
| 461 | # fully removed, or raises OSError if not. | ||
| 462 | platform_utils.removedirs(absDest) | ||
| 459 | else: | 463 | else: |
| 460 | dest_dir = os.path.dirname(absDest) | 464 | dest_dir = os.path.dirname(absDest) |
| 461 | if not platform_utils.isdir(dest_dir): | 465 | if not platform_utils.isdir(dest_dir): |
diff --git a/subcmds/sync.py b/subcmds/sync.py index 2517694e7..291bc6243 100644 --- a/subcmds/sync.py +++ b/subcmds/sync.py | |||
| @@ -1631,9 +1631,10 @@ later is required to fix a server side protocol bug. | |||
| 1631 | new_paths = {} | 1631 | new_paths = {} |
| 1632 | new_linkfile_paths = [] | 1632 | new_linkfile_paths = [] |
| 1633 | new_copyfile_paths = [] | 1633 | new_copyfile_paths = [] |
| 1634 | for project in self.GetProjects( | 1634 | projects = self.GetProjects( |
| 1635 | None, missing_ok=True, manifest=manifest, all_manifests=False | 1635 | None, missing_ok=True, manifest=manifest, all_manifests=False |
| 1636 | ): | 1636 | ) |
| 1637 | for project in projects: | ||
| 1637 | new_linkfile_paths.extend(x.dest for x in project.linkfiles) | 1638 | new_linkfile_paths.extend(x.dest for x in project.linkfiles) |
| 1638 | new_copyfile_paths.extend(x.dest for x in project.copyfiles) | 1639 | new_copyfile_paths.extend(x.dest for x in project.copyfiles) |
| 1639 | 1640 | ||
| @@ -1669,17 +1670,36 @@ later is required to fix a server side protocol bug. | |||
| 1669 | ) | 1670 | ) |
| 1670 | 1671 | ||
| 1671 | for need_remove_file in need_remove_files: | 1672 | for need_remove_file in need_remove_files: |
| 1672 | # Try to remove the updated copyfile or linkfile. | 1673 | need_remove_path = os.path.join( |
| 1673 | # So, if the file is not exist, nothing need to do. | 1674 | self.client.topdir, need_remove_file |
| 1674 | platform_utils.remove( | ||
| 1675 | os.path.join(self.client.topdir, need_remove_file), | ||
| 1676 | missing_ok=True, | ||
| 1677 | ) | 1675 | ) |
| 1676 | if os.path.isfile(need_remove_path): | ||
| 1677 | platform_utils.remove(need_remove_path) | ||
| 1678 | else: | ||
| 1679 | platform_utils.removedirs(need_remove_path) | ||
| 1680 | |||
| 1681 | # Also try to remove empty parent directories. | ||
| 1682 | parent = os.path.dirname(need_remove_path) | ||
| 1683 | while parent != self.client.topdir: | ||
| 1684 | try: | ||
| 1685 | os.rmdir(parent) | ||
| 1686 | except OSError: | ||
| 1687 | break | ||
| 1688 | parent = os.path.dirname(parent) | ||
| 1678 | 1689 | ||
| 1679 | # Create copy-link-files.json, save dest path of "copyfile" and | 1690 | # Create copy-link-files.json, save dest path of "copyfile" and |
| 1680 | # "linkfile". | 1691 | # "linkfile". |
| 1681 | with open(copylinkfile_path, "w", encoding="utf-8") as fp: | 1692 | with open(copylinkfile_path, "w", encoding="utf-8") as fp: |
| 1682 | json.dump(new_paths, fp) | 1693 | json.dump(new_paths, fp) |
| 1694 | |||
| 1695 | # Retry linkfile/copyfile creation for all projects. In | ||
| 1696 | # interleaved sync mode, _CopyAndLinkFiles runs before this | ||
| 1697 | # cleanup, so linkfiles whose dest was blocked by an old | ||
| 1698 | # directory may have failed. _CopyAndLinkFiles is idempotent | ||
| 1699 | # and skips dests that are already correct. | ||
| 1700 | for project in projects: | ||
| 1701 | project._CopyAndLinkFiles() | ||
| 1702 | |||
| 1683 | return True | 1703 | return True |
| 1684 | 1704 | ||
| 1685 | def _SmartSyncSetup(self, opt, smart_sync_manifest_path, manifest): | 1705 | def _SmartSyncSetup(self, opt, smart_sync_manifest_path, manifest): |
diff --git a/tests/test_platform_utils.py b/tests/test_platform_utils.py index e89965391..919351e7f 100644 --- a/tests/test_platform_utils.py +++ b/tests/test_platform_utils.py | |||
| @@ -46,3 +46,71 @@ def test_remove_missing_ok(tmp_path: Path) -> None: | |||
| 46 | path.touch() | 46 | path.touch() |
| 47 | platform_utils.remove(path, missing_ok=False) | 47 | platform_utils.remove(path, missing_ok=False) |
| 48 | assert not path.exists() | 48 | assert not path.exists() |
| 49 | |||
| 50 | |||
| 51 | def test_removedirs_nonexistent(tmp_path: Path) -> None: | ||
| 52 | """removedirs should silently succeed on nonexistent paths.""" | ||
| 53 | platform_utils.removedirs(tmp_path / "does-not-exist") | ||
| 54 | |||
| 55 | |||
| 56 | def test_removedirs_symlink(tmp_path: Path) -> None: | ||
| 57 | """removedirs should remove a symlink.""" | ||
| 58 | link = tmp_path / "link" | ||
| 59 | link.symlink_to("target") | ||
| 60 | platform_utils.removedirs(link) | ||
| 61 | assert not link.exists() | ||
| 62 | |||
| 63 | |||
| 64 | def test_removedirs_empty_dir(tmp_path: Path) -> None: | ||
| 65 | """removedirs should remove an empty directory.""" | ||
| 66 | d = tmp_path / "empty" | ||
| 67 | d.mkdir() | ||
| 68 | platform_utils.removedirs(d) | ||
| 69 | assert not d.exists() | ||
| 70 | |||
| 71 | |||
| 72 | def test_removedirs_nested_empty_dirs(tmp_path: Path) -> None: | ||
| 73 | """removedirs should remove nested empty directories.""" | ||
| 74 | d = tmp_path / "a" / "b" / "c" | ||
| 75 | d.mkdir(parents=True) | ||
| 76 | platform_utils.removedirs(tmp_path / "a") | ||
| 77 | assert not (tmp_path / "a").exists() | ||
| 78 | |||
| 79 | |||
| 80 | def test_removedirs_symlinks_inside_dir(tmp_path: Path) -> None: | ||
| 81 | """removedirs should remove symlinks inside a directory.""" | ||
| 82 | d = tmp_path / "dir" | ||
| 83 | d.mkdir() | ||
| 84 | (d / "link1").symlink_to("target1") | ||
| 85 | (d / "link2").symlink_to("target2") | ||
| 86 | platform_utils.removedirs(d) | ||
| 87 | assert not d.exists() | ||
| 88 | |||
| 89 | |||
| 90 | def test_removedirs_preserves_user_files(tmp_path: Path) -> None: | ||
| 91 | """removedirs should not delete regular files or their parent dirs.""" | ||
| 92 | d = tmp_path / "dir" | ||
| 93 | d.mkdir() | ||
| 94 | (d / "link").symlink_to("target") | ||
| 95 | (d / "user-file.txt").write_text("keep me") | ||
| 96 | platform_utils.removedirs(d) | ||
| 97 | assert d.exists() | ||
| 98 | assert not (d / "link").exists() | ||
| 99 | assert (d / "user-file.txt").read_text() == "keep me" | ||
| 100 | |||
| 101 | |||
| 102 | def test_removedirs_deep_nested_with_symlinks(tmp_path: Path) -> None: | ||
| 103 | """removedirs should handle deep nesting: sub/dir/target.""" | ||
| 104 | d = tmp_path / "sub" / "dir" | ||
| 105 | d.mkdir(parents=True) | ||
| 106 | (d / "link").symlink_to("target") | ||
| 107 | platform_utils.removedirs(tmp_path / "sub") | ||
| 108 | assert not (tmp_path / "sub").exists() | ||
| 109 | |||
| 110 | |||
| 111 | def test_removedirs_regular_file_noop(tmp_path: Path) -> None: | ||
| 112 | """removedirs should not delete a regular file.""" | ||
| 113 | f = tmp_path / "file.txt" | ||
| 114 | f.write_text("data") | ||
| 115 | platform_utils.removedirs(f) | ||
| 116 | assert f.exists() | ||
diff --git a/tests/test_project.py b/tests/test_project.py index 1deec0726..728d5a54c 100644 --- a/tests/test_project.py +++ b/tests/test_project.py | |||
| @@ -365,6 +365,47 @@ class LinkFile(CopyLinkTestCase): | |||
| 365 | os.path.join("git-project", "foo.txt"), os.readlink(dest) | 365 | os.path.join("git-project", "foo.txt"), os.readlink(dest) |
| 366 | ) | 366 | ) |
| 367 | 367 | ||
| 368 | def test_replace_empty_dir_with_symlink(self): | ||
| 369 | """A linkfile should replace an empty real directory at the dest path. | ||
| 370 | |||
| 371 | This is the common case: the old linkfiles inside the directory were | ||
| 372 | already cleaned up by UpdateCopyLinkfileList, leaving an empty parent | ||
| 373 | directory behind. | ||
| 374 | """ | ||
| 375 | src_dir = os.path.join(self.worktree, "dot-llms") | ||
| 376 | os.makedirs(src_dir) | ||
| 377 | |||
| 378 | dest = os.path.join(self.topdir, "mydir") | ||
| 379 | os.makedirs(dest) | ||
| 380 | |||
| 381 | lf = self.LinkFile("dot-llms", "mydir") | ||
| 382 | lf._Link() | ||
| 383 | self.assertTrue(os.path.islink(dest)) | ||
| 384 | self.assertEqual( | ||
| 385 | os.path.join("git-project", "dot-llms"), os.readlink(dest) | ||
| 386 | ) | ||
| 387 | |||
| 388 | def test_nonempty_dir_not_clobbered(self): | ||
| 389 | """A linkfile must not delete a non-empty directory. | ||
| 390 | |||
| 391 | If the user created files in a directory that a new linkfile wants | ||
| 392 | to replace, __linkIt should fail safely rather than deleting content. | ||
| 393 | """ | ||
| 394 | src_dir = os.path.join(self.worktree, "dot-llms") | ||
| 395 | os.makedirs(src_dir) | ||
| 396 | |||
| 397 | dest = os.path.join(self.topdir, "mydir") | ||
| 398 | os.makedirs(dest) | ||
| 399 | user_file = os.path.join(dest, "user-notes.txt") | ||
| 400 | self.touch(user_file) | ||
| 401 | |||
| 402 | lf = self.LinkFile("dot-llms", "mydir") | ||
| 403 | lf._Link() | ||
| 404 | # The directory should NOT be replaced — user content is preserved. | ||
| 405 | self.assertFalse(os.path.islink(dest)) | ||
| 406 | self.assertTrue(os.path.isdir(dest)) | ||
| 407 | self.assertTrue(os.path.exists(user_file)) | ||
| 408 | |||
| 368 | 409 | ||
| 369 | class MigrateWorkTreeTests(unittest.TestCase): | 410 | class MigrateWorkTreeTests(unittest.TestCase): |
| 370 | """Check _MigrateOldWorkTreeGitDir handling.""" | 411 | """Check _MigrateOldWorkTreeGitDir handling.""" |
diff --git a/tests/test_subcmds_sync.py b/tests/test_subcmds_sync.py index 669027c98..ef162392b 100644 --- a/tests/test_subcmds_sync.py +++ b/tests/test_subcmds_sync.py | |||
| @@ -13,6 +13,7 @@ | |||
| 13 | # limitations under the License. | 13 | # limitations under the License. |
| 14 | """Unittests for the subcmds/sync.py module.""" | 14 | """Unittests for the subcmds/sync.py module.""" |
| 15 | 15 | ||
| 16 | import json | ||
| 16 | import os | 17 | import os |
| 17 | import shutil | 18 | import shutil |
| 18 | import tempfile | 19 | import tempfile |
| @@ -1145,3 +1146,143 @@ class InterleavedSyncTest(unittest.TestCase): | |||
| 1145 | self.assertTrue(result.checkout_success) | 1146 | self.assertTrue(result.checkout_success) |
| 1146 | project.Sync_NetworkHalf.assert_called_once() | 1147 | project.Sync_NetworkHalf.assert_called_once() |
| 1147 | project.Sync_LocalHalf.assert_not_called() | 1148 | project.Sync_LocalHalf.assert_not_called() |
| 1149 | |||
| 1150 | |||
| 1151 | class UpdateCopyLinkfileListTest(unittest.TestCase): | ||
| 1152 | """Tests for Sync.UpdateCopyLinkfileList.""" | ||
| 1153 | |||
| 1154 | def setUp(self): | ||
| 1155 | self.tempdirobj = tempfile.TemporaryDirectory(prefix="repo_tests") | ||
| 1156 | self.topdir = self.tempdirobj.name | ||
| 1157 | self.repodir = os.path.join(self.topdir, ".repo") | ||
| 1158 | os.makedirs(self.repodir) | ||
| 1159 | |||
| 1160 | manifest = mock.MagicMock() | ||
| 1161 | manifest.subdir = self.repodir | ||
| 1162 | self.manifest = manifest | ||
| 1163 | |||
| 1164 | git_event_log = mock.MagicMock(ErrorEvent=mock.Mock(return_value=None)) | ||
| 1165 | self.cmd = sync.Sync( | ||
| 1166 | manifest=manifest, | ||
| 1167 | outer_client=mock.MagicMock(), | ||
| 1168 | git_event_log=git_event_log, | ||
| 1169 | ) | ||
| 1170 | self.cmd.client = mock.MagicMock(topdir=self.topdir) | ||
| 1171 | |||
| 1172 | def tearDown(self): | ||
| 1173 | self.tempdirobj.cleanup() | ||
| 1174 | |||
| 1175 | def _write_copylinkfile_json(self, data: dict) -> None: | ||
| 1176 | path = os.path.join(self.repodir, "copy-link-files.json") | ||
| 1177 | with open(path, "w") as f: | ||
| 1178 | json.dump(data, f) | ||
| 1179 | |||
| 1180 | def _setup_projects(self, linkfile_dests: list) -> None: | ||
| 1181 | project = mock.MagicMock() | ||
| 1182 | project.linkfiles = [mock.MagicMock(dest=d) for d in linkfile_dests] | ||
| 1183 | project.copyfiles = [] | ||
| 1184 | mock.patch.object( | ||
| 1185 | self.cmd, "GetProjects", return_value=[project] | ||
| 1186 | ).start() | ||
| 1187 | |||
| 1188 | def test_removes_old_symlink_dest(self): | ||
| 1189 | """Old linkfile dests that are symlinks should be removed.""" | ||
| 1190 | old_dest = os.path.join(self.topdir, "old-link") | ||
| 1191 | os.symlink("target", old_dest) | ||
| 1192 | |||
| 1193 | self._write_copylinkfile_json( | ||
| 1194 | {"linkfile": ["old-link"], "copyfile": []} | ||
| 1195 | ) | ||
| 1196 | self._setup_projects([]) | ||
| 1197 | |||
| 1198 | self.cmd.UpdateCopyLinkfileList(self.manifest) | ||
| 1199 | self.assertFalse(os.path.lexists(old_dest)) | ||
| 1200 | |||
| 1201 | def test_does_not_delete_through_new_symlink(self): | ||
| 1202 | """Old dests that resolve through a new symlink must not delete files. | ||
| 1203 | |||
| 1204 | When the manifest changes from individual linkfiles inside a directory | ||
| 1205 | to a single directory linkfile, and _CopyAndLinkFiles has already | ||
| 1206 | created the symlink (interleaved mode), cleanup must not follow the | ||
| 1207 | symlink and delete real project files. | ||
| 1208 | """ | ||
| 1209 | project_dir = os.path.join(self.topdir, "vendor", "tools", "llms") | ||
| 1210 | os.makedirs(os.path.join(project_dir, "dot-llms", "rules")) | ||
| 1211 | os.makedirs(os.path.join(project_dir, "dot-llms", "skills")) | ||
| 1212 | with open( | ||
| 1213 | os.path.join(project_dir, "dot-llms", "rules", "basics.md"), "w" | ||
| 1214 | ) as f: | ||
| 1215 | f.write("# basics") | ||
| 1216 | with open( | ||
| 1217 | os.path.join(project_dir, "dot-llms", "skills", "repo.md"), "w" | ||
| 1218 | ) as f: | ||
| 1219 | f.write("# repo") | ||
| 1220 | |||
| 1221 | # Simulate interleaved mode: .llms -> vendor/tools/llms/dot-llms. | ||
| 1222 | llms_link = os.path.join(self.topdir, ".llms") | ||
| 1223 | os.symlink("vendor/tools/llms/dot-llms", llms_link) | ||
| 1224 | |||
| 1225 | self._write_copylinkfile_json( | ||
| 1226 | {"linkfile": [".llms/rules", ".llms/skills"], "copyfile": []} | ||
| 1227 | ) | ||
| 1228 | self._setup_projects([".llms"]) | ||
| 1229 | |||
| 1230 | self.cmd.UpdateCopyLinkfileList(self.manifest) | ||
| 1231 | |||
| 1232 | # Real project files must still exist. | ||
| 1233 | self.assertTrue( | ||
| 1234 | os.path.exists( | ||
| 1235 | os.path.join(project_dir, "dot-llms", "rules", "basics.md") | ||
| 1236 | ) | ||
| 1237 | ) | ||
| 1238 | self.assertTrue( | ||
| 1239 | os.path.exists( | ||
| 1240 | os.path.join(project_dir, "dot-llms", "skills", "repo.md") | ||
| 1241 | ), | ||
| 1242 | ) | ||
| 1243 | self.assertTrue(os.path.islink(llms_link)) | ||
| 1244 | |||
| 1245 | def test_cleans_up_empty_parent_dirs(self): | ||
| 1246 | """After removing old dests, empty parent directories are removed.""" | ||
| 1247 | llms_dir = os.path.join(self.topdir, ".llms") | ||
| 1248 | os.makedirs(llms_dir) | ||
| 1249 | os.symlink( | ||
| 1250 | "../vendor/tools/llms/rules", os.path.join(llms_dir, "rules") | ||
| 1251 | ) | ||
| 1252 | os.symlink( | ||
| 1253 | "../vendor/tools/llms/skills", | ||
| 1254 | os.path.join(llms_dir, "skills"), | ||
| 1255 | ) | ||
| 1256 | |||
| 1257 | self._write_copylinkfile_json( | ||
| 1258 | {"linkfile": [".llms/rules", ".llms/skills"], "copyfile": []} | ||
| 1259 | ) | ||
| 1260 | self._setup_projects([".llms"]) | ||
| 1261 | |||
| 1262 | self.cmd.UpdateCopyLinkfileList(self.manifest) | ||
| 1263 | |||
| 1264 | self.assertFalse(os.path.lexists(os.path.join(llms_dir, "rules"))) | ||
| 1265 | self.assertFalse(os.path.lexists(os.path.join(llms_dir, "skills"))) | ||
| 1266 | # Parent directory should be removed since it's now empty. | ||
| 1267 | self.assertFalse(os.path.exists(llms_dir)) | ||
| 1268 | |||
| 1269 | def test_preserves_nonempty_parent_dirs(self): | ||
| 1270 | """Non-empty parent directories are preserved after old dest removal.""" | ||
| 1271 | llms_dir = os.path.join(self.topdir, ".llms") | ||
| 1272 | os.makedirs(llms_dir) | ||
| 1273 | os.symlink( | ||
| 1274 | "../vendor/tools/llms/rules", os.path.join(llms_dir, "rules") | ||
| 1275 | ) | ||
| 1276 | with open(os.path.join(llms_dir, "my-notes.txt"), "w") as f: | ||
| 1277 | f.write("user content") | ||
| 1278 | |||
| 1279 | self._write_copylinkfile_json( | ||
| 1280 | {"linkfile": [".llms/rules"], "copyfile": []} | ||
| 1281 | ) | ||
| 1282 | self._setup_projects([".llms"]) | ||
| 1283 | |||
| 1284 | self.cmd.UpdateCopyLinkfileList(self.manifest) | ||
| 1285 | |||
| 1286 | self.assertFalse(os.path.lexists(os.path.join(llms_dir, "rules"))) | ||
| 1287 | self.assertTrue(os.path.exists(os.path.join(llms_dir, "my-notes.txt"))) | ||
| 1288 | self.assertTrue(os.path.isdir(llms_dir)) | ||
