Skip to content

Commit 286af15

Browse files
Byroncodex
andcommitted
fix: Preserve submodule paths and release checkout handles on Windows
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. A further Windows run exposed a separate sharing violation during the checkout move. Submodule.add() read HEAD through its temporary Repo but left that Repo's persistent cat-file processes open. Those processes can hold the checkout as their current directory and prevent its rename. Close the owned Repo with a context manager when reading HEAD, including on read failure, instead of waiting for garbage collection. Add a regression that observes the real cat-file processes started for the new checkout and requires them to have exited before add() returns. It failed before the fix and now passes, along with the immediate move. Validation: 155 passed, 3 skipped, and 1 expected failure across the submodule and diff suites plus the commit-message hook success test on macOS. Ruff lint and formatting, mypy for the changed module, and git diff --check passed. Native Windows validation remains for CI. Assisted-by: GPT 6.0 Co-authored-by: GPT 6.0 <codex@openai.com>
1 parent 43a43cd commit 286af15

2 files changed

Lines changed: 76 additions & 7 deletions

File tree

git/objects/submodule/base.py

Lines changed: 21 additions & 5 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
@@ -685,7 +699,9 @@ def add(
685699

686700
# We deliberately assume that our head matches our index!
687701
if mrepo:
688-
sm.binsha = mrepo.head.commit.binsha
702+
# Release cat-file processes before callers move the checkout on Windows.
703+
with mrepo:
704+
sm.binsha = mrepo.head.commit.binsha
689705
index.add([sm], write=True)
690706

691707
return sm
@@ -1120,7 +1136,7 @@ def move(self, module_path: PathLike, configuration: bool = True, module: bool =
11201136
# Move the module into place if possible.
11211137
renamed_module = False
11221138
if module and osp.exists(cur_path):
1123-
os.renames(cur_path, module_checkout_abspath)
1139+
self._renames(cur_path, module_checkout_abspath)
11241140
renamed_module = True
11251141

11261142
if osp.isfile(osp.join(module_checkout_abspath, ".git")):
@@ -1151,7 +1167,7 @@ def move(self, module_path: PathLike, configuration: bool = True, module: bool =
11511167
# END handle configuration flag
11521168
except Exception:
11531169
if renamed_module:
1154-
os.renames(module_checkout_abspath, cur_path)
1170+
self._renames(module_checkout_abspath, cur_path)
11551171
# END undo module renaming
11561172
raise
11571173
# END handle undo rename
@@ -1504,10 +1520,10 @@ def rename(self, new_name: str) -> "Submodule":
15041520
# Let's be sure the submodule name is not so obviously tied to a directory.
15051521
if str(destination_module_abspath).startswith(str(mod.git_dir)):
15061522
tmp_dir = self._module_abspath(self.repo, self.path, str(uuid.uuid4()))
1507-
os.renames(source_dir, tmp_dir)
1523+
self._renames(source_dir, tmp_dir)
15081524
source_dir = tmp_dir
15091525
# END handle self-containment
1510-
os.renames(source_dir, destination_module_abspath)
1526+
self._renames(source_dir, destination_module_abspath)
15111527
if mod.working_tree_dir:
15121528
self._write_git_file_and_module_config(mod.working_tree_dir, destination_module_abspath)
15131529
# END move separate git repository

test/test_submodule.py

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -252,14 +252,59 @@ 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+
def test_add_closes_checkout_processes(movable_submodule, monkeypatch):
256+
"""Adding a submodule must not leave a child process holding its checkout open."""
257+
sm = movable_submodule
258+
checkout = Path(sm.repo.working_tree_dir, "new")
259+
execute = Git.execute
260+
processes = []
261+
262+
def capture_process(self, command, *args, **kwargs):
263+
result = execute(self, command, *args, **kwargs)
264+
if (
265+
kwargs.get("as_process")
266+
and "cat-file" in command
267+
and Path(self.working_dir).resolve() == checkout.resolve()
268+
):
269+
processes.append(result.proc)
270+
return result
271+
272+
monkeypatch.setattr(Git, "execute", capture_process)
273+
try:
274+
added = Submodule.add(sm.repo, "new", "new", sm.url)
275+
assert processes, "The HEAD read must exercise a persistent cat-file process"
276+
assert all(process.poll() is not None for process in processes)
277+
added.move("moved")
278+
finally:
279+
for process in processes:
280+
if process.poll() is None:
281+
process.terminate()
282+
process.wait()
283+
284+
285+
@pytest.fixture
286+
def windows_directory_symlink_removal(monkeypatch):
287+
"""Exercise Windows rmdir semantics on POSIX, where rmdir rejects symlinks."""
288+
if sys.platform != "win32":
289+
original_rmdir = os.rmdir
290+
291+
def rmdir(path, *args, **kwargs):
292+
if osp.islink(path):
293+
return os.unlink(path, *args, **kwargs)
294+
return original_rmdir(path, *args, **kwargs)
295+
296+
monkeypatch.setattr(os, "rmdir", rmdir)
297+
298+
299+
def test_submodule_allows_symlink_above_worktree(movable_submodule, tmp_path, windows_directory_symlink_removal):
256300
"""Allow adding and moving submodules when the parent is opened through a symlink."""
257301
sm = movable_submodule
258302
alias = tmp_path / "alias"
259303
alias.symlink_to(sm.repo.working_tree_dir, target_is_directory=True)
260304
with git.Repo(alias) as parent:
261305
added = Submodule.add(parent, "new", "new", sm.url)
262306
added.move("moved")
307+
assert alias.is_symlink()
263308
with added.module() as module:
264309
assert Path(module.git.rev_parse("--show-toplevel")).resolve() == Path(added.abspath).resolve()
265310
assert Path(added.abspath, "file").read_text() == "content"
@@ -296,7 +341,9 @@ def test_submodule_allows_metadata_destination_symlinks(movable_submodule, tmp_p
296341

297342
@pytest.mark.parametrize("kind", ["modules", "intermediate", "leaf", "gitfile", "config", "alias"])
298343
@pytest.mark.parametrize("operation", ["update", "move", "rename", "remove"])
299-
def test_submodule_allows_existing_metadata_symlinks(movable_submodule, tmp_path, kind, operation):
344+
def test_submodule_allows_existing_metadata_symlinks(
345+
movable_submodule, tmp_path, kind, operation, windows_directory_symlink_removal
346+
):
300347
"""Keep submodule operations compatible with existing symlinks in Git metadata.
301348
302349
Cover linked metadata directories, gitfiles, configs, and internal aliases.
@@ -336,6 +383,12 @@ def test_submodule_allows_existing_metadata_symlinks(movable_submodule, tmp_path
336383
sm.move("moved")
337384
else:
338385
sm.rename("renamed")
386+
if kind == "modules" or (operation == "rename" and kind in ("intermediate", "alias")):
387+
assert link.is_symlink()
388+
assert link.is_dir()
389+
if operation == "rename" and kind == "leaf":
390+
assert (modules / "renamed").is_symlink()
391+
assert target.is_dir()
339392
with sm.module() as module:
340393
assert Path(module.git.rev_parse("--show-toplevel")).resolve() == Path(sm.abspath).resolve()
341394
assert Path(sm.abspath, "file").read_text() == "content"

0 commit comments

Comments
 (0)