Split Git worktree responsibilities

This commit is contained in:
Neil
2026-08-29 10:29:38 -07:00
parent f1dc9b061c
commit 1e8a56d054
17 changed files with 1828 additions and 1694 deletions
-1
View File
@@ -10,7 +10,6 @@ inline src/main/claude-accounts/runtime-auth-service.ts
inline src/main/codex-accounts/runtime-home-service.ts
inline src/main/codex-accounts/service.ts
inline src/main/codex/hook-service.ts
inline src/main/git/worktree.ts
inline src/main/github/project-view.ts
inline src/main/index.ts
inline src/main/ipc/filesystem-watcher.ts
+193
View File
@@ -0,0 +1,193 @@
import { resolveWorktreeAddBaseRef } from '../../shared/worktree/base-ref'
import type {
LocalBaseRefRefreshResult,
LocalBaseRefUpdateSuggestion
} from '../../shared/worktree/base-ref-drift-types'
import { windowsLongPathGitArgs } from '../../shared/windows-long-path-git-args'
import { gitExecFileAsync } from './runner'
import { runWithGitReadCacheInvalidation } from './status'
import {
getLocalBaseRefUpdateSuggestionForWorktreeCreate,
refreshLocalBaseRefForWorktreeCreate
} from './worktree-base-refresh'
import { hasWorktreeBaseCommitRef } from './worktree-base-ref-probe'
import type {
AddWorktreeOptions,
AddWorktreeResult,
GitWorktreeExecOptions
} from './worktree-operation-options'
import { gitExecOptions, resolveWorktreeAddTimeoutMs } from './worktree-operation-options'
import { bumpWorktreeScanGeneration } from './worktree-scan-cache'
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, unset it so consumers don't trust stale lineage.
await gitExecFileAsync(['config', '--local', '--unset-all', configKey], {
...gitExecOptions(worktreePath, options)
})
} catch (unsetError) {
console.warn(
`addWorktree: failed to unset stale ${configKey} for ${worktreePath}`,
unsetError
)
}
}
}
export 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; leave the original sparse-setup error as the actionable failure.
}
}
/**
* 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 effects (best-effort, warn-only): 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.
*/
export async function addWorktree(
repoPath: string,
worktreePath: string,
branch: string,
baseBranch?: string,
refreshLocalBaseRef = false,
noCheckout = false,
options: AddWorktreeOptions = {}
): Promise<AddWorktreeResult> {
try {
return await runWithGitReadCacheInvalidation(() =>
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
// Why: enable long paths for this Windows checkout without changing user Git config.
const args = [...windowsLongPathGitArgs(repoPath), '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 avoids inheriting the base's upstream so `git status` won't misreport "behind by N" pre-publish; first push sets it (see push.autoSetupRemote below).
args.push('--no-track', '-b', branch, worktreePath)
if (baseBranch) {
effectiveBase = await resolveWorktreeAddBaseRef(baseBranch, (qualifiedRef) =>
hasWorktreeBaseCommitRef(repoPath, qualifiedRef, options)
)
// Why: resolve the creation base first to distinguish remote-tracking refs from slash-containing local branches (mutation gated behind the explicit setting).
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: resolve per call — hoisting this to a module const would freeze the override at import.
timeout: resolveWorktreeAddTimeoutMs()
})
if (options.checkoutExistingBranch) {
return localBaseRefRefresh ? { localBaseRefRefresh } : {}
}
if (effectiveBase) {
await persistWorktreeCreationBase(worktreePath, branch, effectiveBase, options)
}
// SSH parity: relay's addWorktreeOp (src/relay/git-handler-worktree-ops.ts) mirrors this — change both in lockstep.
// Why: --no-track leaves no upstream until first push; push.autoSetupRemote=true lets a plain
// `git push` create+set origin/<branch> (git >=2.37; older clients ignore it). `--local` on a
// linked worktree writes the shared common-dir config (whole repo) — intentional and idempotent,
// so it's warn-only and not rolled back on failure.
try {
// Why: `--get` (not `--local --get`) so a value at any scope counts as "user already chose" and isn't overwritten.
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 unset at every scope; any other code is a real read failure — rethrow rather than overwrite the user's value.
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 } : {})
}
}
@@ -0,0 +1,210 @@
import { parseGitRevListAheadBehindCounts } from '../../shared/git-rev-list-output'
import type {
LocalBaseRefRefreshResult,
LocalBaseRefUpdateSuggestion
} from '../../shared/worktree/base-ref-drift-types'
import { gitExecFileAsync, translateWslOutputPaths } from './runner'
import { probeWorktreeBaseRefPresence } from './worktree-base-ref-probe'
import { parseWorktreeList } from './worktree-list-parser'
import type { AddWorktreeOptions, GitWorktreeExecOptions } from './worktree-operation-options'
import { gitExecOptions } from './worktree-operation-options'
type LocalBaseRefRefreshability =
| {
refreshable: true
baseRef: string
localBranch: string
fullRef: string
remoteTrackingRef: string
localOid: string
remoteOid: string
behind: number
ownerWorktreePath?: string
}
| {
refreshable: false
result: LocalBaseRefRefreshResult
}
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 proven remote-tracking refs get refresh status; slash-containing local branches (release/2026) must not fake a "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
}
export async function evaluateLocalBaseRefRefreshability(
repoPath: string,
baseBranch: string,
remoteTrackingRef: string,
remoteTrackingBase?: AddWorktreeOptions['remoteTrackingBase'],
options: GitWorktreeExecOptions = {},
shouldInspectOwner: (behind: number) => boolean = () => true
): 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 no local-only commits and how far 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' } }
}
if (!shouldInspectOwner(parsedDrift.behind)) {
// Why: a current local ref yields no update suggestion, so the advisory path skips OID resolution and owner inspection.
return undefined
}
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 {
// Why (#15331): the probes above also fail when refs/heads/<branch> is simply absent; a branch that
// does not exist yet cannot be stale, so report nothing instead of a bogus divergence warning.
// Only a proven absence suppresses: an unusable repo leaves the warning alone.
const presence = await probeWorktreeBaseRefPresence(
(args) => gitExecFileAsync(args, gitExecOptions(repoPath, options)),
parsed.fullRef
)
if (presence === 'absent') {
return undefined
}
return { refreshable: false, result: { ...resultBase, status: 'skipped_not_fast_forward' } }
}
try {
// Why: if the local base branch is checked out, 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 a bare-ref fast-forward is safe; omitting ownerWorktreePath signals the mutating path to take it.
return {
refreshable: true,
...resultBase,
fullRef: parsed.fullRef,
remoteTrackingRef,
localOid,
remoteOid,
behind: drift.behind
}
} catch {
return { refreshable: false, result: { ...resultBase, status: 'skipped_error' } }
}
}
export 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,
(behind) => behind > 0
)
if (!evaluation?.refreshable || evaluation.behind <= 0) {
return undefined
}
return {
baseRef: evaluation.baseRef,
localBranch: evaluation.localBranch,
behind: evaluation.behind
}
}
+76
View File
@@ -0,0 +1,76 @@
import type { LocalBaseRefRefreshResult } from '../../shared/worktree/base-ref-drift-types'
import { gitExecFileAsync, translateWslOutputPaths } from './runner'
import {
evaluateLocalBaseRefRefreshability,
getLocalBaseRefUpdateSuggestionForWorktreeCreate
} from './worktree-base-refresh-analysis'
import { parseWorktreeList } from './worktree-list-parser'
import type { AddWorktreeOptions, GitWorktreeExecOptions } from './worktree-operation-options'
import { gitExecOptions } from './worktree-operation-options'
export { getLocalBaseRefUpdateSuggestionForWorktreeCreate }
export 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 is a no-op-safe CAS if the ref moved since evaluation.
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 or odd worktree states; worktree creation should still proceed.
return { ...resultBase, status: 'skipped_error' }
}
}
+188
View File
@@ -0,0 +1,188 @@
import {
branchHasNoUnmergedChangesWithLazyTargetRefresh,
getBranchCleanupTargetRefs
} from '../../shared/git-branch-cleanup'
import type { RemoveWorktreeResult } from '../../shared/worktree/create-types'
import { withLocalGitCapabilityCacheForExecution } from './git-capability-state'
import { gitExecFileAsync } from './runner'
import { parseWorktreeList } from './worktree-list-parser'
import type { GitWorktreeExecOptions, RemoveWorktreeOptions } from './worktree-operation-options'
import {
gitExecOptions,
isBranchCheckedOutInWorktreeError,
normalizeLocalBranchRef
} from './worktree-operation-options'
export async function deleteBranchAfterWorktreeRemoval(
repoPath: string,
branchName: string,
branchHead: string,
options: RemoveWorktreeOptions
): Promise<RemoveWorktreeResult> {
try {
// Why: also drop the now-orphaned branch so delete-worktree leaves none; `-d` (not `-D`) preserves
// unmerged work, and forceBranchDelete opts into `-D` for failed-creation rollback.
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: worktree is already gone; a raced branch cleanup should degrade to preserved-branch recovery, not fail delete.
console.warn(
`[git] Failed to delete already-merged local branch "${branchName}" after removing worktree`,
alreadyMergedDeleteError
)
}
}
// Keep an unmerged/unpublished branch: 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: only pay for `worktree prune` when a stale admin record may be blocking `branch -d`.
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)
// Why: squash merges rewrite commit IDs, so `branch -d` rejects already-merged branches; delete only when Git proves no unmerged tree changes.
const hasNoUnmergedChanges = await withLocalGitCapabilityCacheForExecution(
{ cwd: repoPath, wslDistro: options.wslDistro, signal: options.signal },
(capabilities) =>
branchHasNoUnmergedChangesWithLazyTargetRefresh(runGit, branchName, targetRefs, capabilities)
)
if (!hasNoUnmergedChanges) {
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; `update-ref -d` deletes only if the ref still == 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.
}
}
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
)
}
+103
View File
@@ -0,0 +1,103 @@
import { decodeGitCQuotedPath } from '../../shared/git-cquoted-path'
import type { GitWorktreeInfo } from '../../shared/worktree/types'
/**
* 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
let locked = false
let lockReason = ''
let prunable = false
let prunableReason = ''
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
} else if (line === 'locked' || line.startsWith('locked ')) {
locked = true
const rawReason = line.slice('locked'.length).trim()
lockReason = options.nulDelimited ? rawReason : decodeGitCQuotedPath(rawReason)
} else if (line === 'prunable' || line.startsWith('prunable ')) {
// Why: Git ≥2.36 flags registrations whose directory is gone; ignoring it shows the stale worktree as live (#8389).
prunable = true
const rawReason = line.slice('prunable'.length).trim()
prunableReason = options.nulDelimited ? rawReason : decodeGitCQuotedPath(rawReason)
}
}
if (path) {
// `git worktree list` always emits the main working tree first.
worktrees.push({
path,
head,
branch,
isBare,
...(isSparse ? { isSparse } : {}),
...(locked ? { locked: true } : {}),
...(lockReason ? { lockReason } : {}),
...(prunable ? { prunable: true } : {}),
...(prunableReason ? { prunableReason } : {}),
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
}
+205
View File
@@ -0,0 +1,205 @@
import { stat } from 'node:fs/promises'
import type { GitWorktreeInfo } from '../../shared/worktree/types'
import { parseWslUncPath } from '../../shared/wsl-paths'
import {
hasUnsupportedRevParsePathFormatEcho,
isUnsupportedRevParsePathFormatError,
isUnsupportedWorktreeListZError
} from '../../shared/git-worktree-command-capabilities'
import { withLocalGitCapabilityCacheForExecution } from './git-capability-state'
import { parseWorktreeList } from './worktree-list-parser'
import type { GitWorktreeExecOptions } from './worktree-operation-options'
import {
WORKTREE_LIST_TIMEOUT_MS,
getErrorCode,
gitExecOptions
} from './worktree-operation-options'
import {
areWorktreePathsEqual,
resolveRevParsePath,
translateWorktreePath
} from './worktree-path-comparison'
import { gitExecFileAsync } from './runner'
const PRUNABLE_EXISTENCE_PROBE_CONCURRENCY = 8
type RepoLocation = { topLevel: string; commonDir: string }
function parseRepoLocation(repoPath: string, output: string): RepoLocation | undefined {
// Old git echoes the unrecognized `--path-format` flag and exits 0, so drop `-`-prefixed lines and
// read the last two path lines (toplevel, git-common-dir); strip only trailing CR — paths may have edge spaces.
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 {
return await withLocalGitCapabilityCacheForExecution(
{ cwd: repoPath, wslDistro: options.wslDistro, signal: options.signal },
(capabilities) =>
capabilities.runWithFallback(
'rev-parse-path-format',
async () => {
const { stdout } = await gitExecFileAsync(
['rev-parse', '--path-format=absolute', '--show-toplevel', '--git-common-dir'],
gitExecOptions(repoPath, options)
)
if (hasUnsupportedRevParsePathFormatEcho(stdout)) {
// Why: some old Git echoes the unknown option and exits zero; remember that compat signal even though parsing recovers.
capabilities.rememberUnsupported('rev-parse-path-format')
}
return parseRepoLocation(resolveBasePath, stdout)
},
async () => {
const { stdout } = await gitExecFileAsync(
['rev-parse', '--show-toplevel', '--git-common-dir'],
gitExecOptions(repoPath, options)
)
return parseRepoLocation(resolveBasePath, stdout)
},
isUnsupportedRevParsePathFormatError
)
)
} 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]
// Why: under WSL, porcelain/rev-parse paths are Linux but repoPath is UNC; compare in Git-output
// space so the early-return matches and we skip a needless rev-parse per poll (runner still gets repoPath).
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 git-common-dir as its path; gate on
// that equality so we don't overwrite a linked worktree's real working root with its own toplevel.
if (!areWorktreePathsEqual(mainWorktree.path, location.commonDir)) {
return worktrees
}
const normalized = [...worktrees]
normalized[mainIndex] = { ...mainWorktree, path: location.topLevel }
return normalized
}
export async function readWorktreeList(
repoPath: string,
options: GitWorktreeExecOptions = {}
): Promise<GitWorktreeInfo[]> {
const execOptions = {
cwd: repoPath,
...options,
timeout: options.timeout ?? WORKTREE_LIST_TIMEOUT_MS
}
return withLocalGitCapabilityCacheForExecution(
{ cwd: repoPath, wslDistro: options.wslDistro, signal: options.signal },
(capabilities) =>
capabilities.runWithFallback(
'worktree-list-z',
async () => {
const { stdout } = await gitExecFileAsync(
['worktree', 'list', '--porcelain', '-z'],
execOptions
)
return normalizeMainWorktreePath(
repoPath,
parseWorktreeList(stdout, { nulDelimited: true }),
options
)
},
async () => {
// Why: `-z` preserves worktree paths with newlines but Git <2.36 rejects it; fall back to the line parser.
const { stdout } = await gitExecFileAsync(
['worktree', 'list', '--porcelain'],
execOptions
)
const normalized = await normalizeMainWorktreePath(
repoPath,
parseWorktreeList(stdout),
options
)
// Why: Git <2.31 emits no `prunable`, so probe each linked path for existence instead of trusting
// stale registrations; a harmless backstop on 2.31–2.35 where parseWorktreeList already set it (#8389).
return annotatePrunableByExistence(normalized, repoPath, options)
},
isUnsupportedWorktreeListZError
)
)
}
async function annotatePrunableByExistence(
worktrees: GitWorktreeInfo[],
repoPath: string,
options: GitWorktreeExecOptions = {}
): Promise<GitWorktreeInfo[]> {
const annotated = [...worktrees]
let nextIndex = 0
async function probeNext(): Promise<void> {
while (nextIndex < worktrees.length) {
const index = nextIndex
nextIndex += 1
const worktree = worktrees[index]
// Git only prunes linked worktrees, never locked ones (a lock shields a missing dir; `locked`
// parses only on Git >=2.31). A missing main worktree is handled by the repo-level ENOENT paths.
if (
!worktree ||
worktree.isMainWorktree ||
worktree.isBare ||
worktree.locked ||
worktree.prunable
) {
continue
}
try {
await stat(translateWorktreePath(worktree.path, repoPath, options))
} catch (err) {
if (getErrorCode(err) === 'ENOENT') {
annotated[index] = { ...worktree, prunable: true }
}
}
}
}
const workerCount = Math.min(PRUNABLE_EXISTENCE_PROBE_CONCURRENCY, worktrees.length)
await Promise.all(Array.from({ length: workerCount }, () => probeNext()))
return annotated
}
export 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 }
})
}
+99
View File
@@ -0,0 +1,99 @@
import { stat } from 'node:fs/promises'
import type { GitWorktreeInfo } from '../../shared/worktree/types'
import { readTranslatedWorktreeGraph, readWorktreeList } from './worktree-list-reader'
import type { GitWorktreeExecOptions } from './worktree-operation-options'
import { getErrorCode, isNotGitRepositoryError } from './worktree-operation-options'
import { translateWorktreePath } from './worktree-path-comparison'
import { detectSparseCheckout } from './worktree-sparse-state'
const SPARSE_CHECKOUT_DETECTION_CONCURRENCY = 8
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 []
}
}
export 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: don't swallow git-compat/repo-state failures — else they resurface as opaque "created but not found in listing" errors.
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: cap concurrency so status-poll refreshes don't fan out many sparse-checkout filesystem probes at once.
const workerCount = Math.min(SPARSE_CHECKOUT_DETECTION_CONCURRENCY, worktrees.length)
await Promise.all(Array.from({ length: workerCount }, () => detectNext()))
return annotated
}
+23
View File
@@ -0,0 +1,23 @@
import { gitExecFileAsync } from './runner'
import { runWithGitReadCacheInvalidation } from './status'
import { bumpWorktreeScanGeneration } from './worktree-scan-cache'
/**
* Move a worktree with `git worktree move` (not `fs.rename`, which corrupts the
* `.git` file and the `.git/worktrees/<name>/gitdir` back-pointer). Local-only,
* so there is no relay parity handler. Caller owns migrating Orca's
* path-derived worktree identity and pre-checks that the destination is free.
*/
export async function moveWorktree(
repoPath: string,
oldPath: string,
newPath: string
): Promise<void> {
try {
await runWithGitReadCacheInvalidation(() =>
gitExecFileAsync(['worktree', 'move', oldPath, newPath], { cwd: repoPath })
)
} finally {
bumpWorktreeScanGeneration(repoPath)
}
}
+125
View File
@@ -0,0 +1,125 @@
import type {
LocalBaseRefRefreshResult,
LocalBaseRefUpdateSuggestion
} from '../../shared/worktree/base-ref-drift-types'
import type { RemoveWorktreeResult } from '../../shared/worktree/create-types'
import type { GitWorktreeInfo } from '../../shared/worktree/types'
export type AddWorktreeResult = {
localBaseRefRefresh?: LocalBaseRefRefreshResult
localBaseRefUpdateSuggestion?: LocalBaseRefUpdateSuggestion
}
export type SparseWorktreeCreateError = Error & {
cleanupFailed?: boolean
}
export type GitWorktreeExecOptions = {
wslDistro?: string
signal?: AbortSignal
timeout?: number
}
export type WorktreeRemovalPreflightOptions = GitWorktreeExecOptions & {
ignoredUntrackedPaths?: readonly string[]
}
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' | 'locked' | 'lockReason'>
}
// Why: bound `git worktree add` so a OneDrive cloud-placeholder stall fails fast (STA-1292); ample for an ordinary large checkout, but not one behind a slow content filter (#12696).
// Doubles as the floor for ORCA_WORKTREE_ADD_TIMEOUT_MS — lowering it to fail faster also lowers the minimum any override can request.
export const WORKTREE_ADD_TIMEOUT_MS = 180_000
// Why: ceiling for ORCA_WORKTREE_ADD_TIMEOUT_MS (#12696) — ~8x the slowest reported checkout (3.5 min). The cost is that a genuine stall now blocks a create for up to 30 min instead of 3.
export const WORKTREE_ADD_TIMEOUT_MAX_MS = 30 * 60_000
export const WORKTREE_REMOVAL_PREFLIGHT_TIMEOUT_MS = 30_000
export const WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS = 30_000
// Why: one wedged shared scan otherwise hangs every later list, including create's post-add re-list.
export const WORKTREE_LIST_TIMEOUT_MS = 30_000
/**
* `ORCA_WORKTREE_ADD_TIMEOUT_MS` clamped into [{@link WORKTREE_ADD_TIMEOUT_MS},
* {@link WORKTREE_ADD_TIMEOUT_MAX_MS}]; unset, blank, or unparseable yields the default.
* Warns when a non-blank value is rejected or clamped; trimming and fractional truncation are silent.
* `env` is injectable for tests.
*/
export function resolveWorktreeAddTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
const raw = env.ORCA_WORKTREE_ADD_TIMEOUT_MS?.trim()
const requested = Math.floor(Number(raw))
// Why: `=300` reads as seconds to most operators, so clamp rather than obey.
const resolved = Number.isNaN(requested)
? WORKTREE_ADD_TIMEOUT_MS
: Math.min(Math.max(requested, WORKTREE_ADD_TIMEOUT_MS), WORKTREE_ADD_TIMEOUT_MAX_MS)
// Why: an `isNaN` guard here would delete the unparseable-value warning — comparing against NaN is unequal, and that is what catches it.
if (raw && resolved !== requested) {
const problem = Number.isNaN(requested)
? // Why: `600_000` copied out of this file is NaN, not out of range — say which.
'is not a number'
: `is outside [${WORKTREE_ADD_TIMEOUT_MS}, ${WORKTREE_ADD_TIMEOUT_MAX_MS}]ms`
console.warn(
`[git/worktree] ORCA_WORKTREE_ADD_TIMEOUT_MS="${raw}" ${problem}; using ${resolved}ms`
)
}
return resolved
}
export function gitExecOptions(
cwd: string,
options: GitWorktreeExecOptions = {}
): { cwd: string; wslDistro?: string; signal?: AbortSignal; timeout?: number } {
return {
cwd,
...(options.wslDistro ? { wslDistro: options.wslDistro } : {}),
...(options.signal ? { signal: options.signal } : {}),
...(options.timeout ? { timeout: options.timeout } : {})
}
}
export 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)
}
export function isNotGitRepositoryError(error: unknown): boolean {
return /not a git repository/i.test(getErrorText(error))
}
export function isBranchCheckedOutInWorktreeError(error: unknown): boolean {
return /cannot delete branch .*(?:used by worktree|checked out)|branch .*is checked out/i.test(
getErrorText(error)
)
}
export function normalizeLocalBranchRef(branch: string): string {
return branch.replace(/^refs\/heads\//, '')
}
export type { LocalBaseRefRefreshResult, LocalBaseRefUpdateSuggestion, RemoveWorktreeResult }
+41
View File
@@ -0,0 +1,41 @@
import { posix, win32 } from 'node:path'
import type { GitWorktreeExecOptions } from './worktree-operation-options'
import { translateWslOutputPaths } from './runner'
export 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('\\\\')
}
export function resolveRevParsePath(repoPath: string, value: string): string {
if (posix.isAbsolute(value) || win32.isAbsolute(value)) {
return value
}
// Old git ignores `--path-format=absolute`, so resolve a relative toplevel/git-dir against the scanned repo path.
return looksLikeWindowsPath(repoPath)
? win32.resolve(repoPath, value)
: posix.resolve(repoPath, value)
}
export 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
}
@@ -0,0 +1,68 @@
import { gitExecFileAsync } from './runner'
import type { WorktreeRemovalPreflightOptions } from './worktree-operation-options'
import { WORKTREE_REMOVAL_PREFLIGHT_TIMEOUT_MS, gitExecOptions } from './worktree-operation-options'
/**
* Assert a worktree is clean enough for non-force removal.
*/
export async function assertWorktreeCleanForRemoval(
worktreePath: string,
force = false,
options: WorktreeRemovalPreflightOptions = {}
): Promise<void> {
if (force) {
return
}
const { ignoredUntrackedPaths = [], ...gitOptions } = options
const useNullTerminatedStatus = ignoredUntrackedPaths.length > 0
const { stdout } = await gitExecFileAsync(
['status', '--porcelain', ...(useNullTerminatedStatus ? ['-z'] : []), '--untracked-files=all'],
{
...gitExecOptions(worktreePath, gitOptions),
timeout: gitOptions.timeout ?? WORKTREE_REMOVAL_PREFLIGHT_TIMEOUT_MS
}
)
// Why one parse feeds both: the clean verdict and the error text must never
// disagree about which entries block removal.
const blockingEntries = useNullTerminatedStatus
? getBlockingUntrackedStatusEntries(stdout, ignoredUntrackedPaths)
: null
if (blockingEntries ? blockingEntries.length === 0 : !stdout.trim()) {
return
}
const error = new Error('Worktree has uncommitted or untracked changes.')
// Why not the raw stdout: `-z` output is NUL-delimited and `.trim()` leaves
// interior NULs, so attaching it verbatim put raw control bytes into the
// user-facing removal error — and listed the tolerated shared link, the one
// entry that is not the user's work and cannot be committed away.
;(error as Error & { stdout?: string }).stdout = blockingEntries
? blockingEntries.join('\n')
: stdout
throw error
}
/** The `git status --porcelain -z` entries that genuinely block removal:
* everything except the untracked shared links the caller tolerates. */
function getBlockingUntrackedStatusEntries(
status: string,
ignoredUntrackedPaths: readonly string[]
): string[] {
const ignored = new Set(
ignoredUntrackedPaths
.map((entry) =>
entry
.trim()
.replace(/^[\\/]+/, '')
.replace(/\\/g, '/')
)
.filter((entry) => entry && !entry.split('/').includes('..'))
)
return status
.split('\0')
.filter(Boolean)
.filter(
(entry) => !(entry.startsWith('?? ') && ignored.has(entry.slice(3).replace(/\\/g, '/')))
)
}
+173
View File
@@ -0,0 +1,173 @@
import type { RemoveWorktreeResult } from '../../shared/worktree/create-types'
import { assertWorktreeUnlockedForRemoval } from '../../shared/worktree/removal'
import { isSubmoduleWorktreeRemovalRefusal } from '../../shared/worktree/submodule-removal'
import { withWorktreeRemoveStageSpan } from '../observability/instrumentation'
import { withSpan } from '../observability/tracer'
import {
moveWorktreeDirectoryToTrash,
restoreWorktreeDirectoryFromTrash,
scheduleWorktreeTrashDeletion
} from '../worktree-trash'
import { parseWslPath } from '../wsl'
import { gitExecFileAsync } from './runner'
import { runWithGitReadCacheInvalidation } from './status'
import { deleteBranchAfterWorktreeRemoval } from './worktree-branch-removal'
import { listWorktreesStrict } from './worktree-listing'
import type { RemoveWorktreeOptions } from './worktree-operation-options'
import {
WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS,
gitExecOptions,
normalizeLocalBranchRef
} from './worktree-operation-options'
import { areWorktreePathsEqual } from './worktree-path-comparison'
import { assertWorktreeCleanForRemoval } from './worktree-removal-preflight'
import { bumpWorktreeScanGeneration, listWorktrees } from './worktree-scan-cache'
/**
* Remove a worktree.
*/
export async function removeWorktree(
repoPath: string,
worktreePath: string,
force = false,
// forceBranchDelete: for failed-creation rollback (fresh branch, no user work); user deletes leave it false so unmerged commits survive.
options: RemoveWorktreeOptions = {}
): Promise<RemoveWorktreeResult> {
try {
return await runWithGitReadCacheInvalidation(() =>
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 ?? ''
// Why: callers outside the IPC/runtime preflight must not bypass Git's lock contract or rely on localized stderr after side effects.
assertWorktreeUnlockedForRemoval(removedWorktree)
if (
!(await tryRemoveWorktreeWithDeferredDirectoryDeletion(repoPath, worktreePath, force, options))
) {
const args = ['worktree', 'remove']
if (force) {
args.push('--force')
}
args.push(worktreePath)
try {
await gitExecFileAsync(args, gitExecOptions(repoPath, options))
} catch (error) {
if (force || !isSubmoduleWorktreeRemovalRefusal(error)) {
throw error
}
// Why: Git refuses non-force removal of a worktree with an initialised submodule even when clean; re-prove cleanliness, then --force.
await assertWorktreeCleanForRemoval(worktreePath, false, options)
await gitExecFileAsync(
['worktree', 'remove', '--force', worktreePath],
gitExecOptions(repoPath, options)
)
}
}
if (!branchName) {
return {}
}
if (options.deleteBranch === false) {
return {}
}
// Why its own span: branch cleanup can reach the network (`fetch --prune`), so a stall here reads as
// `git worktree remove` being slow unless it is timed separately.
return withSpan('worktree.remove.branch_delete', () =>
deleteBranchAfterWorktreeRemoval(repoPath, branchName, branchHead, options)
)
}
/**
* Rename the checkout into a sibling trash directory and clear Git's registration for the
* now-missing path, so the multi-GB recursive delete runs after this removal has returned.
* Returns false when the caller must let `git worktree remove` delete the directory inline.
*/
async function tryRemoveWorktreeWithDeferredDirectoryDeletion(
repoPath: string,
worktreePath: string,
force: boolean,
options: RemoveWorktreeOptions
): Promise<boolean> {
// Why: WSL-owned checkouts are deleted inside the distro, so Node on Windows must not rename them.
if (options.wslDistro || parseWslPath(worktreePath)) {
return false
}
if (!force) {
try {
// Why: `git worktree remove` re-checks cleanliness as it removes; prove the same thing here or leave removal to Git.
await assertWorktreeCleanForRemoval(worktreePath, false, options)
} catch {
return false
}
}
const trashPath = await withWorktreeRemoveStageSpan('trash_rename', 'local', () =>
moveWorktreeDirectoryToTrash(worktreePath)
)
if (!trashPath) {
return false
}
try {
await clearGitRegistrationForMissingWorktree(repoPath, worktreePath, options)
} catch (error) {
// Why: put the checkout back so the in-place removal below still sees the worktree Git registered.
if (await restoreWorktreeDirectoryFromTrash(trashPath, worktreePath)) {
return false
}
throw error
}
scheduleWorktreeTrashDeletion(trashPath)
return true
}
async function clearGitRegistrationForMissingWorktree(
repoPath: string,
worktreePath: string,
options: RemoveWorktreeOptions
): Promise<void> {
const registrationOptions = {
...options,
timeout: options.timeout ?? WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS
}
try {
// Removing an already-missing directory is accepted back to the Git 2.25 baseline and touches only this entry.
await gitExecFileAsync(
['worktree', 'remove', '--force', worktreePath],
gitExecOptions(repoPath, registrationOptions)
)
return
} catch (error) {
console.warn(
`[git] Failed to deregister the moved worktree "${worktreePath}"; pruning instead`,
error
)
}
await gitExecFileAsync(['worktree', 'prune'], gitExecOptions(repoPath, registrationOptions))
// Strict (not the shared scan): an unreadable repo must not read as proof that the row is gone.
const stillRegistered = (await listWorktreesStrict(repoPath, registrationOptions)).some(
(worktree) => areWorktreePathsEqual(worktree.path, worktreePath)
)
if (stillRegistered) {
throw new Error(`Git still reports a registration for "${worktreePath}" after pruning it.`)
}
}
+80
View File
@@ -0,0 +1,80 @@
import type { GitWorktreeInfo } from '../../shared/worktree/types'
import { listWorktreesUnshared } from './worktree-listing'
import type { GitWorktreeExecOptions } from './worktree-operation-options'
import { WORKTREE_LIST_TIMEOUT_MS } from './worktree-operation-options'
// Why: share concurrent `git worktree list` scans, which are expensive on Windows.
const inFlightWorktreeScans = new Map<string, Promise<GitWorktreeInfo[]>>()
// Why: mutation generations prevent listings from joining stale scans.
const worktreeScanGenerations = new Map<string, number>()
function hasInFlightWorktreeScanForRepo(repoPath: string): boolean {
const keyPrefix = `${repoPath}\0`
for (const key of inFlightWorktreeScans.keys()) {
if (key.startsWith(keyPrefix)) {
return true
}
}
return false
}
export function bumpWorktreeScanGeneration(repoPath: string): void {
// Why: generations only prevent joining a pre-mutation scan; with no active scan, keeping the repo path just leaks completed mutation keys.
if (!hasInFlightWorktreeScanForRepo(repoPath)) {
return
}
worktreeScanGenerations.set(repoPath, (worktreeScanGenerations.get(repoPath) ?? 0) + 1)
}
function pruneWorktreeScanGeneration(repoPath: string): void {
// Why: keep ordinary scan settlement O(1); only repos invalidated during an active scan need the cross-generation check.
if (!worktreeScanGenerations.has(repoPath)) {
return
}
if (!hasInFlightWorktreeScanForRepo(repoPath)) {
worktreeScanGenerations.delete(repoPath)
}
}
export function _getWorktreeScanCacheSizesForTests(): { inFlight: number; generations: number } {
return {
inFlight: inFlightWorktreeScans.size,
generations: worktreeScanGenerations.size
}
}
export function _resetWorktreeScanCacheForTests(): void {
inFlightWorktreeScans.clear()
worktreeScanGenerations.clear()
}
/**
* 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 timeout = options.timeout ?? WORKTREE_LIST_TIMEOUT_MS
// Why: callers with different deadlines cannot safely share which timeout wins the scan.
const key = `${repoPath}\0${options.wslDistro ?? ''}\0${timeout}\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)
}
pruneWorktreeScanGeneration(repoPath)
})
inFlightWorktreeScans.set(key, scan)
return scan
}
+79
View File
@@ -0,0 +1,79 @@
import { windowsLongPathGitArgs } from '../../shared/windows-long-path-git-args'
import { gitExecFileAsync } from './runner'
import { addWorktree, unsetWorktreeCreationBase } from './worktree-add'
import type {
AddWorktreeOptions,
AddWorktreeResult,
SparseWorktreeCreateError
} from './worktree-operation-options'
import { gitExecOptions } from './worktree-operation-options'
import { removeWorktree } from './worktree-removal'
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
// Why: `worktree add --no-checkout` writes no files, so these are the calls that
// actually materialize the deep path and need the long-path escape hatch.
const longPathArgs = windowsLongPathGitArgs(worktreePath)
await gitExecFileAsync(
['sparse-checkout', 'init', '--cone'],
gitExecOptions(worktreePath, options)
)
await gitExecFileAsync(
[...longPathArgs, 'sparse-checkout', 'set', '--', ...directories],
gitExecOptions(worktreePath, options)
)
await gitExecFileAsync(
[...longPathArgs, '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) {
try {
await unsetWorktreeCreationBase(worktreePath, branch, options)
} catch (cleanupError) {
console.warn(
`addSparseWorktree: failed to clear creation base for ${worktreePath}`,
cleanupError
)
}
}
try {
await removeWorktree(repoPath, worktreePath, true, {
deleteBranch: !options.checkoutExistingBranch,
// Why: failed-creation rollback — the fresh branch has no user commits, so force-delete rather than orphan it.
forceBranchDelete: !options.checkoutExistingBranch,
...(options.wslDistro ? { wslDistro: options.wslDistro } : {})
})
} catch {
wrapped.cleanupFailed = true
// Why: surface that manual cleanup may be needed, else a half-created worktree lingers silently on disk.
wrapped.message = `${wrapped.message} (cleanup also failed — the partially created worktree at "${worktreePath}" may need manual removal)`
}
}
throw wrapped
}
}
+137
View File
@@ -0,0 +1,137 @@
import { readFile, stat } from 'node:fs/promises'
import { isAbsolute, join, resolve } from 'node:path'
import { resolveGitDir } from './status'
export async function detectSparseCheckout(worktreePath: string): Promise<boolean> {
// Why: fs.stat the per-worktree gitdir's sparse-checkout pattern file instead of a per-poll `git sparse-checkout list` subprocess that regressed responsiveness (PR #1290);
// this is the cheap fast-path gate before the enabled check below.
try {
const gitDir = await resolveGitDir(worktreePath)
const stats = await stat(join(gitDir, 'info', 'sparse-checkout'))
if (!stats.isFile() || stats.size === 0) {
return false
}
// Why the extra config read: `git sparse-checkout disable` restores every file to the
// working tree and sets core.sparseCheckout=false, but it deliberately LEAVES
// <gitdir>/info/sparse-checkout in place so the checkout can be re-enabled with the same
// patterns. A non-empty pattern file is therefore necessary but not sufficient — without
// confirming core.sparseCheckout is actually on we would flag a fully-populated worktree as
// sparse and show a misleading "files are not on disk" badge. This runs only for the rare
// worktree that still has a non-empty pattern file, so it does not reintroduce the per-poll
// subprocess fan-out PR #1290 removed, and it reads git's config files directly (no
// subprocess) so it stays cheap and needs no exec options.
return await isSparseCheckoutEnabled(gitDir)
} catch {
return false
}
}
// Resolve the shared common gitdir for a (possibly linked) worktree gitdir. A linked worktree's
// gitdir holds a `commondir` file pointing at the repo's main `.git`; the main worktree's gitdir
// is itself the common dir.
async function resolveGitCommonDir(gitDir: string): Promise<string> {
try {
const raw = (await readFile(join(gitDir, 'commondir'), 'utf-8')).trim()
if (raw.length > 0) {
return isAbsolute(raw) ? raw : resolve(gitDir, raw)
}
} catch {
// No `commondir` file: this gitdir is already the common dir.
}
return gitDir
}
// Whether core.sparseCheckout is actually enabled for this worktree. The value can live in the
// shared repo config or, when extensions.worktreeConfig is on, in the worktree-local
// `config.worktree`; later files override earlier ones, matching git's config precedence.
async function isSparseCheckoutEnabled(gitDir: string): Promise<boolean> {
const commonDir = await resolveGitCommonDir(gitDir)
const sharedConfig = await readGitConfigText(join(commonDir, 'config'))
const sharedFlag = parseCoreSparseCheckoutFlag(sharedConfig)
// Git reads `config.worktree` only while extensions.worktreeConfig is on; without that gate a
// stale worktree config left behind by an earlier sparse checkout overrides the real repo value.
if (parseGitConfigFlag(sharedConfig, 'extensions', 'worktreeconfig') !== true) {
return sharedFlag ?? false
}
const worktreeConfig = await readGitConfigText(join(gitDir, 'config.worktree'))
return parseCoreSparseCheckoutFlag(worktreeConfig) ?? sharedFlag ?? false
}
async function readGitConfigText(configPath: string): Promise<string> {
try {
return await readFile(configPath, 'utf-8')
} catch {
return ''
}
}
// Read the effective `core.sparseCheckout` boolean from one git config file's text, or `undefined`
// when the plain `[core]` section does not set it. Kept as a pure, exported function so the
// git-config parsing edge cases can be unit tested without touching the filesystem. Only the last
// assignment wins, and a `[core "subsection"]` header is intentionally not treated as `[core]`.
export function parseCoreSparseCheckoutFlag(configContent: string): boolean | undefined {
return parseGitConfigFlag(configContent, 'core', 'sparsecheckout')
}
// A section header may be followed on the same line by further headers and then one assignment
// (`[core] sparseCheckout = true` is legal git config); the value runs to end of line, so at most
// one assignment can share a line and the last header before it decides the section.
const GIT_CONFIG_SECTION_HEADER = /^\[\s*([A-Za-z0-9.-]+)(\s+"(?:[^"\\]|\\.)*")?\s*\]/
const GIT_CONFIG_ASSIGNMENT = /^([A-Za-z][A-Za-z0-9-]*)\s*(?:=\s*(.*))?$/
// `section` and `key` must be lowercase: git config names are case-insensitive.
function parseGitConfigFlag(
configContent: string,
section: string,
key: string
): boolean | undefined {
let inSection = false
let value: boolean | undefined
for (const rawLine of configContent.split(/\r?\n/)) {
let rest = stripGitConfigComment(rawLine).trim()
for (
let header = rest.match(GIT_CONFIG_SECTION_HEADER);
header;
header = rest.match(GIT_CONFIG_SECTION_HEADER)
) {
inSection = header[1].toLowerCase() === section && header[2] === undefined
rest = rest.slice(header[0].length).trim()
}
if (!inSection || rest.length === 0) {
continue
}
const assignment = rest.match(GIT_CONFIG_ASSIGNMENT)
if (!assignment || assignment[1].toLowerCase() !== key) {
continue
}
value = parseGitConfigBoolean(assignment[2])
}
return value
}
// Drop a trailing `#`/`;` comment that is not inside a double-quoted value.
function stripGitConfigComment(line: string): string {
let inQuotes = false
for (let index = 0; index < line.length; index += 1) {
const char = line[index]
if (char === '"' && line[index - 1] !== '\\') {
inQuotes = !inQuotes
} else if ((char === '#' || char === ';') && !inQuotes) {
return line.slice(0, index)
}
}
return line
}
// Git treats a valueless boolean (`sparseCheckout` with no `=`) as true and only true/yes/on/1 as
// true otherwise; everything else (including the disable-written `false`) is false.
function parseGitConfigBoolean(raw: string | undefined): boolean {
if (raw === undefined) {
return true
}
const value = raw
.trim()
.replace(/^"(.*)"$/, '$1')
.toLowerCase()
return value === 'true' || value === 'yes' || value === 'on' || value === '1'
}
+28 -1693
View File
File diff suppressed because it is too large Load Diff