SLOPSHOPPER

git-changes

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

newpaneguardcommandprocesstimer
v0.1.0MITupdated 2026-10-12aecheman27-gif/claude-mods/git-changes
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · git-changes
│ ┃ Changes ✕ › fix the failing auth test and add an audit log call │ ┃ ⎇ (no upstream) │ ┃ Working tree clean. ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ [ Refresh ] ⏺ 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 │ │ › /changes │ ⎿ git-changes: Changes pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Changes
⎇ (no upstream) Working tree clean. [ Refresh ]
README

claude-mods

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.

Source 3 files
hooks/register.tsx 208 lines
1import { 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}
208
hooks/git.ts 75 lines
1// 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' }
75
types/index.d.ts 22 lines
1export 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