From f47337d852b44ee99397d4540d69466f0037dd47 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:02:29 -0700 Subject: [PATCH] refactor(worktree): one worktree path equality; the ipc copy folded WSL ext4 tails (#30227) src/main/ipc/worktree-path-comparison.ts kept its own areWorktreePathsEqual and dedupeWorktreesByPath. Its Windows branch lowercased a WSL UNC path's ext4 tail, so two case-twin WSL checkouts merged into one row in removal safety, folder-upgrade dedupe and the SSH listing fallback. Every caller now uses git/worktree-path-comparison, which gains the macOS /private/tmp fold the ipc copy had. worktreePathComparisonKey keeps its exact bytes (retirement namespace keys persist it) in worktree-path-comparison-key.ts, and the worktree-logic re-export goes. --- config/scripts/ci-shard-timings.json | 2 +- config/tsconfig.tc.web.json | 1 - .../scanner-attribution-memo.test.ts | 6 +- src/main/folder-upgrade-worktree-path.ts | 2 +- src/main/git/worktree-path-comparison.ts | 73 ++++++++++- .../worktree-path-deduplication.test.ts | 33 ++++- .../ipc/created-worktree-reconciliation.ts | 2 +- src/main/ipc/worktree-logic.test.ts | 4 +- src/main/ipc/worktree-logic.ts | 1 - src/main/ipc/worktree-path-comparison.ts | 118 ------------------ ...rktree-symlink-reconciliation.real.test.ts | 2 +- .../detected-worktree-classification.test.ts | 2 +- .../listing/ssh-worktree-fallback.ts | 4 +- .../register-worktree-removal-handlers.ts | 3 +- src/main/local-worktree-removal-recovery.ts | 3 +- src/main/nested-worktree-removal-plan.ts | 2 +- src/main/repo-worktrees.ts | 2 +- .../imported-values.spec.ts | 2 +- .../runtime/repo-worktree-row-resolution.ts | 3 +- .../runtime/runtime-worktree-summary-paths.ts | 2 +- .../runtime/workspaces/workspace-selectors.ts | 3 +- .../usage/usage-worktree-resolver.test.ts | 6 +- src/main/usage/usage-worktree-resolver.ts | 2 +- src/main/worktree-lineage-host-peers.ts | 2 +- src/main/worktree-name-retirement.ts | 2 +- src/main/worktree-path-comparison-key.test.ts | 23 ++++ src/main/worktree-path-comparison-key.ts | 15 +++ src/main/worktree-removal-safety.ts | 2 +- .../worktree-retention-path-comparison.ts | 2 +- src/main/worktree-retirement-namespace.ts | 2 +- ...paired-external-worktree-discovery.spec.ts | 2 +- 31 files changed, 171 insertions(+), 157 deletions(-) rename src/main/{ipc => git}/worktree-path-deduplication.test.ts (60%) delete mode 100644 src/main/ipc/worktree-path-comparison.ts create mode 100644 src/main/worktree-path-comparison-key.test.ts create mode 100644 src/main/worktree-path-comparison-key.ts diff --git a/config/scripts/ci-shard-timings.json b/config/scripts/ci-shard-timings.json index b92157a4706..39f78de6adb 100644 --- a/config/scripts/ci-shard-timings.json +++ b/config/scripts/ci-shard-timings.json @@ -2959,7 +2959,7 @@ "src/main/ipc/worktree-logic-wsl.test.ts": 95, "src/main/ipc/worktree-logic.test.ts": 203, "src/main/ipc/worktree-metadata-merge.test.ts": 42, - "src/main/ipc/worktree-path-deduplication.test.ts": 63, + "src/main/git/worktree-path-deduplication.test.ts": 63, "src/main/ipc/worktree-push-target-cleanup.test.ts": 639, "src/main/ipc/worktree-push-target-reconciliation-real-git.test.ts": 950, "src/main/ipc/worktree-push-target-reconciliation.test.ts": 600, diff --git a/config/tsconfig.tc.web.json b/config/tsconfig.tc.web.json index f2b6d4c2517..7583b8811a3 100644 --- a/config/tsconfig.tc.web.json +++ b/config/tsconfig.tc.web.json @@ -39,7 +39,6 @@ "../src/main/ipc/worktree-display-name.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-availability.ts", "../src/main/wsl-directory-probe-command.ts", "../src/main/wsl-distro-list-output.ts", diff --git a/src/main/codex-usage/scanner-attribution-memo.test.ts b/src/main/codex-usage/scanner-attribution-memo.test.ts index 18a8326a981..ead2cb5c30c 100644 --- a/src/main/codex-usage/scanner-attribution-memo.test.ts +++ b/src/main/codex-usage/scanner-attribution-memo.test.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import type * as NodeOs from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, expect, it, vi } from 'vitest' -import type * as WorktreeLogic from '../ipc/worktree-logic' +import type * as WorktreePathComparison from '../git/worktree-path-comparison' const { getPathMock, homedirMock, worktreePathComparisons } = vi.hoisted(() => ({ getPathMock: vi.fn<(name: string) => string>(), @@ -25,8 +25,8 @@ vi.mock('node:os', async () => { } }) -vi.mock('../ipc/worktree-logic', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('../git/worktree-path-comparison', async (importOriginal) => { + const actual = await importOriginal() return { ...actual, areWorktreePathsEqual: (left: string, right: string) => { diff --git a/src/main/folder-upgrade-worktree-path.ts b/src/main/folder-upgrade-worktree-path.ts index eb70bd65031..5d96034e62b 100644 --- a/src/main/folder-upgrade-worktree-path.ts +++ b/src/main/folder-upgrade-worktree-path.ts @@ -2,7 +2,7 @@ import { realpathSync } from 'node:fs' import type { Repo } from '../shared/repo-types' import type { GitWorktreeInfo } from '../shared/worktree/types' import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../shared/execution-host' -import { areWorktreePathsEqual, dedupeWorktreesByPath } from './ipc/worktree-path-comparison' +import { areWorktreePathsEqual, dedupeWorktreesByPath } from './git/worktree-path-comparison' function stillNamesRegisteredCheckout(repo: Repo, gitRoot: string): boolean { if (areWorktreePathsEqual(repo.path, gitRoot)) { diff --git a/src/main/git/worktree-path-comparison.ts b/src/main/git/worktree-path-comparison.ts index 2bc65098c3f..8da0a9547f1 100644 --- a/src/main/git/worktree-path-comparison.ts +++ b/src/main/git/worktree-path-comparison.ts @@ -1,4 +1,5 @@ import { posix, win32 } from 'node:path' +import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' import { foldWslUncPathCaseInsensitiveParts } from '../../shared/wsl-paths' import type { GitWorktreeExecOptions } from './worktree-operation-options' import { translateWslOutputPaths } from './runner' @@ -17,12 +18,11 @@ import { translateWslOutputPaths } from './runner' * as `\\wsl.localhost\Ubuntu\home\alice\Feature` and a plain `toLowerCase` folded the ext4 tail. * `foldWslUncPathCaseInsensitiveParts` already draws that line — Windows folds the share, the distro * and a drvfs `/mnt/` tail, and nothing else — and `git-fetch-head-lock` already relies on - * it. `isSameCommonDirPath` and `ipc/worktree-path-comparison` carry local copies of the POSIX half; - * this is both halves at the source. + * it. `isSameCommonDirPath` carries a local copy of the POSIX half; this is both halves at the source. */ export function canonicalWorktreePath(pathValue: string, platform = process.platform): string { if (looksLikePosixAbsolutePath(pathValue)) { - return posix.normalize(posix.resolve(pathValue)) + return canonicalPosixPath(pathValue, platform) } const wslKey = wslUncComparisonKey(pathValue) if (wslKey) { @@ -30,7 +30,19 @@ export function canonicalWorktreePath(pathValue: string, platform = process.plat } return platform === 'win32' || looksLikeWindowsPath(pathValue) ? win32.normalize(win32.resolve(pathValue)).toLowerCase() - : posix.normalize(posix.resolve(pathValue)) + : canonicalPosixPath(pathValue, platform) +} + +// Why: macOS git reports a `/tmp` checkout as `/private/tmp`, the same directory. +function canonicalPosixPath(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 } /** @@ -45,7 +57,11 @@ function wslUncComparisonKey(pathValue: string): string | null { if (!folded) { return null } - return foldWslUncPathCaseInsensitiveParts(win32.normalize(pathValue)) ?? folded + // A trailing separator names the same directory, as `resolve` already treats it elsewhere. + return (foldWslUncPathCaseInsensitiveParts(win32.normalize(pathValue)) ?? folded).replace( + /\/+$/, + '' + ) } export function areWorktreePathsEqual( @@ -63,12 +79,57 @@ export function areWorktreePathsEqual( canonicalWorktreePath(leftPath, platform) === canonicalWorktreePath(rightPath, platform) ) } - if (platform === 'win32' || looksLikeWindowsPath(leftPath) || looksLikeWindowsPath(rightPath)) { + if ( + platform === 'win32' || + isWindowsAbsolutePathLike(leftPath) || + isWindowsAbsolutePathLike(rightPath) + ) { return canonicalWorktreePath(leftPath, 'win32') === canonicalWorktreePath(rightPath, 'win32') } return canonicalWorktreePath(leftPath, platform) === canonicalWorktreePath(rightPath, platform) } +export function dedupeWorktreesByPath( + worktrees: readonly T[], + platform = process.platform +): T[] { + // Why: large Git/relay listings key each absolute path once, keeping the first row under + // `areWorktreePathsEqual`; only relative rows, whose flavor depends on the peer, compare pairwise. + const unique: T[] = [] + const posixKeys = new Set() + const windowsKeys = new Set() + const windowsPaths: string[] = [] + const relativePaths: string[] = [] + const matchesAny = (paths: string[], pathValue: string): boolean => + paths.some((existing) => areWorktreePathsEqual(existing, pathValue, platform)) + + for (const worktree of worktrees) { + const pathValue = worktree.path + if (looksLikePosixAbsolutePath(pathValue)) { + const key = canonicalWorktreePath(pathValue, platform) + if (!posixKeys.has(key)) { + posixKeys.add(key) + unique.push(worktree) + } + continue + } + if (platform === 'win32' || isWindowsAbsolutePathLike(pathValue)) { + const key = canonicalWorktreePath(pathValue, 'win32') + if (!windowsKeys.has(key) && !matchesAny(relativePaths, pathValue)) { + windowsKeys.add(key) + windowsPaths.push(pathValue) + unique.push(worktree) + } + continue + } + if (!matchesAny(relativePaths, pathValue) && !matchesAny(windowsPaths, pathValue)) { + relativePaths.push(pathValue) + unique.push(worktree) + } + } + return unique +} + function looksLikeWindowsPath(pathValue: string): boolean { return /^[A-Za-z]:[\\/]/.test(pathValue) || pathValue.startsWith('\\\\') } diff --git a/src/main/ipc/worktree-path-deduplication.test.ts b/src/main/git/worktree-path-deduplication.test.ts similarity index 60% rename from src/main/ipc/worktree-path-deduplication.test.ts rename to src/main/git/worktree-path-deduplication.test.ts index 70ebbbc4004..fdc5716606a 100644 --- a/src/main/ipc/worktree-path-deduplication.test.ts +++ b/src/main/git/worktree-path-deduplication.test.ts @@ -28,7 +28,10 @@ describe('dedupeWorktreesByPath', () => { { 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' } + { id: 'relative-duplicate', path: 'workspaces/./feature' }, + { id: 'wsl-upper', path: '\\\\wsl.localhost\\Ubuntu\\home\\dev\\Feature' }, + { id: 'wsl-lower', path: '\\\\wsl$\\ubuntu\\home\\dev\\feature' }, + { id: 'wsl-alias', path: '//wsl$/UBUNTU/home/dev/feature' } ] for (let offset = 0; offset < worktrees.length; offset += 1) { @@ -44,6 +47,34 @@ describe('dedupeWorktreesByPath', () => { } ) + // An ext4 tail is case-sensitive: folding it merged two real checkouts into one row. + it.each(['darwin', 'linux', 'win32'])( + 'keeps WSL worktrees that differ only in tail case on %s', + (platform) => { + const ids = dedupeWorktreesByPath( + [ + { id: 'upper', path: '\\\\wsl.localhost\\Ubuntu\\home\\dev\\Feature' }, + { id: 'lower', path: '\\\\wsl$\\ubuntu\\home\\dev\\feature' }, + { id: 'alias', path: '//wsl$/UBUNTU/home/dev/feature' } + ], + platform + ).map((worktree) => worktree.id) + expect(ids).toEqual(['upper', 'lower']) + } + ) + + it('treats a trailing separator on a WSL path as the same worktree', () => { + const ids = dedupeWorktreesByPath([ + { id: 'bare', path: '//wsl$/Ubuntu/home/dev/feature' }, + { id: 'slash', path: '//wsl$/Ubuntu/home/dev/feature/' }, + { id: 'backslash', path: '\\\\wsl.localhost\\Ubuntu\\home\\dev\\feature\\' } + ]).map((worktree) => worktree.id) + expect(ids).toEqual(['bare']) + expect( + areWorktreePathsEqual('//wsl$/Ubuntu/home/dev/feature/', '//wsl$/Ubuntu/home/dev/feature') + ).toBe(true) + }) + it('reads each path once for a large unique list', () => { let pathReads = 0 const worktrees = Array.from({ length: 1_000 }, (_, index) => ({ diff --git a/src/main/ipc/created-worktree-reconciliation.ts b/src/main/ipc/created-worktree-reconciliation.ts index 3369ae6f646..8b1953bbc13 100644 --- a/src/main/ipc/created-worktree-reconciliation.ts +++ b/src/main/ipc/created-worktree-reconciliation.ts @@ -3,7 +3,7 @@ import { describeCreatedWorktree, listWorktreesSharedStrict } from '../git/workt import { WORKTREE_LIST_TIMEOUT_MS } from '../git/worktree-operation-options' import type { GitWorktreeExecOptions } from '../git/worktree' import type { GitWorktreeInfo } from '../../shared/worktree/types' -import { areWorktreePathsEqual } from './worktree-path-comparison' +import { areWorktreePathsEqual } from '../git/worktree-path-comparison' export function findCreatedWorktree( worktrees: readonly T[], diff --git a/src/main/ipc/worktree-logic.test.ts b/src/main/ipc/worktree-logic.test.ts index a502e8023dd..0cab9ede517 100644 --- a/src/main/ipc/worktree-logic.test.ts +++ b/src/main/ipc/worktree-logic.test.ts @@ -21,9 +21,9 @@ import { formatWorktreeRemovalError, isWindowsLongPathWorktreeRemovalError, isOrphanCompatiblePreflightError, - isOrphanedWorktreeError, - areWorktreePathsEqual + isOrphanedWorktreeError } from './worktree-logic' +import { areWorktreePathsEqual } from '../git/worktree-path-comparison' describe('sanitizeWorktreeName', () => { it('replaces spaces with hyphens', () => { diff --git a/src/main/ipc/worktree-logic.ts b/src/main/ipc/worktree-logic.ts index 744572f6e37..e6d371ec698 100644 --- a/src/main/ipc/worktree-logic.ts +++ b/src/main/ipc/worktree-logic.ts @@ -27,7 +27,6 @@ export { computeValidatedBranchName } 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. diff --git a/src/main/ipc/worktree-path-comparison.ts b/src/main/ipc/worktree-path-comparison.ts deleted file mode 100644 index 71b4055752e..00000000000 --- a/src/main/ipc/worktree-path-comparison.ts +++ /dev/null @@ -1,118 +0,0 @@ -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 worktreePathComparisonKey(pathValue: string, platform = process.platform): string { - if (looksLikePosixAbsolutePath(pathValue)) { - return `posix:${normalizePosixWorktreePathForComparison(pathValue, platform)}` - } - if (platform === 'win32' || isWindowsAbsolutePathLike(pathValue)) { - return `windows:${normalizeWindowsWorktreePathForComparison(pathValue)}` - } - return `posix:${normalizePosixWorktreePathForComparison(pathValue, platform)}` -} - -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-symlink-reconciliation.real.test.ts b/src/main/ipc/worktree-symlink-reconciliation.real.test.ts index 1c48934b607..b8c464dbf3f 100644 --- a/src/main/ipc/worktree-symlink-reconciliation.real.test.ts +++ b/src/main/ipc/worktree-symlink-reconciliation.real.test.ts @@ -3,7 +3,7 @@ import { mkdtemp, mkdir, realpath, rm, symlink, writeFile } from 'node:fs/promis import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { findCreatedWorktree } from './created-worktree-reconciliation' -import { areWorktreePathsEqual } from './worktree-path-comparison' +import { areWorktreePathsEqual } from '../git/worktree-path-comparison' type ListedWorktree = { path: string; branch?: string } diff --git a/src/main/ipc/worktrees/listing/detected-worktree-classification.test.ts b/src/main/ipc/worktrees/listing/detected-worktree-classification.test.ts index 6a25d696c9b..ea4cff00d30 100644 --- a/src/main/ipc/worktrees/listing/detected-worktree-classification.test.ts +++ b/src/main/ipc/worktrees/listing/detected-worktree-classification.test.ts @@ -40,7 +40,7 @@ const { createWorktreeVisibilitySourceMatcher, resolveCustomWorktreeVisibilitySo await import('../../../../shared/worktree/visibility-sources') const { resolveConfiguredWorktreeBasePaths } = await import('../../../../shared/worktree/configured-worktree-base-path') -const { dedupeWorktreesByPath } = await import('../../worktree-path-comparison') +const { dedupeWorktreesByPath } = await import('../../../git/worktree-path-comparison') const { readWorktreeMetaForHost } = await import('../../../persistence/host-qualified-worktree-meta') const { getRepoOwnedWorktreeMeta } = await import('../../../worktree-metadata-ownership') diff --git a/src/main/ipc/worktrees/listing/ssh-worktree-fallback.ts b/src/main/ipc/worktrees/listing/ssh-worktree-fallback.ts index b621dba5fbc..acb839fe3a2 100644 --- a/src/main/ipc/worktrees/listing/ssh-worktree-fallback.ts +++ b/src/main/ipc/worktrees/listing/ssh-worktree-fallback.ts @@ -1,6 +1,6 @@ import { preserveFolderUpgradeWorktreePath } from '../../../folder-upgrade-worktree-path' import type { WorktreeMeta } from '../../../../shared/worktree/meta-types' -import { parseWorktreeId, areWorktreePathsEqual, mergeWorktree } from '../../worktree-logic' +import { parseWorktreeId, mergeWorktree } from '../../worktree-logic' import { getRepoIdFromWorktreeId, getWorktreePathBasenameFromId @@ -19,7 +19,7 @@ import { isLegacyRepoForExternalWorktreeVisibility, toDetectedWorktree } from '../../../../shared/worktree/ownership' -import { dedupeWorktreesByPath } from '../../worktree-path-comparison' +import { areWorktreePathsEqual, dedupeWorktreesByPath } from '../../../git/worktree-path-comparison' import { createWorktreeVisibilitySourceMatcher, resolveCustomWorktreeVisibilitySources diff --git a/src/main/ipc/worktrees/removal/register-worktree-removal-handlers.ts b/src/main/ipc/worktrees/removal/register-worktree-removal-handlers.ts index 7a3a43dfe27..9a2323d6277 100644 --- a/src/main/ipc/worktrees/removal/register-worktree-removal-handlers.ts +++ b/src/main/ipc/worktrees/removal/register-worktree-removal-handlers.ts @@ -3,7 +3,8 @@ import { getLocalWorktreeCatalogVersion } from '../../../local-worktree-scan-gen import type { RemoveWorktreeResult } from '../../../../shared/worktree/create-types' import { getRepoExecutionHostId } from '../../../../shared/execution-host' import { withWorktreeSpan } from '../../../observability/instrumentation' -import { areWorktreePathsEqual, parseWorktreeId } from '../../worktree-logic' +import { areWorktreePathsEqual } from '../../../git/worktree-path-comparison' +import { parseWorktreeId } from '../../worktree-logic' import type { RemoveWorktreeArgs } from '../ipc-context-schemas' import type { WorktreeIpcContext } from '../worktree-ipc-context' import { executeWorktreeRemoval } from './execute-worktree-removal' diff --git a/src/main/local-worktree-removal-recovery.ts b/src/main/local-worktree-removal-recovery.ts index 46f1bb15c87..bff0d2d02a7 100644 --- a/src/main/local-worktree-removal-recovery.ts +++ b/src/main/local-worktree-removal-recovery.ts @@ -1,7 +1,8 @@ import type { RemoveWorktreeResult } from '../shared/worktree/create-types' import type { GitWorktreeInfo } from '../shared/worktree/types' import { assertWorktreeUnlockedForRemoval } from '../shared/worktree/removal' -import { areWorktreePathsEqual, formatWorktreeRemovalError } from './ipc/worktree-logic' +import { areWorktreePathsEqual } from './git/worktree-path-comparison' +import { formatWorktreeRemovalError } from './ipc/worktree-logic' import { gitExecFileAsync } from './git/runner' import { listWorktreesStrict, type GitWorktreeExecOptions } from './git/worktree' import { removeLocalWorktreePath } from './local-worktree-filesystem' diff --git a/src/main/nested-worktree-removal-plan.ts b/src/main/nested-worktree-removal-plan.ts index 7c6a079e2ba..03e49288b9a 100644 --- a/src/main/nested-worktree-removal-plan.ts +++ b/src/main/nested-worktree-removal-plan.ts @@ -1,7 +1,7 @@ import type { GitWorktreeInfo } from '../shared/worktree/types' import type { NestedWorktreeRemovalApproval } from '../shared/worktree/nested-removal' import { assertWorktreeUnlockedForRemoval } from '../shared/worktree/removal' -import { areWorktreePathsEqual } from './ipc/worktree-logic' +import { areWorktreePathsEqual } from './git/worktree-path-comparison' import { containsPath, getPathOps, diff --git a/src/main/repo-worktrees.ts b/src/main/repo-worktrees.ts index 01feae9df09..39a3f777f3d 100644 --- a/src/main/repo-worktrees.ts +++ b/src/main/repo-worktrees.ts @@ -10,7 +10,7 @@ import { import { isFolderRepo } from '../shared/repo-kind' import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../shared/execution-host' import { resolveGitRouteForHost } from './providers/execution-host-provider-dispatch' -import { areWorktreePathsEqual } from './ipc/worktree-logic' +import { areWorktreePathsEqual } from './git/worktree-path-comparison' import { WorktreeCatalogUnavailableError } from '../shared/worktree/worktree-catalog-availability' type LocalRepoWorktreeListOptions = { diff --git a/src/main/runtime/orca-runtime-test-mocks/imported-values.spec.ts b/src/main/runtime/orca-runtime-test-mocks/imported-values.spec.ts index a7d1df876ab..b9e8b94dc96 100644 --- a/src/main/runtime/orca-runtime-test-mocks/imported-values.spec.ts +++ b/src/main/runtime/orca-runtime-test-mocks/imported-values.spec.ts @@ -98,7 +98,7 @@ import { unregisterPty as unregisterLocalPtyMemoryRow } from '../../memory/pty-registry' import { inspectPtyProviderProcess } from '../../providers/pty-process-inspection' -import * as worktreePathComparison from '../../ipc/worktree-path-comparison' +import * as worktreePathComparison from '../../worktree-path-comparison-key' import * as localWorktreeFilesystem from '../../local-worktree-filesystem' import { DEFAULT_REPO_BADGE_COLOR, diff --git a/src/main/runtime/repo-worktree-row-resolution.ts b/src/main/runtime/repo-worktree-row-resolution.ts index e0cce0d2ac3..a81672acc37 100644 --- a/src/main/runtime/repo-worktree-row-resolution.ts +++ b/src/main/runtime/repo-worktree-row-resolution.ts @@ -23,7 +23,8 @@ import type { WorktreeMeta } from '../../shared/worktree/meta-types' import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' import type { RuntimeStore } from './runtime-store-contract' -import { areWorktreePathsEqual, mergeWorktree } from '../ipc/worktree-logic' +import { areWorktreePathsEqual } from '../git/worktree-path-comparison' +import { mergeWorktree } from '../ipc/worktree-logic' import { pruneLineageForMissingRepoWorktrees } from '../worktree-lineage-pruning' import { getRepoOwnedWorktreeMeta } from '../worktree-metadata-ownership' import { resolveLocalProjectRuntimesForRepos } from '../project-runtime-git-options' diff --git a/src/main/runtime/runtime-worktree-summary-paths.ts b/src/main/runtime/runtime-worktree-summary-paths.ts index 99a304f6789..0ed1e587d14 100644 --- a/src/main/runtime/runtime-worktree-summary-paths.ts +++ b/src/main/runtime/runtime-worktree-summary-paths.ts @@ -1,6 +1,6 @@ import type { RuntimeWorktreePsSummary } from '../../shared/runtime-types' import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path' -import { worktreePathComparisonKey } from '../ipc/worktree-path-comparison' +import { worktreePathComparisonKey } from '../worktree-path-comparison-key' import { parseRuntimeWorktreeId } from './runtime-worktree-path-identity' type ResolvedWorktreePath = { id: string; repoId: string; path: string } diff --git a/src/main/runtime/workspaces/workspace-selectors.ts b/src/main/runtime/workspaces/workspace-selectors.ts index 67def6fad2b..bd51fe90a81 100644 --- a/src/main/runtime/workspaces/workspace-selectors.ts +++ b/src/main/runtime/workspaces/workspace-selectors.ts @@ -14,7 +14,8 @@ import type { WorkspaceKey } from '../../../shared/folder-workspace-types' import type { Repo } from '../../../shared/repo-types' import type { RuntimeStore } from '../runtime-store-contract' import type { RuntimeWorkspaceCatalog } from './workspace-catalog' -import { areWorktreePathsEqual, mergeWorktree } from '../../ipc/worktree-logic' +import { areWorktreePathsEqual } from '../../git/worktree-path-comparison' +import { mergeWorktree } from '../../ipc/worktree-logic' export abstract class RuntimeWorkspaceSelectors extends OrcaRuntimeWithResolveBrowserNetworkExecutionHostForWorktree { protected abstract hasFreshResolvedWorktreeCache( diff --git a/src/main/usage/usage-worktree-resolver.test.ts b/src/main/usage/usage-worktree-resolver.test.ts index f53cb74c2af..e1a9cd2e378 100644 --- a/src/main/usage/usage-worktree-resolver.test.ts +++ b/src/main/usage/usage-worktree-resolver.test.ts @@ -1,13 +1,13 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import type * as WorktreeLogic from '../ipc/worktree-logic' +import type * as WorktreePathComparison from '../git/worktree-path-comparison' import type { UsageScanWorktreeRef } from './usage-provider-contract' const { worktreePathComparisons } = vi.hoisted(() => ({ worktreePathComparisons: { count: 0 } })) -vi.mock('../ipc/worktree-logic', async (importOriginal) => { - const actual = await importOriginal() +vi.mock('../git/worktree-path-comparison', async (importOriginal) => { + const actual = await importOriginal() return { ...actual, areWorktreePathsEqual: (left: string, right: string) => { diff --git a/src/main/usage/usage-worktree-resolver.ts b/src/main/usage/usage-worktree-resolver.ts index 1ae49f2a889..c7823e28655 100644 --- a/src/main/usage/usage-worktree-resolver.ts +++ b/src/main/usage/usage-worktree-resolver.ts @@ -1,6 +1,6 @@ import { realpath } from 'node:fs/promises' import { posix, win32 } from 'node:path' -import { areWorktreePathsEqual } from '../ipc/worktree-logic' +import { areWorktreePathsEqual } from '../git/worktree-path-comparison' import { canonicalizeUsageWorktreePaths } from '../usage-worktree-canonicalizer' import { looksLikeWindowsPath, normalizeFsPath } from './usage-path-comparison' import type { UsageScanWorktreeRef } from './usage-provider-contract' diff --git a/src/main/worktree-lineage-host-peers.ts b/src/main/worktree-lineage-host-peers.ts index b001b9d23d8..a0463f04f60 100644 --- a/src/main/worktree-lineage-host-peers.ts +++ b/src/main/worktree-lineage-host-peers.ts @@ -8,7 +8,7 @@ import { projectResolvedWorktreeLineage } from '../shared/resolved-worktree-lineage' import { readAllWorktreeMetaForHost } from './persistence/host-qualified-worktree-meta' -import { areWorktreePathsEqual } from './ipc/worktree-path-comparison' +import { areWorktreePathsEqual } from './git/worktree-path-comparison' import { mergeWorktree } from './ipc/worktree-metadata-merge' import { resolveRepoOwnershipEvidence } from './ipc/worktrees/listing/worktree-host-ownership' import { diff --git a/src/main/worktree-name-retirement.ts b/src/main/worktree-name-retirement.ts index 99a6fdd559c..7e88baa0d88 100644 --- a/src/main/worktree-name-retirement.ts +++ b/src/main/worktree-name-retirement.ts @@ -20,7 +20,7 @@ import { getWorktreePathSettings, hasRepoWorktreeBasePath } from './ipc/worktree-logic' -import { worktreePathComparisonKey } from './ipc/worktree-path-comparison' +import { worktreePathComparisonKey } from './worktree-path-comparison-key' import { retirementHostIdentity, retirementNamespaceKey, diff --git a/src/main/worktree-path-comparison-key.test.ts b/src/main/worktree-path-comparison-key.test.ts new file mode 100644 index 00000000000..2466d83d4d7 --- /dev/null +++ b/src/main/worktree-path-comparison-key.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import { worktreePathComparisonKey } from './worktree-path-comparison-key' + +// Retirement namespace keys persist these bytes; a change orphans saved retired names. +describe('worktreePathComparisonKey bytes', () => { + it.each<[string, NodeJS.Platform, string]>([ + ['/home/dev/Repo/', 'linux', 'posix:/home/dev/Repo'], + ['/home/dev/./a/../Repo', 'win32', 'posix:/home/dev/Repo'], + ['/private/tmp/orca/feature', 'darwin', 'posix:/tmp/orca/feature'], + ['/private/tmp', 'darwin', 'posix:/tmp'], + ['/private/tmp/orca/feature', 'linux', 'posix:/private/tmp/orca/feature'], + ['C:/Workspaces/Feature', 'darwin', 'windows:c:\\workspaces\\feature'], + ['\\\\Server\\Share\\Feature', 'linux', 'windows:\\\\server\\share\\feature'], + ['//server/share/Feature', 'darwin', 'windows:\\\\server\\share\\feature'], + [ + '\\\\wsl.localhost\\Ubuntu\\home\\dev\\Feature', + 'win32', + 'windows:\\\\wsl.localhost\\ubuntu\\home\\dev\\feature' + ] + ])('keys %s on %s', (pathValue, platform, expected) => { + expect(worktreePathComparisonKey(pathValue, platform)).toBe(expected) + }) +}) diff --git a/src/main/worktree-path-comparison-key.ts b/src/main/worktree-path-comparison-key.ts new file mode 100644 index 00000000000..6b76ac3cf73 --- /dev/null +++ b/src/main/worktree-path-comparison-key.ts @@ -0,0 +1,15 @@ +import { win32 } from 'node:path' +import { isWindowsAbsolutePathLike } from '../shared/cross-platform-path' +import { canonicalWorktreePath } from './git/worktree-path-comparison' + +/** + * Bytes frozen: retirement namespace keys persist this, so a changed fold would orphan saved + * retired names. That is why a WSL UNC tail still folds here; equality uses `areWorktreePathsEqual`. + */ +export function worktreePathComparisonKey(pathValue: string, platform = process.platform): string { + const isPosixAbsolute = pathValue.startsWith('/') && !pathValue.startsWith('//') + if (!isPosixAbsolute && (platform === 'win32' || isWindowsAbsolutePathLike(pathValue))) { + return `windows:${win32.normalize(win32.resolve(pathValue)).toLowerCase()}` + } + return `posix:${canonicalWorktreePath(pathValue, platform)}` +} diff --git a/src/main/worktree-removal-safety.ts b/src/main/worktree-removal-safety.ts index 97af3a8893a..3eb9ef093c9 100644 --- a/src/main/worktree-removal-safety.ts +++ b/src/main/worktree-removal-safety.ts @@ -2,7 +2,7 @@ import { lstat, readFile } from 'node:fs/promises' import type { WorktreeMeta } from '../shared/worktree/meta-types' import type { GitWorktreeInfo } from '../shared/worktree/types' import { NESTED_WORKTREE_REMOVAL_PREFIX } from '../shared/worktree/nested-removal' -import { areWorktreePathsEqual } from './ipc/worktree-logic' +import { areWorktreePathsEqual } from './git/worktree-path-comparison' import { containsPath, getPathOps, diff --git a/src/main/worktree-retention-path-comparison.ts b/src/main/worktree-retention-path-comparison.ts index 2ff3fd02990..76dc0ba5bbb 100644 --- a/src/main/worktree-retention-path-comparison.ts +++ b/src/main/worktree-retention-path-comparison.ts @@ -1,5 +1,5 @@ import { splitWorktreeId } from '../shared/worktree/id' -import { worktreePathComparisonKey } from './ipc/worktree-path-comparison' +import { worktreePathComparisonKey } from './worktree-path-comparison-key' /** Comparison keys used only to retain state; canonical equivalence must never select a delete target. */ export function worktreeRetentionPathComparisonKey( diff --git a/src/main/worktree-retirement-namespace.ts b/src/main/worktree-retirement-namespace.ts index 7f45ed72e92..08b011e9339 100644 --- a/src/main/worktree-retirement-namespace.ts +++ b/src/main/worktree-retirement-namespace.ts @@ -4,7 +4,7 @@ import { type RetiredNameRegistry } from '../shared/worktree/retired-name-registry' import type { Repo } from '../shared/repo-types' -import { worktreePathComparisonKey } from './ipc/worktree-path-comparison' +import { worktreePathComparisonKey } from './worktree-path-comparison-key' import { sshEndpointKey, type SshIdentityFields } from './ssh/ssh-target-identity' /** diff --git a/tests/e2e/paired-external-worktree-discovery.spec.ts b/tests/e2e/paired-external-worktree-discovery.spec.ts index 6e188ae517e..dc99668509a 100644 --- a/tests/e2e/paired-external-worktree-discovery.spec.ts +++ b/tests/e2e/paired-external-worktree-discovery.spec.ts @@ -3,7 +3,7 @@ import path from 'node:path' import type { Page } from '@stablyai/playwright-test' import { gitExecFileAsync } from '../../src/main/git/runner' import { listWorktreesStrict } from '../../src/main/git/worktree' -import { areWorktreePathsEqual } from '../../src/main/ipc/worktree-path-comparison' +import { areWorktreePathsEqual } from '../../src/main/git/worktree-path-comparison' import { expect, test } from './helpers/orca-app' import { createRuntimeDesktopPairingOffer,