From d887405adda220df31eaa27a09ef2c419c969deb Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:16:07 -0700 Subject: [PATCH] fix(wsl): refuse a drive/UNC conversion that would split a nested duplicate project (#36155) * fix(wsl): refuse a drive/UNC conversion that would split a nested duplicate project A refused duplicate inside a converting folder had its path rewritten while its ids stayed, so its terminal history and sessions no longer matched. Another spelling of the same folder still converts with it. Co-Authored-By: Claude * fix(wsl): refuse only duplicates inside a converting folder, as an overlap Review: an enclosing duplicate never splits, the same-spelling exemption keys on the main folder, the blocker reads as an overlap instead of naming the clicked row, and the scan is skipped when nothing is a duplicate. Co-Authored-By: Claude --------- Co-authored-by: Claude --- .../wsl/wsl-drive-unc-conversion-run.test.ts | 36 ++++++++++++++++ src/main/wsl/wsl-drive-unc-conversion-run.ts | 32 ++++++++++---- src/main/wsl/wsl-drive-unc-project-scope.ts | 42 ++++++++++++++++++- 3 files changed, 101 insertions(+), 9 deletions(-) diff --git a/src/main/wsl/wsl-drive-unc-conversion-run.test.ts b/src/main/wsl/wsl-drive-unc-conversion-run.test.ts index 73d961a6bd3..5998bf73436 100644 --- a/src/main/wsl/wsl-drive-unc-conversion-run.test.ts +++ b/src/main/wsl/wsl-drive-unc-conversion-run.test.ts @@ -137,6 +137,42 @@ describe('startWslDriveUncConversion', () => { } ) + /** A Windows project and the UNC spelling of its folder, which the scope refuses as a duplicate. */ + const withDuplicate = (windowsPath: string, duplicate: string): PersistedState => { + const value = state() + value.repos.push( + { id: 'windows', path: windowsPath, displayName: 'win', badgeColor: '', addedAt: 0 }, + { id: 'repo-dup', path: duplicate, displayName: 'dup', badgeColor: '', addedAt: 0 } + ) + return value + } + + it('refuses when a duplicate inside the folder would move without its ids', async () => { + const value = withDuplicate(String.raw`C:\src\app\sub`, `${UNC}\\sub`) + const run = deps({ readProfileState: async () => value }) + expect(await startWslDriveUncConversion(run, REQUEST)).toMatchObject({ + blockers: [{ kind: 'not-convertible', id: 'repo-dup', reason: 'overlaps-refused-project' }] + }) + expect(run.pause).not.toHaveBeenCalled() + }) + + it.each([ + { + shape: 'another spelling of the folder', + windowsPath: String.raw`C:\src\app\sub`, + duplicate: String.raw`\\wsl$\Ubuntu\mnt\c\src\app` + }, + { + shape: 'a folder around it', + windowsPath: String.raw`C:\src`, + duplicate: String.raw`\\wsl$\Ubuntu\mnt\c\src` + } + ])('converts beside a duplicate of $shape', async ({ windowsPath, duplicate }) => { + const value = withDuplicate(windowsPath, duplicate) + const run = deps({ readProfileState: async () => value }) + expect(await startWslDriveUncConversion(run, REQUEST)).toMatchObject({ outcome: 'restarting' }) + }) + it('refuses, pausing nothing, when the distro does not show /mnt/c as drive C:', async () => { for (const mounts of [null, [{ mountPoint: '/mnt/c', windowsRoot: null }]]) { const run = deps({ readDriveMounts: async () => mounts }) diff --git a/src/main/wsl/wsl-drive-unc-conversion-run.ts b/src/main/wsl/wsl-drive-unc-conversion-run.ts index dd7e515f88c..aced1843d7b 100644 --- a/src/main/wsl/wsl-drive-unc-conversion-run.ts +++ b/src/main/wsl/wsl-drive-unc-conversion-run.ts @@ -27,6 +27,7 @@ import { WslDriveUncKeyCollisionError } from './wsl-drive-unc-identity-rewrite' import { collectWslDriveUncScope, withNestedWslDriveUncProjects, + wslDriveUncDuplicatesSplitBy, wslUncDrivePathToWindows } from './wsl-drive-unc-project-scope' import { rewriteWslDriveUncState, wslDriveUncRewriteRoots } from './wsl-drive-unc-state-rewrite' @@ -142,15 +143,30 @@ function checkScope( if (repoIds.length + folderWorkspaceIds.length === 0 && blockers.length === 0) { blockers.push({ kind: 'not-convertible', id: '', reason: 'not-in-scope' }) } + if (blockers.length > 0) { + return { blockers } + } + const rewriteRoots = (project: { repoIds: string[]; folderWorkspaceIds: string[] }): string[] => + wslDriveUncRewriteRoots(state, { distro: request.distro, ...project }) // Bare paths are re-keyed by folder, so a nested project's rows would move without its ids. - return blockers.length > 0 - ? { blockers } - : withNestedWslDriveUncProjects( - scope, - request.distro, - { repoIds, folderWorkspaceIds }, - (project) => wslDriveUncRewriteRoots(state, { distro: request.distro, ...project }) - ) + const converting = withNestedWslDriveUncProjects( + scope, + request.distro, + { repoIds, folderWorkspaceIds }, + rewriteRoots + ) + const split = wslDriveUncDuplicatesSplitBy(state, scope, request.distro, converting, () => + rewriteRoots(converting) + ) + return split.length > 0 + ? { + blockers: split.map((id) => ({ + kind: 'not-convertible', + id, + reason: 'overlaps-refused-project' + })) + } + : converting } /** Windows Git runs in the drive path, which is the same folder the distro shows. */ diff --git a/src/main/wsl/wsl-drive-unc-project-scope.ts b/src/main/wsl/wsl-drive-unc-project-scope.ts index d2a5567e295..9306b6f036a 100644 --- a/src/main/wsl/wsl-drive-unc-project-scope.ts +++ b/src/main/wsl/wsl-drive-unc-project-scope.ts @@ -121,7 +121,7 @@ export function collectWslDriveUncScope( /** A bare path is re-keyed by the folder it lies in, so nested projects must convert together. */ function refuseOverlaps(scope: WslDriveUncScope, repos: readonly Repo[], distro: string): void { - // A duplicate's folder is the project it duplicates, so it never splits a converted folder. + // A same-folder duplicate is the project it duplicates; nested ones: wslDriveUncDuplicatesSplitBy. const refusedIds = new Set( scope.refused .filter((entry) => entry.reason !== 'duplicate-windows-project') @@ -183,6 +183,46 @@ export function withNestedWslDriveUncProjects( return { repoIds: [...repoIds], folderWorkspaceIds: [...folderWorkspaceIds] } } +/** + * Refused duplicates a conversion rewriting these folders would split: their paths inside them + * would move while their ids stay. A second spelling of a converting project is that project. + */ +export function wslDriveUncDuplicatesSplitBy( + state: WslDriveUncScopeState, + scope: WslDriveUncScope, + distro: string, + converting: { repoIds: readonly string[]; folderWorkspaceIds: readonly string[] }, + rewriteRoots: () => readonly string[] +): string[] { + const duplicates = scope.refused.filter((entry) => entry.reason === 'duplicate-windows-project') + if (duplicates.length === 0) { + return [] + } + const convertingFolders = new Set( + driveRoots( + [ + ...state.repos.filter(({ id }) => converting.repoIds.includes(id)).map(({ path }) => path), + ...state.folderWorkspaces + .filter(({ id }) => converting.folderWorkspaceIds.includes(id)) + .map(({ folderPath }) => folderPath) + ], + distro + ) + ) + const roots = driveRoots(rewriteRoots(), distro) + const splits = (folder: string, paths: readonly string[]): boolean => + !driveRoots([folder], distro).some((path) => convertingFolders.has(path)) && + driveRoots(paths, distro).some((path) => roots.some((root) => isInside(path, root))) + return duplicates.flatMap(({ kind, id }) => { + if (kind === 'repo') { + const repo = state.repos.find((entry) => entry.id === id) + return repo && splits(repo.path, wslMoveRepoPaths(repo)) ? [id] : [] + } + const folder = state.folderWorkspaces.find((entry) => entry.id === id) + return folder && splits(folder.folderPath, [folder.folderPath]) ? [id] : [] + }) +} + function driveRoots(paths: readonly string[], distro: string): string[] { return paths.flatMap((path) => { const drive = wslUncDrivePathToWindows(path, distro)