A small git-flow graph of main, develop and your branch, with what is waiting to release

Little mods for Claude Code: panels that keep the stuff you'd normally go check next to the chat, and helpers that do the chores for you.
You spend all day in Claude Code. But the small stuff keeps pulling you out of it: checking git, rescuing a session that got too long, doing the same cleanup by hand.
These mods put that stuff inside Claude Code. Some draw a live panel next to the chat. Some do a chore for you at the right moment. You keep your eyes on the work.
Each one installs on its own, so you take only what you want.
Add the marketplace once, then install a mod:
/plugin marketplace add spardutti/claude-mods
/plugin install branch-status@spardutti-mods
/plugin install auto-handoff@spardutti-mods
/plugin install handoff-pickup@spardutti-mods
/plugin install blocked-run@spardutti-mods
Install any you like. Each one starts working on the next session start.
Each mod has its own page with the full story.
| Mod | What it does |
|---|---|
| branch-status | A panel with your branch, develop and main as a tiny git-flow graph, so you see what's waiting to ship without running git log. |
| auto-handoff | When the context gets too full, it warns you, counts down, then hands the work to a fresh session. |
| handoff-pickup | Reminds you of a handoff you left behind. /pickup picks it up, /pickup done deletes it. |
| blocked-run | When auto mode blocks a command, it shows above the prompt with a button to run it yourself. |
/plugin marketplace update spardutti-mods
/plugin update branch-status@spardutti-mods
/plugin update auto-handoff@spardutti-mods
/plugin update handoff-pickup@spardutti-mods
/plugin update blocked-run@spardutti-mods
/reload-pluginshooks/register.tsx 108 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Snapshot } from '../types'
5import { drawLanes, shortSubject } from './graph'
6import type { Lane } from './graph'
7import { BASE, RELEASE, collect } from './snapshot'
8
9const PANE = 'branch-status'
10const PANE_COLUMNS = 56
11const snapshot = atom({ plugin: 'branch-status', key: 'snapshot' } as const, { branch: '' } as Snapshot)
12
13let isRefreshing = false
14
15async function refresh($: EngineInterface) {
16 if (isRefreshing) return
17 isRefreshing = true
18 try {
19 const next = await collect(async args => {
20 const { exitCode, stdout } = await $.process.run(['git', ...args])
21 return exitCode === 0 ? stdout : undefined
22 })
23 await update($, snapshot, () => next)
24 } finally {
25 isRefreshing = false
26 }
27}
28
29const commitCount = (n: number) => `${n} commit${n === 1 ? '' : 's'}`
30
31function releaseLane(release: NonNullable<Snapshot['release']>, branch: string): Lane {
32 return {
33 name: RELEASE,
34 color: 'yellow',
35 depth: 1,
36 note: release.merges.length ? { text: `${release.merges.length} to release`, color: 'yellow' } : { text: 'all released', dimColor: true },
37 commits: release.merges.map(shortSubject),
38 warning: release.behind ? `${release.behind} on ${BASE}, not on ${RELEASE}` : undefined,
39 isYou: branch === RELEASE,
40 }
41}
42
43function workLane(branch: string, work: NonNullable<Snapshot['work']>, depth: number): Lane {
44 const lane = { name: branch, color: 'blue', depth, isYou: true }
45 if (work.isMerged) return { ...lane, note: { text: `merged into ${work.parent}`, color: 'green' }, hint: `switch to ${work.parent} and pull` }
46 return {
47 ...lane,
48 note: { text: work.ahead ? commitCount(work.ahead) : 'no commits yet', dimColor: true },
49 commits: work.commits,
50 total: work.ahead,
51 warning: work.behind ? `${work.parent} has ${work.behind} new, pull it` : undefined,
52 }
53}
54
55function lanesOf({ branch, release, work, others = [] }: Snapshot): Lane[] {
56 const depth = release ? 2 : 1
57 const open = others.map(o => ({ name: o.name, color: 'magenta', depth, note: { text: commitCount(o.ahead), dimColor: true }, commits: o.commits, total: o.ahead }))
58 return [
59 { name: BASE, color: 'green', depth: 0, isYou: branch === BASE },
60 ...(release ? [releaseLane(release, branch)] : []),
61 ...(work ? [workLane(branch, work, depth)] : []),
62 ...open,
63 ]
64}
65
66export const register: Register = on => {
67 on('session.start', async ($, e, next) => {
68 await $.command.register({ name: 'branch-status', description: 'Show where this branch stands against main' })
69 void $.ui.open({ id: PANE, title: 'Git', columns: PANE_COLUMNS })
70 void refresh($)
71 $.clock.every(30_000, () => void refresh($))
72
73 return next(e)
74 })
75
76 on('command.run', { command: 'branch-status' }, async $ => {
77 await $.ui.open({ id: PANE, title: 'Git', columns: PANE_COLUMNS })
78 await refresh($)
79
80 return { text: 'Git panel opened.' }
81 })
82
83 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
84 const ran = await next(e)
85 if (/\b(git|gh)\s/.test(e.command)) void refresh($)
86
87 return ran
88 })
89
90 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
91 const { Box, Text } = $.ui.resolve(e)
92 const state = await read($, snapshot)
93 if (state.error) return <Text dimColor>{state.error}</Text>
94 if (!state.hasBase) return <Text dimColor>No {BASE} branch here.</Text>
95
96 return (
97 <Box flexDirection="column">
98 {drawLanes(lanesOf(state)).map(({ lead, tail }) => (
99 <Box>
100 <Box flexShrink={0}><Text>{lead.map(({ text, ...style }) => <Text {...style}>{text}</Text>)}</Text></Box>
101 <Box flexShrink={1}><Text>{tail.map(({ text, ...style }) => <Text {...style}>{text}</Text>)}</Text></Box>
102 </Box>
103 ))}
104 </Box>
105 )
106 })
107}
108hooks/graph.ts 85 lines1export type Segment = { text: string; color?: string; bold?: boolean; dimColor?: boolean }
2
3export type Row = { lead: Segment[]; tail: Segment[] }
4
5export type Lane = {
6 name: string
7 color: string
8 depth: number
9 note?: Segment
10 commits?: string[]
11 total?: number
12 hint?: string
13 warning?: string
14 isYou?: boolean
15}
16
17const STEP = ' '
18const MAX_COMMITS = 5
19
20function laterSibling(lanes: Lane[], i: number): Lane | undefined {
21 const depth = lanes[i]?.depth ?? 0
22 for (const lane of lanes.slice(i + 1)) {
23 if (lane.depth < depth) return undefined
24 if (lane.depth === depth) return lane
25 }
26 return undefined
27}
28
29function ancestorIndex(lanes: Lane[], i: number, depth: number) {
30 let j = i
31 while (j > 0 && (lanes[j]?.depth ?? 0) > depth) j -= 1
32 return j
33}
34
35// One column per level: a line runs down where that level still has a branch to come.
36function rail(lanes: Lane[], i: number, levels: number): Segment[] {
37 return Array.from({ length: levels }, (_, k) => {
38 const sibling = laterSibling(lanes, ancestorIndex(lanes, i, k + 1))
39 return sibling ? { text: '│ ', color: sibling.color } : { text: STEP }
40 })
41}
42
43function nodeLead(lanes: Lane[], i: number, lane: Lane): Segment[] {
44 if (lane.depth === 0) return [{ text: '● ', color: lane.color }]
45 const fork = laterSibling(lanes, i) ? '├─● ' : '└─● '
46 return [...rail(lanes, i, lane.depth - 1), { text: fork, color: lane.color }]
47}
48
49function notesLead(lanes: Lane[], i: number, lane: Lane): Segment[] {
50 const child = lanes[i + 1]?.depth === lane.depth + 1 ? lanes[i + 1] : undefined
51 return [...rail(lanes, i, lane.depth), child ? { text: '│ ', color: child.color } : { text: STEP }]
52}
53
54function commitRows(lead: Segment[], lane: Lane): Row[] {
55 const commits = lane.commits ?? []
56 const more = (lane.total ?? commits.length) - MAX_COMMITS
57 const rows = commits.slice(0, MAX_COMMITS).map(text => ({ lead: [...lead, { text: '· ', dimColor: true }], tail: [{ text }] }))
58 return more > 0 ? [...rows, { lead, tail: [{ text: `+${more} more`, dimColor: true }] }] : rows
59}
60
61function laneRows(lanes: Lane[], i: number, lane: Lane): Row[] {
62 const name = { text: lane.name, color: lane.color, bold: lane.isYou }
63 const node = { lead: nodeLead(lanes, i, lane), tail: lane.isYou ? [name, { text: ' ← you', bold: true }] : [name] }
64 const lead = notesLead(lanes, i, lane)
65 const line = (segment?: Segment) => (segment ? [{ lead, tail: [segment] }] : [])
66 return [
67 node,
68 ...line(lane.note),
69 ...commitRows(lead, lane),
70 ...line(lane.hint ? { text: lane.hint, dimColor: true } : undefined),
71 ...line(lane.warning ? { text: lane.warning, color: 'red' } : undefined),
72 ]
73}
74
75// Lanes come in tree order, each with its depth; notes sit beneath their lane, so depth costs little width.
76export function drawLanes(lanes: Lane[]): Row[] {
77 return lanes.flatMap((lane, i) => laneRows(lanes, i, lane))
78}
79
80export function shortSubject(subject: string) {
81 const pr = subject.match(/^Merge pull request (#\d+) from [^/]+\/(.+)$/)
82 if (pr) return `${pr[1]} ${pr[2]}`
83 return subject.match(/^Merge branch '([^']+)'/)?.[1] ?? subject
84}
85hooks/snapshot.ts 90 lines1import type { OpenBranch, Snapshot } from '../types'
2import { openCandidates, parseBranches } from './branches'
3import { parseCounts } from './counts'
4
5export const BASE = 'main'
6export const RELEASE = 'develop'
7const MAX_COMMITS = 5
8
9export type Git = (args: string[]) => Promise<string | undefined>
10
11async function hasRef(git: Git, ref: string) {
12 return (await git(['rev-parse', '--verify', '-q', ref])) !== undefined
13}
14
15async function drift(git: Git, from: string, to: string) {
16 return parseCounts((await git(['rev-list', '--no-merges', '--left-right', '--count', `${from}...${to}`])) ?? '') ?? { ahead: 0, behind: 0 }
17}
18
19async function commitsOf(git: Git, from: string, to: string) {
20 return ((await git(['log', '--no-merges', '--format=%s', '-n', String(MAX_COMMITS), `${from}..${to}`])) ?? '').split('\n').filter(Boolean)
21}
22
23// A merge whose side branch holds no commits missing from base (a sync from main) has nothing to release.
24async function bringsWork(git: Git, base: string, hash: string) {
25 return (await git(['rev-list', '--no-merges', '--count', `${base}..${hash}^2`]))?.trim() !== '0'
26}
27
28async function unreleased(git: Git, base: string, ref: string) {
29 const lines = ((await git(['log', '--first-parent', '--format=%H %s', `${base}..${ref}`])) ?? '').split('\n').filter(Boolean)
30 const kept: string[] = []
31 for (const line of lines) {
32 const [hash = '', ...subject] = line.split(' ')
33 if (await bringsWork(git, base, hash)) kept.push(subject.join(' '))
34 }
35 return kept
36}
37
38// Branches other than yours are read from origin, since local copies of them go stale.
39async function refOf(git: Git, name: string, branch: string) {
40 return name !== branch && (await hasRef(git, `origin/${name}`)) ? `origin/${name}` : name
41}
42
43// Commits whose change is not in ref yet; --cherry-pick also drops ones squash-merged there.
44async function missingFrom(git: Git, ref: string, name: string) {
45 const lines = ((await git(['log', '--no-merges', '--cherry-pick', '--right-only', '--format=%H %s', `${ref}...${name}`])) ?? '').split('\n')
46 return lines.filter(Boolean).map(line => ({ hash: line.slice(0, line.indexOf(' ')), subject: line.slice(line.indexOf(' ') + 1) }))
47}
48
49// A branch merged straight into base, skipping the parent, is done too.
50async function unmergedSubjects(git: Git, refs: { parent: string; base: string }, name: string) {
51 const inBase = new Set((await missingFrom(git, refs.base, name)).map(c => c.hash))
52 return (await missingFrom(git, refs.parent, name)).filter(c => inBase.has(c.hash)).map(c => c.subject)
53}
54
55async function openBranches(git: Git, refs: { parent: string; base: string }, names: string[]) {
56 const open: OpenBranch[] = []
57 for (const name of names) {
58 const subjects = await unmergedSubjects(git, refs, name)
59 if (subjects.length) open.push({ name, ahead: subjects.length, commits: subjects.slice(0, MAX_COMMITS) })
60 }
61 return open
62}
63
64type Parent = { name: string; ref: string }
65
66async function releaseOf(git: Git, base: string, ref: string) {
67 return { ...(await drift(git, base, ref)), merges: await unreleased(git, base, ref) }
68}
69
70async function workOf(git: Git, parent: Parent, isMerged: boolean) {
71 return { parent: parent.name, isMerged, ...(await drift(git, parent.ref, 'HEAD')), commits: await commitsOf(git, parent.ref, 'HEAD') }
72}
73
74export async function collect(git: Git): Promise<Snapshot> {
75 const branch = (await git(['rev-parse', '--abbrev-ref', 'HEAD']))?.trim()
76 if (!branch) return { branch: '', error: 'Not a git repository.' }
77 if (!(await hasRef(git, BASE))) return { branch, hasBase: false }
78
79 const base = await refOf(git, BASE, branch)
80 const hasRelease = await hasRef(git, RELEASE)
81 const parent = hasRelease ? { name: RELEASE, ref: await refOf(git, RELEASE, branch) } : { name: BASE, ref: base }
82 const release = hasRelease ? await releaseOf(git, base, parent.ref) : undefined
83 const locals = parseBranches((await git(['for-each-ref', '--format=%(refname:short)%09%(upstream:track)', 'refs/heads'])) ?? '')
84 const isLongLived = branch === BASE || branch === RELEASE
85 const work = isLongLived ? undefined : await workOf(git, parent, locals.find(b => b.name === branch)?.isGone ?? false)
86 const others = await openBranches(git, { parent: parent.ref, base }, openCandidates(locals, branch, [BASE, RELEASE]))
87
88 return { branch, hasBase: true, release, work, others }
89}
90hooks/branches.ts 20 lines1export type LocalBranch = { name: string; isGone: boolean }
2
3// Reads `git for-each-ref --format=%(refname:short)%09%(upstream:track) refs/heads`.
4export function parseBranches(stdout: string): LocalBranch[] {
5 return stdout
6 .split('\n')
7 .filter(Boolean)
8 .map(line => {
9 const [name = '', track = ''] = line.split('\t')
10 return { name, isGone: track === '[gone]' }
11 })
12}
13
14// A branch whose upstream is gone was deleted on origin, almost always after its PR merged.
15export function openCandidates(branches: LocalBranch[], current: string, longLived: string[]): string[] {
16 return branches
17 .filter(b => !b.isGone && b.name !== current && !longLived.includes(b.name) && !b.name.startsWith('release/'))
18 .map(b => b.name)
19}
20hooks/counts.ts 5 lines1export function parseCounts(stdout: string): { behind: number; ahead: number } | undefined {
2 const [behind = NaN, ahead = NaN] = stdout.trim().split(/\s+/).map(Number)
3 return Number.isFinite(behind) && Number.isFinite(ahead) ? { behind, ahead } : undefined
4}
5types/index.d.ts 19 lines1export type Drift = { ahead: number; behind: number }
2
3export type OpenBranch = { name: string; ahead: number; commits: string[] }
4
5export type Snapshot = {
6 branch: string
7 hasBase?: boolean
8 release?: Drift & { merges: string[] }
9 work?: Drift & { parent: string; commits: string[]; isMerged: boolean }
10 others?: OpenBranch[]
11 error?: string
12}
13
14declare module 'claude-code' {
15 interface PluginState {
16 'branch-status': { snapshot: Snapshot }
17 }
18}
19