A departure board for gh stack: live stack pane, a one-line status above the prompt, and raw force-pushes, rebases and PR base edits on stacked branches…


The band above the prompt, the /stack departure board, and a raw force-push refused with the gh stack alternative. MP4
A Claude Code mod that acts as a departure board for gh stack, GitHub's official stacked-PR extension.
/stack opens a pane listing the stack top to bottom. For each branch it shows the PR number, CI checks (✓ ✗ …), and whether the branch is ready, needs a rebase, is queued or has landed. It also shows unpushed commits (↑, or unpublished for a branch that has never been pushed) and uncommitted changes (✎). A refresh button updates it.🛫 stack 2/4 · feature-b · 1 needs rebase.gh stack command to use instead and why. The repo's stack file (<git-common-dir>/gh-stack) decides what is protected: every branch of every stack, and their trunks, are protected from force-pushes and deletes, whichever branch you're on. Rebase, pull and PR rules apply when you're on a stacked branch.| Raw command | Use instead |
|---|---|
git push --force / -f / --force-with-lease / +refspec / --mirror / glob refspecs / --delete hitting a stack branch | gh stack push, gh stack submit --auto, gh stack sync --prune |
git rebase onto the trunk, a layer or @{u} (also --onto=, or a bare git rebase) | gh stack rebase (--downstack, --upstack, --no-trunk) |
git pull that would rebase or merge (flags and pull.rebase / pull.ff config; fast-forward-only pulls pass) | gh stack sync |
gh pr edit --base on a stack PR | gh stack submit --auto / gh stack modify |
gh pr create for a stack branch | gh stack submit --auto |
These commands still run, with a note attached for the model:
git rebase -i HEAD~3) runs, with a reminder to follow up with gh stack rebase --upstack.git rebase --continue, --abort and --skip run, with a reminder about the gh stack rebase equivalents.These pass untouched:
--repo)The guard reads the Bash line the way a shell would. It looks inside:
$( … ) is checked.;, &&, || and |( … ), groups { … }, if/while bodies, and $( … ) / backtick substitutionsbash -c '…' and evalenv, sudo, nohup, time, nice, command, exec, xargs, timeoutcd / pushd (including ~ and options), env -C, sudo -D, git -C, --git-dir and --work-tree. A cd inside a pipeline, a background job or an if body is handled the way the shell handles it. It also remembers where the Bash tool's persistent shell is across calls.It fails closed in four cases, but only in repos that use gh stack:
cd leaves the directory unknowable (cd -, cd $X, a cd that may not have run). Git steps are refused until a literal cd settles it.The guard also follows:
remote.<name>.push, remote.<name>.mirror) and push.default, including upstreambranch.<name>.rebase, pull.rebase and pull.ff, plus git -c overridesGIT_DIR / GIT_WORK_TREE prefixesgit rebase <upstream> <branch>, which rewrites <branch> whatever HEAD isKnown limits, as a safety net and not a sandbox:
$GIT push -f)bash script.sh / source with no git text on the linegit reset --hard and git merge on a stacked branch, which aren't checked"Needs rebase" combines gh stack's own flag with a check that each layer contains its parent layer's tip. Repos that have never used gh stack (no stack file) are never looked into further, so the guard costs nothing there.
gh extension install github/gh-stack/plugin marketplace add ccdwyer/claude-mods
/plugin install stack-traffic-control@ccdwyer-mods
/reload-plugins
claude plugin validate .
claude plugin test .
Events this mod hooks, as claude plugin validate reads the module:
session.startcommand.run{command=stack}turn.completetool.call{tool=Bash}ui.render{component=AbovePrompt}ui.render{component=PanerequestId=stack-board}Engine calls it makes: $.clock.now (via lookAt, stackAt), $.command.register, $.env.get (via shellContext), $.process.run (via run), $.state.get, $.state.set, $.ui.open, $.ui.resolve.
A tool.call hook sits in the middle of every tool call: it can see the call, refuse it, or add context to its result. This mod uses that only for the behaviour described above.
It uses your existing GitHub CLI (gh, with the gh stack extension) to read your stack and pull request status from GitHub, under your own account. Nothing is sent anywhere else.
The mod collects no analytics or telemetry, and its author receives no data from it.
Full policy: PRIVACY.md.
MIT
hooks/register.tsx 870 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { BranchDetail, Stack, StackBranch } from '../types'
5import type { Context } from './shell'
6import { UNKNOWN_DIR, isUnknown, joinDir, parse, resolvePath } from './shell'
7
8const stacks = atom({ plugin: 'stack-traffic-control', key: 'stacks' } as const, {})
9const root = atom({ plugin: 'stack-traffic-control', key: 'root' } as const, null)
10const board = atom({ plugin: 'stack-traffic-control', key: 'board' } as const, null)
11const loading = atom({ plugin: 'stack-traffic-control', key: 'loading' } as const, false)
12const pending = atom({ plugin: 'stack-traffic-control', key: 'pending' } as const, false)
13const shellDir = atom({ plugin: 'stack-traffic-control', key: 'shellDir' } as const, null)
14
15const PANE = 'stack-board'
16// How long a look stays good for the band. The guard always looks fresh.
17const TTL_MS = 30_000
18
19type Engine = EngineInterface
20type Ran = { exitCode: number; stdout: string; stderr: string; threw: boolean }
21
22async function run($: Engine, argv: string[], cwd?: string, timeoutMs = 8_000): Promise<Ran> {
23 try {
24 const r = await $.process.run(argv, { cwd, timeoutMs })
25 return { exitCode: r.exitCode, stdout: r.stdout, stderr: r.stderr, threw: false }
26 } catch (err) {
27 return { exitCode: -1, stdout: '', stderr: String(err).slice(0, 160), threw: true }
28 }
29}
30
31// The repo root at `cwd`: a path, `null` when it is certainly not a repo, or `undefined` when the check failed.
32async function repoRoot($: Engine, cwd?: string): Promise<string | null | undefined> {
33 const top = await run($, ['git', 'rev-parse', '--show-toplevel'], cwd, 3_000)
34 if (top.exitCode === 0) return top.stdout.trim()
35 if (/not a git repository|cannot change to|No such file or directory|Not a directory/i.test(top.stderr)) return null
36 return undefined
37}
38
39const lines = (text: string) => text.split('\n').map(l => l.trim()).filter(l => l !== '')
40
41// The truth about one checkout. The stack file is the authority: every branch
42// of every stack it lists is protected, whatever `gh stack view` says.
43async function lookAt($: Engine, at: string): Promise<Stack> {
44 const now = await $.clock.now()
45 const head = await run($, ['git', 'symbolic-ref', '--quiet', '--short', 'HEAD'], at, 3_000)
46 const branch = head.exitCode === 0 ? head.stdout.trim() : ''
47 const base: Stack = {
48 root: at, status: 'none', reason: '', branch, protectedBranches: [], trunks: [], trunk: '', branches: [],
49 remotes: [], config: {}, at: now,
50 }
51 const unknown = (reason: string): Stack => ({ ...base, status: 'unknown', reason })
52
53 const common = await run($, ['git', 'rev-parse', '--git-common-dir'], at, 3_000)
54 if (common.exitCode !== 0) return unknown(`git rev-parse --git-common-dir failed${common.stderr ? `: ${common.stderr.trim()}` : ''}`)
55 const file = `${joinDir(at, common.stdout.trim())}/gh-stack`
56 const exists = await run($, ['test', '-e', file], at, 3_000)
57 // Exit 1 is a firm "nothing there": gh stack has never been used in this repo.
58 if (exists.exitCode === 1) return base
59 if (exists.exitCode !== 0) return unknown(`could not check for the stack file (${exists.threw ? exists.stderr : `exit ${exists.exitCode}`})`)
60
61 const raw = await run($, ['cat', file], at, 3_000)
62 if (raw.exitCode !== 0) return unknown('could not read the stack file')
63 const catalog = readCatalog(raw.stdout)
64 if (typeof catalog === 'string') return unknown(catalog)
65
66 const protectedBranches = [...new Set(catalog.flatMap(st => st.branches))]
67 const trunks = [...new Set(catalog.map(st => st.trunk).filter(t => t !== ''))]
68 const mine = catalog.find(st => branch !== '' && st.branches.includes(branch))
69
70 const [remotes, settings] = await Promise.all([
71 run($, ['git', 'remote'], at, 3_000),
72 run($, ['git', 'config', '--get-regexp', CONFIG_KEYS], at, 3_000),
73 ])
74 const config: Record<string, string[]> = {}
75 if (settings.exitCode === 0) {
76 for (const line of settings.stdout.split('\n')) {
77 const space = line.indexOf(' ')
78 if (line.trim() === '') continue
79 const key = (space === -1 ? line : line.slice(0, space)).toLowerCase()
80 const val = space === -1 ? '' : line.slice(space + 1)
81 ;(config[key] ??= []).push(val)
82 }
83 }
84
85 let branches: StackBranch[] = []
86 if (mine !== undefined) {
87 // `gh stack view` adds merged / queued / needs-rebase for the current stack; the file alone does not.
88 const view = await run($, ['gh', 'stack', 'view', '--json'], at)
89 const flags = new Map<string, Record<string, unknown>>()
90 if (view.exitCode === 0) {
91 try {
92 const parsed = JSON.parse(view.stdout) as { branches?: unknown }
93 if (Array.isArray(parsed.branches)) {
94 for (const b of parsed.branches as Array<Record<string, unknown>>) flags.set(String(b.name ?? ''), b)
95 }
96 } catch {
97 // The file already told us what to protect; the flags are only for the board.
98 }
99 }
100 const layers = mine.branches.map(name => ({
101 name,
102 isMerged: flags.get(name)?.isMerged === true,
103 isQueued: flags.get(name)?.isQueued === true,
104 flagged: flags.get(name)?.needsRebase === true,
105 }))
106 branches = await Promise.all(
107 layers.map(async (b, i) => {
108 if (b.isMerged) return { name: b.name, isMerged: true, isQueued: b.isQueued, needsRebase: false }
109 // The active parent skips merged and queued layers below, down to the trunk.
110 let k = i - 1
111 while (k >= 0 && (layers[k]?.isMerged || layers[k]?.isQueued)) k -= 1
112 const parent = k >= 0 ? (layers[k]?.name as string) : mine.trunk
113 const check = await run($, ['git', 'merge-base', '--is-ancestor', parent, b.name], at, 3_000)
114 // Exit 1 is a firm no; anything else (a missing ref, a timeout) leaves gh's flag to decide.
115 return { name: b.name, isMerged: false, isQueued: b.isQueued, needsRebase: b.flagged || check.exitCode === 1 }
116 }),
117 )
118 }
119
120 return {
121 ...base,
122 status: 'stacked',
123 protectedBranches,
124 trunks,
125 trunk: mine?.trunk ?? '',
126 branches,
127 remotes: remotes.exitCode === 0 ? lines(remotes.stdout) : [],
128 config,
129 }
130}
131
132const CONFIG_KEYS = '^(remote\\..*\\.(push|mirror)|remote\\.pushdefault|push\\.default|pull\\.(rebase|ff)|branch\\..*\\.(merge|rebase|remote|pushremote))$'
133
134// The stack file's catalog, checked against the shape gh stack writes; a string says why it was refused.
135function readCatalog(text: string): Array<{ trunk: string; branches: string[] }> | string {
136 let parsed: unknown
137 try {
138 parsed = JSON.parse(text)
139 } catch {
140 return 'the stack file is not readable JSON'
141 }
142 const stacksOf = (parsed as { stacks?: unknown } | null)?.stacks
143 if (!Array.isArray(stacksOf)) return 'the stack file has no stacks list'
144 const out: Array<{ trunk: string; branches: string[] }> = []
145 for (const st of stacksOf) {
146 const trunk = (st as { trunk?: { branch?: unknown } } | null)?.trunk?.branch
147 const list = (st as { branches?: unknown } | null)?.branches
148 if (typeof trunk !== 'string' || trunk === '' || !Array.isArray(list)) return 'the stack file has a stack without a trunk or branches'
149 const names: string[] = []
150 for (const b of list) {
151 const name = (b as { branch?: unknown } | null)?.branch
152 if (typeof name !== 'string' || name === '') return 'the stack file has a branch without a name'
153 names.push(name)
154 }
155 out.push({ trunk, branches: names })
156 }
157 return out
158}
159
160// The last value of a git config key: a `-c` on the command wins over the repo's config.
161function setting(s: Stack, overrides: Record<string, string>, key: string): string | undefined {
162 const k = key.toLowerCase()
163 if (k in overrides) return overrides[k]
164 const all = s.config[k]
165 return all === undefined ? undefined : all[all.length - 1]
166}
167
168// The checkout at `cwd`; `null` when it is not a git repo.
169async function stackAt($: Engine, cwd: string | undefined, fresh: boolean): Promise<Stack | null> {
170 const at = await repoRoot($, cwd)
171 if (at === null) return null
172 if (at === undefined) {
173 return {
174 root: cwd ?? '', status: 'unknown', reason: 'git could not say whether this is a repo', branch: '', protectedBranches: [],
175 trunks: [], trunk: '', branches: [], remotes: [], config: {}, at: await $.clock.now(),
176 }
177 }
178 if (!fresh) {
179 const cached = (await read($, stacks))[at]
180 if (cached !== undefined && (await $.clock.now()) - cached.at < TTL_MS) return cached
181 }
182 const look = await lookAt($, at)
183 await update($, stacks, all => ({ ...all, [at]: look }))
184 // A look at the session's own checkout is what the band draws.
185 if (cwd === undefined) await update($, root, () => at)
186 return look
187}
188
189const onStack = (s: Stack) => s.branch !== '' && s.branches.some(b => b.name === s.branch)
190
191// ---- The board ------------------------------------------------------------
192
193const FAILED = ['FAILURE', 'ERROR', 'CANCELLED', 'TIMED_OUT', 'ACTION_REQUIRED', 'STARTUP_FAILURE']
194const NEUTRAL = ['SKIPPED', 'NEUTRAL']
195
196function checksOf(rollup: unknown): BranchDetail['checks'] {
197 if (!Array.isArray(rollup) || rollup.length === 0) return 'none'
198 const states = rollup.map(c => {
199 const one = c as { conclusion?: unknown; state?: unknown; status?: unknown }
200 const done = String(one.status ?? '').toUpperCase()
201 // A check run still going has no conclusion yet.
202 if (done !== '' && done !== 'COMPLETED') return 'PENDING'
203 return String(one.conclusion || one.state || '').toUpperCase()
204 })
205 if (states.some(s => FAILED.includes(s))) return 'fail'
206 if (states.some(s => s !== 'SUCCESS' && !NEUTRAL.includes(s))) return 'pending'
207 return states.includes('SUCCESS') ? 'pass' : 'none'
208}
209
210async function detailOf($: Engine, at: string, name: string, isCurrent: boolean): Promise<BranchDetail> {
211 const [pr, ahead, status] = await Promise.all([
212 run($, ['gh', 'pr', 'view', name, '--json', 'number,state,statusCheckRollup'], at, 10_000),
213 run($, ['git', 'rev-list', '--count', `${name}@{upstream}..${name}`], at, 3_000),
214 isCurrent ? run($, ['git', 'status', '--porcelain'], at, 3_000) : Promise.resolve(null),
215 ])
216 let info: { number?: unknown; state?: unknown; statusCheckRollup?: unknown } = {}
217 if (pr.exitCode === 0) {
218 try {
219 info = JSON.parse(pr.stdout)
220 } catch {
221 info = {}
222 }
223 }
224 let unpushed: number | null = null
225 let published: boolean | null = null
226 if (ahead.exitCode === 0) {
227 unpushed = Number(ahead.stdout.trim())
228 published = true
229 } else {
230 const upstream = await run($, ['git', 'rev-parse', '--verify', '--quiet', `${name}@{upstream}`], at, 3_000)
231 if (upstream.exitCode === 1) published = false
232 }
233 const hasPr = typeof info.number === 'number'
234 return {
235 pr: hasPr ? (info.number as number) : null,
236 state: typeof info.state === 'string' ? info.state : null,
237 checks: hasPr ? checksOf(info.statusCheckRollup) : null,
238 unpushed,
239 published,
240 dirty: status !== null && status.exitCode === 0 && status.stdout.trim() !== '',
241 }
242}
243
244async function refresh($: Engine) {
245 if (await read($, loading)) {
246 await update($, pending, () => true)
247 return
248 }
249 await update($, loading, () => true)
250 try {
251 for (let pass = 0; pass < 3; pass += 1) {
252 await update($, pending, () => false)
253 const saved = await read($, shellDir)
254 const where = saved === null || isUnknown(saved) ? undefined : saved
255 const stack = await stackAt($, where, true)
256 await update($, root, () => stack?.root ?? null)
257 if (stack === null || stack.status !== 'stacked' || !onStack(stack)) {
258 await update($, board, () => null)
259 } else {
260 const rows = await Promise.all(
261 stack.branches.map(async b => [b.name, await detailOf($, stack.root, b.name, b.name === stack.branch)] as const),
262 )
263 await update($, board, () => ({ root: stack.root, rows: Object.fromEntries(rows), at: stack.at }))
264 }
265 if (!(await read($, pending))) break
266 }
267 } finally {
268 await update($, loading, () => false)
269 }
270}
271
272// ---- The guard ------------------------------------------------------------
273
274type Where = { dir?: string }
275type GitOp =
276 | ({ kind: 'push'; args: string[]; cfg: Record<string, string> } & Where)
277 | ({ kind: 'rebase'; args: string[]; cfg: Record<string, string> } & Where)
278 | ({ kind: 'pull'; args: string[]; cfg: Record<string, string> } & Where)
279type PrOp =
280 | ({ kind: 'pr-edit'; args: string[]; repo?: string } & Where)
281 | ({ kind: 'pr-create'; args: string[]; repo?: string } & Where)
282type Op = GitOp | PrOp | ({ kind: 'checkout' } & Where)
283
284const GIT_GLOBAL_VALUED = new Set(['--namespace', '--config-env', '--super-prefix', '--list-cmds'])
285const STACK_MUTATORS = new Set(['checkout', 'up', 'down', 'top', 'bottom', 'switch', 'trunk', 'init', 'add', 'modify', 'unstack', 'sync', 'rebase', 'merge'])
286// `git checkout` modes that restore files and leave HEAD alone.
287const PATH_CHECKOUT = new Set(['--', '-p', '--patch', '--ours', '--theirs', '--pathspec-from-file'])
288
289// Does `git checkout <args>` move HEAD (as opposed to restoring files)?
290function checkoutMoves(args: string[]): boolean {
291 if (args.some(a => PATH_CHECKOUT.has(a) || a.startsWith('--pathspec-from-file='))) return false
292 if (args.some(a => ['-b', '-B', '--orphan', '--detach', '-'].includes(a))) return true
293 const plain = args.filter((a, k) => !a.startsWith('-') && args[k - 1] !== '--conflict')
294 return plain.length === 1 && plain[0] !== '.' && plain[0] !== '..'
295}
296
297// The repo a git command acts on: GIT_DIR / --git-dir name it outright; else -C, GIT_WORK_TREE / --work-tree, the cwd.
298function gitRepoDir(cwd: string | undefined, gitDir: string | undefined, workTree: string | undefined, ctx: Context): string | undefined {
299 if (gitDir !== undefined) {
300 const at = resolvePath(cwd, gitDir, ctx)
301 if (isUnknown(at)) return UNKNOWN_DIR
302 // `<repo>/.git` → `<repo>`; a bare repo is its own directory.
303 return at.replace(/\/\.git\/?$/, '') || '/'
304 }
305 // A work tree alone does not choose the repository: git still finds it from the cwd.
306 void workTree
307 return cwd
308}
309
310// What one simple command would do, before we know anything about the stack.
311function opOf(argv: string[], startDir: string | undefined, env: Record<string, string>, ctx: Context): Op | null {
312 const tool = argv[0]?.split('/').pop()
313 const rest = argv.slice(1)
314 if (tool === 'git') {
315 let cwd = startDir
316 let gitDir = env.GIT_DIR
317 let workTree = env.GIT_WORK_TREE
318 const cfg: Record<string, string> = {}
319 const config = (pair: string) => {
320 const eq = pair.indexOf('=')
321 const key = (eq === -1 ? pair : pair.slice(0, eq)).toLowerCase()
322 const val = eq === -1 ? 'true' : pair.slice(eq + 1)
323 // remote.<name>.push is multi-valued and adds to the file's values; the rest are last-wins.
324 cfg[key] = /^remote\..*\.push$/.test(key) && cfg[key] !== undefined ? `${cfg[key]}\n${val}` : val
325 }
326 let j = 0
327 while (j < rest.length && (rest[j] as string).startsWith('-')) {
328 const opt = rest[j] as string
329 const value = rest[j + 1]
330 if (opt === '-C') {
331 cwd = resolvePath(cwd, value ?? '.', ctx)
332 j += 2
333 } else if (opt.startsWith('-C') && opt.length > 2) {
334 cwd = resolvePath(cwd, opt.slice(2), ctx)
335 j += 1
336 } else if (opt === '-c') {
337 config(value ?? '')
338 j += 2
339 } else if (opt.startsWith('-c') && opt.length > 2) {
340 config(opt.slice(2))
341 j += 1
342 } else if (opt === '--git-dir' || opt === '--work-tree') {
343 if (opt === '--git-dir') gitDir = value ?? '.'
344 else workTree = value ?? '.'
345 j += 2
346 } else if (opt.startsWith('--git-dir=')) {
347 gitDir = opt.slice(10)
348 j += 1
349 } else if (opt.startsWith('--work-tree=')) {
350 workTree = opt.slice(12)
351 j += 1
352 } else if (GIT_GLOBAL_VALUED.has(opt)) j += 2
353 else j += 1
354 }
355 const dir = gitRepoDir(cwd, gitDir, workTree, ctx)
356 const sub = rest[j]
357 const args = rest.slice(j + 1)
358 if (sub === 'push' || sub === 'rebase' || sub === 'pull') return { kind: sub, args, cfg, dir }
359 if (sub === 'switch' || (sub === 'checkout' && checkoutMoves(args))) return { kind: 'checkout', dir }
360 return null
361 }
362 if (tool === 'gh') {
363 let repo: string | undefined
364 const words: string[] = []
365 for (let k = 0; k < rest.length; k += 1) {
366 const w = rest[k] as string
367 if (w === '-R' || w === '--repo') {
368 repo = rest[k + 1]
369 k += 1
370 } else if (w.startsWith('--repo=')) repo = w.slice(7)
371 else if (w.startsWith('-R') && w.length > 2) repo = w.slice(2)
372 else words.push(w)
373 }
374 const [noun, verb, ...args] = words
375 if (noun === 'pr' && verb === 'edit') return { kind: 'pr-edit', args, repo, dir: startDir }
376 if (noun === 'pr' && verb === 'create') return { kind: 'pr-create', args, repo, dir: startDir }
377 if (noun === 'pr' && verb === 'checkout') return { kind: 'checkout', dir: startDir }
378 if (noun === 'stack' && verb !== undefined && STACK_MUTATORS.has(verb)) return { kind: 'checkout', dir: startDir }
379 }
380 return null
381}
382
383const layerNames = (s: Stack) => s.branches.map(b => b.name)
384const stackPath = (s: Stack) => (s.trunk === '' ? '' : ` (stack: ${[s.trunk, ...layerNames(s)].join(' → ')})`)
385const isProtected = (s: Stack, name: string) => s.protectedBranches.includes(name) || s.trunks.includes(name)
386
387function isStackRef(s: Stack, ref: string | undefined): boolean {
388 if (ref === undefined) return false
389 // `@{u}` alone is the current branch's upstream: on a stacked branch, a layer or the trunk.
390 if (/^(HEAD)?@\{(u|upstream|push)\}$/i.test(ref)) return true
391 const upstreamOf = ref.match(/^(.+)@\{(u|upstream|push)\}$/i)
392 if (upstreamOf !== null) return isProtected(s, upstreamOf[1] as string)
393 return [...s.protectedBranches, ...s.trunks].some(n =>
394 ref === n || ref === `refs/heads/${n}` ||
395 s.remotes.some(r => ref === `${r}/${n}` || ref === `refs/remotes/${r}/${n}`))
396}
397
398const PUSH_VALUED = new Set(['-o', '--push-option', '--receive-pack', '--exec'])
399// Rebase options that take the next word as their value (-S/--gpg-sign only take an attached one).
400const REBASE_VALUED = new Set(['-x', '--exec', '-s', '--strategy', '-X', '--strategy-option', '-C', '--onto', '--empty', '--whitespace'])
401
402type Judgement = { deny: string } | { note: string } | null
403
404function remoteHint(s: Stack): string {
405 return s.remotes.length > 1 && setting(s, {}, 'remote.pushDefault') === undefined
406 ? ` This repo has several remotes (${s.remotes.join(', ')}) and no remote.pushDefault: add \`--remote <name>\` to the gh stack command.`
407 : ''
408}
409
410function header(s: Stack, command: string): string {
411 const shown = command.length > 160 ? `${command.slice(0, 159)}…` : command
412 const where = s.branch === '' ? 'with HEAD detached in a repo that uses gh stack' : onStack(s) ? `on stacked branch \`${s.branch}\`` : 'in a repo that uses gh stack'
413 return `Stack Traffic Control: \`${shown}\` runs ${where}${stackPath(s)}.`
414}
415
416const hasGlob = (ref: string) => /[*?[]/.test(ref)
417const isTrue = (v: string | undefined) => v !== undefined && ['true', 'yes', 'on', '1'].includes(v.toLowerCase())
418
419function judgePush(s: Stack, args: string[], cfg: Record<string, string>, command: string): Judgement {
420 let forceAll = false
421 let mirror = false
422 let all = false
423 let deleting = false
424 let repoFlag: string | undefined
425 const positional: string[] = []
426 for (let k = 0; k < args.length; k += 1) {
427 const a = args[k] as string
428 if (a === '--') {
429 positional.push(...args.slice(k + 1))
430 break
431 }
432 if (a === '--repo') {
433 repoFlag = args[k + 1] ?? ''
434 k += 1
435 continue
436 }
437 if (a.startsWith('--repo=')) {
438 repoFlag = a.slice(7)
439 continue
440 }
441 if (PUSH_VALUED.has(a)) {
442 k += 1
443 continue
444 }
445 if (a === '-f' || a === '--force' || a.startsWith('--force-with-lease') || a === '--force-if-includes') forceAll = true
446 else if (a === '--mirror') mirror = true
447 else if (a === '--all' || a === '--branches') all = true
448 else if (a === '--delete' || a === '-d') deleting = true
449 else if (/^-[a-zA-Z]+$/.test(a)) {
450 if (a.includes('f')) forceAll = true
451 if (a.includes('d')) deleting = true
452 } else if (!a.startsWith('-')) positional.push(a)
453 }
454 const deny = (what: string) => ({
455 deny: `${header(s, command)} ${what} Raw force-pushes and deletes move one layer without the others and can clobber a parent or child PR. ` +
456 'Use `gh stack push` (per-branch --force-with-lease; skips merged and queued branches), `gh stack submit --auto` ' +
457 'to also create PRs and fix their bases, or `gh stack sync --prune` to clean up merged branches.' +
458 `${remoteHint(s)} Ask the user if a raw push is really needed.`,
459 })
460 const remote = repoFlag ?? positional[0] ??
461 setting(s, cfg, `branch.${s.branch}.pushRemote`) ?? setting(s, cfg, 'remote.pushDefault') ??
462 setting(s, cfg, `branch.${s.branch}.remote`) ?? 'origin'
463 if (mirror || isTrue(setting(s, cfg, `remote.${remote}.mirror`))) return deny('A mirror push force-updates every branch, the stack included.')
464 if (all && forceAll) return deny('A forced `--all` push rewrites every branch, the stack included.')
465 let refspecs = positional.slice(repoFlag !== undefined ? 0 : 1)
466 if (refspecs.length === 0) {
467 // No refspec: the remote's configured push refspecs, else push.default decides what moves.
468 const key = `remote.${remote.toLowerCase()}.push`
469 const configured = [...(s.config[key] ?? []), ...(cfg[key]?.split('\n') ?? [])].filter(r => r !== '')
470 if (configured.length > 0) refspecs = configured
471 else {
472 if (!forceAll) return null
473 const mode = (setting(s, cfg, 'push.default') ?? 'simple').toLowerCase()
474 if (mode === 'matching') return deny('With push.default=matching, a forced push with no refspec rewrites every matching branch, the stack included.')
475 if (mode === 'nothing') return null
476 if (s.branch === '') return deny('HEAD is detached, so it is not clear which branch a forced push updates.')
477 const merge = setting(s, cfg, `branch.${s.branch}.merge`)?.replace(/^refs\/heads\//, '')
478 const target = (mode === 'upstream' || mode === 'tracking') && merge !== undefined ? merge : s.branch
479 return isProtected(s, target) ? deny(`It force-pushes \`${target}\`.`) : null
480 }
481 }
482 for (const spec of refspecs) {
483 const plus = spec.startsWith('+')
484 const body = plus ? spec.slice(1) : spec
485 const forced = forceAll || plus
486 // `:` and `+:` push every branch that exists on both sides.
487 if (body === ':') {
488 if (forced) return deny(`The matching refspec \`${spec}\` force-updates every branch the remote shares, the stack included.`)
489 continue
490 }
491 const colon = body.indexOf(':')
492 const src = colon === -1 ? body : body.slice(0, colon)
493 const dstRaw = colon === -1 ? body : body.slice(colon + 1)
494 let dst = dstRaw.replace(/^refs\/heads\//, '')
495 if (dst === 'HEAD' || dst === '@') dst = s.branch
496 if (dst.startsWith('refs/') && !dst.startsWith('refs/heads/')) continue
497 if (hasGlob(src) || hasGlob(dst)) {
498 if (forced || deleting) return deny(`The pattern \`${spec}\` can match stack branches.`)
499 continue
500 }
501 if (dst === '') {
502 if (forced) return deny('HEAD is detached, so it is not clear which branch a forced push updates.')
503 continue
504 }
505 if ((src === '' || deleting) && isProtected(s, dst)) return deny(`It deletes stack branch \`${dst}\` on the remote.`)
506 if (forced && isProtected(s, dst)) return deny(`It force-pushes stack branch \`${dst}\`.`)
507 }
508 return null
509}
510
511// The arguments of a rebase: where onto, the upstream, and the branch it checks out and rewrites.
512function rebaseArgs(args: string[]): { onto?: string; upstream?: string; branch?: string; root: boolean } {
513 let onto: string | undefined
514 const positional: string[] = []
515 for (let k = 0; k < args.length; k += 1) {
516 const a = args[k] as string
517 if (a === '--onto') {
518 onto = args[k + 1]
519 k += 1
520 } else if (a.startsWith('--onto=')) onto = a.slice(7)
521 else if (REBASE_VALUED.has(a)) k += 1
522 else if (a.startsWith('-')) continue
523 else positional.push(a)
524 }
525 const root = args.includes('--root')
526 return root ? { onto, branch: positional[0], root } : { onto, upstream: positional[0], branch: positional[1], root }
527}
528
529const REBASE_CONTROL = ['--continue', '--abort', '--quit', '--edit-todo', '--show-current-patch', '--skip']
530
531function judgeRebase(s: Stack, args: string[], command: string): Judgement {
532 if (args.some(a => REBASE_CONTROL.includes(a) && a !== '--skip')) {
533 return { note: 'If this rebase was started by `gh stack rebase`, finish it with `gh stack rebase --continue` / `--abort` instead so the cascade carries on.' }
534 }
535 if (args.includes('--skip')) {
536 return { note: '`gh stack rebase` has no --skip: skipping during a stack rebase drops a commit and stops the cascade. If gh stack started this rebase, resolve and use `gh stack rebase --continue`.' }
537 }
538 const { onto, upstream, branch, root } = rebaseArgs(args)
539 const rewritten = branch ?? s.branch
540 const deny = (what: string) => ({
541 deny: `${header(s, command)} ${what} ` +
542 'Use `gh stack rebase` (cascades the whole stack), `gh stack rebase --downstack` (trunk up to this branch), ' +
543 '`gh stack rebase --upstack` (this branch to the top) or `gh stack rebase --no-trunk` (layers only). ' +
544 `On conflicts, resolve and run \`gh stack rebase --continue\` (or \`--abort\`).${remoteHint(s)}`,
545 })
546 if (branch !== undefined) {
547 // `git rebase <upstream> <branch>` checks out and rewrites <branch>, whatever HEAD is.
548 if (!s.protectedBranches.includes(branch) && !s.trunks.includes(branch)) return null
549 return deny(`It checks out and rewrites stack branch \`${branch}\`, leaving the branches above it on the old commits.`)
550 }
551 if (!onStack(s)) return null
552 // No upstream named means the branch's upstream, which on a stacked branch is a layer or the trunk.
553 if (isStackRef(s, onto) || isStackRef(s, upstream) || (upstream === undefined && !root)) {
554 return deny(`Rebasing stacked branch \`${rewritten}\` by hand leaves the branches above it on the old commits.`)
555 }
556 return { note: `\`${rewritten}\` is stacked: after rewriting it, run \`gh stack rebase --upstack\` so the branches above follow, then \`gh stack push\`.` }
557}
558
559// The last fast-forward flag given, if any.
560function lastFf(args: string[]): 'only' | 'ff' | 'no-ff' | null {
561 let ff: 'only' | 'ff' | 'no-ff' | null = null
562 for (const a of args) {
563 if (a === '--ff-only') ff = 'only'
564 else if (a === '--ff') ff = 'ff'
565 else if (a === '--no-ff') ff = 'no-ff'
566 }
567 return ff
568}
569
570function rebaseValue(v: string | undefined): boolean | null {
571 if (v === undefined) return null
572 const low = v.toLowerCase()
573 if (['true', 'yes', 'on', '1', 'merges', 'm', 'interactive', 'i', 'preserve', 'p'].includes(low)) return true
574 if (['false', 'no', 'off', '0'].includes(low)) return false
575 return null
576}
577
578function judgePull(s: Stack, args: string[], cfg: Record<string, string>, command: string): Judgement {
579 if (!onStack(s)) return null
580 // Flags win over -c, -c over branch config, branch config over pull.* config; for flags the last one wins.
581 let rebase: boolean | null = null
582 for (const a of args) {
583 if (a === '--rebase' || a === '-r') rebase = true
584 else if (a.startsWith('--rebase=')) rebase = rebaseValue(a.slice(9)) ?? true
585 else if (a === '--no-rebase') rebase = false
586 }
587 if (rebase === null) rebase = rebaseValue(setting(s, cfg, `branch.${s.branch}.rebase`))
588 if (rebase === null) rebase = rebaseValue(setting(s, cfg, 'pull.rebase'))
589 let ff = lastFf(args)
590 if (ff === null) {
591 const conf = setting(s, cfg, 'pull.ff')?.toLowerCase()
592 ff = conf === 'only' ? 'only' : conf === undefined ? null : rebaseValue(conf) === false ? 'no-ff' : 'ff'
593 }
594 // Fast-forward only never rewrites or merges (it wins over --rebase too). With no policy at all,
595 // git refuses a divergent pull and only fast-forwards.
596 if (ff === 'only') return null
597 if (rebase === null && ff === null) return null
598 const what = rebase === true ? 'a rebase pull strands the layers above this one' : 'a merge pull puts a merge commit into a stack that must stay linear'
599 return {
600 deny: `${header(s, command)} \`git pull\` updates this layer alone: ${what}. Use \`gh stack sync\` (fetch, cascade-rebase, ` +
601 `push with lease, sync PR state), or \`gh stack rebase\`. \`git pull --ff-only\` is still allowed.${remoteHint(s)}`,
602 }
603}
604
605// `owner/name` from OWNER/REPO, HOST/OWNER/REPO, git@host:owner/name.git or https://host/owner/name(.git)(/).
606function slugOf(repo: string): string {
607 const trimmed = repo.trim().replace(/\/+$/, '').replace(/\.git$/, '').replace(/\/+$/, '')
608 const parts = trimmed.split(/[/:]/).filter(p => p !== '')
609 return parts.slice(-2).join('/').toLowerCase()
610}
611
612async function repoSlug($: Engine, at: string): Promise<string | null> {
613 const view = await run($, ['gh', 'repo', 'view', '--json', 'nameWithOwner', '-q', '.nameWithOwner'], at, 8_000)
614 return view.exitCode === 0 ? view.stdout.trim() : null
615}
616
617function flag(args: string[], short: string, long: string): string | null {
618 for (let k = 0; k < args.length; k += 1) {
619 const a = args[k] as string
620 if (a === short || a === long) return args[k + 1] ?? ''
621 if (a.startsWith(`${long}=`)) return a.slice(long.length + 1)
622 if (a.startsWith(short) && a.length > short.length && !a.startsWith('--')) return a.slice(short.length)
623 }
624 return null
625}
626
627const PR_EDIT_VALUED = new Set(['-B', '--base', '-t', '--title', '-b', '--body', '-F', '--body-file', '-m', '--milestone', '--add-assignee', '--remove-assignee', '--add-label', '--remove-label', '--add-reviewer', '--remove-reviewer', '--add-project', '--remove-project'])
628
629async function judgePr($: Engine, s: Stack, op: PrOp, command: string): Promise<Judgement> {
630 if (op.kind === 'pr-edit' && flag(op.args, '-B', '--base') === null) return null
631 if (op.repo !== undefined) {
632 const slug = await repoSlug($, s.root)
633 // Another repository's PR has nothing to do with this stack.
634 if (slug !== null && slugOf(op.repo) !== slugOf(slug)) return null
635 }
636 const stackBranch = (name: string) => s.protectedBranches.includes(name.replace(/^[^:]+:/, ''))
637 if (op.kind === 'pr-edit') {
638 const target = op.args.find((a, k) => !a.startsWith('-') && !PR_EDIT_VALUED.has(op.args[k - 1] ?? ''))
639 const deny = (what: string) => ({
640 deny: `${header(s, command)} ${what} Use \`gh stack submit --auto\` (updates every base) or \`gh stack modify\` ` +
641 `to restructure the stack.${remoteHint(s)}`,
642 })
643 if (target === undefined) return onStack(s) ? deny('PR bases in a stack are managed by gh stack; editing one by hand breaks the chain.') : null
644 const argv = ['gh', 'pr', 'view', target, '--json', 'headRefName', '-q', '.headRefName']
645 if (op.repo !== undefined) argv.push('--repo', op.repo)
646 const head = await run($, argv, s.root, 8_000)
647 if (head.exitCode === 0) {
648 return stackBranch(head.stdout.trim()) ? deny('PR bases in a stack are managed by gh stack; editing one by hand breaks the chain.') : null
649 }
650 // A base change on a PR we cannot look up, in a stack repo: it may be a layer.
651 return deny(`Couldn't look up PR \`${target}\` to check whether it is a stack layer, so its base was not changed.`)
652 }
653 const head = flag(op.args, '-H', '--head')
654 const forStack = head !== null && head !== '' ? stackBranch(head) : onStack(s)
655 if (!forStack) return null
656 return {
657 deny: `${header(s, command)} \`gh pr create\` would open a PR outside the stack, against the wrong base. ` +
658 'Use `gh stack submit --auto` (creates PRs for every branch without one, bases set, linked as a stack); ' +
659 `add \`--open\` to mark them ready for review.${remoteHint(s)}`,
660 }
661}
662
663const PROTECTED_TEXT = /\b(push|rebase|pull)\b|\bpr\b[\s\S]*\b(edit|create)\b/
664
665const short = (command: string) => (command.length > 120 ? `${command.slice(0, 119)}…` : command)
666
667async function shellContext($: Engine): Promise<Context> {
668 return { home: await $.env.get('HOME') }
669}
670
671export const register: Register = on => {
672
673 on('session.start', async ($, e, next) => {
674 await $.command.register({
675 name: 'stack',
676 description: 'Stack Traffic Control: show the current gh stack as a departure board',
677 immediate: true,
678 })
679 void refresh($)
680 return next(e)
681 })
682
683 on('command.run', { command: 'stack' }, async $ => {
684 const opened = await $.ui.open({ id: PANE, title: 'Stack' })
685 void refresh($)
686 return { text: opened.isPlaced ? 'Stack board opened.' : 'Stack board opens once the terminal is wide enough.' }
687 })
688
689 // Checkouts change between turns: keep the band honest.
690 on('turn.complete', async ($, e, next) => {
691 const ran = await next(e)
692 void refresh($)
693 return ran
694 })
695
696 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
697 const command = String(e.command ?? '')
698 const saved = await read($, shellDir)
699 const base = saved ?? undefined
700 const c = await shellContext($)
701
702 const parsed = parse(command, base, c)
703
704 // Track where the persistent shell is, for every Bash call, not only git ones.
705 const settle = async (ran: Awaited<ReturnType<typeof next>>) => {
706 if (ran.deny !== undefined || !parsed.changesDir) return ran
707 // A failed line may have stopped before or after its cd.
708 let after = ran.isError === true ? UNKNOWN_DIR : parsed.endDir
709 // `cd x; …` succeeding overall says nothing about the cd: x must exist.
710 if (after !== UNKNOWN_DIR && parsed.uncertainCd !== undefined && after === parsed.uncertainCd.target) {
711 if ((await run($, ['test', '-d', parsed.uncertainCd.target], undefined, 3_000)).exitCode !== 0) after = parsed.uncertainCd.dir
712 }
713 if (after !== base) await update($, shellDir, () => after ?? null)
714 return ran
715 }
716
717 if (!/\b(git|gh)\b/.test(command)) return settle(await next(e))
718 const notes: string[] = []
719 const looked = new Map<string, Stack | null>()
720 const look = async (dir: string | undefined) => {
721 const key = dir ?? ''
722 if (!looked.has(key)) looked.set(key, await stackAt($, dir, true))
723 return looked.get(key) ?? null
724 }
725 // Some repo this session looked at uses gh stack (or could not be checked).
726 const anyStackRepo = async () =>
727 Object.values(await read($, stacks)).some(s => s.status !== 'none') || [...looked.values()].some(s => s !== null && s.status !== 'none')
728 const unreadable = (why: string) => ({
729 deny: `Stack Traffic Control: ${why}, and this session works in a repo that uses gh stack, so \`${short(command)}\` ` +
730 'was not run. Run the git or gh step as a plain command, from a directory given literally (`cd /abs/path && …` or `git -C /abs/path …`).',
731 })
732
733 if (parsed.opaque && PROTECTED_TEXT.test(command)) {
734 const s = isUnknown(base) ? null : await look(base)
735 if ((s !== null && s.status !== 'none') || (isUnknown(base) && (await anyStackRepo()))) {
736 return unreadable("part of the line can't be read (a substitution as the program, a script, an unbalanced quote or a case statement)")
737 }
738 }
739
740 let switched = false
741 for (const cmd of parsed.commands) {
742 let at = cmd.dir
743 // After `cd x; …`, the command runs in x only if x exists.
744 if (cmd.ifCdFailed !== undefined && (await run($, ['test', '-d', cmd.ifCdFailed.target], undefined, 3_000)).exitCode !== 0) {
745 at = cmd.ifCdFailed.dir
746 }
747 const op = opOf(cmd.argv, at, cmd.env, c)
748 if (op === null) continue
749 if (op.kind === 'checkout') {
750 switched = true
751 continue
752 }
753 if (isUnknown(op.dir)) {
754 if (await anyStackRepo()) return unreadable(`it's not clear which directory \`${cmd.argv.slice(0, 3).join(' ')}\` runs in (\`cd -\`, \`cd $VAR\`, or a \`cd\` that may not have run)`)
755 continue
756 }
757 const s = await look(op.dir)
758 if (s === null || s.status === 'none') continue
759 // The checkout this command would see is not the one we can look at now.
760 if (switched) {
761 return {
762 deny: `Stack Traffic Control: \`${short(command)}\` changes branch and then runs \`${cmd.argv.slice(0, 3).join(' ')}\` in one go, ` +
763 'in a repo that uses gh stack, so the second step cannot be checked against the branch it will run on. Run them as separate commands.',
764 }
765 }
766 if (s.status === 'unknown') {
767 if (op.kind === 'rebase' && op.args.some(a => ['--continue', '--abort', '--skip', '--quit'].includes(a))) continue
768 if (op.kind === 'pull' && lastFf(op.args) === 'only') continue
769 return {
770 deny: `Stack Traffic Control: couldn't confirm whether \`${short(command)}\` touches a gh stack, so it was not run ` +
771 `(${s.reason}). Retry in a moment, run \`gh stack view\` to see what's wrong, or ask the user.`,
772 }
773 }
774 let verdict: Judgement
775 if (op.kind === 'push') verdict = judgePush(s, op.args, op.cfg, command)
776 else if (op.kind === 'rebase') verdict = judgeRebase(s, op.args, command)
777 else if (op.kind === 'pull') verdict = judgePull(s, op.args, op.cfg, command)
778 else verdict = await judgePr($, s, op, command)
779 // `git rebase <upstream> <branch>` leaves HEAD on <branch>: later steps see another checkout.
780 if (op.kind === 'rebase' && rebaseArgs(op.args).branch !== undefined) switched = true
781 if (verdict === null) continue
782 if ('deny' in verdict) return verdict
783 notes.push(verdict.note)
784 }
785
786 const ran = await settle(await next(e))
787 if (notes.length === 0 || ran.deny !== undefined) return ran
788 return { ...ran, context: [...(ran.context ?? []), ...notes.map(n => `Stack Traffic Control: ${n}`)] }
789 })
790
791 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
792 const at = await read($, root)
793 if (at === null || e.props.hasSurvey) return next(e)
794 const stack = (await read($, stacks))[at]
795 if (stack === undefined || stack.status !== 'stacked' || !onStack(stack)) return next(e)
796 const index = stack.branches.findIndex(b => b.name === stack.branch)
797 const behind = stack.branches.filter(b => b.needsRebase).length
798 const { Box, Text } = $.ui.resolve(e)
799 const mine = (
800 <Box>
801 <Text>🛫 stack {index + 1}/{stack.branches.length}</Text>
802 <Text dimColor> · {stack.branch}</Text>
803 {behind > 0
804 ? <Text color="yellow"> · {behind} need{behind === 1 ? 's' : ''} rebase</Text>
805 : <Text color="green"> · no rebase needed</Text>}
806 </Box>
807 )
808 // The band is one instance shared by every mod: draw ours above whatever the others drew.
809 const below = await next(e)
810 return (
811 <Box flexDirection="column">
812 {mine}
813 {below}
814 </Box>
815 )
816 })
817
818 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
819 const { Box, Text, Button } = $.ui.resolve(e)
820 const at = await read($, root)
821 const stack = at === null ? undefined : (await read($, stacks))[at]
822 const shown = await read($, board)
823 const busy = await read($, loading)
824 const refreshButton = <Button key="refresh" label={busy ? 'refreshing…' : 'refresh'} onPress={() => refresh($)} />
825 if (stack === undefined || stack.status !== 'stacked' || !onStack(stack)) {
826 const why =
827 stack?.status === 'unknown' ? `Couldn't read the stack: ${stack.reason}`
828 : stack?.status === 'stacked' ? `\`${stack.branch || 'HEAD'}\` is not on a stack in this repo.`
829 : 'This checkout is not on a gh stack.'
830 return (
831 <Box flexDirection="column">
832 <Text dimColor>{busy && stack === undefined ? 'Looking for a stack…' : why}</Text>
833 {stack?.status === 'unknown' ? null : <Text dimColor>Start one with `gh stack init`, or switch with `gh stack checkout`.</Text>}
834 {refreshButton}
835 </Box>
836 )
837 }
838 // Details from another checkout (or none yet) are not shown against this stack.
839 const rows = shown !== null && shown.root === stack.root ? shown.rows : {}
840 const width = Math.max(10, Math.min(28, (e.props.bodyColumns ?? 60) - 34))
841 const cut = (s: string) => (s.length > width ? `${s.slice(0, width - 1)}…` : s.padEnd(width))
842 return (
843 <Box flexDirection="column">
844 <Text bold>DEPARTURES · trunk {stack.trunk}</Text>
845 {[...stack.branches].reverse().map(b => {
846 const d = rows[b.name]
847 const isCurrent = b.name === stack.branch
848 const status = b.isMerged ? 'landed' : b.isQueued ? 'queued' : b.needsRebase ? 'rebase' : 'ready'
849 const color = b.isMerged ? 'gray' : b.needsRebase ? 'yellow' : 'green'
850 const pr = d === undefined ? '…' : d.pr !== null ? `#${d.pr}` : '—'
851 const ci = d?.checks === 'pass' ? '✓' : d?.checks === 'fail' ? '✗' : d?.checks === 'pending' ? '…' : ' '
852 const push = d === undefined ? '' : d.published === false ? ' unpublished' : d.unpushed === null ? ' ?' : d.unpushed > 0 ? ` ↑${d.unpushed}` : ''
853 const dirty = d?.dirty ? ' ✎' : ''
854 return (
855 <Box key={b.name}>
856 <Text bold={isCurrent}>{isCurrent ? '▶ ' : ' '}{cut(b.name)} </Text>
857 <Text dimColor>{pr.padEnd(6)} </Text>
858 <Text color={d?.checks === 'fail' ? 'red' : undefined}>{ci} </Text>
859 <Text color={color}>{status}</Text>
860 <Text dimColor>{push}{dirty}</Text>
861 </Box>
862 )
863 })}
864 <Text dimColor>✓✗… checks · ↑ unpushed · ✎ uncommitted · ? unknown</Text>
865 {refreshButton}
866 </Box>
867 )
868 })
869}
870hooks/shell.ts 638 lines1// A small shell reader: enough of POSIX sh to find every simple command a
2// Bash line would run, where it would run, and whether something hid one.
3// It never expands globs; it only finds commands and their directories.
4
5// A directory we cannot know from the text (`cd -`, `cd $X`, a cd that may not have run).
6export const UNKNOWN_DIR = '\u0000unknown'
7
8export type SimpleCommand = {
9 // The words, wrappers (env, sudo, nohup, time, nice, command, exec, xargs, timeout) peeled.
10 argv: string[]
11 // Where it runs: undefined is where the line started; UNKNOWN_DIR is unknowable.
12 dir: string | undefined
13 // Variables set for it alone (`GIT_DIR=… git …`, `env GIT_DIR=… git …`).
14 env: Record<string, string>
15 // After `cd x; …` the cd may have failed: `dir` holds if x exists, else this does.
16 ifCdFailed?: { target: string; dir: string | undefined }
17}
18
19export type Parsed = {
20 commands: SimpleCommand[]
21 // True when part of the line could not be read: an unbalanced quote or
22 // substitution, a program name that is itself an expansion, a case statement.
23 opaque: boolean
24 // Where the shell itself is once the line has run, if it changed directory.
25 endDir: string | undefined
26 // The line itself runs a cd/pushd/popd (not inside a subshell or substitution).
27 changesDir: boolean
28 // A top-level `cd x` followed by `;`/newline: the shell is in `endDir` only if x exists.
29 uncertainCd?: { target: string; dir: string | undefined }
30}
31
32export type Context = { home?: string }
33
34const WRAPPER_FLAGS_WITH_VALUE: Record<string, string[]> = {
35 env: ['-u', '--unset', '-C', '--chdir', '-S', '--split-string'],
36 sudo: ['-u', '--user', '-g', '--group', '-h', '--host', '-p', '--prompt', '-C', '--close-from', '-D', '--chdir', '-r', '--role', '-t', '--type', '-T', '--command-timeout', '-U', '--other-user'],
37 nice: ['-n', '--adjustment'],
38 xargs: ['-I', '-L', '-n', '-P', '-s', '-d', '-E', '-a', '--arg-file', '--delimiter', '--max-args', '--max-procs', '--max-lines', '--replace'],
39 timeout: ['-s', '--signal', '-k', '--kill-after'],
40 command: [],
41 exec: ['-a'],
42 nohup: [],
43 time: ['-f', '--format', '-o', '--output'],
44}
45// Flags of a wrapper that change the directory the program runs in (short form may be glued: -C/dir).
46const CHDIR_FLAGS: Record<string, { short: string; long: string }> = {
47 env: { short: '-C', long: '--chdir' },
48 sudo: { short: '-D', long: '--chdir' },
49}
50const SHELLS = new Set(['bash', 'sh', 'zsh', 'dash', 'ksh', 'fish'])
51// Reserved words that lead into a command without being one; a cd after one may not run.
52const LEADING_KEYWORDS = new Set(['if', 'then', 'elif', 'else', 'do', 'while', 'until', '!', 'time'])
53// Reserved words that end a construct and carry no command.
54const CLOSING_KEYWORDS = new Set(['fi', 'done', 'esac'])
55const ASSIGNMENT = /^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/s
56
57export const isUnknown = (dir: string | undefined) => dir !== undefined && dir.startsWith(UNKNOWN_DIR)
58
59export const joinDir = (base: string | undefined, next: string): string => {
60 if (isUnknown(next)) return UNKNOWN_DIR
61 if (next.startsWith('/')) return next
62 if (base === undefined) return next
63 if (isUnknown(base)) return UNKNOWN_DIR
64 return `${base.replace(/\/$/, '')}/${next}`
65}
66
67// Resolve a path the way a shell would, as far as the text allows: `~` from HOME,
68// anything with an expansion or `-` (OLDPWD) is unknowable.
69export function resolvePath(base: string | undefined, target: string | undefined, ctx: Context): string {
70 if (target === undefined || target === '~') return ctx.home ?? UNKNOWN_DIR
71 if (target.startsWith('~/')) return ctx.home === undefined ? UNKNOWN_DIR : `${ctx.home}/${target.slice(2)}`
72 if (target === '-' || /[~$`*?]/.test(target)) return UNKNOWN_DIR
73 return joinDir(base, target)
74}
75
76type Token =
77 | { kind: 'word'; text: string; raw: boolean; subs: string[] }
78 // Substitutions that run with the command but are not its words (redirect targets, heredoc bodies).
79 | { kind: 'subs'; subs: string[] }
80 | { kind: 'sep'; text: string }
81
82// Read `$( ... )` starting just past `$(`; returns the inner text and the index after `)`.
83function readSubstitution(src: string, start: number): { inner: string; end: number } | null {
84 let depth = 1
85 let quote: string | null = null
86 for (let i = start; i < src.length; i += 1) {
87 const ch = src[i] as string
88 if (quote !== null) {
89 if (ch === '\\' && quote === '"') i += 1
90 else if (ch === quote) quote = null
91 continue
92 }
93 if (ch === '\\') {
94 i += 1
95 continue
96 }
97 if (ch === "'" || ch === '"') quote = ch
98 else if (ch === '(') depth += 1
99 else if (ch === ')') {
100 depth -= 1
101 if (depth === 0) return { inner: src.slice(start, i), end: i + 1 }
102 }
103 }
104 return null
105}
106
107// Every `$(...)` and backtick in text that the shell expands (a heredoc body); null if unbalanced.
108function substitutionsIn(text: string): string[] | null {
109 const found: string[] = []
110 for (let i = 0; i < text.length; i += 1) {
111 const ch = text[i]
112 if (ch === '\\') {
113 i += 1
114 continue
115 }
116 if (ch === '$' && text[i + 1] === '(') {
117 const sub = readSubstitution(text, i + 2)
118 if (sub === null) return null
119 found.push(sub.inner)
120 i = sub.end - 1
121 } else if (ch === '`') {
122 const end = text.indexOf('`', i + 1)
123 if (end === -1) return null
124 found.push(text.slice(i + 1, end))
125 i = end
126 }
127 }
128 return found
129}
130
131// A heredoc delimiter word: its text, and whether any of it was quoted (which turns expansion off).
132function readDelimiter(src: string, i: number): { text: string; quoted: boolean; end: number } {
133 let j = i
134 while (j < src.length && /[ \t]/.test(src[j] as string)) j += 1
135 let text = ''
136 let quoted = false
137 while (j < src.length && !/[\s;&|()<>]/.test(src[j] as string)) {
138 const ch = src[j] as string
139 if (ch === "'" || ch === '"') {
140 const end = src.indexOf(ch, j + 1)
141 if (end === -1) break
142 text += src.slice(j + 1, end)
143 quoted = true
144 j = end + 1
145 } else if (ch === '\\') {
146 text += src[j + 1] ?? ''
147 quoted = true
148 j += 2
149 } else {
150 text += ch
151 j += 1
152 }
153 }
154 return { text, quoted, end: j }
155}
156
157function tokenize(src: string): { tokens: Token[]; opaque: boolean } {
158 const tokens: Token[] = []
159 let cur = ''
160 let has = false
161 // A word holding an expansion we cannot see through ($VAR, $(...), `...`).
162 let raw = false
163 let subs: string[] = []
164 // The next word is a redirection target, not an argument.
165 let redirectNext = false
166 let opaque = false
167 const heredocs: Array<{ delimiter: string; strip: boolean; quoted: boolean }> = []
168 const push = () => {
169 if (has || cur !== '') {
170 if (redirectNext) tokens.push({ kind: 'subs', subs })
171 else tokens.push({ kind: 'word', text: cur, raw, subs })
172 redirectNext = false
173 } else if (subs.length > 0) tokens.push({ kind: 'subs', subs })
174 cur = ''
175 has = false
176 raw = false
177 subs = []
178 }
179 // At a newline, read the bodies of any heredocs opened on that line.
180 const readHeredocs = (from: number): number => {
181 let i = from
182 const bodySubs: string[] = []
183 while (heredocs.length > 0) {
184 const { delimiter, strip, quoted } = heredocs.shift() as { delimiter: string; strip: boolean; quoted: boolean }
185 let found = false
186 const body: string[] = []
187 while (i < src.length) {
188 const end = src.indexOf('\n', i)
189 const line = src.slice(i, end === -1 ? src.length : end)
190 i = end === -1 ? src.length : end + 1
191 if ((strip ? line.replace(/^\t+/, '') : line) === delimiter) {
192 found = true
193 break
194 }
195 body.push(line)
196 }
197 if (!found) i = src.length
198 // An unquoted delimiter means the body is expanded: its substitutions run.
199 if (!quoted) {
200 const inner = substitutionsIn(body.join('\n'))
201 if (inner === null) opaque = true
202 else bodySubs.push(...inner)
203 }
204 }
205 if (bodySubs.length > 0) tokens.push({ kind: 'subs', subs: bodySubs })
206 return i
207 }
208 let i = 0
209 while (i < src.length) {
210 const ch = src[i] as string
211 const nextCh = src[i + 1]
212 if (ch === '\\') {
213 if (nextCh === '\n') i += 2
214 else {
215 cur += nextCh ?? ''
216 has = true
217 i += 2
218 }
219 continue
220 }
221 if (ch === "'") {
222 const end = src.indexOf("'", i + 1)
223 if (end === -1) {
224 opaque = true
225 cur += src.slice(i + 1)
226 has = true
227 break
228 }
229 cur += src.slice(i + 1, end)
230 has = true
231 i = end + 1
232 continue
233 }
234 if (ch === '"') {
235 let j = i + 1
236 let closed = false
237 while (j < src.length) {
238 const c = src[j] as string
239 if (c === '\\' && j + 1 < src.length) {
240 const n = src[j + 1] as string
241 if ('"\\$`\n'.includes(n)) {
242 if (n !== '\n') cur += n
243 j += 2
244 continue
245 }
246 cur += c
247 j += 1
248 continue
249 }
250 if (c === '"') {
251 closed = true
252 break
253 }
254 if (c === '$' && src[j + 1] === '(') {
255 const sub = readSubstitution(src, j + 2)
256 if (sub === null) {
257 opaque = true
258 j = src.length
259 break
260 }
261 subs.push(sub.inner)
262 raw = true
263 j = sub.end
264 continue
265 }
266 if (c === '`') {
267 const end = src.indexOf('`', j + 1)
268 if (end === -1) {
269 opaque = true
270 j = src.length
271 break
272 }
273 subs.push(src.slice(j + 1, end))
274 raw = true
275 j = end + 1
276 continue
277 }
278 if (c === '$') raw = true
279 cur += c
280 j += 1
281 }
282 if (!closed) opaque = true
283 has = true
284 i = j + 1
285 continue
286 }
287 if (ch === '$' && nextCh === '(') {
288 const sub = readSubstitution(src, i + 2)
289 if (sub === null) {
290 opaque = true
291 break
292 }
293 subs.push(sub.inner)
294 raw = true
295 has = true
296 i = sub.end
297 continue
298 }
299 if (ch === '`') {
300 const end = src.indexOf('`', i + 1)
301 if (end === -1) {
302 opaque = true
303 break
304 }
305 subs.push(src.slice(i + 1, end))
306 raw = true
307 has = true
308 i = end + 1
309 continue
310 }
311 if (ch === '$') raw = true
312 if (ch === '#' && !has && cur === '') {
313 const end = src.indexOf('\n', i)
314 i = end === -1 ? src.length : end
315 continue
316 }
317 if (ch === '<' && nextCh === '<') {
318 push()
319 if (src[i + 2] === '<') {
320 // A here-string: its word is data, but its substitutions run.
321 redirectNext = true
322 i += 3
323 continue
324 }
325 const strip = src[i + 2] === '-'
326 const word = readDelimiter(src, i + (strip ? 3 : 2))
327 heredocs.push({ delimiter: word.text, strip, quoted: word.quoted })
328 i = word.end
329 continue
330 }
331 if (ch === '>' || ch === '<') {
332 // A redirection, glued to a word or not (`+b2>/tmp/log`, `2>&1`): its target is not an argument.
333 if (/^\d+$/.test(cur) && !raw) {
334 cur = ''
335 has = false
336 }
337 push()
338 let j = i + 1
339 while (src[j] === '>' || src[j] === '&' || src[j] === '|') j += 1
340 if (src[j - 1] === '&' && /[\d-]/.test(src[j] ?? '')) {
341 i = j + 1
342 continue
343 }
344 redirectNext = true
345 i = j
346 continue
347 }
348 if (/\s/.test(ch) && ch !== '\n') {
349 push()
350 i += 1
351 continue
352 }
353 if (ch === ';' || ch === '\n' || ch === '&' || ch === '|' || ch === '(' || ch === ')') {
354 push()
355 redirectNext = false
356 if (ch === '\n' && heredocs.length > 0) {
357 i = readHeredocs(i + 1)
358 tokens.push({ kind: 'sep', text: '\n' })
359 continue
360 }
361 const two = ch + (nextCh ?? '')
362 if (two === '&&' || two === '||' || two === ';;' || two === '|&') {
363 tokens.push({ kind: 'sep', text: two })
364 i += 2
365 } else {
366 tokens.push({ kind: 'sep', text: ch })
367 i += 1
368 }
369 continue
370 }
371 cur += ch
372 i += 1
373 }
374 push()
375 if (heredocs.length > 0) readHeredocs(src.length)
376 return { tokens, opaque }
377}
378
379type Peeled = {
380 argv: string[]
381 dir: string | undefined
382 env: Record<string, string>
383 inner: string | null
384 opaque: boolean
385 // A reserved word (if, then, do, ...) led into it: it may not run.
386 guarded: boolean
387}
388
389// Strip leading wrappers so argv[0] is the program that really runs.
390function peel(argv: string[], dir: string | undefined, ctx: Context): Peeled {
391 let words = [...argv]
392 let where = dir
393 let guarded = false
394 const env: Record<string, string> = {}
395 for (let round = 0; round < 12; round += 1) {
396 for (;;) {
397 const m = words[0]?.match(ASSIGNMENT)
398 if (m === undefined || m === null) break
399 env[m[1] as string] = m[2] as string
400 words = words.slice(1)
401 }
402 while (words.length > 0 && LEADING_KEYWORDS.has(words[0] as string)) {
403 guarded = guarded || words[0] !== 'time'
404 words = words.slice(1)
405 }
406 const head = words[0]
407 if (head === undefined) break
408 const base = head.split('/').pop() as string
409 if (base in WRAPPER_FLAGS_WITH_VALUE) {
410 const valued = WRAPPER_FLAGS_WITH_VALUE[base] as string[]
411 const chdir = CHDIR_FLAGS[base]
412 let k = 1
413 while (k < words.length) {
414 const w = words[k] as string
415 const assign = base === 'env' ? w.match(ASSIGNMENT) : null
416 if (assign !== null) {
417 env[assign[1] as string] = assign[2] as string
418 k += 1
419 } else if (w === '--') {
420 k += 1
421 break
422 } else if (w.startsWith('-') && w !== '-') {
423 if (chdir !== undefined && (w === chdir.short || w === chdir.long)) where = resolvePath(where, words[k + 1], ctx)
424 else if (chdir !== undefined && w.startsWith(`${chdir.long}=`)) where = resolvePath(where, w.slice(chdir.long.length + 1), ctx)
425 else if (chdir !== undefined && w.startsWith(chdir.short) && w.length > 2 && !w.startsWith('--')) where = resolvePath(where, w.slice(2), ctx)
426 k += valued.includes(w) ? 2 : 1
427 } else if (base === 'nice' && /^-?\d+$/.test(w)) k += 1
428 else if (base === 'timeout' && /^\d+(\.\d+)?[smhd]?$/.test(w)) k += 1
429 else break
430 }
431 words = words.slice(k)
432 continue
433 }
434 if (SHELLS.has(base)) {
435 // `bash -c 'cmd'`, `sh -lc 'cmd'`: the string is the command line.
436 const c = words.findIndex((w, k) => k > 0 && /^-[a-zA-Z]*c[a-zA-Z]*$/.test(w))
437 if (c !== -1) return { argv: words, dir: where, env, inner: words[c + 1] ?? '', opaque: words[c + 1] === undefined, guarded }
438 // A shell reading a script file or stdin: we cannot see inside.
439 return { argv: words, dir: where, env, inner: null, opaque: true, guarded }
440 }
441 if (base === 'eval') return { argv: words, dir: where, env, inner: words.slice(1).join(' '), opaque: false, guarded }
442 break
443 }
444 return { argv: words, dir: where, env, inner: null, opaque: false, guarded }
445}
446
447// The directory a `cd`/`pushd`/`popd` command moves to, as far as the text says.
448function cdOf(argv: string[], dir: string | undefined, ctx: Context): string {
449 const [head, ...args] = argv
450 if (head === 'popd') return UNKNOWN_DIR
451 let k = 0
452 while (k < args.length && (args[k] as string).startsWith('-') && args[k] !== '-') {
453 if (args[k] === '--') {
454 k += 1
455 break
456 }
457 k += 1
458 }
459 const target = args[k]
460 // `pushd` with no argument (or +N/-N) rotates the directory stack.
461 if (head === 'pushd' && (target === undefined || /^[+-]\d+$/.test(target))) return UNKNOWN_DIR
462 return resolvePath(dir, target, ctx)
463}
464
465export function parse(line: string, startDir: string | undefined, ctx: Context = {}, budget = { left: 64 }): Parsed {
466 const commands: SimpleCommand[] = []
467 const { tokens, opaque: tokOpaque } = tokenize(line)
468 let opaque = tokOpaque
469 let dir = startDir
470 let changesDir = false
471 const dirs: Array<string | undefined> = []
472 let words: Token[] = []
473 // The separator before the current command.
474 let before = ''
475 // A cd waiting for the separator after it: in a pipeline or in the background it runs in a subshell.
476 let pendingCd: { to: string; before: string; guarded: boolean } | null = null
477 // A cd ran after `&&` in this list: once the list ends, it may not have run.
478 let chainMoved = false
479 let depth = 0
480 // An unconditional `cd x;` that may have failed: where the shell is if it did.
481 let ifCdFailed: { target: string; dir: string | undefined } | undefined
482 // Brace groups: where each started, and the one that just closed (rolled back if piped or backgrounded).
483 const braceDirs: Array<string | undefined> = []
484 let closedBrace: { dir: string | undefined; fallback: typeof ifCdFailed } | null = null
485 // Functions defined on the line whose bodies cd: calling one makes the directory unknown.
486 const cdFunctions = new Set<string>()
487 let defining: { name: string; depth: number; dir: string | undefined; fallback: typeof ifCdFailed; cds: boolean } | null = null
488 let pendingName: string | null = null
489
490 const sub = (inner: string, at: string | undefined) => {
491 if (budget.left <= 0) {
492 opaque = true
493 return
494 }
495 budget.left -= 1
496 const p = parse(inner, at, ctx, budget)
497 commands.push(...p.commands)
498 if (p.opaque) opaque = true
499 }
500
501 const flush = () => {
502 if (words.length === 0) return
503 const taken = words
504 words = []
505 // Substitutions run first, in the directory the command starts in.
506 for (const t of taken) if (t.kind !== 'sep') for (const s of t.subs) sub(s, dir)
507 const wordTokens = taken.filter((t): t is Extract<Token, { kind: 'word' }> => t.kind === 'word')
508 let argv = wordTokens.map(t => t.text)
509 // `{ … }` runs in the current shell: it groups, nothing more.
510 while (argv[0] === '{') {
511 braceDirs.push(dir)
512 argv = argv.slice(1)
513 }
514 while (argv[argv.length - 1] === '}') {
515 argv = argv.slice(0, -1)
516 if (argv.length > 0) {
517 // `{ cmd }`-style close on the same command: settle it first.
518 words = []
519 }
520 const start = braceDirs.length > 0 ? braceDirs.pop() : dir
521 closedBrace = { dir: start, fallback: ifCdFailed }
522 if (defining !== null && braceDirs.length === defining.depth) {
523 // A function body ends: defining it ran nothing, so the shell stays where it was.
524 if (defining.cds) cdFunctions.add(defining.name)
525 dir = defining.dir
526 ifCdFailed = defining.fallback
527 defining = null
528 closedBrace = null
529 }
530 }
531 if (argv.length === 0 || CLOSING_KEYWORDS.has(argv[0] as string) || argv[0] === '}') return
532 if (['for', 'select', 'function', 'in'].includes(argv[0] as string)) return
533 if (argv[0] === 'case') {
534 // Patterns and arms are hard to separate reliably: say so rather than guess.
535 opaque = true
536 return
537 }
538 const first = wordTokens.find(t => t.text !== '{')
539 if (first !== undefined && first.raw && !LEADING_KEYWORDS.has(first.text)) opaque = true
540 const peeled = peel(argv, dir, ctx)
541 if (peeled.opaque) opaque = true
542 if (cdFunctions.has(peeled.argv[0] ?? '')) {
543 dir = UNKNOWN_DIR
544 return
545 }
546 if (peeled.inner !== null) {
547 sub(peeled.inner, peeled.dir)
548 return
549 }
550 const head = peeled.argv[0]
551 if (head === 'cd' || head === 'pushd' || head === 'popd') {
552 if (defining !== null) defining.cds = true
553 if (depth === 0 && defining === null) changesDir = true
554 pendingCd = { to: cdOf(peeled.argv, dir, ctx), before, guarded: peeled.guarded }
555 return
556 }
557 if (peeled.argv.length > 0) {
558 const fallback = ifCdFailed !== undefined && peeled.dir === dir ? { target: ifCdFailed.target, dir: ifCdFailed.dir } : undefined
559 commands.push({ argv: peeled.argv, dir: peeled.dir, env: peeled.env, ...(fallback ? { ifCdFailed: fallback } : {}) })
560 }
561 }
562
563 // Settle a waiting cd once we know what follows it.
564 const settleCd = (after: string) => {
565 const cd = pendingCd
566 pendingCd = null
567 if (cd === null) return
568 const inSubshell = ['|', '|&', '&'].includes(after) || ['|', '|&'].includes(cd.before)
569 if (inSubshell) return
570 if (cd.guarded || cd.before === '||') {
571 dir = UNKNOWN_DIR
572 ifCdFailed = undefined
573 } else if (cd.before === '&&' || after === '&&') {
574 // After `&&` the cd ran only if it worked; before `&&` what follows runs only if it worked.
575 dir = cd.to
576 if (cd.before === '&&') chainMoved = true
577 ifCdFailed = undefined
578 } else {
579 // `cd x;` — what follows runs either way: in x if it exists, else where we were.
580 // A second uncertain cd stacks guesses we cannot keep apart: say unknown.
581 if (ifCdFailed !== undefined || isUnknown(cd.to)) {
582 ifCdFailed = undefined
583 dir = UNKNOWN_DIR
584 } else {
585 ifCdFailed = { target: cd.to, dir }
586 dir = cd.to
587 }
588 }
589 }
590
591 for (const t of tokens) {
592 if (t.kind !== 'sep') {
593 words.push(t)
594 continue
595 }
596 // `name ( )` starts a function definition.
597 if (t.text === '(' && words.length === 1 && words[0]?.kind === 'word') {
598 pendingName = (words[0] as { text: string }).text
599 words = []
600 continue
601 }
602 if (t.text === ')' && pendingName !== null) {
603 defining = { name: pendingName, depth: braceDirs.length, dir, fallback: ifCdFailed, cds: false }
604 pendingName = null
605 continue
606 }
607 flush()
608 settleCd(t.text)
609 const closed = closedBrace as { dir: string | undefined; fallback: typeof ifCdFailed } | null
610 closedBrace = null
611 if (closed !== null && ['|', '|&', '&'].includes(t.text)) {
612 // A piped or backgrounded group runs in a subshell: its cd never reached us.
613 dir = closed.dir
614 ifCdFailed = closed.fallback
615 }
616 if (t.text === '(') {
617 dirs.push(dir)
618 depth += 1
619 } else if (t.text === ')') {
620 if (dirs.length > 0) dir = dirs.pop()
621 depth = Math.max(0, depth - 1)
622 } else if (t.text === ';' || t.text === '\n' || t.text === '&' || t.text === ';;' || t.text === '||') {
623 // What follows runs whether or not the cd after `&&` did.
624 if (chainMoved) dir = UNKNOWN_DIR
625 chainMoved = false
626 }
627 before = t.text
628 }
629 flush()
630 settleCd('')
631 if (defining !== null) {
632 dir = defining.dir
633 opaque = true
634 }
635 // A line ending in `… && cd x` moved only if it succeeded; the caller treats a failed line as unknown.
636 return { commands, opaque, endDir: dir, changesDir, ...(ifCdFailed !== undefined ? { uncertainCd: ifCdFailed } : {}) }
637}
638types/index.d.ts 61 lines1// One layer of the stack, as `gh stack view --json` reports it plus our own ancestry check.
2export type StackBranch = {
3 name: string
4 isMerged: boolean
5 isQueued: boolean
6 needsRebase: boolean
7}
8
9// What a look at one checkout found. `stacked` means the repo uses gh stack
10// (its stack file was read); `none` is confirmed (no stack file); `unknown`
11// means the look itself failed, so the guard fails closed.
12export type Stack = {
13 root: string
14 status: 'stacked' | 'none' | 'unknown'
15 reason: string
16 // The checked-out branch ('' when detached, as in the middle of a rebase).
17 branch: string
18 // Every branch of every stack in the repo, and every stack's trunk.
19 protectedBranches: string[]
20 trunks: string[]
21 // The stack the current branch is on, if any: its trunk and layers bottom to top.
22 trunk: string
23 branches: StackBranch[]
24 remotes: string[]
25 // The git config the guard reads (push/pull policy, remote push refspecs, branch upstreams), keys lowercased.
26 config: Record<string, string[]>
27 at: number
28}
29
30// What the pane adds on a refresh: PR, checks, unpushed commits, a dirty tree.
31export type BranchDetail = {
32 pr: number | null
33 state: string | null
34 checks: 'pass' | 'fail' | 'pending' | 'none' | null
35 // Commits ahead of the branch's upstream; null when unknown.
36 unpushed: number | null
37 // False when the branch has no upstream yet (never pushed); null when unknown.
38 published: boolean | null
39 dirty: boolean
40}
41
42// One refresh of the pane, for one checkout, published whole.
43export type Board = { root: string; rows: Record<string, BranchDetail>; at: number }
44
45declare module 'claude-code' {
46 interface PluginState {
47 'stack-traffic-control': {
48 // Keyed by repo root: the last look at that checkout's stack (band only; the guard looks fresh).
49 stacks: Record<string, Stack>
50 // The repo the session is in, so the band and pane know which stack to draw.
51 root: string | null
52 board: Board | null
53 loading: boolean
54 // A refresh was asked for while one ran: run again when it ends.
55 pending: boolean
56 // Where the Bash tool's persistent shell is, as far as top-level `cd`s tell us.
57 shellDir: string | null
58 }
59 }
60}
61