Просмотр исходного кода

Add progress parameter to pack_loose_objects and repack methods

This partially addresses progress output during tests, though some progress
messages still come from other parts of the codebase (server handlers,
dumb HTTP client, and direct pack operations).
Jelmer Vernooij 5 месяцев назад
Родитель
Сommit
4d8a10d346

+ 8 - 8
dulwich/cloud/gcs.py

@@ -80,17 +80,17 @@ class GcsObjectStore(BucketBasedObjectStore):
 
 
         from ..file import _GitFile
         from ..file import _GitFile
 
 
-        f = tempfile.SpooledTemporaryFile(max_size=PACK_SPOOL_FILE_MAX_SIZE)
-        b.download_to_file(f)
-        f.seek(0)
-        return PackData(name + ".pack", cast(_GitFile, f))
+        with tempfile.SpooledTemporaryFile(max_size=PACK_SPOOL_FILE_MAX_SIZE) as f:
+            b.download_to_file(f)
+            f.seek(0)
+            return PackData(name + ".pack", cast(_GitFile, f))
 
 
     def _load_pack_index(self, name: str) -> PackIndex:
     def _load_pack_index(self, name: str) -> PackIndex:
         b = self.bucket.blob(posixpath.join(self.subpath, name + ".idx"))
         b = self.bucket.blob(posixpath.join(self.subpath, name + ".idx"))
