Skip to content

Commit 5aab0a0

Browse files
codexByron
authored andcommitted
fix: stabilize Windows submodules and temporary test cleanup
On Windows, `git init --separate-git-dir` can try to rename a submodule's metadata directory onto itself. An open file inside that directory makes the rename fail with `Directory not empty`, as seen in the CI tutorial. Close the cloned `Repo` before reconnecting it and ask `git rev-parse --resolve-git-dir` whether its gitfile already points to the destination. When it does, ordinary `git init` preserves the connection without the redundant rename. Missing or stale connections still use `--separate-git-dir`, with Git retaining ownership of initialization and storage formats. Add `test.cleanup.cleanup_directory` and `TemporaryDirectory` for disposal of isolated test directories. Remove whatever is possible, log filesystem errors, and leave locked files behind without changing the test result. Retry read-only Windows files and directories without changing symlink or junction targets. Support both cleanup callback APIs and Python 3.8+. Migrate test contexts and fixture teardown to these helpers while keeping `git.util.rmtree`, operations under test, and fixture reuse strict. Release repository handles before deletion, run the missing performance test teardown, and wait for the killed Git daemon to exit. Invalidate cached fixture layouts when disposal fails so subsequent tests rebuild at fresh paths. Give `test/run-local.py` a private pytest temporary root to avoid shared-root ownership failures. Remove the diff test's cleanup xfail and document the test cleanup convention. Regression coverage includes real Windows file locks, metadata reconnects through ordinary and 8.3 paths, read-only directories, unchanged symlink targets, cleanup retries, preserved test-body exceptions, decorator teardown, and recovery from a locked cached fixture. Validation: - Windows with Git 2.55.0.windows.5: 164 passed and 1 skipped in the CLI regression selection; final focused checks passed with 25 passed and 1 skipped for each of the CLI and Gix backends. - Linux under WSL: 27 passed and 4 skipped in the portable regression selection. - Pinned pre-commit hooks, `mypy`, and `basedpyright` passed.
1 parent 81394ce commit 5aab0a0

20 files changed

Lines changed: 551 additions & 159 deletions

‎CONTRIBUTING.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,19 @@ Attributing AI assistance in commit metadata, for example with a `Co-authored-by
3131
trailer, is welcome but not required. Code is reviewed the same way regardless of its
3232
origin.
3333

34+
## Temporary test directories
35+
36+
Use `test.cleanup.TemporaryDirectory` for isolated temporary directories and
37+
`test.cleanup.cleanup_directory` when disposing of directories created by test
38+
fixtures. Close repository handles before removing their files. Cleanup is
39+
best-effort: it logs filesystem errors, removes whatever it can, and leaves
40+
locked files behind without failing or skipping a test. The shared writable
41+
repository decorators still keep failed tests' directories for debugging.
42+
43+
Deletions and renames that exercise library behavior or prepare a fixture for
44+
reuse must remain strict. If a cached fixture cannot be cleaned up, rebuild it
45+
at a fresh location before handing it to another test.
46+
3447
## Fuzzing Test Specific Documentation
3548

3649
For details related to contributing to the fuzzing test suite and OSS-Fuzz integration, please

‎doc/gix-backend.md‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -54,9 +54,11 @@ The prepared environments in this checkout are `.venv` (CLI) and `.tox/gix`
5454
```
5555

5656
The runner uses local version tags, creates an isolated Git configuration,
57-
prepares the historical test fixture inside a temporary shared clone, and
58-
disables package-index access. Tests use local repositories, including the
59-
tutorial example. The suite needs loopback sockets for its Git daemon and
57+
prepares the historical test fixture inside a temporary shared clone, gives
58+
pytest a separate temporary root for each run, and disables package-index
59+
access. Cleanup of these isolated directories is best-effort, so a locked
60+
leftover cannot change pytest's exit status. Tests use local repositories,
61+
including the tutorial example. The suite needs loopback sockets for its Git daemon and
6062
permission to inspect its own child processes. It does not need a remote Git
6163
server. Missing local tags or packages are errors, not invitations to download.
6264

