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:
@@ -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' };
|
||||
}
|
||||
Reference in New Issue
Block a user