Watches your git branches by itself (nothing to run) and notices when a branch's work is done (merged, squash-merged, pull request merged), even if you merged…

Install (Claude Code 2.1.287 or later): /plugin install branch-janitor --marketplace samuelbfernandes/moddeck
Notices when the work a git branch was made for is finished, checks that deleting it loses nothing, and offers to delete it. It never deletes without your click; the copy on the server is a separate, second question. "Finished" needs proof: merged into the default branch, squash- or rebase-merged, or a merged GitHub pull request (needs gh). A branch whose remote was deleted is only a "check first" note. An offered branch must also pass: not checked out anywhere, no commits only it has, nothing unpushed, no stash made on it, tip recorded, not just created. A branch with an open pull request is never offered: the list says #42 still open. Each question says how to get the branch back (git branch <name> <sha>). The first answer, Keep, is the safe one.
Nothing to type. Every prompt costs one quick git for-each-ref; only if your branches changed does it look properly, so a merge done in a plain terminal is noticed on your next prompt. It also looks after Claude's own git merge/pull/push/fetch and gh pr merge at once (no waiting), git checkout/switch and remote prune at most every minMinutes, and at session start. To look on demand, run a slash command:
/branch-janitor lists every branch and deletes nothing. With gh, it ends with one line per local branch that has an open pull request: #42 open · checks passing · review approved · my-branch (checks: passing, failing, pending, none; review: approved, changes requested, none)./branch-janitor clean scans now and offers. /branch-janitor off / on stops / resumes it in this repository./plugin disable branch-janitor turns it off everywhere.
minMinutes (30), protectedBranches (main, master, develop, dev, trunk, release/*, gh-pages; yours replaces it), staleDays (60). Only git, and gh if installed (read-only gh pr list, 8 s each); no model calls. Pull request lines and protection need gh installed and a gh command run by Claude in this repository (remembered); gh use in a plain terminal is not visible. It asks gh for open PRs only for the report, clean, and just before a question would open, never for an unchanged repository. No gh, not signed in or offline: the lines are silently left out. PRs match local branches by name only. Not verified live: dialogs, gh against GitHub (field names follow gh's documented --json list), the desktop app. Nothing run on Windows. bun tests/real-git.integration.ts runs the real-git scenarios.
hooks/register.tsx 183 lines1// branch-janitor: notices when the work a git branch was made for is done, verifies the branch can go
2// without losing anything, and OFFERS to delete it. Nothing is deleted without a confirmation click; the
3// remote copy is a second, separate question.
4//
5// WHEN IT LOOKS, in order of how much it costs:
6// 1. Every prompt the person sends (`prompt.submit`). The hook itself does nothing but start a timer; the timer
7// runs ONE bounded `git for-each-ref` probe and compares its fingerprint with the last one looked at in this
8// session. Unchanged: that is all. Changed, or the session's first prompt: the full look below. So a merge done
9// in a plain terminal outside Claude Code is noticed on the next prompt, and nothing else costs anything.
10// 2. After Claude's own git commands and a moment after session start (below).
11// The Bash path: the types have no "after a tool ran" event, so the Bash hook below is a `tool.call` hook
12// that calls `next(e)` first (the command runs and its result comes back) and only then looks. It returns the
13// result object it got, untouched: it never alters, delays-on-purpose or denies the user's command.
14// A finished `git merge`, `git pull`, `git checkout|switch <branch>`, `gh pr merge`, `git push`,
15// `git fetch --prune` and `git remote prune` qualify (a failed command, `isError`, does not).
16// At session start the `session.start` event schedules one look a moment later, once the screen is up.
17//
18// HOW THE QUESTION IS HELD OPEN: `$.ui.ask` is a `$` call, and a hook's 10 s budget does not run while a `$`
19// call is in flight (HookBudget in the API types), so the dialog can wait as long as the person likes.
20//
21// FAIL-OPEN. Every failure here (a git probe, the dialog, a thrown error, the budget) ends in "pass the
22// Bash result / the prompt through unchanged": the `.catch` replays `next(e)`. This mod is a convenience, never
23// a gate.
24import { atom, update } from 'claude-code'
25import type { EngineInterface, Register } from 'claude-code'
26
27import { readSettings } from './config'
28import { lookAndOffer, noteGhUse, runCommand, watchPrompt } from './janitor'
29import type { Deps, Look } from './janitor'
30import { triggerOf, usesGh } from './trigger'
31
32const askingSince = { plugin: 'branch-janitor', key: 'askingSince' } as const
33const hasGhRef = { plugin: 'branch-janitor', key: 'hasGh' } as const
34const memoryRef = { plugin: 'branch-janitor', key: 'memory' } as const
35const watchingRef = { plugin: 'branch-janitor', key: 'watching' } as const
36
37const START_DELAY_MS = 3_000
38const PROMPT_DELAY_MS = 300 // the prompt is in and its turn started before the probe runs
39const STALE_LOCK_MS = 30 * 60_000 // a lock older than this is a dialog that died with a reload
40const WATCHING_NOTICE = 'Watching branches · /branch-janitor to list them'
41const FAILED = 'Branches · something went wrong. Nothing was changed.'
42
43export const register: Register = (on, options) => {
44 const settings = readSettings(options)
45
46 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
47 // Remember that gh is used here (one cheap git call, only for a gh command): it unlocks the pull-request lines.
48 if (usesGh(e.command)) await quietly(async () => noteGhUse(depsOf($, settings), await $.session.cwd()))
49 const look = triggerOf(e.command)
50 if (look === undefined) return next(e)
51 const ran = await next(e)
52 const result = ran.result as { interrupted?: boolean } | undefined
53 const succeeded = ran.deny === undefined && ran.isError !== true && result?.interrupted !== true
54 if (succeeded) await lookIfPossible($, settings, look)
55 return ran
56 }).catch(($, e, next) => next(e))
57
58 on('session.start', async ($, e, next) => {
59 await quietly(() => registerCommand($))
60 if (e.isInteractive) await quietly(() => syncOff($)) // only a session with a screen shows the switch
61 if (e.isInteractive) $.clock.after(START_DELAY_MS, () => void lookIfPossible($, settings, 'start'))
62 return next(e)
63 }).catch(($, e, next) => next(e))
64
65 on('prompt.submit', async ($, e, next) => {
66 // Only the person's own words: not a notification, a schedule, another plugin or a peer session.
67 if (e.origin.kind !== 'composer' && e.origin.kind !== 'bridge') return next(e)
68 // Nothing is awaited here: the probe (and any look) runs from a timer after the prompt has gone in.
69 $.clock.after(PROMPT_DELAY_MS, () => void watchIfPossible($, settings))
70 return next(e)
71 }).catch(($, e, next) => next(e))
72
73 on('command.run', { command: 'branch-janitor' }, async ($, e) => {
74 const text = await runCommand(depsOf($, settings), await $.session.cwd(), e.args.trim().toLowerCase())
75 await quietly(() => syncOff($)) // on, off, or the dialog's "never" may have changed it
76 return { text }
77 }).catch(() => ({ text: FAILED }))
78}
79
80/** Off for this repository (`git config toolkit.branchJanitor false`): the moddeck switch reads it; null outside a repository. */
81const isOff = atom({ plugin: 'branch-janitor', key: 'isOff' } as const, null as boolean | null)
82
83async function syncOff($: EngineInterface): Promise<void> {
84 const cwd = await $.session.cwd()
85 const inRepo = await $.process.run(['git', '-C', cwd, 'rev-parse', '--is-inside-work-tree'], { timeoutMs: 5_000 })
86 if (inRepo.exitCode !== 0) return void (await update($, isOff, () => null))
87 const r = await $.process.run(['git', '-C', cwd, 'config', '--get', 'toolkit.branchJanitor'], { timeoutMs: 5_000 })
88 await update($, isOff, () => r.stdout.trim() === 'false')
89}
90
91const hasSurface = async ($: EngineInterface) => (await $.session.surfaces()).length > 0
92
93/** The per-prompt watch: only where a person can be asked, and any error is swallowed. */
94async function watchIfPossible($: EngineInterface, settings: Deps['settings']): Promise<void> {
95 await quietly(async () => {
96 if (!(await hasSurface($))) return // headless: nobody to ask, so not even the probe
97 const deps = depsOf($, settings)
98 await watchPrompt(deps, await $.session.cwd())
99 })
100}
101
102function registerCommand($: EngineInterface) {
103 return $.command.register({
104 name: 'branch-janitor',
105 description: 'Tidies finished git branches. Runs by itself, or run /branch-janitor. You run this, not Claude.',
106 argumentHint: '[clean|on|off]',
107 })
108}
109
110/** The automatic look: only where a person can be asked, and any error is swallowed. */
111async function lookIfPossible($: EngineInterface, settings: Deps['settings'], look: Look): Promise<void> {
112 await quietly(async () => {
113 const surfaces = await $.session.surfaces()
114 if (surfaces.length === 0) return // headless: nobody to ask
115 await lookAndOffer(depsOf($, settings), await $.session.cwd(), look)
116 })
117}
118
119function depsOf($: EngineInterface, settings: Deps['settings']): Deps {
120 return {
121 settings,
122 run: (argv, o) => $.process.run(argv, { cwd: o?.cwd, timeoutMs: o?.timeoutMs, stdin: o?.stdin }),
123 ask: (question, o) => $.ui.ask(question, { options: o.options, header: o.header }),
124 toast: text => {
125 $.ui.toast(text, { timeoutMs: 20_000 })
126 // The toast fades; the restore commands stay in the transcript, one `$.ui.log` call per line so each can be
127 // copied (a newline inside one call draws as a stray glyph).
128 for (const line of text.split('\n')) if (line.trim() !== '') $.ui.log(line)
129 },
130 now: () => $.clock.now(),
131 store: { get: key => $.store.get(key), set: (key, value) => $.store.set(key, value) },
132 hasGh: () => probeGh($),
133 session: { get: async key => (await $.state.get(memoryRef)).value?.[key], set: (key, value) => remember($, key, value) },
134 announce: () => announceOnce($),
135 tryLock: async () => {
136 const now = await $.clock.now()
137 const held = await $.state.get(askingSince)
138 if (held.value !== undefined && held.value > 0 && now - held.value < STALE_LOCK_MS) return undefined
139 const taken = await $.state.set(askingSince, now, { ifVersion: held.version })
140 if (!taken.isSet) return undefined
141 return async () => void (await $.state.set(askingSince, 0))
142 },
143 }
144}
145
146/** One value of the session's memory; a lost race is retried, three losses give up (memory only saves work). */
147async function remember($: EngineInterface, key: string, value: unknown): Promise<void> {
148 for (let attempt = 0; attempt < 3; attempt++) {
149 const current = await $.state.get(memoryRef)
150 const taken = await $.state.set(memoryRef, { ...current.value, [key]: value }, { ifVersion: current.version })
151 if (taken.isSet) return
152 }
153}
154
155/** The "watching" line, once per session; the caller has already checked that a person can see it. */
156async function announceOnce($: EngineInterface): Promise<void> {
157 if ((await $.state.get(watchingRef)).value === true) return
158 await $.state.set(watchingRef, true)
159 $.ui.toast(WATCHING_NOTICE, { timeoutMs: 6_000 }) // a toast only: no box left in the transcript
160}
161
162/** Is `gh` installed? Asked once per session, remembered in state. */
163async function probeGh($: EngineInterface): Promise<boolean> {
164 const known = await $.state.get(hasGhRef)
165 if (typeof known.value === 'boolean') return known.value
166 let isThere = false
167 try {
168 isThere = (await $.process.run(['gh', '--version'], { timeoutMs: 5_000 })).exitCode === 0
169 } catch {
170 isThere = false
171 }
172 await $.state.set(hasGhRef, isThere)
173 return isThere
174}
175
176async function quietly(f: () => unknown): Promise<void> {
177 try {
178 await f()
179 } catch {
180 // fail-open: see the header comment
181 }
182}
183hooks/config.ts 21 lines1import type { PluginOptions } from 'claude-code'
2
3import type { Settings } from './janitor'
4import { DEFAULT_PROTECTED } from './scan'
5
6const num = (v: unknown, fallback: number, min: number, max: number) => {
7 const n = Number(v)
8 return Number.isFinite(n) && v !== '' ? Math.min(max, Math.max(min, Math.round(n))) : fallback
9}
10
11/** userConfig into settings. Empty or missing protectedBranches means the built-in list. */
12export function readSettings(options: PluginOptions): Settings {
13 const raw = Array.isArray(options.protectedBranches) ? (options.protectedBranches as string[]) : []
14 const listed = raw.map(s => String(s).trim()).filter(s => s !== '')
15 return {
16 minMinutes: num(options.minMinutes, 30, 1, 1440),
17 staleDays: num(options.staleDays, 60, 1, 3650),
18 protectedBranches: listed.length > 0 ? listed : DEFAULT_PROTECTED,
19 }
20}
21hooks/janitor.ts 324 lines1// The behaviour, written against a small `Deps` record instead of `$` so the same code runs under the
2// engine (register.tsx builds Deps from `$`), under the unit tests (fake `$`) and under the real-git
3// integration script (real git, scripted answers).
4import type { Verdict } from './decide'
5import { repoFingerprint } from './fingerprint'
6import { gitIn, lines, matchesPattern } from './git'
7import type { Run } from './git'
8import { dialogText, neverAskText, remoteDialogText, reportText, singleDialogText, toastText } from './format'
9import { deleteLocal, deleteRemote, remoteOffer } from './remove'
10import type { RemoteOffer } from './remove'
11import { fetchOpenPrs, protectOpen } from './prs'
12import { isDisabled, repoRoot, scan } from './scan'
13import type { GhGate, ScanConfig, ScanResult } from './scan'
14
15export type Settings = ScanConfig & { minMinutes: number }
16export type AskOptions = { options: string[]; header: string }
17
18export type Deps = {
19 run: Run
20 /** Resolves the chosen label (or free text); rejects when dismissed or when no one can be asked. */
21 ask: (question: string, options: AskOptions) => Promise<string>
22 toast: (text: string) => void
23 now: () => Promise<number>
24 store: { get: (key: string) => Promise<unknown>; set: (key: string, value: unknown) => Promise<void> }
25 /** Is `gh` installed? Probed lazily, once per session by the caller. */
26 hasGh: () => Promise<boolean>
27 /** Take the "a dialog is open" lock; resolves a release function, or undefined when one is already open. */
28 tryLock: () => Promise<(() => Promise<void>) | undefined>
29 /** Memory that lasts one session (what was last looked at, which branches `gh` was asked about); `store` outlives it. */
30 session: { get: (key: string) => Promise<unknown>; set: (key: string, value: unknown) => Promise<void> }
31 /** Tells the person, once per session and only where one can see it, that branches are being watched. */
32 announce: () => Promise<void>
33 settings: Settings
34}
35
36/**
37 * Why a look happens. merge: Claude ran a merge or a merged PR. changed: the per-prompt probe saw the branches
38 * change (or this is the first prompt of the session). other: Claude pulled, pushed, switched or pruned.
39 * start: the session just began.
40 */
41export type Look = 'merge' | 'changed' | 'other' | 'start'
42
43export const SNOOZE_MS = 24 * 3_600_000
44const MAX_OFFERED = 8
45
46/** The first answer is always the safe one. */
47export const LABEL = {
48 keep: 'Keep',
49 never: 'Never ask here',
50 oneByOne: 'Review one by one',
51 keepRest: 'Keep the rest',
52} as const
53
54type RepoState = { lastLook: number; snoozeUntil: number }
55const stateKey = (root: string) => `repo:${root}`
56/** Branches the person was shown and did not delete, by name -> tip: never offered again at that tip. */
57type Declined = Record<string, string>
58const declinedKey = (root: string) => `declined:${root}`
59const seenKey = (cwd: string) => `fingerprint:${cwd}`
60const ghKey = (cwd: string) => `gh:${cwd}`
61const ghUsedKey = (root: string) => `ghUsed:${root}`
62
63/** Claude ran `gh` in this repository: remembered (store), so the pull-request lines are shown only where gh is in use. */
64export async function noteGhUse(d: Deps, cwd: string): Promise<void> {
65 const root = await repoRoot(d.run, cwd)
66 if (root !== undefined && (await d.store.get(ghUsedKey(root))) !== true) await d.store.set(ghUsedKey(root), true)
67}
68
69/**
70 * Add open pull requests to a scan: only when gh is installed AND has been used in this repository, one read-only
71 * `gh pr list`, silent on any failure. A branch with an open PR is never "finished": its offer becomes a keep.
72 * Called for the explicit report, `clean`, and just before a dialog would open: never for an unchanged repository.
73 */
74async function withOpenPrs(d: Deps, root: string, res: ScanResult): Promise<ScanResult> {
75 try {
76 if ((await d.store.get(ghUsedKey(root))) !== true || !(await d.hasGh())) return res
77 const prs = await fetchOpenPrs(d.run, root)
78 return prs === undefined ? res : { ...res, verdicts: protectOpen(res.verdicts, prs), openPrs: prs }
79 } catch {
80 return res
81 }
82}
83
84async function readDeclined(d: Deps, root: string): Promise<Declined> {
85 const v = await d.store.get(declinedKey(root))
86 return v !== null && typeof v === 'object' ? (v as Declined) : {}
87}
88
89async function readState(d: Deps, root: string): Promise<RepoState> {
90 const v = (await d.store.get(stateKey(root))) as Partial<RepoState> | undefined
91 return { lastLook: Number(v?.lastLook) || 0, snoozeUntil: Number(v?.snoozeUntil) || 0 }
92}
93const writeState = (d: Deps, root: string, s: RepoState) => d.store.set(stateKey(root), s)
94
95/**
96 * The rate limit: once per repo per `minMinutes`, and not while snoozed, unless the branches just changed.
97 * A merge (Claude's, or one the probe saw as a changed set of refs) is the clearest sign a branch is finished, so
98 * it never waits. What still protects work in progress is not time but the checks: the branch you stand on, other
99 * worktrees, unpushed commits, stashes and branches nobody has committed on yet are never offered; and a branch
100 * the person already turned down at its current tip is not offered again (see `declined`).
101 */
102export function mayLook(look: Look, s: RepoState, now: number, minMinutes: number): boolean {
103 if (look === 'merge' || look === 'changed') return true
104 return now - s.lastLook >= minMinutes * 60_000 && now >= s.snoozeUntil
105}
106
107export type Outcome = { deleted: Verdict[]; failed: { name: string; why: string }[]; remote: string[]; text: string }
108
109export type LookResult = 'done' | 'busy'
110
111/**
112 * The per-prompt watch (runs from a timer, never inside the prompt): one probe; if the fingerprint is the one
113 * already looked at in this session, that is all. Otherwise (a first prompt counts: nothing was looked at yet) a
114 * full look. 'busy' means a dialog is open, so the change is still unlooked.
115 */
116export async function watchPrompt(d: Deps, cwd: string): Promise<LookResult> {
117 const fingerprint = await repoFingerprint(d.run, cwd)
118 if (fingerprint === undefined) return 'done' // not a repository, or git failed: fail open, say nothing
119 if ((await d.session.get(seenKey(cwd))) === fingerprint) return 'done'
120 return lookAndOffer(d, cwd, 'changed', { fingerprint })
121}
122
123/** Hook path: look, and offer if something is finished. Everything here is silent when there is nothing to say. */
124export async function lookAndOffer(d: Deps, cwd: string, look: Look, o: { fingerprint?: string } = {}): Promise<LookResult> {
125 const root = await repoRoot(d.run, cwd)
126 if (root === undefined) return 'done'
127 if (await isDisabled(d.run, root)) {
128 if (o.fingerprint !== undefined) await d.session.set(seenKey(cwd), o.fingerprint) // stay quiet until something changes
129 return 'done'
130 }
131 const fingerprint = o.fingerprint ?? (await repoFingerprint(d.run, cwd))
132 await d.announce()
133 const now = await d.now()
134 const state = await readState(d, root)
135 if (!mayLook(look, state, now, d.settings.minMinutes)) return 'done'
136 const release = await d.tryLock()
137 if (release === undefined) return 'busy' // another dialog is open: never stack questions
138 try {
139 await writeState(d, root, { ...state, lastLook: now })
140 if (fingerprint !== undefined) await d.session.set(seenKey(cwd), fingerprint)
141 const ledger = await ghLedger(d, cwd, fingerprint ?? '')
142 let res = await scan(d.run, root, d.settings, { ghScope: 'gone', hasGh: d.hasGh, now, ghGate: ledger.gate })
143 await ledger.save()
144 const declined = await readDeclined(d, root)
145 const wanted = (r: ScanResult | undefined) => r?.verdicts.filter(v => v.status === 'offer' && declined[v.name] !== v.sha) ?? []
146 if (res && wanted(res).length > 0) res = await withOpenPrs(d, root, res) // only now that a question would open
147 const offers = wanted(res)
148 if (res && offers.length > 0) await offer(d, res, offers, { auto: true })
149 } finally {
150 await release()
151 }
152 return 'done'
153}
154
155/**
156 * `gh` is asked about a branch tip at most once per fingerprint: a look the fingerprint did not change repeats
157 * nothing, and entries of an older fingerprint are dropped, so a changed repository may ask again.
158 */
159async function ghLedger(d: Deps, cwd: string, fingerprint: string): Promise<{ gate: GhGate; save: () => Promise<void> }> {
160 const old = (await d.session.get(ghKey(cwd))) as Record<string, string> | undefined
161 const fresh: Record<string, string> = Object.fromEntries(Object.entries(old ?? {}).filter(([, fp]) => fp === fingerprint))
162 const id = (branch: string, sha: string) => `${branch}@${sha}`
163 return {
164 gate: { shouldAsk: (b, sha) => fresh[id(b, sha)] === undefined, asked: (b, sha) => void (fresh[id(b, sha)] = fingerprint) },
165 save: () => d.session.set(ghKey(cwd), fresh),
166 }
167}
168
169/** `/branch-janitor clean`: scan with gh help and offer, whatever the rate limit says. */
170export async function cleanNow(d: Deps, cwd: string): Promise<string> {
171 const root = await repoRoot(d.run, cwd)
172 if (root === undefined) return NOT_A_REPO
173 const release = await d.tryLock()
174 if (release === undefined) return 'Branches · answer the open question first'
175 try {
176 const scanned = await scan(d.run, root, d.settings, { ghScope: 'pushed', hasGh: d.hasGh, now: await d.now() })
177 if (!scanned) return UNREADABLE
178 const res = await withOpenPrs(d, root, scanned)
179 const offers = res.verdicts.filter(v => v.status === 'offer')
180 if (offers.length === 0) return ['Branches · nothing to delete right now', 'Run /branch-janitor to see why each branch stays.'].join('\n')
181 const out = await offer(d, res, offers, { auto: false })
182 return out.text || 'Branches · nothing deleted'
183 } finally {
184 await release()
185 }
186}
187
188/** The slash command: `arg` is '' (list), `clean`, `on`, `off`; anything else is the usage text. */
189export async function runCommand(d: Deps, cwd: string, arg: string): Promise<string> {
190 if (arg === '') return report(d, cwd)
191 if (arg === 'clean') return cleanNow(d, cwd)
192 if (arg === 'on' || arg === 'off') return setEnabled(d, cwd, arg === 'on')
193 return USAGE
194}
195
196const NOT_A_REPO = 'Branches · not in a git repository'
197const UNREADABLE = "Branches · couldn't read this repository (is git installed?)"
198
199const USAGE = [
200 'Branches · commands',
201 '/branch-janitor · list branches, delete nothing',
202 '/branch-janitor clean · review and delete finished ones',
203 '/branch-janitor off · stop looking here',
204 '/branch-janitor on · look again',
205].join('\n')
206
207/** `/branch-janitor`: the full table, no questions, no deletion. */
208export async function report(d: Deps, cwd: string): Promise<string> {
209 const root = await repoRoot(d.run, cwd)
210 if (root === undefined) return NOT_A_REPO
211 const scanned = await scan(d.run, root, d.settings, { ghScope: 'pushed', hasGh: d.hasGh, now: await d.now() })
212 if (!scanned) return UNREADABLE
213 const res = await withOpenPrs(d, root, scanned)
214 return reportText(res, { isOff: await isDisabled(d.run, root), staleDays: d.settings.staleDays })
215}
216
217/** `/branch-janitor on|off` for the current repository. */
218export async function setEnabled(d: Deps, cwd: string, isOn: boolean): Promise<string> {
219 const root = await repoRoot(d.run, cwd)
220 if (root === undefined) return NOT_A_REPO
221 const git = gitIn(d.run, root)
222 if (isOn) {
223 await git('config', '--local', '--unset', 'toolkit.branchJanitor') // exit 5 when it was never set: fine
224 await writeState(d, root, { lastLook: 0, snoozeUntil: 0 })
225 await d.store.set(declinedKey(root), {}) // a fresh start: everything finished may be offered again
226 return 'Branches · on for this repository'
227 }
228 const r = await git('config', '--local', 'toolkit.branchJanitor', 'false')
229 return r.exitCode === 0 ? neverAskText() : `Branches · couldn't save the setting: ${lines(r.stderr)[0] ?? 'git refused'}`
230}
231
232// ---------------------------------------------------------------- the questions
233
234async function safeAsk(d: Deps, question: string, o: AskOptions): Promise<string | undefined> {
235 try {
236 return await d.ask(question, o)
237 } catch {
238 return undefined // dismissed, interrupted, or nobody to ask: treated as "no", nothing is changed
239 }
240}
241
242/** `auto`: an automatic look, so a "no" is remembered (snooze, and each shown branch at its tip); a command never is. */
243async function offer(d: Deps, res: ScanResult, all: Verdict[], o: { auto: boolean }): Promise<Outcome> {
244 const offers = all.slice(0, MAX_OFFERED)
245 const n = offers.length
246 const deleteAll = n === 1 ? 'Delete branch' : `Delete all ${n}`
247 const options = [LABEL.keep, deleteAll, ...(n > 1 ? [LABEL.oneByOne] : []), LABEL.never]
248 const answer = await safeAsk(d, dialogText(offers, all.length - n), { options, header: 'Branches' })
249
250 if (answer === LABEL.never) return finish(d, res, [], { text: await setEnabled(d, res.root, false) })
251 if (answer === LABEL.keep) {
252 if (o.auto) {
253 await snooze(d, res.root)
254 await decline(d, res, offers)
255 }
256 return finish(d, res, [], { text: 'Branches · kept everything' })
257 }
258 if (answer === deleteAll) return finish(d, res, offers, {})
259 if (answer === LABEL.oneByOne) {
260 const chosen: Verdict[] = []
261 const skipped: Verdict[] = []
262 for (const v of offers) {
263 const a = await safeAsk(d, singleDialogText(v), { options: [LABEL.keep, `Delete ${v.name}`], header: 'Branches' })
264 if (a === `Delete ${v.name}`) chosen.push(v)
265 else if (a === LABEL.keep) skipped.push(v)
266 }
267 if (o.auto) await decline(d, res, skipped)
268 return finish(d, res, chosen, {})
269 }
270 return finish(d, res, [], { text: 'Branches · no answer, kept everything' })
271}
272
273/** Remember branches the person saw and kept, so an automatic look does not ask about the same tip twice. */
274async function decline(d: Deps, res: ScanResult, shown: Verdict[]): Promise<void> {
275 if (shown.length === 0) return
276 const exists = new Set(res.verdicts.map(v => v.name))
277 const kept = Object.entries(await readDeclined(d, res.root)).filter(([name]) => exists.has(name)) // forget deleted branches
278 await d.store.set(declinedKey(res.root), { ...Object.fromEntries(kept), ...Object.fromEntries(shown.map(v => [v.name, v.sha])) })
279}
280
281async function snooze(d: Deps, root: string): Promise<void> {
282 const now = await d.now()
283 await writeState(d, root, { ...(await readState(d, root)), snoozeUntil: now + SNOOZE_MS })
284}
285
286/** Delete the chosen branches (locally), then ask about each remote copy separately; one toast at the end. */
287async function finish(d: Deps, res: ScanResult, chosen: Verdict[], base: { text?: string }): Promise<Outcome> {
288 if (chosen.length === 0) return { deleted: [], failed: [], remote: [], text: base.text ?? '' }
289 const deleted: { v: Verdict; flag: string }[] = []
290 const failed: { name: string; why: string }[] = []
291 for (const v of chosen) {
292 const r = await deleteLocal(d.run, res.root, v)
293 if (r.ok) deleted.push({ v, flag: r.flag })
294 else failed.push({ name: v.name, why: r.why })
295 }
296 const remote = await askRemotes(d, res, deleted.map(x => x.v), failed)
297 const text = toastText(deleted, failed, remote)
298 d.toast(text)
299 return { deleted: deleted.map(x => x.v), failed, remote, text }
300}
301
302async function askRemotes(d: Deps, res: ScanResult, deleted: Verdict[], failed: { name: string; why: string }[]): Promise<string[]> {
303 const offers: { v: Verdict; offer: RemoteOffer }[] = []
304 for (const v of deleted) {
305 const ro = await remoteOffer(d.run, res.root, v, res.bases, b => b === res.defaultName || d.settings.protectedBranches.some(p => matchesPattern(b, p)))
306 if (ro) offers.push({ v, offer: ro })
307 }
308 const done: string[] = []
309 for (const [i, { offer: ro }] of offers.entries()) {
310 const full = `${ro.remote}/${ro.branch}`
311 const yes = `Delete ${full}`
312 const rest = offers.length - i - 1
313 const options = [LABEL.keep, yes, ...(rest > 0 ? [LABEL.keepRest] : [])]
314 const a = await safeAsk(d, remoteDialogText(ro.remote, ro.branch, ro.tip), { options, header: 'Remote' })
315 if (a === LABEL.keepRest) break
316 if (a !== yes) continue
317 const r = await deleteRemote(d.run, res.root, ro)
318 if (r.ok) done.push(full)
319 else failed.push({ name: full, why: r.why })
320 }
321 return done
322}
323
324hooks/trigger.ts 109 lines1// Which finished Bash commands are worth a look at the branches. A small quote-aware splitter, not a
2// shell: it only needs to find `git merge`, `gh pr merge` and friends inside `a && b; c | d`.
3export type Trigger = 'merge' | 'other'
4
5/** Split on `&&`, `||`, `;`, `|`, `&` and newlines outside quotes, then on spaces; quotes are removed. */
6export function segments(command: string): string[][] {
7 const out: string[][] = [[]]
8 let token = ''
9 let hasToken = false
10 let quote: '"' | "'" | '' = ''
11 const endToken = () => {
12 if (hasToken) out[out.length - 1]!.push(token)
13 token = ''
14 hasToken = false
15 }
16 const endSegment = () => {
17 endToken()
18 if (out[out.length - 1]!.length > 0) out.push([])
19 }
20 for (const ch of command) {
21 if (quote) {
22 if (ch === quote) quote = ''
23 else token += ch
24 } else if (ch === '"' || ch === "'") {
25 quote = ch
26 hasToken = true
27 } else if (ch === ' ' || ch === '\t' || ch === '\r') endToken()
28 else if (ch === ';' || ch === '|' || ch === '&' || ch === '\n') endSegment()
29 else {
30 token += ch
31 hasToken = true
32 }
33 }
34 endSegment()
35 return out.filter(s => s.length > 0)
36}
37
38const WRAPPERS = new Set(['sudo', 'command', 'time', 'env', 'nohup'])
39const base = (p: string) => (p.split(/[\\/]/).pop() ?? p).replace(/\.exe$/i, '')
40
41/** The program and its arguments, skipping `VAR=value` prefixes and wrappers like `sudo`. */
42function program(seg: string[]): { name: string; args: string[] } | undefined {
43 let i = 0
44 while (i < seg.length && (/^[A-Za-z_]\w*=/.test(seg[i]!) || WRAPPERS.has(seg[i]!))) i++
45 const name = seg[i]
46 return name === undefined ? undefined : { name: base(name), args: seg.slice(i + 1) }
47}
48
49const GIT_OPTS_WITH_VALUE = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace'])
50
51function gitSub(args: string[]): { sub: string; rest: string[] } | undefined {
52 for (let i = 0; i < args.length; i++) {
53 const a = args[i]!
54 if (GIT_OPTS_WITH_VALUE.has(a)) i++
55 else if (!a.startsWith('-')) return { sub: a, rest: args.slice(i + 1) }
56 }
57 return undefined
58}
59
60const NOT_A_BRANCH_SWITCH = new Set(['-b', '-B', '-c', '-C', '--orphan', '--', '.', '-p', '--patch', '-f', '--force', '--detach', '-d'])
61
62function classifyGit(args: string[]): Trigger | undefined {
63 const g = gitSub(args)
64 if (!g) return undefined
65 const { sub, rest } = g
66 const has = (...flags: string[]) => rest.some(a => flags.includes(a))
67 switch (sub) {
68 case 'merge':
69 return has('--abort', '--quit') ? undefined : 'merge'
70 case 'pull':
71 case 'push': // a push to the default branch, or a pull / fetch that brings a merge in, is how a branch usually finishes
72 case 'fetch':
73 return 'merge'
74 case 'checkout':
75 case 'switch': {
76 const positional = rest.filter(a => a === '-' || !a.startsWith('-')) // a lone '-' is "the previous branch"
77 return positional.length > 0 && !rest.some(a => NOT_A_BRANCH_SWITCH.has(a)) ? 'other' : undefined
78 }
79 case 'remote':
80 return rest[0] === 'prune' || (rest[0] === 'update' && has('-p', '--prune')) ? 'other' : undefined
81 default:
82 return undefined
83 }
84}
85
86/** `merge` for a merge, a merged PR, a pull, a push or a fetch (always worth a look, never rate-limited), `other` for the quieter triggers, else undefined. */
87export function triggerOf(command: string): Trigger | undefined {
88 let found: Trigger | undefined
89 for (const seg of segments(command)) {
90 const p = program(seg)
91 if (!p) continue
92 let t: Trigger | undefined
93 if (p.name === 'git') t = classifyGit(p.args)
94 else if (p.name === 'gh') {
95 const words = p.args.filter(a => !a.startsWith('-'))
96 const at = words.indexOf('pr') // `gh -R owner/repo pr merge` has a repo word first
97 t = at >= 0 && words[at + 1] === 'merge' ? 'merge' : undefined
98 }
99 if (t === 'merge') return 'merge'
100 found = found ?? t
101 }
102 return found
103}
104
105/** Did this command run `gh` (any subcommand)? Only the fact is kept: it tells the mod the person uses gh here. */
106export function usesGh(command: string): boolean {
107 return segments(command).some(seg => program(seg)?.name === 'gh')
108}
109hooks/scan.ts 222 lines1// The scan: every probe is a read-only git call (plus an optional gh query), O(branches), no model
2// calls and no network except gh. It gathers Facts for each local branch and hands them to decide().
3import { decide } from './decide'
4import type { Facts, Verdict } from './decide'
5import {
6 BRANCH_FORMAT, gitIn, isSafeName, lines, matchesPattern, parseBranchRefs, parseCherry, parseJustCreated, parseMergedPr,
7 parseRemoteTips, parseStashBranches, parseWorktrees,
8} from './git'
9import type { BranchRef, Git, MergedPr, Run } from './git'
10import type { OpenPr } from './prs'
11
12export const MAX_BRANCHES = 60
13const MAX_GH_QUERIES = 10
14const GH_TIMEOUT_MS = 8_000
15const CONTENT_MAX_PATHS = 400
16const CONTENT_CHUNK = 50
17
18export const DEFAULT_PROTECTED = ['main', 'master', 'develop', 'dev', 'trunk', 'release/*', 'gh-pages']
19
20export type ScanConfig = { protectedBranches: string[]; staleDays: number }
21/** none: git only. gone: ask gh about branches whose remote vanished. pushed: ask about every pushed, unproven branch. */
22export type GhScope = 'none' | 'gone' | 'pushed'
23/** Lets the caller keep `gh` from asking about the same branch tip twice (see janitor.ts). */
24export type GhGate = { shouldAsk: (branch: string, sha: string) => boolean; asked: (branch: string, sha: string) => void }
25export type ScanOptions = { ghScope: GhScope; hasGh: () => Promise<boolean>; now: number; ghGate?: GhGate }
26
27export type ScanResult = {
28 root: string
29 /** The local default branch name (`main`), as origin/HEAD says or main/master/develop found locally. */
30 defaultName: string
31 /** The refs finished work is compared against (`refs/heads/main`, `refs/remotes/origin/main`), spelled short. */
32 bases: string[]
33 verdicts: Verdict[]
34 /** Branches left unexamined because of the 60-branch cap. */
35 capped: number
36 /** Unmerged branches untouched for more than `staleDays` days (information only). */
37 stale: { name: string; days: number }[]
38 notes: string[]
39 /** Open pull requests by local branch, set by janitor.ts when gh answered; absent = not asked or no answer. */
40 openPrs?: OpenPr[]
41}
42
43/** The repository top level, or undefined outside a repository (or when git is missing). */
44export async function repoRoot(run: Run, cwd: string | undefined): Promise<string | undefined> {
45 const r = await gitIn(run, cwd)('rev-parse', '--show-toplevel')
46 const root = lines(r.stdout)[0]
47 return r.exitCode === 0 && root ? root : undefined
48}
49
50/** `git config --local toolkit.branchJanitor false` switches the mod off for one repository. */
51export async function isDisabled(run: Run, cwd: string): Promise<boolean> {
52 const r = await gitIn(run, cwd)('config', '--local', '--get', 'toolkit.branchJanitor')
53 return r.exitCode === 0 && r.stdout.trim().toLowerCase() === 'false'
54}
55
56export async function scan(run: Run, root: string, cfg: ScanConfig, opts: ScanOptions): Promise<ScanResult | undefined> {
57 const git = gitIn(run, root)
58 const refsOut = await git('for-each-ref', `--format=${BRANCH_FORMAT}`, 'refs/heads')
59 if (refsOut.exitCode !== 0) return undefined
60 const refs = parseBranchRefs(refsOut.stdout)
61 const remoteTips = parseRemoteTips((await git('for-each-ref', '--format=%(refname)%09%(objectname)', 'refs/remotes')).stdout)
62 const defaultName = await findDefaultName(git, refs)
63 if (defaultName === undefined) {
64 return { root, defaultName: '', bases: [], verdicts: [], capped: 0, stale: [], notes: ['No default branch found (main, master or develop), so nothing was compared.'] }
65 }
66 const bases = await chooseBases(git, refs, defaultName, remoteTips)
67 const worktrees = parseWorktrees((await git('worktree', 'list', '--porcelain')).stdout)
68 const stashed = parseStashBranches((await git('stash', 'list', '--format=%gs')).stdout)
69 const checkedOut = new Map(worktrees.filter(w => w.branch).map(w => [w.branch!, w.path]))
70
71 const excluded = (r: BranchRef): string | undefined => exclusionOf(r, defaultName, cfg.protectedBranches, checkedOut)
72 const candidates = refs.filter(r => excluded(r) === undefined).sort((a, b) => a.lastCommit - b.lastCommit)
73 const examined = candidates.slice(0, MAX_BRANCHES)
74 const merged = await mergedSets(git, bases)
75
76 const facts: Facts[] = []
77 for (const ref of refs) {
78 const exclusion = excluded(ref)
79 let why = exclusion
80 // The current branch is never deleted; say when it still holds work the default branch lacks, which is what keeps it
81 // alive on the server too (for example new commits pushed after its pull request was merged).
82 if (exclusion === 'current branch' && bases[0] !== undefined) {
83 const n = Number((await git('rev-list', '--count', `${bases[0]}..${ref.sha}`)).stdout.trim())
84 if (n > 0) why = `current branch · ${n} ${n === 1 ? 'commit' : 'commits'} not in ${defaultName}: open a pull request for ${n === 1 ? 'it' : 'them'}`
85 }
86 const f: Facts = { ref, exclusion: why, hasStash: stashed.has(ref.name) }
87 if (exclusion === undefined && examined.includes(ref)) await gatherEvidence(git, f, bases, merged)
88 facts.push(f)
89 }
90 await askGh(run, root, facts, defaultName, opts)
91
92 const verdicts = facts
93 .filter(f => f.exclusion !== undefined || examined.includes(f.ref))
94 .map(f => decide(f, { defaultName }))
95 const hasProof = (v: Verdict) => v.evidence.some(e => e.kind !== 'gone')
96 const stale = verdicts
97 .filter(v => (v.status === 'keep' || v.status === 'maybe') && !hasProof(v))
98 .map(v => ({ name: v.name, days: Math.floor((opts.now / 1000 - v.ref.lastCommit) / 86_400) }))
99 .filter(s => s.days > cfg.staleDays)
100 return { root, defaultName, bases, verdicts, capped: candidates.length - examined.length, stale, notes: [] }
101}
102
103function exclusionOf(r: BranchRef, defaultName: string, patterns: string[], checkedOut: Map<string, string>): string | undefined {
104 if (!isSafeName(r.name)) return 'unusual name, never touched'
105 if (r.isCurrent) return 'current branch'
106 if (r.name === defaultName) return 'default branch'
107 const pattern = patterns.find(p => matchesPattern(r.name, p))
108 if (pattern !== undefined) return `protected by ${pattern}`
109 const tree = checkedOut.get(r.name)
110 if (tree !== undefined) return `open in worktree ${tree}`
111 return undefined
112}
113
114async function findDefaultName(git: Git, refs: BranchRef[]): Promise<string | undefined> {
115 const head = await git('symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD')
116 const ref = lines(head.stdout)[0]
117 if (head.exitCode === 0 && ref?.startsWith('refs/remotes/origin/')) return ref.slice('refs/remotes/origin/'.length)
118 return ['main', 'master', 'develop'].find(n => refs.some(r => r.name === n))
119}
120
121/**
122 * What finished work is compared against. The local default branch can be stale (a PR merged on the
123 * server, `git fetch` run, `git pull` not yet), so `origin/<default>` counts too; when one contains the
124 * other only the newer is kept, so the usual case costs one comparison per branch.
125 */
126async function chooseBases(git: Git, refs: BranchRef[], name: string, remoteTips: Map<string, string>): Promise<string[]> {
127 const local = refs.find(r => r.name === name)?.sha
128 const remote = remoteTips.get(`origin/${name}`)
129 const localRef = `refs/heads/${name}`
130 const remoteRef = `refs/remotes/origin/${name}`
131 if (local && remote && local !== remote) {
132 if ((await git('merge-base', '--is-ancestor', localRef, remoteRef)).exitCode === 0) return [remoteRef]
133 if ((await git('merge-base', '--is-ancestor', remoteRef, localRef)).exitCode === 0) return [localRef]
134 return [localRef, remoteRef]
135 }
136 return local ? [localRef] : remote ? [remoteRef] : []
137}
138
139/** For each base, the set of local branches whose tip is an ancestor: one call per base. */
140async function mergedSets(git: Git, bases: string[]): Promise<Map<string, Set<string>>> {
141 const sets = new Map<string, Set<string>>()
142 for (const base of bases) {
143 const out = await git('for-each-ref', `--merged=${base}`, '--format=%(refname)', 'refs/heads')
144 sets.set(base, new Set(lines(out.stdout).map(l => l.slice('refs/heads/'.length))))
145 }
146 return sets
147}
148
149const short = (ref: string) => ref.replace(/^refs\/(heads|remotes)\//, '')
150
151async function gatherEvidence(git: Git, f: Facts, bases: string[], merged: Map<string, Set<string>>): Promise<void> {
152 const full = `refs/heads/${f.ref.name}`
153 const hit = bases.find(b => merged.get(b)?.has(f.ref.name))
154 if (hit) {
155 f.mergedInto = short(hit)
156 // Only an ancestor can be a branch that was never worked on, so only these pay for the reflog probe.
157 f.justCreated = parseJustCreated((await git('reflog', 'show', '--format=%gs', '-n', '2', full)).stdout)
158 return
159 }
160 for (const base of bases) {
161 const r = await git('cherry', base, full)
162 if (r.exitCode !== 0) continue
163 const c = { base: short(base), ...parseCherry(r.stdout) }
164 if (!f.cherry || c.plus < f.cherry.plus) f.cherry = c
165 if (c.plus === 0) return
166 }
167 for (const base of bases) {
168 if (await contentIsInBase(git, base, full)) {
169 f.contentBase = short(base)
170 return
171 }
172 }
173}
174
175/**
176 * A squash merge of several commits has a patch that matches none of them, so `git cherry` still shows
177 * '+'. The test that does hold: every file the branch changed (since it left the base) already has the
178 * branch's exact content in the base. Read-only: `git diff --name-only` then `git diff --quiet` on those paths.
179 */
180async function contentIsInBase(git: Git, base: string, branch: string): Promise<boolean> {
181 const mb = lines((await git('merge-base', base, branch)).stdout)[0]
182 if (!mb) return false
183 const names = await git('diff', '--name-only', '--no-renames', '-z', mb, branch)
184 if (names.exitCode !== 0) return false
185 const paths = names.stdout.split('\0').filter(p => p !== '')
186 if (paths.length === 0 || paths.length > CONTENT_MAX_PATHS) return false
187 for (let i = 0; i < paths.length; i += CONTENT_CHUNK) {
188 const same = await git('diff', '--quiet', branch, base, '--', ...paths.slice(i, i + CONTENT_CHUNK))
189 if (same.exitCode !== 0) return false
190 }
191 return true
192}
193
194/** Optional pull-request evidence: silent on any failure, at most 10 queries of 8 s each. */
195async function askGh(run: Run, root: string, facts: Facts[], defaultName: string, opts: ScanOptions): Promise<void> {
196 if (opts.ghScope === 'none') return
197 const wanted = facts.filter(f => {
198 if (f.exclusion !== undefined || f.mergedInto || f.contentBase) return false
199 if (f.cherry && f.cherry.plus === 0 && f.cherry.minus > 0) return false
200 if (opts.ghGate && !opts.ghGate.shouldAsk(f.ref.name, f.ref.sha)) return false
201 return f.ref.isGone || (opts.ghScope === 'pushed' && f.ref.upstream !== '')
202 }).slice(0, MAX_GH_QUERIES)
203 if (wanted.length === 0 || !(await opts.hasGh())) return
204 for (const f of wanted) {
205 opts.ghGate?.asked(f.ref.name, f.ref.sha)
206 const pr = await queryPr(run, root, f.ref.name)
207 if (pr && pr.baseRefName === defaultName) f.pr = pr
208 }
209}
210
211async function queryPr(run: Run, root: string, branch: string): Promise<MergedPr | undefined> {
212 try {
213 const r = await run(
214 ['gh', 'pr', 'list', '--head', branch, '--state', 'merged', '--json', 'number,mergedAt,baseRefName,headRefOid', '--limit', '1'],
215 { cwd: root, timeoutMs: GH_TIMEOUT_MS },
216 )
217 return r.exitCode === 0 ? parseMergedPr(r.stdout) : undefined
218 } catch {
219 return undefined
220 }
221}
222hooks/decide.ts 116 lines1// The decision table, as pure functions of facts already gathered by scan.ts: for one local
2// branch, what proves its work is done (evidence), which safety checks hold, and therefore whether
3// it may be OFFERED for deletion. A branch with no strong evidence is never offered, and any
4// failing check removes an otherwise finished branch from the offer.
5import type { BranchRef, MergedPr } from './git'
6
7export type EvidenceKind = 'merged' | 'patch' | 'content' | 'pr' | 'gone'
8export type Evidence = { kind: EvidenceKind; text: string }
9export type Check = { id: 'a' | 'b' | 'c' | 'd' | 'e'; label: string; ok: boolean; detail: string }
10/** offer: finished and safe. keep: not offered (reason says why). maybe: remote gone, unproven. excluded: never considered. */
11export type Status = 'offer' | 'keep' | 'maybe' | 'excluded'
12
13export type Facts = {
14 ref: BranchRef
15 /** Why this branch is never considered (current, default, protected, worktree, unsafe name). */
16 exclusion?: string
17 /** The base (`main` or `origin/main`) the tip is an ancestor of. */
18 mergedInto?: string
19 /** `git cherry` against the best base: how many commits have / lack a patch-equivalent there. */
20 cherry?: { base: string; plus: number; minus: number }
21 /** Base on which every file the branch changed already has the branch's content. */
22 contentBase?: string
23 pr?: MergedPr
24 hasStash: boolean
25 /** The branch has never moved since it was created (see parseJustCreated): "merged" only because it starts at the base. */
26 justCreated?: boolean
27}
28
29export type Context = { defaultName: string }
30
31export type Verdict = {
32 name: string
33 sha: string
34 ref: BranchRef
35 status: Status
36 evidence: Evidence[]
37 checks: Check[]
38 /** One plain sentence: why it is offered, or why it is not. */
39 reason: string
40 /** `-d` is git's own safe delete; `-D` only where git cannot see the merge (squash / rebase / PR). */
41 deleteFlag: '-d' | '-D'
42}
43
44const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`
45
46export function evidenceOf(f: Facts, ctx: Context): Evidence[] {
47 const out: Evidence[] = []
48 if (f.mergedInto) out.push({ kind: 'merged', text: `merged into ${f.mergedInto}` })
49 else if (f.cherry && f.cherry.plus === 0 && f.cherry.minus > 0)
50 out.push({ kind: 'patch', text: `all ${plural(f.cherry.minus, 'commit')} already in ${f.cherry.base} (squash/rebase merge)` })
51 if (!f.mergedInto && f.contentBase)
52 out.push({ kind: 'content', text: `its files already match ${f.contentBase} (squash merge)` })
53 if (f.pr && f.pr.baseRefName === ctx.defaultName) out.push({ kind: 'pr', text: `PR #${f.pr.number} merged into ${ctx.defaultName}` })
54 if (f.ref.isGone) out.push({ kind: 'gone', text: 'its remote branch was deleted' })
55 return out
56}
57
58/** Check b: nothing on the branch is missing from the default branch. */
59function checkUnique(f: Facts, ev: Evidence[]): Check {
60 const label = 'No commits that exist only on this branch'
61 const has = (k: EvidenceKind) => ev.some(e => e.kind === k)
62 const prAtTip = f.pr !== undefined && f.pr.headRefOid !== '' && f.pr.headRefOid === f.ref.sha && has('pr')
63 if (has('merged')) return { id: 'b', label, ok: true, detail: 'every commit is already inside the default branch' }
64 if (has('patch')) return { id: 'b', label, ok: true, detail: 'every commit has an identical change in the default branch' }
65 if (has('content')) return { id: 'b', label, ok: true, detail: 'every changed file already has this content in the default branch' }
66 if (prAtTip) return { id: 'b', label, ok: true, detail: `the merged PR #${f.pr!.number} ended at exactly this commit` }
67 if (f.cherry && f.cherry.plus > 0)
68 return { id: 'b', label, ok: false, detail: `${plural(f.cherry.plus, 'commit')} not found in ${f.cherry.base}${has('pr') ? ' (added after the PR merged?)' : ''}` }
69 if (has('pr')) return { id: 'b', label, ok: false, detail: `commits added after PR #${f.pr!.number} merged` }
70 return { id: 'b', label, ok: false, detail: "couldn't compare it with the default branch" }
71}
72
73function checkPushed(f: Facts, unique: Check): Check {
74 const label = 'Nothing waiting to be pushed'
75 if (f.ref.upstream === '') return { id: 'c', label, ok: true, detail: 'never pushed, no remote copy to lose' }
76 if (f.ref.isGone)
77 return unique.ok
78 ? { id: 'c', label, ok: true, detail: 'remote copy is already gone and nothing is unique to this branch' }
79 : { id: 'c', label, ok: false, detail: 'remote copy is gone and some commits are not in the default branch' }
80 if (f.ref.ahead > 0) return { id: 'c', label, ok: false, detail: `${plural(f.ref.ahead, 'commit')} not pushed to ${f.ref.upstream.replace('refs/remotes/', '')}` }
81 return { id: 'c', label, ok: true, detail: 'everything is on the remote' }
82}
83
84export function decide(f: Facts, ctx: Context): Verdict {
85 const base = { name: f.ref.name, sha: f.ref.sha, ref: f.ref }
86 if (f.exclusion) return { ...base, status: 'excluded', evidence: [], checks: [], reason: f.exclusion, deleteFlag: '-d' }
87
88 const evidence = evidenceOf(f, ctx)
89 const strong = evidence.filter(e => e.kind !== 'gone')
90 const unique = checkUnique(f, evidence)
91 const checks: Check[] = [
92 { id: 'a', label: 'Not checked out anywhere', ok: true, detail: 'not the current branch, not open in another worktree' },
93 unique,
94 checkPushed(f, unique),
95 f.hasStash
96 ? { id: 'd', label: 'No stash saved on this branch', ok: false, detail: 'a stash was saved on it' }
97 : { id: 'd', label: 'No stash saved on this branch', ok: true, detail: 'no stash entries' },
98 /^[0-9a-f]{40,64}$/.test(f.ref.sha)
99 ? { id: 'e', label: 'Tip recorded so it can be restored', ok: true, detail: `git branch ${f.ref.name} ${f.ref.sha}` }
100 : { id: 'e', label: 'Tip recorded so it can be restored', ok: false, detail: 'could not read the branch tip' },
101 ]
102 const deleteFlag = evidence.some(e => e.kind === 'merged') ? '-d' : '-D'
103
104 if (strong.length === 0) {
105 if (f.ref.isGone)
106 return { ...base, status: 'maybe', evidence, checks, deleteFlag, reason: 'remote branch is gone, but no merge found: check before deleting' }
107 return { ...base, status: 'keep', evidence, checks, deleteFlag, reason: 'not merged yet' }
108 }
109 if (f.justCreated)
110 return { ...base, status: 'keep', evidence, checks, deleteFlag, reason: 'just created, no commits yet' }
111 const failed = checks.filter(c => !c.ok)
112 if (failed.length > 0)
113 return { ...base, status: 'keep', evidence, checks, deleteFlag, reason: `${strong[0]!.text}, but ${failed.map(c => c.detail).join('; ')}` }
114 return { ...base, status: 'offer', evidence, checks, deleteFlag, reason: strong[0]!.text }
115}
116hooks/fingerprint.ts 32 lines1// The per-prompt probe: ONE bounded, read-only git call whose output changes whenever something the janitor
2// cares about changes (a branch moved, appeared or vanished, a remote-tracking ref moved after a fetch, a
3// remote branch was pruned, the checked-out branch changed, an upstream became ahead/behind/gone).
4// The output is reduced to a short string so it is cheap to keep and compare.
5import { gitIn } from './git'
6import type { Run } from './git'
7
8const PROBE_TIMEOUT_MS = 3_000
9
10/** `%(HEAD)` is `*` on the checked-out branch, so switching branches changes the print too. */
11export const PROBE_FORMAT = '%(HEAD)%(refname) %(objectname) %(upstream:track)'
12
13/** 53-bit string hash (cyrb53): no crypto API is available to a hooks module, and a collision only costs a missed look. */
14export function hashText(text: string): string {
15 let h1 = 0xdeadbeef
16 let h2 = 0x41c6ce57
17 for (let i = 0; i < text.length; i++) {
18 const c = text.charCodeAt(i)
19 h1 = Math.imul(h1 ^ c, 2654435761)
20 h2 = Math.imul(h2 ^ c, 1597334677)
21 }
22 h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507) ^ Math.imul(h2 ^ (h2 >>> 13), 3266489909)
23 h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507) ^ Math.imul(h1 ^ (h1 >>> 13), 3266489909)
24 return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(36)
25}
26
27/** The fingerprint of the repository around `cwd`, or undefined outside a repository or when git fails or times out. */
28export async function repoFingerprint(run: Run, cwd: string | undefined): Promise<string | undefined> {
29 const r = await gitIn(run, cwd, PROBE_TIMEOUT_MS)('for-each-ref', `--format=${PROBE_FORMAT}`, 'refs/heads', 'refs/remotes')
30 return r.exitCode === 0 ? `${r.stdout.length}.${hashText(r.stdout)}` : undefined
31}
32hooks/git.ts 164 lines1// Thin, read-only-by-default git layer: argv builder, never-throwing runner, and the pure
2// parsers for the handful of git outputs the mod reads. No shell is ever involved, so the same
3// code runs on macOS, Windows and Linux; every parser tolerates CRLF line endings.
4
5export type RunResult = { exitCode: number; stdout: string; stderr: string }
6export type RunOptions = { cwd?: string; timeoutMs?: number; stdin?: string }
7/** `$.process.run` as the mod needs it: an argv ARRAY, never a shell string. */
8export type Run = (argv: readonly string[], options?: RunOptions) => Promise<RunResult>
9
10/** Global git flags for every call: no octal-escaped paths, no index lock, no pager, no pathspec magic. */
11const GIT_PREFIX = ['git', '-c', 'core.quotepath=off', '--no-optional-locks', '--no-pager', '--literal-pathspecs']
12const GIT_TIMEOUT_MS = 20_000
13
14export type Git = (...args: string[]) => Promise<RunResult>
15
16/** A git runner bound to a directory. It never throws: a spawn failure or timeout reads as exit code 127. */
17export function gitIn(run: Run, cwd: string | undefined, timeoutMs: number = GIT_TIMEOUT_MS): Git {
18 return async (...args) => {
19 try {
20 return await run([...GIT_PREFIX, ...args], { cwd, timeoutMs })
21 } catch (error) {
22 return { exitCode: 127, stdout: '', stderr: error instanceof Error ? error.message : String(error) }
23 }
24 }
25}
26
27export const lines = (text: string): string[] => text.split('\n').map(l => l.replace(/\r$/, '')).filter(l => l !== '')
28
29/** A branch or remote name that is safe to hand to git as an argument and to print in a dialog. */
30export function isSafeName(name: string): boolean {
31 if (name === '' || name.length > 255) return false
32 if (name.startsWith('-')) return false // would read as an option
33 if (/[\u0000- \u007f]/.test(name)) return false // control characters and spaces (spaces are illegal in refs anyway)
34 return !name.includes('..') && !name.includes('@{') && !name.endsWith('/') && !name.endsWith('.lock')
35}
36
37/** `release/*` style patterns: `*` matches any run of characters, everything else is literal. */
38export function matchesPattern(name: string, pattern: string): boolean {
39 const re = pattern.split('*').map(part => part.replace(/[.+?^${}()|[\]\\]/g, '\\$&')).join('.*')
40 return new RegExp(`^${re}$`).test(name)
41}
42
43// ---------------------------------------------------------------- for-each-ref
44
45export type BranchRef = {
46 name: string
47 sha: string
48 isCurrent: boolean
49 /** Full upstream ref (`refs/remotes/origin/x`), '' when none is configured. */
50 upstream: string
51 remote: string
52 ahead: number
53 behind: number
54 /** The upstream is configured but no longer exists (`[gone]`). */
55 isGone: boolean
56 /** Committer date of the tip, seconds since the epoch (0 when unknown). */
57 lastCommit: number
58}
59
60/** The `--format` for `git for-each-ref refs/heads` that `parseBranchRefs` reads (tab separated). */
61export const BRANCH_FORMAT = '%(HEAD)%09%(refname)%09%(objectname)%09%(upstream)%09%(upstream:remotename)%09%(upstream:track)%09%(committerdate:unix)'
62
63export function parseTrack(track: string): { isGone: boolean; ahead: number; behind: number } {
64 const t = track.trim()
65 return {
66 isGone: t.includes('gone'),
67 ahead: Number(/ahead (\d+)/.exec(t)?.[1] ?? 0),
68 behind: Number(/behind (\d+)/.exec(t)?.[1] ?? 0),
69 }
70}
71
72export function parseBranchRefs(out: string): BranchRef[] {
73 const refs: BranchRef[] = []
74 for (const line of lines(out)) {
75 const [head = '', refname = '', sha = '', upstream = '', remote = '', track = '', date = ''] = line.split('\t')
76 if (!refname.startsWith('refs/heads/')) continue
77 refs.push({
78 name: refname.slice('refs/heads/'.length),
79 sha,
80 isCurrent: head === '*',
81 upstream,
82 remote,
83 ...parseTrack(track),
84 lastCommit: Number(date) || 0,
85 })
86 }
87 return refs
88}
89
90/** `for-each-ref --format=%(refname)%09%(objectname) refs/remotes` into name -> sha, keyed `origin/main`. */
91export function parseRemoteTips(out: string): Map<string, string> {
92 const tips = new Map<string, string>()
93 for (const line of lines(out)) {
94 const [ref = '', sha = ''] = line.split('\t')
95 if (ref.startsWith('refs/remotes/') && sha) tips.set(ref.slice('refs/remotes/'.length), sha)
96 }
97 return tips
98}
99
100/**
101 * `git reflog show --format=%gs -n 2 <branch>` (newest first): true when the branch's whole history is its
102 * creation. A branch nobody has committed on or fast-forwarded since `git branch` / `git switch -c` has the
103 * same tip as the branch it came from, so it looks "merged" while it is really work that has not started.
104 * An empty reflog (reflogs off, or expired) says nothing, so it is not "just created".
105 */
106export function parseJustCreated(out: string): boolean {
107 const entries = lines(out)
108 return entries.length === 1 && /^branch: Created from /i.test(entries[0]!)
109}
110
111// ---------------------------------------------------------------- cherry / worktree / stash / gh
112
113/** `git cherry <base> <branch>`: '+' = commit with no patch-equivalent on base, '-' = it has one. Empty = nothing ahead. */
114export function parseCherry(out: string): { plus: number; minus: number } {
115 let plus = 0
116 let minus = 0
117 for (const line of lines(out)) {
118 if (line.startsWith('+ ')) plus++
119 else if (line.startsWith('- ')) minus++
120 }
121 return { plus, minus }
122}
123
124export type Worktree = { path: string; branch?: string; isBare: boolean }
125
126/** `git worktree list --porcelain`: blocks separated by blank lines; a path may hold spaces. */
127export function parseWorktrees(out: string): Worktree[] {
128 const trees: Worktree[] = []
129 let cur: Worktree | undefined
130 for (const raw of out.split('\n')) {
131 const line = raw.replace(/\r$/, '')
132 if (line.startsWith('worktree ')) {
133 cur = { path: line.slice('worktree '.length), isBare: false }
134 trees.push(cur)
135 } else if (cur && line.startsWith('branch refs/heads/')) cur.branch = line.slice('branch refs/heads/'.length)
136 else if (cur && line === 'bare') cur.isBare = true
137 }
138 return trees
139}
140
141/** Branch names that have a stash: lines `stash@{0}: WIP on <branch>: ...` / `On <branch>: ...` (or bare `%gs`). */
142export function parseStashBranches(out: string): Set<string> {
143 const names = new Set<string>()
144 for (const line of lines(out)) {
145 const m = /^(?:stash@\{\d+\}: )?(?:WIP on|On) (.+?): /.exec(line)
146 if (m?.[1]) names.add(m[1])
147 }
148 return names
149}
150
151export type MergedPr = { number: number; baseRefName: string; headRefOid: string }
152
153/** `gh pr list --state merged --json number,mergedAt,baseRefName,headRefOid`; anything unexpected is no PR. */
154export function parseMergedPr(out: string): MergedPr | undefined {
155 try {
156 const rows: unknown = JSON.parse(out)
157 const row = Array.isArray(rows) ? (rows[0] as Record<string, unknown> | undefined) : undefined
158 if (!row || typeof row.number !== 'number' || !row.mergedAt) return undefined
159 return { number: row.number, baseRefName: String(row.baseRefName ?? ''), headRefOid: String(row.headRefOid ?? '') }
160 } catch {
161 return undefined
162 }
163}
164hooks/format.ts 87 lines1// Every sentence the person reads: the confirmation dialog, toasts, and the branch-janitor report.
2// Calm and short: a one-line headline, then a few plain lines. Quiet symbols (✓ ✗ · →), no banners, and
3// nothing relies on colour.
4import type { Verdict } from './decide'
5import { restoreCommand } from './remove'
6import { prSection } from './prs'
7import type { ScanResult } from './scan'
8
9export const UNDO_OFF = 'Turn it on again: /branch-janitor on'
10export const LOCAL_ONLY = 'Local branches only. Remote copies are asked separately.'
11
12const branches = (n: number) => `${n} branch${n === 1 ? '' : 'es'}`
13const finished = (n: number) => `${n} finished branch${n === 1 ? '' : 'es'}`
14const upper = (s: string) => s.charAt(0).toUpperCase() + s.slice(1)
15
16/** One branch: what proves it is done, what keeps it safe, how to get it back. Only an offered branch gets here. */
17function branchBlock(v: Verdict): string {
18 const proof = v.evidence.find(e => e.kind !== 'gone')
19 const failed = v.checks.filter(c => !c.ok)
20 return [
21 v.name,
22 ...(proof ? [`✓ ${upper(proof.text)}`] : []),
23 ...failed.map(c => `✗ ${upper(c.detail)}`),
24 ...(failed.length === 0 ? ['✓ Nothing unpushed · no stash'] : []),
25 `→ Restore: ${restoreCommand(v.name, v.sha)}`,
26 ...(v.deleteFlag === '-D' ? ["· Uses git branch -D, because git can't see squash merges"] : []),
27 ].join('\n')
28}
29
30export function dialogText(offers: Verdict[], hiddenCount: number): string {
31 const more = hiddenCount > 0 ? [`${hiddenCount} more next time.`] : []
32 return [`Delete ${finished(offers.length)}?`, '', offers.map(branchBlock).join('\n\n'), '', ...more, LOCAL_ONLY].join('\n')
33}
34
35export function singleDialogText(v: Verdict): string {
36 return [`Delete ${v.name}?`, '', branchBlock(v).split('\n').slice(1).join('\n')].join('\n')
37}
38
39export function remoteDialogText(remote: string, branch: string, tip: string): string {
40 return [`Also delete ${remote}/${branch}?`, `✓ Server copy (${tip.slice(0, 12)}) holds nothing that is not merged or just deleted.`].join('\n')
41}
42
43export function neverAskText(): string {
44 return ['Branches · off for this repository', UNDO_OFF].join('\n')
45}
46
47export function toastText(deleted: { v: Verdict; flag: string }[], failed: { name: string; why: string }[], remoteDone: string[]): string {
48 const out: string[] = []
49 if (deleted.length > 0) {
50 out.push(`Deleted ${branches(deleted.length)} · restore with:`)
51 for (const d of deleted) out.push(restoreCommand(d.v.name, d.v.sha))
52 }
53 for (const r of remoteDone) out.push(`✓ Deleted ${r} on the server`)
54 for (const f of failed) out.push(`✗ ${f.name}: ${f.why}`)
55 return out.join('\n')
56}
57
58// ---------------------------------------------------------------- the report
59
60const symbol: Record<Verdict['status'], string> = { offer: '✓', keep: '·', maybe: '·', excluded: '·' }
61const MAX_SKIPPED = 8
62
63export function reportText(r: ScanResult, o: { isOff: boolean; staleDays: number }): string {
64 const shown = r.verdicts.filter(v => v.status !== 'excluded')
65 const offers = shown.filter(v => v.status === 'offer').length
66 const keeps = shown.length - offers
67 const parts = [offers > 0 ? `${offers} ready to delete` : 'nothing to delete', ...(keeps > 0 ? [`${keeps} to keep`] : [])]
68 const out: string[] = [`Branches · ${parts.join(', ')}`]
69 if (o.isOff) out.push(`Off for this repository. ${UNDO_OFF}`)
70 for (const n of r.notes) out.push(n)
71 for (const v of shown) out.push(`${symbol[v.status]} ${v.name} · ${v.reason}`)
72 out.push(...prSection(r.openPrs ?? [], r.verdicts.map(v => v.name)))
73 const skipped = r.verdicts.filter(v => v.status === 'excluded')
74 if (skipped.length > 0) {
75 const list = skipped.slice(0, MAX_SKIPPED).map(v => `${v.name} (${v.reason})`)
76 const rest = skipped.length - list.length
77 out.push(`Skipped: ${list.join(', ')}${rest > 0 ? ` and ${rest} more` : ''}`)
78 }
79 if (r.capped > 0) out.push(`Checked the ${shown.length} oldest; ${r.capped} more not checked.`)
80 if (r.stale.length > 0) {
81 out.push(`Quiet for over ${o.staleDays} days, never offered:`)
82 for (const s of r.stale) out.push(`· ${s.name} · last commit ${s.days} days ago`)
83 }
84 if (r.defaultName) out.push(`Compared with ${r.bases.map(b => b.replace(/^refs\/(heads|remotes)\//, '')).join(', ') || r.defaultName}`)
85 return out.join('\n')
86}
87hooks/remove.ts 72 lines1// The only code that changes anything, and it runs only after a confirmation click.
2// Local: re-verify the branch has not moved, then `git branch -d` / `-D`. Remote: a separate step,
3// only when the remote-tracking tip is exactly what we deleted (or already inside the default branch).
4import type { Verdict } from './decide'
5import { gitIn, isSafeName, lines } from './git'
6import type { Run } from './git'
7
8export type LocalResult = { ok: true; flag: '-d' | '-D'; note?: string } | { ok: false; why: string }
9
10export const restoreCommand = (name: string, sha: string) => `git branch ${name} ${sha}`
11
12export async function deleteLocal(run: Run, root: string, v: Verdict): Promise<LocalResult> {
13 const git = gitIn(run, root)
14 if (!isSafeName(v.name)) return { ok: false, why: 'unusual name, left alone' }
15 // Re-check just before deleting: the branch may have moved or been checked out since the scan.
16 const tip = lines((await git('rev-parse', '--verify', '--quiet', `refs/heads/${v.name}`)).stdout)[0]
17 if (tip === undefined) return { ok: false, why: 'already gone' }
18 if (tip !== v.sha) return { ok: false, why: 'new commits since the check, left alone' }
19 const head = lines((await git('symbolic-ref', '--quiet', '--short', 'HEAD')).stdout)[0]
20 if (head === v.name) return { ok: false, why: 'checked out now, left alone' }
21
22 const first = await git('branch', v.deleteFlag, '--', v.name)
23 if (first.exitCode === 0) return { ok: true, flag: v.deleteFlag }
24 // `-d` judges "merged" against HEAD (or the upstream), not against the default branch we verified.
25 // We proved the tip is inside the default branch, so the stronger delete is still safe, and the tip is recorded.
26 const isMergeRefusal = /not fully merged/i.test(first.stderr)
27 if (v.deleteFlag === '-d' && isMergeRefusal) {
28 const retry = await git('branch', '-D', '--', v.name)
29 if (retry.exitCode === 0) return { ok: true, flag: '-D', note: 'git checks "merged" against the branch you are on, not main; we had verified it against main' }
30 return { ok: false, why: firstLine(retry.stderr) }
31 }
32 return { ok: false, why: firstLine(first.stderr) }
33}
34
35const firstLine = (s: string) => lines(s)[0] ?? 'git refused'
36
37export type RemoteOffer = { remote: string; branch: string; tip: string }
38
39/**
40 * The remote copy to offer deleting, or undefined. Uses only local refs (no network): the remote-tracking
41 * ref must exist, the upstream must not be `[gone]`, and its tip must equal the sha we deleted or already
42 * be an ancestor of a base (the default branch). Never the server's default or a protected branch, whatever a local
43 * branch tracks: a branch whose upstream is `origin/main` must not lead to deleting `origin/main`.
44 */
45export async function remoteOffer(run: Run, root: string, v: Verdict, bases: string[], isProtected: (branch: string) => boolean = () => false): Promise<RemoteOffer | undefined> {
46 const git = gitIn(run, root)
47 if (v.ref.isGone) return undefined
48 const { remote, branch } = remoteTarget(v)
49 if (remote === '' || !isSafeName(remote) || !isSafeName(branch) || isProtected(branch)) return undefined
50 const tip = lines((await git('rev-parse', '--verify', '--quiet', `refs/remotes/${remote}/${branch}^{commit}`)).stdout)[0]
51 if (!tip) return undefined
52 if (tip === v.sha) return { remote, branch, tip }
53 for (const base of bases) {
54 if ((await git('merge-base', '--is-ancestor', tip, base)).exitCode === 0) return { remote, branch, tip }
55 }
56 return undefined
57}
58
59/** Where the branch's remote copy lives: its upstream if it has one, else `origin/<same name>`. */
60function remoteTarget(v: Verdict): { remote: string; branch: string } {
61 const m = /^refs\/remotes\/([^/]+)\/(.+)$/.exec(v.ref.upstream)
62 if (m) return { remote: m[1]!, branch: m[2]! }
63 return v.ref.upstream === '' ? { remote: 'origin', branch: v.name } : { remote: '', branch: '' }
64}
65
66/** `git push <remote> --delete <branch>`, guarded by a lease on the tip we checked so a branch that moved is refused. */
67export async function deleteRemote(run: Run, root: string, offer: RemoteOffer): Promise<{ ok: true } | { ok: false; why: string }> {
68 const lease = `--force-with-lease=refs/heads/${offer.branch}:${offer.tip}`
69 const r = await gitIn(run, root)('push', offer.remote, lease, '--delete', offer.branch)
70 return r.exitCode === 0 ? { ok: true } : { ok: false, why: firstLine(r.stderr) }
71}
72hooks/prs.ts 87 lines1// Open pull requests, read-only: ONE `gh pr list` (argv array, 8 s timeout) answers for every local branch.
2// Used for the /branch-janitor report and to keep a branch with an open PR out of the "finished" offers.
3// Fails soft everywhere: no gh, not signed in, no network, odd output -> undefined, and the caller says nothing.
4import type { Verdict } from './decide'
5import type { Run } from './git'
6
7const GH_TIMEOUT_MS = 8_000
8const MAX_LINES = 8
9
10export type Checks = 'passing' | 'failing' | 'pending' | 'none'
11export type Review = 'approved' | 'changes requested' | 'none'
12export type OpenPr = { number: number; branch: string; checks: Checks; review: Review }
13
14/** The documented `gh pr list --json` fields used here (all exist in gh's JSON field list). */
15export const PR_FIELDS = 'number,headRefName,statusCheckRollup,reviewDecision,state'
16
17/**
18 * statusCheckRollup is a list of two kinds of entry: a CheckRun (status + conclusion) or a StatusContext (state).
19 * Any failure wins, then anything unfinished or unknown; an empty list means no checks are configured.
20 */
21export function checksOf(rollup: unknown): Checks {
22 if (!Array.isArray(rollup) || rollup.length === 0) return 'none'
23 let pending = false
24 for (const item of rollup as Record<string, unknown>[]) {
25 const word = String(item?.conclusion || item?.state || item?.status || '').toUpperCase()
26 if (['FAILURE', 'ERROR', 'TIMED_OUT', 'CANCELLED', 'STARTUP_FAILURE', 'ACTION_REQUIRED'].includes(word)) return 'failing'
27 if (!['SUCCESS', 'NEUTRAL', 'SKIPPED'].includes(word)) pending = true // QUEUED, IN_PROGRESS, PENDING, EXPECTED, unknown
28 }
29 return pending ? 'pending' : 'passing'
30}
31
32export function reviewOf(decision: unknown): Review {
33 return decision === 'APPROVED' ? 'approved' : decision === 'CHANGES_REQUESTED' ? 'changes requested' : 'none'
34}
35
36/** `gh pr list --json ...` output into open PRs by branch name (the highest number wins); anything unexpected is none. */
37export function parseOpenPrs(out: string): OpenPr[] {
38 let rows: unknown
39 try {
40 rows = JSON.parse(out)
41 } catch {
42 return []
43 }
44 if (!Array.isArray(rows)) return []
45 const byBranch = new Map<string, OpenPr>()
46 for (const row of rows as Record<string, unknown>[]) {
47 if (typeof row?.number !== 'number' || typeof row.headRefName !== 'string' || row.headRefName === '') continue
48 if (row.state !== undefined && row.state !== 'OPEN') continue
49 const known = byBranch.get(row.headRefName)
50 if (known && known.number > row.number) continue
51 byBranch.set(row.headRefName, { number: row.number, branch: row.headRefName, checks: checksOf(row.statusCheckRollup), review: reviewOf(row.reviewDecision) })
52 }
53 return [...byBranch.values()]
54}
55
56/** Open PRs of the repository at `root`, or undefined when gh could not answer (never throws). */
57export async function fetchOpenPrs(run: Run, root: string): Promise<OpenPr[] | undefined> {
58 try {
59 const r = await run(['gh', 'pr', 'list', '--state', 'open', '--limit', '100', '--json', PR_FIELDS], { cwd: root, timeoutMs: GH_TIMEOUT_MS })
60 return r.exitCode === 0 ? parseOpenPrs(r.stdout) : undefined
61 } catch {
62 return undefined
63 }
64}
65
66/** '#42 open · checks passing · review approved' */
67export const prLine = (p: OpenPr): string => `#${p.number} open · checks ${p.checks} · review ${p.review}`
68
69/** A branch with an open PR is not finished: an offer becomes a keep that says why. Other verdicts stay as they are. */
70export function protectOpen(verdicts: Verdict[], prs: OpenPr[]): Verdict[] {
71 const open = new Map(prs.map(p => [p.branch, p]))
72 return verdicts.map(v => {
73 const pr = open.get(v.name)
74 return pr && v.status === 'offer' ? { ...v, status: 'keep', reason: `#${pr.number} still open` } : v
75 })
76}
77
78/** The report section: one line per local branch that has an open PR (only branches that exist locally). */
79export function prSection(prs: OpenPr[], localBranches: string[]): string[] {
80 const local = new Set(localBranches)
81 const mine = prs.filter(p => local.has(p.branch)).sort((a, b) => a.number - b.number)
82 if (mine.length === 0) return []
83 const shown = mine.slice(0, MAX_LINES).map(p => `${prLine(p)} · ${p.branch}`)
84 const rest = mine.length - shown.length
85 return ['Open pull requests', ...shown, ...(rest > 0 ? [`and ${rest} more`] : [])]
86}
87types/index.d.ts 19 lines1/** Whether `gh` answered `--version` this session; null = not probed yet. */
2export type BranchJanitorGh = boolean | null
3
4declare module 'claude-code' {
5 interface PluginState {
6 'branch-janitor': {
7 /** When the open cleanup question was asked (ms since epoch); 0 or absent = no question open. */
8 askingSince: number
9 hasGh: BranchJanitorGh
10 /** Session memory: what was last looked at per directory, and which branch tips `gh` was asked about. */
11 memory: Record<string, unknown>
12 /** The "watching branches" line was already shown this session. */
13 watching: boolean
14 /** Off for this repository (git config toolkit.branchJanitor false); null outside a repository. */
15 isOff: boolean | null
16 }
17 }
18}
19