Glanceable git health above the Claude Code prompt: branch, dirty files, ahead/behind, and a toast when a merge, rebase, conflict or detached HEAD appears.

Repo Pulse is a Claude Code mod that shows your repository's git health in a one-row band above the prompt, so you can see where you are without asking Claude or switching terminals:
main · ±3 · ↑1 ↓2
It also shows a short toast when the repository enters a state that is easy to miss in the middle of a session: a merge conflict, a merge in progress, a rebase in progress, or a detached HEAD.
| Part | Meaning |
|---|---|
main | Current branch, or HEAD@abc1234 when HEAD is detached |
±3 | Changed, staged and untracked files (hidden when 0) |
↑1 ↓2 | Commits ahead of and behind the upstream, as of your last fetch (hidden without an upstream) |
⚠ CONFLICT, ⚠ REBASE, ⚠ MERGE, ⚠ DETACHED | Risk badge |
Outside a git repository the band shows nothing.
Repo Pulse runs these commands locally, in the session's working directory:
git --no-optional-locks status --porcelain=v2 --branchgit rev-parse --absolute-git-dirgit --version, only when a git command fails, to tell a missing git from a slow repositoryIt also checks whether MERGE_HEAD, rebase-merge or rebase-apply exist in the repository's git directory.
It refreshes after Claude runs Bash or edits a file, when you submit a prompt, when a turn ends, and every 30 seconds (every 2 minutes if git status takes longer than 5 seconds).
Why it starts programs: git has no API inside Claude Code, so the only way to read branch, dirty files and ahead/behind is to run the git commands above. Their output is parsed in memory and drawn in the band; nothing else is done with it.
What it sends: nothing. Repo Pulse reads the conversation only to notice when Claude edits a file or runs a command (to refresh the band); it never copies that text anywhere. The only programs it starts are the git commands listed above.
Repo Pulse makes no network requests, never runs git fetch, stores no data, sends no data anywhere, and never takes git's index lock. It turns off core.fsmonitor for its own git commands (through the environment variables GIT_CONFIG_COUNT=1, GIT_CONFIG_KEY_0=core.fsmonitor, GIT_CONFIG_VALUE_0=false), so a repository's config cannot make those commands start another program.
git 2.31 or later on your PATHThe band draws in the Claude Code terminal. Claude Code raises the band above the prompt only in the terminal, so in the Desktop app, the VS Code extension's chat panel and claude -p the mod loads but draws nothing. The band steps aside while Claude Code shows its feedback survey.
claude --plugin-dir . # load the working copy, hot-reloads on save
claude plugin test # run the tests
claude plugin validate . --strict
test/capture-fixtures.sh # regenerate git fixtures (needs git and jq)
MIT
hooks/register.js 176 lines1// Repo Pulse: git health in the band above the prompt.
2// This is the only file that calls the mods API. It runs git, keeps the last
3// state, and hands the pure modules their inputs.
4import { NOT_A_REPO, parse, sameState } from './git-state.js'
5import { render } from './band.js'
6import { diff } from './risk.js'
7
8const DEBOUNCE_MS = 500
9const POLL_MS = 30_000
10const SLOW_POLL_MS = 120_000
11const GIT_TIMEOUT_MS = 5_000
12// Turns off core.fsmonitor for our own git commands, so a repository's config
13// cannot make them start another program. Passed as environment (git 2.31+)
14// rather than `-c`, so the command line stays plain fixed text.
15const SAFE_GIT_ENV = { GIT_CONFIG_COUNT: '1', GIT_CONFIG_KEY_0: 'core.fsmonitor', GIT_CONFIG_VALUE_0: 'false' }
16const VERSION_TIMEOUT_MS = 2_000
17// core.fsmonitor=false: a repository's own config must not make git run a program.
18
19/** @type {import('./git-state.js').PulseState | null} */
20let prev = null
21let isSlow = false
22let inFlight = false
23let rerun = false
24let pendingTimer = null
25let pollTimer = null
26let pollMs = 0
27let gitMissingLogged = false
28let parseErrorLogged = false
29/** @type {Map<string, string>} cwd -> absolute git dir */
30const gitDirCache = new Map()
31
32function scheduleRefresh($) {
33 if (pendingTimer) pendingTimer.cancel()
34 pendingTimer = $.clock.after(DEBOUNCE_MS, () => {
35 pendingTimer = null
36 void refresh($)
37 })
38}
39
40function setPoll($, ms) {
41 if (pollTimer && pollMs === ms) return
42 if (pollTimer) pollTimer.cancel()
43 pollMs = ms
44 pollTimer = $.clock.every(ms, () => scheduleRefresh($))
45}
46
47function setSlow($, slow) {
48 if (isSlow === slow) return
49 isSlow = slow
50 setPoll($, slow ? SLOW_POLL_MS : POLL_MS)
51 $.ui.invalidate('ui.render')
52}
53
54async function refresh($) {
55 if (inFlight) {
56 rerun = true
57 return
58 }
59 inFlight = true
60 try {
61 const next = await readState($)
62 if (next) await commit($, next)
63 } catch (err) {
64 if (!parseErrorLogged) {
65 parseErrorLogged = true
66 $.ui.log('could not read git status: ' + (err && err.message ? err.message : String(err)), { to: 'debug' })
67 }
68 } finally {
69 inFlight = false
70 }
71 if (rerun) {
72 rerun = false
73 await refresh($)
74 }
75}
76
77// Resolves the new state, or null to keep the previous one (git was slow).
78async function readState($) {
79 const cwd = await $.session.cwd()
80 const opts = { cwd, env: SAFE_GIT_ENV, timeoutMs: GIT_TIMEOUT_MS }
81 const cachedDir = gitDirCache.get(cwd)
82
83 let status
84 let dirRun
85 try {
86 ;[status, dirRun] = await Promise.all([
87 $.process.run(['git', '--no-optional-locks', 'status', '--porcelain=v2', '--branch'], opts),
88 cachedDir ? null : $.process.run(['git', 'rev-parse', '--absolute-git-dir'], opts),
89 ])
90 } catch {
91 return onRunRejected($, cwd)
92 }
93
94 setSlow($, false)
95 if (status.exitCode !== 0 || (dirRun && dirRun.exitCode !== 0)) return NOT_A_REPO
96
97 const gitDir = cachedDir ?? dirRun.stdout.trim()
98 gitDirCache.set(cwd, gitDir)
99 const [mergeHead, rebaseMerge, rebaseApply] = await Promise.all([
100 $.fs.exists(gitDir + '/MERGE_HEAD'),
101 $.fs.exists(gitDir + '/rebase-merge'),
102 $.fs.exists(gitDir + '/rebase-apply'),
103 ])
104 return parse(status.stdout, { mergeHead, rebaseMerge, rebaseApply })
105}
106
107// A run rejected: either git can't start (missing) or it timed out (slow repo).
108async function onRunRejected($, cwd) {
109 try {
110 await $.process.run(['git', '--version'], { cwd, timeoutMs: VERSION_TIMEOUT_MS })
111 } catch {
112 if (!gitMissingLogged) {
113 gitMissingLogged = true
114 $.ui.log('git was not found, so the band is hidden')
115 }
116 return NOT_A_REPO
117 }
118 setSlow($, true)
119 return null
120}
121
122async function commit($, next) {
123 if (prev && sameState(prev, next)) return
124 const messages = diff(prev, next)
125 prev = next
126 $.ui.invalidate('ui.render')
127 for (const message of messages) await $.ui.toast(message)
128}
129
130export function register(on) {
131 on('session.start', async ($, e, next) => {
132 setPoll($, POLL_MS)
133 scheduleRefresh($)
134 return next(e)
135 }).catch(($, e, next) => next(e))
136
137 on('tool.call', { tool: ['Bash', 'Edit', 'Write', 'MultiEdit', 'NotebookEdit'] }, async ($, e, next) => {
138 try {
139 return await next(e)
140 } finally {
141 try {
142 scheduleRefresh($)
143 } catch {
144 // Never let a refresh problem affect the tool call.
145 }
146 }
147 }).catch(($, e, next) => next(e)) // next is replay-safe in .catch: the tool never runs twice
148
149 on('prompt.submit', async ($, e, next) => {
150 scheduleRefresh($)
151 return next(e)
152 }).catch(($, e, next) => next(e))
153
154 on('turn.complete', async ($, e, next) => {
155 scheduleRefresh($)
156 return next(e)
157 }).catch(($, e, next) => next(e))
158
159 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
160 // A survey holds the band while it is up; step aside for it.
161 if (e.props.hasSurvey) return next(e)
162 const row = prev ? render(prev, e.props.bodyColumns) : null
163 if (!row) return next(e)
164
165 const { Box, Text } = $.ui.resolve(e)
166 const textProps = isSlow ? { dimColor: true } : {}
167 const children = [Text({ ...textProps, wrap: 'truncate-end', children: [row.text] })]
168 if (row.badge) children.push(Text({ color: 'warning', bold: true, children: [row.badge] }))
169 const ours = Box({ key: 'repo-pulse', flexDirection: 'row', columnGap: 1, children })
170
171 // Keep what the mods after this one draw in the band.
172 const theirs = await next(e)
173 return Box({ flexDirection: 'column', children: theirs ? [ours, theirs] : [ours] })
174 }).catch(($, e, next) => next(e))
175}
176hooks/git-state.js 67 lines1// Turns `git status --porcelain=v2 --branch` output into a PulseState.
2// Pure: no I/O. The caller supplies which in-progress marker files exist.
3
4/**
5 * @typedef {{ isRepo: boolean, branch: string | null, sha: string | null,
6 * dirty: number, hasUpstream: boolean, ahead: number, behind: number,
7 * conflicts: number, op: null | 'merge' | 'rebase' }} PulseState
8 */
9
10/** @type {PulseState} */
11export const NOT_A_REPO = Object.freeze({
12 isRepo: false, branch: null, sha: null, dirty: 0,
13 hasUpstream: false, ahead: 0, behind: 0, conflicts: 0, op: null,
14})
15
16const FIELDS = Object.keys(NOT_A_REPO)
17
18/**
19 * @param {string} statusOutput
20 * @param {{ mergeHead: boolean, rebaseMerge: boolean, rebaseApply: boolean }} markers
21 * @returns {PulseState}
22 */
23export function parse(statusOutput, markers) {
24 const state = { ...NOT_A_REPO, isRepo: true }
25 let sawHead = false
26
27 for (const line of statusOutput.split('\n')) {
28 if (line === '') continue
29 if (line.startsWith('# branch.oid ')) {
30 const oid = line.slice('# branch.oid '.length)
31 state.sha = oid === '(initial)' ? null : oid.slice(0, 7)
32 } else if (line.startsWith('# branch.head ')) {
33 const head = line.slice('# branch.head '.length)
34 state.branch = head === '(detached)' ? null : head
35 sawHead = true
36 } else if (line.startsWith('# branch.upstream ')) {
37 state.hasUpstream = true
38 } else if (line.startsWith('# branch.ab ')) {
39 const m = /^# branch\.ab \+(\d+) -(\d+)$/.exec(line)
40 if (!m) throw new Error('unrecognised branch.ab line: ' + line)
41 state.ahead = Number(m[1])
42 state.behind = Number(m[2])
43 } else if (line.startsWith('# ') || line.startsWith('! ')) {
44 // Other headers (such as # stash) and ignored files don't affect the band.
45 } else if (line.startsWith('u ')) {
46 state.dirty += 1
47 state.conflicts += 1
48 } else if (line.startsWith('1 ') || line.startsWith('2 ') || line.startsWith('? ')) {
49 state.dirty += 1
50 } else {
51 throw new Error('unrecognised status line: ' + line)
52 }
53 }
54
55 if (!sawHead) throw new Error('git status output has no branch.head line')
56 state.op = markers.rebaseMerge || markers.rebaseApply ? 'rebase' : markers.mergeHead ? 'merge' : null
57 return state
58}
59
60/**
61 * @param {PulseState} a
62 * @param {PulseState} b
63 */
64export function sameState(a, b) {
65 return FIELDS.every((k) => a[k] === b[k])
66}
67hooks/band.js 62 lines1// Turns a PulseState into the band's one-row text and risk badge. Pure.
2
3const SEP = ' · '
4
5// Control characters and bidirectional overrides could make a branch name
6// draw as something else, so they never reach the band.
7const UNSAFE = /[\u0000-\u001F\u007F-\u009F\u200E\u200F\u202A-\u202E\u2066-\u2069]/g
8
9/**
10 * @param {import('./git-state.js').PulseState} state
11 * @returns {string | null}
12 */
13export function badgeFor(state) {
14 if (state.conflicts > 0) return '⚠ CONFLICT'
15 if (state.op === 'rebase') return '⚠ REBASE'
16 if (state.op === 'merge') return '⚠ MERGE'
17 if (state.branch === null) return '⚠ DETACHED'
18 return null
19}
20
21/**
22 * Shortens `s` to at most `max` characters by replacing its middle with `…`.
23 * Never returns less than `…`.
24 * @param {string} s
25 * @param {number} max
26 */
27export function truncateMiddle(s, max) {
28 if (s.length <= max) return s
29 if (max <= 1) return '…'
30 const keep = max - 1
31 const head = Math.ceil(keep / 2)
32 const tail = keep - head
33 return s.slice(0, head) + '…' + (tail > 0 ? s.slice(-tail) : '')
34}
35
36/**
37 * @param {import('./git-state.js').PulseState} state
38 * @param {number} columns width the band row may use
39 * @returns {{ text: string, badge: string | null } | null}
40 */
41export function render(state, columns) {
42 if (!state.isRepo) return null
43
44 const badge = badgeFor(state)
45 const label = (state.branch ?? '').replace(UNSAFE, '') || 'HEAD@' + (state.sha ?? '?')
46
47 const segments = []
48 if (state.dirty > 0) segments.push('±' + state.dirty)
49 if (state.hasUpstream) {
50 const arrows = []
51 if (state.ahead > 0) arrows.push('↑' + state.ahead)
52 if (state.behind > 0) arrows.push('↓' + state.behind)
53 if (arrows.length > 0) segments.push(arrows.join(' '))
54 }
55 const tail = segments.map((s) => SEP + s).join('')
56
57 // The badge is drawn after the text with a one-column gap.
58 const reserved = tail.length + (badge ? badge.length + 1 : 0)
59 const room = Math.max(1, columns - reserved)
60 return { text: truncateMiddle(label, room) + tail, badge }
61}
62hooks/risk.js 30 lines1// Decides which toasts to show when the repository state changes. Pure.
2// A toast fires only when a risky state appears, not while it lasts.
3
4/** @type {import('./git-state.js').PulseState} */
5const CLEAN = Object.freeze({
6 isRepo: true, branch: '', sha: null, dirty: 0,
7 hasUpstream: false, ahead: 0, behind: 0, conflicts: 0, op: null,
8})
9
10/**
11 * @param {import('./git-state.js').PulseState | null} prev null on the first refresh
12 * @param {import('./git-state.js').PulseState} next
13 * @returns {string[]}
14 */
15export function diff(prev, next) {
16 if (!next.isRepo) return []
17 const before = prev && prev.isRepo ? prev : CLEAN
18 const messages = []
19
20 if (before.conflicts === 0 && next.conflicts > 0) {
21 messages.push('Merge conflict in ' + next.conflicts + ' file(s)')
22 }
23 if (before.op !== 'merge' && next.op === 'merge') messages.push('Merge in progress')
24 if (before.op !== 'rebase' && next.op === 'rebase') messages.push('Rebase in progress')
25 if (before.branch !== null && next.branch === null && next.op !== 'rebase') {
26 messages.push('Detached HEAD at ' + (next.sha ?? 'unknown commit'))
27 }
28 return messages
29}
30