-        f = tempfile.SpooledTemporaryFile(max_size=PACK_SPOOL_FILE_MAX_SIZE)
-        b.download_to_file(f)
-        f.seek(0)
-        return load_pack_index_file(name + ".idx", f)
+        with tempfile.SpooledTemporaryFile(max_size=PACK_SPOOL_FILE_MAX_SIZE) as f:
+            b.download_to_file(f)
+            f.seek(0)
+            return load_pack_index_file(name + ".idx", f)
 
 
     def _get_pack(self, name: str) -> Pack:
     def _get_pack(self, name: str) -> Pack:
         return Pack.from_lazy_objects(  # type: ignore[no-untyped-call]
         return Pack.from_lazy_objects(  # type: ignore[no-untyped-call]

+ 10 - 6
dulwich/gc.py

@@ -1,6 +1,7 @@
 """Git garbage collection implementation."""
 """Git garbage collection implementation."""
 
 
 import collections
 import collections
+import logging
 import os
 import os
 import time
 import time
 from dataclasses import dataclass, field
 from dataclasses import dataclass, field
@@ -292,10 +293,10 @@ def garbage_collect(
     if not dry_run:
     if not dry_run:
         if prune and unreachable_to_prune:
         if prune and unreachable_to_prune:
             # Repack excluding unreachable objects
             # Repack excluding unreachable objects
-            object_store.repack(exclude=unreachable_to_prune)
+            object_store.repack(exclude=unreachable_to_prune, progress=progress)
         else:
         else:
             # Normal repack
             # Normal repack
-            object_store.repack()
+            object_store.repack(progress=progress)
 
 
     # Prune orphaned temporary files
     # Prune orphaned temporary files
     if progress:
     if progress:
@@ -367,12 +368,13 @@ def should_run_gc(repo: "BaseRepo", config: Optional["Config"] = None) -> bool:
     return False
     return False
 
 
 
 
-def maybe_auto_gc(repo: "Repo", config: Optional["Config"] = None) -> bool:
+def maybe_auto_gc(repo: "Repo", config: Optional["Config"] = None, progress: Optional[Callable] = None) -> bool:
     """Run automatic garbage collection if needed.
     """Run automatic garbage collection if needed.
 
 
     Args:
     Args:
         repo: Repository to potentially GC
         repo: Repository to potentially GC
         config: Configuration to use (defaults to repo config)
         config: Configuration to use (defaults to repo config)
+        progress: Optional progress reporting callback
 
 
     Returns:
     Returns:
         True if GC was run, False otherwise
         True if GC was run, False otherwise
@@ -383,7 +385,7 @@ def maybe_auto_gc(repo: "Repo", config: Optional["Config"] = None) -> bool:
     # Check for gc.log file - only for disk-based repos
     # Check for gc.log file - only for disk-based repos
     if not hasattr(repo, "controldir"):
     if not hasattr(repo, "controldir"):
         # For non-disk repos, just run GC without gc.log handling
         # For non-disk repos, just run GC without gc.log handling
-        garbage_collect(repo, auto=True)
+        garbage_collect(repo, auto=True, progress=progress)
         return True
         return True
 
 
     gc_log_path = os.path.join(repo.controldir(), "gc.log")
     gc_log_path = os.path.join(repo.controldir(), "gc.log")
@@ -409,7 +411,9 @@ def maybe_auto_gc(repo: "Repo", config: Optional["Config"] = None) -> bool:
         if time.time() - stat_info.st_mtime < expiry_seconds:
         if time.time() - stat_info.st_mtime < expiry_seconds:
             # gc.log exists and is not expired - skip GC
             # gc.log exists and is not expired - skip GC
             with open(gc_log_path, "rb") as f:
             with open(gc_log_path, "rb") as f:
-                print(f.read().decode("utf-8", errors="replace"))
+                logging.info(
+                    "gc.log content: %s", f.read().decode("utf-8", errors="replace")
+                )
             return False
             return False
 
 
     # TODO: Support gc.autoDetach to run in background
     # TODO: Support gc.autoDetach to run in background
@@ -417,7 +421,7 @@ def maybe_auto_gc(repo: "Repo", config: Optional["Config"] = None) -> bool:
 
 
     try:
     try:
         # Run GC with auto=True flag
         # Run GC with auto=True flag
-        garbage_collect(repo, auto=True)
+        garbage_collect(repo, auto=True, progress=progress)
 
 
         # Remove gc.log on successful completion
         # Remove gc.log on successful completion
         if os.path.exists(gc_log_path):
         if os.path.exists(gc_log_path):

+ 3 - 1
dulwich/lfs.py

@@ -35,6 +35,8 @@ Key components:
 import hashlib
 import hashlib
 import json
 import json
 import logging
 import logging
+
+logger = logging.getLogger(__name__)
 import os
 import os
 import tempfile
 import tempfile
 from collections.abc import Iterable
 from collections.abc import Iterable
@@ -272,7 +274,7 @@ class LFSFilterDriver:
                 return content
                 return content
             except LFSError as e:
             except LFSError as e:
                 # Download failed, fall back to returning pointer
                 # Download failed, fall back to returning pointer
-                logging.warning("LFS object download failed for %s: %s", pointer.oid, e)
+                logger.warning("LFS object download failed for %s: %s", pointer.oid, e)
 
 
                 # Return pointer as-is when object is missing and download failed
                 # Return pointer as-is when object is missing and download failed
                 return data
                 return data

+ 12 - 5
dulwich/object_store.py

@@ -769,9 +769,12 @@ class PackBasedObjectStore(BaseObjectStore, PackedObjectContainer):
     def _remove_pack(self, pack: "Pack") -> None:
     def _remove_pack(self, pack: "Pack") -> None:
         raise NotImplementedError(self._remove_pack)
         raise NotImplementedError(self._remove_pack)
 
 
-    def pack_loose_objects(self) -> int:
+    def pack_loose_objects(self, progress: Optional[Callable] = None) -> int:
         """Pack loose objects.
         """Pack loose objects.
 
 
+        Args:
+          progress: Optional progress reporting callback
+          
         Returns: Number of objects packed
         Returns: Number of objects packed
         """
         """
         objects: list[tuple[ShaFile, None]] = []
         objects: list[tuple[ShaFile, None]] = []
@@ -779,12 +782,12 @@ class PackBasedObjectStore(BaseObjectStore, PackedObjectContainer):
             obj = self._get_loose_object(sha)
             obj = self._get_loose_object(sha)
             if obj is not None:
             if obj is not None:
                 objects.append((obj, None))
                 objects.append((obj, None))
-        self.add_objects(objects)
+        self.add_objects(objects, progress=progress)
         for obj, path in objects:
         for obj, path in objects:
             self.delete_loose_object(obj.id)
             self.delete_loose_object(obj.id)
         return len(objects)
         return len(objects)
 
 
-    def repack(self, exclude: Optional[set] = None) -> int:
+    def repack(self, exclude: Optional[set] = None, progress: Optional[Callable] = None) -> int:
         """Repack the packs in this repository.
         """Repack the packs in this repository.
 
 
         Note that this implementation is fairly naive and currently keeps all
         Note that this implementation is fairly naive and currently keeps all
@@ -792,6 +795,7 @@ class PackBasedObjectStore(BaseObjectStore, PackedObjectContainer):
 
 
         Args:
         Args:
           exclude: Optional set of object SHAs to exclude from repacking
           exclude: Optional set of object SHAs to exclude from repacking
+          progress: Optional progress reporting callback
         """
         """
         if exclude is None:
         if exclude is None:
             exclude = set()
             exclude = set()
@@ -818,7 +822,7 @@ class PackBasedObjectStore(BaseObjectStore, PackedObjectContainer):
             # The name of the consolidated pack might match the name of a
             # The name of the consolidated pack might match the name of a
             # pre-existing pack. Take care not to remove the newly created
             # pre-existing pack. Take care not to remove the newly created
             # consolidated pack.
             # consolidated pack.
-            consolidated = self.add_objects(list(objects))
+            consolidated = self.add_objects(list(objects), progress=progress)
             if consolidated is not None:
             if consolidated is not None:
                 old_packs.pop(consolidated.name(), None)
                 old_packs.pop(consolidated.name(), None)
 
 
@@ -2507,10 +2511,13 @@ class BucketBasedObjectStore(PackBasedObjectStore):
         """
         """
         # Doesn't exist..
         # Doesn't exist..
 
 
-    def pack_loose_objects(self) -> int:
+    def pack_loose_objects(self, progress: Optional[Callable] = None) -> int:
         """Pack loose objects. Returns number of objects packed.
         """Pack loose objects. Returns number of objects packed.
 
 
         BucketBasedObjectStore doesn't support loose objects, so this is a no-op.
         BucketBasedObjectStore doesn't support loose objects, so this is a no-op.
+        
+        Args:
+          progress: Optional progress reporting callback (ignored)
         """
         """
         return 0
         return 0
 
 

+ 1 - 0
tests/__init__.py

@@ -30,6 +30,7 @@ __all__ = [
 ]
 ]
 
 
 import doctest
 import doctest
+import logging
 import os
 import os
 import shutil
 import shutil
 import subprocess
 import subprocess

+ 14 - 3
tests/compat/test_server.py

@@ -58,9 +58,20 @@ class GitServerTestCase(ServerTests, CompatTestCase):
         backend = DictBackend({b"/": repo})
         backend = DictBackend({b"/": repo})
         dul_server = TCPGitServer(backend, b"localhost", 0, handlers=self._handlers())
         dul_server = TCPGitServer(backend, b"localhost", 0, handlers=self._handlers())
         self._check_server(dul_server)
         self._check_server(dul_server)
-        self.addCleanup(dul_server.shutdown)
-        self.addCleanup(dul_server.server_close)
-        threading.Thread(target=dul_server.serve).start()
+
+        # Start server in a thread
+        server_thread = threading.Thread(target=dul_server.serve)
+        server_thread.daemon = True  # Make thread daemon so it dies with main thread
+        server_thread.start()
+
+        # Add cleanup in the correct order
+        def cleanup_server():
+            dul_server.shutdown()
+            dul_server.server_close()
+            # Give thread a moment to exit cleanly
+            server_thread.join(timeout=1.0)
+
+        self.addCleanup(cleanup_server)
         self._server = dul_server
         self._server = dul_server
         _, port = self._server.socket.getsockname()
         _, port = self._server.socket.getsockname()
         return port
         return port

+ 18 - 12
tests/test_commit_graph.py

@@ -119,18 +119,22 @@ class CommitGraphTests(unittest.TestCase):
     def test_from_invalid_signature(self) -> None:
     def test_from_invalid_signature(self) -> None:
         data = b"XXXX" + b"\\x00" * 100
         data = b"XXXX" + b"\\x00" * 100
         f = io.BytesIO(data)
         f = io.BytesIO(data)
-
-        with self.assertRaises(ValueError) as cm:
-            CommitGraph.from_file(f)
-        self.assertIn("Invalid commit graph signature", str(cm.exception))
+        try:
+            with self.assertRaises(ValueError) as cm:
+                CommitGraph.from_file(f)
+            self.assertIn("Invalid commit graph signature", str(cm.exception))
+        finally:
+            f.close()
 
 
     def test_from_invalid_version(self) -> None:
     def test_from_invalid_version(self) -> None:
         data = COMMIT_GRAPH_SIGNATURE + struct.pack(">B", 99) + b"\\x00" * 100
         data = COMMIT_GRAPH_SIGNATURE + struct.pack(">B", 99) + b"\\x00" * 100
         f = io.BytesIO(data)
         f = io.BytesIO(data)
-
-        with self.assertRaises(ValueError) as cm:
-            CommitGraph.from_file(f)
-        self.assertIn("Unsupported commit graph version", str(cm.exception))
+        try:
+            with self.assertRaises(ValueError) as cm:
+                CommitGraph.from_file(f)
+            self.assertIn("Unsupported commit graph version", str(cm.exception))
+        finally:
+            f.close()
 
 
     def test_from_invalid_hash_version(self) -> None:
     def test_from_invalid_hash_version(self) -> None:
         data = (
         data = (
@@ -140,10 +144,12 @@ class CommitGraphTests(unittest.TestCase):
             + b"\\x00" * 100
             + b"\\x00" * 100
         )
         )
         f = io.BytesIO(data)
         f = io.BytesIO(data)
-
-        with self.assertRaises(ValueError) as cm:
-            CommitGraph.from_file(f)
-        self.assertIn("Unsupported hash version", str(cm.exception))
+        try:
+            with self.assertRaises(ValueError) as cm:
+                CommitGraph.from_file(f)
+            self.assertIn("Unsupported hash version", str(cm.exception))
+        finally:
+            f.close()
 
 
     def create_minimal_commit_graph_data(self) -> bytes:
     def create_minimal_commit_graph_data(self) -> bytes:
         """Create minimal valid commit graph data for testing."""
         """Create minimal valid commit graph data for testing."""

+ 40 - 28
tests/test_gc.py

@@ -21,6 +21,11 @@ from dulwich.objects import Blob, Commit, Tag, Tree
 from dulwich.repo import MemoryRepo, Repo
 from dulwich.repo import MemoryRepo, Repo
 
 
 
 
+def no_op_progress(msg):
+    """Progress callback that does nothing."""
+    pass
+
+
 class GCTestCase(TestCase):
 class GCTestCase(TestCase):
     """Tests for garbage collection functionality."""
     """Tests for garbage collection functionality."""
 
 
@@ -159,7 +164,7 @@ class GCTestCase(TestCase):
         self.repo.object_store.add_object(unreachable_blob)
         self.repo.object_store.add_object(unreachable_blob)
 
 
         # Run garbage collection (grace_period=None means no grace period check)
         # Run garbage collection (grace_period=None means no grace period check)
-        stats = garbage_collect(self.repo, prune=True, grace_period=None)
+        stats = garbage_collect(self.repo, prune=True, grace_period=None, progress=no_op_progress)
 
 
         # Check results
         # Check results
         self.assertIsInstance(stats, GCStats)
         self.assertIsInstance(stats, GCStats)
@@ -180,7 +185,7 @@ class GCTestCase(TestCase):
         self.repo.object_store.add_object(unreachable_blob)
         self.repo.object_store.add_object(unreachable_blob)
 
 
         # Run garbage collection without pruning
         # Run garbage collection without pruning
-        stats = garbage_collect(self.repo, prune=False)
+        stats = garbage_collect(self.repo, prune=False, progress=no_op_progress)
 
 
         # Check that nothing was pruned
         # Check that nothing was pruned
         self.assertEqual(set(), stats.pruned_objects)
         self.assertEqual(set(), stats.pruned_objects)
@@ -194,7 +199,7 @@ class GCTestCase(TestCase):
         self.repo.object_store.add_object(unreachable_blob)
         self.repo.object_store.add_object(unreachable_blob)
 
 
         # Run garbage collection with dry run (grace_period=None means no grace period check)
         # Run garbage collection with dry run (grace_period=None means no grace period check)
-        stats = garbage_collect(self.repo, prune=True, grace_period=None, dry_run=True)
+        stats = garbage_collect(self.repo, prune=True, grace_period=None, dry_run=True, progress=no_op_progress)
 
 
         # Check that object would be pruned but still exists
         # Check that object would be pruned but still exists
         # On Windows, the repository initialization might create additional unreachable objects
         # On Windows, the repository initialization might create additional unreachable objects
@@ -214,7 +219,7 @@ class GCTestCase(TestCase):
 
 
         # Run garbage collection with a 1 hour grace period, but dry run to avoid packing
         # Run garbage collection with a 1 hour grace period, but dry run to avoid packing
         # The object was just created, so it should not be pruned
         # The object was just created, so it should not be pruned
-        stats = garbage_collect(self.repo, prune=True, grace_period=3600, dry_run=True)
+        stats = garbage_collect(self.repo, prune=True, grace_period=3600, dry_run=True, progress=no_op_progress)
 
 
         # Check that the object was NOT pruned
         # Check that the object was NOT pruned
         self.assertEqual(set(), stats.pruned_objects)
         self.assertEqual(set(), stats.pruned_objects)
@@ -244,7 +249,7 @@ class GCTestCase(TestCase):
 
 
         # Run garbage collection with a 1 hour grace period
         # Run garbage collection with a 1 hour grace period
         # The object is 2 hours old, so it should be pruned
         # The object is 2 hours old, so it should be pruned
-        stats = garbage_collect(self.repo, prune=True, grace_period=3600)
+        stats = garbage_collect(self.repo, prune=True, grace_period=3600, progress=no_op_progress)
 
 
         # Check that the object was pruned
         # Check that the object was pruned
         self.assertEqual({old_blob.id}, stats.pruned_objects)
         self.assertEqual({old_blob.id}, stats.pruned_objects)
@@ -257,14 +262,14 @@ class GCTestCase(TestCase):
         self.repo.object_store.add_object(unreachable_blob)
         self.repo.object_store.add_object(unreachable_blob)
 
 
         # Pack the objects to ensure the blob is in a pack
         # Pack the objects to ensure the blob is in a pack
-        self.repo.object_store.pack_loose_objects()
+        self.repo.object_store.pack_loose_objects(progress=no_op_progress)
 
 
         # Ensure the object is NOT loose anymore
         # Ensure the object is NOT loose anymore
         self.assertFalse(self.repo.object_store.contains_loose(unreachable_blob.id))
         self.assertFalse(self.repo.object_store.contains_loose(unreachable_blob.id))
         self.assertIn(unreachable_blob.id, self.repo.object_store)
         self.assertIn(unreachable_blob.id, self.repo.object_store)
 
 
         # Run garbage collection (grace_period=None means no grace period check)
         # Run garbage collection (grace_period=None means no grace period check)
-        stats = garbage_collect(self.repo, prune=True, grace_period=None)
+        stats = garbage_collect(self.repo, prune=True, grace_period=None, progress=no_op_progress)
 
 
         # Check that the packed object was pruned
         # Check that the packed object was pruned
         self.assertEqual({unreachable_blob.id}, stats.pruned_objects)
         self.assertEqual({unreachable_blob.id}, stats.pruned_objects)
@@ -410,7 +415,7 @@ class GCTestCase(TestCase):
             self.repo.object_store, "get_object_mtime", side_effect=KeyError
             self.repo.object_store, "get_object_mtime", side_effect=KeyError
         ):
         ):
             # Run garbage collection with grace period
             # Run garbage collection with grace period
