Skip to content

Commit 84d33f3

Browse files
Byroncodex
andcommitted
fix: Preserve directory symlinks when pruning submodule move sources
A quick rubber-stamp, admittedly. V4 will probably review all tests and make it more proper, if there can be such a thing in python anyway. <!-- agent --> Both Windows failures came from os.renames() pruning a directory symlink above the source after the rename succeeded. Unlike POSIX, Windows rmdir() can remove a directory symlink even when its target is nonempty. Moving a checkout through a worktree alias therefore deleted the alias and broke configuration updates and rollback. Renaming metadata through a linked .git/modules directory deleted that link and broke config.lock creation. Route checkout moves, rollback, and metadata renames through one helper. It creates destination parents and renames the source, then prunes empty source parents only until it reaches a symlink or a directory it cannot remove. This keeps ordinary empty-directory cleanup while preserving parent links and their targets, including targets that become empty. Leaf symlinks continue to move as links rather than moving their targets. Exercise Windows directory-symlink removal semantics on POSIX in the existing compatibility tests, and use native behavior on Windows. Both reported failures reproduced locally before the fix. Strengthen assertions that worktree and metadata parent aliases survive, their targets remain directories, and leaf metadata symlinks move without moving their targets. Assisted-by: GPT 6.0 Co-authored-by: GPT 6.0 <codex@openai.com>
1 parent 43a43cd commit 84d33f3

2 files changed

Lines changed: 43 additions & 6 deletions

File tree

git/objects/submodule/base.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -444,6 +444,20 @@ def _checked_abspath(
444444
raise ValueError("Submodule path %r contains a symbolic link" % relative_path)
445445
return path
446446

447+
@staticmethod
448+
def _renames(source: PathLike, destination: PathLike) -> None:
449+
os.makedirs(osp.dirname(destination), exist_ok=True)
450+
os.rename(source, destination)
451+
# Match renames() cleanup, but stop before directory symlinks: Windows
452+
# rmdir() removes the link even when its target is nonempty.
453+
parent = osp.dirname(source)
454+
while parent and not osp.islink(parent):
455+
try:
456+
os.rmdir(parent)
457+
except OSError:
458+
break
459+
parent = osp.dirname(parent)
460+
447461
@classmethod
448462
def _write_git_file_and_module_config(cls, working_tree_dir: PathLike, module_abspath: PathLike) -> None:
449463
"""Write a ``.git`` file containing a (preferably) relative path to the actual
@@ -1120,7 +1134,7 @@ def move(self, module_path: PathLike, configuration: bool = True, module: bool =
11201134
# Move the module into place if possible.
11211135
renamed_module = False
11221136
if module and osp.exists(cur_path):
1123-
os.renames(cur_path, module_checkout_abspath)
1137+
self._renames(cur_path, module_checkout_abspath)
11241138
renamed_module = True
11251139

11261140
if osp.isfile(osp.join(module_checkout_abspath, ".git")):
@@ -1151,7 +1165,7 @@ def move(self, module_path: PathLike, configuration: bool = True, module: bool =
11511165
# END handle configuration flag
11521166
except Exception:
11531167
if renamed_module:
1154-
os.renames(module_checkout_abspath, cur_path)
1168+
self._renames(module_checkout_abspath, cur_path)
11551169
# END undo module renaming
11561170
raise
11571171
# END handle undo rename
@@ -1504,10 +1518,10 @@ def rename(self, new_name: str) -> "Submodule":
15041518
# Let's be sure the submodule name is not so obviously tied to a directory.
15051519
if str(destination_module_abspath).startswith(str(mod.git_dir)):
15061520
tmp_dir = self._module_abspath(self.repo, self.path, str(uuid.uuid4()))
1507-
os.renames(source_dir, tmp_dir)
1521+
self._renames(source_dir, tmp_dir)
15081522
source_dir = tmp_dir
15091523
# END handle self-containment
1510-
os.renames(source_dir, destination_module_abspath)
1524+
self._renames(source_dir, destination_module_abspath)
15111525
if mod.working_tree_dir:
15121526
self._write_git_file_and_module_config(mod.working_tree_dir, destination_module_abspath)
15131527
# END move separate git repository

test/test_submodule.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -252,14 +252,29 @@ def test_submodule_rejects_checkout_and_gitmodules_symlinks(movable_submodule, t
252252
assert (root / "moved").is_dir()
253253

254254

255-
def test_submodule_allows_symlink_above_worktree(movable_submodule, tmp_path):
255+
@pytest.fixture
256+
def windows_directory_symlink_removal(monkeypatch):
257+
"""Exercise Windows rmdir semantics on POSIX, where rmdir rejects symlinks."""
258+
if sys.platform != "win32":
259+
original_rmdir = os.rmdir
260+
261+
def rmdir(path, *args, **kwargs):
262+
if osp.islink(path):
263+
return os.unlink(path, *args, **kwargs)
264+
return original_rmdir(path, *args, **kwargs)
265+
266+
monkeypatch.setattr(os, "rmdir", rmdir)
267+
268+
269+
def test_submodule_allows_symlink_above_worktree(movable_submodule, tmp_path, windows_directory_symlink_removal):
256270
"""Allow adding and moving submodules when the parent is opened through a symlink."""
257271
sm = movable_submodule
258272
alias = tmp_path / "alias"
259273
alias.symlink_to(sm.repo.working_tree_dir, target_is_directory=True)
260274
with git.Repo(alias) as parent:
261275
added = Submodule.add(parent, "new", "new", sm.url)
262276
added.move("moved")
277+
assert alias.is_symlink()
263278
with added.module() as module:
264279
assert Path(module.git.rev_parse("--show-toplevel")).resolve() == Path(added.abspath).resolve()
265280
assert Path(added.abspath, "file").read_text() == "content"
@@ -296,7 +311,9 @@ def test_submodule_allows_metadata_destination_symlinks(movable_submodule, tmp_p
296311

297312
@pytest.mark.parametrize("kind", ["modules", "intermediate", "leaf", "gitfile", "config", "alias"])
298313
@pytest.mark.parametrize("operation", ["update", "move", "rename", "remove"])
299-
def test_submodule_allows_existing_metadata_symlinks(movable_submodule, tmp_path, kind, operation):
314+
def test_submodule_allows_existing_metadata_symlinks(
315+
movable_submodule, tmp_path, kind, operation, windows_directory_symlink_removal
316+
):
300317
"""Keep submodule operations compatible with existing symlinks in Git metadata.
301318
302319
Cover linked metadata directories, gitfiles, configs, and internal aliases.
@@ -336,6 +353,12 @@ def test_submodule_allows_existing_metadata_symlinks(movable_submodule, tmp_path
336353
sm.move("moved")
337354
else:
338355
sm.rename("renamed")
356+
if kind == "modules" or (operation == "rename" and kind in ("intermediate", "alias")):
357+
assert link.is_symlink()
358+
assert link.is_dir()
359+
if operation == "rename" and kind == "leaf":
360+
assert (modules / "renamed").is_symlink()
361+
assert target.is_dir()
339362
with sm.module() as module:
340363
assert Path(module.git.rev_parse("--show-toplevel")).resolve() == Path(sm.abspath).resolve()
341364
assert Path(sm.abspath, "file").read_text() == "content"

0 commit comments

Comments
 (0)