SLOPSHOPPER

git-graph

접었다 펼 수 있는 깃 커밋 그래프 패널 (/git-graph)

newpanecommandprocesstimer
v0.1.2no licenseupdated 2026-10-04nemokoala/claude-mods/plugins/git-graph
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · git-graph
│ ┃ Git graph ✕ › fix the failing auth test and add an audit log call │ ┃ c: ▾ app ⎇ [ ↻ 새로고침 ] │ ┃ [ 더 보기 (0개 표시 중) ] ⏺ 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 │ │ › /git-graph │ ⎿ git-graph: Git graph 패널을 열었어요. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Git graph
c: ▾ app ⎇ [ ↻ 새로고침 ] [ 더 보기 (0개 표시 중) ]
README

nemokoala-mods

Claude Code 모드 모음 (CLI · 데스크톱 앱 Code 탭 공용)

플러그인설명
usage-band입력창 위에 5시간 / 주간 / Fable 주간 사용량 게이지 표시 (claude.ai Pro·Max 로그인 필요)
git-graph접었다 펼 수 있는 깃 커밋 그래프 패널. /git-graph [저장소 경로]

설치

Claude Code에서:

/plugin marketplace add nemokoala/claude-mods
/plugin install usage-band@nemokoala-mods
/plugin install git-graph@nemokoala-mods

또는 터미널에서:

claude plugin marketplace add nemokoala/claude-mods
claude plugin install usage-band@nemokoala-mods
claude plugin install git-graph@nemokoala-mods

설치 후 새 세션부터 적용됩니다.

