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

Claude Code plugins by @josselinonduty.
| Plugin | Description |
|---|---|
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. |
secret-guard | Detects secrets in prompts and conversation rows, moves them to the project's .env file and redacts them from the history. |
prompt-library | Save past prompts to a searchable library; fuzzy search, then Copy or Apply to the prompt field. |
/plugin marketplace add josselinonduty/claude-plugins
/plugin install commit-changes@josselinonduty-plugins
/plugin install secret-guard@josselinonduty-plugins
/plugin install prompt-library@josselinonduty-pluginshooks/register.tsx 301 lines1import { 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}
301hooks/plan.ts 216 lines1import 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}
216types/index.d.ts 23 lines1export 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