SLOPSHOPPER

git-status

Shows the branch, uncommitted files and unpushed commits in the footer.

newspinnerguardpromptprocess
v0.1.0MITupdated 2026-10-02choas/claude-mods/git-status
A shopper browsing a rack in a slop shop
README

my-mods

Small Claude Code mods that add live info to the footer, next to the mode labels.

ModShowsExample
cwd-statusThe folder Claude is working in, including where a Bash command cds to๐Ÿ“ ~/prj/app
git-statusBranch, uncommitted files and unpushed commits of that folder's repoโއ main โ—3 โ†‘2

Together they read like ๐Ÿ“ ~/prj/app โއ main โ—3 โ†‘2.

Install

In Claude Code, add this marketplace once:

/plugin marketplace add choas/claude-mods

Then install one or both mods:

/plugin install cwd-status@my-mods
/plugin install git-status@my-mods

Run /reload-plugins (or restart Claude Code) and the labels show up in the footer.

You can also browse and install them interactively with /plugin โ†’ Marketplaces โ†’ my-mods.

Update

/plugin marketplace update my-mods

Uninstall

/plugin uninstall cwd-status@my-mods
/plugin uninstall git-status@my-mods

cwd-status

Shows ๐Ÿ“ <folder> with your home folder shortened to ~.

  • Follows the session's working folder.
  • When a Bash command cds or pushdes somewhere (cd ../other && make), it shows that folder until the next tool runs.

git-status

Shows the git state of the current folder:

PartMeaning
โއ mainCurrent branch (short commit hash on a detached HEAD)
โ—3Uncommitted entries: modified, staged, renamed, conflicted and untracked
โ†‘2Commits not pushed yet: ahead of the upstream, or, without an upstream, commits on no remote
  • โ— and โ†‘ only appear when non-zero; outside a git repo nothing is shown.
  • Refreshes when the session starts, on every prompt (so commits made in another terminal show up), after Bash/Edit/Write tool calls, and when the folder changes.
  • Follows cd inside Bash commands on its own, so it works without cwd-status.
  • Uses git status --porcelain=v2 --branch with optional locks off and a 5 s timeout, so it never blocks your repo.

Requires git on your PATH.

Development

Each mod is a plugin folder with a hooks module (hooks/register.ts), a state contract (types/index.d.ts) and tests:

claude plugin validate git-status
claude plugin test git-status

To try local changes, point Claude Code at the folder: claude --plugin-dir ./git-status.

License

MIT

