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:
Neil
2026-08-22 23:00:31 -07:00
committed by GitHub
co-authored by hwantage
parent af2e825626
commit 202d74a8a4
7 changed files with 320 additions and 33 deletions
+42 -1
View File
@@ -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
+11 -3
View File
@@ -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 =
+110 -24
View File
@@ -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(
+13 -4
View File
@@ -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'])
})
})
+25
View File
@@ -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]
}