SLOPSHOPPER

stack-traffic-control

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…

panebandguardcommandprocess
v0.2.0MITupdated 2026-10-02ccdwyer/stack-traffic-control
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · stack-traffic-control
│ ┃ Stack ✕ › fix the failing auth test and add an audit log call │ ┃ Couldn't read the stack: the stack file is │ ┃ not readable JSON ⏺ Read(src/auth.ts) │ ┃ [ refreshing… ] ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(rm -rf build && git push --force origin main) │ ⎿ Denied by stack-traffic-control: Stack Traffic Control: c │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /stack │ ⎿ stack-traffic-control: Stack board opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Stack
Couldn't read the stack: the stack file is not readable JSON [ refreshing… ]
README

Stack Traffic Control

Stack Traffic Control demo

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.
  • Above the prompt, when you're on a stacked branch: 🛫 stack 2/4 · feature-b · 1 needs rebase.
  • Guard. In a repo that uses gh stack, some raw commands are refused, and the model is shown the exact 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 commandUse instead
git push --force / -f / --force-with-lease / +refspec / --mirror / glob refspecs / --delete hitting a stack branchgh 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 PRgh stack submit --auto / gh stack modify
gh pr create for a stack branchgh stack submit --auto

These commands still run, with a note attached for the model:

  • A rebase that stays inside the branch (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:

  • force-pushing tags or unrelated branches
  • PRs whose head isn't a stack branch
  • PRs in another repo (--repo)
  • anything run off a stack

The guard reads the Bash line the way a shell would. It looks inside:

  • quoting, escapes, line continuations and redirections. Quoted heredoc bodies are data. In unquoted heredocs and redirect targets, $( … ) is checked.
  • ;, &&, || and |
  • subshells ( … ), groups { … }, if/while bodies, and $( … ) / backtick substitutions
  • bash -c '…' and eval
  • wrappers: env, sudo, nohup, time, nice, command, exec, xargs, timeout
  • cd / 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:

  • The stack file can't be checked or read. The command is refused with the reason rather than allowed.
  • A 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 line can't be read (a substitution as the program, an unbalanced quote) and could push, rebase, pull or edit a PR. It is refused, and the model is asked to run that step as a plain command.
  • A line switches branch and then pushes. It is refused, and the model is asked to split it, because the push can't be checked against a branch it isn't on yet.

The guard also follows:

  • configured push refspecs (remote.<name>.push, remote.<name>.mirror) and push.default, including upstream
  • branch.<name>.rebase, pull.rebase and pull.ff, plus git -c overrides
  • GIT_DIR / GIT_WORK_TREE prefixes
  • git rebase <upstream> <branch>, which rewrites <branch> whatever HEAD is

Known limits, as a safety net and not a sandbox:

  • a program name hidden in a variable ($GIT push -f)
  • a script run as bash script.sh / source with no git text on the line
  • git aliases
  • git 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.

Requirements

  • GitHub CLI with the stack extension: gh extension install github/gh-stack

Install

/plugin marketplace add ccdwyer/claude-mods
/plugin install stack-traffic-control@ccdwyer-mods
/reload-plugins

Develop

claude plugin validate .
claude plugin test .

What it hooks

Events this mod hooks, as claude plugin validate reads the module:

  • session.start
  • command.run{command=stack}
  • turn.complete
  • tool.call{tool=Bash}
  • ui.render{component=AbovePrompt}
  • ui.render{component=Pane
  • requestId=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.

Privacy

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.

License

MIT

Source 3 files
hooks/register.tsx 870 lines
1import { 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}
870
hooks/shell.ts 638 lines
1// 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}
638
types/index.d.ts 61 lines
1// 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