perf(main): make worktree path dedupe linear (#8177)
This commit is contained in:
parent
435de94102
commit
2a01b41638
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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<Repo, 'path' | 'worktreeBasePath'>
|
|||
|
||||
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<Repo, 'worktreeBasePath'>): 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<typeof posix, 'basename' | 'isAbsolute' | 'join' | 'normalize'> {
|
||||
return looksLikeWindowsPath(repoPath) || looksLikeWindowsPath(workspaceDir) ? win32 : posix
|
||||
return isWindowsAbsolutePathLike(repoPath) || isWindowsAbsolutePathLike(workspaceDir)
|
||||
? win32
|
||||
: posix
|
||||
}
|
||||
|
||||
function resolveWorkspaceDirForRepo(repoPath: string, workspaceDir: string): string {
|
||||
|
|
|
|||
|
|
@ -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<T extends { path: string }>(
|
||||
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<string>()
|
||||
const windowsKeys = new Set<string>()
|
||||
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
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { areWorktreePathsEqual, dedupeWorktreesByPath } from './worktree-path-comparison'
|
||||
|
||||
function dedupeWithComparator<T extends { path: string }>(
|
||||
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<NodeJS.Platform>(['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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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<void>
|
|||
})
|
||||
}
|
||||
|
||||
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)
|
||||
|
|
|
|||
Loading…
Reference in New Issue