SLOPSHOPPER

git-guard

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…

guardprocess
★ 2v0.1.0no licenseupdated 2026-10-19cpeaustriajc/cj-stack/mods/git-guard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · git-guard
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(rm -rf build && git push --force origin main) ⎿ Denied by git-guard: git-guard: a force push to main or master rewrites shared history. Push to your own b ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

git-guard

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.

Why

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.

Install

/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.

Example

  1. Claude has edited three files and runs git checkout -- . to start again.
  2. The hook runs git status --porcelain --untracked-files=no and finds changed files.
  3. The hook denies the command. Claude reads: "git-guard: this discards the uncommitted changes in every tracked file."
  4. Claude runs git stash push -u -m retry, then starts again with the edits saved.

What it does

The hook checks every Bash command. It ignores text inside quotes and comments, so git commit -m "git push -f origin main" passes.

CommandDecisionSafe way out in the deny text
git branch -D, or -d with -f or --forceDeny.git branch -d <branch>, which deletes only a merged branch
git push with --force, -f, --force-with-lease or a + refspec, to main or masterDeny.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 masterDeny.The same as above
git checkout -- ., git checkout ., git restore . when a tracked file has changesDeny.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 branchPass.
git restore --staged ., git checkout -- src/app.ts, git reset --softPass.
git reset --hard, git clean -fdPass: Claude Code decides these itself.
The hook fails, or git status exits with an errorDeny 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.

Settings

No settings and no allow list.

To turn the mod off, run claude plugin disable git-guard@cj-stack.

Limits

  • It does not deny git reset --hard or git clean. Claude Code and its auto mode decide those, and a mod never overrides them.
  • It checks only the Bash tool. Git called from a script file, bash -c "..." or eval passes.
  • A quoted branch name or path is not read: git push -f origin "main" passes.
  • If a cd target is quoted or starts with ~, the hook treats the tree as dirty and denies.
  • It does not check git push --mirror, git push --all or a deleted remote branch.
  • If the hook fails, it denies. Retry, or run a command that names the branch and the files.

More

Source 1 files
hooks/register.ts 230 lines
1import 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