@@ -581,7 +583,7 @@ candidates, not proof that a shared fixture is safe in every execution order.
581583
| --- | --- | --- |
582584
| 166 | `TExc` (157) and `TestActor` (9) inherit repository-building `TestBase`. | Use the existing `TestCase` base without repository setup. |
583585
| 182 | Three submodule rejection bodies repeatedly build `movable_submodule`, then check snapshots for no mutation. | Prepare logical-name baselines once and copy the parent per case, retaining fresh wrappers and independent writable files. |
584-
| 51 | Six submodule rejection bodies prepare nested metadata, separate metadata, intermediate/leaf symlinks, or retained metadata before checking rejection. | Cache ten prepared layouts and restore complete copies at their original paths, preserving absolute Git links and symlinks. Cleanup removes the active copy even after failure. |
586+
| 51 | Six submodule rejection bodies prepare nested metadata, separate metadata, intermediate/leaf symlinks, or retained metadata before checking rejection. | Cache ten prepared layouts and restore complete copies at their original paths, preserving absolute Git links and symlinks. Cleanup is best-effort; a locked active copy invalidates the layout so the next case rebuilds at a fresh path. |
585587
| 15 | Eight revision-query bodies rebuild the same four-commit graph, refs, index, and reflogs through `rev_parse_repo`. | Prepare the graph once and copy it for every consumer, including mutating cases; recreate repository, branch and commit wrappers. |
586588
| 8 | Tree lookup bodies clone and check out `0.3.2.1` through `with_rw_repo`. | Read the historical tree directly through the existing class repository, removing clones and checkouts. |
587589

