From 1e8a56d054e4677443285b7e51cc0a0eb0941dc5 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 29 Aug 2026 10:22:20 -0700 Subject: [PATCH] Split Git worktree responsibilities --- config/max-lines-baseline.txt | 1 - src/main/git/worktree-add.ts | 193 ++ .../git/worktree-base-refresh-analysis.ts | 210 ++ src/main/git/worktree-base-refresh.ts | 76 + src/main/git/worktree-branch-removal.ts | 188 ++ src/main/git/worktree-list-parser.ts | 103 + src/main/git/worktree-list-reader.ts | 205 ++ src/main/git/worktree-listing.ts | 99 + src/main/git/worktree-move.ts | 23 + src/main/git/worktree-operation-options.ts | 125 ++ src/main/git/worktree-path-comparison.ts | 41 + src/main/git/worktree-removal-preflight.ts | 68 + src/main/git/worktree-removal.ts | 173 ++ src/main/git/worktree-scan-cache.ts | 80 + src/main/git/worktree-sparse-add.ts | 79 + src/main/git/worktree-sparse-state.ts | 137 ++ src/main/git/worktree.ts | 1721 +---------------- 17 files changed, 1828 insertions(+), 1694 deletions(-) create mode 100644 src/main/git/worktree-add.ts create mode 100644 src/main/git/worktree-base-refresh-analysis.ts create mode 100644 src/main/git/worktree-base-refresh.ts create mode 100644 src/main/git/worktree-branch-removal.ts create mode 100644 src/main/git/worktree-list-parser.ts create mode 100644 src/main/git/worktree-list-reader.ts create mode 100644 src/main/git/worktree-listing.ts create mode 100644 src/main/git/worktree-move.ts create mode 100644 src/main/git/worktree-operation-options.ts create mode 100644 src/main/git/worktree-path-comparison.ts create mode 100644 src/main/git/worktree-removal-preflight.ts create mode 100644 src/main/git/worktree-removal.ts create mode 100644 src/main/git/worktree-scan-cache.ts create mode 100644 src/main/git/worktree-sparse-add.ts create mode 100644 src/main/git/worktree-sparse-state.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index b8ac217bdc7..1fee74a8eb0 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -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 diff --git a/src/main/git/worktree-add.ts b/src/main/git/worktree-add.ts new file mode 100644 index 00000000000..4110a4bda0b --- /dev/null +++ b/src/main/git/worktree-add.ts @@ -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 { + 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 { + 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..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 { + 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 { + 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/ (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 } : {}) + } +} diff --git a/src/main/git/worktree-base-refresh-analysis.ts b/src/main/git/worktree-base-refresh-analysis.ts new file mode 100644 index 00000000000..bcf014db1e1 --- /dev/null +++ b/src/main/git/worktree-base-refresh-analysis.ts @@ -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 { + 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/ 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 { + 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 + } +} diff --git a/src/main/git/worktree-base-refresh.ts b/src/main/git/worktree-base-refresh.ts new file mode 100644 index 00000000000..c85e4063e3b --- /dev/null +++ b/src/main/git/worktree-base-refresh.ts @@ -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 { + 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' } + } +} diff --git a/src/main/git/worktree-branch-removal.ts b/src/main/git/worktree-branch-removal.ts new file mode 100644 index 00000000000..5e64b4a582d --- /dev/null +++ b/src/main/git/worktree-branch-removal.ts @@ -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 { + 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 { + 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 { + 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 { + const { stdout } = await runGit(['worktree', 'list', '--porcelain'], repoPath) + return parseWorktreeList(stdout).some( + (worktree) => normalizeLocalBranchRef(worktree.branch) === branchName + ) +} diff --git a/src/main/git/worktree-list-parser.ts b/src/main/git/worktree-list-parser.ts new file mode 100644 index 00000000000..34589015d24 --- /dev/null +++ b/src/main/git/worktree-list-parser.ts @@ -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 +} diff --git a/src/main/git/worktree-list-reader.ts b/src/main/git/worktree-list-reader.ts new file mode 100644 index 00000000000..1b17cfd17c4 --- /dev/null +++ b/src/main/git/worktree-list-reader.ts @@ -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 { + 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 { + 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 { + 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 { + const annotated = [...worktrees] + let nextIndex = 0 + + async function probeNext(): Promise { + 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 { + return (await readWorktreeList(repoPath, options)).map((worktree) => { + const translatedPath = translateWorktreePath(worktree.path, repoPath, options) + return translatedPath === worktree.path ? worktree : { ...worktree, path: translatedPath } + }) +} diff --git a/src/main/git/worktree-listing.ts b/src/main/git/worktree-listing.ts new file mode 100644 index 00000000000..dd6ee20765f --- /dev/null +++ b/src/main/git/worktree-listing.ts @@ -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 { + 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 { + 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 { + 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 { + const annotated = [...worktrees] + let nextIndex = 0 + + async function detectNext(): Promise { + 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 +} diff --git a/src/main/git/worktree-move.ts b/src/main/git/worktree-move.ts new file mode 100644 index 00000000000..d90569ecb47 --- /dev/null +++ b/src/main/git/worktree-move.ts @@ -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//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 { + try { + await runWithGitReadCacheInvalidation(() => + gitExecFileAsync(['worktree', 'move', oldPath, newPath], { cwd: repoPath }) + ) + } finally { + bumpWorktreeScanGeneration(repoPath) + } +} diff --git a/src/main/git/worktree-operation-options.ts b/src/main/git/worktree-operation-options.ts new file mode 100644 index 00000000000..c88c246be9c --- /dev/null +++ b/src/main/git/worktree-operation-options.ts @@ -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 +} + +// 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 } diff --git a/src/main/git/worktree-path-comparison.ts b/src/main/git/worktree-path-comparison.ts new file mode 100644 index 00000000000..6bd8fd7aacb --- /dev/null +++ b/src/main/git/worktree-path-comparison.ts @@ -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 +} diff --git a/src/main/git/worktree-removal-preflight.ts b/src/main/git/worktree-removal-preflight.ts new file mode 100644 index 00000000000..1e3ee3e8c3d --- /dev/null +++ b/src/main/git/worktree-removal-preflight.ts @@ -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 { + 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, '/'))) + ) +} diff --git a/src/main/git/worktree-removal.ts b/src/main/git/worktree-removal.ts new file mode 100644 index 00000000000..ebb96ac1d90 --- /dev/null +++ b/src/main/git/worktree-removal.ts @@ -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 { + try { + return await runWithGitReadCacheInvalidation(() => + performRemoveWorktree(repoPath, worktreePath, force, options) + ) + } finally { + bumpWorktreeScanGeneration(repoPath) + } +} + +async function performRemoveWorktree( + repoPath: string, + worktreePath: string, + force = false, + options: RemoveWorktreeOptions = {} +): Promise { + 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 { + // 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 { + 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.`) + } +} diff --git a/src/main/git/worktree-scan-cache.ts b/src/main/git/worktree-scan-cache.ts new file mode 100644 index 00000000000..2320ecd3fb0 --- /dev/null +++ b/src/main/git/worktree-scan-cache.ts @@ -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>() + +// Why: mutation generations prevent listings from joining stale scans. +const worktreeScanGenerations = new Map() + +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 { + 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 +} diff --git a/src/main/git/worktree-sparse-add.ts b/src/main/git/worktree-sparse-add.ts new file mode 100644 index 00000000000..43d13a89fe6 --- /dev/null +++ b/src/main/git/worktree-sparse-add.ts @@ -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 { + 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 + } +} diff --git a/src/main/git/worktree-sparse-state.ts b/src/main/git/worktree-sparse-state.ts new file mode 100644 index 00000000000..1cf55fe712f --- /dev/null +++ b/src/main/git/worktree-sparse-state.ts @@ -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 { + // 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 + // /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 { + 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 { + 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 { + 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' +} diff --git a/src/main/git/worktree.ts b/src/main/git/worktree.ts index 55c5d33cf79..da9cb42e898 100644 --- a/src/main/git/worktree.ts +++ b/src/main/git/worktree.ts @@ -1,1693 +1,28 @@ -/* 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 { readFile, stat } from 'node:fs/promises' -import { isAbsolute, join, posix, resolve, win32 } from 'node:path' -import { - branchHasNoUnmergedChangesWithLazyTargetRefresh, - getBranchCleanupTargetRefs -} from '../../shared/git-branch-cleanup' -import { resolveWorktreeAddBaseRef } from '../../shared/worktree/base-ref' -import { withSpan } from '../observability/tracer' -import { withWorktreeRemoveStageSpan } from '../observability/instrumentation' -import { - moveWorktreeDirectoryToTrash, - restoreWorktreeDirectoryFromTrash, - scheduleWorktreeTrashDeletion -} from '../worktree-trash' -import { parseWslPath } from '../wsl' -import { windowsLongPathGitArgs } from '../../shared/windows-long-path-git-args' -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' -import { assertWorktreeUnlockedForRemoval } from '../../shared/worktree/removal' -import { isSubmoduleWorktreeRemovalRefusal } from '../../shared/worktree/submodule-removal' -import { decodeGitCQuotedPath } from '../../shared/git-cquoted-path' -import { parseGitRevListAheadBehindCounts } from '../../shared/git-rev-list-output' -import { parseWslUncPath } from '../../shared/wsl-paths' -import { - hasUnsupportedRevParsePathFormatEcho, - isUnsupportedRevParsePathFormatError, - isUnsupportedWorktreeListZError -} from '../../shared/git-worktree-command-capabilities' -import { withLocalGitCapabilityCacheForExecution } from './git-capability-state' -import { gitExecFileAsync, translateWslOutputPaths } from './runner' -import { resolveGitDir, runWithGitReadCacheInvalidation } from './status' -import { hasWorktreeBaseCommitRef, probeWorktreeBaseRefPresence } from './worktree-base-ref-probe' - -export type AddWorktreeResult = { - localBaseRefRefresh?: LocalBaseRefRefreshResult - localBaseRefUpdateSuggestion?: LocalBaseRefUpdateSuggestion -} - -type SparseWorktreeCreateError = Error & { - cleanupFailed?: boolean -} - -export type GitWorktreeExecOptions = { - wslDistro?: string - signal?: AbortSignal - timeout?: number -} - -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 -} - -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 - -const PRUNABLE_EXISTENCE_PROBE_CONCURRENCY = 8 - -// 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 -} - -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 } : {}) - } -} - -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 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 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 -} - -async function evaluateLocalBaseRefRefreshability( - repoPath: string, - baseBranch: string, - remoteTrackingRef: string, - remoteTrackingBase?: AddWorktreeOptions['remoteTrackingBase'], - options: GitWorktreeExecOptions = {}, - shouldInspectOwner: (behind: number) => boolean = () => true -): Promise { - 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/ 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' } } - } -} - -async function getLocalBaseRefUpdateSuggestionForWorktreeCreate( - repoPath: string, - baseBranch: string, - remoteTrackingRef: string, - remoteTrackingBase?: AddWorktreeOptions['remoteTrackingBase'], - options: GitWorktreeExecOptions = {} -): Promise { - 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 - } -} - -async function persistWorktreeCreationBase( - worktreePath: string, - branch: string, - effectiveBase: string, - options: GitWorktreeExecOptions = {} -): Promise { - 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 - ) - } - } -} - -async function unsetWorktreeCreationBase( - worktreePath: string, - branch: string, - options: GitWorktreeExecOptions = {} -): Promise { - 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. - } -} - -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 resolve a relative toplevel/git-dir 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 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 { - 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 { - 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 -} - -/** - * 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 -} - -async function readWorktreeList( - repoPath: string, - options: GitWorktreeExecOptions = {} -): Promise { - 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 { - const annotated = [...worktrees] - let nextIndex = 0 - - async function probeNext(): Promise { - 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 -} - -async function readTranslatedWorktreeGraph( - repoPath: string, - options: GitWorktreeExecOptions = {} -): Promise { - 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 { - 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: share concurrent `git worktree list` scans, which are expensive on Windows. -const inFlightWorktreeScans = new Map>() - -// Why: mutation generations prevent listings from joining stale scans. -const worktreeScanGenerations = new Map() - -function hasInFlightWorktreeScanForRepo(repoPath: string): boolean { - const keyPrefix = `${repoPath}\0` - for (const key of inFlightWorktreeScans.keys()) { - if (key.startsWith(keyPrefix)) { - return true - } - } - return false -} - -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 { - 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 -} - -async function listWorktreesUnshared( - repoPath: string, - options: GitWorktreeExecOptions = {} -): Promise { - 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 { - 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 { - const annotated = [...worktrees] - let nextIndex = 0 - - async function detectNext(): Promise { - 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 -} - -async function refreshLocalBaseRefForWorktreeCreate( - repoPath: string, - baseBranch: string, - remoteTrackingRef: string, - remoteTrackingBase?: AddWorktreeOptions['remoteTrackingBase'], - options: GitWorktreeExecOptions = {} -): Promise { - 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' } - } -} - -/** - * 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..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 { - 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 { - 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/ (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 } : {}) - } -} - -export async function addSparseWorktree( - repoPath: string, - worktreePath: string, - branch: string, - directories: string[], - baseBranch?: string, - refreshLocalBaseRef = false, - options: AddWorktreeOptions = {} -): Promise { - 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 - } -} - -/** - * Move a worktree with `git worktree move` (not `fs.rename`, which corrupts the - * `.git` file and the `.git/worktrees//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 { - try { - await runWithGitReadCacheInvalidation(() => - gitExecFileAsync(['worktree', 'move', oldPath, newPath], { cwd: repoPath }) - ) - } finally { - bumpWorktreeScanGeneration(repoPath) - } -} - -/** - * 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 { - try { - return await runWithGitReadCacheInvalidation(() => - performRemoveWorktree(repoPath, worktreePath, force, options) - ) - } finally { - bumpWorktreeScanGeneration(repoPath) - } -} - -async function performRemoveWorktree( - repoPath: string, - worktreePath: string, - force = false, - options: RemoveWorktreeOptions = {} -): Promise { - 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 { - // 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 { - 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.`) - } -} - -async function deleteBranchAfterWorktreeRemoval( - repoPath: string, - branchName: string, - branchHead: string, - options: RemoveWorktreeOptions -): Promise { - 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 { - 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 { - 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 { - 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: WorktreeRemovalPreflightOptions = {} -): Promise { - 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, '/'))) - ) -} - -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 { - // 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 - // /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 { - 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 { - 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 { - 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' -} +export { addWorktree } from './worktree-add' +export { forceDeleteLocalBranch } from './worktree-branch-removal' +export { parseWorktreeList } from './worktree-list-parser' +export { listWorktreeGraph, listWorktreesStrict } from './worktree-listing' +export { moveWorktree } from './worktree-move' +export { + WORKTREE_ADD_TIMEOUT_MAX_MS, + WORKTREE_ADD_TIMEOUT_MS, + WORKTREE_LIST_TIMEOUT_MS, + WORKTREE_REMOVAL_PREFLIGHT_TIMEOUT_MS, + WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS, + resolveWorktreeAddTimeoutMs +} from './worktree-operation-options' +export type { + AddWorktreeOptions, + AddWorktreeResult, + GitWorktreeExecOptions, + RemoveWorktreeOptions +} from './worktree-operation-options' +export { assertWorktreeCleanForRemoval } from './worktree-removal-preflight' +export { removeWorktree } from './worktree-removal' +export { + _getWorktreeScanCacheSizesForTests, + _resetWorktreeScanCacheForTests, + listWorktrees +} from './worktree-scan-cache' +export { addSparseWorktree } from './worktree-sparse-add' +export { parseCoreSparseCheckoutFlag } from './worktree-sparse-state'