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