diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 925c64b3a7..a7bb1aa447 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -3297,6 +3297,16 @@ class GatewaySlashCommandsMixin: cwd = os.getenv("TERMINAL_CWD", str(Path.home())) arg = event.get_command_args().strip() + # --all / --force: classic full restore, overwriting user edits too. + restore_all = False + arg_parts = [] + for tok in arg.split(): + if tok.lower() in ("--all", "--force"): + restore_all = True + else: + arg_parts.append(tok) + arg = " ".join(arg_parts) + if not arg: checkpoints = mgr.list_checkpoints(cwd) return format_checkpoint_list(checkpoints, cwd) @@ -3316,13 +3326,22 @@ class GatewaySlashCommandsMixin: except ValueError: target_hash = arg - result = mgr.restore(cwd, target_hash) + result = mgr.restore(cwd, target_hash, safe=not restore_all) if result["success"]: - return t( + msg = t( "gateway.rollback.restored", hash=result["restored_to"], reason=result["reason"], ) + skipped = result.get("skipped_user_edits") or [] + if skipped: + shown = ", ".join(skipped[:5]) + more = f" (+{len(skipped) - 5})" if len(skipped) > 5 else "" + msg += "\n" + t( + "gateway.rollback.kept_user_edits", + files=shown + more, + ) + return msg return t("gateway.rollback.restore_failed", error=result["error"]) async def _handle_diff_command(self, event: MessageEvent) -> str: diff --git a/hermes_cli/cli_commands_mixin.py b/hermes_cli/cli_commands_mixin.py index 4349db8926..b2853dab30 100644 --- a/hermes_cli/cli_commands_mixin.py +++ b/hermes_cli/cli_commands_mixin.py @@ -53,7 +53,10 @@ class CLICommandsMixin: Syntax: /rollback — list checkpoints - /rollback — restore checkpoint N (also undoes last chat turn) + /rollback — restore checkpoint N, preserving user + hand-edits (also undoes last chat turn) + /rollback --all — classic full restore (may overwrite + files you edited after Hermes did) /rollback diff — preview changes since checkpoint N /rollback — restore a single file from checkpoint N """ @@ -74,6 +77,16 @@ class CLICommandsMixin: parts = command.split() args = parts[1:] if len(parts) > 1 else [] + # --all / --force: classic full restore, overwriting user edits too. + restore_all = False + filtered = [] + for a in args: + if a.lower() in ("--all", "--force"): + restore_all = True + else: + filtered.append(a) + args = filtered + if not args: # List checkpoints checkpoints = mgr.list_checkpoints(cwd) @@ -126,12 +139,21 @@ class CLICommandsMixin: # Check for file-level restore: /rollback file_path = args[1] if len(args) > 1 else None - result = mgr.restore(cwd, target_hash, file_path=file_path) + result = mgr.restore( + cwd, target_hash, file_path=file_path, + safe=not restore_all and not file_path, + ) if result["success"]: if file_path: print(f" ✅ Restored {file_path} from checkpoint {result['restored_to']}: {result['reason']}") else: print(f" ✅ Restored to checkpoint {result['restored_to']}: {result['reason']}") + skipped = result.get("skipped_user_edits") or [] + if skipped: + shown = ", ".join(skipped[:5]) + more = f" (+{len(skipped) - 5} more)" if len(skipped) > 5 else "" + print(f" ↷ Kept your hand-edits: {shown}{more}") + print(" Use /rollback --all to restore those too.") print(" A pre-rollback snapshot was saved automatically.") # Also undo the last conversation turn so the agent's context diff --git a/hermes_cli/commands.py b/hermes_cli/commands.py index c1865dfad7..19eac30f94 100644 --- a/hermes_cli/commands.py +++ b/hermes_cli/commands.py @@ -169,8 +169,8 @@ COMMAND_REGISTRY: list[CommandDef] = [ aliases=("fork",), args_hint="[name]"), CommandDef("compress", "Compress conversation context (add 'here [N]' to keep recent N turns; --preview shows what would happen)", "Session", aliases=("compact",), args_hint="[here [N] | focus topic | --preview|--dry-run]"), - CommandDef("rollback", "List or restore filesystem checkpoints", "Session", - args_hint="[number]"), + CommandDef("rollback", "List or restore filesystem checkpoints (restores keep your hand-edits; --all overrides)", "Session", + args_hint="[number] [--all]"), CommandDef("snapshot", "Create or restore state snapshots of Hermes config/state", "Session", cli_only=True, aliases=("snap",), args_hint="[create|restore |prune]"), CommandDef("export", "Export a profile (config, skills, theme) to a shareable archive", "Configuration", diff --git a/locales/af.yaml b/locales/af.yaml index 6658c2da20..3b6c7fcf7a 100644 --- a/locales/af.yaml +++ b/locales/af.yaml @@ -279,6 +279,7 @@ Future messages in this room will use that transcript until `/reset` or another none_found: "Geen kontrolepunte vir {cwd} gevind nie" invalid_number: "Ongeldige kontrolepunt-nommer. Gebruik 1-{max}." restored: "✅ Herstel na kontrolepunt {hash}: {reason}\n'n Voor-terugrol-momentopname is outomaties gestoor." + kept_user_edits: "↷ Jou handwysigings is behou: {files}\nGebruik /rollback --all om dié ook te herstel." restore_failed: "❌ {error}" diff: diff --git a/locales/ar.yaml b/locales/ar.yaml index 9d98db08eb..13694d8162 100644 --- a/locales/ar.yaml +++ b/locales/ar.yaml @@ -299,6 +299,7 @@ gateway: none_found: "لم يُعثر على نقاط تحقّق لـ {cwd}" invalid_number: "رقم نقطة تحقّق غير صالح. استخدم 1-{max}." restored: "✅ استُعيد إلى نقطة التحقّق {hash}: {reason}\nحُفظت لقطة ما قبل التراجع تلقائيًا." + kept_user_edits: "↷ احتُفظ بتعديلاتك اليدوية: {files}\nاستخدم ‎/rollback --all لاستعادتها أيضًا." restore_failed: "❌ {error}" diff: diff --git a/locales/de.yaml b/locales/de.yaml index 52d14ad237..5115dd6f39 100644 --- a/locales/de.yaml +++ b/locales/de.yaml @@ -279,6 +279,7 @@ Future messages in this room will use that transcript until `/reset` or another none_found: "Keine Checkpoints für {cwd} gefunden" invalid_number: "Ungültige Checkpoint-Nummer. Verwenden Sie 1-{max}." restored: "✅ Auf Checkpoint {hash} wiederhergestellt: {reason}\nEin Pre-Rollback-Snapshot wurde automatisch gespeichert." + kept_user_edits: "↷ Deine manuellen Änderungen wurden behalten: {files}\nNutze /rollback --all, um auch diese wiederherzustellen." restore_failed: "❌ {error}" diff: diff --git a/locales/en.yaml b/locales/en.yaml index d1069d7eac..842404b5ad 100644 --- a/locales/en.yaml +++ b/locales/en.yaml @@ -291,6 +291,7 @@ gateway: none_found: "No checkpoints found for {cwd}" invalid_number: "Invalid checkpoint number. Use 1-{max}." restored: "✅ Restored to checkpoint {hash}: {reason}\nA pre-rollback snapshot was saved automatically." + kept_user_edits: "↷ Kept your hand-edits: {files}\nUse /rollback --all to restore those too." restore_failed: "❌ {error}" diff: diff --git a/locales/es.yaml b/locales/es.yaml index 16c030d616..57395422f9 100644 --- a/locales/es.yaml +++ b/locales/es.yaml @@ -276,6 +276,7 @@ gateway: none_found: "No se encontraron checkpoints para {cwd}" invalid_number: "Número de checkpoint inválido. Usa 1-{max}." restored: "✅ Restaurado al checkpoint {hash}: {reason}\nSe guardó automáticamente un snapshot previo al rollback." + kept_user_edits: "↷ Se conservaron tus ediciones manuales: {files}\nUsa /rollback --all para restaurarlas también." restore_failed: "❌ {error}" diff: diff --git a/locales/fr.yaml b/locales/fr.yaml index 77869000e2..d6127b8418 100644 --- a/locales/fr.yaml +++ b/locales/fr.yaml @@ -279,6 +279,7 @@ Future messages in this room will use that transcript until `/reset` or another none_found: "Aucun point de contrôle trouvé pour {cwd}" invalid_number: "Numéro de point de contrôle invalide. Utilisez 1-{max}." restored: "✅ Restauré au point de contrôle {hash} : {reason}\nUn instantané pré-rollback a été enregistré automatiquement." + kept_user_edits: "↷ Vos modifications manuelles ont été conservées : {files}\nUtilisez /rollback --all pour les restaurer aussi." restore_failed: "❌ {error}" diff: diff --git a/locales/ga.yaml b/locales/ga.yaml index 1e9231a405..06ec627d91 100644 --- a/locales/ga.yaml +++ b/locales/ga.yaml @@ -283,6 +283,7 @@ Future messages in this room will use that transcript until `/reset` or another none_found: "Níor aimsíodh aon seicphointe do {cwd}" invalid_number: "Uimhir seicphointe neamhbhailí. Úsáid 1-{max}." restored: "✅ Aischurtha go seicphointe {hash}: {reason}\nSábháladh roghchóip réamh-rollback go huathoibríoch." + kept_user_edits: "↷ Coinníodh do chuid athruithe láimhe: {files}\nÚsáid /rollback --all chun iad sin a aischur freisin." restore_failed: "❌ {error}" diff: diff --git a/locales/hu.yaml b/locales/hu.yaml index fa4705deb2..833ed202c1 100644 --- a/locales/hu.yaml +++ b/locales/hu.yaml @@ -279,6 +279,7 @@ Future messages in this room will use that transcript until `/reset` or another none_found: "Nem található ellenőrzőpont ehhez: {cwd}" invalid_number: "Érvénytelen ellenőrzőpont-szám. Használj 1-{max} közötti értéket." restored: "✅ Visszaállítva a(z) {hash} ellenőrzőpontra: {reason}\nA visszaállítás előtti pillanatkép automatikusan elmentve." + kept_user_edits: "↷ A kézi szerkesztéseid megmaradtak: {files}\nHasználd a /rollback --all parancsot, hogy azokat is visszaállítsd." restore_failed: "❌ {error}" diff: diff --git a/locales/it.yaml b/locales/it.yaml index 6549980473..023fff4e4f 100644 --- a/locales/it.yaml +++ b/locales/it.yaml @@ -279,6 +279,7 @@ Future messages in this room will use that transcript until `/reset` or another none_found: "Nessun checkpoint trovato per {cwd}" invalid_number: "Numero di checkpoint non valido. Usa 1-{max}." restored: "✅ Ripristinato al checkpoint {hash}: {reason}\nUno snapshot pre-rollback è stato salvato automaticamente." + kept_user_edits: "↷ Le tue modifiche manuali sono state conservate: {files}\nUsa /rollback --all per ripristinare anche quelle." restore_failed: "❌ {error}" diff: diff --git a/locales/ja.yaml b/locales/ja.yaml index 17b66572b4..695b662384 100644 --- a/locales/ja.yaml +++ b/locales/ja.yaml @@ -279,6 +279,7 @@ Future messages in this room will use that transcript until `/reset` or another none_found: "{cwd} のチェックポイントが見つかりません" invalid_number: "無効なチェックポイント番号です。1-{max} を使用してください。" restored: "✅ チェックポイント {hash} に復元しました: {reason}\nロールバック前のスナップショットが自動的に保存されました。" + kept_user_edits: "↷ 手動編集は保持されました: {files}\nそれらも復元するには /rollback --all を使用してください。" restore_failed: "❌ {error}" diff: diff --git a/locales/ko.yaml b/locales/ko.yaml index cdc43eb6d0..1ef6671c0d 100644 --- a/locales/ko.yaml +++ b/locales/ko.yaml @@ -279,6 +279,7 @@ Future messages in this room will use that transcript until `/reset` or another none_found: "{cwd}에 체크포인트를 찾을 수 없습니다" invalid_number: "잘못된 체크포인트 번호입니다. 1-{max}을 사용하세요." restored: "✅ 체크포인트 {hash}(으)로 복원됨: {reason}\n롤백 전 스냅샷이 자동으로 저장되었습니다." + kept_user_edits: "↷ 직접 수정한 내용은 유지되었습니다: {files}\n해당 파일도 복원하려면 /rollback --all 을 사용하세요." restore_failed: "❌ {error}" diff: diff --git a/locales/pt.yaml b/locales/pt.yaml index 0cbf524b68..d9bdf1b7c6 100644 --- a/locales/pt.yaml +++ b/locales/pt.yaml @@ -279,6 +279,7 @@ Future messages in this room will use that transcript until `/reset` or another none_found: "Não foram encontrados checkpoints para {cwd}" invalid_number: "Número de checkpoint inválido. Usa 1-{max}." restored: "✅ Restaurado para o checkpoint {hash}: {reason}\nFoi guardado automaticamente um snapshot anterior ao rollback." + kept_user_edits: "↷ As suas edições manuais foram mantidas: {files}\nUse /rollback --all para restaurar essas também." restore_failed: "❌ {error}" diff: diff --git a/locales/ru.yaml b/locales/ru.yaml index a0d8eb508c..92309f6294 100644 --- a/locales/ru.yaml +++ b/locales/ru.yaml @@ -279,6 +279,7 @@ Future messages in this room will use that transcript until `/reset` or another none_found: "Контрольных точек для {cwd} не найдено" invalid_number: "Недействительный номер контрольной точки. Используйте 1-{max}." restored: "✅ Восстановлено до контрольной точки {hash}: {reason}\nСнимок перед откатом сохранён автоматически." + kept_user_edits: "↷ Ваши ручные правки сохранены: {files}\nИспользуйте /rollback --all, чтобы восстановить и их." restore_failed: "❌ {error}" diff: diff --git a/locales/tr.yaml b/locales/tr.yaml index f39605c4b4..db3cf75b94 100644 --- a/locales/tr.yaml +++ b/locales/tr.yaml @@ -279,6 +279,7 @@ Future messages in this room will use that transcript until `/reset` or another none_found: "{cwd} için kontrol noktası bulunamadı" invalid_number: "Geçersiz kontrol noktası numarası. 1-{max} aralığını kullanın." restored: "✅ {hash} kontrol noktasına geri yüklendi: {reason}\nGeri alma öncesi anlık görüntü otomatik olarak kaydedildi." + kept_user_edits: "↷ Elle yaptığınız düzenlemeler korundu: {files}\nOnları da geri yüklemek için /rollback --all kullanın." restore_failed: "❌ {error}" diff: diff --git a/locales/uk.yaml b/locales/uk.yaml index 08f40a8492..b4a5e414d2 100644 --- a/locales/uk.yaml +++ b/locales/uk.yaml @@ -279,6 +279,7 @@ Future messages in this room will use that transcript until `/reset` or another none_found: "Контрольних точок для {cwd} не знайдено" invalid_number: "Недійсний номер контрольної точки. Використовуйте 1-{max}." restored: "✅ Відновлено до контрольної точки {hash}: {reason}\nЗнімок перед відкатом збережено автоматично." + kept_user_edits: "↷ Ваші ручні правки збережено: {files}\nВикористайте /rollback --all, щоб відновити і їх." restore_failed: "❌ {error}" diff: diff --git a/locales/zh-hant.yaml b/locales/zh-hant.yaml index c08ee22d68..a79f5f983b 100644 --- a/locales/zh-hant.yaml +++ b/locales/zh-hant.yaml @@ -279,6 +279,7 @@ Future messages in this room will use that transcript until `/reset` or another none_found: "找不到 {cwd} 的檢查點" invalid_number: "無效的檢查點編號。請使用 1-{max}。" restored: "✅ 已還原至檢查點 {hash}:{reason}\n已自動儲存回復前的快照。" + kept_user_edits: "↷ 已保留您的手動編輯:{files}\n若也要還原這些檔案,請使用 /rollback --all。" restore_failed: "❌ {error}" diff: diff --git a/locales/zh.yaml b/locales/zh.yaml index 2c8aafdae3..e6c0e1e124 100644 --- a/locales/zh.yaml +++ b/locales/zh.yaml @@ -279,6 +279,7 @@ Future messages in this room will use that transcript until `/reset` or another none_found: "未找到 {cwd} 的检查点" invalid_number: "无效的检查点编号。请使用 1-{max}。" restored: "✅ 已恢复到检查点 {hash}:{reason}\n已自动保存回滚前的快照。" + kept_user_edits: "↷ 已保留您的手动编辑:{files}\n如需一并恢复这些文件,请使用 /rollback --all。" restore_failed: "❌ {error}" diff: diff --git a/run_agent.py b/run_agent.py index a3da633bba..c7a23b8d74 100644 --- a/run_agent.py +++ b/run_agent.py @@ -3565,9 +3565,19 @@ class AIAgent: return landed = file_mutation_result_landed(tool_name, result) if landed: + landed_paths = _extract_landed_file_mutation_paths(tool_name, args, result) changed = getattr(self, "_turn_file_mutation_paths", None) if changed is not None: - changed.update(_extract_landed_file_mutation_paths(tool_name, args, result)) + changed.update(landed_paths) + # Feed the checkpoint agent-write ledger so /rollback's safe mode + # can tell Hermes-authored content from later user hand-edits. + mgr = getattr(self, "_checkpoint_mgr", None) + if mgr is not None and getattr(mgr, "enabled", False): + for _p in landed_paths: + try: + mgr.record_agent_write(_p) + except Exception: + pass if is_error and not landed: preview = _extract_error_preview(result) for path in targets: diff --git a/tests/tools/test_checkpoint_manager.py b/tests/tools/test_checkpoint_manager.py index db96b0eafa..d0af8e9e58 100644 --- a/tests/tools/test_checkpoint_manager.py +++ b/tests/tools/test_checkpoint_manager.py @@ -280,7 +280,6 @@ class TestRestore: mgr.ensure_checkpoint(str(work_dir), "initial") assert mgr.restore(str(work_dir), "deadbeef1234")["success"] is False - def test_tilde_path_supports_diff_and_restore_flow( self, checkpoint_base, fake_home, monkeypatch, ): @@ -307,6 +306,111 @@ class TestRestore: assert file_path.read_text() == "original\n" +class TestSafeRestore: + """Safe restore: preserve user hand-edits, revert only Hermes-authored changes. + + Inspired by Copilot CLI's /rewind, which "restores only the files Copilot + changed, skipping any file whose contents no longer match what Copilot + last wrote". + """ + + def _checkpoint(self, mgr, work_dir): + assert mgr.ensure_checkpoint(str(work_dir), "initial") is True + mgr.new_turn() + cps = mgr.list_checkpoints(str(work_dir)) + assert cps + return cps[0]["hash"] + + def test_safe_restore_skips_user_edited_file(self, mgr, work_dir): + base = self._checkpoint(mgr, work_dir) + + # Hermes writes main.py and records it in the ledger. + (work_dir / "main.py").write_text("agent version\n") + mgr.record_agent_write(str(work_dir / "main.py")) + + # The user then hand-edits README.md (Hermes never wrote it). + (work_dir / "README.md").write_text("user hand edit\n") + + result = mgr.restore(str(work_dir), base, safe=True) + assert result["success"] is True + # Hermes-authored change reverted... + assert (work_dir / "main.py").read_text() == "print('hello')\n" + # ...user's hand edit preserved. + assert (work_dir / "README.md").read_text() == "user hand edit\n" + assert "README.md" in result["skipped_user_edits"] + assert "main.py" in result["restored_files"] + + def test_safe_restore_skips_file_user_edited_after_agent(self, mgr, work_dir): + base = self._checkpoint(mgr, work_dir) + + # Hermes writes the file, then the user modifies it afterwards. + (work_dir / "main.py").write_text("agent version\n") + mgr.record_agent_write(str(work_dir / "main.py")) + (work_dir / "main.py").write_text("user tweaked the agent's file\n") + + result = mgr.restore(str(work_dir), base, safe=True) + assert result["success"] is True + # Content no longer matches what Hermes last wrote → preserved. + assert (work_dir / "main.py").read_text() == "user tweaked the agent's file\n" + assert "main.py" in result["skipped_user_edits"] + + def test_safe_restore_restores_agent_deleted_file(self, mgr, work_dir): + base = self._checkpoint(mgr, work_dir) + + (work_dir / "main.py").write_text("agent version\n") + mgr.record_agent_write(str(work_dir / "main.py")) + (work_dir / "main.py").unlink() + + result = mgr.restore(str(work_dir), base, safe=True) + assert result["success"] is True + assert (work_dir / "main.py").read_text() == "print('hello')\n" + + def test_safe_restore_falls_back_to_full_when_no_ledger(self, mgr, work_dir): + """Empty ledger (pre-existing stores) → classic full restore.""" + base = self._checkpoint(mgr, work_dir) + (work_dir / "main.py").write_text("changed without ledger\n") + + result = mgr.restore(str(work_dir), base, safe=True) + assert result["success"] is True + assert (work_dir / "main.py").read_text() == "print('hello')\n" + # Fallback path: no per-file classification in the result. + assert "skipped_user_edits" not in result + + def test_safe_restore_removes_agent_created_file_keeps_user_edit(self, mgr, work_dir): + base = self._checkpoint(mgr, work_dir) + + # Hermes creates a brand-new file after the checkpoint... + (work_dir / "agent.txt").write_text("agent file\n") + mgr.record_agent_write(str(work_dir / "agent.txt")) + # ...and the user hand-edits an existing one. + (work_dir / "README.md").write_text("user edit\n") + + result = mgr.restore(str(work_dir), base, safe=True) + assert result["success"] is True + # User edit preserved; Hermes-created file removed (not in checkpoint). + assert (work_dir / "README.md").read_text() == "user edit\n" + assert not (work_dir / "agent.txt").exists() + assert "README.md" in result["skipped_user_edits"] + assert "agent.txt" in result["restored_files"] + + def test_unsafe_restore_overwrites_everything(self, mgr, work_dir): + base = self._checkpoint(mgr, work_dir) + (work_dir / "main.py").write_text("agent version\n") + mgr.record_agent_write(str(work_dir / "main.py")) + (work_dir / "README.md").write_text("user hand edit\n") + + result = mgr.restore(str(work_dir), base, safe=False) + assert result["success"] is True + assert (work_dir / "main.py").read_text() == "print('hello')\n" + assert (work_dir / "README.md").read_text() == "# Project\n" + + def test_record_agent_write_disabled_manager_noop(self, checkpoint_base, work_dir, monkeypatch): + monkeypatch.setattr("tools.checkpoint_manager.CHECKPOINT_BASE", checkpoint_base) + m = CheckpointManager(enabled=False) + m.record_agent_write(str(work_dir / "main.py")) # must not raise + assert not (checkpoint_base / "store").exists() + + # ========================================================================= # CheckpointManager — working dir resolution # ========================================================================= diff --git a/tools/checkpoint_manager.py b/tools/checkpoint_manager.py index df3666f134..5448632103 100644 --- a/tools/checkpoint_manager.py +++ b/tools/checkpoint_manager.py @@ -76,8 +76,12 @@ _STORE_DIRNAME = "store" _REFS_PREFIX = "refs/hermes" _INDEXES_DIRNAME = "indexes" _PROJECTS_DIRNAME = "projects" +_LEDGERS_DIRNAME = "ledgers" _LEGACY_PREFIX = "legacy-" +# Agent-write ledger cap: newest entries retained per project. +_LEDGER_MAX_ENTRIES = 2000 + DEFAULT_EXCLUDES = [ # Dependency / build output "node_modules/", @@ -224,6 +228,56 @@ def _index_path(store: Path, dir_hash: str) -> Path: return store / _INDEXES_DIRNAME / dir_hash +def _ledger_path(store: Path, dir_hash: str) -> Path: + return store / _LEDGERS_DIRNAME / f"{dir_hash}.json" + + +def _hash_file(path: Path) -> Optional[str]: + """Streaming sha256 of a file's bytes. None if unreadable/missing.""" + try: + h = hashlib.sha256() + with open(path, "rb") as fh: + for chunk in iter(lambda: fh.read(1 << 20), b""): + h.update(chunk) + return h.hexdigest() + except OSError: + return None + + +def _load_ledger(store: Path, dir_hash: str) -> Dict[str, Dict]: + """Load the agent-write ledger: {relpath: {"sha256": ..., "ts": ...}}. + + The ledger records the content hash of every file the last successful + ``write_file`` / ``patch`` produced, so restores can tell "Hermes wrote + this" apart from "the user hand-edited this afterwards". + """ + try: + raw = _ledger_path(store, dir_hash).read_text(encoding="utf-8") + data = json.loads(raw) + return data if isinstance(data, dict) else {} + except (OSError, ValueError): + return {} + + +def _save_ledger(store: Path, dir_hash: str, ledger: Dict[str, Dict]) -> None: + """Persist the agent-write ledger, capped to the newest entries.""" + try: + if len(ledger) > _LEDGER_MAX_ENTRIES: + newest = sorted( + ledger.items(), + key=lambda kv: kv[1].get("ts", 0) if isinstance(kv[1], dict) else 0, + reverse=True, + )[:_LEDGER_MAX_ENTRIES] + ledger = dict(newest) + path = _ledger_path(store, dir_hash) + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(".json.tmp") + tmp.write_text(json.dumps(ledger), encoding="utf-8") + tmp.replace(path) + except OSError: + logger.debug("Failed to save agent-write ledger for %s", dir_hash, exc_info=True) + + def _ref_name(dir_hash: str) -> str: return f"{_REFS_PREFIX}/{dir_hash}" @@ -746,6 +800,99 @@ class CheckpointManager: # Public API # ------------------------------------------------------------------ + def record_agent_write(self, file_path: str) -> None: + """Record the content hash of a file Hermes just successfully wrote. + + Feeds the agent-write ledger used by :meth:`restore` in safe mode: + at restore time, a file whose current content no longer matches the + recorded hash was hand-edited by the user after Hermes last touched + it, and is skipped instead of clobbered. + + Never raises — the ledger is best-effort bookkeeping. + """ + if not self.enabled: + return + try: + path = _normalize_path(file_path) + digest = _hash_file(path) + if digest is None: + return + working_dir = self.get_working_dir_for_path(str(path)) + store = _store_path(CHECKPOINT_BASE) + dir_hash = _project_hash(working_dir) + ledger = _load_ledger(store, dir_hash) + ledger[str(path)] = {"sha256": digest, "ts": time.time()} + _save_ledger(store, dir_hash, ledger) + except Exception as exc: + logger.debug("record_agent_write failed for %s: %s", file_path, exc) + + def safe_restore_plan(self, working_dir: str, commit_hash: str) -> Dict: + """Classify files changed since ``commit_hash`` for a safe restore. + + Returns ``{"success", "restore": [rel...], "skipped": [rel...], + "error"?}`` where ``restore`` lists files whose current content + still matches what Hermes last wrote (per the agent-write ledger) + and ``skipped`` lists files the user hand-edited after Hermes' + last write or that Hermes never wrote at all. + """ + hash_err = _validate_commit_hash(commit_hash) + if hash_err: + return {"success": False, "error": hash_err} + + abs_dir = str(_normalize_path(working_dir)) + store = _store_path(CHECKPOINT_BASE) + if not (store / "HEAD").exists(): + return {"success": False, "error": "No checkpoints exist for this directory"} + + dir_hash = _project_hash(abs_dir) + index_file = _index_path(store, dir_hash) + + # Stage the current tree so the name-only diff sees new files too. + _run_git(["add", "-A"], store, abs_dir, + timeout=_GIT_TIMEOUT * 2, index_file=index_file) + ok, names_out, err = _run_git( + ["diff", "--name-only", commit_hash, "--cached"], + store, abs_dir, index_file=index_file, + ) + # Reset the index back to the project ref so it doesn't drift. + _run_git(["read-tree", _ref_name(dir_hash)], store, abs_dir, + index_file=index_file, allowed_returncodes={128}) + if not ok: + return {"success": False, "error": f"Could not compute changed files: {err}"} + + ledger = _load_ledger(store, dir_hash) + if not ledger: + # No agent-write ledger yet (pre-existing store, or Hermes has + # not written any files here since the ledger was introduced). + # Signal callers to fall back to a full restore rather than + # skipping every file. + return {"success": True, "restore": [], "skipped": [], + "ledger_empty": True} + restore: List[str] = [] + skipped: List[str] = [] + for rel in names_out.splitlines(): + rel = rel.strip() + if not rel: + continue + abs_path = Path(abs_dir) / rel + entry = ledger.get(str(abs_path)) + recorded = entry.get("sha256") if isinstance(entry, dict) else None + if recorded is None: + # Hermes never wrote this file (or the ledger predates it) — + # do not touch it in safe mode. + skipped.append(rel) + continue + current = _hash_file(abs_path) + if current is None: + # File deleted since Hermes wrote it: restoring it back is + # safe — its last content was Hermes-authored. + restore.append(rel) + elif current == recorded: + restore.append(rel) + else: + skipped.append(rel) + return {"success": True, "restore": restore, "skipped": skipped} + def ensure_checkpoint(self, working_dir: str, reason: str = "auto") -> bool: """Take a checkpoint if enabled and not already done this turn. @@ -916,8 +1063,20 @@ class CheckpointManager: result["empty"] = True return result - def restore(self, working_dir: str, commit_hash: str, file_path: str = None) -> Dict: - """Restore files to a checkpoint state.""" + def restore( + self, + working_dir: str, + commit_hash: str, + file_path: str = None, + safe: bool = False, + ) -> Dict: + """Restore files to a checkpoint state. + + With ``safe=True`` (full-directory restores only), files the user + hand-edited after Hermes' last write — per the agent-write ledger — + are left untouched, and only Hermes-authored changes are reverted. + The result gains ``skipped_user_edits`` listing the preserved paths. + """ hash_err = _validate_commit_hash(commit_hash) if hash_err: return {"success": False, "error": hash_err} @@ -941,18 +1100,67 @@ class CheckpointManager: return {"success": False, "error": f"Checkpoint '{commit_hash}' not found", "debug": err or None} + skipped_user_edits: List[str] = [] + restore_paths: Optional[List[str]] = None + if safe and not file_path: + plan = self.safe_restore_plan(abs_dir, commit_hash) + if not plan.get("success"): + return {"success": False, "error": plan.get("error", "Safe-restore plan failed")} + if plan.get("ledger_empty"): + # No agent-write history to compare against — fall back to + # the classic full restore rather than restoring nothing. + restore_paths = None + else: + restore_paths = plan["restore"] + skipped_user_edits = plan["skipped"] + if not restore_paths: + return { + "success": True, + "restored_to": commit_hash[:8], + "reason": "nothing to restore (all changed files were user-edited)", + "directory": abs_dir, + "restored_files": [], + "skipped_user_edits": skipped_user_edits, + } + # Take a pre-rollback snapshot so you can undo the undo. self._take(abs_dir, f"pre-rollback snapshot (restoring to {commit_hash[:8]})") 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], - store, abs_dir, timeout=_GIT_TIMEOUT * 2, - index_file=index_file, - ) + if restore_paths is not None: + # Split into files present in the checkpoint (checkout) and + # Hermes-created files absent from it (delete to restore state). + checkout_targets: List[str] = [] + delete_targets: List[str] = [] + for rel in restore_paths: + ok_in_commit, _, _ = _run_git( + ["cat-file", "-e", f"{commit_hash}:{rel}"], + store, abs_dir, allowed_returncodes={1, 128}, + ) + (checkout_targets if ok_in_commit else delete_targets).append(rel) + for rel in delete_targets: + try: + target = Path(abs_dir) / rel + if target.is_file() or target.is_symlink(): + target.unlink() + except OSError as exc: + logger.debug("Safe restore: could not remove %s: %s", rel, exc) + if not checkout_targets: + ok, stdout, err = True, "", "" + else: + ok, stdout, err = _run_git( + ["checkout", commit_hash, "--", *checkout_targets], + store, abs_dir, timeout=_GIT_TIMEOUT * 2, + index_file=index_file, + ) + else: + ok, stdout, err = _run_git( + ["checkout", commit_hash, "--", file_path if file_path else "."], + store, abs_dir, timeout=_GIT_TIMEOUT * 2, + index_file=index_file, + ) if not ok: return {"success": False, "error": f"Restore failed: {err}", @@ -971,6 +1179,9 @@ class CheckpointManager: } if file_path: result["file"] = file_path + if restore_paths is not None: + result["restored_files"] = restore_paths + result["skipped_user_edits"] = skipped_user_edits return result def get_working_dir_for_path(self, file_path: str) -> str: diff --git a/website/docs/user-guide/checkpoints-and-rollback.md b/website/docs/user-guide/checkpoints-and-rollback.md index a71ec2fe5b..1a14d7c1be 100644 --- a/website/docs/user-guide/checkpoints-and-rollback.md +++ b/website/docs/user-guide/checkpoints-and-rollback.md @@ -40,7 +40,8 @@ In-session slash commands: | Command | Description | |---------|-------------| | `/rollback` | List all checkpoints with change stats | -| `/rollback ` | Restore to checkpoint N (also undoes last chat turn) | +| `/rollback ` | Restore to checkpoint N, keeping your hand-edits (also undoes last chat turn) | +| `/rollback --all` | Full restore — overwrites your hand-edits too | | `/rollback diff ` | Preview diff between checkpoint N and current state | | `/rollback ` | Restore a single file from checkpoint N | @@ -133,7 +134,8 @@ Hermes responds with a formatted list showing change statistics: 2. eaf4c1f 2026-03-16 04:35 before write_file 3. b3f9d2e 2026-03-16 04:34 before terminal: sed -i s/old/new/ config.py (1 file, +1/-1) - /rollback restore to checkpoint N + /rollback restore to checkpoint N (keeps your hand-edits) + /rollback --all full restore, overwriting your hand-edits too /rollback diff preview changes since checkpoint N /rollback restore a single file from checkpoint N ``` @@ -191,9 +193,34 @@ Behind the scenes, Hermes: 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. +3. Restores tracked files in your working directory — **preserving your hand-edits** (see below). 4. **Undoes the last conversation turn** so the agent's context matches the restored filesystem state. +### User hand-edits are preserved by default + +`/rollback ` restores only the files Hermes itself changed. Every successful +`write_file` / `patch` records the file's content hash in an **agent-write +ledger**; at restore time, any file whose current contents no longer match what +Hermes last wrote (you edited it afterwards, or Hermes never touched it) is +**skipped** instead of overwritten, and listed in the output: + +``` +✅ Restored to checkpoint a1b2c3d4: before write_file +↷ Kept your hand-edits: src/config.py, notes.md +Use /rollback --all to restore those too. +``` + +To force the classic full restore that reverts everything — including your own +edits — add `--all`: + +``` +/rollback 1 --all +``` + +If the ledger is empty (a store created before this feature, or Hermes hasn't +written any files in the project yet), `/rollback` falls back to the full +restore automatically. + ## Single-File Restore Restore just one file from a checkpoint without affecting the rest of the directory: