SLOPSHOPPER

git-commit

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…

newguardcommandpromptprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · git-commit
› fix the failing auth test and add an audit log call ● git-commit: stopped: push not asked ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(rm -rf build && git push --force origin main) ⎿ Denied by git-commit: stopped before it ran, because it breaks the git-commit:commit skill: - The user did ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /git-commit ⎿ git-commit: on · mode deny ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

git-commit

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.

What it does

  1. The mod ships the skill as 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.
  2. When the skill opens, the mod appends a 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.
  3. The skill counts as open for the agent loop that opened it, until that loop's turn ends. The main loop and each subagent are separate: a subagent that commits opens the skill itself. A new turn opens it again.
  4. Each Bash command that names git is read before it runs. The mod splits the command at &&, ||, ;, |, &, 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.
  5. To measure what a commit records, the mod copies the index to a temporary file and replays the command's own 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.
  6. A rule is either hard or soft. In 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:

  • A git commit runs only after the git-commit:commit skill was opened in this turn, by this agent. Call the Skill tool with skill "git-commit:commit" and, as args, the options the user gave (such as --push or --amend), follow its steps, then run the commit again. There is no way around this gate.

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.

  1. You read one line per rule: in the sidebar stream while it is open (red for a hard rule, yellow for a soft one, under git command stopped or git command noted), else one transcript line such as git-commit: stopped: skill not opened.
  2. While the mod is on, the engine's commit attribution text is empty, so the model is not told to add a 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.

Rules

Hard rules (deny mode stops the command):

CommandStopped when
git committhe 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 adda 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 -fa path that an ignore file names
git committhe 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 committhe 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 committhe 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 committhe subject is empty, longer than 72 characters, or ends with a period
git committhe 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 pushyour 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 configit writes a setting; --get, --list and the other reads pass
git rebase-i

Soft rules (a note in both modes):

  • The commit changes more than 100 lines.
  • The commit touches more than one area, where an area is the first two path segments (plugins/a and plugins/b).
  • The subject's first word ends in -ed or -ing (added, adding).
  • The subject's case after type(scope): differs from the case of the recent subjects.
  • git add stages untracked files; the note names them.
  • The mod could not read the message (the shell builds it at run time, it comes from another commit, or git opens an editor), or could not measure what the commit holds (after a 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.

Command

/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

Install

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.

After installing

  1. Restart Claude Code.
  2. Type /git-commit:commit to commit through the skill. /commit does not open a plugin skill.
  3. If you keep a commit skill of your own (~/.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.
  4. If your CLAUDE.md tells the model to commit through a skill, name it git-commit:commit there.

What it can reach

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.

  1. Reads: the Bash command text of each call that names git; your prompts, of which it keeps the last one in memory; the Skill tool's arguments; a -F message file; the repository through git
  2. Runs: git with LC_ALL=C: rev-parse, status, log -20, diff --cached, check-ignore, ls-files, and read-tree, add and rm --cached into a temporary index through GIT_INDEX_FILE; cp to copy the index; rm -f to delete the temporary index files
  3. Sends: a deny text or a note to the model, a repository state block after the skill's text, one sidebar entry or transcript line to you, and an empty commit attribution text to the engine; nothing leaves the machine
  4. Persists: in $.store, the on/off setting and the mode; for the length of one command, temporary index files beside .git/index
  5. Hostile input: the command text is parsed, never run by a shell; the command's own git add arguments are replayed as argv into a temporary index; a secret is named by file, line and kind, never by its value

Limits

  • The gate reads the Bash command text. A commit through sh -c, a script, a make target, a git alias, git commit-tree or an MCP git tool is not read.
  • The push and branch checks search your last prompt for a word. push etme ("do not push") reads as a request for a push.
  • A message that the shell builds when the command runs ($VAR, backticks, a $(...) other than cat <<'EOF') is not checked; the model reads a note instead.
  • The style rules follow the last 20 subjects. Where fewer than 3 exist or fewer than half are conventional, only the length, period and signature rules apply.
  • The secret check matches file names and added lines with regular expressions. A key of another shape is not seen.
  • The mood check reads the ending of the first word, not its grammar.
  • A git merge is checked only for skipped hooks.
  • The deny mode has no bypass. When a hard rule cannot be met, you turn the gate off with /git-commit mode note.

Development

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

Source 8 files
hooks/register.ts 440 lines
1import 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}
440
hooks/command.ts 209 lines
1/**
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}
209
hooks/context.ts 86 lines
1/**
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}
86
hooks/finding.ts 16 lines
1/**
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}
16
hooks/gitargs.ts 224 lines
1/**
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}
224
hooks/message.ts 167 lines
1/**
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}
167
hooks/rules.ts 218 lines
1/**
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}
218
hooks/secrets.ts 80 lines
1/**
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