/* Is every plugin checkout still a well-formed clone of its own repository? ── Why a script and not care ── On 2026-08-27 one commit had to go into all twenty-one plugin repos. Seven pushes failed, each for a different reason, and none of the reasons had anything to do with the commit: · six remotes were HTTPS, which cannot authenticate non-interactively — they simply hung and failed · three checkouts were behind their published remote, so the push was rejected as non-fast-forward · one (`offscale`) had a fetch refspec and an upstream naming a branch called `main` that does not exist on the remote. That one is the reason this file exists: every command reported on a phantom. `git push` said "Everything up-to-date" while `git status` said the branch had diverged, and the repository was actually fine. Twenty minutes went into a repo with nothing wrong with it. · one remote URL had no `.git` suffix, so the catalogue published a clone URL unlike every other None of that is visible until you try to push, which is the worst possible moment to find out — you are mid-task, across twenty-one repositories, and the failures look like they are about your change. ── What it checks, and what it deliberately does not ── Only mechanical facts with one right answer: the remote's URL form, the fetch refspec, the branch, its upstream, and whether the checkout has diverged. It does not judge content, does not fetch by default (that would make a local check depend on the network), and never writes — `--fix` is a separate flag so that running the check can never be the thing that changes your tree. */ import { readdirSync, existsSync, statSync } from 'node:fs'; import { join } from 'node:path'; /* Which plugins directory to check. `platform/plugins/` is the one that exists. There was briefly a second — `officer-dev/plugins/`, meant to hold every repo in the org side by side — and it was removed on 2026-08-28 once it held nothing but symlinks into this one. A plugin cannot live outside the platform anyway: Bun resolves a symlink to its real path, so `@@/…` and every relative import fail from there. Tested, including `--preserve-symlinks`, which Bun accepts and ignores. `PLUGINS_DIR` stays overridable for a checkout somewhere else, which costs nothing and is the only reason this is not a constant. */ const PLUGINS_DIR = process.env.PLUGINS_DIR ?? join(import.meta.dir, '..', 'plugins'); const HOST = 'git@gitea.officer.dev'; const BRANCH = 'master'; const REFSPEC = '+refs/heads/*:refs/remotes/origin/*'; const FIX = process.argv.includes('--fix'); const FETCH = process.argv.includes('--fetch'); const git = (dir: string, args: string[]): string | null => { const p = Bun.spawnSync(['git', '-C', dir, ...args], { stdout: 'pipe', stderr: 'pipe' }); return p.exitCode === 0 ? p.stdout.toString().trim() : null; }; type Problem = { plugin: string; what: string; fix?: () => void }; const problems: Problem[] = []; const dirs = readdirSync(PLUGINS_DIR, { withFileTypes: true }) // `statSync().isDirectory()`, not the dirent's — a Dirent for a SYMLINK to a directory answers false to // `isDirectory()` and true to `isSymbolicLink()`, so filtering on the dirent silently skips every // symlinked plugin. `platform/plugins/` is symlinks into the workspace clones since 2026-08-27, which // would have made this report "0 plugin repositories, all well-formed" — a green check over nothing. // `discover.ts` already does it this way, at line 239, for the same reason. .filter((e) => !e.name.startsWith('.') && e.name !== 'node_modules') .filter((e) => { try { return statSync(join(PLUGINS_DIR, e.name)).isDirectory(); } catch { return false; // a broken symlink, which the checks below have nothing useful to say about } }) .map((e) => e.name) .filter((name) => existsSync(join(PLUGINS_DIR, name, '.git'))) .sort(); /* A repo with no `manifest.ts` is not a plugin, and that is not drift. `av-club` is the case: a standalone media service living in the plugins org, intended to become a plugin in time. Checking it against a plugin's conventions would report its branch name as a defect forever, and a check that cries wolf is one people stop reading. Presence is the declaration here exactly as it is in `plugins/discover.ts` — the manifest is what makes something a plugin, so its absence is what makes something not one yet. */ const notYet = dirs.filter((name) => !existsSync(join(PLUGINS_DIR, name, 'manifest.ts'))); const checkable = dirs.filter((name) => !notYet.includes(name)); for (const name of checkable) { const dir = join(PLUGINS_DIR, name); const expected = `${HOST}:plugins/${name}.git`; // 1. The remote, exactly. SSH because that is what can push unattended, and `.git` because a URL unlike // every other is the one an installer trips over. const url = git(dir, ['remote', 'get-url', 'origin']); if (url !== expected) { problems.push({ plugin: name, what: `remote is ${url ?? 'missing'}, expected ${expected}`, fix: () => void git(dir, ['remote', 'set-url', 'origin', expected]), }); } // 2. The fetch refspec. A narrowed one (a --single-branch clone) hides every other branch; a WRONG one // makes git report on a branch that does not exist, which is the failure that cost the most time. const refspecs = (git(dir, ['config', '--get-all', 'remote.origin.fetch']) ?? '').split('\n').filter(Boolean); if (refspecs.length !== 1 || refspecs[0] !== REFSPEC) { problems.push({ plugin: name, what: `fetch refspec is ${refspecs.join(' + ') || 'unset'}, expected ${REFSPEC}`, fix: () => { git(dir, ['config', '--unset-all', 'remote.origin.fetch']); git(dir, ['config', '--add', 'remote.origin.fetch', REFSPEC]); }, }); } // 3. The branch, and what it tracks. const branch = git(dir, ['rev-parse', '--abbrev-ref', 'HEAD']); if (branch !== BRANCH) { problems.push({ plugin: name, what: `on branch ${branch ?? '?'}, expected ${BRANCH}` }); continue; // the checks below are all about this branch } const upstream = git(dir, ['config', '--get', `branch.${BRANCH}.merge`]); if (upstream !== `refs/heads/${BRANCH}`) { problems.push({ plugin: name, what: `${BRANCH} tracks ${upstream ?? 'nothing'}, expected refs/heads/${BRANCH}`, fix: () => void git(dir, ['branch', `--set-upstream-to=origin/${BRANCH}`, BRANCH]), }); } // 4. Uncommitted work, and divergence. Not fixable here — both need a human decision about the content. const dirty = git(dir, ['status', '--porcelain']); if (dirty) problems.push({ plugin: name, what: `${dirty.split('\n').length} uncommitted file(s)` }); if (FETCH) git(dir, ['fetch', '-q', 'origin']); const counts = git(dir, ['rev-list', '--left-right', '--count', `origin/${BRANCH}...HEAD`]); if (counts) { const [behind, ahead] = counts.split(/\s+/).map(Number); if (behind) problems.push({ plugin: name, what: `${behind} commit(s) behind origin/${BRANCH}` }); if (ahead) problems.push({ plugin: name, what: `${ahead} commit(s) NOT PUSHED` }); } } const skipped = notYet.length ? ` (${notYet.length} not a plugin yet: ${notYet.join(', ')})` : ''; if (!problems.length) { console.log(`plugins:check — ${checkable.length} plugin repositories, all well-formed${skipped}`); process.exit(0); } for (const p of problems) console.error(` ${p.plugin}: ${p.what}`); if (FIX) { const fixable = problems.filter((p) => p.fix); for (const p of fixable) p.fix!(); console.error(`\nplugins:check --fix — repaired ${fixable.length} of ${problems.length}; re-run to confirm.`); console.error('Divergence and uncommitted work are never repaired here: both need a decision about content.'); } else { console.error( `\nplugins:check — ${problems.length} problem(s). \`bun run plugins:check --fix\` repairs the mechanical ones.`, ); } process.exit(1);