feat(update): clean up a partial clone's packs a little on every update

Each `hermes update` now spends at most 60 s on the on-demand packs a
partial clone piles up (#129712), and every finished unit is kept, so a
large checkout converges over a few updates instead of blocking one
update for 20+ minutes:

1. Erase on-demand packs (empty .promisor marker, older than an hour)
   that hold no commits. Their objects are copies of what the promisor
   remote serves; git fetches what it needs again. This frees disk,
   which the old gc fold never did. A pack holding commits is kept
   because a ref may point into it; a pack git cannot classify in time
   counts as holding one.
2. While more than 50 promisor packs remain, merge the smallest few
   with `git pack-objects --stdin-packs` (cost proportional to those
   packs, no reachability walk), marker first and index last so the
   merged pack is a promisor pack from the moment git sees it.

Verdicts and a merge batch size that halves after a timed-out merge are
remembered in .git/hermes-pack-tidy.json, so no update repeats work an
earlier one could not finish. bounded_probe_run gains `input=` for
stdin-fed git commands.
This commit is contained in:
teknium1 authored and Teknium committed 2026-10-05 03:00:01 -07:00
1 parent 762f419fe8
commit a0f47657b9
7 files changed
+346 -7

No files matched your search

+6 -3
View File
@@ -777,10 +777,12 @@ def _legacy_kill_process_tree(proc: "subprocess.Popen") -> None:
def bounded_probe_run(
argv: Sequence[str], *, timeout: float, errors: str = "replace",
env: "Mapping[str, str] | None" = None, cwd: "str | os.PathLike[str] | None" = None,
raise_on_spawn_failure: bool = False,
raise_on_spawn_failure: bool = False, input: "str | None" = None,
) -> "subprocess.CompletedProcess[str] | None":
"""Deadlock-safe ``subprocess.run(argv, capture_output=True, timeout=…)`` for fail-open probes.
``input`` is written to the child's stdin (closed afterwards); without it stdin is ``DEVNULL``.
Returns a ``CompletedProcess`` when the child finished within *timeout* (any exit code), or
``None`` on spawn failure or timeout. With ``raise_on_spawn_failure=True`` the ``Popen``
exception propagates instead, so callers that treat a *timeout* as a verdict can still tell
@@ -803,7 +805,8 @@ def bounded_probe_run(
from hermes_cli.local_runtime.processes import spawn_server
proc, job = spawn_server(
list(argv), stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.DEVNULL,
list(argv), stdout=subprocess.PIPE, stderr=subprocess.PIPE,
stdin=subprocess.DEVNULL if input is None else subprocess.PIPE,
text=True, encoding="utf-8", errors=errors,
env=dict(env) if env is not None else None, cwd=cwd, **_popen_kwargs)
except Exception:
@@ -811,7 +814,7 @@ def bounded_probe_run(
raise
return None
try:
stdout, stderr = proc.communicate(timeout=timeout)
stdout, stderr = proc.communicate(input=input, timeout=timeout)
except Exception:
# Timeout OR any other communicate() failure (torn-down pipe, decode error): tree-kill and
# drain bounded — leaving it running would leak the suspended-descendant class this guards.
+234
View File
@@ -0,0 +1,234 @@
"""Progressive, time-boxed cleanup of a partial clone's on-demand packfiles (#129712).
A partial clone (the installer's ``--filter=tree:0`` checkout) downloads trees and blobs when git
first needs them, and every such download lands as its own small 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 on-demand packs that hold no commits. Their objects are copies of what the promisor
remote serves, so deleting them frees the disk and git fetches anything it needs again. A pack
holding commits is kept, because a ref may point into it.
2. While more than ``PACK_COUNT_TARGET`` promisor packs remain, merge the smallest few into one
with ``git pack-objects --stdin-packs`` (cost proportional to those packs, no reachability walk).
Each erased pack and each merge is atomic, so an update that runs out of budget leaves the rest for
the next one.
"""
from __future__ import annotations
import json
import logging
import os
import shutil
import struct
import time
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, List
from hermes_cli._subprocess_compat import (
NO_LAZY_FETCH_ENV,
bounded_probe_run,
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.
_MIN_PACK_AGE_SECONDS = 60 * 60
_MERGE_BATCH_BYTES = 256 * 1024 * 1024
# Kept in .git/: per-pack "holds commits" verdicts (pack names are content hashes, so a verdict never
# goes stale) 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 could not finish.
_STATE_FILE = "hermes-pack-tidy.json"
_PACK_SUFFIXES = (".idx", ".pack", ".rev", ".bitmap", ".mtimes", ".promisor", ".keep")
@dataclass
class TidyResult:
erased: int = 0
freed_bytes: int = 0
merged: int = 0
packs_left: int = 0
out_of_time: bool = False
def _git_env() -> Dict[str, str]:
return {**noninteractive_git_env(), **NO_LAZY_FETCH_ENV}
def _oids_in_pack_order(idx: Path) -> List[str]:
"""Object ids of a version-2 SHA-1 pack index (every pack a clone of GitHub writes), in the order
they sit in the pack. git writes commits first, so a pack that holds any shows one up front."""
data = idx.read_bytes()
if data[:8] != b"\xfftOc\x00\x00\x00\x02":
raise ValueError(f"{idx.name}: not a v2 pack index")
count = struct.unpack(">I", data[8 + 255 * 4: 8 + 256 * 4])[0]
names = 8 + 256 * 4
offsets = struct.unpack(f">{count}I", data[names + count * 24: names + count * 28]) # after names + CRCs
order = sorted(range(count), key=lambda i: offsets[i]) # MSB-set (past 2 GiB) entries sort last
return [data[names + i * 20: names + (i + 1) * 20].hex() for i in order]
def _holds_commits(repo_root: Path, pack: Path, timeout: float) -> bool:
"""Whether the pack holds a commit. A pack git cannot classify in time counts as holding one:
keeping a pack only costs disk, erasing one a ref points into costs history."""
oids = _oids_in_pack_order(pack.with_suffix(".idx"))
deadline = time.monotonic() + timeout
for chunk in (oids[:256], oids[256:]):
left = deadline - time.monotonic()
if not chunk:
continue
result = bounded_probe_run(
["git", "cat-file", "--batch-check=%(objecttype)"], timeout=max(left, 0.1), cwd=str(repo_root),
env=_git_env(), input="\n".join(chunk) + "\n")
if left <= 0 or result is None or result.returncode != 0:
return True
if "commit" in result.stdout.split():
return True
return False
def _remove_pack(pack: Path) -> int:
"""Delete one pack's files, index first so git stops seeing it; returns the bytes freed."""
freed = 0
for suffix in _PACK_SUFFIXES:
part = pack.with_suffix(suffix)
try:
size = part.stat().st_size
if os.name == "nt":
os.chmod(part, 0o666) # git writes packs read-only; Windows refuses to unlink those
part.unlink()
freed += size
except FileNotFoundError:
continue
return freed
def _load_state(pack_dir: Path) -> dict:
try:
state = json.loads((pack_dir.parent.parent / _STATE_FILE).read_text(encoding="utf-8-sig"))
return {"commits": dict(state.get("commits", {})),
"merge_bytes": int(state.get("merge_bytes", _MERGE_BATCH_BYTES))}
except (OSError, ValueError, TypeError, AttributeError):
return {"commits": {}, "merge_bytes": _MERGE_BATCH_BYTES}
def _save_state(pack_dir: Path, state: dict) -> None:
live = {p.stem for p in pack_dir.glob("pack-*.pack")}
state = {**state, "commits": {k: v for k, v in state["commits"].items() if k in live}}
try:
(pack_dir.parent.parent / _STATE_FILE).write_text(json.dumps(state), encoding="utf-8")
except OSError:
logger.debug("could not record pack tidy state in %s", pack_dir, exc_info=True)
def _erase_on_demand_packs(repo_root: Path, pack_dir: Path, deadline: float, result: TidyResult,
verdicts: Dict[str, bool]) -> None:
cutoff = time.time() - _MIN_PACK_AGE_SECONDS
candidates = []
for marker in pack_dir.glob("pack-*.promisor"):
pack = marker.with_suffix(".pack")
try:
# An on-demand fetch writes an empty marker; a ref fetch lists the refs it fetched.
if marker.stat().st_size == 0 and pack.stat().st_mtime < cutoff and not pack.with_suffix(".keep").exists():
candidates.append((pack.stat().st_mtime, pack))
except OSError:
continue
for _mtime, pack in sorted(candidates):
left = deadline - time.monotonic()
if left <= 0:
result.out_of_time = True
return
if pack.stem not in verdicts:
try:
verdicts[pack.stem] = _holds_commits(repo_root, pack, left)
except (OSError, ValueError):
verdicts[pack.stem] = True
if verdicts[pack.stem]:
continue
try:
result.freed_bytes += _remove_pack(pack)
result.erased += 1
except OSError as exc:
logger.warning("Could not erase on-demand pack %s (skipping): %s", pack.name, exc)
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
while True:
packs = sorted((p.stat().st_size, p) for p in pack_dir.glob("pack-*.pack")
if p.with_suffix(".promisor").exists() and not p.with_suffix(".keep").exists())
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:
done = bounded_probe_run(
["git", "pack-objects", "-q", "--stdin-packs", str(staging / "pack")],
timeout=left, cwd=str(repo_root), env=_git_env(),
input="".join(p.name + "\n" for p in batch))
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
if done.returncode != 0:
logger.warning("Merging %d packs failed: %s", len(batch), done.stderr.strip()[-300:])
return
new = next(staging.glob("pack-*.idx")).with_suffix("")
# Marker first, index last: the merged pack is a promisor pack from the moment git sees it.
(pack_dir / (new.name + ".promisor")).write_bytes(
b"".join(p.with_suffix(".promisor").read_bytes() for p in batch))
for suffix in (".pack", ".rev", ".idx"):
if new.with_suffix(suffix).exists():
os.replace(new.with_suffix(suffix), pack_dir / (new.name + suffix))
for pack in batch:
if pack.stem != new.name:
_remove_pack(pack)
result.merged += len(batch)
finally:
shutil.rmtree(staging, ignore_errors=True)
def tidy_partial_clone_packs(repo_root: Path, *, budget_seconds: float = TIDY_BUDGET_SECONDS) -> TidyResult:
"""Spend at most ``budget_seconds`` erasing and merging on-demand 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
state = _load_state(pack_dir)
try:
_erase_on_demand_packs(repo_root, pack_dir, deadline, result, state["commits"])
if not result.out_of_time:
_merge_smallest_packs(repo_root, pack_dir, deadline, result, state)
finally:
_save_state(pack_dir, state)
if result.erased or result.merged:
# A multi-pack-index names the packs it covers; git rebuilds one on its own maintenance.
for midx in pack_dir.glob("multi-pack-index*"):
midx.unlink(missing_ok=True)
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
+1
View File
@@ -1544,6 +1544,7 @@ def _cmd_update_impl(args, gateway_mode: bool):
# A partial clone must never write a commit-graph (#127711); keep its keys in place.
from hermes_cli.gitlock import settle_partial_clone_maintenance
settle_partial_clone_maintenance(_m().PROJECT_ROOT)
_check.report_pack_tidy(_m().PROJECT_ROOT)
# Shallow installer checkouts collect one `.git/shallow` graft per past depth-1 fetch
# (#105951); stale grafts break merge-base and push this run into the divergence path.
from hermes_cli.gitlock import repair_broken_shallow_boundaries, prune_stale_shallow_grafts
+12
View File
@@ -48,6 +48,18 @@ def clear_git_debris(root: Path) -> None:
settle_partial_clone_maintenance(root)
def report_pack_tidy(root: Path) -> None:
"""Spend the update's bounded slice on a partial clone's on-demand packs, and say what it did."""
from hermes_cli.git_pack_tidy import TIDY_BUDGET_SECONDS, tidy_partial_clone_packs
tidy = tidy_partial_clone_packs(root)
if tidy.erased or tidy.merged:
print(f" (git cleanup: erased {tidy.erased} on-demand pack(s), {tidy.freed_bytes / 1e6:.0f} MB freed;"
f" merged {tidy.merged}; {tidy.packs_left} left)")
if tidy.out_of_time:
print(f" (git cleanup stopped at its {TIDY_BUDGET_SECONDS}s limit; the next update continues it)")
def channel_compare_branch(selected_channel: str, git_cmd: list[str], root: Path) -> str | None:
"""Report a release-pinned channel's verdict, or return the branch to compare against.
+84
View File
@@ -0,0 +1,84 @@
"""Progressive partial-clone pack cleanup (#129712), on real git: a blobless clone of a local upstream."""
from __future__ import annotations
import os
import subprocess
import time
from pathlib import Path
import pytest
import hermes_cli.git_pack_tidy as tidy
_GIT_ENV = {**os.environ, "GIT_CONFIG_GLOBAL": os.devnull, "GIT_CONFIG_NOSYSTEM": "1"}
_OFFLINE = {**_GIT_ENV, "GIT_NO_LAZY_FETCH": "1"}
def _git(*args: str, cwd: Path, env: dict = _GIT_ENV) -> str:
return subprocess.run(["git", "-c", "user.email=t@t", "-c", "user.name=t", *args], cwd=cwd, env=env,
check=True, capture_output=True, text=True).stdout.strip()
def _packs(repo: Path) -> list[Path]:
return sorted((repo / ".git" / "objects" / "pack").glob("pack-*.pack"))
@pytest.fixture
def clone(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> tuple[Path, str]:
"""A tree:0 clone with one on-demand pack per file it read, plus one holding a fetched-by-id commit."""
monkeypatch.setenv("GIT_CONFIG_GLOBAL", os.devnull)
monkeypatch.setenv("GIT_CONFIG_NOSYSTEM", "1")
seed, up, repo = tmp_path / "seed", tmp_path / "up.git", tmp_path / "clone"
_git("init", "-q", "-b", "main", str(seed), cwd=tmp_path)
for i in range(6):
(seed / f"f{i}.txt").write_text(f"v{i}\n", encoding="utf-8")
_git("add", "-A", cwd=seed)
_git("commit", "-qm", f"c{i}", cwd=seed)
_git("switch", "-q", "-c", "side", cwd=seed)
_git("commit", "-q", "--allow-empty", "-m", "side", cwd=seed)
side = _git("rev-parse", "side", cwd=seed)
_git("clone", "-q", "--bare", str(seed), str(up), cwd=tmp_path)
for key in ("uploadpack.allowFilter", "uploadpack.allowAnySHA1InWant"):
_git("config", key, "true", cwd=up)
_git("clone", "-q", "--filter=tree:0", "--no-checkout", "--single-branch", "-b", "main", up.as_uri(), str(repo),
cwd=tmp_path)
_git("config", "maintenance.auto", "false", cwd=repo)
for i in range(6):
_git("cat-file", "-p", _git("rev-parse", f"HEAD:f{i}.txt", cwd=repo), cwd=repo)
_git("cat-file", "-t", side, cwd=repo) # an on-demand fetch that brings a commit
_git("update-ref", "refs/heads/keep-side", side, cwd=repo)
old = time.time() - 2 * 3600
for entry in (repo / ".git" / "objects" / "pack").iterdir():
os.utime(entry, (old, old))
return repo, side
def test_erases_commit_free_on_demand_packs_and_keeps_what_refs_need(clone: tuple[Path, str]) -> None:
repo, side = clone
before = _packs(repo)
result = tidy.tidy_partial_clone_packs(repo)
after = _packs(repo)
assert result.erased > 0 and len(after) == len(before) - result.erased and result.freed_bytes > 0
assert _git("cat-file", "-t", side, cwd=repo, env=_OFFLINE) == "commit", "a ref points into that pack"
assert _git("rev-list", "--count", "HEAD", cwd=repo, env=_OFFLINE) == "6"
# Erased objects are copies of what the promisor serves: git fetches them again on demand.
assert _git("cat-file", "-p", "HEAD:f3.txt", cwd=repo) == "v3"
assert tidy.tidy_partial_clone_packs(repo).erased == 0
def test_merges_down_to_the_target_without_losing_objects(clone: tuple[Path, str],
monkeypatch: pytest.MonkeyPatch) -> None:
repo, side = clone
monkeypatch.setattr(tidy, "_MIN_PACK_AGE_SECONDS", 10 ** 9) # nothing is old enough to erase
monkeypatch.setattr(tidy, "PACK_COUNT_TARGET", 1)
assert len(_packs(repo)) > 2
result = tidy.tidy_partial_clone_packs(repo)
assert result.erased == 0 and result.packs_left == 1
assert _packs(repo)[0].with_suffix(".promisor").exists()
assert _git("cat-file", "-t", side, cwd=repo, env=_OFFLINE) == "commit"
assert _git("cat-file", "-p", "HEAD:f3.txt", cwd=repo, env=_OFFLINE) == "v3"
@@ -63,7 +63,7 @@ def _make_fake_popen(spawns, *, stdout="ok\n", returncode=0):
spawns.append((cmd, kwargs))
self.returncode = returncode
def communicate(self, timeout=None):
def communicate(self, input=None, timeout=None):
return (stdout, "")
def kill(self): # pragma: no cover - never reached on the fast path
+8 -3
View File
@@ -179,9 +179,14 @@ fetch fails, the update prints a warning, carries on, and retries the conversion
set `maintenance.commit-graph.enabled`, `gc.writeCommitGraph` and `fetch.writeCommitGraph` to
`false` in that checkout, because a commit-graph write over commits the graph has not seen yet
downloads every one of their trees. Leave those settings alone, and leave `gc.auto` at its
default so git's own automatic gc can still fold packs. The update does not fold them itself:
on a large checkout that fold is a full repack that can run for many minutes. To fold by hand
(with Hermes closed):
default so git's own automatic gc can still fold packs.
Each `hermes update` also spends at most 60 seconds cleaning those packs up, picking up where the
previous update stopped. It first deletes on-demand packs that hold no commits (git downloads
anything it needs from them again), then merges the smallest remaining packs while there are more
than 50. A pack that holds commits is never deleted, because a branch may point into it. To fold
everything at once by hand instead (with Hermes closed; on a large checkout this is a full repack
that can run for many minutes):
```bash
git -C "$repo" -c gc.writeCommitGraph=false gc --auto