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>
diff --git a/platform_utils.py b/platform_utils.py
index 45ffec7..58df6a9 100644
--- a/platform_utils.py
+++ b/platform_utils.py
@@ -222,6 +222,43 @@
os.rmdir(_makelongpath(path))
+def removedirs(path: str) -> None:
+ """Remove a directory tree of symlinks and empty directories.
+
+ Walks |path| bottom-up without following symlinks. Removes symlinks
+ and empty directories. Stops at (and preserves) regular files and
+ non-empty directories. Silently succeeds if |path| does not exist.
+
+ Availability: Unix, Windows.
+ """
+ if islink(path):
+ remove(path)
+ return
+
+ if not isdir(path):
+ return
+
+ for dirpath, dirnames, filenames in walk(path, topdown=False):
+ for name in dirnames:
+ entry = os.path.join(dirpath, name)
+ if islink(entry):
+ remove(entry)
+ else:
+ try:
+ rmdir(entry)
+ except OSError:
+ pass
+ for name in filenames:
+ entry = os.path.join(dirpath, name)
+ if islink(entry):
+ remove(entry)
+
+ try:
+ rmdir(path)
+ except OSError:
+ pass
+
+
def isdir(path):
"""os.path.isdir(path) wrapper with support for long paths on Windows.
diff --git a/project.py b/project.py
index f2fe7bc..5f2a98e 100644
--- a/project.py
+++ b/project.py
@@ -453,9 +453,13 @@
platform_utils.readlink(absDest) != relSrc
):
try:
- # Remove existing file first, since it might be read-only.
+ # Remove existing path first, since it might be read-only.
if os.path.lexists(absDest):
- platform_utils.remove(absDest)
+ # removedirs handles symlinks, empty dirs, and nested
+ # trees of stale linkfile dests without deleting user
+ # content. Falls through to symlink() if the path was
+ # fully removed, or raises OSError if not.
+ platform_utils.removedirs(absDest)
else:
dest_dir = os.path.dirname(absDest)
if not platform_utils.isdir(dest_dir):
diff --git a/subcmds/sync.py b/subcmds/sync.py
index 2517694..291bc62 100644
--- a/subcmds/sync.py
+++ b/subcmds/sync.py
@@ -1631,9 +1631,10 @@
new_paths = {}
new_linkfile_paths = []
new_copyfile_paths = []
- for project in self.GetProjects(
+ projects = self.GetProjects(
None, missing_ok=True, manifest=manifest, all_manifests=False
- ):
+ )
+ for project in projects:
new_linkfile_paths.extend(x.dest for x in project.linkfiles)
new_copyfile_paths.extend(x.dest for x in project.copyfiles)
@@ -1669,17 +1670,36 @@
)
for need_remove_file in need_remove_files:
- # Try to remove the updated copyfile or linkfile.
- # So, if the file is not exist, nothing need to do.
- platform_utils.remove(
- os.path.join(self.client.topdir, need_remove_file),
- missing_ok=True,
+ need_remove_path = os.path.join(
+ self.client.topdir, need_remove_file
)
+ if os.path.isfile(need_remove_path):
+ platform_utils.remove(need_remove_path)
+ else:
+ platform_utils.removedirs(need_remove_path)
+
+ # Also try to remove empty parent directories.
+ parent = os.path.dirname(need_remove_path)
+ while parent != self.client.topdir:
+ try:
+ os.rmdir(parent)
+ except OSError:
+ break
+ parent = os.path.dirname(parent)
# Create copy-link-files.json, save dest path of "copyfile" and
# "linkfile".
with open(copylinkfile_path, "w", encoding="utf-8") as fp:
json.dump(new_paths, fp)
+
+ # Retry linkfile/copyfile creation for all projects. In
+ # interleaved sync mode, _CopyAndLinkFiles runs before this
+ # cleanup, so linkfiles whose dest was blocked by an old
+ # directory may have failed. _CopyAndLinkFiles is idempotent
+ # and skips dests that are already correct.
+ for project in projects:
+ project._CopyAndLinkFiles()
+
return True
def _SmartSyncSetup(self, opt, smart_sync_manifest_path, manifest):
diff --git a/tests/test_platform_utils.py b/tests/test_platform_utils.py
index e899653..919351e 100644
--- a/tests/test_platform_utils.py
+++ b/tests/test_platform_utils.py
@@ -46,3 +46,71 @@
path.touch()
platform_utils.remove(path, missing_ok=False)
assert not path.exists()
+
+
+def test_removedirs_nonexistent(tmp_path: Path) -> None:
+ """removedirs should silently succeed on nonexistent paths."""
+ platform_utils.removedirs(tmp_path / "does-not-exist")
+
+
+def test_removedirs_symlink(tmp_path: Path) -> None:
+ """removedirs should remove a symlink."""
+ link = tmp_path / "link"
+ link.symlink_to("target")
+ platform_utils.removedirs(link)
+ assert not link.exists()
+
+
+def test_removedirs_empty_dir(tmp_path: Path) -> None:
+ """removedirs should remove an empty directory."""
+ d = tmp_path / "empty"
+ d.mkdir()
+ platform_utils.removedirs(d)
+ assert not d.exists()
+
+
+def test_removedirs_nested_empty_dirs(tmp_path: Path) -> None:
+ """removedirs should remove nested empty directories."""
+ d = tmp_path / "a" / "b" / "c"
+ d.mkdir(parents=True)
+ platform_utils.removedirs(tmp_path / "a")
+ assert not (tmp_path / "a").exists()
+
+
+def test_removedirs_symlinks_inside_dir(tmp_path: Path) -> None:
+ """removedirs should remove symlinks inside a directory."""
+ d = tmp_path / "dir"
+ d.mkdir()
+ (d / "link1").symlink_to("target1")
+ (d / "link2").symlink_to("target2")
+ platform_utils.removedirs(d)
+ assert not d.exists()
+
+
+def test_removedirs_preserves_user_files(tmp_path: Path) -> None:
+ """removedirs should not delete regular files or their parent dirs."""
+ d = tmp_path / "dir"
+ d.mkdir()
+ (d / "link").symlink_to("target")
+ (d / "user-file.txt").write_text("keep me")
+ platform_utils.removedirs(d)
+ assert d.exists()
+ assert not (d / "link").exists()
+ assert (d / "user-file.txt").read_text() == "keep me"
+
+
+def test_removedirs_deep_nested_with_symlinks(tmp_path: Path) -> None:
+ """removedirs should handle deep nesting: sub/dir/target."""
+ d = tmp_path / "sub" / "dir"
+ d.mkdir(parents=True)
+ (d / "link").symlink_to("target")
+ platform_utils.removedirs(tmp_path / "sub")
+ assert not (tmp_path / "sub").exists()
+
+
+def test_removedirs_regular_file_noop(tmp_path: Path) -> None:
+ """removedirs should not delete a regular file."""
+ f = tmp_path / "file.txt"
+ f.write_text("data")
+ platform_utils.removedirs(f)
+ assert f.exists()
diff --git a/tests/test_project.py b/tests/test_project.py
index 1deec07..728d5a5 100644
--- a/tests/test_project.py
+++ b/tests/test_project.py
@@ -365,6 +365,47 @@
os.path.join("git-project", "foo.txt"), os.readlink(dest)
)
+ def test_replace_empty_dir_with_symlink(self):
+ """A linkfile should replace an empty real directory at the dest path.
+
+ This is the common case: the old linkfiles inside the directory were
+ already cleaned up by UpdateCopyLinkfileList, leaving an empty parent
+ directory behind.
+ """
+ src_dir = os.path.join(self.worktree, "dot-llms")
+ os.makedirs(src_dir)
+
+ dest = os.path.join(self.topdir, "mydir")
+ os.makedirs(dest)
+
+ lf = self.LinkFile("dot-llms", "mydir")
+ lf._Link()
+ self.assertTrue(os.path.islink(dest))
+ self.assertEqual(
+ os.path.join("git-project", "dot-llms"), os.readlink(dest)
+ )
+
+ def test_nonempty_dir_not_clobbered(self):
+ """A linkfile must not delete a non-empty directory.
+
+ If the user created files in a directory that a new linkfile wants
+ to replace, __linkIt should fail safely rather than deleting content.
+ """
+ src_dir = os.path.join(self.worktree, "dot-llms")
+ os.makedirs(src_dir)
+
+ dest = os.path.join(self.topdir, "mydir")
+ os.makedirs(dest)
+ user_file = os.path.join(dest, "user-notes.txt")
+ self.touch(user_file)
+
+ lf = self.LinkFile("dot-llms", "mydir")
+ lf._Link()
+ # The directory should NOT be replaced — user content is preserved.
+ self.assertFalse(os.path.islink(dest))
+ self.assertTrue(os.path.isdir(dest))
+ self.assertTrue(os.path.exists(user_file))
+
class MigrateWorkTreeTests(unittest.TestCase):
"""Check _MigrateOldWorkTreeGitDir handling."""
diff --git a/tests/test_subcmds_sync.py b/tests/test_subcmds_sync.py
index 669027c..ef16239 100644
--- a/tests/test_subcmds_sync.py
+++ b/tests/test_subcmds_sync.py
@@ -13,6 +13,7 @@
# limitations under the License.
"""Unittests for the subcmds/sync.py module."""
+import json
import os
import shutil
import tempfile
@@ -1145,3 +1146,143 @@
self.assertTrue(result.checkout_success)
project.Sync_NetworkHalf.assert_called_once()
project.Sync_LocalHalf.assert_not_called()
+
+
+class UpdateCopyLinkfileListTest(unittest.TestCase):
+ """Tests for Sync.UpdateCopyLinkfileList."""
+
+ def setUp(self):
+ self.tempdirobj = tempfile.TemporaryDirectory(prefix="repo_tests")
+ self.topdir = self.tempdirobj.name
+ self.repodir = os.path.join(self.topdir, ".repo")
+ os.makedirs(self.repodir)
+
+ manifest = mock.MagicMock()
+ manifest.subdir = self.repodir
+ self.manifest = manifest
+
+ git_event_log = mock.MagicMock(ErrorEvent=mock.Mock(return_value=None))
+ self.cmd = sync.Sync(
+ manifest=manifest,
+ outer_client=mock.MagicMock(),
+ git_event_log=git_event_log,
+ )
+ self.cmd.client = mock.MagicMock(topdir=self.topdir)
+
+ def tearDown(self):
+ self.tempdirobj.cleanup()
+
+ def _write_copylinkfile_json(self, data: dict) -> None:
+ path = os.path.join(self.repodir, "copy-link-files.json")
+ with open(path, "w") as f:
+ json.dump(data, f)
+
+ def _setup_projects(self, linkfile_dests: list) -> None:
+ project = mock.MagicMock()
+ project.linkfiles = [mock.MagicMock(dest=d) for d in linkfile_dests]
+ project.copyfiles = []
+ mock.patch.object(
+ self.cmd, "GetProjects", return_value=[project]
+ ).start()
+
+ def test_removes_old_symlink_dest(self):
+ """Old linkfile dests that are symlinks should be removed."""
+ old_dest = os.path.join(self.topdir, "old-link")
+ os.symlink("target", old_dest)
+
+ self._write_copylinkfile_json(
+ {"linkfile": ["old-link"], "copyfile": []}
+ )
+ self._setup_projects([])
+
+ self.cmd.UpdateCopyLinkfileList(self.manifest)
+ self.assertFalse(os.path.lexists(old_dest))
+
+ def test_does_not_delete_through_new_symlink(self):
+ """Old dests that resolve through a new symlink must not delete files.
+
+ When the manifest changes from individual linkfiles inside a directory
+ to a single directory linkfile, and _CopyAndLinkFiles has already
+ created the symlink (interleaved mode), cleanup must not follow the
+ symlink and delete real project files.
+ """
+ project_dir = os.path.join(self.topdir, "vendor", "tools", "llms")
+ os.makedirs(os.path.join(project_dir, "dot-llms", "rules"))
+ os.makedirs(os.path.join(project_dir, "dot-llms", "skills"))
+ with open(
+ os.path.join(project_dir, "dot-llms", "rules", "basics.md"), "w"
+ ) as f:
+ f.write("# basics")
+ with open(
+ os.path.join(project_dir, "dot-llms", "skills", "repo.md"), "w"
+ ) as f:
+ f.write("# repo")
+
+ # Simulate interleaved mode: .llms -> vendor/tools/llms/dot-llms.
+ llms_link = os.path.join(self.topdir, ".llms")
+ os.symlink("vendor/tools/llms/dot-llms", llms_link)
+
+ self._write_copylinkfile_json(
+ {"linkfile": [".llms/rules", ".llms/skills"], "copyfile": []}
+ )
+ self._setup_projects([".llms"])
+
+ self.cmd.UpdateCopyLinkfileList(self.manifest)
+
+ # Real project files must still exist.
+ self.assertTrue(
+ os.path.exists(
+ os.path.join(project_dir, "dot-llms", "rules", "basics.md")
+ )
+ )
+ self.assertTrue(
+ os.path.exists(
+ os.path.join(project_dir, "dot-llms", "skills", "repo.md")
+ ),
+ )
+ self.assertTrue(os.path.islink(llms_link))
+
+ def test_cleans_up_empty_parent_dirs(self):
+ """After removing old dests, empty parent directories are removed."""
+ llms_dir = os.path.join(self.topdir, ".llms")
+ os.makedirs(llms_dir)
+ os.symlink(
+ "../vendor/tools/llms/rules", os.path.join(llms_dir, "rules")
+ )
+ os.symlink(
+ "../vendor/tools/llms/skills",
+ os.path.join(llms_dir, "skills"),
+ )
+
+ self._write_copylinkfile_json(
+ {"linkfile": [".llms/rules", ".llms/skills"], "copyfile": []}
+ )
+ self._setup_projects([".llms"])
+
+ self.cmd.UpdateCopyLinkfileList(self.manifest)
+
+ self.assertFalse(os.path.lexists(os.path.join(llms_dir, "rules")))
+ self.assertFalse(os.path.lexists(os.path.join(llms_dir, "skills")))
+ # Parent directory should be removed since it's now empty.
+ self.assertFalse(os.path.exists(llms_dir))
+
+ def test_preserves_nonempty_parent_dirs(self):
+ """Non-empty parent directories are preserved after old dest removal."""
+ llms_dir = os.path.join(self.topdir, ".llms")
+ os.makedirs(llms_dir)
+ os.symlink(
+ "../vendor/tools/llms/rules", os.path.join(llms_dir, "rules")
+ )
+ with open(os.path.join(llms_dir, "my-notes.txt"), "w") as f:
+ f.write("user content")
+
+ self._write_copylinkfile_json(
+ {"linkfile": [".llms/rules"], "copyfile": []}
+ )
+ self._setup_projects([".llms"])
+
+ self.cmd.UpdateCopyLinkfileList(self.manifest)
+
+ self.assertFalse(os.path.lexists(os.path.join(llms_dir, "rules")))
+ self.assertTrue(os.path.exists(os.path.join(llms_dir, "my-notes.txt")))
+ self.assertTrue(os.path.isdir(llms_dir))