Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion hermes_cli/backup.py
Original file line number Diff line number Diff line change
Expand Up @@ -563,11 +563,14 @@ def _print_capped(header: str, lines: List[str], indent: str) -> None:

# --- Backup ---

_RUN_BACKUP_PREFIX = "hermes-backup-"


def _resolve_backup_output_path(output: Optional[str]) -> Path:
"""Turn ``--output`` (file, directory, or None) into a ``.zip`` path whose parent exists;
an unwritable path exits with a one-line error, not a traceback."""
out_path = None
default_name = f"hermes-backup-{datetime.now().strftime('%Y-%m-%d-%H%M%S')}.zip"
default_name = f"{_RUN_BACKUP_PREFIX}{datetime.now().strftime('%Y-%m-%d-%H%M%S')}.zip"
try:
if output:
out_path = Path(output).expanduser().resolve()
Expand Down Expand Up @@ -679,6 +682,11 @@ def _progress(i: int) -> None:
_print_capped(f"\n Warnings ({len(errors)} files skipped):", errors, " ")
else:
print(f"\nRestore with: hermes import {out_path.name}")
keep = getattr(args, "keep", 0) # 0 / absent: never prune (non-CLI callers)
if keep and out_path.name.startswith(_RUN_BACKUP_PREFIX):
pruned = _prune_prefixed_zips(out_path.parent, _RUN_BACKUP_PREFIX, keep, "backup")
if pruned:
print(f" Pruned {pruned} older {_RUN_BACKUP_PREFIX}*.zip (keeping {keep}).")


# --- Import ---
Expand Down
4 changes: 4 additions & 0 deletions hermes_cli/subcommands/backup.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,8 @@ def build_backup_parser(subparsers, *, cmd_backup: Callable) -> None:
help="Quick snapshot: only critical state files (config, state.db, .env, auth, cron)")
backup_parser.add_argument(
"-l", "--label", help="Label for the snapshot (only used with --quick)")
backup_parser.add_argument(
"-k", "--keep", type=int, default=3, metavar="N",
help="After a full backup, delete older hermes-backup-*.zip files in the output "
"directory beyond the newest N (default 3; 0 keeps everything)")
backup_parser.set_defaults(func=cmd_backup)
22 changes: 22 additions & 0 deletions tests/hermes_cli/test_backup.py
Original file line number Diff line number Diff line change
Expand Up @@ -2423,3 +2423,25 @@ def _count_rows(db_path: Path) -> tuple[int, int]:
)
finally:
conn.close()


def test_run_backup_prunes_older_default_named_zips_but_not_others(tmp_path, monkeypatch):
"""Hourly `hermes backup` callers accumulated 150+ zips; --keep bounds the default-named
ones and leaves custom-named or foreign zips alone (#81317)."""
from argparse import Namespace
from hermes_cli import backup as backup_mod

home = tmp_path / ".hermes"
home.mkdir()
(home / "config.yaml").write_text("model: x\n")
monkeypatch.setenv("HERMES_HOME", str(home))
monkeypatch.setattr(Path, "home", lambda: tmp_path)
for i in range(4):
(tmp_path / f"hermes-backup-2026-01-0{i + 1}-000000.zip").write_bytes(b"old")
(tmp_path / "my-archive.zip").write_bytes(b"mine")

backup_mod.run_backup(Namespace(output=None, keep=2))

kept = sorted(p.name for p in tmp_path.glob("hermes-backup-*.zip"))
assert len(kept) == 2 and kept[0] == "hermes-backup-2026-01-04-000000.zip"
assert (tmp_path / "my-archive.zip").exists()
1 change: 1 addition & 0 deletions website/docs/reference/cli-commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -950,6 +950,7 @@ Create a zip archive of your Hermes configuration, skills, sessions, and data. T
| `-o`, `--output <path>` | Output path for the zip file (default: `~/hermes-backup-<timestamp>.zip`). |
| `-q`, `--quick` | Quick snapshot: only critical state files (config.yaml, state.db, .env, auth, cron jobs). Much faster than a full backup. |
| `-l`, `--label <name>` | Label for the snapshot (only used with `--quick`). |
| `-k`, `--keep <N>` | After a full backup, delete older `hermes-backup-*.zip` files in the output directory beyond the newest N (default 3; `0` keeps everything). Custom-named zips are never touched. |

The backup uses SQLite's `backup()` API for safe copying, so it works correctly even when Hermes is running (WAL-mode safe).

Expand Down
Loading