SLOPSHOPPER

branch-janitor

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…

newguardcommandtoastpromptprocess
★ 1v0.4.5MITupdated 2026-10-15samuelbfernandes/moddeck/mods/branch-janitor
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · branch-janitor
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /branch-janitor ⎿ branch-janitor: Branches · not in a git repository ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

branch-janitor

Install (Claude Code 2.1.287 or later): /plugin install branch-janitor --marketplace samuelbfernandes/moddeck

What it does

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.

Use

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.

Settings and limits

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.

Source 12 files
hooks/register.tsx 183 lines
1// 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}
183
hooks/config.ts 21 lines
1import 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}
21
hooks/janitor.ts 324 lines
1// 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
324
hooks/trigger.ts 109 lines
1// 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}
109
hooks/scan.ts 222 lines
1// 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}
222
hooks/decide.ts 116 lines
1// 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}
116
hooks/fingerprint.ts 32 lines
1// 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}
32
hooks/git.ts 164 lines
1// 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}
164
hooks/format.ts 87 lines
1// 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}
87
hooks/remove.ts 72 lines
1// 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}
72
hooks/prs.ts 87 lines
1// 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}
87
types/index.d.ts 19 lines
1/** 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