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>
This commit is contained in:
Carlos Fernandez
2026-05-07 09:00:33 -07:00
committed by gerrit-scoped@luci-project-accounts.iam.gserviceaccount.com
parent 67e52a120b
commit 5534f164d6
6 changed files with 320 additions and 9 deletions
+27 -7
View File
@@ -1631,9 +1631,10 @@ later is required to fix a server side protocol bug.
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 @@ later is required to fix a server side protocol bug.
)
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):