/changes pane: branch, ahead/behind, staged, unstaged, untracked and conflicted files with +/- line counts, recent commits. Read-only git, refreshed as Claude…

Personal Claude Code mods, built or vendored at a reviewed commit. Each mod lives in its own folder and is listed in .claude-plugin/marketplace.json.
hooks/register.tsx 208 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { FileChange, Snapshot, Status } from '../types'
5import { parseLog, parseNumstat, parseStatus, withCounts } from './git'
6
7// A /changes pane: the branch and how far it is from its upstream, what is
8// staged, changed, untracked and conflicted (with +/- line counts), and the
9// last few commits. Read-only: it runs `git status`, `git diff --numstat`,
10// `git log` and `git stash list` in the session's directory, only while the
11// pane is open, after Claude edits files or runs a shell command, and at the
12// end of each turn. No network, no model calls, nothing that writes.
13
14const PANE = 'changes'
15const STORE_OPEN = 'open'
16const SHOW = 25
17const snapshot = atom({ plugin: 'git-changes', key: 'snapshot' } as const, null)
18
19async function git($: EngineInterface, cwd: string, args: string[]): Promise<string> {
20 const run = await $.process.run(['git', '--no-optional-locks', '-C', cwd, ...args], { timeoutMs: 8_000, env: { GIT_OPTIONAL_LOCKS: '0' } })
21 if (run.exitCode !== 0) throw new Error(run.stderr.trim().split(/\r?\n/)[0] || `git ${args[0]} failed`)
22 return run.stdout
23}
24
25async function look($: EngineInterface): Promise<Snapshot> {
26 const cwd = await $.session.cwd()
27 const at = await $.clock.now()
28 let raw: string
29 try {
30 raw = await git($, cwd, ['status', '--porcelain=v2', '--branch', '-z'])
31 } catch (err) {
32 const why = err instanceof Error ? err.message : String(err)
33 return { status: null, problem: /not a git repository/i.test(why) ? `Not a git repository: ${cwd}` : `git: ${why}`, at }
34 }
35 const base = parseStatus(raw)
36 const hasCommits = !raw.includes('# branch.oid (initial)')
37 const [unstagedN, stagedN, log, stash] = await Promise.all([
38 git($, cwd, ['diff', '--numstat', '-z', '--no-renames']).catch(() => ''),
39 git($, cwd, ['diff', '--cached', '--numstat', '-z', '--no-renames']).catch(() => ''),
40 hasCommits ? git($, cwd, ['log', '-5', '--format=%h%x1f%s%x1f%cr']).catch(() => '') : Promise.resolve(''),
41 git($, cwd, ['stash', 'list']).catch(() => ''),
42 ])
43 const status: Status = {
44 ...base,
45 staged: withCounts(base.staged, parseNumstat(stagedN)),
46 unstaged: withCounts(base.unstaged, parseNumstat(unstagedN)),
47 commits: parseLog(log),
48 stashes: stash.split(/\r?\n/).filter(Boolean).length,
49 }
50 return { status, problem: null, at }
51}
52
53const isOpen = async ($: EngineInterface) => (await $.ui.panes()).some(p => p.id === PANE)
54
55async function refresh($: EngineInterface) {
56 const snap = await look($)
57 await update($, snapshot, () => snap)
58}
59
60async function refreshIfOpen($: EngineInterface) {
61 if (await isOpen($)) await refresh($)
62}
63
64// Edits and shell commands come in bursts: one look after the burst settles.
65// Never two looks at once: calls that land while one runs (a slow `git status`
66// in a big repo) earn exactly one more look after it.
67let isQueued = false
68let isRunning = false
69let isStale = false
70function soon($: EngineInterface) {
71 if (isRunning) {
72 isStale = true
73 return
74 }
75 if (isQueued) return
76 isQueued = true
77 $.clock.after(800, () => void settle($))
78}
79
80async function settle($: EngineInterface) {
81 isRunning = true
82 try {
83 do {
84 isStale = false
85 await refreshIfOpen($).catch(() => undefined)
86 } while (isStale)
87 } finally {
88 isRunning = false
89 isQueued = false
90 }
91}
92
93async function openPane($: EngineInterface) {
94 await $.ui.open({ id: PANE, title: 'Changes' })
95 await refresh($).catch(err => update($, snapshot, () => ({ status: null, problem: String(err), at: 0 })))
96}
97
98export const register: Register = on => {
99 on('session.start', async ($, e, next) => {
100 await $.command.register({ name: 'changes', description: 'Show or hide the git changes pane (staged, unstaged, untracked, ahead/behind, recent commits)' })
101 const ran = await next(e)
102 if ((await $.store.get(STORE_OPEN).catch(() => false)) === true) void openPane($).catch(() => undefined)
103 return ran
104 })
105
106 on('command.run', { command: 'changes' }, async $ => {
107 if (await isOpen($)) {
108 await $.ui.close({ id: PANE })
109 await $.store.set(STORE_OPEN, false)
110 return { text: 'Changes pane closed.' }
111 }
112 await openPane($)
113 await $.store.set(STORE_OPEN, true)
114 return { text: 'Changes pane opened.' }
115 })
116
117 on('tool.call', { tool: ['Bash', 'PowerShell', 'Edit', 'Write', 'NotebookEdit'] }, async ($, e, next) => {
118 const ran = await next(e)
119 soon($)
120 return ran
121 }).catch(($, e, next) => next(e))
122
123 on('turn.complete', async ($, e, next) => {
124 const ran = await next(e)
125 soon($)
126 return ran
127 }).catch(($, e, next) => next(e))
128
129 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
130 const { Box, Button, Text } = $.ui.resolve(e)
131 const snap = await read($, snapshot)
132 const width = e.props.bodyColumns
133 const s = snap?.status
134
135 const fileRow = (f: FileChange, color: string) => {
136 const name = f.from ? `${f.from} → ${f.path}` : f.path
137 const room = Math.max(10, width - 18) // the counts keep their place; a long path loses its head
138 return (
139 <Text wrap="truncate-end">
140 {' '}
141 <Text color={f.code === 'D' ? '#f87171' : color}>{f.code} </Text>
142 {name.length > room ? '…' + name.slice(name.length - room + 1) : name}
143 {f.isBinary ? (
144 <Text dimColor> bin</Text>
145 ) : f.added !== undefined ? (
146 <Text>
147 {' '}
148 <Text color="#4ade80">+{f.added}</Text> <Text color="#f87171">-{f.removed}</Text>
149 </Text>
150 ) : null}
151 </Text>
152 )
153 }
154 const section = (title: string, color: string, files: FileChange[]) =>
155 files.length === 0 ? null : (
156 <Box flexDirection="column">
157 <Text bold color={color}>
158 {title} ({files.length})
159 </Text>
160 {files.slice(0, SHOW).map(f => fileRow(f, color))}
161 {files.length > SHOW && <Text dimColor> +{files.length - SHOW} more</Text>}
162 </Box>
163 )
164 const paths = (list: string[], code: string) => list.map(path => ({ path, code }))
165
166 return (
167 <Box flexDirection="column" width={width}>
168 {!snap && <Text dimColor>Looking…</Text>}
169 {snap?.problem && <Text color="#facc15">{snap.problem}</Text>}
170 {s && (
171 <Text wrap="truncate-end">
172 <Text bold color="#22d3ee">
173 ⎇ {s.branch}
174 </Text>
175 {s.upstream ? (
176 <Text>
177 {' '}
178 → {s.upstream} <Text color={s.ahead ? '#4ade80' : 'gray'}>↑{s.ahead}</Text> <Text color={s.behind ? '#facc15' : 'gray'}>↓{s.behind}</Text>
179 </Text>
180 ) : (
181 <Text dimColor> (no upstream)</Text>
182 )}
183 {s.stashes > 0 && <Text dimColor> · {s.stashes} stash{s.stashes === 1 ? '' : 'es'}</Text>}
184 </Text>
185 )}
186 {s && s.staged.length + s.unstaged.length + s.untracked.length + s.conflicts.length === 0 && <Text color="#4ade80">Working tree clean.</Text>}
187 {s && section('Conflicts', '#f87171', paths(s.conflicts, 'U'))}
188 {s && section('Staged', '#4ade80', s.staged)}
189 {s && section('Changes', '#facc15', s.unstaged)}
190 {s && section('Untracked', 'gray', paths(s.untracked, '?'))}
191 {s && s.commits.length > 0 && (
192 <Box flexDirection="column">
193 <Text bold>Recent commits</Text>
194 {s.commits.map(c => (
195 <Text wrap="truncate-end">
196 {' '}
197 <Text color="#a78bfa">{c.hash}</Text> {c.subject} <Text dimColor>{c.when}</Text>
198 </Text>
199 ))}
200 </Box>
201 )}
202 <Text> </Text>
203 <Button key="refresh" label="Refresh" onPress={() => void refresh($).catch(() => undefined)} />
204 </Box>
205 )
206 })
207}
208hooks/git.ts 75 lines1// Pure parsing of git's machine-readable output.
2
3import type { Commit, FileChange, Status } from '../types'
4
5/** `git status --porcelain=v2 --branch -z`: the branch headers and one entry per changed path. */
6export function parseStatus(z: string): Omit<Status, 'commits' | 'stashes'> {
7 const s: Omit<Status, 'commits' | 'stashes'> = { branch: '', ahead: 0, behind: 0, staged: [], unstaged: [], untracked: [], conflicts: [] }
8 const records = z.split('\0')
9 for (let i = 0; i < records.length; i++) {
10 const r = records[i]
11 if (!r) continue
12 if (r.startsWith('# branch.head ')) s.branch = r.slice(14)
13 else if (r.startsWith('# branch.upstream ')) s.upstream = r.slice(18)
14 else if (r.startsWith('# branch.ab ')) {
15 const m = /^# branch\.ab \+(\d+) -(\d+)$/.exec(r)
16 if (m) {
17 s.ahead = Number(m[1])
18 s.behind = Number(m[2])
19 }
20 } else if (r.startsWith('? ')) s.untracked.push(r.slice(2))
21 else if (r.startsWith('u ')) s.conflicts.push(fields(r, 10).path)
22 else if (r.startsWith('1 ') || r.startsWith('2 ')) {
23 const isRename = r[0] === '2'
24 const { head, path } = fields(r, isRename ? 9 : 8)
25 const from = isRename ? records[++i] : undefined // a rename's next record is the path it came from
26 const [x, y] = head[1]
27 if (x !== '.') s.staged.push(from ? { path, code: x, from } : { path, code: x })
28 if (y !== '.') s.unstaged.push({ path, code: y })
29 }
30 }
31 if (s.branch === '(detached)') s.branch = 'detached HEAD'
32 return s
33}
34
35/** The first `n` space-separated fields, and the path after them (which may hold spaces). */
36function fields(record: string, n: number): { head: string[]; path: string } {
37 const head: string[] = []
38 let at = 0
39 for (let k = 0; k < n; k++) {
40 const sp = record.indexOf(' ', at)
41 head.push(record.slice(at, sp))
42 at = sp + 1
43 }
44 return { head, path: record.slice(at) }
45}
46
47/** `git diff --numstat -z --no-renames`: lines added and removed per path; binary files say so. */
48export function parseNumstat(z: string): Map<string, { added?: number; removed?: number; isBinary?: boolean }> {
49 const out = new Map<string, { added?: number; removed?: number; isBinary?: boolean }>()
50 for (const r of z.split('\0')) {
51 const m = /^(\d+|-)\t(\d+|-)\t(.+)$/s.exec(r)
52 if (!m) continue
53 out.set(m[3], m[1] === '-' ? { isBinary: true } : { added: Number(m[1]), removed: Number(m[2]) })
54 }
55 return out
56}
57
58export function withCounts(list: FileChange[], counts: Map<string, { added?: number; removed?: number; isBinary?: boolean }>): FileChange[] {
59 return list.map(f => ({ ...f, ...(counts.get(f.path) ?? {}) }))
60}
61
62/** `git log --format=%h%x1f%s%x1f%cr`: one commit a line. */
63export function parseLog(text: string): Commit[] {
64 return text
65 .split(/\r?\n/)
66 .filter(Boolean)
67 .map(line => {
68 const [hash, subject = '', when = ''] = line.split('\x1f')
69 return { hash, subject, when }
70 })
71}
72
73/** What a status letter means, as `git status` words it. */
74export const WORDS: Record<string, string> = { M: 'modified', A: 'added', D: 'deleted', R: 'renamed', C: 'copied', T: 'type changed', U: 'conflict' }
75types/index.d.ts 22 lines1export type FileChange = { path: string; code: string; from?: string; added?: number; removed?: number; isBinary?: boolean }
2export type Commit = { hash: string; subject: string; when: string }
3export type Status = {
4 branch: string
5 upstream?: string
6 ahead: number
7 behind: number
8 staged: FileChange[]
9 unstaged: FileChange[]
10 untracked: string[]
11 conflicts: string[]
12 commits: Commit[]
13 stashes: number
14}
15export type Snapshot = { status: Status | null; problem: string | null; at: number }
16
17declare module 'claude-code' {
18 interface PluginState {
19 'git-changes': { snapshot: Snapshot | null }
20 }
21}
22