SLOPSHOPPER

git-status

A status line below the prompt with the changes and unpushed commits of the session's git repository — local only, no fetch.

newstatusprocesstimer
v1.1.0MITupdated 2026-10-14marco-rademacher/claude-git-status
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · git-status
› 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-status:  │  up to date
README

git-status

A Claude Code mod that keeps one line below the prompt telling you whether your repository has loose ends:

git-status │  up to date · main
git-status │  unpushed: app main ↑4 (2 merges)
git-status │  app feature/login: 3 changed · shared main ↑1
  • the branch after each repository's name — cut with … past 24 characters
  • changed — entries in git status (modified, staged, untracked)
  • ↑n — commits on the branch that its upstream does not have yet
  • (n merges) — how many of those commits are merge commits: with a merge per finished task, the tasks still waiting to be pushed
  • up to date — nothing changed, nothing to push; with the branch of the session's repository

It checks every five seconds and only locally: no git fetch, so the upstream is as fresh as your last fetch or push. git status runs with --no-optional-locks, so it never leaves an index.lock in the way of your own git commands.

What it runs

Everything happens on your machine and only reads. The mod is one TypeScript hooks module (hooks/register.ts); the parts it uses of Claude Code's mod API are listed here.

Programs

It runs one program, git, because git is the only reliable source for whether a repository has changes or commits its upstream lacks. It starts it through $.process.run with a fixed argument list, never through a shell. These are all the calls (<repo> is a repository path, <range> is @{u}..HEAD, or <branch>@{u}..<branch> when the branch setting is set):

CommandWhenWhy
git -C <cwd> rev-parse --show-toplevelonce per working directoryfind the session's repository
git -C <repo> branch --show-currentevery five seconds, per repositoryname the checked-out branch
git -C <repo> rev-list --count <range>every five seconds, per repositorycount commits not yet on the upstream
git -C <repo> rev-list --count --merges <range>every five seconds, per repositorycount the merge commits among them
git --no-optional-locks -C <repo> status --porcelainevery five seconds, per repositorycount changed and untracked entries

No git fetch, no network access, no writes: none of these commands changes a repository.

Other calls

  • $.clock.every(5000, …) — the five-second tick, started once when the session starts
  • $.session.cwd() — the session's working directory
  • $.fs.exists(<repo>/.git) — every five seconds, per repository: skip a path that is no repository
  • $.fs.list(<repo>), $.fs.exists(…/.git), $.fs.stat(…/.git) — once per working directory, when nestedRepos is on: list the repository's top folder and tell nested repositories (a .git folder) from worktrees and submodules (a .git file)
  • $.env.get("HOME") — expand ~ in extraRepos

What it sends, and where

Nothing leaves your machine. The mod's only output is $.ui.status(text): Claude Code draws that text as this plugin's line below the prompt. The text holds the names of the repository folders, their branch names and counts, for example ios feature/login: 3 changed · shared main ↑4 (2 merges). It writes no files, changes no settings and sends nothing to any server.

Install

Mods need Claude Code 2.1.287 or later. At the prompt of a session:

/plugin install git-status --marketplace marco-rademacher/claude-git-status

Answer y to add the marketplace and pick a scope; the line appears in the next session.

Settings

Under /config (or pluginConfigs in ~/.claude/settings.json):

SettingDefaultWhat it does
branchemptyEmpty compares the checked-out branch with its upstream. Name one (develop) to always compare that branch with its upstream, whatever is checked out.
nestedReposoffAlso checks folders directly inside the session's repository that are repositories of their own (ios/, android/). Worktrees and submodules stay out.
extraReposnoneFurther repositories to check in every session, such as a shared config repo; ~ is your home folder.

A repository without an upstream counts as nothing to push.

Notes

  • In the desktop app the line appears after your first message in a session: the app starts Claude Code, and with it every mod, only then.
  • The app writes the plugin's name in front of the line; the │ keeps the two apart.
  • The line has no colour: the status line takes plain text only (an ANSI code shows as raw characters in the desktop app).

Development

claude plugin test .
claude plugin validate .

License

MIT — see LICENSE.

