diff --git a/src/main/git/repo-git-marker-scan.ts b/src/main/git/repo-git-marker-scan.ts index 5442d74d391..9341c3d15ff 100644 --- a/src/main/git/repo-git-marker-scan.ts +++ b/src/main/git/repo-git-marker-scan.ts @@ -1,8 +1,7 @@ import { readFileSync, realpathSync, statSync } from 'node:fs' -import { dirname, isAbsolute, join, relative, resolve } from 'node:path' +import { dirname, isAbsolute, join, relative } from 'node:path' import { normalizeRuntimePathSeparators } from '../../shared/cross-platform-path' -import { parseWslUncPath } from '../../shared/wsl-paths' -import { toWindowsWslPath } from '../wsl' +import { resolveGitMetadataPath } from '../../shared/git-metadata-path' export type GitMarkerScanResult = | { status: 'valid'; rootPath: string } @@ -136,18 +135,6 @@ function parseGitdirFile(basePath: string, content: string): string | null { return resolveGitMetadataPath(basePath, match[1]) } -function resolveGitMetadataPath(basePath: string, rawPath: string): string | null { - const value = rawPath.trim() - if (!value) { - return null - } - const baseWsl = parseWslUncPath(basePath) - if (baseWsl && value.startsWith('/')) { - return toWindowsWslPath(value, baseWsl.distro) - } - return isAbsolute(value) ? value : resolve(basePath, value) -} - function hasValidGitDirectorySync(gitDir: string): boolean { return hasValidCommonGitDirectorySync(gitDir) || hasValidLinkedWorktreeGitDirectorySync(gitDir) } diff --git a/src/shared/git-metadata-path.test.ts b/src/shared/git-metadata-path.test.ts new file mode 100644 index 00000000000..2c0a24b0672 --- /dev/null +++ b/src/shared/git-metadata-path.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' +import { resolveGitMetadataPath } from './git-metadata-path' + +describe('resolveGitMetadataPath', () => { + it('maps a drvfs pointer to its drive spelling on a Windows host with no distro context', () => { + expect( + resolveGitMetadataPath( + String.raw`C:\Users\me\repo`, + '/mnt/c/Users/me/repo/.git/worktrees/feature', + 'win32' + ) + ).toBe(String.raw`C:\Users\me\repo\.git\worktrees\feature`) + }) + + it('maps a drvfs drive root to its drive spelling', () => { + expect(resolveGitMetadataPath(String.raw`D:\repo`, '/mnt/d', 'win32')).toBe('D:\\') + }) + + it('keeps a non-drvfs guest pointer verbatim when no distro names it', () => { + // Windows already reads this as drive-relative; guessing a distro would probe the wrong disk. + expect(resolveGitMetadataPath(String.raw`C:\repo`, '/home/me/repo/.git', 'win32')).toBe( + '/home/me/repo/.git' + ) + }) + + it('maps a guest pointer through the distro encoded by a WSL UNC base', () => { + expect( + resolveGitMetadataPath( + String.raw`\\wsl.localhost\Debian\home\me\repo`, + '/home/me/repo/.git', + 'win32' + ) + ).toBe(String.raw`\\wsl.localhost\Debian\home\me\repo\.git`) + }) + + it('resolves a relative pointer against a WSL UNC base', () => { + expect( + resolveGitMetadataPath( + String.raw`\\wsl.localhost\Debian\home\me\repo\.git\worktrees\feature`, + '../..', + 'win32' + ) + ).toBe(String.raw`\\wsl.localhost\Debian\home\me\repo\.git`) + }) + + it('resolves relative pointers with the reading host path flavor', () => { + expect(resolveGitMetadataPath('/repo/worktree', '../.git/worktrees/feature', 'linux')).toBe( + '/repo/.git/worktrees/feature' + ) + expect( + resolveGitMetadataPath( + String.raw`C:\repo\worktree`, + String.raw`..\.git\worktrees\feature`, + 'win32' + ) + ).toBe(String.raw`C:\repo\.git\worktrees\feature`) + }) + + it('leaves absolute pointers alone on a POSIX host, drvfs spelling included', () => { + expect( + resolveGitMetadataPath('/repo/worktree', '/var/lib/git/worktrees/feature', 'linux') + ).toBe('/var/lib/git/worktrees/feature') + expect(resolveGitMetadataPath('/repo/worktree', '/mnt/c/repo/.git', 'darwin')).toBe( + '/mnt/c/repo/.git' + ) + }) + + it.each(['', ' ', '\t'])('rejects an empty metadata pointer %j', (rawPath) => { + expect(resolveGitMetadataPath('/repo', rawPath, 'linux')).toBeNull() + }) + + it('trims a padded pointer before resolving it', () => { + expect(resolveGitMetadataPath('/repo/worktree', ' ../.git ', 'linux')).toBe('/repo/.git') + }) +}) diff --git a/src/shared/git-metadata-path.ts b/src/shared/git-metadata-path.ts new file mode 100644 index 00000000000..a0d275a8092 --- /dev/null +++ b/src/shared/git-metadata-path.ts @@ -0,0 +1,46 @@ +import { posix, win32 } from 'node:path' +import { parseWslUncPath, toWindowsWslDrivePath, toWindowsWslPath } from './wsl-paths' + +/** + * Resolve a Git metadata pointer (a `.git` gitfile payload or a `commondir`) in the path namespace + * of the host that reads it. + * + * Why: git running inside WSL writes these pointers in the guest namespace, but Node reads them + * back through Win32, where a drvfs pointer like `/mnt/c/repo/.git` silently means + * `C:\mnt\c\repo\.git`. Returns null only for an empty pointer. + */ +export function resolveGitMetadataPath( + basePath: string, + rawPath: string, + platform: NodeJS.Platform = process.platform +): string | null { + const value = rawPath.trim() + if (!value) { + return null + } + if (value.startsWith('/')) { + const translated = translateGuestPointer(value, basePath, platform) + if (translated) { + return translated + } + } + const host = platform === 'win32' ? win32 : posix + return host.isAbsolute(value) ? value : host.resolve(basePath, value) +} + +/** + * The Win32 spelling of a POSIX-rooted pointer, or null to leave it alone. A WSL UNC base names the + * distro that wrote the pointer; failing that, only a drvfs mount has a spelling we can derive, and + * only a Windows host needs one. + */ +function translateGuestPointer( + value: string, + basePath: string, + platform: NodeJS.Platform +): string | null { + const distro = parseWslUncPath(basePath)?.distro + if (distro) { + return toWindowsWslPath(value, distro) + } + return platform === 'win32' ? toWindowsWslDrivePath(value) : null +} diff --git a/src/shared/wsl-paths.test.ts b/src/shared/wsl-paths.test.ts index c6cc52c182f..b4f8995f25e 100644 --- a/src/shared/wsl-paths.test.ts +++ b/src/shared/wsl-paths.test.ts @@ -6,6 +6,7 @@ import { isWslUncPath, parseWslUncPath, resolveWslRepoWorktreeBasePath, + toWindowsWslDrivePath, toWindowsWslPath, toWindowsWslUncPath } from './wsl-paths' @@ -37,6 +38,31 @@ describe('wsl path helpers', () => { expect(toWindowsWslPath(linuxPath, 'Ubuntu')).toBe(expected) }) + it.each([ + ['/mnt/c/Users/jin', 'C:\\Users\\jin'], + ['/mnt/d', 'D:\\'], + ['/mnt/d/', 'D:\\'], + ['/MNT/c/Users/jin', null], + ['/mnt/C/Repo', null], + ['/home/jin', null], + // A drvfs prefix on a line that still carries a terminator is not a drive path. + ['/mnt/c/Users/jin\r', null], + ['/mnt/c/Users/jin\n', null], + ['/mnt/c/Users/jin\u2028', null], + ['/mnt/c/Users/jin\u2029', null] + ] as const)('converts the DrvFs path %j without a distro lookup', (linuxPath, expected) => { + expect(toWindowsWslDrivePath(linuxPath)).toBe(expected) + }) + + it.each(['\r', '\n', '\u2028', '\u2029'])( + 'leaves a DrvFs line ending in %j on the distro UNC view', + (terminator) => { + expect(toWindowsWslPath(`/mnt/c/Users/jin${terminator}`, 'Ubuntu')).toBe( + `\\\\wsl.localhost\\Ubuntu\\mnt\\c\\Users\\jin${terminator}` + ) + } + ) + it('keeps mounted-drive paths on the distro UNC view when requested', () => { expect(toWindowsWslUncPath('/mnt/c/Users/jin', 'Ubuntu')).toBe( '\\\\wsl.localhost\\Ubuntu\\mnt\\c\\Users\\jin' diff --git a/src/shared/wsl-paths.ts b/src/shared/wsl-paths.ts index 9dc465c75b1..ba7f9a12f92 100644 --- a/src/shared/wsl-paths.ts +++ b/src/shared/wsl-paths.ts @@ -49,13 +49,7 @@ export function toLinuxPath(windowsPath: string): string { /** Convert an absolute Linux path in a known WSL distro to its Windows form. */ export function toWindowsWslPath(linuxPath: string, distro: string): string { - const mntMatch = linuxPath.match(/^\/mnt\/([a-z])(\/.*)?$/) - if (mntMatch) { - const rest = (mntMatch[2] || '').replace(/\//g, '\\') - return `${mntMatch[1].toUpperCase()}:${rest || '\\'}` - } - - return toWindowsWslUncPath(linuxPath, distro) + return toWindowsWslDrivePath(linuxPath) ?? toWindowsWslUncPath(linuxPath, distro) } /** Keep a Linux path addressable through its distro, including drvfs mounts. */ @@ -132,7 +126,12 @@ export function toWslExecutionSpace(path: string): string { return parseWslUncPath(path)?.linuxPath ?? path } -/** The drvfs automount is literally lowercase `/mnt/`; `/MNT` is an ordinary Linux dir. */ +/** + * The drvfs automount is literally lowercase `/mnt/`; `/MNT` is an ordinary Linux dir. + * Deliberately looser than `toWindowsWslDrivePath`'s end-anchored matcher below: this one only + * classifies a prefix, so do not unify them — the anchoring there is what keeps a path carrying a + * stray line terminator off the drive spelling. + */ const DRVFS_LINUX_PATH = /^\/mnt\/[a-z](?:\/|$)/ /** True for a Linux path that is really a Windows drive reached through drvfs. */ @@ -140,6 +139,21 @@ export function isDrvfsLinuxPath(linuxPath: string): boolean { return DRVFS_LINUX_PATH.test(linuxPath) } +/** + * The Windows drive spelling of a drvfs path, or null when the path is not one. Needs no distro: + * the bytes sit on the drive whichever distro mounted them. + */ +export function toWindowsWslDrivePath(linuxPath: string): string | null { + // `.` excludes every line terminator, so a drvfs prefix on a stray output line (an rg hit that + // still carries its CR) stays off the drive spelling. + const match = linuxPath.match(/^\/mnt\/([a-z])(\/.*)?$/) + if (!match) { + return null + } + const tail = (match[2] ?? '').replace(/\//g, '\\') + return `${match[1].toUpperCase()}:${tail || '\\'}` +} + /** * The distro whose git would reach `projectPath` across the 9p/drvfs boundary, or null when it * would not.