Files
hermes-agent/tests/scripts/install/test_install_unmerged_index.py
T
teknium1 d10bb2ab6f test: make tests/ mirror the source tree; drop issue numbers from filenames
`scripts/run_tests.sh tests/<dir>/` is how a change gets its regression
coverage run, so a test filed under the wrong directory is a test nobody
runs when that code changes. Two kinds of drift had accumulated.

Parallel directories for one source package, folded into the mirror:
  tests/acp        -> tests/acp_adapter   (its __init__/conftest move with it)
  tests/cli        -> tests/hermes_cli    (prompt_toolkit fixture merged into
                                           hermes_cli/conftest.py)
  tests/run_agent  -> tests/agent         (backoff fixture becomes
                                           agent/conftest.py)
  tests/relay      -> tests/gateway/relay
  tests/state      -> tests/hermes_state

246 loose files at tests/ root, routed by the package they import/patch:
hermes_cli, hermes_state, agent, gateway, tools, plugins, tui_gateway, cron.
Installer and desktop-update script tests go to tests/scripts/{install,
desktop_update}/. 43 tests of root-level modules (batch_runner, utils,
hermes_constants, packaging) stay at the root.

Filenames drop their issue numbers (95 files: test_89315_x.py -> test_x.py);
the number stays in the module docstring where it has context.