-            stats = garbage_collect(self.repo, prune=True, grace_period=3600)
+            stats = garbage_collect(self.repo, prune=True, grace_period=3600, progress=no_op_progress)
 
 
         # Object should be kept because mtime couldn't be determined
         # Object should be kept because mtime couldn't be determined
         self.assertEqual(set(), stats.pruned_objects)
         self.assertEqual(set(), stats.pruned_objects)
@@ -487,7 +492,7 @@ class AutoGCTestCase(TestCase):
                 blob = Blob()
                 blob = Blob()
                 blob.data = f"test blob {i}".encode()
                 blob.data = f"test blob {i}".encode()
                 r.object_store.add_object(blob)
                 r.object_store.add_object(blob)
-                r.object_store.pack_loose_objects()
+                r.object_store.pack_loose_objects(progress=no_op_progress)
 
 
             # Force re-enumeration of packs
             # Force re-enumeration of packs
             r.object_store._update_pack_cache()
             r.object_store._update_pack_cache()
@@ -525,7 +530,7 @@ class AutoGCTestCase(TestCase):
             blob = Blob()
             blob = Blob()
             blob.data = b"test blob"
             blob.data = b"test blob"
             r.object_store.add_object(blob)
             r.object_store.add_object(blob)
-            r.object_store.pack_loose_objects()
+            r.object_store.pack_loose_objects(progress=no_op_progress)
 
 
             # Force re-enumeration of packs
             # Force re-enumeration of packs
             r.object_store._update_pack_cache()
             r.object_store._update_pack_cache()