Source 2 files
hooks/register.ts 130 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4const label = atom({ plugin: 'git-status', key: 'label' } as const, null)
5
6export type GitSummary = { branch: string; changed: number; ahead: number | undefined }
7
8// Reads `git status --porcelain=v2 --branch`; `ahead` is undefined when there is no upstream.
9export function parseStatus(out: string): GitSummary {
10  let branch = '?'
11  let oid = ''
12  let changed = 0
13  let ahead: number | undefined
14  for (const line of out.split('\n')) {
15    if (line.startsWith('# branch.oid ')) oid = line.slice(13)
16    else if (line.startsWith('# branch.head ')) branch = line.slice(14)
17    else if (line.startsWith('# branch.ab ')) ahead = Number(line.match(/\+(\d+)/)?.[1] ?? 0)
18    else if (/^[12u?] /.test(line)) changed++
19  }
20  if (branch === '(detached)') branch = oid.slice(0, 7) || 'detached'
21  return { branch, changed, ahead }
22}
23
24export function format({ branch, changed, ahead }: GitSummary): string {
25  const parts = [`โއ ${branch}`]
26  if (changed) parts.push(`โ—${changed}`)
27  if (ahead) parts.push(`โ†‘${ahead}`)
28  return parts.join(' ')
29}
30
31// Joins `path` onto `base` the way `cd` would: `~` is home, `.` and `..` fold away.
32export function resolveDir(base: string, path: string, home: string | undefined): string {
33  const expanded = home && (path === '~' || path.startsWith('~/')) ? home + path.slice(1) : path
34  const parts: string[] = []
35  for (const part of (expanded.startsWith('/') ? expanded : `${base}/${expanded}`).split('/')) {
36    if (part === '..') parts.pop()
37    else if (part !== '' && part !== '.') parts.push(part)
38  }
39  return `/${parts.join('/')}`
40}
41
42// The folder the last top-level `cd`/`pushd` in a Bash command leaves it in, if any.
43export function cdTarget(command: string, base: string, home: string | undefined): string | undefined {
44  let dir: string | undefined
45  for (const step of command.split(/&&|\|\||;|\n/)) {
46    const m = step.trim().match(/^(?:cd|pushd)(?:\s+(?:--\s+)?("[^"]*"|'[^']*'|\S+))?\s*$/)
47    if (!m) continue
48    const arg = m[1]?.replace(/^(["'])(.*)\1$/, '$2')
49    if (arg === '-') continue
50    dir = arg === undefined ? home : resolveDir(dir ?? base, arg, home)
51  }
52  return dir
53}
54
55async function git($: EngineInterface, cwd: string, ...args: string[]) {
56  return $.process.run(['git', ...args], { cwd, timeoutMs: 5000, env: { GIT_OPTIONAL_LOCKS: '0' } })
57}
58
59// The folder the label describes: the last Bash command's `cd` target, else the session's.
60let followed: string | undefined
61
62async function refresh($: EngineInterface) {
63  let next: string | null = null
64  try {
65    const cwd = followed ?? (await $.session.cwd())
66    const status = await git($, cwd, 'status', '--porcelain=v2', '--branch')
67    if (status.exitCode === 0) {
68      const summary = parseStatus(status.stdout)
69      // No upstream: count the commits that are on no remote at all.
70      if (summary.ahead === undefined) {
71        const local = await git($, cwd, 'rev-list', '--count', 'HEAD', '--not', '--remotes')
72        summary.ahead = local.exitCode === 0 ? Number(local.stdout.trim()) || 0 : 0
73      }
74      next = format(summary)
75    }
76  } catch {
77    // git missing or too slow: show nothing
78  }
79  if ((await read($, label)) !== next) await update($, label, () => next)
80}
81
82export const register: Register = on => {
83  on('session.start', async ($, e, next) => {
84    const result = await next(e)
85    await refresh($)
86    return result
87  })
88
89  // A commit made outside Claude shows up with the next prompt.
90  on('prompt.submit', async ($, e, next) => {
91    const result = await next(e)
92    await refresh($)
93    return result
94  })
95
96  // A `cd` inside a Bash command moves that command only, so follow where it ran
97  // and keep showing that repo until another tool or a real folder change.
98  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
99    const before = await $.session.cwd()
100    const result = await next(e)
101    const target = cdTarget(e.command, before, await $.env.get('HOME'))
102    const after = await $.session.cwd()
103    followed = after !== before || !target ? undefined : target
104    await refresh($)
105    return result
106  })
107
108  on('tool.call', async ($, e, next) => {
109    const result = await next(e)
110    if (e.tool === 'Edit' || e.tool === 'Write' || e.tool === 'NotebookEdit') {
111      followed = undefined
112      await refresh($)
113    }
114    return result
115  })
116
117  on('classic.CwdChanged', async ($, e, next) => {
118    const result = await next(e)
119    followed = undefined
120    await refresh($)
121    return result
122  })
123
124  // Shown with the footer's mode labels, next to cwd-status.
125  on('ui.render', { component: 'SessionMode' }, async ($, e, next) => {
126    const text = await read($, label)
127    return text === null ? next(e) : next({ ...e, props: { ...e.props, modes: [...e.props.modes, text] } })
128  })
129}
130
types/index.d.ts 8 lines
1export type GitStatusLabel = string | null
2
3declare module 'claude-code' {
4  interface PluginState {
5    'git-status': { label: GitStatusLabel }
6  }
7}
8