Branch, changed files and commits ahead or behind upstream, in a status entry under the prompt.

Branch, changed files and commits ahead or behind upstream, in a status entry under the prompt.
One status entry under the prompt with the git state of the session's folder:
⎇ feature/discount-codes · 3 changed · ↑2 ↓0
Other states it shows:
⎇ main · clean · ↑0 ↓2
⎇ fix/cart-rounding · clean
⎇ detached HEAD · 1 changed
git status lists: modified, staged and untracked files. clean means none.The entry refreshes:
ls. Calls close together share one refresh;It runs git rev-parse --abbrev-ref HEAD and git status --porcelain=v1 -b in the session's folder with GIT_OPTIONAL_LOCKS=0, so it never holds the index lock your own git commands need. One refresh runs at a time. Git never runs inside a tool call, so no tool waits on it.
/plugin install git-glance --marketplace KROU4/claude-code-mod-showroomclaude plugin install git-glance --marketplace KROU4/claude-code-mod-showroomKROU4/claude-code-mod-showroom → Add plugingit status lists it.MIT. See LICENSE.
hooks/register.ts 97 lines1import type { EngineInterface, ProcessRunInit, Register, Timer } from 'claude-code'
2
3const DEBOUNCE_MS = 1_000
4const POLL_MS = 30_000
5// GIT_OPTIONAL_LOCKS=0 keeps `git status` off the index lock; git older than
6// 2.15 ignores the variable, where it would refuse `--no-optional-locks`.
7const GIT: ProcessRunInit = { timeoutMs: 10_000, env: { GIT_OPTIONAL_LOCKS: '0' } }
8
9let poll: Timer | undefined
10let pending: Timer | undefined
11let running = false
12let again = false
13// The text last sent to the status line; null until a session's first refresh.
14let shown: string | undefined | null = null
15
16export const register: Register = on => {
17 on('session.start', async ($, e, next) => {
18 const started = await next(e)
19 poll?.cancel()
20 poll = $.clock.every(POLL_MS, () => void refresh($))
21 shown = null
22 void refresh($)
23
24 return started
25 })
26
27 // MultiEdit is matched for builds that still have it; PowerShell is the
28 // shell tool on Windows.
29 on(
30 'tool.call',
31 { tool: /^(Edit|Write|MultiEdit|NotebookEdit|Bash|PowerShell)$/ },
32 async ($, e, next) => {
33 const ran = await next(e)
34 // A deny can come after the tool ran and undoes nothing, so only a call
35 // core ran read-only (a shell `ls`) is skipped.
36 if (ran.isReadOnly !== true) {
37 pending?.cancel()
38 pending = $.clock.after(DEBOUNCE_MS, () => void refresh($))
39 }
40
41 return ran
42 },
43 ).catch(($, e, next) => next(e)) // an observer, not a guard: the call always goes on
44}
45
46// One git pair at a time: refreshes asked for while one runs make one more run.
47async function refresh($: EngineInterface) {
48 if (running) {
49 again = true
50 return
51 }
52 running = true
53 try {
54 do {
55 again = false
56 const text = await describeRepo($).catch(() => undefined)
57 if (text !== shown) $.ui.status((shown = text))
58 } while (again)
59 } finally {
60 running = false
61 }
62}
63
64// "⎇ main · 3 changed · ↑1 ↓0", or undefined outside a git repository.
65async function describeRepo($: EngineInterface) {
66 const [head, status] = await Promise.all([
67 $.process.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], GIT),
68 $.process.run(['git', 'status', '--porcelain=v1', '-b'], GIT),
69 ])
70 if (status.exitCode !== 0) return undefined
71
72 const [header = '', ...files] = status.stdout.split(/\r?\n/).filter(line => line !== '')
73 const branch = head.exitCode === 0 ? head.stdout.trim() : unbornBranch(header)
74 const parts = [
75 `⎇ ${branch === 'HEAD' ? 'detached HEAD' : branch}`,
76 files.length === 0 ? 'clean' : `${files.length} changed`,
77 ]
78
79 // "## main...origin/main [ahead 1, behind 2]"; no "..." means no upstream.
80 const tracking = /^## \S+?\.\.\.\S+(?: \[(.*)\])?$/.exec(header)
81 const counts = tracking?.[1] ?? ''
82 if (tracking && counts !== 'gone') {
83 parts.push(`↑${count(counts, 'ahead')} ↓${count(counts, 'behind')}`)
84 }
85
86 return parts.join(' · ')
87}
88
89// rev-parse fails on a branch with no commits yet: "## No commits yet on main".
90function unbornBranch(header: string) {
91 return /^## (?:No commits yet|Initial commit) on (.+?)(?:\.\.\..*)?$/.exec(header)?.[1] ?? 'HEAD'
92}
93
94function count(counts: string, word: 'ahead' | 'behind') {
95 return Number(new RegExp(`${word} (\\d+)`).exec(counts)?.[1] ?? 0)
96}
97