Files
hermes-agent/hermes_cli/git_pack_tidy.py
2026-10-08 17:22:54 +02:00

387 lines
17 KiB
Python

"""Progressive, time-boxed cleanup of a partial clone's promisor packfiles (#129712).
A partial clone downloads objects when git first needs them, and git asks for them without telling
the server what it already has, so the same trees (and, for history downloads, commits) land again
and again, each download as its own packfile that nothing removes: ``.git`` reached 39 GiB on a
~1 GiB repository. A full ``git gc`` over that is all-or-nothing and on a big checkout runs for tens
of minutes, so ``hermes update`` instead spends at most ``TIDY_BUDGET_SECONDS`` per run and every
unit of work it finishes is kept:
1. Erase promisor packs whose every object is also in another promisor pack. Nothing is lost, and
nothing depends on the remote still serving it: the rule is proven against the local packs alone.
2. While more than ``PACK_COUNT_TARGET`` promisor packs remain, merge the smallest few into one
with ``git pack-objects --stdin-packs``; the merged pack holds each object once.
Each erase and each merge is atomic for git (a pack exists for git only while both its ``.idx`` and
``.pack`` do), so an update that runs out of budget, fails or is killed leaves the rest for the next.
"""
from __future__ import annotations
import contextlib
import json
import logging
import mmap
import os
import shutil
import struct
import subprocess
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, Iterable, Iterator, List, Optional
from hermes_cli._subprocess_compat import (
NO_LAZY_FETCH_ENV,
noninteractive_git_env,
windows_hide_flags,
)
logger = logging.getLogger(__name__)
TIDY_BUDGET_SECONDS = 60
PACK_COUNT_TARGET = 50
# A pack this young may still be in use by the git that just fetched it; index-pack also writes a
# new pack's .promisor/.keep before its .pack, so a young marker without a payload may be a live fetch.
_MIN_PACK_AGE_SECONDS = 60 * 60
_MERGE_BATCH_BYTES = 256 * 1024 * 1024
# Kept in .git/: packs proven to hold an object no other pack has (valid until a new pack appears,
# since removing packs never adds copies) and the merge batch size, halved after a merge that ran out
# of time. Remembering both is what guarantees progress: no update repeats work an earlier one finished.
_STATE_FILE = "hermes-pack-tidy.json"
_STATE_VERSION = 2 # v1 could record a pack as unique after comparing it with only some packs
# Two tidies at once could each erase a pack because the other still has its copies.
_LOCK_FILE = "hermes-pack-tidy.lock"
# Payload first: git loads a pack only when both .idx and .pack exist, so once .pack is gone the pack is
# gone for git, and a failure on .pack itself (a reader still maps it on Windows) changes nothing.
_PACK_SUFFIXES = (".pack", ".idx", ".rev", ".bitmap", ".mtimes", ".promisor", ".keep")
_IDX_V2 = b"\xfftOc\x00\x00\x00\x02"
_FANOUT = 8
_NAMES = _FANOUT + 256 * 4
@dataclass
class TidyResult:
erased: int = 0
freed_bytes: int = 0
merged: int = 0
packs_left: int = 0
out_of_time: bool = False
class _Index:
"""A version-2 SHA-1 pack index (what a GitHub clone writes), searched in place."""
def __init__(self, idx: Path) -> None:
with open(idx, "rb") as fh:
self._map = mmap.mmap(fh.fileno(), 0, access=mmap.ACCESS_READ)
if self._map[:8] != _IDX_V2:
self.close()
raise ValueError(f"{idx.name}: not a v2 SHA-1 pack index")
self.count = struct.unpack_from(">I", self._map, _FANOUT + 255 * 4)[0]
def oids(self) -> Iterator[bytes]:
for i in range(self.count): # lazily: a big index must not run past the deadline before its first check
yield self._map[_NAMES + i * 20: _NAMES + i * 20 + 20]
def __contains__(self, oid: bytes) -> bool:
lo = struct.unpack_from(">I", self._map, _FANOUT + (oid[0] - 1) * 4)[0] if oid[0] else 0
hi = struct.unpack_from(">I", self._map, _FANOUT + oid[0] * 4)[0]
while lo < hi:
mid = (lo + hi) // 2
name = self._map[_NAMES + mid * 20: _NAMES + mid * 20 + 20]
if name == oid:
return True
if name < oid:
lo = mid + 1
else:
hi = mid
return False
def close(self) -> None:
self._map.close() # Windows refuses to unlink a mapped file
def _git_env() -> dict[str, str]:
return {**noninteractive_git_env(), **NO_LAZY_FETCH_ENV}
def _promisor_packs(pack_dir: Path) -> list[Path]:
return [p for p in pack_dir.glob("pack-*.pack")
if p.with_suffix(".promisor").exists() and p.with_suffix(".idx").exists()]
def _pinned(pack: Path, cutoff: float) -> bool:
"""Whether a ``.keep`` pins the pack. fetch-pack and index-pack write ``<cmd> <pid> on <host>`` and
delete it once the fetch has updated its refs, so an old one of theirs belongs to a fetch that was
killed; it pins nothing, and git's own repack never touches the pack while it exists."""
keep = pack.with_suffix(".keep")
try:
return not (keep.read_text(encoding="utf-8-sig", errors="replace").startswith(("fetch-pack ", "index-pack "))
and keep.stat().st_mtime < cutoff)
except FileNotFoundError:
return False
except OSError:
return True
def _unlink(part: Path) -> None:
if os.name == "nt":
os.chmod(part, 0o666) # git writes packs read-only; Windows refuses to unlink those
part.unlink()
def _drop_midx(pack_dir: Path) -> None:
"""A multi-pack-index names the packs it covers; dropping it before any pack goes means no crash
can leave one naming a deleted pack. git rebuilds it on its own maintenance.
The incremental layout (``multi-pack-index.d/``) is read through its chain file, so once the chain
is gone git ignores the layers and removing them is only tidying."""
(pack_dir / "multi-pack-index").unlink(missing_ok=True)
layers = pack_dir / "multi-pack-index.d"
(layers / "multi-pack-index-chain").unlink(missing_ok=True)
for layer in layers.glob("*"):
with contextlib.suppress(OSError): # a layer a reader still maps goes on a later run
_unlink(layer)
with contextlib.suppress(OSError):
layers.rmdir()
def _remove_pack(pack: Path) -> int:
"""Delete one pack's files, payload first; returns the bytes freed.
Raises on the first part that will not go. Leftovers after the payload are invisible to git and
:func:`_sweep_remnants` retries them on a later run."""
_drop_midx(pack.parent)
freed = 0
for suffix in _PACK_SUFFIXES:
part = pack.with_suffix(suffix)
try:
size = part.stat().st_size
_unlink(part)
freed += size
except FileNotFoundError:
continue
return freed
def _sweep_remnants(pack_dir: Path) -> None:
"""Finish deletions an earlier run could not complete: pack files whose payload is already gone.
Aborted-transfer ``tmp_*`` files are gitlock.clear_stale_tmp_packs's job, earlier in the update."""
cutoff = time.time() - _MIN_PACK_AGE_SECONDS
for part in pack_dir.glob("pack-*.*"):
try:
if part.suffix in _PACK_SUFFIXES[1:] and not part.with_suffix(".pack").exists() \
and part.stat().st_mtime < cutoff:
_remove_pack(part)
except OSError:
logger.debug("pack remnant %s still in use", part.name, exc_info=True)
def _load_state(pack_dir: Path) -> dict:
try:
state = json.loads((pack_dir.parent.parent / _STATE_FILE).read_text(encoding="utf-8-sig"))
if state.get("v") != _STATE_VERSION:
raise ValueError("pack tidy state from another version")
kept, seen = set(state.get("kept", [])), set(state.get("seen", []))
merge_bytes = int(state.get("merge_bytes", _MERGE_BATCH_BYTES))
except (OSError, ValueError, TypeError, AttributeError):
kept, seen, merge_bytes = set(), set(), _MERGE_BATCH_BYTES
if {p.stem for p in pack_dir.glob("pack-*.pack")} - seen:
kept = set() # a new pack may hold the other copy of what a kept pack holds
return {"kept": kept, "merge_bytes": merge_bytes}
def _save_state(pack_dir: Path, state: dict) -> None:
live = sorted(p.stem for p in pack_dir.glob("pack-*.pack"))
data = {"v": _STATE_VERSION, "kept": sorted(state["kept"] & set(live)), "seen": live,
"merge_bytes": state["merge_bytes"]}
try:
(pack_dir.parent.parent / _STATE_FILE).write_text(json.dumps(data), encoding="utf-8")
except OSError:
logger.debug("could not record pack tidy state in %s", pack_dir, exc_info=True)
def _is_redundant(oids: Iterable[bytes], others: list[_Index], deadline: float) -> Optional[bool]:
"""Whether every oid is in one of ``others``; None when the deadline passed first."""
hit = 0
for n, oid in enumerate(oids):
if n % 1024 == 0 and time.monotonic() > deadline:
return None
if oid in others[hit]:
continue
for j, other in enumerate(others): # objects cluster: try the pack that had the last one first
if j != hit and oid in other:
hit = j
break
else:
return False
return True
def _erase_redundant_packs(pack_dir: Path, deadline: float, result: TidyResult, state: dict) -> None:
cutoff = time.time() - _MIN_PACK_AGE_SECONDS
sizes: dict[Path, int] = {}
candidates: list[Path] = []
for pack in _promisor_packs(pack_dir):
try:
st = pack.stat()
except OSError:
continue
sizes[pack] = st.st_size
if pack.stem not in state["kept"] and st.st_mtime < cutoff and not _pinned(pack, cutoff):
candidates.append(pack)
if not candidates:
return
indexes: dict[str, _Index] = {}
try:
for pack in sizes:
try:
indexes[pack.stem] = _Index(pack.with_suffix(".idx"))
except (OSError, ValueError): # unreadable now: neither a candidate nor a copy, retried next run
logger.debug("pack index %s unreadable", pack.name, exc_info=True)
every_index_read = len(indexes) == len(sizes)
by_size = sorted(sizes, key=lambda p: sizes[p], reverse=True) # big packs hold most copies
for pack in sorted(candidates, key=lambda p: sizes[p]): # small first: cheapest to prove, likeliest copies
if pack.stem not in indexes:
continue
others = [indexes[p.stem] for p in by_size if p.stem != pack.stem and p.stem in indexes]
verdict = _is_redundant(indexes[pack.stem].oids(), others, deadline) if others else False
if verdict is None:
result.out_of_time = True
return
if not verdict:
if every_index_read: # unique among ALL packs; with one unread it is only unknown
state["kept"].add(pack.stem)
continue
indexes.pop(pack.stem).close()
try:
result.freed_bytes += _remove_pack(pack)
result.erased += 1
except OSError as exc:
logger.warning("Could not erase redundant pack %s (a later update retries): %s", pack.name, exc)
finally:
for index in indexes.values():
index.close()
def _merge_git(repo_root: Path, staging: Path, batch: list[Path], timeout: float):
"""``git pack-objects --stdin-packs`` over *batch* into *staging*, in the update's custody (a
mutator: it holds the checkout lock fd, and inside an update it is job-bound on Windows).
``None`` when it ran out of time or could not start; a custody refusal propagates, so an update
stops the way it does for any updater git Windows will not bind."""
from hermes_cli.update_custody import CustodyRefused, run_git
try:
return run_git(
["git"], ["-c", "pack.packSizeLimit=0", "pack-objects", "-q", "--stdin-packs", str(staging / "pack")],
cwd=str(repo_root), env=_git_env(), input="".join(p.name + "\n" for p in batch),
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout,
creationflags=windows_hide_flags())
except CustodyRefused:
raise
except (OSError, subprocess.SubprocessError):
return None
def _merge_smallest_packs(repo_root: Path, pack_dir: Path, deadline: float, result: TidyResult,
state: dict) -> None:
"""Merge the smallest promisor packs, a batch at a time, until the count is under target."""
staging = pack_dir.parent.parent / "hermes-tidy-staging" # outside objects/: git counts strays there as garbage
cutoff = time.time() - _MIN_PACK_AGE_SECONDS
while True:
packs = sorted((p.stat().st_size, p) for p in _promisor_packs(pack_dir) if not _pinned(p, cutoff))
if len(packs) <= PACK_COUNT_TARGET:
return
batch, total = [], 0
for size, pack in packs:
if len(batch) >= 2 and total + size > state["merge_bytes"]:
break
batch.append(pack)
total += size
left = deadline - time.monotonic()
if left <= 0:
result.out_of_time = True
return
shutil.rmtree(staging, ignore_errors=True)
staging.mkdir()
try:
# packSizeLimit=0 whatever the user's config says: inputs retire only after everything
# they held is published, and that is one output pack.
done = _merge_git(repo_root, staging, batch, left)
if done is None:
result.out_of_time = True
state["merge_bytes"] = max(state["merge_bytes"] // 2, 1) # next update tries a smaller batch
return
outputs = list(staging.glob("pack-*.idx"))
if done.returncode != 0 or len(outputs) != 1:
logger.warning("Merging %d packs failed (%d outputs): %s", len(batch), len(outputs),
done.stderr.strip()[-300:])
return
new = outputs[0].with_suffix("")
# Payload last: git ignores an index whose .pack is absent, so a kill before the final move
# leaves only metadata, which _sweep_remnants removes (a lone .pack would be kept forever).
(pack_dir / (new.name + ".promisor")).write_bytes(
b"".join(p.with_suffix(".promisor").read_bytes() for p in batch))
if new.with_suffix(".rev").exists():
os.replace(new.with_suffix(".rev"), pack_dir / (new.name + ".rev"))
os.replace(new.with_suffix(".idx"), pack_dir / (new.name + ".idx"))
os.replace(new.with_suffix(".pack"), pack_dir / (new.name + ".pack"))
for pack in batch:
if pack.stem != new.name:
try:
_remove_pack(pack)
except OSError as exc: # its objects are in the merged pack; a later run retries
logger.warning("Could not retire merged pack %s: %s", pack.name, exc)
result.merged += len(batch)
finally:
shutil.rmtree(staging, ignore_errors=True)
def _take_lock(git_dir: Path) -> Optional[int]:
"""A kernel lock on the tidy lock file, or None while another tidy holds it. The OS drops it
when the holder exits, killed or not, so there is no stale lock to judge or take over; the file
itself is never removed (a removed path lets a later run lock a fresh file beside a live one)."""
from pm.filesystem import lock_fd
fd = os.open(git_dir / _LOCK_FILE, os.O_CREAT | os.O_RDWR, 0o600)
try:
if lock_fd(fd, wait=False):
return fd
except OSError: # a filesystem that cannot lock: skip the tidy rather than run unguarded
logger.debug("pack tidy lock unavailable in %s", git_dir, exc_info=True)
os.close(fd)
return None
def tidy_partial_clone_packs(repo_root: Path, *, budget_seconds: float = TIDY_BUDGET_SECONDS) -> TidyResult:
"""Spend at most ``budget_seconds`` erasing redundant and merging small promisor packs. Never raises."""
from hermes_cli.gitlock import _partial_clone_filter
result = TidyResult()
pack_dir = Path(repo_root) / ".git" / "objects" / "pack"
try:
if not pack_dir.is_dir() or _partial_clone_filter(repo_root, creationflags=windows_hide_flags()) is None:
return result
deadline = time.monotonic() + budget_seconds
lock = _take_lock(pack_dir.parent.parent)
if lock is None:
return result
try:
state = _load_state(pack_dir)
try:
_sweep_remnants(pack_dir)
_erase_redundant_packs(pack_dir, deadline, result, state)
if not result.out_of_time:
_merge_smallest_packs(repo_root, pack_dir, deadline, result, state)
finally:
_save_state(pack_dir, state)
finally:
os.close(lock)
result.packs_left = len(list(pack_dir.glob("pack-*.pack")))
except Exception:
logger.warning("partial-clone pack tidy failed in %s", repo_root, exc_info=True)
return result