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

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.
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.
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.
See AGENTS.md for repo structure, conventions, and the symsource/symlink pattern in detail.
hooks/register.ts 103 lines1import 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}
103hooks/command.ts 157 lines1export 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}
157hooks/policy.ts 277 lines1import { 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}
277hooks/repo.ts 44 lines1import 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