mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
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:
1 parent
762f419fe8
commit
a0f47657b9
7 files changed
+346
-7
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user