/* 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 } from 'node:fs'; import { join } from 'node:path'; /* Which plugins directory to check. There are two as of 2026-08-27: `platform/plugins/` — what this machine has INSTALLED — and `officer-dev/plugins/`, every repo in the org cloned side by side for working on. They hold different sets, so the check has to be pointable at either rather than assuming the one it happens to sit next to. */ 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 }) .filter((e) => e.isDirectory() && !e.name.startsWith('.') && e.name !== 'node_modules') .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);