Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
9859f1c7aThis file is pinned to the selected release. Download the original to open it in your design tool.
import { execFile } from 'node:child_process'
import { cp, lstat, mkdir, realpath, stat, writeFile } from 'node:fs/promises'
import { basename, dirname, isAbsolute, join, normalize } from 'node:path'
import { promisify } from 'node:util'
import { placeholderBranch, plausibleBranchName, worktreeFolderName } from './agentNames.js'
const exec = promisify(execFile)
export class GitProjectError extends Error {
constructor(readonly code: string, message: string) { super(message) }
}
export function validGitPath(path: unknown): path is string {
return typeof path === 'string' && isAbsolute(path) && path.length <= 4096 && !/[\x00-\x1f\x7f]/.test(path)
}
async function git(path: string, args: string[], timeout = 4000): Promise<string> {
const env = { ...process.env, GIT_TERMINAL_PROMPT: '0', GCM_INTERACTIVE: 'Never', GIT_OPTIONAL_LOCKS: '0' }
for (const key of ['GIT_DIR', 'GIT_WORK_TREE', 'GIT_COMMON_DIR', 'GIT_INDEX_FILE', 'GIT_NAMESPACE', 'GIT_PREFIX']) delete (env as NodeJS.ProcessEnv)[key]
return (await exec('git', ['--no-optional-locks', '-C', path, ...args], {
timeout, killSignal: 'SIGKILL', maxBuffer: 1024 * 1024,
env,
})).stdout.replace(/\r?\n$/, '')
}
/** `git worktree list --porcelain`, main checkout first. A bare repository has no files and a
* prunable entry lost its folder, so neither is somewhere to work. */
type Worktree = { path: string; ref: string | null; usable: boolean }
function parseWorktrees(output: string): Worktree[] {
return output.split(/\n\s*\n/).flatMap(block => {
const lines = block.split('\n')
const path = lines.find(line => line.startsWith('worktree '))?.slice('worktree '.length)
if (!path) return []
return [{
path,
ref: lines.find(line => line.startsWith('branch '))?.slice('branch '.length) ?? null,
usable: !lines.some(line => line === 'bare' || line.startsWith('prunable')),
}]
})
}
/** `branch.<name>.harness` in the repository's config marks a branch Harness made: `placeholder` while
* its name waits for the session's, `created` once it has one or was named at Start. */
export const HARNESS_BRANCH_KEY = 'harness'
async function harnessBranches(path: string): Promise<Set<string>> {
const out = await git(path, ['config', '--get-regexp', `^branch\\..*\\.${HARNESS_BRANCH_KEY}$`]).catch(() => '')
return new Set(out.split('\n').flatMap(line => {
const key = line.split(' ')[0] ?? ''
return key.startsWith('branch.') && key.endsWith(`.${HARNESS_BRANCH_KEY}`) ? [key.slice(7, -(HARNESS_BRANCH_KEY.length + 1))] : []
}))
}
const worktrees = (path: string) => git(path, ['worktree', 'list', '--porcelain']).then(parseWorktrees, () => [])
const real = (path: string) => realpath(path).catch(() => normalize(path))
const isDirectory = (path: string) => stat(path).then(info => info.isDirectory(), () => false)
/** The main checkout, when `root` is one of its linked worktrees. */
async function mainCheckout(root: string, trees: Worktree[]): Promise<string | null> {
if (trees.length < 2 || !trees[0]!.usable) return null
const here = await real(root)
if (await real(trees[0]!.path) === here) return null
for (const tree of trees.slice(1)) if (await real(tree.path) === here) return trees[0]!.path
return null
}
/** Read only: listing a branch never checks it out or fetches from a remote. */
export async function readGitProject(path: string) {
if (!validGitPath(path)) return { error: 'INVALID_PATH' }
let root: string
try { root = await git(path, ['rev-parse', '--show-toplevel']) }
catch (error) {
const failure = error as { code?: number | string; killed?: boolean }
return !failure.killed && failure.code === 128 ? { isGit: false, branches: [] } : { error: 'GIT_UNAVAILABLE' }
}
try {
const [branch, refs, trees, originHead, marked] = await Promise.all([
git(path, ['symbolic-ref', '--quiet', 'HEAD']).then(ref => ref.replace(/^refs\/heads\//, '')).catch(() => null),
git(path, ['for-each-ref', '--format=%(refname)%09%(refname:short)%09%(symref)', 'refs/heads', 'refs/remotes']),
worktrees(path),
git(path, ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD']).catch(() => null),
harnessBranches(path),
])
const checkedOut = new Map(trees.flatMap(tree => tree.usable && tree.ref ? [[tree.ref, tree.path] as const] : []))
const branches = refs.split('\n').filter(Boolean).flatMap(line => {
const [ref, name, symbolic] = line.split('\t')
if (!ref || !name || symbolic) return []
const worktree = checkedOut.get(ref)
const harness = !ref.startsWith('refs/remotes/') && (marked.has(name) || name.startsWith('harness/'))
return [{ ref, name, remote: ref.startsWith('refs/remotes/'), ...(worktree ? { worktree } : {}), ...(harness ? { harness } : {}) }]
})
// Where new work starts by default: the remote's default branch, as a clone names it, else its
// main or master.
const known = new Set(branches.map(row => row.ref))
const defaultRef = [originHead, 'refs/remotes/origin/main', 'refs/remotes/origin/master'].find(ref => ref && known.has(ref))
const found = { isGit: true, root, branch, branches, ...(defaultRef ? { defaultRef } : {}) }
// A worktree is a temporary folder. The launcher shows its repository.
const main = await mainCheckout(root, trees)
if (!main) return found
const prefix = (await git(path, ['rev-parse', '--show-prefix']).catch(() => '')).replace(/\/$/, '')
const mainFolder = prefix && await isDirectory(join(main, prefix)) ? join(main, prefix) : main
const mainBranch = trees[0]!.ref?.replace(/^refs\/heads\//, '')
return { ...found, mainFolder, ...(mainBranch ? { mainBranch } : {}) }
} catch { return { error: 'GIT_UNAVAILABLE' } }
}
/** `refs/remotes/origin/fix/typo` → origin, fix/typo, and a refspec updating exactly that ref. */
function remoteBranch(ref: string | undefined): { remote: string; branch: string; refspec: string } | null {
const match = /^refs\/remotes\/([A-Za-z0-9._][A-Za-z0-9._-]*)\/([A-Za-z0-9._]\S*)$/.exec(ref ?? '')
if (!match || match[2] === 'HEAD') return null
return { remote: match[1]!, branch: match[2]!, refspec: `+refs/heads/${match[2]}:refs/remotes/${match[1]}/${match[2]}` }
}
export type GitProjectOptions = {
root: string
worktree: boolean
/** Worktree on: what a new branch starts from. Worktree off: the branch the folder is on. */
ref?: string
/** The worktree's branch: created from `ref`, or with `existingBranch` checked out as it is. */
branchName?: string
existingBranch?: boolean
/** `branchName` was made up (`brave-otter`): the session's name replaces it once there is one. */
placeholder?: boolean
pick?: (n: number) => number
}
/** Worktree on: a new worktree under `<root>/worktrees/<repository>`, on `branchName` — created from
* `ref`, or with `existingBranch` an existing local branch checked out as it is. Without a name one
* is made up (`harness/brave-otter`). A remote base is fetched first, briefly.
*
* Worktree off: the folder on the local `ref`. A branch that already has a worktree opens there; any
* other is switched to, only in a folder with nothing uncommitted. With `branchName` the folder moves
* to that new branch, made where it is now, uncommitted changes and all. */
export async function prepareGitProject(source: string, options: GitProjectOptions): Promise<string> {
if (!validGitPath(source) || (options.branchName !== undefined && !plausibleBranchName(options.branchName))
|| (options.existingBranch && !options.branchName)) {
throw new GitProjectError('INVALID_PROJECT_SOURCE', 'Choose a Git project folder.')
}
const existing = options.existingBranch === true
// A new branch for the folder itself: `ref` names it and does not exist.
const creating = !options.worktree && !!options.branchName && !existing
let root: string, head: string, prefix: string
try {
root = await git(source, ['rev-parse', '--show-toplevel'])
if (options.ref && !creating) {
if (!/^refs\/(heads|remotes)\/[^\s\x00-\x1f\x7f]+$/.test(options.ref)) throw new Error('Invalid branch')
await git(source, ['show-ref', '--verify', '--hash', '--', options.ref])
}
// New work starts from where its branch is now, not from the last fetch: a remote branch is
// fetched; a local one is fetched against its upstream and the newer of the two taken, so nothing
// only the local one has is lost. Offline, slow or refused, it starts from what is here.
let start = existing ? `refs/heads/${options.branchName}` : creating ? 'HEAD' : options.ref ?? 'HEAD'
const remote = options.worktree && !existing ? remoteBranch(options.ref) : null
const upstream = options.worktree && !existing && !remote && options.ref
? await git(source, ['for-each-ref', '--format=%(upstream)', options.ref]).then(ref => remoteBranch(ref) ? ref : null, () => null)
: null
const fetch = remote ?? remoteBranch(upstream ?? undefined)
if (fetch) await git(source, ['fetch', '--quiet', '--no-tags', fetch.remote, fetch.refspec], 10_000).catch(() => '')
// Behind or level with its upstream: the upstream is the newer.
if (upstream && options.ref && await git(source, ['merge-base', '--is-ancestor', options.ref, upstream]).then(() => true, () => false)) {
start = upstream
}
head = await git(source, ['rev-parse', '--verify', '--end-of-options', `${start}^{commit}`])
prefix = await git(source, ['rev-parse', '--show-prefix'])
if (prefix && await git(source, ['cat-file', '-t', `${head}:${prefix.replace(/\/$/, '')}`]) !== 'tree') throw new Error('Missing folder')
} catch {
throw new GitProjectError('GIT_PROJECT_UNAVAILABLE', 'Choose a Git project and branch with at least one commit. For a new folder, turn Worktree off.')
}
const inside = (folder: string) => prefix ? join(folder, prefix.replace(/\/$/, '')) : folder
const trees = await worktrees(source)
if (creating) {
const name = options.branchName!
if (await git(source, ['show-ref', '--verify', '--quiet', '--', `refs/heads/${name}`]).then(() => true, () => false)) {
throw new GitProjectError('BRANCH_EXISTS', `A branch named ${name} already exists. Choose it instead.`)
}
try { await git(source, ['check-ref-format', '--branch', name]) }
catch { throw new GitProjectError('INVALID_BRANCH', `${name} is not a valid branch name.`) }
try { await git(source, ['switch', '-c', name]) }
catch { throw new GitProjectError('BRANCH_SWITCH_FAILED', `Could not create ${name} here.`) }
return source
}
if (!options.worktree) {
if (!options.ref?.startsWith('refs/heads/')) throw new GitProjectError('INVALID_BRANCH', 'Choose a local branch, or turn Worktree on.')
const current = await git(source, ['symbolic-ref', '--quiet', 'HEAD']).catch(() => null)
if (current === options.ref) return source
// A branch that already has a worktree is worked on there: Git would refuse to check it out
// twice, and the folder is not the person's concern.
for (const tree of trees) {
if (tree.usable && tree.ref === options.ref && await isDirectory(tree.path)) return inside(tree.path)
}
const status = await git(source, ['status', '--porcelain']).catch(() => null)
if (status !== '') {
throw new GitProjectError('BRANCH_SWITCH_FAILED', 'This folder has uncommitted changes. Commit or stash them before switching branches, or turn Worktree on.')
}
try {
await git(source, ['switch', '--', options.ref.slice('refs/heads/'.length)], 120_000)
return source
} catch {
throw new GitProjectError('BRANCH_SWITCH_FAILED', 'Could not switch branches. Commit or stash conflicting changes, or turn Worktree on.')
}
}
// Grouped by repository, so the leaf only needs the branch.
const repository = trees[0]?.usable ? basename(trees[0].path) : basename(root)
const taken = new Set((await git(source, ['for-each-ref', '--format=%(refname)', 'refs/heads']).catch(() => '')).split('\n'))
const branch = options.branchName ?? placeholderBranch(taken, options.pick)
const placeholder = !options.branchName || options.placeholder === true
if (existing) {
if (!taken.has(`refs/heads/${branch}`)) throw new GitProjectError('GIT_PROJECT_UNAVAILABLE', 'That branch is no longer available. Choose another branch.')
if (trees.some(tree => tree.ref === `refs/heads/${branch}`)) {
throw new GitProjectError('BRANCH_IN_USE', `${branch} is checked out in another worktree. Turn Worktree off to open it there.`)
}
} else {
if (taken.has(`refs/heads/${branch}`)) throw new GitProjectError('BRANCH_EXISTS', `A branch named ${branch} already exists. Choose another name.`)
try { await git(source, ['check-ref-format', '--branch', branch]) }
catch { throw new GitProjectError('INVALID_BRANCH', `${branch} is not a valid branch name.`) }
}
let destination: string | undefined
try {
const home = join(options.root, 'worktrees')
const parent = join(home, repository)
await mkdir(parent, { recursive: true })
// Spotlight would index every checkout again.
if (process.platform === 'darwin') await writeFile(join(home, '.metadata_never_index'), '').catch(() => {})
const leaf = worktreeFolderName(branch)
// mkdir reserves the name atomically, so simultaneous starts never share a worktree.
for (let attempt = 1; attempt <= 100 && !destination; attempt++) {
const folder = join(parent, attempt === 1 ? leaf : `${leaf}-${attempt}`)
try { await mkdir(folder); destination = folder }
catch (error) { if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error }
}
} catch { destination = undefined }
if (!destination) throw new GitProjectError('WORKTREE_FAILED', 'Could not create a worktree folder. Check folder permissions, then retry.')
const remote = remoteBranch(options.ref)
try {
await git(source, existing
? ['worktree', 'add', '--', destination, branch]
// A remote branch checked out under its own name tracks it; a new branch from one must not,
// or it would push onto the base.
: remote && remote.branch === branch
? ['worktree', 'add', '--track', '-b', branch, '--', destination, options.ref!]
: ['worktree', 'add', '--no-track', '-b', branch, '--', destination, head], 120_000)
} catch {
// Keep any partial checkout and branch available for recovery.
throw new GitProjectError('WORKTREE_FAILED', `Could not create the worktree at ${destination}. Check Git and folder permissions, then retry.`)
}
// Harness made this branch: its cleanup may remove it, and a made-up name gives way to the session's.
if (!existing) {
await git(source, ['config', `branch.${branch}.${HARNESS_BRANCH_KEY}`, placeholder ? 'placeholder' : 'created']).catch(() => '')
}
await copyIncluded(root, destination)
return inside(destination)
}
/** `.worktreeinclude` (gitignore syntax) in the repository names ignored files a new worktree needs
* and Git will not bring: `.env`, local config. Only files both listed and ignored are copied, never
* over one the checkout has. A copy that fails leaves the worktree as Git made it. */
async function copyIncluded(from: string, to: string): Promise<void> {
const include = join(from, '.worktreeinclude')
try {
if (!(await stat(include)).isFile()) return
} catch { return }
const list = (exclude: string) => git(from, ['ls-files', '-z', '--others', '--ignored', '--directory', exclude])
.then(out => out.split('\0').filter(Boolean), () => [] as string[])
const [listed, ignored] = await Promise.all([list(`--exclude-from=${include}`), list('--exclude-standard')])
const isIgnored = (entry: string) => ignored.some(path => path === entry || (path.endsWith('/') && entry.startsWith(path)))
for (const entry of listed.filter(isIgnored).slice(0, 200)) {
const relative = entry.replace(/\/$/, '')
if (relative.split('/').includes('..')) continue
const target = join(to, relative)
try { await lstat(target); continue } catch { /* free */ }
try {
await mkdir(dirname(target), { recursive: true })
await cp(join(from, relative), target, { recursive: true, errorOnExist: false, force: false, verbatimSymlinks: true, preserveTimestamps: true })
} catch { /* the worktree stays as Git made it */ }
}
}