SLOPSHOPPER

git-cleanup

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…

newguardpromptprocessnetworktimer
v1.0.1MITupdated 2026-10-07bahaospanov/skills/git-cleanup
A shopper browsing a rack in a slop shop
README

bahaospanov

Bakhtiyar Ospanov's agent skills, and Claude Code mods: plugins built on function hooks.

Skills

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.

User-invoked

Reachable only when you type them (Claude Code: disable-model-invocation: true; Codex: policy.allow_implicit_invocation: false in agents/openai.yaml).

  • deploy-to-prod: Integration branch to production: squash iterative commits, cut waves around migrations and one-time steps, ship wave by wave with a runbook issue.

Model-invoked

Model- or user-reachable.

Mods

Early access; the API changes between releases.

One mod per purpose.

ModPurpose
git-gatesGit work is authorized and tidy
git-cleanupMerged work is cleaned up once it shipped
lean-docsDocs worth keeping
lean-commentsComments worth keeping
lean-scriptsScripts 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.

git-gates

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.

CheckRuns onNeedsThen
consentgit commit, push; PR/MR mergecommit, 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 namedCall denied
grantsLater commits in the sessionA message asking for a commit per task, or the grant tool after an authorizing messageCommits spend the grant; pushes never
messagesA git commitConventional 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 subtleCommit denied
descriptionsSetting an MR/PR descriptionFixed-label blocks at column 0Call denied
landed branchA git pushThe branch's pushed head already sits in a protected branch, and the message names no new MRPush denied
every commit worksA git push of 2 to 15 commits no remote hasSonnet: no commit removes something a later one stops using, or uses something a later one adds; skipped when the message says the order is finePush 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.

git-cleanup

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.

CheckRuns onNeedsThen
merged firstRemoving a worktree or branch, local or on originThe 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 firstRemoving 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 waitCall denied while it runs or after it failed
stale workThe end of a turnA branch the session committed to or pushed that sits in an integration branch, its worktree clean, no pipeline holding it still running or failedFollow-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.

lean-docs

CheckRuns onFlagsThen
docs-reviewA doc grown in a git checkoutHaiku: text nobody reads after the task (runbooks, setup pages, narration)Claude gets the reason
docs-no-repeat-codeA doc line being writtenIdentifiers that already appear together in one code fileWrite denied
limit-docsThe end of a turnNew or grown docs, prose outweighing code, doc lines repeating codeFollow-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.

lean-comments

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.

CheckRuns onFlagsThen
limit-editsA Write or EditMore than 3 added comment lines, or a comment-heavy region around the editClaude gets the guidance
limit-turnsThe end of a turnMore than 3 new comment lines per file in the turn's diffFollow-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.

lean-scripts

CheckRuns onFlagsThen
scripts-reviewA script written or grown in a git checkoutHaiku: scripts you could just type again when neededClaude gets the reason

Install mods

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

Develop

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.

Check

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

types/ is written by /plugin-types types, run inside a session started as above. Regenerate, never edit, when:

  • Claude Code updates (head -1 types/claude-code.d.ts vs claude --version)
  • a plugin that adds to $ is enabled or disabled
  • an MCP server is connected or disconnected

Commit the result; git diff types/ shows what the update changed.

Source 6 files
hooks/register.ts 289 lines
1import 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}
289
hooks/merged-first.ts 24 lines
1const 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.`
24
hooks/pipeline-first.ts 159 lines
1// 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}
159
hooks/shared/git-commands.ts 139 lines
1export 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}
139
hooks/shared/issue-refs.ts 57 lines
1const 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}
57
hooks/stale-work.ts 46 lines
1// 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