Skip to content

Commit 09213fa

Browse files
Sync public snapshot from freebuff-private
Source: CodebuffAI/freebuff-private@98f8172e4753742f0e890cfc5ddd654e4ed66745
1 parent 2c4e71b commit 09213fa

6 files changed

Lines changed: 377 additions & 9 deletions

File tree

bun.lock

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

sdk/src/__tests__/path-utils.test.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
1-
import { describe, expect, test } from 'bun:test'
1+
import { beforeEach, describe, expect, test } from 'bun:test'
22

33
import {
44
getProjectPathLookupKeys,
55
resolveFilePathWithinProject,
66
} from '../tools/path-utils'
7+
import { resetTranslationCache } from '../tools/windows-bash'
78

89
describe('resolveFilePathWithinProject', () => {
910
test('normalizes relative paths to full and project-relative paths', () => {
@@ -56,3 +57,36 @@ describe('getProjectPathLookupKeys', () => {
5657
])
5758
})
5859
})
60+
61+
describe('getProjectPathLookupKeys with Git Bash paths', () => {
62+
beforeEach(() => {
63+
resetTranslationCache()
64+
})
65+
66+
test('keys an out-of-project MSYS path by its translated path as well', () => {
67+
expect(
68+
getProjectPathLookupKeys('/repo', '/tmp/probe.txt', {
69+
platform: 'win32',
70+
env: {},
71+
findBash: () => '/git/bin/bash.exe',
72+
pathExists: () => true,
73+
spawnCygpath: () => ({
74+
status: 0,
75+
stdout: '/msys-root/tmp/probe.txt\n',
76+
}),
77+
}),
78+
).toEqual(['/msys-root/tmp/probe.txt', '/tmp/probe.txt'])
79+
})
80+
81+
test('translates an MSYS drive path without any bash lookup and keys by the native path', () => {
82+
expect(
83+
getProjectPathLookupKeys('/Users/me/repo', '/c/Users/me/repo/src/a.ts', {
84+
platform: 'win32',
85+
env: {},
86+
findBash: () => {
87+
throw new Error('drive paths must not need bash')
88+
},
89+
}),
90+
).toEqual(['C:\\Users\\me\\repo\\src\\a.ts', '/c/Users/me/repo/src/a.ts'])
91+
})
92+
})

sdk/src/__tests__/windows-bash.test.ts

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ import * as path from 'path'
99

1010
import {
1111
findWindowsBash,
12+
isGitBashAbsolutePath,
1213
parseRegistryPath,
14+
resetTranslationCache,
15+
translateGitBashPath,
1316
windowsBashCandidates,
1417
} from '../tools/windows-bash'
1518

