mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-05-25 03:00:23 +00:00
Merge pull request #2764 from nesquena/release/stage-407
Release CL: v0.51.114 (stage-407 — 1-PR — update-check recovery from remote re-tags)
This commit is contained in:
@@ -3,6 +3,12 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [v0.51.114] — 2026-05-22 — Release CL (stage-407 — 1-PR — update-check recovery from remote re-tags)
|
||||
|
||||
### Fixed
|
||||
|
||||
- **PR #2758** by @nesquena-hermes — fix(updates): pass `--force` to `git fetch --tags` in `api/updates.py` so the WebUI's release-tracking update check can recover from a remote re-tag (e.g. a release tag that was force-pushed to a new commit after a squash-merge). Without `--force`, plain `git fetch origin --tags` returns `! [rejected] vX.Y.Z (would clobber existing tag)` and the entire update path (check, force-apply, normal-apply) jams indefinitely — neither the periodic check nor manual "Check now" nor the Update button can recover. Three fetch call sites were patched (`_check_repo`, `apply_force_update`, `apply_update`) to use `--tags --force`; the WebUI never pushes tags, so deferring to the remote's view is the right contract. Closes #2756.
|
||||
|
||||
## [v0.51.113] — 2026-05-22 — Release CK (stage-406 — 1-PR — composer model picker lag fix + hard-refresh recovery)
|
||||
|
||||
### Fixed
|
||||
|
||||
+14
-3
@@ -477,7 +477,14 @@ def _check_repo(path, name):
|
||||
|
||||
# Fetch tags first so update prompts track published releases, not every
|
||||
# development commit that lands on master/main after the latest release.
|
||||
fetch_out, fetch_ok = _run_git(['fetch', 'origin', '--tags'], path, timeout=15)
|
||||
#
|
||||
# --force is required because the WebUI is a release-tracking consumer:
|
||||
# it never pushes tags, so it should always defer to whatever the remote
|
||||
# says a release tag points to. Without --force, a remote re-tag (e.g.
|
||||
# after a squash-merge that re-points a release tag at a new SHA) jams
|
||||
# the update path indefinitely with "would clobber existing tag" errors.
|
||||
# See #2756.
|
||||
fetch_out, fetch_ok = _run_git(['fetch', 'origin', '--tags', '--force'], path, timeout=15)
|
||||
if not fetch_ok:
|
||||
release_info = _check_repo_release(path, name)
|
||||
message = 'fetch failed'
|
||||
@@ -896,7 +903,10 @@ def apply_force_update(target: str) -> dict:
|
||||
if path is None or not (path / '.git').exists():
|
||||
return {'ok': False, 'message': 'Not a git repository'}
|
||||
|
||||
_, fetch_ok = _run_git(['fetch', 'origin', '--quiet', '--tags'], path, timeout=15)
|
||||
# --force so a remote re-tag (e.g. squash-merge that re-points an
|
||||
# existing release tag) doesn't jam the apply path with "would clobber
|
||||
# existing tag". See #2756.
|
||||
_, fetch_ok = _run_git(['fetch', 'origin', '--quiet', '--tags', '--force'], path, timeout=15)
|
||||
if not fetch_ok:
|
||||
return {
|
||||
'ok': False,
|
||||
@@ -953,7 +963,8 @@ def _apply_update_inner(target):
|
||||
return {'ok': False, 'message': 'Not a git repository'}
|
||||
|
||||
# Fetch before attempting pull, so the remote ref is current.
|
||||
_, fetch_ok = _run_git(['fetch', 'origin', '--quiet', '--tags'], path, timeout=15)
|
||||
# --force so a remote re-tag doesn't block the update path (see #2756).
|
||||
_, fetch_ok = _run_git(['fetch', 'origin', '--quiet', '--tags', '--force'], path, timeout=15)
|
||||
if not fetch_ok:
|
||||
return {
|
||||
'ok': False,
|
||||
|
||||
@@ -487,7 +487,7 @@ class TestSuccessfulUpdateReturnsRestartScheduled:
|
||||
|
||||
result = upd.apply_update('webui')
|
||||
assert result['ok'] is True
|
||||
assert ['fetch', 'origin', '--quiet', '--tags'] in ran
|
||||
assert ['fetch', 'origin', '--quiet', '--tags', '--force'] in ran
|
||||
assert ['pull', '--ff-only', 'origin', 'v0.51.106'] in ran
|
||||
assert ['rev-parse', '--abbrev-ref', '@{upstream}'] not in ran
|
||||
|
||||
|
||||
+137
-3
@@ -5,7 +5,7 @@ import api.updates as updates
|
||||
|
||||
|
||||
def _fake_git_for_release_fetch_failure(args, cwd, timeout=10):
|
||||
if args == ['fetch', 'origin', '--tags']:
|
||||
if args == ['fetch', 'origin', '--tags', '--force']:
|
||||
return 'would clobber existing tag v0.50.294', False
|
||||
if args == ['tag', '--list', 'v*', '--sort=-v:refname']:
|
||||
return 'v0.51.106\nv0.51.103', True
|
||||
@@ -41,7 +41,7 @@ def test_check_repo_redacts_credentialed_fetch_failure(tmp_path):
|
||||
)
|
||||
|
||||
def fake_git(args, cwd, timeout=10):
|
||||
if args == ['fetch', 'origin', '--tags']:
|
||||
if args == ['fetch', 'origin', '--tags', '--force']:
|
||||
return raw_error, False
|
||||
if args == ['tag', '--list', 'v*', '--sort=-v:refname']:
|
||||
return '', True
|
||||
@@ -64,7 +64,7 @@ def test_check_repo_fetch_failure_without_tags_is_not_up_to_date(tmp_path):
|
||||
(tmp_path / '.git').mkdir()
|
||||
|
||||
def fake_git(args, cwd, timeout=10):
|
||||
if args == ['fetch', 'origin', '--tags']:
|
||||
if args == ['fetch', 'origin', '--tags', '--force']:
|
||||
return 'network unavailable', False
|
||||
if args == ['tag', '--list', 'v*', '--sort=-v:refname']:
|
||||
return '', True
|
||||
@@ -126,3 +126,137 @@ def test_split_remote_ref_splits_tracking_ref():
|
||||
assert updates._split_remote_ref('origin/master') == ('origin', 'master')
|
||||
assert updates._split_remote_ref('origin/feature/foo') == ('origin', 'feature/foo')
|
||||
assert updates._split_remote_ref('master') == (None, 'master')
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# #2756 — Update check fails with "would clobber existing tag" when an
|
||||
# upstream release tag was moved.
|
||||
#
|
||||
# All three fetch-tag call sites in api/updates.py must use --force so the
|
||||
# WebUI (a release-tracking consumer that never pushes tags) always defers
|
||||
# to whatever the remote says a release tag points to. Without --force,
|
||||
# any remote re-tag (e.g. squash-merge that re-points a release tag at a
|
||||
# new SHA) jams the update path indefinitely.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_check_repo_fetches_tags_with_force(tmp_path):
|
||||
"""_check_repo must pass --force to git fetch --tags (regression for #2756)."""
|
||||
(tmp_path / '.git').mkdir()
|
||||
|
||||
seen_args = []
|
||||
|
||||
def fake_git(args, cwd, timeout=10):
|
||||
seen_args.append(args)
|
||||
if args[:2] == ['fetch', 'origin']:
|
||||
# Force a fetch failure path so we don't have to mock the rest of
|
||||
# the release/branch logic; the assertion is about the args shape.
|
||||
return '', False
|
||||
if args == ['tag', '--list', 'v*', '--sort=-v:refname']:
|
||||
return '', True
|
||||
raise AssertionError(f'unexpected git args: {args!r}')
|
||||
|
||||
with patch.object(updates, '_run_git', side_effect=fake_git):
|
||||
updates._check_repo(tmp_path, 'webui')
|
||||
|
||||
fetch_calls = [a for a in seen_args if a[:2] == ['fetch', 'origin']]
|
||||
assert fetch_calls, 'expected at least one fetch call'
|
||||
for call in fetch_calls:
|
||||
assert '--tags' in call, f'fetch should include --tags: {call!r}'
|
||||
assert '--force' in call, (
|
||||
f'fetch should include --force to recover from remote re-tags '
|
||||
f'(see #2756): {call!r}'
|
||||
)
|
||||
|
||||
|
||||
def test_apply_force_update_fetches_tags_with_force(tmp_path):
|
||||
"""apply_force_update must pass --force to git fetch --tags (#2756)."""
|
||||
(tmp_path / '.git').mkdir()
|
||||
|
||||
seen_args = []
|
||||
|
||||
def fake_git(args, cwd, timeout=10):
|
||||
seen_args.append(args)
|
||||
if args[:2] == ['fetch', 'origin']:
|
||||
return '', False # short-circuit; we just want the args shape.
|
||||
raise AssertionError(f'unexpected git args: {args!r}')
|
||||
|
||||
with patch.object(updates, '_run_git', side_effect=fake_git), \
|
||||
patch.object(updates, 'REPO_ROOT', tmp_path), \
|
||||
patch.object(updates, '_active_stream_count', return_value=0):
|
||||
updates.apply_force_update('webui')
|
||||
|
||||
fetch_calls = [a for a in seen_args if a[:2] == ['fetch', 'origin']]
|
||||
assert fetch_calls, 'expected at least one fetch call'
|
||||
for call in fetch_calls:
|
||||
assert '--tags' in call and '--force' in call, (
|
||||
f'apply_force_update fetch should be --tags --force (see #2756): {call!r}'
|
||||
)
|
||||
|
||||
|
||||
def test_apply_update_fetches_tags_with_force(tmp_path):
|
||||
"""apply_update must pass --force to git fetch --tags (#2756)."""
|
||||
(tmp_path / '.git').mkdir()
|
||||
|
||||
seen_args = []
|
||||
|
||||
def fake_git(args, cwd, timeout=10):
|
||||
seen_args.append(args)
|
||||
if args[:2] == ['fetch', 'origin']:
|
||||
return '', False # short-circuit on fetch failure.
|
||||
raise AssertionError(f'unexpected git args: {args!r}')
|
||||
|
||||
with patch.object(updates, '_run_git', side_effect=fake_git), \
|
||||
patch.object(updates, 'REPO_ROOT', tmp_path), \
|
||||
patch.object(updates, '_active_stream_count', return_value=0):
|
||||
updates.apply_update('webui')
|
||||
|
||||
fetch_calls = [a for a in seen_args if a[:2] == ['fetch', 'origin']]
|
||||
assert fetch_calls, 'expected at least one fetch call'
|
||||
for call in fetch_calls:
|
||||
assert '--tags' in call and '--force' in call, (
|
||||
f'apply_update fetch should be --tags --force (see #2756): {call!r}'
|
||||
)
|
||||
|
||||
|
||||
def test_check_repo_recovers_from_remote_retag(tmp_path):
|
||||
"""End-to-end: a remote-retag scenario should now succeed (#2756).
|
||||
|
||||
Before the fix, `git fetch origin --tags` would return "would clobber
|
||||
existing tag v0.51.5" indefinitely. With --force the fetch succeeds and
|
||||
the regular up-to-date / behind path runs.
|
||||
"""
|
||||
(tmp_path / '.git').mkdir()
|
||||
|
||||
def fake_git(args, cwd, timeout=10):
|
||||
# The --force flag makes the fetch succeed even when local tags
|
||||
# diverge from remote tags. Refuse to honor a plain --tags fetch
|
||||
# (no --force) so the test fails loudly if the regression returns.
|
||||
if args == ['fetch', 'origin', '--tags']:
|
||||
return (
|
||||
' ! [rejected] v0.51.5 -> v0.51.5 '
|
||||
'(would clobber existing tag)'
|
||||
), False
|
||||
if args == ['fetch', 'origin', '--tags', '--force']:
|
||||
return '', True
|
||||
if args == ['tag', '--list', 'v*', '--sort=-v:refname']:
|
||||
return 'v0.51.110\nv0.51.109', True
|
||||
if args == ['describe', '--tags', '--abbrev=0']:
|
||||
return 'v0.51.110', True
|
||||
if args == ['describe', '--tags', '--always']:
|
||||
return 'v0.51.110', True
|
||||
if args == ['remote', 'get-url', 'origin']:
|
||||
return 'https://github.com/nesquena/hermes-webui.git', True
|
||||
# Branch-check fallback is fine to no-op for this assertion.
|
||||
return '', True
|
||||
|
||||
with patch.object(updates, '_run_git', side_effect=fake_git):
|
||||
info = updates._check_repo(tmp_path, 'webui')
|
||||
|
||||
assert info is not None
|
||||
assert info.get('error') is None, (
|
||||
f'expected clean update check, got error: {info.get("error")!r}'
|
||||
)
|
||||
assert info.get('stale_check') is not True, (
|
||||
'fetch with --force should have succeeded, not marked stale'
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user