Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/dulwich/gc.py: 23%
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
Shortcuts on this page
r m x toggle line displays
j k next/prev highlighted chunk
0 (zero) top of page
1 (one) first highlighted chunk
1# gc.py -- Git garbage collection implementation
2# Copyright (C) 2025 Jelmer Vernooij <jelmer@jelmer.uk>
3#
4# SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later
5# Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU
6# General Public License as published by the Free Software Foundation; version 2.0
7# or (at your option) any later version. You can redistribute it and/or
8# modify it under the terms of either of these two licenses.
9#
10# Unless required by applicable law or agreed to in writing, software
11# distributed under the License is distributed on an "AS IS" BASIS,
12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13# See the License for the specific language governing permissions and
14# limitations under the License.
15#
16# You should have received a copy of the licenses; if not, see
17# <http://www.gnu.org/licenses/> for a copy of the GNU General Public License
18# and <http://www.apache.org/licenses/LICENSE-2.0> for a copy of the Apache
19# License, Version 2.0.
20#
22"""Git garbage collection implementation."""
24__all__ = [
25 "DEFAULT_GC_AUTO",
26 "DEFAULT_GC_AUTO_PACK_LIMIT",
27 "DEFAULT_GC_PRUNE_EXPIRE",
28 "GCStats",
29 "find_reachable_objects",
30 "find_unreachable_objects",
31 "garbage_collect",
32 "get_prune_grace_period",
33 "maybe_auto_gc",
34 "prune_unreachable_objects",
35 "should_run_gc",
36]
38import logging
39import os
40import time
41from collections import deque
42from collections.abc import Callable
43from dataclasses import dataclass, field
44from typing import TYPE_CHECKING
46from dulwich.object_store import (
47 BaseObjectStore,
48 DiskObjectStore,
49)
50from dulwich.objects import Commit, ObjectID, Tag, Tree
51from dulwich.refs import RefsContainer
53if TYPE_CHECKING:
54 from .config import Config
55 from .repo import BaseRepo, Repo
58logger = logging.getLogger(__name__)
60DEFAULT_GC_AUTO = 6700
61DEFAULT_GC_AUTO_PACK_LIMIT = 50
62DEFAULT_GC_PRUNE_EXPIRE = 1209600 # 2 weeks in seconds
65def get_prune_grace_period(config: "Config") -> int:
66 """Read gc.pruneExpire from config and return grace period in seconds.
68 If gc.pruneExpire is not set, returns the default of 2 weeks.
70 Args:
71 config: Repository configuration
73 Returns:
74 Grace period in seconds
76 Raises:
77 ValueError: If the configured value cannot be parsed
78 """
79 from .approxidate import parse_approxidate
81 try:
82 raw_value = config.get(b"gc", b"pruneExpire")
83 if isinstance(raw_value, bytes):
84 value = raw_value.decode("utf-8")
85 else:
86 value = raw_value
87 except KeyError:
88 return DEFAULT_GC_PRUNE_EXPIRE
90 value = value.strip()
91 if value == "now":
92 return 0
94 timestamp = parse_approxidate(value)
95 return max(0, int(time.time() - timestamp))
98@dataclass
99class GCStats:
100 """Statistics from garbage collection."""
102 pruned_objects: set[ObjectID] = field(default_factory=set)
103 bytes_freed: int = 0
104 packs_before: int = 0
105 packs_after: int = 0
106 loose_objects_before: int = 0
107 loose_objects_after: int = 0
110def find_reachable_objects(
111 object_store: BaseObjectStore,
112 refs_container: RefsContainer,
113 include_reflogs: bool = True,
114 progress: Callable[[str], None] | None = None,
115) -> set[ObjectID]:
116 """Find all reachable objects in the repository.
118 Args:
119 object_store: Object store to search
120 refs_container: Reference container
121 include_reflogs: Whether to include reflog entries
122 progress: Optional progress callback
124 Returns:
125 Set of reachable object SHAs
126 """
127 reachable: set[ObjectID] = set()
128 pending: deque[ObjectID] = deque()
130 # Start with all refs
131 for ref in refs_container.allkeys():
132 try:
133 sha = refs_container[ref] # This follows symbolic refs
134 if sha and sha not in reachable:
135 pending.append(sha)
136 reachable.add(sha)
137 except KeyError:
138 # Broken ref
139 if progress:
140 progress(f"Warning: Broken ref {ref.decode('utf-8', 'replace')}")
141 continue
143 # TODO: Add reflog support when reflog functionality is available
145 # Walk all reachable objects
146 while pending:
147 sha = pending.popleft()
149 if progress:
150 progress(f"Checking object {sha.decode('ascii', 'replace')}")
152 try:
153 obj = object_store[sha]
154 except KeyError:
155 continue
157 # Add referenced objects
158 if isinstance(obj, Commit):
159 # Tree
160 if obj.tree not in reachable:
161 pending.append(obj.tree)
162 reachable.add(obj.tree)
163 # Parents
164 for parent in obj.parents:
165 if parent not in reachable:
166 pending.append(parent)
167 reachable.add(parent)
168 elif isinstance(obj, Tree):
169 # Tree entries
170 for entry in obj.items():
171 assert entry.sha is not None
172 if entry.sha not in reachable:
173 pending.append(entry.sha)
174 reachable.add(entry.sha)
175 elif isinstance(obj, Tag):
176 # Tagged object
177 if obj.object[1] not in reachable:
178 pending.append(obj.object[1])
179 reachable.add(obj.object[1])
181 return reachable
184def find_unreachable_objects(
185 object_store: BaseObjectStore,
186 refs_container: RefsContainer,
187 include_reflogs: bool = True,
188 progress: Callable[[str], None] | None = None,
189) -> set[ObjectID]:
190 """Find all unreachable objects in the repository.
192 Args:
193 object_store: Object store to search
194 refs_container: Reference container
195 include_reflogs: Whether to include reflog entries
196 progress: Optional progress callback
198 Returns:
199 Set of unreachable object SHAs
200 """
201 reachable = find_reachable_objects(
202 object_store, refs_container, include_reflogs, progress
203 )
205 unreachable: set[ObjectID] = set()
206 for sha in object_store:
207 if sha not in reachable:
208 unreachable.add(sha)
210 return unreachable
213def prune_unreachable_objects(
214 object_store: DiskObjectStore,
215 refs_container: RefsContainer,
216 grace_period: int | None = None,
217 dry_run: bool = False,
218 progress: Callable[[str], None] | None = None,
219) -> tuple[set[ObjectID], int]:
220 """Remove unreachable objects from the repository.
222 Args:
223 object_store: Object store to prune
224 refs_container: Reference container
225 grace_period: Grace period in seconds (objects newer than this are kept)
226 dry_run: If True, only report what would be deleted
227 progress: Optional progress callback
229 Returns:
230 Tuple of (set of pruned object SHAs, total bytes freed)
231 """
232 unreachable = find_unreachable_objects(
233 object_store, refs_container, progress=progress
234 )
236 pruned: set[ObjectID] = set()
237 bytes_freed = 0
239 for sha in unreachable:
240 try:
241 obj = object_store[sha]
243 # Check grace period
244 if grace_period is not None:
245 try:
246 mtime = object_store.get_object_mtime(sha)
247 age = time.time() - mtime
248 if age < grace_period:
249 if progress:
250 progress(
251 f"Keeping {sha.decode('ascii', 'replace')} (age: {age:.0f}s < grace period: {grace_period}s)"
252 )
253 continue
254 except KeyError:
255 # Object not found, skip it
256 continue
258 if progress:
259 progress(f"Pruning {sha.decode('ascii', 'replace')}")
261 # Calculate size before attempting deletion
262 obj_size = len(obj.as_raw_string())
264 if not dry_run:
265 object_store.delete_loose_object(sha)
267 # Only count as pruned if we get here (deletion succeeded or dry run)
268 pruned.add(sha)
269 bytes_freed += obj_size
271 except KeyError:
272 # Object already gone
273 pass
274 except OSError as e:
275 # File system errors during deletion
276 if progress:
277 progress(f"Error pruning {sha.decode('ascii', 'replace')}: {e}")
278 return pruned, bytes_freed
281def garbage_collect(
282 repo: "Repo",
283 auto: bool = False,
284 aggressive: bool = False,
285 prune: bool = True,
286 grace_period: int | None = 1209600, # 2 weeks default
287 dry_run: bool = False,
288 progress: Callable[[str], None] | None = None,
289) -> GCStats:
290 """Run garbage collection on a repository.
292 Args:
293 repo: Repository to garbage collect
294 auto: Whether this is an automatic gc
295 aggressive: Whether to use aggressive settings
296 prune: Whether to prune unreachable objects
297 grace_period: Grace period for pruning in seconds
298 dry_run: If True, only report what would be done
299 progress: Optional progress callback
301 Returns:
302 GCStats object with garbage collection statistics
303 """
304 stats = GCStats()
306 object_store = repo.object_store
307 refs_container = repo.refs
309 # Count initial state
310 stats.packs_before = len(list(object_store.packs))
311 stats.loose_objects_before = object_store.count_loose_objects()
313 # Find unreachable objects to exclude from repacking
314 unreachable_to_prune = set()
315 if prune:
316 if progress:
317 progress("Finding unreachable objects")
318 unreachable = find_unreachable_objects(
319 object_store, refs_container, progress=progress
320 )
322 # Apply grace period check
323 for sha in unreachable:
324 try:
325 if grace_period is not None:
326 try:
327 mtime = object_store.get_object_mtime(sha)
328 age = time.time() - mtime
329 if age < grace_period:
330 if progress:
331 progress(
332 f"Keeping {sha.decode('ascii', 'replace')} (age: {age:.0f}s < grace period: {grace_period}s)"
333 )
334 continue
335 except KeyError:
336 # Object not found, skip it
337 continue
339 unreachable_to_prune.add(sha)
340 obj = object_store[sha]
341 stats.bytes_freed += len(obj.as_raw_string())
342 except KeyError:
343 pass
345 stats.pruned_objects = unreachable_to_prune
347 # Pack refs
348 if progress:
349 progress("Packing references")
350 if not dry_run:
351 repo.refs.pack_refs()
353 # Delete loose unreachable objects
354 if prune and not dry_run:
355 for sha in unreachable_to_prune:
356 if object_store.contains_loose(sha):
357 try:
358 object_store.delete_loose_object(sha)
359 except OSError:
360 pass
362 # Repack everything, excluding unreachable objects
363 # This handles both loose object packing and pack consolidation
364 if progress:
365 progress("Repacking repository")
366 if not dry_run:
367 if prune and unreachable_to_prune:
368 # Repack excluding unreachable objects
369 object_store.repack(exclude=unreachable_to_prune, progress=progress)
370 else:
371 # Normal repack
372 object_store.repack(progress=progress)
374 # Prune orphaned temporary files
375 if progress:
376 progress("Pruning temporary files")
377 if not dry_run:
378 object_store.prune(grace_period=grace_period)
380 # Count final state
381 stats.packs_after = len(list(object_store.packs))
382 stats.loose_objects_after = object_store.count_loose_objects()
384 return stats
387def should_run_gc(repo: "BaseRepo", config: "Config | None" = None) -> bool:
388 """Check if automatic garbage collection should run.
390 Args:
391 repo: Repository to check
392 config: Configuration to use (defaults to repo config)
394 Returns:
395 True if GC should run, False otherwise
396 """
397 # Check environment variable first
398 if os.environ.get("GIT_AUTO_GC") == "0":
399 return False
401 # Check programmatic disable flag
402 if getattr(repo, "_autogc_disabled", False):
403 return False
405 if config is None:
406 config = repo.get_config()
408 # Check if auto GC is disabled
409 try:
410 gc_auto = config.get(b"gc", b"auto")
411 gc_auto_value = int(gc_auto)
412 except KeyError:
413 gc_auto_value = DEFAULT_GC_AUTO
415 if gc_auto_value == 0:
416 # Auto GC is disabled
417 return False
419 # Check loose object count
420 object_store = repo.object_store
421 if not isinstance(object_store, DiskObjectStore):
422 # Can't count loose objects on non-disk stores
423 return False
425 loose_count = object_store.count_loose_objects()
426 if loose_count >= gc_auto_value:
427 return True
429 # Check pack file count
430 try:
431 gc_auto_pack_limit = config.get(b"gc", b"autoPackLimit")
432 pack_limit = int(gc_auto_pack_limit)
433 except KeyError:
434 pack_limit = DEFAULT_GC_AUTO_PACK_LIMIT
436 if pack_limit > 0:
437 pack_count = object_store.count_pack_files()
438 if pack_count >= pack_limit:
439 return True
441 return False
444def maybe_auto_gc(
445 repo: "Repo",
446 config: "Config | None" = None,
447 progress: Callable[[str], None] | None = None,
448) -> bool:
449 """Run automatic garbage collection if needed.
451 Args:
452 repo: Repository to potentially GC
453 config: Configuration to use (defaults to repo config)
454 progress: Optional progress reporting callback
456 Returns:
457 True if GC was run, False otherwise
458 """
459 if not should_run_gc(repo, config):
460 return False
462 # Check for gc.log file - only for disk-based repos
463 if not hasattr(repo, "controldir"):
464 # For non-disk repos, just run GC without gc.log handling
465 garbage_collect(repo, auto=True, progress=progress)
466 return True
468 gc_log_path = os.path.join(repo.controldir(), "gc.log")
469 if os.path.exists(gc_log_path):
470 # Check gc.logExpiry
471 if config is None:
472 config = repo.get_config()
473 try:
474 log_expiry = config.get(b"gc", b"logExpiry")
475 except KeyError:
476 # Default to 1 day
477 expiry_seconds = 86400
478 else:
479 # Parse time value (simplified - just support days for now)
480 if log_expiry.endswith((b".days", b".day")):
481 days = int(log_expiry.split(b".")[0])
482 expiry_seconds = days * 86400
483 else:
484 # Default to 1 day
485 expiry_seconds = 86400
487 stat_info = os.stat(gc_log_path)
488 if time.time() - stat_info.st_mtime < expiry_seconds:
489 # gc.log exists and is not expired - skip GC
490 with open(gc_log_path, "rb") as f:
491 logger.info(
492 "gc.log content: %s", f.read().decode("utf-8", errors="replace")
493 )
494 return False
496 # TODO: Support gc.autoDetach to run in background
497 # For now, run in foreground
499 try:
500 # Run GC with auto=True flag
501 garbage_collect(repo, auto=True, progress=progress)
503 # Remove gc.log on successful completion
504 if os.path.exists(gc_log_path):
505 try:
506 os.unlink(gc_log_path)
507 except FileNotFoundError:
508 pass
510 return True
511 except OSError as e:
512 # Write error to gc.log
513 with open(gc_log_path, "wb") as f:
514 f.write(f"Auto GC failed: {e}\n".encode())
515 # Don't propagate the error - auto GC failures shouldn't break operations
516 return False