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