From 3a5cf6cda6fd5b7de34272ed85fae1e022cb431f Mon Sep 17 00:00:00 2001 From: Pamnard Date: Sun, 31 May 2026 12:50:32 +0300 Subject: [PATCH] fix(updates): fall through when release tag is not ff-reachable When agent checkouts track main past an older tag but the newest published tag is on a divergent side branch, stop advertising tag-based updates and route check/apply through the upstream branch instead. --- CHANGELOG.md | 3 ++ api/updates.py | 16 +++++++++ tests/test_update_banner_fixes.py | 2 ++ tests/test_updates.py | 56 +++++++++++++++++++++++++++++++ 4 files changed, 77 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 523e8716..af30077a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ ## [Unreleased] +### Fixed +- Agent self-update no longer advertises or applies unreachable release tags when the checkout tracks `main` past an older tag but the newest published tag lives on a divergent side branch (for example `v2026.5.29` → `v2026.5.29.2`). The update checker and apply path now fall through to the configured upstream branch when `git pull --ff-only ` cannot fast-forward, matching the existing #2653/#3140 release-vs-branch routing. + ## [v0.51.185] — 2026-05-31 — Release FE (stage-batchE — clarify-card bug-fix batch: identical-prompt dedup + autofill guard + GBK startup crash) ### Fixed diff --git a/api/updates.py b/api/updates.py index 55d0c75a..7fbeb512 100644 --- a/api/updates.py +++ b/api/updates.py @@ -466,6 +466,14 @@ def _head_contains_ref(path, ref): return bool(ok) +def _can_fast_forward_to(path, ref): + """Return True when ``ref`` is a descendant of HEAD (``git pull --ff-only`` can reach it).""" + if not ref: + return False + _, ok = _run_git(['merge-base', '--is-ancestor', 'HEAD', ref], path) + return bool(ok) + + def _select_apply_compare_ref(path): """Return the same remote ref family that the update check reports. @@ -497,6 +505,8 @@ def _select_apply_compare_ref(path): behind == 0 and _head_is_past_latest_tag(path, current_tag) ) or ( behind > 0 and _head_contains_ref(path, latest_tag) + ) or ( + behind > 0 and not _can_fast_forward_to(path, latest_tag) ): pass else: @@ -537,6 +547,12 @@ def _check_repo_release(path, name): if behind > 0 and _head_contains_ref(path, latest_tag): return None + # Patch releases can land on a side branch while day-to-day installs track + # main past an older tag. A positive tag-name gap then advertises an update + # that `git pull --ff-only ` cannot reach. + if behind > 0 and not _can_fast_forward_to(path, latest_tag): + return None + remote_url, _ = _run_git(['remote', 'get-url', 'origin'], path) remote_url = _normalize_remote_url(remote_url) diff --git a/tests/test_update_banner_fixes.py b/tests/test_update_banner_fixes.py index 957a9904..eda213c0 100644 --- a/tests/test_update_banner_fixes.py +++ b/tests/test_update_banner_fixes.py @@ -224,6 +224,8 @@ class TestUpdateChecker: return 'v0.51.35\nv0.51.34\nv0.51.33', True if args[:3] == ['describe', '--tags', '--abbrev=0']: return 'v0.51.34', True + if args == ['merge-base', '--is-ancestor', 'HEAD', 'v0.51.35']: + return '', True if args[:2] == ['remote', 'get-url']: return 'https://github.com/nesquena/hermes-webui.git', True return '', False diff --git a/tests/test_updates.py b/tests/test_updates.py index cafd55b2..3297ade4 100644 --- a/tests/test_updates.py +++ b/tests/test_updates.py @@ -13,6 +13,8 @@ def _fake_git_for_release_fetch_failure(args, cwd, timeout=10): return 'v0.51.103', True if args == ['merge-base', '--is-ancestor', 'v0.51.106', 'HEAD']: return '', False + if args == ['merge-base', '--is-ancestor', 'HEAD', 'v0.51.106']: + return '', True if args == ['remote', 'get-url', 'origin']: return 'https://github.com/nesquena/hermes-webui.git', True raise AssertionError(f'unexpected git args: {args!r}') @@ -675,3 +677,57 @@ def test_select_apply_compare_ref_case_d_older_tag_with_commits_and_newer_tag_ex 'origin/. Regression for Opus-flagged drift in #2855.' ) + +def test_check_repo_release_falls_through_when_latest_tag_is_not_ff_reachable(tmp_path): + """Main-tracking HEAD past an older tag cannot ff to a patch release tag. + + Repro: installer puts agent on main at v2026.5.29+N-g..., maintainers cut + v2026.5.29.2 from a side branch. Tag gap is positive, but + ``git pull --ff-only v2026.5.29.2`` fails with diverging branches. + The release check must fall through to the upstream branch comparison. + """ + (tmp_path / '.git').mkdir() + + def fake_git(args, cwd, timeout=10): + if args == ['tag', '--list', 'v*', '--sort=-v:refname']: + return 'v2026.5.29.2\nv2026.5.29', True + if args == ['describe', '--tags', '--abbrev=0']: + return 'v2026.5.29', True + if args == ['describe', '--tags', '--always']: + return 'v2026.5.29-265-g5921d6678', True + if args == ['merge-base', '--is-ancestor', 'v2026.5.29.2', 'HEAD']: + return '', False + if args == ['merge-base', '--is-ancestor', 'HEAD', 'v2026.5.29.2']: + return '', False + raise AssertionError(f'unexpected git args: {args!r}') + + with patch.object(updates, '_run_git', side_effect=fake_git): + result = updates._check_repo_release(tmp_path, 'agent') + + assert result is None + + +def test_select_apply_compare_ref_falls_through_when_latest_tag_is_not_ff_reachable(tmp_path): + """Apply path mirrors the ff-unreachable release-tag fall-through.""" + (tmp_path / '.git').mkdir() + + def fake_git(args, cwd, timeout=10): + if args == ['tag', '--list', 'v*', '--sort=-v:refname']: + return 'v2026.5.29.2\nv2026.5.29', True + if args == ['describe', '--tags', '--abbrev=0']: + return 'v2026.5.29', True + if args == ['describe', '--tags', '--always']: + return 'v2026.5.29-265-g5921d6678', True + if args == ['merge-base', '--is-ancestor', 'v2026.5.29.2', 'HEAD']: + return '', False + if args == ['merge-base', '--is-ancestor', 'HEAD', 'v2026.5.29.2']: + return '', False + if args == ['rev-parse', '--abbrev-ref', '@{upstream}']: + return 'origin/main', True + raise AssertionError(f'unexpected git args: {args!r}') + + with patch.object(updates, '_run_git', side_effect=fake_git): + ref = updates._select_apply_compare_ref(tmp_path) + + assert ref == 'origin/main' +