Files
hermes-agent/tests/hermes_cli/test_update_target_identity.py
Teknium f03358537f fix(update): the fork sync counts and merges one resolved upstream commit (m2)
The merge used refs/remotes/upstream/main but the commit counts used the short
upstream/main, which a local branch of that name shadows (refs/heads before
refs/remotes): the sync printed '1 behind' and merged 2 commits. The sync now
resolves refs/remotes/upstream/main^{commit} once and uses that SHA for both
counts (against refs/remotes/origin/main) and the fast-forward.
2026-10-06 04:36:02 -07:00

474 lines
24 KiB
Python

"""Stable updates consume one remote commit across Git and archive transports."""
from copy import deepcopy
import json
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import os
from pathlib import Path
import subprocess
import sys
import threading
from types import SimpleNamespace
from urllib.parse import urlsplit
import urllib.request
import pytest
from hermes_cli import main as cli_main, update_cmd, update_receipt
from hermes_cli.update_inventory import UpdatePlan
from hermes_cli.update_cmd import _sync_with_upstream_if_needed
def git(root, *args):
return subprocess.run(['git', *args], cwd=root, check=True, capture_output=True,
text=True, encoding='utf-8').stdout.strip()
@pytest.fixture
def update_tree(tmp_path, monkeypatch):
monkeypatch.setenv('GIT_CONFIG_GLOBAL', str(tmp_path / 'git-config'))
monkeypatch.setenv('GIT_CONFIG_NOSYSTEM', '1')
monkeypatch.setenv('GIT_ALLOW_PROTOCOL', 'file')
monkeypatch.delenv('HERMES_UPDATE_HANDOFF_PID', raising=False)
monkeypatch.delenv('HERMES_UPDATE_REEXEC', raising=False)
origin = tmp_path / 'origin'
origin.mkdir()
git(origin, 'init', '-q', '-b', 'main')
git(origin, 'config', 'user.name', 'Fixture')
git(origin, 'config', 'user.email', 'fixture@example.invalid')
(origin / 'content.txt').write_text('base\n', encoding='utf-8')
(origin / '.gitignore').write_text('.bytecode-fingerprint\n', encoding='utf-8')
git(origin, 'add', 'content.txt', '.gitignore')
git(origin, '-c', 'commit.gpgsign=false', 'commit', '-qm', 'base')
git(origin, 'tag', 'v1.0.0')
base = git(origin, 'rev-parse', 'HEAD')
clone = tmp_path / 'install'
git(tmp_path, 'clone', '-q', str(origin), str(clone))
git(clone, 'config', 'user.name', 'Fixture')
git(clone, 'config', 'user.email', 'fixture@example.invalid')
git(clone, 'checkout', '-qb', 'retained-branch')
git(clone, 'tag', 'v1.1.0') # A stale local tag must never choose the release.
(origin / 'content.txt').write_text('release\n', encoding='utf-8')
git(origin, '-c', 'commit.gpgsign=false', 'commit', '-qam', 'release')
wanted = git(origin, 'rev-parse', 'HEAD')
git(origin, '-c', 'tag.gpgSign=false', 'tag', '-a', 'v1.1.0', '-m', 'release')
(origin / 'content.txt').write_text('unreleased-main\n', encoding='utf-8')
git(origin, '-c', 'commit.gpgsign=false', 'commit', '-qam', 'unreleased')
newer = git(origin, 'rev-parse', 'HEAD')
monkeypatch.setattr(cli_main, 'PROJECT_ROOT', clone)
monkeypatch.setattr(update_receipt, '_code_identity', lambda **_: {'commit': base})
monkeypatch.setattr(cli_main, '_run_pre_update_backup', lambda *_: 'release-snapshot')
monkeypatch.setattr(cli_main, '_pause_windows_gateways_for_update', lambda: None)
resumed = []
monkeypatch.setattr(cli_main, '_resume_windows_gateways_after_update', lambda state: resumed.append(state))
monkeypatch.setattr(cli_main, '_install_hangup_protection', lambda **_: {'installed': False})
monkeypatch.setattr(cli_main, '_finalize_update_output', lambda *_: None)
plans = []
def inventory():
sha = git(clone, 'rev-parse', 'HEAD') if (clone / '.git').exists() else base
plan = UpdatePlan(install_method='git', expected_sha=sha, profiles=['default'])
plans.append(plan)
return plan
monkeypatch.setattr('hermes_cli.update_inventory.collect_runtime_inventory', inventory)
monkeypatch.setattr(cli_main, '_sync_with_upstream_if_needed',
lambda *_a, **_k: pytest.fail('stable update reached upstream branch sync'))
requests = []
def capture(request):
requests.append(deepcopy(request))
return {'exit_code': 0, 'receipt': None}
monkeypatch.setattr(update_cmd, 'run_completion', capture)
args = SimpleNamespace(branch=None, channel='stable', yes=True, force=True, force_venv=True,
check=False, plan=False, gateway=False, install_id=False, set_channel=None)
return SimpleNamespace(origin=origin, clone=clone, base=base, wanted=wanted, newer=newer,
args=args, resumed=resumed, requests=requests, plans=plans)
@pytest.mark.parametrize('case', ['main', 'explicit', 'missing', 'no-move', 'wrong-branch',
'fork-no-upstream', 'fork-upstream', 'fork-upstream-push-ok',
'fork-upstream-wrong-branch', 'fork-upstream-reverted',
'fork-late', 'fork-late-push-ok', 'fork-late-wrong-branch',
'fork-late-reverted', 'check-main',
'check-explicit', 'check-missing', 'check-upstream'])
def test_branch_update_uses_real_refs_and_completion_request(update_tree, monkeypatch, case, capsys):
t = update_tree
git(t.clone, 'checkout', '-q', 'main')
t.args.channel = 'main'
t.args.gateway = True
t.args.check = case.startswith('check-')
monkeypatch.setattr(cli_main, '_sync_with_upstream_if_needed', _sync_with_upstream_if_needed)
if case in {'explicit', 'check-explicit'}:
git(t.origin, 'branch', 'chosen', t.wanted)
t.args.branch = 'chosen'
if case in {'missing', 'check-missing'}:
t.args.branch = 'absent'
if case.startswith('fork-') or case == 'check-upstream':
# Early sync starts level with origin; late sync first consumes origin.
git(t.origin, 'reset', '--hard', t.wanted if case.startswith('fork-late') else t.base)
if case.startswith(('fork-upstream', 'fork-late')) or case == 'check-upstream':
upstream = t.origin.parent / 'upstream'
git(t.origin.parent, 'clone', '-q', str(t.origin), str(upstream))
git(upstream, 'reset', '--hard', t.newer)
git(t.clone, 'remote', 'add', 'upstream', str(upstream))
if case.endswith('push-ok'):
git(t.origin, 'config', 'receive.denyCurrentBranch', 'updateInstead')
local = t.clone / '.gitignore'
local_work = local.read_bytes() + b'# local work\n'
if case.startswith('fork-late'):
local.write_bytes(local_work)
run = subprocess.run
pushes = []
def _upstream_sha():
# The fork sync merges the commit it resolved from refs/remotes/upstream/main (m2).
found = run(['git', 'rev-parse', '-q', '--verify', 'refs/remotes/upstream/main'], cwd=t.clone,
capture_output=True, text=True, encoding='utf-8')
return found.stdout.strip() or '<no upstream>'
def fault(command, *args, **kwargs):
assert Path(command[0]).name.lower() in {'git', 'git.exe'} or command[0] == sys.executable, command
assert Path(kwargs['cwd']).resolve() in {t.clone, t.origin}, command
if 'merge' in command and _upstream_sha() in command: # the fork sync's local fast-forward
result = run(command, *args, **kwargs)
if case.startswith('fork-late'):
if case.endswith('wrong-branch'):
run(['git', 'checkout', '-qb', 'wrong'], cwd=t.clone, check=True, capture_output=True)
if case.endswith('reverted'):
run(['git', 'reset', '--hard', t.base], cwd=t.clone, check=True, capture_output=True)
return result
if 'merge' in command and '--ff-only' in command:
if case == 'no-move':
return subprocess.CompletedProcess(command, 0, stdout='', stderr='')
result = run(command, *args, **kwargs)
if case in {'wrong-branch', 'fork-upstream-wrong-branch'}:
run(['git', 'checkout', '-qb', 'wrong'], cwd=t.clone, check=True, capture_output=True)
if case == 'fork-upstream-reverted':
run(['git', 'reset', '--hard', t.base], cwd=t.clone, check=True, capture_output=True)
return result
result = run(command, *args, **kwargs)
if 'push' in command and 'origin' in command:
pushes.append(result.returncode)
return result
monkeypatch.setattr(subprocess, 'run', fault)
fails = case in {'missing', 'check-missing', 'no-move', 'wrong-branch',
'fork-upstream-wrong-branch', 'fork-upstream-reverted',
'fork-late-wrong-branch', 'fork-late-reverted'}
if fails:
with pytest.raises(SystemExit) as error:
cli_main.cmd_update(t.args)
assert error.value.code == 1
assert t.requests == []
if case.startswith('fork-late'):
assert git(t.clone, 'show', 'stash@{0}:.gitignore') == local_work.decode().strip()
assert local.read_bytes() != local_work
else:
cli_main.cmd_update(t.args)
if t.args.check:
assert t.requests == []
assert git(t.clone, 'rev-parse', 'HEAD') == t.base
output = capsys.readouterr().out
assert ('absent' if case == 'check-missing' else 'update') in output.lower()
if case == 'check-upstream':
assert git(t.clone, 'rev-parse', 'upstream/main') == t.newer
else:
request, = t.requests
assert request['assume_yes'] is True
assert request['gateway_mode'] is True
expected = t.base if case == 'fork-no-upstream' else t.wanted if case == 'explicit' else t.newer
assert git(t.clone, 'rev-parse', 'HEAD') == expected
assert request['source'] == str(t.clone)
if case.startswith(('fork-upstream', 'fork-late')):
assert request['expected_sha'] == expected
output = capsys.readouterr().out
assert 'Code did not move' not in output
assert 'Already up to date' not in output
origin_before = t.wanted if case.startswith('fork-late') else t.base
assert git(t.origin, 'rev-parse', 'HEAD') == (t.newer if case.endswith('push-ok') else origin_before)
assert pushes and all((rc == 0) is case.endswith('push-ok') for rc in pushes)
if case.startswith('fork-late'):
assert local.read_bytes() == local_work
assert not git(t.clone, 'stash', 'list')
if case == 'fork-no-upstream':
assert 'official repo not checked' in request['completion_message']
assert git(t.clone, 'remote') == 'origin'
@pytest.mark.parametrize('server', ['sha', 'fetch-refused', 'at-release', 'ahead-release', 'explicit-branch'])
def test_stable_git_uses_remote_identity_without_moving_local_tags(update_tree, monkeypatch, server):
"""A stable update is pinned to the channel's exact commit: no tag lookup on
origin, the stale local ``v1.1.0`` never moves, and an explicit --branch
bypasses the channel."""
from hermes_cli import source_releases
from hermes_cli.release_channels import ChannelResolution
t = update_tree
# The stable channel is an R2 record whose published build pins t.wanted
# (the documented reader seam; see test_source_channel_integration).
record = {"schema": 1, "name": "stable", "repository": "NousResearch/hermes-agent",
"policy": "stable-release", "state": "active", "identity": {}, "nextSequence": 2,
"head": {"buildId": "build-fixture", "sequence": 1}}
manifest = {"schema": 1, "request": {"buildId": "build-fixture", "channel": "stable", "sequence": 1,
"repository": "NousResearch/hermes-agent", "commit": t.wanted, "sourceVersion": "1.1.0",
"version": "0.0.1", "identity": {}, "bundleEnv": {}}, "packages": []}
monkeypatch.setattr(source_releases, '_resolve_channel',
lambda name, repository: ChannelResolution(record, record, manifest))
expected = t.wanted
if server in {'at-release', 'ahead-release'}:
git(t.clone, 'fetch', '--no-tags', 'origin', t.wanted)
git(t.clone, 'merge', '--ff-only', t.wanted)
if server == 'ahead-release':
(t.clone / 'local.txt').write_text('local commit\n', encoding='utf-8')
git(t.clone, 'add', 'local.txt')
git(t.clone, '-c', 'commit.gpgsign=false', 'commit', '-qm', 'local')
if server == 'explicit-branch':
git(t.origin, 'branch', 'retained-branch', t.newer)
t.args.branch = 'retained-branch'
expected = t.newer
run = subprocess.run
calls = []
def guarded_run(command, *args, **kwargs):
command = list(map(str, command))
assert Path(command[0]).name.lower() in {'git', 'git.exe'}, command
cwd = Path(kwargs.get('cwd', os.getcwd())).resolve()
assert cwd in {t.clone, t.origin}, (command, cwd)
assert 'push' not in command and 'pull' not in command and 'ls-remote' not in command, command
calls.append(command)
if 'fetch' in command and t.wanted in command and server == 'fetch-refused':
return subprocess.CompletedProcess(command, 128, stdout='', stderr='fixture: raw SHA wants disabled')
return run(command, *args, **kwargs)
monkeypatch.setattr(subprocess, 'run', guarded_run)
if server == 'fetch-refused':
with pytest.raises(SystemExit) as error:
cli_main.cmd_update(t.args)
assert error.value.code == 1
assert git(t.clone, 'rev-parse', 'HEAD') == t.base
assert t.resumed
assert t.requests == []
else:
cli_main.cmd_update(t.args)
request, = t.requests
plan, = t.plans
assert request['source'] == str(t.clone.resolve())
assert request['branch'] == (t.args.branch or 'main')
assert request['plan'] == plan.to_dict()
assert request['receipt']['plan'] == plan.to_dict()
assert request['snapshot_id'] == 'release-snapshot'
if server == 'at-release':
assert request['completion_message'] == '✓ Already up to date!'
assert request['plan']['expected_sha'] == expected
else:
assert request['expected_sha'] == expected
assert git(t.clone, 'rev-parse', 'HEAD') == expected
content = 'unreleased-main\n' if server == 'explicit-branch' else 'release\n'
assert (t.clone / 'content.txt').read_text(encoding='utf-8-sig') == content
branch = 'retained-branch' if server in {'at-release', 'explicit-branch'} else ''
assert git(t.clone, 'branch', '--show-current') == branch
assert git(t.clone, 'rev-parse', 'v1.1.0') == t.base
assert not git(t.clone, 'status', '--porcelain')
@pytest.mark.platforms('windows')
@pytest.mark.parametrize('transport', ['gitless', 'no-git', 'git-error', 'dirty'])
def test_stable_zip_consumes_the_same_commit_through_the_real_swap(update_tree, monkeypatch, tmp_path, transport):
from hermes_cli import source_releases
from hermes_cli.release_channels import ChannelResolution
t = update_tree
monkeypatch.setattr(cli_main, '_pause_windows_gateways_for_update',
lambda: {"resume_needed": True})
archive = tmp_path / 'source.zip'
git(t.origin, 'archive', '--format=zip', '--prefix=hermes-agent-source/', f'--output={archive}', t.wanted)
archive_bytes = archive.read_bytes()
record = {"schema": 1, "name": "stable", "repository": "NousResearch/hermes-agent",
"policy": "stable-release", "state": "active", "identity": {}, "nextSequence": 2,
"head": {"buildId": "build-fixture", "sequence": 1}}
manifest = {"schema": 1, "request": {"buildId": "build-fixture", "channel": "stable", "sequence": 1,
"repository": "NousResearch/hermes-agent", "commit": t.wanted, "sourceVersion": "1.1.0",
"version": "0.0.1", "identity": {}, "bundleEnv": {}}, "packages": []}
monkeypatch.setattr(source_releases, '_resolve_channel',
lambda name, repository: ChannelResolution(record, record, manifest))
routes = {
f'/NousResearch/hermes-agent/archive/{t.wanted}.zip': archive_bytes,
}
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
value = routes.get(self.path)
if value is None:
self.send_error(404)
return
body = value if isinstance(value, bytes) else json.dumps(value).encode()
self.send_response(200)
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *_args):
pass
server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
urls, git_calls = [], []
real_open, real_run = urllib.request.urlopen, subprocess.run
fetched, failed = False, False
def local_open(request, *args, **kwargs):
url = request.full_url if isinstance(request, urllib.request.Request) else request
parsed = urlsplit(url)
assert parsed.scheme == 'https' and parsed.netloc in {'api.github.com', 'github.com', 'hermes-assets.nousresearch.com'}, url
urls.append(url)
local = f'http://127.0.0.1:{server.server_port}{parsed.path}'
if parsed.query:
local += '?' + parsed.query
return real_open(local, *args, **kwargs)
from hermes_cli import update_custody
real_custody_run, seam = update_custody.run, threading.local()
def guard(command, args, kwargs, call):
nonlocal fetched, failed
command = list(map(str, command))
assert Path(command[0]).name.lower() in {'git', 'git.exe'}, command
cwd = Path(kwargs.get('cwd', os.getcwd())).resolve()
assert cwd in {t.clone, t.origin}, (command, cwd)
assert 'push' not in command and 'pull' not in command, command
git_calls.append(command)
if transport == 'no-git':
raise FileNotFoundError('fixture: no Git executable')
if fetched and not failed and '--abbrev-ref' in command and kwargs.get('check'):
failed = True
raise subprocess.CalledProcessError(128, command, '', 'fixture: Git file I/O failed')
result = call(command, *args, **kwargs)
if 'fetch' in command:
fetched = True
return result
def guarded_run(command, *args, **kwargs):
if getattr(seam, 'custody', False): # already guarded at the custody seam below
return real_run(command, *args, **kwargs)
return guard(command, args, kwargs, real_run)
def custody_call(command, *args, **kwargs):
seam.custody = True
try:
return real_custody_run(command, *args, **kwargs)
finally:
seam.custody = False
def guarded_custody_run(command, *args, **kwargs):
# The updater's git goes through update_custody.run: on Windows inside an update that is a
# suspended Popen bound to the update's job, not subprocess.run, so faults go in here too.
return guard(command, args, kwargs, custody_call)
if transport in {'gitless', 'no-git'}:
(t.clone / '.git').rename(tmp_path / 'git-state')
if transport == 'dirty':
(t.clone / 'content.txt').write_text('local work\n', encoding='utf-8')
before = (t.clone / 'content.txt').read_bytes()
monkeypatch.setattr(urllib.request, 'urlopen', local_open)
monkeypatch.setattr(subprocess, 'run', guarded_run)
monkeypatch.setattr(update_custody, 'run', guarded_custody_run)
try:
if transport == 'dirty':
with pytest.raises(SystemExit) as error:
cli_main.cmd_update(t.args)
assert error.value.code == 1
assert (t.clone / 'content.txt').read_bytes() == before
assert not any('/archive/' in url for url in urls)
assert t.requests == []
else:
cli_main.cmd_update(t.args)
request, = t.requests
plan, = t.plans
assert request['source'] == str(t.clone.resolve())
assert request['expected_sha'] == t.wanted
assert request['plan'] == plan.to_dict()
assert request['receipt']['plan'] == plan.to_dict()
assert request['snapshot_id'] == 'release-snapshot'
assert (t.clone / 'content.txt').read_text(encoding='utf-8-sig') == 'release\n'
assert [url for url in urls if '/archive/' in url] == [
f'https://github.com/NousResearch/hermes-agent/archive/{t.wanted}.zip']
assert t.resumed
if transport in {'git-error', 'dirty'}:
assert failed and fetched
assert not any('ls-remote' in cmd for cmd in git_calls)
finally:
server.shutdown()
thread.join(timeout=5)
server.server_close()
@pytest.mark.parametrize('sync_phase', ['origin', 'early', 'late', 'late-other-branch'])
@pytest.mark.parametrize('dirty', [False, True])
def test_update_syntax_failure_restores_pre_update_head(update_tree, monkeypatch, capsys, sync_phase, dirty):
t = update_tree
git(t.clone, 'checkout', '-q', 'main')
t.args.channel = 'main'
monkeypatch.setattr(cli_main, '_sync_with_upstream_if_needed', _sync_with_upstream_if_needed)
if sync_phase != 'origin':
upstream = t.origin.parent / 'upstream'
git(t.origin.parent, 'clone', '-q', str(t.origin), str(upstream))
git(t.clone, 'remote', 'add', 'upstream', str(upstream))
if sync_phase == 'early':
git(t.origin, 'reset', '--hard', t.base)
remote = upstream
else:
remote = t.origin
bad = remote / 'hermes_cli' / 'config.py'
bad.parent.mkdir()
bad.write_text('def broken(:\n', encoding='utf-8')
git(remote, 'add', 'hermes_cli/config.py')
git(remote, '-c', 'user.name=Fixture', '-c', 'user.email=fixture@example.invalid',
'-c', 'commit.gpgsign=false', 'commit', '-qm', 'invalid syntax')
unexpected_head = git(remote, 'rev-parse', 'HEAD')
if sync_phase == 'late-other-branch':
def switch_after_sync(*args, **kwargs):
result = _sync_with_upstream_if_needed(*args, **kwargs)
git(t.clone, 'checkout', '-qb', 'unexpected')
return result
monkeypatch.setattr(cli_main, '_sync_with_upstream_if_needed', switch_after_sync)
local = t.clone / '.gitignore'
staged = local.read_bytes() + b'# staged local work\n'
unstaged = staged + b'# unstaged local work\n'
if dirty:
local.write_bytes(staged)
git(t.clone, 'add', '.gitignore')
local.write_bytes(unstaged)
(t.clone / 'notes.txt').write_bytes(b'untracked local work\n')
with pytest.raises(SystemExit) as error:
cli_main.cmd_update(t.args)
assert error.value.code == 1
assert not t.requests
output = capsys.readouterr().out
if sync_phase == 'late-other-branch':
assert git(t.clone, 'rev-parse', 'unexpected') == unexpected_head
assert git(t.clone, 'branch', '--show-current') == 'unexpected'
assert (t.clone / 'hermes_cli/config.py').read_bytes() == bad.read_bytes()
assert "checkout is on 'unexpected'" in output
assert 'Rolling back' not in output
else:
assert 'Pulled code has a syntax error' in output
assert git(t.clone, 'rev-parse', 'HEAD') == t.base
assert not (t.clone / 'hermes_cli' / 'config.py').exists()
assert not git(t.clone, 'status', '--porcelain')
assert bool(git(t.clone, 'stash', 'list')) is dirty
if dirty:
assert git(t.clone, 'show', 'stash@{0}^2:.gitignore') == staged.decode().strip()
assert git(t.clone, 'show', 'stash@{0}:.gitignore') == unstaged.decode().strip()
assert git(t.clone, 'show', 'stash@{0}^3:notes.txt') == 'untracked local work'
git(t.clone, 'stash', 'apply', '--index')
assert local.read_bytes() == unstaged
assert git(t.clone, 'show', ':.gitignore') == staged.decode().strip()
assert (t.clone / 'notes.txt').read_bytes() == b'untracked local work\n'