Skip to content

Commit 2bfd829

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. The remaining metadata failures also reproduce with Python 3.7's Windows path semantics: ntpath.realpath is an alias of abspath and does not resolve symlinks. Relative core.worktree values were calculated from the metadata alias instead of the repository directory Git actually opens. This broke add/reconnect HEAD reads and made moves and renames point at nonexistent worktrees. The SHA/dubious-ownership message was a secondary read failure. Use pathlib.Path.resolve(), which resolves Windows symlinks on Python 3.7, for both endpoints of gitfile/config rewrites and for metadata removal. Run metadata and worktree-alias tests with native and simulated Windows 3.7 realpath behavior. The simulation reproduced all eight reported failures plus a leaf-symlink removal failure before this change. Reference: https://github.com/python/cpython/blob/3.7/Lib/ntpath.py and https://github.com/python/cpython/blob/3.7/Lib/pathlib.py. Validation: 183 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 2bfd829

2 files changed

Lines changed: 95 additions & 11 deletions

File tree

git/objects/submodule/base.py

Lines changed: 26 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import ntpath
1010
import os
1111
import os.path as osp
12+
from pathlib import Path
1213
import shlex
1314
import stat
1415
import sys
@@ -444,6 +445,20 @@ def _checked_abspath(
444445
raise ValueError("Submodule path %r contains a symbolic link" % relative_path)
445446
return path
446447

448+
@staticmethod
449+
def _renames(source: PathLike, destination: PathLike) -> None:
450+
os.makedirs(osp.dirname(destination), exist_ok=True)
451+
os.rename(source, destination)
452+
# Match renames() cleanup, but stop before directory symlinks: Windows
453+
# rmdir() removes the link even when its target is nonempty.
454+
parent = osp.dirname(source)
455+
while parent and not osp.islink(parent):
456+
try:
457+
os.rmdir(parent)
458+
except OSError:
459+
break
460+
parent = osp.dirname(parent)
461+
447462
@classmethod
448463
def _write_git_file_and_module_config(cls, working_tree_dir: PathLike, module_abspath: PathLike) -> None:
449464
"""Write a ``.git`` file containing a (preferably) relative path to the actual
@@ -468,8 +483,9 @@ def _write_git_file_and_module_config(cls, working_tree_dir: PathLike, module_ab
468483
Absolute path to the bare repository.
469484
"""
470485
# Git resolves metadata symlinks before interpreting core.worktree.
471-
module_abspath = osp.realpath(module_abspath)
472-
working_tree_dir = osp.realpath(working_tree_dir)
486+
# Path.resolve() also handles Windows symlinks on Python 3.7.
487+
module_abspath = str(Path(module_abspath).resolve())
488+
working_tree_dir = str(Path(working_tree_dir).resolve())
473489
git_file = osp.join(working_tree_dir, ".git")
474490
module_config = osp.join(module_abspath, "config")
475491
rela_path = osp.relpath(module_abspath, start=working_tree_dir)
@@ -685,7 +701,9 @@ def add(
685701

686702
# We deliberately assume that our head matches our index!
687703
if mrepo:
688-
sm.binsha = mrepo.head.commit.binsha
704+
# Release cat-file processes before callers move the checkout on Windows.
705+
with mrepo:
706+
sm.binsha = mrepo.head.commit.binsha
689707
index.add([sm], write=True)
690708

691709
return sm
@@ -1120,7 +1138,7 @@ def move(self, module_path: PathLike, configuration: bool = True, module: bool =
11201138
# Move the module into place if possible.
11211139
renamed_module = False
11221140
if module and osp.exists(cur_path):
1123-
os.renames(cur_path, module_checkout_abspath)
1141+
self._renames(cur_path, module_checkout_abspath)
11241142
renamed_module = True
11251143

11261144
if osp.isfile(osp.join(module_checkout_abspath, ".git")):
@@ -1151,7 +1169,7 @@ def move(self, module_path: PathLike, configuration: bool = True, module: bool =
11511169
# END handle configuration flag
11521170
except Exception:
11531171
if renamed_module:
1154-
os.renames(module_checkout_abspath, cur_path)
1172+
self._renames(module_checkout_abspath, cur_path)
11551173
# END undo module renaming
11561174
raise
11571175
# END handle undo rename
@@ -1239,7 +1257,7 @@ def remove(
12391257
################################
12401258
if module and self.module_exists():
12411259
mod = self.module()
1242-
git_dir = osp.realpath(mod.git_dir)
1260+
git_dir = str(Path(mod.git_dir).resolve())
12431261
if force:
12441262
# Take the fast lane and just delete everything in our module path.
12451263
# TODO: If we run into permission problems, we have a highly
@@ -1504,10 +1522,10 @@ def rename(self, new_name: str) -> "Submodule":
15041522
# Let's be sure the submodule name is not so obviously tied to a directory.
15051523
if str(destination_module_abspath).startswith(str(mod.git_dir)):
15061524
tmp_dir = self._module_abspath(self.repo, self.path, str(uuid.uuid4()))
1507-
os.renames(source_dir, tmp_dir)
1525+
self._renames(source_dir, tmp_dir)
15081526
source_dir = tmp_dir
15091527
# END handle self-containment
1510-
os.renames(source_dir, destination_module_abspath)
1528+
self._renames(source_dir, destination_module_abspath)
15111529
if mod.working_tree_dir:
15121530
self._write_git_file_and_module_config(mod.working_tree_dir, destination_module_abspath)
15131531
# END move separate git repository

test/test_submodule.py

Lines changed: 69 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -252,21 +252,79 @@ 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(
300+
movable_submodule, tmp_path, windows_directory_symlink_removal, metadata_realpath
301+
):
256302
"""Allow adding and moving submodules when the parent is opened through a symlink."""
257303
sm = movable_submodule
258304
alias = tmp_path / "alias"
259305
alias.symlink_to(sm.repo.working_tree_dir, target_is_directory=True)
260306
with git.Repo(alias) as parent:
261307
added = Submodule.add(parent, "new", "new", sm.url)
262308
added.move("moved")
309+
assert alias.is_symlink()
263310
with added.module() as module:
264311
assert Path(module.git.rev_parse("--show-toplevel")).resolve() == Path(added.abspath).resolve()
265312
assert Path(added.abspath, "file").read_text() == "content"
266313

267314

315+
@pytest.fixture(params=[False, True], ids=["native-realpath", "windows37-realpath"])
316+
def metadata_realpath(request):
317+
"""Model Python 3.7 on Windows without altering pathlib's own resolver."""
318+
if request.param:
319+
with mock.patch("git.objects.submodule.base.osp", wraps=osp) as paths:
320+
paths.realpath.side_effect = osp.abspath
321+
yield
322+
else:
323+
yield
324+
325+
268326
@pytest.mark.parametrize("operation", ["add", "reconnect", "rename"])
269-
def test_submodule_allows_metadata_destination_symlinks(movable_submodule, tmp_path, operation):
327+
def test_submodule_allows_metadata_destination_symlinks(movable_submodule, tmp_path, operation, metadata_realpath):
270328
"""Allow linked metadata destinations while keeping the checkout correctly connected.
271329
272330
Adding, reconnecting after deinit, and renaming may store metadata outside the
@@ -296,7 +354,9 @@ def test_submodule_allows_metadata_destination_symlinks(movable_submodule, tmp_p
296354

297355
@pytest.mark.parametrize("kind", ["modules", "intermediate", "leaf", "gitfile", "config", "alias"])
298356
@pytest.mark.parametrize("operation", ["update", "move", "rename", "remove"])
299-
def test_submodule_allows_existing_metadata_symlinks(movable_submodule, tmp_path, kind, operation):
357+
def test_submodule_allows_existing_metadata_symlinks(
358+
movable_submodule, tmp_path, kind, operation, windows_directory_symlink_removal, metadata_realpath
359+
):
300360
"""Keep submodule operations compatible with existing symlinks in Git metadata.
301361
302362
Cover linked metadata directories, gitfiles, configs, and internal aliases.
@@ -336,6 +396,12 @@ def test_submodule_allows_existing_metadata_symlinks(movable_submodule, tmp_path
336396
sm.move("moved")
337397
else:
338398
sm.rename("renamed")
399+
if kind == "modules" or (operation == "rename" and kind in ("intermediate", "alias")):
400+
assert link.is_symlink()
401+
assert link.is_dir()
402+
if operation == "rename" and kind == "leaf":
403+
assert (modules / "renamed").is_symlink()
404+
assert target.is_dir()
339405
with sm.module() as module:
340406
assert Path(module.git.rev_parse("--show-toplevel")).resolve() == Path(sm.abspath).resolve()
341407
assert Path(sm.abspath, "file").read_text() == "content"

0 commit comments

Comments
 (0)