Files
orca/src/main/ipc/worktrees-test-module-mocks.ts
T
Neil 1268fb56f1 fix(worktree): complete a create Git can confirm but cannot list (#17388)
* fix(worktree): complete a create Git can confirm but cannot list

`worktree.create` verified against `listWorktrees`, which softens every git
failure to `[]`. Any listing failure therefore failed a create whose worktree
and branch `git worktree add` had already written, orphaning both, and reported
only 'Worktree created but not found in listing' — the real cause reached the
main-process console and never the user.

Verify against the error-propagating listing instead, and when that fails or
omits the row, rebuild the row by asking Git about the worktree itself. The
direct read returns nothing unless Git resolves the path into this repo's
object store with the expected branch checked out, so an unrelated or half-made
checkout still fails the create.

Fixes #16520

* fix(worktree): authorize a recovered create and reject an unreadable HEAD

Review follow-ups on the create-verification fallback:
- register the recovered worktree's own root, additively, so the create the
  user just made is not rejected by filesystem/git-status IPC
- treat an unreadable HEAD as no recovery instead of a blank OID
- keep the direct read's failure when the listing merely omitted the row
- skip the symlink cases on Windows and reset the new harness mock

* fix(worktree): bound the create-recovery disk read and keep WSL paths case-sensitive

Readiness-scan follow-ups:
- deadline the filesystem common-dir read; a .git on a hung mount left the whole
  create IPC pending where it used to fail after the Git deadline
- offer no disk candidate for a bare repo instead of a fabricated <repo>/.git
- compare POSIX common dirs case-sensitively, so two WSL repos differing only in
  case are not accepted as one object store on a Windows desktop
- move toGitOutputSpace to shared/wsl-paths as toWslExecutionSpace, next to the
  parseWslUncPath callers that already open-code it

* fix(worktree): share one budget for create verification and keep recovered roots

Three follow-ups from review of the create-recovery path:

- The recovery no longer starts a fresh 30s deadline after the listing already
  burned one, so worst-case create verification stays at ~30s instead of ~60s.
  A 5s floor keeps the direct read a chance to answer when the listing spent
  the whole budget.
- rebuildAuthorizedRootsCache now carries a repo's previously registered roots
  forward when its listing throws. A rebuild running while Git is still broken
  could otherwise un-authorize the worktree a create just recovered.
- Corrected the scan-cache doc comment: it claimed strict and lenient listings
  coalesce, but the cache key includes the runner name precisely to keep them
  apart, so a strict joiner can never inherit a lenient scan's softened [].

Each change has a negative control: reverting the hunk fails exactly its own
test and nothing else.

* fix(worktree): keep a recovered worktree authorized across roots-cache rebuilds

The previous approach registered a recovered create into the same per-repo set
the rebuild recomputes from `git worktree list`. That set is derived from the
very listing that failed, so a rebuild would re-deny the worktree — either by
overlapping the registration, or by simply listing again and omitting the row.
Carrying old roots forward on a thrown listing did not cover either case.

Recovered roots now live in their own additive layer that rebuilds union in
rather than replace. The layer is retired on evidence, not on a timer:

- the listing can see the worktree again (Git recovered), or
- the listing succeeded and the directory is gone (worktree removed).

A repo whose listing threw is left untouched, because a dead mount fails both
the listing and the stat, and treating that as "removed" would revoke the
worktree in exactly the outage this layer exists for. The layer is capped so it
cannot grow unbounded, and survives cache invalidation deliberately: repo
mutations are frequent and would otherwise re-deny a recovered worktree.

Three tests cover the healthy-rebuild-omits-the-row case, the in-flight rebuild
race, and retirement once the listing sees it again. Removing the union fails
exactly the two keep-tests and nothing else.

* perf(worktree): only read the repo's .git from disk when Git's own answer disagrees

The disk read is a second opinion on Git's reading of the common dir, but it ran
unconditionally as part of the same Promise.all. A deadline bounds the IPC, not
the syscall: Promise.race cannot cancel an in-flight fs operation, and a `.git`
on a hung mount (dead NFS/SSHFS, stalled WSL 9p) pins a libuv threadpool thread
that no timeout can reclaim. AbortSignal would not help either — fsPromises.stat
takes no signal, and a blocked syscall is not interruptible from userland.

So stop paying it on the happy path: read from disk only when Git's own reading
did not already confirm the common dir. Same accept/reject outcome, but the
threadpool exposure now requires both a failed listing and Git disagreeing about
the repo, instead of every recovered create.

* fix(worktree): compare the disk common-dir witness in Git's execution space

Exercising the fix on a real Windows host against WSL Ubuntu-24.04 found the
filesystem second opinion is inert there. Node reads `.git` in the caller's
space and answers `\\wsl.localhost\<Distro>\home\...\.git`, while Git-in-the-
distro answers `/home/...`. isSameCommonDirPath refuses to compare a POSIX path
against a Windows one, and canonicalizeLocalPath cannot bridge them because
realpath on a Linux path from a Windows process is ENOENT.

So the candidate could never match, and the one case that depends on this
witness alone — a symlinked repo root on the Git 2.25 fallback — declined a
worktree Git had already confirmed. Run the disk result through
toWslExecutionSpace, the same translation readRepoLocation already uses.

This is a false reject, not a false accept: it made recovery give up, never
adopt the wrong repo. Verified on awin; the modern --path-format=absolute
branch was unaffected because Git answers both sides itself there.

* fix(worktree): retire a recovered root only on proof, never on a stalled probe

The prune ran an unbounded stat and read every failure as removal. Two consequences, both in
the outage the recovered layer exists for: a hung mount stalled the rebuild that gates
filesystem auth, and a transient EACCES/EIO revoked a live worktree. The listingFailed guard
did not cover either, because listWorktrees softens Git failures to [] and never throws.

Prune now retires on definitive ENOENT only, probes in parallel under a deadline, and treats a
stall as inconclusive. The capacity bound refuses a new root instead of evicting an authorized
one, so an over-cap create is merely unauthorized rather than a live worktree being revoked.
2026-08-30 14:42:07 -07:00

270 lines
11 KiB
TypeScript

import { type Mock, vi } from 'vitest'
import type { HandlerMap } from './worktrees-test-ipc-surface'
/** Loose signature: one mock stands in for many unrelated module exports. */
export type ModuleMock = Mock<(...args: unknown[]) => unknown>
/** Mocks tests re-implement with a leading path/name/script argument. */
export type StringArgMock = Mock<(value: string, ...rest: unknown[]) => unknown>
/** Lookups tests re-implement per branch, e.g. conflict kind and PR resolution. */
export type RepoBranchMock = Mock<(repoPath: string, branch: string, ...rest: unknown[]) => unknown>
/** Git invocations tests re-implement by inspecting the argv array. */
export type GitArgvMock = Mock<(args: string[], ...rest: unknown[]) => unknown>
export type ParsedWorktreeRow = {
path: string
branch: string
head: string
isBare: boolean
isMainWorktree: boolean
}
export const ORIGINAL_PLATFORM = process.platform
export function setPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', {
configurable: true,
value: platform
})
}
export const removeWorktreeLinkedPathsMock: ModuleMock = vi.fn()
export const findExistingWorktreeSymlinkPathsMock: ModuleMock = vi.fn()
export const handleMock: Mock<(channel: string, handler: HandlerMap[string]) => void> = vi.fn()
export const removeHandlerMock: ModuleMock = vi.fn()
export const listWorktreesMock: StringArgMock = vi.fn()
export const describeCreatedWorktreeMock: ModuleMock = vi.fn()
export const parseWorktreeListMock: Mock<(output: string) => ParsedWorktreeRow[]> = vi.fn(
(output: string) =>
output
.trim()
.split(/\n\s*\n/)
.filter(Boolean)
.map((block, index) => {
const lines = block.split(/\r?\n/)
const path = lines.find((line) => line.startsWith('worktree '))?.slice(9) ?? ''
const branch = lines.find((line) => line.startsWith('branch '))?.slice(7) ?? ''
return { path, branch, head: String(index), isBare: false, isMainWorktree: index === 0 }
})
)
export const assertWorktreeCleanForRemovalMock: ModuleMock = vi.fn()
export const addWorktreeMock: ModuleMock = vi.fn()
export const addSparseWorktreeMock: ModuleMock = vi.fn()
export const removeWorktreeMock: ModuleMock = vi.fn()
export const forceDeleteLocalBranchMock: ModuleMock = vi.fn()
export const resolveLocalGitUsernameMock: ModuleMock = vi.fn()
export const getBaseRefDefaultMock: ModuleMock = vi.fn()
export const resolveDefaultBaseRefWithLocalGitMock: ModuleMock = vi.fn()
export const resolveDefaultBaseRefViaExecMock: ModuleMock = vi.fn()
export const getDefaultRemoteMock: ModuleMock = vi.fn()
export const getBranchConflictKindMock: RepoBranchMock = vi.fn()
export const getPRForBranchMock: RepoBranchMock = vi.fn()
export const getHostedReviewForBranchMock: ModuleMock = vi.fn()
export const getWorkItemMock: ModuleMock = vi.fn()
export const getPullRequestPushTargetMock: ModuleMock = vi.fn()
export const getEffectiveHooksMock: Mock<(repo?: unknown, worktreePath?: string) => unknown> =
vi.fn()
export const createIssueCommandRunnerScriptMock: ModuleMock = vi.fn()
export const createSetupRunnerScriptMock: ModuleMock = vi.fn()
export const getEffectiveHooksFromConfigMock: ModuleMock = vi.fn()
export const getDefaultTabsLaunchMock: ModuleMock = vi.fn()
export const parseOrcaYamlMock: ModuleMock = vi.fn()
export const shouldRunSetupForCreateMock: ModuleMock = vi.fn()
export const buildPosixRunnerScriptMock: StringArgMock = vi.fn()
export const buildWindowsRunnerScriptMock: StringArgMock = vi.fn()
export const getSetupRunnerEnvVarsMock: Mock<
(repo: { path: string }, worktreePath: string) => Record<string, string>
> = vi.fn()
export const resolveSetupRunnerShellMock: ModuleMock = vi.fn()
export const runHookMock: ModuleMock = vi.fn()
export const hasHooksFileMock: ModuleMock = vi.fn()
export const loadHooksMock: ModuleMock = vi.fn()
export const computeWorktreePathMock: Mock<
(
sanitizedName: string,
repoPath: string,
settings: { nestWorkspaces: boolean; workspaceDir: string }
) => string
> = vi.fn()
export const ensurePathWithinWorkspaceMock: StringArgMock = vi.fn()
export const gitExecFileAsyncMock: GitArgvMock = vi.fn()
export const getSshGitProviderMock: StringArgMock = vi.fn()
export const getSshFilesystemProviderMock: ModuleMock = vi.fn()
export const getActiveMultiplexerMock: ModuleMock = vi.fn()
export const deleteWorktreeHistoryDirMock: ModuleMock = vi.fn()
export const advertisedUrlWatcherForgetWorktreeMock: ModuleMock = vi.fn()
export const pruneCleanupScanSnapshotMock: ModuleMock = vi.fn().mockResolvedValue(undefined)
export const pruneCleanupScanSnapshotsMock: ModuleMock = vi.fn().mockResolvedValue(undefined)
export const pruneSpaceAnalysisSnapshotMock: ModuleMock = vi.fn().mockResolvedValue(undefined)
export const pruneSpaceAnalysisSnapshotsMock: ModuleMock = vi.fn().mockResolvedValue(undefined)
export const recordRemovalSnapshotPruneMock: ModuleMock = vi.fn()
export const killAllProcessesForWorktreeMock: ModuleMock = vi.fn()
export const clearProviderPtyStateMock: ModuleMock = vi.fn()
export const getLocalPtyProviderMock: ModuleMock = vi.fn()
export const getSshPtyProviderMock: ModuleMock = vi.fn()
// Why: vi.mock factories are hoisted per test file, so each file calls these builders instead of
// closing over module-scope bindings.
export const electronModuleMock = () => ({
ipcMain: {
handle: handleMock,
removeHandler: removeHandlerMock
}
})
export const gitWorktreeModuleMock = () => ({
listWorktrees: listWorktreesMock,
listWorktreesStrict: listWorktreesMock,
listWorktreesSharedStrict: listWorktreesMock,
describeCreatedWorktree: describeCreatedWorktreeMock,
parseWorktreeList: parseWorktreeListMock,
assertWorktreeCleanForRemoval: assertWorktreeCleanForRemovalMock,
addWorktree: addWorktreeMock,
addSparseWorktree: addSparseWorktreeMock,
removeWorktree: removeWorktreeMock,
forceDeleteLocalBranch: forceDeleteLocalBranchMock
})
export const gitRunnerModuleMock = (): {
gitExecFileAsync: GitArgvMock
gitExecFileSync: ModuleMock
} => ({
gitExecFileAsync: gitExecFileAsyncMock,
gitExecFileSync: vi.fn()
})
export const gitRepoModuleMock = () => ({
getBaseRefDefault: getBaseRefDefaultMock,
resolveDefaultBaseRefWithLocalGit: resolveDefaultBaseRefWithLocalGitMock,
resolveDefaultBaseRefViaExec: resolveDefaultBaseRefViaExecMock,
getDefaultRemote: getDefaultRemoteMock,
getBranchConflictKind: getBranchConflictKindMock
})
export const githubClientModuleMock = () => ({
getPRForBranch: getPRForBranchMock,
getWorkItem: getWorkItemMock,
getPullRequestPushTarget: getPullRequestPushTargetMock
})
export const hostedReviewModuleMock = () => ({
getHostedReviewForBranch: getHostedReviewForBranchMock
})
export const sshGitDispatchModuleMock = () => ({
getSshGitProviderGeneration: () => 0,
getSshGitProvider: getSshGitProviderMock,
SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE:
'Remote connection dropped. Click Reconnect on the SSH target before retrying.',
requireSshGitProvider: (connectionId: string) => {
const provider = getSshGitProviderMock(connectionId)
if (!provider) {
throw new Error(
'Remote connection dropped. Click Reconnect on the SSH target before retrying.'
)
}
return provider
}
})
export const sshFilesystemDispatchModuleMock = () => ({
getSshFilesystemProvider: getSshFilesystemProviderMock
})
export const worktreeSymlinksModuleMock = (): {
createWorktreeCopiedPaths: ModuleMock
createWorktreeLinkedPaths: ModuleMock
findExistingWorktreeSymlinkPaths: ModuleMock
removeWorktreeLinkedPaths: ModuleMock
} => ({
createWorktreeCopiedPaths: vi.fn(),
createWorktreeLinkedPaths: vi.fn(),
findExistingWorktreeSymlinkPaths: findExistingWorktreeSymlinkPathsMock,
removeWorktreeLinkedPaths: removeWorktreeLinkedPathsMock
})
export const sshModuleMock = () => ({
getActiveMultiplexer: getActiveMultiplexerMock
})
// Why a second builder: getActiveMultiplexer moved to ../ssh/ssh-target-registry so the
// runtime could reach it without ipcMain. Production imports it from there now, so a
// vi.mock('./ssh') factory alone is inert.
export const sshTargetRegistryModuleMock = () => ({
getActiveMultiplexer: getActiveMultiplexerMock
})
export const hooksModuleMock = () => ({
getEffectiveHooks: getEffectiveHooksMock,
loadHooks: loadHooksMock,
parseOrcaYaml: parseOrcaYamlMock,
runHook: runHookMock,
hasHooksFile: hasHooksFileMock
})
// Why: the runner-script/hook-config surface moved out of hooks.ts, so each home
// module needs its own mock or the worktree handlers run the real implementations.
export const setupRunnerScriptTextModuleMock = (actual: Record<string, unknown>) => ({
...actual,
buildPosixRunnerScript: buildPosixRunnerScriptMock,
buildWindowsRunnerScript: buildWindowsRunnerScriptMock
})
export const worktreeRunnerScriptModuleMock = (actual: Record<string, unknown>) => ({
...actual,
createIssueCommandRunnerScript: createIssueCommandRunnerScriptMock,
createSetupRunnerScript: createSetupRunnerScriptMock,
resolveSetupRunnerShell: resolveSetupRunnerShellMock
})
export const effectiveHookConfigModuleMock = (actual: Record<string, unknown>) => ({
...actual,
getDefaultTabsLaunch: getDefaultTabsLaunchMock,
getEffectiveHooksFromConfig: getEffectiveHooksFromConfigMock,
shouldRunSetupForCreate: shouldRunSetupForCreateMock
})
export const setupHookEnvVarsModuleMock = (actual: Record<string, unknown>) => ({
...actual,
getSetupRunnerEnvVars: getSetupRunnerEnvVarsMock
})
export const worktreeLogicModuleMock = (actual: Record<string, unknown>) => ({
...actual,
computeWorktreePath: computeWorktreePathMock,
ensurePathWithinWorkspace: ensurePathWithinWorkspaceMock
})
export const terminalHistoryDeletionModuleMock = () => ({
deleteWorktreeHistoryDir: deleteWorktreeHistoryDirMock
})
export const advertisedUrlWatcherModuleMock = () => ({
advertisedUrlWatcher: {
forgetWorktree: advertisedUrlWatcherForgetWorktreeMock
}
})
export const workspaceCleanupScanSnapshotModuleMock = () => ({
pruneWorkspaceCleanupScanSnapshot: pruneCleanupScanSnapshotMock,
pruneWorkspaceCleanupScanSnapshots: pruneCleanupScanSnapshotsMock
})
export const workspaceSpaceAnalysisSnapshotModuleMock = () => ({
pruneWorkspaceSpaceAnalysisSnapshot: pruneSpaceAnalysisSnapshotMock,
pruneWorkspaceSpaceAnalysisSnapshots: pruneSpaceAnalysisSnapshotsMock
})
export const workspaceCleanupRemovalSnapshotPruneModuleMock = () => ({
recordWorkspaceCleanupRemovalSnapshotPrune: recordRemovalSnapshotPruneMock
})
export const worktreeTeardownModuleMock = () => ({
killAllProcessesForWorktree: killAllProcessesForWorktreeMock
})
export const ptyModuleMock = () => ({
clearProviderPtyState: clearProviderPtyStateMock,
getLocalPtyProvider: getLocalPtyProviderMock,
getSshPtyProvider: getSshPtyProviderMock
})