Ships a commit skill and holds every Bash git commit to it: a commit the skill did not open, a blanket git add, a signature trailer, a secret, an ignored path…

A commit skill tells the model how to commit: explicit paths, no AI signature, no push nobody asked for. The model reads the skill, and then it sometimes runs git commit straight from Bash without it. This mod ships the commit skill and holds every Bash git command to it. A commit the skill did not open, a blanket git add, a signature trailer, a secret, an ignored path, or a push or branch change nobody asked for is stopped before git runs. The model reads which rule the command broke and runs it again the right way.
git-commit:commit (skills/commit/SKILL.md). The model opens it with the Skill tool, or you type /git-commit:commit, with the options you gave: --all, --staged, --modified, --no-verify, --amend, --push.Current repository state (git-commit) block to its text: the branch, the staged, unstaged, untracked and conflicted files (40 of each at most), the options the skill was opened with, the style of the last 20 subjects, the last 10 subjects, and a warning for each staged file whose name holds credentials or that an ignore file names. The model starts from that block instead of running git status and git log first.&&, ||, ;, |, &, parentheses and new lines, skips env, command, exec, time, nohup and variable assignments in front of git, and follows a cd and git -C. It reads the arguments of add, rm, commit, push, merge, config, rebase, switch, branch and checkout the way git's own option parser reads them (-am, -mtext, --message=text, --), and a heredoc as the message it holds. Every other git call runs unread.git add and git rm --cached calls into that copy through GIT_INDEX_FILE. A commit -a stages the tracked changes into the copy; a commit with a pathspec starts from a fresh index that holds HEAD. The real index is never written, and the temporary files are deleted after the measure.deny mode, the default, a command that breaks a hard rule stops before it runs, and the model reads:git-commit: stopped before it ran, because it breaks the git-commit:commit skill:
In note mode the command runs, and the model reads the broken rules after its result. A soft rule never stops a command: in both modes its note comes after the result.
git command stopped or git command noted), else one transcript line such as git-commit: stopped: skill not opened.Co-Authored-By trailer.In the live check on Claude Code 2.1.284 the gate stopped a git commit run without the skill, a git add ., a git add -f of a file .gitignore names, a commit with a Co-Authored-By: Claude line, and a git push the prompt did not ask for. After the model opened git-commit:commit, it read the state block and the same commit ran. In note mode git add . ran, and the model read the broken rule and the three new files it staged.
Hard rules (deny mode stops the command):
| Command | Stopped when |
|---|---|
git commit | the skill was not opened in this turn by this agent |
git commit | --no-verify or -n without the skill's --no-verify; --amend without --amend; -a without --all or --modified |
git commit | --allow-empty, --allow-empty-message, --interactive, -p |
git commit, git add | a pathspec that reaches past this change's files: ., .., *, a glob, a : pathspec, a directory (a submodule is a file here); the skill's --all allows them |
git add | -A without --all; -u without --all or --modified; -i, -p, -e, --pathspec-from-file |
git add -f | a path that an ignore file names |
git commit | the commit holds a path that an ignore file names: the project's .gitignore files, .git/info/exclude and the global excludes file, as git check-ignore -v reports them, with the file and the line |
git commit | the commit holds a file whose name holds credentials (.env and .env.* except the example files, *.pem, *.key, *.p12, *.pfx, *.keystore, *.jks, id_rsa and the other id_* keys, credentials.json, .netrc, .pgpass), or adds a line that looks like a private key, an AWS, Google, GitHub, Slack, Stripe, npm, Hugging Face or sk- key, a JSON Web Token, or a quoted value of 16 or more characters with letters and digits after api_key, secret, token or password; the note names the file, the line and the kind, never the value |
git commit | the message carries an AI signature: a Co-authored-by: line that names an AI tool, a Generated with or Created by line that names one, noreply@anthropic.com, or 🤖. A Co-authored-by: line for a person passes |
git commit | the subject is empty, longer than 72 characters, or ends with a period |
git commit | the repository writes conventional subjects (more than half of the last 20, from at least 3), and the subject is not type(scope): subject, or its type is not in the skill's list |
git commit, git push, git merge | -c core.hooksPath=...; HUSKY=0, HUSKY_SKIP_HOOKS, SKIP or LEFTHOOK=0 without the skill's --no-verify |
git push | your last prompt does not say push and the skill was not opened with --push; --no-verify without the skill's --no-verify. A --dry-run passes |
git switch, git branch <name>, git branch -d/-m/-c, git checkout -b/-B/--orphan/--detach, git checkout <name> | your last prompt does not say branch, checkout or switch. A git checkout with --, with two or more operands, or with one operand that is an existing path restores files and passes |
git config | it writes a setting; --get, --list and the other reads pass |
git rebase | -i |
Soft rules (a note in both modes):
plugins/a and plugins/b).-ed or -ing (added, adding).type(scope): differs from the case of the recent subjects.git add stages untracked files; the note names them.cd -, a cd ~ or a cd to a path with $, or when git could not replay the command's staging).A git commit --dry-run, --short, --porcelain, --long or --help records nothing and is not read.
/git-commit on or off, and the mode /git-commit on | off on by default; off also gives the engine its commit attribution text back /git-commit mode deny a command that breaks a hard rule stops; the default /git-commit mode note every command runs, and the model reads the broken rules after it
claude plugin marketplace add KilimcininKorOglu/claude-code-mods claude plugin install git-commit@kilimcininkoroglu-mods
Function hooks are early access. Claude Code 2.1.288 and later load them by default, so there is nothing to switch on.
/git-commit:commit to commit through the skill. /commit does not open a plugin skill.~/.claude/skills/commit), remove it. The mod counts only git-commit:commit as the skill, so a commit after your own skill is stopped, and the model reads two skills that say the same thing.CLAUDE.md tells the model to commit through a skill, name it git-commit:commit there.Validated with claude plugin validate on Claude Code 2.1.284:
❯ ./register.ts hooks: session.start, command.run{command=git-commit}, turn.start, prompt.submit, tool.call{tool=Skill}, skill.prompt{skill=git-commit:commit}, attribution.text{kind=commit}, tool.call{tool=Bash}, turn.complete ❯ ./register.ts calls: $.command.register, $.fs.exists (via directoryFindings, judgeCheckout, scratchIndex), $.fs.read (via messageCheck), $.fs.stat (via directoryFindings), $.process.run (via dropTemps, git, scratchIndex), $.session.cwd (via judge, repoBlock), $.sidebar.set (via toPerson), $.store.get (via readSettings), $.store.set (via runCommand, setMode), $.ui.log (via dropTemps, toPerson)
Reach L2, it runs git, cp and rm.
sh -c, a script, a make target, a git alias, git commit-tree or an MCP git tool is not read.push etme ("do not push") reads as a request for a push.$VAR, backticks, a $(...) other than cat <<'EOF') is not checked; the model reads a note instead.git merge is checked only for skipped hooks.deny mode has no bypass. When a hard rule cannot be met, you turn the gate off with /git-commit mode note.make install # eslint, typescript-eslint, typescript make lint # complexity limit 10, the build fails above it make typecheck # needs .claude/types/ from /plugin-types make validate make test # claude plugin test
hooks/register.ts 440 lines1import type { EngineInterface, ProcessRunResult, Register, ToolCallResult } from 'claude-code'
2import { gitCalls, heredocsOf, joinPath, type GitCall, type Heredoc } from './command.ts'
3import { parseStatus, readNumstat, stateBlock } from './context.ts'
4import { note, type Finding } from './finding.ts'
5import { addArgs, checkoutKind, commitArgs, isBranchChange, isConfigWrite, isDryCommit, isInteractiveRebase, pushArgs, type Parsed } from './gitargs.ts'
6import { messageFindings, messageOf, styleOf } from './message.ts'
7import { addFlagFindings, argumentsOf, blanketFinding, blanketReason, branchFindings, commitFlagFindings, configFindings, denyText, hookSkipFindings, ignoreHits, ignoredFindings, logText, noteText, optionsOf, pathspecFindings, pushFindings, rebaseFindings, secretLineFindings, secretNameFindings, sizeFindings, SKILL, spreadFindings, typedSkill, untrackedFindings } from './rules.ts'
8import { isSecretName, secretLines } from './secrets.ts'
9
10const ENABLED_KEY = 'enabled'
11const MODE_KEY = 'mode'
12const MAIN = 'main'
13const USAGE = 'expects nothing (the status), on, off or mode note | deny'
14
15type Mode = 'note' | 'deny'
16
17/**
18 * The on/off setting and the mode as the store held them at the last read; the skill options each agent
19 * loop opened in its current turn (`main` for the main loop, the agent id for a subagent), dropped at that
20 * loop's turn end; and the person's last prompt, which says whether a push or a branch operation was asked.
21 */
22type State = { enabled: boolean; mode: Mode; loops: Map<string, Set<string>>; lastPrompt: string }
23
24/** Prompt origins that are the person's own words. */
25const PERSON = new Set(['composer', 'bridge', 'sdk'])
26const PUSH_WORD = /push/i
27const BRANCH_WORD = /branch|checkout|switch/i
28
29/** The git subcommands the mod rules on; any other git call runs unread. */
30const RULED = new Set(['add', 'rm', 'commit', 'push', 'config', 'rebase', 'merge', 'switch', 'branch', 'checkout'])
31
32/**
33 * One command's measure: the loop that runs it, the directory it starts in, its heredocs (and which a
34 * commit message used), whether it commits, the scratch index per repository its `git add`s were replayed
35 * into, every temporary index file to delete, and whether a replay failed.
36 */
37type Run = {
38 loop: string
39 start: string
40 heredocs: Heredoc[]
41 used: Set<number>
42 hasCommit: boolean
43 scratches: Map<string, string>
44 temps: string[]
45 replayFailed: boolean
46}
47
48/**
49 * Reads the on/off setting and the mode from the store, which every window shares, so a change made in
50 * another window applies here at the next hook that acts on it.
51 */
52async function readSettings($: EngineInterface, state: State): Promise<void> {
53 state.enabled = (await $.store.get(ENABLED_KEY)) !== false
54 state.mode = (await $.store.get(MODE_KEY)) === 'note' ? 'note' : 'deny'
55}
56
57/** Runs git in the C locale, with the index `index` names; undefined when git did not start or timed out. */
58async function git($: EngineInterface, args: readonly string[], cwd: string, index?: string, stdin?: string): Promise<ProcessRunResult | undefined> {
59 const env: Record<string, string> = index === undefined ? { LC_ALL: 'C' } : { LC_ALL: 'C', GIT_INDEX_FILE: index }
60 try {
61 return await $.process.run(['git', ...args], { cwd, env, ...(stdin === undefined ? {} : { stdin }) })
62 } catch {
63 // git did not run: the caller reports the measure as missing.
64 return undefined
65 }
66}
67
68/** git's standard output when it exited 0, else undefined. */
69async function gitOut($: EngineInterface, args: readonly string[], cwd: string, index?: string): Promise<string | undefined> {
70 const r = await git($, args, cwd, index)
71 return r?.exitCode === 0 ? r.stdout : undefined
72}
73
74/** Whether git ran and exited 0. */
75async function gitOk($: EngineInterface, args: readonly string[], cwd: string, index?: string): Promise<boolean> {
76 return (await git($, args, cwd, index))?.exitCode === 0
77}
78
79/** The top of the repository a directory is in, or undefined outside one. */
80async function topOf($: EngineInterface, dir: string): Promise<string | undefined> {
81 return (await gitOut($, ['rev-parse', '--show-toplevel'], dir))?.trim() || undefined
82}
83
84/**
85 * The finding the person reads: an entry in the shared sidebar's stream while it is open, else the
86 * transcript line. The model reads the deny text or the note, another channel.
87 */
88async function toPerson($: EngineInterface, findings: readonly Finding[], stopped: boolean): Promise<void> {
89 const lines = findings.map(f => ({ text: f.short, kind: f.level === 'deny' ? ('error' as const) : ('warn' as const) }))
90 try {
91 const taken = await $.sidebar.set({ consumer: 'git-commit', key: 'git', title: stopped ? 'git command stopped' : 'git command noted', lines, until: 'stream' })
92 if (taken) return
93 } catch {
94 // The sidebar mod is not installed.
95 }
96 $.ui.log(logText(findings, stopped))
97}
98
99/** A note for a call whose paths the mod could not measure. */
100function unmeasured(what: string, why: string): Finding {
101 return note(`${what} not measured`, `The mod did not measure ${what}: ${why}.`)
102}
103
104/**
105 * The index this command's `git add`s are replayed into for one repository, a copy of the real index made
106 * at the first replay, so the commit later in the command is measured as git will record it.
107 */
108async function scratchIndex($: EngineInterface, run: Run, top: string): Promise<string | undefined> {
109 const held = run.scratches.get(top)
110 if (held !== undefined) return held
111 const real = (await gitOut($, ['rev-parse', '--path-format=absolute', '--git-path', 'index'], top))?.trim()
112 if (real === undefined) return undefined
113 const index = `${real}.git-commit-${crypto.randomUUID()}`
114 run.temps.push(index)
115 const copied = (await $.fs.exists(real)) ? await $.process.run(['cp', real, index]).catch(() => undefined) : { exitCode: 0 }
116 if (copied?.exitCode !== 0) return undefined
117 run.scratches.set(top, index)
118 return index
119}
120
121/**
122 * Replays one index-only git call (`add`, `rm --cached`) into the scratch index of its repository. It
123 * never runs without a scratch index, so the real index is never touched.
124 */
125async function replay($: EngineInterface, run: Run, dir: string, args: readonly string[]): Promise<void> {
126 const top = await topOf($, dir)
127 const index = top === undefined ? undefined : await scratchIndex($, run, top)
128 if (index === undefined || !(await gitOk($, args, dir, index))) run.replayFailed = true
129}
130
131/** The directory a call runs in, or undefined when the command text does not say. */
132function dirOf(run: Run, call: GitCall): string | undefined {
133 return call.where === null ? undefined : joinPath(run.start, call.where)
134}
135
136/** Operands that are directories (a submodule excepted), where the skill names files. */
137async function directoryFindings($: EngineInterface, operands: readonly string[], dir: string): Promise<Finding[]> {
138 const out: Finding[] = []
139 for (const operand of operands.filter(o => blanketReason(o) === undefined)) {
140 const path = joinPath(dir, operand)
141 const stat = await $.fs.stat(path).catch(() => undefined)
142 if (stat?.kind === 'dir' && !(await $.fs.exists(`${path}/.git`))) out.push(blanketFinding(operand, 'is a directory'))
143 }
144 return out
145}
146
147/** Paths an ignore file names, as `git check-ignore -v` reports them; undefined when git did not answer. */
148async function ignoredOf($: EngineInterface, paths: readonly string[], cwd: string, index?: string): Promise<ReturnType<typeof ignoreHits> | undefined> {
149 if (paths.length === 0) return []
150 const r = await git($, ['check-ignore', '--no-index', '-v', '-z', '--stdin'], cwd, index, paths.join('\0'))
151 if (r?.exitCode === 1) return []
152 return r?.exitCode === 0 ? ignoreHits(r.stdout) : undefined
153}
154
155/** A `git add -f` of a path an ignore file names. */
156async function forcedFindings($: EngineInterface, a: Parsed, dir: string): Promise<Finding[]> {
157 if (!a.flags.has('force')) return []
158 const hits = await ignoredOf($, a.operands, dir)
159 return hits === undefined ? [unmeasured('the forced paths', 'git check-ignore did not answer')] : ignoredFindings(hits, 'add')
160}
161
162/** The untracked files a `git add` stages, named in a note. */
163async function newFileFindings($: EngineInterface, a: Parsed, dir: string): Promise<Finding[]> {
164 if (a.operands.length === 0) return []
165 const out = await gitOut($, ['ls-files', '--others', '--exclude-standard', '-z', '--', ...a.operands], dir)
166 return untrackedFindings((out ?? '').split('\0').filter(Boolean))
167}
168
169const INTERACTIVE_ADD = ['interactive', 'patch', 'edit']
170
171/** A `git add`: its options and operands, the directories and forced ignored paths it names, the new files it stages. */
172async function judgeAdd($: EngineInterface, state: State, run: Run, call: GitCall): Promise<Finding[]> {
173 const a = addArgs(call.args)
174 const options = state.loops.get(run.loop)
175 const out = addFlagFindings(a, options)
176 const dir = dirOf(run, call)
177 if (dir === undefined) return [...out, unmeasured('the staged paths', 'a cd in the command goes where its text does not say')]
178 if (options?.has('all') !== true) out.push(...(await directoryFindings($, a.operands, dir)))
179 out.push(...(await forcedFindings($, a, dir)), ...(await newFileFindings($, a, dir)))
180 if (run.hasCommit && !INTERACTIVE_ADD.some(flag => a.flags.has(flag))) await replay($, run, dir, ['add', ...call.args])
181 return out
182}
183
184/** A `git rm --cached` before a commit changes only the index, so it is replayed; any other `git rm` is left alone. */
185async function judgeRemoval($: EngineInterface, run: Run, call: GitCall): Promise<Finding[]> {
186 const dir = dirOf(run, call)
187 if (run.hasCommit && dir !== undefined && call.args.includes('--cached')) await replay($, run, dir, ['rm', ...call.args])
188 return []
189}
190
191/** The last 20 subjects of the repository, newest first; none before the first commit. */
192async function subjectsOf($: EngineInterface, top: string): Promise<string[]> {
193 const out = await gitOut($, ['log', '-20', '--format=%s'], top)
194 return (out ?? '').split('\n').filter(Boolean)
195}
196
197/** The message rules, measured against the repository's recent subjects; a note when the command does not say the message. */
198async function messageCheck($: EngineInterface, run: Run, c: Parsed, dir: string, top: string): Promise<Finding[]> {
199 const file = c.values.get('file')?.[0]
200 const fileText = file === undefined || file === '-' ? undefined : await $.fs.read(joinPath(dir, file)).catch(() => undefined)
201 const message = messageOf(c, run.heredocs, run.used, fileText)
202 if ('unknown' in message) return [unmeasured('the commit message', message.unknown)]
203 return messageFindings(message.text, styleOf(await subjectsOf($, top)))
204}
205
206/** A fresh index holding HEAD's tree (empty before the first commit), for a commit that records only its pathspec. */
207async function headIndex($: EngineInterface, run: Run, top: string): Promise<string | undefined> {
208 const real = (await gitOut($, ['rev-parse', '--path-format=absolute', '--git-path', 'index'], top))?.trim()
209 if (real === undefined) return undefined
210 const index = `${real}.git-commit-only-${crypto.randomUUID()}`
211 run.temps.push(index)
212 const hasHead = (await gitOut($, ['rev-parse', '--verify', '--quiet', 'HEAD'], top)) !== undefined
213 const read = await git($, ['read-tree', hasHead ? 'HEAD' : '--empty'], top, index)
214 return read?.exitCode === 0 ? index : undefined
215}
216
217/**
218 * The index the commit's own staging starts from: HEAD's tree for a commit that records only its pathspec,
219 * the scratch copy for `-a` or `--include`, else the scratch this command's `git add`s were replayed into,
220 * or the real index (`{}`) when it replayed none. Undefined when a temporary index could not be made.
221 */
222async function baseIndex($: EngineInterface, run: Run, c: Parsed, top: string): Promise<{ index?: string } | undefined> {
223 const staging = c.operands.length > 0 || c.flags.has('all')
224 const made = c.operands.length > 0 && !c.flags.has('include') ? await headIndex($, run, top) : staging ? await scratchIndex($, run, top) : run.scratches.get(top)
225 if (made !== undefined) return { index: made }
226 return staging ? undefined : {}
227}
228
229/**
230 * The index that holds what the commit records: the base, with `-a`'s tracked changes and the commit's
231 * own pathspec staged into it. The real index is never written: staging runs only into a temporary one.
232 */
233async function commitIndex($: EngineInterface, run: Run, c: Parsed, dir: string, top: string): Promise<{ index?: string } | undefined> {
234 const base = await baseIndex($, run, c, top)
235 const index = base?.index
236 if (base === undefined || (index === undefined && (c.flags.has('all') || c.operands.length > 0))) return base
237 if (c.flags.has('all') && !(await gitOk($, ['add', '-u', '--', ':/'], top, index))) return undefined
238 if (c.operands.length > 0 && !(await gitOk($, ['add', '--', ...c.operands], dir, index))) return undefined
239 return base
240}
241
242const DIFF = ['-c', 'core.quotePath=false', 'diff', '--cached', '--no-renames', '--diff-filter=d', '--no-color', '--no-ext-diff']
243
244/** What the commit holds: its paths, its changed lines, its zero-context patch; undefined when git did not answer. */
245async function viewOf($: EngineInterface, top: string, index?: string): Promise<{ names: string[]; lines: number; patch: string } | undefined> {
246 const stat = await gitOut($, [...DIFF, '--numstat', '-z'], top, index)
247 const patch = await gitOut($, [...DIFF, '-U0'], top, index)
248 return stat === undefined || patch === undefined ? undefined : { ...readNumstat(stat), patch }
249}
250
251/** What the commit records: secret files and lines, ignored paths, its size and its spread. */
252async function contentFindings($: EngineInterface, run: Run, c: Parsed, dir: string, top: string): Promise<Finding[]> {
253 const picked = run.replayFailed ? undefined : await commitIndex($, run, c, dir, top)
254 const view = picked === undefined ? undefined : await viewOf($, top, picked.index)
255 if (view === undefined) return [unmeasured('what the commit holds', 'git could not replay or diff this command\'s staging, so secrets and ignored paths were not checked')]
256 const ignored = await ignoredOf($, view.names, top, picked?.index)
257 const ignoreFindings = ignored === undefined ? [unmeasured('ignored paths', 'git check-ignore did not answer')] : ignoredFindings(ignored, 'commit')
258 return [...secretNameFindings(view.names.filter(isSecretName)), ...secretLineFindings(secretLines(view.patch)), ...ignoreFindings, ...sizeFindings(view.lines), ...spreadFindings(view.names)]
259}
260
261/** A `git commit`: the skill, its flags, its pathspec, its message, and what it records. */
262async function judgeCommit($: EngineInterface, state: State, run: Run, call: GitCall): Promise<Finding[]> {
263 const c = commitArgs(call.args)
264 if (isDryCommit(c)) return []
265 const options = state.loops.get(run.loop)
266 const out = [...hookSkipFindings(call, options), ...commitFlagFindings(c, options), ...pathspecFindings(c.operands, options)]
267 const dir = dirOf(run, call)
268 const top = dir === undefined ? undefined : await topOf($, dir)
269 if (dir === undefined || top === undefined) return [...out, unmeasured('the commit', 'the command does not say which repository it runs in')]
270 const dirs = options?.has('all') === true ? [] : await directoryFindings($, c.operands, dir)
271 return [...out, ...dirs, ...(await messageCheck($, run, c, dir, top)), ...(await contentFindings($, run, c, dir, top))]
272}
273
274/** A `git checkout` that switches or creates a branch; a single operand that is an existing path is a file restore. */
275async function judgeCheckout($: EngineInterface, state: State, run: Run, call: GitCall): Promise<Finding[]> {
276 const kind = checkoutKind(call.args)
277 const label = `git checkout ${call.args.join(' ')}`
278 const asked = BRANCH_WORD.test(state.lastPrompt)
279 if (kind === 'branch') return branchFindings(label, asked)
280 if (typeof kind === 'string') return []
281 const dir = dirOf(run, call)
282 const isPath = dir !== undefined && (await $.fs.exists(joinPath(dir, kind.operand)))
283 return isPath || dir === undefined ? [] : branchFindings(label, asked)
284}
285
286/** A `git push`: asked for in the person's last prompt or by the skill's --push, and without skipping hooks. */
287function judgePush(state: State, options: ReadonlySet<string> | undefined, call: GitCall): Finding[] {
288 const asked = PUSH_WORD.test(state.lastPrompt) || options?.has('push') === true
289 return [...hookSkipFindings(call, options), ...pushFindings(pushArgs(call.args), asked, options)]
290}
291
292/** The calls the mod rules on by their arguments alone. */
293function judgeArgs(state: State, run: Run, call: GitCall): Finding[] {
294 const options = state.loops.get(run.loop)
295 const branchAsked = BRANCH_WORD.test(state.lastPrompt)
296 if (call.sub === 'push') return judgePush(state, options, call)
297 if (call.sub === 'merge') return hookSkipFindings(call, options)
298 if (call.sub === 'config') return isConfigWrite(call.args) ? configFindings() : []
299 if (call.sub === 'rebase') return isInteractiveRebase(call.args) ? rebaseFindings() : []
300 if (call.sub === 'switch') return branchFindings(`git switch ${call.args.join(' ')}`, branchAsked)
301 return call.sub === 'branch' && isBranchChange(call.args) ? branchFindings(`git branch ${call.args.join(' ')}`, branchAsked) : []
302}
303
304async function judgeCall($: EngineInterface, state: State, run: Run, call: GitCall): Promise<Finding[]> {
305 if (call.sub === 'add') return judgeAdd($, state, run, call)
306 if (call.sub === 'rm') return judgeRemoval($, run, call)
307 if (call.sub === 'commit') return judgeCommit($, state, run, call)
308 if (call.sub === 'checkout') return judgeCheckout($, state, run, call)
309 return judgeArgs(state, run, call)
310}
311
312/** Deletes the command's temporary index files; one that stays behind is named in the transcript. */
313async function dropTemps($: EngineInterface, run: Run): Promise<void> {
314 if (run.temps.length === 0) return
315 const done = await $.process.run(['rm', '-f', ...run.temps]).catch(() => undefined)
316 if (done?.exitCode !== 0) $.ui.log(`temporary index files were left behind: ${run.temps.join(', ')}`)
317}
318
319/** Every rule of the skill a Bash command breaks, call by call, in the order the calls run. */
320async function judge($: EngineInterface, state: State, command: string, loop: string): Promise<Finding[]> {
321 const calls = gitCalls(command)
322 if (!calls.some(c => RULED.has(c.sub))) return []
323 const run: Run = { loop, start: await $.session.cwd(), heredocs: heredocsOf(command), used: new Set(), hasCommit: calls.some(c => c.sub === 'commit'), scratches: new Map(), temps: [], replayFailed: false }
324 try {
325 const out: Finding[] = []
326 for (const call of calls) out.push(...(await judgeCall($, state, run, call)))
327 return out
328 } finally {
329 await dropTemps($, run)
330 }
331}
332
333/**
334 * The gate: in the `deny` mode a command that breaks a hard rule stops before it runs; otherwise it runs
335 * and the model reads every finding after its result.
336 */
337async function onBash($: EngineInterface, state: State, command: string, loop: string, run: () => Promise<ToolCallResult>): Promise<ToolCallResult> {
338 if (!/\bgit\b/.test(command)) return run()
339 await readSettings($, state)
340 if (!state.enabled) return run()
341 const findings = await judge($, state, command, loop)
342 const hard = findings.filter(f => f.level === 'deny')
343 if (state.mode === 'deny' && hard.length > 0) {
344 await toPerson($, hard, true)
345 return { deny: denyText(hard) }
346 }
347 const r = await run()
348 if (findings.length === 0 || r.deny !== undefined) return r
349 await toPerson($, findings, false)
350 return { ...r, context: [...(r.context ?? []), noteText(findings)] }
351}
352
353/** The repository state appended to the skill's text; undefined outside a repository. */
354async function repoBlock($: EngineInterface, options: ReadonlySet<string>): Promise<string | undefined> {
355 const top = await topOf($, await $.session.cwd())
356 const status = top === undefined ? undefined : await gitOut($, ['status', '--porcelain=v1', '-z', '--branch'], top)
357 if (top === undefined || status === undefined) return undefined
358 const subjects = await subjectsOf($, top)
359 const staged = (await gitOut($, ['diff', '--cached', '--name-only', '-z', '--diff-filter=d'], top) ?? '').split('\0').filter(Boolean)
360 const ignored = (await ignoredOf($, staged, top)) ?? []
361 const warnings = [
362 ...staged.filter(isSecretName).map(p => `staged ${p} is a file name that holds credentials`),
363 ...ignored.map(h => `staged ${h.path} is ignored by ${h.source}:${h.line} (${h.pattern})`),
364 ]
365 return stateBlock(parseStatus(status), subjects, styleOf(subjects), options, warnings)
366}
367
368async function setMode($: EngineInterface, state: State, word: string): Promise<string> {
369 if (word !== 'note' && word !== 'deny') return 'mode expects note or deny'
370 await $.store.set(MODE_KEY, word)
371 state.mode = word
372 return word === 'deny' ? `mode deny: a git command that breaks the ${SKILL} skill stops before it runs` : 'mode note: a git command that breaks the skill runs, and the model reads which rule it broke'
373}
374
375async function runCommand($: EngineInterface, state: State, args: string): Promise<string> {
376 const word = args.trim()
377 if (word === 'on' || word === 'off') {
378 await $.store.set(ENABLED_KEY, word === 'on')
379 state.enabled = word === 'on'
380 return word === 'on' ? `on: every Bash git command is held to the ${SKILL} skill` : 'off: git commands run unchecked, and the engine\'s own commit trailer text is back'
381 }
382 if (word.startsWith('mode')) return setMode($, state, word.slice(4).trim())
383 if (word !== '') return USAGE
384 await readSettings($, state)
385 return `${state.enabled ? 'on' : 'off'} · mode ${state.mode}`
386}
387
388export const register: Register = on => {
389 const state: State = { enabled: true, mode: 'deny', loops: new Map(), lastPrompt: '' }
390
391 on('session.start', async ($, e, next) => {
392 const r = await next(e)
393 await $.command.register({ name: 'git-commit', description: `Holds Bash git commands to the ${SKILL} skill: status, on, off, mode (git-commit)`, argumentHint: '[on | off | mode note | deny]' })
394 await readSettings($, state)
395 return r
396 })
397
398 // The engine prints the plugin name in front of command text and log lines, so the texts do not repeat it.
399 on('command.run', { command: 'git-commit' }, async ($, e) => ({ text: await runCommand($, state, String(e.args ?? '')) }))
400
401 on('turn.start', async ($, e, next) => {
402 await readSettings($, state)
403 return next(e)
404 })
405
406 // A typed `/git-commit:commit` opens the main loop's turn, and the person's words say whether a push or a branch operation was asked.
407 on('prompt.submit', async (_, e, next) => {
408 if (!PERSON.has(e.origin?.kind ?? '')) return next(e)
409 state.lastPrompt = e.text
410 const typed = typedSkill(e.text)
411 if (typed !== undefined) state.loops.set(MAIN, typed)
412 return next(e)
413 })
414
415 // The skill opened through the Skill tool opens the calling loop's turn, with the options its args name.
416 on('tool.call', { tool: 'Skill' }, async (_, e, next) => {
417 const r = await next(e)
418 if (e.skill === SKILL && r.deny === undefined && r.isError !== true) state.loops.set(e.agentId ?? MAIN, optionsOf(e.args ?? ''))
419 return r
420 })
421
422 on('skill.prompt', { skill: 'git-commit:commit' }, async ($, e, next) => {
423 const r = await next(e)
424 if (!state.enabled) return r
425 const block = await repoBlock($, optionsOf(argumentsOf(e.text)))
426 return block === undefined ? r : { text: `${r.text}\n\n${block}` }
427 })
428
429 // The skill never signs a commit, so the engine's commit trailer text is left empty while the mod is on.
430 on('attribution.text', { kind: 'commit' }, async (_, e, next) => (state.enabled ? { text: '' } : next(e)))
431
432 on('tool.call', { tool: 'Bash' }, async ($, e, next) => onBash($, state, e.command, e.agentId ?? MAIN, () => next(e)))
433
434 on('turn.complete', async (_, e, next) => {
435 const r = await next(e)
436 state.loops.delete(e.agentId ?? MAIN)
437 return r
438 })
439}
440hooks/command.ts 209 lines1/**
2 * Reads a Bash command: the git calls it makes, each with the directory it
3 * runs in, and the bodies of its heredocs.
4 */
5
6/** One heredoc of a command: its end word, the line that opened it and its body. */
7export type Heredoc = { tag: string; opener: string; body: string }
8
9const HEREDOC = /<<(-?)\s*(['"]?)([A-Za-z_]\w*)\2/
10
11/** Drops the body and the end line of every heredoc, so text inside a commit message is not read as a command. */
12export function stripHeredocs(text: string): string {
13 const out: string[] = []
14 let end: string | undefined
15 for (const line of text.split('\n')) {
16 if (end !== undefined) {
17 if (line.trim() === end) end = undefined
18 continue
19 }
20 out.push(line)
21 end = HEREDOC.exec(line)?.[3]
22 }
23 return out.join('\n')
24}
25
26/** The heredocs of a command, in the order they appear; `<<-` drops the leading tabs of the body. */
27export function heredocsOf(text: string): Heredoc[] {
28 const out: Heredoc[] = []
29 let open: { tag: string; opener: string; tabs: boolean; lines: string[] } | undefined
30 for (const line of text.split('\n')) {
31 if (open === undefined) {
32 const m = HEREDOC.exec(line)
33 if (m !== null) open = { tag: m[3] ?? '', opener: line, tabs: m[1] === '-', lines: [] }
34 continue
35 }
36 if (line.trim() !== open.tag) {
37 open.lines.push(open.tabs ? line.replace(/^\t+/, '') : line)
38 continue
39 }
40 out.push({ tag: open.tag, opener: open.opener, body: open.lines.join('\n') })
41 open = undefined
42 }
43 return out
44}
45
46type Scan = { segments: string[][]; tokens: string[]; token: string; started: boolean; quote: string | undefined }
47
48function endToken(s: Scan): void {
49 if (s.started) s.tokens.push(s.token)
50 s.token = ''
51 s.started = false
52}
53
54function endSegment(s: Scan): void {
55 endToken(s)
56 if (s.tokens.length > 0) s.segments.push(s.tokens)
57 s.tokens = []
58}
59
60/** One character inside quotes; returns how many characters it used. */
61function quoted(s: Scan, text: string, i: number): number {
62 const c = text[i] ?? ''
63 if (c === s.quote) s.quote = undefined
64 else if (c === '\\' && s.quote === '"' && i + 1 < text.length) {
65 s.token += text[i + 1]
66 return 2
67 } else s.token += c
68 return 1
69}
70
71const SEPARATORS = new Set([';', '&', '|', '\n', '(', ')'])
72
73/** An `&` that belongs to a redirection (`2>&1`, `<&3`, `&>out`), not a separator. */
74function isRedirectAmp(text: string, i: number): boolean {
75 return text[i] === '&' && (text[i - 1] === '>' || text[i - 1] === '<' || text[i + 1] === '>')
76}
77
78/** One character outside quotes; returns how many characters it used. */
79function unquoted(s: Scan, text: string, i: number): number {
80 const c = text[i] ?? ''
81 if (SEPARATORS.has(c) && !isRedirectAmp(text, i)) endSegment(s)
82 else if (c === ' ' || c === '\t') endToken(s)
83 else if (c === '"' || c === "'") {
84 s.quote = c
85 s.started = true
86 } else if (c === '\\' && i + 1 < text.length) {
87 s.token += text[i + 1]
88 s.started = true
89 return 2
90 } else {
91 s.token += c
92 s.started = true
93 }
94 return 1
95}
96
97/** A redirection such as `>out`, `2>&1`, `&>out`, `<in`; with a bare operator the target is the next word. */
98const REDIRECT = /^(?:\d*|&)[<>]/
99const BARE_REDIRECT = /^(?:\d*|&)[<>]+$/
100
101function withoutRedirections(words: readonly string[]): string[] {
102 const kept: string[] = []
103 for (let i = 0; i < words.length; i++) {
104 const word = words[i] ?? ''
105 if (!REDIRECT.test(word)) kept.push(word)
106 else if (BARE_REDIRECT.test(word)) i++
107 }
108 return kept
109}
110
111/** The simple commands of a command line, each as its words with the quotes and redirections removed. */
112export function splitCommand(text: string): string[][] {
113 const s: Scan = { segments: [], tokens: [], token: '', started: false, quote: undefined }
114 const source = stripHeredocs(text)
115 for (let i = 0; i < source.length; ) i += s.quote === undefined ? unquoted(s, source, i) : quoted(s, source, i)
116 endSegment(s)
117 return s.segments.map(withoutRedirections).filter(words => words.length > 0)
118}
119
120/**
121 * One git call of a command. `where` is the directory it runs in, relative to the directory the command
122 * starts in ('' for that directory itself) or absolute, and null when a `cd` went somewhere the command
123 * text does not say (`cd -`, `cd ~`, `cd $DIR`).
124 */
125export type GitCall = { env: string[]; configs: string[]; where: string | null; sub: string; args: string[] }
126
127/** Words that run the command after them. */
128const WRAPPERS = new Set(['command', 'exec', 'time', 'nohup', 'env'])
129
130/** Where the command's words begin: past the wrappers and the leading variable assignments (`NAME=value`). */
131function commandStart(words: readonly string[]): { env: string[]; at: number } {
132 const env: string[] = []
133 let at = 0
134 for (;;) {
135 const word = words[at] ?? ''
136 if (WRAPPERS.has(word)) at++
137 else if (/^[A-Za-z_]\w*=/.test(word)) env.push(words[at++] ?? '')
138 else return { env, at }
139 }
140}
141
142/** git's own options that take the next word as their value. */
143const GIT_VALUES = new Set(['-C', '-c', '--git-dir', '--work-tree', '--namespace', '--config-env', '--super-prefix'])
144
145type GitHead = { dir: string; configs: string[]; at: number }
146
147/** git's own options before the subcommand, from `start`: the `-C` directories, the `-c` settings, and where the subcommand is. */
148function gitOptions(words: readonly string[], start: number): GitHead {
149 const head: GitHead = { dir: '', configs: [], at: start }
150 while ((words[head.at] ?? '').startsWith('-')) {
151 const option = words[head.at++] ?? ''
152 if (!GIT_VALUES.has(option)) continue
153 const value = words[head.at++] ?? ''
154 if (option === '-C') head.dir = joinPath(head.dir, value)
155 else if (option === '-c') head.configs.push(value)
156 }
157 return head
158}
159
160/** The git call of one simple command, or undefined when it runs no git subcommand. */
161function gitCall(words: readonly string[], cwd: string | null): GitCall | undefined {
162 const { env, at } = commandStart(words)
163 if (!/(^|\/)git$/.test(words[at] ?? '')) return undefined
164 const head = gitOptions(words, at + 1)
165 const sub = words[head.at]
166 if (sub === undefined) return undefined
167 const where = cwd === null || /[$~]/.test(head.dir) ? null : joinPath(cwd, head.dir)
168 return { env, configs: head.configs, where, sub, args: words.slice(head.at + 1) }
169}
170
171/** The directory after a `cd`, or null when the command text does not say where it goes. */
172function cdTarget(words: readonly string[], cwd: string | null): string | null {
173 const target = words[1]
174 if (cwd === null || target === undefined || words.length > 2) return null
175 if (target === '-' || target.startsWith('~') || target.includes('$')) return null
176 return joinPath(cwd, target)
177}
178
179/** The git calls of a command in the order they run, each with the directory it runs in. */
180export function gitCalls(command: string): GitCall[] {
181 const calls: GitCall[] = []
182 let cwd: string | null = ''
183 for (const words of splitCommand(command)) {
184 if (words[0] === 'cd') cwd = cdTarget(words, cwd)
185 const call = gitCall(words, cwd)
186 if (call !== undefined) calls.push(call)
187 }
188 return calls
189}
190
191/** Adds one path segment: `.` and empty ones drop, `..` takes the last segment back where there is one. */
192function pushPart(parts: string[], part: string, absolute: boolean): void {
193 if (part === '' || part === '.') return
194 const last = parts[parts.length - 1]
195 if (part !== '..') parts.push(part)
196 else if (last !== undefined && last !== '..') parts.pop()
197 else if (!absolute) parts.push(part)
198}
199
200/** `rel` read from `base`, with `.` and `..` resolved; an absolute `rel` stands alone, and '' is `base` itself. */
201export function joinPath(base: string, rel: string): string {
202 const start = rel.startsWith('/') || base === '' ? rel : `${base}/${rel}`
203 const absolute = start.startsWith('/')
204 const parts: string[] = []
205 for (const part of start.split('/')) pushPart(parts, part, absolute)
206 const joined = parts.join('/')
207 return absolute ? `/${joined}` : joined
208}
209hooks/context.ts 86 lines1/**
2 * The repository state the mod appends to the skill's text, so the model
3 * starts from the branch, the status and the style without running them.
4 */
5import type { Style } from './message.ts'
6
7/** The paths of `git status`, by what the commit would do with them. */
8export type Status = { branch: string; staged: string[]; unstaged: string[]; untracked: string[]; conflicted: string[] }
9
10const CONFLICT = new Set(['DD', 'AU', 'UD', 'UA', 'DU', 'AA', 'UU'])
11
12/** Files one status entry adds to. */
13function sort(s: Status, code: string, path: string): void {
14 if (code === '??') s.untracked.push(path)
15 else if (CONFLICT.has(code)) s.conflicted.push(path)
16 else {
17 if (code[0] !== ' ') s.staged.push(path)
18 if (code[1] !== ' ') s.unstaged.push(path)
19 }
20}
21
22/** Reads `git status --porcelain=v1 -z --branch`: the `## ` branch entry, then `XY path` entries; a rename carries its source next. */
23export function parseStatus(stdout: string): Status {
24 const s: Status = { branch: '', staged: [], unstaged: [], untracked: [], conflicted: [] }
25 const entries = stdout.split('\0')
26 for (let i = 0; i < entries.length; i++) {
27 const entry = entries[i] ?? ''
28 if (entry.startsWith('## ')) s.branch = entry.slice(3)
29 else if (entry.length > 3) {
30 const code = entry.slice(0, 2)
31 sort(s, code, entry.slice(3))
32 if (/[RC]/.test(code[0] ?? '')) i++
33 }
34 }
35 return s
36}
37
38/** Reads `git diff --numstat -z --no-renames`: the paths and the lines added and deleted; a binary file counts no lines. */
39export function readNumstat(stdout: string): { names: string[]; lines: number } {
40 const names: string[] = []
41 let lines = 0
42 for (const entry of stdout.split('\0')) {
43 const [added = '', deleted = '', ...path] = entry.split('\t')
44 if (path.length === 0) continue
45 names.push(path.join('\t'))
46 lines += (Number(added) || 0) + (Number(deleted) || 0)
47 }
48 return { names, lines }
49}
50
51const SHOWN = 40
52
53/** One list of paths, cut at 40. */
54function listLine(label: string, paths: readonly string[]): string {
55 if (paths.length === 0) return `${label}: none`
56 const cut = paths.length > SHOWN ? `, and ${paths.length - SHOWN} more (run git status for the whole list)` : ''
57 return `${label} (${paths.length}): ${paths.slice(0, SHOWN).join(', ')}${cut}`
58}
59
60/** The style line: what the recent subjects follow, and the skill's default when they follow nothing. */
61export function styleLine(style: Style): string {
62 if (style.count === 0) return 'Style: no commits yet; the skill\'s default is `type(scope): subject`'
63 if (!style.isConventional) return `Style: ${style.conventional} of the last ${style.count} subjects are conventional; follow the style the subjects below show, or the skill's default \`type(scope): subject\` when they show none`
64 const cased = style.lowercase === true ? ', the description starts lowercase' : style.lowercase === false ? ', the description starts uppercase' : ''
65 return `Style: conventional commits (${style.conventional} of the last ${style.count} subjects)${cased}`
66}
67
68/** The block the model reads after the skill's text. */
69export function stateBlock(status: Status, subjects: readonly string[], style: Style, options: ReadonlySet<string>, warnings: readonly string[]): string {
70 const lines = [
71 '## Current repository state (git-commit)',
72 '',
73 `Branch: ${status.branch === '' ? 'unknown' : status.branch}`,
74 listLine('Staged', status.staged),
75 listLine('Unstaged', status.unstaged),
76 listLine('Untracked', status.untracked),
77 listLine('Conflicted', status.conflicted),
78 `Options this skill was opened with: ${options.size === 0 ? 'none' : [...options].map(o => `--${o}`).join(' ')}`,
79 styleLine(style),
80 'Recent subjects:',
81 ...(subjects.length === 0 ? [' (none)'] : subjects.slice(0, 10).map(s => ` ${s}`)),
82 ]
83 if (warnings.length > 0) lines.push('Warnings:', ...warnings.map(w => ` - ${w}`))
84 return lines.join('\n')
85}
86hooks/finding.ts 16 lines1/**
2 * One rule a git command broke. A `deny` finding stops the command in the
3 * `deny` mode and reaches the model as a note in the `note` mode; a `note`
4 * finding is a heuristic and only ever reaches the model as a note.
5 * `short` is the person's line, `text` the model's.
6 */
7export type Finding = { level: 'deny' | 'note'; short: string; text: string }
8
9export function deny(short: string, text: string): Finding {
10 return { level: 'deny', short, text }
11}
12
13export function note(short: string, text: string): Finding {
14 return { level: 'note', short, text }
15}
16hooks/gitargs.ts 224 lines1/**
2 * Reads the arguments of the git subcommands the mod rules on, the way git's
3 * own option parser reads them: short clusters (`-am`), attached values
4 * (`-mtext`, `--message=text`), a value in the next word, and `--`.
5 */
6
7/** The options a call named, by their long name, the values of the valued ones, and the operands. */
8export type Parsed = { flags: Set<string>; values: Map<string, string[]>; operands: string[] }
9
10/**
11 * One subcommand's options: short letters and long names mapped to one name, the ones that take the next
12 * word as their value, and the short ones whose value can only be attached (`-S<key>`).
13 */
14type Spec = {
15 short: Partial<Record<string, string>>
16 shortValue: Partial<Record<string, string>>
17 shortAttached: Partial<Record<string, string>>
18 long: Partial<Record<string, string>>
19 longValue: Partial<Record<string, string>>
20}
21
22/** A spec with the tables it does not name left empty. */
23function spec(given: Partial<Spec>): Spec {
24 return { short: {}, shortValue: {}, shortAttached: {}, long: {}, longValue: {}, ...given }
25}
26
27function put(p: Parsed, name: string, value: string): void {
28 p.flags.add(name)
29 p.values.set(name, [...(p.values.get(name) ?? []), value])
30}
31
32/** One letter of a short cluster; returns the words the cluster used when this letter ends it, else 0. */
33function shortLetter(p: Parsed, s: Spec, word: string, i: number, next: string | undefined): number {
34 const c = word[i] ?? ''
35 const rest = word.slice(i + 1)
36 const attached = s.shortAttached[c]
37 if (attached !== undefined) {
38 put(p, attached, rest)
39 return 1
40 }
41 const valued = s.shortValue[c]
42 if (valued === undefined) {
43 p.flags.add(s.short[c] ?? c)
44 return 0
45 }
46 put(p, valued, rest === '' ? (next ?? '') : rest)
47 return rest === '' && next !== undefined ? 2 : 1
48}
49
50/** One short cluster (`-am`, `-mtext`); returns how many words it used. */
51function parseShort(p: Parsed, s: Spec, word: string, next: string | undefined): number {
52 for (let i = 1; i < word.length; i++) {
53 const used = shortLetter(p, s, word, i, next)
54 if (used > 0) return used
55 }
56 return 1
57}
58
59/** The known long name `key` abbreviates, as git accepts a unique prefix; `key` itself when none or several fit. */
60function fullName(s: Spec, key: string): string {
61 const names = [...Object.keys(s.long), ...Object.keys(s.longValue)]
62 if (names.includes(key) || key.length < 3) return key
63 const fits = names.filter(name => name.startsWith(key))
64 return fits.length === 1 ? (fits[0] ?? key) : key
65}
66
67/** One long option (`--all`, `--message=text`, `--message text`); returns how many words it used. */
68function parseLong(p: Parsed, s: Spec, word: string, next: string | undefined): number {
69 const eq = word.indexOf('=')
70 const key = fullName(s, eq < 0 ? word.slice(2) : word.slice(2, eq))
71 const valued = s.longValue[key]
72 if (valued !== undefined && eq < 0) {
73 put(p, valued, next ?? '')
74 return next === undefined ? 1 : 2
75 }
76 const name = valued ?? s.long[key] ?? key
77 if (eq < 0) p.flags.add(name)
78 else put(p, name, word.slice(eq + 1))
79 return 1
80}
81
82/** Reads a subcommand's arguments; `--` ends the options and is itself kept as a flag. */
83function parseArgs(args: readonly string[], spec: Spec): Parsed {
84 const p: Parsed = { flags: new Set(), values: new Map(), operands: [] }
85 for (let i = 0; i < args.length; ) {
86 const word = args[i] ?? ''
87 if (word === '--') {
88 p.flags.add('--')
89 p.operands.push(...args.slice(i + 1))
90 break
91 }
92 if (word.startsWith('--')) i += parseLong(p, spec, word, args[i + 1])
93 else if (word.startsWith('-') && word !== '-') i += parseShort(p, spec, word, args[i + 1])
94 else {
95 p.operands.push(word)
96 i++
97 }
98 }
99 return p
100}
101
102/** Names that map to themselves. */
103function same(...names: string[]): Record<string, string> {
104 return Object.fromEntries(names.map(name => [name, name]))
105}
106
107const ADD = spec({
108 short: { A: 'all', u: 'update', f: 'force', i: 'interactive', p: 'patch', e: 'edit', n: 'dry-run', N: 'intent-to-add', v: 'verbose' },
109 long: { ...same('all', 'update', 'force', 'interactive', 'patch', 'edit', 'dry-run', 'intent-to-add', 'verbose', 'help'), 'no-ignore-removal': 'all' },
110 longValue: same('pathspec-from-file', 'chmod'),
111})
112
113const COMMIT = spec({
114 short: { a: 'all', n: 'no-verify', p: 'patch', i: 'include', o: 'only', e: 'edit', v: 'verbose', q: 'quiet', s: 'signoff', z: 'null', h: 'help' },
115 shortValue: { m: 'message', F: 'file', C: 'reuse', c: 'reuse', t: 'template' },
116 shortAttached: { S: 'gpg-sign', u: 'untracked-files' },
117 long: {
118 ...same('all', 'no-verify', 'patch', 'interactive', 'amend', 'allow-empty', 'allow-empty-message', 'dry-run', 'help', 'only', 'include', 'no-edit', 'edit', 'signoff', 'verbose', 'quiet'),
119 short: 'dry-run',
120 porcelain: 'dry-run',
121 long: 'dry-run',
122 },
123 longValue: { ...same('message', 'file', 'fixup', 'squash', 'author', 'date', 'template', 'cleanup', 'trailer', 'pathspec-from-file'), 'reuse-message': 'reuse', 'reedit-message': 'reuse' },
124})
125
126/** What a `git add` names: its options and its paths. */
127export function addArgs(args: readonly string[]): Parsed {
128 return parseArgs(args, ADD)
129}
130
131/** What a `git commit` names: its options, the values of `-m`, `-F`, `--trailer` and the rest, and its pathspec. */
132export function commitArgs(args: readonly string[]): Parsed {
133 return parseArgs(args, COMMIT)
134}
135
136/** Whether a `git commit` records nothing: a dry run, a status listing or the help. */
137export function isDryCommit(p: Parsed): boolean {
138 return p.flags.has('dry-run') || p.flags.has('help')
139}
140
141const PUSH = spec({
142 short: { n: 'dry-run', f: 'force', u: 'set-upstream', q: 'quiet', v: 'verbose', d: 'delete', h: 'help' },
143 long: same('no-verify', 'dry-run', 'force', 'set-upstream', 'delete', 'help', 'tags', 'all', 'mirror'),
144 longValue: same('repo', 'receive-pack', 'exec', 'push-option', 'recurse-submodules'),
145})
146
147/** What a `git push` names. */
148export function pushArgs(args: readonly string[]): Parsed {
149 return parseArgs(args, PUSH)
150}
151
152const REBASE = spec({
153 short: { i: 'interactive', h: 'help' },
154 shortValue: { x: 'exec', s: 'strategy', X: 'strategy-option' },
155 long: same('interactive', 'help'),
156 longValue: same('onto', 'exec', 'strategy', 'strategy-option'),
157})
158
159/** Whether a `git rebase` opens the interactive editor. */
160export function isInteractiveRebase(args: readonly string[]): boolean {
161 return parseArgs(args, REBASE).flags.has('interactive')
162}
163
164const CONFIG = spec({
165 short: { l: 'list', e: 'edit', z: 'null' },
166 shortValue: { f: 'file' },
167 long: same('list', 'edit', 'get', 'get-all', 'get-regexp', 'get-urlmatch', 'get-color', 'get-colorbool', 'add', 'unset', 'unset-all', 'replace-all', 'rename-section', 'remove-section', 'global', 'system', 'local', 'worktree', 'show-origin', 'show-scope', 'name-only'),
168 longValue: same('file', 'blob', 'type', 'default', 'comment', 'value'),
169})
170
171const CONFIG_WRITE_SUBS = new Set(['set', 'unset', 'rename-section', 'remove-section', 'edit'])
172const CONFIG_READ_SUBS = new Set(['get', 'list'])
173const CONFIG_WRITE_FLAGS = ['add', 'unset', 'unset-all', 'replace-all', 'rename-section', 'remove-section', 'edit']
174const CONFIG_READ_FLAGS = ['get', 'get-all', 'get-regexp', 'get-urlmatch', 'get-color', 'get-colorbool', 'list']
175
176/** Whether a `git config` changes a setting: a write subcommand or option, or a key with a value. */
177export function isConfigWrite(args: readonly string[]): boolean {
178 const p = parseArgs(args, CONFIG)
179 const first = p.operands[0] ?? ''
180 if (CONFIG_WRITE_SUBS.has(first)) return true
181 if (CONFIG_READ_SUBS.has(first)) return false
182 if (CONFIG_WRITE_FLAGS.some(flag => p.flags.has(flag))) return true
183 if (CONFIG_READ_FLAGS.some(flag => p.flags.has(flag))) return false
184 return p.operands.length >= 2
185}
186
187const BRANCH = spec({
188 short: { d: 'delete', D: 'delete', m: 'move', M: 'move', c: 'copy', C: 'copy', f: 'force', a: 'all', r: 'remotes', l: 'list', v: 'verbose', q: 'quiet', t: 'track' },
189 shortValue: { u: 'set-upstream-to' },
190 long: same('delete', 'move', 'copy', 'force', 'all', 'remotes', 'list', 'verbose', 'quiet', 'track', 'no-track', 'show-current', 'edit-description', 'unset-upstream'),
191 longValue: same('set-upstream-to', 'contains', 'no-contains', 'merged', 'no-merged', 'points-at', 'sort', 'format'),
192})
193
194const BRANCH_CHANGE = ['delete', 'move', 'copy']
195const BRANCH_READ = ['list', 'all', 'remotes', 'show-current', 'contains', 'no-contains', 'merged', 'no-merged', 'points-at', 'edit-description', 'set-upstream-to', 'unset-upstream']
196
197/** Whether a `git branch` creates, renames, copies or deletes a branch, rather than listing or configuring one. */
198export function isBranchChange(args: readonly string[]): boolean {
199 const p = parseArgs(args, BRANCH)
200 if (BRANCH_CHANGE.some(flag => p.flags.has(flag))) return true
201 if (BRANCH_READ.some(flag => p.flags.has(flag))) return false
202 return p.operands.length > 0
203}
204
205const CHECKOUT = spec({
206 short: { f: 'force', q: 'quiet', m: 'merge', p: 'patch', l: 'reflog', t: 'track' },
207 shortValue: { b: 'create', B: 'create' },
208 long: same('detach', 'force', 'merge', 'patch', 'track', 'no-track', 'quiet', 'ours', 'theirs', 'guess', 'no-guess', 'overlay', 'no-overlay', 'progress'),
209 longValue: same('orphan', 'conflict', 'pathspec-from-file'),
210})
211
212/**
213 * What a `git checkout` does: `branch` when it creates or switches a branch, `paths` when it restores
214 * files, `none` when it names nothing, and the single operand when only the file system can tell (a path
215 * that exists is a restore, anything else a switch).
216 */
217export function checkoutKind(args: readonly string[]): 'branch' | 'paths' | 'none' | { operand: string } {
218 const p = parseArgs(args, CHECKOUT)
219 if (['create', 'orphan', 'detach'].some(flag => p.flags.has(flag))) return 'branch'
220 if (p.flags.has('--') || p.flags.has('pathspec-from-file') || p.operands.length >= 2) return 'paths'
221 const operand = p.operands[0]
222 return operand === undefined ? 'none' : { operand }
223}
224hooks/message.ts 167 lines1/**
2 * The message a `git commit` records, read from the command, and the skill's
3 * rules for it measured against the repository's own style.
4 */
5import type { Heredoc } from './command.ts'
6import { deny, note, type Finding } from './finding.ts'
7import type { Parsed } from './gitargs.ts'
8
9/** The message the commit records, or why the command does not say it. */
10export type Message = { text: string } | { unknown: string }
11
12/** The heredoc a `$(cat <<'EOF'` value stands for: the first unused one with that end word. */
13function heredocFor(value: string, heredocs: readonly Heredoc[], used: Set<number>): string | undefined {
14 const tag = /<<-?\s*['"]?([A-Za-z_]\w*)/.exec(value)?.[1]
15 const at = heredocs.findIndex((h, i) => !used.has(i) && h.tag === tag && h.opener.includes('$('))
16 if (tag === undefined || at < 0) return undefined
17 used.add(at)
18 return heredocs[at]?.body
19}
20
21/** The heredoc a `-F -` reads: the first unused one that is not inside a `$(...)`. */
22function stdinHeredoc(heredocs: readonly Heredoc[], used: Set<number>): string | undefined {
23 const at = heredocs.findIndex((h, i) => !used.has(i) && !h.opener.includes('$('))
24 if (at < 0) return undefined
25 used.add(at)
26 return heredocs[at]?.body
27}
28
29/** One `-m` value as git receives it, or undefined when the shell builds it at run time. */
30function messagePart(value: string, heredocs: readonly Heredoc[], used: Set<number>): string | undefined {
31 if (value.includes('<<')) return heredocFor(value, heredocs, used)
32 return /\$[({A-Za-z_]|`/.test(value) ? undefined : value
33}
34
35/** The text of the `-m` values, joined as git joins them, or why one of them cannot be read. */
36function fromMessages(values: readonly string[], heredocs: readonly Heredoc[], used: Set<number>): Message {
37 const parts: string[] = []
38 for (const value of values) {
39 const part = messagePart(value, heredocs, used)
40 if (part === undefined) return { unknown: 'the shell builds the message when the command runs' }
41 parts.push(part.trim())
42 }
43 return { text: parts.join('\n\n') }
44}
45
46/** The text a `-F <file>` names: the heredoc for `-F -`, else the file's text as the caller read it. */
47function fromFile(file: string, heredocs: readonly Heredoc[], used: Set<number>, fileText: string | undefined): Message {
48 const text = file === '-' ? stdinHeredoc(heredocs, used) : fileText
49 if (text !== undefined) return { text }
50 return { unknown: file === '-' ? 'the message comes from standard input' : `the message file ${file} was not read` }
51}
52
53/**
54 * The message a commit records. `fileText` is the text of the `-F` file when the caller read it.
55 * `--trailer` values join the text, since git appends them as trailer lines.
56 */
57export function messageOf(c: Parsed, heredocs: readonly Heredoc[], used: Set<number>, fileText?: string): Message {
58 if (['reuse', 'fixup', 'squash'].some(flag => c.flags.has(flag))) return { unknown: 'the message comes from another commit' }
59 const messages = c.values.get('message') ?? []
60 const file = c.values.get('file')?.[0]
61 let found: Message = { unknown: c.flags.has('amend') ? 'the commit keeps the message of the commit it amends' : 'no message was given, so git opens an editor' }
62 if (messages.length > 0) found = fromMessages(messages, heredocs, used)
63 else if (file !== undefined) found = fromFile(file, heredocs, used, fileText)
64 const trailers = c.values.get('trailer') ?? []
65 if (!('text' in found) || trailers.length === 0) return found
66 return { text: `${found.text}\n\n${trailers.join('\n')}` }
67}
68
69/** The types the skill lists, core and extended. */
70export const TYPES = new Set([
71 'feat', 'fix', 'docs', 'style', 'refactor', 'perf', 'test', 'chore', 'ci', 'security', 'hotfix', 'revert',
72 'lint', 'move', 'arch', 'deps-add', 'deps-remove', 'deps-pin',
73 'format', 'patch', 'catch', 'remove', 'typo', 'comments', 'deprecate',
74 'init', 'seed', 'ux', 'a11y', 'i18n', 'animation', 'ui', 'responsive',
75 'db', 'analytics', 'logs', 'logs-remove', 'backup', 'metrics', 'flags',
76 'release', 'wip', 'ci-fix', 'ci-build', 'merge', 'license', 'breaking',
77 'experiment', 'mock', 'snapshots', 'experimental', 'dx',
78 'docs-api', 'docs-readme', 'types', 'business', 'assets', 'gitignore',
79 'dead', 'cleanup', 'validation', 'thread', 'offline',
80])
81
82/** A conventional subject: its type, its scope, and the first character of its description. */
83const CONVENTIONAL = /^([a-z][a-z0-9-]*)(\([^()]*\))?!?: (\S)/
84
85/**
86 * The style of the repository's recent subjects. `conventional` needs three subjects and a majority;
87 * `lowercase` says how a subject starts after its prefix, undefined when neither case leads.
88 */
89export type Style = { count: number; conventional: number; isConventional: boolean; lowercase?: boolean }
90
91export function styleOf(subjects: readonly string[]): Style {
92 const firsts = subjects.map(s => CONVENTIONAL.exec(s)?.[3]).filter((c): c is string => c !== undefined)
93 const lower = firsts.filter(c => c !== c.toUpperCase()).length
94 const upper = firsts.filter(c => c !== c.toLowerCase()).length
95 const style: Style = { count: subjects.length, conventional: firsts.length, isConventional: subjects.length >= 3 && firsts.length * 2 > subjects.length }
96 if (lower !== upper) style.lowercase = lower > upper
97 return style
98}
99
100const AI = String.raw`(?:claude|anthropic|openai|chatgpt|gpt|copilot|gemini|codex|cursor|\bai\b|assistant|llm)`
101
102/** Lines that sign a commit as the work of an AI tool. */
103const SIGNATURES = [
104 new RegExp(String.raw`^co-authored-by:[^\n]*${AI}`, 'im'),
105 new RegExp(String.raw`^[^A-Za-z0-9\n]*(?:generated|created|written|authored|made)\s+(?:with|by|using)\b[^\n]*${AI}`, 'im'),
106 /noreply@anthropic\.com/i,
107 /🤖/u,
108]
109
110/** The first line that signs the commit as AI work, or undefined. */
111export function signatureLine(text: string): string | undefined {
112 for (const re of SIGNATURES) {
113 const m = re.exec(text)
114 if (m === null) continue
115 const start = text.lastIndexOf('\n', m.index) + 1
116 const end = text.indexOf('\n', m.index)
117 return text.slice(start, end < 0 ? undefined : end).trim()
118 }
119 return undefined
120}
121
122/** Imperative verbs that end like a past tense or a gerund. */
123const IMPERATIVE_ED_ING = new Set(['embed', 'need', 'seed', 'feed', 'bleed', 'breed', 'exceed', 'proceed', 'succeed', 'shed', 'shred', 'speed', 'bring', 'ring', 'sing', 'sting', 'string', 'swing', 'spring', 'fling', 'cling', 'ping'])
124
125/** The hard rules of the first line: present, at most 72 characters, no closing period. */
126function lineFindings(subject: string): Finding[] {
127 const length = [...subject].length
128 if (length === 0) return [deny('empty subject', 'The commit message has an empty first line. Write a subject line.')]
129 const out: Finding[] = []
130 if (length > 72) out.push(deny('subject over 72 characters', `The subject line is ${length} characters long; the skill's limit is 72. Shorten it and move the detail into the body.`))
131 if (subject.endsWith('.')) out.push(deny('subject ends with a period', 'The subject line ends with a period, which the skill does not allow. Drop the period.'))
132 return out
133}
134
135/** The conventional rules, where the repository's recent subjects follow them. */
136function styleFindings(subject: string, style: Style): Finding[] {
137 if (!style.isConventional) return []
138 const m = CONVENTIONAL.exec(subject)
139 const share = `${style.conventional} of the last ${style.count} subjects`
140 if (m === null) return [deny('subject is not type(scope): subject', `This repository writes conventional subjects (${share}), and this subject is not \`type(scope): subject\`. Rewrite it, for example \`fix(parser): handle empty input\`.`)]
141 const type = m[1] ?? ''
142 if (!TYPES.has(type)) return [deny(`unknown type ${type}`, `\`${type}\` is not a type the commit skill lists. Use one of its types, such as feat, fix, docs, refactor, test or chore.`)]
143 const first = m[3] ?? ''
144 const isLower = first !== first.toUpperCase()
145 const isUpper = first !== first.toLowerCase()
146 if (style.lowercase === true && isUpper) return [note('subject case', `This repository starts its subjects lowercase after the type (${share}), and this one starts uppercase.`)]
147 if (style.lowercase === false && isLower) return [note('subject case', `This repository starts its subjects uppercase after the type (${share}), and this one starts lowercase.`)]
148 return []
149}
150
151/** A note when the subject's first word reads as a past tense or a gerund, not an imperative. */
152function moodFindings(subject: string): Finding[] {
153 const word = (subject.replace(CONVENTIONAL, '$3').split(/\s+/)[0] ?? '').toLowerCase()
154 if (!/^[a-z]+(?:ed|ing)$/.test(word) || IMPERATIVE_ED_ING.has(word)) return []
155 return [note('subject not imperative', `The subject starts with "${word}"; the skill writes subjects in the imperative mood ("add", not "added" or "adding").`)]
156}
157
158/** Every rule of the skill the message breaks, measured against the repository's style. */
159export function messageFindings(text: string, style: Style): Finding[] {
160 const subject = (text.split('\n')[0] ?? '').trim()
161 const signature = signatureLine(text)
162 const signed = signature === undefined ? [] : [deny('AI signature in the message', `The message carries an AI signature line (\`${signature}\`), which the skill never adds. Remove that line.`)]
163 const line = lineFindings(subject)
164 if (subject === '') return [...signed, ...line]
165 return [...signed, ...line, ...styleFindings(subject, style), ...moodFindings(subject)]
166}
167hooks/rules.ts 218 lines1/**
2 * The skill's rules as findings: what each git call broke, given what the
3 * command names and what the caller measured in the repository.
4 */
5import type { GitCall } from './command.ts'
6import { deny, note, type Finding } from './finding.ts'
7import type { Parsed } from './gitargs.ts'
8import type { SecretHit } from './secrets.ts'
9
10/** The skill as the model calls it. */
11export const SKILL = 'git-commit:commit'
12
13/** The skill options the mod reads: each opens one operation the skill otherwise refuses. */
14export const OPTIONS = ['all', 'staged', 'modified', 'no-verify', 'amend', 'push'] as const
15
16/** The skill options named in an argument string (`--push --amend`). */
17export function optionsOf(args: string): Set<string> {
18 const words = new Set(args.split(/\s+/).map(w => w.replace(/^--/, '')))
19 return new Set(OPTIONS.filter(option => words.has(option)))
20}
21
22/** The `ARGUMENTS:` line the engine appends to a skill's text, '' when it has none. */
23export function argumentsOf(text: string): string {
24 const at = text.lastIndexOf('\nARGUMENTS: ')
25 return at < 0 ? '' : (text.slice(at + 12).split('\n')[0] ?? '')
26}
27
28/** The options a typed `/git-commit:commit ...` prompt opens, or undefined when the prompt is not one. */
29export function typedSkill(prompt: string): Set<string> | undefined {
30 const m = /^\/git-commit:commit(?:\s+([\s\S]*))?$/.exec(prompt.trim())
31 return m === null ? undefined : optionsOf(m[1] ?? '')
32}
33
34/** Environment variables that switch a repository's hook runner off. */
35const HOOK_SKIPS = /^(?:HUSKY=0|HUSKY_SKIP_HOOKS=\S+|SKIP=\S+|LEFTHOOK=0)$/
36
37/** A call that skips the repository's hooks through `-c core.hooksPath` or a hook runner's variable. */
38export function hookSkipFindings(call: GitCall, options: ReadonlySet<string> | undefined): Finding[] {
39 const out: Finding[] = []
40 if (call.configs.some(c => /^core\.hookspath=/i.test(c))) {
41 out.push(deny('-c core.hooksPath', '`-c core.hooksPath=...` replaces the repository\'s hooks for this call. Run it without that setting; when the user asked to skip the hooks, the skill uses `--no-verify`.'))
42 }
43 const skip = call.env.find(v => HOOK_SKIPS.test(v))
44 if (skip !== undefined && options?.has('no-verify') !== true) {
45 out.push(deny(`${skip} skips hooks`, `\`${skip}\` switches the repository's hooks off, which the skill allows only when the user passed --no-verify. Run the command without it.`))
46 }
47 return out
48}
49
50const SKILL_TEXT = `A git commit runs only after the ${SKILL} skill was opened in this turn, by this agent. Call the Skill tool with skill "${SKILL}" and, as args, the options the user gave (such as --push or --amend), follow its steps, then run the commit again.`
51
52/** A commit flag the skill allows only with one of its options. */
53type Gated = { flag: string; options: string[]; short: string; text: string }
54
55const GATED: Gated[] = [
56 { flag: 'no-verify', options: ['no-verify'], short: '--no-verify', text: '`--no-verify` skips the repository\'s hooks; the skill allows it only when the user passed --no-verify. Run the commit without it.' },
57 { flag: 'amend', options: ['amend'], short: '--amend', text: '`--amend` rewrites the last commit; the skill allows it only when the user passed --amend. Make a new commit instead.' },
58 { flag: 'all', options: ['all', 'modified'], short: 'commit -a', text: '`git commit -a` stages every modified tracked file; the skill stages the files of this change by explicit path, and allows -a only when the user passed --all or --modified. Run `git add <path>...` for this change\'s files, then commit.' },
59]
60
61/** Commit flags the skill never uses. */
62const NEVER: { flag: string; short: string; text: string }[] = [
63 { flag: 'allow-empty', short: '--allow-empty', text: '`--allow-empty` records a commit with no change, which the skill never does.' },
64 { flag: 'allow-empty-message', short: '--allow-empty-message', text: '`--allow-empty-message` records a commit without a message, which the skill never does.' },
65 { flag: 'interactive', short: 'commit --interactive', text: '`git commit --interactive` opens an interactive session, which does not work here. Stage explicit paths with `git add <path>`.' },
66 { flag: 'patch', short: 'commit -p', text: '`git commit -p` asks which hunks to take interactively, which does not work here. Stage explicit paths with `git add <path>`.' },
67]
68
69/** What a `git commit` breaks by its options alone: the skill not opened, a gated flag, a flag the skill never uses. */
70export function commitFlagFindings(c: Parsed, options: ReadonlySet<string> | undefined): Finding[] {
71 if (options === undefined) return [deny('skill not opened', SKILL_TEXT)]
72 const gated = GATED.filter(g => c.flags.has(g.flag) && !g.options.some(o => options.has(o)))
73 const never = NEVER.filter(n => c.flags.has(n.flag))
74 return [...gated, ...never].map(f => deny(f.short, f.text))
75}
76
77/** Why an operand is more than a list of this change's files, or undefined when it names a file. */
78export function blanketReason(operand: string): string | undefined {
79 if (/^(?:\.{1,2}\/?|\*)$/.test(operand)) return 'names a whole directory tree'
80 if (operand.startsWith(':')) return 'is a pathspec that reaches past the named files'
81 if (/[*?[]/.test(operand)) return 'is a glob'
82 return operand.endsWith('/') ? 'is a directory' : undefined
83}
84
85/** `git add` options the skill never uses. */
86const ADD_NEVER: { flag: string; short: string; text: string }[] = [
87 { flag: 'interactive', short: 'add -i', text: '`git add -i` opens an interactive session, which does not work here. Name the files: `git add <path> <path>`.' },
88 { flag: 'patch', short: 'add -p', text: '`git add -p` asks which hunks to take interactively, which does not work here. Name the files: `git add <path> <path>`.' },
89 { flag: 'edit', short: 'add -e', text: '`git add -e` opens an editor, which does not work here. Name the files: `git add <path> <path>`.' },
90 { flag: 'pathspec-from-file', short: 'add --pathspec-from-file', text: '`--pathspec-from-file` hides which files are staged. Name the files: `git add <path> <path>`.' },
91]
92
93/** What a `git add` breaks by its options and operands, before the file system is asked. */
94export function addFlagFindings(a: Parsed, options: ReadonlySet<string> | undefined): Finding[] {
95 const out = ADD_NEVER.filter(n => a.flags.has(n.flag)).map(n => deny(n.short, n.text))
96 if (options?.has('all') === true) return out
97 if (a.flags.has('all')) out.push(deny('add -A', '`git add -A` stages every change in the repository, including other work and new files. Name this change\'s files: `git add <path> <path>`.'))
98 if (a.flags.has('update') && options?.has('modified') !== true) out.push(deny('add -u', '`git add -u` stages every modified tracked file. Name this change\'s files: `git add <path> <path>`.'))
99 return [...out, ...pathspecFindings(a.operands, options)]
100}
101
102/** Operands that reach past this change's files; `--all` opens them, since the user asked for everything. */
103export function pathspecFindings(operands: readonly string[], options: ReadonlySet<string> | undefined): Finding[] {
104 if (options?.has('all') === true) return []
105 return operands.flatMap(operand => {
106 const why = blanketReason(operand)
107 return why === undefined ? [] : [blanketFinding(operand, why)]
108 })
109}
110
111/** A staging operand that reaches past this change's files. */
112export function blanketFinding(operand: string, why: string): Finding {
113 return deny(`git add ${operand}`, `\`${operand}\` ${why}, so it can sweep in unrelated changes or another session's work. Name this change's files one by one: \`git add <path> <path>\`.`)
114}
115
116/** One path `git check-ignore -v` matched: the ignore file, its line, the pattern. */
117export type IgnoreHit = { path: string; source: string; line: string; pattern: string }
118
119/** Reads `git check-ignore --no-index -v -z` output; a negated pattern (`!x`) means the path is kept, not ignored. */
120export function ignoreHits(stdout: string): IgnoreHit[] {
121 const fields = stdout.split('\0')
122 const hits: IgnoreHit[] = []
123 for (let i = 0; i + 3 < fields.length; i += 4) {
124 const [source = '', line = '', pattern = '', path = ''] = fields.slice(i, i + 4)
125 if (!pattern.startsWith('!')) hits.push({ path, source, line, pattern })
126 }
127 return hits
128}
129
130/** A path an ignore file names, forced into the index or held by the commit. */
131export function ignoredFindings(hits: readonly IgnoreHit[], how: 'add' | 'commit'): Finding[] {
132 return hits.map(h => {
133 const where = `${h.source}:${h.line} (\`${h.pattern}\`)`
134 const text = how === 'add'
135 ? `\`git add -f\` forces \`${h.path}\`, which ${where} ignores. Leave it out of the index.`
136 : `The commit holds \`${h.path}\`, which ${where} ignores. Take it out of the index with \`git rm --cached -- ${h.path}\`, or leave it out of this commit.`
137 return deny(`ignored ${h.path}`, text)
138 })
139}
140
141/** Staged files whose names hold credentials. */
142export function secretNameFindings(paths: readonly string[]): Finding[] {
143 return paths.map(p => deny(`secret file ${p}`, `The commit holds \`${p}\`, a file name that holds credentials (.env, keys, credential files). Leave it out: \`git restore --staged -- ${p}\`, and warn the user.`))
144}
145
146/** Added lines that look like credentials, named by place and kind only. */
147export function secretLineFindings(hits: readonly SecretHit[]): Finding[] {
148 if (hits.length === 0) return []
149 const places = hits.slice(0, 10).map(h => `${h.path}:${h.line} (${h.kind})`).join(', ')
150 const more = hits.length > 10 ? ` and ${hits.length - 10} more` : ''
151 return [deny(`${hits.length} secret line(s)`, `The commit adds line(s) that look like credentials: ${places}${more}. Remove them or keep those files out of the commit, and warn the user.`)]
152}
153
154/** A note when the commit changes more lines than the skill's split threshold. */
155export function sizeFindings(lines: number): Finding[] {
156 if (lines <= 100) return []
157 return [note(`${lines} lines changed`, `The commit changes ${lines} lines. The skill splits a change over 100 lines when it holds more than one concern.`)]
158}
159
160/** A note when the commit touches files in more than one package or module (the first two path segments). */
161export function spreadFindings(paths: readonly string[]): Finding[] {
162 const units = [...new Set(paths.map(p => p.split('/')).filter(parts => parts.length >= 3).map(parts => parts.slice(0, 2).join('/')))]
163 if (units.length < 2) return []
164 const named = units.slice(0, 6).join(', ') + (units.length > 6 ? `, and ${units.length - 6} more` : '')
165 return [note(`${units.length} areas in one commit`, `The commit touches ${units.length} areas (${named}). The skill splits different modules or packages into separate commits.`)]
166}
167
168/** A note naming the new files a `git add` stages. */
169export function untrackedFindings(paths: readonly string[]): Finding[] {
170 if (paths.length === 0) return []
171 const named = paths.slice(0, 10).join(', ') + (paths.length > 10 ? `, and ${paths.length - 10} more` : '')
172 return [note(`${paths.length} new file(s) staged`, `git add stages ${paths.length} untracked file(s): ${named}. The skill asks the user whether new files belong in the commit, unless the user asked for them or this session created them.`)]
173}
174
175/** A push nobody asked for. */
176export function pushFindings(p: Parsed, asked: boolean, options: ReadonlySet<string> | undefined): Finding[] {
177 if (p.flags.has('dry-run') || p.flags.has('help')) return []
178 const out: Finding[] = []
179 if (!asked) out.push(deny('push not asked', 'The user did not ask for a push: the last prompt does not say push, and the skill was not opened with --push. A plain commit never pushes.'))
180 if (p.flags.has('no-verify') && options?.has('no-verify') !== true) out.push(deny('push --no-verify', '`git push --no-verify` skips the pre-push hook; the skill allows it only when the user passed --no-verify.'))
181 return out
182}
183
184/** A branch operation nobody asked for. */
185export function branchFindings(command: string, asked: boolean): Finding[] {
186 if (asked) return []
187 return [deny(`${command} changes the branch`, `\`${command}\` creates, switches or renames a branch, and the user's last prompt does not ask for a branch operation. A commit stays on the current branch.`)]
188}
189
190export function configFindings(): Finding[] {
191 return [deny('git config write', '`git config` here changes a setting, which the skill never does. Leave the git config as it is.')]
192}
193
194export function rebaseFindings(): Finding[] {
195 return [deny('rebase -i', '`git rebase -i` opens an interactive editor, which does not work here.')]
196}
197
198/** The deny text both the model and the person read. */
199export function denyText(findings: readonly Finding[]): string {
200 const lines = findings.map(f => `- ${f.text}`).join('\n')
201 return `stopped before it ran, because it breaks the ${SKILL} skill:\n${lines}\nThere is no way around this gate.`
202}
203
204/** The model's note after a command ran; `broken` says the mode let rule breaks run. */
205export function noteText(findings: readonly Finding[]): string {
206 const broken = findings.filter(f => f.level === 'deny')
207 const soft = findings.filter(f => f.level === 'note')
208 const parts: string[] = []
209 if (broken.length > 0) parts.push(`git-commit: this command broke the ${SKILL} skill (the mode is note, so it ran):\n${broken.map(f => `- ${f.text}`).join('\n')}`)
210 if (soft.length > 0) parts.push(`git-commit notes on this command:\n${soft.map(f => `- ${f.text}`).join('\n')}`)
211 return parts.join('\n\n')
212}
213
214/** The person's one line: what happened and the rules by their short names. */
215export function logText(findings: readonly Finding[], stopped: boolean): string {
216 return `${stopped ? 'stopped' : 'noted'}: ${findings.map(f => f.short).join('; ')}`
217}
218hooks/secrets.ts 80 lines1/**
2 * Secrets a commit would record: files whose names hold credentials, and
3 * added lines that look like a key or a token. A hit names the place and the
4 * kind, never the value, so the secret does not travel into a note.
5 */
6
7/** Example and template env files, which hold names without values. */
8const ENV_EXAMPLE = /^\.env\.(?:example|sample|template|dist|defaults)$/
9
10/** File names that hold credentials: env files, private keys, key stores, credential files. */
11const SECRET_NAME = /^(?:\.env(?:\..+)?|.*\.(?:pem|key|p12|pfx|keystore|jks)|id_(?:rsa|dsa|ecdsa|ed25519)|credentials\.json|\.netrc|\.pgpass)$/
12
13/** Whether a path's file name is one that holds credentials. */
14export function isSecretName(path: string): boolean {
15 const name = path.slice(path.lastIndexOf('/') + 1)
16 return SECRET_NAME.test(name) && !ENV_EXAMPLE.test(name)
17}
18
19/** A credential shape. `mixed` asks for letters and digits in the value, so an identifier is not read as a key. */
20type Shape = { kind: string; re: RegExp; mixed?: boolean }
21
22const SHAPES: Shape[] = [
23 { kind: 'private key', re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
24 { kind: 'AWS access key', re: /\bAKIA[0-9A-Z]{16}\b/ },
25 { kind: 'Google API key', re: /\bAIza[0-9A-Za-z_-]{35}\b/ },
26 { kind: 'GitHub token', re: /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{22,})/ },
27 { kind: 'Slack token', re: /\bxox[baprs]-[A-Za-z0-9-]{10,}/ },
28 { kind: 'API key', re: /\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{20,}/, mixed: true },
29 { kind: 'Hugging Face token', re: /\bhf_[A-Za-z0-9]{30,}\b/ },
30 { kind: 'Stripe key', re: /\b(?:sk|pk|rk)_(?:live|test)_[A-Za-z0-9]{20,}\b/ },
31 { kind: 'npm token', re: /\bnpm_[A-Za-z0-9]{36}\b/ },
32 { kind: 'JSON Web Token', re: /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/ },
33 { kind: 'credential assignment', re: /\b(?:api[_-]?key|secret|token|password|passwd)\b["']?\s*[:=]\s*["']([^"'\s]{16,})["']/i, mixed: true },
34]
35
36/** The kind of credential a line holds, or undefined. */
37export function secretKind(line: string): string | undefined {
38 for (const shape of SHAPES) {
39 const m = shape.re.exec(line)
40 if (m === null) continue
41 const value = m[1] ?? m[0]
42 if (shape.mixed === true && !(/[A-Za-z]/.test(value) && /\d/.test(value))) continue
43 return shape.kind
44 }
45 return undefined
46}
47
48/** One added line that looks like a credential: the file, the line number, and the kind. */
49export type SecretHit = { path: string; line: number; kind: string }
50
51/** The path of a `+++ b/<path>` line, without git's quotes and the tab git adds after a path with a space. */
52function newPath(header: string): string | undefined {
53 const raw = header.slice(4).replace(/\t$/, '')
54 const path = raw.startsWith('"') && raw.endsWith('"') ? raw.slice(1, -1) : raw
55 return path.startsWith('b/') ? path.slice(2) : undefined
56}
57
58type Cursor = { path?: string; line: number; hits: SecretHit[] }
59
60/** A file header's new side: `+++ b/<path>`, `+++ "b/<path>"` or `+++ /dev/null`. */
61const NEW_SIDE = /^\+\+\+ (?:"?b\/|\/dev\/null$)/
62
63/** Reads one line of a zero-context patch into the cursor. */
64function readPatchLine(c: Cursor, text: string): void {
65 if (NEW_SIDE.test(text)) c.path = newPath(text)
66 else if (text.startsWith('@@ ')) c.line = Number(/\+(\d+)/.exec(text)?.[1] ?? '0')
67 else if (text.startsWith('+') && c.path !== undefined) {
68 const kind = secretKind(text.slice(1))
69 if (kind !== undefined) c.hits.push({ path: c.path, line: c.line, kind })
70 c.line++
71 }
72}
73
74/** The added lines of a patch (`git diff -U0`) that look like credentials. */
75export function secretLines(patch: string): SecretHit[] {
76 const c: Cursor = { line: 0, hits: [] }
77 for (const text of patch.split('\n')) readPatchLine(c, text)
78 return c.hits
79}
80