@@ -222,3 +225,175 @@ describe('parseRegistryPath', () => {
222225
expect(parseRegistryPath(' InstallPath REG_SZ ', 'InstallPath')).toBeNull()
223226
})
224227
})
228+
229+
describe('Git Bash path translation', () => {
230+
beforeEach(() => {
231+
resetTranslationCache()
232+
})
233+
234+
it('recognizes MSYS absolute paths without claiming native UNC paths', () => {
235+
expect(isGitBashAbsolutePath('/tmp/file.txt')).toBe(true)
236+
expect(isGitBashAbsolutePath('/c/Users/Alice/file.txt')).toBe(true)
237+
expect(isGitBashAbsolutePath('//server/share/file.txt')).toBe(false)
238+
expect(isGitBashAbsolutePath('C:\\tmp\\file.txt')).toBe(false)
239+
expect(isGitBashAbsolutePath('src/file.txt')).toBe(false)
240+
})
241+
242+
it('translates a drive-letter MSYS path without any lookup or spawn', () => {
243+
const neverFindBash = () => {
244+
throw new Error('must not look for bash')
245+
}
246+
expect(
247+
translateGitBashPath('/c/Users/me/repo/src/a.ts', {
248+
platform: 'win32',
249+
env: {},
250+
findBash: neverFindBash,
251+
}),
252+
).toBe('C:\\Users\\me\\repo\\src\\a.ts')
253+
expect(
254+
translateGitBashPath('/D/work/file.txt', {
255+
platform: 'win32',
256+
env: {},
257+
findBash: neverFindBash,
258+
}),
259+
).toBe('D:\\work\\file.txt')
260+
})
261+
262+
it('spawns the cygpath.exe derived from the found bash, directly', () => {
263+
const bash = path.join(installGitAt('Git'), 'bin', 'bash.exe')
264+
const cygpath = touch(path.join(root, 'Git', 'usr', 'bin'), 'cygpath.exe')
265+
const calls: [string, string][] = []
266+
267+
expect(
268+
translateGitBashPath('/tmp/file.txt', {
269+
platform: 'win32',
270+
env: {},
271+
findBash: () => bash,
272+
spawnCygpath: (cygpath, filePath) => {
273+
calls.push([cygpath, filePath])
274+
return {
275+
status: 0,
276+
stdout: 'C:\\Users\\Alice\\AppData\\Local\\Temp\\file.txt\n',
277+
}
278+
},
279+
}),
280+
).toBe('C:\\Users\\Alice\\AppData\\Local\\Temp\\file.txt')
281+
expect(calls).toEqual([[cygpath, '/tmp/file.txt']])
282+
})
283+
284+
it('spawns the cygpath.exe sitting beside a usr\\bin bash', () => {
285+
const bash = touch(path.join(root, 'Git', 'usr', 'bin'), 'bash.exe')
286+
const cygpath = touch(path.join(root, 'Git', 'usr', 'bin'), 'cygpath.exe')
287+
const calls: [string, string][] = []
288+
289+
expect(
290+
translateGitBashPath('/tmp/sibling.txt', {
291+
platform: 'win32',
292+
env: {},
293+
findBash: () => bash,
294+
spawnCygpath: (cygpath, filePath) => {
295+
calls.push([cygpath, filePath])
296+
return { status: 0, stdout: 'C:\\Temp\\sibling.txt\n' }
297+
},
298+
}),
299+
).toBe('C:\\Temp\\sibling.txt')
300+
expect(calls).toEqual([[cygpath, '/tmp/sibling.txt']])
301+
})
302+
303+
it('skips the spawn and keeps the original path when no cygpath.exe exists', () => {
304+
const bash = touch(path.join(root, 'Git', 'bin'), 'bash.exe')
305+
let spawns = 0
306+
307+
expect(
308+
translateGitBashPath('/tmp/no-cygpath.txt', {
309+
platform: 'win32',
310+
env: {},
311+
findBash: () => bash,
312+
spawnCygpath: () => {
313+
spawns++
314+
return { status: 0, stdout: 'C:\\Temp\\no-cygpath.txt\n' }
315+
},
316+
}),
317+
).toBe('/tmp/no-cygpath.txt')
318+
expect(spawns).toBe(0)
319+
})
320+
321+
it('reads only the last stdout line, so leading banner output is ignored', () => {
322+
expect(
323+
translateGitBashPath('/tmp/banner.txt', {
324+
platform: 'win32',
325+
env: {},
326+
findBash: () => path.join(root, 'Git', 'bin', 'bash.exe'),
327+
pathExists: () => true,
328+
spawnCygpath: () => ({
329+
status: 0,
330+
stdout:
331+
'conda environment activated\nNow using node v22.1.0\nC:\\Temp\\banner.txt\r\n',
332+
}),
333+
}),
334+
).toBe('C:\\Temp\\banner.txt')
335+
})
336+
337+
it('falls back to the original path on a non-zero exit even when stdout is non-empty', () => {
338+
expect(
339+
translateGitBashPath('/tmp/exit-code.txt', {
340+
platform: 'win32',
341+
env: {},
342+
findBash: () => path.join(root, 'Git', 'bin', 'bash.exe'),
343+
pathExists: () => true,
344+
spawnCygpath: () => ({
345+
status: 1,
346+
stdout: 'C:\\Temp\\exit-code.txt\n',
347+
}),
348+
}),
349+
).toBe('/tmp/exit-code.txt')
350+
})
351+
352+
it('spawns once per path and answers repeats from the cache', () => {
353+
let spawns = 0
354+
const dependencies = {
355+
platform: 'win32' as NodeJS.Platform,
356+
env: {},
357+
findBash: () => path.join(root, 'Git', 'bin', 'bash.exe'),
358+
pathExists: () => true,
359+
spawnCygpath: () => {
360+
spawns++
361+
return { status: 0, stdout: 'C:\\Temp\\repeat.txt\n' }
362+
},
363+
}
364+
expect(translateGitBashPath('/tmp/repeat.txt', dependencies)).toBe(
365+
'C:\\Temp\\repeat.txt',
366+
)
367+
expect(translateGitBashPath('/tmp/repeat.txt', dependencies)).toBe(
368+
'C:\\Temp\\repeat.txt',
369+
)
370+
expect(spawns).toBe(1)
371+
})
372+
373+
it('leaves paths unchanged when translation does not apply or is unavailable', () => {
374+
const neverFindBash = () => {
375+
throw new Error('must not look for bash')
376+
}
377+
expect(
378+
translateGitBashPath('/tmp/file.txt', {
379+
platform: 'darwin',
380+
findBash: neverFindBash,
381+
}),
382+
).toBe('/tmp/file.txt')
383+
expect(
384+
translateGitBashPath('C:\\tmp\\file.txt', {
385+
platform: 'win32',
386+
findBash: neverFindBash,
387+
}),
388+
).toBe('C:\\tmp\\file.txt')
389+
expect(
390+
translateGitBashPath('/tmp/file.txt', {
391+
platform: 'win32',
392+
env: {},
393+
findBash: () => path.join(root, 'Git', 'bin', 'bash.exe'),
394+
pathExists: () => true,
395+
spawnCygpath: () => ({ status: 0, stdout: '' }),
396+
}),
397+
).toBe('/tmp/file.txt')
398+
})
399+
})
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { expect, test } from 'bun:test'
2+
import { randomUUID } from 'crypto'
3+
import { promises as fs, rmSync } from 'fs'
4+
import { spawnSync } from 'child_process'
5+
6+
import { getSystemProcessEnv } from '../env'
7+
import { getFiles } from '../tools/read-files'
8+
import { findWindowsBash, translateGitBashPath } from '../tools/windows-bash'
9+
10+
const windowsTest = process.platform === 'win32' ? test : test.skip
11+
12+
windowsTest(
13+
'reads a file created under Git Bash /tmp with the native file tool',
14+
async () => {
15+
const bash = findWindowsBash(getSystemProcessEnv())
16+
expect(bash).not.toBeNull()
17+
18+
const msysPath = `/tmp/freebuff-path-${randomUUID()}.txt`
19+
const content = 'freebuff msys path probe'
20+
const created = spawnSync(
21+
bash!,
22+
['-lc', `printf '${content}' > '${msysPath}'`],
23+
{
24+
encoding: 'utf8',
25+
windowsHide: true,
26+
},
27+
)
28+
expect(created.status, created.stderr).toBe(0)
29+
30+
const nativePath = translateGitBashPath(msysPath)
31+
expect(nativePath).not.toBe(msysPath)
32+
try {
33+
const result = await getFiles({
34+
filePaths: [msysPath],
35+
cwd: process.cwd(),
36+
fs,
37+
limitContent: false,
38+
enforceEnvPolicy: false,
39+
})
40+
expect(Object.values(result)).toEqual([content])
41+
} finally {
42+
rmSync(nativePath, { force: true })
43+
}
44+
},
45+
)