Collisions: test_cli_skin_integration.py existed in both tests/ and tests/cli
with different subsets — merged into one (10 tests, all kept);
run_agent/test_pre_compress_memory_context.py -> agent/..._handoff.py;
tests/test_account_usage.py -> agent/test_account_usage_fetch.py;
tests/test_web_server.py -> hermes_cli/test_web_server_ws_ping.py.
Deleted: test_minisweagent_path.py (empty since PR #2804),
test_model_picker_scroll.py (tested a private copy of the logic, imported
nothing), test_process_loop_event_loop_warning.py (asserted asyncio behaviour,
imported nothing from Hermes).

Repo-root path arithmetic (Path(__file__).parents[N], dirname chains) is
bumped for the 202 files that changed depth and verified by evaluating every
such expression against the new location. classify_changes' desktop-updater
lane prefix, tests-os.yml's ignore glob and every in-tree path comment follow
the moves. tests/test_tests_tree_layout.py keeps the tree from drifting back.
2026-09-13 09:18:02 -07:00

192 lines
7.4 KiB
Python

"""Regression: installer fails when the existing checkout has an unmerged index.
A previously interrupted update can leave ``$INSTALL_DIR`` with unmerged index
entries (files in a conflicted, "needs merge" state). In that state the update
path's ``git stash`` aborts with "could not write index" and the following
``git checkout <branch>`` aborts with "you need to resolve your current index
first" -- surfacing to GUI/bootstrap users as ``git checkout main failed
(exit 1)`` and failing the whole install at the repository stage.
The ``hermes update`` Python path already clears the conflict with ``git reset``
before stashing (#4735); both installer scripts must do the same.
"""
from __future__ import annotations
import re
import shutil
import subprocess
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parent.parent.parent.parent
INSTALL_SH = REPO_ROOT / "scripts" / "install.sh"
INSTALL_PS1 = REPO_ROOT / "scripts" / "install.ps1"
pytestmark = pytest.mark.skipif(
shutil.which("git") is None or shutil.which("bash") is None,
reason="needs git and bash",
)
def _git(cwd: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess:
return subprocess.run(
["git", "-c", "user.email=t@t", "-c", "user.name=t", *args],
cwd=cwd,
check=check,
capture_output=True,
text=True,
)
def _extract_autostash_block() -> str:
"""Pull the autostash if-block from install.sh's update_repo()."""
text = INSTALL_SH.read_text()
m = re.search(
r'local autostash_ref="".*?\n fi\n',
text,
re.DOTALL,
)
assert m is not None, "autostash block not found in install.sh"
return m.group(0)
def _extract_install_sh_function(name: str) -> str:
text = INSTALL_SH.read_text()
match = re.search(rf"{name}\(\) \{{.*?\n\}}", text, re.DOTALL)
assert match is not None, f"{name}() not found in install.sh"
return match.group(0)
def _make_unmerged_repo(repo: Path) -> None:
"""Leave ``repo`` with a conflicted (unmerged) index, as an interrupted
update would."""
_git(repo, "init")
(repo / "f.txt").write_text("base\n")
_git(repo, "add", "f.txt")
_git(repo, "commit", "-m", "base")
# Capture the default branch name only after the first commit exists
# (rev-parse on an unborn HEAD errors).
start = _git(repo, "rev-parse", "--abbrev-ref", "HEAD").stdout.strip()
_git(repo, "checkout", "-b", "feature")
(repo / "f.txt").write_text("feature side\n")
_git(repo, "add", "f.txt")
_git(repo, "commit", "-m", "feature")
_git(repo, "checkout", start)
(repo / "f.txt").write_text("main side\n")
_git(repo, "add", "f.txt")
_git(repo, "commit", "-m", "mainside")
# Conflicting merge — exits non-zero and leaves the index unmerged.
_git(repo, "merge", "feature", check=False)
@pytest.mark.live_system_guard_bypass # runs against a dedicated throwaway repo
def test_install_sh_clears_unmerged_index_then_stashes(tmp_path: Path) -> None:
repo = tmp_path / "hermes-agent"
repo.mkdir()
_make_unmerged_repo(repo)
# Sanity: this is exactly the state that breaks `git stash` / `git checkout`.
assert _git(repo, "ls-files", "--unmerged").stdout.strip(), (
"test setup failed to produce an unmerged index"
)
block = _extract_autostash_block()
script = (
"set -e\n"
'log_info() { echo "INFO: $*"; }\n'
f'INSTALL_DIR="{repo}"\n'
f"{_extract_install_sh_function('discard_update_lockfile_churn')}\n"
"run() {\n"
f"{block}"
"}\n"
"run\n"
"echo BLOCK_OK\n"
)
res = subprocess.run(
["bash", "-c", script], cwd=repo, capture_output=True, text=True
)
# The block must complete (previously `git stash` failed with "could not
# write index" on the unmerged tree).
assert res.returncode == 0, res.stderr
assert "BLOCK_OK" in res.stdout
assert "Clearing unmerged index entries" in res.stdout
# The conflict state is gone ...
assert _git(repo, "ls-files", "--unmerged").stdout.strip() == "", (
"unmerged entries should have been cleared"
)
# ... and the local changes were preserved in a stash, not discarded.
assert _git(repo, "stash", "list").stdout.strip(), (
"local changes should be preserved in a stash"
)
def test_install_ps1_clears_unmerged_index_before_stash() -> None:
"""install.ps1 must clear an unmerged index before stash/checkout, and do
so *before* the stash push (order matters — the fix is a no-op otherwise)."""
text = INSTALL_PS1.read_text()
assert "ls-files --unmerged" in text, (
"install.ps1 must detect an unmerged index before updating"
)
idx_unmerged = text.index("ls-files --unmerged")
idx_reset = text.index("reset -q", idx_unmerged)
idx_stash = text.index("stash push --include-untracked")
assert idx_unmerged < idx_stash, (
"the unmerged-index clear must run before `git stash push`"
)
assert idx_reset < idx_stash, "`git reset` must run before `git stash push`"
def test_install_sh_clears_unmerged_index_before_stash_source_order() -> None:
"""Same ordering contract for install.sh's source."""
text = INSTALL_SH.read_text()
assert "ls-files --unmerged" in text
idx_unmerged = text.index("ls-files --unmerged")
idx_stash = text.index("stash push --include-untracked")
assert idx_unmerged < idx_stash
def test_install_ps1_stops_venv_resident_processes_before_parking_venv() -> None:
"""The Windows venv-recreate path must stop every process running out of the
old venv before moving it aside.
A gateway autostarted by a scheduled task runs as
``venv\\Scripts\\pythonw.exe -m hermes_cli.main gateway run`` — image name
``pythonw``, not ``hermes.exe`` — so the ``taskkill /IM hermes.exe`` guard
misses it and the loaded ``.pyd`` stays locked (issues #47036/#47557/#47910).
The recreate branch must sweep by venv path prefix before Rename-Item, and
must never fall back to an in-place ``Remove-Item`` of the live ``venv``
(#83149 — that path can gut site-packages with no rollback).
"""
text = INSTALL_PS1.read_text()
# The hermes.exe tree-kill is preserved (kills spawned child processes too).
assert 'taskkill /F /T /IM hermes.exe' in text
# The venv path-prefix sweep exists. It must match by case-insensitive
# StartsWith, NOT PowerShell -like: a venv path containing wildcard
# metacharacters ('[', ']') — legal in a Windows user name — silently fails
# to match under -like, reintroducing the exact miss this fix closes.
idx_recreate = text.index("Virtual environment already exists, recreating")
idx_sweep = text.index("StartsWith($venvPrefix", idx_recreate)
assert "[System.StringComparison]::OrdinalIgnoreCase" in text[idx_sweep:idx_sweep + 200]
assert 'ExecutablePath -like "$venvRoot' not in text, (
"the -like wildcard match must not be used for venv path scoping"
)
# The process sweep must run before the venv is parked, or it is a no-op.
idx_park = text.index('Rename-Item -LiteralPath "venv"', idx_recreate)
assert idx_sweep < idx_park, (
"venv-resident processes must be stopped before Rename-Item parks the venv"
)
assert 'Remove-Item -Recurse -Force "venv"' not in text[idx_recreate:], (
"must not fall back to in-place delete of the live venv (#83149)"
)
assert "Could not move the existing venv aside" in text[idx_recreate:]