Asks before a git commit or git add includes large files, big binaries or build/release artifacts (dmg, zip, app, ipa, xcarchive, DerivedData, node_modules…

Asks before a git commit or git add sweeps into git things that should not be there: large files, big binaries, build and release artifacts.
Bash call through the permission system (tool.check). A git commit or git add that would include an offending file becomes an "ask" prompt; it never denies, and a command already denied stays denied.rtk, env, sudo, VAR=value prefixes, a cd <dir> && before it and git -C <dir> / -c key=value.git commit: lists the staged files with git diff --cached --numstat -z, and for -a/--all (also inside clusters such as -am) the modified tracked files with git diff --numstat -z. Deletions are left out. Sizes come from the working tree (stat), or from the staged blob (git cat-file -s) when the file is no longer there.git add: the paths named on the line (even ignored ones, for git add -f), the untracked files the pathspec covers (git ls-files --others --exclude-standard -z; the whole repository for -A without a path) and the modified tracked files under it. -n/--dry-run is not checked.maxMB (default 5 MB);- -) and larger than 1 MB;*.dmg, *.zip, *.ipa, anything inside a *.app/ or *.xcarchive bundle, DerivedData/, node_modules/ or .build/, or any file under the repository's top-level release/ folder except *.md release notes..gitignore and git restore --staged.-z, numstat, cat-file -s), so a localised git or RTK's output rewriting do not change the result.| Command | Effect |
|---|---|
/commit-size-guard status | Shows whether the guard is on and the limits. |
/commit-size-guard off | Stops asking, kept across sessions; status line shows commit-size-guard: OFF. |
/commit-size-guard on | Turns it back on. |
| Option | Default | Meaning |
|---|---|---|
maxMB | 5 | A file larger than this many MB asks first. |
claude --plugin-dir /path/to/ModsTools/mods/commit-size-guard
git commit <path> / --only / --include commit working-tree content of the named paths: only what is staged (and -a) is checked.$(...), aliases, scripts or bash -c with complex quoting.node_modules can hold thousands); beyond that only the path patterns apply.auto/bypassPermissions mode the mode settles the question.claude plugin validate mods/commit-size-guard
claude plugin test mods/commit-size-guard # 24 testshooks/register.ts 179 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import {
4 MB,
5 addArgs,
6 artifactOf,
7 askReason,
8 commitArgs,
9 gitRuns,
10 offenders,
11 parseMaxMb,
12 parseNulList,
13 parseNumstat,
14 parseSize,
15 relativeTo,
16 resolvePath,
17 wholeEntries,
18 withTruncation,
19} from './rules'
20import type { Candidate, Changed, GitRun, Offender } from './rules'
21
22const GIT_MS = 5_000
23const OFF = 'off'
24// Untracked files sized one by one past this many are left unsized (an un-ignored node_modules can hold thousands).
25const MAX_STATS = 500
26const MAX_CAT_FILE = 5
27
28class GitFailed extends Error {}
29
30// Set when a git listing went past what one process run keeps (4 MiB of stdout).
31type Seen = { truncated: boolean }
32
33// One git call; any failure (cannot start, timeout, non-zero exit) throws so the engine's verdict stands.
34// A cut-off `-z` listing is kept up to its last whole entry and noted in `seen`.
35async function git($: EngineInterface, dir: string, args: string[], seen?: Seen): Promise<string> {
36 let ran
37 try {
38 ran = await $.process.run(['git', '-C', dir, ...args], { timeoutMs: GIT_MS })
39 } catch {
40 throw new GitFailed('git unavailable')
41 }
42 if (ran.exitCode !== 0) throw new GitFailed(`git ${args[0]} failed`)
43 if (!ran.isStdoutTruncated) return ran.stdout
44 if (seen === undefined) throw new GitFailed(`git ${args[0]} output cut off`)
45 seen.truncated = true
46
47 return wholeEntries(ran.stdout)
48}
49
50async function rootOf($: EngineInterface, dir: string): Promise<string> {
51 const top = (await git($, dir, ['rev-parse', '--show-toplevel'])).trim()
52 if (!top.startsWith('/')) throw new GitFailed('no repository root')
53
54 return top
55}
56
57async function worktreeSize($: EngineInterface, root: string, path: string): Promise<number | null> {
58 const stat = await $.fs.stat(`${root}/${path}`).catch(() => null)
59
60 return stat !== null && stat.kind === 'file' ? stat.size : null
61}
62
63// Sizes the files: from the working tree, else the staged blob (`git cat-file -s :path`) for a few.
64async function sized($: EngineInterface, root: string, files: readonly Changed[], useIndex: boolean): Promise<Candidate[]> {
65 const out: Candidate[] = []
66 let stats = 0
67 let catFiles = 0
68 for (const file of files) {
69 let size: number | null = null
70 if (stats < MAX_STATS) {
71 stats += 1
72 size = await worktreeSize($, root, file.path)
73 }
74 if (size === null && useIndex && catFiles < MAX_CAT_FILE) {
75 catFiles += 1
76 size = parseSize(await git($, root, ['cat-file', '-s', `:${file.path}`]).catch(() => ''))
77 }
78 out.push({ path: file.path, size, binary: file.binary })
79 }
80
81 return out
82}
83
84function merge(lists: readonly Changed[][]): Changed[] {
85 const byPath = new Map<string, Changed>()
86 for (const list of lists) for (const one of list) byPath.set(one.path, { path: one.path, binary: one.binary || (byPath.get(one.path)?.binary ?? false) })
87
88 return [...byPath.values()]
89}
90
91const NUMSTAT = ['--numstat', '-z', '--no-renames', '--diff-filter=d']
92
93async function commitOffenders($: EngineInterface, run: GitRun, cwd: string, maxBytes: number): Promise<Offender[]> {
94 const opts = commitArgs(run.args)
95 if (opts.dryRun) return []
96 const dir = resolvePath(run.dir ?? '.', cwd)
97 const root = await rootOf($, dir)
98 const seen: Seen = { truncated: false }
99 const lists = [parseNumstat(await git($, dir, ['diff', '--cached', ...NUMSTAT], seen))]
100 if (opts.all) lists.push(parseNumstat(await git($, dir, ['diff', ...NUMSTAT], seen)))
101
102 return withTruncation(offenders(await sized($, root, merge(lists), true), maxBytes), seen.truncated)
103}
104
105async function addOffenders($: EngineInterface, run: GitRun, cwd: string, maxBytes: number): Promise<Offender[]> {
106 const opts = addArgs(run.args)
107 if (opts.dryRun || (opts.paths.length === 0 && !opts.all && !opts.update)) return []
108 const dir = resolvePath(run.dir ?? '.', cwd)
109 const root = await rootOf($, dir)
110 const specs = opts.paths.length > 0 ? opts.paths : [':/']
111 // Paths named on the line, as given, even when ignored (`git add -f`).
112 const named: Changed[] = []
113 for (const given of opts.paths) {
114 const rel = relativeTo(resolvePath(given, dir), root)
115 if (rel !== null && rel !== '' && !/[*?[]/.test(given) && artifactOf(rel) !== null) named.push({ path: rel, binary: false })
116 }
117 const seen: Seen = { truncated: false }
118 const tracked = parseNumstat(await git($, dir, ['diff', ...NUMSTAT, '--', ...specs], seen))
119 const untracked = opts.update
120 ? []
121 : parseNulList(await git($, dir, ['ls-files', '--others', '--exclude-standard', '--full-name', '-z', '--', ...specs], seen)).map(path => ({ path, binary: false }))
122
123 return withTruncation(offenders(await sized($, root, merge([named, tracked, untracked]), false), maxBytes), seen.truncated)
124}
125
126async function inspect($: EngineInterface, command: string, maxBytes: number): Promise<{ found: Offender[]; verb: 'commit' | 'add' } | null> {
127 const runs = gitRuns(command).filter(run => run.sub === 'commit' || run.sub === 'add')
128 if (runs.length === 0) return null
129 const cwd = await $.session.cwd()
130 for (const run of runs) {
131 const found = run.sub === 'commit' ? await commitOffenders($, run, cwd, maxBytes) : await addOffenders($, run, cwd, maxBytes)
132 if (found.length > 0) return { found, verb: run.sub === 'commit' ? 'commit' : 'add' }
133 }
134
135 return null
136}
137
138const isOff = async ($: EngineInterface) => (await $.store.get(OFF).catch(() => false)) === true
139
140export const register: Register = (on, options) => {
141 const maxMb = parseMaxMb(options?.maxMB)
142
143 on('session.start', async ($, e, next) => {
144 await $.command.register({
145 name: 'commit-size-guard',
146 description: 'commit-size-guard on|off|status: ask before committing large files or build artifacts',
147 })
148 if (await isOff($)) $.ui.status('commit-size-guard: OFF')
149
150 return next(e)
151 })
152
153 on('command.run', { command: 'commit-size-guard' }, async ($, e) => {
154 const arg = e.args.trim()
155 if (arg === 'off' || arg === 'on') {
156 await $.store.set(OFF, arg === 'off')
157 $.ui.status(arg === 'off' ? 'commit-size-guard: OFF' : undefined)
158 }
159 const off = await isOff($)
160
161 return { text: `commit-size-guard is ${off ? 'OFF (kept across sessions)' : 'ON'}. Asks above ${maxMb} MB, for binaries above 1 MB and for build/release artifacts.` }
162 })
163
164 on('tool.check', { tool: 'Bash' }, async ($, e, next) => {
165 const verdict = await next(e)
166 const command = (e.input as { command?: unknown }).command
167 if (verdict.decision === 'deny' || typeof command !== 'string' || !/\bgit\b/.test(command) || (await isOff($))) return verdict
168 let result
169 try {
170 result = await inspect($, command, maxMb * MB)
171 } catch {
172 return verdict
173 }
174 if (result === null) return verdict
175
176 return { decision: 'ask', reason: askReason(result.found, result.verb) }
177 })
178}
179hooks/rules.ts 334 lines1export const DEFAULT_MAX_MB = 5
2export const MB = 1024 * 1024
3export const BINARY_MAX_BYTES = MB
4export const MAX_LISTED = 5
5
6// ---------------------------------------------------------------- shell
7
8// The words of each simple command of a shell line, quotes removed (a quoted argument stays one word).
9export function shellWords(command: string): string[][] {
10 const statements: string[][] = []
11 let words: string[] = []
12 let word = ''
13 let inWord = false
14 const endWord = () => {
15 if (inWord) words.push(word)
16 word = ''
17 inWord = false
18 }
19 const endStatement = () => {
20 endWord()
21 if (words.length > 0) statements.push(words)
22 words = []
23 }
24 for (let i = 0; i < command.length; i++) {
25 const c = command[i]!
26 if (c === "'") {
27 const end = command.indexOf("'", i + 1)
28 word += command.slice(i + 1, end === -1 ? undefined : end)
29 inWord = true
30 i = end === -1 ? command.length : end
31 } else if (c === '"') {
32 inWord = true
33 for (i++; i < command.length && command[i] !== '"'; i++) {
34 if (command[i] === '\\' && '"\\$`\n'.includes(command[i + 1] ?? 'x')) i++
35 word += command[i] ?? ''
36 }
37 } else if (c === '\\') {
38 if (command[i + 1] !== '\n') {
39 word += command[i + 1] ?? ''
40 inWord = true
41 }
42 i++
43 } else if (c === ';' || c === '&' || c === '|' || c === '\n' || c === '(' || c === ')') endStatement()
44 else if (c === ' ' || c === '\t') endWord()
45 else {
46 word += c
47 inWord = true
48 }
49 }
50 endStatement()
51
52 return statements
53}
54
55const isAssignment = (word: string) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(word)
56
57// Prefixes that run the command after them, with their options that take an argument.
58const WRAPPERS: Record<string, Set<string>> = {
59 rtk: new Set(),
60 env: new Set(['-u', '-C', '-S']),
61 command: new Set(),
62 nohup: new Set(),
63 time: new Set(),
64 sudo: new Set(['-u', '-g', '-C', '-D', '-h', '-p', '-U']),
65}
66
67function unwrap(words: string[]): string[] {
68 let i = 0
69 for (;;) {
70 while (words[i] !== undefined && isAssignment(words[i]!)) i++
71 const takesArg = WRAPPERS[words[i] ?? '']
72 if (takesArg === undefined) break
73 i++
74 while (words[i]?.startsWith('-')) i += takesArg.has(words[i]!) ? 2 : 1
75 }
76
77 return words.slice(i)
78}
79
80// `a` then `b`, `b` absolute replacing `a`; undefined stands for the session folder.
81export function joinDir(a: string | undefined, b: string): string {
82 if (b.startsWith('/') || a === undefined) return b
83
84 return `${a.replace(/\/+$/, '')}/${b}`
85}
86
87// An absolute path with `.`, `..` and repeated slashes resolved; relative paths are taken under `cwd`.
88export function resolvePath(path: string, cwd: string): string {
89 const joined = path.startsWith('/') ? path : `${cwd.replace(/\/+$/, '')}/${path}`
90 const parts: string[] = []
91 for (const part of joined.split('/')) {
92 if (part === '' || part === '.') continue
93 if (part === '..') parts.pop()
94 else parts.push(part)
95 }
96
97 return `/${parts.join('/')}`
98}
99
100const GIT_OPTS_WITH_ARG = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--config-env', '--super-prefix', '--exec-path'])
101
102export type GitRun = { sub: string; args: string[]; dir: string | undefined }
103
104// Each `git <sub>` of the line with the folder it runs in (`cd` before it, `git -C`), relative to the session folder.
105export function gitRuns(command: string): GitRun[] {
106 const runs: GitRun[] = []
107 let dir: string | undefined
108 for (const statement of shellWords(command)) {
109 const words = unwrap(statement)
110 if (words[0] === 'cd') {
111 const target = words[1]
112 if (target !== undefined && target !== '-' && !target.startsWith('~') && !target.includes('$')) dir = joinDir(dir, target)
113 continue
114 }
115 if (words[0] !== 'git') continue
116 let runDir = dir
117 let i = 1
118 while (words[i]?.startsWith('-')) {
119 const opt = words[i]!
120 if (opt === '-C' && words[i + 1] !== undefined) runDir = joinDir(runDir, words[i + 1]!)
121 i += GIT_OPTS_WITH_ARG.has(opt) ? 2 : 1
122 }
123 const sub = words[i]
124 if (sub !== undefined) runs.push({ sub, args: words.slice(i + 1), dir: runDir })
125 }
126
127 return runs
128}
129
130// ---------------------------------------------------------------- git commit / add arguments
131
132const COMMIT_SHORT_WITH_ARG = new Set(['m', 'F', 'C', 'c', 't'])
133const COMMIT_LONG_WITH_ARG = new Set([
134 '--message',
135 '--file',
136 '--reuse-message',
137 '--reedit-message',
138 '--template',
139 '--author',
140 '--date',
141 '--cleanup',
142 '--fixup',
143 '--squash',
144 '--trailer',
145 '--pathspec-from-file',
146])
147
148export type CommitArgs = { all: boolean; dryRun: boolean; paths: string[] }
149
150export function commitArgs(args: string[]): CommitArgs {
151 const out: CommitArgs = { all: false, dryRun: false, paths: [] }
152 for (let i = 0; i < args.length; i++) {
153 const arg = args[i]!
154 if (arg === '--') {
155 out.paths.push(...args.slice(i + 1))
156 break
157 }
158 if (arg.startsWith('--')) {
159 const name = arg.split('=')[0]!
160 if (name === '--all') out.all = true
161 else if (name === '--dry-run') out.dryRun = true
162 if (COMMIT_LONG_WITH_ARG.has(name) && !arg.includes('=')) i++
163 } else if (arg.startsWith('-') && arg.length > 1) {
164 for (let j = 1; j < arg.length; j++) {
165 const flag = arg[j]!
166 if (flag === 'a') out.all = true
167 if (COMMIT_SHORT_WITH_ARG.has(flag)) {
168 if (j === arg.length - 1) i++
169 break
170 }
171 // -u and -S take their value attached only
172 if (flag === 'u' || flag === 'S') break
173 }
174 } else out.paths.push(arg)
175 }
176
177 return out
178}
179
180export type AddArgs = { all: boolean; update: boolean; dryRun: boolean; paths: string[] }
181
182export function addArgs(args: string[]): AddArgs {
183 const out: AddArgs = { all: false, update: false, dryRun: false, paths: [] }
184 for (let i = 0; i < args.length; i++) {
185 const arg = args[i]!
186 if (arg === '--') {
187 out.paths.push(...args.slice(i + 1))
188 break
189 }
190 if (arg.startsWith('--')) {
191 const name = arg.split('=')[0]!
192 if (name === '--all' || name === '--no-ignore-removal') out.all = true
193 else if (name === '--update') out.update = true
194 else if (name === '--dry-run') out.dryRun = true
195 else if (name === '--chmod' && !arg.includes('=')) i++
196 } else if (arg.startsWith('-') && arg.length > 1) {
197 if (arg.includes('A')) out.all = true
198 if (arg.includes('u')) out.update = true
199 if (arg.includes('n')) out.dryRun = true
200 } else out.paths.push(arg)
201 }
202
203 return out
204}
205
206// ---------------------------------------------------------------- git output
207
208export type Changed = { path: string; binary: boolean }
209
210// `git diff --numstat -z`: `added\tdeleted\tpath\0`, or `added\tdeleted\t\0from\0to\0` for a rename; binary shows `-\t-`.
211export function parseNumstat(text: string): Changed[] {
212 const fields = text.split('\0')
213 const out: Changed[] = []
214 for (let i = 0; i < fields.length; i++) {
215 const field = fields[i]!
216 const match = /^(-|\d+)\t(-|\d+)\t([\s\S]*)$/.exec(field)
217 if (match === null) continue
218 const binary = match[1] === '-' && match[2] === '-'
219 let path = match[3]!
220 if (path === '') {
221 path = fields[i + 2] ?? ''
222 i += 2
223 }
224 if (path !== '') out.push({ path, binary })
225 }
226
227 return out
228}
229
230// A `-z` listing cut off mid-entry, kept up to its last whole entry.
231export function wholeEntries(text: string): string {
232 return text.slice(0, text.lastIndexOf('\0') + 1)
233}
234
235export const parseNulList = (text: string) => text.split('\0').filter(one => one !== '')
236
237export function parseSize(text: string): number | null {
238 const n = Number(text.trim())
239
240 return text.trim() !== '' && Number.isFinite(n) && n >= 0 ? n : null
241}
242
243// The path relative to `root`, or null when it is outside.
244export function relativeTo(path: string, root: string): string | null {
245 const base = root.replace(/\/+$/, '')
246 if (path === base) return ''
247
248 return path.startsWith(`${base}/`) ? path.slice(base.length + 1) : null
249}
250
251// ---------------------------------------------------------------- what should not be committed
252
253const ARCHIVE = /\.(dmg|zip|ipa)$/i
254const BUNDLE = /\.(app|xcarchive)$/i
255const CACHE_DIRS = new Set(['DerivedData', 'node_modules', '.build'])
256
257// Why a repo-relative path looks like a build or release artifact, with the folder that holds it; null otherwise.
258export function artifactOf(path: string): { reason: string; group: string } | null {
259 const parts = path.split('/').filter(Boolean)
260 for (let i = 0; i < parts.length; i++) {
261 const part = parts[i]!
262 const isDir = i < parts.length - 1
263 if (CACHE_DIRS.has(part)) return { reason: `inside ${part}/`, group: `${parts.slice(0, i + 1).join('/')}/` }
264 if (BUNDLE.test(part) && (isDir || /\.xcarchive$/i.test(part))) {
265 return { reason: `inside a ${part.slice(part.lastIndexOf('.'))} bundle`, group: `${parts.slice(0, i + 1).join('/')}/` }
266 }
267 }
268 const name = parts[parts.length - 1] ?? ''
269 if (ARCHIVE.test(name)) return { reason: `build artifact (*${name.slice(name.lastIndexOf('.')).toLowerCase()})`, group: path }
270 if (parts[0] === 'release' && parts.length > 1 && !/\.md$/i.test(name)) return { reason: 'release artifact (release/)', group: path }
271
272 return null
273}
274
275export type Candidate = { path: string; size: number | null; binary: boolean }
276export type Offender = { path: string; size: number | null; reasons: string[]; files: number }
277
278// The files to ask about, an artifact folder counted once with its files.
279export function offenders(files: readonly Candidate[], maxBytes: number): Offender[] {
280 const out = new Map<string, Offender>()
281 for (const file of files) {
282 const artifact = artifactOf(file.path)
283 const reasons: string[] = []
284 if (artifact !== null) reasons.push(artifact.reason)
285 if (file.size !== null && file.size > maxBytes) reasons.push(`over ${formatMb(maxBytes)}`)
286 else if (file.binary && file.size !== null && file.size > BINARY_MAX_BYTES) reasons.push(`binary over ${formatMb(BINARY_MAX_BYTES)}`)
287 if (reasons.length === 0) continue
288 const key = artifact?.group ?? file.path
289 const seen = out.get(key)
290 if (seen === undefined) out.set(key, { path: key, size: file.size, reasons, files: 1 })
291 else {
292 seen.files += 1
293 seen.size = seen.size === null || file.size === null ? seen.size ?? file.size : seen.size + file.size
294 for (const reason of reasons) if (!seen.reasons.includes(reason)) seen.reasons.push(reason)
295 }
296 }
297
298 return [...out.values()]
299}
300
301export const TRUNCATED: Offender = { path: 'file list', size: null, reasons: ['over 4 MiB of paths, only its start was checked'], files: 1 }
302
303// A listing too long to read in full is itself worth asking about (tens of thousands of files).
304export function withTruncation(found: readonly Offender[], truncated: boolean): Offender[] {
305 return truncated ? [...found, TRUNCATED] : [...found]
306}
307
308export function formatMb(bytes: number): string {
309 const mb = bytes / MB
310 if (mb >= 1) return `${Number.isInteger(mb) ? mb : mb.toFixed(1)} MB`
311
312 return `${(bytes / 1024).toFixed(1)} KB`
313}
314
315export function askReason(found: readonly Offender[], verb: 'commit' | 'add'): string {
316 const lines = found.slice(0, MAX_LISTED).map(one => {
317 const size = one.size === null ? '' : `, ${formatMb(one.size)}`
318 const count = one.files > 1 ? ` (${one.files} files)` : ''
319 return `- ${one.path}${count}: ${one.reasons.join(', ')}${size}`
320 })
321 if (found.length > MAX_LISTED) lines.push(`- and ${found.length - MAX_LISTED} more`)
322 const what = verb === 'commit' ? 'this commit includes' : 'this git add stages'
323 const fix =
324 verb === 'commit'
325 ? 'add them to .gitignore and unstage them (git restore --staged <path>)'
326 : 'add them to .gitignore instead of staging them'
327
328 return `commit-size-guard: ${what} files that usually do not belong in git:\n${lines.join('\n')}\nConfirm only if they are meant to be versioned; otherwise ${fix}. Stop the checks with /commit-size-guard off.`
329}
330
331export function parseMaxMb(raw: unknown): number {
332 return typeof raw === 'number' && Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_MAX_MB
333}
334