Files
hermes-agent/tests/hermes_cli/test_config_backups.py
Teknium bf53ff00a7 fix(config): one bounded backups/config/ dir replaces four config.yaml.bak schemes
Four writers each dropped their own uniquely-named copy of config.yaml next to
the real file and none of them ever deleted anything: hermes setup
(config.yaml.bak.YYYYMMDD_HHMMSS, one per run even with no change), the
corrupt-YAML snapshot (config.yaml.corrupt.<ts>.bak), hermes migrate xai
(config.yaml.bak-pre-migrate-xai-<ts>) and the Docker boot migration
(config.yaml.bak-<ts>, .env.bak-<ts>). A home dir accumulated a dozen variants
with no way to tell which mattered.

hermes_cli/config_backups.py::backup_config is now the single writer:
backups/config/config.yaml.<reason>.<YYYYMMDD-HHMMSS>, skipped when the newest
copy for that reason is byte-identical, rotated to the newest five per reason.
backups/ is already excluded from full backups so nothing nests. Legacy
siblings written by the old schemes are moved into the dir on first use;
hand-named copies (config.yaml.bak-my-note) are left alone.

Live: three `hermes setup --non-interactive` runs against an unchanged config
went from three .bak files in HERMES_HOME to one pre-setup copy under
backups/config/; repeated loads of broken YAML produce one corrupt copy
instead of one per process (deduped by content).
2026-09-09 02:36:00 -07:00

41 lines
1.9 KiB
Python

"""config.yaml backups: one dir, deduped, bounded — never a pile of siblings in HERMES_HOME."""
from pathlib import Path
from hermes_cli.config_backups import backup_config, list_config_backups
def test_repeat_backups_dedupe_and_rotate(tmp_path: Path, monkeypatch):
cfg = tmp_path / "config.yaml"
cfg.write_text("model: a\n")
stamps = iter(f"2026010100000{i}" for i in range(10))
monkeypatch.setattr("hermes_cli.config_backups.time.strftime", lambda _fmt: next(stamps))
first = backup_config(cfg, "pre-setup", keep=2)
assert first is not None and first.parent == tmp_path / "backups" / "config"
# Same bytes again → no new file (the hermes-setup-three-times case).
assert backup_config(cfg, "pre-setup", keep=2) is None
for i in range(3):
cfg.write_text(f"model: {i}\n")
backup_config(cfg, "pre-setup", keep=2)
kept = list_config_backups(cfg, "pre-setup")
assert len(kept) == 2 and kept[0].read_text() == "model: 2\n"
# Nothing left beside config.yaml in the home root.
assert [p.name for p in tmp_path.iterdir() if p.is_file()] == ["config.yaml"]
def test_legacy_siblings_move_but_user_named_copies_stay(tmp_path: Path):
cfg = tmp_path / "config.yaml"
cfg.write_text("model: a\n")
for name in ("config.yaml.bak.1778718391", "config.yaml.corrupt.20260729-093706.bak",
"config.yaml.bak-pre-migrate-xai-20260515-120000"):
(tmp_path / name).write_text("old")
(tmp_path / "config.yaml.bak-my-note").write_text("mine")
backup_config(cfg, "corrupt")
root = tmp_path / "backups" / "config"
assert (root / "config.yaml.bak.1778718391").exists()
assert (root / "config.yaml.corrupt.20260729-093706.bak").exists()
assert (tmp_path / "config.yaml.bak-my-note").read_text() == "mine"
assert not list(tmp_path.glob("config.yaml.bak.*")) and not list(tmp_path.glob("config.yaml.corrupt.*"))