/ship: squash-merges the branch's PR once its checks passed on its head after the base last moved and GitHub calls it mergeable, then pulls the base. Merges…

Dev-environment provisioning for Linux hosts (native and WSL), plus Warp and Windows Terminal on Windows. One command per host installs the tools, system packages, /etc files, services and dotfiles.
One entry script per OS (bootstrap.sh, bootstrap.ps1) installs a pinned mise (mise.jdx.dev) and hands off to it. mise owns every tool and every piece of host state. This checkout is mise's global config directory: ~/.config/mise on Linux, %USERPROFILE%\.config\mise on Windows.
| Piece | Role |
|---|---|
bootstrap.sh / bootstrap.ps1 | Install pinned mise, decide the system steps, run mise. Nothing is installed by hand in the scripts beyond mise (and, on Windows, SSHFS-Win). |
| mise tools | Every tool is a pin in config.toml / config.linux.toml. mise ls is the tool list. |
mise bootstrap | Host state from [bootstrap.*] tables: dnf packages, /etc files, services, repos, dotfiles, then the tools and the bootstrap task. |
mise dot ([dotfiles]) | Personal config under $HOME, templated per machine. Every deployed file is an independent copy, never a symlink into the checkout. |
| starship, zsh plugins, fzf, zoxide | Prompt, completion, fuzzy find and directory jumping. |
| zellij | Persistent sessions; runs on the remote host so a dropped SSH tab loses nothing. |
| helix | Modal editor and $EDITOR, unless the shell inherits one, as a ws open workspace's panes do (dotfiles/config/helix). It uses Catppuccin Mocha with the mauve accent and the same clangd flags as Neovim. clang-format runs on save only where the project has a .clang-format. Files auto-save 3 s after an edit, and yanks copy over OSC 52 on Linux (the Windows clipboard on Windows). |
| Warp, Windows Terminal, WezTerm | The Windows terminals (see Terminals). |
Nothing is pushed anywhere and there is no host list: each host keeps itself current with wsu.
Every host gets the same setup. Three tracked config files hold it, plus the git-ignored config.local.toml for per-host values, and miserc.toml (git-ignored, written by scripts/lib/mise-env.sh on Linux and bootstrap.ps1 on Windows) picks the OS file:
| File | Loads on | Holds |
|---|---|---|
config.toml | every host | tools for both OSes (Windows-only and Linux-only entries carry os = [...]), [vars] pins, cross-platform dotfiles |
config.linux.toml | Linux | the Linux toolbelt, dnf packages, /etc/wsl.conf, hooks, Linux dotfiles |
config.windows.toml | Windows | Windows dotfiles, winget GUI apps |
config.local.toml | every host, git-ignored | name, email, sudo, per-host overrides |
System steps and sudo. dnf packages, /etc files and zsh as the login shell need sudo. bootstrap.sh checks once: cached sudo or a password prompt that succeeds means they run; a failed prompt saves sudo = "no" in config.local.toml, and from then on bootstrap.sh, mise run update and wsu skip them (mise bootstrap --skip packages,files) while everything user-level still installs. An unattended run with no terminal skips them for that run only. To apply them later, get sudo and re-run ./bootstrap.sh. mise run health shows the state in its "system steps" row.
A compiler without sudo (Linux). Every Linux host also gets mise's zig, which adds only zig to PATH, so a host's own gcc always wins. Neovim builds its parsers with it when there is no cc. CC=zcc CXX=zc++ uses it for your own builds: clang-based, targeting glibc 2.28, with LLVM's libc++ and no AddressSanitizer. cmake, ninja and make stay dnf-only, because mise copies would shadow dnf's. A no-sudo host that lacks them adds e.g. cmake = "4.4.4" to its config.local.toml, then runs cd ~ && mise lock --global --platform linux-x64 cmake && mise install, which writes the git-ignored mise.local.lock.
bootstrap.sh, bootstrap.ps1 entry points (Linux; Windows, no admin)
config.toml, config.linux.toml, config.windows.toml
mise tool pins, [vars] pins, [dotfiles] entries,
host state as [bootstrap.*] tables (Linux)
config.local.toml git-ignored: this host's name, email, sudo
mise.lock, mise.linux.lock, locks/
generated lockfiles; never hand-edit
tasks/ file tasks with real logic (bootstrap, health, update, ...);
one-line wrappers are [tasks] in config.toml / config.linux.toml
scripts/, scripts/lib/ checks (check-invariants.sh, check-templates.sh), helpers, tests
dotfiles/ every deployed source under its real name
*.tera templates (zshrc, bashrc, zshenv, gitconfig, ssh/config, ...)
config/ ~/.config/*: starship, helix, nvim, zellij, zsh plugins, ...
claude/ ~/.claude/*: CLAUDE.md, hooks, skills, settings seed and enforced keys
local/bin/ ~/.local/bin: batpipe, winterop, zcc, zc++
windows/ the Windows-only sources
configs/wsl/wsl.conf source of /etc/wsl.conf ([bootstrap.files], not [dotfiles])
docs/claude/, .claude/ notes, hooks and settings for Claude Code
docs/windows/application_list.md apps to hand-install on a fresh Windows machine
Dotfile modes: template for the .tera sources, copy for everything else, on every platform. A directory source carries exclude = [".vendor", ".gitkeep"]. ~/.claude/settings.json is not a dotfile entry: a jq merge (scripts/lib/claude-settings-merge.sh; on Windows scripts/claude-settings-merge.ps1, with the same filter) combines dotfiles/claude/settings.seed.json, the live file and settings.enforced.json, the last winning. wsu runs it on both OSes.
Prerequisites: curl, git, tar (a single preflight lists every missing one at once), and free space in $HOME for the tools: the linux figure in disk-budget.toml plus 1 GB, less what is already installed (about 6.9 GB fresh; none when every tool is installed). Without it the tools step stops before installing anything and lists the largest folders in your home; WORKSTATION_SKIP_DISK_CHECK=1 overrides. RHEL-family EL8 and EL9 are supported (tested on AlmaLinux 9.8 and RHEL 8.10). On EL8, bear isn't packaged and is skipped; plain gdb is pwndbg's bundled GDB 17 on every host, so GEF gets a modern gdb there too. Then, on the host:
curl -fsSL https://raw.githubusercontent.com/ArrushC/workstation/main/bootstrap.sh | bash && \
exec zsh # or: source ~/.bashrc, if you can't chsh on this host
The repo is public, so no token or SSH key is needed. GITHUB_TOKEN is optional: mise uses it to lift GitHub's 60-requests/hour anonymous API limit. To use a (public) fork, change DOTFILES_REPO at the top of bootstrap.sh ($DotfilesRepo in bootstrap.ps1). Your git name and email are asked once and stored in config.local.toml.
What bootstrap.sh does:
curl, git, tar.~/.config/mise (or pull it), then carry on in the checkout's own bootstrap.sh, so a pull's changes apply in the same run.~/.local/bin.config.local.toml) and decide the system steps (sudo; see above).miserc.toml, then run mise bootstrap --yes (packages, /etc files, services, repos, dotfiles, the tools, the bootstrap task, then the Linux-only final hook in config.linux.toml: vcpkg, claude, fonts, which skips itself under WSL, and WezTerm's terminfo entry). The tools come from its pre-tools hook (scripts/lib/mise-install.sh: the disk check, then mise install), after the dnf batch, because node needs dnf's libatomic to start. The first run passes --force-dotfiles (why).sudo usermod -s for a local account, or, for a directory (AD/LDAP) account served by SSSD, a per-host sss_override (installing sssd-tools) and an sssd restart.It is idempotent; re-run any time. Copy your SSH key from a client with ssh-copy-id <user>@<host>.
| Flag | Meaning |
|---|---|
--reinstall | Wipe the cloned repo (including config.local.toml, so name and email are asked again), then re-bootstrap. Prompts first. Installed tools, deployed dotfiles, SSH keys and system packages are kept. |
--yes, -y | Skip the --reinstall confirmation. |
--help, -h | Usage. |
Running --reinstall from inside the repo is refused (the script would delete itself); run it from outside, with the script in memory:
curl -fsSL https://raw.githubusercontent.com/ArrushC/workstation/main/bootstrap.sh | bash -s -- --reinstall
mise run check-updates runs mise outdated --bump for every pinned tool, dnf check-update on Linux hosts, and git ls-remote for the [vars] pins. Tools tracking latest (the pypi: tools) are reported as rolling. It only reports; the weekly bump workflow (see Adding things) does the bumping.
Extras. A host's first bootstrap ends by offering the Claude Code status line (ccstatusline), also under curl | bash: use the tracked config, define one for this machine only (persisted as a per-host opt-out in config.local.toml), set a new global one (committed back to dotfiles/config/ccstatusline/settings.json), or skip. Re-run any time with mise run statusline. On native (non-WSL) Linux hosts the fonts task installs JetBrainsMono Nerd Font Mono to ~/.local/share/fonts/JetBrainsMonoNerdFontMono/ (needed for glyphs in starship, eza, lazygit, yazi, helix); WSL hosts skip it because Windows Terminal reads Windows-registered fonts. The Claude Code installer, plugins, settings merge and herdr plugin run from tasks/claude, a final hook in config.linux.toml, so they run on Linux hosts only and only on a full mise bootstrap (not wsa). Re-run with mise run fonts or mise run claude.
The Windows host is a client. No admin is needed: everything installs under your user profile (%LOCALAPPDATA%\workstation, mise's %LOCALAPPDATA%\mise, the User PATH, CurrentUser PSGallery, HKCU fonts). One best-effort exception: SSHFS-Win depends on WinFsp, a kernel driver, so its first install raises UAC (two prompts on a host without WinFsp: one for WinFsp, one for SSHFS-Win); decline them or pass -SkipElevated and everything else still completes.
Prerequisites: Git (the script hard-fails with a link if it is missing; winget install Git.Git), a working curl.exe (curl.exe --version), and room on the drive holding %LOCALAPPDATA% for the tools: disk-budget.toml's windows figure plus 1 GB, less what is already installed (about 4 GB fresh; none when every tool is installed; $env:WORKSTATION_SKIP_DISK_CHECK = '1' overrides). PowerShell 5.1 and 7 are supported. Windows HTTP downloads use curl.exe with redirects, retries and checked exit codes.
$f = "$env:TEMP\bootstrap.ps1"; curl.exe -fsSL --retry 3 -o $f https://raw.githubusercontent.com/ArrushC/workstation/main/bootstrap.ps1
if ($?) { powershell -NoProfile -ExecutionPolicy Bypass -File $f }
Paste both lines into PowerShell. The script runs in its own Windows PowerShell process with the execution policy bypassed for that process only, so it works on a machine still at the default Restricted policy, and a failure leaves your window open with the error. Flags go at the end of the second line (... -File $f -SkipKeyGen). Re-run the same two lines any time, or .\bootstrap.ps1 from the clone if your execution policy allows scripts.
What bootstrap.ps1 does:
curl.exe and git (it never installs Git).%USERPROFILE%\.config\mise (or pull it), then carry on in the checkout's own bootstrap.ps1, so a pull's changes apply in the same run.%LOCALAPPDATA%\workstation\mise. mise is the only version bootstrap.ps1 pins; every CLI tool is a mise pin in config*.toml.miserc.toml (windows), ask once for your git name and email, check that mise loads config.windows.toml (it stops otherwise: the Windows dotfiles and apps would be skipped silently), and run mise bootstrap --only dotfiles,tools: the dotfiles plus every CLI tool (gh, Starship, Helix, Nushell, jq, OpenCode, omp, jcode, DevToys CLI, dnGrep, LogExpert, Node, Go, uv, gopls, language servers, ccstatusline, oh-my-mermaid, archify). The first run passes --force-dotfiles.mise prune and mise reshim. A changed .wslconfig prints the wsl --shutdown reminder. mise bootstrap's post-tools hook (config.windows.toml) runs mise run nu-init, which regenerates Nushell's init files (starship, mise, zoxide, atuin) in %APPDATA%\nushell\vendor\autoload, so wsu refreshes them too. The same hook merges ~/.claude/settings.json (scripts/claude-settings-merge.ps1), so a change to settings.enforced.json, such as the theme or a plugin, reaches Windows with wsu. Its mise.nu builds on each session's own PATH rather than the PATH mise activate nu saw, so Nushell finds what its terminal passes down, as PowerShell does.mise bootstrap --only packages installs config.windows.toml's [bootstrap.packages] winget list (Windows Terminal, Warp, Obsidian, DevToys, DBeaver, WinSCP, Beyond Compare, Zed; latest, checked against the winget manifest's sha256, each self-updating). winget's own settings.json, a tracked dotfile, prefers per-user installers; Zed's only installer is machine scope but installs per-user, without admin. An app counts as installed when winget list --id <Id> --exact finds it. DevToys is the Microsoft Store build (9NBN8W1DS547). Then SSHFS-Win (UAC). Without winget (App Installer) the step warns and skips.~\.local\bin goes on the User PATH), merge ~/.claude/settings.json again (for -SkipToolInstall, which runs no hook) and seed settings.local.json.wpy Python env (a uv venv on mise's python with the libraries in scripts/python-env.txt) and install the Nerd Font (mise's github:ryanoasis/nerd-fonts, registered per-user by scripts/install-nerd-fonts.ps1 with a logon task that re-activates it).Restart the shell afterwards so the new profile loads. Nushell is the default local shell (in Windows Terminal and Zed, both through mise's shim, %LOCALAPPDATA%\mise\shims\nu.exe); PowerShell stays for .NET/COM/registry tasks. Zed's SSH remote terminals use the remote's login shell instead: Zed sends its settings to the remote, so the Nushell setting sits under its "windows" key. Nushell's command-line colours match zsh's on the Linux hosts (commands green, unknown commands red, quoted strings yellow, flags peach, paths underlined): autoload/syntax-highlight.nu overrides the vendored Catppuccin theme's, and lint keeps the two in step. One gap: Nushell gives every argument of an external command (ssh, git) one colour, so its flags and quoted strings stay plain where zsh colours them. atuin's own background jobs don't light starship's jobs gear (scripts/nu-init.nu labels them). Both shells carry the zsh toolbelt (wsh lists it):
z/zi (zoxide), l/la/ll/lt/lta/ltl (eza), y (yazi), fssh (pick an SSH host from ~/.ssh/config and the files it Includes, so config.local's too; the same on Linux) and hidebootstrap.ps1's own flags), as wide as the pane allows, so a long path shows the part that differs; an alias completes as its expansion, so gco <Tab> offers branches, and wse/wsd complete the tracked targets; Ctrl-O edits the command line in Helix; a command that isn't found but mise installs is named, with the way out; ls shows timestamps as ll doesnu-init writes both shells' tool init after every tools phase, and mise's prompt hook runs only when a config file, an installed tool or a MISE_* variable changes (WORKSTATION_MISE_GATE=0 turns that check off), so a prompt costs milliseconds rather than a mise.exe startgc/gp/gl run git (PowerShell's built-in aliases are removed first), and the prompt shows a failed command's exit coderm goes to the Recycle Bin (rm --permanent skips it). The theme is Catppuccin Mocha, vendored into %APPDATA%\nushell\autoload\; ls, eza and the completion menu use the Linux hosts' LS_COLORS (from dotfiles/dircolors), and PowerShell's command line has zsh's colours.
| Flag | Meaning |
|---|---|
-RepoPath <dir> | Clone somewhere other than %USERPROFILE%\.config\mise. |
-SkipKeyGen | Skip the SSH-key prompt. |
-SkipToolInstall | Skip mise, the mise tools phase, the winget GUI apps, the Python env and Claude Code (assume present; the post-tools hook, which runs nu-init, the Neovim plugins, the archify skill copy and the WezTerm nightly install, doesn't run either). |
-SkipDotfiles | Clone and install tools but do not apply dotfiles. On a host's first run this also skips winget's settings.json (the per-user preference), so the GUI apps install with winget's default scope. |
-SkipBurntToast | Skip the BurntToast PowerShell module install. |
-SkipNerdFonts | Skip the Nerd Font install. |
-SkipElevated | Skip SSHFS-Win/WinFsp, the one UAC prompt. Warp's VC++ runtime dependency can also raise UAC on a host without that runtime; this flag does not cover it. |
-Reinstall | Wipe the cloned repo, then re-bootstrap. Prompts unless -Yes. |
-Yes | Skip confirmation prompts (-Reinstall). |
-Reinstall from inside the repo is refused, since the script would delete itself; the download above runs a copy from %TEMP%, so put -Reinstall at the end of its second line.
Health and updates. There is no report mode in bootstrap.ps1. Use mise doctor, mise bootstrap status and mise dot status for the tools, host state and dotfiles. wsu updates the dotfiles and every CLI tool. The GUI apps are config.windows.toml's [bootstrap.packages]: mise bootstrap packages status lists them as installed or missing, mise bootstrap packages apply --manager winget installs the missing ones, and mise bootstrap packages upgrade --manager winget updates them. To add one, add a "winget:<Id>" = "latest" line (the Id from winget search).
A mise version bump reaches Windows through wsu's pull, but the installed mise is then older than config.toml's min_version and refuses the new config, and so does every mise shim, including Windows Terminal's Nushell. Re-run .\bootstrap.ps1 from a Windows PowerShell window to install the new mise.
Windows Terminal's settings.json (Catppuccin Mocha, Nushell default profile) is a tracked copy dotfile deployed straight to the Store package's LocalState. Warp's settings.toml and keybindings.yaml (%LOCALAPPDATA%\warp\Warp\config\) and theme (%APPDATA%\warp\Warp\data\themes\) are tracked too. Both apps rewrite their own files, so record changes made in their UIs with wsr. The generated parts are separate: Warp workstation-*.toml Tab Configs in %APPDATA%\warp\Warp\data\tab_configs\, and the Windows Terminal fragment under %LOCALAPPDATA%\Microsoft\Windows Terminal\Fragments\workstation\. Your own Tab Configs and profiles are never touched.
SSH host launchers. Every concrete Host alias in the git-ignored ~/.ssh/config.local becomes an SSH: <alias> Warp Tab Config and Windows Terminal profile that runs ssh -t <alias> zellij attach --create main. Patterns and ! negations are skipped. WezTerm lists the same hosts in its launcher, and CTRL+ALT+S there adds, edits or deletes one without opening the file (see Terminals). Re-run .\bootstrap.ps1 after a change, by hand or from WezTerm, then restart Warp or Windows Terminal.
Example: Host build1 with HostName build1.example.com in %USERPROFILE%\.ssh\config.local.
WSL distros are ordinary managed Linux hosts, reached through the AlmaLinux-9 profile in Windows Terminal or Warp (or wsl.exe). bootstrap.sh detects WSL ($WSL_DISTRO_NAME, /proc/version) and the rest of the flow is unchanged. Inside the distro:
sudo dnf install -y curl git tar python3 python3-pip # RHEL / AlmaLinux / Fedora
sudo apt install -y curl git tar python3 python3-pip # Debian / Ubuntu
curl -fsSL https://raw.githubusercontent.com/ArrushC/workstation/main/bootstrap.sh | bash
Run it where you have sudo (the usual case); without it the system steps, /etc/wsl.conf included, are skipped. Two files configure WSL and the repo owns both:
/etc/wsl.conf (per-distro: systemd, automount, interop, default user, appendWindowsPath=false): deployed on Linux hosts by config.linux.toml from configs/wsl/wsl.conf (on a non-WSL host nothing reads it).%USERPROFILE%\.wslconfig (VM memory, CPU, networking, all distros): deployed on the Windows host by a copy dotfile from dotfiles/wslconfig. It enables autoMemoryReclaim=gradual and pre-opts into sparseVhd=true.Both need wsl --shutdown from a Windows terminal to take effect. bootstrap.ps1 prints that reminder when .wslconfig changes; on Linux, remember it yourself after a /etc/wsl.conf change. WSL tabs open in ~ because the WSL Terminal fragment launches wsl.exe -d AlmaLinux-9 --cd ~. Every tracked shell exports COLORTERM=truecolor, so 24-bit color works in Windows Terminal. Keep long-lived work in a zellij session.
Warp is the day-to-day Windows terminal and opens into AlmaLinux-9 (WSL zsh). Its "+" menu (CTRL+ALT+L) lists the generated Tab Configs. Windows Terminal stays fully configured because Warp cannot run Nushell (its supported shells are PowerShell, WSL2 and Git Bash) and cannot take Windows' default-terminal role. Warp skips the shell-side fzf, atuin, starship, shift-select and fzf-tab wiring (TERM_PROGRAM=WarpTerminal), since its own editor handles those. The pane mnemonics follow zellij's pane mode (d = down, r = right) so muscle memory transfers between local panes and SSH plus zellij.
| Warp | Action |
|---|---|
| ALT+SHIFT+D / R | Split pane down / right |
| ALT+SHIFT+arrows | Move between panes |
| CTRL+SHIFT+Z | Toggle pane maximize |
| CTRL+= / - / 0 | Font size bigger / smaller / reset |
| CTRL+R | Command history search |
| CTRL+↑/↓ | Select previous / next block |
| ALT+↑/↓ | Jump between bookmarked blocks |
| CTRL+SHIFT+P | Command palette |
| CTRL+ALT+L | Tab Config palette |
| Windows Terminal | Action |
|---|
| ALT+SHIFT+D / R | Spl
hooks/register.ts 170 lines1import type { Register } from 'claude-code'
2
3// /ship [pr]: squash-merge a PR only once the checks GitHub shows for it really cover it.
4// GitHub doesn't re-run a PR's checks when its base moves, so a green PR can predate a
5// change on main (a reused bot branch did, .claude/memory/project-version-bumps-pr-stale-
6// checks.md). The checks must have passed on the PR's head, and have started after the
7// base's last commit unless the head already holds it; `--match-head-commit` then refuses
8// the merge if the head moved meanwhile. Only a /ship the person typed merges: a command
9// the model or another plugin runs is refused.
10// /ship loads from this checkout's .claude/skills/workstation-ship, so a PR that changes
11// that folder changes /ship itself: once the checkout has the merge (gh pr merge switches
12// to the base and pulls it when run on the PR's branch), Claude Code reloads /ship and any
13// call it makes after that is refused. For such a PR it runs nothing after the merge.
14
15type Pr = {
16 number: number
17 title: string
18 state: string
19 isDraft: boolean
20 headRefName: string
21 headRefOid: string
22 baseRefName: string
23 mergeStateStatus: string
24 files?: { path: string }[]
25}
26type CheckRun = { name: string; status: string; conclusion: string | null; started_at: string }
27
28const PR_FIELDS = 'number,title,state,isDraft,headRefName,headRefOid,baseRefName,mergeStateStatus,files'
29const PASSED = new Set(['success', 'neutral', 'skipped'])
30const MERGEABLE = new Set(['CLEAN', 'HAS_HOOKS'])
31const WHY_NOT: Record<string, string> = {
32 BEHIND: 'the branch must be brought up to date',
33 BLOCKED: 'a required review or check is missing',
34 DIRTY: 'it conflicts with its base',
35 DRAFT: 'it is a draft',
36 UNKNOWN: 'GitHub is still working it out; try again in a moment',
37 UNSTABLE: 'a check is failing',
38}
39const short = (sha: string) => sha.slice(0, 7)
40const clock = (iso: string) => iso.slice(11, 16)
41
42// A refusal: its message is what the person reads.
43class Stop extends Error {}
44
45export const register: Register = on => {
46 on('session.start', async ($, e, next) => {
47 await $.command.register({
48 name: 'ship',
49 description: "Squash-merge this branch's PR (or PR n) once its checks passed on its head after main last moved, then pull main",
50 argumentHint: '[pr number]',
51 })
52 return next(e)
53 })
54
55 on('command.run', { command: 'ship' }, async ($, e) => {
56 if (e.origin.kind !== 'composer' && e.origin.kind !== 'bridge') {
57 return { text: `/ship merges only when you type it at the prompt; this run came from ${e.origin.kind}, so nothing was merged.` }
58 }
59
60 const done: string[] = []
61 let header = '/ship'
62 let isMerged = false
63 const must = async (argv: string[], timeoutMs = 60_000) => {
64 const ran = await $.process.run(argv, { timeoutMs })
65 if (ran.exitCode !== 0) throw new Stop(`${argv.slice(0, 3).join(' ')} failed: ${(ran.stderr || ran.stdout).trim()}`)
66 return ran.stdout
67 }
68 const json = async <T>(argv: string[]) => JSON.parse(await must(argv)) as T
69 const api = <T>(path: string) => json<T>(['gh', 'api', `repos/{owner}/{repo}/${path}`])
70
71 try {
72 if ((await must(['git', 'status', '--porcelain', '--untracked-files=no'])).trim()) {
73 throw new Stop('the working tree has uncommitted changes; commit or stash them first')
74 }
75 const which = e.args.trim().replace(/^#/, '')
76 if (which && !/^\d+$/.test(which)) throw new Stop(`"${e.args.trim()}" is not a PR number`)
77 const view = await $.process.run(['gh', 'pr', 'view', ...(which ? [which] : []), '--json', PR_FIELDS], { timeoutMs: 60_000 })
78 if (view.exitCode !== 0) {
79 const none = /no pull requests found for branch "([^"]+)"/.exec(view.stderr)
80 if (none) throw new Stop(`no open PR for ${none[1]}; switch to a PR's branch or use /ship <number>`)
81 throw new Stop(`gh pr view failed: ${(view.stderr || view.stdout).trim()}`)
82 }
83 const pr = JSON.parse(view.stdout) as Pr
84 const { number, headRefOid: head, baseRefName: base } = pr
85 header = `/ship #${number} ${pr.title}`
86 if (pr.state !== 'OPEN') throw new Stop(`#${number} is ${pr.state.toLowerCase()}`)
87 if (pr.isDraft) throw new Stop(`#${number} is a draft`)
88 done.push('✓ open, not a draft; working tree clean')
89
90 const { check_runs: runs } = await api<{ check_runs: CheckRun[] }>(`commits/${head}/check-runs?per_page=100`)
91 const latest = new Map<string, CheckRun>()
92 for (const run of runs) {
93 const seen = latest.get(run.name)
94 if (!seen || run.started_at > seen.started_at) latest.set(run.name, run)
95 }
96 const checks = [...latest.values()]
97 if (checks.length === 0) throw new Stop(`no checks ran on ${short(head)}`)
98 const running = checks.filter(c => c.status !== 'completed').map(c => c.name)
99 if (running.length > 0) throw new Stop(`still running: ${running.join(', ')}`)
100 const failed = checks.filter(c => !PASSED.has(c.conclusion ?? '')).map(c => c.name)
101 if (failed.length > 0) throw new Stop(`failed: ${failed.join(', ')}`)
102 const status = await api<{ state: string; total_count: number }>(`commits/${head}/status`)
103 if (status.total_count > 0 && status.state !== 'success') throw new Stop(`the commit status is ${status.state}`)
104
105 const firstStart = checks.map(c => c.started_at).sort()[0] ?? ''
106 const { behind_by: behind } = await api<{ behind_by: number }>(`compare/${base}...${head}`)
107 let fresh = `which holds ${base}`
108 if (behind > 0) {
109 const tip = await api<{ sha: string; commit: { committer: { date: string } } }>(`commits/${base}`)
110 const moved = tip.commit.committer.date
111 if (firstStart < moved) {
112 throw new Stop(
113 `${base} moved (${short(tip.sha)} at ${clock(moved)}) after CI started (${clock(firstStart)}), so the checks predate it; update the branch or re-run CI`,
114 )
115 }
116 fresh = `started after ${base}'s ${short(tip.sha)}`
117 }
118 done.push(`✓ ${checks.length} checks passed on ${short(head)}, ${fresh}`)
119
120 if (!MERGEABLE.has(pr.mergeStateStatus)) {
121 const why = WHY_NOT[pr.mergeStateStatus]
122 throw new Stop(`GitHub won't merge it yet (${pr.mergeStateStatus}${why ? `: ${why}` : ''})`)
123 }
124 done.push(`✓ GitHub says it can merge (${pr.mergeStateStatus})`)
125
126 const ownFolder = `.claude/skills/${$.plugin.name}/`
127 const changesShip = (pr.files ?? []).some(f => f.path.startsWith(ownFolder))
128 const branchBefore = (await must(['git', 'rev-parse', '--abbrev-ref', 'HEAD'])).trim()
129
130 const merge = await $.process.run(
131 ['gh', 'pr', 'merge', String(number), '--squash', '--delete-branch', '--match-head-commit', head],
132 { timeoutMs: 120_000 },
133 )
134 if (merge.exitCode !== 0) {
135 // The merge can land and the branch clean-up fail: ask GitHub which it was.
136 const after = await json<{ state: string }>(['gh', 'pr', 'view', String(number), '--json', 'state'])
137 if (after.state !== 'MERGED') throw new Stop(`gh pr merge failed: ${(merge.stderr || merge.stdout).trim()}`)
138 isMerged = true
139 done.push(`✓ squash-merged; ! ${(merge.stderr || merge.stdout).trim()}`)
140 } else {
141 isMerged = true
142 done.push('✓ squash-merged; branch deleted')
143 }
144
145 if (changesShip) {
146 done.push(
147 branchBefore === pr.headRefName
148 ? `✓ gh switched to ${base} and pulled it; the PR changes /ship, so Claude Code reloads it`
149 : `! the PR changes /ship, so ${base} wasn't pulled (that reloads /ship mid-command): git pull --ff-only --prune`,
150 )
151 return { text: [header, ...done].join('\n') }
152 }
153
154 const branch = (await must(['git', 'rev-parse', '--abbrev-ref', 'HEAD'])).trim()
155 if (branch === base) {
156 await must(['git', 'pull', '--ff-only', '--prune'], 120_000)
157 done.push(`✓ ${base} pulled: ${(await must(['git', 'rev-parse', '--short', 'HEAD'])).trim()}`)
158 } else {
159 await must(['git', 'fetch', '--prune'], 120_000)
160 done.push(`✓ fetched; you're on ${branch}, so ${base} wasn't pulled`)
161 }
162 return { text: [header, ...done].join('\n') }
163 } catch (err) {
164 const reason = err instanceof Error ? err.message : String(err)
165 const verdict = isMerged ? `! merged, but ${reason}` : `✗ not merged: ${reason}`
166 return { text: [header, ...done, verdict].join('\n') }
167 }
168 })
169}
170