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
@@ -523,14 +523,25 @@ export const PluginDetail = () => {
{t('plugins.enable')}
</Button>
)}
{/* Always offered, not gated on `outdated`. That flag compares the manifest's version to the
recorded one, and nothing bumps a version today — so an artwork or code change lands with
the number unmoved and the button would never appear. It also used to run `install`,
which cannot fetch: the directory exists, so the clone is skipped and only the apply
steps re-ran. `update` pulls first. */}
<Button disabled={busy} onClick={() => run('update', plugin.appName)}>
{plugin.outdated ? t('plugins.updateTo', { version: plugin.version }) : 'Update'}
</Button>
{/* Offered only when there is something to update TO — the tag rule, see plugins/release.ts.
A checkout between releases always is; one sitting on a tag only when a newer tag exists.
This was unconditional, with a comment explaining that nothing bumped versions so gating
it on `outdated` would hide it for ever. Versions are dated and tagged now, so the button
can finally mean something — and an Update that lands on the version you already have is
how you teach someone to stop pressing it. */}
{plugin.updateable && (
<Button disabled={busy} onClick={() => run('update', plugin.appName)}>
{plugin.releaseReason === 'new-release' && plugin.newestTag
? `Update to ${plugin.newestTag}`
: 'Update'}
</Button>
)}
{/* Said out loud rather than shown as an absent button. "No Update button" and "up to date"
look identical, and only one of them is information. */}
{!plugin.updateable && plugin.releaseReason === 'at-latest' && plugin.tagAtHead && (
<span className="self-center text-xs text-duck-dark/40">Up to date · {plugin.tagAtHead}</span>
)}
{/* Absent while enabled — disable first. `DeletePlugin` decides that itself rather than being
conditioned here, so the rule lives next to the confirmation that explains it. */}
<DeletePlugin
@@ -74,6 +74,14 @@ export type PluginItem = {
installedVersion: string | null;
/** The code on disk moved after it was installed — normal while developing, and worth seeing. */
outdated: boolean;
/**
* Whether there is anything to update TO. A checkout between releases always is; one sitting on a tag
* only when a newer tag exists. See `plugins/release.ts` for why this is not a version comparison.
*/
updateable: boolean;
releaseReason: 'untagged' | 'new-release' | 'at-latest' | 'not-a-checkout';
tagAtHead: string | null;
newestTag: string | null;
/**
* PM2's word for the sidecar, or null when the plugin has none. `online` while enabled is healthy;
* anything else while enabled is the state worth rendering differently — the difference between a