From 2a01b416384a7ecdfd10268a4cc4a71a80490f55 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Fri, 10 Jul 2026 20:52:24 -0700 Subject: [PATCH] perf(main): make worktree path dedupe linear (#8177) --- config/tsconfig.tc.web.json | 1 + src/main/ipc/worktree-logic.ts | 61 +--------- src/main/ipc/worktree-path-comparison.ts | 108 ++++++++++++++++++ .../ipc/worktree-path-deduplication.test.ts | 59 ++++++++++ src/main/ipc/worktrees.ts | 16 +-- 5 files changed, 175 insertions(+), 70 deletions(-) create mode 100644 src/main/ipc/worktree-path-comparison.ts create mode 100644 src/main/ipc/worktree-path-deduplication.test.ts diff --git a/config/tsconfig.tc.web.json b/config/tsconfig.tc.web.json index 9f71e6e79..f4fb21952 100644 --- a/config/tsconfig.tc.web.json +++ b/config/tsconfig.tc.web.json @@ -10,6 +10,7 @@ "../src/main/ipc/worktree-logic.ts", "../src/main/ipc/worktree-linked-work-item-metadata.ts", "../src/main/ipc/worktree-metadata-merge.ts", + "../src/main/ipc/worktree-path-comparison.ts", "../src/main/wsl.ts" ], "compilerOptions": { diff --git a/src/main/ipc/worktree-logic.ts b/src/main/ipc/worktree-logic.ts index 503c830a9..1f52f2fff 100644 --- a/src/main/ipc/worktree-logic.ts +++ b/src/main/ipc/worktree-logic.ts @@ -1,6 +1,6 @@ import { resolve, relative, isAbsolute, posix, sep, win32 } from 'node:path' import type { GlobalSettings, OrcaWorkspaceLayout, Repo } from '../../shared/types' -import { resolveRuntimePath } from '../../shared/cross-platform-path' +import { isWindowsAbsolutePathLike, resolveRuntimePath } from '../../shared/cross-platform-path' import { isWslUncPath } from '../../shared/wsl-paths' import { splitWorktreeId } from '../../shared/worktree-id' import { getWslHome, parseWslPath } from '../wsl' @@ -10,6 +10,7 @@ type WorktreeBasePathRepo = Pick export { computeBranchName, getConfiguredBranchPrefix } from './worktree-branch-name' export { mergeWorktree } from './worktree-metadata-merge' +export { areWorktreePathsEqual } from './worktree-path-comparison' /** * Sanitize a worktree name for use in branch names and directory paths. @@ -153,65 +154,13 @@ export function hasRepoWorktreeBasePath(repo: Pick): b return getRepoWorktreeBasePath(repo) !== undefined } -export function areWorktreePathsEqual( - leftPath: string, - rightPath: string, - platform = process.platform -): boolean { - if (looksLikePosixAbsolutePath(leftPath) || looksLikePosixAbsolutePath(rightPath)) { - // Why: local WSL projects run POSIX paths on a Windows desktop; comparing - // them with win32 rules can delete or dedupe the wrong runtime-owned path. - if (!looksLikePosixAbsolutePath(leftPath) || !looksLikePosixAbsolutePath(rightPath)) { - return false - } - const left = normalizePosixWorktreePathForComparison(leftPath, platform) - const right = normalizePosixWorktreePathForComparison(rightPath, platform) - return left === right - } - - if (platform === 'win32' || looksLikeWindowsPath(leftPath) || looksLikeWindowsPath(rightPath)) { - const left = win32.normalize(win32.resolve(leftPath)) - const right = win32.normalize(win32.resolve(rightPath)) - // Why: `git worktree list` can report the same Windows path with different - // slash styles or drive-letter casing than the path we computed before - // creation. Orca must treat those as the same worktree or a successful - // create spuriously fails until the next full reload repopulates state. - return left.toLowerCase() === right.toLowerCase() - } - const left = normalizePosixWorktreePathForComparison(leftPath, platform) - const right = normalizePosixWorktreePathForComparison(rightPath, platform) - return left === right -} - -function looksLikeWindowsPath(pathValue: string): boolean { - return ( - /^[A-Za-z]:[\\/]/.test(pathValue) || pathValue.startsWith('\\\\') || pathValue.startsWith('//') - ) -} - -function looksLikePosixAbsolutePath(pathValue: string): boolean { - return pathValue.startsWith('/') && !pathValue.startsWith('//') -} - -function normalizePosixWorktreePathForComparison( - pathValue: string, - platform: NodeJS.Platform -): string { - const normalized = posix.normalize(posix.resolve(pathValue)) - if (platform !== 'darwin') { - return normalized - } - if (normalized === '/private/tmp') { - return '/tmp' - } - return normalized.startsWith('/private/tmp/') ? normalized.slice('/private'.length) : normalized -} - function getRuntimePathOps( repoPath: string, workspaceDir: string ): Pick { - return looksLikeWindowsPath(repoPath) || looksLikeWindowsPath(workspaceDir) ? win32 : posix + return isWindowsAbsolutePathLike(repoPath) || isWindowsAbsolutePathLike(workspaceDir) + ? win32 + : posix } function resolveWorkspaceDirForRepo(repoPath: string, workspaceDir: string): string { diff --git a/src/main/ipc/worktree-path-comparison.ts b/src/main/ipc/worktree-path-comparison.ts new file mode 100644 index 000000000..38bb0aef7 --- /dev/null +++ b/src/main/ipc/worktree-path-comparison.ts @@ -0,0 +1,108 @@ +import { posix, win32 } from 'node:path' +import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' + +export function areWorktreePathsEqual( + leftPath: string, + rightPath: string, + platform = process.platform +): boolean { + if (looksLikePosixAbsolutePath(leftPath) || looksLikePosixAbsolutePath(rightPath)) { + // Why: local WSL projects run POSIX paths on a Windows desktop; comparing + // them with win32 rules can delete or dedupe the wrong runtime-owned path. + if (!looksLikePosixAbsolutePath(leftPath) || !looksLikePosixAbsolutePath(rightPath)) { + return false + } + const left = normalizePosixWorktreePathForComparison(leftPath, platform) + const right = normalizePosixWorktreePathForComparison(rightPath, platform) + return left === right + } + + if ( + platform === 'win32' || + isWindowsAbsolutePathLike(leftPath) || + isWindowsAbsolutePathLike(rightPath) + ) { + const left = normalizeWindowsWorktreePathForComparison(leftPath) + const right = normalizeWindowsWorktreePathForComparison(rightPath) + // Why: Git can report the same Windows path with different slash styles or + // drive-letter casing; treating them as distinct creates duplicate worktrees. + return left === right + } + const left = normalizePosixWorktreePathForComparison(leftPath, platform) + const right = normalizePosixWorktreePathForComparison(rightPath, platform) + return left === right +} + +export function dedupeWorktreesByPath( + worktrees: readonly T[], + platform = process.platform +): T[] { + // Why: large Git/relay listings should normalize each path once while still + // preserving the first row under the cross-platform equality contract above. + const unique: T[] = [] + const posixAbsoluteKeys = new Set() + const windowsKeys = new Set() + const windowsPaths: string[] = [] + const relativePaths: string[] = [] + + for (const worktree of worktrees) { + const pathValue = worktree.path + if (looksLikePosixAbsolutePath(pathValue)) { + const key = normalizePosixWorktreePathForComparison(pathValue, platform) + if (posixAbsoluteKeys.has(key)) { + continue + } + posixAbsoluteKeys.add(key) + unique.push(worktree) + continue + } + + const windowsKey = normalizeWindowsWorktreePathForComparison(pathValue) + if (platform === 'win32' || isWindowsAbsolutePathLike(pathValue)) { + if ( + windowsKeys.has(windowsKey) || + relativePaths.some((existing) => areWorktreePathsEqual(existing, pathValue, platform)) + ) { + continue + } + windowsKeys.add(windowsKey) + windowsPaths.push(pathValue) + unique.push(worktree) + continue + } + + // Why: Git normally reports absolute paths. Retain pair-aware comparison + // only for malformed/legacy relative rows whose flavor depends on its peer. + if ( + relativePaths.some((existing) => areWorktreePathsEqual(existing, pathValue, platform)) || + windowsPaths.some((existing) => areWorktreePathsEqual(existing, pathValue, platform)) + ) { + continue + } + relativePaths.push(pathValue) + unique.push(worktree) + } + return unique +} + +function looksLikePosixAbsolutePath(pathValue: string): boolean { + return pathValue.startsWith('/') && !pathValue.startsWith('//') +} + +function normalizeWindowsWorktreePathForComparison(pathValue: string): string { + return win32.normalize(win32.resolve(pathValue)).toLowerCase() +} + +function normalizePosixWorktreePathForComparison( + pathValue: string, + platform: NodeJS.Platform +): string { + const normalized = posix.normalize(posix.resolve(pathValue)) + if (platform !== 'darwin') { + return normalized + } + if (normalized === '/private/tmp') { + return '/tmp' + } + return normalized.startsWith('/private/tmp/') ? normalized.slice('/private'.length) : normalized +} diff --git a/src/main/ipc/worktree-path-deduplication.test.ts b/src/main/ipc/worktree-path-deduplication.test.ts new file mode 100644 index 000000000..70ebbbc40 --- /dev/null +++ b/src/main/ipc/worktree-path-deduplication.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import { areWorktreePathsEqual, dedupeWorktreesByPath } from './worktree-path-comparison' + +function dedupeWithComparator( + worktrees: readonly T[], + platform: NodeJS.Platform +): T[] { + const unique: T[] = [] + for (const worktree of worktrees) { + if (!unique.some((existing) => areWorktreePathsEqual(existing.path, worktree.path, platform))) { + unique.push(worktree) + } + } + return unique +} + +describe('dedupeWorktreesByPath', () => { + it.each(['darwin', 'linux', 'win32'])( + 'preserves the comparator result on %s', + (platform) => { + const worktrees = [ + { id: 'posix-upper', path: '/home/dev/Repo' }, + { id: 'posix-lower', path: '/home/dev/repo' }, + { id: 'windows-first', path: 'C:\\Workspaces\\Feature' }, + { id: 'windows-duplicate', path: 'c:/workspaces/feature' }, + { id: 'unc-first', path: '\\\\Server\\Share\\Feature' }, + { id: 'unc-duplicate', path: '//server/share/feature' }, + { id: 'temp-private', path: '/private/tmp/orca/feature' }, + { id: 'temp-short', path: '/tmp/orca/feature' }, + { id: 'relative-first', path: 'workspaces/feature' }, + { id: 'relative-duplicate', path: 'workspaces/./feature' } + ] + + for (let offset = 0; offset < worktrees.length; offset += 1) { + const rotated = [...worktrees.slice(offset), ...worktrees.slice(0, offset)] + expect(dedupeWorktreesByPath(rotated, platform)).toEqual( + dedupeWithComparator(rotated, platform) + ) + const reversed = rotated.toReversed() + expect(dedupeWorktreesByPath(reversed, platform)).toEqual( + dedupeWithComparator(reversed, platform) + ) + } + } + ) + + it('reads each path once for a large unique list', () => { + let pathReads = 0 + const worktrees = Array.from({ length: 1_000 }, (_, index) => ({ + get path(): string { + pathReads += 1 + return `/workspaces/feature-${index}` + } + })) + + expect(dedupeWorktreesByPath(worktrees)).toHaveLength(1_000) + expect(pathReads).toBe(1_000) + }) +}) diff --git a/src/main/ipc/worktrees.ts b/src/main/ipc/worktrees.ts index 67468a78b..92e626b2e 100644 --- a/src/main/ipc/worktrees.ts +++ b/src/main/ipc/worktrees.ts @@ -72,6 +72,7 @@ import { isOrphanCompatiblePreflightError, isOrphanedWorktreeError } from './worktree-logic' +import { dedupeWorktreesByPath } from './worktree-path-comparison' import { joinWorktreeRelativePath } from '../runtime/runtime-relative-paths' import { createLocalWorktree, @@ -208,19 +209,6 @@ async function closeLocalWatcherForRemoval(worktreePath: string): Promise }) } -function dedupeGitWorktreesByPath(gitWorktrees: GitWorktreeInfo[]): GitWorktreeInfo[] { - const uniqueGitWorktrees: GitWorktreeInfo[] = [] - for (const gitWorktree of gitWorktrees) { - if ( - uniqueGitWorktrees.some((existing) => areWorktreePathsEqual(existing.path, gitWorktree.path)) - ) { - continue - } - uniqueGitWorktrees.push(gitWorktree) - } - return uniqueGitWorktrees -} - function getProjectHostSetupMetaUpdates( store: Store, repo: Repo, @@ -718,7 +706,7 @@ function buildDetectedGitWorktrees( const settings = store.getSettings() const knownOrcaLayouts = buildKnownOrcaWorkspaceLayouts(settings, repo) const isLegacyRepoForVisibility = isLegacyRepoForExternalWorktreeVisibility(repo) - return dedupeGitWorktreesByPath(gitWorktrees).map((gitWorktree) => { + return dedupeWorktreesByPath(gitWorktrees).map((gitWorktree) => { const worktreeId = `${repo.id}::${gitWorktree.path}` let meta = store.getWorktreeMeta(worktreeId) const worktree = mergeWorktree(repo.id, gitWorktree, meta, repo.displayName)