diff --git a/CHANGELOG.md b/CHANGELOG.md index ea08efa7..4801e6f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/api/updates.py b/api/updates.py index c9b9aaea..9f19ba89 100644 --- a/api/updates.py +++ b/api/updates.py @@ -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, diff --git a/tests/test_update_banner_fixes.py b/tests/test_update_banner_fixes.py index bad59b20..c9473ba0 100644 --- a/tests/test_update_banner_fixes.py +++ b/tests/test_update_banner_fixes.py @@ -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 diff --git a/tests/test_updates.py b/tests/test_updates.py index 1b8e8689..f372233e 100644 --- a/tests/test_updates.py +++ b/tests/test_updates.py @@ -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' + )