Skip to content

Commit 431867f

Browse files
committed
feat(paths): add fromUnixPath to convert MSYS paths to native Windows format
1 parent 0475d45 commit 431867f

File tree

2 files changed

+162
-5
lines changed

2 files changed

+162
-5
lines changed

src/paths/normalize.ts

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -358,6 +358,62 @@ export function isRelative(pathLike: string | Buffer | URL): boolean {
358358
return !isAbsolute(filepath)
359359
}
360360

361+
/**
362+
* Convert Unix-style POSIX paths (MSYS/Git Bash format) back to native Windows paths.
363+
*
364+
* This is the inverse of {@link toUnixPath}. MSYS-style paths use `/c/` notation
365+
* for drive letters, which PowerShell and cmd.exe cannot resolve. This function
366+
* converts them back to native Windows format.
367+
*
368+
* Conversion rules:
369+
* - On Windows: Converts Unix drive notation to Windows drive letters
370+
* - `/c/path/to/file` becomes `C:/path/to/file`
371+
* - `/d/projects/app` becomes `D:/projects/app`
372+
* - Drive letters are always uppercase in the output
373+
* - On Unix: Returns the path unchanged (passes through normalization)
374+
*
375+
* This is particularly important for:
376+
* - GitHub Actions runners where `command -v` returns MSYS paths
377+
* - Tools like sfw that need to resolve real binary paths on Windows
378+
* - Scripts that receive paths from Git Bash but need to pass them to native Windows tools
379+
*
380+
* @param {string | Buffer | URL} pathLike - The MSYS/Unix-style path to convert
381+
* @returns {string} Native Windows path (e.g., `C:/path/to/file`) or normalized Unix path
382+
*
383+
* @example
384+
* ```typescript
385+
* // MSYS drive letter paths
386+
* fromUnixPath('/c/projects/app/file.txt') // 'C:/projects/app/file.txt'
387+
* fromUnixPath('/d/projects/foo/bar') // 'D:/projects/foo/bar'
388+
*
389+
* // Non-drive Unix paths (unchanged)
390+
* fromUnixPath('/tmp/build/output') // '/tmp/build/output'
391+
* fromUnixPath('/usr/local/bin') // '/usr/local/bin'
392+
*
393+
* // Already Windows paths (unchanged)
394+
* fromUnixPath('C:/Windows/System32') // 'C:/Windows/System32'
395+
*
396+
* // Edge cases
397+
* fromUnixPath('/c') // 'C:/'
398+
* fromUnixPath('') // '.'
399+
* ```
400+
*/
401+
/*@__NO_SIDE_EFFECTS__*/
402+
export function fromUnixPath(pathLike: string | Buffer | URL): string {
403+
const normalized = normalizePath(pathLike)
404+
405+
// On Windows, convert MSYS drive notation back to native: /c/path → C:/path
406+
if (WIN32) {
407+
return normalized.replace(
408+
/^\/([a-zA-Z])(\/|$)/,
409+
(_, letter, sep) => `${letter.toUpperCase()}:${sep || '/'}`,
410+
)
411+
}
412+
413+
// On Unix, just return the normalized path
414+
return normalized
415+
}
416+
361417
/**
362418
* Normalize a path by converting backslashes to forward slashes and collapsing segments.
363419
*
@@ -1114,21 +1170,23 @@ export function relativeResolve(from: string, to: string): string {
11141170
}
11151171

11161172
/**
1117-
* Convert Windows paths to Unix-style POSIX paths for Git Bash tools.
1173+
* Convert Windows paths to MSYS/Unix-style POSIX paths for Git Bash tools.
11181174
*
1119-
* Git for Windows tools (like tar, git, etc.) expect POSIX-style paths with
1120-
* forward slashes and Unix drive letter notation (/c/ instead of C:\).
1175+
* Git for Windows and MSYS2 tools (like tar, git, etc.) expect POSIX-style
1176+
* paths with forward slashes and Unix drive letter notation (/c/ instead of C:\).
11211177
* This function handles the conversion for cross-platform compatibility.
11221178
*
1179+
* This is the inverse of {@link fromUnixPath}.
1180+
*
11231181
* Conversion rules:
11241182
* - On Windows: Normalizes separators and converts drive letters
11251183
* - `C:\path\to\file` becomes `/c/path/to/file`
1126-
* - `D:/Users/name` becomes `/d/Users/name`
1184+
* - `D:/projects/app` becomes `/d/projects/app`
11271185
* - Drive letters are always lowercase in the output
11281186
* - On Unix: Returns the path unchanged (passes through normalization)
11291187
*
11301188
* This is particularly important for:
1131-
* - Git Bash tools that interpret `D:\` as a remote hostname
1189+
* - MSYS2/Git Bash tools that interpret `D:\` as a remote hostname
11321190
* - Cross-platform build scripts using tar, git archive, etc.
11331191
* - CI/CD environments where Git for Windows is used
11341192
*

test/unit/paths/normalize.test.mts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,14 @@
1212
* - pathLikeToString() converts Buffer/URL to string
1313
* - relativeResolve() resolves relative paths
1414
* - toUnixPath() converts Windows paths to Unix-style POSIX paths for Git Bash tools
15+
* - fromUnixPath() converts MSYS/Unix-style paths back to native Windows paths
1516
* Used throughout Socket tools for cross-platform path handling.
1617
*/
1718

