fix(checkpoints): bare /rollback falls back to a labeled all-directories view (#10505, reapply #10633)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
70
tests/tools/test_rollback_all_directories.py
Normal file
70
tests/tools/test_rollback_all_directories.py
Normal file
@@ -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
|
||||
@@ -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/<hash>.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 <N> restore to checkpoint N")
|
||||
lines.append(" /rollback diff <N> preview changes since checkpoint N")
|
||||
|
||||
Reference in New Issue
Block a user