Add remote branch tag fetching with cache and fallback to local tags

- Add _get_remote_branch_merged_tags to fetch tags from official repo via temporary bare clone - Add _remote_branch_tag_cache with 60-second TTL to reduce redundant fetches - Add _get_official_remote_url helper for consistent remote URL construction - Rename existing _get_branch_merged_tags to _get_local_branch_merged_tags - Update _get_branch_merged_tags to prefer remote tags with fallback to local - Add REMOTE_BRANCH_TAG_CACHE_TTL_SECONDS constant

frdel committed Mar 24, 2026 at 21:23 UTC 07386c6801eea0a87a2f1406ccec024961089685
2 files changed +83 -1
helpers/self_update.py
+53 -1
@@ -3,6 +3,8 @@ from __future__ import annotations
3 import os
4 import re
5 import subprocess
6 +import tempfile
7 +import time
8 from datetime import UTC, datetime
9 from pathlib import Path
10 from typing import Any, Literal, TypedDict
@@ -20,11 +22,14 @@ BRANCH_OPTIONS = [
22 SUPPORTED_BRANCHES = {option["value"] for option in BRANCH_OPTIONS}
23 BACKUP_CONFLICT_POLICIES = {"rename", "overwrite", "fail"}
24 MIN_SELECTOR_VERSION = (1, 0)
25 +REMOTE_BRANCH_TAG_CACHE_TTL_SECONDS = 60.0
26
27 UPDATE_FILE_PATH = Path("/exe/a0-self-update.yaml")
28 STATUS_FILE_PATH = Path("/exe/a0-self-update-status.yaml")
29 LOG_FILE_PATH = Path("/exe/a0-self-update.log")
30
31 +_remote_branch_tag_cache: dict[str, tuple[float, set[str]]] = {}
32 +
33
34 class PendingUpdateConfig(TypedDict):
35 branch: Literal["main", "testing", "development"]
@@ -113,6 +118,10 @@ def get_repo_dir(repo_dir: str | Path | None = None) -> Path:
118 return Path(__file__).resolve().parents[1]
119
120
121 +def _get_official_remote_url() -> str:
122 + return f"https://github.com/{OFFICIAL_REPO_AUTHOR}/{OFFICIAL_REPO_NAME}.git"
123 +
124 +
125 def _run_git(repo_dir: str | Path, *args: str) -> str:
126 completed = subprocess.run(
127 ["git", "-C", str(get_repo_dir(repo_dir)), *args],
@@ -192,7 +201,37 @@ def _get_branch_reference_names(branch: str) -> list[str]:
201 return [f"origin/{normalized_branch}", normalized_branch]
202
203
195 -def _get_branch_merged_tags(
204 +def _get_remote_branch_merged_tags(branch: str) -> set[str]:
205 + normalized_branch = branch.strip().lower()
206 + if normalized_branch not in SUPPORTED_BRANCHES:
207 + return set()
208 +
209 + cached = _remote_branch_tag_cache.get(normalized_branch)
210 + now = time.monotonic()
211 + if cached and now - cached[0] <= REMOTE_BRANCH_TAG_CACHE_TTL_SECONDS:
212 + return set(cached[1])
213 +
214 + with tempfile.TemporaryDirectory(prefix="a0-self-update-tags-") as temp_dir:
215 + repository = Path(temp_dir)
216 + _run_git(repository, "init", "--bare")
217 + _run_git(
218 + repository,
219 + "fetch",
220 + "--quiet",
221 + "--prune",
222 + "--filter=blob:none",
223 + "--tags",
224 + _get_official_remote_url(),
225 + f"refs/heads/{normalized_branch}:refs/remotes/origin/{normalized_branch}",
226 + )
227 + output = _run_git(repository, "tag", "--merged", f"refs/remotes/origin/{normalized_branch}")
228 + merged_tags = {line.strip() for line in output.splitlines() if line.strip()}
229 +
230 + _remote_branch_tag_cache[normalized_branch] = (now, merged_tags)
231 + return set(merged_tags)
232 +
233 +
234 +def _get_local_branch_merged_tags(
235 branch: str,
236 repo_dir: str | Path | None = None,
237 ) -> set[str]:
@@ -207,6 +246,19 @@ def _get_branch_merged_tags(
246 return set()
247
248
249 +def _get_branch_merged_tags(
250 + branch: str,
251 + repo_dir: str | Path | None = None,
252 +) -> set[str]:
253 + try:
254 + remote_tags = _get_remote_branch_merged_tags(branch)
255 + if remote_tags:
256 + return remote_tags
257 + except Exception:
258 + pass
259 + return _get_local_branch_merged_tags(branch, repo_dir=repo_dir)
260 +
261 +
262 def _parse_selector_version(tag: str) -> tuple[int, int] | None:
263 match = re.fullmatch(r"v(\d+)\.(\d+)", tag.strip())
264 if not match:
tests/test_self_update_tag_filter.py
+30
@@ -36,6 +36,36 @@ def test_self_update_selector_tags_are_sorted_numerically():
36 ]
37
38
39 +def test_self_update_branch_filter_prefers_remote_branch_tags(monkeypatch):
40 + monkeypatch.setattr(
41 + self_update.git,
42 + "get_remote_releases",
43 + lambda author, repo: types.SimpleNamespace(
44 + error="",
45 + releases=[
46 + types.SimpleNamespace(tag="v1.2"),
47 + types.SimpleNamespace(tag="v1.1"),
48 + types.SimpleNamespace(tag="v1.0"),
49 + ],
50 + ),
51 + )
52 + monkeypatch.setattr(
53 + self_update,
54 + "_get_remote_branch_merged_tags",
55 + lambda branch: {"v1.1", "v1.0"},
56 + )
57 + monkeypatch.setattr(
58 + self_update,
59 + "_get_local_branch_merged_tags",
60 + lambda branch, repo_dir=None: set(),
61 + )
62 +
63 + tags, error = self_update.get_available_tags("development")
64 +
65 + assert error == ""
66 + assert tags == ["v1.1", "v1.0"]
67 +
68 +
69 def test_self_update_frontend_filters_old_tag_suggestions():
70 store_path = (
71 PROJECT_ROOT