SLOPSHOPPER

Git Branch Above Prompt

Shows the current git branch, how many files changed and ahead/behind counts in a one-line band above the prompt.

newbandguardprocesstimer
A shopper browsing a rack in a slop shop
README

Git Branch Above Prompt (cm-git-branch)

Shows the current git branch, how many files changed and ahead/behind counts in a one-line band above the prompt.

What it does

A one-line band above the prompt shows where you are in git:

⎇ main · 3 changed · ↑1 ↓2

The branch name (or the short commit hash when HEAD is detached), how many paths git status lists as changed, and how far the branch is ahead of and behind its upstream. Outside a git repository the band does not appear. It refreshes when a session starts, after every main-loop turn, right after Claude runs a Bash command that mentions git, and on a timer in between, so a checkout you do in another terminal shows up too.

Install

claude plugin marketplace add <owner>/<repo>
claude plugin install cm-git-branch@claudemods

Configuration (userConfig)

KeyTypeDefaultDescription
intervalMsnumber (2000–3600000)15000How often to re-read git status between turns, in milliseconds (at least 2000).
showAheadBehindbooleantrueShow commits ahead of and behind the upstream branch (↑1 ↓0).
hideWhenCleanbooleanfalseHide the band while the work tree is clean and in sync with its upstream.

Permissions

APIWhy
$.session.cwdTo run git in the session's working directory.
$.process.runRuns git status --porcelain=v1 -b (and git rev-parse --short HEAD only when HEAD is detached). Read-only git commands, a 10-second timeout, no shell.
$.clock.everyRe-reads git status every intervalMs between turns.
$.state.get / $.state.setKeeps the last git status in cm-git-branch.info so the band redraws when it changes.
$.ui.resolveDraws the band's Box/Text elements on the AbovePrompt site.

Compatibility

Tested with Claude Code 2.1.291.

Implementation notes

  • Hooks: session.start, turn.complete (main loop only, subagents are skipped), tool.call{tool=Bash} (observes; refreshes after the call if the command mentions git, with a .catch that always lets the call run) and ui.render{component=AbovePrompt}.
  • One git status --porcelain=v1 -b call gives branch, upstream, ahead/behind and the changed count in one process, instead of separate rev-parse --abbrev-ref / rev-list calls. Handles No commits yet on <branch>, detached HEAD and [gone] upstreams.
  • The band yields to the engine's own AbovePrompt content (next(e)) while a survey is open, outside a repo, or when hideWhenClean applies.
  • Text elements drop a key prop when drawn; only the outer Box carries key="git-branch".
  • The state type is declared in types/index.d.ts (PluginState['cm-git-branch']).

License

MIT. See LICENSE. More at https://claudemods.app/mods/cm-git-branch.

