fix(update): ignore flat-install runtime state so autostash cannot sweep state.db

On a flat install (checkout root == $HERMES_HOME) the untracked autostash of
`hermes update` sweeps the live state.db/-wal, snapshots, cron ledger and
lock/pid files into the stash and unlinks them under the running gateway; the
restart recreates an empty store at the same path and the declined restore
leaves the profile with no transcripts (#110648).

Root-anchor the runtime state set in .gitignore, mirroring the
.hermes-bootstrap-complete (#38529) and /.install_method (#66189) precedent
and the $HERMES_HOME-root enumeration in the platforms base module, so the
stash step is never entered for runtime state alone. Regression test runs the
exact stash command against a real repo carrying the tracked .gitignore.

(cherry picked from commit 6c74c24e6131af189801e54f1e11b260a529b74a)
This commit is contained in:
liuhao1024
2026-09-14 15:30:47 +08:00
committed by kshitij
parent db64ddb58e
commit 8f34499851
2 changed files with 131 additions and 0 deletions
+27
View File
@@ -168,6 +168,33 @@ docs/superpowers/*
# treat it as a local edit and autostash it on every run (#38529).
.hermes-bootstrap-complete
# Flat-install runtime state (checkout root == $HERMES_HOME, e.g. installs made
# with HERMES_INSTALL_DIR=$HERMES_HOME or by older installers): the live session
# store, its SQLite sidecars and retired-WAL capture dirs, quick snapshots, the
# legacy transcripts, the cron executions ledger, gateway lock/pid files, and
# cache/spill directories are Hermes-managed runtime state, never code changes.
# Ignore them so `hermes update`'s `git stash push --include-untracked` cannot
# sweep the live state.db/-wal into the stash and unlink it under the running
# gateway (#110648). Nested installs keep all of this under $HERMES_HOME outside
# the checkout, where the `.hermes/` rule above already applies.
/state.db
/state.db-wal
/state.db-shm
/state.db-journal
/state.db.retired-wal-*/
/kanban.db
/kanban.db-wal
/kanban.db-shm
/kanban.db-journal
/state-snapshots/
/sessions/
/browser-profile/
/cron/executions.db
/gateway.lock
/gateway.pid
/hook_outputs/
/cache/
# Persistent dev sandbox dir (scripts/dev-sandbox.sh --persistent)
.hermes-sandbox/
# Sandbox dirs used by the install/update E2E (tests/install/). The suffix is
@@ -0,0 +1,104 @@
"""Flat-install runtime state must be gitignored so ``hermes update``'s untracked
autostash cannot sweep the live state.db (#110648).
On a flat install (checkout root == $HERMES_HOME) the profile's runtime files
live inside the repo as untracked paths. ``git stash push --include-untracked``
(hermes_cli/update_cmd_stash.py) moves the whole untracked set into the stash and
unlinks it from the working tree under the running gateway, silently stranding
every transcript when the restore is declined or fails its health check. The
tracked .gitignore must cover the runtime state set, mirroring the
.hermes-bootstrap-complete / .install_method precedent (#38529 / #66189).
"""
import shutil
import subprocess
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
# Runtime state that lives at $HERMES_HOME's root on a flat install, exactly as
# ``hermes update`` would sweep it: the session store plus its SQLite sidecars
# (gateway/platforms/base.py _ROOT_CREDENTIAL_PATHS enumerates the same set) and
# retired-WAL capture dirs, quick snapshots, the legacy transcript dir, the
# default kanban board, the cron executions ledger, gateway lock/pid files and
# the cache/spill directories.
FLAT_INSTALL_RUNTIME_STATE = (
"state.db",
"state.db-wal",
"state.db-shm",
"state.db-journal",
"state.db.retired-wal-20260914T000000Z-1234/manifest.json",
"kanban.db",
"kanban.db-wal",
"kanban.db-shm",
"kanban.db-journal",
"state-snapshots/2026-09-14T06-00-00-pre-update/state.db",
"sessions/2026-09-14_06-00-00_abcd123d.jsonl",
"browser-profile/Cookies",
"cron/executions.db",
"gateway.lock",
"gateway.pid",
"hook_outputs/2026-09-14_06-00-00/tool.json",
"cache/banner_snapshot.json",
)
def _run_git(repo: Path, *args: str) -> subprocess.CompletedProcess:
return subprocess.run(
["git", "-C", str(repo), *args], check=True, capture_output=True, text=True
)
@pytest.fixture
def flat_install_repo(tmp_path: Path) -> Path:
"""A real git repo standing in for a flat install, with the tracked .gitignore.
Built in a subdirectory of tmp_path: the suite-wide HERMES_HOME isolation
fixture (tests/conftest.py) materialises its own ``hermes_test/`` tree in
tmp_path itself, which is not part of this repo's story.
"""
repo = tmp_path / "flat-install-checkout"
repo.mkdir()
subprocess.run(["git", "init", "-q", str(repo)], check=True)
shutil.copyfile(REPO_ROOT / ".gitignore", repo / ".gitignore")
(repo / "app.py").write_text("print('hermes')\n")
_run_git(repo, "add", ".gitignore", "app.py")
_run_git(
repo,
"-c", "user.email=t@t", "-c", "user.name=t",
"commit", "-qm", "init",
)
for rel in FLAT_INSTALL_RUNTIME_STATE:
path = repo / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(b"runtime state")
return repo
def test_flat_install_runtime_state_is_ignored(flat_install_repo):
"""`git status --porcelain` must stay empty with the full runtime state present,
so `hermes update` never enters its stash step for runtime state alone."""
status = _run_git(
flat_install_repo, "status", "--porcelain", "--untracked-files=all"
)
assert status.stdout == "", status.stdout
def test_untracked_autostash_cannot_sweep_runtime_state(flat_install_repo):
"""The exact ``git stash push --include-untracked`` the updater runs must leave
every runtime state file in the working tree (issue repro, step 6)."""
# A tracked local change proves the stash really ran: it must be swept away
# while the runtime state survives.
(flat_install_repo / "app.py").write_text("print('changed')\n")
_run_git(
flat_install_repo,
"stash", "push", "--include-untracked", "-m", "hermes-update-autostash",
)
assert (flat_install_repo / "app.py").read_text() == "print('hermes')\n"
missing = [
rel
for rel in FLAT_INSTALL_RUNTIME_STATE
if not (flat_install_repo / rel).exists()
]
assert missing == []