Source 2 files
hooks/register.tsx 433 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Changes, Commit, Graph, GraphError } from '../types'
5
6const PANE = 'git-graph'
7const repo = atom({ plugin: 'git-graph', key: 'repo' } as const, null)
8const isCollapsed = atom({ plugin: 'git-graph', key: 'isCollapsed' } as const, false)
9const limit = atom({ plugin: 'git-graph', key: 'limit' } as const, 40)
10const graph = atom({ plugin: 'git-graph', key: 'graph' } as const, null)
11
12const SEP = '\x1f'
13const LANE_COLORS = ['#3b82f6', '#10b981', '#f59e0b', '#ec4899', '#8b5cf6', '#06b6d4', '#ef4444', '#84cc16']
14
15async function git($: EngineInterface, dir: string | null, args: string[]) {
16  const argv = dir ? ['git', '-C', dir, ...args] : ['git', ...args]
17  // GIT_OPTIONAL_LOCKS=0: `status` must never take the index lock a git
18  // command the person runs at the same moment needs.
19  return $.process.run(argv, { timeoutMs: 15_000, env: { GIT_OPTIONAL_LOCKS: '0' } })
20}
21
22// The pseudo-commit standing for the working tree's uncommitted changes.
23const WORKTREE = 'WORKTREE'
24
25function countChanges(porcelain: string): Changes | null {
26  const c: Changes = { staged: 0, unstaged: 0, untracked: 0 }
27  for (const line of porcelain.split('\n')) {
28    if (line.length < 3) continue
29    const [x, y] = [line[0], line[1]]
30    if (x === '?' && y === '?') { c.untracked++; continue }
31    if (x !== ' ') c.staged++
32    if (y !== ' ') c.unstaged++
33  }
34  return c.staged + c.unstaged + c.untracked > 0 ? c : null
35}
36
37function describeChanges(c: Changes): string {
38  return [
39    c.staged ? `스테이징 ${c.staged}` : '',
40    c.unstaged ? `수정 ${c.unstaged}` : '',
41    c.untracked ? `새 파일 ${c.untracked}` : '',
42  ].filter(Boolean).join(' · ')
43}
44
45const POLL_MS = 3_000
46const FULL_REFRESH_MS = 60_000
47let lastSeen = ''
48let lastFull = 0
49let isPolling = false
50
51// Where HEAD and every branch and tag point, and which files are changed:
52// it moves on any commit, checkout, merge, rebase, reset, fetch, branch
53// edit, or a file edited, staged or added.
54async function fingerprint($: EngineInterface, dir: string | null): Promise<string> {
55  const [refs, head, status] = await Promise.all([
56    git($, dir, ['show-ref', '--head']),
57    git($, dir, ['symbolic-ref', '-q', 'HEAD']),
58    git($, dir, ['status', '--porcelain']),
59  ])
60  return refs.exitCode === 0 ? `${head.stdout}\n${refs.stdout}\n${status.stdout}` : ''
61}
62
63// Runs on a timer: redraws when the repository moved, and once a minute
64// anyway so the "n minutes ago" times stay current.
65async function poll($: EngineInterface): Promise<void> {
66  if (isPolling) return
67  isPolling = true
68  try {
69    const g = await read($, graph)
70    const dir = g && !('error' in g) ? g.root : await read($, repo)
71    const isStale = (await $.clock.now()) - lastFull > FULL_REFRESH_MS
72    if (isStale || (await fingerprint($, dir)) !== lastSeen) await refresh($)
73  } catch {
74    // A failed check just waits for the next tick.
75  } finally {
76    isPolling = false
77  }
78}
79
80async function refresh($: EngineInterface): Promise<void> {
81  lastFull = await $.clock.now()
82  const dir = await read($, repo)
83  lastSeen = await fingerprint($, dir).catch(() => '')
84  const n = await read($, limit)
85  let next: Graph | GraphError
86  try {
87    const top = await git($, dir, ['rev-parse', '--show-toplevel'])
88    if (top.exitCode !== 0) {
89      next = { error: `${dir ?? '현재 폴더'}는 깃 저장소가 아니에요. /git-graph <경로> 로 저장소를 지정하세요.` }
90    } else {
91      const root = top.stdout.trim()
92      const [head, headSha, log, ascii, status] = await Promise.all([
93        git($, root, ['rev-parse', '--abbrev-ref', 'HEAD']),
94        git($, root, ['rev-parse', '-q', '--verify', 'HEAD']),
95        git($, root, ['log', '--all', '--topo-order', '-n', String(n),
96          `--format=%H${SEP}%P${SEP}%D${SEP}%s${SEP}%an${SEP}%ar`]),
97        git($, root, ['log', '--all', '--graph', '--oneline', '--decorate', '-n', String(n)]),
98        git($, root, ['status', '--porcelain']),
99      ])
100      const changes = countChanges(status.stdout)
101      const commits: Commit[] = log.stdout.split('\n').filter(Boolean).map(line => {
102        const [sha, parents, refs, subject, author, when] = line.split(SEP)
103        return {
104          sha,
105          parents: parents ? parents.split(' ') : [],
106          refs: refs ? refs.split(', ').filter(Boolean) : [],
107          subject: subject ?? '',
108          author: author ?? '',
109          when: when ?? '',
110        }
111      })
112      const asciiLines = ascii.stdout.split('\n').filter(Boolean)
113      if (changes) {
114        // Uncommitted work sits on top of HEAD, as its would-be child.
115        const parent = headSha.exitCode === 0 ? headSha.stdout.trim() : ''
116        commits.unshift({
117          sha: WORKTREE,
118          parents: parent ? [parent] : [],
119          refs: [],
120          subject: '커밋하지 않은 변경',
121          author: describeChanges(changes),
122          when: '',
123        })
124        asciiLines.unshift(`◌ 커밋하지 않은 변경 (${describeChanges(changes)})`)
125      }
126      next = {
127        root,
128        branch: head.stdout.trim(),
129        commits,
130        ascii: asciiLines,
131        changes,
132      }
133    }
134  } catch (err) {
135    next = { error: `git 실행 실패: ${String(err)}` }
136  }
137  await update($, graph, () => next)
138}
139
140// ---- layout -------------------------------------------------------------
141
142type Edge = { from: number; lane: number; to: number | null }
143
144// Assigns each commit a lane and records, per parent link, which lane the
145// line travels down in between the child's row and the parent's row.
146function layout(commits: Commit[]) {
147  const lanes: (string | null)[] = []
148  const placed: number[] = []
149  const edges: Edge[] = []
150  const pending = new Map<string, Edge[]>()
151  let width = 1
152
153  const freeSlot = (avoid: number) => {
154    for (let i = 0; i < lanes.length; i++) if (lanes[i] === null && i !== avoid) return i
155    lanes.push(null)
156    return lanes.length - 1
157  }
158
159  commits.forEach((c, row) => {
160    let lane = lanes.indexOf(c.sha)
161    if (lane === -1) lane = freeSlot(-1)
162    for (let i = 0; i < lanes.length; i++) if (lanes[i] === c.sha) lanes[i] = null
163    placed.push(lane)
164    for (const e of pending.get(c.sha) ?? []) e.to = row
165    pending.delete(c.sha)
166
167    c.parents.forEach((p, idx) => {
168      // The first parent always continues in this commit's lane, even when
169      // another lane already heads for it: both lines meet at the parent.
170      let k: number
171      if (idx === 0 && lanes[lane] === null) {
172        k = lane
173        lanes[k] = p
174      } else {
175        k = lanes.indexOf(p)
176        if (k === -1) {
177          k = freeSlot(lane)
178          lanes[k] = p
179        }
180      }
181      const edge: Edge = { from: row, lane: k, to: null }
182      edges.push(edge)
183      pending.set(p, [...(pending.get(p) ?? []), edge])
184    })
185    width = Math.max(width, lanes.length, lane + 1)
186    while (lanes.length && lanes[lanes.length - 1] === null) lanes.pop()
187  })
188  return { placed, edges, width }
189}
190
191const esc = (s: string) =>
192  s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
193
194const textWidth = (s: string, size: number) =>
195  [...s].reduce((w, ch) => w + (ch.charCodeAt(0) > 0x1100 ? size : size * 0.56), 0)
196
197function clip(s: string, maxW: number, size: number): string {
198  if (textWidth(s, size) <= maxW) return s
199  let out = ''
200  for (const ch of s) {
201    if (textWidth(out + ch + '…', size) > maxW) break
202    out += ch
203  }
204  return out + '…'
205}
206
207function svgGraph(g: Graph, W: number): { source: string; height: number } {
208  const ROW = 28
209  const LANE = 14
210  const PAD = 12
211  const FS = 12
212  const { placed, edges, width } = layout(g.commits)
213  const H = g.commits.length * ROW + 6
214  const x = (lane: number) => PAD + lane * LANE
215  const y = (row: number) => 3 + row * ROW + ROW / 2
216  const color = (lane: number) => LANE_COLORS[lane % LANE_COLORS.length]
217  const textX = PAD + width * LANE + 8
218  const parts: string[] = []
219
220  for (const e of edges) {
221    const x1 = x(placed[e.from])
222    const y1 = y(e.from)
223    const half = ROW / 2
224    let d = `M${x1} ${y1}`
225    let cx = x1
226    let cy = y1
227    // One row's worth of S-curve from the current point over to lane x `nx`.
228    const swing = (nx: number) => {
229      d += ` C${cx} ${cy + half} ${nx} ${cy + half} ${nx} ${cy + ROW}`
230      cx = nx
231      cy += ROW
232    }
233    if (e.to !== null && e.to === e.from + 1) {
234      swing(x(placed[e.to]))
235    } else {
236      swing(x(e.lane))
237      if (e.to === null) {
238        d += ` L${cx} ${H}`
239      } else {
240        const x2 = x(placed[e.to])
241        const y2 = y(e.to)
242        if (y2 - ROW > cy) { d += ` L${cx} ${y2 - ROW}`; cy = y2 - ROW }
243        if (cy < y2) swing(x2)
244      }
245    }
246    const dash = g.commits[e.from].sha === WORKTREE ? ' stroke-dasharray="3 3"' : ''
247    parts.push(`<path d="${d}" stroke="${color(e.lane)}" stroke-width="2" fill="none" stroke-linecap="round" opacity="0.9"${dash}/>`)
248  }
249
250  g.commits.forEach((c, row) => {
251    const cx = x(placed[row])
252    const cy = y(row)
253    const col = color(placed[row])
254    if (c.sha === WORKTREE) {
255      parts.push(`<circle cx="${cx}" cy="${cy}" r="5.5" class="bg" stroke="${col}" stroke-width="2" stroke-dasharray="2.5 2"/>`)
256      const tx = textX
257      parts.push(`<text x="${tx}" y="${cy + 4}" class="wt">${esc(c.subject)}</text>`)
258      parts.push(`<text x="${tx + textWidth(c.subject, FS) + 12}" y="${cy + 4}" class="meta">${esc(c.author)}</text>`)
259      return
260    }
261    if (c.refs.some(r => r.startsWith('HEAD'))) {
262      parts.push(`<circle cx="${cx}" cy="${cy}" r="6.5" class="bg" stroke="${col}" stroke-width="2.5"/>`)
263      parts.push(`<circle cx="${cx}" cy="${cy}" r="2.5" fill="${col}"/>`)
264    } else if (c.parents.length > 1) {
265      parts.push(`<circle cx="${cx}" cy="${cy}" r="4.5" class="bg" stroke="${col}" stroke-width="2"/>`)
266    } else {
267      parts.push(`<circle cx="${cx}" cy="${cy}" r="4.5" fill="${col}"/>`)
268    }
269
270    let tx = textX
271    const meta = `${c.author} · ${c.when}`
272    const metaW = textWidth(meta, FS - 1)
273    const right = W - PAD
274    parts.push(`<text x="${tx}" y="${cy + 4}" class="sha">${c.sha.slice(0, 7)}</text>`)
275    tx += 62
276
277    for (const raw of c.refs.slice(0, 3)) {
278      if (raw === 'HEAD') continue
279      const isHeadRef = raw.startsWith('HEAD -> ')
280      const isTag = raw.startsWith('tag: ')
281      const label = isHeadRef ? raw.slice(8) : isTag ? raw.slice(5) : raw
282      const kind = isHeadRef ? 'head' : isTag ? 'tag' : raw.includes('/') ? 'remote' : 'local'
283      const bw = textWidth(label, FS - 1) + 14
284      parts.push(`<rect x="${tx}" y="${cy - 9}" width="${bw}" height="18" rx="9" class="b-${kind}"/>`)
285      parts.push(`<text x="${tx + 7}" y="${cy + 4}" class="t-${kind}">${esc(label)}</text>`)
286      tx += bw + 5
287    }
288
289    const showMeta = right - metaW > tx + 80
290    const room = (showMeta ? right - metaW - 12 : right) - tx
291    if (room > 30) {
292      parts.push(`<text x="${tx}" y="${cy + 4}" class="subj">${esc(clip(c.subject, room, FS))}</text>`)
293    }
294    if (showMeta) {
295      parts.push(`<text x="${right}" y="${cy + 4}" text-anchor="end" class="meta">${esc(meta)}</text>`)
296    }
297  })
298
299  const style = `<style>
300    text{font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI","Malgun Gothic",sans-serif;font-size:${FS}px}
301    .sha{font-family:ui-monospace,"Cascadia Code",Consolas,monospace;font-size:11px;fill:#8b949e}
302    .subj{fill:#1f2328}.meta{fill:#6e7781;font-size:11px}.bg{fill:#ffffff}
303    .wt{fill:#b45309;font-style:italic;font-weight:600}
304    .b-head{fill:#3b82f6}.t-head{fill:#ffffff;font-size:11px;font-weight:600}
305    .b-local{fill:#10b98126;stroke:#10b981}.t-local{fill:#047857;font-size:11px}
306    .b-remote{fill:#8b949e22;stroke:#8b949e}.t-remote{fill:#57606a;font-size:11px}
307    .b-tag{fill:#f59e0b26;stroke:#f59e0b}.t-tag{fill:#b45309;font-size:11px}
308    @media (prefers-color-scheme: dark){
309      .subj{fill:#e6edf3}.meta{fill:#8b949e}.bg{fill:#1e1e1e}.wt{fill:#fbbf24}
310      .t-local{fill:#6ee7b7}.t-remote{fill:#c9d1d9}.t-tag{fill:#fcd34d}
311    }
312  </style>`
313  const source = `<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}">${style}${parts.join('')}</svg>`
314  return { source, height: H }
315}
316
317// ---- hooks --------------------------------------------------------------
318
319export const register: Register = on => {
320  on('session.start', async ($, e, next) => {
321    const result = await next(e)
322    await $.command.register({
323      name: 'git-graph',
324      description: '깃 그래프 패널 열기 (인자로 저장소 경로를 주면 그 저장소를 표시)',
325    })
326    await refresh($)
327    $.clock.every(POLL_MS, () => void poll($))
328    void $.ui.open({ id: PANE, title: 'Git graph' })
329    return result
330  })
331
332  on('command.run', { command: 'git-graph' }, async ($, e) => {
333    const arg = e.args.trim().replace(/^["']|["']$/g, '')
334    if (arg) await update($, repo, () => arg)
335    await update($, isCollapsed, () => false)
336    await refresh($)
337    await $.ui.open({ id: PANE, title: 'Git graph' })
338    const g = await read($, graph)
339    return { text: g && 'error' in g ? g.error : 'Git graph 패널을 열었어요.' }
340  })
341
342  // Commits and checkouts usually happen during a turn: redraw after each one.
343  on('turn.complete', async ($, e, next) => {
344    const result = await next(e)
345    await refresh($)
346    return result
347  })
348
349  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
350    const ui = $.ui.resolve(e)
351    const { Box, Text, Button } = ui
352    const Svg = 'Svg' in ui ? ui.Svg : undefined
353    const g = await read($, graph)
354    const collapsed = await read($, isCollapsed)
355
356    const reload = <Button key="reload" hotkey="r" dimColor label="↻ 새로고침" onPress={() => refresh($)} />
357
358    if (!g) return <Text dimColor>불러오는 중…</Text>
359    if ('error' in g) {
360      return (
361        <Box flexDirection="column" gap={1}>
362          <Text dimColor>{g.error}</Text>
363          <Box flexDirection="row">{reload}</Box>
364        </Box>
365      )
366    }
367
368    const head = g.commits.find(c => c.refs.some(r => r.startsWith('HEAD')))
369    const name = g.root.split(/[\\/]/).pop() ?? g.root
370    const header = (
371      <Box flexDirection="row" alignItems="center" gap={1} flexWrap="wrap">
372        <Button
373          key="toggle"
374          hotkey="c"
375          plain
376          label={collapsed ? '▸' : '▾'}
377          onPress={() => update($, isCollapsed, v => !v)}
378        />
379        <Text bold>{name}</Text>
380        <Text color="#3b82f6" bold>⎇ {g.branch}</Text>
381        {head ? <Text dimColor>{head.sha.slice(0, 7)}</Text> : null}
382        <Box flexGrow={1} />
383        {reload}
384      </Box>
385    )
386
387    if (collapsed) {
388      return (
389        <Box flexDirection="column">
390          {header}
391          {g.changes ? (
392            <Text color="#f59e0b" wrap="truncate-end">◌ 커밋하지 않은 변경 ({describeChanges(g.changes)})</Text>
393          ) : null}
394          {head ? <Text dimColor wrap="truncate-end">{head.subject}</Text> : null}
395        </Box>
396      )
397    }
398
399    const more = (
400      <Button
401        key="more"
402        hotkey="m"
403        dimColor
404        label={`더 보기 (${g.commits.length}개 표시 중)`}
405        onPress={async () => {
406          await update($, limit, n => Math.min(n + 30, 300))
407          await refresh($)
408        }}
409      />
410    )
411
412    if (Svg) {
413      const W = Math.max(360, Math.min(1100, Math.round(e.props.bodyColumns * 7.5)))
414      const { source, height } = svgGraph(g, W)
415      return (
416        <Box flexDirection="column" gap={1}>
417          {header}
418          <Svg source={source} alt={`${name} 커밋 그래프, 최근 ${g.commits.length}개`} width={W} height={height} />
419          <Box flexDirection="row">{more}</Box>
420        </Box>
421      )
422    }
423
424    return (
425      <Box flexDirection="column">
426        {header}
427        {g.ascii.map((line, i) => <Text key={`l${i}`} wrap="truncate-end">{line}</Text>)}
428        <Box flexDirection="row">{more}</Box>
429      </Box>
430    )
431  })
432}
433
types/index.d.ts 33 lines
1export type Commit = {
2  sha: string
3  parents: string[]
4  refs: string[]
5  subject: string
6  author: string
7  when: string
8}
9
10export type Changes = { staged: number; unstaged: number; untracked: number }
11
12export type Graph = {
13  root: string
14  branch: string
15  /** Newest first; led by a `WORKTREE` row while there are uncommitted changes. */
16  commits: Commit[]
17  ascii: string[]
18  changes: Changes | null
19}
20
21export type GraphError = { error: string }
22
23declare module 'claude-code' {
24  interface PluginState {
25    'git-graph': {
26      repo: string | null
27      isCollapsed: boolean
28      limit: number
29      graph: Graph | GraphError | null
30    }
31  }
32}
33