Source 3 files
hooks/register.tsx 77 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { GitInfo } from '../types'
5import { detailParts, isClean, mentionsGit, parseStatus } from './git'
6
7const info = atom({ plugin: 'cm-git-branch', key: 'info' } as const, null)
8
9/** Reads the work tree's status in the session's folder and stores it for the band. */
10async function refresh($: EngineInterface): Promise<void> {
11  const cwd = await $.session.cwd()
12  let next: GitInfo | null = null
13  try {
14    const status = await $.process.run(['git', 'status', '--porcelain=v1', '-b'], { cwd, timeoutMs: 10_000 })
15    next = status.exitCode === 0 ? parseStatus(status.stdout) : null
16    if (next?.isDetached === true) {
17      const sha = await $.process.run(['git', 'rev-parse', '--short', 'HEAD'], { cwd, timeoutMs: 10_000 })
18      if (sha.exitCode === 0 && sha.stdout.trim() !== '') next.branch = sha.stdout.trim()
19    }
20  } catch {
21    // git missing or too slow: show nothing rather than a stale branch.
22    next = null
23  }
24  await update($, info, () => next)
25}
26
27export const register: Register = (on, options) => {
28  const intervalMs = Math.max(2_000, typeof options.intervalMs === 'number' ? options.intervalMs : 15_000)
29  const band = {
30    showAheadBehind: options.showAheadBehind !== false,
31    hideWhenClean: options.hideWhenClean === true,
32  }
33
34  on('session.start', async ($, e, next) => {
35    const started = await next(e)
36    await refresh($)
37    $.clock.every(intervalMs, () => {
38      refresh($).catch(() => undefined)
39    })
40    return started
41  })
42
43  on('turn.complete', async ($, e, next) => {
44    const result = await next(e)
45    if (e.agentId === undefined) await refresh($)
46    return result
47  })
48
49  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
50    const ran = await next(e)
51    if (mentionsGit(e.command)) await refresh($)
52    return ran
53  }).catch(($, e, next) => next(e))
54
55  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
56    const current = await read($, info)
57    if (e.props.hasSurvey || current === null || (band.hideWhenClean && isClean(current))) {
58      return next(e)
59    }
60
61    const { Box, Text } = $.ui.resolve(e)
62    const name = current.isDetached ? `${current.branch} (detached)` : current.branch
63
64    return (
65      <Box key="git-branch" flexDirection="row">
66        <Text dimColor>⎇ </Text>
67        <Text bold color="cyan" wrap="truncate-end">
68          {name}
69        </Text>
70        <Text dimColor wrap="truncate-end">
71          {' · ' + detailParts(current, band).join(' · ')}
72        </Text>
73      </Box>
74    )
75  })
76}
77
hooks/git.ts 67 lines
1/**
2 * Pure helpers for cm-git-branch: parse `git status --porcelain=v1 -b` and
3 * format the band's line. No `$`, so they are unit-tested directly.
4 */
5import type { GitInfo } from '../types'
6
7/**
8 * Reads the porcelain v1 output with its `## ` branch header:
9 *   ## main...origin/main [ahead 1, behind 2]
10 *   ## No commits yet on main
11 *   ## HEAD (no branch)
12 * followed by one line per changed path.
13 */
14export function parseStatus(stdout: string): GitInfo | null {
15  const lines = stdout.split('\n').filter(line => line !== '')
16  const header = lines[0]
17  if (header === undefined || !header.startsWith('## ')) return null
18  const changed = lines.length - 1
19  const head = header.slice(3)
20
21  if (head.startsWith('HEAD (no branch)')) {
22    return { branch: 'HEAD', isDetached: true, ahead: 0, behind: 0, changed }
23  }
24
25  const initial = /^(?:No commits yet on|Initial commit on) (.+)$/.exec(head)
26  if (initial?.[1] !== undefined) {
27    return { branch: initial[1], isDetached: false, ahead: 0, behind: 0, changed }
28  }
29
30  const match = /^(.+?)(?:\.\.\.(\S+))?(?: \[(.*)\])?$/.exec(head)
31  const branch = match?.[1] ?? head
32  const upstream = match?.[2]
33  const track = match?.[3] ?? ''
34  const ahead = Number(/ahead (\d+)/.exec(track)?.[1] ?? 0)
35  const behind = Number(/behind (\d+)/.exec(track)?.[1] ?? 0)
36  const info: GitInfo = { branch, isDetached: false, ahead, behind, changed }
37  if (upstream !== undefined) info.upstream = upstream
38  return info
39}
40
41export type BandOptions = { showAheadBehind: boolean; hideWhenClean: boolean }
42
43/** Whether the band has nothing worth showing under `hideWhenClean`. */
44export function isClean(info: GitInfo): boolean {
45  return info.changed === 0 && info.ahead === 0 && info.behind === 0
46}
47
48/** The parts after the branch name: `3 changed`, `↑1 ↓0`. */
49export function detailParts(info: GitInfo, options: BandOptions): string[] {
50  const parts = [info.changed === 0 ? 'clean' : `${info.changed} changed`]
51  if (options.showAheadBehind && info.upstream !== undefined) {
52    parts.push(`↑${info.ahead} ↓${info.behind}`)
53  }
54  return parts
55}
56
57/** The whole line as text: `⎇ main · 3 changed · ↑1 ↓0`. */
58export function formatLine(info: GitInfo, options: BandOptions): string {
59  const name = info.isDetached ? `${info.branch} (detached)` : info.branch
60  return [`⎇ ${name}`, ...detailParts(info, options)].join(' · ')
61}
62
63/** True for a Bash command that runs git (and so may move HEAD or the index). */
64export function mentionsGit(command: string): boolean {
65  return /(^|[\s;&|(`])git(\s|$)/.test(command) || /\bgh\s+pr\s+(checkout|merge)\b/.test(command)
66}
67
types/index.d.ts 22 lines
1/** What the band shows: one reading of `git status --porcelain=v1 -b`. */
2export type GitInfo = {
3  /** The branch name, or the short commit id when HEAD is detached. */
4  branch: string
5  isDetached: boolean
6  /** The upstream (`origin/main`), when the branch tracks one. */
7  upstream?: string
8  ahead: number
9  behind: number
10  /** Changed, staged and untracked paths together. */
11  changed: number
12}
13
14declare module 'claude-code' {
15  interface PluginState {
16    'cm-git-branch': {
17      /** null while the session's folder is not inside a git work tree. */
18      info: GitInfo | null
19    }
20  }
21}
22