@@ -547,10 +552,10 @@ class AutoGCTestCase(TestCase):
                 r.object_store.add_object(blob)
                 r.object_store.add_object(blob)
 
 
             with patch("dulwich.gc.garbage_collect") as mock_gc:
             with patch("dulwich.gc.garbage_collect") as mock_gc:
-                result = maybe_auto_gc(r, config)
+                result = maybe_auto_gc(r, config, progress=no_op_progress)
 
 
             self.assertTrue(result)
             self.assertTrue(result)
-            mock_gc.assert_called_once_with(r, auto=True)
+            mock_gc.assert_called_once_with(r, auto=True, progress=no_op_progress)
 
 
     def test_maybe_auto_gc_skips_when_not_needed(self):
     def test_maybe_auto_gc_skips_when_not_needed(self):
         """Test that auto GC doesn't run when thresholds are not exceeded."""
         """Test that auto GC doesn't run when thresholds are not exceeded."""
@@ -558,7 +563,7 @@ class AutoGCTestCase(TestCase):
         config = ConfigDict()
         config = ConfigDict()
 
 
         with patch("dulwich.gc.garbage_collect") as mock_gc:
         with patch("dulwich.gc.garbage_collect") as mock_gc:
-            result = maybe_auto_gc(r, config)
+            result = maybe_auto_gc(r, config, progress=no_op_progress)
 
 
         self.assertFalse(result)
         self.assertFalse(result)
         mock_gc.assert_not_called()
         mock_gc.assert_not_called()
