SLOPSHOPPER

git-gate

Reviews core git operations across sibling worktrees and routes branch creation through Worktrunk

newguardprocess
★ 10v0.1.0no licenseupdated 2026-10-09gwenwindflower/dotfiles/plugins/git-gate
A shopper browsing a rack in a slop shop
README

dotfiles

Cross-platform dotfiles managed with chezmoi. Fish + Neovim + Kitty, Catppuccin Frappe throughout. macOS is the persistent workstation; Linux is the dev-focused CLI for ephemeral VMs and containers.

Install — persistent machine

sh -c "$(curl -fsLS get.chezmoi.io)" -- init --apply gwenwindflower

This clones the repo into ~/.local/share/chezmoi and applies it. Subsequent updates: chezmoi update.

Install — ephemeral one-shot (Sprites, exe.dev VMs, containers)

CHEZMOI_ONESHOT=1 sh -c "$(curl -fsLS get.chezmoi.io)" -- init --one-shot gwenwindflower

--one-shot applies the dotfiles, then purges chezmoi (binary, source dir, config, cache) — leaving only the materialized files in $HOME. The CHEZMOI_ONESHOT=1 flag is required here: this repo uses symlinks pointing into the chezmoi source dir for files that external tools edit (lazy-lock.json, Claude settings.json, etc.), and those symlinks would dangle the moment purge fires. Setting the env var triggers an apply-phase script that replaces each such symlink with a copy of its target before purge runs.

Without the env var, --one-shot will succeed but leave broken symlinks in the home dir.

Development

See AGENTS.md for repo structure, conventions, and the symsource/symlink pattern in detail.

