A band above the prompt showing the current git branch

A one-line band above the prompt naming the git branch the session is on, so you always know where edits land without running git status. It also shows the number of changed files and how far you are ahead of or behind the upstream branch, and it hides outside a repository.
⎇ feature/login-flow · 3 changed · ↑2 ↓1
❯ _
When HEAD is detached it reads ⎇ detached @ a1b2c3d, and in a new repo it reads ⎇ master · no commits yet. Below 40 columns it shows only the branch name.
/git-branch demo
/git-branch: prints the current line (or why there is none) and refreshes the band/git-branch demo: shows the four sample states until the turn ends/git-branch hide / show: hides the band for this session, or shows it again/git-branch refresh: reads git again nowThe band refreshes at session start, after every Bash call and when each turn ends.
From the GitHub marketplace:
/plugin install git-branch --marketplace bankielewicz/mod-builder
From a local clone (claude plugin marketplace add <folder> once):
/plugin install git-branch@claudemod
While developing, from its folder:
claude --plugin-dir <path-to>/git-branch
Changes load with /reload-plugins or a new session.
None.
Mods are not sandboxed. They run with your access and skip Claude's permission prompts, so this table lists everything this one can touch. It is generated from claude plugin validate.
| Reach | Detail |
|---|---|
| Hooks | session.start, command.run{command=git-branch}, tool.call{tool=Bash}, turn.complete, ui.render{component=AbovePrompt} |
| Processes | $.process.run: git --no-optional-locks status --porcelain=v2 --branch in the session's cwd (read-only, 5 s timeout) |
| Files | none written; session state only (git-branch.snapshot, isHidden, isDemo) |
| Network | none |
| Model calls | none |
| Gating hooks | tool.call{tool=Bash} with .catch; it only reads the result and never blocks |
claude plugin validate .
claude plugin test .
Built on Claude Code 2.1.296. Written spec-first with /mod-builder:build; see SPEC.md.
hooks/register.tsx 135 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import { bandParts, DEMO, GIT_ARGV, parseStatus, textOf } from './git-branch-logic'
5import type { Part, Snapshot } from './git-branch-logic'
6
7/**
8 * Git Branch: a band above the prompt naming the current git branch.
9 * Archetype: band. It refreshes at session start, after Bash calls and at
10 * the end of each main-loop turn; it hides outside a repository.
11 */
12
13const COLORS = { accent: '#D97757', warn: '#E0B15A' } as const
14
15const snapshot = atom({ plugin: 'git-branch', key: 'snapshot' } as const, null)
16const isHidden = atom({ plugin: 'git-branch', key: 'isHidden' } as const, false)
17const isDemo = atom({ plugin: 'git-branch', key: 'isDemo' } as const, false)
18
19let inFlight: Promise<Snapshot> | null = null
20let queued: Promise<Snapshot> | null = null
21
22async function readGit($: EngineInterface): Promise<Snapshot> {
23 let next: Snapshot
24 try {
25 const r = await $.process.run(GIT_ARGV, { timeoutMs: 5000 })
26 next = parseStatus(r.exitCode, r.stdout, r.stderr)
27 } catch (err) {
28 next = { kind: 'error', message: String((err as Error)?.message ?? err).split('\n')[0] ?? 'unknown error' }
29 }
30 await update($, snapshot, () => next)
31 return next
32}
33
34/**
35 * One git read at a time. A caller during a read gets one fresh read queued
36 * after it (shared by every caller who asks meanwhile), never the read that
37 * started before their change.
38 */
39async function refresh($: EngineInterface): Promise<Snapshot> {
40 if (!inFlight) {
41 inFlight = readGit($).finally(() => { inFlight = null })
42 return inFlight
43 }
44 if (!queued) {
45 queued = inFlight
46 .catch(() => undefined)
47 .then(() => {
48 queued = null
49 return refresh($)
50 })
51 }
52 return queued
53}
54
55async function describeNow($: EngineInterface): Promise<string> {
56 const s = await refresh($)
57 if (s.kind === 'none') return 'not inside a git repository.'
58 if (s.kind === 'error') return `git failed: ${s.message}`
59 return textOf(bandParts(s, 200))
60}
61
62export const register: Register = on => {
63 on('session.start', async ($, e, next) => {
64 await $.command
65 .register({ name: 'git-branch', description: 'A band above the prompt showing the current git branch', argumentHint: '[demo | hide | show | refresh]' })
66 .catch(() => undefined)
67 const started = await next(e)
68 void refresh($).catch(() => undefined)
69 return started
70 })
71
72 on('command.run', { command: 'git-branch' }, async ($, e) => {
73 try {
74 const word = e.args.trim().toLowerCase()
75 if (word === 'demo') {
76 await update($, isHidden, () => false)
77 await update($, isDemo, () => true)
78 return { text: 'four sample states are above the prompt until this turn ends.' }
79 }
80 if (word === 'hide' || word === 'show') {
81 await update($, isHidden, () => word === 'hide')
82 return { text: `band ${word === 'hide' ? 'hidden' : 'shown'}.` }
83 }
84 if (word === '' || word === 'refresh') return { text: await describeNow($) }
85 return { text: 'Usage: /git-branch [demo | hide | show | refresh]' }
86 } catch {
87 return { text: 'something went wrong; see claude --debug.' }
88 }
89 })
90
91 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
92 const ran = await next(e)
93 void refresh($).catch(() => undefined)
94 return ran
95 }).catch(($, e, next) => next(e))
96
97 on('turn.complete', async ($, e, next) => {
98 if (e.agentId) return next(e)
99 await update($, isDemo, () => false)
100 void refresh($).catch(() => undefined)
101 return next(e)
102 })
103
104 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
105 const rest = await next(e)
106 try {
107 if (e.props.hasSurvey || e.props.maxRows < 1 || (await read($, isHidden))) return rest
108 const width = e.props.bodyColumns - 2
109 const lines: Part[][] = (await read($, isDemo))
110 ? DEMO.slice(0, Math.max(1, e.props.maxRows - 1)).map(s => bandParts(s, width))
111 : [bandParts(await read($, snapshot), width)]
112 if (!lines.some(l => l.length)) return rest
113 const { Box, Text } = $.ui.resolve(e)
114 return (
115 <Box flexDirection="column">
116 {lines.map((parts, i) => (
117 <Box key={String(i)} paddingX={1}>
118 <Text>
119 {parts.map((p, j) =>
120 p.role === 'dim'
121 ? <Text key={String(j)} dimColor>{p.text}</Text>
122 : <Text key={String(j)} color={COLORS[p.role]}>{p.text}</Text>,
123 )}
124 </Text>
125 </Box>
126 ))}
127 {rest}
128 </Box>
129 )
130 } catch {
131 return rest
132 }
133 })
134}
135hooks/git-branch-logic.ts 86 lines1/**
2 * Git Branch's rules, free of `$` so tests can call them.
3 */
4
5import type { Snapshot } from '../types'
6
7export type { Snapshot }
8
9/** One coloured run of the band's line. */
10export type Part = { text: string; role: 'accent' | 'warn' | 'dim' }
11
12const SEP = ' · '
13const COMPACT_BELOW = 40
14
15/** The argv the mod runs; read-only, never takes the index lock. */
16export const GIT_ARGV = ['git', '--no-optional-locks', 'status', '--porcelain=v2', '--branch'] as const
17
18/** Reads `git status --porcelain=v2 --branch` output, or its failure, into a Snapshot. */
19export function parseStatus(exitCode: number, stdout: string, stderr: string): Snapshot {
20 if (exitCode !== 0) {
21 if (/not a git repository/i.test(stderr)) return { kind: 'none' }
22 const first = stderr.trim().split('\n')[0] || `exit code ${exitCode}`
23 return { kind: 'error', message: first }
24 }
25 let head = ''
26 let oid = ''
27 let ahead = 0
28 let behind = 0
29 let changed = 0
30 for (const line of stdout.split('\n')) {
31 if (line.startsWith('# branch.head ')) head = line.slice(14).trim()
32 else if (line.startsWith('# branch.oid ')) oid = line.slice(13).trim()
33 else if (line.startsWith('# branch.ab ')) {
34 const m = line.match(/\+(\d+) -(\d+)/)
35 if (m) { ahead = Number(m[1]); behind = Number(m[2]) }
36 } else if (line && !line.startsWith('#')) changed++
37 }
38 if (oid === '(initial)') return { kind: 'unborn', branch: head, changed }
39 if (head === '(detached)') return { kind: 'detached', oid: oid.slice(0, 7), changed }
40 return { kind: 'branch', branch: head, ahead, behind, changed }
41}
42
43/** Cuts `text` to `max` characters, ending in `…` when cut. */
44export function fit(text: string, max: number): string {
45 if (max <= 0) return ''
46 return text.length <= max ? text : `${text.slice(0, max - 1)}…`
47}
48
49/** The band's line as coloured parts, at most `columns` characters; empty when nothing to draw. */
50export function bandParts(s: Snapshot | null, columns: number): Part[] {
51 if (!s || s.kind === 'none' || s.kind === 'error' || columns < 3) return []
52 const glyph = '⎇ '
53 const room = columns - glyph.length
54 const name = s.kind === 'detached' ? `detached @ ${s.oid}` : s.branch
55 const head: Part[] = [{ text: glyph, role: 'accent' }, { text: fit(name, room), role: s.kind === 'detached' ? 'warn' : 'accent' }]
56 if (columns < COMPACT_BELOW) return head
57
58 const extras: Part[] = []
59 if (s.kind === 'unborn') extras.push({ text: 'no commits yet', role: 'dim' })
60 if (s.changed > 0) extras.push({ text: `${s.changed} changed`, role: 'warn' })
61 if (s.kind === 'branch' && (s.ahead || s.behind)) {
62 extras.push({ text: [s.ahead ? `↑${s.ahead}` : '', s.behind ? `↓${s.behind}` : ''].filter(Boolean).join(' '), role: 'dim' })
63 }
64 const out = [...head]
65 let used = glyph.length + fit(name, room).length
66 for (const x of extras) {
67 if (used + SEP.length + x.text.length > columns) break
68 out.push({ text: SEP, role: 'dim' }, x)
69 used += SEP.length + x.text.length
70 }
71 return out
72}
73
74/** The parts as plain text, for tests and the command's reply. */
75export function textOf(parts: Part[]): string {
76 return parts.map(p => p.text).join('')
77}
78
79/** The four states the demo draws. */
80export const DEMO: Snapshot[] = [
81 { kind: 'branch', branch: 'main', ahead: 0, behind: 0, changed: 0 },
82 { kind: 'branch', branch: 'feature/login-flow', ahead: 2, behind: 1, changed: 3 },
83 { kind: 'detached', oid: 'a1b2c3d', changed: 0 },
84 { kind: 'unborn', branch: 'master', changed: 0 },
85]
86types/index.d.ts 16 lines1// Git Branch's state contract. Self-contained: no imports.
2
3/** What `git status --porcelain=v2 --branch` said, or why it said nothing. */
4export type Snapshot =
5 | { kind: 'branch'; branch: string; ahead: number; behind: number; changed: number }
6 | { kind: 'detached'; oid: string; changed: number }
7 | { kind: 'unborn'; branch: string; changed: number }
8 | { kind: 'none' }
9 | { kind: 'error'; message: string }
10
11declare module 'claude-code' {
12 interface PluginState {
13 'git-branch': { snapshot: Snapshot | null; isHidden: boolean; isDemo: boolean }
14 }
15}
16