perf(cli): use git ls-remote in refresh-marketplace --check (~8s -> ~1s) (#313)

* perf(cli): use git ls-remote in refresh-marketplace --check (~8s -> ~1s)

The SessionStart hook fires agnes refresh-marketplace --check on every
Claude Code session in every workspace. The detector used to run git
fetch origin against the per-user marketplace bare repo just to update
FETCH_HEAD for a HEAD-vs-FETCH_HEAD comparison — paying the full git
object/metadata download (~8s) even on the (overwhelming) no-change
path.

Replace with git ls-remote origin HEAD: one HTTPS round-trip, one line
of text, no objects transferred. Compare the returned SHA against local
HEAD via the new _remote_head_sha and _local_head_sha helpers; emit the
/update-agnes-plugins hook JSON on mismatch, silent on match. Same PAT
wiring (AGNES_TOKEN in env, never on argv), same exit-code contract
(remote-read failure -> exit 1 so the hook's || true swallows it).

The default and --bootstrap paths still do real git fetch + reset --hard
— they need the objects.

* docs(cli): update --check help text to mention git ls-remote, not git fetch

Followup to the previous commit — the --check flag's user-facing help
string still described the old  + FETCH_HEAD comparison.
Updated to match the new ls-remote-based implementation.

---------

Co-authored-by: Minas Arustamyan <arustamyan.minas@gmail.com>
This commit is contained in:
minasarustamyan 2026-05-15 06:24:27 +02:00 committed by GitHub
parent 8b5b0f8ef5
commit 7907b8082e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 142 additions and 65 deletions

View file

@ -10,6 +10,19 @@ CalVer image tags (`stable-YYYY.MM.N`, `dev-YYYY.MM.N`) are produced for every C
## [Unreleased] ## [Unreleased]
### Changed
- `agnes refresh-marketplace --check` (the SessionStart-hook detector
that fires on every Claude Code session start in every workspace)
now uses `git ls-remote origin HEAD` instead of `git fetch origin`
to learn whether the remote marketplace has changed. ls-remote
transfers one line of text (`<sha>\tHEAD`) over a single HTTPS
round-trip — no git objects, no metadata — so the hook completes
in ~0.51 s instead of the ~8 s a full fetch took. Detection logic
is unchanged (compare local `HEAD` SHA to remote `HEAD` SHA, emit
the `/update-agnes-plugins` hint JSON on mismatch, silent on
match). The slash-command and `--bootstrap` paths still do real
`git fetch + reset --hard` — they actually need the objects.
### Fixed ### Fixed
- `/me/activity` hero subtitle showed literal `<strong>…</strong>` tags - `/me/activity` hero subtitle showed literal `<strong>…</strong>` tags
around the user's email instead of rendering them bold. The subtitle around the user's email instead of rendering them bold. The subtitle

View file

@ -10,11 +10,13 @@ Three call paths share the same code:
inside Claude Code so the user sees install/update progress in the inside Claude Code so the user sees install/update progress in the
transcript. transcript.
- `agnes refresh-marketplace --check` SessionStart hook context. - `agnes refresh-marketplace --check` SessionStart hook context.
Lightweight detector: `git fetch` only (no reset, no plugin Lightweight detector: `git ls-remote origin HEAD` only (no fetch,
install/update side effects), compares local `HEAD` vs `FETCH_HEAD`, no reset, no plugin install/update side effects), compares the
emits a Claude Code hook JSON message pointing the user at remote HEAD SHA against the local `HEAD` SHA, emits a Claude Code
`/update-agnes-plugins` when there are remote changes. Silent hook JSON message pointing the user at `/update-agnes-plugins`
otherwise. when they differ. Silent otherwise. ls-remote is ~0.51 s vs ~8 s
for fetch matters because every Claude Code session start in
every workspace fires this hook.
Reconcile (default + --bootstrap paths) is version-aware (install Reconcile (default + --bootstrap paths) is version-aware (install
missing / update on version diff / skip on match). Server-side stack missing / update on version diff / skip on match). Server-side stack
@ -58,13 +60,14 @@ def refresh_marketplace(
check: bool = typer.Option( check: bool = typer.Option(
False, "--check", False, "--check",
help=( help=(
"Detect-only mode for the SessionStart hook. Runs `git fetch` " "Detect-only mode for the SessionStart hook. Runs "
"and compares local HEAD with remote FETCH_HEAD. When they " "`git ls-remote origin HEAD` and compares the returned SHA "
"differ, emits a Claude Code hook JSON message hinting the " "with local HEAD. When they differ, emits a Claude Code "
"user at `/update-agnes-plugins`. No `git reset`, no plugin " "hook JSON message hinting the user at "
"install/update side effects — fast, invisible when nothing " "`/update-agnes-plugins`. No `git fetch`, no `git reset`, "
"changed, fully recoverable interactively via the slash " "no plugin install/update side effects — fast, invisible "
"command." "when nothing changed, fully recoverable interactively "
"via the slash command."
), ),
), ),
bootstrap: bool = typer.Option( bootstrap: bool = typer.Option(
@ -128,11 +131,15 @@ def refresh_marketplace(
# --check: lightweight detector. Don't fetch+reset, don't reconcile # --check: lightweight detector. Don't fetch+reset, don't reconcile
# plugins — that's the slash command's job. Just check whether the # plugins — that's the slash command's job. Just check whether the
# remote has new content and tell the user if so. # remote has new content and tell the user if so. `git ls-remote`
# fetches one line of text (the remote HEAD ref) instead of all
# git objects — ~0.51 s vs ~8 s for `git fetch`.
if check: if check:
if not _git_fetch_only(token): remote_sha = _remote_head_sha(token)
if remote_sha is None:
raise typer.Exit(1) raise typer.Exit(1)
if _has_remote_changes(): local_sha = _local_head_sha()
if local_sha is not None and local_sha != remote_sha:
_emit_check_hook_message() _emit_check_hook_message()
raise typer.Exit(0) raise typer.Exit(0)
@ -366,27 +373,62 @@ def _git_fetch_only(token: str) -> bool:
return True return True
def _has_remote_changes() -> bool: def _remote_head_sha(token: str) -> Optional[str]:
"""Return True iff local HEAD differs from remote FETCH_HEAD. """Return the remote `HEAD` SHA via `git ls-remote`, or None on failure.
Caller must have already run `git fetch origin`. Any rev-parse failure `ls-remote` returns one line of text per ref (`<sha>\\tHEAD`); no git
(missing FETCH_HEAD, broken repo) is treated as "no detectable changes" objects are transferred orders of magnitude cheaper than a full
so the hook stays quiet rather than surfacing a misleading hint. `git fetch` for the SessionStart-hook detector path. Same PAT wiring
as `_git_fetch_only`: token in env, never on argv. Surfaces stderr
on failure so auth/network errors aren't swallowed silently — the
`--check` caller turns failure into exit 1.
"""
env = {**os.environ, "AGNES_TOKEN": token}
cmd = [
"git",
"-c", f"credential.helper={_CREDENTIAL_HELPER}",
"-C", str(CLONE_DIR),
"ls-remote", "origin", "HEAD",
]
try:
result = subprocess.run(
cmd, env=env, capture_output=True, text=True,
encoding="utf-8", errors="replace", check=False,
)
except FileNotFoundError:
typer.echo("error: `git` not found in PATH; cannot check marketplace.", err=True)
return None
if result.returncode != 0:
if result.stdout:
typer.echo(result.stdout, err=True)
if result.stderr:
typer.echo(result.stderr, err=True)
return None
first_line = result.stdout.strip().splitlines()[:1]
if not first_line:
return None
sha = first_line[0].split()[0].strip()
return sha or None
def _local_head_sha() -> Optional[str]:
"""Return the local `HEAD` SHA, or None on any rev-parse failure.
None means "can't determine local state" the `--check` caller
treats that as "stay silent" rather than emitting a misleading
updates-available hint built on a missing left-hand side.
""" """
try: try:
local = subprocess.run( result = subprocess.run(
["git", "-C", str(CLONE_DIR), "rev-parse", "HEAD"], ["git", "-C", str(CLONE_DIR), "rev-parse", "HEAD"],
capture_output=True, text=True, encoding="utf-8", errors="replace", check=False, capture_output=True, text=True, encoding="utf-8", errors="replace", check=False,
) )
remote = subprocess.run(
["git", "-C", str(CLONE_DIR), "rev-parse", "FETCH_HEAD"],
capture_output=True, text=True, encoding="utf-8", errors="replace", check=False,
)
except FileNotFoundError: except FileNotFoundError:
return False return None
if local.returncode != 0 or remote.returncode != 0: if result.returncode != 0:
return False return None
return local.stdout.strip() != remote.stdout.strip() sha = result.stdout.strip()
return sha or None
def _git_fetch_and_reset(token: str) -> bool: def _git_fetch_and_reset(token: str) -> bool:

View file

@ -663,27 +663,31 @@ def test_bootstrap_with_existing_clone_skips_clone_proceeds_to_refresh(
# --- --check flag (SessionStart-hook detector mode) ----------------------------- # --- --check flag (SessionStart-hook detector mode) -----------------------------
def _stage_rev_parse(monkeypatch, recorder, *, head: str, fetch_head: str) -> None: def _stage_rev_parse(monkeypatch, recorder, *, head: str, remote_head: str) -> None:
"""Wrap recorder.run so `git rev-parse HEAD` and """Wrap recorder.run so `git rev-parse HEAD` returns the local SHA
`git rev-parse FETCH_HEAD` return scripted SHAs while every other and `git ls-remote origin HEAD` returns the remote SHA, while every
command falls through to the recorder's normal handling. other command falls through to the recorder's normal handling.
Used by --check tests to drive the HEAD-vs-FETCH_HEAD comparison Used by --check tests to drive the local-HEAD vs remote-HEAD
independently of the (mocked) git fetch. comparison independently of the (mocked) git invocation.
""" """
real_run = recorder.run real_run = recorder.run
def staged_run(cmd, *args, **kwargs): def staged_run(cmd, *args, **kwargs):
# Match the trailing rev-parse target so `-C <path>` injection
# doesn't break the prefix.
if "rev-parse" in cmd: if "rev-parse" in cmd:
recorder.calls.append( recorder.calls.append(
_RecordedCall(cmd=list(cmd), env=dict(kwargs.get("env") or {})) _RecordedCall(cmd=list(cmd), env=dict(kwargs.get("env") or {}))
) )
target = cmd[-1]
stdout = head if target == "HEAD" else fetch_head if target == "FETCH_HEAD" else ""
return subprocess.CompletedProcess( return subprocess.CompletedProcess(
args=list(cmd), returncode=0, stdout=stdout + "\n", stderr="", args=list(cmd), returncode=0, stdout=head + "\n", stderr="",
)
if "ls-remote" in cmd:
recorder.calls.append(
_RecordedCall(cmd=list(cmd), env=dict(kwargs.get("env") or {}))
)
return subprocess.CompletedProcess(
args=list(cmd), returncode=0,
stdout=f"{remote_head}\tHEAD\n", stderr="",
) )
return real_run(cmd, *args, **kwargs) return real_run(cmd, *args, **kwargs)
@ -693,13 +697,13 @@ def _stage_rev_parse(monkeypatch, recorder, *, head: str, fetch_head: str) -> No
def test_check_emits_hook_json_when_remote_changed( def test_check_emits_hook_json_when_remote_changed(
with_clone, with_token, claude_in_path, recorder, monkeypatch, tmp_path, with_clone, with_token, claude_in_path, recorder, monkeypatch, tmp_path,
): ):
"""`--check` + local HEAD differs from remote FETCH_HEAD → """`--check` + local HEAD differs from remote HEAD →
Claude Code hook JSON on stdout pointing the user at Claude Code hook JSON on stdout pointing the user at
`/update-agnes-plugins`. The hook never installs anything itself.""" `/update-agnes-plugins`. The hook never installs anything itself."""
workspace = tmp_path / "ws" workspace = tmp_path / "ws"
workspace.mkdir() workspace.mkdir()
monkeypatch.chdir(workspace) monkeypatch.chdir(workspace)
_stage_rev_parse(monkeypatch, recorder, head="abc123", fetch_head="def456") _stage_rev_parse(monkeypatch, recorder, head="abc123", remote_head="def456")
result = runner.invoke(refresh_marketplace_app, ["--check"]) result = runner.invoke(refresh_marketplace_app, ["--check"])
assert result.exit_code == 0 assert result.exit_code == 0
@ -716,13 +720,13 @@ def test_check_emits_hook_json_when_remote_changed(
def test_check_silent_when_remote_unchanged( def test_check_silent_when_remote_unchanged(
with_clone, with_token, claude_in_path, recorder, monkeypatch, tmp_path, with_clone, with_token, claude_in_path, recorder, monkeypatch, tmp_path,
): ):
"""`--check` + HEAD == FETCH_HEAD → silent exit 0, no JSON output. """`--check` + local HEAD == remote HEAD → silent exit 0, no JSON
Avoids spamming the user with "updates available" on every session output. Avoids spamming the user with "updates available" on every
start when nothing actually changed.""" session start when nothing actually changed."""
workspace = tmp_path / "ws" workspace = tmp_path / "ws"
workspace.mkdir() workspace.mkdir()
monkeypatch.chdir(workspace) monkeypatch.chdir(workspace)
_stage_rev_parse(monkeypatch, recorder, head="samehash", fetch_head="samehash") _stage_rev_parse(monkeypatch, recorder, head="samehash", remote_head="samehash")
result = runner.invoke(refresh_marketplace_app, ["--check"]) result = runner.invoke(refresh_marketplace_app, ["--check"])
assert result.exit_code == 0 assert result.exit_code == 0
@ -740,7 +744,7 @@ def test_check_does_not_call_claude_plugin_anything(
workspace.mkdir() workspace.mkdir()
monkeypatch.chdir(workspace) monkeypatch.chdir(workspace)
# Even WITH a remote diff, --check must stay read-only. # Even WITH a remote diff, --check must stay read-only.
_stage_rev_parse(monkeypatch, recorder, head="abc", fetch_head="def") _stage_rev_parse(monkeypatch, recorder, head="abc", remote_head="def")
result = runner.invoke(refresh_marketplace_app, ["--check"]) result = runner.invoke(refresh_marketplace_app, ["--check"])
assert result.exit_code == 0 assert result.exit_code == 0
@ -766,7 +770,7 @@ def test_check_does_not_git_reset(
workspace = tmp_path / "ws" workspace = tmp_path / "ws"
workspace.mkdir() workspace.mkdir()
monkeypatch.chdir(workspace) monkeypatch.chdir(workspace)
_stage_rev_parse(monkeypatch, recorder, head="abc", fetch_head="def") _stage_rev_parse(monkeypatch, recorder, head="abc", remote_head="def")
result = runner.invoke(refresh_marketplace_app, ["--check"]) result = runner.invoke(refresh_marketplace_app, ["--check"])
assert result.exit_code == 0 assert result.exit_code == 0
@ -777,32 +781,46 @@ def test_check_does_not_git_reset(
) )
def test_check_runs_git_fetch( def test_check_runs_git_ls_remote_not_fetch(
with_clone, with_token, claude_in_path, recorder, monkeypatch, tmp_path, with_clone, with_token, claude_in_path, recorder, monkeypatch, tmp_path,
): ):
"""`--check` must run `git fetch origin` (otherwise FETCH_HEAD is """`--check` must use `git ls-remote origin HEAD` — one HTTPS
stale and we'd compare against an old snapshot, missing real round-trip, no objects downloaded and must NOT run `git fetch`.
remote changes).""" This is the whole point of the SessionStart-hook detector: ~0.51 s
instead of ~8 s. If somebody regresses this back to fetch, this
test catches it."""
workspace = tmp_path / "ws" workspace = tmp_path / "ws"
workspace.mkdir() workspace.mkdir()
monkeypatch.chdir(workspace) monkeypatch.chdir(workspace)
_stage_rev_parse(monkeypatch, recorder, head="abc", fetch_head="abc") _stage_rev_parse(monkeypatch, recorder, head="abc", remote_head="abc")
result = runner.invoke(refresh_marketplace_app, ["--check"]) result = runner.invoke(refresh_marketplace_app, ["--check"])
assert result.exit_code == 0 assert result.exit_code == 0
fetch_calls = [ ls_remote_calls = [
c for c in recorder.calls c for c in recorder.calls
if c.cmd and c.cmd[0] == "git" and "fetch" in c.cmd and "origin" in c.cmd if c.cmd and c.cmd[0] == "git" and "ls-remote" in c.cmd
and "origin" in c.cmd and "HEAD" in c.cmd
] ]
assert fetch_calls, ( assert ls_remote_calls, (
f"--check must run `git fetch origin`; got: {[c.cmd for c in recorder.calls]!r}" f"--check must run `git ls-remote origin HEAD`; got: "
f"{[c.cmd for c in recorder.calls]!r}"
) )
# Same credential helper wiring as the default mode — PAT in env, not argv. # Same credential helper wiring as the default mode — PAT in env, not argv.
fetch = fetch_calls[0] ls_remote = ls_remote_calls[0]
assert "-c" in fetch.cmd assert "-c" in ls_remote.cmd
assert fetch.cmd[fetch.cmd.index("-c") + 1].startswith("credential.helper=") assert ls_remote.cmd[ls_remote.cmd.index("-c") + 1].startswith("credential.helper=")
assert fetch.env.get("AGNES_TOKEN") == with_token assert ls_remote.env.get("AGNES_TOKEN") == with_token
# No `git fetch` — that's the slow path we replaced.
fetch_calls = [
c for c in recorder.calls
if c.cmd and c.cmd[0] == "git" and "fetch" in c.cmd
]
assert fetch_calls == [], (
f"--check must NOT run `git fetch` (slow path); got: "
f"{[c.cmd for c in fetch_calls]!r}"
)
def test_check_no_clone_silent_exit_zero(tmp_path, monkeypatch, with_token, recorder): def test_check_no_clone_silent_exit_zero(tmp_path, monkeypatch, with_token, recorder):
@ -817,15 +835,19 @@ def test_check_no_clone_silent_exit_zero(tmp_path, monkeypatch, with_token, reco
assert recorder.calls == [] assert recorder.calls == []
def test_check_fetch_failure_exits_one( def test_check_ls_remote_failure_exits_one(
with_clone, with_token, claude_in_path, recorder, monkeypatch, tmp_path, with_clone, with_token, claude_in_path, recorder, monkeypatch, tmp_path,
): ):
"""A failed `git fetch` (network down, auth rejected, etc.) → exit 1 """A failed `git ls-remote` (network down, auth rejected, etc.) →
so the surrounding `|| true` in the hook command swallows it cleanly. exit 1 so the surrounding `|| true` in the hook command swallows it
No hook JSON is emitted (we don't know if the remote changed).""" cleanly. No hook JSON is emitted (we don't know if the remote
changed)."""
workspace = tmp_path / "ws" workspace = tmp_path / "ws"
workspace.mkdir() workspace.mkdir()
monkeypatch.chdir(workspace) monkeypatch.chdir(workspace)
# `("git", "-c")` matches the credential-helper wiring shared by
# ls-remote and fetch — fine here since ls-remote is the only git
# subprocess --check runs.
recorder.script(("git", "-c"), returncode=1, stderr="fatal: unable to access ...") recorder.script(("git", "-c"), returncode=1, stderr="fatal: unable to access ...")
result = runner.invoke(refresh_marketplace_app, ["--check"]) result = runner.invoke(refresh_marketplace_app, ["--check"])