Blocks git push and gh pr create until you confirm the commits, their authors and the diff size, with a side pane of the last check

A Claude Code mod that stops git push and gh pr create until you confirm.
When Claude runs one of those commands, a dialog shows:
kk5190Choose Continue to run it, or Cancel to refuse. If nobody answers, the command is refused.
The side pane shows the last check: the command, the commits, any author warning, and the outcome (waiting, continued, cancelled, or refused). It opens when a session starts. Reopen it with /push-guard.
/plugin install push-guard --marketplace kk5190/claude-code-mods
claude --plugin-dir . # run a session with this folder loaded; edits hot-reload
claude plugin validate .
claude plugin test .
MIT. See LICENSE.
hooks/register.tsx 86 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Check, Outcome } from '../types'
5import { authorWarnings, isPublishCommand, paneLines, parseCommits, summarize } from './guard'
6
7const PANE = 'push-guard'
8const TITLE = 'Push guard'
9
10const LOG = ['git', 'log', '--format=%an%x09%ae%x09%s', '@{u}..HEAD']
11const STAT = ['git', 'diff', '--shortstat', '@{u}..HEAD']
12
13const lastCheck = atom({ plugin: 'push-guard', key: 'lastCheck' } as const, null)
14
15export const register: Register = on => {
16 on('session.start', async ($, e, next) => {
17 await $.command.register({ name: 'push-guard', description: 'Show the push guard pane' })
18 void $.ui.open({ id: PANE, title: TITLE })
19
20 return next(e)
21 })
22
23 on('command.run', { command: 'push-guard' }, async $ => {
24 await $.ui.open({ id: PANE, title: TITLE })
25
26 return { text: 'Push guard pane opened.' }
27 })
28
29 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
30 if (!isPublishCommand(e.command)) return next(e)
31
32 const settle = (check: Check, outcome: Outcome) => update($, lastCheck, () => ({ ...check, outcome }))
33
34 let check: Check
35 let question: string
36 try {
37 const log = await $.process.run(LOG)
38 const stat = await $.process.run(STAT)
39 const commits = log.exitCode === 0 ? parseCommits(log.stdout) : []
40 const body =
41 log.exitCode === 0 ? summarize(commits, stat.stdout) : 'Could not list the commits (no upstream branch?).'
42 const warnings = authorWarnings(commits)
43 const warn = warnings.length > 0 ? `\nAuthor check failed:\n ${warnings.join('\n ')}` : ''
44 question = `Run: ${e.command}\n${body}${warn}`
45 check = { command: e.command, lines: body.split('\n'), warnings, outcome: 'checking' }
46 } catch {
47 await update($, lastCheck, () => ({
48 command: e.command,
49 lines: ['Could not read the commits.'],
50 warnings: [],
51 outcome: 'refused',
52 }))
53 return { deny: 'push-guard: could not read the commits, so the command was not run' }
54 }
55
56 await update($, lastCheck, () => check)
57
58 try {
59 const answer = await $.ui.ask(question, ['Continue', 'Cancel'])
60 if (answer !== 'Continue') {
61 await settle(check, 'cancelled')
62 return { deny: 'push-guard: cancelled' }
63 }
64 } catch {
65 await settle(check, 'refused')
66 return { deny: 'push-guard: no answer, so the command was not run' }
67 }
68
69 await settle(check, 'continued')
70 return next(e)
71 }).catch(($, e, next) => (next.called ? next(e) : { deny: 'push-guard: its check failed, so the command was not run' }))
72
73 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
74 const { Box, Text } = $.ui.resolve(e)
75 const lines = paneLines(await read($, lastCheck))
76
77 return (
78 <Box flexDirection="column" paddingX={1}>
79 {lines.map((line, i) => (
80 <Text key={i}>{line}</Text>
81 ))}
82 </Box>
83 )
84 })
85}
86hooks/guard.ts 55 lines1// Pure logic for the push guard. No engine imports, so it runs under plain tests.
2
3import type { Check, Outcome } from '../types'
4
5export type Commit = { name: string; email: string; subject: string }
6
7// The author the guard expects on every commit about to be published.
8const EXPECTED_AUTHOR = 'kk5190'
9
10// Matches anywhere in the command, so a chained `git add . && git push` is caught too.
11// Over-matching a quoted string is harmless: the guard asks instead of running.
12const PUBLISH = /\bgit\s+push\b|\bgh\s+pr\s+create\b/
13
14export const isPublishCommand = (command: string): boolean => PUBLISH.test(command)
15
16// Input is `git log --format=%an%x09%ae%x09%s`, one commit per line.
17export const parseCommits = (stdout: string): Commit[] =>
18 stdout
19 .split('\n')
20 .filter(line => line.length > 0)
21 .map(line => {
22 const [name, email, subject] = line.split('\t')
23 return { name, email, subject }
24 })
25
26const isExpected = ({ name, email }: Commit) =>
27 name === EXPECTED_AUTHOR || email.toLowerCase().includes(EXPECTED_AUTHOR)
28
29export const authorWarnings = (commits: Commit[]): string[] =>
30 commits.filter(c => !isExpected(c)).map(c => `${c.name} <${c.email}>: ${c.subject}`)
31
32export const summarize = (commits: Commit[], shortstat: string): string => {
33 const count = `${commits.length} commit${commits.length === 1 ? '' : 's'}`
34 const lines = commits.map(c => ` ${c.subject}`)
35 return [count, ...lines, shortstat.trim()].filter(Boolean).join('\n')
36}
37
38const OUTCOME: Record<Outcome, string> = {
39 checking: 'waiting for your answer',
40 continued: 'continued',
41 cancelled: 'cancelled',
42 refused: 'refused, the command did not run',
43}
44
45// The pane's lines for the last check, one string per row.
46export const paneLines = (check: Check | null): string[] => {
47 if (check === null) return ['No publish command checked yet.']
48 return [
49 `Command: ${check.command}`,
50 ...check.lines,
51 ...check.warnings.map(w => `Author check failed: ${w}`),
52 `Outcome: ${OUTCOME[check.outcome]}`,
53 ]
54}
55types/index.d.ts 11 lines1export type Outcome = 'checking' | 'continued' | 'cancelled' | 'refused'
2
3// The last publish command the guard looked at, as the pane shows it.
4export type Check = { command: string; lines: string[]; warnings: string[]; outcome: Outcome }
5
6declare module 'claude-code' {
7 interface PluginState {
8 'push-guard': { lastCheck: Check | null }
9 }
10}
11