‎git/objects/submodule/base.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -460,8 +460,8 @@ def _clone_repo(
460460
except FileNotFoundError:
461461
pass
462462
raise
463-
cls._connect_module(module_checkout_path, module_abspath)
464463
clone.close()
464+
cls._connect_module(module_checkout_path, module_abspath)
465465
clone = git.Repo(module_checkout_path)
466466

467467
return clone

‎test/cleanup.py‎

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# This module is part of GitPython and is released under the
2+
# 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/
3+
4+
"""Best-effort disposal of isolated test directories, including on Python 3.8.
5+
6+
Use these helpers only when discarding a directory owned by a test. Deleting or
7+
moving files to exercise Git behavior, or to prepare a reusable fixture, must
8+
still report errors.
9+
10+
Keep this module independent of GitPython and pytest so the local test runner
11+
can use it before configuring the test environment.
12+
"""
13+
14+
import logging
15+
import os
16+
import shutil
17+
import stat
18+
import sys
19+
import tempfile
20+
import weakref
21+
22+
_logger = logging.getLogger(__name__)
23+
24+
25+
def cleanup_directory(path):
26+
"""Try to remove an owned test directory; return False and log on filesystem errors."""
27+
errors = []
28+
29+
def onerror(function, filename, exception):
30+
if isinstance(exception, FileNotFoundError):
31+
return
32+
if sys.platform == "win32" and function in (os.unlink, os.rmdir) and isinstance(exception, PermissionError):
33+
try:
34+
# Git files and test directories may be read-only. Never chmod a
35+
# symlink or junction target, which may be outside the owned tree.
36+
if not os.lstat(filename).st_file_attributes & stat.FILE_ATTRIBUTE_REPARSE_POINT:
37+
os.chmod(filename, stat.S_IWUSR)
38+
function(filename)
39+
return
40+
except FileNotFoundError:
41+
return
42+
except OSError as retry_error:
43+
exception = retry_error
44+
errors.append(exception)
45+
46+
try:
47+
if sys.version_info >= (3, 12):
48+
shutil.rmtree(path, onexc=onerror)
49+
else:
50+
shutil.rmtree(path, onerror=lambda function, filename, excinfo: onerror(function, filename, excinfo[1]))
51+
except FileNotFoundError:
52+
pass
53+
except OSError as error:
54+
errors.append(error)
55+
56+
if errors:
57+
_logger.warning("Could not fully remove temporary test directory %r: %s", os.fspath(path), errors[0])
58+
return not errors
59+
60+
61+
class TemporaryDirectory:
62+
"""A test-owned temporary directory whose cleanup cannot fail on file locks.
63+
64+
Supports context management, ``name``, and explicit ``cleanup()``. A finalizer
65+
also attempts cleanup if a test drops the object without closing it. Explicit
66+
cleanup detaches that finalizer, but may be called again to retry later.
67+
"""
68+
69+
def __init__(self, suffix=None, prefix=None, dir=None):
70+
self.name = tempfile.mkdtemp(suffix=suffix, prefix=prefix, dir=dir)
71+
self._finalizer = weakref.finalize(self, cleanup_directory, self.name)
72+
73+
def __enter__(self):
74+
return self.name
75+
76+
def __exit__(self, *args):
77+
self.cleanup()
78+
79+
def cleanup(self):
80+
self._finalizer.detach()
81+
cleanup_directory(self.name)

‎test/lib/helper.py‎

Lines changed: 16 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -42,10 +42,10 @@
4242
import venv
4343
from typing import Union, Type, Tuple
4444

45-
import gitdb
4645
import pytest
4746

48-
from git.util import rmtree, cwd
47+
from git.util import cwd
48+
from test.cleanup import TemporaryDirectory, cleanup_directory
4949

5050
TestCase = unittest.TestCase
5151
SkipTest = unittest.SkipTest
@@ -107,7 +107,10 @@ def wait(self, stderr=None):
107107

108108
def with_rw_directory(func):
109109
"""Create a temporary directory which can be written to, remove it if the
110-
test succeeds, but leave it otherwise to aid additional debugging."""
110+
test succeeds, but leave it otherwise to aid additional debugging.
111+
112+
Cleanup is best-effort: a locked file must not change the test result.
113+
"""
111114

112115
@wraps(func)
113116
def wrapper(self, *args, **kwargs):
@@ -132,7 +135,7 @@ def wrapper(self, *args, **kwargs):
132135
# though this is not the case here unless we collect explicitly.
133136
gc.collect()
134137
if not keep:
135-
rmtree(path)
138+
cleanup_directory(path)
136139

137140
return wrapper
138141

@@ -179,13 +182,10 @@ def repo_creator(self):
179182
raise
180183
finally:
181184
os.chdir(prev_cwd)
182-
rw_repo.git.clear_cache()
185+
rw_repo.close()
183186
rw_repo = None
184187
if repo_dir is not None:
185-
gc.collect()
186-
gitdb.util.mman.collect()
187-
gc.collect()
188-
rmtree(repo_dir)
188+
cleanup_directory(repo_dir)
189189
# END rm test repo if possible
190190
# END cleanup
191191

@@ -257,6 +257,7 @@ def git_daemon_launched(base_path, ip, port):
257257
try:
258258
_logger.debug("Killing git-daemon...")
259259
gd.proc.kill()
260+
gd.proc.wait(timeout=5)
260261
except Exception as ex:
261262
# Either it has died (and we're here), or it won't die, again here...
262263
_logger.debug("Hidden error while Killing git-daemon: %s", ex, exc_info=1)
@@ -346,17 +347,14 @@ def remote_repo_creator(self):
346347
raise
347348

348349
finally:
349-
rw_repo.git.clear_cache()
350-
rw_daemon_repo.git.clear_cache()
350+
rw_repo.close()
351+
rw_daemon_repo.close()
351352
del rw_repo
352353
del rw_daemon_repo
353-
gc.collect()
354-
gitdb.util.mman.collect()
355-
gc.collect()
356354
if rw_repo_dir:
357-
rmtree(rw_repo_dir)
355+
cleanup_directory(rw_repo_dir)
358356
if rw_daemon_repo_dir:
359-
rmtree(rw_daemon_repo_dir)
357+
cleanup_directory(rw_daemon_repo_dir)
360358
# END cleanup
361359

362360
# END bare repo creator
@@ -416,7 +414,7 @@ def setUpClass(cls):
416414

417415
@classmethod
418416
def tearDownClass(cls):
419-
cls.rorepo.git.clear_cache()
417+
cls.rorepo.close()
420418
cls.rorepo.git = None
421419

422420
def _make_file(self, rela_path, data, repo=None):
@@ -496,7 +494,7 @@ def symlinks_supported() -> bool:
496494
Developer Mode or SeCreateSymbolicLinkPrivilege, and an unprivileged process gets
497495
OSError (WinError 1314) instead.
498496
"""
499-
with tempfile.TemporaryDirectory(prefix="gitpython-symlink-check-") as temp_dir:
497+
with TemporaryDirectory(prefix="gitpython-symlink-check-") as temp_dir:
500498
link_path = osp.join(temp_dir, "link")
501499
try:
502500
os.symlink("missing-target", link_path)

‎test/performance/lib.py‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@
1010

1111
from git import Repo
1212
from git.db import GitCmdObjectDB, GitDB
13-
from git.util import rmtree
1413

14+
from test.cleanup import cleanup_directory
1515
from test.lib import TestBase
1616

1717
# { Invariants
@@ -51,9 +51,9 @@ def setUp(self):
5151
self.puregitrorepo = Repo(repo_path, odbt=GitDB, search_parent_directories=True)
5252

5353
def tearDown(self):
54-
self.gitrorepo.git.clear_cache()
54+
self.gitrorepo.close()
5555
self.gitrorepo = None
56-
self.puregitrorepo.git.clear_cache()
56+
self.puregitrorepo.close()
5757
self.puregitrorepo = None
5858

5959

@@ -72,12 +72,15 @@ def setUp(self):
7272

7373
def tearDown(self):
7474
super().tearDown()
75+
dirname = None
7576
if self.gitrwrepo is not None:
76-
rmtree(self.gitrwrepo.working_dir)
77-
self.gitrwrepo.git.clear_cache()
77+
dirname = self.gitrwrepo.working_dir
78+
self.gitrwrepo.close()
7879
self.gitrwrepo = None
79-
self.puregitrwrepo.git.clear_cache()
80+
self.puregitrwrepo.close()
8081
self.puregitrwrepo = None
82+
if dirname is not None:
83+
cleanup_directory(dirname)
8184

8285

8386
# } END base classes

‎test/performance/test_commit.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
class TestPerformance(TestBigRepoRW, TestCommitSerialization):
2222
def tearDown(self):
2323
gc.collect()
24+
super().tearDown()
2425

2526
# ref with about 100 commits in its history.
2627
ref_100 = "0.1.6"

‎test/run-local.py‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,13 @@
99
import socket
1010
import subprocess
1111
import sys
12-
import tempfile
12+
13+
from cleanup import TemporaryDirectory
1314

1415

1516
def main():
1617
root = Path(__file__).resolve().parent.parent
17-
with tempfile.TemporaryDirectory(prefix="gitpython-local-tests-") as directory:
18+
with TemporaryDirectory(prefix="gitpython-local-tests-") as directory:
1819
temporary = Path(directory)
1920
config = temporary / "gitconfig"
2021
config.write_text("[user]\nname = GitPython Tests\nemail = tests@example.invalid\n", encoding="utf-8")
@@ -51,7 +52,11 @@ def git(*args, cwd=root):
5152
with socket.socket() as listener:
5253
listener.bind(("127.0.0.1", 0))
5354
env["GIT_PYTHON_TEST_GIT_DAEMON_PORT"] = str(listener.getsockname()[1])
54-
return subprocess.call([sys.executable, "-m", "pytest", *sys.argv[1:]], cwd=root, env=env)
55+
return subprocess.call(
56+
[sys.executable, "-m", "pytest", "--basetemp", str(temporary / "pytest"), *sys.argv[1:]],
57+
cwd=root,
58+
env=env,
59+
)
5560

5661

5762
if __name__ == "__main__":

0 commit comments

Comments
 (0)