Source 4 files
hooks/register.ts 103 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import { parseBranchCreation, parseGit, tokenize } from './command'
4import type { BranchCreation } from './command'
5import { decideGit, decideWt } from './policy'
6import type { Verdict } from './policy'
7import { loadRepoFacts } from './repo'
8import type { Run } from './repo'
9
10const WORKTREE_TIMEOUT_MS = 600_000
11
12const runner = ($: EngineInterface): Run => (argv, cwd) =>
13  $.process.run(argv, cwd === undefined ? { timeoutMs: 10_000 } : { cwd, timeoutMs: 10_000 })
14
15const review = async ($: EngineInterface, command: string): Promise<Verdict | undefined> => {
16  const argv = tokenize(command)
17  if (!argv) return undefined
18
19  const inv = parseGit(argv)
20  if (!inv) return decideWt(argv)
21
22  const loadFacts = async () => loadRepoFacts(runner($), await $.session.cwd(), inv.dir)
23  return decideGit(inv, () => loadFacts().catch(() => undefined))
24}
25
26const worktreeArgv = (creation: BranchCreation, isHerdr: boolean) => {
27  const base = creation.base === undefined ? [] : ['--base', creation.base]
28  return isHerdr
29    ? ['wtherdr', 'run', 'create', '--branch', creation.branch, ...base]
30    : ['wt', 'switch', '--create', creation.branch, ...base, '--no-cd']
31}
32
33const findWorktree = async (run: Run, branch: string, cwd?: string) => {
34  const listed = await run(['git', 'worktree', 'list', '--porcelain'], cwd)
35  const block = listed.stdout.split('\n\n').find(entry => entry.includes(`\nbranch refs/heads/${branch}`))
36  return block?.match(/^worktree (.+)$/m)?.[1]
37}
38
39const enterWorktree = async ($: EngineInterface, path: string, creation: BranchCreation) => {
40  const retry = `call EnterWorktree with path ${path} to work there`
41  if (creation.dir !== undefined) {
42    return `The worktree for ${creation.branch} is at ${path}; it belongs to the repository at ${creation.dir}, so this session stayed put. To move anyway, ${retry}.`
43  }
44
45  const entered = await $.tool.call({ tool: 'EnterWorktree', path })
46  if (entered.deny !== undefined) return `The worktree for ${creation.branch} is at ${path}, but entering it was refused (${entered.deny}); ${retry}.`
47  if (entered.isError) return `The worktree for ${creation.branch} is at ${path}, but entering it failed (${entered.text ?? 'no reason given'}); ${retry}.`
48
49  return `This session entered the worktree for ${creation.branch} at ${path}.`
50}
51
52export const register: Register = on => {
53  on('tool.check', { tool: 'EnterWorktree' }, async ($, e, next) => {
54    const base = await next(e)
55    if (base.decision === 'deny' || next.origin.plugin !== $.plugin.name) return base
56
57    return { decision: 'allow', reason: 'git-gate: enters the worktree it created for the requested branch' }
58  })
59
60  on('tool.check', { tool: 'Bash' }, async ($, e, next) => {
61    const base = await next(e)
62    if (base.decision === 'deny') return base
63
64    const input = e.input as { command?: unknown; dangerouslyDisableSandbox?: unknown }
65    if (typeof input.command !== 'string') return base
66
67    const verdict = await review($, input.command)
68    if (!verdict) return base
69    if (input.dangerouslyDisableSandbox === true && verdict.decision === 'allow' && !verdict.isHostOk) return base
70
71    return { decision: verdict.decision, reason: `git-gate: ${verdict.reason}` }
72  })
73
74  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
75    if (e.run_in_background) return next(e)
76    const argv = tokenize(e.command)
77    const creation = argv && parseBranchCreation(argv)
78    if (!creation) return next(e)
79
80    const run = runner($)
81    const refFormat = await run(['git', 'check-ref-format', '--branch', creation.branch], creation.dir)
82    if (refFormat.exitCode !== 0) return next(e)
83
84    const wtArgv = worktreeArgv(creation, (await $.env.get('HERDR_ENV')) === '1')
85    const wtCommand = wtArgv.join(' ')
86    const check = await $.tool.check({ tool: 'Bash', input: { command: wtCommand } })
87    if (check.decision === 'deny') return { deny: check.reason ?? `${wtCommand} is denied` }
88    if (check.decision === 'ask') return creation.dir === undefined ? next({ ...e, command: wtCommand }) : next(e)
89
90    const at = creation.dir === undefined ? {} : { cwd: creation.dir }
91    const ran = await $.process.run(wtArgv, { ...at, timeoutMs: WORKTREE_TIMEOUT_MS })
92    const isCreated = ran.exitCode === 0
93    const path = isCreated ? await findWorktree(run, creation.branch, creation.dir) : undefined
94    const stderr = isCreated ? ran.stderr : `${ran.stderr}\nExit code ${ran.exitCode}`.trim()
95    const where = path ? await enterWorktree($, path, creation) : ''
96
97    return {
98      result: { stdout: ran.stdout, stderr, interrupted: false },
99      context: [`git-gate: branch creation runs through Worktrunk, so \`${wtCommand}\` ran in place of \`${e.command}\`. ${where}`.trim()],
100    }
101  })
102}
103
hooks/command.ts 157 lines
1export type GitInvocation = {
2  dir?: string
3  subcommand: string
4  args: string[]
5}
6
7export type BranchCreation = {
8  branch: string
9  base?: string
10  dir?: string
11}
12
13const SHELL_SYNTAX = /[|&;<>()$`\\\n\r]/
14const ENV_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/
15
16/**
17 * Splits a single plain command into words, or answers undefined for anything
18 * a shell would do more with: pipes, chains, substitution, redirects,
19 * escapes, comments, or a leading environment assignment.
20 */
21export const tokenize = (command: string): string[] | undefined => {
22  const words: string[] = []
23  let word = ''
24  let isInWord = false
25  let quote: '"' | "'" | undefined
26
27  for (const char of command) {
28    if (quote) {
29      if (char === quote) {
30        quote = undefined
31      } else if (quote === '"' && /[$`\\]/.test(char)) {
32        return undefined
33      } else {
34        word += char
35      }
36      continue
37    }
38    if (char === '"' || char === "'") {
39      quote = char
40      isInWord = true
41      continue
42    }
43    if (SHELL_SYNTAX.test(char)) return undefined
44    if (/\s/.test(char)) {
45      if (isInWord) words.push(word)
46      word = ''
47      isInWord = false
48      continue
49    }
50    if (char === '#' && !isInWord) return undefined
51    word += char
52    isInWord = true
53  }
54
55  if (quote) return undefined
56  if (isInWord) words.push(word)
57  if (words.length === 0 || ENV_ASSIGNMENT.test(words[0]!)) return undefined
58
59  return words
60}
61
62const joinDir = (base: string | undefined, next: string) =>
63  base === undefined || next.startsWith('/') ? next : `${base}/${next}`
64
65const IGNORED_GLOBALS = new Set(['--no-pager', '-P', '--no-optional-locks'])
66
67/**
68 * Reads `git [-C <dir>]... <subcommand> <args>`. Any other global option
69 * (`-c`, `--git-dir`, `--work-tree`) can change what the subcommand does, so
70 * those invocations are not read at all.
71 */
72export const parseGit = (argv: readonly string[]): GitInvocation | undefined => {
73  if (argv[0] !== 'git') return undefined
74
75  let dir: string | undefined
76  let index = 1
77  while (index < argv.length) {
78    const word = argv[index]!
79    if (word === '-C') {
80      const next = argv[index + 1]
81      if (next === undefined) return undefined
82      dir = joinDir(dir, next)
83      index += 2
84    } else if (IGNORED_GLOBALS.has(word)) {
85      index += 1
86    } else if (word.startsWith('-')) {
87      return undefined
88    } else {
89      break
90    }
91  }
92
93  const subcommand = argv[index]
94  if (subcommand === undefined) return undefined
95  const args = argv.slice(index + 1)
96
97  return dir === undefined ? { subcommand, args } : { dir, subcommand, args }
98}
99
100const isName = (word: string | undefined): word is string =>
101  word !== undefined && word !== '' && !word.startsWith('-')
102
103const withBase = (creation: BranchCreation, base: string | undefined): BranchCreation =>
104  base === undefined ? creation : { ...creation, base }
105
106const GIT_CREATE_FLAGS: Record<string, readonly string[]> = {
107  checkout: ['-b'],
108  switch: ['-c', '--create'],
109}
110
111const parseGitCreation = (inv: GitInvocation): BranchCreation | undefined => {
112  const at = inv.dir === undefined ? {} : { dir: inv.dir }
113  const [first, second, third, ...rest] = inv.args
114
115  if (inv.subcommand === 'branch') {
116    if (!isName(first) || (second !== undefined && !isName(second)) || third !== undefined) return undefined
117    return withBase({ branch: first, ...at }, second)
118  }
119
120  const flags = GIT_CREATE_FLAGS[inv.subcommand]
121  if (!flags || first === undefined || !flags.includes(first)) return undefined
122  if (!isName(second) || (third !== undefined && !isName(third)) || rest.length > 0) return undefined
123
124  return withBase({ branch: second, ...at }, third)
125}
126
127const parseWtCreation = (args: readonly string[]): BranchCreation | undefined => {
128  const [flag, branch, ...rest] = args
129  if ((flag !== '-c' && flag !== '--create') || !isName(branch)) return undefined
130
131  let base: string | undefined
132  for (let index = 0; index < rest.length; index += 1) {
133    const word = rest[index]!
134    if ((word === '--base' || word === '-b') && base === undefined && isName(rest[index + 1])) {
135      base = rest[index + 1]
136      index += 1
137    } else if (word !== '--no-cd') {
138      return undefined
139    }
140  }
141
142  return withBase({ branch }, base)
143}
144
145/**
146 * Reads a command that creates a branch without a worktree, or a
147 * `wt switch --create` that needs its host-side run: the shapes git-gate
148 * routes through Worktrunk. Resets (`-B`, `-C`), tracking setups and
149 * `--execute` runs are left to the command as written.
150 */
151export const parseBranchCreation = (argv: readonly string[]): BranchCreation | undefined => {
152  if (argv[0] === 'wt') return argv[1] === 'switch' ? parseWtCreation(argv.slice(2)) : undefined
153
154  const inv = parseGit(argv)
155  return inv && parseGitCreation(inv)
156}
157
hooks/policy.ts 277 lines
1import { parseBranchCreation } from './command'
2import type { GitInvocation } from './command'
3
4export type Verdict = {
5  decision: 'allow' | 'ask' | 'deny'
6  reason: string
7  /** The command needs the host by design, so an unsandboxed retry keeps this verdict. */
8  isHostOk?: boolean
9}
10
11export type RepoFacts = {
12  /** The target worktree's root. */
13  worktree: string
14  /** The target is another worktree of the session's repository. */
15  isSibling: boolean
16  /** The target worktree's checked-out branch; absent on a detached HEAD. */
17  branch?: string
18  defaultBranch: string
19  /** The remote branch the checked-out branch tracks, without the remote. */
20  upstreamBranch?: string
21  isHeadPushed: boolean
22}
23
24/** Answers undefined when the target is not a worktree of the session's repository. */
25export type LoadFacts = () => Promise<RepoFacts | undefined>
26
27const allow = (reason: string, isHostOk?: true): Verdict =>
28  isHostOk ? { decision: 'allow', reason, isHostOk } : { decision: 'allow', reason }
29const ask = (reason: string): Verdict => ({ decision: 'ask', reason })
30const deny = (reason: string): Verdict => ({ decision: 'deny', reason })
31
32const YOURS_TO_RUN = 'is left for you to run yourself'
33
34const shortLetters = (words: readonly string[]) =>
35  words.filter(word => /^-[A-Za-z]+$/.test(word)).flatMap(word => [...word.slice(1)])
36
37const positionals = (words: readonly string[]) => words.filter(word => !word.startsWith('-'))
38
39const describeBranch = (facts: RepoFacts) => facts.branch ?? 'a detached HEAD'
40
41const siblingWrite = (facts: RepoFacts) =>
42  ask(`writes to sibling worktree ${facts.worktree} on ${describeBranch(facts)}; one agent stages and commits in a worktree at a time`)
43
44const discard = (facts: RepoFacts) =>
45  ask(`discards uncommitted work in ${facts.worktree} (${describeBranch(facts)})`)
46
47const READ_SUBCOMMANDS = new Set([
48  'blame', 'cat-file', 'check-ignore', 'check-ref-format', 'describe', 'diff', 'fetch',
49  'for-each-ref', 'grep', 'log', 'ls-files', 'ls-remote', 'ls-tree', 'merge-base', 'name-rev',
50  'range-diff', 'rev-list', 'rev-parse', 'shortlog', 'show', 'show-ref', 'status',
51])
52
53const BRANCH_MUTATION_LETTERS = new Set(['d', 'D', 'm', 'M', 'c', 'C', 'f', 'u'])
54const BRANCH_MUTATION_FLAGS = /^--(delete|move|copy|force|set-upstream-to|unset-upstream|edit-description|track|no-track|create-reflog)/
55
56const isBranchListing = (args: readonly string[]) => {
57  if (shortLetters(args).some(letter => BRANCH_MUTATION_LETTERS.has(letter))) return false
58  if (args.some(arg => BRANCH_MUTATION_FLAGS.test(arg))) return false
59  return positionals(args).length === 0 || args.includes('--list') || args.includes('-l')
60}
61
62const isRead = ({ subcommand, args }: GitInvocation) => {
63  if (args.some(arg => arg.startsWith('--output'))) return false
64  if (READ_SUBCOMMANDS.has(subcommand)) return true
65
66  const [first] = args
67  switch (subcommand) {
68    case 'worktree':
69      return first === 'list'
70    case 'branch':
71      return isBranchListing(args)
72    case 'remote':
73      return first === undefined || first === '-v' || first === 'show' || first === 'get-url'
74    case 'stash':
75      return first === 'list' || first === 'show'
76    case 'reflog':
77      return first === undefined || first === 'show'
78    case 'tag':
79      return first === undefined || args.includes('-l') || args.includes('--list')
80    case 'config':
81      return first === '--get' || first === '--get-all' || first === '--get-regexp' || first === '--list' || first === '-l'
82    case 'clean':
83      return args.includes('-n') || args.includes('--dry-run')
84    default:
85      return false
86  }
87}
88
89const COMMIT_VALUE_FLAGS = new Set([
90  '-m', '-F', '-C', '-c', '-t', '--author', '--date', '--message', '--file', '--reuse-message',
91  '--reedit-message', '--template', '--fixup', '--squash', '--trailer', '--cleanup',
92])
93const COMMIT_VALUE_LETTERS = new Set(['m', 'F', 'C', 'c', 't'])
94
95const commitFlags = (args: readonly string[]) => {
96  const flags: string[] = []
97  for (let index = 0; index < args.length; index += 1) {
98    const arg = args[index]!
99    if (!arg.startsWith('-')) continue
100    flags.push(arg)
101    const takesValue = COMMIT_VALUE_FLAGS.has(arg) || (/^-[A-Za-z]+$/.test(arg) && COMMIT_VALUE_LETTERS.has(arg.at(-1)!))
102    if (takesValue) index += 1
103  }
104  return flags
105}
106
107const decideCommit = async (args: readonly string[], loadFacts: LoadFacts) => {
108  const flags = commitFlags(args)
109  const letters = shortLetters(flags)
110  if (letters.includes('S') || flags.some(flag => flag.startsWith('--gpg-sign'))) {
111    return deny('signs the commit; agent commits are unsigned')
112  }
113
114  const facts = await loadFacts()
115  if (!facts) return undefined
116  if (facts.isSibling) return siblingWrite(facts)
117  if (letters.includes('n') || flags.includes('--no-verify')) return ask('skips the commit hooks')
118  if (flags.includes('--amend') && facts.isHeadPushed) {
119    return ask(`amends ${describeBranch(facts)}'s HEAD, which is already on a remote`)
120  }
121
122  return allow(`commits in this worktree (${describeBranch(facts)})`)
123}
124
125const PUSH_PLAIN_FLAGS = new Set([
126  '-u', '--set-upstream', '-q', '--quiet', '-v', '--verbose', '--porcelain', '--progress',
127  '--no-progress', '--atomic', '--follow-tags', '--no-follow-tags', '--force-if-includes',
128])
129const PUSH_PLAIN_LETTERS = new Set(['u', 'q', 'v'])
130
131const stripHeads = (ref: string) => ref.replace(/^refs\/heads\//, '')
132
133const decidePush = async (args: readonly string[], loadFacts: LoadFacts) => {
134  let isLease = false
135  let isNoVerify = false
136  let isDryRun = false
137  const words: string[] = []
138
139  for (let index = 0; index < args.length; index += 1) {
140    const arg = args[index]!
141    if (arg === '-o' || arg === '--push-option') {
142      index += 1
143    } else if (arg.startsWith('--push-option=')) {
144      continue
145    } else if (arg === '--force' || arg === '--mirror' || arg === '--delete') {
146      return deny(`${arg} ${YOURS_TO_RUN}`)
147    } else if (arg.startsWith('--force-with-lease')) {
148      isLease = true
149    } else if (arg === '--no-verify') {
150      isNoVerify = true
151    } else if (arg === '--dry-run') {
152      isDryRun = true
153    } else if (arg === '--all' || arg === '--branches' || arg === '--tags') {
154      return ask(`${arg} pushes more than this worktree's branch`)
155    } else if (/^-[A-Za-z]+$/.test(arg)) {
156      for (const letter of arg.slice(1)) {
157        if (letter === 'f' || letter === 'd') return deny(`-${letter} ${YOURS_TO_RUN}`)
158        if (letter === 'n') isDryRun = true
159        else if (!PUSH_PLAIN_LETTERS.has(letter)) return undefined
160      }
161    } else if (arg.startsWith('-')) {
162      if (!PUSH_PLAIN_FLAGS.has(arg)) return undefined
163    } else {
164      words.push(arg)
165    }
166  }
167
168  const refspecs = words.slice(1)
169  for (const refspec of refspecs) {
170    if (refspec.startsWith('+')) return deny(`force-pushes ${refspec.slice(1)}, which ${YOURS_TO_RUN}`)
171    if (refspec.startsWith(':')) return deny(`deletes remote branch ${refspec.slice(1)}, which ${YOURS_TO_RUN}`)
172  }
173  if (isDryRun) return allow('dry-runs a push', true)
174
175  const facts = await loadFacts()
176  if (!facts) return undefined
177
178  const destinations = refspecs.length > 0
179    ? refspecs.map(refspec => {
180        const destination = stripHeads(refspec.includes(':') ? refspec.slice(refspec.indexOf(':') + 1) : refspec)
181        return destination === 'HEAD' ? facts.branch : destination
182      })
183    : [facts.upstreamBranch ?? facts.branch]
184  if (destinations.some(destination => destination === undefined)) return undefined
185
186  const defaults = new Set([facts.defaultBranch, 'main', 'master'])
187  const toDefault = destinations.find(destination => defaults.has(destination!))
188  if (toDefault) return ask(`pushes ${toDefault}, a default branch; that needs your go-ahead`)
189  if (facts.isSibling) return ask(`pushes from sibling worktree ${facts.worktree} (${describeBranch(facts)})`)
190
191  const foreign = destinations.find(destination => destination !== facts.branch)
192  if (foreign) return ask(`pushes ${foreign}, which is not this worktree's branch (${describeBranch(facts)})`)
193  if (isLease) return ask(`force-pushes ${facts.branch} with a lease, rewriting its remote history`)
194  if (isNoVerify) return ask('skips the pre-push hooks')
195
196  return allow(`pushes ${facts.branch}, this worktree's own branch`, true)
197}
198
199const decideBranch = (args: readonly string[]) => {
200  const letters = shortLetters(args)
201  const isDelete = letters.includes('d') || args.includes('--delete')
202  const isForce = letters.includes('f') || args.includes('--force')
203  if (letters.includes('D') || (isDelete && isForce)) return deny(`force-deleting a branch ${YOURS_TO_RUN}`)
204  return undefined
205}
206
207const isDiscard = ({ subcommand, args }: GitInvocation) => {
208  switch (subcommand) {
209    case 'reset':
210      return args.includes('--hard')
211    case 'clean':
212      return shortLetters(args).includes('f') || args.includes('--force')
213    case 'checkout':
214      return args.includes('--') || (args.length === 1 && args[0] === '.')
215    case 'restore':
216      return !(args.includes('--staged') || args.includes('-S')) || args.includes('--worktree') || args.includes('-W')
217    default:
218      return false
219  }
220}
221
222const isStageWrite = ({ subcommand, args }: GitInvocation) =>
223  subcommand === 'add' ||
224  (subcommand === 'restore' && !isDiscard({ subcommand, args })) ||
225  (subcommand === 'stash' && (args[0] === undefined || args[0] === 'push'))
226
227/**
228 * Decides a git invocation, or answers undefined to leave it to the session's
229 * own permission flow. Facts are loaded only for decisions that need them.
230 */
231export const decideGit = async (inv: GitInvocation, loadFacts: LoadFacts): Promise<Verdict | undefined> => {
232  if (isRead(inv)) return allow(`reads repository state (git ${inv.subcommand})`)
233
234  switch (inv.subcommand) {
235    case 'commit':
236      return decideCommit(inv.args, loadFacts)
237    case 'push':
238      return decidePush(inv.args, loadFacts)
239    case 'branch':
240      return decideBranch(inv.args)
241  }
242
243  if (isDiscard(inv)) {
244    const facts = await loadFacts()
245    return facts && discard(facts)
246  }
247  if (isStageWrite(inv)) {
248    const facts = await loadFacts()
249    if (!facts) return undefined
250    return facts.isSibling ? siblingWrite(facts) : allow(`stages in this worktree (${describeBranch(facts)})`)
251  }
252
253  return undefined
254}
255
256const isWtherdrCreation = (argv: readonly string[]) => {
257  const [tool, run, workflow, branchFlag, branch, baseFlag, base, ...rest] = argv
258  if (tool !== 'wtherdr' || run !== 'run' || workflow !== 'create') return false
259  if (branchFlag !== '--branch' || branch === undefined || branch.startsWith('-')) return false
260  if (baseFlag === undefined) return true
261  return baseFlag === '--base' && base !== undefined && !base.startsWith('-') && rest.length === 0
262}
263
264/** Decides a Worktrunk command, or answers undefined to leave it alone. */
265export const decideWt = (argv: readonly string[]): Verdict | undefined => {
266  if (isWtherdrCreation(argv)) return allow('creates a worktree through wtherdr', true)
267  if (argv[0] !== 'wt') return undefined
268
269  const [, subcommand, ...args] = argv
270  if (subcommand === 'list') return allow('lists worktrees')
271  if (subcommand !== 'switch') return undefined
272  if (args.length === 1 && !args[0]!.startsWith('-')) return allow(`switches to worktree ${args[0]}`)
273  if (args.includes('--no-cd') && parseBranchCreation(argv)) return allow('creates a worktree through Worktrunk', true)
274
275  return undefined
276}
277
hooks/repo.ts 44 lines
1import type { RepoFacts } from './policy'
2
3export type Run = (argv: string[], cwd?: string) => Promise<{ exitCode: number; stdout: string }>
4
5const afterRemote = (ref: string | undefined) => (ref?.includes('/') ? ref.slice(ref.indexOf('/') + 1) : undefined)
6
7/**
8 * Reads what the policy needs about the worktree a git command targets, `dir`
9 * resolved against the session's working directory. Answers undefined when
10 * either side is outside a repository or the two belong to different ones.
11 */
12export const loadRepoFacts = async (run: Run, sessionCwd: string, dir?: string): Promise<RepoFacts | undefined> => {
13  const git = async (cwd: string, ...args: string[]) => {
14    const ran = await run(['git', ...args], cwd)
15    return ran.exitCode === 0 ? ran.stdout.trim() : undefined
16  }
17  const target = dir === undefined ? sessionCwd : dir.startsWith('/') ? dir : `${sessionCwd}/${dir}`
18  const ids = ['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir']
19
20  const [targetIds, sessionIds] = await Promise.all([git(target, ...ids), git(sessionCwd, ...ids)])
21  const [worktree, commonDir] = targetIds?.split('\n') ?? []
22  const [sessionWorktree, sessionCommonDir] = sessionIds?.split('\n') ?? []
23  if (!worktree || !commonDir || commonDir !== sessionCommonDir) return undefined
24
25  const [branch, originHead, upstream, remoteContaining] = await Promise.all([
26    git(target, 'symbolic-ref', '--quiet', '--short', 'HEAD'),
27    git(target, 'symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'),
28    git(target, 'rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}'),
29    git(target, 'branch', '-r', '--contains', 'HEAD'),
30  ])
31
32  const facts: RepoFacts = {
33    worktree,
34    isSibling: worktree !== sessionWorktree,
35    defaultBranch: afterRemote(originHead) ?? 'main',
36    isHeadPushed: (remoteContaining ?? '') !== '',
37  }
38  if (branch) facts.branch = branch
39  const upstreamBranch = afterRemote(upstream)
40  if (upstreamBranch) facts.upstreamBranch = upstreamBranch
41
42  return facts
43}
44