Reviews unpushed commits against REVIEW_GUIDELINES.md before Claude runs git push

A mod that reviews your unpushed commits against a short guidelines file before Claude runs git push, and stops the push when it finds a blocking problem. We're sharing it as an example of a mod that gates a tool call on a model's judgment.
When Claude runs git push, the mod:
origin/HEAD when there is no upstream), leaving out lockfiles, build output, snapshots, source maps and minified files.REVIEW_GUIDELINES.md to the model and asks for findings as JSON, each with a rule ID and a severity./push-review runs the same check at any time without pushing.
| Hook | What it does |
|---|---|
session.start | Registers /push-review. |
command.run with {command: "push-review"} | Runs the review and prints the findings. |
tool.call with {tool: "Bash"} | Catches git push, reviews, then denies or passes the call on. A .catch lets the push through with a toast if the hook itself fails. |
Cost and loops are kept down by:
$.store by repository, base and HEAD commit, so pushing again with no new commits doesn't repeat the review.SKIP_REVIEW=1 at the start of the command skips the review.REVIEW_GUIDELINES.md in this folder is a starter for Node.js and React projects moving from JavaScript to TypeScript: security and correctness rules block, maintainability and TypeScript migration rules warn. Copy it to the root of each repository and adjust it there. Without it, the mod uses a short built-in version.
None yet.
haiku and sonnet aliases.claude plugin validate refused helpers defined inside register that take $; they are top-level functions now.tool.call hook without a .catch; one was added so a failure is visible instead of silent.Requirements: Claude Code 2.1.287 or later, git, and a repository with an upstream branch or origin/HEAD.
Steps:
bash claude plugin marketplace add sfrodilla/claude-code-mods claude plugin install pre-push-review@sfrodilla-mods --scope user ` Or clone the repository and try it for one session with claude --plugin-dir ./pre-push-review`.REVIEW_GUIDELINES.md to the root of your repository.const KEY = "sk-live-123" and ask Claude to push. The push should be denied under SEC-1.git push typed in your own terminal is not seen; a git pre-push hook would be needed for that.git push, so aliases and scripts that push are missed.| Name | Version | License (SPDX) | Source |
|---|---|---|---|
| None |
Node.js is a trademark of the OpenJS Foundation; React is a trademark of Meta Platforms, Inc.; TypeScript is a trademark of Microsoft Corporation. Use here is descriptive and implies no endorsement.
Shared as-is. Not an official Anthropic product; no support or maintenance is implied. See the root README and LICENSE.
hooks/register.ts 170 lines1// SPDX-License-Identifier: Apache-2.0
2
3import type { EngineInterface, Register } from 'claude-code'
4
5type Finding = { severity: 'block' | 'warn'; file: string; line?: number; rule: string; why: string }
6type Review = { findings: Finding[]; note?: string }
7
8const PUSH = /(^|[;&|]\s*|\s)git\s+push\b/
9const SKIP = /\bSKIP_REVIEW=1\b/
10const MAX_DIFF_LINES = 3000
11const SMALL_DIFF_LINES = 400
12const MAX_DENIALS = 2
13
14const EXCLUDE = [
15 'package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', '*.min.js', '*.map', '*.snap',
16 'dist/**', 'build/**', 'coverage/**', '**/__generated__/**',
17].map(p => `:(exclude,glob)${p}`)
18
19const FALLBACK_GUIDELINES = `Flag only clear problems: leaked secrets, injection, missing auth on new routes,
20unhandled promises, empty catch blocks, broken React hook rules (block); debug leftovers,
21missing tests, \`any\` or @ts-ignore in TypeScript (warn).`
22
23const SYSTEM = `You review a git diff before it is pushed. Apply only the guidelines given.
24Report a finding only when the diff itself shows the problem and you are confident.
25Use "block" only for the guideline's block rules. No style comments, no praise.
26Reply with JSON only: {"findings":[{"severity":"block"|"warn","file":"...","line":123,"rule":"SEC-1","why":"one sentence"}]}.
27An empty list is a good answer.`
28
29async function git($: EngineInterface, args: string[]) {
30 const r = await $.process.run(['git', ...args], { timeoutMs: 20000 })
31 return r.exitCode === 0 ? r.stdout.trim() : undefined
32}
33
34// Options of `git push` that take a separate value argument.
35const PUSH_VALUE_OPTS = new Set(['-o', '--push-option', '--repo', '--receive-pack', '--exec'])
36
37type PushTarget = { remote?: string; src: string; dst?: string }
38
39// Works out what a `git push` command pushes: `git push [opts] [remote] [src[:dst]]`.
40// Without a refspec it pushes HEAD.
41function parsePush(command: string): PushTarget {
42 const m = PUSH.exec(command)
43 if (!m) return { src: 'HEAD' }
44 const rest = command.slice(m.index + m[0].length).split(/[;&|]/)[0] ?? ''
45 const args: string[] = []
46 const tokens = rest.trim().split(/\s+/).filter(Boolean)
47 for (let i = 0; i < tokens.length; i++) {
48 const t = tokens[i] ?? ''
49 if (PUSH_VALUE_OPTS.has(t)) i++
50 else if (!t.startsWith('-')) args.push(t.replace(/^['"]|['"]$/g, ''))
51 }
52 const [remote, refspec] = args
53 if (!refspec) return { remote, src: 'HEAD' }
54 const [src = '', dst] = refspec.replace(/^\+/, '').split(':')
55 return { remote, src: src || 'HEAD', dst: dst?.replace(/^refs\/heads\//, '') || undefined }
56}
57
58async function review($: EngineInterface, target: PushTarget = { src: 'HEAD' }): Promise<Review> {
59 const root = await git($, ['rev-parse', '--show-toplevel'])
60 if (!root) return { findings: [], note: 'not a git repository, skipped' }
61 const head = await git($, ['rev-parse', '--verify', `${target.src}^{commit}`])
62 if (!head) return { findings: [], note: `could not resolve ${target.src}, skipped` }
63 // Compare with the remote branch being pushed to if it exists, else the source's upstream, else origin/HEAD.
64 const remote = target.remote ?? 'origin'
65 const dstName = target.dst ?? (target.src === 'HEAD' ? undefined : target.src)
66 const base =
67 (dstName ? await git($, ['rev-parse', '--abbrev-ref', '--verify', `${remote}/${dstName}`]) : undefined) ??
68 (await git($, ['rev-parse', '--abbrev-ref', `${target.src}@{upstream}`])) ??
69 (await git($, ['rev-parse', '--abbrev-ref', 'origin/HEAD']))
70 if (!base) return { findings: [], note: 'no upstream or origin/HEAD to compare with, skipped' }
71
72 const cacheKey = `review:${root}:${base}:${head}`
73 const cached = (await $.store.get(cacheKey)) as Review | undefined
74 if (cached) return cached
75
76 const diff = await git($, ['diff', '--unified=3', `${base}...${head}`, '--', '.', ...EXCLUDE])
77 if (!diff) return { findings: [], note: `nothing new compared with ${base}` }
78 const lines = diff.split('\n').length
79 if (lines > MAX_DIFF_LINES)
80 return { findings: [], note: `diff is ${lines} lines (over ${MAX_DIFF_LINES}), skipped; review it in smaller pushes` }
81
82 const guidelines = await $.fs.read(`${root}/REVIEW_GUIDELINES.md`).catch(() => FALLBACK_GUIDELINES)
83 const r = await $.model.complete({
84 model: lines <= SMALL_DIFF_LINES ? 'haiku' : 'sonnet',
85 system: SYSTEM,
86 prompt: `<guidelines>\n${guidelines}\n</guidelines>\n\n<diff base="${base}">\n${diff}\n</diff>`,
87 maxTokens: 2000,
88 effort: 'low',
89 timeoutMs: 90000,
90 })
91 if (!r.isAnswered) return { findings: [], note: `review failed (${r.reason}), not checked` }
92
93 const json = r.text.slice(r.text.indexOf('{'), r.text.lastIndexOf('}') + 1)
94 let result: Review
95 try {
96 result = { findings: (JSON.parse(json).findings ?? []) as Finding[] }
97 } catch {
98 return { findings: [], note: 'could not read the review reply, not checked' }
99 }
100 await $.store.set(cacheKey, result)
101 return result
102}
103
104function format(r: Review) {
105 return (
106 r.note ??
107 (r.findings.length === 0
108 ? 'no findings'
109 : r.findings
110 .map(f => `[${f.severity}] ${f.rule} ${f.file}${f.line ? `:${f.line}` : ''}: ${f.why}`)
111 .join('\n'))
112 )
113}
114
115export const register: Register = on => {
116 on('session.start', async ($, e, next) => {
117 await $.command.register({ name: 'push-review', description: 'Review unpushed commits against REVIEW_GUIDELINES.md' })
118 return next(e)
119 })
120
121 on('command.run', { command: 'push-review' }, async $ => {
122 $.ui.status('pre-push review: running…')
123 const r = await review($)
124 $.ui.status(undefined)
125 return { text: `Pre-push review: ${format(r)}` }
126 })
127
128 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
129 if (!PUSH.test(e.command) || / --dry-run\b/.test(e.command)) return next(e)
130 if (SKIP.test(e.command)) {
131 $.ui.toast('pre-push review: skipped (SKIP_REVIEW=1)')
132 return next(e)
133 }
134
135 $.ui.status('pre-push review: running…')
136 const target = parsePush(String(e.command))
137 const r = await review($, target).catch(err => ({ findings: [], note: `review error (${err}), not checked` }) as Review)
138 $.ui.status(undefined)
139
140 const branch = (await git($, ['rev-parse', '--abbrev-ref', target.src])) ?? '?'
141 const denialsKey = `denials:${await $.session.cwd()}:${branch}`
142 const blocking = r.findings.filter(f => f.severity === 'block')
143
144 if (blocking.length > 0) {
145 const denials = ((await $.store.get(denialsKey)) as number | undefined) ?? 0
146 if (denials < MAX_DENIALS) {
147 await $.store.set(denialsKey, denials + 1)
148 return {
149 deny:
150 `Pre-push review found ${blocking.length} blocking issue(s). Fix them, commit, and push again. ` +
151 `If a finding is wrong, tell the user and let them decide; they can push with SKIP_REVIEW=1.\n\n${format(r)}`,
152 }
153 }
154 $.ui.toast(`pre-push review: pushing after ${denials} blocked attempts; ${blocking.length} issue(s) remain`)
155 } else {
156 $.ui.toast(`pre-push review: ${r.note ?? (r.findings.length ? `${r.findings.length} warning(s)` : 'no findings')}`)
157 }
158 await $.store.delete(denialsKey)
159
160 const ran = await next(e)
161 return r.findings.length > 0 && ran.deny === undefined
162 ? { ...ran, context: [...(ran.context ?? []), `Pre-push review findings:\n${format(r)}`] }
163 : ran
164 }).catch(($, e, next) => {
165 $.ui.status(undefined)
166 $.ui.toast('pre-push review: failed, pushing without a review')
167 return next(e)
168 })
169}
170