update means something now: it depends on tags

"update" was `git pull` and re-install, unconditionally, for every plugin.
nothing could say whether there was anything to pull, so the button was always
there and usually did nothing — and "update all" was twenty-one no-ops with a
spinner on it. the button's own comment admitted it: gating on `outdated` would
hide it for ever, because nothing bumped a version.

versions are dated and tagged now, so the rule the owner asked for is possible:

  · a checkout NOT at a tag is always updateable — it is between releases, no
    claim is being made about it, pulling is always reasonable
  · a checkout AT a tag is updateable only when a NEWER tag exists

the part worth defending is how "newer" is decided. the obvious version-string
comparison is wrong the first time a plugin is tagged `v2` or `1.0.0-rc1`, and
wrong SILENTLY — a plugin that never offers an update looks like one nobody has
released. so newness is asked of git: the newest tag reachable from the REMOTE
branch, compared against the tag at HEAD. that holds for any names anyone
chooses, and there is a test with a tag called `beta` that sorts before `v1` to
keep it honest.

it never fetches. every answer comes from refs already on disk, because this
runs once per plugin on a request that renders a list and twenty-one network
round trips would make the screen feel broken. tags arrive at clone and at the
fetch `pullPlugin` already does.

the VERB refuses too, not just the UI. a plugins list is fetched once and a tag
can be pushed a second later, so the copy someone clicks is routinely older than
the truth — and `updateAll` iterates a list rather than asking per plugin.

"up to date · <tag>" is said out loud rather than shown as an absent button. no
button and up-to-date look identical, and only one of them is information.

verified on the real installs: example pulled to its tag and flipped to
up-to-date, caldav on an older commit stayed updateable, and updatePlugin on
example answered "already at 2026.08.27.01, the newest release" without pulling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
2026-08-27 07:44:00 +00:00
co-authored by Claude Opus 5
parent d58f58813c
commit 68d6dd942e
7 changed files with 261 additions and 13 deletions
+83
View File
@@ -0,0 +1,83 @@
import { existsSync } from 'node:fs';
import { join } from 'node:path';
/**
* Is there anything to update to?
*
* ── The rule, and why it is about TAGS rather than commits ──
*
* Until 2026-08-27 "update" meant `git pull` and re-install, unconditionally, for every plugin. Nothing
* could say whether there was anything to pull, so the button was always offered and usually did nothing
* — and "Update all" was a row of no-ops with a spinner on it.
*
* Tags fix that, and the owner's rule has two halves:
*
* · **A checkout NOT at a tag is always updateable.** It is somewhere between releases — mid-development,
* or on a commit somebody pushed without tagging. There is no claim being made about it, so pulling is
* always reasonable.
* · **A checkout AT a tag is updateable only when a NEWER tag exists.** It is at a release, and a release
* is a claim that this is a finished thing. Offering an update that lands on the same release is how
* you teach someone to ignore the button.
*
* ── Why "newer" is not a version comparison ──
*
* The obvious implementation parses `2026.08.27.01` and compares. It is wrong the first time a plugin is
* tagged `v2` or `1.0.0-rc1`, and wrong SILENTLY — a plugin that never offers an update looks like a
* plugin nobody has released.
*
* So newness is asked of git instead: the newest tag REACHABLE FROM THE REMOTE BRANCH. If the tag at HEAD
* is that tag, this checkout is at the latest release. If it is a different one, the remote has moved on.
* That holds for any tag names anyone ever chooses, including ones that do not sort.
*
* ── What this does NOT do ──
*
* It never fetches. Every answer comes from refs already on disk, because this is called once per plugin
* on a request that renders a list — twenty-one network round trips would make the plugins screen feel
* broken. Tags arrive at clone and at the fetch `pullPlugin` already does, so "check for updates" is a
* consequence of updating rather than a separate poll.
*/
export type ReleaseState = {
/** The tag this checkout is exactly at, or null when it is between releases. */
tagAtHead: string | null;
/** The newest tag reachable from the tracked remote branch, as git orders them. */
newestTag: string | null;
updateable: boolean;
/** Why, in words a UI can show without restating the rule. */
reason: 'untagged' | 'new-release' | 'at-latest' | 'not-a-checkout';
};
const run = async (dir: string, args: string[]): Promise<string | null> => {
const proc = Bun.spawn(['git', '-C', dir, ...args], { stdout: 'pipe', stderr: 'ignore' });
const out = await new Response(proc.stdout).text();
return (await proc.exited) === 0 ? out.trim() || null : null;
};
export async function releaseState(dir: string): Promise<ReleaseState> {
const none = { tagAtHead: null, newestTag: null };
if (!existsSync(join(dir, '.git'))) {
// Copied in by hand or bundled. Nothing to compare, and nothing to pull either — `pullPlugin` says
// the same thing when asked.
return { ...none, updateable: false, reason: 'not-a-checkout' };
}
// `--exact-match`, so this is "the tag AT this commit", not "the last tag before it". Fails, and
// therefore returns null, when HEAD is between releases — which is the first half of the rule.
const tagAtHead = await run(dir, ['describe', '--exact-match', '--tags', 'HEAD']);
if (!tagAtHead) return { ...none, updateable: true, reason: 'untagged' };
// Asked of the REMOTE branch, not of HEAD: a tag pushed after this checkout was made is not reachable
// from HEAD and would be invisible if we described HEAD instead.
const branch = (await run(dir, ['rev-parse', '--abbrev-ref', 'HEAD'])) ?? 'master';
const newestTag =
(await run(dir, ['describe', '--tags', '--abbrev=0', `origin/${branch}`])) ??
(await run(dir, ['describe', '--tags', '--abbrev=0', 'origin/HEAD']));
// No remote tags known yet — a checkout whose origin has never been fetched for tags. Not updateable
// rather than "always updateable": claiming an update exists on no evidence is the worse error.
if (!newestTag) return { tagAtHead, newestTag: null, updateable: false, reason: 'at-latest' };
return newestTag === tagAtHead
? { tagAtHead, newestTag, updateable: false, reason: 'at-latest' }
: { tagAtHead, newestTag, updateable: true, reason: 'new-release' };
}