SLOPSHOPPER

git-line

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

newguardstatusprocesstimer
★ 3v0.1.0no licenseupdated 2026-10-06ewels/dotfiles/claude-mods/git-line
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · git-line
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts ⚠ git-line: not pushed · 2Δ
README

dotfiles

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!

Source 1 files
hooks/register.ts 97 lines
1import 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