Git status at a glance: branch, ahead/behind and changes on the status line, kept fresh as Claude edits, with a heads-up when the branch changes or a turn…

Git status at a glance, kept fresh while Claude edits. Know the branch, what's dirty, and when a turn made commits.
╭──────────────────────────────────────────────────────────────╮
│ > │
╰──────────────────────────────────────────────────────────────╯
⎇ feat/login ↑2 ↓1 ●3 +1 ?2
┌ git-pulse ───────────────────┐
│ 2 new commits on feat/login │
│ this turn │
└──────────────────────────────┘
> /git-pulse
Branch: feat/login
Upstream: origin/feat/login (2 ahead, 1 behind)
Working tree: 1 staged, 3 modified, 2 untracked
Stash: 1 entry
Last commits:
a1b2c3d Add login redirect (4 minutes ago)
d4e5f6a Extract session helper (9 minutes ago)
⎇ branch ↑ahead ↓behind ✖conflicts ●modified +staged ?untracked, or ⎇ main ✓ when clean and in sync. A detached HEAD shows @a1b2c3d; a new repo shows (no commits).Bash, Edit, Write and NotebookEdit calls. A burst of edits settles into one git status, not twenty.branch changed: main → feat/login), and when a turn ends having made commits (2 new commits on feat/login this turn)./git-pulse: branch, upstream sync, working-tree counts, stash and the last three commits./plugin marketplace add Singh-AP/awesome-claude-mods
/plugin install git-pulse@awesome-claude-mods
Requires Claude Code 2.1.287 or later, and git on your PATH.
None. It reads the repository the session runs in.
| Event / API | Why |
|---|---|
session.start, turn.start, turn.complete | Refresh; compare HEAD at turn start and end, then git rev-list --count the new commits |
tool.call on Bash / Edit / Write / NotebookEdit | Schedule a refresh 750 ms after the last change ($.clock.after) |
$.process.run | git --no-optional-locks status --porcelain=v2 --branch, which never takes the index lock from under Claude's own git calls |
$.ui.status, $.ui.toast | The line and the heads-ups |
command.run | /git-pulse |
The parser (hooks/status.ts) is pure TypeScript with no $, so you can import it into your own mod.
claude plugin test mods/awareness/git-pulse # 17 tests
git fetch for you.cd inside a Bash command doesn't move it.git status slow. git-pulse gives each run 5 seconds and goes quiet after three misses.hooks/register.ts 107 lines1import type { EngineInterface, Register, Timer } from 'claude-code'
2
3import { commitsToast, headName, parseStatus, statusLine, summary, type GitState } from './status'
4
5const STATUS = ['git', '--no-optional-locks', 'status', '--porcelain=v2', '--branch']
6const SETTLE_MS = 750
7
8// What the last look found; a reload starts over and looks again at session start.
9let last: GitState | null = null
10let isGitMissing = false
11let failures = 0
12let timer: Timer | null = null
13let turnStart: { turnId: string; oid: string | null } | null = null
14
15async function git($: EngineInterface, args: string[]): Promise<{ exitCode: number; stdout: string } | null> {
16 if (isGitMissing) return null
17 try {
18 const out = await $.process.run(args, { timeoutMs: 5000 })
19 failures = 0
20 return out
21 } catch {
22 // git is not installed, or keeps timing out: after three misses, stay quiet rather than nag.
23 failures += 1
24 if (failures >= 3) isGitMissing = true
25 return null
26 }
27}
28
29async function refresh($: EngineInterface): Promise<GitState | null> {
30 const out = await git($, STATUS)
31 if (out === null || out.exitCode !== 0) {
32 last = null
33 $.ui.status(undefined)
34 return null
35 }
36 const state = parseStatus(out.stdout)
37 if (last !== null && last.branch !== state.branch && (last.branch !== null || state.branch !== null)) {
38 $.ui.toast(`branch changed: ${headName(last)} → ${headName(state)}`)
39 }
40 last = state
41 $.ui.status(statusLine(state))
42 return state
43}
44
45function schedule($: EngineInterface): void {
46 timer?.cancel()
47 timer = $.clock.after(SETTLE_MS, () => {
48 timer = null
49 void refresh($)
50 })
51}
52
53async function countCommits($: EngineInterface, from: string, to: string): Promise<number> {
54 const out = await git($, ['git', 'rev-list', '--count', `${from}..${to}`])
55 if (out === null || out.exitCode !== 0) return 0
56 const count = Number(out.stdout.trim())
57 return Number.isFinite(count) ? count : 0
58}
59
60export const register: Register = on => {
61 on('session.start', async ($, e, next) => {
62 await $.command.register({
63 name: 'git-pulse',
64 description: 'git-pulse: branch, upstream, changes, last commits and stash at a glance',
65 })
66 await refresh($)
67 return next(e)
68 })
69
70 on('tool.call', { tool: ['Bash', 'Edit', 'Write', 'NotebookEdit'] }, async ($, e, next) => {
71 const ran = await next(e)
72 schedule($)
73 return ran
74 })
75
76 on('turn.start', async ($, e, next) => {
77 const state = await refresh($)
78 turnStart = { turnId: e.turnId, oid: state?.oid ?? null }
79 return next(e)
80 })
81
82 on('turn.complete', async ($, e, next) => {
83 if (e.agentId !== undefined) return next(e)
84 timer?.cancel()
85 timer = null
86 const started = turnStart
87 turnStart = null
88 const state = await refresh($)
89 if (state !== null && state.oid !== null && started !== null && started.turnId === e.turnId && started.oid !== null && started.oid !== state.oid) {
90 const count = await countCommits($, started.oid, state.oid)
91 if (count > 0) $.ui.toast(commitsToast(count, state))
92 }
93 return next(e)
94 })
95
96 on('command.run', { command: 'git-pulse' }, async $ => {
97 const state = await refresh($)
98 if (state === null) {
99 return { text: isGitMissing ? 'git-pulse: git is not available here.' : 'git-pulse: this folder is not inside a git repository.' }
100 }
101 const log = state.oid === null ? null : await git($, ['git', 'log', '-3', '--no-decorate', '--format=%h %s (%cr)'])
102 const stash = await git($, ['git', 'stash', 'list', '--format=%gd'])
103 const stashes = stash === null || stash.exitCode !== 0 ? 0 : stash.stdout.split('\n').filter(l => l.trim() !== '').length
104 return { text: summary(state, log?.exitCode === 0 ? log.stdout : '', stashes) }
105 })
106}
107hooks/status.ts 116 lines1// Pure parsing of `git status --porcelain=v2 --branch`: no `$`, so tests drive it directly.
2
3export type GitState = {
4 /** The commit HEAD points at; null before the first commit. */
5 oid: string | null
6 /** The branch; null when HEAD is detached. */
7 branch: string | null
8 upstream: string | null
9 ahead: number
10 behind: number
11 staged: number
12 modified: number
13 untracked: number
14 conflicts: number
15}
16
17export function parseStatus(text: string): GitState {
18 const state: GitState = {
19 oid: null,
20 branch: null,
21 upstream: null,
22 ahead: 0,
23 behind: 0,
24 staged: 0,
25 modified: 0,
26 untracked: 0,
27 conflicts: 0,
28 }
29
30 for (const line of text.split('\n')) {
31 if (line.startsWith('# branch.oid ')) {
32 const oid = line.slice('# branch.oid '.length).trim()
33 state.oid = oid === '(initial)' ? null : oid
34 } else if (line.startsWith('# branch.head ')) {
35 const head = line.slice('# branch.head '.length).trim()
36 state.branch = head === '(detached)' ? null : head
37 } else if (line.startsWith('# branch.upstream ')) {
38 state.upstream = line.slice('# branch.upstream '.length).trim()
39 } else if (line.startsWith('# branch.ab ')) {
40 const match = /\+(\d+) -(\d+)/.exec(line)
41 if (match !== null) {
42 state.ahead = Number(match[1])
43 state.behind = Number(match[2])
44 }
45 } else if (line.startsWith('1 ') || line.startsWith('2 ')) {
46 const xy = line.slice(2, 4)
47 if (xy[0] !== '.') state.staged++
48 if (xy[1] !== '.') state.modified++
49 } else if (line.startsWith('u ')) {
50 state.conflicts++
51 } else if (line.startsWith('? ')) {
52 state.untracked++
53 }
54 }
55
56 return state
57}
58
59export function isClean(state: GitState): boolean {
60 return state.staged + state.modified + state.untracked + state.conflicts === 0
61}
62
63/** `main`, `@a1b2c3d` when detached, `main (no commits)` before the first. */
64export function headName(state: GitState): string {
65 if (state.branch !== null) return state.oid === null ? `${state.branch} (no commits)` : state.branch
66 return state.oid === null ? '(no HEAD)' : `@${state.oid.slice(0, 7)}`
67}
68
69/** `⎇ main ↑2 ↓1 ●3 +1 ?2`, or `⎇ main ✓` when clean and in sync. */
70export function statusLine(state: GitState): string {
71 const parts = [`⎇ ${headName(state)}`]
72 if (state.ahead > 0) parts.push(`↑${state.ahead}`)
73 if (state.behind > 0) parts.push(`↓${state.behind}`)
74 if (state.conflicts > 0) parts.push(`✖${state.conflicts}`)
75 if (state.modified > 0) parts.push(`●${state.modified}`)
76 if (state.staged > 0) parts.push(`+${state.staged}`)
77 if (state.untracked > 0) parts.push(`?${state.untracked}`)
78 if (parts.length === 1 && isClean(state)) parts.push('✓')
79 return parts.join(' ')
80}
81
82const plural = (n: number, one: string) => `${n} ${one}${n === 1 ? '' : 's'}`
83
84/** The `/git-pulse` report. */
85export function summary(state: GitState, log: string, stashes: number): string {
86 const lines = [`Branch: ${headName(state)}`]
87 if (state.upstream !== null) {
88 const sync = state.ahead === 0 && state.behind === 0 ? 'in sync' : `${state.ahead} ahead, ${state.behind} behind`
89 lines.push(`Upstream: ${state.upstream} (${sync})`)
90 } else if (state.branch !== null) {
91 lines.push('Upstream: none (not pushed yet)')
92 }
93 if (isClean(state)) {
94 lines.push('Working tree: clean')
95 } else {
96 const counts = [
97 state.conflicts > 0 ? plural(state.conflicts, 'conflict') : '',
98 state.staged > 0 ? `${state.staged} staged` : '',
99 state.modified > 0 ? `${state.modified} modified` : '',
100 state.untracked > 0 ? `${state.untracked} untracked` : '',
101 ].filter(Boolean)
102 lines.push(`Working tree: ${counts.join(', ')}`)
103 }
104 if (stashes > 0) lines.push(`Stash: ${stashes} ${stashes === 1 ? 'entry' : 'entries'}`)
105 const commits = log.trim()
106 if (commits !== '') {
107 lines.push('', 'Last commits:')
108 for (const line of commits.split('\n')) lines.push(` ${line}`)
109 }
110 return lines.join('\n')
111}
112
113export function commitsToast(count: number, state: GitState): string {
114 return `${plural(count, 'new commit')} on ${headName(state)} this turn`
115}
116