feat: filesystem checkpoints and /rollback command
Automatic filesystem snapshots before destructive file operations,
with user-facing rollback. Inspired by PR #559 (by @alireza78a).
Architecture:
- Shadow git repos at ~/.hermes/checkpoints/{hash}/ via GIT_DIR
- CheckpointManager: take/list/restore, turn-scoped dedup, pruning
- Transparent — the LLM never sees it, no tool schema, no tokens
- Once per turn — only first write_file/patch triggers a snapshot
Integration:
- Config: checkpoints.enabled + checkpoints.max_snapshots
- CLI flag: hermes --checkpoints
- Trigger: run_agent.py _execute_tool_calls() before write_file/patch
- /rollback slash command in CLI + gateway (list, restore by number)
- Pre-rollback snapshot auto-created on restore (undo the undo)
Safety:
- Never blocks file operations — all errors silently logged
- Skips root dir, home dir, dirs >50K files
- Disables gracefully when git not installed
- Shadow repo completely isolated from project git
Tests: 35 new tests, all passing (2798 total suite)
Docs: feature page, config reference, CLI commands reference
2026-03-10 00:49:15 -07:00
|
|
|
"""Tests for tools/checkpoint_manager.py — CheckpointManager."""
|
|
|
|
|
|
2026-03-13 22:14:00 -07:00
|
|
|
import logging
|
|
|
|
|
import subprocess
|
feat: filesystem checkpoints and /rollback command
Automatic filesystem snapshots before destructive file operations,
with user-facing rollback. Inspired by PR #559 (by @alireza78a).
Architecture:
- Shadow git repos at ~/.hermes/checkpoints/{hash}/ via GIT_DIR
- CheckpointManager: take/list/restore, turn-scoped dedup, pruning
- Transparent — the LLM never sees it, no tool schema, no tokens
- Once per turn — only first write_file/patch triggers a snapshot
Integration:
- Config: checkpoints.enabled + checkpoints.max_snapshots
- CLI flag: hermes --checkpoints
- Trigger: run_agent.py _execute_tool_calls() before write_file/patch
- /rollback slash command in CLI + gateway (list, restore by number)
- Pre-rollback snapshot auto-created on restore (undo the undo)
Safety:
- Never blocks file operations — all errors silently logged
- Skips root dir, home dir, dirs >50K files
- Disables gracefully when git not installed
- Shadow repo completely isolated from project git
Tests: 35 new tests, all passing (2798 total suite)
Docs: feature page, config reference, CLI commands reference
2026-03-10 00:49:15 -07:00
|
|
|
import pytest
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from unittest.mock import patch
|
|
|
|
|
|
|
|
|
|
from tools.checkpoint_manager import (
|
|
|
|
|
CheckpointManager,
|
|
|
|
|
_shadow_repo_path,
|
|
|
|
|
_init_shadow_repo,
|
|
|
|
|
_run_git,
|
|
|
|
|
_git_env,
|
|
|
|
|
_dir_file_count,
|
|
|
|
|
format_checkpoint_list,
|
|
|
|
|
DEFAULT_EXCLUDES,
|
|
|
|
|
CHECKPOINT_BASE,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# =========================================================================
|
|
|
|
|
# Fixtures
|
|
|
|
|
# =========================================================================
|
|
|
|
|
|
|
|
|
|
@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")
|
|
|
|
|
return d
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.fixture()
|
|
|
|
|
def checkpoint_base(tmp_path):
|
|
|
|
|
"""Isolated checkpoint base — never writes to ~/.hermes/."""
|
|
|
|
|
return tmp_path / "checkpoints"
|
|
|
|
|
|
|
|
|
|
|
2026-04-11 14:48:51 -07:00
|
|
|
@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))
|
|
|
|
|
monkeypatch.setenv("USERPROFILE", str(home))
|
|
|
|
|
monkeypatch.delenv("HOMEDRIVE", raising=False)
|
|
|
|
|
monkeypatch.delenv("HOMEPATH", raising=False)
|
|
|
|
|
monkeypatch.setattr(Path, "home", classmethod(lambda cls: home))
|
|
|
|
|
return home
|
|
|
|
|
|
|
|
|
|
|
feat: filesystem checkpoints and /rollback command
Automatic filesystem snapshots before destructive file operations,
with user-facing rollback. Inspired by PR #559 (by @alireza78a).
Architecture:
- Shadow git repos at ~/.hermes/checkpoints/{hash}/ via GIT_DIR
- CheckpointManager: take/list/restore, turn-scoped dedup, pruning
- Transparent — the LLM never sees it, no tool schema, no tokens
- Once per turn — only first write_file/patch triggers a snapshot
Integration:
- Config: checkpoints.enabled + checkpoints.max_snapshots
- CLI flag: hermes --checkpoints
- Trigger: run_agent.py _execute_tool_calls() before write_file/patch
- /rollback slash command in CLI + gateway (list, restore by number)
- Pre-rollback snapshot auto-created on restore (undo the undo)
Safety:
- Never blocks file operations — all errors silently logged
- Skips root dir, home dir, dirs >50K files
- Disables gracefully when git not installed
- Shadow repo completely isolated from project git
Tests: 35 new tests, all passing (2798 total suite)
Docs: feature page, config reference, CLI commands reference
2026-03-10 00:49:15 -07:00
|
|
|
@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
|
|
|
|
|
# =========================================================================
|
|
|
|
|
|
|
|
|
|
class TestShadowRepoPath:
|
|
|
|
|
def test_deterministic(self, work_dir, checkpoint_base, monkeypatch):
|
|
|
|
|
monkeypatch.setattr("tools.checkpoint_manager.CHECKPOINT_BASE", checkpoint_base)
|
|
|
|
|
p1 = _shadow_repo_path(str(work_dir))
|
|
|
|
|
p2 = _shadow_repo_path(str(work_dir))
|
|
|
|
|
assert p1 == p2
|
|
|
|
|
|
|
|
|
|
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_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))
|
|
|
|
|
|
2026-04-11 14:48:51 -07:00
|
|
|
def test_tilde_and_expanded_home_share_shadow_repo(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)
|
|
|
|
|
|
feat: filesystem checkpoints and /rollback command
Automatic filesystem snapshots before destructive file operations,
with user-facing rollback. Inspired by PR #559 (by @alireza78a).
Architecture:
- Shadow git repos at ~/.hermes/checkpoints/{hash}/ via GIT_DIR
- CheckpointManager: take/list/restore, turn-scoped dedup, pruning
- Transparent — the LLM never sees it, no tool schema, no tokens
- Once per turn — only first write_file/patch triggers a snapshot
Integration:
- Config: checkpoints.enabled + checkpoints.max_snapshots
- CLI flag: hermes --checkpoints
- Trigger: run_agent.py _execute_tool_calls() before write_file/patch
- /rollback slash command in CLI + gateway (list, restore by number)
- Pre-rollback snapshot auto-created on restore (undo the undo)
Safety:
- Never blocks file operations — all errors silently logged
- Skips root dir, home dir, dirs >50K files
- Disables gracefully when git not installed
- Shadow repo completely isolated from project git
Tests: 35 new tests, all passing (2798 total suite)
Docs: feature page, config reference, CLI commands reference
2026-03-10 00:49:15 -07:00
|
|
|
|
|
|
|
|
# =========================================================================
|
|
|
|
|
# Shadow repo init
|
|
|
|
|
# =========================================================================
|
|
|
|
|
|
|
|
|
|
class TestShadowRepoInit:
|
|
|
|
|
def test_creates_git_repo(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))
|
|
|
|
|
assert err is None
|
|
|
|
|
assert (shadow / "HEAD").exists()
|
|
|
|
|
|
|
|
|
|
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))
|
|
|
|
|
assert not (work_dir / ".git").exists()
|
|
|
|
|
|
|
|
|
|
def test_has_exclude_file(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
|
|
|
|
|
|
|
|
|
|
def test_has_workdir_file(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))
|
|
|
|
|
workdir_file = shadow / "HERMES_WORKDIR"
|
|
|
|
|
assert workdir_file.exists()
|
|
|
|
|
assert str(work_dir.resolve()) in workdir_file.read_text()
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# =========================================================================
|
|
|
|
|
# CheckpointManager — disabled
|
|
|
|
|
# =========================================================================
|
|
|
|
|
|
|
|
|
|
class TestDisabledManager:
|
|
|
|
|
def test_ensure_checkpoint_returns_false(self, disabled_mgr, work_dir):
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# =========================================================================
|
|
|
|
|
# CheckpointManager — taking checkpoints
|
|
|
|
|
# =========================================================================
|
|
|
|
|
|
|
|
|
|
class TestTakeCheckpoint:
|
|
|
|
|
def test_first_checkpoint(self, mgr, work_dir):
|
|
|
|
|
result = mgr.ensure_checkpoint(str(work_dir), "initial")
|
|
|
|
|
assert result is True
|
|
|
|
|
|
2026-03-13 22:14:00 -07:00
|
|
|
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)
|
|
|
|
|
|
feat: filesystem checkpoints and /rollback command
Automatic filesystem snapshots before destructive file operations,
with user-facing rollback. Inspired by PR #559 (by @alireza78a).
Architecture:
- Shadow git repos at ~/.hermes/checkpoints/{hash}/ via GIT_DIR
- CheckpointManager: take/list/restore, turn-scoped dedup, pruning
- Transparent — the LLM never sees it, no tool schema, no tokens
- Once per turn — only first write_file/patch triggers a snapshot
Integration:
- Config: checkpoints.enabled + checkpoints.max_snapshots
- CLI flag: hermes --checkpoints
- Trigger: run_agent.py _execute_tool_calls() before write_file/patch
- /rollback slash command in CLI + gateway (list, restore by number)
- Pre-rollback snapshot auto-created on restore (undo the undo)
Safety:
- Never blocks file operations — all errors silently logged
- Skips root dir, home dir, dirs >50K files
- Disables gracefully when git not installed
- Shadow repo completely isolated from project git
Tests: 35 new tests, all passing (2798 total suite)
Docs: feature page, config reference, CLI commands reference
2026-03-10 00:49:15 -07:00
|
|
|
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")
|
|
|
|
|
assert r1 is True
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
def test_skip_root_dir(self, mgr):
|
|
|
|
|
r = mgr.ensure_checkpoint("/", "root")
|
|
|
|
|
assert r is False
|
|
|
|
|
|
|
|
|
|
def test_skip_home_dir(self, mgr):
|
|
|
|
|
r = mgr.ensure_checkpoint(str(Path.home()), "home")
|
|
|
|
|
assert r is False
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# =========================================================================
|
|
|
|
|
# CheckpointManager — listing checkpoints
|
|
|
|
|
# =========================================================================
|
|
|
|
|
|
|
|
|
|
class TestListCheckpoints:
|
|
|
|
|
def test_empty_when_no_checkpoints(self, mgr, work_dir):
|
|
|
|
|
result = mgr.list_checkpoints(str(work_dir))
|
|
|
|
|
assert result == []
|
|
|
|
|
|
|
|
|
|
def test_list_after_take(self, mgr, work_dir):
|
|
|
|
|
mgr.ensure_checkpoint(str(work_dir), "test checkpoint")
|
|
|
|
|
result = mgr.list_checkpoints(str(work_dir))
|
|
|
|
|
assert len(result) == 1
|
|
|
|
|
assert result[0]["reason"] == "test checkpoint"
|
|
|
|
|
assert "hash" in result[0]
|
|
|
|
|
assert "short_hash" in result[0]
|
|
|
|
|
assert "timestamp" in result[0]
|
|
|
|
|
|
|
|
|
|
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")
|
|
|
|
|
mgr.ensure_checkpoint(str(work_dir), "second")
|
|
|
|
|
mgr.new_turn()
|
|
|
|
|
|
|
|
|
|
(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"
|
|
|
|
|
|
2026-04-11 14:48:51 -07:00
|
|
|
def test_tilde_path_lists_same_checkpoints_as_expanded_path(self, checkpoint_base, fake_home, monkeypatch):
|
|
|
|
|
monkeypatch.setattr("tools.checkpoint_manager.CHECKPOINT_BASE", checkpoint_base)
|
|
|
|
|
mgr = 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 len(listed) == 1
|
|
|
|
|
assert listed[0]["reason"] == "initial"
|
|
|
|
|
|
feat: filesystem checkpoints and /rollback command
Automatic filesystem snapshots before destructive file operations,
with user-facing rollback. Inspired by PR #559 (by @alireza78a).
Architecture:
- Shadow git repos at ~/.hermes/checkpoints/{hash}/ via GIT_DIR
- CheckpointManager: take/list/restore, turn-scoped dedup, pruning
- Transparent — the LLM never sees it, no tool schema, no tokens
- Once per turn — only first write_file/patch triggers a snapshot
Integration:
- Config: checkpoints.enabled + checkpoints.max_snapshots
- CLI flag: hermes --checkpoints
- Trigger: run_agent.py _execute_tool_calls() before write_file/patch
- /rollback slash command in CLI + gateway (list, restore by number)
- Pre-rollback snapshot auto-created on restore (undo the undo)
Safety:
- Never blocks file operations — all errors silently logged
- Skips root dir, home dir, dirs >50K files
- Disables gracefully when git not installed
- Shadow repo completely isolated from project git
Tests: 35 new tests, all passing (2798 total suite)
Docs: feature page, config reference, CLI commands reference
2026-03-10 00:49:15 -07:00
|
|
|
|
|
|
|
|
# =========================================================================
|
|
|
|
|
# CheckpointManager — restoring
|
|
|
|
|
# =========================================================================
|
|
|
|
|
|
|
|
|
|
class TestRestore:
|
|
|
|
|
def test_restore_to_previous(self, mgr, work_dir):
|
|
|
|
|
# Write original content
|
|
|
|
|
(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")
|
|
|
|
|
|
|
|
|
|
# Get the checkpoint hash
|
|
|
|
|
checkpoints = mgr.list_checkpoints(str(work_dir))
|
|
|
|
|
assert len(checkpoints) == 1
|
|
|
|
|
|
|
|
|
|
# Restore
|
|
|
|
|
result = mgr.restore(str(work_dir), checkpoints[0]["hash"])
|
|
|
|
|
assert result["success"] is True
|
|
|
|
|
|
|
|
|
|
# File should be back to original
|
|
|
|
|
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")
|
|
|
|
|
result = mgr.restore(str(work_dir), "deadbeef1234")
|
|
|
|
|
assert result["success"] is False
|
|
|
|
|
|
|
|
|
|
def test_restore_no_checkpoints(self, mgr, work_dir):
|
|
|
|
|
result = mgr.restore(str(work_dir), "abc123")
|
|
|
|
|
assert result["success"] is False
|
|
|
|
|
|
|
|
|
|
def test_restore_creates_pre_rollback_snapshot(self, mgr, work_dir):
|
|
|
|
|
(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")
|
|
|
|
|
|
|
|
|
|
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"]
|
|
|
|
|
|
2026-04-11 14:48:51 -07:00
|
|
|
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)
|
|
|
|
|
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()
|
|
|
|
|
|
|
|
|
|
file_path.write_text("changed\n")
|
|
|
|
|
checkpoints = mgr.list_checkpoints(str(project))
|
|
|
|
|
diff_result = mgr.diff(tilde_path, checkpoints[0]["hash"])
|
|
|
|
|
assert diff_result["success"] is True
|
|
|
|
|
assert "main.py" in diff_result["diff"]
|
|
|
|
|
|
|
|
|
|
restore_result = mgr.restore(tilde_path, checkpoints[0]["hash"])
|
|
|
|
|
assert restore_result["success"] is True
|
|
|
|
|
assert file_path.read_text() == "original\n"
|
|
|
|
|
|
feat: filesystem checkpoints and /rollback command
Automatic filesystem snapshots before destructive file operations,
with user-facing rollback. Inspired by PR #559 (by @alireza78a).
Architecture:
- Shadow git repos at ~/.hermes/checkpoints/{hash}/ via GIT_DIR
- CheckpointManager: take/list/restore, turn-scoped dedup, pruning
- Transparent — the LLM never sees it, no tool schema, no tokens
- Once per turn — only first write_file/patch triggers a snapshot
Integration:
- Config: checkpoints.enabled + checkpoints.max_snapshots
- CLI flag: hermes --checkpoints
- Trigger: run_agent.py _execute_tool_calls() before write_file/patch
- /rollback slash command in CLI + gateway (list, restore by number)
- Pre-rollback snapshot auto-created on restore (undo the undo)
Safety:
- Never blocks file operations — all errors silently logged
- Skips root dir, home dir, dirs >50K files
- Disables gracefully when git not installed
- Shadow repo completely isolated from project git
Tests: 35 new tests, all passing (2798 total suite)
Docs: feature page, config reference, CLI commands reference
2026-03-10 00:49:15 -07:00
|
|
|
|
|
|
|
|
# =========================================================================
|
|
|
|
|
# CheckpointManager — working dir resolution
|
|
|
|
|
# =========================================================================
|
|
|
|
|
|
|
|
|
|
class TestWorkingDirResolution:
|
|
|
|
|
def test_resolves_git_project_root(self, tmp_path):
|
|
|
|
|
mgr = 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")
|
|
|
|
|
|
|
|
|
|
result = mgr.get_working_dir_for_path(str(filepath))
|
|
|
|
|
assert result == str(project)
|
|
|
|
|
|
|
|
|
|
def test_resolves_pyproject_root(self, tmp_path):
|
|
|
|
|
mgr = CheckpointManager(enabled=True)
|
|
|
|
|
project = tmp_path / "pyproj"
|
|
|
|
|
project.mkdir()
|
|
|
|
|
(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)
|
|
|
|
|
|
2026-04-24 03:46:46 -07:00
|
|
|
def test_falls_back_to_parent(self, tmp_path, monkeypatch):
|
feat: filesystem checkpoints and /rollback command
Automatic filesystem snapshots before destructive file operations,
with user-facing rollback. Inspired by PR #559 (by @alireza78a).
Architecture:
- Shadow git repos at ~/.hermes/checkpoints/{hash}/ via GIT_DIR
- CheckpointManager: take/list/restore, turn-scoped dedup, pruning
- Transparent — the LLM never sees it, no tool schema, no tokens
- Once per turn — only first write_file/patch triggers a snapshot
Integration:
- Config: checkpoints.enabled + checkpoints.max_snapshots
- CLI flag: hermes --checkpoints
- Trigger: run_agent.py _execute_tool_calls() before write_file/patch
- /rollback slash command in CLI + gateway (list, restore by number)
- Pre-rollback snapshot auto-created on restore (undo the undo)
Safety:
- Never blocks file operations — all errors silently logged
- Skips root dir, home dir, dirs >50K files
- Disables gracefully when git not installed
- Shadow repo completely isolated from project git
Tests: 35 new tests, all passing (2798 total suite)
Docs: feature page, config reference, CLI commands reference
2026-03-10 00:49:15 -07:00
|
|
|
mgr = CheckpointManager(enabled=True)
|
|
|
|
|
filepath = tmp_path / "random" / "file.py"
|
|
|
|
|
filepath.parent.mkdir(parents=True)
|
|
|
|
|
filepath.write_text("x\\n")
|
|
|
|
|
|
2026-04-24 03:46:46 -07:00
|
|
|
# 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
|
|
|
|
|
|
|
|
|
|
def _guarded_exists(self):
|
|
|
|
|
s = str(self)
|
|
|
|
|
stop = str(tmp_path)
|
|
|
|
|
if not s.startswith(stop) and any(
|
|
|
|
|
s.endswith("/" + m) or s == "/" + m
|
|
|
|
|
for m in (".git", "pyproject.toml", "package.json",
|
|
|
|
|
"Cargo.toml", "go.mod", "Makefile", "pom.xml",
|
|
|
|
|
".hg", "Gemfile")
|
|
|
|
|
):
|
|
|
|
|
return False
|
|
|
|
|
return _real_exists(self)
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(_pl.Path, "exists", _guarded_exists)
|
|
|
|
|
|
feat: filesystem checkpoints and /rollback command
Automatic filesystem snapshots before destructive file operations,
with user-facing rollback. Inspired by PR #559 (by @alireza78a).
Architecture:
- Shadow git repos at ~/.hermes/checkpoints/{hash}/ via GIT_DIR
- CheckpointManager: take/list/restore, turn-scoped dedup, pruning
- Transparent — the LLM never sees it, no tool schema, no tokens
- Once per turn — only first write_file/patch triggers a snapshot
Integration:
- Config: checkpoints.enabled + checkpoints.max_snapshots
- CLI flag: hermes --checkpoints
- Trigger: run_agent.py _execute_tool_calls() before write_file/patch
- /rollback slash command in CLI + gateway (list, restore by number)
- Pre-rollback snapshot auto-created on restore (undo the undo)
Safety:
- Never blocks file operations — all errors silently logged
- Skips root dir, home dir, dirs >50K files
- Disables gracefully when git not installed
- Shadow repo completely isolated from project git
Tests: 35 new tests, all passing (2798 total suite)
Docs: feature page, config reference, CLI commands reference
2026-03-10 00:49:15 -07:00
|
|
|
result = mgr.get_working_dir_for_path(str(filepath))
|
|
|
|
|
assert result == str(filepath.parent)
|
|
|
|
|
|
2026-04-11 14:48:51 -07:00
|
|
|
def test_resolves_tilde_path_to_project_root(self, fake_home):
|
|
|
|
|
mgr = CheckpointManager(enabled=True)
|
|
|
|
|
project = fake_home / "myproject"
|
|
|
|
|
project.mkdir()
|
|
|
|
|
(project / "pyproject.toml").write_text("[project]\n")
|
|
|
|
|
subdir = project / "src"
|
|
|
|
|
subdir.mkdir()
|
|
|
|
|
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)
|
|
|
|
|
|
feat: filesystem checkpoints and /rollback command
Automatic filesystem snapshots before destructive file operations,
with user-facing rollback. Inspired by PR #559 (by @alireza78a).
Architecture:
- Shadow git repos at ~/.hermes/checkpoints/{hash}/ via GIT_DIR
- CheckpointManager: take/list/restore, turn-scoped dedup, pruning
- Transparent — the LLM never sees it, no tool schema, no tokens
- Once per turn — only first write_file/patch triggers a snapshot
Integration:
- Config: checkpoints.enabled + checkpoints.max_snapshots
- CLI flag: hermes --checkpoints
- Trigger: run_agent.py _execute_tool_calls() before write_file/patch
- /rollback slash command in CLI + gateway (list, restore by number)
- Pre-rollback snapshot auto-created on restore (undo the undo)
Safety:
- Never blocks file operations — all errors silently logged
- Skips root dir, home dir, dirs >50K files
- Disables gracefully when git not installed
- Shadow repo completely isolated from project git
Tests: 35 new tests, all passing (2798 total suite)
Docs: feature page, config reference, CLI commands reference
2026-03-10 00:49:15 -07:00
|
|
|
|
|
|
|
|
# =========================================================================
|
|
|
|
|
# Git env isolation
|
|
|
|
|
# =========================================================================
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
def test_sets_work_tree(self, tmp_path):
|
|
|
|
|
shadow = tmp_path / "shadow"
|
|
|
|
|
work = tmp_path / "work"
|
|
|
|
|
env = _git_env(shadow, 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))
|
|
|
|
|
assert "GIT_INDEX_FILE" not in env
|
|
|
|
|
|
2026-04-11 14:48:51 -07:00
|
|
|
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}")
|
|
|
|
|
assert env["GIT_WORK_TREE"] == str(work.resolve())
|
|
|
|
|
|
feat: filesystem checkpoints and /rollback command
Automatic filesystem snapshots before destructive file operations,
with user-facing rollback. Inspired by PR #559 (by @alireza78a).
Architecture:
- Shadow git repos at ~/.hermes/checkpoints/{hash}/ via GIT_DIR
- CheckpointManager: take/list/restore, turn-scoped dedup, pruning
- Transparent — the LLM never sees it, no tool schema, no tokens
- Once per turn — only first write_file/patch triggers a snapshot
Integration:
- Config: checkpoints.enabled + checkpoints.max_snapshots
- CLI flag: hermes --checkpoints
- Trigger: run_agent.py _execute_tool_calls() before write_file/patch
- /rollback slash command in CLI + gateway (list, restore by number)
- Pre-rollback snapshot auto-created on restore (undo the undo)
Safety:
- Never blocks file operations — all errors silently logged
- Skips root dir, home dir, dirs >50K files
- Disables gracefully when git not installed
- Shadow repo completely isolated from project git
Tests: 35 new tests, all passing (2798 total suite)
Docs: feature page, config reference, CLI commands reference
2026-03-10 00:49:15 -07:00
|
|
|
|
|
|
|
|
# =========================================================================
|
|
|
|
|
# format_checkpoint_list
|
|
|
|
|
# =========================================================================
|
|
|
|
|
|
|
|
|
|
class TestFormatCheckpointList:
|
|
|
|
|
def test_empty_list(self):
|
|
|
|
|
result = format_checkpoint_list([], "/some/dir")
|
|
|
|
|
assert "No checkpoints" in result
|
|
|
|
|
|
|
|
|
|
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"},
|
|
|
|
|
]
|
|
|
|
|
result = format_checkpoint_list(cps, "/home/user/project")
|
|
|
|
|
assert "abc1" in result
|
|
|
|
|
assert "def4" in result
|
|
|
|
|
assert "before write_file" in result
|
|
|
|
|
assert "/rollback" in result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# =========================================================================
|
|
|
|
|
# File count guard
|
|
|
|
|
# =========================================================================
|
|
|
|
|
|
|
|
|
|
class TestDirFileCount:
|
|
|
|
|
def test_counts_files(self, work_dir):
|
|
|
|
|
count = _dir_file_count(str(work_dir))
|
|
|
|
|
assert count >= 2 # main.py + README.md
|
|
|
|
|
|
|
|
|
|
def test_nonexistent_dir(self, tmp_path):
|
|
|
|
|
count = _dir_file_count(str(tmp_path / "nonexistent"))
|
|
|
|
|
assert count == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# =========================================================================
|
|
|
|
|
# Error resilience
|
|
|
|
|
# =========================================================================
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
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
|
|
|
|
|
|
2026-03-13 22:14:00 -07:00
|
|
|
def test_run_git_allows_expected_nonzero_without_error_log(self, tmp_path, caplog):
|
2026-04-11 14:48:51 -07:00
|
|
|
work = tmp_path / "work"
|
|
|
|
|
work.mkdir()
|
2026-03-13 22:14:00 -07:00
|
|
|
completed = subprocess.CompletedProcess(
|
|
|
|
|
args=["git", "diff", "--cached", "--quiet"],
|
|
|
|
|
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",
|
2026-04-11 14:48:51 -07:00
|
|
|
str(work),
|
2026-03-13 22:14:00 -07:00
|
|
|
allowed_returncodes={1},
|
|
|
|
|
)
|
|
|
|
|
assert ok is False
|
|
|
|
|
assert stdout == ""
|
|
|
|
|
assert stderr == ""
|
|
|
|
|
assert not caplog.records
|
|
|
|
|
|
2026-04-11 14:48:51 -07:00
|
|
|
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),
|
|
|
|
|
)
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
def test_run_git_missing_git_reports_git_not_found(self, tmp_path, monkeypatch, caplog):
|
|
|
|
|
work = tmp_path / "work"
|
|
|
|
|
work.mkdir()
|
|
|
|
|
|
|
|
|
|
def raise_missing_git(*args, **kwargs):
|
|
|
|
|
raise FileNotFoundError(2, "No such file or directory", "git")
|
|
|
|
|
|
|
|
|
|
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),
|
|
|
|
|
)
|
|
|
|
|
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)
|
|
|
|
|
|
feat: filesystem checkpoints and /rollback command
Automatic filesystem snapshots before destructive file operations,
with user-facing rollback. Inspired by PR #559 (by @alireza78a).
Architecture:
- Shadow git repos at ~/.hermes/checkpoints/{hash}/ via GIT_DIR
- CheckpointManager: take/list/restore, turn-scoped dedup, pruning
- Transparent — the LLM never sees it, no tool schema, no tokens
- Once per turn — only first write_file/patch triggers a snapshot
Integration:
- Config: checkpoints.enabled + checkpoints.max_snapshots
- CLI flag: hermes --checkpoints
- Trigger: run_agent.py _execute_tool_calls() before write_file/patch
- /rollback slash command in CLI + gateway (list, restore by number)
- Pre-rollback snapshot auto-created on restore (undo the undo)
Safety:
- Never blocks file operations — all errors silently logged
- Skips root dir, home dir, dirs >50K files
- Disables gracefully when git not installed
- Shadow repo completely isolated from project git
Tests: 35 new tests, all passing (2798 total suite)
Docs: feature page, config reference, CLI commands reference
2026-03-10 00:49:15 -07:00
|
|
|
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
|
2026-04-11 22:54:45 +03:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# =========================================================================
|
|
|
|
|
# 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
|
|
|
|
|
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
|
|
|
|
|
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")
|
|
|
|
|
assert result["success"] is True
|
fix(checkpoints): isolate shadow git repo from user's global config (#11261)
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 filesystem snapshot — every write_file, patch, or diff
mid-session. With GPG_TTY unset, pinentry-qt/gtk would spawn a GUI
window, constantly interrupting the session.
The shadow repo is internal Hermes infrastructure. It must not
inherit user-level git settings (signing, hooks, aliases, credential
helpers, etc.) under any circumstance.
Fix is layered:
1. _git_env() sets GIT_CONFIG_GLOBAL=os.devnull,
GIT_CONFIG_SYSTEM=os.devnull, and GIT_CONFIG_NOSYSTEM=1. Shadow
git commands no longer see ~/.gitconfig or /etc/gitconfig at all
(uses os.devnull for Windows compat).
2. _init_shadow_repo() explicitly writes commit.gpgsign=false and
tag.gpgSign=false into the shadow's own config, so the repo is
correct even if inspected or run against directly without the
env vars, and for older git versions (<2.32) that predate
GIT_CONFIG_GLOBAL.
3. _take() passes --no-gpg-sign inline on the commit call. This
covers existing shadow repos created before this fix — they will
never re-run _init_shadow_repo (it is gated on HEAD not existing),
so they would miss layer 2. Layer 1 still protects them, but the
inline flag guarantees correctness at the commit call itself.
Existing checkpoints, rollback, list, diff, and restore all continue
to work — history is untouched. Users who had the bug stop getting
pinentry popups; users who didn't see no observable change.
Tests: 5 new regression tests in TestGpgAndGlobalConfigIsolation,
including a full E2E repro with fake HOME, global gpgsign=true, and
a deliberately broken GPG binary — checkpoint succeeds regardless.
2026-04-16 16:06:49 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# =========================================================================
|
|
|
|
|
# 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
|
|
|
|
|
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.
|
|
|
|
|
result = subprocess.run(
|
|
|
|
|
["git", "config", "--file", str(shadow / "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))
|
|
|
|
|
result = subprocess.run(
|
|
|
|
|
["git", "config", "--file", str(shadow / "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
|
|
|
|
|
):
|
|
|
|
|
"""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(
|
|
|
|
|
"[user]\n email = real@user.com\n name = Real User\n"
|
|
|
|
|
"[commit]\n gpgsign = true\n"
|
|
|
|
|
"[tag]\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) # 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
|
2026-04-26 19:05:52 -07:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# =========================================================================
|
|
|
|
|
# Auto-maintenance: prune_checkpoints + maybe_auto_prune_checkpoints
|
|
|
|
|
# =========================================================================
|
|
|
|
|
|
|
|
|
|
class TestPruneCheckpoints:
|
|
|
|
|
"""Sweep orphan/stale shadow repos under CHECKPOINT_BASE (issue #3015 follow-up)."""
|
|
|
|
|
|
|
|
|
|
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 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"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
result = prune_checkpoints(retention_days=0, checkpoint_base=base)
|
|
|
|
|
|
|
|
|
|
assert result["scanned"] == 2
|
|
|
|
|
assert result["deleted_orphan"] == 1
|
|
|
|
|
assert result["deleted_stale"] == 0
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
base = tmp_path / "checkpoints"
|
|
|
|
|
work = tmp_path / "work"
|
|
|
|
|
work.mkdir()
|
|
|
|
|
|
|
|
|
|
fresh_repo = self._seed_shadow_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)
|
|
|
|
|
|
|
|
|
|
result = prune_checkpoints(
|
|
|
|
|
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")
|
|
|
|
|
|
|
|
|
|
result = prune_checkpoints(
|
|
|
|
|
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()
|
|
|
|
|
(base / "garbage-dir" / "random.txt").write_text("hi")
|
|
|
|
|
|
|
|
|
|
result = prune_checkpoints(retention_days=0, checkpoint_base=base)
|
|
|
|
|
assert result["scanned"] == 0
|
|
|
|
|
assert (base / "garbage-dir").exists()
|
|
|
|
|
|
|
|
|
|
def test_tracks_bytes_freed(self, tmp_path):
|
|
|
|
|
from tools.checkpoint_manager import prune_checkpoints
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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")
|
|
|
|
|
|
|
|
|
|
out = maybe_auto_prune_checkpoints(checkpoint_base=base)
|
|
|
|
|
assert out["skipped"] is False
|
|
|
|
|
assert out["result"]["deleted_orphan"] == 1
|
|
|
|
|
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")
|
|
|
|
|
|
|
|
|
|
first = maybe_auto_prune_checkpoints(
|
|
|
|
|
checkpoint_base=base, min_interval_hours=24
|
|
|
|
|
)
|
|
|
|
|
assert first["skipped"] is False
|
|
|
|
|
|
|
|
|
|
self._seed(base, "2222" * 4, tmp_path / "also-gone")
|
|
|
|
|
second = maybe_auto_prune_checkpoints(
|
|
|
|
|
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")
|
|
|
|
|
|
|
|
|
|
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"
|
|
|
|
|
)
|
|
|
|
|
assert out["skipped"] is False
|
|
|
|
|
assert out["result"]["scanned"] == 0
|
|
|
|
|
|