@@ -580,12 +585,15 @@ class AutoGCTestCase(TestCase):
             blob.data = b"test"
             blob.data = b"test"
             r.object_store.add_object(blob)
             r.object_store.add_object(blob)
 
 
-            with patch("builtins.print") as mock_print:
-                result = maybe_auto_gc(r, config)
+            # Capture log messages
+            import logging
+
+            with self.assertLogs(level=logging.INFO) as cm:
+                result = maybe_auto_gc(r, config, progress=no_op_progress)
 
 
             self.assertFalse(result)
             self.assertFalse(result)
-            # Verify gc.log contents were printed
-            mock_print.assert_called_once_with("Previous GC failed\n")
+            # Verify gc.log contents were logged
+            self.assertTrue(any("Previous GC failed" in msg for msg in cm.output))
 
 
     def test_maybe_auto_gc_with_expired_gc_log(self):
     def test_maybe_auto_gc_with_expired_gc_log(self):
         """Test that auto GC runs when gc.log exists but is expired."""
         """Test that auto GC runs when gc.log exists but is expired."""
@@ -610,10 +618,10 @@ class AutoGCTestCase(TestCase):
             r.object_store.add_object(blob)
             r.object_store.add_object(blob)
 
 
             with patch("dulwich.gc.garbage_collect") as mock_gc:
             with patch("dulwich.gc.garbage_collect") as mock_gc:
-                result = maybe_auto_gc(r, config)
+                result = maybe_auto_gc(r, config, progress=no_op_progress)
 
 
             self.assertTrue(result)
             self.assertTrue(result)
-            mock_gc.assert_called_once_with(r, auto=True)
+            mock_gc.assert_called_once_with(r, auto=True, progress=no_op_progress)
             # gc.log should be removed after successful GC
             # gc.log should be removed after successful GC
             self.assertFalse(os.path.exists(gc_log_path))
             self.assertFalse(os.path.exists(gc_log_path))
 
 
@@ -632,10 +640,10 @@ class AutoGCTestCase(TestCase):
             with patch(
             with patch(
                 "dulwich.gc.garbage_collect", side_effect=OSError("GC failed")
                 "dulwich.gc.garbage_collect", side_effect=OSError("GC failed")
             ) as mock_gc:
             ) as mock_gc:
-                result = maybe_auto_gc(r, config)
+                result = maybe_auto_gc(r, config, progress=no_op_progress)
 
 
             self.assertFalse(result)
             self.assertFalse(result)
-            mock_gc.assert_called_once_with(r, auto=True)
+            mock_gc.assert_called_once_with(r, auto=True, progress=no_op_progress)
 
 
             # Check that error was written to gc.log
             # Check that error was written to gc.log
             gc_log_path = os.path.join(r.controldir(), "gc.log")
             gc_log_path = os.path.join(r.controldir(), "gc.log")
@@ -667,10 +675,10 @@ class AutoGCTestCase(TestCase):
             r.object_store.add_object(blob)
             r.object_store.add_object(blob)
 
 
             with patch("dulwich.gc.garbage_collect") as mock_gc:
             with patch("dulwich.gc.garbage_collect") as mock_gc:
-                result = maybe_auto_gc(r, config)
+                result = maybe_auto_gc(r, config, progress=no_op_progress)
 
 
             self.assertTrue(result)
             self.assertTrue(result)
-            mock_gc.assert_called_once_with(r, auto=True)
+            mock_gc.assert_called_once_with(r, auto=True, progress=no_op_progress)
 
 
     def test_gc_log_expiry_invalid_format(self):
     def test_gc_log_expiry_invalid_format(self):
         """Test that invalid gc.logExpiry format defaults to 1 day."""
         """Test that invalid gc.logExpiry format defaults to 1 day."""
@@ -694,12 +702,16 @@ class AutoGCTestCase(TestCase):
             blob.data = b"test"
             blob.data = b"test"
             r.object_store.add_object(blob)
             r.object_store.add_object(blob)
 
 
-            with patch("builtins.print") as mock_print:
-                result = maybe_auto_gc(r, config)
+            # Capture log messages
+            import logging
+
+            with self.assertLogs(level=logging.INFO) as cm:
+                result = maybe_auto_gc(r, config, progress=no_op_progress)
 
 
             # Should not run GC because gc.log is recent (within default 1 day)
             # Should not run GC because gc.log is recent (within default 1 day)
             self.assertFalse(result)
             self.assertFalse(result)
-            mock_print.assert_called_once()
+            # Check that gc.log content was logged
+            self.assertTrue(any("gc.log content:" in msg for msg in cm.output))
 
 
     def test_maybe_auto_gc_non_disk_repo(self):
     def test_maybe_auto_gc_non_disk_repo(self):
         """Test auto GC on non-disk repository (MemoryRepo)."""
         """Test auto GC on non-disk repository (MemoryRepo)."""
@@ -715,7 +727,7 @@ class AutoGCTestCase(TestCase):
 
 
         # For non-disk repos, should_run_gc returns False
         # For non-disk repos, should_run_gc returns False
         # because it can't count loose objects
         # because it can't count loose objects
-        result = maybe_auto_gc(r, config)
+        result = maybe_auto_gc(r, config, progress=no_op_progress)
         self.assertFalse(result)
         self.assertFalse(result)
 
 
     def test_gc_removes_existing_gc_log_on_success(self):
     def test_gc_removes_existing_gc_log_on_success(self):
@@ -740,7 +752,7 @@ class AutoGCTestCase(TestCase):
             r.object_store.add_object(blob)
             r.object_store.add_object(blob)
 
 
             # Run auto GC
             # Run auto GC
-            result = maybe_auto_gc(r, config)
+            result = maybe_auto_gc(r, config, progress=no_op_progress)
 
 
             self.assertTrue(result)
             self.assertTrue(result)
             # gc.log should be removed after successful GC
             # gc.log should be removed after successful GC

+ 39 - 4
tests/test_lfs.py

@@ -36,10 +36,22 @@ from . import TestCase
 class LFSTests(TestCase):
 class LFSTests(TestCase):
     def setUp(self) -> None:
     def setUp(self) -> None:
         super().setUp()
         super().setUp()
+        # Suppress LFS warnings during these tests
+        import logging
+
+        self._old_level = logging.getLogger("dulwich.lfs").level
+        logging.getLogger("dulwich.lfs").setLevel(logging.ERROR)
         self.test_dir = tempfile.mkdtemp()
         self.test_dir = tempfile.mkdtemp()
         self.addCleanup(shutil.rmtree, self.test_dir)
         self.addCleanup(shutil.rmtree, self.test_dir)
         self.lfs = LFSStore.create(self.test_dir)
         self.lfs = LFSStore.create(self.test_dir)
 
 
+    def tearDown(self) -> None:
+        # Restore original logging level
+        import logging
+
+        logging.getLogger("dulwich.lfs").setLevel(self._old_level)
+        super().tearDown()
+
     def test_create(self) -> None:
     def test_create(self) -> None:
         sha = self.lfs.write_object([b"a", b"b"])
         sha = self.lfs.write_object([b"a", b"b"])
         with self.lfs.open_object(sha) as f:
         with self.lfs.open_object(sha) as f:
@@ -209,19 +221,30 @@ class LFSIntegrationTests(TestCase):
 
 
     def setUp(self) -> None:
     def setUp(self) -> None:
         super().setUp()
         super().setUp()
