SLOPSHOPPER

repo-pulse

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.

newbandguardtoastpromptprocess
v0.1.0MITupdated 2026-10-045d0tal1gat0r/repo-pulse
A shopper browsing a rack in a slop shop
README

Repo Pulse

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.

What the band shows

PartMeaning
mainCurrent branch, or HEAD@abc1234 when HEAD is detached
±3Changed, staged and untracked files (hidden when 0)
↑1 ↓2Commits ahead of and behind the upstream, as of your last fetch (hidden without an upstream)
⚠ CONFLICT, ⚠ REBASE, ⚠ MERGE, ⚠ DETACHEDRisk badge

Outside a git repository the band shows nothing.

What it runs, and what it does not do

Repo Pulse runs these commands locally, in the session's working directory:

  • git --no-optional-locks status --porcelain=v2 --branch
  • git rev-parse --absolute-git-dir
  • git --version, only when a git command fails, to tell a missing git from a slow repository

It 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.

Requirements

  • Claude Code v2.1.287 or later (mods), tested with v2.1.287
  • git 2.31 or later on your PATH

The 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.

Development

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)

License

MIT

Source 4 files
hooks/register.js 176 lines
1// 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}
176
hooks/git-state.js 67 lines
1// 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}
67
hooks/band.js 62 lines
1// 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}
62
hooks/risk.js 30 lines
1// 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