SLOPSHOPPER

git-status-band

A band above the prompt that says in plain words whether your work is saved and in sync with GitHub

newbandprocesstimer
v0.1.0no licenseupdated 2026-10-02shuletadev/certbuddycr/mods/git-status-band
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · git-status-band
› 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 ● 1 file has changes that are not saved yet. Say /save when you are done. [ Refresh ] [ Hide ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
● 1 file has changes that are not saved yet. Say /save when you are done. [ Refresh ] [ Hide ]
README

git-status-band

A one-line band above the Claude Code prompt that says, in plain language, whether your work is saved and matches GitHub. Read-only: it only runs git status, never changes anything.

You seeIt meansWhat to do
✓ Everything is saved and matches GitHubNothing to do—
● N files have changes that are not saved yetEdits not committed/save
↑ Saved on this computer, not on GitHub yetCommitted, not pushed/save
↓ GitHub has newer updatesThe other account pushed/start
⇅ Both have new workNeeds careAsk Claude to sort it out

It refreshes every 15 seconds, after each turn, and when you press Refresh. Hide hides it for the session. "Behind GitHub" is based on the last time git contacted GitHub, so /start (which fetches) is what makes it fully current.

Try it

claude --plugin-dir ./mods/git-status-band

Needs Claude Code 2.1.286 or newer with mods. Validate with claude plugin validate mods/git-status-band using the same version (an older claude on PATH will report false errors).

Source 2 files
hooks/register.tsx 129 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { GitSummary } from '../types'
5
6const summary = atom({ plugin: 'git-status-band', key: 'summary' } as const, null)
7const isHidden = atom({ plugin: 'git-status-band', key: 'isHidden' } as const, false)
8
9const REFRESH_MS = 15000
10
11// Turns `git status --porcelain=v1 -b` output into a small summary.
12// First line looks like: "## main...origin/main [ahead 1, behind 2]"
13function parse(stdout: string): GitSummary {
14  const lines = stdout.split('\n').filter(line => line.length > 0)
15  const head = lines[0] ?? ''
16  const changed = lines.length - 1
17
18  const branch = head.replace(/^## /, '').split('...')[0].replace(/^No commits yet on /, '')
19  const hasUpstream = head.includes('...')
20  const ahead = Number(/ahead (\d+)/.exec(head)?.[1] ?? 0)
21  const behind = Number(/behind (\d+)/.exec(head)?.[1] ?? 0)
22
23  let kind: GitSummary['kind'] = 'synced'
24  if (changed > 0) kind = 'dirty'
25  else if (ahead > 0 && behind > 0) kind = 'diverged'
26  else if (ahead > 0) kind = 'ahead'
27  else if (behind > 0) kind = 'behind'
28  else if (!hasUpstream) kind = 'no-upstream'
29
30  return { kind, branch, changed, ahead, behind }
31}
32
33function plural(n: number, one: string, many: string) {
34  return `${n} ${n === 1 ? one : many}`
35}
36
37// The sentence the user sees: plain language, no git jargon.
38function describe(s: GitSummary): { icon: string; color: string; text: string } {
39  switch (s.kind) {
40    case 'dirty':
41      return {
42        icon: '●',
43        color: 'warning',
44        text: `${plural(s.changed, 'file has', 'files have')} changes that are not saved yet. Say /save when you are done.`,
45      }
46    case 'ahead':
47      return {
48        icon: '↑',
49        color: 'warning',
50        text: `Saved on this computer, but ${plural(s.ahead, 'update is', 'updates are')} not on GitHub yet. /save sends them.`,
51      }
52    case 'behind':
53      return {
54        icon: '↓',
55        color: 'warning',
56        text: `GitHub has ${plural(s.behind, 'newer update', 'newer updates')} from the other account. Say /start to get them.`,
57      }
58    case 'diverged':
59      return {
60        icon: '⇅',
61        color: 'error',
62        text: 'This computer and GitHub both have new work. Ask Claude to sort it out before continuing.',
63      }
64    case 'no-upstream':
65      return { icon: '○', color: 'warning', text: 'This work is not connected to GitHub yet.' }
66    default:
67      return { icon: '✓', color: 'success', text: 'Everything is saved and matches GitHub.' }
68  }
69}
70
71let lastKey = ''
72let isRunning = false
73
74async function refresh($: EngineInterface) {
75  if (isRunning) return
76  isRunning = true
77  try {
78    const { exitCode, stdout } = await $.process.run(['git', 'status', '--porcelain=v1', '-b'], {
79      timeoutMs: 10000,
80    })
81    const next = exitCode === 0 ? parse(stdout) : null
82    const key = JSON.stringify(next)
83    if (key !== lastKey) {
84      lastKey = key
85      await update($, summary, () => next)
86    }
87  } catch {
88    // git missing or not a repo: show nothing
89    if (lastKey !== 'null') {
90      lastKey = 'null'
91      await update($, summary, () => null)
92    }
93  } finally {
94    isRunning = false
95  }
96}
97
98export const register: Register = on => {
99  on('session.start', async ($, e, next) => {
100    void refresh($)
101    $.clock.every(REFRESH_MS, () => refresh($))
102    return next(e)
103  })
104
105  on('turn.complete', async ($, e, next) => {
106    void refresh($)
107    return next(e)
108  })
109
110  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
111    const s = await read($, summary)
112    if (e.props.hasSurvey || s === null || (await read($, isHidden))) {
113      return next(e)
114    }
115
116    const { Box, Button, Text } = $.ui.resolve(e)
117    const { icon, color, text } = describe(s)
118
119    return (
120      <Box flexDirection="row" columnGap={1}>
121        <Text color={color}>{icon}</Text>
122        <Text dimColor>{text}</Text>
123        <Button key="refresh" label="Refresh" onPress={() => refresh($)} />
124        <Button key="hide" label="Hide" onPress={() => update($, isHidden, () => true)} />
125      </Box>
126    )
127  })
128}
129
types/index.d.ts 14 lines
1export type GitSummary = {
2  kind: 'none' | 'synced' | 'dirty' | 'ahead' | 'behind' | 'diverged' | 'no-upstream'
3  branch: string
4  changed: number
5  ahead: number
6  behind: number
7}
8
9declare module 'claude-code' {
10  interface PluginState {
11    'git-status-band': { summary: GitSummary | null; isHidden: boolean }
12  }
13}
14