A band above the prompt with Commit / Push / Open PR buttons when the repo has unshipped work

Personal dotfiles for sharing setups and configs between machines.
Feel free to steal or use as you like.. Let me know if I'm missing any good tricks!
hooks/register.tsx 125 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { RepoState } from '../types'
5
6const repo = atom({ plugin: 'ship', key: 'repo' } as const, null)
7const DEFAULT_BRANCHES = new Set(['main', 'master', 'dev'])
8
9let dir = ''
10let sessionCwd = ''
11let isBusy = false
12let prBranch: string | undefined
13let hasPr = false
14
15// Claude often runs `cd other/repo && git ...`; follow it there.
16async function repoDir($: EngineInterface, command: string, fallback: string) {
17 const m = command.match(/(?:^|&&|;)\s*cd\s+("[^"]+"|'[^']+'|[^\s;&]+)/) ?? command.match(/\bgit\s+-C\s+("[^"]+"|'[^']+'|\S+)/)
18 if (!m?.[1]) return fallback
19 let p = m[1].replace(/^['"]|['"]$/g, '')
20 if (p.startsWith('~')) p = ((await $.env.get('HOME')) ?? '') + p.slice(1)
21 return p.startsWith('/') ? p : `${fallback}/${p}`
22}
23
24// Labels the repo when it isn't the session's own folder, so you can tell what you're looking at.
25async function repoName($: EngineInterface) {
26 if (dir === sessionCwd) return ''
27 const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd: dir })
28 return top.stdout.trim().split('/').pop() ?? ''
29}
30
31async function refresh($: EngineInterface, isPrStale = false) {
32 if (isBusy || !dir) return
33 isBusy = true
34 try {
35 const st = await $.process.run(['git', 'status', '--porcelain=v2', '--branch'], { cwd: dir })
36 if (st.exitCode !== 0) {
37 await update($, repo, () => null)
38 return
39 }
40 const next: RepoState = { name: await repoName($), branch: '', changed: 0, ahead: 0, hasUpstream: false, hasPr: false }
41 for (const line of st.stdout.split('\n')) {
42 if (line.startsWith('# branch.head ')) next.branch = line.slice(14)
43 else if (line.startsWith('# branch.upstream ')) next.hasUpstream = true
44 else if (line.startsWith('# branch.ab ')) next.ahead = Number(line.slice(12).split(' ')[0])
45 else if (line && !line.startsWith('#')) next.changed++
46 }
47 if (isPrStale || prBranch !== next.branch) {
48 const view = await $.process.run(['gh', 'pr', 'view', '--json', 'state', '-q', '.state'], { cwd: dir, timeoutMs: 10_000 }).catch(() => undefined)
49 hasPr = view?.exitCode === 0 && view.stdout.trim() === 'OPEN'
50 prBranch = next.branch
51 }
52 next.hasPr = hasPr
53 await update($, repo, prev => (JSON.stringify(prev) === JSON.stringify(next) ? prev : next))
54 } finally {
55 isBusy = false
56 }
57}
58
59// Submitted as the user's own words, so a "never commit unless asked" rule is still honoured.
60function ship($: EngineInterface, text: string) {
61 return $.prompt.submit({ text, asUser: true })
62}
63
64export const register: Register = on => {
65 on('session.start', async ($, e, next) => {
66 const started = await next(e)
67 sessionCwd = await $.session.cwd()
68 dir = sessionCwd
69 void refresh($)
70 $.clock.every(15_000, () => void refresh($))
71 return started
72 })
73
74 on('tool.call', async ($, e, next) => {
75 const ran = await next(e)
76 if (e.tool === 'Bash' && /\b(git|gh)\b/.test(e.command)) {
77 dir = await repoDir($, e.command, await $.session.cwd())
78 void refresh($, /\bgh\s+pr\b/.test(e.command))
79 } else if (e.tool === 'Edit' || e.tool === 'Write') {
80 void refresh($)
81 }
82 return ran
83 }).catch(($, e, next) => next(e))
84
85 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
86 const r = await read($, repo)
87 const unpushed = r ? r.ahead > 0 || !r.hasUpstream : false
88 const canOpenPr = r ? !r.hasPr && !DEFAULT_BRANCHES.has(r.branch) && r.branch !== '(detached)' : false
89 if (!r || e.props.hasSurvey || e.props.isWorking || (r.changed === 0 && !unpushed)) return next(e)
90
91 const { Box, Button, Text } = $.ui.resolve(e)
92 const summary = [
93 r.changed ? `${r.changed} changed` : '',
94 r.ahead ? `${r.ahead} unpushed` : !r.hasUpstream ? 'branch not pushed' : '',
95 ].filter(Boolean).join(' · ')
96 const below = await next(e)
97
98 return (
99 <Box flexDirection="column">
100 <Box>
101 <Text dimColor>
102 {r.name ? `${r.name} ` : ''}
103 {r.branch}: {summary}{' '}
104 </Text>
105 {r.changed > 0 && <Button key="ship-commit" label="Commit" onPress={() => ship($, 'please commit')} />}
106 <Button
107 key="ship-push"
108 label={r.changed > 0 ? 'Commit + push' : 'Push'}
109 variant="primary"
110 onPress={() => ship($, r.changed > 0 ? 'please commit and push' : 'please push')}
111 />
112 {canOpenPr && (
113 <Button
114 key="ship-pr"
115 label="Open PR"
116 onPress={() => ship($, r.changed > 0 ? 'please commit to this branch, push and open a PR' : 'please push and open a PR')}
117 />
118 )}
119 </Box>
120 {below}
121 </Box>
122 )
123 })
124}
125types/index.d.ts 8 lines1export type RepoState = { name: string; branch: string; changed: number; ahead: number; hasUpstream: boolean; hasPr: boolean }
2
3declare module 'claude-code' {
4 interface PluginState {
5 ship: { repo: RepoState | null }
6 }
7}
8