orca/src/main/git/worktree.ts

1326 lines
45 KiB
TypeScript

/* eslint-disable max-lines -- Why: this file keeps git worktree create/remove behavior together so local cleanup and creation invariants stay in one place. */
import { stat } from 'node:fs/promises'
import { join, posix, win32 } from 'node:path'
import {
branchHasNoUnmergedChangesOnAnyTarget,
getBranchCleanupTargetRefs,
refreshBranchCleanupTargetRefs
} from '../../shared/git-branch-cleanup'
import { resolveWorktreeAddBaseRef } from '../../shared/worktree-base-ref'
import type {
GitWorktreeInfo,
LocalBaseRefRefreshResult,
LocalBaseRefUpdateSuggestion,
RemoveWorktreeResult
} from '../../shared/types'
import { parseGitRevListAheadBehindCounts } from '../../shared/git-rev-list-output'
import { parseWslUncPath } from '../../shared/wsl-paths'
import { gitExecFileAsync, translateWslOutputPaths } from './runner'
import { resolveGitDir } from './status'
import { hasWorktreeBaseCommitRef } from './worktree-base-ref-probe'
export type AddWorktreeResult = {
localBaseRefRefresh?: LocalBaseRefRefreshResult
localBaseRefUpdateSuggestion?: LocalBaseRefUpdateSuggestion
}
type SparseWorktreeCreateError = Error & {
cleanupFailed?: boolean
}
export type GitWorktreeExecOptions = {
wslDistro?: string
signal?: AbortSignal
}
export type AddWorktreeOptions = GitWorktreeExecOptions & {
checkoutExistingBranch?: boolean
suggestLocalBaseRefUpdate?: boolean
remoteTrackingBase?: {
base: string
branch: string
ref: string
}
}
export type RemoveWorktreeOptions = GitWorktreeExecOptions & {
deleteBranch?: boolean
forceBranchDelete?: boolean
knownRemovedWorktree?: Pick<GitWorktreeInfo, 'branch' | 'head'>
}
type LocalBaseRefRefreshability =
| {
refreshable: true
baseRef: string
localBranch: string
fullRef: string
remoteTrackingRef: string
localOid: string
remoteOid: string
behind: number
ownerWorktreePath?: string
}
| {
refreshable: false
result: LocalBaseRefRefreshResult
}
const SPARSE_CHECKOUT_DETECTION_CONCURRENCY = 8
// Why: bound `git worktree add` so a OneDrive cloud-placeholder base path can't
// stall its checkout writes for minutes (STA-1292) — a stuck create then fails
// fast instead of spinning forever. Generous enough not to kill a legit large
// checkout (mirrors the SYNC runner's cloud-placeholder floor, #7225).
export const WORKTREE_ADD_TIMEOUT_MS = 180_000
function gitExecOptions(
cwd: string,
options: GitWorktreeExecOptions = {}
): { cwd: string; wslDistro?: string; signal?: AbortSignal } {
return {
cwd,
...(options.wslDistro ? { wslDistro: options.wslDistro } : {}),
...(options.signal ? { signal: options.signal } : {})
}
}
function getErrorCode(error: unknown): string | undefined {
return typeof error === 'object' && error !== null && 'code' in error
? String((error as { code?: unknown }).code)
: undefined
}
function getErrorText(error: unknown): string {
if (typeof error === 'object' && error !== null) {
const parts: string[] = []
if ('message' in error && typeof error.message === 'string') {
parts.push(error.message)
}
if ('stderr' in error && typeof error.stderr === 'string') {
parts.push(error.stderr)
}
return parts.join('\n')
}
return String(error)
}
function isNotGitRepositoryError(error: unknown): boolean {
return /not a git repository/i.test(getErrorText(error))
}
function isUnsupportedWorktreeListZError(error: unknown): boolean {
// `-z` is this command's only flag older Git (<2.36) lacks, so its usage
// exit 129 signals the rejection in any locale; stderr match is a fallback.
if (getErrorCode(error) === '129') {
return true
}
return /(?:unknown|invalid|unrecognized) (?:switch|option).*`?-?z'?/i.test(getErrorText(error))
}
function isUnsupportedRevParsePathFormatError(error: unknown): boolean {
return /(?:unknown|invalid|unrecognized).*(?:--path-format|path-format)/i.test(
getErrorText(error)
)
}
function isBranchCheckedOutInWorktreeError(error: unknown): boolean {
return /cannot delete branch .*(?:used by worktree|checked out)|branch .*is checked out/i.test(
getErrorText(error)
)
}
function normalizeLocalBranchRef(branch: string): string {
return branch.replace(/^refs\/heads\//, '')
}
function parseRemoteTrackingLocalBaseRef(
baseBranch: string,
remoteTrackingRef: string,
remoteTrackingBase?: AddWorktreeOptions['remoteTrackingBase']
): { baseRef: string; localBranch: string; fullRef: string } | undefined {
if (remoteTrackingBase?.ref === remoteTrackingRef) {
return {
baseRef: remoteTrackingBase.base,
localBranch: remoteTrackingBase.branch,
fullRef: `refs/heads/${remoteTrackingBase.branch}`
}
}
const remoteRefPrefix = 'refs/remotes/'
if (!remoteTrackingRef.startsWith(remoteRefPrefix)) {
return undefined
}
// Why: Only refs proven to be remote-tracking refs get refresh status.
// Local branches can contain slashes (e.g. release/2026) and must not
// produce a fake "Local 2026 was not refreshed" warning.
const shortRemoteRef = remoteTrackingRef.slice(remoteRefPrefix.length)
const slashIndex = shortRemoteRef.indexOf('/')
if (slashIndex <= 0) {
return undefined
}
const localBranch = shortRemoteRef.slice(slashIndex + 1)
return {
baseRef: baseBranch,
localBranch,
fullRef: `refs/heads/${localBranch}`
}
}
function parseRevListDrift(output: string): { ahead: number; behind: number } | null {
const counts = parseGitRevListAheadBehindCounts(output)
return counts.status === 'ok' ? { ahead: counts.ahead, behind: counts.behind } : null
}
async function evaluateLocalBaseRefRefreshability(
repoPath: string,
baseBranch: string,
remoteTrackingRef: string,
remoteTrackingBase?: AddWorktreeOptions['remoteTrackingBase'],
options: GitWorktreeExecOptions = {}
): Promise<LocalBaseRefRefreshability | undefined> {
const parsed = parseRemoteTrackingLocalBaseRef(baseBranch, remoteTrackingRef, remoteTrackingBase)
if (!parsed) {
return undefined
}
const resultBase = { baseRef: parsed.baseRef, localBranch: parsed.localBranch }
let drift: { ahead: number; behind: number }
let localOid = ''
let remoteOid = ''
try {
// Why: advisory and mutating paths must agree on "safe to fast-forward".
// `rev-list A...B` proves the local branch exists, has no local-only
// commits, and tells the toast whether it is actually behind.
const { stdout } = await gitExecFileAsync(
['rev-list', '--left-right', '--count', `${parsed.fullRef}...${remoteTrackingRef}`],
gitExecOptions(repoPath, options)
)
const parsedDrift = parseRevListDrift(stdout)
if (!parsedDrift || parsedDrift.ahead !== 0) {
return { refreshable: false, result: { ...resultBase, status: 'skipped_not_fast_forward' } }
}
const { stdout: localOidOutput } = await gitExecFileAsync(
['rev-parse', '--verify', `${parsed.fullRef}^{commit}`],
gitExecOptions(repoPath, options)
)
localOid = localOidOutput.trim()
if (!localOid) {
return { refreshable: false, result: { ...resultBase, status: 'skipped_not_fast_forward' } }
}
const { stdout: remoteOidOutput } = await gitExecFileAsync(
['rev-parse', '--verify', `${remoteTrackingRef}^{commit}`],
gitExecOptions(repoPath, options)
)
remoteOid = remoteOidOutput.trim()
if (!remoteOid) {
return { refreshable: false, result: { ...resultBase, status: 'skipped_not_fast_forward' } }
}
await gitExecFileAsync(
['merge-base', '--is-ancestor', localOid, remoteOid],
gitExecOptions(repoPath, options)
)
drift = parsedDrift
} catch {
return { refreshable: false, result: { ...resultBase, status: 'skipped_not_fast_forward' } }
}
try {
// Why: if the local base branch is checked out anywhere, turning on the
// setting would only update it when that owner worktree is clean.
const { stdout: worktreeListOutput } = await gitExecFileAsync(
['worktree', 'list', '--porcelain'],
gitExecOptions(repoPath, options)
)
const worktrees = parseWorktreeList(
translateWslOutputPaths(worktreeListOutput, repoPath, options)
)
const ownerWorktree = worktrees.find((wt) => wt.branch === parsed.fullRef)
if (ownerWorktree) {
const { stdout: status } = await gitExecFileAsync(
['status', '--porcelain', '--untracked-files=no'],
gitExecOptions(ownerWorktree.path, options)
)
if (status.trim()) {
return {
refreshable: false,
result: {
...resultBase,
status: 'skipped_dirty_worktree',
ownerWorktreePath: ownerWorktree.path
}
}
}
return {
refreshable: true,
...resultBase,
fullRef: parsed.fullRef,
remoteTrackingRef,
localOid,
remoteOid,
behind: drift.behind,
ownerWorktreePath: ownerWorktree.path
}
}
// Why: localBranch isn't checked out anywhere, so there is no working tree
// to desync — a bare ref fast-forward (update-ref) is safe. Omitting
// ownerWorktreePath signals the mutating path to take that branch.
return {
refreshable: true,
...resultBase,
fullRef: parsed.fullRef,
remoteTrackingRef,
localOid,
remoteOid,
behind: drift.behind
}
} catch {
return { refreshable: false, result: { ...resultBase, status: 'skipped_error' } }
}
}
async function getLocalBaseRefUpdateSuggestionForWorktreeCreate(
repoPath: string,
baseBranch: string,
remoteTrackingRef: string,
remoteTrackingBase?: AddWorktreeOptions['remoteTrackingBase'],
options: GitWorktreeExecOptions = {}
): Promise<LocalBaseRefUpdateSuggestion | undefined> {
const evaluation = await evaluateLocalBaseRefRefreshability(
repoPath,
baseBranch,
remoteTrackingRef,
remoteTrackingBase,
options
)
if (!evaluation?.refreshable || evaluation.behind <= 0) {
return undefined
}
return {
baseRef: evaluation.baseRef,
localBranch: evaluation.localBranch,
behind: evaluation.behind
}
}
async function persistWorktreeCreationBase(
worktreePath: string,
branch: string,
effectiveBase: string,
options: GitWorktreeExecOptions = {}
): Promise<void> {
const configKey = `branch.${branch}.base`
try {
await gitExecFileAsync(['config', '--local', '--replace-all', configKey, effectiveBase], {
...gitExecOptions(worktreePath, options)
})
} catch (error) {
console.warn(`addWorktree: failed to set ${configKey} for ${worktreePath}`, error)
try {
// Why: reused branch names may carry stale base metadata; if replacement
// fails, remove the old value so consumers do not trust outdated lineage.
await gitExecFileAsync(['config', '--local', '--unset-all', configKey], {
...gitExecOptions(worktreePath, options)
})
} catch (unsetError) {
console.warn(
`addWorktree: failed to unset stale ${configKey} for ${worktreePath}`,
unsetError
)
}
}
}
async function unsetWorktreeCreationBase(
worktreePath: string,
branch: string,
options: GitWorktreeExecOptions = {}
): Promise<void> {
try {
await gitExecFileAsync(['config', '--local', '--unset-all', `branch.${branch}.base`], {
...gitExecOptions(worktreePath, options)
})
} catch {
// Best-effort cleanup; missing keys and locked config both leave the
// original sparse setup error as the actionable failure.
}
}
function areWorktreePathsEqual(
leftPath: string,
rightPath: string,
platform = process.platform
): boolean {
if (platform === 'win32' || looksLikeWindowsPath(leftPath) || looksLikeWindowsPath(rightPath)) {
return (
win32.normalize(win32.resolve(leftPath)).toLowerCase() ===
win32.normalize(win32.resolve(rightPath)).toLowerCase()
)
}
return posix.normalize(posix.resolve(leftPath)) === posix.normalize(posix.resolve(rightPath))
}
function looksLikeWindowsPath(pathValue: string): boolean {
return /^[A-Za-z]:[\\/]/.test(pathValue) || pathValue.startsWith('\\\\')
}
function resolveRevParsePath(repoPath: string, value: string): string {
if (posix.isAbsolute(value) || win32.isAbsolute(value)) {
return value
}
// Old git ignores `--path-format=absolute`, so a relative toplevel/git-dir
// must be resolved against the scanned repo path.
return looksLikeWindowsPath(repoPath)
? win32.resolve(repoPath, value)
: posix.resolve(repoPath, value)
}
type RepoLocation = { topLevel: string; commonDir: string }
function parseRepoLocation(repoPath: string, output: string): RepoLocation | undefined {
// Old git (pre `--path-format`) echoes the unrecognized flag to stdout and
// exits 0 rather than erroring, so drop any echoed `-`-prefixed lines and
// read the two trailing path lines (toplevel, then git-common-dir). Strip only
// the trailing CR, not surrounding spaces — git paths may legitimately start
// or end with a space.
const lines = output
.split('\n')
.map((line) => (line.endsWith('\r') ? line.slice(0, -1) : line))
.filter((line) => line.length > 0 && !line.startsWith('-'))
if (lines.length < 2) {
return undefined
}
const [topLevel, commonDir] = lines.slice(-2)
return {
topLevel: resolveRevParsePath(repoPath, topLevel),
commonDir: resolveRevParsePath(repoPath, commonDir)
}
}
async function readRepoLocation(
repoPath: string,
resolveBasePath: string,
options: GitWorktreeExecOptions = {}
): Promise<RepoLocation | undefined> {
try {
const { stdout } = await gitExecFileAsync(
['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'],
gitExecOptions(repoPath, options)
)
return parseRepoLocation(resolveBasePath, stdout)
} catch (error) {
if (!isUnsupportedRevParsePathFormatError(error)) {
return undefined
}
}
try {
const { stdout } = await gitExecFileAsync(
['rev-parse', '--show-toplevel', '--git-common-dir'],
gitExecOptions(repoPath, options)
)
return parseRepoLocation(resolveBasePath, stdout)
} catch {
return undefined
}
}
async function normalizeMainWorktreePath(
repoPath: string,
worktrees: GitWorktreeInfo[],
options: GitWorktreeExecOptions = {}
): Promise<GitWorktreeInfo[]> {
const mainIndex = worktrees.findIndex((worktree) => worktree.isMainWorktree)
const mainWorktree = worktrees[mainIndex]
// Compare in the Git-output space: under WSL the porcelain/rev-parse paths are
// Linux while repoPath is a UNC path, so without translating, a UNC repoPath
// never matches the early-return and fires a needless rev-parse on every poll.
// Use the platform-independent UNC parser so the comparison holds regardless
// of host OS; the runner still receives the original repoPath for WSL routing.
const wslRepo = parseWslUncPath(repoPath)
const comparablePath = wslRepo ? wslRepo.linuxPath : repoPath
if (!mainWorktree || areWorktreePathsEqual(mainWorktree.path, comparablePath)) {
return worktrees
}
const location = await readRepoLocation(repoPath, comparablePath, options)
if (!location) {
return worktrees
}
// Why: only a separate-git-dir/submodule main worktree reports the Git
// directory as the main entry — i.e. the main entry equals git-common-dir.
// A linked worktree's main entry is a real working root, so gating on this
// equality avoids overwriting it with the linked worktree's own toplevel.
if (!areWorktreePathsEqual(mainWorktree.path, location.commonDir)) {
return worktrees
}
const normalized = [...worktrees]
normalized[mainIndex] = { ...mainWorktree, path: location.topLevel }
return normalized
}
/**
* Parse the porcelain output of `git worktree list --porcelain`.
*/
export function parseWorktreeList(
output: string,
options: { nulDelimited?: boolean } = {}
): GitWorktreeInfo[] {
const worktrees: GitWorktreeInfo[] = []
const blocks = options.nulDelimited ? splitNulWorktreeList(output) : splitLineWorktreeList(output)
for (const lines of blocks) {
if (lines.length === 0) {
continue
}
let path = ''
let head = ''
let branch = ''
let isBare = false
let isSparse = false
for (const line of lines) {
if (line.startsWith('worktree ')) {
path = line.slice('worktree '.length)
} else if (line.startsWith('HEAD ')) {
head = line.slice('HEAD '.length)
} else if (line.startsWith('branch ')) {
branch = line.slice('branch '.length)
} else if (line === 'bare') {
isBare = true
} else if (line === 'sparse') {
isSparse = true
}
}
if (path) {
// `git worktree list` always emits the main working tree first.
worktrees.push({
path,
head,
branch,
isBare,
...(isSparse ? { isSparse } : {}),
isMainWorktree: worktrees.length === 0
})
}
}
return worktrees
}
function splitLineWorktreeList(output: string): string[][] {
return output
.trim()
.split(/\r?\n\r?\n/)
.map((block) => block.trim().split(/\r?\n/))
}
function splitNulWorktreeList(output: string): string[][] {
if (!output.includes('\0')) {
return splitLineWorktreeList(output)
}
const blocks: string[][] = []
let currentBlock: string[] = []
for (const field of output.split('\0')) {
if (field) {
currentBlock.push(field)
continue
}
if (currentBlock.length > 0) {
blocks.push(currentBlock)
currentBlock = []
}
}
if (currentBlock.length > 0) {
blocks.push(currentBlock)
}
return blocks
}
async function readWorktreeList(
repoPath: string,
options: GitWorktreeExecOptions = {}
): Promise<GitWorktreeInfo[]> {
try {
const { stdout } = await gitExecFileAsync(['worktree', 'list', '--porcelain', '-z'], {
cwd: repoPath,
...options
})
return normalizeMainWorktreePath(
repoPath,
parseWorktreeList(stdout, { nulDelimited: true }),
options
)
} catch (error) {
if (!isUnsupportedWorktreeListZError(error)) {
throw error
}
}
// Why: `-z` is required to preserve worktree paths containing newlines, but
// Git <2.36 rejects it. Keep the old parser as a compatibility fallback.
const { stdout } = await gitExecFileAsync(['worktree', 'list', '--porcelain'], {
cwd: repoPath,
...options
})
return normalizeMainWorktreePath(repoPath, parseWorktreeList(stdout), options)
}
async function readTranslatedWorktreeGraph(
repoPath: string,
options: GitWorktreeExecOptions = {}
): Promise<GitWorktreeInfo[]> {
return (await readWorktreeList(repoPath, options)).map((worktree) => {
const translatedPath = translateWorktreePath(worktree.path, repoPath, options)
return translatedPath === worktree.path ? worktree : { ...worktree, path: translatedPath }
})
}
export async function listWorktreeGraph(
repoPath: string,
options: GitWorktreeExecOptions = {}
): Promise<GitWorktreeInfo[]> {
try {
return await readTranslatedWorktreeGraph(repoPath, options)
} catch (err) {
if (getErrorCode(err) === 'ENOENT') {
try {
await stat(repoPath)
} catch (statErr) {
if (getErrorCode(statErr) === 'ENOENT') {
console.warn(`[git/worktree] repo path missing; skipping worktree list: ${repoPath}`)
return []
}
}
}
if (isNotGitRepositoryError(err)) {
return []
}
console.warn(`[git/worktree] listWorktreeGraph failed for ${repoPath}:`, err)
return []
}
}
// Why: a cold start lists every repo's worktrees from several independent
// paths (renderer worktrees fetch, lineage resolution, boot pty-registry
// hydration), and each `git worktree list` is a full process spawn —
// expensive on Windows (issue #7225). Sharing the in-flight promise collapses
// concurrent duplicate scans; nothing is served after a scan settles.
const inFlightWorktreeScans = new Map<string, Promise<GitWorktreeInfo[]>>()
// Why: a caller listing AFTER a mutation completed must observe it, so it may
// not join a scan that started before the mutation. Bumping the generation on
// mutation completion retires older in-flight scans from sharing (they still
// settle for their original callers).
const worktreeScanGenerations = new Map<string, number>()
function bumpWorktreeScanGeneration(repoPath: string): void {
worktreeScanGenerations.set(repoPath, (worktreeScanGenerations.get(repoPath) ?? 0) + 1)
}
/**
* List all worktrees for a git repo at the given path. Concurrent calls for
* the same repo share one scan (unless the caller passes an AbortSignal,
* which must only cancel its own scan).
*/
export function listWorktrees(
repoPath: string,
options: GitWorktreeExecOptions = {}
): Promise<GitWorktreeInfo[]> {
if (options.signal) {
return listWorktreesUnshared(repoPath, options)
}
const generation = worktreeScanGenerations.get(repoPath) ?? 0
const key = `${repoPath}\0${options.wslDistro ?? ''}\0${generation}`
const inFlight = inFlightWorktreeScans.get(key)
if (inFlight) {
return inFlight
}
const scan = listWorktreesUnshared(repoPath, options).finally(() => {
if (inFlightWorktreeScans.get(key) === scan) {
inFlightWorktreeScans.delete(key)
}
})
inFlightWorktreeScans.set(key, scan)
return scan
}
async function listWorktreesUnshared(
repoPath: string,
options: GitWorktreeExecOptions = {}
): Promise<GitWorktreeInfo[]> {
try {
const worktrees = await readTranslatedWorktreeGraph(repoPath, options)
return annotateSparseCheckoutStatus(worktrees)
} catch (err) {
if (getErrorCode(err) === 'ENOENT') {
try {
await stat(repoPath)
} catch (statErr) {
if (getErrorCode(statErr) === 'ENOENT') {
console.warn(`[git/worktree] repo path missing; skipping worktree list: ${repoPath}`)
return []
}
}
}
if (isNotGitRepositoryError(err)) {
return []
}
// Why: a silent catch turns git compatibility or repo-state failures into
// opaque downstream errors like "Worktree created but not found in listing".
// Surface the cause so future regressions show up immediately.
console.warn(`[git/worktree] listWorktrees failed for ${repoPath}:`, err)
return []
}
}
export async function listWorktreesStrict(
repoPath: string,
options: GitWorktreeExecOptions = {}
): Promise<GitWorktreeInfo[]> {
const worktrees = (await readWorktreeList(repoPath, options)).map((worktree) => {
const translatedPath = translateWorktreePath(worktree.path, repoPath, options)
return translatedPath === worktree.path ? worktree : { ...worktree, path: translatedPath }
})
return annotateSparseCheckoutStatus(worktrees)
}
async function annotateSparseCheckoutStatus(
worktrees: GitWorktreeInfo[]
): Promise<GitWorktreeInfo[]> {
const annotated = [...worktrees]
let nextIndex = 0
async function detectNext(): Promise<void> {
while (nextIndex < worktrees.length) {
const index = nextIndex
nextIndex += 1
const worktree = worktrees[index]
if (!worktree || worktree.isBare || worktree.isSparse) {
continue
}
const isSparse = await detectSparseCheckout(worktree.path)
if (isSparse) {
annotated[index] = { ...worktree, isSparse }
}
}
}
// Why: worktree refreshes run on git-status polling paths. Many worktrees can
// otherwise fan out `.git`/sparse-checkout filesystem probes all at once.
const workerCount = Math.min(SPARSE_CHECKOUT_DETECTION_CONCURRENCY, worktrees.length)
await Promise.all(Array.from({ length: workerCount }, () => detectNext()))
return annotated
}
async function refreshLocalBaseRefForWorktreeCreate(
repoPath: string,
baseBranch: string,
remoteTrackingRef: string,
remoteTrackingBase?: AddWorktreeOptions['remoteTrackingBase'],
options: GitWorktreeExecOptions = {}
): Promise<LocalBaseRefRefreshResult | undefined> {
const evaluation = await evaluateLocalBaseRefRefreshability(
repoPath,
baseBranch,
remoteTrackingRef,
remoteTrackingBase,
options
)
if (!evaluation) {
return undefined
}
if (!evaluation.refreshable) {
return evaluation.result
}
const resultBase = { baseRef: evaluation.baseRef, localBranch: evaluation.localBranch }
try {
if (evaluation.ownerWorktreePath) {
const { stdout: worktreeListOutput } = await gitExecFileAsync(
['worktree', 'list', '--porcelain'],
gitExecOptions(repoPath, options)
)
const worktrees = parseWorktreeList(
translateWslOutputPaths(worktreeListOutput, repoPath, options)
)
const currentOwner = worktrees.find((wt) => wt.branch === evaluation.fullRef)
if (!currentOwner || currentOwner.path !== evaluation.ownerWorktreePath) {
return { ...resultBase, status: 'skipped_error' }
}
const { stdout: status } = await gitExecFileAsync(
['status', '--porcelain', '--untracked-files=no'],
gitExecOptions(currentOwner.path, options)
)
if (status.trim()) {
return {
...resultBase,
status: 'skipped_dirty_worktree',
ownerWorktreePath: currentOwner.path
}
}
await gitExecFileAsync(
['reset', '--hard', evaluation.remoteOid],
gitExecOptions(currentOwner.path, options)
)
return { ...resultBase, status: 'updated', ownerWorktreePath: currentOwner.path }
}
// Why: no owner worktree — fast-forward the bare ref. The expected-old-OID
// form makes this a no-op-safe compare-and-swap if the ref moved since the
// evaluation snapshot.
await gitExecFileAsync(
['update-ref', evaluation.fullRef, evaluation.remoteOid, evaluation.localOid],
gitExecOptions(repoPath, options)
)
return { ...resultBase, status: 'updated' }
} catch {
// update-ref/reset can fail on locked refs, filesystem errors, or unusual
// worktree states. Worktree creation should still proceed.
return { ...resultBase, status: 'skipped_error' }
}
}
/**
* Create a new worktree.
* @param repoPath - Path to the main repo (or bare repo)
* @param worktreePath - Absolute path where the worktree will be created
* @param branch - Branch name for the new worktree
* @param baseBranch - Optional base branch to create from (defaults to HEAD)
* @remarks Side effect: passes `--no-track`, writes `branch.<branch>.base`
* for new-branch worktrees with a base ref, and may write
* `push.autoSetupRemote=true` to the repo's shared config. Config writes are
* best-effort and warn-only. See body comments below for the full rationale.
*/
export async function addWorktree(
repoPath: string,
worktreePath: string,
branch: string,
baseBranch?: string,
refreshLocalBaseRef = false,
noCheckout = false,
options: AddWorktreeOptions = {}
): Promise<AddWorktreeResult> {
try {
return await performAddWorktree(
repoPath,
worktreePath,
branch,
baseBranch,
refreshLocalBaseRef,
noCheckout,
options
)
} finally {
bumpWorktreeScanGeneration(repoPath)
}
}
async function performAddWorktree(
repoPath: string,
worktreePath: string,
branch: string,
baseBranch?: string,
refreshLocalBaseRef = false,
noCheckout = false,
options: AddWorktreeOptions = {}
): Promise<AddWorktreeResult> {
let localBaseRefRefresh: LocalBaseRefRefreshResult | undefined
let localBaseRefUpdateSuggestion: LocalBaseRefUpdateSuggestion | undefined
const args = ['worktree', 'add']
let effectiveBase: string | undefined
if (noCheckout) {
args.push('--no-checkout')
}
if (options.checkoutExistingBranch) {
// Why: -b would create a new branch instead of checking out the selected one.
args.push(worktreePath, branch)
} else {
// Why: --no-track keeps the new branch from inheriting the base ref's
// upstream, so `git status` doesn't report "behind by N" against the base
// pre-publish and tools/agents don't misread an unpublished branch as
// out-of-sync. First push sets the upstream — see push.autoSetupRemote
// below for the terminal ergonomics.
args.push('--no-track', '-b', branch, worktreePath)
if (baseBranch) {
effectiveBase = await resolveWorktreeAddBaseRef(baseBranch, (qualifiedRef) =>
hasWorktreeBaseCommitRef(repoPath, qualifiedRef, options)
)
// Why: resolving the creation base first distinguishes real
// remote-tracking refs from slash-containing local branch names.
// The mutation stays behind the explicit setting so the default
// remains conservative.
if (refreshLocalBaseRef) {
localBaseRefRefresh = await refreshLocalBaseRefForWorktreeCreate(
repoPath,
baseBranch,
effectiveBase,
options.remoteTrackingBase,
options
)
} else if (options.suggestLocalBaseRefUpdate) {
localBaseRefUpdateSuggestion = await getLocalBaseRefUpdateSuggestionForWorktreeCreate(
repoPath,
baseBranch,
effectiveBase,
options.remoteTrackingBase,
options
)
}
args.push(effectiveBase)
}
}
await gitExecFileAsync(args, {
...gitExecOptions(repoPath, options),
// Why: bound the checkout so a OneDrive cloud-placeholder stall (STA-1292)
// fails fast rather than hanging worktree creation indefinitely.
timeout: WORKTREE_ADD_TIMEOUT_MS
})
if (options.checkoutExistingBranch) {
return localBaseRefRefresh ? { localBaseRefRefresh } : {}
}
if (effectiveBase) {
await persistWorktreeCreationBase(worktreePath, branch, effectiveBase, options)
}
// SSH parity: src/relay/git-handler-worktree-ops.ts addWorktreeOp mirrors this exact
// probe-and-write state machine. If you change the logic here, update
// the relay handler in lockstep so local and SSH paths stay aligned.
//
// Why: with --no-track there is no upstream until first push. Setting
// push.autoSetupRemote=true makes a plain `git push` from the terminal
// create origin/<branch> and set it as upstream automatically — matching
// user expectations from modern git without requiring `-u`. Note that
// `--local` on a linked worktree writes to the shared common-dir config,
// so this affects the whole repo, not just this worktree. That is
// intentional and acceptable: the value is benign and idempotent, and
// every Orca-created worktree wants the same default. True per-worktree
// scope would require enabling extensions.worktreeConfig=true repo-wide,
// which is a larger change we deliberately avoid.
//
// Notes on the design:
// - push.autoSetupRemote is honored by git >= 2.37; older clients ignore
// the value, so `git push` falls back to the pre-2.37 "no upstream"
// error and the user runs `git push -u` once.
// - Failures here are warn-only: config writes are best-effort and a
// missing write degrades to the same fallback as old git.
// - The write is skipped when any value is already set (local, global,
// or system) so a deliberate user `false` is preserved.
// - Not rolled back on creation failure: addSparseWorktree's catch path
// removes the worktree but does not unset this config. That is consistent
// with the "benign and idempotent" rationale above — every Orca-created
// worktree wants this default, and a future creation will silently re-set
// it via the existing-value check anyway.
try {
// Why: `--get` (not `--local --get`) so a value set at any scope
// (local/global/system) counts as "user already chose" and we don't
// overwrite it.
let alreadySet = false
try {
await gitExecFileAsync(['config', '--get', 'push.autoSetupRemote'], {
...gitExecOptions(worktreePath, options)
})
alreadySet = true
} catch (readError) {
// Why: `git config --get` exits 1 only when the key is unset at every
// scope. Any other exit code means a real read failure (corrupt config,
// locked file, parse error) — surface that via the outer catch instead
// of silently overwriting whatever value the user actually has.
const code = (readError as { code?: unknown })?.code
if (code !== 1) {
throw readError
}
}
if (!alreadySet) {
await gitExecFileAsync(['config', '--local', 'push.autoSetupRemote', 'true'], {
...gitExecOptions(worktreePath, options)
})
}
} catch (error) {
console.warn(`addWorktree: failed to set push.autoSetupRemote for ${worktreePath}`, error)
}
return {
...(localBaseRefRefresh ? { localBaseRefRefresh } : {}),
...(localBaseRefUpdateSuggestion ? { localBaseRefUpdateSuggestion } : {})
}
}
export async function addSparseWorktree(
repoPath: string,
worktreePath: string,
branch: string,
directories: string[],
baseBranch?: string,
refreshLocalBaseRef = false,
options: AddWorktreeOptions = {}
): Promise<AddWorktreeResult> {
let created = false
let addResult: AddWorktreeResult = {}
try {
addResult = await addWorktree(
repoPath,
worktreePath,
branch,
baseBranch,
refreshLocalBaseRef,
true,
options
)
created = true
await gitExecFileAsync(
['sparse-checkout', 'init', '--cone'],
gitExecOptions(worktreePath, options)
)
await gitExecFileAsync(
['sparse-checkout', 'set', '--', ...directories],
gitExecOptions(worktreePath, options)
)
await gitExecFileAsync(['checkout', branch], gitExecOptions(worktreePath, options))
return addResult
} catch (error) {
const wrapped: SparseWorktreeCreateError =
error instanceof Error ? (error as SparseWorktreeCreateError) : new Error(String(error))
if (created) {
if (!options.checkoutExistingBranch) {
await unsetWorktreeCreationBase(worktreePath, branch, options)
}
try {
await removeWorktree(repoPath, worktreePath, true, {
deleteBranch: !options.checkoutExistingBranch,
// Why: rolling back a failed creation — the just-created branch has no
// user commits, so force-delete it rather than preserving an orphan.
forceBranchDelete: !options.checkoutExistingBranch,
...(options.wslDistro ? { wslDistro: options.wslDistro } : {})
})
} catch {
wrapped.cleanupFailed = true
// Why: the user needs to know that manual cleanup may be required —
// otherwise a half-created worktree silently lingers on disk.
wrapped.message = `${wrapped.message} (cleanup also failed — the partially created worktree at "${worktreePath}" may need manual removal)`
}
}
throw wrapped
}
}
/**
* Move a worktree's directory to a new path with `git worktree move`, which
* relocates the working tree and rewrites git's gitdir pointers so the linkage
* stays intact — a raw `fs.rename` would corrupt the `.git` file and the
* `.git/worktrees/<name>/gitdir` back-pointer. Local worktrees only: the
* first-work folder rename skips SSH/remote, so there is no relay parity handler
* for this op. The caller owns migrating Orca's path-derived worktree identity
* after a successful move, and pre-checks that the destination is free.
*/
export async function moveWorktree(
repoPath: string,
oldPath: string,
newPath: string
): Promise<void> {
try {
await gitExecFileAsync(['worktree', 'move', oldPath, newPath], { cwd: repoPath })
} finally {
bumpWorktreeScanGeneration(repoPath)
}
}
/**
* Remove a worktree.
*/
export async function removeWorktree(
repoPath: string,
worktreePath: string,
force = false,
// Why: forceBranchDelete is for cleaning up a worktree this code just created
// (e.g. rollback of a failed creation) where the fresh branch has no user work
// and must be removed outright. User-initiated deletes leave it false so unmerged
// commits are preserved.
options: RemoveWorktreeOptions = {}
): Promise<RemoveWorktreeResult> {
try {
return await performRemoveWorktree(repoPath, worktreePath, force, options)
} finally {
bumpWorktreeScanGeneration(repoPath)
}
}
async function performRemoveWorktree(
repoPath: string,
worktreePath: string,
force = false,
options: RemoveWorktreeOptions = {}
): Promise<RemoveWorktreeResult> {
const removedWorktree =
options.knownRemovedWorktree ??
(await listWorktrees(repoPath, options)).find((worktree) =>
areWorktreePathsEqual(worktree.path, worktreePath)
)
const branchName = normalizeLocalBranchRef(removedWorktree?.branch ?? '')
const branchHead = removedWorktree?.head ?? ''
const args = ['worktree', 'remove']
if (force) {
args.push('--force')
}
args.push(worktreePath)
await gitExecFileAsync(args, gitExecOptions(repoPath, options))
if (!branchName) {
return {}
}
if (options.deleteBranch === false) {
return {}
}
try {
// Why: `git worktree remove` only detaches the filesystem entry. Orca also
// drops the now-unused local branch here so delete-worktree does not leave
// behind orphaned feature branches unless another worktree still points at it.
// Use `-d` (not `-D`): Git refuses to delete a branch with commits not merged
// into its upstream or HEAD, so unpublished work is preserved instead of
// force-deleted. forceBranchDelete opts into `-D` for failed-creation rollback,
// where the fresh branch has no user work to protect.
const branchDeleteResult = await deleteLocalBranchAfterWorktreeRemoval(
repoPath,
branchName,
options.forceBranchDelete === true,
options
)
if (branchDeleteResult === 'checked-out') {
return {}
}
return {}
} catch (error) {
if (!options.forceBranchDelete && branchHead) {
try {
if (
await deleteAlreadyMergedBranchAfterSafeDeleteFailure(
repoPath,
branchName,
branchHead,
options
)
) {
return {}
}
} catch (alreadyMergedDeleteError) {
// Why: the worktree is already gone; a raced branch cleanup should
// degrade to the preserved-branch recovery path instead of failing delete.
console.warn(
`[git] Failed to delete already-merged local branch "${branchName}" after removing worktree`,
alreadyMergedDeleteError
)
}
}
// Expected when the branch still has unmerged/unpublished commits: keep it.
// Deleting a worktree must never silently discard commits.
console.warn(
`[git] Preserved local branch "${branchName}" after removing worktree (not fully merged)`,
error
)
return { preservedBranch: { branchName, ...(branchHead ? { head: branchHead } : {}) } }
}
}
async function deleteLocalBranchAfterWorktreeRemoval(
repoPath: string,
branchName: string,
forceBranchDelete: boolean,
options: GitWorktreeExecOptions = {}
): Promise<'deleted' | 'checked-out'> {
const deleteFlag = forceBranchDelete ? '-D' : '-d'
try {
await gitExecFileAsync(
['branch', deleteFlag, '--', branchName],
gitExecOptions(repoPath, options)
)
return 'deleted'
} catch (error) {
if (!isBranchCheckedOutInWorktreeError(error)) {
throw error
}
}
try {
// Why: `branch -d` is the cheap live-checkout guard. Only pay for
// `worktree prune` when a stale admin record may be the thing blocking it.
await gitExecFileAsync(['worktree', 'prune'], gitExecOptions(repoPath, options))
} catch (error) {
console.warn(`[git] Failed to prune worktrees before deleting branch "${branchName}"`, error)
return 'checked-out'
}
try {
await gitExecFileAsync(
['branch', deleteFlag, '--', branchName],
gitExecOptions(repoPath, options)
)
return 'deleted'
} catch (error) {
if (isBranchCheckedOutInWorktreeError(error)) {
return 'checked-out'
}
throw error
}
}
async function deleteAlreadyMergedBranchAfterSafeDeleteFailure(
repoPath: string,
branchName: string,
branchHead: string,
options: GitWorktreeExecOptions = {}
): Promise<boolean> {
const runGit = (args: string[], execOptions?: { stdin?: string }) =>
gitExecFileAsync(args, {
...gitExecOptions(repoPath, options),
...(execOptions?.stdin !== undefined ? { stdin: execOptions.stdin } : {})
})
const targetRefs = await getBranchCleanupTargetRefs(runGit, branchName)
await refreshBranchCleanupTargetRefs(runGit, targetRefs)
// Why: squash merges rewrite commit IDs, so `branch -d` can reject a branch
// whose changes are already on the base ref. Delete only when Git can prove
// the branch contributes no tree changes to that base.
if (!(await branchHasNoUnmergedChangesOnAnyTarget(runGit, branchName, targetRefs))) {
return false
}
await forceDeleteLocalBranch(repoPath, branchName, branchHead, (args, cwd) =>
gitExecFileAsync(args, gitExecOptions(cwd, options))
)
return true
}
export async function forceDeleteLocalBranch(
repoPath: string,
branchName: string,
expectedHead: string,
runGit: (args: string[], cwd: string) => Promise<{ stdout: string; stderr: string }> = (
args,
cwd
) => gitExecFileAsync(args, { cwd })
): Promise<void> {
if (!branchName || branchName.includes('\0')) {
throw new Error('Invalid branch name')
}
if (!expectedHead) {
throw new Error(
`Cannot force-delete local branch "${branchName}" without the commit Git preserved.`
)
}
if (await isLocalBranchCheckedOut(repoPath, branchName, runGit)) {
throw new Error(`Local branch "${branchName}" is checked out in another worktree.`)
}
// Why: stale toast actions must not delete a branch that moved after Git
// preserved it. `update-ref` deletes only if the ref still has expectedHead.
try {
await runGit(['update-ref', '-d', `refs/heads/${branchName}`, expectedHead], repoPath)
} catch {
throw new Error(
`Local branch "${branchName}" changed after the workspace was deleted. Review it before deleting it.`
)
}
if (await isLocalBranchCheckedOut(repoPath, branchName, runGit)) {
try {
await runGit(['update-ref', `refs/heads/${branchName}`, expectedHead, ''], repoPath)
} catch (restoreError) {
console.warn(
`[git] Failed to restore local branch "${branchName}" after concurrent checkout`,
restoreError
)
}
throw new Error(`Local branch "${branchName}" is checked out in another worktree.`)
}
try {
await runGit(['config', '--remove-section', `branch.${branchName}`], repoPath)
} catch {
// Best-effort parity with `git branch -D`; stale config is harmless and
// should not make the already-deleted ref look like a failed delete.
}
}
async function isLocalBranchCheckedOut(
repoPath: string,
branchName: string,
runGit: (args: string[], cwd: string) => Promise<{ stdout: string; stderr: string }>
): Promise<boolean> {
const { stdout } = await runGit(['worktree', 'list', '--porcelain'], repoPath)
return parseWorktreeList(stdout).some(
(worktree) => normalizeLocalBranchRef(worktree.branch) === branchName
)
}
/**
* Assert a worktree is clean enough for non-force removal.
*/
export async function assertWorktreeCleanForRemoval(
worktreePath: string,
force = false,
options: GitWorktreeExecOptions = {}
): Promise<void> {
if (force) {
return
}
const { stdout } = await gitExecFileAsync(['status', '--porcelain', '--untracked-files=all'], {
...gitExecOptions(worktreePath, options)
})
if (!stdout.trim()) {
return
}
const error = new Error('Worktree has uncommitted or untracked changes.')
;(error as Error & { stdout?: string }).stdout = stdout
throw error
}
function translateWorktreePath(
worktreePath: string,
repoPath: string,
options: GitWorktreeExecOptions = {}
): string {
const prefix = 'worktree '
const translated = translateWslOutputPaths(`${prefix}${worktreePath}`, repoPath, options)
return translated.startsWith(prefix) ? translated.slice(prefix.length) : worktreePath
}
async function detectSparseCheckout(worktreePath: string): Promise<boolean> {
// Why: `listWorktrees` runs on every 3-second git-status poll and on every
// worktree refresh, so this probe fires N times per poll for N worktrees.
// The previous `git sparse-checkout list` subprocess made that N*poll extra
// git processes, which regressed app responsiveness on machines with many
// worktrees (see PR #1131 revert in #1290). A single fs.stat on the
// per-worktree sparse-checkout config file is ~two orders of magnitude
// cheaper and has the same truthiness semantics: Git writes this file when
// sparse checkout is enabled for the worktree and does not write it
// otherwise.
//
// Why per-worktree gitdir and not `<worktreePath>/.git/info/sparse-checkout`:
// linked worktrees have a `.git` file that points at
// `<repo>/.git/worktrees/<name>`, and that is where Git stores the
// worktree-local sparse-checkout config. `core.sparseCheckout` itself is
// shared across all worktrees, so the presence of the config file is the
// correct per-worktree signal.
try {
const gitDir = await resolveGitDir(worktreePath)
const stats = await stat(join(gitDir, 'info', 'sparse-checkout'))
return stats.isFile() && stats.size > 0
} catch {
return false
}
}