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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Neil
2026-10-29 17:16:07 -07:00
committed by GitHub
co-authored by Claude
parent ab9d846d79
commit d887405add
3 changed files with 101 additions and 9 deletions
@@ -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 })
+24 -8
View File
@@ -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. */
+41 -1
View File
@@ -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)