SLOPSHOPPER

commit-changes

Band above the prompt that splits uncommitted changes into logical sections and commits them one by one or automatically, with clean canonical messages.

newbandtoastmodelprocesstimer
A shopper browsing a rack in a slop shop
README

claude-plugins

Claude Code plugins by @josselinonduty.

PluginDescription
commit-changesBand above the prompt that splits uncommitted changes into logical sections and commits them one by one or automatically, with clean canonical messages.
secret-guardDetects secrets in prompts and conversation rows, moves them to the project's .env file and redacts them from the history.
prompt-librarySave past prompts to a searchable library; fuzzy search, then Copy or Apply to the prompt field.

Install

/plugin marketplace add josselinonduty/claude-plugins
/plugin install commit-changes@josselinonduty-plugins
/plugin install secret-guard@josselinonduty-plugins
/plugin install prompt-library@josselinonduty-plugins
Source 3 files
hooks/register.tsx 301 lines
1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Phase, RepoStatus, Section } from '../types'
5import {
6  buildPrompt,
7  buildSections,
8  diffSamples,
9  parseStatus,
10  title
11} from './plan'
12
13const repos = atom({ plugin: 'commit-changes', key: 'repos' } as const, [] as RepoStatus[])
14const sections = atom({ plugin: 'commit-changes', key: 'sections' } as const, [] as Section[])
15const isAuto = atom({ plugin: 'commit-changes', key: 'isAuto' } as const, false)
16const phase = atom({ plugin: 'commit-changes', key: 'phase' } as const, 'idle' as Phase)
17const note = atom({ plugin: 'commit-changes', key: 'note' } as const, null as string | null)
18
19const POLL_MS = 4000
20const REDISCOVER_EVERY = 15
21const VISIBLE_SECTIONS = 6
22
23const firstLine = (text: string) =>
24  text.split('\n').find(l => l.trim())?.trim() ?? 'unknown error'
25
26const uncovered = (list: RepoStatus[], plan: Section[]) =>
27  list.map(r => {
28    const covered = new Set(
29      plan.filter(s => s.root === r.root).flatMap(s => s.files)
30    )
31    return { repo: r, files: r.files.filter(f => !covered.has(f.path)) }
32  }).filter(u => u.files.length > 0)
33
34type Api = {
35  planChanges: () => Promise<void>
36  exclusive: (work: () => Promise<void>) => Promise<void>
37  commitSection: (id: string) => Promise<void>
38  tick: () => Promise<void>
39}
40
41let api: Api | undefined
42
43export const register: Register = on => {
44  let cwd = ''
45  let roots: string[] = []
46  let ticks = 0
47  let isBusy = false
48  let isTurnRunning = false
49  let counter = 0
50
51  on('session.start', async ($, e, next) => {
52    cwd = e.cwd
53    const nextId = () => `s${++counter}`
54
55    const git = (root: string, args: string[], stdin?: string) =>
56      $.process.run(['git', '-C', root, ...args], {
57        stdin,
58        timeoutMs: 120000
59      })
60
61    const discover = async () => {
62      const top = await git(cwd, ['rev-parse', '--show-toplevel'])
63      if (top.exitCode === 0) {
64        roots = [top.stdout.trim()]
65        return
66      }
67      const found = await $.process.run([
68        'find', cwd, '-maxdepth', '3',
69        '(', '-name', 'node_modules', '-prune', ')',
70        '-o', '-name', '.git', '-print'
71      ])
72      roots = found.stdout
73        .split('\n')
74        .filter(Boolean)
75        .map(p => p.replace(/\/\.git$/, ''))
76        .sort()
77    }
78
79    const refresh = async () => {
80      const fresh: RepoStatus[] = []
81      for (const root of roots) {
82        const r = await git(root, ['status', '--porcelain=v1', '-z', '-uall', '--no-renames'])
83        if (r.exitCode !== 0) continue
84        const files = parseStatus(r.stdout)
85        if (files.length > 0) {
86          fresh.push({ root, name: root.split('/').pop() ?? root, files })
87        }
88      }
89      const changed = new Map(fresh.map(r => [r.root, new Set(r.files.map(f => f.path))]))
90      await update($, repos, () => fresh)
91      await update($, sections, list =>
92        list
93          .map(s => ({ ...s, files: s.files.filter(f => changed.get(s.root)?.has(f)) }))
94          .filter(s => s.files.length > 0)
95      )
96    }
97
98    const fail = async (message: string) => {
99      await update($, note, () => message)
100      await update($, isAuto, () => false)
101      $.ui.toast(`commit-changes: ${message}`)
102    }
103
104    const planChanges = async () => {
105      await update($, phase, () => 'planning')
106      await update($, note, () => null)
107      try {
108        const todo = uncovered(await read($, repos), await read($, sections))
109        for (const { repo, files } of todo) {
110          const batch = files.slice(0, 400)
111          const stat = await git(repo.root, ['diff', 'HEAD', '--numstat'])
112          const stats = new Map<string, string>()
113          for (const line of stat.stdout.split('\n')) {
114            const [a, d, ...p] = line.split('\t')
115            if (p.length) stats.set(p.join('\t'), `+${a} -${d}`)
116          }
117          const diff = await git(repo.root, ['diff', 'HEAD', '-U0', '--no-color'])
118          const log = await git(repo.root, ['log', '-8', '--format=%s'])
119          const subjects = log.exitCode === 0 ? log.stdout.split('\n').filter(Boolean) : []
120          const reply = await $.model.complete({
121            model: 'sonnet',
122            effort: 'low',
123            maxTokens: 8000,
124            timeoutMs: 180000,
125            prompt: buildPrompt(repo.name, batch, stats, diffSamples(diff.stdout), subjects)
126          })
127          if (!reply.isAnswered) {
128            return fail(`could not split ${repo.name}: ${reply.reason}`)
129          }
130          const made = buildSections(reply.text, batch, repo.root, repo.name, nextId)
131          await update($, sections, list => [...list, ...made])
132        }
133      } finally {
134        await update($, phase, () => 'idle')
135      }
136    }
137
138    const commitSection = async (id: string) => {
139      await refresh()
140      const section = (await read($, sections)).find(s => s.id === id)
141      if (!section) return
142      await update($, phase, () => 'committing')
143      try {
144        // Start from an empty index so the commit holds this section alone.
145        await git(section.root, ['reset', '-q'])
146        const add = await git(section.root, ['add', '-A', '--', ...section.files])
147        if (add.exitCode !== 0) {
148          return fail(`git add failed: ${firstLine(add.stderr)}`)
149        }
150        const commit = await git(section.root, ['commit', '-F', '-'], section.message)
151        if (commit.exitCode !== 0) {
152          await git(section.root, ['reset', '-q'])
153          return fail(`git commit failed: ${firstLine(commit.stderr || commit.stdout)}`)
154        }
155        await update($, note, () => null)
156        await update($, sections, list => list.filter(s => s.id !== id))
157        await refresh()
158      } finally {
159        await update($, phase, () => 'idle')
160      }
161    }
162
163    const exclusive = async (work: () => Promise<void>) => {
164      if (isBusy) return
165      isBusy = true
166      try {
167        await work()
168      } catch (error) {
169        await fail(error instanceof Error ? error.message : String(error))
170      } finally {
171        isBusy = false
172      }
173    }
174
175    const autoStep = async () => {
176      const list = await read($, repos)
177      if (list.length === 0) {
178        await update($, isAuto, () => false)
179        $.ui.toast('commit-changes: no changes left')
180        return
181      }
182      const plan = await read($, sections)
183      if (uncovered(list, plan).length > 0) return planChanges()
184      if (plan.length > 0) return commitSection(plan[0].id)
185    }
186
187    const tick = () =>
188      exclusive(async () => {
189        if (ticks++ % REDISCOVER_EVERY === 0) await discover()
190        await refresh()
191        if ((await read($, isAuto)) && !isTurnRunning) await autoStep()
192      })
193
194
195    api = { planChanges, exclusive, commitSection, tick }
196    const started = await next(e)
197    $.clock.every(POLL_MS, () => void tick())
198    void tick()
199    return started
200  })
201
202  on('turn.start', ($, e, next) => {
203    isTurnRunning = true
204    return next(e)
205  })
206
207  on('turn.complete', ($, e, next) => {
208    isTurnRunning = false
209    return next(e)
210  })
211
212  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
213    const [list, plan, auto, current, message] = await Promise.all([
214      read($, repos),
215      read($, sections),
216      read($, isAuto),
217      read($, phase),
218      read($, note)
219    ])
220    const total = list.reduce((n, r) => n + r.files.length, 0)
221    if (e.props.hasSurvey || (total === 0 && plan.length === 0)) return next(e)
222
223    const { Box, Button, Text } = $.ui.resolve(e)
224    const pending = uncovered(list, plan).reduce((n, u) => n + u.files.length, 0)
225    const working = current !== 'idle'
226    const status =
227      current === 'planning'
228        ? 'splitting into commits…'
229        : current === 'committing'
230          ? 'committing…'
231          : auto && e.props.isWorking
232            ? 'auto: waiting for the turn to finish'
233            : auto
234              ? 'auto'
235              : plan.length > 0
236                ? `${plan.length} commit${plan.length > 1 ? 's' : ''} planned`
237                : ''
238
239    const run = (work: () => Promise<void>) => () => api!.exclusive(work)
240
241    return (
242      <Box flexDirection="column">
243        <Box>
244          <Text color="yellow">● {total} uncommitted file{total === 1 ? '' : 's'}</Text>
245          <Text dimColor>
246            {' '}in {list.length} repo{list.length === 1 ? '' : 's'}
247            {status ? ` · ${status}` : ''}{' '}
248          </Text>
249          {pending > 0 && !working ? (
250            <Button
251              key="split"
252              label={plan.length ? `Split ${pending} new` : 'Split into commits'}
253              onPress={run(() => api!.planChanges())}
254            />
255          ) : null}
256          <Text> </Text>
257          <Button
258            key="auto"
259            label={auto ? 'Auto: on' : 'Auto: off'}
260            onPress={async () => {
261              await update($, isAuto, v => !v)
262              void api!.tick()
263            }}
264          />
265          {plan.length > 0 && !working ? (
266            <Button
267              key="redo"
268              label="Redo"
269              onPress={run(async () => {
270                await update($, sections, () => [])
271                await api!.planChanges()
272              })}
273            />
274          ) : null}
275        </Box>
276        {message ? <Text color="red">{message}</Text> : null}
277        {plan.slice(0, VISIBLE_SECTIONS).map((s, i) => (
278          <Box key={`row-${s.id}`}>
279            <Text dimColor>{i + 1}. </Text>
280            <Text wrap="truncate-end">{title(s.message)}</Text>
281            <Text dimColor>
282              {' '}· {s.files.length} file{s.files.length === 1 ? '' : 's'}
283              {list.length > 1 ? ` · ${s.repo}` : ''}{' '}
284            </Text>
285            {working || auto ? null : (
286              <Button
287                key={`commit-${s.id}`}
288                label="Commit"
289                onPress={run(() => api!.commitSection(s.id))}
290              />
291            )}
292          </Box>
293        ))}
294        {plan.length > VISIBLE_SECTIONS ? (
295          <Text dimColor>+{plan.length - VISIBLE_SECTIONS} more</Text>
296        ) : null}
297      </Box>
298    )
299  })
300}
301
hooks/plan.ts 216 lines
1import type { FileChange, Section } from '../types'
2
3export const TYPES = [
4  'feat',
5  'fix',
6  'refactor',
7  'perf',
8  'style',
9  'docs',
10  'test',
11  'build',
12  'ci',
13  'chore',
14  'revert'
15]
16
17const MAX_PROMPT_FILES = 400
18const MAX_SAMPLE_CHARS = 700
19const MAX_TOTAL_SAMPLE_CHARS = 30000
20
21/** Parses `git status --porcelain=v1 -z --no-renames` output. */
22export function parseStatus(out: string): FileChange[] {
23  const changes: FileChange[] = []
24  for (const entry of out.split('\0')) {
25    if (entry.length < 4) continue
26    changes.push({ code: entry.slice(0, 2), path: entry.slice(3) })
27  }
28  return changes
29}
30
31export function describeCode(code: string): string {
32  if (code === '??') return 'new'
33  if (code.includes('D')) return 'deleted'
34  if (code.includes('A')) return 'added'
35  return 'modified'
36}
37
38/** Splits `git diff` output into a map of path to the head of its patch. */
39export function diffSamples(diff: string): Map<string, string> {
40  const samples = new Map<string, string>()
41  let total = 0
42  for (const chunk of diff.split(/^diff --git /m).slice(1)) {
43    const match = /^a\/(.+?) b\/(.+)\n/.exec(chunk)
44    if (!match) continue
45    const body = chunk
46      .split('\n')
47      .filter(line => /^[+-]/.test(line) && !/^(\+\+\+|---)/.test(line))
48      .join('\n')
49      .slice(0, MAX_SAMPLE_CHARS)
50    if (total + body.length > MAX_TOTAL_SAMPLE_CHARS) break
51    total += body.length
52    samples.set(match[2], body)
53  }
54  return samples
55}
56
57export function buildPrompt(
58  repo: string,
59  changes: FileChange[],
60  stats: Map<string, string>,
61  samples: Map<string, string>,
62  recentSubjects: string[]
63): string {
64  const listed = changes.slice(0, MAX_PROMPT_FILES)
65  const lines = listed.map(c => {
66    const stat = stats.get(c.path)
67    const sample = samples.get(c.path)
68    const head = `- ${c.path} [${describeCode(c.code)}${stat ? `, ${stat}` : ''}]`
69    return sample ? `${head}\n${sample.replace(/^/gm, '    ')}` : head
70  })
71  return [
72    `Repository: ${repo}`,
73    '',
74    'Group the uncommitted changes below into logical commits: one per feature, fix,',
75    'domain, refactor, or one commit for related chores (dependencies, config, formatting).',
76    'Every file must appear in exactly one commit. Prefer fewer, coherent commits over',
77    'many tiny ones, and order them so that foundations (deps, config, shared code)',
78    'come before the features that use them.',
79    '',
80    'Commit message rules:',
81    `- type is one of: ${TYPES.join(', ')}`,
82    '- scope is a short lowercase area name, or empty',
83    '- subject is imperative, lowercase, no trailing period, at most 60 characters',
84    '- body is optional: one or two sentences on why, or a short bullet list',
85    '- describe only the code changes; never mention AI, assistants or tooling used to write them',
86    '',
87    recentSubjects.length
88      ? `Recent commit subjects, for style:\n${recentSubjects.map(s => `  ${s}`).join('\n')}\n`
89      : '',
90    'Answer with JSON only, no prose, in this shape:',
91    '{"commits":[{"type":"feat","scope":"map","subject":"add pin button","body":"","files":["path"]}]}',
92    '',
93    'Changes:',
94    ...lines,
95    changes.length > listed.length
96      ? `(${changes.length - listed.length} more files not shown; they are handled separately)`
97      : ''
98  ].join('\n')
99}
100
101type Draft = {
102  type: string
103  scope: string
104  subject: string
105  body: string
106  files: string[]
107}
108
109export function parseDrafts(text: string): Draft[] {
110  const start = text.indexOf('{')
111  const end = text.lastIndexOf('}')
112  if (start < 0 || end < start) return []
113  try {
114    const data = JSON.parse(text.slice(start, end + 1)) as { commits?: unknown }
115    if (!Array.isArray(data.commits)) return []
116    return data.commits.flatMap((c: Record<string, unknown>) => {
117      if (!c || !Array.isArray(c.files)) return []
118      return [
119        {
120          type: String(c.type ?? 'chore'),
121          scope: String(c.scope ?? ''),
122          subject: String(c.subject ?? ''),
123          body: String(c.body ?? ''),
124          files: c.files.map(String)
125        }
126      ]
127    })
128  } catch {
129    return []
130  }
131}
132
133const TRAILER = /^(co-authored-by|claude-session|generated-by)\s*:/i
134const TOOLING = /(generated with|noreply@anthropic|claude\.ai\/|🤖)/i
135const MENTION = /\b(claude|anthropic)\b/i
136
137/**
138 * Removes any trace of the authoring assistant from a message, unless the
139 * commit is about it (a file path naming it).
140 */
141export function sanitizeMessage(message: string, files: string[]): string {
142  const isFeature = files.some(f => MENTION.test(f))
143  const kept = message
144    .split('\n')
145    .filter(line => !TRAILER.test(line) && !TOOLING.test(line))
146    .filter(line => isFeature || !MENTION.test(line))
147  return kept.join('\n').replace(/\n{3,}/g, '\n\n').trim()
148}
149
150export function formatMessage(draft: Draft, files: string[]): string {
151  const type = TYPES.includes(draft.type.toLowerCase())
152    ? draft.type.toLowerCase()
153    : 'chore'
154  const scope = draft.scope
155    .toLowerCase()
156    .replace(/[^a-z0-9/_.-]+/g, '-')
157    .replace(/^-+|-+$/g, '')
158  const prefix = scope ? `${type}(${scope}): ` : `${type}: `
159  let subject = draft.subject.trim().replace(/\.+$/, '')
160  subject = subject.charAt(0).toLowerCase() + subject.slice(1)
161  if (!subject) subject = 'update project files'
162  subject = subject.slice(0, Math.max(20, 72 - prefix.length))
163  const raw = `${prefix}${subject}${draft.body.trim() ? `\n\n${draft.body.trim()}` : ''}`
164  const clean = sanitizeMessage(raw, files)
165  const header = clean.split('\n')[0]
166  return header && header.startsWith(prefix.trim()) ? clean : `chore: update project files`
167}
168
169/**
170 * Turns a model reply into sections that cover `changes` exactly once:
171 * unknown paths are dropped, repeated ones keep their first commit, and
172 * whatever the model left out becomes a final chore commit.
173 */
174export function buildSections(
175  text: string,
176  changes: FileChange[],
177  root: string,
178  repo: string,
179  nextId: () => string
180): Section[] {
181  const known = new Set(changes.map(c => c.path))
182  const seen = new Set<string>()
183  const sections: Section[] = []
184  for (const draft of parseDrafts(text)) {
185    const files: string[] = []
186    for (const f of draft.files) {
187      if (!known.has(f) || seen.has(f)) continue
188      seen.add(f)
189      files.push(f)
190    }
191    if (files.length === 0) continue
192    sections.push({
193      id: nextId(),
194      root,
195      repo,
196      message: formatMessage(draft, files),
197      files
198    })
199  }
200  const rest = changes.map(c => c.path).filter(p => !seen.has(p))
201  if (rest.length > 0) {
202    sections.push({
203      id: nextId(),
204      root,
205      repo,
206      message: sections.length ? 'chore: update remaining files' : 'chore: update project files',
207      files: rest
208    })
209  }
210  return sections
211}
212
213export function title(message: string): string {
214  return message.split('\n')[0]
215}
216
types/index.d.ts 23 lines
1export type FileChange = { path: string; code: string }
2export type RepoStatus = { root: string; name: string; files: FileChange[] }
3export type Section = {
4  id: string
5  root: string
6  repo: string
7  message: string
8  files: string[]
9}
10export type Phase = 'idle' | 'planning' | 'committing'
11
12declare module 'claude-code' {
13  interface PluginState {
14    'commit-changes': {
15      repos: RepoStatus[]
16      sections: Section[]
17      isAuto: boolean
18      phase: Phase
19      note: string | null
20    }
21  }
22}
23