mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
fix(git): enable Windows long paths for worktree creation (local, sparse, and SSH hosts) (#15866)
Co-authored-by: hwantage <hwantagexsw2@gmail.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import type * as FsPromises from 'node:fs/promises'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
|
||||
|
||||
const {
|
||||
gitExecFileAsyncMock,
|
||||
@@ -67,6 +67,10 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
describe('addSparseWorktree', () => {
|
||||
// Why: argv now depends on the host OS, so pin a non-Windows default or the exact-argv
|
||||
// assertions below would fail for a maintainer running vitest on Windows.
|
||||
let platformSpy: MockInstance<() => NodeJS.Platform>
|
||||
|
||||
beforeEach(() => {
|
||||
resetWorktreeGitMocks({
|
||||
gitExecFileAsyncMock,
|
||||
@@ -75,6 +79,43 @@ describe('addSparseWorktree', () => {
|
||||
statMock,
|
||||
resolveGitDirMock
|
||||
})
|
||||
platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
platformSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('enables long paths on the Windows commands that actually write the deep checkout', async () => {
|
||||
// Why: `worktree add --no-checkout` writes nothing, so the long-path flag on it alone
|
||||
// left sparse creation failing with "Filename too long" (issue #15785).
|
||||
platformSpy.mockReturnValue('win32')
|
||||
gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' })
|
||||
|
||||
await addSparseWorktree('C:\\repo', 'C:\\repo-feature', 'feature/test', ['packages/web'])
|
||||
|
||||
const calls = getGitCalls()
|
||||
expect(calls).toEqual(
|
||||
expect.arrayContaining([
|
||||
'git -c core.longpaths=true sparse-checkout set -- packages/web',
|
||||
'git -c core.longpaths=true checkout feature/test'
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('omits the long-path option on non-Windows hosts', async () => {
|
||||
gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' })
|
||||
|
||||
await addSparseWorktree('/repo', '/repo-feature', 'feature/test', ['packages/web'])
|
||||
|
||||
const calls = getGitCalls()
|
||||
expect(calls).toEqual(
|
||||
expect.arrayContaining([
|
||||
'git sparse-checkout set -- packages/web',
|
||||
'git checkout feature/test'
|
||||
])
|
||||
)
|
||||
expect(calls.some((call) => call.includes('core.longpaths'))).toBe(false)
|
||||
})
|
||||
|
||||
it('separates sparse checkout directory operands from options', async () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// addWorktree: checkout creation, branch-base/push.autoSetupRemote config writes, ref qualification.
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
|
||||
|
||||
const {
|
||||
gitExecFileAsyncMock,
|
||||
@@ -40,10 +40,19 @@ describe('addWorktree', () => {
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // config --local --replace-all branch.<branch>.base
|
||||
}
|
||||
|
||||
// Why: argv now depends on the host OS, so pin a non-Windows default or every
|
||||
// exact-argv assertion below would fail for a maintainer running vitest on Windows.
|
||||
let platformSpy: MockInstance<() => NodeJS.Platform>
|
||||
|
||||
beforeEach(() => {
|
||||
gitExecFileAsyncMock.mockReset()
|
||||
gitExecFileSyncMock.mockReset()
|
||||
translateWslOutputPathsMock.mockClear()
|
||||
platformSpy = vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
platformSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('creates the worktree without touching the local base ref by default', async () => {
|
||||
@@ -99,6 +108,86 @@ describe('addWorktree', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('enables long paths for native Windows worktree creation', async () => {
|
||||
platformSpy.mockReturnValue('win32')
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add
|
||||
|
||||
await addWorktree(
|
||||
'C:\\repo',
|
||||
'C:\\repo-feature',
|
||||
'feature/test',
|
||||
'feature/test',
|
||||
false,
|
||||
false,
|
||||
{ checkoutExistingBranch: true }
|
||||
)
|
||||
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
['-c', 'core.longpaths=true', 'worktree', 'add', 'C:\\repo-feature', 'feature/test'],
|
||||
{ cwd: 'C:\\repo', timeout: WORKTREE_ADD_TIMEOUT_MS }
|
||||
)
|
||||
})
|
||||
|
||||
it('still enables long paths for a Windows-path repo that has a WSL distro configured', async () => {
|
||||
// Why: a C:\ cwd can be served by host git.exe even with wslDistro set, and that
|
||||
// is exactly the MAX_PATH-prone case; Linux git parses and ignores the key.
|
||||
platformSpy.mockReturnValue('win32')
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add
|
||||
|
||||
await addWorktree(
|
||||
'C:\\repo',
|
||||
'C:\\repo-feature',
|
||||
'feature/test',
|
||||
'feature/test',
|
||||
false,
|
||||
false,
|
||||
{ checkoutExistingBranch: true, wslDistro: 'Ubuntu' }
|
||||
)
|
||||
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
['-c', 'core.longpaths=true', 'worktree', 'add', 'C:\\repo-feature', 'feature/test'],
|
||||
{ cwd: 'C:\\repo', wslDistro: 'Ubuntu', timeout: WORKTREE_ADD_TIMEOUT_MS }
|
||||
)
|
||||
})
|
||||
|
||||
it('does not pass the Windows-only long-path option for a WSL UNC repo path', async () => {
|
||||
platformSpy.mockReturnValue('win32')
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add
|
||||
|
||||
const repoPath = '\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo'
|
||||
await addWorktree(
|
||||
repoPath,
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo-feature',
|
||||
'feature/test',
|
||||
'feature/test',
|
||||
false,
|
||||
false,
|
||||
{ checkoutExistingBranch: true, wslDistro: 'Ubuntu' }
|
||||
)
|
||||
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
['worktree', 'add', '\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo-feature', 'feature/test'],
|
||||
{ cwd: repoPath, wslDistro: 'Ubuntu', timeout: WORKTREE_ADD_TIMEOUT_MS }
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['darwin', 'linux'] as const)(
|
||||
'does not pass the long-path option on %s',
|
||||
async (platform) => {
|
||||
platformSpy.mockReturnValue(platform)
|
||||
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: '' }) // worktree add
|
||||
|
||||
await addWorktree('/repo', '/repo-feature', 'feature/test', 'feature/test', false, false, {
|
||||
checkoutExistingBranch: true
|
||||
})
|
||||
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
['worktree', 'add', '/repo-feature', 'feature/test'],
|
||||
{ cwd: '/repo', timeout: WORKTREE_ADD_TIMEOUT_MS }
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('bounds the worktree add call with a positive timeout (STA-1292 OneDrive stall guard)', async () => {
|
||||
// Why: without a timeout, a OneDrive cloud-placeholder checkout can stall
|
||||
// `git worktree add` for minutes. Assert the runner receives a non-zero
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
scheduleWorktreeTrashDeletion
|
||||
} from '../worktree-trash'
|
||||
import { parseWslPath } from '../wsl'
|
||||
import { windowsLongPathGitArgs } from '../../shared/windows-long-path-git-args'
|
||||
import type {
|
||||
LocalBaseRefRefreshResult,
|
||||
LocalBaseRefUpdateSuggestion
|
||||
@@ -985,7 +986,8 @@ async function performAddWorktree(
|
||||
): Promise<AddWorktreeResult> {
|
||||
let localBaseRefRefresh: LocalBaseRefRefreshResult | undefined
|
||||
let localBaseRefUpdateSuggestion: LocalBaseRefUpdateSuggestion | undefined
|
||||
const args = ['worktree', 'add']
|
||||
// Why: enable long paths for this Windows checkout without changing user Git config.
|
||||
const args = [...windowsLongPathGitArgs(repoPath), 'worktree', 'add']
|
||||
let effectiveBase: string | undefined
|
||||
if (noCheckout) {
|
||||
args.push('--no-checkout')
|
||||
@@ -1091,15 +1093,21 @@ export async function addSparseWorktree(
|
||||
options
|
||||
)
|
||||
created = true
|
||||
// Why: `worktree add --no-checkout` writes no files, so these are the calls that
|
||||
// actually materialize the deep path and need the long-path escape hatch.
|
||||
const longPathArgs = windowsLongPathGitArgs(worktreePath)
|
||||
await gitExecFileAsync(
|
||||
['sparse-checkout', 'init', '--cone'],
|
||||
gitExecOptions(worktreePath, options)
|
||||
)
|
||||
await gitExecFileAsync(
|
||||
['sparse-checkout', 'set', '--', ...directories],
|
||||
[...longPathArgs, 'sparse-checkout', 'set', '--', ...directories],
|
||||
gitExecOptions(worktreePath, options)
|
||||
)
|
||||
await gitExecFileAsync(
|
||||
[...longPathArgs, 'checkout', branch],
|
||||
gitExecOptions(worktreePath, options)
|
||||
)
|
||||
await gitExecFileAsync(['checkout', branch], gitExecOptions(worktreePath, options))
|
||||
return addResult
|
||||
} catch (error) {
|
||||
const wrapped: SparseWorktreeCreateError =
|
||||
|
||||
@@ -31,12 +31,16 @@ describe('addWorktreeOp', () => {
|
||||
it('writes durable branch base config after creating an SSH new-branch worktree', async () => {
|
||||
const git = vi.fn<GitExec>(async () => ({ stdout: '', stderr: '' }))
|
||||
|
||||
await addWorktreeOp(git, {
|
||||
repoPath: '/repo',
|
||||
branchName: 'feature/test',
|
||||
targetDir: '/repo-feature',
|
||||
base: 'origin/main'
|
||||
})
|
||||
await addWorktreeOp(
|
||||
git,
|
||||
{
|
||||
repoPath: '/repo',
|
||||
branchName: 'feature/test',
|
||||
targetDir: '/repo-feature',
|
||||
base: 'origin/main'
|
||||
},
|
||||
'linux'
|
||||
)
|
||||
|
||||
expect(git.mock.calls.map((call) => call[0])).toEqual([
|
||||
['rev-parse', '--verify', '--quiet', 'refs/remotes/origin/main^{commit}'],
|
||||
@@ -75,13 +79,17 @@ describe('addWorktreeOp', () => {
|
||||
it('does not write branch base config when checking out an existing SSH branch', async () => {
|
||||
const git = vi.fn<GitExec>(async () => ({ stdout: '', stderr: '' }))
|
||||
|
||||
await addWorktreeOp(git, {
|
||||
repoPath: '/repo',
|
||||
branchName: 'feature/test',
|
||||
targetDir: '/repo-feature',
|
||||
base: 'origin/main',
|
||||
checkoutExistingBranch: true
|
||||
})
|
||||
await addWorktreeOp(
|
||||
git,
|
||||
{
|
||||
repoPath: '/repo',
|
||||
branchName: 'feature/test',
|
||||
targetDir: '/repo-feature',
|
||||
base: 'origin/main',
|
||||
checkoutExistingBranch: true
|
||||
},
|
||||
'linux'
|
||||
)
|
||||
|
||||
expect(git.mock.calls.map((call) => call[0])).toEqual([
|
||||
['worktree', 'add', '/repo-feature', 'feature/test']
|
||||
@@ -91,11 +99,15 @@ describe('addWorktreeOp', () => {
|
||||
it('does not write branch base config when SSH creation has no base', async () => {
|
||||
const git = vi.fn<GitExec>(async () => ({ stdout: '', stderr: '' }))
|
||||
|
||||
await addWorktreeOp(git, {
|
||||
repoPath: '/repo',
|
||||
branchName: 'feature/no-base',
|
||||
targetDir: '/repo-feature'
|
||||
})
|
||||
await addWorktreeOp(
|
||||
git,
|
||||
{
|
||||
repoPath: '/repo',
|
||||
branchName: 'feature/no-base',
|
||||
targetDir: '/repo-feature'
|
||||
},
|
||||
'linux'
|
||||
)
|
||||
|
||||
expect(git.mock.calls.map((call) => call[0])).toEqual([
|
||||
['worktree', 'add', '--no-track', '-b', 'feature/no-base', '/repo-feature'],
|
||||
@@ -103,6 +115,76 @@ describe('addWorktreeOp', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('enables long paths when the SSH execution host is Windows', async () => {
|
||||
// Why: only the host's OS matters — a macOS client can drive a Windows SSH host,
|
||||
// which hits the same MAX_PATH ceiling (issue #15785).
|
||||
const git = vi.fn<GitExec>(async () => ({ stdout: '', stderr: '' }))
|
||||
|
||||
await addWorktreeOp(
|
||||
git,
|
||||
{
|
||||
repoPath: 'C:\\repo',
|
||||
branchName: 'feature/test',
|
||||
targetDir: 'C:\\repo-feature',
|
||||
checkoutExistingBranch: true
|
||||
},
|
||||
'win32'
|
||||
)
|
||||
|
||||
expect(git.mock.calls.map((call) => call[0])).toEqual([
|
||||
['-c', 'core.longpaths=true', 'worktree', 'add', 'C:\\repo-feature', 'feature/test']
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps --no-checkout ahead of -b once the long-path prefix is present', async () => {
|
||||
const git = vi.fn<GitExec>(async () => ({ stdout: '', stderr: '' }))
|
||||
|
||||
await addWorktreeOp(
|
||||
git,
|
||||
{
|
||||
repoPath: 'C:\\repo',
|
||||
branchName: 'feature/test',
|
||||
targetDir: 'C:\\repo-feature',
|
||||
noCheckout: true
|
||||
},
|
||||
'win32'
|
||||
)
|
||||
|
||||
expect(git.mock.calls[0][0]).toEqual([
|
||||
'-c',
|
||||
'core.longpaths=true',
|
||||
'worktree',
|
||||
'add',
|
||||
'--no-track',
|
||||
'--no-checkout',
|
||||
'-b',
|
||||
'feature/test',
|
||||
'C:\\repo-feature'
|
||||
])
|
||||
})
|
||||
|
||||
it('omits the long-path option on a WSL UNC target on a Windows SSH host', async () => {
|
||||
const git = vi.fn<GitExec>(async () => ({ stdout: '', stderr: '' }))
|
||||
|
||||
await addWorktreeOp(
|
||||
git,
|
||||
{
|
||||
repoPath: '\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo',
|
||||
branchName: 'feature/test',
|
||||
targetDir: '\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo-feature',
|
||||
checkoutExistingBranch: true
|
||||
},
|
||||
'win32'
|
||||
)
|
||||
|
||||
expect(git.mock.calls[0][0]).toEqual([
|
||||
'worktree',
|
||||
'add',
|
||||
'\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo-feature',
|
||||
'feature/test'
|
||||
])
|
||||
})
|
||||
|
||||
it('warns and unsets stale branch base config when SSH base persistence fails', async () => {
|
||||
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const git = vi.fn<GitExec>(async (args) => {
|
||||
@@ -113,12 +195,16 @@ describe('addWorktreeOp', () => {
|
||||
})
|
||||
|
||||
await expect(
|
||||
addWorktreeOp(git, {
|
||||
repoPath: '/repo',
|
||||
branchName: 'feature/test',
|
||||
targetDir: '/repo-feature',
|
||||
base: 'origin/main'
|
||||
})
|
||||
addWorktreeOp(
|
||||
git,
|
||||
{
|
||||
repoPath: '/repo',
|
||||
branchName: 'feature/test',
|
||||
targetDir: '/repo-feature',
|
||||
base: 'origin/main'
|
||||
},
|
||||
'linux'
|
||||
)
|
||||
).resolves.toBeUndefined()
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import * as path from 'node:path'
|
||||
import { resolveWorktreeAddBaseRef } from '../shared/worktree/base-ref'
|
||||
import { windowsLongPathGitArgs } from '../shared/windows-long-path-git-args'
|
||||
import type { GitExec } from './git-handler-ops'
|
||||
export { removeWorktreeOp } from './git-handler-worktree-remove'
|
||||
export { readRelayWorktreeList } from './git-handler-worktree-list'
|
||||
@@ -28,7 +29,12 @@ async function persistRelayWorktreeCreationBase(
|
||||
}
|
||||
}
|
||||
|
||||
export async function addWorktreeOp(git: GitExec, params: Record<string, unknown>): Promise<void> {
|
||||
export async function addWorktreeOp(
|
||||
git: GitExec,
|
||||
params: Record<string, unknown>,
|
||||
// Why: only the execution host's OS matters here — the client may be macOS while the SSH host is Windows.
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): Promise<void> {
|
||||
const repoPath = params.repoPath as string
|
||||
const branchName = params.branchName as string
|
||||
const targetDir = params.targetDir as string
|
||||
@@ -62,11 +68,14 @@ export async function addWorktreeOp(git: GitExec, params: Record<string, unknown
|
||||
})
|
||||
: undefined
|
||||
|
||||
// Why: a Windows SSH host hits the same MAX_PATH ceiling as a local Windows checkout.
|
||||
const longPathArgs = windowsLongPathGitArgs(targetDir, platform)
|
||||
const args = checkoutExistingBranch
|
||||
? ['worktree', 'add', targetDir, branchName]
|
||||
: ['worktree', 'add', '--no-track', '-b', branchName, targetDir]
|
||||
? [...longPathArgs, 'worktree', 'add', targetDir, branchName]
|
||||
: [...longPathArgs, 'worktree', 'add', '--no-track', '-b', branchName, targetDir]
|
||||
if (!checkoutExistingBranch && noCheckout) {
|
||||
args.splice(3, 0, '--no-checkout')
|
||||
// Why: offset by the global-option prefix so --no-checkout still lands before -b.
|
||||
args.splice(longPathArgs.length + 3, 0, '--no-checkout')
|
||||
}
|
||||
if (effectiveBase) {
|
||||
args.push(effectiveBase)
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { windowsLongPathGitArgs } from './windows-long-path-git-args'
|
||||
|
||||
describe('windowsLongPathGitArgs', () => {
|
||||
it('enables long paths for a Windows drive path', () => {
|
||||
expect(windowsLongPathGitArgs('C:\\Users\\dev\\repo', 'win32')).toEqual([
|
||||
'-c',
|
||||
'core.longpaths=true'
|
||||
])
|
||||
})
|
||||
|
||||
it.each(['darwin', 'linux'] as const)('returns nothing on %s', (platform) => {
|
||||
expect(windowsLongPathGitArgs('/home/dev/repo', platform)).toEqual([])
|
||||
})
|
||||
|
||||
it.each(['\\\\wsl.localhost\\Ubuntu\\home\\dev\\repo', '\\\\wsl$\\Ubuntu\\home\\dev\\repo'])(
|
||||
'returns nothing for the WSL UNC path %s',
|
||||
(cwd) => {
|
||||
expect(windowsLongPathGitArgs(cwd, 'win32')).toEqual([])
|
||||
}
|
||||
)
|
||||
|
||||
it('never mutates the shared constant', () => {
|
||||
const first = windowsLongPathGitArgs('C:\\repo', 'win32')
|
||||
first.push('--bogus')
|
||||
expect(windowsLongPathGitArgs('C:\\repo', 'win32')).toEqual(['-c', 'core.longpaths=true'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import { parseWslUncPath } from './wsl-paths'
|
||||
|
||||
const WINDOWS_LONG_PATH_GIT_ARGS = ['-c', 'core.longpaths=true'] as const
|
||||
|
||||
/**
|
||||
* Global `git -c` options that let a Windows checkout exceed MAX_PATH.
|
||||
*
|
||||
* Why command scope: Git for Windows aborts deep checkouts with "Filename too
|
||||
* long" unless core.longpaths is on, and `-c` applies it to this invocation
|
||||
* only — never `--global`, `--system`, or `--local`, so no user config is
|
||||
* written. Available since Git 1.9, well under the 2.25 baseline.
|
||||
*
|
||||
* Why keyed off cwd rather than a wslDistro option: a `C:\...` cwd can still be
|
||||
* served by host git.exe even when a distro is configured, and Linux git parses
|
||||
* and ignores the key, so only a true `\\wsl.localhost\...` path opts out.
|
||||
*/
|
||||
export function windowsLongPathGitArgs(
|
||||
cwd: string,
|
||||
platform: NodeJS.Platform = process.platform
|
||||
): string[] {
|
||||
if (platform !== 'win32' || parseWslUncPath(cwd)) {
|
||||
return []
|
||||
}
|
||||
return [...WINDOWS_LONG_PATH_GIT_ARGS]
|
||||
}
|
||||
Reference in New Issue
Block a user