sdk/src/tools/path-utils.ts

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import path from 'path'
22

3+
import { translateGitBashPath } from './windows-bash'
4+
5+
import type { WindowsShellPathDependencies } from './windows-bash'
6+
37
export type ResolvedProjectPath = {
48
fullPath: string
59
relativePath: string
@@ -21,11 +25,13 @@ function escapesProject(relativePath: string): boolean {
2125
export function resolveFilePathWithinProject(
2226
projectRoot: string,
2327
filePath: string,
28+
dependencies: WindowsShellPathDependencies = {},
2429
): ResolvedProjectPath | null {
2530
const resolvedRoot = path.resolve(projectRoot)
26-
const fullPath = path.isAbsolute(filePath)
27-
? path.resolve(filePath)
28-
: path.resolve(resolvedRoot, filePath)
31+
const nativeFilePath = translateGitBashPath(filePath, dependencies)
32+
const fullPath = path.isAbsolute(nativeFilePath)
33+
? path.resolve(nativeFilePath)
34+
: path.resolve(resolvedRoot, nativeFilePath)
2935
const relativePath = path.relative(resolvedRoot, fullPath)
3036

3137
if (relativePath === '' || escapesProject(relativePath)) {
@@ -43,15 +49,20 @@ export function resolveFilePathWithinProject(
4349
* when the target is inside the project, otherwise the absolute path.
4450
* `isWithinProject` lets callers skip project-scoped logic (e.g. gitignore) for
4551
* files that live outside the project.
52+
* On Windows, a single-leading-slash path uses Git Bash semantics because that
53+
* is the shell exposed to agents; native absolute paths retain their drive or
54+
* UNC prefix.
4655
*/
4756
export function resolveFilePath(
4857
projectRoot: string,
4958
filePath: string,
59+
dependencies: WindowsShellPathDependencies = {},
5060
): ResolvedFilePath {
5161
const resolvedRoot = path.resolve(projectRoot)
52-
const fullPath = path.isAbsolute(filePath)
53-
? path.resolve(filePath)
54-
: path.resolve(resolvedRoot, filePath)
62+
const nativeFilePath = translateGitBashPath(filePath, dependencies)
63+
const fullPath = path.isAbsolute(nativeFilePath)
64+
? path.resolve(nativeFilePath)
65+
: path.resolve(resolvedRoot, nativeFilePath)
5566
const relativePath = path.relative(resolvedRoot, fullPath)
5667
const isWithinProject = relativePath !== '' && !escapesProject(relativePath)
5768
const displayPath = isWithinProject ? relativePath : fullPath
@@ -62,9 +73,19 @@ export function resolveFilePath(
6273
export function getProjectPathLookupKeys(
6374
projectRoot: string,
6475
filePath: string,
76+
dependencies: WindowsShellPathDependencies = {},
6577
): string[] {
66-
const resolvedPath = resolveFilePathWithinProject(projectRoot, filePath)
67-
const keys = resolvedPath ? [resolvedPath.relativePath, filePath] : [filePath]
78+
const resolvedPath = resolveFilePathWithinProject(
79+
projectRoot,
80+
filePath,
81+
dependencies,
82+
)
83+
const keys = resolvedPath
84+
? [resolvedPath.relativePath, filePath]
85+
: [
86+
resolveFilePath(projectRoot, filePath, dependencies).relativePath,
87+
filePath,
88+
]
6889

6990
return [...new Set(keys)]
7091
}

0 commit comments

Comments
 (0)