Runs npm test before any git push, blocks the push if a test fails or if the push changes existing tests, and shows the last result above the prompt

A Claude Code plugin that runs npm test before Claude runs any git push.
package.json, no test script, or the placeholder script npm init writes), the push goes ahead without running anything.git -C <dir> push and cd <dir> && git push are checked in <dir>, the project being pushed. If the folder is written with ~ or a $VARIABLE, the session's folder is checked instead.push-guard: all tests passing or push-guard: 2 tests failing.It only checks pushes Claude makes. Pushes you type in your own terminal are not checked.
A failing test can be made to pass by changing what it expects instead of fixing the code. So when the tests pass, push-guard also looks at the commits being pushed: from where your branch left its upstream to HEAD (on a new branch with no upstream, from where it left the remote's default branch; on a first push, every commit).
To let a held push through, read the test changes and type, at the prompt:
/push-guard allow
then ask Claude to push again. Only an OK you type counts: Claude can't give it. The OK is for that one commit: a new commit that changes tests is held again.
Test files are *.test.*, *.spec.*, *_test.*, test_*.py, files named test.js or tests.py and the like, and anything in a test/, tests/, spec/, specs/, __tests__/ or __snapshots__/ folder. A project can add its own in package.json:
"pushGuard": { "testFiles": ["e2e/**", "cypress/**"] }
The plugin option testChanges sets what happens: block (the default) holds the push, warn lets it through with a note, off does not look.
return, a new mock, a skip on its own line) counts as an addition and goes through with a note. Any removed line holds the push, even a harmless one (a reworded test name, a tidy-up). A binary test file (an image snapshot, a fixture) has no line count, so any change to one holds the push, a new one too.package.json names them.HEAD. A change already on the remote, a push of a branch other than the one checked out (git push origin other), or tests changed only in the working tree are not judged. On a force push it compares from where the branch left its upstream, so rewritten commits are still looked at./push-guard allow only counts when a person runs it: typed at the prompt or sent from the app. Run by Claude, another plugin or a script (claude -p), it allows nothing. But Claude can still switch the testChanges option to warn or off, edit the plugin, or push from your terminal's shell some other way push-guard does not see. It is a guard against a quiet test edit, not a lock.At the prompt of a Claude Code terminal session:
/plugin install push-guard --marketplace seanjohnson-dev/push-guard
Answer y to add the marketplace, then pick a scope (Enter picks user, so it runs in every session).
From a shell:
claude plugin install push-guard --marketplace seanjohnson-dev/push-guard
In the desktop app: click + next to the prompt box, then Plugins, Add marketplace with seanjohnson-dev/push-guard, then Add plugin.
push-guard runs npm test (and, to see which files a push changes, read-only git commands) on your computer, as you, in the project Claude is working in. That runs whatever the project's test script says, with the same access to your files, network and credentials that you have. Only install it, and only push from projects, whose test scripts you trust. Read hooks/register.tsx before installing: it is short.
MIT. See LICENSE.
hooks/register.tsx 216 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { TestResult } from '../types'
5
6// One shell word: unquoted characters, \-escapes, 'single-quoted' and "double-quoted" parts, run together
7const WORD = String.raw`(?:[^\s;&|()<>'"\\]|\\.|'[^']*'|"(?:[^"\\]|\\.)*")+`
8const PART = /'[^']*'|"(?:[^"\\]|\\.)*"|(?:[^'"\\]|\\.)+/g
9// `git push` as a command: at the start or after ; & | ( or a newline, with options allowed before
10// `push` (`git -C dir push`); the words inside a string or a heredoc do not count. Group 1 holds the
11// `cd dir &&` (or `cd dir;`) commands just before it, group 2 the options between `git` and `push`
12const GIT_PUSH = new RegExp(
13 String.raw`(?:^|[;&|(\n])\s*((?:cd\s+${WORD}\s*(?:&&|;|\n)\s*)*)git((?:\s+-\S+(?:\s+(?!-)${WORD})?)*)\s+push\b`,
14)
15const CD = new RegExp(String.raw`cd\s+(${WORD})`, 'g')
16const OPTION = new RegExp(WORD, 'g')
17// Lines test runners print for a failing test (node:test, jest, tap, mocha, this repo's runner)
18const FAILED_LINE = /✖|✗|✕|\bnot ok\b|\bFAIL\b|\bfailing\b|\bfailed\b/i
19// A runner's summary count, e.g. "2 failing" or "3 failed"
20const FAILED_COUNT = /(\d+) (?:failing|failed)\b/i
21// One line per failing test, without summary lines
22const FAILED_TEST = /✖|✗|✕|\bnot ok\b/
23
24// The `test` script `npm init` writes, which only prints an error
25const NO_TEST_SPECIFIED = /no test specified/
26
27// A test file: *.test.*, *.spec.*, *_test.*, test_*.py, test.js, or anything in a test/, tests/, spec/,
28// specs/, __tests__/ or __snapshots__/ folder. A project adds its own in package.json: "pushGuard": { "testFiles": [globs] }
29const TEST_FILE = /(?:^|\/)(?:tests?|specs?|__tests__|__snapshots__)\/|(?:^|[\/._-])(?:test|spec)s?\.[a-z0-9]+$|(?:^|\/)test_[^/]*\.py$/i
30// Where a command a person ran comes from: the prompt, the app, or a press in the UI
31const PERSON = ['composer', 'bridge', 'person']
32// git's empty tree: on a first push every commit is new, so the diff runs from nothing
33const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'
34
35const last = atom({ plugin: 'push-guard', key: 'last' } as const, null)
36// The commit a push was held at for changed tests, and the one you allowed with /push-guard allow
37const held = atom({ plugin: 'push-guard', key: 'held' } as const, null)
38const allowed = atom({ plugin: 'push-guard', key: 'allowed' } as const, null)
39
40// package.json, or undefined when there is none or npm could not read it either
41async function readPackage(read: () => Promise<string>): Promise<any> {
42 try {
43 return JSON.parse(await read())
44 } catch {
45 return undefined
46 }
47}
48
49// Whether package.json has a `test` script that runs tests
50function hasTests(pkg: any): boolean {
51 const test = pkg?.scripts?.test
52 return typeof test === 'string' && test.trim() !== '' && !NO_TEST_SPECIFIED.test(test)
53}
54
55// A glob from package.json as a pattern: * and ? stay in one folder, ** crosses folders
56const glob = (text: string) =>
57 new RegExp(`(?:^|/)${text.replace(/[.+^${}()|[\]\\]/g, '\\$&').replace(/\*\*\/?/g, '\0').replace(/\*/g, '[^/]*').replace(/\?/g, '[^/]').replace(/\0/g, '.*')}(?:$|/)`)
58
59// The test files the commits being pushed change: from where HEAD left the upstream (or, on a new branch,
60// the remote's default branch) to HEAD. `changed` lost lines or were deleted or renamed, which is how a
61// failing test is made to pass; `added` are new files or only gained lines. Undefined when git can't say
62async function testChanges($: any, cwd: string | undefined, extra: RegExp[]) {
63 const git = async (...args: string[]) => {
64 const { exitCode, stdout } = await $.process.run(['git', ...args], cwd === undefined ? {} : { cwd })
65 return exitCode === 0 ? stdout : undefined
66 }
67 try {
68 const head = (await git('rev-parse', 'HEAD'))?.trim()
69 if (!head) return undefined
70 let base = EMPTY_TREE
71 for (const ref of ['@{upstream}', 'origin/HEAD', 'origin/main', 'origin/master']) {
72 const found = (await git('merge-base', ref, 'HEAD'))?.trim()
73 if (found) { base = found; break }
74 }
75 const diff = await git('diff', '--numstat', '--no-renames', '-z', base, head)
76 if (diff === undefined) return undefined
77 const changed: string[] = []
78 const added: string[] = []
79 for (const line of diff.split('\0')) {
80 const match = /^(\d+|-)\t(\d+|-)\t([^]+)$/.exec(line) // with -z a path may hold any character
81 if (!match || !(TEST_FILE.test(match[3]) || extra.some(re => re.test(match[3])))) continue
82 // A path is shown to Claude, so it gets no newlines or other control characters to hide lines in
83 ;(match[2] === '0' ? added : changed).push(JSON.stringify(match[3]))
84 }
85 return { head, changed, added }
86 } catch {
87 return undefined // no git to ask: the push itself will say
88 }
89}
90
91// A path is absolute from / (or a Windows drive); cd and git -C replace the folder with it
92const isAbsolute = (path: string) => /^(?:[\\/]|[A-Za-z]:[\\/])/.test(path)
93const join = (from: string, to: string) => `${from.replace(/[\\/]+$/, '')}/${to}`
94
95// A word as the shell passes it on, or undefined when only the shell can say: a variable, a command's
96// output, a wildcard, or `~` (the mod API does not give the home folder)
97function unquote(word: string): string | undefined {
98 if (word.startsWith('~')) return undefined
99 let text = ''
100 for (const [part] of word.matchAll(PART)) {
101 if (part.startsWith("'")) text += part.slice(1, -1)
102 else if (/[$`]/.test(part)) return undefined
103 else if (part.startsWith('"')) text += part.slice(1, -1).replace(/\\([$`"\\])/g, '$1')
104 else if (/[*?[{]/.test(part)) return undefined
105 else text += part.replace(/\\(.)/g, '$1')
106 }
107 return text
108}
109
110// The folder `git push` in the command runs in, from the `cd dir &&` just before it and its `-C dir`
111// options, each taken from the one before as git and the shell do. Undefined means the session's
112// folder, also when the folder cannot be told from the command; a relative path is from the session's folder
113function pushFolder(command: string): string | undefined {
114 const match = GIT_PUSH.exec(command)
115 if (!match) return undefined
116 const dirs = [...match[1].matchAll(CD)].map(([, dir]) => dir)
117 if (dirs.some(dir => dir.startsWith('-'))) return undefined // `cd -` and cd's own options
118 const options = match[2].match(OPTION) ?? []
119 for (let i = 0; i < options.length - 1; i++) if (options[i] === '-C') dirs.push(options[++i])
120
121 let folder: string | undefined
122 for (const word of dirs) {
123 const dir = unquote(word)
124 if (dir === undefined) return undefined
125 if (isAbsolute(dir)) folder = dir
126 else if (dir !== '' && dir !== '.') folder = folder === undefined ? dir : join(folder, dir)
127 }
128 return folder
129}
130
131function countFailing(output: string[]): number {
132 for (const line of output) {
133 const match = FAILED_COUNT.exec(line)
134 if (match) return Number(match[1])
135 }
136 return output.filter(line => FAILED_TEST.test(line)).length || -1
137}
138
139export const register: Register = (on, options) => {
140 on('session.start', async ($, e, next) => {
141 await $.command.register({ name: 'push-guard', description: 'push-guard: allow the held push that changes test files', argumentHint: 'allow' })
142 return next(e)
143 })
144
145 on('command.run', { command: 'push-guard' }, async ($, e) => {
146 if (String(e.args ?? '').trim() !== 'allow') return { text: 'push-guard: type /push-guard allow to let through the push it held for changed tests.' }
147 // Only an OK a person gave counts: typed at the prompt, or sent from the app; not a plugin, a script or Claude
148 if (!PERSON.includes((e as any).origin?.kind)) return { text: 'push-guard: only a person can allow a push that changes tests. Type /push-guard allow at the prompt yourself.' }
149 const head = await read($, held)
150 if (head === null) return { text: 'push-guard: no push is waiting for an OK.' }
151 await update($, allowed, () => head)
152 return { text: `push-guard: allowed the push of ${head.slice(0, 7)} with its test changes. Ask Claude to push again; a new commit needs a new OK.` }
153 })
154
155 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
156 if (!GIT_PUSH.test(e.command)) return next(e)
157 // `git -C dir push` and `cd dir && git push` push the project in dir, so its tests are the ones to run
158 const folder = pushFolder(e.command)
159 const dir = folder === undefined || isAbsolute(folder) ? folder : join(await $.session.cwd(), folder)
160 // A project with no npm tests has nothing to check, so the push goes ahead
161 const pkg = await readPackage(() => $.fs.read(dir === undefined ? 'package.json' : join(dir, 'package.json')))
162 if (!hasTests(pkg)) return next(e)
163
164 $.ui.status('push-guard: running npm test…')
165 const init = dir === undefined ? { timeoutMs: 600_000 } : { cwd: dir, timeoutMs: 600_000 }
166 const { exitCode, stdout, stderr } = await $.process.run(['npm', 'test'], init)
167 $.ui.status(undefined)
168
169 const output = `${stdout}\n${stderr}`.split('\n').map(line => line.trimEnd())
170 const result: TestResult = { failing: exitCode === 0 ? 0 : countFailing(output) }
171 await update($, last, () => result)
172
173 if (exitCode === 0) {
174 // Green tests prove little if the push changed the tests to match the code
175 // Anything but warn or off (a typo too) holds, so a slip in the option does not open the gate
176 const mode = ['warn', 'off'].find(m => m === String(options?.testChanges ?? '').trim().toLowerCase()) ?? 'block'
177 const extra = Array.isArray(pkg?.pushGuard?.testFiles) ? pkg.pushGuard.testFiles.filter((g: unknown) => typeof g === 'string').map(glob) : []
178 const tests = mode === 'off' ? undefined : await testChanges($, dir, extra)
179 if (tests?.changed.length && mode === 'block' && (await read($, allowed)) !== tests.head) {
180 await update($, held, () => tests.head)
181 return {
182 deny:
183 `push-guard: the tests pass, but this push changes or deletes existing tests, so it waits for a person's OK.\n\n` +
184 `Changed test files:\n${tests.changed.join('\n')}\n\n` +
185 'If a test was edited to make it pass, put it back and fix the code instead. If the change is meant ' +
186 '(the expected behaviour really changed), ask the user to type /push-guard allow, then push again. Do not run it yourself.',
187 }
188 }
189 // git could not say (no commit yet, a broken repo): there is nothing to hold, so say so instead of passing quietly
190 if (mode !== 'off' && tests === undefined) $.ui.toast('push-guard: could not tell which test files this push changes')
191 const n = tests?.changed.length || tests?.added.length
192 if (n) $.ui.toast(`push-guard: this push ${tests?.changed.length ? 'changes' : 'adds to'} ${n} test file${n === 1 ? '' : 's'}`)
193 return next(e)
194 }
195
196 const failed = output.filter(line => FAILED_LINE.test(line))
197 const shown = (failed.length ? failed : output.filter(Boolean).slice(-15)).join('\n')
198
199 return { deny: `push-guard: npm test failed (exit ${exitCode}), so the push was blocked.\n\nFailed tests:\n${shown}` }
200 }).catch(($, e, next) =>
201 next.called ? next(e) : { deny: `${$.plugin.name}: could not run npm test, so the push was blocked.` },
202 )
203
204 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
205 const result = await read($, last)
206 if (e.props.hasSurvey || result === null) return next(e)
207
208 const { Text } = $.ui.resolve(e)
209
210 if (result.failing === 0) return <Text color="success">push-guard: all tests passing</Text>
211
212 const count = result.failing === -1 ? 'tests failing' : `${result.failing} ${result.failing === 1 ? 'test' : 'tests'} failing`
213 return <Text color="error">push-guard: {count}</Text>
214 })
215}
216types/index.d.ts 8 lines1export type TestResult = { failing: number } // 0 when every test passed; -1 when the count is unknown
2
3declare module 'claude-code' {
4 interface PluginState {
5 'push-guard': { last: TestResult | null; held: string | null; allowed: string | null }
6 }
7}
8