Keep downloads bound to their remote representation and publish through atomic destination-local staging. Serialize shared partial ownership. Keep explicit CA trust scoped to provider probes. Preserve checkpoint history and edited files, validate all profile inputs before dependency publication, and separate data removal from installed runtime ownership. Exclude machine-specific PM state from portable transfers. Keep plugin files and nested skill tools intact. Preserve native test isolation. Focused native Windows receipts cover the individual repairs and their integration. This commit does not claim a full-suite or release build.
113 lines
4.2 KiB
Python
113 lines
4.2 KiB
Python
"""Checked history rewrites shared by snapshot limits and store maintenance."""
|
|
from __future__ import annotations
|
|
|
|
from contextlib import contextmanager
|
|
from dataclasses import dataclass
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Callable
|
|
|
|
|
|
class PruneError(RuntimeError):
|
|
"""Pruning stopped without permission to discard another snapshot."""
|
|
|
|
|
|
def store_lock_path(base: Path) -> Path:
|
|
base = base.resolve()
|
|
return base.with_name(f".{base.name}.lock")
|
|
|
|
|
|
@contextmanager
|
|
def store_lock(base: Path):
|
|
"""Serialize whole operations, including GC and clear, across processes."""
|
|
from hermes_cli.runtime_state import _lock
|
|
|
|
base.parent.mkdir(parents=True, exist_ok=True)
|
|
# Outside base so clear_all and legacy migration cannot replace its inode.
|
|
fd = os.open(store_lock_path(base), os.O_CREAT | os.O_RDWR, 0o600)
|
|
try:
|
|
if not _lock(fd, wait=False):
|
|
raise PruneError(f"checkpoint store is busy: {base}")
|
|
yield
|
|
finally:
|
|
os.close(fd)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Pruner:
|
|
run_git: Callable
|
|
store: Path
|
|
cwd: str
|
|
timeout: int
|
|
dir_size: Callable[[Path], int]
|
|
refs_prefix: str
|
|
|
|
def _git(self, args: list[str], **kwargs) -> str:
|
|
ok, output, error = self.run_git(args, self.store, self.cwd, **kwargs)
|
|
if not ok:
|
|
raise PruneError(f"checkpoint {args[0]} failed: {error}")
|
|
return output
|
|
|
|
def reclaim(self) -> None:
|
|
self._git(["reflog", "expire", "--expire=now", "--all"])
|
|
self._git(["gc", "--prune=now", "--quiet"], timeout=self.timeout * 3)
|
|
# Bare stores must remain usable when gc packs all refs.
|
|
for name in ("refs/heads", "branches"):
|
|
(self.store / name).mkdir(parents=True, exist_ok=True)
|
|
|
|
def _commits(self, ref: str) -> list[str]:
|
|
commits = self._git(["rev-list", "--reverse", ref]).splitlines()
|
|
if not commits:
|
|
raise PruneError(f"checkpoint ref has no readable history: {ref}")
|
|
return commits
|
|
|
|
def _rewrite(self, ref: str, original: list[str], keep: list[str]) -> None:
|
|
parent = None
|
|
for sha in keep:
|
|
metadata = self._git(["log", "-1", "--format=%T%x00%aI%x00%cI%x00%s", sha])
|
|
fields = metadata.split("\x00", 3)
|
|
if len(fields) != 4:
|
|
raise PruneError(f"invalid checkpoint metadata: {sha}")
|
|
tree, author_date, committer_date, message = fields
|
|
args = ["commit-tree", tree, "--no-gpg-sign", "-m", message]
|
|
if parent is not None:
|
|
args += ["-p", parent]
|
|
parent = self._git(args, extra_env={"GIT_AUTHOR_DATE": author_date, "GIT_COMMITTER_DATE": committer_date})
|
|
if not parent:
|
|
raise PruneError("commit-tree returned no checkpoint identity")
|
|
if parent is None:
|
|
raise PruneError("cannot remove a project's last snapshot")
|
|
# Never overwrite a snapshot published since we read this history.
|
|
self._git(["update-ref", ref, parent, original[-1]])
|
|
self.reclaim()
|
|
|
|
def trim(self, ref: str, keep_count: int) -> None:
|
|
commits = self._commits(ref)
|
|
keep_count = max(1, keep_count)
|
|
if len(commits) > keep_count:
|
|
self._rewrite(ref, commits, commits[-keep_count:])
|
|
|
|
def enforce_size(self, cap_bytes: int) -> bool:
|
|
"""Return whether the cap fits; a failed Git step raises immediately."""
|
|
if cap_bytes <= 0 or self.dir_size(self.store) <= cap_bytes:
|
|
return True
|
|
self.reclaim()
|
|
if self.dir_size(self.store) <= cap_bytes:
|
|
return True
|
|
refs = self._git(["for-each-ref", "--format=%(refname)", self.refs_prefix]).splitlines()
|
|
# Keep the existing round-robin policy, remeasure after EACH rewrite.
|
|
# Twenty rounds bound maintenance when many large histories remain.
|
|
for _ in range(20):
|
|
changed = False
|
|
for ref in refs:
|
|
commits = self._commits(ref)
|
|
if len(commits) <= 1:
|
|
continue
|
|
self._rewrite(ref, commits, commits[1:])
|
|
changed = True
|
|
if self.dir_size(self.store) <= cap_bytes:
|
|
return True
|
|
if not changed:
|
|
break
|
|
return False
|