1819
import process from 'node:process'
1920
import { describe, expect, it } from 'vitest'
2021
import {
22+
fromUnixPath,
2123
isAbsolute,
2224
isNodeModules,
2325
isPath,
@@ -83,6 +85,103 @@ describe('paths/normalize', () => {
8385
})
8486
})
8587

88+
describe('fromUnixPath', () => {
89+
const isWindows = process.platform === 'win32'
90+
91+
it.skipIf(!isWindows)(
92+
'should convert MSYS drive letter paths to Windows format',
93+
() => {
94+
expect(fromUnixPath('/c/projects/app/file.txt')).toBe(
95+
'C:/projects/app/file.txt',
96+
)
97+
expect(fromUnixPath('/d/projects/foo/bar')).toBe('D:/projects/foo/bar')
98+
},
99+
)
100+
101+
it.skipIf(!isWindows)(
102+
'should convert lowercase drive letters to uppercase',
103+
() => {
104+
expect(fromUnixPath('/c/path')).toBe('C:/path')
105+
expect(fromUnixPath('/d/path')).toBe('D:/path')
106+
expect(fromUnixPath('/z/path')).toBe('Z:/path')
107+
},
108+
)
109+
110+
it.skipIf(!isWindows)('should handle all drive letters a-z', () => {
111+
expect(fromUnixPath('/a/path')).toBe('A:/path')
112+
expect(fromUnixPath('/e/path')).toBe('E:/path')
113+
expect(fromUnixPath('/z/path')).toBe('Z:/path')
114+
})
115+
116+
it.skipIf(!isWindows)('should handle bare drive letter path', () => {
117+
expect(fromUnixPath('/c')).toBe('C:/')
118+
})
119+
120+
it.skipIf(!isWindows)('should not convert non-drive Unix paths', () => {
121+
expect(fromUnixPath('/tmp/build/output')).toBe('/tmp/build/output')
122+
expect(fromUnixPath('/usr/local/bin')).toBe('/usr/local/bin')
123+
})
124+
125+
it.skipIf(isWindows)('should leave Unix paths unchanged on Unix', () => {
126+
expect(fromUnixPath('/tmp/build/output')).toBe('/tmp/build/output')
127+
expect(fromUnixPath('/usr/local/bin')).toBe('/usr/local/bin')
128+
expect(fromUnixPath('/c/projects/app')).toBe('/c/projects/app')
129+
})
130+
131+
it.skipIf(isWindows)('should normalize paths on Unix', () => {
132+
expect(fromUnixPath('/usr/local/../bin')).toBe('/usr/bin')
133+
expect(fromUnixPath('/usr//local///bin')).toBe('/usr/local/bin')
134+
})
135+
136+
it('should handle relative paths', () => {
137+
const result1 = fromUnixPath('./src/index.ts')
138+
const result2 = fromUnixPath('../lib/utils')
139+
expect(result1).toContain('src')
140+
expect(result2).toContain('lib')
141+
})
142+
143+
it('should handle empty string', () => {
144+
expect(fromUnixPath('')).toBe('.')
145+
})
146+
147+
it.skipIf(!isWindows)('should handle paths with spaces', () => {
148+
expect(fromUnixPath('/c/Program Files/App')).toBe('C:/Program Files/App')
149+
})
150+
151+
it.skipIf(!isWindows)('should handle paths with special characters', () => {
152+
expect(fromUnixPath('/c/projects/file (1).txt')).toBe(
153+
'C:/projects/file (1).txt',
154+
)
155+
expect(fromUnixPath('/d/projects/@scope/package')).toBe(
156+
'D:/projects/@scope/package',
157+
)
158+
})
159+
160+
it('should handle Buffer input', () => {
161+
if (isWindows) {
162+
const buffer = Buffer.from('/c/projects/app')
163+
expect(fromUnixPath(buffer)).toBe('C:/projects/app')
164+
} else {
165+
const buffer = Buffer.from('/usr/local')
166+
expect(fromUnixPath(buffer)).toBe('/usr/local')
167+
}
168+
})
169+
170+
it.skipIf(!isWindows)(
171+
'should be the inverse of toUnixPath on Windows',
172+
() => {
173+
const original = 'C:/projects/app/file.txt'
174+
const unix = toUnixPath(original)
175+
const backToWindows = fromUnixPath(unix)
176+
expect(backToWindows).toBe(original)
177+
},
178+
)
179+
180+
it.skipIf(isWindows)('should handle root path', () => {
181+
expect(fromUnixPath('/')).toBe('/')
182+
})
183+
})
184+
86185
describe('isAbsolute', () => {
87186
it('should detect Unix absolute paths', () => {
88187
expect(isAbsolute('/usr/local')).toBe(true)

0 commit comments

Comments
 (0)