-        import os
+        # Suppress LFS warnings during these integration tests
+        import logging
 
 
-        from dulwich.repo import Repo
+        self._old_level = logging.getLogger("dulwich.lfs").level
+        logging.getLogger("dulwich.lfs").setLevel(logging.ERROR)
 
 
         # Create temporary directory for test repo
         # Create temporary directory for test repo
         self.test_dir = tempfile.mkdtemp()
         self.test_dir = tempfile.mkdtemp()
         self.addCleanup(shutil.rmtree, self.test_dir)
         self.addCleanup(shutil.rmtree, self.test_dir)
 
 
         # Initialize repo
         # Initialize repo
+        from dulwich.repo import Repo
+
         self.repo = Repo.init(self.test_dir)
         self.repo = Repo.init(self.test_dir)
         self.lfs_dir = os.path.join(self.test_dir, ".git", "lfs")
         self.lfs_dir = os.path.join(self.test_dir, ".git", "lfs")
         self.lfs_store = LFSStore.create(self.lfs_dir)
         self.lfs_store = LFSStore.create(self.lfs_dir)
 
 
+    def tearDown(self) -> None:
+        # Restore original logging level
+        import logging
+
+        logging.getLogger("dulwich.lfs").setLevel(self._old_level)
+        super().tearDown()
+
     def test_lfs_with_gitattributes(self) -> None:
     def test_lfs_with_gitattributes(self) -> None:
         """Test LFS integration with .gitattributes."""
         """Test LFS integration with .gitattributes."""
         import os
         import os
@@ -701,7 +724,13 @@ class LFSServerTests(TestCase):
         self.server_thread = threading.Thread(target=self.server.serve_forever)
         self.server_thread = threading.Thread(target=self.server.serve_forever)
         self.server_thread.daemon = True
         self.server_thread.daemon = True
         self.server_thread.start()
         self.server_thread.start()
-        self.addCleanup(self.server.shutdown)
+
+        def cleanup_server():
+            self.server.shutdown()
+            self.server.server_close()
+            self.server_thread.join(timeout=1.0)
+
+        self.addCleanup(cleanup_server)
 
 
     def test_server_batch_endpoint(self) -> None:
     def test_server_batch_endpoint(self) -> None:
         """Test the batch endpoint directly."""
         """Test the batch endpoint directly."""
@@ -974,7 +1003,13 @@ class LFSClientTests(TestCase):
         self.server_thread = threading.Thread(target=self.server.serve_forever)
         self.server_thread = threading.Thread(target=self.server.serve_forever)
         self.server_thread.daemon = True
         self.server_thread.daemon = True
         self.server_thread.start()
         self.server_thread.start()
-        self.addCleanup(self.server.shutdown)
+
+        def cleanup_server():
+            self.server.shutdown()
+            self.server.server_close()
+            self.server_thread.join(timeout=1.0)
+
+        self.addCleanup(cleanup_server)
 
 
         # Create LFS client pointing to our test server
         # Create LFS client pointing to our test server
         self.client = LFSClient(self.server_url)
         self.client = LFSClient(self.server_url)

+ 12 - 0
tests/test_lfs_integration.py

@@ -35,6 +35,11 @@ from . import TestCase
 class LFSFilterIntegrationTests(TestCase):
 class LFSFilterIntegrationTests(TestCase):
     def setUp(self) -> None:
     def setUp(self) -> None:
         super().setUp()
         super().setUp()
+        # Suppress LFS warnings during these integration tests
+        import logging
+
+        self._old_level = logging.getLogger("dulwich.lfs").level
+        logging.getLogger("dulwich.lfs").setLevel(logging.ERROR)
         # Create temporary directory for LFS store
         # Create temporary directory for LFS store
         self.test_dir = tempfile.mkdtemp()
         self.test_dir = tempfile.mkdtemp()
         self.addCleanup(shutil.rmtree, self.test_dir)
         self.addCleanup(shutil.rmtree, self.test_dir)
@@ -60,6 +65,13 @@ class LFSFilterIntegrationTests(TestCase):
             self.config, self.gitattributes, self.registry
             self.config, self.gitattributes, self.registry
         )
         )
 
 
+    def tearDown(self) -> None:
+        # Restore original logging level
+        import logging
+
+        logging.getLogger("dulwich.lfs").setLevel(self._old_level)
+        super().tearDown()
+
     def test_lfs_round_trip(self) -> None:
     def test_lfs_round_trip(self) -> None:
         """Test complete LFS round trip through filter normalizer."""
         """Test complete LFS round trip through filter normalizer."""
         # Create a blob with binary content
         # Create a blob with binary content

+ 51 - 39
tests/test_pack.py

@@ -430,33 +430,39 @@ class TestPackData(PackTests):
 
 
     def test_compute_file_sha(self) -> None:
     def test_compute_file_sha(self) -> None:
         f = BytesIO(b"abcd1234wxyz")
         f = BytesIO(b"abcd1234wxyz")
