SLOPSHOPPER

git-status

Git status line and per-prompt GIT_STATE context

newguardstatuspromptprocesstimer
A shopper browsing a rack in a slop shop
README

git-status

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.

What it shows

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.

Project root

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.

Hooks

HookDoes
session.startStarts a 60 s background refresh and refreshes once.
tool.call on Bash, PowerShell, Edit, Write, MultiEdit, NotebookEditRefreshes after the call returns, without delaying it.
turn.completeRefreshes.
prompt.submitRuns 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.compactForce 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

$.state['git-status'] (contract: types/index.d.ts):

KeyMeaning
gitThe latest snapshot (branch, upstream, ahead, behind, staged, unstaged, untracked); null after a failed run.
lastContextThe GIT_STATE line last attached to a prompt.

Checks

claude plugin validate <mod folder>
claude plugin test <mod folder>

Loading

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.

Source 3 files
hooks/register.ts 8 lines
1import type { Register } from 'claude-code'
2
3import { registerStatus } from './status'
4
5export const register: Register = on => {
6  registerStatus(on)
7}
8
hooks/status.ts 380 lines
1import 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}
380
types/index.d.ts 26 lines
1/** 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