Denies a Bash git command that loses work Claude Code does not already guard: git branch -D, a force push to main or master, and git checkout -- . or git…

A mod (a plugin that adds a hook to Claude Code) that denies a git command which loses work. Each deny message names the safe command to run instead.
Claude sometimes cleans up with a git command that cannot be undone. git branch -D deletes unmerged commits. A force push to main rewrites history that other people pulled. git checkout -- . throws away every uncommitted edit.
git-guard denies these commands and tells Claude the safe way to do the same job.
/plugin marketplace add cpeaustriajc/cj-stack
/plugin install git-guard@cj-stack
To see that it is on, run claude plugin list and look for git-guard. The mod is optional: cj-stack does not install it.
git checkout -- . to start again.git status --porcelain --untracked-files=no and finds changed files.git stash push -u -m retry, then starts again with the edits saved.The hook checks every Bash command. It ignores text inside quotes and comments, so git commit -m "git push -f origin main" passes.
| Command | Decision | Safe way out in the deny text |
|---|---|---|
git branch -D, or -d with -f or --force | Deny. | git branch -d <branch>, which deletes only a merged branch |
git push with --force, -f, --force-with-lease or a + refspec, to main or master | Deny. | Push to your own branch and open a pull request, or git revert <sha> |
A force push that names no branch, while the current branch is main or master | Deny. | The same as above |
git checkout -- ., git checkout ., git restore . when a tracked file has changes | Deny. | git stash push -u -m <tag>, a WIP commit, or name only the files |
git branch -d, git push without force, a force push to a feature branch | Pass. | |
git restore --staged ., git checkout -- src/app.ts, git reset --soft | Pass. | |
git reset --hard, git clean -fd | Pass: Claude Code decides these itself. | |
The hook fails, or git status exits with an error | Deny with "could not check". | Name the branch and the files |
The hook follows git -C <dir> and a plain cd <dir> earlier in the same command when it reads the branch or the status.
No settings and no allow list.
To turn the mod off, run claude plugin disable git-guard@cj-stack.
git reset --hard or git clean. Claude Code and its auto mode decide those, and a mod never overrides them.bash -c "..." or eval passes.git push -f origin "main" passes.cd target is quoted or starts with ~, the hook treats the tree as dirty and denies.git push --mirror, git push --all or a deleted remote branch.git push by text match; git-guard blocks only the force push to main.hooks/register.ts 230 lines1import type { Register } from 'claude-code'
2
3const MOD = 'git-guard'
4
5const DENY = {
6 branch:
7 `${MOD}: \`git branch -D\` deletes a branch even when no other branch holds its commits. ` +
8 'Use `git branch -d <branch>`: it deletes only a merged branch. If git refuses, ask the user before you force it.',
9 push:
10 `${MOD}: a force push to main or master rewrites shared history. ` +
11 'Push to your own branch and open a pull request, or undo a commit on main with `git revert <sha>` and a normal push.',
12 discard:
13 `${MOD}: this discards the uncommitted changes in every tracked file. ` +
14 'Save them first with `git stash push -u -m <tag>` or a WIP commit, or name only the files to restore.',
15}
16
17const FAIL_WAY_OUT = 'Name the branch and the files in the git command, or ask the user.'
18
19// Quoted text becomes Q, so a quoted word is never read as a flag, a branch or a path.
20const Q = '\u0000'
21const PREFIX = new Set(['!', 'if', 'then', 'do', 'else', 'elif', 'while', 'until', '{', 'time', 'exec', 'command', 'builtin', 'nohup', 'sudo', 'doas', 'env', 'nice'])
22const GIT_OPT_WITH_ARG = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--config-env', '--super-prefix'])
23const PUSH_OPT_WITH_ARG = new Set(['--repo', '-o', '--push-option', '--receive-pack', '--exec'])
24const MAIN = new Set(['main', 'master'])
25const WHOLE_TREE = new Set(['.', './', ':/', ':/.', ':', '*'])
26
27type Frame = { segs: Seg[] }
28type Seg = { text: string; frame: Frame }
29type Where = { steps: string[]; unknown: boolean }
30type Probe = { kind: 'branch' } | { kind: 'push'; current?: Where } | { kind: 'discard'; where: Where }
31
32function scan(input: string): Frame[] {
33 const frames: Frame[] = []
34 const open = (): Frame => {
35 const frame: Frame = { segs: [] }
36 frames.push(frame)
37 return frame
38 }
39 const stack: { frame: Frame; kind: 'top' | 'sub' | 'grp' | 'bt' | 'dq' | 'sq' }[] = [{ frame: open(), kind: 'top' }]
40 const begin = (frame: Frame) => frame.segs.push({ text: '', frame })
41 const cur = () => {
42 const frame = stack[stack.length - 1].frame
43 if (!frame.segs.length) begin(frame)
44 return frame.segs[frame.segs.length - 1]
45 }
46 const split = () => begin(stack[stack.length - 1].frame)
47 const enter = (kind: 'sub' | 'grp' | 'bt') => {
48 const frame = kind === 'grp' ? stack[stack.length - 1].frame : open()
49 stack.push({ frame, kind })
50 split()
51 }
52
53 for (let i = 0; i < input.length; i++) {
54 const c = input[i]
55 const top = stack[stack.length - 1].kind
56 const next = input[i + 1]
57 if (top === 'sq') {
58 if (c === "'") stack.pop()
59 cur().text += Q
60 } else if (top === 'dq') {
61 if (c === '\\') {
62 cur().text += Q + Q
63 i++
64 } else if (c === '"') {
65 stack.pop()
66 cur().text += Q
67 } else if (c === '$' && next === '(') {
68 enter('sub')
69 i++
70 } else if (c === '`') {
71 enter('bt')
72 } else cur().text += Q
73 } else if (c === '\\') {
74 cur().text += next === '\n' ? ' ' : Q + Q
75 i++
76 } else if (c === "'") {
77 stack.push({ frame: stack[stack.length - 1].frame, kind: 'sq' })
78 cur().text += Q
79 } else if (c === '"') {
80 stack.push({ frame: stack[stack.length - 1].frame, kind: 'dq' })
81 cur().text += Q
82 } else if (c === '#' && (i === 0 || /\s/.test(input[i - 1]))) {
83 while (i + 1 < input.length && input[i + 1] !== '\n') i++
84 } else if (c === '$' && next === '(') {
85 enter('sub')
86 i++
87 } else if (c === '`') {
88 if (top === 'bt') {
89 stack.pop()
90 cur()
91 } else enter('bt')
92 } else if (c === '(') {
93 enter('grp')
94 } else if (c === ')') {
95 if (top === 'sub' || top === 'grp') stack.pop()
96 split()
97 } else if ((c === '|' || c === '&') && next === c) {
98 split()
99 i++
100 } else if (c === '&' && input[i - 1] === '>') {
101 cur().text += c
102 } else if (c === '|' || c === ';' || c === '&' || c === '\n') {
103 split()
104 } else {
105 cur().text += c
106 }
107 }
108 return frames
109}
110
111function words(seg: Seg): string[] {
112 const all = seg.text.split(/\s+/).filter(Boolean)
113 let i = 0
114 while (i < all.length) {
115 const base = all[i].replace(/^.*\//, '')
116 if (!/^[A-Za-z_]\w*=/.test(all[i]) && !PREFIX.has(base)) break
117 i++
118 if (base === 'sudo' || base === 'doas') while (all[i]?.startsWith('-')) i += /^-[ughCT]$/.test(all[i]) ? 2 : 1
119 }
120 return all.slice(i)
121}
122
123const short = (w: string, letter: string) => /^-[A-Za-z]+$/.test(w) && w.includes(letter)
124
125function branchDeletesForce(args: string[]): boolean {
126 if (args.some(w => short(w, 'D'))) return true
127 const del = args.some(w => w === '--delete' || short(w, 'd'))
128 const force = args.some(w => w === '--force' || short(w, 'f'))
129 return del && force
130}
131
132function destination(refspec: string): string {
133 const spec = refspec.replace(/^\+/, '')
134 const dest = spec.includes(':') ? spec.slice(spec.indexOf(':') + 1) : spec
135 return dest.replace(/^refs\/heads\//, '')
136}
137
138function pushProbe(args: string[], where: Where): Probe | undefined {
139 let force = false
140 const positional: string[] = []
141 for (let i = 0; i < args.length; i++) {
142 const w = args[i]
143 if (w === '--force' || w.startsWith('--force-with-lease') || short(w, 'f')) force = true
144 else if (PUSH_OPT_WITH_ARG.has(w)) i++
145 else if (!w.startsWith('-')) positional.push(w)
146 }
147 const refspecs = positional.slice(1)
148 if (!refspecs.length) return force ? { kind: 'push', current: where } : undefined
149 for (const spec of refspecs) {
150 if (!force && !spec.startsWith('+')) continue
151 const dest = destination(spec)
152 if (MAIN.has(dest)) return { kind: 'push' }
153 if (dest === 'HEAD') return { kind: 'push', current: where }
154 }
155 return undefined
156}
157
158function discards(sub: string, args: string[]): boolean {
159 const dash = args.indexOf('--')
160 const paths = dash >= 0 ? args.slice(dash + 1) : args.filter(w => !w.startsWith('-'))
161 if (!paths.some(p => WHOLE_TREE.has(p))) return false
162 if (sub === 'checkout') return true
163 const staged = args.some(w => w === '--staged' || short(w, 'S'))
164 const worktree = args.some(w => w === '--worktree' || short(w, 'W'))
165 return worktree || !staged
166}
167
168function moved(where: Where, path: string | undefined): Where {
169 if (!path || path.includes(Q) || path.startsWith('~') || path === '-') return { steps: where.steps, unknown: true }
170 return { steps: [...where.steps, path], unknown: where.unknown }
171}
172
173function probes(input: string): Probe[] {
174 const found: Probe[] = []
175 for (const frame of scan(input)) {
176 let where: Where = { steps: [], unknown: false }
177 for (const seg of frame.segs) {
178 const w = words(seg)
179 const name = w[0]?.replace(/^.*\//, '')
180 if (name === 'cd') where = moved(where, w[1])
181 if (name !== 'git') continue
182 let here = where
183 let i = 1
184 while (w[i]?.startsWith('-')) {
185 const opt = w[i].split('=')[0]
186 if (opt === '-C') here = moved(here, w[i + 1])
187 i += GIT_OPT_WITH_ARG.has(opt) && !w[i].includes('=') ? 2 : 1
188 }
189 const sub = w[i]
190 const args = w.slice(i + 1)
191 if (sub === 'branch' && branchDeletesForce(args)) found.push({ kind: 'branch' })
192 if (sub === 'push') {
193 const p = pushProbe(args, here)
194 if (p) found.push(p)
195 }
196 if ((sub === 'checkout' || sub === 'restore') && discards(sub, args)) found.push({ kind: 'discard', where: here })
197 }
198 }
199 return found
200}
201
202export const register: Register = on => {
203 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
204 const found = probes(e.command)
205 if (!found.length) return next(e)
206 let base: string | undefined
207 const dir = async (where: Where) => {
208 base ??= await $.session.cwd()
209 return where.steps.reduce((d, s) => (s.startsWith('/') ? s : `${d}/${s}`), base)
210 }
211 for (const p of found) {
212 if (p.kind === 'branch') return { deny: DENY.branch }
213 if (p.kind === 'push') {
214 if (!p.current || p.current.unknown) return { deny: DENY.push }
215 const r = await $.process.run(['git', 'symbolic-ref', '--short', '-q', 'HEAD'], { cwd: await dir(p.current) })
216 if (r.exitCode === 0 && MAIN.has(r.stdout.trim())) return { deny: DENY.push }
217 }
218 if (p.kind === 'discard') {
219 if (p.where.unknown) return { deny: DENY.discard }
220 const r = await $.process.run(['git', 'status', '--porcelain', '--untracked-files=no'], { cwd: await dir(p.where) })
221 if (r.exitCode !== 0) throw new Error(`git status exited ${r.exitCode}`)
222 if (r.stdout.trim()) return { deny: DENY.discard }
223 }
224 }
225 return next(e)
226 }).catch(($, e, next) =>
227 next.called ? next(e) : { deny: `${MOD}: could not check this command (${next.error.message}). ${FAIL_WAY_OUT}` },
228 )
229}
230