Turns every turn Claude takes into a clean git commit: only the files that turn changed, a Conventional Commits message written by Haiku, a secret scan before…

<img src="docs/poster.png" alt="claude-autocommit: Claude Code commits its own work, one clean commit per turn" width="100%">
Let Claude work for an hour and you usually end up with one of two things: a giant uncommitted blob mixing Claude's work with yours, or a string of wip commits. Asking Claude to commit costs a turn, and it tends to git add -A whatever is lying around.
autocommit is a Claude Code mod that watches which files Claude itself changes. When the turn ends, it makes one commit per repo, and one per agent when subagents worked too, with a message written from the actual diff in the style of your recent commits, scans each one for secrets first, and leaves everything else in your working tree exactly as it was.
https://github.com/user-attachments/assets/188f606e-935e-4aaa-b0c4-e5736469e642
In Claude Code:
/plugin marketplace add Iliesseu28/claude-autocommit
/plugin install autocommit@claude-autocommit
That's it: the defaults work. Change a setting any time with /plugin configure autocommit@claude-autocommit.
Needs git and a Claude Code build with mods (function hooks); tested on Claude Code 2.1.288, on Windows 11 and in headless claude -p runs.
| One commit per turn, per repo, per agent | Claude's changes land as soon as its answer is done, about two seconds later. |
| Only Claude's files | An edit is tracked by its path. A shell command is judged by comparing git status and file contents before and after it. Your own edits in the same repo stay out. |
| A message from the diff | Haiku writes the subject in the style of the repo's last hand-written subjects (type words, scope, capitalization; Conventional Commits when there are none) and one to three lines on why, in the language you pick. |
| A second pair of eyes | The same call flags obvious slips in the diff (debug leftover, broken reference, half-finished code) as an alert. |
| A secret guard | Every commit and every push is scanned: over 20 key formats, .env, .pem, .p8, .p12, SSH keys, service accounts. A flagged file is never committed. |
| Your index untouched | Commits are built in a private git index. Whatever you had staged stays staged. |
| Subagents handled | A subagent that finishes while Claude is still working waits for the end of Claude's turn (even one you interrupt), then gets a commit of its own. Files Claude already committed by hand are left alone. |
| A stale index warning | When git's own index holds 10 or more staged entries that differ from the disk (often an old index a folder sync copied back), one alert per repo says how to see and clear them. Files the mod is about to commit itself are not counted. Your index is never changed. |
| Undo, squash, push | /commits undo, /commits squash, /commits push. |
| A status bar | Model, effort, context gauge, Remote Control, and the commit counters, above the prompt. |
flowchart LR
E["Claude edits a file<br/>(Edit, Write, NotebookEdit)"] --> T["Tracked paths<br/>per agent and per repo"]
B["Claude runs a shell command"] --> S["git status and content hashes<br/>before and after"] --> T
T -->|"Claude's turn ends<br/>(subagents wait for it)"| I["Private index:<br/>HEAD + the tracked paths"]
I --> X{"Secret scan<br/>of the staged patch"}
X -->|"flagged"| A["File dropped,<br/>alert shown"]
X --> M["Haiku writes the message<br/>in the repo's style<br/>and flags obvious bugs"]
M --> C["git commit"]
C --> P{"Remote listed<br/>in autoPush?"}
P -->|"yes"| Q["Scan outgoing commits,<br/>pre-push check, git push"]
Each commit ends with two trailers: Auto-commit: claude-autocommit (so git log --grep "Auto-commit:" finds them all) and the commit attribution of your Claude Code settings (by default Co-Authored-By: Claude <model> <noreply@anthropic.com>; set it empty in your settings to drop it).
| Command | What it does |
|---|---|
/commits | Report: latest commits, alerts, files still pending. Clears the alert count. |
/commits pause / resume | Holds commits (files are still tracked), then sends them. |
/commits now | Commits every pending file now, subagents included, without waiting for a turn to end. |
/commits undo | Drops the session's last auto-commit if it is still HEAD and unpushed. Its files stay changed, uncommitted. |
/commits squash | Folds the session's unpushed auto-commits sitting in a row at HEAD into one, with a new message. No file is touched. |
/commits push | Pushes the session's repos now: secret scan of the outgoing commits, your pre-push check, then git push. |
French words work too: reprendre, tout, annuler, fusionner, pousser.
To keep a repo out entirely, create an empty .no-auto-commit file at its root (and list it in .git/info/exclude).
| Setting | Default | What it does |
|---|---|---|
language | en | Language of the bar, alerts and report: en or fr. |
commitModel | haiku | Model that writes the messages: haiku, sonnet or opus. |
commitLanguage | English | Language of the commit messages: any language name. |
bugCheck | true | Ask the model to flag obvious bugs in the diff. |
autoPush | empty | Remotes to push to after each commit, comma separated: me/notes, github.com/me, or * for all. Empty: never pushes unless you ask. A push sends the whole branch, so your own unpushed commits on it go too (scanned like the others). |
prePushCommand | empty | A command that must succeed before any push. {root} is the repo root, {files} a file listing the paths the push changes. |
bar | full | full, compact (context and commits only) or off. |
maxFilesPerCommand | 40 | A shell command that changes more files than this at once (an install into a tracked folder, a code generator) is reported instead of committed. |
.git (one per session); afterwards only the committed paths are refreshed in your index.index.lock fights, no commit built on a stale HEAD.HEAD moves while the auto-commit is being made (a commit from your terminal, another session, a pull), the auto-commit is rolled back and retried next turn; your commit stays on top.HEAD. The files wait.autoPush. Never a force push.undo and squash only touch unpushed commits made in this session..git/AUTOCOMMIT_RESET lets the next run finish the index refresh..git. The message goes to git on stdin; the private index, the push list and the journal are deleted as soon as they have served.claude -p runs they are committed when the subagent finishes, as the process may exit before another answer. If a commit is refused, the files are retried each time a turn ends.git status an auto-commit already runs, so it only shows in a repo where Claude changed something.git status); a new session does not pick them up.prePushCommand (run before every push) or in CI./commits report. In headless claude -p runs nobody sees them: read git log.prePushCommand or CI if a leak would hurt.Does it slow Claude down? An edit costs one cached lookup. A shell command that may write costs git status plus a content hash before and after it; read-only commands (ls, cat, grep, git status, git log, git diff...) skip it. The commit itself runs after the answer, outside the turn.
What does it cost? One small model call per commit (a trimmed diff in, about a hundred tokens out), on your own Claude Code plan. With haiku, a fraction of a cent at API prices.
Can I still write my own commits? Yes. Commit whenever you like: autocommit only commits what is still pending at the end of a turn. Or /commits pause.
Will it commit my .env? No. .env* (except .env.example), key files, keystores and service accounts are held back by name, before any scan.
My repos live in a synced folder (Syncthing, Dropbox, iCloud). Anything to know? A sync that copies .git between machines can bring an old index back: git status then lists staged changes nobody made, and a plain git commit would commit them all. The mod warns once per repo when it sees 10 or more. See them with git diff --cached --stat; if they are not wanted, git reset -q clears them without touching the files on disk; or commit by naming files, git commit -- <files>. Auto-commits are built in an index of their own and leave nothing in .git for the sync to copy.
Does it work in a monorepo, a worktree, or several repos at once? Yes. Each file is mapped to its own repo root by git; one turn that touches three repos makes three commits.
/plugin uninstall autocommit@claude-autocommit
claude plugin validate . --strict # marketplace manifest
claude plugin validate .claude-plugin/plugin.json --strict
claude plugin test . # 48 tests: pure helpers, then whole sessions against a fake git
python3 scripts/check.py # JSON, manifests, no long dash, no machine path
| File | Role |
|---|---|
hooks/register.tsx | The hooks: tracking, commit, push, undo, squash, the bar. |
hooks/git.ts | Pure helpers: porcelain parsing, read-only command detection, prompts, message layout. |
hooks/secrets.ts | Key patterns and forbidden file names. |
hooks/config.ts, hooks/i18n.ts | Settings and the English and French strings. |
types/index.d.ts | The state contract checked by claude plugin validate. |
hooks/register.tsx 1259 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ProcessRunResult, Register } from 'claude-code'
3
4import type { CommitLogEntry, CommitState, PendingEntry, Snapshot, Tracking } from '../types'
5import { readConfig } from './config'
6import type { Config } from './config'
7import {
8 absolute,
9 changedPaths,
10 commandDirs,
11 commitMessage,
12 commitPrompt,
13 commitSystem,
14 fallbackSubject,
15 fileName,
16 hashablePaths,
17 isReadOnlyCommand,
18 matchStatus,
19 matchesRemote,
20 normPath,
21 parentDir,
22 parseCommitReply,
23 parsePorcelain,
24 prettyModel,
25 RECENT_SUBJECTS,
26 recentSubjects,
27 repoName,
28 splitArgs,
29 squashPrompt,
30 squashSystem,
31 STALE_INDEX_MIN,
32 staleIndexCount,
33 trailers,
34 zipHashes,
35} from './git'
36import type { CommitText } from './git'
37import { actionOf, strings } from './i18n'
38import type { Strings } from './i18n'
39import { forbiddenFile, scanDiff, scanLine } from './secrets'
40
41// The whole mod's engine-facing code lives in this one file: the engine
42// follows `$` only into functions declared next to the hooks. Pure helpers
43// (paths, git output parsing, prompts, the secret scan, strings) are in the
44// files imported above and unit-tested on their own.
45
46// ================================================================ state
47
48export const NO_COMMITS: CommitState = {
49 made: 0,
50 toPush: 0,
51 pending: 0,
52 unseen: 0,
53 isPaused: false,
54 isBusy: false,
55 alerts: [],
56 log: [],
57}
58
59const NO_TRACKING: Tracking = { pending: [], queue: [] }
60
61export const snap = atom({ plugin: 'autocommit', key: 'snap' } as const, null)
62export const commits = atom({ plugin: 'autocommit', key: 'commits' } as const, NO_COMMITS)
63export const tracking = atom({ plugin: 'autocommit', key: 'tracking' } as const, NO_TRACKING)
64// The commit trailer Claude Code composed with the person's settings; null
65// until seen.
66export const attribution = atom({ plugin: 'autocommit', key: 'attribution' } as const, null)
67
68// ================================================================ work
69
70const MAIN = ''
71const PATHS_PER_CALL = 100
72const MAX_NEW_FILE_BYTES = 5_000_000
73const MAX_WATCHED_REPOS = 6
74const KEPT_ALERTS = 30
75const KEPT_LOG = 200
76const BUSY_MARKERS = ['MERGE_HEAD', 'CHERRY_PICK_HEAD', 'REVERT_HEAD', 'BISECT_LOG', 'rebase-merge', 'rebase-apply']
77// Patches read as text, whatever a repo's attributes say, so a scan sees every line.
78const PATCH_FLAGS = [
79 '--text', '--no-textconv', '--no-color', '--no-ext-diff', '--no-renames',
80 '--src-prefix=a/', '--dst-prefix=b/',
81]
82// Files the mod writes inside `.git`, never in the working tree, each named
83// for its session (`w.tag`) so two sessions in one repo never share one, and
84// each deleted once used: a synced folder would carry leftovers to every
85// machine. Commit messages go through standard input, never a file.
86const INDEX_FILE = 'autocommit.index'
87const JOURNAL_FILE = 'AUTOCOMMIT_RESET'
88const PUSH_LIST_FILE = 'AUTOCOMMIT_PUSH_LIST'
89// Auto-commits (ours or another mod's) carry this trailer: left out of the
90// subjects the model copies the repo's style from.
91const AUTO_TRAILER = '^Auto-commit:'
92const EMPTY_TREE = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'
93
94// Where a directory sits in its repo: git's own root (a junction or link
95// resolved) and the directory's path inside it.
96export type RepoAt = { root: string; prefix: string }
97
98// Status and content hashes of a repo's dirty files at one moment.
99export type RepoState = { status: Map<string, string>; hashes: Map<string, string> | null }
100
101type GitInit = { stdin?: string; timeoutMs?: number; env?: Record<string, string> }
102
103// What the module tracks between events. `tracked` and `queue` are mirrored in
104// `$.state` so a reload starts from them; the rest is rebuilt on demand.
105export type Work = {
106 cwd: string
107 isInteractive: boolean
108 // agent key (MAIN for the main loop) -> repo root -> paths it changed
109 tracked: Map<string, Map<string, Set<string>>>
110 // directory -> where it sits in a repo, null when in none
111 places: Map<string, RepoAt | null>
112 // repos this session touched, most recent last
113 repos: Set<string>
114 // agents whose turn ended with files to commit
115 queue: Set<string>
116 // agents whose last turn is over: their leftovers are retried at any turn end
117 idle: Set<string>
118 // the main loop's turn is under way: a subagent that ends meanwhile leaves
119 // its files to the main turn's end
120 isMainWorking: boolean
121 // repos already warned of a stale index this session
122 staleWarned: Set<string>
123 isWindows: boolean
124 // `root\0path` -> how many times it was tracked: a path tracked again while
125 // a drain commits it stays pending for the newer change
126 edits: Map<string, number>
127 // the drain, undo or squash under way: one at a time
128 running: Promise<void> | null
129 // Claude's own git commands under way: no drain starts meanwhile
130 gitCommands: number
131 // suffix of the mod's files in `.git`, from the session id
132 tag: string
133 isPaused: boolean
134 isPushAsked: boolean
135}
136
137export const newWork = (): Work => ({
138 gitCommands: 0,
139 tag: '',
140 cwd: '.',
141 isInteractive: true,
142 tracked: new Map(),
143 places: new Map(),
144 repos: new Set(),
145 queue: new Set(),
146 idle: new Set(),
147 isMainWorking: false,
148 staleWarned: new Set(),
149 isWindows: false,
150 edits: new Map(),
151 running: null,
152 isPaused: false,
153 isPushAsked: false,
154})
155
156export type Ctx = { w: Work; cfg: Config; t: Strings }
157
158const firstLine = (s: string | undefined): string => (s ?? '').trim().split('\n')[0]?.slice(0, 160) ?? ''
159
160export async function git(
161 $: EngineInterface,
162 root: string,
163 args: readonly string[],
164 init: GitInit = {},
165): Promise<ProcessRunResult> {
166 return $.process.run(
167 ['git', '--no-optional-locks', '--literal-pathspecs', '-c', 'core.quotepath=false', '-C', root, ...args],
168 { timeoutMs: 30_000, ...init },
169 )
170}
171
172const out = (r: ProcessRunResult | null): string => (r !== null && r.exitCode === 0 ? r.stdout.trim() : '')
173
174// ---------------------------------------------------------------- tracking
175
176export const pendingCount = (w: Work): number => {
177 const all = new Set<string>()
178 for (const repos of w.tracked.values()) {
179 for (const [root, paths] of repos) for (const p of paths) all.add(`${root}/${p}`)
180 }
181 return all.size
182}
183
184const editKey = (root: string, path: string): string => `${root}\0${path}`
185
186// `-` and the session id's first 8 letters and digits: the suffix of the
187// mod's files in `.git`.
188export const tagOf = (sessionId: string): string => {
189 const s = sessionId.replace(/[^A-Za-z0-9]/g, '').slice(0, 8)
190 return s === '' ? '' : `-${s}`
191}
192
193export const track = (w: Work, agent: string, root: string, paths: readonly string[]): void => {
194 if (paths.length === 0) return
195 const repos = w.tracked.get(agent) ?? new Map<string, Set<string>>()
196 const set = repos.get(root) ?? new Set<string>()
197 for (const p of paths) {
198 set.add(p)
199 w.edits.set(editKey(root, p), (w.edits.get(editKey(root, p)) ?? 0) + 1)
200 }
201 repos.set(root, set)
202 w.tracked.set(agent, repos)
203 w.repos.delete(root)
204 w.repos.add(root)
205}
206
207// Settled paths leave every agent's list: committed, or held back for good.
208// A path tracked again since `seen` was taken keeps its place: its newer
209// change is not in the commit.
210export const forget = (w: Work, root: string, paths: readonly string[], seen?: ReadonlyMap<string, number>): void => {
211 const gone = paths.filter(p => seen === undefined || w.edits.get(editKey(root, p)) === seen.get(editKey(root, p)))
212 for (const [agent, repos] of w.tracked) {
213 const set = repos.get(root)
214 if (set === undefined) continue
215 for (const p of gone) set.delete(p)
216 if (set.size === 0) repos.delete(root)
217 if (repos.size === 0) w.tracked.delete(agent)
218 }
219 for (const p of gone) w.edits.delete(editKey(root, p))
220}
221
222// Every path any agent left pending in `root`.
223const trackedIn = (w: Work, root: string): string[] => [...w.tracked.values()].flatMap(repos => [...(repos.get(root) ?? [])])
224
225// Mirrors `tracked` and `queue` into the host, and the pending count into the band.
226export async function saveTracking($: EngineInterface, c: Ctx): Promise<void> {
227 const pending: PendingEntry[] = []
228 for (const [agent, repos] of c.w.tracked) {
229 for (const [root, paths] of repos) pending.push({ agent, root, paths: [...paths] })
230 }
231 await update($, tracking, () => ({ pending, queue: [...c.w.queue] }))
232 const count = pendingCount(c.w)
233 if ((await read($, commits)).pending !== count) {
234 await update($, commits, s => ({ ...s, pending: count }))
235 }
236}
237
238export async function loadTracking($: EngineInterface, c: Ctx): Promise<void> {
239 const saved = await read($, tracking)
240 for (const e of saved.pending) {
241 track(c.w, e.agent, e.root, e.paths)
242 c.w.idle.add(e.agent)
243 }
244 for (const agent of saved.queue) if (c.w.tracked.has(agent)) c.w.queue.add(agent)
245 c.w.isPaused = (await read($, commits)).isPaused
246}
247
248export async function alert($: EngineInterface, c: Ctx, repo: string, text: string): Promise<void> {
249 const isNew = !(await read($, commits)).alerts.some(a => a.repo === repo && a.text === text)
250 await update($, commits, s => {
251 const i = s.alerts.findIndex(a => a.repo === repo && a.text === text)
252 const seen = i === -1 ? undefined : s.alerts[i]
253 if (seen === undefined) {
254 return {
255 ...s,
256 unseen: s.unseen + 1,
257 alerts: [...s.alerts, { at: Date.now(), repo, text, count: 1 }].slice(-KEPT_ALERTS),
258 }
259 }
260 // The same alert again (a hook refusing every turn): counted, not toasted.
261 return {
262 ...s,
263 alerts: [...s.alerts.filter((_, j) => j !== i), { ...seen, at: Date.now(), count: seen.count + 1 }],
264 }
265 })
266 if (isNew) $.ui.toast(c.t.alert(repo, text), { timeoutMs: 8000 })
267}
268
269export async function repoAt($: EngineInterface, c: Ctx, dir: string): Promise<RepoAt | null> {
270 const key = normPath(dir)
271 const known = c.w.places.get(key)
272 if (known !== undefined) return known
273 const r = await $.process
274 .run(['git', '-C', key, 'rev-parse', '--show-toplevel', '--show-prefix'], { timeoutMs: 10_000 })
275 .catch(() => null)
276 const [top, prefix] = r !== null && r.exitCode === 0 ? r.stdout.split('\n') : []
277 const place =
278 top !== undefined && top.trim() !== '' ? { root: normPath(top.trim()), prefix: (prefix ?? '').trim() } : null
279 // A folder that does not exist yet may become a repo's: asked again later.
280 if (place !== null || !/cannot change to/i.test(r?.stderr ?? '')) c.w.places.set(key, place)
281 return place
282}
283
284export async function trackFile($: EngineInterface, c: Ctx, agent: string, file: string): Promise<void> {
285 const place = await repoAt($, c, parentDir(file))
286 if (place === null) return
287 track(c.w, agent, place.root, [`${place.prefix}${fileName(file)}`])
288 await saveTracking($, c)
289}
290
291// `git status --porcelain=v1 -z` of the person's own index, null when unread.
292async function statusText($: EngineInterface, root: string): Promise<string | null> {
293 // A submodule is its own repo: its pointer is never ours to commit.
294 const r = await git($, root, ['status', '--porcelain=v1', '-z', '--untracked-files=all', '--ignore-submodules=all']).catch(
295 () => null,
296 )
297 return r !== null && r.exitCode === 0 && !r.isStdoutTruncated ? r.stdout : null
298}
299
300async function statusOf($: EngineInterface, root: string): Promise<Map<string, string> | null> {
301 const text = await statusText($, root)
302 return text === null ? null : parsePorcelain(text)
303}
304
305export async function stateOf($: EngineInterface, root: string): Promise<RepoState | null> {
306 const status = await statusOf($, root)
307 if (status === null) return null
308 const paths = hashablePaths(status)
309 if (paths.length === 0) return { status, hashes: new Map() }
310 // Raw content, no clean filter (LFS, line endings): only before and after are compared.
311 const r = await git($, root, ['hash-object', '--no-filters', '--stdin-paths'], { stdin: `${paths.join('\n')}\n` }).catch(
312 () => null,
313 )
314 return { status, hashes: r !== null && r.exitCode === 0 ? zipHashes(paths, r.stdout) : null }
315}
316
317// The repos a shell command may touch: those the session touched lately, the
318// session's folder, and the folders the command names (`cd X`, `git -C X`).
319export async function watchedRoots($: EngineInterface, c: Ctx, command: string): Promise<string[]> {
320 const roots = new Set([...c.w.repos].slice(-MAX_WATCHED_REPOS))
321 for (const dir of [c.w.cwd, ...commandDirs(command).map(d => absolute(c.w.cwd, d))]) {
322 const place = await repoAt($, c, dir)
323 if (place !== null) roots.add(place.root)
324 }
325 return [...roots]
326}
327
328// What a shell command changed, by comparing the repos before and after it.
329export async function recordCommand(
330 $: EngineInterface,
331 c: Ctx,
332 agent: string,
333 roots: readonly string[],
334 before: ReadonlyArray<RepoState | null>,
335 after: ReadonlyArray<RepoState | null>,
336): Promise<void> {
337 for (const [i, root] of roots.entries()) {
338 const was = before[i]
339 const now = after[i]
340 if (was === null || was === undefined || now === null || now === undefined) continue
341 const touched = changedPaths(was.status, now.status, was.hashes, now.hashes)
342 if (touched.length > c.cfg.maxFilesPerCommand) {
343 await alert($, c, repoName(root), c.t.tooManyFiles(touched.length))
344 } else {
345 track(c.w, agent, root, touched)
346 }
347 }
348 await saveTracking($, c)
349}
350
351// ---------------------------------------------------------------- commit
352
353async function gitDirOf($: EngineInterface, root: string): Promise<string | null> {
354 const r = await git($, root, ['rev-parse', '--absolute-git-dir']).catch(() => null)
355 const dir = out(r)
356 return dir === '' ? null : normPath(dir)
357}
358
359// Mid-merge, mid-rebase or detached: nothing is committed, files wait.
360async function isBusy($: EngineInterface, root: string, gitDir: string): Promise<boolean> {
361 if ((await git($, root, ['symbolic-ref', '-q', 'HEAD'])).exitCode !== 0) return true
362 for (const marker of BUSY_MARKERS) {
363 if (await $.fs.exists(`${gitDir}/${marker}`)) return true
364 }
365 return false
366}
367
368async function unstage(
369 $: EngineInterface,
370 root: string,
371 paths: readonly string[],
372 hasHead: boolean,
373 env?: Record<string, string>,
374): Promise<boolean> {
375 if (paths.length === 0) return true
376 const args = hasHead ? ['reset', '-q'] : ['rm', '--cached', '-q']
377 const r = await git($, root, [...args, '--pathspec-from-file=-', '--pathspec-file-nul'], {
378 stdin: paths.join('\0'),
379 ...(env === undefined ? {} : { env }),
380 }).catch(() => null)
381 return r !== null && r.exitCode === 0
382}
383
384// A commit is made in an index of its own, then the person's index catches up
385// for its paths. A journal written before the commit lets the next run finish
386// that catch-up if the process died in between.
387async function writeJournal($: EngineInterface, path: string, head: string, paths: readonly string[]): Promise<void> {
388 await $.fs.write(path, [head, ...paths].join('\0'))
389}
390
391// `$.fs` writes but never deletes: the host's own command does, `rm` or `del`.
392async function removeFiles($: EngineInterface, c: Ctx, paths: readonly string[]): Promise<void> {
393 if (paths.length === 0) return
394 const argv = c.w.isWindows
395 ? ['cmd', '/d', '/c', 'del', '/f', '/q', ...paths.map(p => p.replace(/\//g, '\\'))]
396 : ['rm', '-f', '--', ...paths]
397 await $.process.run(argv, { timeoutMs: 10_000 }).catch(() => undefined)
398}
399
400async function clearJournal($: EngineInterface, c: Ctx, path: string): Promise<void> {
401 await removeFiles($, c, [path])
402}
403
404export async function recoverJournal($: EngineInterface, c: Ctx, root: string, path: string): Promise<void> {
405 if (!(await $.fs.exists(path))) return
406 const text = String(await $.fs.read(path).catch(() => ''))
407 if (text === '') return
408 // The head comes first, empty on a branch with no commit yet.
409 const [head = '', ...rest] = text.split('\0')
410 const paths = rest.filter(p => p !== '')
411 const now = out(await git($, root, ['rev-parse', '-q', '--verify', 'HEAD']).catch(() => null))
412 // HEAD moved since the journal: the commit was made, its index catch-up was not.
413 if (now !== '' && now !== head) await unstage($, root, paths, true)
414 await clearJournal($, c, path)
415}
416
417// The staged patch of `paths`, in calls short enough for any command line;
418// null when git failed or cut the output, so a scan never reads half of it.
419async function cachedDiff(
420 $: EngineInterface,
421 root: string,
422 paths: readonly string[],
423 context: string,
424 env: Record<string, string>,
425): Promise<string | null> {
426 let text = ''
427 for (let i = 0; i < paths.length; i += PATHS_PER_CALL) {
428 const r = await git(
429 $,
430 root,
431 ['diff', '--cached', ...PATCH_FLAGS, context, '--', ...paths.slice(i, i + PATHS_PER_CALL)],
432 { env },
433 )
434 if (r.exitCode !== 0 || r.isStdoutTruncated) return null
435 text += r.stdout
436 }
437 return text
438}
439
440// Once per repo and session: the person's index looks left over from an older
441// state of the repo. Only a warning: their index is never touched.
442async function warnStaleIndex($: EngineInterface, c: Ctx, root: string, count: number): Promise<void> {
443 if (count < STALE_INDEX_MIN || c.w.staleWarned.has(root)) return
444 c.w.staleWarned.add(root)
445 await alert($, c, repoName(root), c.t.staleIndex(count))
446}
447
448async function heldBackReason($: EngineInterface, c: Ctx, root: string, path: string, xy: string): Promise<string | null> {
449 const forbidden = forbiddenFile(path)
450 if (forbidden !== null) return c.t.heldBack(path, forbidden)
451 if (xy !== '??') return null
452 const st = await $.fs.stat(`${root}/${path}`).catch(() => null)
453 return st !== null && st.kind === 'file' && st.size > MAX_NEW_FILE_BYTES
454 ? c.t.tooBig(path, Math.round(st.size / 1_000_000))
455 : null
456}
457
458// A model reply that repeats a key (seen in a removed or context line) is dropped.
459const safeText = (text: CommitText, files: readonly string[]): CommitText =>
460 [text.subject, text.body, ...text.warnings].some(x => x.split('\n').some(l => scanLine(l) !== null))
461 ? { subject: fallbackSubject(files), body: '', warnings: [] }
462 : text
463
464async function trailerLines($: EngineInterface): Promise<string[]> {
465 return trailers(await read($, attribution), prettyModel(await $.session.model()))
466}
467
468async function messageFor(
469 $: EngineInterface,
470 c: Ctx,
471 ready: readonly string[],
472 stat: string,
473 diff: string,
474 recent: readonly string[],
475): Promise<CommitText> {
476 const reply = await $.model
477 .complete({
478 model: c.cfg.commitModel,
479 system: commitSystem({
480 commitLanguage: c.cfg.commitLanguage,
481 warningLanguage: c.t.warningLanguage,
482 isBugCheck: c.cfg.isBugCheck,
483 }),
484 prompt: commitPrompt(stat, diff, recent),
485 maxTokens: 500,
486 effort: 'low',
487 timeoutMs: 45_000,
488 })
489 .catch(() => null)
490 const parsed = parseCommitReply(reply?.isAnswered === true ? reply.text : '', ready)
491 return safeText(c.cfg.isBugCheck ? parsed : { ...parsed, warnings: [] }, ready)
492}
493
494export type CommitResult = { settled: string[]; sha: string | null }
495
496// One commit of the tracked `paths` in `root`, built in an index of its own:
497// what the scan reads is exactly what the commit holds, and the person's own
498// index is touched only once the commit exists. Resolves the tracked paths
499// settled (committed, already clean, or held back with an alert), the others
500// staying pending, and the new commit when one was made.
501export async function commitRepo($: EngineInterface, c: Ctx, root: string, paths: readonly string[]): Promise<CommitResult> {
502 if (await $.fs.exists(`${root}/.no-auto-commit`)) return { settled: [...paths], sha: null }
503 const gitDir = await gitDirOf($, root)
504 if (gitDir === null || (await isBusy($, root, gitDir))) return { settled: [], sha: null }
505 const index = `${gitDir}/${INDEX_FILE}${c.w.tag}`
506 try {
507 return await commitIn($, c, root, gitDir, index, paths)
508 } finally {
509 // Used up, whatever the outcome: the next commit seeds a new one from HEAD.
510 if (await $.fs.exists(index).catch(() => true)) await removeFiles($, c, [index])
511 }
512}
513
514async function commitIn(
515 $: EngineInterface,
516 c: Ctx,
517 root: string,
518 gitDir: string,
519 index: string,
520 paths: readonly string[],
521): Promise<CommitResult> {
522 const { t } = c
523 const name = repoName(root)
524 const none = (settled: string[]): CommitResult => ({ settled, sha: null })
525 const journal = `${gitDir}/${JOURNAL_FILE}${c.w.tag}`
526 await recoverJournal($, c, root, journal)
527
528 const statusRaw = await statusText($, root)
529 if (statusRaw === null) return none([])
530 const status = parsePorcelain(statusRaw)
531 const isCaseInsensitive = out(await git($, root, ['config', '--bool', '--get', 'core.ignorecase'])) === 'true'
532 const { found, missing } = matchStatus(paths, status, isCaseInsensitive)
533 // Staged entries of files the mod commits itself (this commit's, or another
534 // agent's in this repo) say nothing of a stale index.
535 const mine = matchStatus([...paths, ...trackedIn(c.w, root)], status, isCaseInsensitive).found
536 await warnStaleIndex($, c, root, staleIndexCount(statusRaw, new Set(mine.keys())))
537 const settled = [...missing]
538 const spellings = (p: string): string[] => found.get(p) ?? [p]
539
540 const candidates: string[] = []
541 for (const p of found.keys()) {
542 const reason = await heldBackReason($, c, root, p, status.get(p) ?? ' ')
543 if (reason === null) {
544 candidates.push(p)
545 } else {
546 settled.push(...spellings(p))
547 await alert($, c, name, reason)
548 }
549 }
550 if (candidates.length === 0) return none(settled)
551
552 const env = { GIT_INDEX_FILE: index }
553 const head = out(await git($, root, ['rev-parse', '-q', '--verify', 'HEAD']))
554 const hasHead = head !== ''
555 const seed = await git($, root, hasHead ? ['read-tree', 'HEAD'] : ['read-tree', '--empty'], { env })
556 const add =
557 seed.exitCode !== 0
558 ? seed
559 : await git($, root, ['add', '--pathspec-from-file=-', '--pathspec-file-nul'], {
560 stdin: candidates.join('\0'),
561 env,
562 })
563 if (add.exitCode !== 0) {
564 await alert($, c, name, t.prepareFailed(firstLine(add.stderr)))
565 return none(settled)
566 }
567
568 // Secret scan of exactly what the commit will hold.
569 const scanned = await cachedDiff($, root, candidates, '-U0', env)
570 const findings = scanned === null ? null : scanDiff(scanned)
571 const known = new Set(candidates)
572 if (findings === null || findings.some(f => !known.has(f.file))) {
573 await alert($, c, name, t.scanFailed)
574 return none([...settled, ...candidates.flatMap(spellings)])
575 }
576 const flagged = new Set(findings.map(f => f.file))
577 for (const f of findings) await alert($, c, name, t.secret(f.file, f.pattern))
578 if (flagged.size > 0 && !(await unstage($, root, [...flagged], hasHead, env))) {
579 await alert($, c, name, t.dropFailed)
580 return none([...settled, ...candidates.flatMap(spellings)])
581 }
582 const ready = candidates.filter(p => !flagged.has(p))
583 settled.push(...[...flagged].flatMap(spellings))
584 if (ready.length === 0) return none(settled)
585
586 const diff = (await cachedDiff($, root, ready, '-U3', env)) ?? ''
587 const stat = ready.map(p => `${status.get(p) ?? ' '} ${p}`).join('\n')
588 // The repo's style: its latest subjects written by hand.
589 const log = await git($, root, ['log', `-${RECENT_SUBJECTS}`, '--format=%s', '--invert-grep', `--grep=${AUTO_TRAILER}`]).catch(
590 () => null,
591 )
592 const text = await messageFor($, c, ready, stat, diff, recentSubjects(out(log)))
593 const message = `${commitMessage(text, await trailerLines($))}\n`
594
595 // HEAD moved while the message was written: the index is stale, next turn retries.
596 if (out(await git($, root, ['rev-parse', '-q', '--verify', 'HEAD'])) !== head) return none(settled)
597 await writeJournal($, journal, head, ready)
598 // Claude Code runs a mod's git with the repo's hooks off; two minutes covers a slow disk.
599 const commit = await git($, root, ['commit', '-q', '-F', '-'], { env, stdin: message, timeoutMs: 120_000 }).catch(
600 () => null,
601 )
602 if (commit === null || commit.exitCode !== 0) {
603 await clearJournal($, c, journal)
604 await alert($, c, name, t.commitRefused(firstLine(commit?.stderr || commit?.stdout) || t.timedOut))
605 return none(settled)
606 }
607
608 // The commit must sit on the HEAD its index was built from and hold only the
609 // scanned paths. A HEAD that moved during the commit (a commit from a
610 // terminal or another session, a pull) would undo that work, and a hook
611 // that staged more files (should hooks ever run) would slip them in: the
612 // commit is rolled back.
613 const sha = out(await git($, root, ['rev-parse', 'HEAD']))
614 // Our index holds the tree just committed: a HEAD with another tree is a
615 // commit that landed right after ours, never ours to roll back.
616 const tree = out(await git($, root, ['write-tree'], { env }))
617 const headTree = sha === '' ? '' : out(await git($, root, ['rev-parse', `${sha}^{tree}`]))
618 if (tree === '' || headTree !== tree) {
619 if (await unstage($, root, ready, true)) await clearJournal($, c, journal)
620 await alert($, c, name, t.headRaced)
621 return none(settled)
622 }
623 const parent = out(await git($, root, ['rev-parse', '-q', '--verify', 'HEAD^']))
624 const allowed = new Set(ready)
625 const strays = (await filesOf($, root, sha)).filter(f => !allowed.has(f))
626 if (parent !== head || strays.length > 0) {
627 if (sha !== '') {
628 await git(
629 $,
630 root,
631 parent === '' ? ['update-ref', '-d', 'HEAD', sha] : ['update-ref', '-m', 'autocommit: rollback', 'HEAD', parent, sha],
632 )
633 }
634 await clearJournal($, c, journal)
635 if (strays.length === 0) {
636 await alert($, c, name, t.headMoved)
637 return none(settled)
638 }
639 // The hook would do it again on every try: the files are left to the person.
640 await alert($, c, name, t.hookStaged(strays.slice(0, 3).join(', ')))
641 return none([...settled, ...ready.flatMap(spellings)])
642 }
643
644 // The person's index catches up with the new commit for these paths only.
645 if (await unstage($, root, ready, true)) {
646 await clearJournal($, c, journal)
647 } else {
648 await alert($, c, name, t.indexStale(ready.slice(0, 3).join(' ')))
649 }
650 const short = sha.slice(0, 7)
651 const entry: CommitLogEntry = { at: Date.now(), root, sha, subject: text.subject, files: ready.length }
652 await update($, commits, s => ({ ...s, made: s.made + 1, log: [...s.log, entry].slice(-KEPT_LOG) }))
653 $.ui.toast(t.committed(name, short, text.subject), { timeoutMs: 6000 })
654 for (const warning of text.warnings) await alert($, c, name, t.bug(short, warning))
655 return { settled: [...settled, ...ready.flatMap(spellings)], sha }
656}
657
658// ---------------------------------------------------------------- push
659
660// The configured pre-push check, `{root}` and `{files}` (a file listing the
661// paths the push changes, one per line) filled in. Empty: no check.
662async function prePushCheck($: EngineInterface, c: Ctx, root: string, gitDir: string, files: readonly string[]): Promise<boolean> {
663 if (c.cfg.prePushCommand === '') return true
664 const listPath = `${gitDir}/${PUSH_LIST_FILE}${c.w.tag}`
665 await $.fs.write(listPath, `${files.join('\n')}\n`)
666 const argv = splitArgs(c.cfg.prePushCommand).map(a => a.split('{root}').join(root).split('{files}').join(listPath))
667 const r = argv.length === 0 ? null : await $.process.run(argv, { cwd: root, timeoutMs: 120_000 }).catch(() => null)
668 await removeFiles($, c, [listPath])
669 return argv.length === 0 || (r !== null && r.exitCode === 0)
670}
671
672// Pushes `root` once the commits it carries pass our scan of their patches
673// and the configured pre-push check. Unasked, only to the remotes `autoPush`
674// names.
675export async function pushRepo($: EngineInterface, c: Ctx, root: string, isAsked: boolean): Promise<void> {
676 const { t } = c
677 const name = repoName(root)
678 const branch = out(await git($, root, ['symbolic-ref', '-q', '--short', 'HEAD']))
679 if (branch === '') return
680 const remote = out(await git($, root, ['config', '--get', `branch.${branch}.remote`]))
681 const merge = out(await git($, root, ['config', '--get', `branch.${branch}.merge`]))
682 if (remote === '' || remote === '.' || merge === '') {
683 if (isAsked) await alert($, c, name, t.noUpstream)
684 return
685 }
686 const url = out(await git($, root, ['remote', 'get-url', '--push', remote]))
687 if (!isAsked && !matchesRemote(url, c.cfg.autoPush)) return
688
689 const ahead = Number(out(await git($, root, ['rev-list', '--count', '@{u}..HEAD']))) || 0
690 if (ahead === 0) return
691
692 const patches = await git($, root, ['log', '-p', ...PATCH_FLAGS, '-U0', '--format=', '@{u}..HEAD'])
693 const changed = await git($, root, ['diff', '--name-only', '-z', '@{u}..HEAD'])
694 const gitDir = await gitDirOf($, root)
695 if (patches.exitCode !== 0 || patches.isStdoutTruncated || changed.exitCode !== 0 || gitDir === null) {
696 await alert($, c, name, t.unreadable)
697 return
698 }
699 const leak = scanDiff(patches.stdout)[0]
700 if (leak !== undefined) {
701 await alert($, c, name, t.pushSecret(leak.pattern, leak.file))
702 return
703 }
704 const files = changed.stdout.split('\0').filter(f => f !== '')
705 if (!(await prePushCheck($, c, root, gitDir, files))) {
706 await alert($, c, name, t.prePushFailed)
707 return
708 }
709 const push = await git($, root, ['push', '--quiet', remote, `HEAD:${merge}`], { timeoutMs: 180_000 }).catch(
710 () => null,
711 )
712 if (push === null || push.exitCode !== 0) {
713 await alert($, c, name, t.pushRefused(firstLine(push?.stderr) || t.timedOut))
714 return
715 }
716 $.ui.toast(t.pushed(name, ahead), { timeoutMs: 6000 })
717}
718
719async function aheadCount($: EngineInterface, roots: Iterable<string>): Promise<number> {
720 let total = 0
721 for (const root of roots) {
722 const r = await git($, root, ['rev-list', '--count', '@{u}..HEAD']).catch(() => null)
723 total += Number(out(r)) || 0
724 }
725 return total
726}
727
728// ---------------------------------------------------------------- drain
729
730// Commits what the agents of the queue changed, one commit per agent and
731// repo (a subagent's work and the main loop's each get their own message),
732// then pushes where allowed. A pause holds the commits, never an asked push.
733async function drainOnce($: EngineInterface, c: Ctx): Promise<void> {
734 const { w } = c
735 await update($, commits, s => ({ ...s, isBusy: true }))
736 try {
737 if (!w.isPaused) {
738 const jobs: Array<{ root: string; paths: string[] }> = []
739 for (const agent of w.queue) {
740 for (const [root, paths] of w.tracked.get(agent) ?? []) jobs.push({ root, paths: [...paths] })
741 }
742 w.queue.clear()
743 // A path edited again while its commit is being made stays tracked.
744 const seen = new Map<string, number>()
745 for (const { root, paths } of jobs) {
746 for (const p of paths) seen.set(editKey(root, p), w.edits.get(editKey(root, p)) ?? 0)
747 }
748
749 // A path two agents changed goes with the first commit; the second
750 // finds it clean and lets it go.
751 const committed = new Set<string>()
752 for (const { root, paths } of jobs) {
753 const r = await commitRepo($, c, root, paths).catch(async (err: unknown): Promise<CommitResult> => {
754 await alert($, c, repoName(root), c.t.failed(firstLine(String(err))))
755 return { settled: [], sha: null }
756 })
757 forget(w, root, r.settled, seen)
758 if (r.sha !== null) committed.add(root)
759 }
760 for (const root of committed) await pushRepo($, c, root, false).catch(() => undefined)
761 }
762
763 if (w.isPushAsked) {
764 w.isPushAsked = false
765 const cwdPlace = await repoAt($, c, w.cwd)
766 const targets = new Set([...w.repos, ...(cwdPlace === null ? [] : [cwdPlace.root])])
767 for (const root of targets) await pushRepo($, c, root, true).catch(() => undefined)
768 }
769 } finally {
770 await saveTracking($, c).catch(() => undefined)
771 const toPush = await aheadCount($, w.repos).catch(() => 0)
772 await update($, commits, s => ({ ...s, isBusy: false, toPush, pending: pendingCount(w) }))
773 }
774}
775
776// Never two at once: a call while one runs does nothing (the timer calls
777// again), and a caller that must see its own work through awaits `w.running`.
778export async function drain($: EngineInterface, c: Ctx): Promise<void> {
779 if (c.w.running !== null || c.w.gitCommands > 0) return
780 if ((c.w.queue.size === 0 || c.w.isPaused) && !c.w.isPushAsked) return
781 const run: Promise<void> = drainOnce($, c).finally(() => {
782 if (c.w.running === run) c.w.running = null
783 })
784 c.w.running = run
785 await run
786}
787
788// Undo and squash rewrite HEAD: they wait for a running commit, and the next
789// drain waits for them.
790async function exclusive($: EngineInterface, c: Ctx, job: 'undo' | 'squash'): Promise<string> {
791 while (c.w.running !== null) await c.w.running.catch(() => undefined)
792 const run = job === 'undo' ? undoLast($, c) : squashSession($, c)
793 const slot = run.then(
794 () => undefined,
795 () => undefined,
796 )
797 c.w.running = slot
798 try {
799 return await run
800 } finally {
801 if (c.w.running === slot) c.w.running = null
802 }
803}
804
805// ---------------------------------------------------------------- undo, squash
806
807// Commits on HEAD that no remote-tracking branch holds.
808async function unpushed($: EngineInterface, root: string): Promise<Set<string>> {
809 const r = await git($, root, ['rev-list', '--max-count=1000', 'HEAD', '--not', '--remotes']).catch(() => null)
810 return new Set(out(r).split('\n').filter(x => x !== ''))
811}
812
813async function filesOf($: EngineInterface, root: string, sha: string): Promise<string[]> {
814 const r = await git($, root, ['diff-tree', '--no-commit-id', '--name-only', '-r', '-z', '--root', sha])
815 return r.exitCode === 0 ? r.stdout.split('\0').filter(f => f !== '') : []
816}
817
818// Drops the session's last auto-commit when nothing came after it and it was
819// never pushed: HEAD steps back, the files keep their content, uncommitted.
820export async function undoLast($: EngineInterface, c: Ctx): Promise<string> {
821 const { t } = c
822 const log = (await read($, commits)).log
823 const last = log[log.length - 1]
824 if (last === undefined) return t.undoNothing
825 const root = last.root
826 const name = repoName(root)
827 const gitDir = await gitDirOf($, root)
828 if (gitDir === null || (await isBusy($, root, gitDir))) return t.undoBusy(name)
829 if (out(await git($, root, ['rev-parse', 'HEAD'])) !== last.sha) return t.undoMoved(name)
830 if (!(await unpushed($, root)).has(last.sha)) return t.undoPushed(name)
831 const parent = out(await git($, root, ['rev-parse', '-q', '--verify', `${last.sha}^`]))
832 if (parent === '') return t.undoRoot(name)
833
834 const files = await filesOf($, root, last.sha)
835 const moved = await git($, root, ['update-ref', '-m', 'autocommit: undo', 'HEAD', parent, last.sha])
836 if (moved.exitCode !== 0) return t.undoFailed(name, firstLine(moved.stderr))
837 // The index follows HEAD back for these files: their changes show as unstaged.
838 if (!(await unstage($, root, files, true))) await alert($, c, name, t.indexStale(files.slice(0, 3).join(' ')))
839 await update($, commits, s => ({ ...s, made: Math.max(0, s.made - 1), log: s.log.filter(e => e.sha !== last.sha) }))
840 return t.undone(name, last.sha.slice(0, 7), last.subject, files.length)
841}
842
843// Folds the session's unpushed auto-commits sitting in a row at HEAD into one
844// commit with the same tree: no file, index or working tree is touched.
845async function squashRepo($: EngineInterface, c: Ctx, root: string, log: readonly CommitLogEntry[]): Promise<string | null> {
846 const { t } = c
847 const name = repoName(root)
848 const ours = new Map(log.filter(e => e.root === root).map(e => [e.sha, e]))
849 if (ours.size < 2) return null
850 const gitDir = await gitDirOf($, root)
851 if (gitDir === null || (await isBusy($, root, gitDir))) return null
852
853 const free = await unpushed($, root)
854 const chain = out(await git($, root, ['rev-list', '--first-parent', `--max-count=${ours.size + 1}`, 'HEAD']))
855 .split('\n')
856 .filter(x => x !== '')
857 const run: CommitLogEntry[] = []
858 for (const sha of chain) {
859 const entry = ours.get(sha)
860 if (entry === undefined || !free.has(sha)) break
861 run.push(entry)
862 }
863 if (run.length < 2) return null
864 run.reverse()
865 const oldest = run[0]
866 const head = run[run.length - 1]
867 if (oldest === undefined || head === undefined) return null
868 const base = out(await git($, root, ['rev-parse', '-q', '--verify', `${oldest.sha}^`]))
869
870 const subjects = run.map(e => e.subject)
871 const stat = out(await git($, root, ['diff', '--stat=100', base === '' ? EMPTY_TREE : base, head.sha]))
872 const reply = await $.model
873 .complete({
874 model: c.cfg.commitModel,
875 system: squashSystem(c.cfg.commitLanguage),
876 prompt: squashPrompt(subjects, stat),
877 maxTokens: 400,
878 effort: 'low',
879 timeoutMs: 45_000,
880 })
881 .catch(() => null)
882 const answer = reply?.isAnswered === true && /"subject"\s*:/.test(reply.text) ? reply.text : ''
883 const parsed = parseCommitReply(answer, [])
884 const isLeaky = [parsed.subject, parsed.body].some(x => x.split('\n').some(l => scanLine(l) !== null))
885 const isModel = answer !== '' && !isLeaky
886 const subject = isModel ? parsed.subject : `${subjects[0] ?? 'chore: squash'} (+${run.length - 1})`.slice(0, 72)
887 const summary = isModel && parsed.body !== '' ? [parsed.body, ''] : []
888 const body = [...summary, t.squashedHeader, ...subjects.map(x => `- ${x}`)].join('\n')
889 const message = commitMessage({ subject, body }, await trailerLines($))
890
891 const made = await git($, root, ['commit-tree', `${head.sha}^{tree}`, ...(base === '' ? [] : ['-p', base]), '-F', '-'], {
892 stdin: `${message}\n`,
893 })
894 const sha = out(made)
895 if (sha === '') return t.squashFailed(name, firstLine(made.stderr))
896 const moved = await git($, root, ['update-ref', '-m', 'autocommit: squash', 'HEAD', sha, head.sha])
897 if (moved.exitCode !== 0) return t.squashFailed(name, firstLine(moved.stderr))
898
899 const folded = new Set(run.map(e => e.sha))
900 const files = (await filesOf($, root, sha)).length
901 await update($, commits, s => ({
902 ...s,
903 made: Math.max(1, s.made - run.length + 1),
904 log: [...s.log.filter(e => !folded.has(e.sha)), { at: Date.now(), root, sha, subject, files }].slice(-KEPT_LOG),
905 }))
906 return t.squashed(name, run.length, sha.slice(0, 7), subject)
907}
908
909export async function squashSession($: EngineInterface, c: Ctx): Promise<string> {
910 const log = (await read($, commits)).log
911 const roots = [...new Set(log.map(e => e.root))]
912 const results: string[] = []
913 for (const root of roots) {
914 const r = await squashRepo($, c, root, log).catch((err: unknown) => c.t.squashFailed(repoName(root), firstLine(String(err))))
915 if (r !== null) results.push(r)
916 }
917 return results.length === 0 ? c.t.squashNothing : results.join('\n')
918}
919
920// ---------------------------------------------------------------- report
921
922export const commitsReport = (c: Ctx, s: CommitState): string => {
923 const { t, w } = c
924 const ago = (at: number): string => t.ago(Math.round((Date.now() - at) / 60_000))
925 const lines = [t.report(s.isPaused, s.made, s.toPush, s.pending)]
926 if (c.cfg.autoPush.length > 0) lines.push(t.autoPushTo(c.cfg.autoPush.join(', ')))
927 if (s.log.length > 0) {
928 lines.push('', t.latest)
929 for (const e of s.log.slice(-10)) {
930 lines.push(` ${e.sha.slice(0, 7)} ${repoName(e.root)} ${e.subject} (${t.files(e.files)}, ${ago(e.at)})`)
931 }
932 }
933 if (s.alerts.length > 0) {
934 lines.push('', t.alerts)
935 for (const a of s.alerts.slice(-10)) {
936 lines.push(` ⚠ ${t.alert(a.repo, a.text).slice(2)}${a.count > 1 ? ` (x${a.count})` : ''} (${ago(a.at)})`)
937 }
938 }
939 const waiting: string[] = []
940 for (const [agent, repos] of w.tracked) {
941 for (const [root, paths] of repos) {
942 const who = agent === MAIN ? t.session : t.agent(agent.slice(0, 8))
943 const shown = [...paths].slice(0, 5).join(', ')
944 waiting.push(` ${repoName(root)} (${who}): ${shown}${paths.size > 5 ? t.more(paths.size - 5) : ''}`)
945 }
946 }
947 if (waiting.length > 0) lines.push('', t.waitingFor, ...waiting)
948 lines.push('', t.help)
949 return lines.join('\n')
950}
951
952// ================================================================ hooks
953
954const BAR_CELLS = 20
955const NARROW_BAR_CELLS = 10
956const NARROW_COLUMNS = 100
957const REFRESH_MS = 2000
958
959export const shortTokens = (n: number): string => (n >= 1000 ? `${Math.round(n / 1000)}k` : `${n}`)
960
961export const barCells = (tokens: number, max: number, cells = BAR_CELLS): number =>
962 Math.min(cells, Math.max(0, Math.round((tokens / max) * cells)))
963
964export const barColor = (percent: number): string => (percent >= 85 ? 'red' : percent >= 60 ? 'yellow' : 'green')
965
966const asRecord = (v: unknown): Record<string, unknown> =>
967 v !== null && typeof v === 'object' ? (v as Record<string, unknown>) : {}
968
969const sameSnap = (a: Snapshot | null, b: Snapshot): boolean =>
970 a !== null &&
971 a.model === b.model &&
972 a.effort === b.effort &&
973 a.tokens === b.tokens &&
974 a.max === b.max &&
975 a.isRemoteOn === b.isRemoteOn &&
976 a.remoteClients === b.remoteClients
977
978// Model, effort, context against the auto-compact window, Remote Control.
979async function refresh($: EngineInterface, liveEffort: string | null): Promise<void> {
980 const [model, usage, settings, surfaces, rows] = await Promise.all([
981 $.session.model(),
982 $.session.usage(),
983 $.settings.read(),
984 $.session.surfaces(),
985 $.config.list(),
986 ])
987
988 const compactWindow = settings.autoCompactWindow
989 const max =
990 typeof compactWindow === 'number' && compactWindow > 0
991 ? Math.min(compactWindow, usage.context.window)
992 : usage.context.window
993
994 const modelSettings = asRecord(asRecord(settings.modelSettings)[model])
995 const fallbackEffort = modelSettings.effortLevel ?? settings.effortLevel
996 const effort = liveEffort ?? (typeof fallbackEffort === 'string' ? fallbackEffort : null)
997
998 const remoteClients = surfaces.filter(s => s !== 'terminal').length
999 const remoteRow = rows.find(r => typeof r.value === 'boolean' && /remote/i.test(`${r.key} ${r.label}`))
1000 const startupSetting = settings.remoteControlAtStartup
1001 const configured =
1002 remoteRow !== undefined
1003 ? (remoteRow.value as boolean)
1004 : typeof startupSetting === 'boolean'
1005 ? startupSetting
1006 : null
1007 const isRemoteOn = remoteClients > 0 ? true : configured
1008
1009 const next: Snapshot = { model, effort, tokens: usage.context.tokens ?? null, max, isRemoteOn, remoteClients }
1010 if (!sameSnap(await read($, snap), next)) await update($, snap, () => next)
1011}
1012
1013// Commits right after a turn ends, outside the turn's own dispatch; the
1014// two-second timer is the safety net.
1015function drainSoon($: EngineInterface, c: Ctx): void {
1016 $.clock.after(50, () => void drain($, c).catch(() => undefined))
1017}
1018
1019export const register: Register = (on, options) => {
1020 const cfg = readConfig(options)
1021 const t = strings(cfg.language)
1022 const w = newWork()
1023 const c: Ctx = { w, cfg, t }
1024 // Effort seen on the main loop's last request; null until the first one.
1025 let liveEffort: string | null = null
1026
1027 on('session.start', async ($, e, next) => {
1028 const result = await next(e)
1029 w.cwd = normPath(e.cwd)
1030 w.isInteractive = e.isInteractive
1031 w.tag = tagOf(await $.session.id().catch(() => ''))
1032 w.isWindows = (await $.env.get('OS').catch(() => undefined)) === 'Windows_NT'
1033 // A reload (settings changed, mod updated) picks up what was pending.
1034 await loadTracking($, c)
1035 await update($, commits, s => ({ ...s, isBusy: false, pending: pendingCount(w) }))
1036 await $.command.register({
1037 name: 'commits',
1038 description:
1039 cfg.language === 'fr'
1040 ? 'Commits auto : historique, alertes, pause, annuler, fusionner, pousser'
1041 : 'Auto-commits: history, alerts, pause, undo, squash, push',
1042 argumentHint: cfg.language === 'fr' ? '[pause|reprendre|tout|annuler|fusionner|pousser]' : '[pause|resume|now|undo|squash|push]',
1043 })
1044 if (cfg.bar !== 'off') await refresh($, liveEffort).catch(() => undefined)
1045 $.clock.every(REFRESH_MS, () => {
1046 if (cfg.bar !== 'off') void refresh($, liveEffort).catch(() => undefined)
1047 void drain($, c).catch(() => undefined)
1048 })
1049 if (w.queue.size > 0) drainSoon($, c)
1050 return result
1051 })
1052
1053 // The main loop works from its turn's start to its end (a subagent's run
1054 // raises no turn.start).
1055 on('turn.start', async (_$, e, next) => {
1056 w.isMainWorking = true
1057 return next(e)
1058 })
1059
1060 // A main-loop request is only ever made inside a main turn: it also marks
1061 // the turn under way after a reload in mid-turn. A tool call is no such sign
1062 // (one can run between turns).
1063 on('turn.step', async function* (_$, e, next) {
1064 if (e.agentId === undefined) {
1065 w.isMainWorking = true
1066 if (e.effort !== undefined) liveEffort = String(e.effort)
1067 }
1068 return yield* next(e)
1069 })
1070
1071 // The commit trailer as Claude Code composes it with the person's settings.
1072 on('attribution.text', { kind: 'commit' }, async ($, e, next) => {
1073 const result = await next(e)
1074 if ((await read($, attribution)) !== result.text) await update($, attribution, () => result.text)
1075 return result
1076 })
1077
1078 // Who changed what: an edit names its file; a shell command is judged by
1079 // the status and content of the watched repos before and after it.
1080 on('tool.call', async ($, e, next) => {
1081 const agent = e.agentId ?? MAIN
1082 w.idle.delete(agent)
1083 const tool = String(e.tool)
1084 if (tool === 'Edit' || tool === 'Write' || tool === 'MultiEdit' || tool === 'NotebookEdit') {
1085 const result = await next(e)
1086 const input = e as unknown as { file_path?: unknown; notebook_path?: unknown }
1087 const file = tool === 'NotebookEdit' ? input.notebook_path : input.file_path
1088 if (result.deny === undefined && result.isError !== true && typeof file === 'string') {
1089 await trackFile($, c, agent, file).catch(() => undefined)
1090 }
1091 return result
1092 }
1093 if (tool !== 'Bash' && tool !== 'PowerShell') return next(e)
1094 const input = e as unknown as { command?: unknown; run_in_background?: unknown }
1095 const command = typeof input.command === 'string' ? input.command : ''
1096 if (command === '' || input.run_in_background === true || isReadOnlyCommand(command)) return next(e)
1097
1098 // Claude's own git command (a commit, a checkout) never runs beside one
1099 // of ours, and none of ours starts while it runs: they would fight over
1100 // the index lock, or ours would be built on a HEAD that is gone.
1101 const isGit = /\bgit\b/.test(command)
1102 if (isGit) {
1103 while (w.running !== null) await w.running.catch(() => undefined)
1104 w.gitCommands++
1105 }
1106 try {
1107 const roots = await watchedRoots($, c, command).catch(() => [] as string[])
1108 const before = await Promise.all(roots.map(r => stateOf($, r)))
1109 const result = await next(e)
1110 if (result.deny !== undefined || result.isReadOnly === true || roots.length === 0) return result
1111 const after = await Promise.all(roots.map(r => stateOf($, r)))
1112 await recordCommand($, c, agent, roots, before, after).catch(() => undefined)
1113 return result
1114 } finally {
1115 if (isGit) w.gitCommands--
1116 }
1117 })
1118
1119 on('turn.complete', async ($, e, next) => {
1120 const result = await next(e)
1121 const agent = e.agentId ?? MAIN
1122 if (agent === MAIN) w.isMainWorking = false
1123 const isAnswer = e.reason === 'answer'
1124 if (isAnswer) {
1125 w.idle.add(agent)
1126 // A subagent done while the main loop still works (waiting for it, or
1127 // going on): its files wait for the main turn's end, so a commit Claude
1128 // makes of them meanwhile comes first and keeps its message. Headless
1129 // (claude -p), they go at once: the process may end with no main answer.
1130 if (agent !== MAIN && w.isMainWorking && w.isInteractive) return result
1131 } else if (agent !== MAIN) {
1132 return result
1133 }
1134 // This agent's files, and those every idle agent left pending (a
1135 // subagent that ended during the main turn, a commit refused or rolled
1136 // back), whose turn will not end again. A main turn cut short (Esc, an
1137 // error) keeps its own files for its next answer, but the subagents that
1138 // ended during it wait no more: the person may quit right after.
1139 for (const a of w.tracked.keys()) {
1140 if (w.idle.has(a) && (isAnswer || a !== MAIN)) w.queue.add(a)
1141 }
1142 if (w.queue.size > 0) {
1143 await saveTracking($, c).catch(() => undefined)
1144 if (w.isInteractive) {
1145 drainSoon($, c)
1146 } else {
1147 // Without a person (claude -p) the process ends with the turn: commit
1148 // now, after any run already under way.
1149 while (w.running !== null) await w.running.catch(() => undefined)
1150 await drain($, c).catch(() => undefined)
1151 }
1152 }
1153 return result
1154 })
1155
1156 on('command.run', { command: 'commits' }, async ($, e) => {
1157 const action = actionOf(e.args)
1158 switch (action) {
1159 case 'pause':
1160 case 'resume': {
1161 const isPaused = action === 'pause'
1162 w.isPaused = isPaused
1163 await update($, commits, s => ({ ...s, isPaused }))
1164 if (!isPaused) drainSoon($, c)
1165 return { text: isPaused ? t.isPaused : t.resumed }
1166 }
1167 case 'now': {
1168 for (const agent of w.tracked.keys()) w.queue.add(agent)
1169 await saveTracking($, c)
1170 drainSoon($, c)
1171 return { text: t.now(pendingCount(w)) }
1172 }
1173 case 'push':
1174 w.isPushAsked = true
1175 drainSoon($, c)
1176 return { text: t.pushStarted }
1177 case 'undo':
1178 return { text: await exclusive($, c, 'undo') }
1179 case 'squash':
1180 return { text: await exclusive($, c, 'squash') }
1181 default: {
1182 const s = await read($, commits)
1183 await update($, commits, x => ({ ...x, unseen: 0 }))
1184 return { text: commitsReport(c, s) }
1185 }
1186 }
1187 })
1188
1189 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1190 if (cfg.bar === 'off' || e.props.hasSurvey) return next(e)
1191 const s = await read($, snap)
1192 if (s === null) return next(e)
1193 const c = await read($, commits)
1194
1195 // What the plugins beneath draw here (Claude Code's own notices): drawn
1196 // above the band so it never hides them.
1197 const beneath = await next(e)
1198 const { Box, Text } = $.ui.resolve(e)
1199 const isNarrow = e.props.bodyColumns < NARROW_COLUMNS
1200 const cells = isNarrow ? NARROW_BAR_CELLS : BAR_CELLShooks/config.ts 45 lines1import type { PluginOptions } from 'claude-code'
2
3// The mod's settings, read from the manifest's `userConfig` (defaults filled
4// in by the engine, so every field is here; still checked, never trusted).
5export type Language = 'en' | 'fr'
6export type BarMode = 'full' | 'compact' | 'off'
7
8export type Config = {
9 language: Language
10 commitModel: string
11 commitLanguage: string
12 isBugCheck: boolean
13 autoPush: string[]
14 prePushCommand: string
15 bar: BarMode
16 maxFilesPerCommand: number
17}
18
19const text = (o: PluginOptions, key: string, fallback: string): string => {
20 const v = o[key]
21 return typeof v === 'string' && v.trim() !== '' ? v.trim() : fallback
22}
23
24const list = (o: PluginOptions, key: string): string[] => {
25 const v = o[key]
26 const raw = typeof v === 'string' ? v.split(/[,\s]+/) : Array.isArray(v) ? [...(v as readonly string[])] : []
27 return raw.map(s => s.trim()).filter(s => s !== '')
28}
29
30export const readConfig = (o: PluginOptions): Config => {
31 const language = text(o, 'language', 'en')
32 const bar = text(o, 'bar', 'full')
33 const max = o.maxFilesPerCommand
34 return {
35 language: language === 'fr' ? 'fr' : 'en',
36 commitModel: text(o, 'commitModel', 'haiku'),
37 commitLanguage: text(o, 'commitLanguage', 'English'),
38 isBugCheck: o.bugCheck !== false,
39 autoPush: list(o, 'autoPush'),
40 prePushCommand: text(o, 'prePushCommand', ''),
41 bar: bar === 'compact' || bar === 'off' ? bar : 'full',
42 maxFilesPerCommand: typeof max === 'number' && max > 0 ? Math.floor(max) : 40,
43 }
44}
45hooks/git.ts 336 lines1// Pure helpers for the auto-commit feature: no `$`, so they are unit-tested.
2
3export type CommitText = { subject: string; body: string; warnings: string[] }
4export type DiffPart = { file: string; text: string }
5
6const MAX_SUBJECT = 72
7export const RECENT_SUBJECTS = 8
8// Stale entries in the person's index from which it is worth a warning.
9export const STALE_INDEX_MIN = 10
10const MAX_DIFF_CHARS = 12_000
11const MAX_FILE_DIFF_CHARS = 3_000
12
13// "C:\\work\\x" and "C:/work/x" name the same file on Windows.
14export const normPath = (p: string): string => p.replace(/\\/g, '/').replace(/\/+$/, '')
15
16export const parentDir = (p: string): string => {
17 const n = normPath(p)
18 const i = n.lastIndexOf('/')
19 return i <= 0 ? n : n.slice(0, i)
20}
21
22export const fileName = (p: string): string => {
23 const n = normPath(p)
24 return n.slice(n.lastIndexOf('/') + 1)
25}
26
27export const repoName = (root: string): string => fileName(root)
28
29export const isAbsolute = (p: string): boolean => /^([A-Za-z]:[\\/]|[\\/])/.test(p)
30
31export const absolute = (cwd: string, dir: string): string =>
32 isAbsolute(dir) ? normPath(dir) : `${normPath(cwd)}/${dir}`
33
34// `git status --porcelain=v1 -z`: path -> two-letter status ("??", " M", "A ", ...).
35// A rename or copy is followed by its source path, recorded as deleted.
36export const parsePorcelain = (z: string): Map<string, string> => {
37 const out = new Map<string, string>()
38 const parts = z.split('\0')
39 for (let i = 0; i < parts.length; i += 1) {
40 const entry = parts[i]
41 if (entry === undefined || entry.length < 4) continue
42 const xy = entry.slice(0, 2)
43 out.set(entry.slice(3), xy)
44 if (xy[0] === 'R' || xy[0] === 'C') {
45 const source = parts[i + 1]
46 if (source !== undefined && source !== '') out.set(source, 'D ')
47 i += 1
48 }
49 }
50 return out
51}
52
53// Tracked paths matched against `git status`. On a case-insensitive file
54// system a tool may name `readme.md` while git lists `README.md`: both are
55// the same file. `found` maps git's spelling to the tracked spellings.
56export const matchStatus = (
57 tracked: readonly string[],
58 status: ReadonlyMap<string, string>,
59 isCaseInsensitive: boolean,
60): { found: Map<string, string[]>; missing: string[] } => {
61 const folded = new Map<string, string>()
62 if (isCaseInsensitive) for (const p of status.keys()) folded.set(p.toLowerCase(), p)
63 const found = new Map<string, string[]>()
64 const missing: string[] = []
65 for (const p of tracked) {
66 const actual = status.has(p) ? p : isCaseInsensitive ? folded.get(p.toLowerCase()) : undefined
67 if (actual === undefined) {
68 missing.push(p)
69 } else {
70 found.set(actual, [...(found.get(actual) ?? []), p])
71 }
72 }
73 return { found, missing }
74}
75
76// Sections of a `git diff --no-renames --src-prefix=a/ --dst-prefix=b/`: with
77// both sides the same path, "diff --git a/X b/X" gives X even with spaces.
78export const splitDiff = (diff: string): DiffPart[] =>
79 diff
80 .split(/(?=^diff --git )/m)
81 .filter(text => text.startsWith('diff --git '))
82 .map(text => {
83 const end = text.indexOf('\n')
84 const rest = (end === -1 ? text : text.slice(0, end)).replace(/\r$/, '').slice('diff --git '.length)
85 const n = (rest.length - 5) / 2
86 const isPlain = Number.isInteger(n) && n > 0 && rest.startsWith('a/') && rest.slice(n + 2, n + 5) === ' b/'
87 return { file: isPlain ? rest.slice(2, 2 + n) : rest, text }
88 })
89
90// Paths whose status appeared or changed between two snapshots.
91// A status code alone also moves when only the index does (`git add -A` turns
92// " M" into "M "), so a path dirty on both sides counts only when its content
93// hash moved; without hashes, only paths that were clean before count.
94export const changedPaths = (
95 before: ReadonlyMap<string, string>,
96 after: ReadonlyMap<string, string>,
97 hashesBefore: ReadonlyMap<string, string> | null = null,
98 hashesAfter: ReadonlyMap<string, string> | null = null,
99): string[] =>
100 [...after]
101 .filter(([p, xy]) => {
102 const was = before.get(p)
103 if (was === undefined) return true
104 if (hashesBefore === null || hashesAfter === null) return false
105 const h0 = hashesBefore.get(p)
106 const h1 = hashesAfter.get(p)
107 return h0 === undefined || h1 === undefined ? was !== xy : h0 !== h1
108 })
109 .map(([p]) => p)
110
111// Entries of the person's own index that look left over from an older state
112// of the repo, read from `git status --porcelain=v1 -z` of that index: a
113// staged change (X is M or A) whose file differs again on disk (Y not blank),
114// or a staged deletion (X is D) of a file still on disk (listed again as
115// untracked). A few are ordinary work in progress; many at once is the mark
116// of a `.git/index` that a folder sync copied back from another machine.
117// `mine` are the paths the auto-commit is about to settle itself (Claude ran
118// `git add`, then edited on): they are left out of the count.
119export const staleIndexCount = (z: string, mine: ReadonlySet<string> = new Set()): number => {
120 const entries: Array<[string, string]> = []
121 const parts = z.split('\0')
122 for (let i = 0; i < parts.length; i += 1) {
123 const entry = parts[i]
124 if (entry === undefined || entry.length < 4) continue
125 const xy = entry.slice(0, 2)
126 entries.push([xy, entry.slice(3)])
127 if (xy[0] === 'R' || xy[0] === 'C') i += 1
128 }
129 const untracked = new Set(entries.filter(([xy]) => xy === '??').map(([, p]) => p))
130 return entries.filter(
131 ([xy, p]) =>
132 !mine.has(p) && (((xy[0] === 'M' || xy[0] === 'A') && xy[1] !== ' ') || (xy[0] === 'D' && untracked.has(p))),
133 ).length
134}
135
136// Paths `git hash-object --stdin-paths` can read: not deleted on either side.
137// Files git can hash: not deleted, and not a directory (an untracked inner repo
138// shows as `dir/`), since one bad path fails the whole hash-object call.
139export const hashablePaths = (status: ReadonlyMap<string, string>): string[] =>
140 [...status].filter(([p, xy]) => !xy.includes('D') && !p.endsWith('/')).map(([p]) => p)
141
142export const zipHashes = (paths: readonly string[], stdout: string): Map<string, string> | null => {
143 const hashes = stdout.split('\n').map(h => h.trim()).filter(h => h !== '')
144 if (hashes.length !== paths.length) return null
145 return new Map(paths.map((p, i) => [p, hashes[i] ?? '']))
146}
147
148const READ_ONLY_COMMANDS = new Set([
149 'cd', 'ls', 'cat', 'head', 'tail', 'wc', 'grep', 'rg', 'which', 'echo', 'pwd',
150 'test', '[', 'stat', 'du', 'df', 'file', 'date', 'tree', 'type', 'printf',
151 'sort', 'cut', 'tr', 'jq', 'awk', 'basename', 'dirname', 'realpath',
152 'less', 'more', 'diff', 'whoami', 'uname', 'ps',
153 'Get-ChildItem', 'Get-Content', 'Select-String', 'Test-Path', 'Get-Item',
154 'Get-Location', 'Resolve-Path', 'Measure-Object', 'Select-Object', 'Where-Object',
155])
156const READ_ONLY_GIT = new Set([
157 'status', 'log', 'diff', 'show', 'rev-parse', 'ls-files', 'describe', 'rev-list',
158 'shortlog', 'blame', 'cat-file', 'remote', 'config', 'branch', 'grep', 'reflog',
159 'ls-tree', 'merge-base', 'name-rev', 'for-each-ref', 'show-ref', 'whatchanged',
160])
161
162// True only when every piece of the command surely writes nothing; any doubt
163// says false, which costs a git status but never a wrong attribution.
164export const isReadOnlyCommand = (command: string): boolean => {
165 const cleaned = command
166 .replace(/\d?>\s*\/dev\/null/g, '')
167 .replace(/\d?>&\d/g, '')
168 .replace(/\d?>\s*\$null/gi, '')
169 if (
170 /[>`]|\$\(|\btee\b|\bxargs\b|\bsed\s+-i|-delete\b|-exec(dir)?\b|-ok(dir)?\b|-fprint|-fls\b|\bsh\s+-c|\bbash\s+-c/.test(cleaned) ||
171 /\bsort\b[^|;&]*\s(-o|--output)\b/.test(cleaned)
172 ) {
173 return false
174 }
175 const segments = cleaned.split(/&&|\|\||[;|\n]/).map(s => s.trim()).filter(s => s !== '')
176 if (segments.length === 0) return false
177 return segments.every(seg => {
178 const words = seg.split(/\s+/)
179 const head = words[0] ?? ''
180 if (head === 'git') {
181 const sub = words.find((w, i) => i > 0 && !w.startsWith('-') && words[i - 1] !== '-C' && words[i - 1] !== '-c')
182 if (sub === 'branch') return !/\s-(d|D|m|M|c|C|f)\b|\s--(delete|move|copy|force|set-upstream-to|unset-upstream)\b/.test(seg)
183 if (sub === 'config') return /\s--get\b|\s--get-all\b|\s-l\b|\s--list\b/.test(seg)
184 if (sub === 'remote') return !/\s(add|remove|rm|rename|set-url|set-head|prune|update)\b/.test(seg)
185 if (sub === 'reflog') return !/\s(expire|delete)\b/.test(seg)
186 return sub !== undefined && READ_ONLY_GIT.has(sub)
187 }
188 if (head === 'sed') return /\s-n\b/.test(seg)
189 if (head === 'find') return true
190 return READ_ONLY_COMMANDS.has(head)
191 })
192}
193
194// Directories a command moves into or points git at: `cd X`, `git -C X`.
195export const commandDirs = (command: string): string[] => {
196 const dirs: string[] = []
197 const re = /(?:\bcd\s+|\bgit\s+-C\s+|\bSet-Location\s+|\bpushd\s+)("([^"]+)"|'([^']+)'|([^\s;&|)]+))/g
198 for (const m of command.matchAll(re)) {
199 const dir = m[2] ?? m[3] ?? m[4]
200 if (dir !== undefined && dir !== '' && dir !== '-' && !dir.startsWith('$')) dirs.push(dir)
201 }
202 return dirs
203}
204
205// A shell-like split of a configured command: quotes group, nothing expands.
206export const splitArgs = (command: string): string[] => {
207 const out: string[] = []
208 const re = /"([^"]*)"|'([^']*)'|(\S+)/g
209 for (const m of command.matchAll(re)) out.push(m[1] ?? m[2] ?? m[3] ?? '')
210 return out
211}
212
213// A push URL matches a pattern made of whole path segments of it, ignoring
214// case and a trailing ".git": "me/notes" matches "git@github.com:me/notes.git"
215// and "https://github.com/me/notes", never "me/notes-old"; "*" matches all.
216export const matchesRemote = (url: string, patterns: readonly string[]): boolean => {
217 const u = url.trim().toLowerCase().replace(/\.git\/?$/, '').replace(/^[\w.-]+@([^:/]+):/, '$1/')
218 return patterns.some(p => {
219 const q = p.trim().toLowerCase().replace(/\.git\/?$/, '').replace(/^\/+|\/+$/g, '')
220 if (q === '') return false
221 return q === '*' || u === q || u.endsWith(`/${q}`) || u.startsWith(`${q}/`) || u.includes(`/${q}/`)
222 })
223}
224
225// Diff trimmed per file and in total, so one big file never crowds the rest out.
226export const trimDiff = (diff: string): string => {
227 const files = diff.split(/(?=^diff --git )/m)
228 const kept = files.map(f =>
229 f.length > MAX_FILE_DIFF_CHARS ? `${f.slice(0, MAX_FILE_DIFF_CHARS)}\n[... cut ...]\n` : f,
230 )
231 const all = kept.join('')
232 return all.length > MAX_DIFF_CHARS ? `${all.slice(0, MAX_DIFF_CHARS)}\n[... cut ...]\n` : all
233}
234
235export type PromptOptions = { commitLanguage: string; warningLanguage: string; isBugCheck: boolean }
236
237export const commitSystem = (o: PromptOptions): string =>
238 [
239 'You write git commit messages' + (o.isBugCheck ? ' and spot obvious bugs in a diff.' : '.'),
240 'Reply with JSON only, no code fence: {"subject": string, "body": string, "warnings": string[]}.',
241 `subject: imperative, in ${o.commitLanguage}, at most 72 characters.`,
242 "When the prompt lists the repo's recent subjects, write yours in their style: the same type words, a scope in parentheses whenever they use one (picked from the changed files), the same capitalization.",
243 'Without them, Conventional Commits (feat, fix, refactor, docs, chore, test, style, perf, build, ci), optional scope.',
244 `body: one to three short lines in ${o.commitLanguage} on WHY the change was made; empty string when obvious.`,
245 o.isBugCheck
246 ? `warnings: at most 2 items, in ${o.warningLanguage}, only for a likely real bug you can point to (debug leftover, broken reference, syntax error, half-finished code, deleted code still used). Empty array when nothing is clearly wrong.`
247 : 'warnings: always an empty array.',
248 'Never use the characters \u2014 or \u2013.',
249 ].join(' ')
250
251// The subjects of the repo's latest commits, one per line, newest first, each
252// cut to a subject's length: the style the model copies.
253export const recentSubjects = (stdout: string): string[] =>
254 stdout
255 .split('\n')
256 .map(l => l.trim())
257 .filter(l => l !== '')
258 .slice(0, RECENT_SUBJECTS)
259 .map(l => l.slice(0, MAX_SUBJECT))
260
261export const commitPrompt = (stat: string, diff: string, recent: readonly string[] = []): string => {
262 const style =
263 recent.length === 0
264 ? ''
265 : `Recent subjects in this repo, newest first (copy their style, not their content):\n${recent.map(r => `- ${r}`).join('\n')}\n\n`
266 return `${style}Files changed:\n${stat.trim()}\n\nDiff:\n${trimDiff(diff)}`
267}
268
269export const squashSystem = (commitLanguage: string): string =>
270 [
271 'You merge several git commits into one commit message.',
272 'Reply with JSON only, no code fence: {"subject": string, "body": string}.',
273 `subject: Conventional Commits, optional scope, imperative, in ${commitLanguage}, at most 72 characters, covering the whole change.`,
274 `body: two to four short lines in ${commitLanguage} on what changed and why.`,
275 'Never use the characters \u2014 or \u2013.',
276 ].join(' ')
277
278export const squashPrompt = (subjects: readonly string[], stat: string): string =>
279 `Commits, oldest first:\n${subjects.map(s => `- ${s}`).join('\n')}\n\nFiles changed:\n${stat.trim()}`
280
281// No em dash, en dash or horizontal bar in a message.
282export const noLongDash = (s: string): string => s.replace(/\s*[\u2014\u2013\u2015]\s*/g, ', ')
283
284const oneLine = (s: string): string => noLongDash(s).replace(/\s+/g, ' ').trim()
285
286export const fallbackSubject = (files: readonly string[]): string => {
287 const first = files[0] ?? 'files'
288 const name = fileName(first)
289 return files.length > 1 ? `chore: update ${name} and ${files.length - 1} more` : `chore: update ${name}`
290}
291
292export const parseCommitReply = (text: string, files: readonly string[]): CommitText => {
293 const start = text.indexOf('{')
294 const end = text.lastIndexOf('}')
295 let raw: unknown = null
296 if (start !== -1 && end > start) {
297 try {
298 raw = JSON.parse(text.slice(start, end + 1))
299 } catch {
300 raw = null
301 }
302 }
303 const obj = raw !== null && typeof raw === 'object' ? (raw as Record<string, unknown>) : {}
304 const subject = typeof obj.subject === 'string' ? oneLine(obj.subject) : ''
305 const body = typeof obj.body === 'string' ? noLongDash(obj.body).trim() : ''
306 const warnings = Array.isArray(obj.warnings)
307 ? obj.warnings.filter((w): w is string => typeof w === 'string').map(oneLine).filter(w => w !== '').slice(0, 2)
308 : []
309 return {
310 subject: (subject === '' ? fallbackSubject(files) : subject).slice(0, MAX_SUBJECT),
311 body,
312 warnings,
313 }
314}
315
316export const TRAILER = 'Auto-commit: claude-autocommit'
317
318// The trailer block: ours, then the person's commit attribution as Claude Code
319// composed it (their settings applied; empty when they turned it off).
320export const trailers = (attribution: string | null, model: string): string[] => {
321 const own = attribution === null ? `Co-Authored-By: Claude ${model} <noreply@anthropic.com>` : attribution.trim()
322 return [TRAILER, ...(own === '' ? [] : [own])]
323}
324
325export const commitMessage = (text: Pick<CommitText, 'subject' | 'body'>, trailerLines: readonly string[]): string =>
326 [text.subject, '', ...(text.body === '' ? [] : [text.body, '']), ...trailerLines].join('\n')
327
328// "claude-opus-5-5[1m]" -> "Opus 5.5"; anything else is shown as given.
329export const prettyModel = (raw: string): string => {
330 const m = /(opus|sonnet|haiku|fable)-(\d+)(?:-(\d+))?/i.exec(raw)
331 const [, family, major, minor] = m ?? []
332 if (family === undefined || major === undefined) return raw
333 const name = family.charAt(0).toUpperCase() + family.slice(1).toLowerCase()
334 return minor === undefined || minor.length > 2 ? `${name} ${major}` : `${name} ${major}.${minor}`
335}
336hooks/i18n.ts 187 lines1import type { Language } from './config'
2
3const s = (n: number): string => (n === 1 ? '' : 's')
4// French keeps the singular for 0 and 1.
5const frS = (n: number): string => (n > 1 ? 's' : '')
6
7const en = {
8 // Band above the prompt
9 effort: 'effort',
10 context: 'context',
11 waiting: 'waiting',
12 rc: 'RC',
13 rcOn: 'on',
14 rcOff: 'off',
15 rcUnknown: '?',
16 devices: (n: number) => ` (${n} device${s(n)})`,
17 commits: 'commits',
18 made: (n: number) => `${n} auto`,
19 toPush: (n: number) => `${n} to push`,
20 pending: (n: number) => `${n} pending`,
21 paused: 'paused',
22 percent: (p: number) => `${p}%`,
23
24 // Toasts and alerts
25 committed: (repo: string, sha: string, subject: string) => `✓ ${repo} ${sha} ${subject}`,
26 alert: (repo: string, text: string) => `⚠ ${repo}: ${text}`,
27 pushed: (repo: string, n: number) => `↑ ${repo}: ${n} commit${s(n)} pushed`,
28 heldBack: (file: string, why: string) => `${file}: ${why}, never committed`,
29 tooBig: (file: string, mb: number) => `${file}: new file of ${mb} MB, too big to auto-commit`,
30 prepareFailed: (err: string) => `could not prepare the commit (${err}), retrying next turn`,
31 scanFailed: 'secret scan failed, nothing committed',
32 secret: (file: string, kind: string) => `${file}: possible secret (${kind}), not committed`,
33 dropFailed: 'could not drop the suspect file, nothing committed',
34 commitRefused: (err: string) => `commit refused (${err})`,
35 headMoved: 'HEAD moved during the commit, undone; retrying next turn',
36 headRaced: 'another commit landed right after the auto-commit: check git log',
37 hookStaged: (files: string) => `a git hook staged other files (${files}): commit undone, left uncommitted`,
38 indexStale: (paths: string) => `index not refreshed, run: git reset -q -- ${paths}`,
39 staleIndex: (n: number) =>
40 `git's index holds ${n} staged entries that differ from the files on disk, often an old index a folder sync copied back. A plain git commit would commit them all. See them: git diff --cached --stat. Not wanted: git reset -q (files on disk untouched). Or commit by naming files: git commit -- <files>`,
41 bug: (sha: string, text: string) => `possible bug (${sha}): ${text}`,
42 tooManyFiles: (n: number) => `a command changed ${n} files at once, not auto-committed`,
43 failed: (err: string) => `auto-commit error (${err})`,
44 noUpstream: 'no upstream branch, nothing pushed',
45 unreadable: 'commits to push are unreadable, nothing pushed',
46 pushSecret: (kind: string, file: string) => `push blocked: possible secret (${kind}) in ${file}`,
47 prePushFailed: 'push blocked: the pre-push check failed',
48 pushRefused: (err: string) => `push refused (${err})`,
49 timedOut: 'timed out',
50 warningLanguage: 'English',
51
52 // /commits
53 report: (paused: boolean, made: number, toPush: number, pending: number) =>
54 `Auto-commits: ${paused ? 'PAUSED' : 'on'}. ${made} commit${s(made)} this session, ${toPush} to push, ${pending} file${s(pending)} pending.`,
55 latest: 'Latest commits:',
56 alerts: 'Alerts:',
57 waitingFor: 'Pending (committed when their agent\'s turn ends):',
58 session: 'session',
59 agent: (id: string) => `agent ${id}`,
60 more: (n: number) => ` and ${n} more`,
61 files: (n: number) => `${n} file${s(n)}`,
62 ago: (min: number) => (min < 1 ? 'just now' : min < 60 ? `${min} min ago` : `${Math.round(min / 60)} h ago`),
63 autoPushTo: (patterns: string) => `Auto-push to: ${patterns}`,
64 help: '/commits pause | resume | now | undo | squash | push',
65 isPaused: 'Auto-commits paused (files are still tracked).',
66 resumed: 'Auto-commits resumed.',
67 now: (n: number) => `Committing ${n} pending file${s(n)} now.`,
68 pushStarted: 'Pushing the session\'s repos: secret scan of the outgoing commits, the pre-push check if set, then git push. Results come as notifications and in /commits.',
69
70 // /commits undo
71 undoNothing: 'No auto-commit to undo in this session.',
72 undoMoved: (repo: string) => `${repo}: HEAD is no longer the last auto-commit, nothing undone.`,
73 undoPushed: (repo: string) => `${repo}: the last auto-commit is already pushed, nothing undone (use git revert).`,
74 undoRoot: (repo: string) => `${repo}: the last auto-commit is the first commit of the repo, nothing undone.`,
75 undoBusy: (repo: string) => `${repo}: a merge, rebase or detached HEAD is in progress, nothing undone.`,
76 undoFailed: (repo: string, err: string) => `${repo}: undo failed (${err}).`,
77 undone: (repo: string, sha: string, subject: string, n: number) =>
78 `Undone ${repo} ${sha} "${subject}". Its ${n} file${s(n)} stay changed in the working tree, uncommitted.`,
79
80 // /commits squash
81 squashNothing: 'Nothing to squash: no repo has two or more unpushed auto-commits in a row at HEAD.',
82 squashed: (repo: string, n: number, sha: string, subject: string) =>
83 `${repo}: ${n} auto-commits squashed into ${sha} "${subject}".`,
84 squashFailed: (repo: string, err: string) => `${repo}: squash failed (${err}).`,
85 squashedHeader: 'Squashed commits:',
86}
87
88export type Strings = typeof en
89
90const fr: Strings = {
91 effort: 'effort',
92 context: 'contexte',
93 waiting: 'en attente',
94 rc: 'RC',
95 rcOn: 'actif',
96 rcOff: 'coupé',
97 rcUnknown: 'inconnu',
98 devices: n => ` (${n} appareil${frS(n)})`,
99 commits: 'commits',
100 made: n => `${n} auto`,
101 toPush: n => `${n} à pousser`,
102 pending: n => `${n} en attente`,
103 paused: 'en pause',
104 percent: p => `${p} %`,
105
106 committed: (repo, sha, subject) => `✓ ${repo} ${sha} ${subject}`,
107 alert: (repo, text) => `⚠ ${repo} : ${text}`,
108 pushed: (repo, n) => `↑ ${repo} : ${n} commit${frS(n)} poussé${frS(n)}`,
109 heldBack: (file, why) => `${file} : ${why}, jamais commité`,
110 tooBig: (file, mb) => `${file} : nouveau fichier de ${mb} Mo, trop gros pour un commit auto`,
111 prepareFailed: err => `préparation du commit impossible (${err}), nouvel essai au prochain tour`,
112 scanFailed: 'scan des secrets impossible, rien commité',
113 secret: (file, kind) => `${file} : secret possible (${kind}), pas commité`,
114 dropFailed: 'retrait du fichier suspect impossible, rien commité',
115 commitRefused: err => `commit refusé (${err})`,
116 headMoved: 'HEAD a bougé pendant le commit, annulé ; nouvel essai au prochain tour',
117 headRaced: 'un autre commit est arrivé juste après le commit auto : vérifier git log',
118 hookStaged: files => `un hook git a ajouté d'autres fichiers (${files}) : commit annulé, laissés non commités`,
119 indexStale: paths => `index pas mis à jour, lancer : git reset -q -- ${paths}`,
120 staleIndex: n =>
121 `l'index git contient ${n} entrées indexées qui diffèrent des fichiers sur le disque, souvent un vieil index recopié par une synchro de dossier. Un simple git commit les commiterait toutes. Les voir : git diff --cached --stat. Pas voulu : git reset -q (les fichiers sur le disque ne bougent pas). Ou commiter en nommant les fichiers : git commit -- <fichiers>`,
122 bug: (sha, text) => `bug possible (${sha}) : ${text}`,
123 tooManyFiles: n => `une commande a changé ${n} fichiers d'un coup, pas de commit auto pour eux`,
124 failed: err => `erreur du commit auto (${err})`,
125 noUpstream: 'pas de branche distante suivie, rien poussé',
126 unreadable: 'commits à pousser illisibles, rien poussé',
127 pushSecret: (kind, file) => `push bloqué : secret possible (${kind}) dans ${file}`,
128 prePushFailed: 'push bloqué : la vérification avant push a échoué',
129 pushRefused: err => `push refusé (${err})`,
130 timedOut: 'délai dépassé',
131 warningLanguage: 'simple French',
132
133 report: (paused, made, toPush, pending) =>
134 `Commits auto : ${paused ? 'EN PAUSE' : 'actifs'}. ${made} commit${frS(made)} dans la session, ${toPush} à pousser, ${pending} fichier${frS(pending)} en attente.`,
135 latest: 'Derniers commits :',
136 alerts: 'Alertes :',
137 waitingFor: 'En attente (commités à la fin du tour de leur agent) :',
138 session: 'session',
139 agent: id => `agent ${id}`,
140 more: n => ` et ${n} autre${frS(n)}`,
141 files: n => `${n} fichier${frS(n)}`,
142 ago: min => (min < 1 ? "à l'instant" : min < 60 ? `il y a ${min} min` : `il y a ${Math.round(min / 60)} h`),
143 autoPushTo: patterns => `Push automatique vers : ${patterns}`,
144 help: '/commits pause | reprendre | tout | annuler | fusionner | pousser',
145 isPaused: 'Commits auto en pause (le suivi des fichiers continue).',
146 resumed: 'Commits auto repris.',
147 now: n => `${n} fichier${frS(n)} en attente commité${frS(n)} tout de suite.`,
148 pushStarted: 'Push lancé : scan des commits sortants, vérification avant push si réglée, puis git push. Résultat en notification et dans /commits.',
149
150 undoNothing: 'Aucun commit auto à annuler dans cette session.',
151 undoMoved: repo => `${repo} : HEAD n'est plus le dernier commit auto, rien annulé.`,
152 undoPushed: repo => `${repo} : le dernier commit auto est déjà poussé, rien annulé (utiliser git revert).`,
153 undoRoot: repo => `${repo} : le dernier commit auto est le premier du dépôt, rien annulé.`,
154 undoBusy: repo => `${repo} : merge, rebase ou HEAD détachée en cours, rien annulé.`,
155 undoFailed: (repo, err) => `${repo} : annulation impossible (${err}).`,
156 undone: (repo, sha, subject, n) =>
157 `Annulé : ${repo} ${sha} « ${subject} ». Ses ${n} fichier${frS(n)} restent modifié${frS(n)} dans le dossier, non commité${frS(n)}.`,
158
159 squashNothing: 'Rien à fusionner : aucun dépôt n\'a au moins deux commits auto non poussés à la suite en tête.',
160 squashed: (repo, n, sha, subject) => `${repo} : ${n} commits auto fusionnés en ${sha} « ${subject} ».`,
161 squashFailed: (repo, err) => `${repo} : fusion impossible (${err}).`,
162 squashedHeader: 'Commits fusionnés :',
163}
164
165export const strings = (language: Language): Strings => (language === 'fr' ? fr : en)
166
167// Subcommands of /commits, French words included, to one action.
168export type Action = 'report' | 'pause' | 'resume' | 'now' | 'undo' | 'squash' | 'push'
169
170const ACTIONS: Record<string, Action> = {
171 '': 'report',
172 status: 'report',
173 pause: 'pause',
174 resume: 'resume',
175 reprendre: 'resume',
176 now: 'now',
177 tout: 'now',
178 undo: 'undo',
179 annuler: 'undo',
180 squash: 'squash',
181 fusionner: 'squash',
182 push: 'push',
183 pousser: 'push',
184}
185
186export const actionOf = (args: string): Action | null => ACTIONS[args.trim().toLowerCase()] ?? null
187hooks/secrets.ts 124 lines1// Secret scan of what an auto-commit or a push would add. A finding names the
2// file and the kind of key, never the value.
3
4import { splitDiff } from './git'
5
6export type SecretFinding = { file: string; pattern: string }
7
8// Files never committed, whatever they hold.
9const FORBIDDEN_FILES: ReadonlyArray<readonly [string, RegExp]> = [
10 ['.env file', /(^|\/)\.env(\..+)?$/i],
11 ['Apple .p8 key', /\.p8$/i],
12 ['.pem key', /\.pem$/i],
13 ['.p12 / .pfx certificate', /\.(p12|pfx)$/i],
14 ['keystore', /\.(jks|keystore)$/i],
15 ['SSH private key', /(^|\/)id_(rsa|dsa|ecdsa|ed25519)$/i],
16 ['Google service account', /(^|\/)[^/]*service[-_]?account[^/]*\.json$/i],
17 ['.netrc', /(^|\/)[._]netrc$/i],
18 ['Claude Code local settings', /(^|\/)\.claude\/settings\.local\.json$/i],
19]
20
21const CONTENT_PATTERNS: ReadonlyArray<readonly [string, RegExp]> = [
22 ['private key block', /-----BEGIN [A-Z ]*PRIVATE KEY-----/],
23 ['AWS access key', /\b(AKIA|ASIA)[0-9A-Z]{16}\b/],
24 ['Anthropic API key', /\bsk-ant-[A-Za-z0-9_-]{20,}/],
25 ['OpenAI API key', /\bsk-(proj|svcacct|admin)-[A-Za-z0-9_-]{30,}|\bsk-[A-Za-z0-9]{20}T3BlbkFJ[A-Za-z0-9]{20}\b/],
26 ['GitHub token', /\b(gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{30,})/],
27 ['GitLab token', /\bglpat-[A-Za-z0-9_-]{20,}/],
28 ['Google API key', /\bAIza[A-Za-z0-9_-]{30,}/],
29 ['Google OAuth client secret', /\bGOCSPX-[A-Za-z0-9_-]{20,}/],
30 ['Stripe live key', /\b(sk|rk)_live_[A-Za-z0-9]{8,}/],
31 ['Stripe webhook secret', /\bwhsec_[A-Za-z0-9]{20,}\b/],
32 ['secret key (sk_)', /\bsk_[A-Za-z0-9]{20,}\b/],
33 ['Slack token', /\bxox[abprs]-[A-Za-z0-9-]{10,}/],
34 ['npm token', /\bnpm_[A-Za-z0-9]{36}\b/],
35 ['Hugging Face token', /\bhf_[A-Za-z0-9]{30,}\b/],
36 ['Supabase access token', /\bsbp_[A-Za-z0-9]{20,}\b/],
37 ['PostHog personal key', /\bphx_[A-Za-z0-9]{20,}\b/],
38 ['Notion token', /\bntn_[A-Za-z0-9]{20,}\b/],
39 ['Resend API key', /\bre_[A-Za-z0-9_]{20,}\b/],
40 ['Sentry token', /\bsntry[su]_[A-Za-z0-9_.=-]{20,}/],
41 ['xAI API key', /\bxai-[A-Za-z0-9]{40,}/],
42 ['Telegram bot token', /(?<!\d)\d{8,10}:AA[A-Za-z0-9_-]{30,}/],
43]
44
45const JWT = /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*/g
46// Documentation values: a known prefix followed only by filler.
47const PLACEHOLDER = /^(sk-ant-|sk-proj-|sk-|sk_live_|rk_live_|sk_|gh[pousr]_|github_pat_|glpat-|AIza|GOCSPX-|whsec_|xox[abprs]-|npm_|hf_|sbp_|phx_|ntn_|re_|sntry[su]_|xai-|AKIA|ASIA)[xX0*.…_-]+$/
48const TEMPLATE_FILE = /\.(template|example|sample|dist)$/i
49
50export const forbiddenFile = (path: string): string | null => {
51 if (TEMPLATE_FILE.test(path)) return null
52 for (const [name, re] of FORBIDDEN_FILES) if (re.test(path)) return name
53 return null
54}
55
56const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'
57
58// base64url to text, enough to read a JWT payload; null when malformed.
59const decodeBase64Url = (s: string): string | null => {
60 let bits = 0
61 let value = 0
62 let out = ''
63 for (const ch of s.replace(/=+$/, '')) {
64 const n = B64.indexOf(ch === '+' ? '-' : ch === '/' ? '_' : ch)
65 if (n === -1) return null
66 value = ((value << 6) | n) & 0xffffff
67 bits += 6
68 if (bits >= 8) {
69 bits -= 8
70 out += String.fromCharCode((value >> bits) & 0xff)
71 }
72 }
73 return out
74}
75
76// A Supabase anon JWT is a public key: only a long JWT of another role counts.
77const isSecretJwt = (token: string): boolean => {
78 if (token.length < 60) return false
79 const payload = token.split('.')[1]
80 const json = payload === undefined ? null : decodeBase64Url(payload)
81 if (json === null) return true
82 try {
83 const role = (JSON.parse(json) as { role?: unknown }).role
84 return role !== 'anon'
85 } catch {
86 return true
87 }
88}
89
90// Inside a {{VAR}} placeholder the match is a template, not a key.
91const insidePlaceholder = (line: string, index: number): boolean => {
92 const open = line.lastIndexOf('{{', index)
93 return open !== -1 && line.indexOf('}}', open) >= index
94}
95
96export const scanLine = (line: string): string | null => {
97 if (line.includes('secret-scan:ignore')) return null
98 // Every match counts: a placeholder first on the line must not hide a real key after it.
99 for (const [name, re] of CONTENT_PATTERNS) {
100 for (const m of line.matchAll(new RegExp(re.source, 'g'))) {
101 if (!PLACEHOLDER.test(m[0]) && !insidePlaceholder(line, m.index ?? 0)) return name
102 }
103 }
104 for (const m of line.matchAll(JWT)) {
105 if (isSecretJwt(m[0]) && !insidePlaceholder(line, m.index ?? 0)) return 'JWT (not an anon key)'
106 }
107 return null
108}
109
110// Added lines of a `git diff` only (after its first hunk header): what the
111// commit would introduce. One finding per file is enough to hold it back.
112export const scanDiff = (diff: string): SecretFinding[] =>
113 splitDiff(diff).flatMap(({ file, text }) => {
114 let isInHunk = false
115 for (const raw of text.split('\n')) {
116 const line = raw.replace(/\r$/, '')
117 if (line.startsWith('@@')) isInHunk = true
118 if (!isInHunk || !line.startsWith('+')) continue
119 const pattern = scanLine(line.slice(1))
120 if (pattern !== null) return [{ file, pattern }]
121 }
122 return []
123 })
124types/index.d.ts 48 lines1export type Snapshot = {
2 model: string
3 effort: string | null
4 tokens: number | null
5 max: number
6 isRemoteOn: boolean | null
7 remoteClients: number
8}
9
10export type CommitAlert = { at: number; repo: string; text: string; count: number }
11
12export type CommitLogEntry = {
13 at: number
14 root: string
15 // Full object name, so undo and squash only ever touch our own commits.
16 sha: string
17 subject: string
18 files: number
19}
20
21export type CommitState = {
22 made: number
23 toPush: number
24 pending: number
25 unseen: number
26 isPaused: boolean
27 isBusy: boolean
28 alerts: CommitAlert[]
29 log: CommitLogEntry[]
30}
31
32// What the session changed and has not committed yet, kept by the host so a
33// reload of the mod (a settings change, an update) loses nothing.
34export type PendingEntry = { agent: string; root: string; paths: string[] }
35
36export type Tracking = { pending: PendingEntry[]; queue: string[] }
37
38declare module 'claude-code' {
39 interface PluginState {
40 autocommit: {
41 snap: Snapshot | null
42 commits: CommitState
43 tracking: Tracking
44 attribution: string | null
45 }
46 }
47}
48