Git status line and per-prompt GIT_STATE context

A git status line and a per-prompt GIT_STATE context line, written as a CLI plugin of function hooks. Cross-platform: it runs git through the engine, with no shell script.
Status line (at most 80 characters):
⎇ feature/x ↑2 ↓1 · +3 ~3 ?2
The branch (clipped to 32 characters), ahead/behind its upstream or no upstream, then staged (+), unstaged and unmerged (~) and untracked (?) counts, or clean.
Prompt context, attached only when it changed since the line last attached:
GIT_STATE BRANCH=feature/x UPSTREAM=origin/feature/x AHEAD=2 BEHIND=1 STAGED=3 UNSTAGED=3 UNTRACKED=2 DIRTY=true
After /clear, a resume or a compaction of the main conversation, the next prompt gets the line again even when unchanged.
git -C <start> rev-parse --show-toplevel, asked first from the session cwd (a linked worktree resolves to itself) and then from CLAUDE_PROJECT_DIR. The first top level git answers is the root; each start folder's answer is kept for the life of the module. No repository anywhere clears the status line and attaches nothing.
| Hook | Does |
|---|---|
session.start | Starts a 60 s background refresh and refreshes once. |
tool.call on Bash, PowerShell, Edit, Write, MultiEdit, NotebookEdit | Refreshes after the call returns, without delaying it. |
turn.complete | Refreshes. |
prompt.submit | Runs its own git status (after a background run in flight, waiting at most 2 s) and attaches the GIT_STATE line when it changed. |
session.end, session.compact | Force the next prompt's line after /clear, a resume or a main compaction. |
Background runs coalesce: one git status in flight, at least 2 s between background starts, and a request made while one runs is owed a single run after it. git status runs with GIT_OPTIONAL_LOCKS=0, so it never takes the index lock. Every failure (git missing, not a repository, a timeout, a refused status line) clears the status and never blocks a prompt or a tool call.
$.state['git-status'] (contract: types/index.d.ts):
| Key | Meaning |
|---|---|
git | The latest snapshot (branch, upstream, ahead, behind, staged, unstaged, untracked); null after a failed run. |
lastContext | The GIT_STATE line last attached to a prompt. |
claude plugin validate <mod folder>
claude plugin test <mod folder>
Install from the operator-straps marketplace:
claude plugin marketplace add t968rs/operator-straps
claude plugin install git-status@operator-straps
For development, add the folder to CLAUDE_CODE_PLUGIN_DIRS in the user-level ~/.claude/settings.json env block (;-separated on Windows), or start one session with claude --plugin-dir <folder>.
.claude-plugin/types/ is generated by the engine on load and git-ignored; tsconfig.json extends it.
hooks/register.ts 8 lines1import type { Register } from 'claude-code'
2
3import { registerStatus } from './status'
4
5export const register: Register = on => {
6 registerStatus(on)
7}
8hooks/status.ts 380 lines1import type {
2 EngineInterface,
3 PromptSubmitResult,
4 Register,
5 SessionCompactInput,
6 SessionCompactResult,
7} from 'claude-code'
8import { atom, read, update } from 'claude-code'
9
10import type { GitSnapshot } from '../types'
11
12/**
13 * Cross-platform git status line and per-prompt git context.
14 *
15 * The project root is `git rev-parse --show-toplevel` from the session cwd (else CLAUDE_PROJECT_DIR);
16 * the per-prompt line is attached only when it changed since the last one attached. Every failure path
17 * (no root, no git, not a repo) clears the status and never throws.
18 */
19
20type Dollar = EngineInterface
21
22const gitState = atom({ plugin: 'git-status', key: 'git' } as const, null)
23const lastContext = atom({ plugin: 'git-status', key: 'lastContext' } as const, '')
24
25/** Minimum gap between two background git runs, in milliseconds. */
26export const MIN_GAP_MS = 2000
27/** Background refresh period while the session is open, in milliseconds. */
28export const TICK_MS = 60_000
29const MAX_STATUS = 80
30const MAX_BRANCH = 32
31const GIT_TIMEOUT_MS = 10_000
32/**
33 * Longest wait, in milliseconds, a prompt gives a background git run in flight before it runs its own:
34 * that wait is no `$` call of the hook's, so it counts against the hook's 10 s budget.
35 */
36export const TAIL_WAIT_MS = 2000
37const REFRESH_TOOLS = /^(Bash|PowerShell|Edit|Write|MultiEdit|NotebookEdit)$/
38
39// ---------------------------------------------------------------------------
40// Pure parsing and formatting
41
42/** Parses `git status --porcelain=v2 --branch` output; unknown lines are ignored. */
43export const parsePorcelain = (stdout: string): GitSnapshot => {
44 const out: GitSnapshot = {
45 branch: '', upstream: null, ahead: 0, behind: 0, staged: 0, unstaged: 0, untracked: 0,
46 }
47 for (const raw of stdout.split(/\r?\n/)) parseLine(raw, out)
48 return out
49}
50
51/** Folds one porcelain line into `out`. */
52const parseLine = (line: string, out: GitSnapshot): void => {
53 let m: RegExpMatchArray | null
54 if ((m = line.match(/^# branch\.head (.+)$/))) out.branch = m[1] ?? ''
55 else if ((m = line.match(/^# branch\.upstream (.+)$/))) out.upstream = m[1] ?? null
56 else if ((m = line.match(/^# branch\.ab \+(\d+) -(\d+)$/))) {
57 out.ahead = Number(m[1])
58 out.behind = Number(m[2])
59 } else if ((m = line.match(/^[12] (.)(.) /))) {
60 if (m[1] !== '.') out.staged++
61 if (m[2] !== '.') out.unstaged++
62 } else if (/^u /.test(line)) out.unstaged++
63 else if (/^\? /.test(line)) out.untracked++
64}
65
66/** Whole-string truncation with an ellipsis. */
67const clip = (text: string, max: number): string => (text.length <= max ? text : `${text.slice(0, max - 1)}…`)
68
69/** The branch segment: name, then ahead/behind or "no upstream". */
70const branchSegment = (g: GitSnapshot): string => {
71 const head = `⎇ ${clip(g.branch || '?', MAX_BRANCH)}`
72 if (g.upstream === null) return `${head} no upstream`
73 return g.ahead > 0 || g.behind > 0 ? `${head} ↑${g.ahead} ↓${g.behind}` : head
74}
75
76/** The working-tree segment: non-zero counts, or "clean". */
77const treeSegment = (g: GitSnapshot): string => {
78 const parts: string[] = []
79 if (g.staged > 0) parts.push(`+${g.staged}`)
80 if (g.unstaged > 0) parts.push(`~${g.unstaged}`)
81 if (g.untracked > 0) parts.push(`?${g.untracked}`)
82 return parts.length === 0 ? 'clean' : parts.join(' ')
83}
84
85/** The status line, at most 80 characters. */
86export const formatStatus = (g: GitSnapshot): string => clip(`${branchSegment(g)} · ${treeSegment(g)}`, MAX_STATUS)
87
88/** The prompt context line under a GIT_STATE tag. */
89export const formatContext = (g: GitSnapshot): string => {
90 const dirty = g.staged > 0 || g.unstaged > 0 || g.untracked > 0
91 return (
92 `GIT_STATE BRANCH=${g.branch} UPSTREAM=${g.upstream ?? 'none'} AHEAD=${g.ahead} BEHIND=${g.behind}` +
93 ` STAGED=${g.staged} UNSTAGED=${g.unstaged} UNTRACKED=${g.untracked} DIRTY=${dirty}`
94 )
95}
96
97/** A path with forward slashes and no trailing slash; undefined when blank. */
98export const cleanPath = (p: string | undefined): string | undefined => {
99 const out = (p ?? '').trim().replace(/\\/g, '/').replace(/\/+$/, '')
100 return out === '' ? undefined : out
101}
102
103/** Where to ask git for the top level, in order: the session cwd, then CLAUDE_PROJECT_DIR (no repeats). */
104export const rootStarts = (rawEnv: string | undefined, rawCwd: string | undefined): string[] => {
105 const out: string[] = []
106 for (const c of [cleanPath(rawCwd), cleanPath(rawEnv)]) {
107 if (c !== undefined && !out.some(o => o.toLowerCase() === c.toLowerCase())) out.push(c)
108 }
109 return out
110}
111
112// ---------------------------------------------------------------------------
113// Host access (kept in this file: the engine follows `$` past an import only into function declarations)
114
115/** Top levels already answered, by start folder (a reload starts afresh). */
116const toplevels = new Map<string, string>()
117
118/** `git rev-parse --show-toplevel` from `start`; undefined on any failure. */
119const toplevelOf = async ($: Dollar, start: string): Promise<string | undefined> => {
120 const known = toplevels.get(start)
121 if (known !== undefined) return known
122 const argv = ['git', '-C', start, 'rev-parse', '--show-toplevel']
123 const ran = await $.process.run(argv, { timeoutMs: GIT_TIMEOUT_MS }).catch(() => undefined)
124 const top = ran === undefined || ran.exitCode !== 0 ? undefined : cleanPath(ran.stdout)
125 if (top !== undefined) toplevels.set(start, top)
126 return top
127}
128
129/** The project root: the first start folder git answers for; undefined when none does. Never throws. */
130const resolveRoot = async ($: Dollar): Promise<string | undefined> => {
131 const env = await $.env.get('CLAUDE_PROJECT_DIR').catch(() => undefined)
132 const cwd = await $.session.cwd().catch(() => undefined)
133 for (const start of rootStarts(env, cwd)) {
134 const top = await toplevelOf($, start)
135 if (top !== undefined) return top
136 }
137 return undefined
138}
139
140/** Runs git status; undefined on any failure (git missing, not a repo, timeout). */
141const runGitStatus = async ($: Dollar, root: string): Promise<GitSnapshot | undefined> => {
142 const argv = ['git', '-C', root, 'status', '--porcelain=v2', '--branch']
143 const ran = await $.process
144 .run(argv, { timeoutMs: GIT_TIMEOUT_MS, env: { GIT_OPTIONAL_LOCKS: '0' } })
145 .catch(() => undefined)
146 if (ran === undefined || ran.exitCode !== 0) return undefined
147 const snap = parsePorcelain(ran.stdout)
148 return snap.branch === '' ? undefined : snap
149}
150
151/** A full snapshot, or undefined when the root or git is unavailable. */
152const takeSnapshot = async ($: Dollar): Promise<GitSnapshot | undefined> => {
153 const root = await resolveRoot($)
154 return root === undefined ? undefined : runGitStatus($, root)
155}
156
157/** Sets or clears the status line, swallowing a refusal. */
158const showStatus = async ($: Dollar, text: string | undefined): Promise<void> => {
159 try {
160 await $.ui.status(text)
161 } catch {
162 // a surface that draws no status line
163 }
164}
165
166/** Stores the snapshot and draws (or clears) the status line; never throws. */
167const publish = async ($: Dollar, snap: GitSnapshot | undefined): Promise<void> => {
168 await update($, gitState, () => snap ?? null).catch(() => undefined)
169 return showStatus($, snap === undefined ? undefined : formatStatus(snap))
170}
171
172/** Snapshot then publish; swallows every error. */
173const refreshNow = async ($: Dollar): Promise<GitSnapshot | undefined> => {
174 const snap = await takeSnapshot($).catch(() => undefined)
175 await publish($, snap).catch(() => undefined)
176 return snap
177}
178
179// ---------------------------------------------------------------------------
180// Coalescing: one git run in flight, at least MIN_GAP_MS between background starts
181
182/** Per-load refresh bookkeeping (a reload starts afresh). */
183const pacing = {
184 /** A background run is scheduled or in flight. */
185 isBusy: false,
186 /** Background git is running now (past the pacing wait): a request then is owed a run after it. */
187 isRunning: false,
188 isOwed: false,
189 lastStart: Number.NEGATIVE_INFINITY,
190 /** The next prompt gets the context line even when unchanged: a new conversation, or a compacted one. */
191 isContextForced: true,
192 /** Tail of the git runs, background and foreground: each starts after the one before settled. */
193 gitTail: Promise.resolve() as Promise<unknown>,
194 /** Git runs chained on `gitTail` that have not settled. */
195 inFlight: 0,
196 tick: undefined as { cancel: () => void } | undefined,
197}
198
199/**
200 * Snapshot and publish after any run in flight settles, so git never runs twice at once and the
201 * last run started is the last one published.
202 */
203const serialRefresh = ($: Dollar): Promise<GitSnapshot | undefined> => {
204 const run = pacing.gitTail.then(() => refreshNow($))
205 track(run)
206 return run
207}
208
209/** Chains `run` onto the tail and counts it in flight until it settles. */
210const track = (run: Promise<unknown>): void => {
211 pacing.inFlight += 1
212 const settled = run.catch(() => undefined).finally(() => {
213 pacing.inFlight -= 1
214 })
215 pacing.gitTail = Promise.all([pacing.gitTail, settled]).catch(() => undefined)
216}
217
218/** Waits for the runs in flight, at most TAIL_WAIT_MS; returns at once when none is. Never throws. */
219const awaitTail = async ($: Dollar, signal: AbortSignal): Promise<void> => {
220 if (pacing.inFlight === 0) return
221 const stop = new AbortController()
222 const timer = $.clock.sleep(TAIL_WAIT_MS, { signal: AbortSignal.any([signal, stop.signal]) })
223 try {
224 await Promise.race([pacing.gitTail, timer.catch(() => undefined)])
225 } finally {
226 stop.abort()
227 }
228}
229
230/**
231 * The prompt's own snapshot: after the runs in flight when they settle within TAIL_WAIT_MS, else at
232 * once beside them (git status takes no lock: GIT_OPTIONAL_LOCKS=0).
233 */
234const promptRefresh = async ($: Dollar, signal: AbortSignal): Promise<GitSnapshot | undefined> => {
235 await awaitTail($, signal)
236 const run = refreshNow($)
237 track(run)
238 return run
239}
240
241/** Milliseconds left before a background run may start. */
242const gapLeft = async ($: Dollar): Promise<number> => pacing.lastStart + MIN_GAP_MS - (await $.clock.now())
243
244/** One paced background run; a refused wait rejects, which ends the cycle rather than hanging it. */
245const cycle = async ($: Dollar): Promise<void> => {
246 for (let wait = await gapLeft($); wait > 0; wait = await gapLeft($)) await $.clock.sleep(wait)
247 pacing.lastStart = await $.clock.now()
248 pacing.isRunning = true
249 await serialRefresh($)
250}
251
252/** Asks for a background refresh and returns at once; folds into a run already in flight. */
253const requestRefresh = ($: Dollar): void => {
254 if (pacing.isBusy) {
255 // a run still waiting out its gap will read the newer state anyway
256 if (pacing.isRunning) pacing.isOwed = true
257 return
258 }
259 pacing.isBusy = true
260 void cycle($)
261 .catch(() => undefined)
262 .finally(() => finishCycle($))
263}
264
265/** Ends a run and starts the one owed, if any. */
266const finishCycle = ($: Dollar): void => {
267 pacing.isBusy = false
268 pacing.isRunning = false
269 if (!pacing.isOwed) return
270 pacing.isOwed = false
271 requestRefresh($)
272}
273
274/** Records a foreground run, so the next background one keeps its distance. */
275const noteForegroundRun = async ($: Dollar): Promise<void> => {
276 const now = await $.clock.now().catch(() => undefined)
277 if (now !== undefined) pacing.lastStart = Math.max(pacing.lastStart, now)
278}
279
280/** Schedules a refresh after the current dispatch, so the caller's result is not delayed. */
281const refreshLater = ($: Dollar): void => {
282 try {
283 $.clock.after(0, () => requestRefresh($))
284 } catch {
285 // no timer: this refresh is skipped
286 }
287}
288
289/** (Re)starts the periodic refresh; a timer costs no model tokens. */
290const startTicking = ($: Dollar): void => {
291 try {
292 pacing.tick?.cancel()
293 pacing.tick = $.clock.every(TICK_MS, () => requestRefresh($))
294 } catch {
295 pacing.tick = undefined
296 }
297}
298
299// ---------------------------------------------------------------------------
300// Prompt context
301
302/** A fresh snapshot's context line when it should be attached to this prompt; records nothing. */
303const promptContext = async ($: Dollar, signal: AbortSignal): Promise<string | undefined> => {
304 await noteForegroundRun($)
305 const snap = await promptRefresh($, signal)
306 if (snap === undefined) return undefined
307 const line = formatContext(snap)
308 if (pacing.isContextForced) return line
309 const previous = await read($, lastContext).catch(() => '')
310 return previous === line ? undefined : line
311}
312
313/** Records `line` as attached, once the prompt entered with it and the dispatch did not move on. */
314const recordAttached = async (
315 $: Dollar,
316 line: string,
317 result: PromptSubmitResult,
318 signal: AbortSignal,
319): Promise<void> => {
320 if (signal.aborted || result.drop !== undefined || !(result.context ?? []).includes(line)) return
321 pacing.isContextForced = false
322 await update($, lastContext, () => line).catch(() => undefined)
323}
324
325/** Session-end reasons after which the same process goes on with a fresh conversation. */
326const FRESH_CONVERSATION = new Set<string>(['clear', 'resume'])
327
328/** Whether a compaction result replaced the main conversation (its GIT_STATE line may be gone). */
329const isMainCompaction = (e: SessionCompactInput, result: SessionCompactResult): boolean =>
330 result.skip === undefined && e.agentId === undefined && e.trigger !== 'precompute'
331
332/** Forces the context line on the next prompt after `/clear`, a resume, or a main compaction. */
333const registerContextResets = (on: Parameters<Register>[0]): void => {
334 on('session.end', async ($, e, next) => {
335 if (FRESH_CONVERSATION.has(e.reason)) pacing.isContextForced = true
336 return next(e)
337 }).catch((_$, e, next) => next(e))
338
339 on('session.compact', async ($, e, next) => {
340 const result = await next(e)
341 if (isMainCompaction(e, result)) pacing.isContextForced = true
342 return result
343 }).catch((_$, e, next) => next(e))
344}
345
346// ---------------------------------------------------------------------------
347// Registration
348
349/** Registers the status line, its refresh triggers and the per-prompt git context. */
350export const registerStatus = (on: Parameters<Register>[0]): void => {
351 on('session.start', async ($, e, next) => {
352 const started = await next(e)
353 startTicking($)
354 refreshLater($)
355 return started
356 })
357
358 on('tool.call', { tool: REFRESH_TOOLS }, async ($, e, next) => {
359 const result = await next(e)
360 refreshLater($)
361 return result
362 }).catch((_$, e, next) => next(e))
363
364 on('turn.complete', async ($, e, next) => {
365 const result = await next(e)
366 refreshLater($)
367 return result
368 })
369
370 on('prompt.submit', async ($, e, next) => {
371 const line = await promptContext($, next.signal).catch(() => undefined)
372 if (line === undefined || next.signal.aborted) return next(e)
373 const result = await next({ ...e, context: [...(e.context ?? []), line] })
374 await recordAttached($, line, result, next.signal)
375 return result
376 }).catch((_$, e, next) => next(e))
377
378 registerContextResets(on)
379}
380types/index.d.ts 26 lines1/** One `git status --porcelain=v2 --branch` reading of the project root. */
2export type GitSnapshot = {
3 /** `# branch.head`: the branch name, or `(detached)`. */
4 branch: string
5 /** `# branch.upstream`; null when the branch tracks none. */
6 upstream: string | null
7 ahead: number
8 behind: number
9 /** Entries with an index (X) change. */
10 staged: number
11 /** Entries with a worktree (Y) change, plus unmerged entries. */
12 unstaged: number
13 untracked: number
14}
15
16declare module 'claude-code' {
17 interface PluginState {
18 'git-status': {
19 /** The latest snapshot; null before the first or after a failed run. */
20 git: GitSnapshot | null
21 /** The GIT_STATE line last attached to a prompt. */
22 lastContext: string
23 }
24 }
25}
26