입력창 위에 프로젝트 · 브랜치 · 변경 · 동기화 · CI 상태를 한 줄로 보여 준다

Claude Code 안에서 도는 개인용 모드(function hooks 플러그인). 2026-10-14 에 Nexus 저장소(.claude/mods/)에서 떼어 왔다.
| 모드 | 하는 일 |
|---|---|
git-band | 입력창 위에 프로젝트 · 브랜치 · 원격 대비 앞뒤 · 변경 · CI(최신 워크플로 실행) · 마지막 커밋을 보인다. /git-band 로 즉시 새로 고침 |
ko-labels | 슬래시 명령 설명과 /config 설정 이름에 한국어를 병기한다. 사전(hooks/dictionary.ts)에 없는 것은 원문 그대로 |
터미널의 Claude Code 프롬프트에서(이 저장소가 마켓플레이스다 — 루트의 .claude-plugin/marketplace.json):
/plugin install git-band --marketplace dbsrjs/claude-mods
/plugin install ko-labels --marketplace dbsrjs/claude-mods
y 로 마켓플레이스를 추가하고 범위는 user 를 고른다.
고치면서 쓸 때는 설치 대신 폴더를 직접 올린다 — 저장하면 다시 읽힌다:
claude --plugin-dir git-band --plugin-dir ko-labels
git-band 의 CI 칸은 gh 가 설치 · 로그인돼 있어야 나온다. 없으면 CI ? (gh 없음) · (gh 로그인 필요) 로 이유를 보인다이전 커밋 기준 이 붙으면 최신 CI 실행이 지금 HEAD 의 것이 아니다(아직 push 안 했거나 CI 가 안 돌았다)claude plugin validate git-band
claude plugin test git-band
.claude-plugin/types/ 는 엔진이 로드할 때마다 새로 깔기 때문에 커밋하지 않는다(자체 .gitignore).
hooks/register.tsx 204 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { CiSnapshot, GitSnapshot } from '../types'
5import { ago, ciLabel, parseRuns, parseStatus } from './parse'
6
7const git = atom({ plugin: 'git-band', key: 'git' } as const, null)
8const ci = atom({ plugin: 'git-band', key: 'ci' } as const, null)
9
10// git 은 싸서 자주, CI(gh → GitHub API)는 비싸서 드물게 본다.
11// 실행 중인 CI 만은 결과가 곧 바뀌므로 git 주기에 맞춰 따라간다.
12const TICK_MS = 15_000
13const CI_EVERY_TICKS = 6
14
15// 진행 중인 작업 표시. 파일 이름은 git 이 쓰는 그대로다.
16const OPERATIONS: readonly [string, string][] = [
17 ['rebase-merge', '리베이스 중'],
18 ['rebase-apply', '리베이스 중'],
19 ['MERGE_HEAD', '병합 중'],
20 ['CHERRY_PICK_HEAD', '체리픽 중'],
21 ['REVERT_HEAD', '되돌리기 중'],
22 ['BISECT_LOG', 'bisect 중'],
23]
24
25// 모듈 변수는 리로드 때 초기화돼도 괜찮은 것만 둔다(겹침 방지 · 주기 계산).
26// $ 를 받는 함수는 검증기가 따라갈 수 있게 최상위 함수 선언으로 둔다.
27let busy = false
28let tick = 0
29let lastCiKey = ''
30
31async function run($: EngineInterface, argv: string[], cwd?: string) {
32 try {
33 return await $.process.run(argv, { cwd, timeoutMs: 20_000 })
34 } catch {
35 // 명령이 없거나(ENOENT) 시간 초과 — 호출부가 「모름」으로 접는다.
36 return null
37 }
38}
39
40async function readGit($: EngineInterface): Promise<GitSnapshot | null> {
41 const top = await run($, ['git', 'rev-parse', '--show-toplevel', '--absolute-git-dir'])
42 if (!top || top.exitCode !== 0) return null
43 const [root, gitDir] = top.stdout.trim().split(/\r?\n/)
44 if (!root || !gitDir) return null
45
46 const [status, log] = await Promise.all([
47 run($, ['git', 'status', '--porcelain=v2', '--branch'], root),
48 // 날짜는 %cr(로케일 영어) 대신 ISO 로 받아 한국어로 직접 쓴다.
49 run($, ['git', 'log', '-1', '--format=%h%x1f%s%x1f%cI'], root),
50 ])
51 if (!status || status.exitCode !== 0) return null
52
53 let operation: string | null = null
54 for (const [file, label] of OPERATIONS) {
55 try {
56 await $.fs.stat(`${gitDir}/${file}`)
57 operation = label
58 break
59 } catch {
60 // 없으면 그 작업이 아니다.
61 }
62 }
63
64 let lastCommit: GitSnapshot['lastCommit'] = null
65 if (log && log.exitCode === 0 && log.stdout.trim()) {
66 const [hash, subject, iso] = log.stdout.trim().split('\x1f')
67 lastCommit = { hash: hash ?? '', subject: subject ?? '', age: iso ?? '' }
68 }
69
70 const name = root.replace(/[\\/]+$/, '').split(/[\\/]/).pop() ?? root
71 return { project: name, operation, lastCommit, ...parseStatus(status.stdout) }
72}
73
74async function readCi($: EngineInterface, snap: GitSnapshot): Promise<CiSnapshot> {
75 if (snap.isDetached) return { kind: 'unknown', reason: '분리된 HEAD' }
76 const r = await run($, [
77 'gh', 'run', 'list',
78 '--branch', snap.branch,
79 '--limit', '1',
80 '--json', 'status,conclusion,workflowName,createdAt,headSha',
81 ])
82 if (!r) return { kind: 'unknown', reason: 'gh 없음' }
83 if (r.exitCode !== 0) {
84 const msg = r.stderr.toLowerCase()
85 if (msg.includes('auth') || msg.includes('login')) return { kind: 'unknown', reason: 'gh 로그인 필요' }
86 if (msg.includes('could not resolve') || msg.includes('no git remote')) return { kind: 'unknown', reason: 'GitHub 원격 없음' }
87 return { kind: 'unknown', reason: 'gh 실패' }
88 }
89 return parseRuns(r.stdout)
90}
91
92async function refresh($: EngineInterface, forceCi: boolean) {
93 if (busy) return
94 busy = true
95 try {
96 const snap = await readGit($)
97 await update($, git, () => snap)
98 if (!snap) return
99
100 const prev = await read($, ci)
101 const ciKey = `${snap.project}|${snap.branch}|${snap.oid}`
102 const isRunning = prev?.kind === 'run' && prev.status !== 'completed'
103 // 브랜치나 HEAD 가 바뀌면(커밋 · 체크아웃 · push 후) 곧바로 다시 본다.
104 if (forceCi || isRunning || ciKey !== lastCiKey || tick % CI_EVERY_TICKS === 0) {
105 lastCiKey = ciKey
106 const next = await readCi($, snap)
107 await update($, ci, () => next)
108 }
109 } finally {
110 busy = false
111 }
112}
113
114export const register: Register = on => {
115 on('session.start', async ($, e, next) => {
116 const r = await next(e)
117 void refresh($, true)
118 $.clock.every(TICK_MS, () => {
119 tick++
120 void refresh($, false)
121 })
122 await $.command.register({
123 name: 'git-band',
124 description: 'git 상태 띠를 지금 새로 고친다(CI 포함)',
125 })
126 return r
127 })
128
129 on('command.run', { command: 'git-band' }, async $ => {
130 await refresh($, true)
131 return { text: 'git 상태를 새로 고쳤습니다.' }
132 })
133
134 // 턴이 끝나면 Claude 가 커밋 · 체크아웃 · push 했을 수 있으니 바로 반영한다.
135 on('turn.complete', async ($, e, next) => {
136 const r = await next(e)
137 void refresh($, false)
138 return r
139 })
140
141 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
142 if (e.props.hasSurvey) return next(e)
143 const snap = await read($, git)
144 if (!snap) return next(e)
145 const c = await read($, ci)
146 const { Box, Text } = $.ui.resolve(e)
147 const now = await $.clock.now()
148
149 const sync =
150 snap.upstream === null
151 ? '원격 추적 없음'
152 : snap.ahead === 0 && snap.behind === 0
153 ? '동기화됨'
154 : `↑${snap.ahead} ↓${snap.behind}`
155 const changes = [
156 snap.conflicted ? `충돌 ${snap.conflicted}` : '',
157 snap.staged ? `스테이징 ${snap.staged}` : '',
158 snap.unstaged ? `수정 ${snap.unstaged}` : '',
159 snap.untracked ? `새 파일 ${snap.untracked}` : '',
160 ].filter(Boolean)
161 const changeText = changes.length ? changes.join(' · ') : '깨끗함'
162 const changeColor = snap.conflicted ? 'error' : changes.length ? 'warning' : 'success'
163
164 let ciText: { text: string; color: string } | null = null
165 let ciMeta = ''
166 if (c) {
167 ciText = ciLabel(c)
168 if (c.kind === 'run') {
169 const when = ago(c.createdAt, now)
170 // 최신 실행이 지금 HEAD 의 것이 아니면 「아직 push 안 함 / CI 안 돎」을 알린다.
171 const stale = c.headSha && snap.oid && c.headSha !== snap.oid ? ' · 이전 커밋 기준' : ''
172 ciMeta = ` ${c.workflow}${when ? ` · ${when}` : ''}${stale}`
173 }
174 }
175
176 return (
177 <Box flexDirection="column" width={e.props.bodyColumns}>
178 <Box flexDirection="row">
179 <Text wrap="truncate-end">
180 <Text bold color="claude">{snap.project}</Text>
181 <Text dimColor> ⎇ </Text>
182 <Text color="suggestion">{snap.branch}</Text>
183 {snap.isDetached ? <Text color="warning"> (분리된 HEAD)</Text> : null}
184 {snap.operation ? <Text color="error"> [{snap.operation}]</Text> : null}
185 <Text dimColor> │ </Text>
186 <Text>{sync}</Text>
187 <Text dimColor> │ </Text>
188 <Text color={changeColor}>{changeText}</Text>
189 {ciText ? <Text dimColor> │ </Text> : null}
190 {ciText ? <Text color={ciText.color}>{ciText.text}</Text> : null}
191 {ciMeta ? <Text dimColor>{ciMeta}</Text> : null}
192 </Text>
193 </Box>
194 {snap.lastCommit ? (
195 <Text dimColor wrap="truncate-end">
196 {snap.lastCommit.hash} {snap.lastCommit.subject}
197 {ago(snap.lastCommit.age, now) ? ` · ${ago(snap.lastCommit.age, now)}` : ''}
198 </Text>
199 ) : null}
200 </Box>
201 )
202 })
203}
204hooks/parse.ts 104 lines1import type { CiSnapshot, GitSnapshot } from '../types'
2
3// git status --porcelain=v2 --branch 를 파싱한다. 사람이 읽는 형식(git status)은
4// 로케일 · 버전마다 문구가 바뀌어 기계용 v2 형식을 쓴다.
5export function parseStatus(
6 text: string,
7): Omit<GitSnapshot, 'project' | 'operation' | 'lastCommit'> {
8 let oid = ''
9 let head = ''
10 let upstream: string | null = null
11 let ahead = 0
12 let behind = 0
13 let staged = 0
14 let unstaged = 0
15 let untracked = 0
16 let conflicted = 0
17
18 for (const line of text.split(/\r?\n/)) {
19 if (line.startsWith('# branch.oid ')) oid = line.slice(13)
20 else if (line.startsWith('# branch.head ')) head = line.slice(14)
21 else if (line.startsWith('# branch.upstream ')) upstream = line.slice(18)
22 else if (line.startsWith('# branch.ab ')) {
23 const m = /\+(\d+) -(\d+)/.exec(line)
24 if (m) {
25 ahead = Number(m[1])
26 behind = Number(m[2])
27 }
28 } else if (line.startsWith('1 ') || line.startsWith('2 ')) {
29 // XY: X 는 인덱스(스테이징), Y 는 작업 트리. '.' 은 변경 없음.
30 const xy = line.slice(2, 4)
31 if (xy[0] !== '.') staged++
32 if (xy[1] !== '.') unstaged++
33 } else if (line.startsWith('u ')) conflicted++
34 else if (line.startsWith('? ')) untracked++
35 }
36
37 const isDetached = head === '(detached)'
38 return {
39 oid,
40 branch: isDetached ? oid.slice(0, 7) : head,
41 isDetached,
42 upstream,
43 ahead,
44 behind,
45 staged,
46 unstaged,
47 untracked,
48 conflicted,
49 }
50}
51
52// gh run list --json 결과 중 첫 줄. 실패를 'unknown' 으로 접어 띠가 이유를 보이게 한다.
53export function parseRuns(stdout: string): CiSnapshot {
54 let runs: unknown
55 try {
56 runs = JSON.parse(stdout)
57 } catch {
58 return { kind: 'unknown', reason: 'gh 응답 해석 실패' }
59 }
60 if (!Array.isArray(runs) || runs.length === 0) return { kind: 'none' }
61 const r = runs[0] as Record<string, unknown>
62 return {
63 kind: 'run',
64 workflow: String(r.workflowName ?? ''),
65 status: String(r.status ?? ''),
66 conclusion: String(r.conclusion ?? ''),
67 createdAt: String(r.createdAt ?? ''),
68 headSha: String(r.headSha ?? ''),
69 }
70}
71
72// 「3분 전」 같은 상대 시각. 모르는 값이면 빈 문자열(화면이 말하지 않는다).
73export function ago(iso: string, nowMs: number): string {
74 const t = Date.parse(iso)
75 if (Number.isNaN(t)) return ''
76 const s = Math.max(0, Math.round((nowMs - t) / 1000))
77 if (s < 60) return '방금'
78 if (s < 3600) return `${Math.floor(s / 60)}분 전`
79 if (s < 86400) return `${Math.floor(s / 3600)}시간 전`
80 return `${Math.floor(s / 86400)}일 전`
81}
82
83export function ciLabel(ci: CiSnapshot): { text: string; color: string } {
84 if (ci.kind === 'unknown') return { text: `CI ? (${ci.reason})`, color: 'inactive' }
85 if (ci.kind === 'none') return { text: 'CI 실행 없음', color: 'inactive' }
86 if (ci.status !== 'completed') {
87 return { text: ci.status === 'queued' ? 'CI ⏳ 대기' : 'CI ⟳ 실행 중', color: 'warning' }
88 }
89 switch (ci.conclusion) {
90 case 'success':
91 return { text: 'CI ✓ 통과', color: 'success' }
92 case 'failure':
93 case 'timed_out':
94 case 'startup_failure':
95 return { text: 'CI ✗ 실패', color: 'error' }
96 case 'cancelled':
97 return { text: 'CI ⊘ 취소', color: 'inactive' }
98 case 'skipped':
99 return { text: 'CI – 건너뜀', color: 'inactive' }
100 default:
101 return { text: `CI ${ci.conclusion}`, color: 'inactive' }
102 }
103}
104types/index.d.ts 36 lines1// 띠가 그리는 git 상태. 저장소가 아니면 null.
2export type GitSnapshot = {
3 project: string
4 oid: string
5 branch: string
6 isDetached: boolean
7 upstream: string | null
8 ahead: number
9 behind: number
10 staged: number
11 unstaged: number
12 untracked: number
13 conflicted: number
14 operation: string | null
15 lastCommit: { hash: string; subject: string; age: string } | null
16}
17
18// gh 로 읽은 최신 워크플로 실행. gh 가 없거나 인증이 안 되면 kind 가 'unknown'.
19export type CiSnapshot =
20 | { kind: 'unknown'; reason: string }
21 | { kind: 'none' }
22 | {
23 kind: 'run'
24 workflow: string
25 status: string
26 conclusion: string
27 createdAt: string
28 headSha: string
29 }
30
31declare module 'claude-code' {
32 interface PluginState {
33 'git-band': { git: GitSnapshot | null; ci: CiSnapshot | null }
34 }
35}
36