Cleans up after merged work, once it shipped: removing a branch needs git, the forge or the user to say it merged; removing it or filling in its issue waits…

Bakhtiyar Ospanov's agent skills, and Claude Code mods: plugins built on function hooks.
For any agent the skills CLI supports:
npx skills add bahaospanov/skills --skill <skill>
Or in Claude Code, all of them as one plugin, invoked as /bahaospanov-skills:<skill>:
/plugin marketplace add bahaospanov/skills
/plugin install bahaospanov-skills@bahaospanov
Pick one: installing both leaves every skill twice.
Reachable only when you type them (Claude Code: disable-model-invocation: true; Codex: policy.allow_implicit_invocation: false in agents/openai.yaml).
Model- or user-reachable.
prototype (MIT), its UI branch reworked: whole flows, options grouped by stage in a one-click panel.Early access; the API changes between releases.
One mod per purpose.
| Mod | Purpose |
|---|---|
| git-gates | Git work is authorized and tidy |
| git-cleanup | Merged work is cleaned up once it shipped |
| lean-docs | Docs worth keeping |
| lean-comments | Comments worth keeping |
| lean-scripts | Scripts worth keeping |
Haiku reviews are gated in code first, so a call that cannot fail the review costs no model call. End-of-turn checks read the git diff of repos the turn touched, Bash edits included, and send at most two follow-up prompts a session.
Pushing is a deploy, so the agent needs the user's word in the current turn: the prompt that opened it or one typed while it ran. If a consent check itself fails, the call is blocked.
| Check | Runs on | Needs | Then |
|---|---|---|---|
| consent | git commit, push; PR/MR merge | commit, push, ship, deploy, pr, mr, tag or release typed in the current turn (a message typed while it runs or a background task's report does not withdraw it); merge needs "merge"; a protected branch must be named | Call denied |
| grants | Later commits in the session | A message asking for a commit per task, or the grant tool after an authorizing message | Commits spend the grant; pushes never |
| messages | A git commit | Conventional Commits subject, no reviewer pre-answers, a last line with the issue or ticket (#87, #BLK-23) when your messages or the branch name one; then Haiku: a body only when the cause is subtle | Commit denied |
| descriptions | Setting an MR/PR description | Fixed-label blocks at column 0 | Call denied |
| landed branch | A git push | The branch's pushed head already sits in a protected branch, and the message names no new MR | Push denied |
| every commit works | A git push of 2 to 15 commits no remote has | Sonnet: no commit removes something a later one stops using, or uses something a later one adds; skipped when the message says the order is fine | Push denied |
Protected branches come from a repo's own push policy file. Integration branches are the protected ones; with no policy, the remote's default branch and any of dev, develop, main, master that exist. Deleting a branch on origin needs no keyword when origin's head of it already sits in an integration branch. A bare #87 counts only in a repo with a remote; with no issue tracker, nothing is asked. Issues you typed bind only commits in the session's repo and its worktrees; a branch ending in its issue number (perf/mobile-lcp-89) lets the message end with that one instead.
Merged is not shipped: a branch is cleaned up and its issue filled in only once the pipeline holding the merge has passed. Closing the issue is left to you.
| Check | Runs on | Needs | Then |
|---|---|---|---|
| merged first | Removing a worktree or branch, local or on origin | The branch sits in an integration branch, a merged PR/MR has it as source branch, or the current turn's message says it merged (or to abandon it) | Call denied |
| pipeline first | Removing a worktree or branch, local or on origin; rewriting an issue's body (checklist ticks, How to test) | The work (the branch, or the newest integration commit naming the issue) landed and a pipeline holding it passed; skipped when the message says not to wait | Call denied while it runs or after it failed |
| stale work | The end of a turn | A branch the session committed to or pushed that sits in an integration branch, its worktree clean, no pipeline holding it still running or failed | Follow-up prompt to remove the worktree and the branch, local and on origin, once checked |
Integration branches are found as git-gates finds them. A branch sits in an integration branch when its head does, or when every commit of it has a copy there (a rebase merge). Pipelines are read with gh on GitHub and, on GitLab, with the gitlab_token option: a read_api token, asked when the plugin is enabled, kept in the keychain on macOS and in ~/.claude/.credentials.json elsewhere. With no token or no pipeline holding the work, the pipeline check holds nothing back and a log line says why; merged first still applies, and on GitLab sees a squash merge only with the token.
| Check | Runs on | Flags | Then |
|---|---|---|---|
| docs-review | A doc grown in a git checkout | Haiku: text nobody reads after the task (runbooks, setup pages, narration) | Claude gets the reason |
| docs-no-repeat-code | A doc line being written | Identifiers that already appear together in one code file | Write denied |
| limit-docs | The end of a turn | New or grown docs, prose outweighing code, doc lines repeating code | Follow-up prompt |
No check reads a skill's folder, the one holding SKILL.md, or anything under it: a skill is read again on every use.
No comments by default: keep the ones that record a measured number, a trap or an invariant, cut the ones that restate the code or narrate the change.
| Check | Runs on | Flags | Then |
|---|---|---|---|
| limit-edits | A Write or Edit | More than 3 added comment lines, or a comment-heavy region around the edit | Claude gets the guidance |
| limit-turns | The end of a turn | More than 3 new comment lines per file in the turn's diff | Follow-up prompt |
No check reads a file installed under ~/.agents/skills, ~/.claude/skills or ~/.claude/plugins: it is someone else's code. A link from there into a checkout is followed, and the file is checked.
| Check | Runs on | Flags | Then |
|---|---|---|---|
| scripts-review | A script written or grown in a git checkout | Haiku: scripts you could just type again when needed | Claude gets the reason |
Mods load only with function hooks enabled, so export this in your shell profile first:
export CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1
Without it Claude Code skips the mods silently. Then, in Claude Code:
/plugin marketplace add bahaospanov/skills
/plugin install <mod>@bahaospanov
One folder per mod. tsconfig.json and types/ are shared. An installed mod carries only its own folder, so code two mods share is copied into each one's hooks/shared/.
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude --plugin-dir ./<mod> --debug
Saving a file under <mod>/hooks/ reloads the mod. Repeat --plugin-dir to load several.
npm run typecheck # tsc over every mod and its tests
npm run check:shared # hooks/shared/ copies are identical across mods
npm run check:version # every plugin.json carries package.json's version
claude plugin validate ./<mod> # what the engine sees the module hook and call
claude plugin test ./<mod> # the mod's tests/
types/ is written by /plugin-types types, run inside a session started as above. Regenerate, never edit, when:
head -1 types/claude-code.d.ts vs claude --version)$ is enabled or disabledCommit the result; git diff types/ shows what the update changed.
hooks/register.ts 289 lines1import type { EngineInterface, Register } from 'claude-code'
2import { listsAny, saysMerged, unmergedRefused } from './merged-first'
3import {
4 acknowledgesPipeline,
5 cleanupOf,
6 coverageOf,
7 githubPipelines,
8 gitlabPipelines,
9 issuesEditedBy,
10 pipelineRefused,
11 remoteOf,
12 type Coverage,
13 type Pipeline,
14} from './pipeline-first'
15import {
16 branchesOf,
17 commandDir,
18 currentTurn,
19 defaultBranchOf,
20 deletedBranches,
21 FALLBACK_BASES,
22 pushTargets,
23 verbOf,
24 type Verb,
25} from './shared/git-commands'
26import { refsInText } from './shared/issue-refs'
27import { staleReport, worktreesOf, type Stale } from './stale-work'
28
29const HUMAN_ORIGINS: readonly string[] = ['composer', 'bridge', 'sdk']
30const LOOKBACK = 30
31const MAX_STALE_FOLLOW_UPS = 2
32const FETCH_TIMEOUT_MS = 20_000
33const PIPELINE_PAGE = 20
34const ISSUE_LOOKBACK = 200
35
36type Prompt = { text: string; turnId?: string | undefined }
37type Held =
38 | { kind: 'pipeline'; what: string; base: string; coverage: Exclude<Coverage, { kind: 'green' | 'unknown' }> }
39 | { kind: 'unmerged'; branch: string; bases: string[] }
40
41let typed: Prompt[] = []
42let gitlabToken: string | undefined
43// Branches this session committed to or pushed, by repo: the only work the stale check tidies.
44const worked = new Map<string, { dir: string | undefined; branches: Set<string> }>()
45const reportedStale = new Set<string>()
46let staleFollowUps = 0
47
48const firstLine = (text: string) => text.split('\n')[0] ?? ''
49
50const git = async ($: EngineInterface, args: string[], cwd?: string) => {
51 const run = await $.process.run(['git', ...args], cwd === undefined ? undefined : { cwd })
52 return run.exitCode === 0 ? run.stdout.trim() : undefined
53}
54
55// The loader only admits literal $.env.get names; HOME is all a `cd ~/…` needs.
56const repoOf = async ($: EngineInterface, command: string) => {
57 const dir = commandDir(command)
58 return dir?.startsWith('~') ? `${await $.env.get('HOME')}${dir.slice(1)}` : dir
59}
60
61const currentBranch = async ($: EngineInterface, cwd?: string) => {
62 const branch = await git($, ['rev-parse', '--abbrev-ref', 'HEAD'], cwd)
63 return branch && branch !== 'HEAD' ? branch : undefined
64}
65
66// `git()` swallows a non-zero exit, and that exit is the answer here.
67const isAncestor = async ($: EngineInterface, commit: string, of: string, cwd?: string) => {
68 const run = await $.process.run(['git', 'merge-base', '--is-ancestor', commit, of], cwd === undefined ? undefined : { cwd })
69 return run.exitCode === 0
70}
71
72// A rebase merge lands copies, never the branch's own commits; `git cherry` matches them by patch.
73const holds = async ($: EngineInterface, upstream: string, head: string, cwd?: string) => {
74 if (await isAncestor($, head, upstream, cwd)) return true
75 const cherry = await git($, ['cherry', upstream, head], cwd)
76 return cherry !== undefined && !cherry.split('\n').some((line) => line.startsWith('+'))
77}
78
79const integrationBases = async ($: EngineInterface, cwd?: string) => {
80 const top = await git($, ['rev-parse', '--show-toplevel'], cwd)
81 const policy = top ? await $.fs.read(`${top}/.claude/push-policy.json`).catch(() => undefined) : undefined
82 const protectedBranches = policy === undefined ? [] : branchesOf(policy)
83 if (protectedBranches.length > 0) return protectedBranches
84 const head = defaultBranchOf(await git($, ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], cwd))
85 const bases: string[] = []
86 for (const base of new Set([...(head ? [head] : []), ...FALLBACK_BASES])) {
87 if ((await git($, ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${base}`], cwd)) !== undefined) bases.push(base)
88 }
89 return bases
90}
91
92const landedBase = async ($: EngineInterface, commit: string, bases: string[], cwd?: string) => {
93 for (const base of bases) if (await holds($, `refs/remotes/origin/${base}`, commit, cwd)) return base
94 return undefined
95}
96
97const pipelinesOf = async ($: EngineInterface, base: string, cwd?: string): Promise<Pipeline[] | string> => {
98 const url = await git($, ['remote', 'get-url', 'origin'], cwd)
99 const remote = url === undefined ? undefined : remoteOf(url)
100 if (remote === undefined) return 'origin is no forge this check reads'
101 if (remote.host === 'github.com') {
102 const argv = ['gh', 'run', 'list', '--branch', base, '--limit', String(PIPELINE_PAGE), '--json', 'databaseId,headSha,status,conclusion,url']
103 const run = await $.process.run(argv, cwd === undefined ? undefined : { cwd }).catch(() => undefined)
104 if (run?.exitCode !== 0) return `gh run list failed: ${firstLine(run?.stderr ?? 'gh not found')}`
105 return githubPipelines(run.stdout) ?? 'gh run list answered no list'
106 }
107 if (gitlabToken === undefined) return 'no gitlab_token in git-cleanup options'
108 const api = `https://${remote.host}/api/v4/projects/${encodeURIComponent(remote.path)}/pipelines`
109 const response = await $.http.fetch(`${api}?ref=${encodeURIComponent(base)}&per_page=${PIPELINE_PAGE}`, {
110 headers: { 'PRIVATE-TOKEN': gitlabToken },
111 })
112 if (!response.ok) return `GitLab answered ${response.status}`
113 return gitlabPipelines(response.text) ?? 'GitLab answered no list'
114}
115
116// Pipelines run on a linear base, so the ones holding the work are the newest few: stop at the first that does not.
117const coverage = async ($: EngineInterface, head: string, base: string, cwd?: string): Promise<Coverage> => {
118 const pipelines = await pipelinesOf($, base, cwd)
119 if (typeof pipelines === 'string') return { kind: 'unknown', why: pipelines }
120 const marked: (Pipeline & { covers: boolean })[] = []
121 for (const pipeline of pipelines) {
122 const covers = await holds($, pipeline.sha, head, cwd)
123 marked.push({ ...pipeline, covers })
124 if (!covers) break
125 }
126 return coverageOf(marked)
127}
128
129const issueLanding = async ($: EngineInterface, ref: string, bases: string[], cwd?: string) => {
130 for (const base of bases) {
131 const log = await git($, ['log', `refs/remotes/origin/${base}`, `-n${ISSUE_LOOKBACK}`, '--format=%H%x09%s'], cwd)
132 for (const line of log?.split('\n') ?? []) {
133 const [sha, subject] = line.split('\t')
134 if (sha && subject && refsInText(subject, true).includes(ref)) return { head: sha, base, subject }
135 }
136 }
137 return undefined
138}
139
140// Covers squash merges, whose commits `git cherry` cannot match.
141const mergedOnForge = async ($: EngineInterface, branch: string, cwd?: string) => {
142 const url = await git($, ['remote', 'get-url', 'origin'], cwd)
143 const remote = url === undefined ? undefined : remoteOf(url)
144 if (remote === undefined) return false
145 if (remote.host === 'github.com') {
146 const argv = ['gh', 'pr', 'list', '--head', branch, '--state', 'merged', '--limit', '1', '--json', 'number']
147 const run = await $.process.run(argv, cwd === undefined ? undefined : { cwd }).catch(() => undefined)
148 return run?.exitCode === 0 && listsAny(run.stdout)
149 }
150 if (gitlabToken === undefined) return false
151 const api = `https://${remote.host}/api/v4/projects/${encodeURIComponent(remote.path)}/merge_requests`
152 const response = await $.http.fetch(`${api}?source_branch=${encodeURIComponent(branch)}&state=merged&per_page=1`, {
153 headers: { 'PRIVATE-TOKEN': gitlabToken },
154 })
155 return response.ok && listsAny(response.text)
156}
157
158const heldWork = async ($: EngineInterface, command: string, saidMerged: boolean): Promise<Held | undefined> => {
159 const cleanup = cleanupOf(command)
160 const branches = [...(cleanup?.branches ?? []), ...(deletedBranches(command) ?? [])]
161 const issues = issuesEditedBy(command)
162 if (branches.length === 0 && issues.length === 0 && !cleanup?.worktrees.length) return undefined
163 const cwd = await repoOf($, command)
164 if (cleanup?.worktrees.length) {
165 const trees = worktreesOf((await git($, ['worktree', 'list', '--porcelain'], cwd)) ?? '')
166 for (const path of cleanup.worktrees) {
167 const tail = path.replace(/^\.\//, '').replace(/\/$/, '')
168 const tree = trees.find((wt) => wt.path === tail || wt.path.endsWith(`/${tail}`))
169 if (tree?.branch !== undefined) branches.push(tree.branch)
170 }
171 }
172 await $.process.run(['git', 'fetch', '--quiet', 'origin'], { ...(cwd === undefined ? {} : { cwd }), timeoutMs: FETCH_TIMEOUT_MS }).catch(() => undefined)
173 const bases = await integrationBases($, cwd)
174 const work: { what: string; head: string; base: string }[] = []
175 for (const branch of new Set(branches)) {
176 if (bases.includes(branch)) continue
177 const head =
178 (await git($, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`], cwd)) ??
179 (await git($, ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${branch}`], cwd))
180 if (head === undefined) continue
181 const base = await landedBase($, head, bases, cwd)
182 if (base !== undefined) work.push({ what: `'${branch}'`, head, base })
183 else if (!saidMerged && !(await mergedOnForge($, branch, cwd))) return { kind: 'unmerged', branch, bases }
184 }
185 for (const ref of issues) {
186 const landing = await issueLanding($, ref, bases, cwd)
187 if (landing !== undefined) work.push({ what: `the work on ${ref} (${landing.subject})`, head: landing.head, base: landing.base })
188 }
189 for (const item of work) {
190 const shipped = await coverage($, item.head, item.base, cwd)
191 if (shipped.kind === 'unknown') $.ui.log(`git-cleanup (pipeline first): ${item.what}: pipeline unknown (${shipped.why}); not gating`)
192 if (shipped.kind === 'waiting' || shipped.kind === 'failed') return { kind: 'pipeline', what: item.what, base: item.base, coverage: shipped }
193 }
194 return undefined
195}
196
197const recordWork = async ($: EngineInterface, command: string, verb: Verb) => {
198 if (deletedBranches(command) !== undefined) return
199 const dir = await repoOf($, command)
200 const common = await git($, ['rev-parse', '--path-format=absolute', '--git-common-dir'], dir)
201 if (common === undefined) return
202 const current = await currentBranch($, dir)
203 const branches = verb === 'push' ? (pushTargets(command, current) ?? []) : current ? [current] : []
204 const repo = worked.get(common) ?? { dir, branches: new Set<string>() }
205 for (const branch of branches) if (branch !== '*') repo.branches.add(branch)
206 worked.set(common, repo)
207}
208
209const staleWork = async ($: EngineInterface) => {
210 const reports: { main: string; items: Stale[] }[] = []
211 for (const [common, repo] of worked) {
212 const trees = worktreesOf((await git($, ['worktree', 'list', '--porcelain'], repo.dir)) ?? '')
213 const main = trees[0]?.path
214 if (main === undefined) continue
215 await $.process.run(['git', 'fetch', '--quiet', '--prune', 'origin'], { cwd: main, timeoutMs: FETCH_TIMEOUT_MS }).catch(() => undefined)
216 const bases = await integrationBases($, main)
217 const items: Stale[] = []
218 for (const branch of repo.branches) {
219 if (bases.includes(branch) || reportedStale.has(`${common}\0${branch}`)) continue
220 const local = await git($, ['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`], main)
221 const remote = await git($, ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${branch}`], main)
222 const head = local ?? remote
223 if (head === undefined) continue
224 const base = await landedBase($, head, bases, main)
225 if (base === undefined) continue
226 if (remote !== undefined && remote !== head && (await landedBase($, remote, bases, main)) === undefined) continue
227 const tree = trees.slice(1).find((wt) => wt.branch === branch)
228 if (tree !== undefined && (await git($, ['status', '--porcelain'], tree.path)) !== '') continue
229 const shipped = await coverage($, head, base, main)
230 if (shipped.kind === 'waiting' || shipped.kind === 'failed') continue
231 reportedStale.add(`${common}\0${branch}`)
232 items.push({ branch, base, worktree: tree?.path, local: local !== undefined, remote: remote !== undefined })
233 }
234 if (items.length > 0) reports.push({ main, items })
235 }
236 return reports
237}
238
239export const register: Register = (on, options) => {
240 gitlabToken = typeof options.gitlab_token === 'string' && options.gitlab_token !== '' ? options.gitlab_token : undefined
241
242 on('prompt.submit', ($, e, next) => {
243 if (HUMAN_ORIGINS.includes(e.origin.kind)) typed = [...typed, { text: e.text, turnId: e.turnId }].slice(-LOOKBACK)
244 return next(e)
245 })
246
247 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
248 const turn = currentTurn(typed)
249 const held = await heldWork($, e.command, turn.some((p) => saysMerged(p.text)))
250 if (held === undefined) return next(e)
251 if (held.kind === 'unmerged') {
252 $.ui.log(`git-cleanup (merged first): nothing says '${held.branch}' is merged`)
253 return { deny: unmergedRefused(e.command, held.branch, held.bases) }
254 }
255 if (turn.some((p) => acknowledgesPipeline(p.text))) return next(e)
256 $.ui.log(`git-cleanup (pipeline first): ${held.what} waits on its pipeline`)
257 return { deny: pipelineRefused(e.command, held.what, held.base, held.coverage) }
258 }).catch(($, e, next) => {
259 $.ui.log(`git-cleanup: the cleanup check failed (${next.error.message ?? next.error.kind}); not gating`)
260 return undefined
261 })
262
263 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
264 const verb = verbOf(e.command)
265 const result = await next(e)
266 if (verb !== 'commit' && verb !== 'push') return result
267 if ('deny' in result && result.deny !== undefined) return result
268 await recordWork($, e.command, verb).catch(() => undefined)
269 return result
270 })
271
272 on('turn.complete', async ($, e, next) => {
273 const result = await next(e)
274 if (e.agentId !== undefined || e.reason !== 'answer' || worked.size === 0 || staleFollowUps >= MAX_STALE_FOLLOW_UPS) {
275 return result
276 }
277 const reports = await staleWork($).catch(() => [])
278 if (reports.length === 0) return result
279 staleFollowUps++
280 const branches = reports.flatMap((r) => r.items.map((s) => s.branch))
281 $.ui.log(`git-cleanup (stale work): ${branches.join(', ')} merged and clean; a follow-up prompt asks to remove them`)
282 const text = reports.map((r) => staleReport(r.items, r.main)).join('\n\n')
283 $.clock.after(0, () => {
284 $.prompt.submit({ text }).catch(() => undefined)
285 })
286 return result
287 })
288}
289hooks/merged-first.ts 24 lines1const SAYS_MERGED = /(^|[^\p{L}])(merged|смержен[аоы]?|смержил[аи]?|влит[аоы]?|влил[аи]?)(?!\p{L})/iu
2const DENIES_MERGED = /(\bnot|n't|\bnever|\bun)[\s-]*(yet\s+)?merged|не\s+(смерж|влит|влил)/iu
3const LETS_GO = /\b(abandon(ed)?|discard(ed)?|throw (it )?away|delete (it )?(anyway|unmerged))\b|выброс|не нужн/iu
4
5export const saysMerged = (text: string) => (SAYS_MERGED.test(text) && !DENIES_MERGED.test(text)) || LETS_GO.test(text)
6
7export const listsAny = (json: string) => {
8 try {
9 const parsed: unknown = JSON.parse(json)
10 return Array.isArray(parsed) && parsed.length > 0
11 } catch {
12 return false
13 }
14}
15
16export const unmergedRefused = (command: string, branch: string, bases: string[]) =>
17 `git-cleanup (merged first): blocking '${command}' — nothing says '${branch}' is merged.
18
19Its commits are not in ${bases.map((b) => `origin/${b}`).join(', ') || 'any integration branch'}, and no merged
20PR or MR has it as its source branch. Removing it loses that work.
21
22If it is merged where git cannot see it (a squash merge), or the user wants it
23gone anyway, they say so ("it's merged", "abandon it") and this check steps aside.`
24hooks/pipeline-first.ts 159 lines1// Observed 2026-10-05: right after an MR merged, the agent removed its worktree and branch
2// and ticked the issue while the pipeline deploying the merge still ran.
3
4export type PipelineState = 'success' | 'running' | 'failed'
5export type Pipeline = { id: string; sha: string; state: PipelineState; status: string; url?: string | undefined }
6export type Coverage =
7 | { kind: 'green' }
8 | { kind: 'waiting' | 'failed'; pipeline: Pipeline }
9 | { kind: 'unknown'; why: string }
10export type Cleanup = { branches: string[]; worktrees: string[] }
11
12const SEPARATORS = ['&&', '||', ';', '|']
13// Anything neither success nor in flight (manual, canceled, skipped) did not ship.
14const RUNNING = new Set([
15 'created', 'waiting_for_resource', 'preparing', 'pending', 'running', 'scheduled',
16 'queued', 'in_progress', 'requested', 'waiting',
17])
18const GITHUB_PASS = new Set(['success', 'skipped', 'neutral'])
19
20const segmentsOf = (command: string) => {
21 const tokens = command.replace(/\n/g, ' ; ').split(/\s+/).filter(Boolean)
22 const segments: string[][] = [[]]
23 for (const token of tokens) {
24 if (SEPARATORS.includes(token)) segments.push([])
25 else segments.at(-1)?.push(token)
26 }
27 return segments
28}
29
30export const cleanupOf = (command: string): Cleanup | undefined => {
31 const cleanup: Cleanup = { branches: [], worktrees: [] }
32 for (const [git, sub, ...rest] of segmentsOf(command)) {
33 if (git !== 'git') continue
34 if (sub === 'branch' && rest.some((t) => t === '-d' || t === '-D' || t === '--delete')) {
35 if (rest.some((t) => t === '-r' || t === '--remotes')) continue
36 cleanup.branches.push(...rest.filter((t) => !t.startsWith('-')))
37 }
38 if (sub === 'worktree' && rest[0] === 'remove') cleanup.worktrees.push(...rest.slice(1).filter((t) => !t.startsWith('-')))
39 }
40 return cleanup.branches.length + cleanup.worktrees.length > 0 ? cleanup : undefined
41}
42
43const REST_ISSUE = /\/issues\/(\d+)(?=['"\s?]|$)/g
44const REST_BODY = /(?<![\w-])description\s*[=:"']|"description"\s*:|(?<![\w-])body\s*=|"body"\s*:/
45const CLI_ISSUE = /(?:^|[\s&;|(])(gh|glab)\s+issue\s+(edit|update)\s+#?(\d+)\b([^&;|\n]*)/g
46const CLI_BODY = /--body(?:-file)?\b|--description\b|(?<![\w-])-[bd]\b/
47
48// A rewritten body (checklist ticks, How to test) says the work shipped; closing is the user's, a comment says nothing.
49export const issuesEditedBy = (command: string): string[] => {
50 const found = new Set<string>()
51 if (REST_BODY.test(command)) for (const m of command.matchAll(REST_ISSUE)) found.add(`#${m[1]}`)
52 for (const m of command.matchAll(CLI_ISSUE)) if (CLI_BODY.test(m[4] ?? '')) found.add(`#${m[3]}`)
53 return [...found]
54}
55
56export const remoteOf = (url: string): { host: string; path: string } | undefined => {
57 const trimmed = url.trim().replace(/\.git$/, '').replace(/\/$/, '')
58 const scp = trimmed.match(/^[\w.-]+@([\w.-]+):(?!\/)(.+)$/)
59 if (scp) return { host: scp[1] ?? '', path: scp[2] ?? '' }
60 const full = trimmed.match(/^(?:ssh|https?|git):\/\/(?:[^@/]+@)?([\w.-]+)(?::\d+)?\/(.+)$/)
61 return full ? { host: full[1] ?? '', path: full[2] ?? '' } : undefined
62}
63
64const stateOf = (status: string): PipelineState =>
65 status === 'success' ? 'success' : RUNNING.has(status) ? 'running' : 'failed'
66
67const parse = (json: string): unknown => {
68 try {
69 return JSON.parse(json)
70 } catch {
71 return undefined
72 }
73}
74
75const field = (value: unknown, key: string): unknown =>
76 typeof value === 'object' && value !== null && key in value ? (value as Record<string, unknown>)[key] : undefined
77
78export const gitlabPipelines = (json: string): Pipeline[] | undefined => {
79 const list = parse(json)
80 if (!Array.isArray(list)) return undefined
81 return list.flatMap((p) => {
82 const id = field(p, 'id')
83 const sha = field(p, 'sha')
84 const status = field(p, 'status')
85 if (typeof sha !== 'string' || typeof status !== 'string') return []
86 const url = field(p, 'web_url')
87 return [{ id: `#${String(id)}`, sha, state: stateOf(status), status, url: typeof url === 'string' ? url : undefined }]
88 })
89}
90
91// One entry per workflow run, so a sha is green only when every run on it passed.
92export const githubPipelines = (json: string): Pipeline[] | undefined => {
93 const list = parse(json)
94 if (!Array.isArray(list)) return undefined
95 const bySha = new Map<string, Pipeline>()
96 for (const run of list) {
97 const sha = field(run, 'headSha')
98 const status = field(run, 'status')
99 if (typeof sha !== 'string' || typeof status !== 'string') continue
100 const conclusion = String(field(run, 'conclusion') ?? '')
101 const own: PipelineState =
102 status !== 'completed' ? 'running' : GITHUB_PASS.has(conclusion) ? 'success' : 'failed'
103 const seen = bySha.get(sha)
104 if (seen === undefined) {
105 const url = field(run, 'url')
106 bySha.set(sha, {
107 id: `run ${String(field(run, 'databaseId'))}`,
108 sha,
109 state: own,
110 status: status === 'completed' ? conclusion : status,
111 url: typeof url === 'string' ? url : undefined,
112 })
113 } else if (seen.state !== 'failed' && own !== 'success') {
114 bySha.set(sha, { ...seen, state: own, status: status === 'completed' ? conclusion : status })
115 }
116 }
117 return [...bySha.values()]
118}
119
120// Newest first. None holding the work is a commit CI skipped or one whose pipeline is not created yet,
121// which this check cannot tell apart.
122export const coverageOf = (pipelines: (Pipeline & { covers: boolean })[]): Coverage => {
123 const covering = pipelines.filter((p) => p.covers)
124 if (covering.some((p) => p.state === 'success')) return { kind: 'green' }
125 const newest = covering[0]
126 if (newest === undefined) {
127 return { kind: 'unknown', why: pipelines.length === 0 ? 'the branch has no pipelines' : 'no pipeline holds this work' }
128 }
129 const running = covering.find((p) => p.state === 'running')
130 return running !== undefined ? { kind: 'waiting', pipeline: running } : { kind: 'failed', pipeline: newest }
131}
132
133const ACKNOWLEDGES =
134 /\b(skip|ignore|without(\s+waiting\s+for)?|don'?t\s+wait\s+for|no\s+need\s+to\s+wait\s+for)\s+(the\s+)?(ci|pipeline)\b|\b(ci|pipeline)\s+(doesn'?t\s+matter|is\s+irrelevant)\b/i
135
136export const acknowledgesPipeline = (text: string) => ACKNOWLEDGES.test(text)
137
138const named = (p: Pipeline) => `${p.id}${p.url ? ` (${p.url})` : ''}`
139
140export const pipelineRefused = (command: string, what: string, base: string, coverage: Exclude<Coverage, { kind: 'green' | 'unknown' }>) => {
141 const state =
142 coverage.kind === 'waiting'
143 ? `pipeline ${named(coverage.pipeline)} is still ${coverage.pipeline.status}`
144 : `its newest pipeline, ${named(coverage.pipeline)}, ended ${coverage.pipeline.status}`
145 const step =
146 coverage.kind === 'waiting'
147 ? `Wait for it in the background (a loop on its status that exits on success,
148failed or canceled), check the change where it deploys, then retry.`
149 : `Find out why and get a green pipeline first.`
150 return `git-cleanup (pipeline first): blocking '${command}' — ${what} landed in '${base}', and ${state}.
151
152Removing a branch or filling in its issue says the work is done. It is not
153until the pipeline that ships it passes and the change is checked live.
154${step}
155
156If the user wants it done regardless, they say so ("skip the pipeline") and
157this check steps aside.`
158}
159hooks/shared/git-commands.ts 139 lines1export type Verb = 'merge' | 'push' | 'commit'
2
3const MERGE = /(merge_requests\/[0-9]+\/merge|pulls\/[0-9]+\/merge|(^|[\s&;|(])(gh\s+pr|glab\s+mr)\s+merge(\s|$))/m
4const PUSH = /(^|[\s&;|(])git\s+push(\s|$)/m
5const COMMIT = /(^|[\s&;|(])git\s+commit(\s|$)/m
6
7// With no push policy a repo's integration branches are guessed: the remote's default branch, then these.
8export const FALLBACK_BASES: readonly string[] = ['dev', 'develop', 'main', 'master']
9
10// Quotes are stripped first so a message quoting `git push` is not a push.
11export const verbOf = (command: string): Verb | undefined => {
12 if (MERGE.test(command)) return 'merge'
13 const unquoted = command.replace(/'[^']*'/g, '').replace(/"[^"]*"/g, '')
14 if (PUSH.test(unquoted)) return 'push'
15 if (COMMIT.test(unquoted)) return 'commit'
16 return undefined
17}
18
19// A prompt typed over a running turn joins the one that opened that turn, so a quick follow-up cannot withdraw its word.
20export const currentTurn = <P extends { turnId?: string | undefined }>(prompts: P[]): P[] => {
21 const last = prompts.at(-1)
22 if (last === undefined) return []
23 if (last.turnId === undefined) return [last]
24 let start = prompts.length - 1
25 while (start > 0 && prompts[start - 1]?.turnId === last.turnId) start--
26 return prompts.slice(Math.max(0, start - 1))
27}
28
29export const branchesOf = (policy: string): string[] => {
30 try {
31 const parsed: unknown = JSON.parse(policy)
32 const branches = typeof parsed === 'object' && parsed !== null && 'protected_branches' in parsed ? parsed.protected_branches : []
33 return Array.isArray(branches) ? branches.filter((b): b is string => typeof b === 'string' && b !== '') : []
34 } catch {
35 return []
36 }
37}
38
39export const defaultBranchOf = (symref: string | undefined) => symref?.trim().replace(/^refs\/remotes\/origin\//, '') || undefined
40
41// Every branch the command's `git push`es write to; `*` for --all/--mirror, undefined when one cannot be known.
42export const pushTargets = (command: string, current: string | undefined): string[] | undefined => {
43 const tokens = command.replace(/\n/g, ' ; ').split(/\s+/).filter(Boolean)
44 const targets: string[] = []
45 let found = false
46 let i = 0
47 while (i < tokens.length) {
48 if (tokens[i] !== 'git' || tokens[i + 1] !== 'push') {
49 i++
50 continue
51 }
52 found = true
53 i += 2
54 let remoteSeen = false
55 let all = false
56 const refs: string[] = []
57 for (; i < tokens.length; i++) {
58 const token = tokens[i] ?? ''
59 if (['&&', '||', ';', '|'].includes(token)) break
60 if (token === '--all' || token === '--mirror') all = true
61 else if (['--repo', '--push-option', '--receive-pack', '--exec', '-o'].includes(token)) i++
62 else if (token.startsWith('-')) continue
63 else if (!remoteSeen) remoteSeen = true
64 else refs.push(token)
65 }
66 if (all) {
67 targets.push('*')
68 } else if (refs.length === 0) {
69 if (current === undefined) return undefined
70 targets.push(current)
71 } else {
72 for (const ref of refs) {
73 let branch = ref.replace(/^\+/, '')
74 branch = branch.slice(branch.lastIndexOf(':') + 1).replace(/^refs\/heads\//, '')
75 if (branch === 'HEAD') {
76 if (current === undefined) return undefined
77 branch = current
78 }
79 if (branch !== '') targets.push(branch)
80 }
81 }
82 }
83 return found ? targets : undefined
84}
85
86export const deletedBranches = (command: string): string[] | undefined => {
87 const tokens = command.replace(/\n/g, ' ; ').split(/\s+/).filter(Boolean)
88 const deleted: string[] = []
89 let found = false
90 let i = 0
91 while (i < tokens.length) {
92 if (tokens[i] !== 'git' || tokens[i + 1] !== 'push') {
93 i++
94 continue
95 }
96 found = true
97 i += 2
98 let remoteSeen = false
99 let deleting = false
100 const refs: string[] = []
101 for (; i < tokens.length; i++) {
102 const token = tokens[i] ?? ''
103 if (['&&', '||', ';', '|'].includes(token)) break
104 if (token === '--delete' || token === '-d') deleting = true
105 else if (['--repo', '--push-option', '--receive-pack', '--exec', '-o'].includes(token)) i++
106 else if (token.startsWith('-')) continue
107 else if (!remoteSeen) remoteSeen = true
108 else refs.push(token)
109 }
110 if (refs.length === 0) return undefined
111 for (const ref of refs) {
112 if (!deleting && !ref.startsWith(':')) return undefined
113 const branch = ref.replace(/^:/, '').replace(/^refs\/heads\//, '')
114 if (branch === '' || branch.includes(':')) return undefined
115 deleted.push(branch)
116 }
117 }
118 return found && deleted.length > 0 ? deleted : undefined
119}
120
121const DIR = String.raw`("[^"]*"|'[^']*'|[^\s;&|()]+)`
122const CD_STEP = new RegExp(String.raw`(?:^|[;&|\n(])\s*cd\s+${DIR}`, 'g')
123const GIT_DIR = new RegExp(String.raw`^git\s+-C\s+${DIR}`)
124
125const unquote = (value: string) => value.replace(/^(["'])([\s\S]*)\1$/, '$2')
126const joinDir = (base: string | undefined, dir: string) =>
127 base === undefined || dir.startsWith('/') || dir.startsWith('~') ? dir : `${base.replace(/\/$/, '')}/${dir}`
128
129// The directory a command's first `git` runs in, from the `cd` steps before it and its `-C`;
130// undefined when that is the session's own. `~` is left for the caller to expand.
131export const commandDir = (command: string): string | undefined => {
132 const at = command.search(/\bgit\s/)
133 if (at < 0) return undefined
134 let dir: string | undefined
135 for (const step of command.slice(0, at).matchAll(CD_STEP)) dir = joinDir(dir, unquote(step[1] ?? ''))
136 const flag = command.slice(at).match(GIT_DIR)?.[1]
137 return flag === undefined ? dir : joinDir(dir, unquote(flag))
138}
139hooks/shared/issue-refs.ts 57 lines1const NUMBER = /(?<![\w&#/])#([1-9]\d*)\b/g
2const ISSUE_URL = /\/(?:-\/)?(?:issues|pull|merge_requests)\/(\d+)\b/g
3const KEY = /(?<![\w-])#?([A-Z][A-Z0-9]{1,9})-(\d+)(?![\w-])/g
4const BRANCH_KEY = /^([a-z][a-z0-9]{1,9})-(\d+)(?:-|$)/i
5const BRANCH_NUMBER = /^(\d+)-[a-z]/i
6const BRANCH_TAIL = /(?:^|-)[a-z0-9]*[a-z][a-z0-9]*-(\d+)$/i
7const TRAILING = /(?:^|\s)((?:\(?#[A-Za-z0-9]+(?:-\d+)?\)?[.,]?\s*)+)$/
8const ENDED = /#(\d+|[A-Za-z][A-Za-z0-9]*-\d+)/g
9
10// Shaped like tracker keys but naming standards, so "UTF-8" or "SHA-256" in a message is not a ticket.
11const NOT_TRACKERS = new Set([
12 'AES', 'ANSI', 'ASCII', 'BASE', 'CP', 'CRC', 'CVE', 'COVID', 'ECMA', 'ES', 'GMT', 'GPT', 'HTTP', 'IEEE', 'IPV',
13 'ISO', 'KOI', 'MD', 'PEP', 'RFC', 'RSA', 'SHA', 'SSL', 'TCP', 'TLS', 'UDP', 'USB', 'UTC', 'UTF', 'WCAG', 'WIN',
14])
15
16const ticket = (key: string, n: string) => (NOT_TRACKERS.has(key.toUpperCase()) ? undefined : `#${key.toUpperCase()}-${n}`)
17
18const unique = (refs: (string | undefined)[]) => [...new Set(refs.filter((r): r is string => r !== undefined))]
19
20// `tracked` is whether the repo has a remote: without one, "#87" cannot point at an issue.
21export const refsInText = (text: string, tracked: boolean): string[] => {
22 const found: { at: number; ref: string | undefined }[] = [...text.matchAll(KEY)].map((m) => ({
23 at: m.index,
24 ref: ticket(m[1] ?? '', m[2] ?? ''),
25 }))
26 if (tracked) {
27 for (const m of [...text.matchAll(NUMBER), ...text.matchAll(ISSUE_URL)]) found.push({ at: m.index, ref: `#${m[1]}` })
28 }
29 return unique(found.sort((a, b) => a.at - b.at).map((f) => f.ref))
30}
31
32export const refsInBranch = (branch: string, tracked: boolean): string[] =>
33 unique(
34 branch.split('/').map((segment) => {
35 const key = segment.match(BRANCH_KEY)
36 if (key) return ticket(key[1] ?? '', key[2] ?? '')
37 const number = segment.match(BRANCH_NUMBER)?.[1]
38 return tracked && number !== undefined ? `#${number}` : undefined
39 }),
40 )
41
42// The issue number a slug ends with (`perf/mobile-lcp-89`). Accepted as a message's ending, never
43// demanded: in `chore/node-22` it is a version.
44export const tailRefInBranch = (branch: string, tracked: boolean): string[] => {
45 const number = tracked ? branch.split('/').at(-1)?.match(BRANCH_TAIL)?.[1] : undefined
46 return number === undefined ? [] : [`#${number}`]
47}
48
49export const missingRefViolation = (message: string, refs: string[], accepted: string[] = []): string | undefined => {
50 if (refs.length === 0) return undefined
51 const last = message.trim().split(/\r?\n/).filter((line) => line.trim() !== '').at(-1) ?? ''
52 const tail = last.match(TRAILING)?.[1] ?? ''
53 const ended = [...tail.matchAll(ENDED)].map((m) => `#${(m[1] ?? '').toUpperCase()}`)
54 if (ended.some((ref) => refs.includes(ref) || accepted.includes(ref))) return undefined
55 return `end the message with the issue it is about, e.g. a last line "${refs[0]}" (mentioned: ${refs.join(', ')})`
56}
57hooks/stale-work.ts 46 lines1// Observed 2026-10-05: a merged, checked branch kept its worktree, its local branch and its origin branch,
2// and nothing in the session's flow ever came back for them.
3
4export type Worktree = { path: string; branch?: string | undefined }
5
6export type Stale = { branch: string; base: string; worktree?: string | undefined; local: boolean; remote: boolean }
7
8export const worktreesOf = (porcelain: string): Worktree[] =>
9 porcelain
10 .split(/\n\s*\n/)
11 .map((block): Worktree | undefined => {
12 const lines = block.split('\n')
13 const path = lines.find((line) => line.startsWith('worktree '))?.slice('worktree '.length)
14 const branch = lines.find((line) => line.startsWith('branch refs/heads/'))?.slice('branch refs/heads/'.length)
15 return path === undefined ? undefined : { path, branch }
16 })
17 .filter((wt): wt is Worktree => wt !== undefined)
18
19const quote = (path: string) => (/^[\w./~-]+$/.test(path) ? path : `'${path.replace(/'/g, `'\\''`)}'`)
20
21export const staleReport = (items: Stale[], main: string) => {
22 const lines = items.map((s) => {
23 const where = [s.worktree ? `worktree ${s.worktree}` : undefined, s.local ? 'local branch' : undefined, s.remote ? 'on origin' : undefined]
24 return ` - ${s.branch}: in origin/${s.base}; ${where.filter(Boolean).join(', ')}`
25 })
26 const commands = [
27 `cd ${quote(main)}`,
28 ...items.flatMap((s) => [
29 ...(s.worktree ? [`git worktree remove ${quote(s.worktree)}`] : []),
30 ...(s.local ? [`git branch -d ${s.branch}`] : []),
31 ...(s.remote ? [`git push origin --delete ${s.branch}`] : []),
32 ]),
33 ]
34 return `git-cleanup (stale work): this session's work below is merged and its worktree is clean:
35${lines.join('\n')}
36
37If it is finished and checked (verified where it deploys), remove it now,
38from the main checkout:
39${commands.map((c) => ` ${c}`).join('\n')}
40
41\`branch -d\` may call a branch unmerged when the local base lags behind
42origin or the merge rebased it; its commits are in origin, so -D is safe then.
43If a check is still pending, say what is left instead and clean up when it
44passes: this note does not repeat.`
45}
46