From fa89d87ea6cce17d377028401b98604e08a08e14 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Wed, 19 Aug 2026 20:11:41 -0700 Subject: [PATCH] fix(checkpoints): bare /rollback falls back to a labeled all-directories view (#10505, reapply #10633) --- hermes_cli/cli_commands_mixin.py | 13 +++- tests/tools/test_rollback_all_directories.py | 70 ++++++++++++++++++++ tools/checkpoint_manager.py | 34 +++++++++- 3 files changed, 115 insertions(+), 2 deletions(-) create mode 100644 tests/tools/test_rollback_all_directories.py diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index 3b57335f74..1778c28749 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -88,8 +88,19 @@ class CLICommandsMixin: args = filtered if not args: - # List checkpoints + # List checkpoints — fall back to the cross-project view when the + # current directory has none (#10505, reapply of PR #10633 by + # @nightq). The Aug 2026 QA sweep hit this live: writes landed + # checkpoints under the session cwd (/tmp/qa-repo) while bare + # /rollback searched only TERMINAL_CWD's project and reported + # "No checkpoints found" despite fresh checkpoints existing. checkpoints = mgr.list_checkpoints(cwd) + if not checkpoints: + all_checkpoints = mgr.list_all_checkpoints() + if all_checkpoints: + print(f" No checkpoints for {cwd} — showing all directories.") + print(format_checkpoint_list(all_checkpoints, "all directories")) + return print(format_checkpoint_list(checkpoints, cwd)) return diff --git a/tests/tools/test_rollback_all_directories.py b/tests/tools/test_rollback_all_directories.py new file mode 100644 index 0000000000..66f4f8c635 --- /dev/null +++ b/tests/tools/test_rollback_all_directories.py @@ -0,0 +1,70 @@ +"""Regression tests for the /rollback all-directories fallback (#10505). + +Bare /rollback searched only TERMINAL_CWD's project; checkpoints created +under a different session cwd were invisible ("No checkpoints found for +/home/user" while checkpoints existed seconds earlier). Reapply of PR +#10633 by @nightq onto the v2 single-store layout. +""" + +import json + +import tools.checkpoint_manager as cm +from tools.checkpoint_manager import CheckpointManager, format_checkpoint_list + + +def _make_store_with_project(tmp_path, workdir: str): + store = tmp_path / "store" + (store / cm._PROJECTS_DIRNAME).mkdir(parents=True) + (store / "HEAD").write_text("ref: refs/heads/main\n") + dir_hash = cm._project_hash(workdir) + meta = {"workdir": workdir, "created_at": 1, "last_touch": 2} + (store / cm._PROJECTS_DIRNAME / f"{dir_hash}.json").write_text(json.dumps(meta)) + return store + + +class TestListAllCheckpoints: + def test_aggregates_projects_and_labels_workdir(self, tmp_path, monkeypatch): + workdir = str(tmp_path / "proj") + store = _make_store_with_project(tmp_path, workdir) + monkeypatch.setattr(cm, "CHECKPOINT_BASE", tmp_path) + monkeypatch.setattr(cm, "_store_path", lambda base=None: store) + + mgr = CheckpointManager(enabled=True) + fake_entries = [ + {"hash": "a" * 40, "short_hash": "aaaaaaa", "timestamp": "2026-08-19T10:00:00", + "reason": "before write_file", "files_changed": 1, "insertions": 2, "deletions": 0}, + ] + monkeypatch.setattr( + CheckpointManager, "list_checkpoints", lambda self, wd: list(fake_entries) + ) + + results = mgr.list_all_checkpoints() + assert len(results) == 1 + assert results[0]["workdir"] == workdir + + def test_empty_store_returns_empty(self, tmp_path, monkeypatch): + monkeypatch.setattr(cm, "CHECKPOINT_BASE", tmp_path) + monkeypatch.setattr(cm, "_store_path", lambda base=None: tmp_path / "missing") + assert CheckpointManager(enabled=True).list_all_checkpoints() == [] + + +class TestFormatAllDirectories: + def test_workdir_label_shown_in_all_directories_view(self): + cps = [{ + "hash": "b" * 40, "short_hash": "bbbbbbb", + "timestamp": "2026-08-19T10:00:00", "reason": "before patch", + "files_changed": 0, "insertions": 0, "deletions": 0, + "workdir": "/tmp/qa-repo", + }] + out = format_checkpoint_list(cps, "all directories") + assert "[qa-repo]" in out + + def test_single_directory_view_unchanged(self): + cps = [{ + "hash": "c" * 40, "short_hash": "ccccccc", + "timestamp": "2026-08-19T10:00:00", "reason": "before patch", + "files_changed": 0, "insertions": 0, "deletions": 0, + }] + out = format_checkpoint_list(cps, "/tmp/qa-repo") + assert "[qa-repo]" not in out + assert "ccccccc" in out diff --git a/tools/checkpoint_manager.py b/tools/checkpoint_manager.py index 5448632103..7149eebc90 100644 --- a/tools/checkpoint_manager.py +++ b/tools/checkpoint_manager.py @@ -968,6 +968,29 @@ class CheckpointManager: results.append(entry) return results + def list_all_checkpoints(self) -> List[Dict]: + """List checkpoints across every registered project (most recent first). + + Surgical reapply of PR #10633 by @nightq (#10505) onto the v2 + single-store layout: iterate ``projects/.json`` metadata via + ``_list_projects`` instead of the pre-v2 per-shadow-dir scan. Each + entry carries the extra ``workdir`` key so callers can label which + project a checkpoint belongs to. + """ + store = _store_path(CHECKPOINT_BASE) + if not (store / "HEAD").exists(): + return [] + results: List[Dict] = [] + for meta in _list_projects(store): + workdir = meta.get("workdir") or "" + if not workdir: + continue + for entry in self.list_checkpoints(workdir): + entry["workdir"] = workdir + results.append(entry) + results.sort(key=lambda x: x.get("timestamp", ""), reverse=True) + return results + @staticmethod def _parse_shortstat(stat_line: str, entry: Dict) -> None: """Parse git --shortstat output into entry dict.""" @@ -1563,7 +1586,16 @@ def format_checkpoint_list(checkpoints: List[Dict], directory: str) -> str: else: stat = "" - lines.append(f" {i}. {cp['short_hash']} {ts} {cp['reason']}{stat}") + # Label per-project entries when showing the cross-project view + # (workdir key only present on list_all_checkpoints results). + workdir = cp.get("workdir", "") + if workdir and directory == "all directories": + workdir_short = Path(workdir).name or workdir + lines.append( + f" {i}. {cp['short_hash']} {ts} [{workdir_short}] {cp['reason']}{stat}" + ) + else: + lines.append(f" {i}. {cp['short_hash']} {ts} {cp['reason']}{stat}") lines.append("\n /rollback restore to checkpoint N") lines.append(" /rollback diff preview changes since checkpoint N")