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

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
설치 후 새 세션부터 적용됩니다.
hooks/register.tsx 433 lines1import { 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
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}
433types/index.d.ts 33 lines1export 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