Status line with branch, ahead/behind, changed files, PR and worktree count

Personal dotfiles for sharing setups and configs between machines.
Feel free to steal or use as you like.. Let me know if I'm missing any good tricks!
hooks/register.ts 97 lines1import type { EngineInterface, Register } from 'claude-code'
2
3// Claude often runs `cd other/repo && git ...`; follow it there.
4async function repoDir($: EngineInterface, command: string, fallback: string) {
5 const m = command.match(/(?:^|&&|;)\s*cd\s+("[^"]+"|'[^']+'|[^\s;&]+)/) ?? command.match(/\bgit\s+-C\s+("[^"]+"|'[^']+'|\S+)/)
6 if (!m?.[1]) return fallback
7 let p = m[1].replace(/^['"]|['"]$/g, '')
8 if (p.startsWith('~')) p = ((await $.env.get('HOME')) ?? '') + p.slice(1)
9 return p.startsWith('/') ? p : `${fallback}/${p}`
10}
11
12let dir = ''
13let sessionCwd = ''
14let pr = { branch: '', text: '' }
15let isBusy = false
16
17// Labels the repo when it isn't the session's own folder, so you can tell what you're looking at.
18async function repoName($: EngineInterface) {
19 if (dir === sessionCwd) return ''
20 const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd: dir })
21 return top.stdout.trim().split('/').pop() ?? ''
22}
23
24async function refresh($: EngineInterface, isPrStale = false) {
25 if (isBusy || !dir) return
26 isBusy = true
27 try {
28 const st = await $.process.run(['git', 'status', '--porcelain=v2', '--branch'], { cwd: dir })
29 if (st.exitCode !== 0) return $.ui.status(undefined)
30
31 let branch = ''
32 let ahead = 0
33 let behind = 0
34 let hasUpstream = false
35 let changed = 0
36 for (const line of st.stdout.split('\n')) {
37 if (line.startsWith('# branch.head ')) branch = line.slice(14)
38 else if (line.startsWith('# branch.upstream ')) hasUpstream = true
39 else if (line.startsWith('# branch.ab ')) {
40 const [a = '0', b = '0'] = line.slice(12).split(' ')
41 ahead = Number(a)
42 behind = -Number(b)
43 } else if (line && !line.startsWith('#')) changed++
44 }
45
46 if (isPrStale || pr.branch !== branch) {
47 const view = await $.process.run(['gh', 'pr', 'view', '--json', 'number,state'], { cwd: dir, timeoutMs: 10_000 }).catch(() => undefined)
48 let text = ''
49 if (view?.exitCode === 0) {
50 const { number, state } = JSON.parse(view.stdout) as { number: number; state: string }
51 text = `PR #${number}${state === 'OPEN' ? '' : ` ${state.toLowerCase()}`}`
52 }
53 pr = { branch, text }
54 }
55
56 const wt = await $.process.run(['git', 'worktree', 'list', '--porcelain'], { cwd: dir })
57 const worktrees = wt.stdout.split('\n').filter(l => l.startsWith('worktree ')).length - 1
58
59 const name = await repoName($)
60 const parts = [
61 name ? `${name}: ${branch}` : branch,
62 ahead ? `↑${ahead}` : '',
63 behind ? `↓${behind}` : '',
64 !hasUpstream && branch !== '(detached)' ? 'not pushed' : '',
65 changed ? `${changed}Δ` : '',
66 pr.text,
67 worktrees > 0 ? `${worktrees} worktree${worktrees > 1 ? 's' : ''}` : '',
68 ]
69 $.ui.status(parts.filter(Boolean).join(' · '))
70 } finally {
71 isBusy = false
72 }
73}
74
75export const register: Register = on => {
76 on('session.start', async ($, e, next) => {
77 const started = await next(e)
78 sessionCwd = await $.session.cwd()
79 dir = sessionCwd
80 void refresh($)
81 // ponytail: polls every 15 s to catch commits made outside Claude; a git index watcher if that ever matters
82 $.clock.every(15_000, () => void refresh($))
83 return started
84 })
85
86 on('tool.call', async ($, e, next) => {
87 const ran = await next(e)
88 if (e.tool === 'Bash' && /\b(git|gh)\b/.test(e.command)) {
89 dir = await repoDir($, e.command, await $.session.cwd())
90 void refresh($, /\bgit\s+push\b|\bgh\s+pr\b/.test(e.command))
91 } else if (e.tool === 'Edit' || e.tool === 'Write') {
92 void refresh($)
93 }
94 return ran
95 }).catch(($, e, next) => next(e))
96}
97