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

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
git status (modified, staged, untracked)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.
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.
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):
| Command | When | Why |
|---|---|---|
git -C <cwd> rev-parse --show-toplevel | once per working directory | find the session's repository |
git -C <repo> branch --show-current | every five seconds, per repository | name the checked-out branch |
git -C <repo> rev-list --count <range> | every five seconds, per repository | count commits not yet on the upstream |
git -C <repo> rev-list --count --merges <range> | every five seconds, per repository | count the merge commits among them |
git --no-optional-locks -C <repo> status --porcelain | every five seconds, per repository | count changed and untracked entries |
No git fetch, no network access, no writes: none of these commands changes a repository.
$.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 extraReposNothing 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.
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.
Under /config (or pluginConfigs in ~/.claude/settings.json):
| Setting | Default | What it does |
|---|---|---|
branch | empty | Empty compares the checked-out branch with its upstream. Name one (develop) to always compare that branch with its upstream, whatever is checked out. |
nestedRepos | off | Also checks folders directly inside the session's repository that are repositories of their own (ios/, android/). Worktrees and submodules stay out. |
extraRepos | none | Further 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.
│ keeps the two apart.claude plugin test .
claude plugin validate .
MIT — see LICENSE.
hooks/register.ts 87 lines1// 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}
87hooks/line.ts 103 lines1// 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