Shows the session's git branch in the HUD, with how many files changed and how far it is ahead of or behind its upstream. Needs the HUD plugin.

Shows the session's git branch in the HUD, with how many files changed and how far the branch is ahead of or behind its upstream.
| Branch | Note |
|---|---|
feat/x | 3 files changed · 2 ahead |
It sends the HUD one slot, hud-git/branch, a stat: the branch name is the value and the note says the rest.
detached 1a2b3c4. A name longer than 24 characters is cut with …, and the note then ends with the whole name, after the counts (the HUD cuts a note at 120 characters from the end, so a long name can never push the counts out).git status lists them (an untracked folder is one). Ignored files are not counted. With none it says clean.2 ahead, 1 behind), only when the branch has an upstream and the count is above 0. With no upstream the note leaves them out, because it doesn't know them.git status takes more than 2 seconds. The slot is taken away and nothing is said.It reads at session start and after each finished Bash or PowerShell command that runs git (git commit, cd app && git switch main, git -C ../other pull). It doesn't poll, so a change made outside Claude shows at the next git command.
Its access line, as the HUD shows it beside the slot and in /hud sources:
Runs
git statusin the session's folder at session start and after git commands. Reads the branch and counts; never file contents.
To check it yourself, run claude plugin validate sources/hud-git, which lists the hooks and calls: $.process.run, and the $.hud calls to declare, push and clear its slot.
git status --porcelain=v2 --branch, in the session's folder, with a 2-second limit. GIT_OPTIONAL_LOCKS=0 is set so reading never takes git's index lock. The $.process.run call runs git with repository hooks off.It needs the HUD plugin, and doesn't load without it. In Claude Code:
/plugin install hud --marketplace kyledarling-io/claude-code-desktop-hud
/plugin install hud-git --marketplace kyledarling-io/claude-code-desktop-hud
Or in the Claude Desktop app, add the marketplace as the README says and install hud-git beside hud.
It works where Claude Code lets a plugin run a process. That isn't confirmed in the desktop app yet. Where it can't run one, it says nothing and the HUD shows no branch.
/hud mute hud-git hides the branch from the strip and the dashboard, now and in later sessions. /hud unmute hud-git brings it back. /hud sources lists every source by name.
claude plugin validate sources/hud-git checks the manifest and hooks. Its tests, hooks/git.test.ts, load it beside the HUD as another plugin would be and answer $.process themselves, so they need no repository and run with the rest from the repo root: claude plugin test ..
The hooks module is one register function with everything inside it, because a test loads it as an inline plugin and an inline plugin is self-contained.
hooks/register.ts 84 lines1import type { Register } from 'claude-code'
2
3// The git source: the session's branch, how many files changed and how far ahead or behind, sent to the HUD as one
4// slot (`hud-git/branch`). Everything lives inside `register`, because a test loads this function as an inline plugin and
5// an inline plugin is self-contained. Every call it makes is caught: a source never throws into its own hooks, and
6// a folder that isn't a repository, a missing git or a HUD that refuses means the slot goes and nothing is said.
7export const register: Register = on => {
8 const declaration = {
9 v: 1 as const,
10 name: 'Git',
11 access: "Runs `git status` in the session's folder at session start and after git commands. Reads the branch and counts; never file contents.",
12 slots: [{ id: 'branch', pattern: 'stat' as const, title: 'Branch' }],
13 }
14 // GIT_OPTIONAL_LOCKS=0: reading status must not take the index lock a command of Claude's or the person's may want
15 const status = ['git', 'status', '--porcelain=v2', '--branch']
16 const limits = { env: { GIT_OPTIONAL_LOCKS: '0' }, timeoutMs: 2000 }
17 const VALUE = 24 // the HUD cuts a value here, silently; a branch name is cut with … instead
18
19 // simplification: words, not a shell parser, and no pattern that can backtrack (this runs on every shell command): the
20 // command is cut into steps and the first word of each is compared. A step that starts with git (after env
21 // assignments, a path or a subshell); a miss only means the slot waits for the next git command, a false hit costs
22 // one status.
23 const ranGit = (command: string) =>
24 command.split(/&&|\|\||[;|\n]/).some(step => {
25 const w = step.replace(/^[\s(&]+/, '').split(/\s+/)
26 return /^git(?:\.exe)?$/i.test(w[w.findIndex(x => !/^\w+=/.test(x))]?.split(/[\\/]/).at(-1) ?? '')
27 })
28
29 // `git status --porcelain=v2 --branch` → the slot, or undefined when the text is not status. Files are counted
30 // (changed, renamed, unmerged, untracked; ignored ones are not): their names are never read out, let alone sent.
31 // simplification: an untracked folder is one entry in git's own count, so it is one here
32 const slotOf = (out: string) => {
33 let head: string | undefined, oid = '', ab: RegExpMatchArray | null = null, files = 0
34 for (const line of out.split('\n')) {
35 if (line.startsWith('# branch.head ')) head = line.slice(14).trim()
36 else if (line.startsWith('# branch.oid ')) oid = line.slice(13).trim()
37 else if (line.startsWith('# branch.ab ')) ab = line.match(/\+(\d+) -(\d+)/)
38 else if (/^[12u?] /.test(line)) files++
39 }
40 if (!head) return undefined
41 const name = head === '(detached)' ? `detached ${oid.slice(0, 7)}` : head
42 const long = name.length > VALUE
43 // the counts first, the long name (if any) last: the HUD cuts a note at 120 characters, from the end
44 const note = [
45 files ? `${files} file${files === 1 ? '' : 's'} changed` : 'clean',
46 // no upstream, no line: the counts it can't know are not 0
47 ab && Number(ab[1]) > 0 && `${ab[1]} ahead`, ab && Number(ab[2]) > 0 && `${ab[2]} behind`,
48 long && name,
49 ].filter(Boolean).join(' · ')
50 return { v: 1 as const, id: 'branch', pattern: 'stat' as const, title: 'Branch', value: long ? `${name.slice(0, VALUE - 1)}…` : name, note }
51 }
52
53 // what the HUD was last sent, so a status that hasn't changed isn't pushed again (a push is a change to the HUD).
54 // A reload resets it, which costs one push.
55 let sent = ''
56
57 // try/catch, not .catch on the call: a call that throws before it returns a promise ($.process on a host without it,
58 // which its types call CLI only) gets past a .catch (SOURCES.md)
59 on('session.start', async ($, e, next) => {
60 try { await $.hud.declare(declaration) } catch { /* the HUD isn't answering */ }
61 const started = await next(e)
62 let got
63 try { got = await $.process.run(status, limits) } catch { /* no git, or no $.process here: nothing to say */ }
64 const slot = got?.exitCode === 0 ? slotOf(got.stdout) : undefined
65 sent = ''
66 try {
67 if (slot) sent = (await $.hud.push(slot)).ok ? JSON.stringify(slot) : ''
68 else await $.hud.clear({ id: 'branch' })
69 } catch { /* the next git command sends it again */ }
70 return started
71 })
72
73 on('tool.call', async ($, e, next) => {
74 const ran = await next(e)
75 if ((e.tool !== 'Bash' && e.tool !== 'PowerShell') || ran.deny !== undefined || !ranGit(e.command)) return ran
76 const got = await $.process.run(status, limits).catch(() => undefined)
77 const slot = got?.exitCode === 0 ? slotOf(got.stdout) : undefined
78 if (slot) {
79 if (JSON.stringify(slot) !== sent) sent = (await $.hud.push(slot).catch(() => undefined))?.ok ? JSON.stringify(slot) : ''
80 } else { sent = ''; await $.hud.clear({ id: 'branch' }).catch(() => undefined) }
81 return ran
82 }).catch(($, e, next) => next(e)) // a fault here never costs the call
83}
84