diff --git a/cli.py b/cli.py index 30b33001c7..fcc08ce378 100644 --- a/cli.py +++ b/cli.py @@ -987,6 +987,7 @@ def _run_checkpoint_auto_maintenance() -> None: retention_days=int(cfg.get("retention_days", 7)), min_interval_hours=int(cfg.get("min_interval_hours", 24)), delete_orphans=bool(cfg.get("delete_orphans", True)), + max_total_size_mb=int(cfg.get("max_total_size_mb", 500)), ) except Exception as exc: logger.debug("checkpoint auto-maintenance skipped: %s", exc) @@ -2273,7 +2274,9 @@ class HermesCLI: if isinstance(cp_cfg, bool): cp_cfg = {"enabled": cp_cfg} self.checkpoints_enabled = checkpoints or cp_cfg.get("enabled", False) - self.checkpoint_max_snapshots = cp_cfg.get("max_snapshots", 50) + self.checkpoint_max_snapshots = cp_cfg.get("max_snapshots", 20) + self.checkpoint_max_total_size_mb = cp_cfg.get("max_total_size_mb", 500) + self.checkpoint_max_file_size_mb = cp_cfg.get("max_file_size_mb", 10) self.pass_session_id = pass_session_id # --ignore-rules: honor either the constructor flag or the env var set # by `hermes chat --ignore-rules` in hermes_cli/main.py. When true we @@ -3845,6 +3848,8 @@ class HermesCLI: thinking_callback=self._on_thinking, checkpoints_enabled=self.checkpoints_enabled, checkpoint_max_snapshots=self.checkpoint_max_snapshots, + checkpoint_max_total_size_mb=self.checkpoint_max_total_size_mb, + checkpoint_max_file_size_mb=self.checkpoint_max_file_size_mb, pass_session_id=self.pass_session_id, skip_context_files=self.ignore_rules, skip_memory=self.ignore_rules, diff --git a/gateway/run.py b/gateway/run.py index 2ea1e5117f..fe2ed84e6c 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -1160,6 +1160,7 @@ class GatewayRunner: retention_days=int(_ckpt_cfg.get("retention_days", 7)), min_interval_hours=int(_ckpt_cfg.get("min_interval_hours", 24)), delete_orphans=bool(_ckpt_cfg.get("delete_orphans", True)), + max_total_size_mb=int(_ckpt_cfg.get("max_total_size_mb", 500)), ) except Exception as exc: logger.debug("checkpoint auto-maintenance skipped: %s", exc) diff --git a/hermes_cli/checkpoints.py b/hermes_cli/checkpoints.py new file mode 100644 index 0000000000..cac5cd0979 --- /dev/null +++ b/hermes_cli/checkpoints.py @@ -0,0 +1,244 @@ +"""`hermes checkpoints` CLI subcommand. + +Gives users direct visibility and control over the filesystem checkpoint +store at ``~/.hermes/checkpoints/``. Actions: + + hermes checkpoints # same as `status` + hermes checkpoints status # total size, project count, breakdown + hermes checkpoints list # per-project checkpoint counts + workdir + hermes checkpoints prune [opts] # force a sweep (ignores the 24h marker) + hermes checkpoints clear [-f] # nuke the entire base (asks first) + hermes checkpoints clear-legacy # delete just the legacy-* archives + +Examples:: + + hermes checkpoints + hermes checkpoints prune --retention-days 3 --max-size-mb 200 + hermes checkpoints clear -f + +None of these require the agent to be running. Safe to call any time. +""" + +from __future__ import annotations + +import argparse +import time +from datetime import datetime +from pathlib import Path +from typing import Any, Dict + + +def _fmt_bytes(n: int) -> str: + units = ("B", "KB", "MB", "GB", "TB") + size = float(n or 0) + for unit in units: + if size < 1024 or unit == units[-1]: + if unit == "B": + return f"{int(size)} {unit}" + return f"{size:.1f} {unit}" + size /= 1024 + return f"{size:.1f} TB" + + +def _fmt_ts(ts: Any) -> str: + try: + return datetime.fromtimestamp(float(ts)).strftime("%Y-%m-%d %H:%M") + except (TypeError, ValueError): + return "—" + + +def _fmt_age(ts: Any) -> str: + try: + age = time.time() - float(ts) + except (TypeError, ValueError): + return "—" + if age < 0: + return "now" + if age < 60: + return f"{int(age)}s ago" + if age < 3600: + return f"{int(age / 60)}m ago" + if age < 86400: + return f"{int(age / 3600)}h ago" + return f"{int(age / 86400)}d ago" + + +def cmd_status(args: argparse.Namespace) -> int: + from tools.checkpoint_manager import store_status + + info = store_status() + base = info["base"] + print(f"Checkpoint base: {base}") + print(f"Total size: {_fmt_bytes(info['total_size_bytes'])}") + print(f" store/ {_fmt_bytes(info['store_size_bytes'])}") + print(f" legacy-* {_fmt_bytes(info['legacy_size_bytes'])}") + print(f"Projects: {info['project_count']}") + + projects = sorted( + info["projects"], + key=lambda p: (p.get("last_touch") or 0), + reverse=True, + ) + if projects: + print() + print(f" {'WORKDIR':<60} {'COMMITS':>7} {'LAST TOUCH':>12} STATE") + for p in projects[: args.limit if hasattr(args, "limit") and args.limit else 20]: + wd = p.get("workdir") or "(unknown)" + if len(wd) > 60: + wd = "…" + wd[-59:] + exists = p.get("exists") + state = "live" if exists else "orphan" + commits = p.get("commits", 0) + last = _fmt_age(p.get("last_touch")) + print(f" {wd:<60} {commits:>7} {last:>12} {state}") + + legacy = info.get("legacy_archives", []) + if legacy: + print() + print(f"Legacy archives ({len(legacy)}):") + for arch in sorted(legacy, key=lambda a: a.get("mtime", 0), reverse=True): + print(f" {arch['name']:<40} {_fmt_bytes(arch['size_bytes']):>10}") + print() + print("Clear with: hermes checkpoints clear-legacy") + return 0 + + +def cmd_list(args: argparse.Namespace) -> int: + # `list` is just a terser status — already covered. + return cmd_status(args) + + +def cmd_prune(args: argparse.Namespace) -> int: + from tools.checkpoint_manager import prune_checkpoints + + retention_days = args.retention_days + max_size_mb = args.max_size_mb + + print("Pruning checkpoint store…") + print(f" retention_days: {retention_days}") + print(f" delete_orphans: {not args.keep_orphans}") + print(f" max_total_size_mb: {max_size_mb}") + print() + + result = prune_checkpoints( + retention_days=retention_days, + delete_orphans=not args.keep_orphans, + max_total_size_mb=max_size_mb, + ) + print(f"Scanned: {result['scanned']}") + print(f"Deleted orphan: {result['deleted_orphan']}") + print(f"Deleted stale: {result['deleted_stale']}") + print(f"Errors: {result['errors']}") + print(f"Bytes reclaimed: {_fmt_bytes(result['bytes_freed'])}") + return 0 + + +def _confirm(prompt: str) -> bool: + try: + resp = input(f"{prompt} [y/N]: ").strip().lower() + except (EOFError, KeyboardInterrupt): + print() + return False + return resp in ("y", "yes") + + +def cmd_clear(args: argparse.Namespace) -> int: + from tools.checkpoint_manager import CHECKPOINT_BASE, clear_all, store_status + + info = store_status() + if info["total_size_bytes"] == 0 and not Path(CHECKPOINT_BASE).exists(): + print("Nothing to clear — checkpoint base does not exist.") + return 0 + + print(f"This will delete the ENTIRE checkpoint base at {info['base']}") + print(f" size: {_fmt_bytes(info['total_size_bytes'])}") + print(f" projects: {info['project_count']}") + print(f" legacy dirs: {len(info.get('legacy_archives', []))}") + print() + print("All /rollback history for every working directory will be lost.") + if not args.force and not _confirm("Proceed?"): + print("Aborted.") + return 1 + + result = clear_all() + if result["deleted"]: + print(f"Cleared. Reclaimed {_fmt_bytes(result['bytes_freed'])}.") + return 0 + print("Could not clear checkpoint base (see logs).") + return 2 + + +def cmd_clear_legacy(args: argparse.Namespace) -> int: + from tools.checkpoint_manager import clear_legacy, store_status + + info = store_status() + legacy = info.get("legacy_archives", []) + if not legacy: + print("No legacy archives to clear.") + return 0 + + total = sum(a.get("size_bytes", 0) for a in legacy) + print(f"Found {len(legacy)} legacy archive(s), total {_fmt_bytes(total)}:") + for arch in legacy: + print(f" {arch['name']:<40} {_fmt_bytes(arch['size_bytes']):>10}") + print() + print("Legacy archives hold pre-v2 per-project shadow repos, moved aside") + print("during the single-store migration. Delete when you're confident") + print("you don't need the old /rollback history.") + if not args.force and not _confirm("Delete all legacy archives?"): + print("Aborted.") + return 1 + + result = clear_legacy() + print(f"Deleted {result['deleted']} archive(s), reclaimed {_fmt_bytes(result['bytes_freed'])}.") + return 0 + + +def register_cli(parser: argparse.ArgumentParser) -> None: + """Wire subcommands onto the ``hermes checkpoints`` parser.""" + parser.set_defaults(func=cmd_status) # bare `hermes checkpoints` → status + subs = parser.add_subparsers(dest="checkpoints_command", metavar="COMMAND") + + p_status = subs.add_parser( + "status", + help="Show total size, project count, and per-project breakdown", + ) + p_status.add_argument("--limit", type=int, default=20, + help="Max projects to list (default 20)") + p_status.set_defaults(func=cmd_status) + + p_list = subs.add_parser( + "list", + help="Alias for 'status'", + ) + p_list.add_argument("--limit", type=int, default=20) + p_list.set_defaults(func=cmd_list) + + p_prune = subs.add_parser( + "prune", + help="Delete orphan/stale checkpoints and GC the store", + ) + p_prune.add_argument("--retention-days", type=int, default=7, + help="Drop projects whose last_touch is older than N days (default 7)") + p_prune.add_argument("--max-size-mb", type=int, default=500, + help="After orphan/stale prune, drop oldest commits " + "per project until total size <= this (default 500)") + p_prune.add_argument("--keep-orphans", action="store_true", + help="Skip deleting projects whose workdir no longer exists") + p_prune.set_defaults(func=cmd_prune) + + p_clear = subs.add_parser( + "clear", + help="Delete the entire checkpoint base (all /rollback history)", + ) + p_clear.add_argument("-f", "--force", action="store_true", + help="Skip confirmation prompt") + p_clear.set_defaults(func=cmd_clear) + + p_legacy = subs.add_parser( + "clear-legacy", + help="Delete only the legacy-/ archives from v1 migration", + ) + p_legacy.add_argument("-f", "--force", action="store_true", + help="Skip confirmation prompt") + p_legacy.set_defaults(func=cmd_clear_legacy) diff --git a/hermes_cli/config.py b/hermes_cli/config.py index 89397b1cb5..2d11a868fc 100644 --- a/hermes_cli/config.py +++ b/hermes_cli/config.py @@ -574,21 +574,39 @@ DEFAULT_CONFIG = { }, # Filesystem checkpoints — automatic snapshots before destructive file ops. - # When enabled, the agent takes a snapshot of the working directory once per - # conversation turn (on first write_file/patch call). Use /rollback to restore. + # When enabled, the agent takes a snapshot of the working directory once + # per conversation turn (on first write_file/patch call). Use /rollback + # to restore. + # + # Defaults changed in v2 (single shared shadow store, real pruning): + # - enabled: True -> False (opt-in; most users never use /rollback) + # - max_snapshots: 50 -> 20 (now actually enforced via ref rewrite) + # - auto_prune: False -> True (orphans/stale pruned automatically) + # Opt in via ``hermes chat --checkpoints`` or set enabled=True here. "checkpoints": { - "enabled": True, - "max_snapshots": 50, # Max checkpoints to keep per directory - # Auto-maintenance: shadow repos accumulate forever under - # ~/.hermes/checkpoints/ (one per cd'd working directory). Field - # reports put the typical offender at 1000+ repos / ~12 GB. When - # auto_prune is on, hermes sweeps at startup (at most once per - # min_interval_hours) and deletes: - # * orphan repos: HERMES_WORKDIR no longer exists on disk - # * stale repos: newest mtime older than retention_days - # Opt-in so users who rely on /rollback against long-ago sessions - # never lose data silently. - "auto_prune": False, + "enabled": False, + # Max checkpoints to keep per working directory. Pre-v2 this only + # limited the `/rollback` listing; v2 actually rewrites the ref and + # garbage-collects older commits. + "max_snapshots": 20, + # Hard ceiling on total ``~/.hermes/checkpoints/`` size (MB). When + # exceeded, the oldest checkpoint per project is dropped in a + # round-robin pass until total size falls under the cap. + # 0 disables the size cap. + "max_total_size_mb": 500, + # Skip any single file larger than this when staging a checkpoint. + # Prevents accidental snapshotting of datasets, model weights, and + # other large generated assets. 0 disables the filter. + "max_file_size_mb": 10, + # Auto-maintenance: hermes sweeps the checkpoint base at startup + # (at most once per ``min_interval_hours``) and: + # * deletes project entries whose workdir no longer exists (orphan) + # * deletes project entries whose last_touch is older than + # ``retention_days`` + # * GCs the single shared store to reclaim unreachable objects + # * enforces ``max_total_size_mb`` across remaining projects + # * deletes ``legacy-*`` archives older than ``retention_days`` + "auto_prune": True, "retention_days": 7, "delete_orphans": True, "min_interval_hours": 24, diff --git a/hermes_cli/main.py b/hermes_cli/main.py index fb3435df3a..19029d7207 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -9379,6 +9379,20 @@ Examples: ) backup_parser.set_defaults(func=cmd_backup) + # ========================================================================= + # checkpoints command + # ========================================================================= + checkpoints_parser = subparsers.add_parser( + "checkpoints", + help="Inspect / prune / clear ~/.hermes/checkpoints/", + description="Manage the filesystem checkpoint store — the shadow git " + "repo hermes uses to snapshot working directories before " + "write_file/patch/terminal calls. Lets you see how much " + "space checkpoints occupy, force a prune, or wipe the base.", + ) + from hermes_cli.checkpoints import register_cli as _register_checkpoints_cli + _register_checkpoints_cli(checkpoints_parser) + # ========================================================================= # import command # ========================================================================= diff --git a/run_agent.py b/run_agent.py index 0b69a17175..919a5875b6 100644 --- a/run_agent.py +++ b/run_agent.py @@ -966,7 +966,9 @@ class AIAgent: fallback_model: Dict[str, Any] = None, credential_pool=None, checkpoints_enabled: bool = False, - checkpoint_max_snapshots: int = 50, + checkpoint_max_snapshots: int = 20, + checkpoint_max_total_size_mb: int = 500, + checkpoint_max_file_size_mb: int = 10, pass_session_id: bool = False, ): """ @@ -1689,6 +1691,8 @@ class AIAgent: self._checkpoint_mgr = CheckpointManager( enabled=checkpoints_enabled, max_snapshots=checkpoint_max_snapshots, + max_total_size_mb=checkpoint_max_total_size_mb, + max_file_size_mb=checkpoint_max_file_size_mb, ) # SQLite session store (optional -- provided by CLI or gateway) diff --git a/tests/tools/test_checkpoint_manager.py b/tests/tools/test_checkpoint_manager.py index 4b7f89644d..2c87db0e5e 100644 --- a/tests/tools/test_checkpoint_manager.py +++ b/tests/tools/test_checkpoint_manager.py @@ -1,7 +1,10 @@ -"""Tests for tools/checkpoint_manager.py — CheckpointManager.""" +"""Tests for tools/checkpoint_manager.py — CheckpointManager (v2 single-store).""" +import json import logging +import os import subprocess +import time import pytest from pathlib import Path from unittest.mock import patch @@ -10,12 +13,22 @@ from tools.checkpoint_manager import ( CheckpointManager, _shadow_repo_path, _init_shadow_repo, + _init_store, _run_git, _git_env, _dir_file_count, + _project_hash, + _store_path, + _ref_name, + _project_meta_path, format_checkpoint_list, DEFAULT_EXCLUDES, CHECKPOINT_BASE, + prune_checkpoints, + maybe_auto_prune_checkpoints, + store_status, + clear_all, + clear_legacy, ) @@ -25,11 +38,10 @@ from tools.checkpoint_manager import ( @pytest.fixture() def work_dir(tmp_path): - """Temporary working directory.""" d = tmp_path / "project" d.mkdir() - (d / "main.py").write_text("print('hello')\\n") - (d / "README.md").write_text("# Project\\n") + (d / "main.py").write_text("print('hello')\n") + (d / "README.md").write_text("# Project\n") return d @@ -41,7 +53,6 @@ def checkpoint_base(tmp_path): @pytest.fixture() def fake_home(tmp_path, monkeypatch): - """Set a deterministic fake home for expanduser/path-home behavior.""" home = tmp_path / "home" home.mkdir() monkeypatch.setenv("HOME", str(home)) @@ -54,94 +65,103 @@ def fake_home(tmp_path, monkeypatch): @pytest.fixture() def mgr(work_dir, checkpoint_base, monkeypatch): - """CheckpointManager with redirected checkpoint base.""" monkeypatch.setattr("tools.checkpoint_manager.CHECKPOINT_BASE", checkpoint_base) return CheckpointManager(enabled=True, max_snapshots=50) @pytest.fixture() def disabled_mgr(checkpoint_base, monkeypatch): - """Disabled CheckpointManager.""" monkeypatch.setattr("tools.checkpoint_manager.CHECKPOINT_BASE", checkpoint_base) return CheckpointManager(enabled=False) # ========================================================================= -# Shadow repo path +# Store path + project hash # ========================================================================= -class TestShadowRepoPath: - def test_deterministic(self, work_dir, checkpoint_base, monkeypatch): +class TestStorePath: + def test_store_is_single_shared_path(self, work_dir, checkpoint_base, monkeypatch): monkeypatch.setattr("tools.checkpoint_manager.CHECKPOINT_BASE", checkpoint_base) + # All projects resolve to the same store. p1 = _shadow_repo_path(str(work_dir)) - p2 = _shadow_repo_path(str(work_dir)) - assert p1 == p2 + p2 = _shadow_repo_path(str(work_dir.parent / "other")) + assert p1 == p2 == _store_path(checkpoint_base) - def test_different_dirs_different_paths(self, tmp_path, checkpoint_base, monkeypatch): - monkeypatch.setattr("tools.checkpoint_manager.CHECKPOINT_BASE", checkpoint_base) - p1 = _shadow_repo_path(str(tmp_path / "a")) - p2 = _shadow_repo_path(str(tmp_path / "b")) - assert p1 != p2 + def test_project_hash_deterministic(self, work_dir): + assert _project_hash(str(work_dir)) == _project_hash(str(work_dir)) - def test_under_checkpoint_base(self, work_dir, checkpoint_base, monkeypatch): - monkeypatch.setattr("tools.checkpoint_manager.CHECKPOINT_BASE", checkpoint_base) - p = _shadow_repo_path(str(work_dir)) - assert str(p).startswith(str(checkpoint_base)) + def test_project_hash_differs_per_dir(self, tmp_path): + assert _project_hash(str(tmp_path / "a")) != _project_hash(str(tmp_path / "b")) - def test_tilde_and_expanded_home_share_shadow_repo(self, fake_home, checkpoint_base, monkeypatch): + def test_tilde_and_expanded_home_share_project_hash( + self, fake_home, checkpoint_base, monkeypatch, + ): monkeypatch.setattr("tools.checkpoint_manager.CHECKPOINT_BASE", checkpoint_base) project = fake_home / "project" project.mkdir() - - tilde_path = f"~/{project.name}" - expanded_path = str(project) - - assert _shadow_repo_path(tilde_path) == _shadow_repo_path(expanded_path) + tilde = f"~/{project.name}" + assert _project_hash(tilde) == _project_hash(str(project)) # ========================================================================= -# Shadow repo init +# Store init + legacy migration # ========================================================================= -class TestShadowRepoInit: - def test_creates_git_repo(self, work_dir, checkpoint_base, monkeypatch): +class TestStoreInit: + def test_creates_git_store(self, work_dir, checkpoint_base, monkeypatch): monkeypatch.setattr("tools.checkpoint_manager.CHECKPOINT_BASE", checkpoint_base) - shadow = _shadow_repo_path(str(work_dir)) - err = _init_shadow_repo(shadow, str(work_dir)) + store = _store_path(checkpoint_base) + err = _init_store(store, str(work_dir)) assert err is None - assert (shadow / "HEAD").exists() + assert (store / "HEAD").exists() + assert (store / "objects").exists() + assert (store / "info" / "exclude").exists() + assert "node_modules/" in (store / "info" / "exclude").read_text() def test_no_git_in_project_dir(self, work_dir, checkpoint_base, monkeypatch): monkeypatch.setattr("tools.checkpoint_manager.CHECKPOINT_BASE", checkpoint_base) - shadow = _shadow_repo_path(str(work_dir)) - _init_shadow_repo(shadow, str(work_dir)) + store = _store_path(checkpoint_base) + _init_store(store, str(work_dir)) assert not (work_dir / ".git").exists() - def test_has_exclude_file(self, work_dir, checkpoint_base, monkeypatch): + def test_init_idempotent(self, work_dir, checkpoint_base, monkeypatch): monkeypatch.setattr("tools.checkpoint_manager.CHECKPOINT_BASE", checkpoint_base) - shadow = _shadow_repo_path(str(work_dir)) - _init_shadow_repo(shadow, str(work_dir)) - exclude = shadow / "info" / "exclude" - assert exclude.exists() - content = exclude.read_text() - assert "node_modules/" in content - assert ".env" in content + store = _store_path(checkpoint_base) + assert _init_store(store, str(work_dir)) is None + assert _init_store(store, str(work_dir)) is None - def test_has_workdir_file(self, work_dir, checkpoint_base, monkeypatch): + def test_bc_init_shadow_repo_shim(self, work_dir, checkpoint_base, monkeypatch): + """Backward-compatible helper still works for old callers/tests.""" monkeypatch.setattr("tools.checkpoint_manager.CHECKPOINT_BASE", checkpoint_base) - shadow = _shadow_repo_path(str(work_dir)) - _init_shadow_repo(shadow, str(work_dir)) - workdir_file = shadow / "HERMES_WORKDIR" - assert workdir_file.exists() - assert str(work_dir.resolve()) in workdir_file.read_text() + store = _shadow_repo_path(str(work_dir)) + err = _init_shadow_repo(store, str(work_dir)) + assert err is None + assert (store / "HEAD").exists() + assert (store / "HERMES_WORKDIR").exists() - def test_idempotent(self, work_dir, checkpoint_base, monkeypatch): - monkeypatch.setattr("tools.checkpoint_manager.CHECKPOINT_BASE", checkpoint_base) - shadow = _shadow_repo_path(str(work_dir)) - err1 = _init_shadow_repo(shadow, str(work_dir)) - err2 = _init_shadow_repo(shadow, str(work_dir)) - assert err1 is None - assert err2 is None + def test_legacy_migration_archives_prev2_repos( + self, checkpoint_base, work_dir, + ): + """Pre-v2 per-project shadow repos get moved into legacy-/.""" + base = checkpoint_base + base.mkdir(parents=True) + # Simulate a pre-v2 repo directly under base + fake_repo = base / "deadbeefcafebabe" + fake_repo.mkdir() + (fake_repo / "HEAD").write_text("ref: refs/heads/main\n") + (fake_repo / "HERMES_WORKDIR").write_text(str(work_dir) + "\n") + (fake_repo / "objects").mkdir() + + # Init store — should migrate the fake pre-v2 repo + store = _store_path(base) + err = _init_store(store, str(work_dir)) + assert err is None + + assert not fake_repo.exists() + legacies = [p for p in base.iterdir() if p.name.startswith("legacy-")] + assert len(legacies) == 1 + assert (legacies[0] / fake_repo.name).exists() + assert (legacies[0] / fake_repo.name / "HEAD").exists() # ========================================================================= @@ -153,7 +173,7 @@ class TestDisabledManager: assert disabled_mgr.ensure_checkpoint(str(work_dir)) is False def test_new_turn_works(self, disabled_mgr): - disabled_mgr.new_turn() # should not raise + disabled_mgr.new_turn() # ========================================================================= @@ -165,12 +185,6 @@ class TestTakeCheckpoint: result = mgr.ensure_checkpoint(str(work_dir), "initial") assert result is True - def test_successful_checkpoint_does_not_log_expected_diff_exit(self, mgr, work_dir, caplog): - with caplog.at_level(logging.ERROR, logger="tools.checkpoint_manager"): - result = mgr.ensure_checkpoint(str(work_dir), "initial") - assert result is True - assert not any("diff --cached --quiet" in r.getMessage() for r in caplog.records) - def test_dedup_same_turn(self, mgr, work_dir): r1 = mgr.ensure_checkpoint(str(work_dir), "first") r2 = mgr.ensure_checkpoint(str(work_dir), "second") @@ -178,42 +192,51 @@ class TestTakeCheckpoint: assert r2 is False # dedup'd def test_new_turn_resets_dedup(self, mgr, work_dir): - r1 = mgr.ensure_checkpoint(str(work_dir), "turn 1") - assert r1 is True - + assert mgr.ensure_checkpoint(str(work_dir), "turn 1") is True mgr.new_turn() - - # Modify a file so there's something to commit - (work_dir / "main.py").write_text("print('modified')\\n") - r2 = mgr.ensure_checkpoint(str(work_dir), "turn 2") - assert r2 is True + (work_dir / "main.py").write_text("print('modified')\n") + assert mgr.ensure_checkpoint(str(work_dir), "turn 2") is True def test_no_changes_skips_commit(self, mgr, work_dir): - # First checkpoint mgr.ensure_checkpoint(str(work_dir), "initial") mgr.new_turn() - - # No file changes — should return False (nothing to commit) - r = mgr.ensure_checkpoint(str(work_dir), "no changes") - assert r is False + assert mgr.ensure_checkpoint(str(work_dir), "no changes") is False def test_skip_root_dir(self, mgr): - r = mgr.ensure_checkpoint("/", "root") - assert r is False + assert mgr.ensure_checkpoint("/", "root") is False def test_skip_home_dir(self, mgr): - r = mgr.ensure_checkpoint(str(Path.home()), "home") - assert r is False + assert mgr.ensure_checkpoint(str(Path.home()), "home") is False + + def test_multiple_projects_share_store(self, mgr, tmp_path): + """Two projects commit to the SAME shared store (dedup wins).""" + a = tmp_path / "proj-a" + a.mkdir() + (a / "f.py").write_text("a\n") + b = tmp_path / "proj-b" + b.mkdir() + (b / "g.py").write_text("b\n") + + assert mgr.ensure_checkpoint(str(a), "a") is True + mgr.new_turn() + assert mgr.ensure_checkpoint(str(b), "b") is True + + # Only one "store" directory exists. + bases = list(Path(mgr._checkpointed_dirs).__iter__()) if False else None + from tools.checkpoint_manager import CHECKPOINT_BASE as BASE + # Exactly one store dir + two project metas + assert (BASE / "store" / "HEAD").exists() + assert (BASE / "store" / "projects" / f"{_project_hash(str(a))}.json").exists() + assert (BASE / "store" / "projects" / f"{_project_hash(str(b))}.json").exists() # ========================================================================= -# CheckpointManager — listing checkpoints +# CheckpointManager — listing # ========================================================================= class TestListCheckpoints: def test_empty_when_no_checkpoints(self, mgr, work_dir): - result = mgr.list_checkpoints(str(work_dir)) - assert result == [] + assert mgr.list_checkpoints(str(work_dir)) == [] def test_list_after_take(self, mgr, work_dir): mgr.ensure_checkpoint(str(work_dir), "test checkpoint") @@ -227,59 +250,109 @@ class TestListCheckpoints: def test_multiple_checkpoints_ordered(self, mgr, work_dir): mgr.ensure_checkpoint(str(work_dir), "first") mgr.new_turn() - - (work_dir / "main.py").write_text("v2\\n") + (work_dir / "main.py").write_text("v2\n") mgr.ensure_checkpoint(str(work_dir), "second") mgr.new_turn() - - (work_dir / "main.py").write_text("v3\\n") + (work_dir / "main.py").write_text("v3\n") mgr.ensure_checkpoint(str(work_dir), "third") result = mgr.list_checkpoints(str(work_dir)) assert len(result) == 3 - # Most recent first assert result[0]["reason"] == "third" assert result[2]["reason"] == "first" - def test_tilde_path_lists_same_checkpoints_as_expanded_path(self, checkpoint_base, fake_home, monkeypatch): + def test_list_isolated_per_project(self, mgr, tmp_path): + """Listing one project doesn't leak checkpoints from another.""" + a = tmp_path / "a" + a.mkdir() + (a / "f").write_text("A\n") + b = tmp_path / "b" + b.mkdir() + (b / "g").write_text("B\n") + + mgr.ensure_checkpoint(str(a), "A-1") + mgr.new_turn() + mgr.ensure_checkpoint(str(b), "B-1") + + assert [c["reason"] for c in mgr.list_checkpoints(str(a))] == ["A-1"] + assert [c["reason"] for c in mgr.list_checkpoints(str(b))] == ["B-1"] + + def test_tilde_path_lists_same_checkpoints(self, checkpoint_base, fake_home, monkeypatch): monkeypatch.setattr("tools.checkpoint_manager.CHECKPOINT_BASE", checkpoint_base) - mgr = CheckpointManager(enabled=True, max_snapshots=50) + m = CheckpointManager(enabled=True, max_snapshots=50) project = fake_home / "project" project.mkdir() (project / "main.py").write_text("v1\n") - - tilde_path = f"~/{project.name}" - assert mgr.ensure_checkpoint(tilde_path, "initial") is True - - listed = mgr.list_checkpoints(str(project)) + assert m.ensure_checkpoint(f"~/{project.name}", "initial") is True + listed = m.list_checkpoints(str(project)) assert len(listed) == 1 assert listed[0]["reason"] == "initial" +# ========================================================================= +# Pruning: max_snapshots actually enforced (v2 fix) +# ========================================================================= + +class TestRealPruning: + def test_max_snapshots_trims_history(self, work_dir, checkpoint_base, monkeypatch): + monkeypatch.setattr("tools.checkpoint_manager.CHECKPOINT_BASE", checkpoint_base) + # Tiny cap to test enforcement. + m = CheckpointManager(enabled=True, max_snapshots=3) + + for i in range(6): + (work_dir / "main.py").write_text(f"v{i}\n") + m.new_turn() + m.ensure_checkpoint(str(work_dir), f"step-{i}") + + cps = m.list_checkpoints(str(work_dir)) + assert len(cps) == 3 + reasons = [c["reason"] for c in cps] + # Newest first — step-5, step-4, step-3 + assert reasons[0] == "step-5" + assert reasons[-1] == "step-3" + + def test_max_file_size_mb_skips_large_files( + self, tmp_path, checkpoint_base, monkeypatch, + ): + monkeypatch.setattr("tools.checkpoint_manager.CHECKPOINT_BASE", checkpoint_base) + wd = tmp_path / "proj" + wd.mkdir() + (wd / "small.py").write_text("tiny\n") + big = wd / "weights.bin" + big.write_bytes(b"\0" * (2 * 1024 * 1024)) # 2 MB + + m = CheckpointManager(enabled=True, max_snapshots=5, max_file_size_mb=1) + assert m.ensure_checkpoint(str(wd), "initial") is True + + store = _store_path(checkpoint_base) + ok, files, _ = _run_git( + ["ls-tree", "-r", "--name-only", _ref_name(_project_hash(str(wd)))], + store, str(wd), + ) + assert ok + names = set(files.splitlines()) + assert "small.py" in names + assert "weights.bin" not in names # filtered by size cap + + # ========================================================================= # CheckpointManager — restoring # ========================================================================= class TestRestore: def test_restore_to_previous(self, mgr, work_dir): - # Write original content - (work_dir / "main.py").write_text("original\\n") + (work_dir / "main.py").write_text("original\n") mgr.ensure_checkpoint(str(work_dir), "original state") mgr.new_turn() - # Modify the file - (work_dir / "main.py").write_text("modified\\n") + (work_dir / "main.py").write_text("modified\n") - # Get the checkpoint hash - checkpoints = mgr.list_checkpoints(str(work_dir)) - assert len(checkpoints) == 1 + cps = mgr.list_checkpoints(str(work_dir)) + assert len(cps) == 1 - # Restore - result = mgr.restore(str(work_dir), checkpoints[0]["hash"]) + result = mgr.restore(str(work_dir), cps[0]["hash"]) assert result["success"] is True - - # File should be back to original - assert (work_dir / "main.py").read_text() == "original\\n" + assert (work_dir / "main.py").read_text() == "original\n" def test_restore_invalid_hash(self, mgr, work_dir): mgr.ensure_checkpoint(str(work_dir), "initial") @@ -291,39 +364,39 @@ class TestRestore: assert result["success"] is False def test_restore_creates_pre_rollback_snapshot(self, mgr, work_dir): - (work_dir / "main.py").write_text("v1\\n") + (work_dir / "main.py").write_text("v1\n") mgr.ensure_checkpoint(str(work_dir), "v1") mgr.new_turn() - (work_dir / "main.py").write_text("v2\\n") + (work_dir / "main.py").write_text("v2\n") + cps = mgr.list_checkpoints(str(work_dir)) + mgr.restore(str(work_dir), cps[0]["hash"]) - checkpoints = mgr.list_checkpoints(str(work_dir)) - mgr.restore(str(work_dir), checkpoints[0]["hash"]) - - # Should now have 2 checkpoints: original + pre-rollback all_cps = mgr.list_checkpoints(str(work_dir)) assert len(all_cps) >= 2 assert "pre-rollback" in all_cps[0]["reason"] - def test_tilde_path_supports_diff_and_restore_flow(self, checkpoint_base, fake_home, monkeypatch): + def test_tilde_path_supports_diff_and_restore_flow( + self, checkpoint_base, fake_home, monkeypatch, + ): monkeypatch.setattr("tools.checkpoint_manager.CHECKPOINT_BASE", checkpoint_base) - mgr = CheckpointManager(enabled=True, max_snapshots=50) + m = CheckpointManager(enabled=True, max_snapshots=50) project = fake_home / "project" project.mkdir() file_path = project / "main.py" file_path.write_text("original\n") - tilde_path = f"~/{project.name}" - assert mgr.ensure_checkpoint(tilde_path, "initial") is True - mgr.new_turn() + tilde = f"~/{project.name}" + assert m.ensure_checkpoint(tilde, "initial") is True + m.new_turn() file_path.write_text("changed\n") - checkpoints = mgr.list_checkpoints(str(project)) - diff_result = mgr.diff(tilde_path, checkpoints[0]["hash"]) + cps = m.list_checkpoints(str(project)) + diff_result = m.diff(tilde, cps[0]["hash"]) assert diff_result["success"] is True assert "main.py" in diff_result["diff"] - restore_result = mgr.restore(tilde_path, checkpoints[0]["hash"]) + restore_result = m.restore(tilde, cps[0]["hash"]) assert restore_result["success"] is True assert file_path.read_text() == "original\n" @@ -334,39 +407,32 @@ class TestRestore: class TestWorkingDirResolution: def test_resolves_git_project_root(self, tmp_path): - mgr = CheckpointManager(enabled=True) + m = CheckpointManager(enabled=True) project = tmp_path / "myproject" project.mkdir() (project / ".git").mkdir() subdir = project / "src" subdir.mkdir() filepath = subdir / "main.py" - filepath.write_text("x\\n") + filepath.write_text("x\n") - result = mgr.get_working_dir_for_path(str(filepath)) - assert result == str(project) + assert m.get_working_dir_for_path(str(filepath)) == str(project) def test_resolves_pyproject_root(self, tmp_path): - mgr = CheckpointManager(enabled=True) + m = CheckpointManager(enabled=True) project = tmp_path / "pyproj" project.mkdir() - (project / "pyproject.toml").write_text("[project]\\n") + (project / "pyproject.toml").write_text("[project]\n") subdir = project / "src" subdir.mkdir() - - result = mgr.get_working_dir_for_path(str(subdir / "file.py")) - assert result == str(project) + assert m.get_working_dir_for_path(str(subdir / "file.py")) == str(project) def test_falls_back_to_parent(self, tmp_path, monkeypatch): - mgr = CheckpointManager(enabled=True) + m = CheckpointManager(enabled=True) filepath = tmp_path / "random" / "file.py" filepath.parent.mkdir(parents=True) - filepath.write_text("x\\n") + filepath.write_text("x\n") - # The walk-up scan for project markers (.git, pyproject.toml, etc.) - # stops at tmp_path — otherwise stray markers in ``/tmp`` (e.g. - # ``/tmp/pyproject.toml`` left by other tools on the host) get - # picked up as the project root and this test flakes on shared CI. import pathlib as _pl _real_exists = _pl.Path.exists @@ -383,12 +449,10 @@ class TestWorkingDirResolution: return _real_exists(self) monkeypatch.setattr(_pl.Path, "exists", _guarded_exists) - - result = mgr.get_working_dir_for_path(str(filepath)) - assert result == str(filepath.parent) + assert m.get_working_dir_for_path(str(filepath)) == str(filepath.parent) def test_resolves_tilde_path_to_project_root(self, fake_home): - mgr = CheckpointManager(enabled=True) + m = CheckpointManager(enabled=True) project = fake_home / "myproject" project.mkdir() (project / "pyproject.toml").write_text("[project]\n") @@ -397,8 +461,9 @@ class TestWorkingDirResolution: filepath = subdir / "main.py" filepath.write_text("x\n") - result = mgr.get_working_dir_for_path(f"~/{project.name}/src/main.py") - assert result == str(project) + assert m.get_working_dir_for_path( + f"~/{project.name}/src/main.py" + ) == str(project) # ========================================================================= @@ -407,28 +472,32 @@ class TestWorkingDirResolution: class TestGitEnvIsolation: def test_sets_git_dir(self, tmp_path): - shadow = tmp_path / "shadow" - env = _git_env(shadow, str(tmp_path / "work")) - assert env["GIT_DIR"] == str(shadow) + store = tmp_path / "store" + env = _git_env(store, str(tmp_path / "work")) + assert env["GIT_DIR"] == str(store) def test_sets_work_tree(self, tmp_path): - shadow = tmp_path / "shadow" + store = tmp_path / "store" work = tmp_path / "work" - env = _git_env(shadow, str(work)) + env = _git_env(store, str(work)) assert env["GIT_WORK_TREE"] == str(work.resolve()) def test_clears_index_file(self, tmp_path, monkeypatch): monkeypatch.setenv("GIT_INDEX_FILE", "/some/index") - shadow = tmp_path / "shadow" - env = _git_env(shadow, str(tmp_path)) + env = _git_env(tmp_path / "store", str(tmp_path)) assert "GIT_INDEX_FILE" not in env + def test_sets_index_file_when_provided(self, tmp_path): + env = _git_env( + tmp_path / "store", str(tmp_path), + index_file=tmp_path / "store" / "indexes" / "abc", + ) + assert env["GIT_INDEX_FILE"].endswith("indexes/abc") + def test_expands_tilde_in_work_tree(self, fake_home, tmp_path): - shadow = tmp_path / "shadow" work = fake_home / "work" work.mkdir() - - env = _git_env(shadow, f"~/{work.name}") + env = _git_env(tmp_path / "store", f"~/{work.name}") assert env["GIT_WORK_TREE"] == str(work.resolve()) @@ -438,13 +507,16 @@ class TestGitEnvIsolation: class TestFormatCheckpointList: def test_empty_list(self): - result = format_checkpoint_list([], "/some/dir") - assert "No checkpoints" in result + assert "No checkpoints" in format_checkpoint_list([], "/some/dir") def test_formats_entries(self): cps = [ - {"hash": "abc123", "short_hash": "abc1", "timestamp": "2026-03-09T21:15:00-07:00", "reason": "before write_file"}, - {"hash": "def456", "short_hash": "def4", "timestamp": "2026-03-09T21:10:00-07:00", "reason": "before patch"}, + {"hash": "abc123", "short_hash": "abc1", + "timestamp": "2026-03-09T21:15:00-07:00", + "reason": "before write_file"}, + {"hash": "def456", "short_hash": "def4", + "timestamp": "2026-03-09T21:10:00-07:00", + "reason": "before patch"}, ] result = format_checkpoint_list(cps, "/home/user/project") assert "abc1" in result @@ -454,17 +526,15 @@ class TestFormatCheckpointList: # ========================================================================= -# File count guard +# Dir size / file count guards # ========================================================================= class TestDirFileCount: def test_counts_files(self, work_dir): - count = _dir_file_count(str(work_dir)) - assert count >= 2 # main.py + README.md + assert _dir_file_count(str(work_dir)) >= 2 def test_nonexistent_dir(self, tmp_path): - count = _dir_file_count(str(tmp_path / "nonexistent")) - assert count == 0 + assert _dir_file_count(str(tmp_path / "nonexistent")) == 0 # ========================================================================= @@ -474,49 +544,46 @@ class TestDirFileCount: class TestErrorResilience: def test_no_git_installed(self, work_dir, checkpoint_base, monkeypatch): monkeypatch.setattr("tools.checkpoint_manager.CHECKPOINT_BASE", checkpoint_base) - mgr = CheckpointManager(enabled=True) - # Mock git not found + m = CheckpointManager(enabled=True) monkeypatch.setattr("shutil.which", lambda x: None) - mgr._git_available = None # reset lazy probe - result = mgr.ensure_checkpoint(str(work_dir), "test") - assert result is False + m._git_available = None + assert m.ensure_checkpoint(str(work_dir), "test") is False - def test_run_git_allows_expected_nonzero_without_error_log(self, tmp_path, caplog): + def test_run_git_allows_expected_nonzero_without_error_log( + self, tmp_path, caplog, + ): work = tmp_path / "work" work.mkdir() completed = subprocess.CompletedProcess( args=["git", "diff", "--cached", "--quiet"], - returncode=1, - stdout="", - stderr="", + returncode=1, stdout="", stderr="", ) with patch("tools.checkpoint_manager.subprocess.run", return_value=completed): with caplog.at_level(logging.ERROR, logger="tools.checkpoint_manager"): ok, stdout, stderr = _run_git( ["diff", "--cached", "--quiet"], - tmp_path / "shadow", - str(work), + tmp_path / "store", str(work), allowed_returncodes={1}, ) assert ok is False assert stdout == "" - assert stderr == "" assert not caplog.records def test_run_git_invalid_working_dir_reports_path_error(self, tmp_path, caplog): missing = tmp_path / "missing" with caplog.at_level(logging.ERROR, logger="tools.checkpoint_manager"): - ok, stdout, stderr = _run_git( - ["status"], - tmp_path / "shadow", - str(missing), + ok, _, stderr = _run_git( + ["status"], tmp_path / "store", str(missing), ) assert ok is False - assert stdout == "" assert "working directory not found" in stderr - assert not any("Git executable not found" in r.getMessage() for r in caplog.records) + assert not any( + "Git executable not found" in r.getMessage() for r in caplog.records + ) - def test_run_git_missing_git_reports_git_not_found(self, tmp_path, monkeypatch, caplog): + def test_run_git_missing_git_reports_git_not_found( + self, tmp_path, monkeypatch, caplog, + ): work = tmp_path / "work" work.mkdir() @@ -525,144 +592,115 @@ class TestErrorResilience: monkeypatch.setattr("tools.checkpoint_manager.subprocess.run", raise_missing_git) with caplog.at_level(logging.ERROR, logger="tools.checkpoint_manager"): - ok, stdout, stderr = _run_git( - ["status"], - tmp_path / "shadow", - str(work), + ok, _, stderr = _run_git( + ["status"], tmp_path / "store", str(work), ) assert ok is False - assert stdout == "" assert stderr == "git not found" - assert any("Git executable not found" in r.getMessage() for r in caplog.records) + assert any( + "Git executable not found" in r.getMessage() for r in caplog.records + ) def test_checkpoint_failure_does_not_raise(self, mgr, work_dir, monkeypatch): - """Checkpoint failures should never raise — they're silently logged.""" def broken_run_git(*args, **kwargs): raise OSError("git exploded") monkeypatch.setattr("tools.checkpoint_manager._run_git", broken_run_git) - # Should not raise - result = mgr.ensure_checkpoint(str(work_dir), "test") - assert result is False + assert mgr.ensure_checkpoint(str(work_dir), "test") is False # ========================================================================= -# Security / Input validation +# Security / input validation # ========================================================================= class TestSecurity: def test_restore_rejects_argument_injection(self, mgr, work_dir): mgr.ensure_checkpoint(str(work_dir), "initial") - # Try to pass a git flag as a commit hash result = mgr.restore(str(work_dir), "--patch") assert result["success"] is False assert "Invalid commit hash" in result["error"] assert "must not start with '-'" in result["error"] - + result = mgr.restore(str(work_dir), "-p") assert result["success"] is False assert "Invalid commit hash" in result["error"] - + def test_restore_rejects_invalid_hex_chars(self, mgr, work_dir): mgr.ensure_checkpoint(str(work_dir), "initial") - # Git hashes should not contain characters like ;, &, | result = mgr.restore(str(work_dir), "abc; rm -rf /") assert result["success"] is False assert "expected 4-64 hex characters" in result["error"] - + result = mgr.diff(str(work_dir), "abc&def") assert result["success"] is False assert "expected 4-64 hex characters" in result["error"] def test_restore_rejects_path_traversal(self, mgr, work_dir): mgr.ensure_checkpoint(str(work_dir), "initial") - # Real commit hash but malicious path - checkpoints = mgr.list_checkpoints(str(work_dir)) - target_hash = checkpoints[0]["hash"] - - # Absolute path outside + cps = mgr.list_checkpoints(str(work_dir)) + target_hash = cps[0]["hash"] + result = mgr.restore(str(work_dir), target_hash, file_path="/etc/passwd") assert result["success"] is False assert "got absolute path" in result["error"] - - # Relative traversal outside path + result = mgr.restore(str(work_dir), target_hash, file_path="../outside_file.txt") assert result["success"] is False assert "escapes the working directory" in result["error"] def test_restore_accepts_valid_file_path(self, mgr, work_dir): mgr.ensure_checkpoint(str(work_dir), "initial") - checkpoints = mgr.list_checkpoints(str(work_dir)) - target_hash = checkpoints[0]["hash"] - - # Valid path inside directory + cps = mgr.list_checkpoints(str(work_dir)) + target_hash = cps[0]["hash"] + result = mgr.restore(str(work_dir), target_hash, file_path="main.py") assert result["success"] is True - - # Another valid path with subdirectories + (work_dir / "subdir").mkdir() (work_dir / "subdir" / "test.txt").write_text("hello") mgr.new_turn() mgr.ensure_checkpoint(str(work_dir), "second") - checkpoints = mgr.list_checkpoints(str(work_dir)) - target_hash = checkpoints[0]["hash"] - - result = mgr.restore(str(work_dir), target_hash, file_path="subdir/test.txt") + cps = mgr.list_checkpoints(str(work_dir)) + result = mgr.restore(str(work_dir), cps[0]["hash"], file_path="subdir/test.txt") assert result["success"] is True # ========================================================================= # GPG / global git config isolation # ========================================================================= -# Regression tests for the bug where users with ``commit.gpgsign = true`` -# in their global git config got a pinentry popup (or a failed commit) -# every time the agent took a background snapshot. - -import os as _os - class TestGpgAndGlobalConfigIsolation: def test_git_env_isolates_global_and_system_config(self, tmp_path): - """_git_env must null out GIT_CONFIG_GLOBAL / GIT_CONFIG_SYSTEM so the - shadow repo does not inherit user-level gpgsign, hooks, aliases, etc.""" - env = _git_env(tmp_path / "shadow", str(tmp_path)) - assert env["GIT_CONFIG_GLOBAL"] == _os.devnull - assert env["GIT_CONFIG_SYSTEM"] == _os.devnull + env = _git_env(tmp_path / "store", str(tmp_path)) + assert env["GIT_CONFIG_GLOBAL"] == os.devnull + assert env["GIT_CONFIG_SYSTEM"] == os.devnull assert env["GIT_CONFIG_NOSYSTEM"] == "1" def test_init_sets_commit_gpgsign_false(self, work_dir, checkpoint_base, monkeypatch): monkeypatch.setattr("tools.checkpoint_manager.CHECKPOINT_BASE", checkpoint_base) - shadow = _shadow_repo_path(str(work_dir)) - _init_shadow_repo(shadow, str(work_dir)) - # Inspect the shadow's own config directly — the settings must be - # written into the repo, not just inherited via env vars. + store = _store_path(checkpoint_base) + _init_store(store, str(work_dir)) result = subprocess.run( - ["git", "config", "--file", str(shadow / "config"), "--get", "commit.gpgsign"], + ["git", "config", "--file", str(store / "config"), + "--get", "commit.gpgsign"], capture_output=True, text=True, ) assert result.stdout.strip() == "false" def test_init_sets_tag_gpgsign_false(self, work_dir, checkpoint_base, monkeypatch): monkeypatch.setattr("tools.checkpoint_manager.CHECKPOINT_BASE", checkpoint_base) - shadow = _shadow_repo_path(str(work_dir)) - _init_shadow_repo(shadow, str(work_dir)) + store = _store_path(checkpoint_base) + _init_store(store, str(work_dir)) result = subprocess.run( - ["git", "config", "--file", str(shadow / "config"), "--get", "tag.gpgSign"], + ["git", "config", "--file", str(store / "config"), + "--get", "tag.gpgSign"], capture_output=True, text=True, ) assert result.stdout.strip() == "false" def test_checkpoint_works_with_global_gpgsign_and_broken_gpg( - self, work_dir, checkpoint_base, monkeypatch, tmp_path + self, work_dir, checkpoint_base, monkeypatch, tmp_path, ): - """The real bug scenario: user has global commit.gpgsign=true but GPG - is broken or pinentry is unavailable. Before the fix, every snapshot - either failed or spawned a pinentry window. After the fix, snapshots - succeed without ever invoking GPG.""" monkeypatch.setattr("tools.checkpoint_manager.CHECKPOINT_BASE", checkpoint_base) - - # Fake HOME with global gpgsign=true and a deliberately broken GPG - # binary. If isolation fails, the commit will try to exec this - # nonexistent path and the checkpoint will fail. fake_home = tmp_path / "fake_home" fake_home.mkdir() (fake_home / ".gitconfig").write_text( @@ -673,88 +711,57 @@ class TestGpgAndGlobalConfigIsolation: ) monkeypatch.setenv("HOME", str(fake_home)) monkeypatch.delenv("GPG_TTY", raising=False) - monkeypatch.delenv("DISPLAY", raising=False) # block GUI pinentry - - mgr = CheckpointManager(enabled=True) - assert mgr.ensure_checkpoint(str(work_dir), reason="with-global-gpgsign") is True - assert len(mgr.list_checkpoints(str(work_dir))) == 1 - - def test_checkpoint_works_on_prefix_shadow_without_local_gpgsign( - self, work_dir, checkpoint_base, monkeypatch, tmp_path - ): - """Users with shadow repos created before the fix will not have - commit.gpgsign=false in their shadow's own config. The inline - ``--no-gpg-sign`` flag on the commit call must cover them.""" - monkeypatch.setattr("tools.checkpoint_manager.CHECKPOINT_BASE", checkpoint_base) - - # Simulate a pre-fix shadow repo: init without commit.gpgsign=false - # in its own config. _init_shadow_repo now writes it, so we must - # manually remove it to mimic the pre-fix state. - shadow = _shadow_repo_path(str(work_dir)) - _init_shadow_repo(shadow, str(work_dir)) - subprocess.run( - ["git", "config", "--file", str(shadow / "config"), - "--unset", "commit.gpgsign"], - capture_output=True, text=True, check=False, - ) - subprocess.run( - ["git", "config", "--file", str(shadow / "config"), - "--unset", "tag.gpgSign"], - capture_output=True, text=True, check=False, - ) - - # And simulate hostile global config - fake_home = tmp_path / "fake_home" - fake_home.mkdir() - (fake_home / ".gitconfig").write_text( - "[commit]\n gpgsign = true\n" - "[gpg]\n program = /nonexistent/fake-gpg-binary\n" - ) - monkeypatch.setenv("HOME", str(fake_home)) - monkeypatch.delenv("GPG_TTY", raising=False) monkeypatch.delenv("DISPLAY", raising=False) - mgr = CheckpointManager(enabled=True) - assert mgr.ensure_checkpoint(str(work_dir), reason="prefix-shadow") is True - assert len(mgr.list_checkpoints(str(work_dir))) == 1 + m = CheckpointManager(enabled=True) + assert m.ensure_checkpoint(str(work_dir), reason="with-global-gpgsign") is True + assert len(m.list_checkpoints(str(work_dir))) == 1 # ========================================================================= -# Auto-maintenance: prune_checkpoints + maybe_auto_prune_checkpoints +# prune_checkpoints + maybe_auto_prune_checkpoints # ========================================================================= -class TestPruneCheckpoints: - """Sweep orphan/stale shadow repos under CHECKPOINT_BASE (issue #3015 follow-up).""" +def _seed_legacy_repo(base: Path, name: str, workdir: Path, mtime: float = None) -> Path: + """Create a minimal pre-v2 shadow repo directly under base.""" + shadow = base / name + shadow.mkdir(parents=True) + (shadow / "HEAD").write_text("ref: refs/heads/main\n") + (shadow / "HERMES_WORKDIR").write_text(str(workdir) + "\n") + (shadow / "info").mkdir() + (shadow / "info" / "exclude").write_text("node_modules/\n") + if mtime is not None: + for p in shadow.rglob("*"): + os.utime(p, (mtime, mtime)) + os.utime(shadow, (mtime, mtime)) + return shadow - def _seed_shadow_repo( - self, base: Path, dir_hash: str, workdir: Path, mtime: float = None - ) -> Path: - """Create a minimal shadow repo on disk without invoking real git.""" - import time as _time - shadow = base / dir_hash - shadow.mkdir(parents=True) - (shadow / "HEAD").write_text("ref: refs/heads/main\n") - (shadow / "HERMES_WORKDIR").write_text(str(workdir) + "\n") - (shadow / "info").mkdir() - (shadow / "info" / "exclude").write_text("node_modules/\n") - if mtime is not None: - for p in shadow.rglob("*"): - import os - os.utime(p, (mtime, mtime)) - import os - os.utime(shadow, (mtime, mtime)) - return shadow + +def _seed_v2_project(base: Path, workdir: Path, last_touch: float = None) -> str: + """Register a v2 project in the shared store (no commits, just metadata).""" + store = _store_path(base) + _init_store(store, str(workdir if workdir.exists() else base)) + dir_hash = _project_hash(str(workdir)) + meta = { + "workdir": str(workdir.resolve()) if workdir.exists() else str(workdir), + "created_at": (last_touch or time.time()), + "last_touch": (last_touch or time.time()), + } + mp = _project_meta_path(store, dir_hash) + mp.parent.mkdir(parents=True, exist_ok=True) + mp.write_text(json.dumps(meta)) + return dir_hash + + +class TestPruneCheckpointsLegacy: + """Backwards-compat: prune still handles pre-v2 per-project shadow repos.""" def test_deletes_orphan_when_workdir_missing(self, tmp_path): - from tools.checkpoint_manager import prune_checkpoints - base = tmp_path / "checkpoints" alive_work = tmp_path / "alive" alive_work.mkdir() - alive_repo = self._seed_shadow_repo(base, "aaaa" * 4, alive_work) - orphan_repo = self._seed_shadow_repo( - base, "bbbb" * 4, tmp_path / "was-deleted" - ) + alive_repo = _seed_legacy_repo(base, "aaaa" * 4, alive_work) + orphan_repo = _seed_legacy_repo(base, "bbbb" * 4, tmp_path / "was-deleted") result = prune_checkpoints(retention_days=0, checkpoint_base=base) @@ -764,58 +771,34 @@ class TestPruneCheckpoints: assert alive_repo.exists() assert not orphan_repo.exists() - def test_deletes_stale_by_mtime_when_workdir_alive(self, tmp_path): - from tools.checkpoint_manager import prune_checkpoints - import time as _time - + def test_deletes_stale_by_mtime(self, tmp_path): base = tmp_path / "checkpoints" work = tmp_path / "work" work.mkdir() - - fresh_repo = self._seed_shadow_repo(base, "cccc" * 4, work) + fresh_repo = _seed_legacy_repo(base, "cccc" * 4, work) stale_work = tmp_path / "stale_work" stale_work.mkdir() - old = _time.time() - 60 * 86400 # 60 days ago - stale_repo = self._seed_shadow_repo(base, "dddd" * 4, stale_work, mtime=old) + old = time.time() - 60 * 86400 + stale_repo = _seed_legacy_repo(base, "dddd" * 4, stale_work, mtime=old) result = prune_checkpoints( - retention_days=30, delete_orphans=False, checkpoint_base=base + retention_days=30, delete_orphans=False, checkpoint_base=base, ) - - assert result["deleted_orphan"] == 0 assert result["deleted_stale"] == 1 assert fresh_repo.exists() assert not stale_repo.exists() - def test_orphan_takes_priority_over_stale(self, tmp_path): - """Orphan detection counts first — reason="orphan" even if also stale.""" - from tools.checkpoint_manager import prune_checkpoints - import time as _time - - base = tmp_path / "checkpoints" - old = _time.time() - 60 * 86400 - self._seed_shadow_repo(base, "eeee" * 4, tmp_path / "gone", mtime=old) - - result = prune_checkpoints(retention_days=30, checkpoint_base=base) - assert result["deleted_orphan"] == 1 - assert result["deleted_stale"] == 0 - def test_delete_orphans_disabled_keeps_orphans(self, tmp_path): - from tools.checkpoint_manager import prune_checkpoints - base = tmp_path / "checkpoints" - orphan = self._seed_shadow_repo(base, "ffff" * 4, tmp_path / "gone") + orphan = _seed_legacy_repo(base, "ffff" * 4, tmp_path / "gone") result = prune_checkpoints( - retention_days=0, delete_orphans=False, checkpoint_base=base + retention_days=0, delete_orphans=False, checkpoint_base=base, ) assert result["deleted_orphan"] == 0 assert orphan.exists() def test_skips_non_shadow_dirs(self, tmp_path): - """Dirs without HEAD (non-initialised) are left alone.""" - from tools.checkpoint_manager import prune_checkpoints - base = tmp_path / "checkpoints" base.mkdir() (base / "garbage-dir").mkdir() @@ -825,42 +808,100 @@ class TestPruneCheckpoints: assert result["scanned"] == 0 assert (base / "garbage-dir").exists() - def test_tracks_bytes_freed(self, tmp_path): - from tools.checkpoint_manager import prune_checkpoints + def test_base_missing_returns_empty_counts(self, tmp_path): + result = prune_checkpoints(checkpoint_base=tmp_path / "does-not-exist") + assert result["scanned"] == 0 + assert result["deleted_orphan"] == 0 + +class TestPruneCheckpointsV2: + """v2 pruning walks the shared store's projects/ metadata.""" + + def test_deletes_orphan_project_entry(self, tmp_path, monkeypatch): base = tmp_path / "checkpoints" - orphan = self._seed_shadow_repo(base, "1234" * 4, tmp_path / "gone") - (orphan / "objects").mkdir() - (orphan / "objects" / "pack.bin").write_bytes(b"x" * 5000) + monkeypatch.setattr("tools.checkpoint_manager.CHECKPOINT_BASE", base) + + alive = tmp_path / "alive" + alive.mkdir() + (alive / "f").write_text("a") + gone = tmp_path / "was-gone" + gone.mkdir() + (gone / "g").write_text("b") + + m = CheckpointManager(enabled=True) + assert m.ensure_checkpoint(str(alive), "alive") is True + m.new_turn() + assert m.ensure_checkpoint(str(gone), "gone") is True + + # Simulate deletion of "gone" + import shutil as _shutil + _shutil.rmtree(gone) result = prune_checkpoints(retention_days=0, checkpoint_base=base) - assert result["deleted_orphan"] == 1 - assert result["bytes_freed"] >= 5000 - def test_base_missing_returns_empty_counts(self, tmp_path): - from tools.checkpoint_manager import prune_checkpoints + assert result["deleted_orphan"] >= 1 + # Alive project survives + alive_hash = _project_hash(str(alive)) + assert (base / "store" / "projects" / f"{alive_hash}.json").exists() + # Gone project metadata wiped + gone_hash = _project_hash(str(gone)) + assert not (base / "store" / "projects" / f"{gone_hash}.json").exists() - result = prune_checkpoints(checkpoint_base=tmp_path / "does-not-exist") - assert result == { - "scanned": 0, "deleted_orphan": 0, "deleted_stale": 0, - "errors": 0, "bytes_freed": 0, - } + def test_deletes_stale_project_by_last_touch(self, tmp_path, monkeypatch): + base = tmp_path / "checkpoints" + monkeypatch.setattr("tools.checkpoint_manager.CHECKPOINT_BASE", base) + + fresh = tmp_path / "fresh" + fresh.mkdir() + (fresh / "f").write_text("f") + stale = tmp_path / "stale" + stale.mkdir() + (stale / "s").write_text("s") + + m = CheckpointManager(enabled=True) + m.ensure_checkpoint(str(fresh), "fresh") + m.new_turn() + m.ensure_checkpoint(str(stale), "stale") + + # Backdate stale's last_touch to 60 days ago + stale_hash = _project_hash(str(stale)) + meta_path = base / "store" / "projects" / f"{stale_hash}.json" + meta = json.loads(meta_path.read_text()) + meta["last_touch"] = time.time() - 60 * 86400 + meta_path.write_text(json.dumps(meta)) + + result = prune_checkpoints( + retention_days=30, delete_orphans=False, checkpoint_base=base, + ) + + assert result["deleted_stale"] >= 1 + fresh_hash = _project_hash(str(fresh)) + assert (base / "store" / "projects" / f"{fresh_hash}.json").exists() + assert not meta_path.exists() + + def test_legacy_archive_dirs_also_pruned(self, tmp_path, monkeypatch): + """legacy-/ dirs older than retention_days get wiped.""" + base = tmp_path / "checkpoints" + base.mkdir() + monkeypatch.setattr("tools.checkpoint_manager.CHECKPOINT_BASE", base) + + old_legacy = base / "legacy-20200101-000000" + old_legacy.mkdir() + (old_legacy / "junk").write_bytes(b"x" * 1000) + old = time.time() - 60 * 86400 + for p in old_legacy.rglob("*"): + os.utime(p, (old, old)) + os.utime(old_legacy, (old, old)) + + result = prune_checkpoints(retention_days=7, checkpoint_base=base) + assert result["deleted_stale"] >= 1 + assert not old_legacy.exists() class TestMaybeAutoPruneCheckpoints: - def _seed(self, base, dir_hash, workdir): - base.mkdir(parents=True, exist_ok=True) - shadow = base / dir_hash - shadow.mkdir() - (shadow / "HEAD").write_text("ref: refs/heads/main\n") - (shadow / "HERMES_WORKDIR").write_text(str(workdir) + "\n") - return shadow - def test_first_call_prunes_and_writes_marker(self, tmp_path): - from tools.checkpoint_manager import maybe_auto_prune_checkpoints - base = tmp_path / "checkpoints" - self._seed(base, "0000" * 4, tmp_path / "gone") + _seed_legacy_repo(base, "0000" * 4, tmp_path / "gone") out = maybe_auto_prune_checkpoints(checkpoint_base=base) assert out["skipped"] is False @@ -868,42 +909,107 @@ class TestMaybeAutoPruneCheckpoints: assert (base / ".last_prune").exists() def test_second_call_within_interval_skips(self, tmp_path): - from tools.checkpoint_manager import maybe_auto_prune_checkpoints - base = tmp_path / "checkpoints" - self._seed(base, "1111" * 4, tmp_path / "gone") + _seed_legacy_repo(base, "1111" * 4, tmp_path / "gone") first = maybe_auto_prune_checkpoints( - checkpoint_base=base, min_interval_hours=24 + checkpoint_base=base, min_interval_hours=24, ) assert first["skipped"] is False - self._seed(base, "2222" * 4, tmp_path / "also-gone") + _seed_legacy_repo(base, "2222" * 4, tmp_path / "also-gone") second = maybe_auto_prune_checkpoints( - checkpoint_base=base, min_interval_hours=24 + checkpoint_base=base, min_interval_hours=24, ) assert second["skipped"] is True - # The second orphan must still exist — skip was honoured. assert (base / ("2222" * 4)).exists() def test_corrupt_marker_treated_as_no_prior_run(self, tmp_path): - from tools.checkpoint_manager import maybe_auto_prune_checkpoints - base = tmp_path / "checkpoints" base.mkdir() (base / ".last_prune").write_text("not-a-timestamp") - self._seed(base, "3333" * 4, tmp_path / "gone") + _seed_legacy_repo(base, "3333" * 4, tmp_path / "gone") out = maybe_auto_prune_checkpoints(checkpoint_base=base) assert out["skipped"] is False assert out["result"]["deleted_orphan"] == 1 def test_missing_base_no_raise(self, tmp_path): - from tools.checkpoint_manager import maybe_auto_prune_checkpoints - out = maybe_auto_prune_checkpoints( - checkpoint_base=tmp_path / "does-not-exist" + checkpoint_base=tmp_path / "does-not-exist", ) assert out["skipped"] is False assert out["result"]["scanned"] == 0 + +# ========================================================================= +# store_status / clear_all / clear_legacy +# ========================================================================= + +class TestStoreStatus: + def test_empty_base(self, tmp_path, monkeypatch): + base = tmp_path / "checkpoints" + monkeypatch.setattr("tools.checkpoint_manager.CHECKPOINT_BASE", base) + info = store_status() + assert info["project_count"] == 0 + assert info["total_size_bytes"] == 0 + + def test_reports_projects_and_legacy(self, tmp_path, monkeypatch, work_dir): + base = tmp_path / "checkpoints" + monkeypatch.setattr("tools.checkpoint_manager.CHECKPOINT_BASE", base) + + m = CheckpointManager(enabled=True) + m.ensure_checkpoint(str(work_dir), "initial") + + # Add a legacy archive dir manually + legacy = base / "legacy-20200101-000000" + legacy.mkdir() + (legacy / "junk").write_bytes(b"x" * 100) + + info = store_status() + assert info["project_count"] == 1 + assert info["projects"][0]["workdir"] == str(work_dir.resolve()) + assert info["projects"][0]["commits"] >= 1 + assert info["projects"][0]["exists"] is True + assert len(info["legacy_archives"]) == 1 + assert info["legacy_archives"][0]["size_bytes"] >= 100 + + +class TestClearFunctions: + def test_clear_all_wipes_base(self, tmp_path, monkeypatch, work_dir): + base = tmp_path / "checkpoints" + monkeypatch.setattr("tools.checkpoint_manager.CHECKPOINT_BASE", base) + m = CheckpointManager(enabled=True) + m.ensure_checkpoint(str(work_dir), "initial") + assert base.exists() + + result = clear_all() + assert result["deleted"] is True + assert result["bytes_freed"] > 0 + assert not base.exists() + + def test_clear_legacy_only_removes_legacy_dirs( + self, tmp_path, monkeypatch, work_dir, + ): + base = tmp_path / "checkpoints" + monkeypatch.setattr("tools.checkpoint_manager.CHECKPOINT_BASE", base) + m = CheckpointManager(enabled=True) + m.ensure_checkpoint(str(work_dir), "initial") + + legacy = base / "legacy-20200101-000000" + legacy.mkdir() + (legacy / "junk").write_bytes(b"x" * 1000) + + result = clear_legacy() + assert result["deleted"] == 1 + assert result["bytes_freed"] >= 1000 + assert not legacy.exists() + # Store preserved + assert (base / "store" / "HEAD").exists() + + def test_clear_all_on_missing_base_is_noop(self, tmp_path, monkeypatch): + base = tmp_path / "does-not-exist" + monkeypatch.setattr("tools.checkpoint_manager.CHECKPOINT_BASE", base) + result = clear_all() + assert result["deleted"] is False + assert result["bytes_freed"] == 0 diff --git a/tools/checkpoint_manager.py b/tools/checkpoint_manager.py index dbeb2554ff..15b106f512 100644 --- a/tools/checkpoint_manager.py +++ b/tools/checkpoint_manager.py @@ -1,32 +1,64 @@ """ -Checkpoint Manager — Transparent filesystem snapshots via shadow git repos. +Checkpoint Manager — Transparent filesystem snapshots via a single shared +shadow git store. Creates automatic snapshots of working directories before file-mutating -operations (write_file, patch), triggered once per conversation turn. -Provides rollback to any previous checkpoint. +operations (``write_file``, ``patch``, ``terminal`` with destructive flags), +triggered once per conversation turn. Provides rollback to any previous +checkpoint. This is NOT a tool — the LLM never sees it. It's transparent infrastructure controlled by the ``checkpoints`` config flag or ``--checkpoints`` CLI flag. -Architecture: - ~/.hermes/checkpoints/{sha256(abs_dir)[:16]}/ — shadow git repo - HEAD, refs/, objects/ — standard git internals - HERMES_WORKDIR — original dir path - info/exclude — default excludes +Storage layout (single shared store, git objects deduplicated across projects) +----------------------------------------------------------------------------- -The shadow repo uses GIT_DIR + GIT_WORK_TREE so no git state leaks -into the user's project directory. + ~/.hermes/checkpoints/ + store/ — single bare-ish git repo + HEAD, config, objects/ — standard git internals (shared) + refs/hermes/ — per-project branch tip + indexes/ — per-project git index + projects/.json — {workdir, created_at, last_touch} + info/exclude — default excludes (shared) + .last_prune — auto-prune idempotency marker + legacy-/ — archived pre-v2 per-project shadow + repos (auto-migrated on first init) + +Why a single store? +------------------- + +The pre-v2 design kept a full shadow repo per working directory. Each one +re-stored most of the project's files under its own ``objects/`` tree, with +zero sharing across worktrees of the same project. A single user with a +dozen worktrees of the same repo burned ~40 MB each (~500 MB total) storing +the same blobs over and over. A single shared store lets git's content- +addressable object DB deduplicate across projects and across turns, so adding +a new worktree costs near-zero. + +The shadow store uses ``GIT_DIR`` + ``GIT_WORK_TREE`` + ``GIT_INDEX_FILE`` +so no git state leaks into the user's project directory. + +Auto-maintenance +---------------- + +Shadow state accumulates over time. ``prune_checkpoints`` deletes refs whose +recorded working directory no longer exists (orphan) or whose last touch is +older than ``retention_days`` (stale), then runs ``git gc --prune=now`` to +reclaim object storage. A size-cap pass drops the oldest checkpoints per +project until total store size is under ``max_total_size_mb``. """ import hashlib +import json import logging import os import re import shutil import subprocess +import time from pathlib import Path from hermes_constants import get_hermes_home -from typing import Dict, List, Optional, Set +from typing import Dict, List, Optional, Set, Tuple logger = logging.getLogger(__name__) @@ -36,27 +68,74 @@ logger = logging.getLogger(__name__) CHECKPOINT_BASE = get_hermes_home() / "checkpoints" +# Single shared store directory under CHECKPOINT_BASE. +_STORE_DIRNAME = "store" +_REFS_PREFIX = "refs/hermes" +_INDEXES_DIRNAME = "indexes" +_PROJECTS_DIRNAME = "projects" +_LEGACY_PREFIX = "legacy-" + DEFAULT_EXCLUDES = [ + # Dependency / build output "node_modules/", "dist/", "build/", + "target/", + "out/", + ".next/", + ".nuxt/", + # Caches + "__pycache__/", + "*.pyc", + "*.pyo", + ".cache/", + ".pytest_cache/", + ".mypy_cache/", + ".ruff_cache/", + "coverage/", + ".coverage", + # Virtualenvs + ".venv/", + "venv/", + "env/", + # VCS + ".git/", + ".hg/", + ".svn/", + # Worktrees (Hermes convention — don't recursively snapshot siblings) + ".worktrees/", + # Native / compiled binaries + "*.so", + "*.dylib", + "*.dll", + "*.o", + "*.a", + "*.jar", + "*.class", + "*.exe", + "*.obj", + # Media / large binaries + "*.mp4", + "*.mov", + "*.mkv", + "*.webm", + "*.zip", + "*.tar", + "*.tar.gz", + "*.tgz", + "*.7z", + "*.rar", + "*.iso", + # Secrets ".env", ".env.*", ".env.local", ".env.*.local", - "__pycache__/", - "*.pyc", - "*.pyo", + # OS junk ".DS_Store", + "Thumbs.db", + # Logs "*.log", - ".cache/", - ".next/", - ".nuxt/", - "coverage/", - ".pytest_cache/", - ".venv/", - "venv/", - ".git/", ] # Git subprocess timeout (seconds). @@ -96,10 +175,8 @@ def _validate_file_path(file_path: str, working_dir: str) -> Optional[str]: """ if not file_path or not file_path.strip(): return "Empty file path" - # Reject absolute paths — restore targets must be relative to the workdir if os.path.isabs(file_path): return f"File path must be relative, got absolute path: {file_path!r}" - # Resolve and check containment within working_dir abs_workdir = _normalize_path(working_dir) resolved = (abs_workdir / file_path).resolve() try: @@ -110,7 +187,7 @@ def _validate_file_path(file_path: str, working_dir: str) -> Optional[str]: # --------------------------------------------------------------------------- -# Shadow repo helpers +# Path / hash helpers # --------------------------------------------------------------------------- def _normalize_path(path_value: str) -> Path: @@ -118,17 +195,52 @@ def _normalize_path(path_value: str) -> Path: return Path(path_value).expanduser().resolve() -def _shadow_repo_path(working_dir: str) -> Path: - """Deterministic shadow repo path: sha256(abs_path)[:16].""" +def _project_hash(working_dir: str) -> str: + """Deterministic per-project hash: sha256(abs_path)[:16].""" abs_path = str(_normalize_path(working_dir)) - dir_hash = hashlib.sha256(abs_path.encode()).hexdigest()[:16] - return CHECKPOINT_BASE / dir_hash + return hashlib.sha256(abs_path.encode()).hexdigest()[:16] -def _git_env(shadow_repo: Path, working_dir: str) -> dict: - """Build env dict that redirects git to the shadow repo. +def _store_path(base: Optional[Path] = None) -> Path: + """Return the single shared shadow store path.""" + return (base or CHECKPOINT_BASE) / _STORE_DIRNAME - The shadow repo is internal Hermes infrastructure — it must NOT inherit + +def _shadow_repo_path(working_dir: str) -> Path: # pragma: no cover — kept for BC + """Return the shared store path. + + Retained for backward-compatibility with callers / tests that imported + this helper. Under v2 the shadow git storage is shared across all + projects — per-project isolation lives in refs and indexes, not in + separate repo directories. + """ + return _store_path() + + +def _index_path(store: Path, dir_hash: str) -> Path: + return store / _INDEXES_DIRNAME / dir_hash + + +def _ref_name(dir_hash: str) -> str: + return f"{_REFS_PREFIX}/{dir_hash}" + + +def _project_meta_path(store: Path, dir_hash: str) -> Path: + return store / _PROJECTS_DIRNAME / f"{dir_hash}.json" + + +# --------------------------------------------------------------------------- +# Git env +# --------------------------------------------------------------------------- + +def _git_env( + store: Path, + working_dir: str, + index_file: Optional[Path] = None, +) -> dict: + """Build env dict that redirects git to the shared store. + + The shared store is internal Hermes infrastructure — it must NOT inherit the user's global or system git config. User-level settings like ``commit.gpgsign = true``, signing hooks, or credential helpers would either break background snapshots or, worse, spawn interactive prompts @@ -139,20 +251,19 @@ def _git_env(shadow_repo: Path, working_dir: str) -> dict: * ``GIT_CONFIG_SYSTEM=`` — ignore ``/etc/gitconfig`` (git 2.32+). * ``GIT_CONFIG_NOSYSTEM=1`` — legacy belt-and-suspenders for older git. - The shadow repo still has its own per-repo config (user.email, user.name, - commit.gpgsign=false) set in ``_init_shadow_repo``. + ``index_file``, if given, forces git to use a per-project index under + ``store/indexes/`` so projects don't race on a shared index. """ normalized_working_dir = _normalize_path(working_dir) env = os.environ.copy() - env["GIT_DIR"] = str(shadow_repo) + env["GIT_DIR"] = str(store) env["GIT_WORK_TREE"] = str(normalized_working_dir) - env.pop("GIT_INDEX_FILE", None) env.pop("GIT_NAMESPACE", None) env.pop("GIT_ALTERNATE_OBJECT_DIRECTORIES", None) - # Isolate the shadow repo from the user's global/system git config. - # Prevents commit.gpgsign, hooks, aliases, credential helpers, etc. from - # leaking into background snapshots. Uses os.devnull for cross-platform - # support (``/dev/null`` on POSIX, ``nul`` on Windows). + if index_file is not None: + env["GIT_INDEX_FILE"] = str(index_file) + else: + env.pop("GIT_INDEX_FILE", None) env["GIT_CONFIG_GLOBAL"] = os.devnull env["GIT_CONFIG_SYSTEM"] = os.devnull env["GIT_CONFIG_NOSYSTEM"] = "1" @@ -161,12 +272,13 @@ def _git_env(shadow_repo: Path, working_dir: str) -> dict: def _run_git( args: List[str], - shadow_repo: Path, + store: Path, working_dir: str, timeout: int = _GIT_TIMEOUT, allowed_returncodes: Optional[Set[int]] = None, -) -> tuple: - """Run a git command against the shadow repo. Returns (ok, stdout, stderr). + index_file: Optional[Path] = None, +) -> Tuple[bool, str, str]: + """Run a git command against the shared store. Returns (ok, stdout, stderr). ``allowed_returncodes`` suppresses error logging for known/expected non-zero exits while preserving the normal ``ok = (returncode == 0)`` contract. @@ -182,7 +294,7 @@ def _run_git( logger.error("Git command skipped: %s (%s)", " ".join(["git"] + list(args)), msg) return False, "", msg - env = _git_env(shadow_repo, str(normalized_working_dir)) + env = _git_env(store, str(normalized_working_dir), index_file=index_file) cmd = ["git"] + list(args) allowed_returncodes = allowed_returncodes or set() try: @@ -220,41 +332,184 @@ def _run_git( return False, "", str(exc) -def _init_shadow_repo(shadow_repo: Path, working_dir: str) -> Optional[str]: - """Initialise shadow repo if needed. Returns error string or None.""" - if (shadow_repo / "HEAD").exists(): +# --------------------------------------------------------------------------- +# Store initialisation + legacy migration +# --------------------------------------------------------------------------- + +def _migrate_legacy_store(base: Path) -> Optional[Path]: + """Move pre-v2 per-project shadow repos into a ``legacy-/`` dir. + + The pre-v2 layout had one shadow git repo per working directory directly + under ``CHECKPOINT_BASE``. The v2 layout wants a single ``store/`` dir. + Rather than delete the old data (users might want to recover), rename + everything except our own v2 entries into ``legacy-/``. The + legacy dir is subject to the same retention sweep and can be manually + cleared with ``hermes checkpoints clear-legacy``. + + Returns the legacy-archive path, or None if nothing to migrate. + """ + if not base.exists(): + return None + store = _store_path(base) + legacy_root: Optional[Path] = None + # Reserved top-level entries managed by v2. + reserved = {_STORE_DIRNAME, _PRUNE_MARKER_NAME} + for child in list(base.iterdir()): + name = child.name + if name in reserved or name.startswith(_LEGACY_PREFIX): + continue + # Candidate: pre-v2 shadow repo (has HEAD) OR stray dir. Either way + # we archive it so v2 starts clean. + if legacy_root is None: + stamp = time.strftime("%Y%m%d-%H%M%S") + legacy_root = base / f"{_LEGACY_PREFIX}{stamp}" + try: + legacy_root.mkdir(parents=True, exist_ok=True) + except OSError as exc: + logger.warning("Could not create legacy archive dir: %s", exc) + return None + dest = legacy_root / name + try: + shutil.move(str(child), str(dest)) + except OSError as exc: + logger.warning("Could not archive legacy checkpoint %s: %s", child, exc) + # If the store still hasn't been created, create it here. + _ = store + if legacy_root is not None: + logger.info( + "Migrated pre-v2 checkpoint repos to %s. " + "Clear with `hermes checkpoints clear-legacy` when safe.", + legacy_root, + ) + return legacy_root + + +def _init_store(store: Path, working_dir: str) -> Optional[str]: + """Initialise the shared shadow store if needed. Returns error or None. + + Also performs one-time migration of pre-v2 per-directory shadow repos + into ``legacy-/``. + """ + base = store.parent + # One-time legacy migration before we create the store. + if not store.exists(): + try: + base.mkdir(parents=True, exist_ok=True) + except OSError as exc: + return f"Could not create checkpoint base: {exc}" + # Only migrate if the base dir has pre-existing content that isn't + # our own v2 layout. + _migrate_legacy_store(base) + + if (store / "HEAD").exists(): return None - shadow_repo.mkdir(parents=True, exist_ok=True) + store.mkdir(parents=True, exist_ok=True) + (store / _INDEXES_DIRNAME).mkdir(exist_ok=True) + (store / _PROJECTS_DIRNAME).mkdir(exist_ok=True) - ok, _, err = _run_git(["init"], shadow_repo, working_dir) - if not ok: - return f"Shadow repo init failed: {err}" + # ``git init --bare`` rejects GIT_WORK_TREE, so we can't use _run_git + # here (which always sets GIT_DIR + GIT_WORK_TREE). Use a raw + # subprocess with just the config-isolation env vars. + init_env = os.environ.copy() + init_env["GIT_CONFIG_GLOBAL"] = os.devnull + init_env["GIT_CONFIG_SYSTEM"] = os.devnull + init_env["GIT_CONFIG_NOSYSTEM"] = "1" + # Drop any inherited GIT_* that would interfere. + for k in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_NAMESPACE", + "GIT_ALTERNATE_OBJECT_DIRECTORIES"): + init_env.pop(k, None) + try: + result = subprocess.run( + ["git", "init", "--bare", str(store)], + capture_output=True, text=True, + env=init_env, timeout=_GIT_TIMEOUT, + ) + if result.returncode != 0: + return f"Shadow store init failed: {result.stderr.strip()}" + except (subprocess.TimeoutExpired, FileNotFoundError) as exc: + return f"Shadow store init failed: {exc}" - _run_git(["config", "user.email", "hermes@local"], shadow_repo, working_dir) - _run_git(["config", "user.name", "Hermes Checkpoint"], shadow_repo, working_dir) - # Explicitly disable commit/tag signing in the shadow repo. _git_env - # already isolates from the user's global config, but writing these into - # the shadow's own config is belt-and-suspenders — it guarantees the - # shadow repo is correct even if someone inspects or runs git against it - # directly (without the GIT_CONFIG_* env vars). - _run_git(["config", "commit.gpgsign", "false"], shadow_repo, working_dir) - _run_git(["config", "tag.gpgSign", "false"], shadow_repo, working_dir) + # Per-store config (isolated by env vars above, but belt-and-suspenders). + # Use the base dir as the working_dir for config commands — it always + # exists since we just created the store inside it. + cfg_wd = str(base) + _run_git(["config", "user.email", "hermes@local"], store, cfg_wd) + _run_git(["config", "user.name", "Hermes Checkpoint"], store, cfg_wd) + _run_git(["config", "commit.gpgsign", "false"], store, cfg_wd) + _run_git(["config", "tag.gpgSign", "false"], store, cfg_wd) + _run_git(["config", "gc.auto", "0"], store, cfg_wd) - info_dir = shadow_repo / "info" + info_dir = store / "info" info_dir.mkdir(exist_ok=True) (info_dir / "exclude").write_text( "\n".join(DEFAULT_EXCLUDES) + "\n", encoding="utf-8" ) - (shadow_repo / "HERMES_WORKDIR").write_text( - str(_normalize_path(working_dir)) + "\n", encoding="utf-8" - ) - - logger.debug("Initialised checkpoint repo at %s for %s", shadow_repo, working_dir) + logger.debug("Initialised checkpoint store at %s", store) return None +def _register_project(store: Path, working_dir: str) -> None: + """Create or update ``projects/.json`` with workdir + timestamps.""" + dir_hash = _project_hash(working_dir) + meta_path = _project_meta_path(store, dir_hash) + now = time.time() + meta: Dict = {"workdir": str(_normalize_path(working_dir)), + "created_at": now, "last_touch": now} + if meta_path.exists(): + try: + existing = json.loads(meta_path.read_text(encoding="utf-8")) + if isinstance(existing, dict): + meta["created_at"] = existing.get("created_at", now) + except (OSError, ValueError): + pass + try: + meta_path.parent.mkdir(parents=True, exist_ok=True) + meta_path.write_text(json.dumps(meta), encoding="utf-8") + except OSError as exc: + logger.debug("Could not write project metadata %s: %s", meta_path, exc) + + +def _touch_project(store: Path, working_dir: str) -> None: + """Update last_touch for a project, preserving created_at.""" + dir_hash = _project_hash(working_dir) + meta_path = _project_meta_path(store, dir_hash) + if not meta_path.exists(): + _register_project(store, working_dir) + return + try: + meta = json.loads(meta_path.read_text(encoding="utf-8")) + except (OSError, ValueError): + meta = {} + meta["workdir"] = str(_normalize_path(working_dir)) + meta["last_touch"] = time.time() + meta.setdefault("created_at", meta["last_touch"]) + try: + meta_path.write_text(json.dumps(meta), encoding="utf-8") + except OSError as exc: + logger.debug("Could not update project metadata %s: %s", meta_path, exc) + + +def _list_projects(store: Path) -> List[Dict]: + """Return all registered projects under the store.""" + projects_dir = store / _PROJECTS_DIRNAME + if not projects_dir.exists(): + return [] + out: List[Dict] = [] + for meta_path in projects_dir.glob("*.json"): + dir_hash = meta_path.stem + try: + meta = json.loads(meta_path.read_text(encoding="utf-8")) + except (OSError, ValueError): + continue + if not isinstance(meta, dict): + continue + meta["_hash"] = dir_hash + out.append(meta) + return out + + def _dir_file_count(path: str) -> int: """Quick file count estimate (stops early if over _MAX_FILES).""" count = 0 @@ -268,6 +523,49 @@ def _dir_file_count(path: str) -> int: return count +def _dir_size_bytes(path: Path) -> int: + """Best-effort recursive size in bytes. Returns 0 on error.""" + total = 0 + try: + for p in path.rglob("*"): + try: + if p.is_file(): + total += p.stat().st_size + except OSError: + continue + except OSError: + pass + return total + + +# Backwards-compatibility shim — some tests import ``_init_shadow_repo`` and +# look for ``HEAD``/``info/exclude``/``HERMES_WORKDIR``. In v2 we also write +# those markers, but inside the shared store + under ``projects/.json``. +# The shim initialises the store and registers the project so the old +# surface keeps roughly the same shape. +def _init_shadow_repo(shadow_repo: Path, working_dir: str) -> Optional[str]: + """Backwards-compatible initialiser. + + In v1 ``shadow_repo`` was a per-project dir; in v2 it's the shared + ``store/`` path (or a test path that we respect). We initialise the + store at ``shadow_repo``, create per-project markers, and return None + on success. + """ + err = _init_store(shadow_repo, working_dir) + if err: + return err + _register_project(shadow_repo, working_dir) + # Compat marker for tests that look at HERMES_WORKDIR + # (write in addition to the JSON metadata). + try: + (shadow_repo / "HERMES_WORKDIR").write_text( + str(_normalize_path(working_dir)) + "\n", encoding="utf-8" + ) + except OSError: + pass + return None + + # --------------------------------------------------------------------------- # CheckpointManager # --------------------------------------------------------------------------- @@ -286,11 +584,25 @@ class CheckpointManager: Master switch (from config / CLI flag). max_snapshots : int Keep at most this many checkpoints per directory. + max_total_size_mb : int + Hard ceiling on total store size. Oldest checkpoints per project + are dropped when the store exceeds this after a commit. + max_file_size_mb : int + Skip adding any single file larger than this to a checkpoint. + (Implemented via ``.gitignore`` excludes + a post-stage size check.) """ - def __init__(self, enabled: bool = False, max_snapshots: int = 50): + def __init__( + self, + enabled: bool = False, + max_snapshots: int = 20, + max_total_size_mb: int = 500, + max_file_size_mb: int = 10, + ): self.enabled = enabled - self.max_snapshots = max_snapshots + self.max_snapshots = max(1, int(max_snapshots)) + self.max_total_size_mb = max(0, int(max_total_size_mb)) + self.max_file_size_mb = max(0, int(max_file_size_mb)) self._checkpointed_dirs: Set[str] = set() self._git_available: Optional[bool] = None # lazy probe @@ -315,7 +627,6 @@ class CheckpointManager: if not self.enabled: return False - # Lazy git probe if self._git_available is None: self._git_available = shutil.which("git") is not None if not self._git_available: @@ -330,7 +641,6 @@ class CheckpointManager: logger.debug("Checkpoint skipped: directory too broad (%s)", abs_dir) return False - # Already checkpointed this turn? if abs_dir in self._checkpointed_dirs: return False @@ -343,26 +653,24 @@ class CheckpointManager: return False def list_checkpoints(self, working_dir: str) -> List[Dict]: - """List available checkpoints for a directory. - - Returns a list of dicts with keys: hash, short_hash, timestamp, reason, - files_changed, insertions, deletions. Most recent first. - """ + """List available checkpoints for a directory (most recent first).""" abs_dir = str(_normalize_path(working_dir)) - shadow = _shadow_repo_path(abs_dir) + store = _store_path(CHECKPOINT_BASE) - if not (shadow / "HEAD").exists(): + if not (store / "HEAD").exists(): return [] + ref = _ref_name(_project_hash(abs_dir)) ok, stdout, _ = _run_git( - ["log", "--format=%H|%h|%aI|%s", "-n", str(self.max_snapshots)], - shadow, abs_dir, + ["log", ref, f"--format=%H|%h|%aI|%s", "-n", str(self.max_snapshots)], + store, abs_dir, + allowed_returncodes={128, 129}, ) if not ok or not stdout: return [] - results = [] + results: List[Dict] = [] for line in stdout.splitlines(): parts = line.split("|", 3) if len(parts) == 4: @@ -375,11 +683,10 @@ class CheckpointManager: "insertions": 0, "deletions": 0, } - # Get diffstat for this commit stat_ok, stat_out, _ = _run_git( ["diff", "--shortstat", f"{parts[0]}~1", parts[0]], - shadow, abs_dir, - allowed_returncodes={128, 129}, # first commit has no parent + store, abs_dir, + allowed_returncodes={128, 129}, ) if stat_ok and stat_out: self._parse_shortstat(stat_out, entry) @@ -400,45 +707,45 @@ class CheckpointManager: entry["deletions"] = int(m.group(1)) def diff(self, working_dir: str, commit_hash: str) -> Dict: - """Show diff between a checkpoint and the current working tree. - - Returns dict with success, diff text, and stat summary. - """ - # Validate commit_hash to prevent git argument injection + """Show diff between a checkpoint and the current working tree.""" hash_err = _validate_commit_hash(commit_hash) if hash_err: return {"success": False, "error": hash_err} abs_dir = str(_normalize_path(working_dir)) - shadow = _shadow_repo_path(abs_dir) + store = _store_path(CHECKPOINT_BASE) - if not (shadow / "HEAD").exists(): + if not (store / "HEAD").exists(): return {"success": False, "error": "No checkpoints exist for this directory"} - # Verify the commit exists ok, _, err = _run_git( - ["cat-file", "-t", commit_hash], shadow, abs_dir, + ["cat-file", "-t", commit_hash], store, abs_dir, ) if not ok: return {"success": False, "error": f"Checkpoint '{commit_hash}' not found"} - # Stage current state to compare against checkpoint - _run_git(["add", "-A"], shadow, abs_dir, timeout=_GIT_TIMEOUT * 2) + dir_hash = _project_hash(abs_dir) + index_file = _index_path(store, dir_hash) + + # Stage current state into the per-project index to compare. + _run_git(["add", "-A"], store, abs_dir, + timeout=_GIT_TIMEOUT * 2, index_file=index_file) - # Get stat summary: checkpoint vs current working tree ok_stat, stat_out, _ = _run_git( ["diff", "--stat", commit_hash, "--cached"], - shadow, abs_dir, + store, abs_dir, index_file=index_file, ) - - # Get actual diff (limited to avoid terminal flood) ok_diff, diff_out, _ = _run_git( ["diff", commit_hash, "--cached", "--no-color"], - shadow, abs_dir, + store, abs_dir, index_file=index_file, ) - # Unstage to avoid polluting the shadow repo index - _run_git(["reset", "HEAD", "--quiet"], shadow, abs_dir) + # Reset staged tree back to the project's last checkpoint so the + # index doesn't drift out of sync with the ref. + ref = _ref_name(dir_hash) + _run_git(["read-tree", ref], store, abs_dir, + index_file=index_file, + allowed_returncodes={128}) if not ok_stat and not ok_diff: return {"success": False, "error": "Could not generate diff"} @@ -450,59 +757,49 @@ class CheckpointManager: } def restore(self, working_dir: str, commit_hash: str, file_path: str = None) -> Dict: - """Restore files to a checkpoint state. - - Uses ``git checkout -- .`` (or a specific file) which restores - tracked files without moving HEAD — safe and reversible. - - Parameters - ---------- - file_path : str, optional - If provided, restore only this file instead of the entire directory. - - Returns dict with success/error info. - """ - # Validate commit_hash to prevent git argument injection + """Restore files to a checkpoint state.""" hash_err = _validate_commit_hash(commit_hash) if hash_err: return {"success": False, "error": hash_err} abs_dir = str(_normalize_path(working_dir)) - # Validate file_path to prevent path traversal outside the working dir if file_path: path_err = _validate_file_path(file_path, abs_dir) if path_err: return {"success": False, "error": path_err} - shadow = _shadow_repo_path(abs_dir) + store = _store_path(CHECKPOINT_BASE) - if not (shadow / "HEAD").exists(): + if not (store / "HEAD").exists(): return {"success": False, "error": "No checkpoints exist for this directory"} - # Verify the commit exists ok, _, err = _run_git( - ["cat-file", "-t", commit_hash], shadow, abs_dir, + ["cat-file", "-t", commit_hash], store, abs_dir, ) if not ok: - return {"success": False, "error": f"Checkpoint '{commit_hash}' not found", "debug": err or None} + return {"success": False, "error": f"Checkpoint '{commit_hash}' not found", + "debug": err or None} - # Take a checkpoint of current state before restoring (so you can undo the undo) + # Take a pre-rollback snapshot so you can undo the undo. self._take(abs_dir, f"pre-rollback snapshot (restoring to {commit_hash[:8]})") - # Restore — full directory or single file + dir_hash = _project_hash(abs_dir) + index_file = _index_path(store, dir_hash) + restore_target = file_path if file_path else "." ok, stdout, err = _run_git( ["checkout", commit_hash, "--", restore_target], - shadow, abs_dir, timeout=_GIT_TIMEOUT * 2, + store, abs_dir, timeout=_GIT_TIMEOUT * 2, + index_file=index_file, ) if not ok: - return {"success": False, "error": f"Restore failed: {err}", "debug": err or None} + return {"success": False, "error": f"Restore failed: {err}", + "debug": err or None} - # Get info about what was restored ok2, reason_out, _ = _run_git( - ["log", "--format=%s", "-1", commit_hash], shadow, abs_dir, + ["log", "--format=%s", "-1", commit_hash], store, abs_dir, ) reason = reason_out if ok2 else "unknown" @@ -517,19 +814,13 @@ class CheckpointManager: return result def get_working_dir_for_path(self, file_path: str) -> str: - """Resolve a file path to its working directory for checkpointing. - - Walks up from the file's parent to find a reasonable project root - (directory containing .git, pyproject.toml, package.json, etc.). - Falls back to the file's parent directory. - """ + """Resolve a file path to its working directory for checkpointing.""" path = _normalize_path(file_path) if path.is_dir(): candidate = path else: candidate = path.parent - # Walk up looking for project root markers markers = {".git", "pyproject.toml", "package.json", "Cargo.toml", "go.mod", "Makefile", "pom.xml", ".hg", "Gemfile"} check = candidate @@ -538,7 +829,6 @@ class CheckpointManager: return str(check) check = check.parent - # No project root found — use the file's parent return str(candidate) # ------------------------------------------------------------------ @@ -547,79 +837,336 @@ class CheckpointManager: def _take(self, working_dir: str, reason: str) -> bool: """Take a snapshot. Returns True on success.""" - shadow = _shadow_repo_path(working_dir) + store = _store_path(CHECKPOINT_BASE) - # Init if needed - err = _init_shadow_repo(shadow, working_dir) + err = _init_store(store, working_dir) if err: - logger.debug("Checkpoint init failed: %s", err) + logger.debug("Checkpoint store init failed: %s", err) return False + _touch_project(store, working_dir) + # Quick size guard — don't try to snapshot enormous directories if _dir_file_count(working_dir) > _MAX_FILES: logger.debug("Checkpoint skipped: >%d files in %s", _MAX_FILES, working_dir) return False - # Stage everything + dir_hash = _project_hash(working_dir) + index_file = _index_path(store, dir_hash) + ref = _ref_name(dir_hash) + + # Seed the per-project index from the last checkpoint, if any, so the + # diff/commit machinery sees only changes since then. On first call, + # clear the index so ``git add -A`` produces a clean tree. + if index_file.exists(): + # Reset index to current ref tip to avoid accumulating stale paths. + ok_ref, ref_commit, _ = _run_git( + ["rev-parse", "--verify", ref + "^{commit}"], + store, working_dir, + allowed_returncodes={128}, + ) + if ok_ref and ref_commit: + _run_git( + ["read-tree", ref_commit], + store, working_dir, + index_file=index_file, + allowed_returncodes={128}, + ) + else: + try: + index_file.unlink() + except OSError: + pass + else: + # First snapshot for this project. + index_file.parent.mkdir(parents=True, exist_ok=True) + + # Stage with per-project index. Include a per-stage file-size filter + # via ``core.bigFileThreshold`` is not what we want — instead, we + # rely on the exclude file for broad patterns and post-stage prune + # any path whose size exceeds max_file_size_mb. ok, _, err = _run_git( - ["add", "-A"], shadow, working_dir, timeout=_GIT_TIMEOUT * 2, + ["add", "-A"], store, working_dir, + timeout=_GIT_TIMEOUT * 2, index_file=index_file, ) if not ok: logger.debug("Checkpoint git-add failed: %s", err) return False - # Check if there's anything to commit - ok_diff, diff_out, _ = _run_git( - ["diff", "--cached", "--quiet"], - shadow, - working_dir, - allowed_returncodes={1}, + if self.max_file_size_mb > 0: + self._drop_oversize_from_index(store, working_dir, index_file) + + # Compare against the current ref tip (not HEAD — HEAD points to a + # branch that doesn't exist on a bare store, so ``diff --cached`` + # against HEAD would always show "new file" for every staged path). + ok_ref, ref_commit, _ = _run_git( + ["rev-parse", "--verify", ref + "^{commit}"], + store, working_dir, + allowed_returncodes={128}, ) - if ok_diff: - # No changes to commit - logger.debug("Checkpoint skipped: no changes in %s", working_dir) + has_ref = ok_ref and bool(ref_commit) + + if has_ref: + ok_diff, _, _ = _run_git( + ["diff-index", "--cached", "--quiet", ref_commit], + store, working_dir, + allowed_returncodes={1}, + index_file=index_file, + ) + if ok_diff: + logger.debug("Checkpoint skipped: no changes in %s", working_dir) + return False + else: + # No ref yet — skip only if the index is empty. + ok_ls, ls_out, _ = _run_git( + ["ls-files", "--cached"], + store, working_dir, + index_file=index_file, + ) + if ok_ls and not ls_out.strip(): + logger.debug("Checkpoint skipped: empty tree in %s", working_dir) + return False + + # Write tree from per-project index. + ok_tree, tree_sha, err = _run_git( + ["write-tree"], store, working_dir, + index_file=index_file, + ) + if not ok_tree or not tree_sha: + logger.debug("Checkpoint write-tree failed: %s", err) return False - # Commit. ``--no-gpg-sign`` inline covers shadow repos created before - # the commit.gpgsign=false config was added to _init_shadow_repo — so - # users with existing checkpoints never hit a GPG pinentry popup. - ok, _, err = _run_git( - ["commit", "-m", reason, "--allow-empty-message", "--no-gpg-sign"], - shadow, working_dir, timeout=_GIT_TIMEOUT * 2, + # Build commit (parent = current ref tip, if any). + commit_args = ["commit-tree", tree_sha, "-m", reason, "--no-gpg-sign"] + if has_ref: + commit_args = ["commit-tree", tree_sha, "-p", ref_commit, "-m", reason, "--no-gpg-sign"] + ok_commit, new_sha, err = _run_git( + commit_args, store, working_dir, + index_file=index_file, ) - if not ok: - logger.debug("Checkpoint commit failed: %s", err) + if not ok_commit or not new_sha: + logger.debug("Checkpoint commit-tree failed: %s", err) return False - logger.debug("Checkpoint taken in %s: %s", working_dir, reason) + # Update the per-project ref. + update_args = ["update-ref", ref, new_sha] + if has_ref: + update_args = ["update-ref", ref, new_sha, ref_commit] + ok_update, _, err = _run_git( + update_args, store, working_dir, + ) + if not ok_update: + logger.debug("Checkpoint update-ref failed: %s", err) + return False - # Prune old snapshots - self._prune(shadow, working_dir) + logger.debug("Checkpoint taken in %s: %s (%s)", working_dir, reason, new_sha[:8]) + + # Real pruning — drop old commits beyond max_snapshots. + self._prune(store, working_dir, ref) + + # Enforce global size cap. + self._enforce_size_cap(store) return True - def _prune(self, shadow_repo: Path, working_dir: str) -> None: - """Keep only the last max_snapshots commits via orphan reset.""" + def _drop_oversize_from_index( + self, store: Path, working_dir: str, index_file: Path, + ) -> None: + """Remove any staged file larger than ``max_file_size_mb`` from the index. + + Lets the agent keep snapshotting source code while refusing to + swallow generated assets (datasets, model weights, logs, videos). + """ + cap = self.max_file_size_mb * 1024 * 1024 + if cap <= 0: + return ok, stdout, _ = _run_git( - ["rev-list", "--count", "HEAD"], shadow_repo, working_dir, + ["ls-files", "--cached", "-z"], + store, working_dir, index_file=index_file, + ) + if not ok or not stdout: + return + # ls-files -z output is NUL-separated. _run_git strips trailing + # whitespace but that leaves NULs alone; rebuild list. + paths = [p for p in stdout.split("\x00") if p] + abs_workdir = _normalize_path(working_dir) + oversize: List[str] = [] + for rel in paths: + try: + size = (abs_workdir / rel).stat().st_size + except OSError: + continue + if size > cap: + oversize.append(rel) + if not oversize: + return + logger.debug( + "Checkpoint: dropping %d oversize file(s) (>%d MB) from index", + len(oversize), self.max_file_size_mb, + ) + # Use --pathspec-from-file for safety with many paths. + # Chunk into manageable batches. + BATCH = 200 + for i in range(0, len(oversize), BATCH): + chunk = oversize[i:i + BATCH] + _run_git( + ["rm", "--cached", "--quiet", "--"] + chunk, + store, working_dir, index_file=index_file, + allowed_returncodes={128}, + ) + + def _prune(self, store: Path, working_dir: str, ref: str) -> None: + """Keep only the last ``max_snapshots`` commits on the per-project ref. + + v1's ``_prune`` was documented as a no-op (``git``'s pack mechanism + was supposed to handle it, but only the log view was limited — loose + objects accumulated forever). v2 actually rewrites the ref to drop + commits older than ``max_snapshots`` and then runs ``git gc`` on the + store so unreachable objects are reclaimed. + """ + ok, stdout, _ = _run_git( + ["rev-list", "--count", ref], store, working_dir, + allowed_returncodes={128}, ) if not ok: return - try: count = int(stdout) except ValueError: return - if count <= self.max_snapshots: return - # For simplicity, we don't actually prune — git's pack mechanism - # handles this efficiently, and the objects are small. The log - # listing is already limited by max_snapshots. - # Full pruning would require rebase --onto or filter-branch which - # is fragile for a background feature. We just limit the log view. - logger.debug("Checkpoint repo has %d commits (limit %d)", count, self.max_snapshots) + # Collect commits oldest → newest, take last N. + ok_list, list_out, _ = _run_git( + ["rev-list", "--reverse", ref], store, working_dir, + ) + if not ok_list or not list_out: + return + commits = list_out.splitlines() + keep = commits[-self.max_snapshots:] + + # Rebuild a linear chain off keep[0]'s tree. + new_parent: Optional[str] = None + for sha in keep: + ok_tree, tree_sha, _ = _run_git( + ["rev-parse", f"{sha}^{{tree}}"], store, working_dir, + ) + if not ok_tree or not tree_sha: + return + ok_msg, msg, _ = _run_git( + ["log", "--format=%s", "-1", sha], store, working_dir, + ) + commit_msg = msg if ok_msg and msg else "checkpoint" + args = ["commit-tree", tree_sha, "-m", commit_msg, "--no-gpg-sign"] + if new_parent is not None: + args = ["commit-tree", tree_sha, "-p", new_parent, + "-m", commit_msg, "--no-gpg-sign"] + ok_commit, new_sha, _ = _run_git(args, store, working_dir) + if not ok_commit or not new_sha: + return + new_parent = new_sha + + if new_parent is None: + return + _run_git(["update-ref", ref, new_parent], store, working_dir) + + # Reclaim objects from the dropped commits. + _run_git( + ["reflog", "expire", "--expire=now", "--all"], + store, working_dir, + ) + _run_git( + ["gc", "--prune=now", "--quiet"], + store, working_dir, timeout=_GIT_TIMEOUT * 3, + ) + + def _enforce_size_cap(self, store: Path) -> None: + """If total store size exceeds ``max_total_size_mb``, drop oldest + checkpoints across ALL projects until under the cap. + """ + if self.max_total_size_mb <= 0: + return + cap_bytes = self.max_total_size_mb * 1024 * 1024 + size = _dir_size_bytes(store) + if size <= cap_bytes: + return + logger.info( + "Checkpoint store exceeded %d MB (actual %d MB) — pruning oldest", + self.max_total_size_mb, size // (1024 * 1024), + ) + + # Collect (commit_time, ref, sha) across all per-project refs. + ok, stdout, _ = _run_git( + ["for-each-ref", "--format=%(refname)", _REFS_PREFIX], + store, str(store.parent), + allowed_returncodes={128}, + ) + if not ok or not stdout: + return + refs = [r for r in stdout.splitlines() if r.strip()] + + any_dropped = False + # Round-robin-drop oldest commit per ref until under cap. + for _ in range(20): # hard upper bound to avoid pathological loops + size = _dir_size_bytes(store) + if size <= cap_bytes: + break + for ref in refs: + ok_count, count_out, _ = _run_git( + ["rev-list", "--count", ref], store, str(store.parent), + allowed_returncodes={128}, + ) + try: + count = int(count_out) if ok_count else 0 + except ValueError: + count = 0 + if count <= 1: + continue # keep at least one snapshot per project + ok_list, list_out, _ = _run_git( + ["rev-list", "--reverse", ref], store, str(store.parent), + ) + if not ok_list or not list_out: + continue + commits = list_out.splitlines() + keep = commits[1:] # drop oldest + new_parent: Optional[str] = None + fail = False + for sha in keep: + ok_tree, tree_sha, _ = _run_git( + ["rev-parse", f"{sha}^{{tree}}"], store, str(store.parent), + ) + if not ok_tree or not tree_sha: + fail = True + break + ok_msg, msg, _ = _run_git( + ["log", "--format=%s", "-1", sha], store, str(store.parent), + ) + commit_msg = msg if ok_msg and msg else "checkpoint" + args = ["commit-tree", tree_sha, "-m", commit_msg, "--no-gpg-sign"] + if new_parent is not None: + args = ["commit-tree", tree_sha, "-p", new_parent, + "-m", commit_msg, "--no-gpg-sign"] + ok_commit, new_sha, _ = _run_git(args, store, str(store.parent)) + if not ok_commit or not new_sha: + fail = True + break + new_parent = new_sha + if fail or new_parent is None: + continue + _run_git(["update-ref", ref, new_parent], store, str(store.parent)) + any_dropped = True + if not any_dropped: + break + + _run_git( + ["reflog", "expire", "--expire=now", "--all"], + store, str(store.parent), + ) + _run_git( + ["gc", "--prune=now", "--quiet"], + store, str(store.parent), timeout=_GIT_TIMEOUT * 3, + ) def format_checkpoint_list(checkpoints: List[Dict], directory: str) -> str: @@ -629,14 +1176,12 @@ def format_checkpoint_list(checkpoints: List[Dict], directory: str) -> str: lines = [f"📸 Checkpoints for {directory}:\n"] for i, cp in enumerate(checkpoints, 1): - # Parse ISO timestamp to something readable ts = cp["timestamp"] if "T" in ts: - ts = ts.split("T")[1].split("+")[0].split("-")[0][:5] # HH:MM + ts = ts.split("T")[1].split("+")[0].split("-")[0][:5] date = cp["timestamp"].split("T")[0] ts = f"{date} {ts}" - # Build change summary files = cp.get("files_changed", 0) ins = cp.get("insertions", 0) dele = cp.get("deletions", 0) @@ -654,72 +1199,45 @@ def format_checkpoint_list(checkpoints: List[Dict], directory: str) -> str: # --------------------------------------------------------------------------- -# Auto-maintenance (issue #3015 follow-up) +# Auto-maintenance # --------------------------------------------------------------------------- # -# Every working directory the agent has ever touched gets its own shadow -# repo under CHECKPOINT_BASE. Per-repo ``_prune`` is a no-op (see comment -# in CheckpointManager._prune), so abandoned repos (deleted projects, -# one-off tmp dirs, long-stale work trees) accumulate forever. Field -# reports put the typical offender at 1000+ repos / ~12 GB on active -# contributor machines. -# -# ``prune_checkpoints`` sweeps CHECKPOINT_BASE at startup, deleting shadow -# repos that match either criterion: -# * orphan: the ``HERMES_WORKDIR`` path no longer exists on disk -# * stale: the repo's newest mtime is older than ``retention_days`` -# -# ``maybe_auto_prune_checkpoints`` wraps it with an idempotency marker -# (``CHECKPOINT_BASE/.last_prune``) so calling it on every CLI/gateway -# startup is free after the first run of the day. Opt-in via -# ``checkpoints.auto_prune`` in config.yaml — default off so users who -# rely on ``/rollback`` against long-ago sessions never lose data -# silently. +# v2 rewrite. The sweep now operates on per-project refs inside the shared +# store rather than per-project shadow repos. Legacy-archive dirs +# (``legacy-/``) are swept with the same retention policy. _PRUNE_MARKER_NAME = ".last_prune" -def _read_workdir_marker(shadow_repo: Path) -> Optional[str]: - """Read ``HERMES_WORKDIR`` from a shadow repo, or None if missing/unreadable.""" - try: - return (shadow_repo / "HERMES_WORKDIR").read_text(encoding="utf-8").strip() - except (OSError, UnicodeDecodeError): - return None - - -def _shadow_repo_newest_mtime(shadow_repo: Path) -> float: - """Return newest mtime across the shadow repo (walks objects/refs/HEAD). - - We walk instead of trusting the directory mtime because git's pack - operations can leave the top-level dir untouched while refs/objects - inside get updated. Best-effort — returns 0.0 on any error. - """ - newest = 0.0 - try: - for p in shadow_repo.rglob("*"): - try: - m = p.stat().st_mtime - if m > newest: - newest = m - except OSError: - continue - except OSError: - pass - return newest +def _delete_ref(store: Path, ref: str) -> bool: + """Delete a ref from the store. Returns True on success.""" + ok, _, _ = _run_git( + ["update-ref", "-d", ref], store, str(store.parent), + allowed_returncodes={128}, + ) + return ok def prune_checkpoints( retention_days: int = 7, delete_orphans: bool = True, checkpoint_base: Optional[Path] = None, + max_total_size_mb: int = 0, ) -> Dict[str, int]: - """Delete stale/orphan shadow repos under ``checkpoint_base``. + """Delete stale/orphan checkpoints and reclaim store space. - A shadow repo is deleted when either: + A project entry is deleted when either: - * ``delete_orphans=True`` and its ``HERMES_WORKDIR`` path no longer - exists on disk (the original project was deleted / moved); OR - * its newest in-repo mtime is older than ``retention_days`` days. + * ``delete_orphans=True`` and its ``workdir`` no longer exists on disk + (the original project was deleted / moved); OR + * its ``last_touch`` is older than ``retention_days`` days. + + Additionally, if ``max_total_size_mb > 0`` and the store exceeds that + after orphan/stale pruning, the oldest commit per remaining project is + dropped until the store is under the cap. + + Legacy-archive dirs (``legacy-*``) older than ``retention_days`` are + also deleted. Returns a dict with counts ``{"scanned", "deleted_orphan", "deleted_stale", "errors", "bytes_freed"}``. @@ -737,51 +1255,207 @@ def prune_checkpoints( if not base.exists(): return result + size_before = _dir_size_bytes(base) + + # --- Legacy pre-v2 per-project shadow repos (kept directly under base) --- + # Pre-v2 layout: ``base//HEAD`` etc. We treat these exactly as the + # v1 pruner did so behaviour is unchanged for anyone still on that layout + # or sitting on a mid-migration system. cutoff = 0.0 if retention_days > 0: - import time as _time - cutoff = _time.time() - retention_days * 86400 + cutoff = time.time() - retention_days * 86400 for child in base.iterdir(): if not child.is_dir(): continue - # Protect the marker file and anything that isn't a real shadow - # repo (no HEAD = not initialised, leave alone). + if child.name == _STORE_DIRNAME: + continue + if child.name.startswith(_LEGACY_PREFIX): + # Legacy archive: prune by dir mtime using same retention rule. + if retention_days <= 0: + continue + try: + m = child.stat().st_mtime + except OSError: + continue + if m >= cutoff: + continue + try: + size = _dir_size_bytes(child) + shutil.rmtree(child) + result["bytes_freed"] += size + result["deleted_stale"] += 1 + except OSError as exc: + result["errors"] += 1 + logger.warning("Failed to delete legacy archive %s: %s", child, exc) + continue + # Only count as a pre-v2 shadow repo if it has a HEAD. if not (child / "HEAD").exists(): continue result["scanned"] += 1 - reason: Optional[str] = None if delete_orphans: - workdir = _read_workdir_marker(child) + workdir: Optional[str] = None + wd_marker = child / "HERMES_WORKDIR" + if wd_marker.exists(): + try: + workdir = wd_marker.read_text(encoding="utf-8").strip() + except (OSError, UnicodeDecodeError): + workdir = None if workdir is None or not Path(workdir).exists(): reason = "orphan" - if reason is None and retention_days > 0: - newest = _shadow_repo_newest_mtime(child) + newest = 0.0 + try: + for p in child.rglob("*"): + try: + mt = p.stat().st_mtime + if mt > newest: + newest = mt + except OSError: + continue + except OSError: + pass if newest > 0 and newest < cutoff: reason = "stale" - if reason is None: continue - - # Measure size before delete (best-effort) - try: - size = sum(p.stat().st_size for p in child.rglob("*") if p.is_file()) - except OSError: - size = 0 try: + size = _dir_size_bytes(child) shutil.rmtree(child) result["bytes_freed"] += size if reason == "orphan": result["deleted_orphan"] += 1 else: result["deleted_stale"] += 1 - logger.debug("Pruned %s checkpoint repo: %s (%d bytes)", reason, child.name, size) except OSError as exc: result["errors"] += 1 logger.warning("Failed to prune checkpoint repo %s: %s", child.name, exc) + # --- v2 shared store: per-project ref pruning via metadata --- + store = _store_path(base) + if (store / "HEAD").exists(): + for meta in _list_projects(store): + dir_hash = meta.get("_hash") or "" + workdir = meta.get("workdir") or "" + if not dir_hash: + continue + result["scanned"] += 1 + reason = None + if delete_orphans and (not workdir or not Path(workdir).exists()): + reason = "orphan" + elif retention_days > 0: + last_touch = float(meta.get("last_touch", 0) or 0) + if last_touch > 0 and last_touch < cutoff: + reason = "stale" + if reason is None: + continue + ref = _ref_name(dir_hash) + _delete_ref(store, ref) + # Drop per-project index and metadata. + try: + idx = _index_path(store, dir_hash) + if idx.exists(): + idx.unlink() + except OSError: + pass + try: + mp = _project_meta_path(store, dir_hash) + if mp.exists(): + mp.unlink() + except OSError: + pass + if reason == "orphan": + result["deleted_orphan"] += 1 + else: + result["deleted_stale"] += 1 + + # GC the store to reclaim unreachable objects from dropped refs. + _run_git( + ["reflog", "expire", "--expire=now", "--all"], + store, str(base), + ) + _run_git( + ["gc", "--prune=now", "--quiet"], + store, str(base), timeout=_GIT_TIMEOUT * 3, + ) + + # Size-cap pass across remaining projects. + if max_total_size_mb > 0: + cap_bytes = max_total_size_mb * 1024 * 1024 + for _i in range(20): + size = _dir_size_bytes(store) + if size <= cap_bytes: + break + ok, stdout, _ = _run_git( + ["for-each-ref", "--format=%(refname)", _REFS_PREFIX], + store, str(base), + allowed_returncodes={128}, + ) + refs = [r for r in stdout.splitlines() if r.strip()] if ok else [] + if not refs: + break + any_drop = False + for ref in refs: + ok_c, count_out, _ = _run_git( + ["rev-list", "--count", ref], store, str(base), + allowed_returncodes={128}, + ) + try: + count = int(count_out) if ok_c else 0 + except ValueError: + count = 0 + if count <= 1: + continue + ok_l, lo, _ = _run_git( + ["rev-list", "--reverse", ref], store, str(base), + ) + if not ok_l or not lo: + continue + commits = lo.splitlines() + keep = commits[1:] + new_parent: Optional[str] = None + fail = False + for sha in keep: + ok_t, tsha, _ = _run_git( + ["rev-parse", f"{sha}^{{tree}}"], store, str(base), + ) + if not ok_t or not tsha: + fail = True + break + ok_m, m, _ = _run_git( + ["log", "--format=%s", "-1", sha], store, str(base), + ) + msg = m if ok_m and m else "checkpoint" + args = ["commit-tree", tsha, "-m", msg, "--no-gpg-sign"] + if new_parent is not None: + args = ["commit-tree", tsha, "-p", new_parent, + "-m", msg, "--no-gpg-sign"] + ok_cm, new_sha, _ = _run_git(args, store, str(base)) + if not ok_cm or not new_sha: + fail = True + break + new_parent = new_sha + if fail or new_parent is None: + continue + _run_git(["update-ref", ref, new_parent], store, str(base)) + any_drop = True + if not any_drop: + break + _run_git( + ["reflog", "expire", "--expire=now", "--all"], + store, str(base), + ) + _run_git( + ["gc", "--prune=now", "--quiet"], + store, str(base), timeout=_GIT_TIMEOUT * 3, + ) + + size_after = _dir_size_bytes(base) + delta = size_before - size_after + if delta > result["bytes_freed"]: + result["bytes_freed"] = delta + return result @@ -790,18 +1464,16 @@ def maybe_auto_prune_checkpoints( min_interval_hours: int = 24, delete_orphans: bool = True, checkpoint_base: Optional[Path] = None, + max_total_size_mb: int = 0, ) -> Dict[str, object]: """Idempotent wrapper around ``prune_checkpoints`` for startup hooks. Writes ``CHECKPOINT_BASE/.last_prune`` on completion so subsequent - calls within ``min_interval_hours`` short-circuit. Designed to be - called once per CLI/gateway process startup; the marker keeps costs - bounded regardless of how many times hermes is invoked per day. + calls within ``min_interval_hours`` short-circuit. Returns ``{"skipped": bool, "result": prune_checkpoints-dict, "error": optional str}``. """ - import time as _time base = checkpoint_base or CHECKPOINT_BASE out: Dict[str, object] = {"skipped": False} @@ -814,7 +1486,7 @@ def maybe_auto_prune_checkpoints( return out marker = base / _PRUNE_MARKER_NAME - now = _time.time() + now = time.time() if marker.exists(): try: last_ts = float(marker.read_text(encoding="utf-8").strip()) @@ -828,6 +1500,7 @@ def maybe_auto_prune_checkpoints( retention_days=retention_days, delete_orphans=delete_orphans, checkpoint_base=base, + max_total_size_mb=max_total_size_mb, ) out["result"] = result @@ -839,7 +1512,7 @@ def maybe_auto_prune_checkpoints( total = result["deleted_orphan"] + result["deleted_stale"] if total > 0: logger.info( - "checkpoint auto-maintenance: pruned %d repo(s) " + "checkpoint auto-maintenance: pruned %d entry(ies) " "(%d orphan, %d stale), reclaimed %.1f MB", total, result["deleted_orphan"], @@ -852,3 +1525,114 @@ def maybe_auto_prune_checkpoints( return out + +# --------------------------------------------------------------------------- +# Public helpers for `hermes checkpoints` CLI +# --------------------------------------------------------------------------- + +def store_status(checkpoint_base: Optional[Path] = None) -> Dict: + """Return a summary of the shadow store. + + ``{"base": path, "store_size_bytes": N, "legacy_size_bytes": N, + "total_size_bytes": N, "project_count": N, "projects": [...], + "legacy_archives": [...]}`` + """ + base = checkpoint_base or CHECKPOINT_BASE + out: Dict = { + "base": str(base), + "store_size_bytes": 0, + "legacy_size_bytes": 0, + "total_size_bytes": 0, + "project_count": 0, + "projects": [], + "legacy_archives": [], + } + if not base.exists(): + return out + + store = _store_path(base) + if store.exists(): + out["store_size_bytes"] = _dir_size_bytes(store) + if (store / "HEAD").exists(): + for meta in _list_projects(store): + dir_hash = meta.get("_hash") or "" + workdir = meta.get("workdir") or "" + ref = _ref_name(dir_hash) + ok, count_out, _ = _run_git( + ["rev-list", "--count", ref], store, str(base), + allowed_returncodes={128}, + ) + try: + commits = int(count_out) if ok else 0 + except ValueError: + commits = 0 + out["projects"].append({ + "hash": dir_hash, + "workdir": workdir, + "exists": bool(workdir) and Path(workdir).exists(), + "created_at": meta.get("created_at"), + "last_touch": meta.get("last_touch"), + "commits": commits, + }) + out["project_count"] = len(out["projects"]) + + for child in base.iterdir(): + if child.is_dir() and child.name.startswith(_LEGACY_PREFIX): + try: + size = _dir_size_bytes(child) + except OSError: + size = 0 + out["legacy_size_bytes"] += size + try: + mt = child.stat().st_mtime + except OSError: + mt = 0 + out["legacy_archives"].append({ + "name": child.name, + "size_bytes": size, + "mtime": mt, + }) + + out["total_size_bytes"] = _dir_size_bytes(base) + return out + + +def clear_all(checkpoint_base: Optional[Path] = None) -> Dict[str, int]: + """Nuke the entire checkpoint base (store + legacy). Irreversible. + + Returns ``{"bytes_freed": N, "deleted": bool}``. + """ + base = checkpoint_base or CHECKPOINT_BASE + out = {"bytes_freed": 0, "deleted": False} + if not base.exists(): + return out + size = _dir_size_bytes(base) + try: + shutil.rmtree(base) + out["bytes_freed"] = size + out["deleted"] = True + except OSError as exc: + logger.warning("Could not clear checkpoint base %s: %s", base, exc) + return out + + +def clear_legacy(checkpoint_base: Optional[Path] = None) -> Dict[str, int]: + """Delete all ``legacy-*`` archive directories. + + Returns ``{"bytes_freed": N, "deleted": count}``. + """ + base = checkpoint_base or CHECKPOINT_BASE + out = {"bytes_freed": 0, "deleted": 0} + if not base.exists(): + return out + for child in list(base.iterdir()): + if not child.is_dir() or not child.name.startswith(_LEGACY_PREFIX): + continue + try: + size = _dir_size_bytes(child) + shutil.rmtree(child) + out["bytes_freed"] += size + out["deleted"] += 1 + except OSError as exc: + logger.warning("Could not delete legacy archive %s: %s", child, exc) + return out diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index cf1c80379d..ea3983ae75 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -54,6 +54,7 @@ hermes [global-options] [subcommand/options] | `hermes dump` | Copy-pasteable setup summary for support/debugging. | | `hermes debug` | Debug tools — upload logs and system info for support. | | `hermes backup` | Back up Hermes home directory to a zip file. | +| `hermes checkpoints` | Inspect / prune / clear `~/.hermes/checkpoints/` (the shadow store used by `/rollback`). Run with no args for a status overview. | | `hermes import` | Restore a Hermes backup from a zip file. | | `hermes logs` | View, tail, and filter agent/gateway/error log files. | | `hermes config` | Show, edit, migrate, and query configuration files. | @@ -579,6 +580,44 @@ hermes backup --quick # Quick state-only snapshot hermes backup --quick --label "pre-upgrade" # Quick snapshot with label ``` +## `hermes checkpoints` + +```bash +hermes checkpoints [COMMAND] +``` + +Inspect and manage the shadow git store at `~/.hermes/checkpoints/` — the storage layer behind the in-session `/rollback` command. Safe to run any time; does not require the agent to be running. + +| Subcommand | Description | +|------------|-------------| +| `status` (default) | Show total size, project count, and per-project breakdown. Bare `hermes checkpoints` is equivalent. | +| `list` | Alias for `status`. | +| `prune` | Force a cleanup sweep — delete orphan and stale projects, GC the store, enforce the size cap. Ignores the 24h idempotency marker. | +| `clear` | Delete the entire checkpoint base. Irreversible; asks for confirmation unless `-f`. | +| `clear-legacy` | Delete only the `legacy-/` archives produced by the v1→v2 migration. | + +### Options + +| Option | Subcommand | Description | +|--------|------------|-------------| +| `--limit N` | `status`, `list` | Max projects to list (default 20). | +| `--retention-days N` | `prune` | Drop projects whose `last_touch` is older than N days (default 7). | +| `--max-size-mb N` | `prune` | After the orphan/stale pass, drop the oldest commit per project until total store size ≤ N MB (default 500). | +| `--keep-orphans` | `prune` | Skip deleting projects whose working directory no longer exists. | +| `-f`, `--force` | `clear`, `clear-legacy` | Skip the confirmation prompt. | + +### Examples + +```bash +hermes checkpoints # status overview +hermes checkpoints prune --retention-days 3 # aggressive cleanup +hermes checkpoints prune --max-size-mb 200 # tighten size cap once +hermes checkpoints clear-legacy -f # drop v1 archive dirs +hermes checkpoints clear -f # wipe everything +``` + +See [Checkpoints and `/rollback`](../user-guide/checkpoints-and-rollback.md) for the full architecture and the in-session commands. + ## `hermes import` ```bash diff --git a/website/docs/user-guide/checkpoints-and-rollback.md b/website/docs/user-guide/checkpoints-and-rollback.md index ed50c011ec..1393060612 100644 --- a/website/docs/user-guide/checkpoints-and-rollback.md +++ b/website/docs/user-guide/checkpoints-and-rollback.md @@ -7,9 +7,22 @@ description: "Filesystem safety nets for destructive operations using shadow git # Checkpoints and `/rollback` -Hermes Agent automatically snapshots your project before **destructive operations** and lets you restore it with a single command. Checkpoints are **enabled by default** — there's zero cost when no file-mutating tools fire. +Hermes Agent can automatically snapshot your project before **destructive operations** and restore it with a single command. Checkpoints are **opt-in** as of v2 — most users never use `/rollback`, and the shadow-store storage is non-trivial over time, so the default is off. -This safety net is powered by an internal **Checkpoint Manager** that keeps a separate shadow git repository under `~/.hermes/checkpoints/` — your real project `.git` is never touched. +Enable checkpoints per-session with `--checkpoints`: + +```bash +hermes chat --checkpoints +``` + +Or enable globally in `~/.hermes/config.yaml`: + +```yaml +checkpoints: + enabled: true +``` + +This safety net is powered by an internal **Checkpoint Manager** that keeps a single shared shadow git repository under `~/.hermes/checkpoints/store/` — your real project `.git` is never touched. Every project the agent works in shares the same store, so git's content-addressable object DB deduplicates across projects and across turns. ## What Triggers a Checkpoint @@ -22,6 +35,8 @@ The agent creates **at most one checkpoint per directory per turn**, so long-run ## Quick Reference +In-session slash commands: + | Command | Description | |---------|-------------| | `/rollback` | List all checkpoints with change stats | @@ -29,6 +44,17 @@ The agent creates **at most one checkpoint per directory per turn**, so long-run | `/rollback diff ` | Preview diff between checkpoint N and current state | | `/rollback ` | Restore a single file from checkpoint N | +CLI for inspecting and managing the store outside a session: + +| Command | Description | +|---------|-------------| +| `hermes checkpoints` | Show total size, project count, per-project breakdown | +| `hermes checkpoints status` | Same as bare `checkpoints` | +| `hermes checkpoints list` | Alias for `status` | +| `hermes checkpoints prune` | Force a sweep: delete orphans/stale, GC, enforce size cap | +| `hermes checkpoints clear` | Nuke the entire checkpoint base (asks first) | +| `hermes checkpoints clear-legacy` | Delete only the `legacy-*` archives from v1 migration | + ## How Checkpoints Work At a high level: @@ -36,9 +62,9 @@ At a high level: - Hermes detects when tools are about to **modify files** in your working tree. - Once per conversation turn (per directory), it: - Resolves a reasonable project root for the file. - - Initialises or reuses a **shadow git repo** tied to that directory. - - Stages and commits the current state with a short, human‑readable reason. -- These commits form a checkpoint history that you can inspect and restore via `/rollback`. + - Initialises or reuses the **single shared shadow store** at `~/.hermes/checkpoints/store/`. + - Stages into a per-project index, builds a tree, and commits to a per-project ref (`refs/hermes/`). +- These per-project refs form a checkpoint history that you can inspect and restore via `/rollback`. ```mermaid flowchart LR @@ -46,44 +72,46 @@ flowchart LR agent["AIAgent\n(run_agent.py)"] tools["File & terminal tools"] cpMgr["CheckpointManager"] - shadowRepo["Shadow git repo\n~/.hermes/checkpoints/"] + store["Shared shadow store\n~/.hermes/checkpoints/store/"] user --> agent agent -->|"tool call"| tools tools -->|"before mutate\nensure_checkpoint()"| cpMgr - cpMgr -->|"git add/commit"| shadowRepo + cpMgr -->|"git add/commit-tree/update-ref"| store cpMgr -->|"OK / skipped"| tools tools -->|"apply changes"| agent ``` ## Configuration -Checkpoints are enabled by default. Configure in `~/.hermes/config.yaml`: +Configure in `~/.hermes/config.yaml`: ```yaml checkpoints: - enabled: true # master switch (default: true) - max_snapshots: 50 # max checkpoints per directory + enabled: false # master switch (default: false — opt-in) + max_snapshots: 20 # max checkpoints per project (enforced via ref rewrite + gc) + max_total_size_mb: 500 # hard cap on total store size; oldest commits dropped + max_file_size_mb: 10 # skip any single file larger than this - # Auto-maintenance (opt-in): sweep ~/.hermes/checkpoints/ at startup - # and delete shadow repos whose working directory no longer exists - # (orphans) or whose newest commit is older than retention_days. - # Runs at most once per min_interval_hours, tracked via a - # .last_prune marker inside ~/.hermes/checkpoints/. - auto_prune: false # default off — enable to reclaim disk + # Auto-maintenance (on by default): sweep ~/.hermes/checkpoints/ at startup + # and delete project entries whose working directory no longer exists + # (orphans) or whose last_touch is older than retention_days. Runs at most + # once per min_interval_hours, tracked via a .last_prune marker. + auto_prune: true retention_days: 7 - delete_orphans: true # delete repos whose workdir is gone + delete_orphans: true min_interval_hours: 24 ``` -To disable: +To disable everything: ```yaml checkpoints: enabled: false + auto_prune: false ``` -When disabled, the Checkpoint Manager is a no‑op and never attempts git operations. +When `enabled: false`, the Checkpoint Manager is a no-op and never attempts git operations. When `auto_prune: false`, the store grows until you run `hermes checkpoints prune` manually. ## Listing Checkpoints @@ -107,12 +135,38 @@ Hermes responds with a formatted list showing change statistics: /rollback restore a single file from checkpoint N ``` -Each entry shows: +## Inspecting the Store from the Shell -- Short hash -- Timestamp -- Reason (what triggered the snapshot) -- Change summary (files changed, insertions/deletions) +```bash +hermes checkpoints +``` + +Sample output: + +```text +Checkpoint base: /home/you/.hermes/checkpoints +Total size: 142.3 MB + store/ 138.1 MB + legacy-* 4.2 MB +Projects: 12 + + WORKDIR COMMITS LAST TOUCH STATE + /home/you/code/hermes-agent 20 2h ago live + /home/you/code/experiments/rl-runner 8 1d ago live + /home/you/code/old-prototype 3 9d ago orphan + ... + +Legacy archives (1): + legacy-20260506-050616 4.2 MB + +Clear with: hermes checkpoints clear-legacy +``` + +Force a full sweep (ignores the 24h idempotency marker): + +```bash +hermes checkpoints prune --retention-days 3 --max-size-mb 200 +``` ## Previewing Changes with `/rollback diff` @@ -122,49 +176,21 @@ Before committing to a restore, preview what has changed since a checkpoint: /rollback diff 1 ``` -This shows a git diff stat summary followed by the actual diff: - -```text -test.py | 2 +- - 1 file changed, 1 insertion(+), 1 deletion(-) - -diff --git a/test.py b/test.py ---- a/test.py -+++ b/test.py -@@ -1 +1 @@ --print('original content') -+print('modified content') -``` - -Long diffs are capped at 80 lines to avoid flooding the terminal. +This shows a git diff stat summary followed by the actual diff. ## Restoring with `/rollback` -Restore to a checkpoint by number: - ``` /rollback 1 ``` Behind the scenes, Hermes: -1. Verifies the target commit exists in the shadow repo. -2. Takes a **pre‑rollback snapshot** of the current state so you can "undo the undo" later. +1. Verifies the target commit exists in the shadow store. +2. Takes a **pre-rollback snapshot** of the current state so you can "undo the undo" later. 3. Restores tracked files in your working directory. 4. **Undoes the last conversation turn** so the agent's context matches the restored filesystem state. -On success: - -```text -✅ Restored to checkpoint 4270a8c5: before patch -A pre-rollback snapshot was saved automatically. -(^_^)b Undid 4 message(s). Removed: "Now update test.py to ..." - 4 message(s) remaining in history. - Chat turn undone to match restored file state. -``` - -The conversation undo ensures the agent doesn't "remember" changes that have been rolled back, avoiding confusion on the next turn. - ## Single-File Restore Restore just one file from a checkpoint without affecting the rest of the directory: @@ -173,42 +199,51 @@ Restore just one file from a checkpoint without affecting the rest of the direct /rollback 1 src/broken_file.py ``` -This is useful when the agent made changes to multiple files but only one needs to be reverted. - ## Safety and Performance Guards -To keep checkpointing safe and fast, Hermes applies several guardrails: - - **Git availability** — if `git` is not found on `PATH`, checkpoints are transparently disabled. - **Directory scope** — Hermes skips overly broad directories (root `/`, home `$HOME`). -- **Repository size** — directories with more than 50,000 files are skipped to avoid slow git operations. -- **No‑change snapshots** — if there are no changes since the last snapshot, the checkpoint is skipped. -- **Non‑fatal errors** — all errors inside the Checkpoint Manager are logged at debug level; your tools continue to run. +- **Repository size** — directories with more than 50,000 files are skipped. +- **Per-file size cap** — files larger than `max_file_size_mb` (default 10 MB) are excluded from the snapshot. Prevents accidentally swallowing datasets, model weights, or generated media. +- **Total store size cap** — when the store exceeds `max_total_size_mb` (default 500 MB), the oldest commit per project is dropped round-robin until under the cap. +- **Real pruning** — `max_snapshots` is enforced by rewriting the per-project ref and running `git gc --prune=now` afterwards, so loose objects don't accumulate. +- **No-change snapshots** — if there are no changes since the last snapshot, the checkpoint is skipped. +- **Non-fatal errors** — all errors inside the Checkpoint Manager are logged at debug level; your tools continue to run. ## Where Checkpoints Live -All shadow repos live under: - ```text ~/.hermes/checkpoints/ - ├── / # shadow git repo for one working directory - ├── / - └── ... + ├── store/ # single shared bare git repo + │ ├── HEAD, objects/ # git internals (shared across projects) + │ ├── refs/hermes/ # per-project branch tip + │ ├── indexes/ # per-project git index + │ ├── projects/.json # workdir + created_at + last_touch + │ └── info/exclude + ├── .last_prune # auto-prune idempotency marker + └── legacy-/ # archived pre-v2 per-project shadow repos ``` -Each `` is derived from the absolute path of the working directory. Inside each shadow repo you'll find: +Each `` is derived from the absolute path of the working directory. You normally never need to touch these manually — use `hermes checkpoints status` / `prune` / `clear` instead. -- Standard git internals (`HEAD`, `refs/`, `objects/`) -- An `info/exclude` file containing a curated ignore list -- A `HERMES_WORKDIR` file pointing back to the original project root +### Migration from v1 -You normally never need to touch these manually. +Before the v2 rewrite, each working directory got its own complete shadow git repo directly under `~/.hermes/checkpoints//`. That layout couldn't dedup objects across projects and had a documented no-op pruner — the store would grow without bound. + +On first v2 run, any pre-v2 shadow repos are moved into `~/.hermes/checkpoints/legacy-/` so the new single-store layout starts clean. Old `/rollback` history is still reachable by manually inspecting the legacy archive with `git`; once you're confident you don't need it, run: + +```bash +hermes checkpoints clear-legacy +``` + +to reclaim the space. Legacy archives are also swept by `auto_prune` after `retention_days`. ## Best Practices -- **Leave checkpoints enabled** — they're on by default and have zero cost when no files are modified. +- **Enable checkpoints only when you need them** — `hermes chat --checkpoints` or per-profile `enabled: true`. - **Use `/rollback diff` before restoring** — preview what will change to pick the right checkpoint. - **Use `/rollback` instead of `git reset`** when you want to undo agent-driven changes only. +- **Check `hermes checkpoints status` occasionally** if you use checkpoints regularly — shows which projects are active and what the store costs you. - **Combine with Git worktrees** for maximum safety — keep each Hermes session in its own worktree/branch, with checkpoints as an extra layer. For running multiple agents in parallel on the same repo, see the guide on [Git worktrees](./git-worktrees.md).