Source 2 files
hooks/register.ts 87 lines
1// git-status: a status line below the prompt with the changes and unpushed commits of the session's
2// repository, its nested repositories and any extra ones the person names.
3//
4// Every five seconds, local only: no fetch, so the upstream is as old as the last fetch or push.
5// `git status` runs with --no-optional-locks so it never takes an index.lock another git command of
6// the person's (or a parallel session's) would trip over.
7//
8// Learned in the desktop app (Claude Code 2.1.295): a label in the footer (`ui.render` on
9// `SessionMode`) is not drawn there, the status line is — with the plugin's name in front.
10
11import type { EngineInterface, Register } from 'claude-code'
12
13import { aheadRange, checkoutPaths, expandHome, readCheckout, repoName, settingsFrom, statusLine } from './line'
14import type { Checkout, Settings } from './line'
15
16const INTERVAL_MS = 5000
17
18async function output($: EngineInterface, argv: string[]): Promise<string> {
19  try {
20    const r = await $.process.run(argv, { timeoutMs: 15000 })
21    return r.exitCode === 0 ? r.stdout : ''
22  } catch {
23    return ''
24  }
25}
26
27/** The checkouts to look at for this working directory; worked out once per directory, not every tick. */
28let paths: { cwd: string; list: string[] } | null = null
29
30async function pathsFor($: EngineInterface, settings: Settings): Promise<string[]> {
31  const cwd = await $.session.cwd()
32  if (paths?.cwd === cwd) return paths.list
33
34  const top = (await output($, ['git', '-C', cwd, 'rev-parse', '--show-toplevel'])).trim() || null
35  const nested: string[] = []
36  if (top && settings.nestedRepos) {
37    // A nested repository has a .git folder of its own; a worktree or a submodule has a .git file and
38    // belongs to another repository's history, so it stays out.
39    for (const entry of await $.fs.list(top)) {
40      if (entry.kind !== 'dir' || entry.name.startsWith('.')) continue
41      const git = `${top}/${entry.name}/.git`
42      if (await $.fs.exists(git) && (await $.fs.stat(git)).kind === 'dir') nested.push(`${top}/${entry.name}`)
43    }
44  }
45  const home = await $.env.get('HOME')
46  const list = checkoutPaths(top, nested, settings.extraRepos.map(p => expandHome(p, home)))
47  paths = { cwd, list }
48  return list
49}
50
51/** Every checked repository, clean ones included: the first is the session's, whose branch a clean line names. */
52async function measure($: EngineInterface, settings: Settings): Promise<Checkout[]> {
53  const found: Checkout[] = []
54  const range = aheadRange(settings.branch)
55  for (const path of await pathsFor($, settings)) {
56    if (!(await $.fs.exists(`${path}/.git`))) continue
57    found.push(readCheckout(repoName(path), {
58      branch: await output($, ['git', '-C', path, 'branch', '--show-current']),
59      ahead: await output($, ['git', '-C', path, 'rev-list', '--count', range]),
60      merges: await output($, ['git', '-C', path, 'rev-list', '--count', '--merges', range]),
61      status: await output($, ['git', '--no-optional-locks', '-C', path, 'status', '--porcelain']),
62    }))
63  }
64  return found
65}
66
67let busy = false
68
69async function refresh($: EngineInterface, settings: Settings) {
70  if (busy) return
71  busy = true
72  try {
73    $.ui.status(statusLine(await measure($, settings), settings.branch))
74  } finally {
75    busy = false
76  }
77}
78
79export const register: Register = (on, options) => {
80  const settings = settingsFrom(options)
81  on('session.start', async ($, e, next) => {
82    await refresh($, settings)
83    $.clock.every(INTERVAL_MS, () => refresh($, settings))
84    return next(e)
85  })
86}
87
hooks/line.ts 103 lines
1// The pure part of git-status: which checkouts to look at, what `git` says about them and the line
2// that comes out of it. No `$` here, so `claude plugin test` checks it without an engine.
3
4/**
5 * One checked repository: `branch` the checked-out branch (null when detached), `ahead` commits on the
6 * compared branch not yet on its upstream (as of the last fetch), `merges` how many of those are merge
7 * commits, `changed` entries in `git status`.
8 */
9export type Checkout = { name: string; branch: string | null; ahead: number; merges: number; changed: number }
10
11/** The options a person sets under /config (plugin.json `userConfig`), defaults filled in. */
12export type Settings = { branch: string; nestedRepos: boolean; extraRepos: readonly string[] }
13
14/** Reads the options as `register` receives them; a value of the wrong type counts as unset. */
15export function settingsFrom(options: Readonly<Record<string, unknown>>): Settings {
16  const extra = options.extraRepos
17  return {
18    branch: typeof options.branch === 'string' ? options.branch.trim() : '',
19    nestedRepos: options.nestedRepos === true,
20    extraRepos: Array.isArray(extra) ? extra.filter((p): p is string => typeof p === 'string' && p.trim() !== '')
21      : typeof extra === 'string' && extra.trim() !== '' ? [extra] : [],
22  }
23}
24
25/** `~` and `~/…` against the home directory; anything else as given, without a trailing slash. */
26export function expandHome(path: string, home: string | undefined): string {
27  const p = path.trim().replace(/(.)\/+$/, '$1')
28  if (!home) return p
29  if (p === '~') return home
30  return p.startsWith('~/') ? `${home}/${p.slice(2)}` : p
31}
32
33/** The paths to check, in order and each once: the session's repo, its nested repos, the extra ones. */
34export function checkoutPaths(sessionRepo: string | null, nested: readonly string[], extra: readonly string[]): string[] {
35  return [...new Set([...(sessionRepo ? [sessionRepo] : []), ...nested, ...extra])]
36}
37
38/**
39 * The revision range of unpushed commits: the current branch against its upstream, or the configured
40 * branch against its own upstream.
41 */
42export function aheadRange(branch: string): string {
43  return branch === '' ? '@{u}..HEAD' : `${branch}@{u}..${branch}`
44}
45
46/** A count from `git rev-list --count`; empty or an error (such as no upstream) counts as zero. */
47function count(text: string): number {
48  const t = text.trim()
49  return /^\d+$/.test(t) ? Number(t) : 0
50}
51
52/**
53 * One checkout from git's answers: `git branch --show-current` (empty when detached),
54 * `git rev-list --count <range>`, the same with `--merges`, and `git status --porcelain` (one entry per
55 * line).
56 */
57export function readCheckout(name: string, git: { branch: string; ahead: string; merges: string; status: string }): Checkout {
58  const branch = git.branch.trim()
59  return {
60    name,
61    branch: branch === '' ? null : branch,
62    ahead: count(git.ahead),
63    merges: count(git.merges),
64    changed: git.status.split('\n').filter(l => l.trim() !== '').length,
65  }
66}
67
68const BRANCH_MAX = 24
69
70/** A branch name as the line shows it: cut with an ellipsis past 24 characters. */
71export function shortBranch(branch: string): string {
72  return branch.length > BRANCH_MAX ? `${branch.slice(0, BRANCH_MAX - 1)}…` : branch
73}
74
75/**
76 * The status line, always shown, each repository with its branch: changes first, then unpushed commits
77 * and how many of them are merges — `ios feature/login: 3 changed · shared develop ↑4 (2 merges)`;
78 * commits only `unpushed: shared develop ↑4 (2 merges)`; nothing open `up to date · develop`, the
79 * session repository's branch. `compared` is the `branch` setting: when set, the commits counted are that
80 * branch's, so the line names it rather than the checked-out one.
81 *
82 * It starts with a bar between no-break spaces: the app puts the plugin's name in front of the line and
83 * collapses ordinary spaces; two after the bar match the gap before it. No colour — the API has none for
84 * the status line, and the desktop app shows an ANSI code as raw characters.
85 */
86export function statusLine(checkouts: readonly Checkout[], compared = ''): string {
87  const label = (c: Checkout, branch: string | null) => (branch ? `${c.name} ${shortBranch(branch)}` : c.name)
88  const merges = (n: number) => (n === 0 ? '' : ` (${n} merge${n === 1 ? '' : 's'})`)
89  const changed = checkouts.filter(c => c.changed > 0).map(c => `${label(c, c.branch)}: ${c.changed} changed`)
90  const ahead = checkouts.filter(c => c.ahead > 0)
91    .map(c => `${label(c, compared || c.branch)} ↑${c.ahead}${merges(c.merges)}`)
92  const current = checkouts[0]?.branch
93  const text = changed.length > 0
94    ? [...changed, ...ahead].join(' · ')
95    : ahead.length > 0 ? `unpushed: ${ahead.join(' · ')}` : `up to date${current ? ` · ${shortBranch(current)}` : ''}`
96  return `\u00a0│\u00a0\u00a0${text}`
97}
98
99/** The name a checkout goes by in the line: its folder's name. */
100export function repoName(path: string): string {
101  return path.split('/').filter(Boolean).pop() ?? path
102}
103