Repo status row above the prompt and a /ship pane whose buttons fill ready-to-run git and gh commands — you press Enter.

A Claude Code mod that shows the state of your git repo above the prompt. A /ship pane has buttons that fill ready-to-run git and gh commands into the prompt box. The buttons only fill; nothing runs until you press Enter. You read the command, edit it if you like, and press Enter. Git Ship then runs the line itself in Git Bash from the repo root, and Claude never sees it.
claude plugin marketplace add hearsawho25/roberts-mods
claude plugin install git-ship@roberts-mods
Inside a git repo, one line sits in the band above the prompt:
◆ Ship main · 3 changed · ↑2 ↓0 [ Open ]
no upstream.detached <sha>.· N conflicts.◆ Ship git unavailable.The row refreshes on these occasions:
!git or !gh line for you! command you run, and once more 1.5 seconds laterIt never polls on a timer. Every git call it makes is read-only and times out after 5 seconds. None of them hold up a reply.
Open it with the row's [ Open ], with /ship, or from Robert's Toolbox. It shows:
| Button | Fills |
|---|---|
| Stage all | !git add -A |
| Draft commit | !git commit -m "<subject>" -m "<body>". The message is written by a side Sonnet call from the diff and your last 5 commit subjects, and ends with a Co-Authored-By: Claude line. If nothing is staged, the command starts with git add -A && . |
| Push | !git push, or !git push -u origin "<branch>" the first time |
| Pull | !git pull |
| New branch | !git switch -c (type the name) |
| PR | !gh pr create --title "…" --body "…", drafted from the branch's commits. If the branch already has an open PR, it fills !gh pr view --web instead. |
| Close | closes the pane |
git add <file> yourself.A plugin can't put the prompt box into shell mode, so a filled !git push would otherwise go to Claude as an ordinary message. Git Ship catches it instead:
!git or !gh , inside a repo, is dropped before it reaches Claude, and Git Ship runs it exactly as you left it, edits included.$(…) inside quoted messages stay as written. GIT_TERMINAL_PROMPT=0, GH_PROMPT_DISABLED=1 and GIT_EDITOR=false mean nothing waits for input or opens an editor.CLAUDE_CODE_GIT_BASH_PATH if that's set. Otherwise it's found from git --exec-path (on Windows), or plain bash elsewhere. If it can't be found, the line is dropped with a message and nothing runs.running <command>….done or failed (exit N), or did not finish — it may still be running after the limit. The pane shows the command, the last 30 lines of output (plain text, capped), and the exit code. A failure opens the pane. Git state is read again afterwards, and once more 30 seconds later if the run didn't finish.Messages are quoted for Git Bash, which is the shell these lines run in.
See docs/DESIGN.md.
hooks/register.tsx 884 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, ModelCompleteResult, ProcessRunResult, Register, Timer } from 'claude-code'
3
4import {
5 branchCommand,
6 buttonStates,
7 capDiff,
8 commandHeadline,
9 commandLabel,
10 COMMIT_TRAILER,
11 commitCommand,
12 commitDraftPrompt,
13 gitBashFromExecPath,
14 interceptedCommand,
15 isWindowsPath,
16 isWslBash,
17 newFilesText,
18 parseDefaultBranch,
19 parseDraft,
20 parsePrView,
21 parseStatus,
22 PR_FOOTER,
23 prCreateCommand,
24 prDraftPrompt,
25 prViewCommand,
26 pullCommand,
27 pushCommand,
28 rowText,
29 runOutput,
30 runStatusText,
31 stageCommand,
32 withTrailer,
33} from './core'
34import type { ButtonId, RepoState } from './core'
35import type { GitShipRunning } from '../types'
36
37/**
38 * Git Ship: a repo-status row above the prompt, the `/ship` pane, and buttons that
39 * fill ready-to-run `!git …` / `!gh …` commands into the prompt box (never sent).
40 * Pure logic lives in core.ts; this file holds every hook, every function that
41 * takes `$` and every atom (the validator follows `$` and atoms only within the
42 * file that holds the hooks).
43 */
44
45type Engine = EngineInterface
46
47/** Robert's Toolbox palette. */
48const COLORS = {
49 green: '#3DD68C',
50 muted: '#7D8590',
51 red: '#F85149',
52 text: '#E6EDF3',
53} as const
54
55const repoAtom = atom({ plugin: 'git-ship', key: 'repo' } as const, null)
56const paneOpenAtom = atom({ plugin: 'git-ship', key: 'paneOpen' } as const, false)
57const draftingAtom = atom({ plugin: 'git-ship', key: 'drafting' } as const, false)
58const hasOpenPrAtom = atom({ plugin: 'git-ship', key: 'hasOpenPr' } as const, null)
59const runningAtom = atom({ plugin: 'git-ship', key: 'running' } as const, null)
60const lastRunAtom = atom({ plugin: 'git-ship', key: 'lastRun' } as const, null)
61
62const PANE = 'git-ship'
63const ROW_TITLE = '◆ Ship'
64const GIT_TIMEOUT_MS = 5_000
65/** The second read after a `!` command, for one still running when its prompt was handled. */
66const SHELL_BACKSTOP_MS = 1_500
67/** gh never asks a question or checks for updates: it runs unattended. */
68const GH_ENV = { GH_PROMPT_DISABLED: '1', GH_NO_UPDATE_NOTIFIER: '1' }
69const MAX_FILES = 10
70const CONFLICT_TEXT = 'resolve conflicts, then git add <file> yourself'
71const DRAFT_FAILED = 'Git Ship: the draft failed — fill in the message yourself.'
72const DIFF_UNREAD = 'Git Ship: could not read the diff — fill in the message yourself.'
73const NOTHING_STAGED = ' Nothing is staged yet.'
74/** Untracked names listed for a draft of everything (git diff leaves them out). */
75const MAX_UNTRACKED = 20
76/** New files whose text a commit draft reads; the rest it names only. */
77const MAX_NEW_FILES = 30
78/** Drafts are a cheap, standalone completion over the diff. */
79const DRAFT_MODEL = 'sonnet'
80const DRAFT_MAX_TOKENS = 1024
81/** A draft still running after this resolves `aborted`: the failure path. */
82const DRAFT_TIMEOUT_MS = 60_000
83/** `git diff` for a draft: plain text whatever the config (no colour, no external differ), paths as written. */
84const DIFF_ARGS = ['-c', 'core.quotePath=false', 'diff', '--no-color', '--no-ext-diff'] as const
85/** How long after a drop the box is checked for the line that ran. */
86const CLEAR_AFTER_MS = 300
87/** A line Git Ship runs when Robert presses Enter on it may take this long. */
88const SHELL_TIMEOUT_MS = 300_000
89/** The run slot frees itself this long after a run started, whatever became of it. */
90const RUN_WATCHDOG_MS = SHELL_TIMEOUT_MS + 10_000
91/** One more read this long after a run that did not finish (it may still be running). */
92const UNFINISHED_REREAD_MS = 30_000
93/** Nothing a run starts may stop to ask for input or open an editor: there is no terminal to answer it. */
94const SHELL_ENV = { GIT_TERMINAL_PROMPT: '0', GH_PROMPT_DISABLED: '1', GIT_EDITOR: 'false', GIT_SEQUENCE_EDITOR: 'false' }
95/**
96 * The script every run gets. The line itself travels in GIT_SHIP_COMMAND, never in argv: on
97 * Windows argv is flattened into one command line that MSYS parses again, collapsing
98 * backslashes and running a quoted `\$(…)`.
99 */
100const SHELL_SCRIPT = 'eval "$GIT_SHIP_COMMAND"'
101const NO_GIT_BASH = 'Git Ship: could not find Git Bash — run it yourself.'
102const COULD_NOT_RUN = 'Git Ship could not run it — nothing was sent'
103const COULD_NOT_READ = 'Git Ship: could not read git here — nothing was sent'
104/** How long an intercepted line waits for a git read, well inside the hook's 10 s budget. */
105const LINE_READ_WAIT_MS = 6_000
106
107/** The pane's buttons, in order. */
108const BUTTONS: ReadonlyArray<{ id: ButtonId; label: string }> = [
109 { id: 'stage', label: 'Stage all' },
110 { id: 'commit', label: 'Draft commit' },
111 { id: 'push', label: 'Push' },
112 { id: 'pull', label: 'Pull' },
113 { id: 'branch', label: 'New branch' },
114 { id: 'pr', label: 'PR' },
115 { id: 'close', label: 'Close' },
116]
117
118/** What the module keeps between events; a hot reload starts it over (everything drawn is in $.state). */
119const runtime = {
120 /** The refresh running now, if any. */
121 running: null as Promise<void> | null,
122 /** The one refresh queued behind it, if any: every caller meanwhile shares it. */
123 queued: null as Promise<void> | null,
124 /** Each root's default branch, worked out once. */
125 bases: new Map<string, { name: string; ref: string }>(),
126 /** Git Bash's path, found once a session (a failed search is tried again next line). */
127 bashPath: null as string | null,
128 /** The last run token handed out: each run is told apart by its own, never by its command. */
129 runToken: 0,
130 /** The tokens of the runs this load has going. */
131 activeRuns: new Set<number>(),
132}
133
134function errorText(err: unknown): string {
135 return err instanceof Error ? err.message : String(err)
136}
137
138/** The session root, or undefined when the engine cannot say. */
139async function sessionRoot($: Engine): Promise<string | undefined> {
140 try {
141 return await $.session.root()
142 } catch {
143 return undefined
144 }
145}
146
147/** One command's result, or undefined when it could not start or timed out (git's 5 s unless told otherwise). */
148async function run(
149 $: Engine,
150 root: string,
151 argv: readonly string[],
152 env?: Record<string, string>,
153 timeoutMs: number = GIT_TIMEOUT_MS,
154): Promise<ProcessRunResult | undefined> {
155 try {
156 return await $.process.run(argv, { cwd: root, timeoutMs, ...(env !== undefined ? { env } : {}) })
157 } catch {
158 return undefined
159 }
160}
161
162/** One read-only git command's stdout, or undefined when it failed or could not start. */
163async function git($: Engine, root: string, args: readonly string[]): Promise<string | undefined> {
164 const result = await run($, root, ['git', ...args])
165 return result?.exitCode === 0 ? result.stdout : undefined
166}
167
168/**
169 * origin/HEAD's branch, else `main` or `master` when it exists locally, else `main`; and the
170 * ref a PR is drafted against: `origin/<name>` when origin/HEAD named it, else the local name.
171 * Cached per root only when every probe ran: a timed-out one is asked again next read.
172 */
173async function defaultBranch($: Engine, root: string): Promise<{ name: string; ref: string }> {
174 const cached = runtime.bases.get(root)
175 if (cached !== undefined) return cached
176 const head = await run($, root, ['git', 'symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])
177 let isComplete = head !== undefined
178 const remote = head?.exitCode === 0 ? parseDefaultBranch(head.stdout) : null
179 let base = remote
180 for (const name of ['main', 'master']) {
181 if (base !== null) break
182 const probe = await run($, root, ['git', 'rev-parse', '--verify', '--quiet', `refs/heads/${name}`])
183 if (probe === undefined) isComplete = false
184 else if (probe.exitCode === 0) base = name
185 }
186 const found = remote !== null ? { name: remote, ref: `origin/${remote}` } : { name: base ?? 'main', ref: base ?? 'main' }
187 if (isComplete) runtime.bases.set(root, found)
188 return found
189}
190
191/** Reads the repo with read-only git only: null outside a repo, 'error' when git cannot run. */
192async function readRepo($: Engine): Promise<RepoState | null | 'error'> {
193 const root = await sessionRoot($)
194 if (root === undefined) return null
195 const top = await run($, root, ['git', 'rev-parse', '--show-toplevel'])
196 if (top === undefined) return 'error'
197 if (top.exitCode !== 0) return null
198 // quotePath off: non-ASCII paths come back as written, not octal-escaped.
199 const [statusOut, log, base] = await Promise.all([
200 run($, root, ['git', '-c', 'core.quotePath=false', '--no-optional-locks', 'status', '--porcelain=v2', '--branch']),
201 git($, root, ['log', '-1', '--format=%h %s']),
202 defaultBranch($, root),
203 ])
204 if (statusOut === undefined || statusOut.exitCode !== 0) return 'error'
205 const lastCommit = (log ?? '').trim()
206 return {
207 ...parseStatus(statusOut.stdout),
208 root: top.stdout.trim() || root,
209 lastCommit: lastCommit === '' ? null : lastCommit,
210 defaultBranch: base.name,
211 baseRef: base.ref,
212 }
213}
214
215/** The branch a read names, for telling a switch apart; undefined outside a repo or on an error. */
216function branchOf(repo: RepoState | null | 'error'): string | null | undefined {
217 return repo === null || repo === 'error' ? undefined : repo.branch
218}
219
220/** readRepo, or 'error' once the watchdog fires: a hung run never wedges the coalescer. */
221async function readWithWatchdog($: Engine): Promise<RepoState | null | 'error'> {
222 let fire = () => {}
223 const watchdog = new Promise<'error'>(resolve => {
224 fire = () => resolve('error')
225 })
226 let timer: Timer | null = null
227 try {
228 timer = $.clock.after(GIT_TIMEOUT_MS * 5, () => fire())
229 } catch {
230 timer = null
231 }
232 try {
233 return await Promise.race([readRepo($), watchdog])
234 } catch {
235 return 'error'
236 } finally {
237 timer?.cancel()
238 }
239}
240
241/** One refresh, start to finish; never rejects. A branch switch drops the PR answer (checked again while the pane is open). */
242function startRefresh($: Engine): Promise<void> {
243 const current = (async () => {
244 const previous = await read($, repoAtom)
245 const repo = await readWithWatchdog($)
246 await update($, repoAtom, () => repo)
247 if (branchOf(previous) !== branchOf(repo)) {
248 await update($, hasOpenPrAtom, () => null)
249 if (await read($, paneOpenAtom)) void checkPr($).catch(() => undefined)
250 }
251 })()
252 .catch(() => undefined)
253 .finally(() => {
254 if (runtime.running === current) runtime.running = null
255 })
256 runtime.running = current
257 return current
258}
259
260/**
261 * Re-reads git state. Coalesced: while one runs, one more is queued (never several),
262 * and every caller meanwhile resolves once that queued one has read.
263 */
264function refresh($: Engine): Promise<void> {
265 if (runtime.queued !== null) return runtime.queued
266 const running = runtime.running
267 if (running === null) return startRefresh($)
268 const queued = running.then(() => {
269 runtime.queued = null
270 return startRefresh($)
271 })
272 runtime.queued = queued
273 return queued
274}
275
276/** Pane open only: whether the branch has an open PR (skipped where the PR button is off anyway). */
277async function checkPr($: Engine) {
278 const repo = await read($, repoAtom)
279 if (repo === null || repo === 'error' || !buttonStates(repo, { drafting: false, hasOpenPr: null }).pr) {
280 await update($, hasOpenPrAtom, () => null)
281 return
282 }
283 const result = await run($, repo.root, ['gh', 'pr', 'view', '--json', 'number,state'], GH_ENV)
284 const isOpen = result?.exitCode === 0 ? parsePrView(result.stdout) : false
285 // An answer for a branch since switched away from is stale: the switch's own check stands.
286 if (branchOf(await read($, repoAtom)) !== repo.branch) return
287 await update($, hasOpenPrAtom, () => isOpen)
288}
289
290async function openPane($: Engine) {
291 await update($, paneOpenAtom, () => true)
292 try {
293 const opened = await $.ui.open({ id: PANE, title: 'Git Ship' })
294 if (!opened.isPlaced) $.ui.toast('Widen the window to see the Git Ship pane.')
295 } catch (err) {
296 await update($, paneOpenAtom, () => false)
297 $.ui.toast(`Git Ship: the pane could not open — ${errorText(err)}`)
298 return
299 }
300 // Never held on git: the pane draws now and fills in as the read lands.
301 void refresh($)
302 .then(() => checkPr($))
303 .catch(() => undefined)
304}
305
306async function closePane($: Engine) {
307 await update($, paneOpenAtom, () => false)
308 try {
309 await $.ui.close({ id: PANE })
310 } catch {
311 // Already closed.
312 }
313}
314
315/** The row's Open/Close and `/ship`, decided from the engine's own list of open panes. */
316async function togglePane($: Engine) {
317 let isOpen: boolean
318 try {
319 isOpen = (await $.ui.panes()).some(pane => pane.id === PANE)
320 } catch {
321 isOpen = await read($, paneOpenAtom)
322 }
323 if (isOpen) await closePane($)
324 else await openPane($)
325}
326
327/** A `!` command may still be running when its prompt is handled: one more read shortly after. */
328function refreshAfterShell($: Engine) {
329 void refresh($).catch(() => undefined)
330 try {
331 $.clock.after(SHELL_BACKSTOP_MS, () => {
332 void refresh($).catch(() => undefined)
333 })
334 } catch {
335 // No clock: the first read stands.
336 }
337}
338
339/** Puts a command in the prompt box over the draft (never sent); a refused fill is toasted so the command is still seen. */
340async function fill($: Engine, text: string) {
341 let isFilled = false
342 try {
343 isFilled = (await $.prompt.fill({ text, mode: 'replace' })).isFilled
344 } catch {
345 isFilled = false
346 }
347 if (!isFilled) $.ui.toast(`Git Ship: could not fill the prompt box — ${text}`)
348}
349
350/** Claims the one drafting slot: false when a draft is already running. */
351async function startDrafting($: Engine): Promise<boolean> {
352 let wasDrafting = true
353 // update may run the change again on a write miss: the last run is the one written.
354 await update($, draftingAtom, drafting => {
355 wasDrafting = drafting
356 return true
357 })
358 return !wasDrafting
359}
360
361/**
362 * One standalone completion over the diff alone (`$.model.complete`, not a fork): it works
363 * in a new session or after `/clear`, sends no transcript, and never joins the main
364 * conversation. Its subject and body, or undefined when it was not answered, threw, gave
365 * no subject, or ran past DRAFT_TIMEOUT_MS (it resolves `aborted`).
366 */
367async function completeDraft($: Engine, prompt: string): Promise<{ subject: string; body: string } | undefined> {
368 let result: ModelCompleteResult
369 try {
370 result = await $.model.complete({ model: DRAFT_MODEL, prompt, maxTokens: DRAFT_MAX_TOKENS, effort: 'low', timeoutMs: DRAFT_TIMEOUT_MS })
371 } catch {
372 return undefined
373 }
374 if (!result.isAnswered) return undefined
375 const draft = parseDraft(result.text)
376 return draft.subject === '' ? undefined : draft
377}
378
379/**
380 * A failed commit draft: a bare `git commit -m ""` (never `git add -A &&`, so nothing is
381 * staged on a draft that did not happen) and a toast, saying so when nothing is staged yet.
382 */
383async function commitDraftFailed($: Engine, toast: string, stageFirst: boolean) {
384 await fill($, commitCommand('', '', false))
385 $.ui.toast(stageFirst ? `${toast}${NOTHING_STAGED}` : toast)
386}
387
388/** Draft commit for the repo pressButton checked: a message drafted from the staged diff (or everything, staged first), filled into the prompt box. */
389/** Every untracked, not-ignored file, one per path; status's own `??` entries when ls-files fails. */
390async function untrackedFiles($: Engine, repo: RepoState): Promise<string[]> {
391 const listed = await git($, repo.root, ['-c', 'core.quotePath=false', 'ls-files', '--others', '--exclude-standard'])
392 if (listed === undefined) return repo.files.filter(file => file.xy === '??').map(file => file.path)
393 return listed.split(/\r?\n/).filter(line => line.trim() !== '')
394}
395
396/** The first MAX_NEW_FILES new files' text for the draft; one that cannot be read is named only. */
397async function readNewFiles($: Engine, root: string, paths: readonly string[]): Promise<string> {
398 const files = await Promise.all(
399 paths.slice(0, MAX_NEW_FILES).map(async path => {
400 try {
401 // git lists paths with `/`; join them in the root's own separator.
402 const sep = root.includes('\\') ? '\\' : '/'
403 const content = await $.fs.read(`${root}${sep}${path.split('/').join(sep)}`)
404 return { path, text: typeof content === 'string' ? content : undefined }
405 } catch {
406 return { path, text: undefined }
407 }
408 }),
409 )
410 return newFilesText(files)
411}
412
413async function draftCommit($: Engine, repo: RepoState) {
414 if (!(await startDrafting($))) return
415 const stageFirst = repo.staged === 0
416 try {
417 const cached = stageFirst ? [] : ['--cached']
418 const [diff, stat, subjects] = await Promise.all([
419 git($, repo.root, [...DIFF_ARGS, ...cached]),
420 git($, repo.root, [...DIFF_ARGS, ...cached, '--stat']),
421 git($, repo.root, ['log', '-5', '--format=%s']),
422 ])
423 // Untracked files join the commit only when it stages everything first. Status shows a new
424 // folder as one entry, so the draft lists and reads the files inside it.
425 const untracked = stageFirst ? await untrackedFiles($, repo) : []
426 // Never draft from nothing: an unread diff, or nothing at all to describe.
427 if (diff === undefined || stat === undefined || (diff.trim() === '' && stat.trim() === '' && untracked.length === 0)) {
428 return await commitDraftFailed($, DIFF_UNREAD, stageFirst)
429 }
430 let statText = stat.trimEnd()
431 if (untracked.length > 0) {
432 const names = untracked.slice(0, MAX_UNTRACKED).join(', ')
433 const more = untracked.length > MAX_UNTRACKED ? `, +${untracked.length - MAX_UNTRACKED} more` : ''
434 const line = `Untracked files: ${names}${more}`
435 statText = statText === '' ? line : `${statText}\n${line}`
436 }
437 const added = untracked.length === 0 ? '' : await readNewFiles($, repo.root, untracked)
438 const prompt = commitDraftPrompt({
439 diff: capDiff(added === '' ? diff : `${diff.trimEnd()}\n\n${added}`.trimStart()).text,
440 stat: statText,
441 recentSubjects: (subjects ?? '').split(/\r?\n/).filter(line => line.trim() !== ''),
442 })
443 const draft = await completeDraft($, prompt)
444 if (draft === undefined) return await commitDraftFailed($, DRAFT_FAILED, stageFirst)
445 await fill($, commitCommand(draft.subject, withTrailer(draft.body, COMMIT_TRAILER), stageFirst))
446 } finally {
447 await update($, draftingAtom, () => false)
448 }
449}
450
451/** PR for the repo pressButton checked: a title and body drafted from the branch's commits since its base, filled into the prompt box. */
452async function draftPr($: Engine, repo: RepoState) {
453 if (!(await startDrafting($))) return
454 try {
455 // origin/<base> when origin/HEAD named it: a stale local base would list commits already merged.
456 const base = repo.baseRef
457 const [log, stat] = await Promise.all([
458 git($, repo.root, ['log', `${base}..HEAD`, '--format=%s%n%n%b%n---']),
459 git($, repo.root, [...DIFF_ARGS, '--stat', `${base}...HEAD`]),
460 ])
461 // Never draft from nothing: no commits to describe, or a log that could not be read.
462 if (log === undefined || log.trim() === '') {
463 await fill($, prCreateCommand('', ''))
464 $.ui.toast(`Git Ship: no commits since ${base} to draft from.`)
465 return
466 }
467 const prompt = prDraftPrompt({ log: log.trimEnd(), stat: (stat ?? '').trimEnd(), branch: repo.branch ?? 'HEAD', base })
468 const draft = await completeDraft($, prompt)
469 if (draft === undefined) {
470 await fill($, prCreateCommand('', ''))
471 $.ui.toast(DRAFT_FAILED)
472 return
473 }
474 await fill($, prCreateCommand(draft.subject, withTrailer(draft.body, PR_FOOTER)))
475 } finally {
476 await update($, draftingAtom, () => false)
477 }
478}
479
480/**
481 * Git Bash: CLAUDE_CODE_GIT_BASH_PATH when set, else derived from `git --exec-path` on
482 * Windows (OS says so, or the repo sits on a drive letter), else plain `bash`. Never a bare
483 * `bash` on Windows, and never WSL's (System32 or WindowsApps); on Windows the env var must be
484 * absolute (a drive letter or `\\`). A path found is checked once (`-c 'exit 0'`) before its
485 * first use. Null when it cannot be found. An engine failure reading the environment rejects:
486 * the hook's catch drops the line.
487 */
488async function gitBash($: Engine, root: string): Promise<string | null> {
489 if (runtime.bashPath !== null) return runtime.bashPath
490 const isWindows = (await $.env.get('OS')) === 'Windows_NT' || isWindowsPath(root)
491 const configured = (await $.env.get('CLAUDE_CODE_GIT_BASH_PATH'))?.trim() ?? ''
492 let found: string | null
493 if (configured !== '') found = !isWindows || isWindowsPath(configured) || configured.startsWith('\\\\') ? configured : null
494 else if (isWindows) found = gitBashFromExecPath(await git($, root, ['--exec-path']))
495 else found = 'bash'
496 if (found !== null && isWslBash(found)) found = null
497 if (found !== null) {
498 const check = await run($, root, [found, '-c', 'exit 0'], SHELL_ENV)
499 if (check?.exitCode !== 0) found = null
500 }
501 runtime.bashPath = found
502 return found
503}
504
505/** Whether `work` settled within `ms` (false at the cap, or when there is no clock to cap it by and it never settles). */
506async function settlesWithin($: Engine, work: Promise<unknown>, ms: number): Promise<boolean> {
507 let fire = () => {}
508 const cap = new Promise<false>(resolve => {
509 fire = () => resolve(false)
510 })
511 let timer: Timer | null = null
512 try {
513 timer = $.clock.after(ms, () => fire())
514 } catch {
515 timer = null
516 }
517 try {
518 return await Promise.race([work.then(() => true as const, () => true as const), cap])
519 } finally {
520 timer?.cancel()
521 }
522}
523
524/** Puts Robert's dropped line back in the prompt box, so his edits survive the drop. */
525async function restoreLine($: Engine, text: string) {
526 try {
527 await $.prompt.fill({ text, mode: 'replace' })
528 } catch {
529 // The drop's reason still shows.
530 }
531}
532
533/**
534 * Drops a line Git Ship could not run, putting it back in the box first, unless the hook's
535 * dispatch already ended (`signal`): its catch dropped and restored the line then, and what
536 * Robert typed since must stand.
537 */
538async function dropAndRestore($: Engine, text: string, reason: string, signal?: AbortSignal): Promise<{ drop: string }> {
539 if (signal?.aborted !== true) await restoreLine($, text)
540 return { drop: reason }
541}
542
543/** Claims the one run slot for `command` under a fresh token: the run already going when it is taken. */
544async function startRunning($: Engine, command: string): Promise<{ already: GitShipRunning | null; token: number }> {
545 const token = ++runtime.runToken
546 let already: GitShipRunning | null = null
547 // update may run the change again on a write miss: the last run is the one written.
548 await update($, runningAtom, running => {
549 already = running
550 return running ?? { command, token }
551 })
552 return { already, token }
553}
554
555/** Frees the run slot only while it still holds `token`'s run. */
556async function stopRunning($: Engine, token: number) {
557 runtime.activeRuns.delete(token)
558 await update($, runningAtom, running => (running?.token === token ? null : running))
559}
560
561/** Whether the pane is open, by the engine's own list. */
562async function isPaneOpen($: Engine): Promise<boolean> {
563 try {
564 return (await $.ui.panes()).some(pane => pane.id === PANE)
565 } catch {
566 return read($, paneOpenAtom)
567 }
568}
569
570/** Reads git again a while from now; no clock, no second read. */
571function refreshLater($: Engine, ms: number) {
572 try {
573 $.clock.after(ms, () => {
574 void refresh($).catch(() => undefined)
575 })
576 } catch {
577 // No clock: the read after the run stands.
578 }
579}
580
581/**
582 * Runs a claimed line in Git Bash from the repo root, records it, reports it, then reads git
583 * again. A watchdog frees the slot whatever becomes of the run. `text` is the line as
584 * submitted, put back in the box when Git Bash would not start.
585 */
586async function runLine($: Engine, root: string, bash: string, command: string, text: string, token: number) {
587 const label = commandLabel(command)
588 runtime.activeRuns.add(token)
589 let watchdog: Timer | null = null
590 try {
591 watchdog = $.clock.after(RUN_WATCHDOG_MS, () => {
592 void stopRunning($, token).catch(() => undefined)
593 })
594 } catch {
595 watchdog = null
596 }
597 try {
598 const startedAt = await $.clock.now().catch(() => 0)
599 const result = await run($, root, [bash, '-c', SHELL_SCRIPT], { ...SHELL_ENV, GIT_SHIP_COMMAND: command }, SHELL_TIMEOUT_MS)
600 const at = await $.clock.now().catch(() => startedAt)
601 // process.run rejects only when the command cannot start or outlives its timeout: a
602 // rejection well before the timeout means Git Bash itself would not start (a backstop to
603 // the check gitBash made). With no clock to tell, it counts as not finished.
604 if (result === undefined && startedAt !== 0 && at - startedAt < SHELL_TIMEOUT_MS - 1_000) {
605 runtime.bashPath = null
606 $.ui.toast(NO_GIT_BASH)
607 await restoreLine($, text)
608 return
609 }
610 const exitCode = result === undefined ? null : result.exitCode
611 const output = result === undefined ? '' : runOutput(result.stdout, result.stderr, result.isStdoutTruncated || result.isStderrTruncated)
612 await update($, lastRunAtom, () => ({ command, exitCode, output, at }))
613 if (exitCode === 0) {
614 $.ui.toast(`Git Ship: ${label} done`)
615 } else {
616 $.ui.toast(exitCode === null ? `Git Ship: ${label} did not finish — it may still be running` : `Git Ship: ${label} failed (exit ${exitCode}) — see the pane`)
617 if (exitCode === null) refreshLater($, UNFINISHED_REREAD_MS)
618 if (!(await isPaneOpen($))) await openPane($)
619 }
620 } catch (err) {
621 $.ui.toast(`Git Ship: ${label} — ${errorText(err)}`)
622 } finally {
623 watchdog?.cancel()
624 await stopRunning($, token).catch(() => undefined)
625 void refresh($).catch(() => undefined)
626 }
627}
628
629/**
630 * A `!git` / `!gh` line Robert pressed Enter on: dropped before it reaches Claude, and run
631 * by Git Ship in Git Bash beside the hook (never awaited, so the prompt box is free at once).
632 * The drop's reason is what Robert sees.
633 */
634async function interceptLine($: Engine, repo: RepoState, command: string, text: string, signal: AbortSignal): Promise<{ drop: string }> {
635 const running = await read($, runningAtom)
636 if (running !== null) return dropAndRestore($, text, `Git Ship is still running ${commandLabel(running.command)}`, signal)
637 const bash = await gitBash($, repo.root)
638 if (bash === null) return dropAndRestore($, text, NO_GIT_BASH, signal)
639 // The dispatch ended while this hook waited: its catch already dropped the line and
640 // handed it back, so running it now would run it twice.
641 if (signal.aborted) return { drop: COULD_NOT_RUN }
642 const { already, token } = await startRunning($, command)
643 if (already !== null) return dropAndRestore($, text, `Git Ship is still running ${commandLabel(already.command)}`, signal)
644 void runLine($, repo.root, bash, command, text, token).catch(() => undefined)
645 void clearRanLine($, text).catch(() => undefined)
646 return { drop: `Git Ship ran: ${commandLabel(command)}` }
647}
648
649/**
650 * The engine hands a dropped prompt back to the box, where a second Enter would run it again.
651 * Once the drop has landed, clears the box when it still holds the line that ran; leaves
652 * anything Robert has typed since.
653 */
654async function clearRanLine($: Engine, text: string) {
655 await new Promise<void>(resolve => {
656 $.clock.after(CLEAR_AFTER_MS, () => resolve())
657 })
658 const box = (await $.prompt.read()).text
659 if (box.trim() === text.trim()) await $.prompt.fill({ text: '', mode: 'replace' })
660}
661
662/**
663 * The repo for an intercepted line: the last read, or a fresh one when there is none or it
664 * failed. Undefined outside a repo (the line passes on); 'error' when git still cannot be read.
665 */
666async function repoForLine($: Engine): Promise<RepoState | 'error' | undefined> {
667 let repo = await read($, repoAtom)
668 if (repo === null || repo === 'error') {
669 // Capped well inside the hook's budget: a read still out at the cap counts as unreadable.
670 if (!(await settlesWithin($, refresh($), LINE_READ_WAIT_MS))) return 'error'
671 repo = await read($, repoAtom)
672 }
673 return repo === null ? undefined : repo
674}
675
676/** A pane button, judged again at press time: a disabled one does nothing, drawn dim or not. */
677async function pressButton($: Engine, id: ButtonId) {
678 if (id === 'close') return closePane($)
679 const [repo, drafting, hasOpenPr] = await Promise.all([read($, repoAtom), read($, draftingAtom), read($, hasOpenPrAtom)])
680 if (repo === null || repo === 'error') return
681 if (!buttonStates(repo, { drafting, hasOpenPr })[id]) return
682 switch (id) {
683 case 'stage':
684 return fill($, stageCommand())
685 case 'commit':
686 return draftCommit($, repo)
687 case 'push': {
688 const command = pushCommand(repo)
689 return command === '' ? undefined : fill($, command)
690 }
691 case 'pull':
692 return fill($, pullCommand())
693 case 'branch':
694 return fill($, branchCommand())
695 case 'pr':
696 return hasOpenPr === true ? fill($, prViewCommand()) : draftPr($, repo)
697 }
698}
699
700/** Runs a button's work; a rejection becomes a toast instead of an unhandled error. */
701async function safely($: Engine, work: () => Promise<unknown>) {
702 try {
703 await work()
704 } catch (err) {
705 $.ui.toast(`Git Ship: ${errorText(err)}`)
706 }
707}
708
709/** The pane's header line: `<branch> → <upstream> ↑a ↓b`. */
710function headerText(repo: RepoState): string {
711 const where = repo.branch ?? `detached ${repo.sha ?? 'HEAD'}`
712 return repo.upstream === null ? `${where} → no upstream` : `${where} → ${repo.upstream} ↑${repo.ahead} ↓${repo.behind}`
713}
714
715export const register: Register = on => {
716 on('session.start', async ($, e, next) => {
717 // /ship first: Robert's Toolbox runs a mod's first registered command.
718 await $.command.register({
719 name: 'ship',
720 description: 'Open the Git Ship pane — repo status and ready-to-run git commands',
721 immediate: true,
722 })
723 // A fresh load has no draft or run going: a reload mid-way would leave them stuck.
724 await update($, draftingAtom, () => false)
725 // A run of an earlier load is gone with it; one this load still has going stays.
726 await update($, runningAtom, running => (running !== null && runtime.activeRuns.has(running.token) ? running : null))
727 runtime.bashPath = null
728 // No hook waits on git: every refresh runs beside the event.
729 void refresh($)
730 return next(e)
731 }).catch(($, e, next) => next(e))
732
733 on('command.run', { command: 'ship' }, async $ => {
734 await safely($, () => togglePane($))
735 return {}
736 })
737
738 on('turn.complete', async ($, e, next) => {
739 const result = await next(e)
740 if (e.agentId === undefined) void refresh($)
741 return result
742 }).catch(($, e, next) => next(e))
743
744 // A filled `!git` / `!gh` line Robert pressed Enter on is Git Ship's to run, never Claude's:
745 // a plugin fill cannot put the box in shell mode, so it would otherwise go to Claude as a prompt.
746 // Any other `!` shell command Robert ran may have changed the repo.
747 on('prompt.submit', async ($, e, next) => {
748 const command = e.origin.kind === 'composer' ? interceptedCommand(e.text) : null
749 if (command !== null) {
750 const repo = await repoForLine($)
751 if (repo === 'error') return dropAndRestore($, e.text, COULD_NOT_READ, next.signal)
752 if (repo !== undefined) return interceptLine($, repo, command, e.text, next.signal)
753 }
754 const result = await next(e)
755 if (e.text.trimStart().startsWith('!')) refreshAfterShell($)
756 return result
757 }).catch(async ($, e, next) => {
758 // A line of Git Ship's own never falls through to Claude, whatever broke.
759 if (!next.called && e.origin.kind === 'composer' && interceptedCommand(e.text) !== null) {
760 return dropAndRestore($, e.text, COULD_NOT_RUN)
761 }
762 return next(e)
763 })
764
765 // The person's own close (the pane's mark or key) keeps the row's button in step.
766 on('ui.close', async ($, e, next) => {
767 const result = await next(e)
768 if (e.id === PANE) await update($, paneOpenAtom, () => false)
769 return result
770 }).catch(($, e, next) => next(e))
771
772 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
773 const { Box, Text, Button } = $.ui.resolve(e)
774 // A drawing that throws would drop the pane (an unload close): a dim line instead.
775 try {
776 const [repo, drafting, hasOpenPr, lastRun] = await Promise.all([
777 read($, repoAtom),
778 read($, draftingAtom),
779 read($, hasOpenPrAtom),
780 read($, lastRunAtom),
781 ])
782 if (repo === null) return <Text dimColor>Not inside a git repository.</Text>
783 if (repo === 'error') return <Text dimColor>git unavailable.</Text>
784 const states = buttonStates(repo, { drafting, hasOpenPr })
785 const more = repo.files.length - MAX_FILES
786 return (
787 <Box flexDirection="column">
788 {/* Each line sits in a keyed Box: a Text keeps no key of its own. */}
789 <Box key="git-ship-head">
790 <Text color={COLORS.green} bold>{headerText(repo)}</Text>
791 </Box>
792 {repo.files.slice(0, MAX_FILES).map((file, i) => (
793 <Box key={`git-ship-file-${i}`}>
794 <Text color={COLORS.text}>{` ${file.xy} ${file.path}`}</Text>
795 </Box>
796 ))}
797 {more > 0 ? (
798 <Box key="git-ship-more">
799 <Text color={COLORS.muted}>{` +${more} more`}</Text>
800 </Box>
801 ) : null}
802 <Box key="git-ship-last">
803 <Text color={COLORS.muted}>{`last: ${repo.lastCommit ?? '(no commits)'}`}</Text>
804 </Box>
805 {repo.unmerged > 0 ? (
806 <Box key="git-ship-conflicts">
807 <Text dimColor>{CONFLICT_TEXT}</Text>
808 </Box>
809 ) : null}
810 {drafting ? (
811 <Box key="git-ship-drafting">
812 <Text color={COLORS.muted}>drafting…</Text>
813 </Box>
814 ) : null}
815 {/* Wraps onto more rows in a narrow pane; each button keeps its label whole. */}
816 <Box key="git-ship-buttons" flexDirection="row" flexWrap="wrap" columnGap={1}>
817 {BUTTONS.map(({ id, label }) => (
818 <Box key={`git-ship-cell-${id}`} flexShrink={0}>
819 <Button key={`git-ship-${id}`} label={label} dimColor={!states[id]} onPress={() => safely($, () => pressButton($, id))} />
820 </Box>
821 ))}
822 </Box>
823 {lastRun === null ? null : (
824 <Box key="git-ship-run" flexDirection="column">
825 <Box key="git-ship-run-command">
826 <Text color={COLORS.text}>{`$ ${commandHeadline(lastRun.command)}`}</Text>
827 </Box>
828 {lastRun.output === '' ? null : (
829 <Box key="git-ship-run-output">
830 <Text color={COLORS.muted}>{lastRun.output}</Text>
831 </Box>
832 )}
833 <Box key="git-ship-run-status">
834 <Text color={lastRun.exitCode === 0 ? COLORS.green : COLORS.red}>{runStatusText(lastRun.exitCode)}</Text>
835 </Box>
836 </Box>
837 )}
838 </Box>
839 )
840 } catch (err) {
841 return <Text dimColor>{`Git Ship could not draw: ${errorText(err)}`}</Text>
842 }
843 })
844
845 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
846 const below = await next(e)
847 if (e.props.hasSurvey) return below
848 const [repo, paneOpen, running] = await Promise.all([read($, repoAtom), read($, paneOpenAtom), read($, runningAtom)])
849 if (repo === null) return below
850 const { Box, Text, Button } = $.ui.resolve(e)
851 const row =
852 repo === 'error' ? (
853 <Box key="git-ship-row" flexDirection="row">
854 <Box key="git-ship-text" flexShrink={1}>
855 <Text>
856 <Text color={COLORS.green} bold>{ROW_TITLE}</Text>
857 <Text dimColor>{' git unavailable'}</Text>
858 </Text>
859 </Box>
860 {/* No buttons, save a way to close a pane git failed under. */}
861 {paneOpen ? <Box key="git-ship-spacer" flexGrow={1} /> : null}
862 {paneOpen ? <Button key="git-ship-toggle" label="Close" onPress={() => safely($, () => closePane($))} /> : null}
863 </Box>
864 ) : (
865 <Box key="git-ship-row" flexDirection="row">
866 <Box key="git-ship-text" flexShrink={1}>
867 <Text>
868 <Text color={COLORS.green} bold>{ROW_TITLE}</Text>
869 <Text dimColor>{` ${rowText(repo)}${running === null ? '' : ` · running ${commandLabel(running.command)}…`}`}</Text>
870 </Text>
871 </Box>
872 <Box key="git-ship-spacer" flexGrow={1} />
873 <Button key="git-ship-toggle" label={paneOpen ? 'Close' : 'Open'} onPress={() => safely($, () => togglePane($))} />
874 </Box>
875 )
876 return (
877 <Box flexDirection="column">
878 {below}
879 {row}
880 </Box>
881 )
882 }).catch(($, e, next) => next(e))
883}
884hooks/core.ts 381 lines1/**
2 * Git Ship's pure logic: parsing `git status --porcelain=v2 --branch`, the row text,
3 * which buttons are enabled, the commands the buttons fill, shell quoting and the
4 * drafting prompts. Nothing here touches `$`, so every function is unit-tested directly.
5 */
6
7// ---------------------------------------------------------------------------
8// Types
9// ---------------------------------------------------------------------------
10
11export type FileChange = { xy: string; path: string }
12
13/** What one `git status --porcelain=v2 --branch` run says. */
14export type StatusFacts = {
15 /** null when HEAD is detached. */
16 branch: string | null
17 /** The first 7 characters of HEAD; null on an unborn branch. */
18 sha: string | null
19 upstream: string | null
20 ahead: number
21 behind: number
22 files: FileChange[]
23 staged: number
24 unstaged: number
25 untracked: number
26 /** Unmerged (conflicted) paths; they also count as unstaged. */
27 unmerged: number
28}
29
30/** `baseRef` is what a PR is drafted against: `origin/<base>` when origin/HEAD names it, else the local `<base>`. */
31export type RepoState = StatusFacts & { root: string; lastCommit: string | null; defaultBranch: string; baseRef: string }
32
33export type ButtonId = 'stage' | 'commit' | 'push' | 'pull' | 'branch' | 'pr' | 'close'
34
35// ---------------------------------------------------------------------------
36// Status parsing and the row
37// ---------------------------------------------------------------------------
38
39/** The text after the first `n` space-separated fields of `line`. */
40function afterFields(line: string, n: number): string {
41 let at = 0
42 for (let i = 0; i < n; i++) {
43 const space = line.indexOf(' ', at)
44 if (space === -1) return ''
45 at = space + 1
46 }
47 return line.slice(at)
48}
49
50/** Parse `git status --porcelain=v2 --branch` output (not `-z`). */
51export function parseStatus(text: string): StatusFacts {
52 const facts: StatusFacts = {
53 branch: null,
54 sha: null,
55 upstream: null,
56 ahead: 0,
57 behind: 0,
58 files: [],
59 staged: 0,
60 unstaged: 0,
61 untracked: 0,
62 unmerged: 0,
63 }
64 for (const line of text.split(/\r?\n/)) {
65 if (line.startsWith('# branch.oid ')) {
66 const oid = line.slice('# branch.oid '.length).trim()
67 facts.sha = oid === '(initial)' || oid === '' ? null : oid.slice(0, 7)
68 } else if (line.startsWith('# branch.head ')) {
69 const head = line.slice('# branch.head '.length).trim()
70 facts.branch = head === '(detached)' || head === '' ? null : head
71 } else if (line.startsWith('# branch.upstream ')) {
72 facts.upstream = line.slice('# branch.upstream '.length).trim() || null
73 } else if (line.startsWith('# branch.ab ')) {
74 const m = /\+(\d+) -(\d+)/.exec(line)
75 if (m) {
76 facts.ahead = Number(m[1])
77 facts.behind = Number(m[2])
78 }
79 } else if (line.startsWith('1 ') || line.startsWith('2 ') || line.startsWith('u ')) {
80 const xy = line.slice(2, 4)
81 let path: string
82 if (line[0] === '1') path = afterFields(line, 8)
83 else if (line[0] === '2') path = afterFields(line, 9).split('\t')[0] ?? ''
84 else path = afterFields(line, 10)
85 facts.files.push({ xy, path })
86 if (line[0] === 'u') {
87 facts.unstaged++
88 facts.unmerged++
89 } else {
90 if (xy[0] !== '.') facts.staged++
91 if (xy[1] !== '.') facts.unstaged++
92 }
93 } else if (line.startsWith('? ')) {
94 facts.files.push({ xy: '??', path: line.slice(2) })
95 facts.untracked++
96 }
97 }
98 return facts
99}
100
101/** The row's status text, e.g. `main · 3 changed · ↑2 ↓1`. */
102export function rowText(state: RepoState): string {
103 const where = state.branch ?? `detached ${state.sha ?? 'HEAD'}`
104 const changed = state.files.length === 0 ? 'clean' : `${state.files.length} changed`
105 const conflicts = state.unmerged === 0 ? '' : ` · ${state.unmerged} ${state.unmerged === 1 ? 'conflict' : 'conflicts'}`
106 const sync = state.upstream === null ? 'no upstream' : `↑${state.ahead} ↓${state.behind}`
107 return `${where} · ${changed}${conflicts} · ${sync}`
108}
109
110/** Which buttons are enabled. `close` and `branch` are always on. */
111export function buttonStates(
112 state: RepoState,
113 opts: { drafting: boolean; hasOpenPr: boolean | null },
114): Record<ButtonId, boolean> {
115 return {
116 // Staging or committing during a conflict would take the conflict markers.
117 stage: state.unmerged === 0 && state.unstaged + state.untracked > 0,
118 commit: state.unmerged === 0 && state.files.length > 0 && !opts.drafting,
119 push: state.branch !== null && state.sha !== null && (state.ahead > 0 || state.upstream === null),
120 pull: state.upstream !== null,
121 branch: true,
122 // With an open PR the button only views it, so drafting does not matter.
123 pr:
124 state.branch !== null &&
125 state.branch !== state.defaultBranch &&
126 state.upstream !== null &&
127 (opts.hasOpenPr === true || !opts.drafting),
128 close: true,
129 }
130}
131
132// ---------------------------------------------------------------------------
133// Commands
134// ---------------------------------------------------------------------------
135
136/** Wrap `s` in bash double quotes, backslash-escaping `\`, `"`, `$` and a backtick. Newlines stay. */
137export function bashQuote(s: string): string {
138 return `"${s.replace(/[\\"$`]/g, '\\$&')}"`
139}
140
141export function stageCommand(): string {
142 return '!git add -A'
143}
144
145/** `!git commit`, with `git add -A &&` in front when nothing is staged. An empty body adds no second `-m`. */
146export function commitCommand(subject: string, body: string, stageFirst: boolean): string {
147 const parts = `-m ${bashQuote(subject)}${body === '' ? '' : ` -m ${bashQuote(body)}`}`
148 return `!${stageFirst ? 'git add -A && ' : ''}git commit ${parts}`
149}
150
151/** `!git push`, or `-u origin "<branch>"` without an upstream. Empty on a detached HEAD. */
152export function pushCommand(state: RepoState): string {
153 if (state.branch === null) return ''
154 return state.upstream === null ? `!git push -u origin ${bashQuote(state.branch)}` : '!git push'
155}
156
157export function pullCommand(): string {
158 return '!git pull'
159}
160
161/** Ends in a space so Robert types the branch name. */
162export function branchCommand(): string {
163 return '!git switch -c '
164}
165
166export function prCreateCommand(title: string, body: string): string {
167 return `!gh pr create --title ${bashQuote(title)} --body ${bashQuote(body)}`
168}
169
170export function prViewCommand(): string {
171 return '!gh pr view --web'
172}
173
174// ---------------------------------------------------------------------------
175// Drafting
176// ---------------------------------------------------------------------------
177
178export const COMMIT_TRAILER = 'Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>'
179export const PR_FOOTER = '🤖 Generated with [Claude Code](https://claude.com/claude-code)'
180
181const SUBJECT_MAX = 72
182
183/** Cut a diff to `max` characters, marking the cut. */
184export function capDiff(text: string, max = 40_000): { text: string; isTruncated: boolean } {
185 if (text.length <= max) return { text, isTruncated: false }
186 return { text: `${text.slice(0, max)}\n… (diff truncated)`, isTruncated: true }
187}
188
189/**
190 * New (untracked) files as text the draft can read, since `git diff` leaves them out: each
191 * under a `New file:` header, cut to `perFile` characters; unreadable or binary ones by name.
192 */
193export function newFilesText(files: readonly { path: string; text: string | undefined }[], perFile = 4_000): string {
194 return files
195 .map(({ path, text }) => {
196 if (text === undefined || text.includes('\u0000')) return `New file: ${path} (not shown)`
197 const body = text.length > perFile ? `${text.slice(0, perFile)}\n… (file truncated)` : text
198 return `New file: ${path}\n${body.trimEnd()}`
199 })
200 .join('\n\n')
201}
202
203/** Append `trailer` below `body` unless the body already ends with it. */
204export function withTrailer(body: string, trailer: string): string {
205 if (body === '') return trailer
206 if (body.trimEnd().endsWith(trailer)) return body
207 return `${body}\n\n${trailer}`
208}
209
210/** Read the model's "subject, blank line, body" reply. */
211export function parseDraft(reply: string): { subject: string; body: string } {
212 // Normalise line endings; trim() also drops any leading blank lines.
213 let text = reply.replace(/\r\n?/g, '\n').trim()
214 const fence = /^```[^\n]*\n([\s\S]*?)\n?```$/.exec(text)
215 if (fence) text = (fence[1] ?? '').trim()
216 const nl = text.indexOf('\n')
217 const first = nl === -1 ? text : text.slice(0, nl)
218 const rest = nl === -1 ? '' : text.slice(nl + 1)
219 // Cut by code point, so an emoji at the boundary is never split into half a surrogate pair.
220 return { subject: Array.from(first.trim()).slice(0, SUBJECT_MAX).join(''), body: rest.trim() }
221}
222
223export function commitDraftPrompt(i: { diff: string; stat: string; recentSubjects: string[] }): string {
224 return [
225 'Write a git commit message for the changes below.',
226 '',
227 'Recent commit subjects (for style):',
228 i.recentSubjects.join('\n'),
229 '',
230 'Diff stat:',
231 i.stat,
232 '',
233 'Diff:',
234 i.diff,
235 '',
236 'Reply with only the commit message: one subject line of at most 72 characters in the same style as the recent subjects, a blank line, then a short body of what changed and why. No trailers, no code fences.',
237 ].join('\n')
238}
239
240export function prDraftPrompt(i: { log: string; stat: string; branch: string; base: string }): string {
241 return [
242 `Write a pull request for branch ${i.branch}, to be merged into ${i.base}.`,
243 '',
244 `Commits on ${i.branch} since ${i.base}:`,
245 i.log,
246 '',
247 'Diff stat:',
248 i.stat,
249 '',
250 'Reply with only a PR title line, a blank line, then a markdown body with a short summary and a bullet list of changes. No footer, no code fences.',
251 ].join('\n')
252}
253
254// ---------------------------------------------------------------------------
255// Git output
256// ---------------------------------------------------------------------------
257
258/** `git symbolic-ref --short refs/remotes/origin/HEAD` output (`origin/main`) to the branch name. */
259export function parseDefaultBranch(out: string | undefined): string | null {
260 const ref = out?.trim() ?? ''
261 if (ref === '') return null
262 const slash = ref.indexOf('/')
263 return slash === -1 ? ref : ref.slice(slash + 1) || null
264}
265
266/** Whether `gh pr view --json number,state` reports an open PR. */
267export function parsePrView(json: string | undefined): boolean {
268 if (!json) return false
269 try {
270 return (JSON.parse(json) as { state?: unknown }).state === 'OPEN'
271 } catch {
272 return false
273 }
274}
275
276// ---------------------------------------------------------------------------
277// Running a filled command
278// ---------------------------------------------------------------------------
279
280/** Lines of a run's output the pane keeps. */
281const RUN_OUTPUT_LINES = 30
282/** Characters one output line keeps, and the whole output. */
283const RUN_LINE_MAX = 500
284const RUN_OUTPUT_MAX = 4_000
285const TRUNCATED = '… (output truncated)'
286/** How much of a command a toast, the row or a drop reason shows. */
287const LABEL_MAX = 60
288
289/**
290 * The command a submitted line asks Git Ship to run: the text after `!` (edits and all)
291 * when the trimmed line starts with `!git ` or `!gh `; null for anything else.
292 */
293export function interceptedCommand(text: string): string | null {
294 const trimmed = text.trim()
295 if (!trimmed.startsWith('!git ') && !trimmed.startsWith('!gh ')) return null
296 return text.trimStart().slice(1)
297}
298
299/** Git Bash from `git --exec-path` (`<Git>/mingw64/libexec/git-core`): three folders up, then `bin/bash.exe`. */
300export function gitBashFromExecPath(out: string | undefined): string | null {
301 const parts = (out ?? '').trim().replace(/\\/g, '/').replace(/\/+$/, '').split('/')
302 if (parts.length < 4) return null
303 const top = parts.slice(0, -3).join('/')
304 return top === '' ? null : `${top}/bin/bash.exe`
305}
306
307/** One stream as plain lines: ANSI escapes and control characters gone, a carriage return keeping what was drawn last. */
308function plainLines(stream: string): string[] {
309 const text = stream
310 .replace(/\r\n/g, '\n')
311 // CSI (colours, cursor moves) and OSC (titles) sequences, then any other escape pair.
312 .replace(/\u001b\[[0-?]*[ -/]*[@-~]/g, '')
313 .replace(/\u001b\][^\u0007\u001b]*(\u0007|\u001b\\)?/g, '')
314 .replace(/\u001b[@-_]?/g, '')
315 .replace(/\t/g, ' ')
316 .replace(/\n+$/, '')
317 if (text === '') return []
318 return text.split('\n').map(line => {
319 // Progress output redraws a line with \r: keep the last drawing.
320 const drawn = line.replace(/\r+$/, '')
321 const redrawn = drawn.slice(drawn.lastIndexOf('\r') + 1)
322 return redrawn.replace(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g, '')
323 })
324}
325
326/**
327 * A run's stdout then stderr for the pane: plain text, the last 30 lines, each cut to 500
328 * characters and the whole to 4,000 (keeping the end), marked when anything was cut whole
329 * or the engine cut a stream (`isTruncated`).
330 */
331export function runOutput(stdout: string, stderr: string, isTruncated = false): string {
332 let lines = [...plainLines(stdout), ...plainLines(stderr)]
333 .slice(-RUN_OUTPUT_LINES)
334 .map(line => {
335 const chars = Array.from(line)
336 return chars.length > RUN_LINE_MAX ? `${chars.slice(0, RUN_LINE_MAX).join('')}…` : line
337 })
338 let isCut = isTruncated
339 while (lines.length > 0 && lines.join('\n').length > RUN_OUTPUT_MAX) {
340 lines = lines.slice(1)
341 isCut = true
342 }
343 if (isCut) lines.push(TRUNCATED)
344 return lines.join('\n')
345}
346
347/** A bash that is WSL's, not Git Bash: System32's or the WindowsApps alias. */
348export function isWslBash(path: string): boolean {
349 return /(^|[\\/])(system32|windowsapps)[\\/]bash\.exe$/i.test(path.trim())
350}
351
352/** A path that starts with a drive letter: the repo is on Windows. */
353export function isWindowsPath(path: string): boolean {
354 return /^[A-Za-z]:/.test(path)
355}
356
357/** A command's first line, cut to 60 characters, for a toast, the row or a drop reason. */
358export function commandLabel(command: string): string {
359 const nl = command.indexOf('\n')
360 const first = (nl === -1 ? command : command.slice(0, nl)).trim()
361 const chars = Array.from(first)
362 return chars.length > LABEL_MAX || nl !== -1 ? `${chars.slice(0, LABEL_MAX).join('')}…` : first
363}
364
365const HEADLINE_MAX = 100
366
367/** The pane's `$` line for a run: the command's first line, cut to 100 characters, and how many lines follow it. */
368export function commandHeadline(command: string): string {
369 const lines = command.split('\n')
370 const chars = Array.from((lines[0] ?? '').trimEnd())
371 const isCut = chars.length > HEADLINE_MAX || lines.length > 1
372 const first = isCut ? `${chars.slice(0, HEADLINE_MAX).join('')}…` : chars.join('')
373 return lines.length > 1 ? `${first} (+${lines.length - 1} lines)` : first
374}
375
376/** A run's last line in the pane: `✓ exit 0`, `✗ exit N`, or that it did not finish (timed out, and may still be running). */
377export function runStatusText(exitCode: number | null): string {
378 if (exitCode === null) return '✗ did not finish — it may still be running'
379 return exitCode === 0 ? '✓ exit 0' : `✗ exit ${exitCode}`
380}
381types/index.d.ts 67 lines1/** One changed path: its two-letter porcelain status (`??` untracked) and its path. */
2export type GitShipFile = { xy: string; path: string }
3
4/** The repo as the row and the pane draw it; the same shape as core.ts's RepoState. */
5export type GitShipRepo = {
6 /** null when HEAD is detached. */
7 branch: string | null
8 /** The first 7 characters of HEAD; null on an unborn branch. */
9 sha: string | null
10 upstream: string | null
11 ahead: number
12 behind: number
13 files: GitShipFile[]
14 staged: number
15 unstaged: number
16 untracked: number
17 /** Unmerged (conflicted) paths; they also count as unstaged. */
18 unmerged: number
19 /** The work tree's top folder. */
20 root: string
21 /** `<short sha> <subject>` of HEAD; null with no commits. */
22 lastCommit: string | null
23 /** origin/HEAD's branch, else `main` or `master`. */
24 defaultBranch: string
25 /** What a PR is drafted against: `origin/<defaultBranch>` when origin/HEAD names it, else the local `<defaultBranch>`. */
26 baseRef: string
27}
28
29/** The line Git Ship is running now. */
30export type GitShipRunning = {
31 /** The text after `!`, as submitted. */
32 command: string
33 /** This run's own token: only its own run (or a later load) clears the slot, never a run of the same command. */
34 token: number
35}
36
37/** One line Git Ship ran when Robert pressed Enter on it. */
38export type GitShipRun = {
39 /** The text after `!`, as submitted. */
40 command: string
41 /** null when the run threw or timed out. */
42 exitCode: number | null
43 /** stdout then stderr, the last 30 lines. */
44 output: string
45 /** When it finished, epoch ms. */
46 at: number
47}
48
49declare module 'claude-code' {
50 interface PluginState {
51 'git-ship': {
52 /** The repo as last read; null outside a repo (or before the first read), 'error' when git failed. */
53 repo: GitShipRepo | null | 'error'
54 /** Whether the Git Ship pane is open. */
55 paneOpen: boolean
56 /** True while a commit or PR draft is being written (Task 3). */
57 drafting: boolean
58 /** Whether the branch has an open PR, checked when the pane opens; null when not checked. */
59 hasOpenPr: boolean | null
60 /** The `!git`/`!gh` line Git Ship is running now, with its run's token; null when none. */
61 running: GitShipRunning | null
62 /** The last line Git Ship ran; null before the first. */
63 lastRun: GitShipRun | null
64 }
65 }
66}
67