Shows the current git branch, how many files changed and ahead/behind counts in a one-line band above the prompt.

cm-git-branch)Shows the current git branch, how many files changed and ahead/behind counts in a one-line band above the prompt.
A one-line band above the prompt shows where you are in git:
⎇ main · 3 changed · ↑1 ↓2
The branch name (or the short commit hash when HEAD is detached), how many paths git status lists as changed, and how far the branch is ahead of and behind its upstream. Outside a git repository the band does not appear. It refreshes when a session starts, after every main-loop turn, right after Claude runs a Bash command that mentions git, and on a timer in between, so a checkout you do in another terminal shows up too.
claude plugin marketplace add <owner>/<repo>
claude plugin install cm-git-branch@claudemods
| Key | Type | Default | Description |
|---|---|---|---|
intervalMs | number (2000–3600000) | 15000 | How often to re-read git status between turns, in milliseconds (at least 2000). |
showAheadBehind | boolean | true | Show commits ahead of and behind the upstream branch (↑1 ↓0). |
hideWhenClean | boolean | false | Hide the band while the work tree is clean and in sync with its upstream. |
| API | Why |
|---|---|
$.session.cwd | To run git in the session's working directory. |
$.process.run | Runs git status --porcelain=v1 -b (and git rev-parse --short HEAD only when HEAD is detached). Read-only git commands, a 10-second timeout, no shell. |
$.clock.every | Re-reads git status every intervalMs between turns. |
$.state.get / $.state.set | Keeps the last git status in cm-git-branch.info so the band redraws when it changes. |
$.ui.resolve | Draws the band's Box/Text elements on the AbovePrompt site. |
Tested with Claude Code 2.1.291.
session.start, turn.complete (main loop only, subagents are skipped), tool.call{tool=Bash} (observes; refreshes after the call if the command mentions git, with a .catch that always lets the call run) and ui.render{component=AbovePrompt}.git status --porcelain=v1 -b call gives branch, upstream, ahead/behind and the changed count in one process, instead of separate rev-parse --abbrev-ref / rev-list calls. Handles No commits yet on <branch>, detached HEAD and [gone] upstreams.next(e)) while a survey is open, outside a repo, or when hideWhenClean applies.key prop when drawn; only the outer Box carries key="git-branch".types/index.d.ts (PluginState['cm-git-branch']).MIT. See LICENSE. More at https://claudemods.app/mods/cm-git-branch.
hooks/register.tsx 77 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { GitInfo } from '../types'
5import { detailParts, isClean, mentionsGit, parseStatus } from './git'
6
7const info = atom({ plugin: 'cm-git-branch', key: 'info' } as const, null)
8
9/** Reads the work tree's status in the session's folder and stores it for the band. */
10async function refresh($: EngineInterface): Promise<void> {
11 const cwd = await $.session.cwd()
12 let next: GitInfo | null = null
13 try {
14 const status = await $.process.run(['git', 'status', '--porcelain=v1', '-b'], { cwd, timeoutMs: 10_000 })
15 next = status.exitCode === 0 ? parseStatus(status.stdout) : null
16 if (next?.isDetached === true) {
17 const sha = await $.process.run(['git', 'rev-parse', '--short', 'HEAD'], { cwd, timeoutMs: 10_000 })
18 if (sha.exitCode === 0 && sha.stdout.trim() !== '') next.branch = sha.stdout.trim()
19 }
20 } catch {
21 // git missing or too slow: show nothing rather than a stale branch.
22 next = null
23 }
24 await update($, info, () => next)
25}
26
27export const register: Register = (on, options) => {
28 const intervalMs = Math.max(2_000, typeof options.intervalMs === 'number' ? options.intervalMs : 15_000)
29 const band = {
30 showAheadBehind: options.showAheadBehind !== false,
31 hideWhenClean: options.hideWhenClean === true,
32 }
33
34 on('session.start', async ($, e, next) => {
35 const started = await next(e)
36 await refresh($)
37 $.clock.every(intervalMs, () => {
38 refresh($).catch(() => undefined)
39 })
40 return started
41 })
42
43 on('turn.complete', async ($, e, next) => {
44 const result = await next(e)
45 if (e.agentId === undefined) await refresh($)
46 return result
47 })
48
49 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
50 const ran = await next(e)
51 if (mentionsGit(e.command)) await refresh($)
52 return ran
53 }).catch(($, e, next) => next(e))
54
55 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
56 const current = await read($, info)
57 if (e.props.hasSurvey || current === null || (band.hideWhenClean && isClean(current))) {
58 return next(e)
59 }
60
61 const { Box, Text } = $.ui.resolve(e)
62 const name = current.isDetached ? `${current.branch} (detached)` : current.branch
63
64 return (
65 <Box key="git-branch" flexDirection="row">
66 <Text dimColor>⎇ </Text>
67 <Text bold color="cyan" wrap="truncate-end">
68 {name}
69 </Text>
70 <Text dimColor wrap="truncate-end">
71 {' · ' + detailParts(current, band).join(' · ')}
72 </Text>
73 </Box>
74 )
75 })
76}
77hooks/git.ts 67 lines1/**
2 * Pure helpers for cm-git-branch: parse `git status --porcelain=v1 -b` and
3 * format the band's line. No `$`, so they are unit-tested directly.
4 */
5import type { GitInfo } from '../types'
6
7/**
8 * Reads the porcelain v1 output with its `## ` branch header:
9 * ## main...origin/main [ahead 1, behind 2]
10 * ## No commits yet on main
11 * ## HEAD (no branch)
12 * followed by one line per changed path.
13 */
14export function parseStatus(stdout: string): GitInfo | null {
15 const lines = stdout.split('\n').filter(line => line !== '')
16 const header = lines[0]
17 if (header === undefined || !header.startsWith('## ')) return null
18 const changed = lines.length - 1
19 const head = header.slice(3)
20
21 if (head.startsWith('HEAD (no branch)')) {
22 return { branch: 'HEAD', isDetached: true, ahead: 0, behind: 0, changed }
23 }
24
25 const initial = /^(?:No commits yet on|Initial commit on) (.+)$/.exec(head)
26 if (initial?.[1] !== undefined) {
27 return { branch: initial[1], isDetached: false, ahead: 0, behind: 0, changed }
28 }
29
30 const match = /^(.+?)(?:\.\.\.(\S+))?(?: \[(.*)\])?$/.exec(head)
31 const branch = match?.[1] ?? head
32 const upstream = match?.[2]
33 const track = match?.[3] ?? ''
34 const ahead = Number(/ahead (\d+)/.exec(track)?.[1] ?? 0)
35 const behind = Number(/behind (\d+)/.exec(track)?.[1] ?? 0)
36 const info: GitInfo = { branch, isDetached: false, ahead, behind, changed }
37 if (upstream !== undefined) info.upstream = upstream
38 return info
39}
40
41export type BandOptions = { showAheadBehind: boolean; hideWhenClean: boolean }
42
43/** Whether the band has nothing worth showing under `hideWhenClean`. */
44export function isClean(info: GitInfo): boolean {
45 return info.changed === 0 && info.ahead === 0 && info.behind === 0
46}
47
48/** The parts after the branch name: `3 changed`, `↑1 ↓0`. */
49export function detailParts(info: GitInfo, options: BandOptions): string[] {
50 const parts = [info.changed === 0 ? 'clean' : `${info.changed} changed`]
51 if (options.showAheadBehind && info.upstream !== undefined) {
52 parts.push(`↑${info.ahead} ↓${info.behind}`)
53 }
54 return parts
55}
56
57/** The whole line as text: `⎇ main · 3 changed · ↑1 ↓0`. */
58export function formatLine(info: GitInfo, options: BandOptions): string {
59 const name = info.isDetached ? `${info.branch} (detached)` : info.branch
60 return [`⎇ ${name}`, ...detailParts(info, options)].join(' · ')
61}
62
63/** True for a Bash command that runs git (and so may move HEAD or the index). */
64export function mentionsGit(command: string): boolean {
65 return /(^|[\s;&|(`])git(\s|$)/.test(command) || /\bgh\s+pr\s+(checkout|merge)\b/.test(command)
66}
67types/index.d.ts 22 lines1/** What the band shows: one reading of `git status --porcelain=v1 -b`. */
2export type GitInfo = {
3 /** The branch name, or the short commit id when HEAD is detached. */
4 branch: string
5 isDetached: boolean
6 /** The upstream (`origin/main`), when the branch tracks one. */
7 upstream?: string
8 ahead: number
9 behind: number
10 /** Changed, staged and untracked paths together. */
11 changed: number
12}
13
14declare module 'claude-code' {
15 interface PluginState {
16 'cm-git-branch': {
17 /** null while the session's folder is not inside a git work tree. */
18 info: GitInfo | null
19 }
20 }
21}
22