-        self.assertEqual(
-            sha1(b"abcd1234wxyz").hexdigest(), compute_file_sha(f).hexdigest()
-        )
-        self.assertEqual(
-            sha1(b"abcd1234wxyz").hexdigest(),
-            compute_file_sha(f, buffer_size=5).hexdigest(),
-        )
-        self.assertEqual(
-            sha1(b"abcd1234").hexdigest(),
-            compute_file_sha(f, end_ofs=-4).hexdigest(),
-        )
-        self.assertEqual(
-            sha1(b"1234wxyz").hexdigest(),
-            compute_file_sha(f, start_ofs=4).hexdigest(),
-        )
-        self.assertEqual(
-            sha1(b"1234").hexdigest(),
-            compute_file_sha(f, start_ofs=4, end_ofs=-4).hexdigest(),
-        )
+        try:
+            self.assertEqual(
+                sha1(b"abcd1234wxyz").hexdigest(), compute_file_sha(f).hexdigest()
+            )
+            self.assertEqual(
+                sha1(b"abcd1234wxyz").hexdigest(),
+                compute_file_sha(f, buffer_size=5).hexdigest(),
+            )
+            self.assertEqual(
+                sha1(b"abcd1234").hexdigest(),
+                compute_file_sha(f, end_ofs=-4).hexdigest(),
+            )
+            self.assertEqual(
+                sha1(b"1234wxyz").hexdigest(),
+                compute_file_sha(f, start_ofs=4).hexdigest(),
+            )
+            self.assertEqual(
+                sha1(b"1234").hexdigest(),
+                compute_file_sha(f, start_ofs=4, end_ofs=-4).hexdigest(),
+            )
+        finally:
+            f.close()
 
 
     def test_compute_file_sha_short_file(self) -> None:
     def test_compute_file_sha_short_file(self) -> None:
         f = BytesIO(b"abcd1234wxyz")
         f = BytesIO(b"abcd1234wxyz")
-        self.assertRaises(AssertionError, compute_file_sha, f, end_ofs=-20)
-        self.assertRaises(AssertionError, compute_file_sha, f, end_ofs=20)
-        self.assertRaises(
-            AssertionError, compute_file_sha, f, start_ofs=10, end_ofs=-12
-        )
+        try:
+            self.assertRaises(AssertionError, compute_file_sha, f, end_ofs=-20)
+            self.assertRaises(AssertionError, compute_file_sha, f, end_ofs=20)
+            self.assertRaises(
+                AssertionError, compute_file_sha, f, start_ofs=10, end_ofs=-12
+            )
+        finally:
+            f.close()
 
 
 
 
 class TestPack(PackTests):
 class TestPack(PackTests):
@@ -729,24 +735,30 @@ class TestThinPack(PackTests):
 class WritePackTests(TestCase):
 class WritePackTests(TestCase):
     def test_write_pack_header(self) -> None:
     def test_write_pack_header(self) -> None:
         f = BytesIO()
         f = BytesIO()
-        write_pack_header(f.write, 42)
-        self.assertEqual(b"PACK\x00\x00\x00\x02\x00\x00\x00*", f.getvalue())
+        try:
+            write_pack_header(f.write, 42)
+            self.assertEqual(b"PACK\x00\x00\x00\x02\x00\x00\x00*", f.getvalue())
+        finally:
+            f.close()
 
 
     def test_write_pack_object(self) -> None:
     def test_write_pack_object(self) -> None:
         f = BytesIO()
         f = BytesIO()
-        f.write(b"header")
-        offset = f.tell()
-        crc32 = write_pack_object(f.write, Blob.type_num, b"blob")
-        self.assertEqual(crc32, zlib.crc32(f.getvalue()[6:]) & 0xFFFFFFFF)
-
-        f.write(b"x")  # unpack_object needs extra trailing data.
-        f.seek(offset)
-        unpacked, unused = unpack_object(f.read, compute_crc32=True)
-        self.assertEqual(Blob.type_num, unpacked.pack_type_num)
-        self.assertEqual(Blob.type_num, unpacked.obj_type_num)
-        self.assertEqual([b"blob"], unpacked.decomp_chunks)
-        self.assertEqual(crc32, unpacked.crc32)
-        self.assertEqual(b"x", unused)
+        try:
+            f.write(b"header")
+            offset = f.tell()
+            crc32 = write_pack_object(f.write, Blob.type_num, b"blob")
+            self.assertEqual(crc32, zlib.crc32(f.getvalue()[6:]) & 0xFFFFFFFF)
+
+            f.write(b"x")  # unpack_object needs extra trailing data.
+            f.seek(offset)
+            unpacked, unused = unpack_object(f.read, compute_crc32=True)
+            self.assertEqual(Blob.type_num, unpacked.pack_type_num)
+            self.assertEqual(Blob.type_num, unpacked.obj_type_num)
+            self.assertEqual([b"blob"], unpacked.decomp_chunks)
+            self.assertEqual(crc32, unpacked.crc32)
+            self.assertEqual(b"x", unused)
+        finally:
+            f.close()
 
 
     def test_write_pack_object_sha(self) -> None:
     def test_write_pack_object_sha(self) -> None:
         f = BytesIO()
         f = BytesIO()