SLOPSHOPPER

autocommit

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…

newbandguardcommandtoastmodel
v1.1.0MITupdated 2026-10-05Iliesseu28/claude-autocommit
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · autocommit
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /commits ⎿ autocommit: Auto-commits: on. 0 commits this session, 0 to push, 0 files pending. ⎿ autocommit: ⎿ autocommit: /commits pause | resume | now | undo | squash | push ⟨Claude Code's own drawing⟩ Opus 5.5 effort ? context ██████████░░░░░░░░░░ 97k / 200k (49%) commits 0 auto ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ Opus 5.5 effort ? context ██████████░░░░░░░░░░ 97k / 200k (49%) commits 0 auto
README

<img src="docs/poster.png" alt="claude-autocommit: Claude Code commits its own work, one clean commit per turn" width="100%">

claude-autocommit

Claude Code commits its own work. One clean commit per turn, a real message, and nothing that was not Claude's.

ci Claude Code mod dependencies License: MIT

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

Install

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.

What you get

One commit per turn, per repo, per agentClaude's changes land as soon as its answer is done, about two seconds later.
Only Claude's filesAn 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 diffHaiku 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 eyesThe same call flags obvious slips in the diff (debug leftover, broken reference, half-finished code) as an alert.
A secret guardEvery 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 untouchedCommits are built in a private git index. Whatever you had staged stays staged.
Subagents handledA 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 warningWhen 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 barModel, effort, context gauge, Remote Control, and the commit counters, above the prompt.

How it works

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

Commands

CommandWhat it does
/commitsReport: latest commits, alerts, files still pending. Clears the alert count.
/commits pause / resumeHolds commits (files are still tracked), then sends them.
/commits nowCommits every pending file now, subagents included, without waiting for a turn to end.
/commits undoDrops the session's last auto-commit if it is still HEAD and unpushed. Its files stay changed, uncommitted.
/commits squashFolds the session's unpushed auto-commits sitting in a row at HEAD into one, with a new message. No file is touched.
/commits pushPushes 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).

Settings

SettingDefaultWhat it does
languageenLanguage of the bar, alerts and report: en or fr.
commitModelhaikuModel that writes the messages: haiku, sonnet or opus.
commitLanguageEnglishLanguage of the commit messages: any language name.
bugChecktrueAsk the model to flag obvious bugs in the diff.
autoPushemptyRemotes 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).
prePushCommandemptyA command that must succeed before any push. {root} is the repo root, {files} a file listing the paths the push changes.
barfullfull, compact (context and commits only) or off.
maxFilesPerCommand40A shell command that changes more files than this at once (an install into a tracked folder, a code generator) is reported instead of committed.

What it never does

  • Commit a file Claude did not change. Your other dirty files stay out. (A file Claude does edit is committed whole, so any uncommitted change you had already made to that same file goes with it.)
  • Touch what you staged. The commit is built in an index file of its own in .git (one per session); afterwards only the committed paths are refreshed in your index.
  • Run beside a git command of Claude's. If Claude commits by hand, the command waits for an auto-commit under way, and no auto-commit starts while it runs. No index.lock fights, no commit built on a stale HEAD.
  • Undo someone else's commit. If 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.
  • Commit mid-merge, mid-rebase, mid-cherry-pick or on a detached HEAD. The files wait.
  • Commit a likely secret or a new file over 5 MB.
  • Push unless you asked or the remote is in autoPush. Never a force push.
  • Rewrite pushed history. undo and squash only touch unpushed commits made in this session.
  • Lose your index on a crash. A small journal in .git/AUTOCOMMIT_RESET lets the next run finish the index refresh.
  • Leave files behind in .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.

Honest limits

  • During a shell command, the mod compares the repo before and after. Any file another process writes while that command runs (your editor, another Claude Code session, a sync tool such as Syncthing or Dropbox) looks like the command's work and is committed with it, with Claude's attribution.
  • Two sessions editing the same file: the commit takes the file as it is on disk.
  • A subagent's files are committed when Claude's turn ends, even one you interrupt with Esc (at once if Claude is already idle), one commit per agent. In headless 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.
  • The stale index warning reads the git status an auto-commit already runs, so it only shows in a repo where Claude changed something.
  • Files still pending when you quit stay uncommitted (they show in git status); a new session does not pick them up.
  • Your git hooks do not run on auto-commits: Claude Code starts a mod's git commands with the repo's hooks turned off, a safety default. Checks that must pass belong in prePushCommand (run before every push) or in CI.
  • Alerts are notifications and the /commits report. In headless claude -p runs nobody sees them: read git log.
  • The secret scan is pattern based. It catches the common key formats, not every secret; keep a real scanner such as gitleaks in your prePushCommand or CI if a leak would hurt.

FAQ

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.

Uninstall

/plugin uninstall autocommit@claude-autocommit

Development

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
FileRole
hooks/register.tsxThe hooks: tracking, commit, push, undo, squash, the bar.
hooks/git.tsPure helpers: porcelain parsing, read-only command detection, prompts, message layout.
hooks/secrets.tsKey patterns and forbidden file names.
hooks/config.ts, hooks/i18n.tsSettings and the English and French strings.
types/index.d.tsThe state contract checked by claude plugin validate.

License

MIT

Source 6 files
hooks/register.tsx 1259 lines
1import { 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_CELLS
hooks/config.ts 45 lines
1import 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}
45
hooks/git.ts 336 lines
1// 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}
336
hooks/i18n.ts 187 lines
1import 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
187
hooks/secrets.ts 124 lines
1// 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  })
124
types/index.d.ts 48 lines
1export 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