SLOPSHOPPER

git-ship

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

newpanebandcommandtoastprompt
v0.1.0MITupdated 2026-10-15hearsawho25/roberts-mods/git-ship
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · git-ship
│ ┃ Git Ship ✕ › fix the failing auth test and add an audit log call │ ┃ detached HEAD → no upstream │ ┃ last: a1b2c3d fix: refresh expired tokens (2 ⏺ Read(src/auth.ts) │ ┃ minutes ago) 9f8e7d6 feat: add audit log (3 ⎿ Read 6 lines │ ┃ hours ago) ⏺ Update(src/auth.ts) │ ┃ [ Stage all ] [ Draft commit ] [ Push ] [ Pu ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /ship │ │ ⟨Claude Code's own drawing⟩ ◆ Ship detached HEAD · clean · no upstream[ Close ] ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
⟨Claude Code's own drawing⟩ ◆ Ship detached HEAD · clean · no upstream[ Close ]
Pane · Git Ship
detached HEAD → no upstream last: a1b2c3d fix: refresh expired tokens (2 minutes ago) 9f8e7d6 feat: add audit log (3 hours ago) [ Stage all ] [ Draft commit ] [ Push ] [ Pull ] [ New branc
README

Git Ship

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.

Install

claude plugin marketplace add hearsawho25/roberts-mods
claude plugin install git-ship@roberts-mods

The row

Inside a git repo, one line sits in the band above the prompt:

◆ Ship  main · 3 changed · ↑2 ↓0  [ Open ]
  • With no upstream, the arrows are replaced by no upstream.
  • On a detached HEAD, the branch name is replaced by detached <sha>.
  • During a merge conflict, the row adds · N conflicts.
  • If git can't be read, the row shows ◆ Ship git unavailable.
  • Outside a repo, nothing shows.

The row refreshes on these occasions:

  • when the session starts
  • after each reply
  • after Git Ship runs a !git or !gh line for you
  • after any other ! command you run, and once more 1.5 seconds later
  • when you open the pane

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

The pane

Open it with the row's [ Open ], with /ship, or from Robert's Toolbox. It shows:

  • the branch and its upstream
  • up to 10 changed files
  • the last commit
  • these buttons:
ButtonFills
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.
Closecloses the pane
  • Buttons that don't apply are dimmed and do nothing:
  • Push with nothing to push
  • PR on the default branch
  • Stage all and Draft commit during a merge conflict. Resolve the conflict, then git add <file> yourself.
  • If a draft fails, the command is filled with empty quotes for you to type into.

Pressing Enter

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:

  • Any line you submit that starts with !git or !gh , inside a repo, is dropped before it reaches Claude, and Git Ship runs it exactly as you left it, edits included.
  • It runs in Git Bash from the repo root, with a 5-minute limit. The line is handed to bash through an environment variable, never on the command line, so backslashes and $(…) 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.
  • If a line can't be run (no Git Bash, git unreadable, or another line still running), it's dropped with a reason and put back in the prompt box. It never goes to Claude.
  • Git Bash is 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.
  • One line runs at a time. While it runs, the row shows running <command>….
  • When it finishes, a toast says 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.

Design

See docs/DESIGN.md.

Source 3 files
hooks/register.tsx 884 lines
1import { 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}
884
hooks/core.ts 381 lines
1/**
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}
381
types/index.d.ts 67 lines
1/** 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