summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorCarlos Fernandez <carlosfsanz@meta.com>2026-05-07 09:00:33 -0700
committergerrit-scoped@luci-project-accounts.iam.gserviceaccount.com <gerrit-scoped@luci-project-accounts.iam.gserviceaccount.com>2026-05-12 11:10:43 -0700
commit5534f164d6aff567f7ea0b5b41e2e5bb35a0212c (patch)
treec5cfaedb059c82a61bf0972b743cd5470fdc7fb9
parent67e52a120b210f70b7a5de06996d9036f9f68011 (diff)
downloadgit-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.py37
-rw-r--r--project.py8
-rw-r--r--subcmds/sync.py34
-rw-r--r--tests/test_platform_utils.py68
-rw-r--r--tests/test_project.py41
-rw-r--r--tests/test_subcmds_sync.py141
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
225def 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
225def isdir(path): 262def 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
51def 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
56def 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
64def 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
72def 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
80def 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
90def 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
102def 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
111def 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
369class MigrateWorkTreeTests(unittest.TestCase): 410class 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
16import json
16import os 17import os
17import shutil 18import shutil
18import tempfile 19import 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
1151class 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))