fix(wsl): run WSL git with Windows Git line-ending settings on checkouts Windows Git wrote (#27778)

* fix(wsl): run WSL git with Windows Git settings on Windows-authored drive checkouts

* fix(wsl): detect Windows Git checkouts from host autocrlf plus a whole-tree sample

* fix(wsl): keep WSL-created worktrees on Linux Git defaults and never commit CRLF on an unproven checkout

- worktree add / clone write a new checkout whose .git points at /mnt/..., which Windows Git
  cannot open; injecting autocrlf=true there produced CRLF files later read unflagged.
- Writes on a host-autocrlf=true checkout whose sample is unproven run with autocrlf=input,
  so staging normalizes CRLF without ever writing CRLF to disk.
- Writes wait for a stale host answer and re-check checkout identity after joining a sample.

* fix(wsl): compare a joined sample with the checkout identity read after it settles
This commit is contained in:
Neil
2026-10-12 18:40:06 -07:00
committed by GitHub
parent 26cd1c22da
commit a7e24766b7
13 changed files with 1136 additions and 24 deletions
@@ -0,0 +1,84 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { resolveGitCommand, resolveGitCommandWithoutProbe } from './git-command-resolution'
import {
recordHostAutocrlf,
recordLineEndingSample,
resetWslWindowsCheckoutGitConfigForTests,
wslWindowsCheckoutKey
} from '../wsl-windows-checkout-git-config'
import {
resetWslLinkedWorktreeGitRoutingForTests,
seedWslLinkedWorktreeGitRoutingForTests
} from '../wsl-linked-worktree-git-routing'
import { bulkPathspecCommands } from '../source-control/git-pathspec'
import {
commandLineLength,
MAX_COMMAND_LINE_CHARS
} from '../../../shared/windows-command-line-budget'
const originalPlatform = process.platform
const DRIVE_REPO = String.raw`C:\Users\dev\repo`
function detectWindowsCheckout(): void {
const key = wslWindowsCheckoutKey(DRIVE_REPO, 'Ubuntu')
recordHostAutocrlf(key, 'true', Date.now())
recordLineEndingSample(key, 'windows', 'clone', Date.now())
}
describe('WSL git on a Windows Git checkout', () => {
beforeEach(() => {
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
})
afterEach(() => {
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
resetWslWindowsCheckoutGitConfigForTests()
resetWslLinkedWorktreeGitRoutingForTests()
})
it('runs WSL git with Windows Git line-ending and stat settings once detected', () => {
detectWindowsCheckout()
const resolved = resolveGitCommandWithoutProbe(['add', '--', 'a.ts'], {
cwd: DRIVE_REPO,
wslDistro: 'Ubuntu'
})
expect(resolved.binary).toBe('wsl.exe')
expect(resolved.args.join(' ')).toMatch(/core\.autocrlf=true.+core\.checkStat=minimal.+add/)
expect(resolved.args.join(' ')).not.toContain('trustctime')
})
it('leaves host-routed commands and undetected checkouts unchanged', () => {
expect(
resolveGitCommandWithoutProbe(['status'], { cwd: DRIVE_REPO, wslDistro: 'Ubuntu' }).args.join(
' '
)
).not.toContain('core.autocrlf')
detectWindowsCheckout()
expect(resolveGitCommand(['status'], { cwd: DRIVE_REPO }).args).toEqual(['status'])
seedWslLinkedWorktreeGitRoutingForTests(DRIVE_REPO)
expect(resolveGitCommand(['status'], { cwd: DRIVE_REPO, wslDistro: 'Ubuntu' }).args).toEqual([
'status'
])
})
it('keeps every bulk chunk under the command-line cap once the config is prepended', () => {
const paths = Array.from({ length: 400 }, (_, index) => `src/${'deep/'.repeat(12)}f${index}.ts`)
const chunks = bulkPathspecCommands(['add', '--'], paths, DRIVE_REPO, { wslDistro: 'Ubuntu' })
detectWindowsCheckout()
expect(chunks.length).toBeGreaterThan(1)
for (const chunk of chunks) {
const resolved = resolveGitCommandWithoutProbe(chunk, {
cwd: DRIVE_REPO,
wslDistro: 'Ubuntu'
})
expect(commandLineLength([resolved.binary, ...resolved.args])).toBeLessThanOrEqual(
MAX_COMMAND_LINE_CHARS
)
}
})
})
@@ -11,9 +11,16 @@ import {
WSL_GIT_READ_ENVIRONMENT_WAIT_MS
} from '../wsl-git-read-environment'
import { usesHostGitForWslLinkedWorktree } from '../wsl-linked-worktree-git-routing'
import { withWslWindowsCheckoutGitConfig } from '../wsl-windows-checkout-git-config'
import { resolveCommand, type ResolvedCommand } from './wsl-command-resolution'
import type { GitExecOptions } from './git-exec-options'
// Why: Linux Git misreads a Windows Git checkout unless it runs with Windows Git's settings.
// Classification reads the caller's args; only the spawned argv carries the injected config.
function wslGitArgs(args: string[], options: GitExecOptions): string[] {
return withWslWindowsCheckoutGitConfig(args, options.cwd, options.wslDistro)
}
/** The WSL distro a Git command with this cwd runs in, or null when it runs on the host. */
export function wslDistroForCommand(cwd: string | undefined, override?: string): string | null {
if (process.platform !== 'win32') {
@@ -36,7 +43,7 @@ export function resolveGitCommand(
if (distro) {
const environment = peekWslGitReadEnvironment(distro)
if (environment) {
return resolveCommand('git', args, options.cwd, distro, {
return resolveCommand('git', wslGitArgs(args, options), options.cwd, distro, {
wslGitReadEnvironment: environment,
env: options.env,
terminationBarrier: options.terminationBarrier
@@ -114,7 +121,7 @@ export function resolveGitCommandWithoutProbe(
options: GitExecOptions,
captureLoginShellOutput = false
): ResolvedCommand {
return resolveCommand('git', args, options.cwd, options.wslDistro, {
return resolveCommand('git', wslGitArgs(args, options), options.cwd, options.wslDistro, {
useWslLoginShell: Boolean(options.wslDistro),
captureLoginShellOutput,
terminationBarrier: options.terminationBarrier
+7 -12
View File
@@ -5,10 +5,7 @@ import {
resolveGitFetchHeadCommand,
runWithGitFetchHeadLock
} from '../../../shared/git-fetch-head-lock'
import {
isWslLinkedWorktreeGitRoutingCandidate,
prepareWslLinkedWorktreeGitRouting
} from '../wsl-linked-worktree-git-routing'
import { pendingWslDriveCheckoutGitRouting } from '../wsl-drive-checkout-git-routing'
import { resolveCommand, type ResolvedCommand } from './wsl-command-resolution'
import { annotateWslHostFailure } from './wsl-host-failure'
import type { GitAdmissionTier, GitExecOptions } from './git-exec-options'
@@ -41,10 +38,9 @@ async function gitExecFileAsyncUnlocked(
return withGitSpan(
{ args, ...(options.cwd !== undefined ? { cwd: options.cwd } : {}) },
async (span) => {
if (isWslLinkedWorktreeGitRoutingCandidate(options.cwd, options.wslDistro)) {
await prepareWslLinkedWorktreeGitRouting(options.cwd, options.wslDistro, {
signal: options.signal
})
const checkoutRoutingReady = pendingWslDriveCheckoutGitRouting(args, options)
if (checkoutRoutingReady) {
await checkoutRoutingReady
}
const readEnvironmentReady = pendingWslDirectGitReadEnvironment(args, options)
if (readEnvironmentReady) {
@@ -205,10 +201,9 @@ export async function gitExecFileAsyncBuffer(
}
): Promise<{ stdout: Buffer }> {
return withGitSpan({ args, cwd: options.cwd }, async (span) => {
if (isWslLinkedWorktreeGitRoutingCandidate(options.cwd, options.wslDistro)) {
await prepareWslLinkedWorktreeGitRouting(options.cwd, options.wslDistro, {
signal: options.signal
})
const checkoutRoutingReady = pendingWslDriveCheckoutGitRouting(args, options)
if (checkoutRoutingReady) {
await checkoutRoutingReady
}
const readEnvironmentReady = pendingWslDirectGitReadEnvironment(args, options)
if (readEnvironmentReady) {
+5
View File
@@ -3,6 +3,7 @@ import { recordSubprocessSpawn } from '../../diagnostics/main-thread-churn-probe
import { startGitSpan } from '../../observability/instrumentation'
import { createAbortError } from './abort-error'
import { resolveGitCommand } from './git-command-resolution'
import { pendingWslDriveCheckoutGitRouting } from '../wsl-drive-checkout-git-routing'
import { untranslatedGitOutputEnv } from './git-process-env'
import { prepareWindowsHostGitEnvironment } from './windows-host-git-environment'
import type { GitAdmissionTier } from './git-exec-options'
@@ -27,6 +28,10 @@ export async function gitSpawnAfterWindowsEnvironmentReady(
if (options.signal?.aborted) {
throw createAbortError()
}
const lineEndingsReady = pendingWslDriveCheckoutGitRouting(args, options)
if (lineEndingsReady) {
await lineEndingsReady
}
const resolved = resolveGitCommand(args, {
cwd: options.cwd,
...(options.wslDistro ? { wslDistro: options.wslDistro } : {}),
@@ -2,12 +2,9 @@ import { spawn, type ChildProcess, type SpawnOptions } from 'node:child_process'
import { StringDecoder } from 'node:string_decoder'
import { withGitSpan } from '../../observability/instrumentation'
import { recordSubprocessSpawn } from '../../diagnostics/main-thread-churn-probe'
import {
isWslLinkedWorktreeGitRoutingCandidate,
prepareWslLinkedWorktreeGitRouting
} from '../wsl-linked-worktree-git-routing'
import { createAbortError } from './abort-error'
import { killSpawnedCommandTree } from './spawned-command-tree-kill'
import { pendingWslDriveCheckoutGitRouting } from '../wsl-drive-checkout-git-routing'
import type { ResolvedCommand } from './wsl-command-resolution'
import {
DEFAULT_GIT_MAX_BUFFER,
@@ -65,10 +62,9 @@ export async function gitStreamStdout(
const maxBuffer = options.maxBuffer ?? DEFAULT_GIT_MAX_BUFFER
const timeoutMs = gitCommandTimeoutMs(args, options.timeoutMs, options.timeoutMsForTest)
return withGitSpan({ args, cwd: options.cwd }, async (span) => {
if (isWslLinkedWorktreeGitRoutingCandidate(options.cwd, options.wslDistro)) {
await prepareWslLinkedWorktreeGitRouting(options.cwd, options.wslDistro, {
signal: options.signal
})
const checkoutRoutingReady = pendingWslDriveCheckoutGitRouting(args, options)
if (checkoutRoutingReady) {
await checkoutRoutingReady
}
const gitOptions: GitExecOptions = {
cwd: options.cwd,
+4 -1
View File
@@ -3,6 +3,7 @@ import {
MAX_COMMAND_LINE_CHARS
} from '../../../shared/windows-command-line-budget'
import { resolveGitCommandWithoutProbe } from '../command-runner/git-command-resolution'
import { WINDOWS_CHECKOUT_GIT_CONFIG_ARGS } from '../wsl-windows-checkout-git-config'
import type { GitRuntimeOptions } from '../git-runtime-options'
/** Ceiling on argv entries per invocation; under WSL the byte budget bites first. */
@@ -37,7 +38,9 @@ function finishedCommandLineLength(
worktreePath: string,
options: GitRuntimeOptions
): number {
const resolved = resolveGitCommandWithoutProbe([...args], {
// Why worst case: the Windows-checkout config can be prepended at spawn, after this chunking.
const budgetArgs = options.wslDistro ? [...WINDOWS_CHECKOUT_GIT_CONFIG_ARGS, ...args] : [...args]
const resolved = resolveGitCommandWithoutProbe(budgetArgs, {
cwd: worktreePath,
...(options.wslDistro ? { wslDistro: options.wslDistro } : {})
})
@@ -0,0 +1,35 @@
import type { GitAdmissionTier } from './command-runner/git-exec-options'
import {
isWslLinkedWorktreeGitRoutingCandidate,
prepareWslLinkedWorktreeGitRouting,
usesHostGitForWslLinkedWorktree
} from './wsl-linked-worktree-git-routing'
import { pendingWslWindowsCheckoutGitConfig } from './wsl-windows-checkout-git-probe'
/**
* Settles the per-checkout routing a WSL git command on a Windows drive reads synchronously at
* spawn — which Git owns a linked worktree, and whether Windows Git wrote the checkout — or null
* when the command is not one.
*/
export function pendingWslDriveCheckoutGitRouting(
args: readonly string[],
options: {
cwd: string
wslDistro?: string
signal?: AbortSignal
admissionTier?: GitAdmissionTier
}
): Promise<void> | null {
if (!isWslLinkedWorktreeGitRoutingCandidate(options.cwd, options.wslDistro)) {
return null
}
return (async () => {
await prepareWslLinkedWorktreeGitRouting(options.cwd, options.wslDistro, {
signal: options.signal
})
if (usesHostGitForWslLinkedWorktree(options.cwd, options.wslDistro)) {
return
}
await pendingWslWindowsCheckoutGitConfig(args, options)
})()
}
@@ -0,0 +1,166 @@
import { afterEach, describe, expect, it } from 'vitest'
import {
classifyLineEndingSample,
LINE_ENDING_SAMPLE_LIMIT,
parseHostAutocrlf,
recordHostAutocrlf,
recordLineEndingSample,
resetWslWindowsCheckoutGitConfigForTests,
UNPROVEN_CHECKOUT_WRITE_GIT_CONFIG_ARGS,
usesWindowsCheckoutGitConfig,
WINDOWS_CHECKOUT_GIT_CONFIG_ARGS,
withWslWindowsCheckoutGitConfig,
wslWindowsCheckoutKey,
WSL_WINDOWS_CHECKOUT_TTL_MS
} from './wsl-windows-checkout-git-config'
import {
ATTRIBUTE_PINNED_ROOT_SAMPLE,
HOST_CONFIG_LOCAL_AUTOCRLF_FALSE,
HOST_CONFIG_LOCAL_EOL_LF,
HOST_CONFIG_SYSTEM_AUTOCRLF,
LOCAL_AUTOCRLF_FALSE_CLONE_SAMPLE,
NO_TOP_LEVEL_FILES_SAMPLE,
PLAIN_WINDOWS_CLONE_SAMPLE,
PRETTIER_ROOT_LF_SAMPLE,
WSL_CLONE_WITH_CRLF_README_SAMPLE
} from './wsl-windows-checkout-line-endings.test-fixture'
afterEach(() => resetWslWindowsCheckoutGitConfigForTests())
const CWD = String.raw`C:\Users\dev\repo`
const KEY = wslWindowsCheckoutKey(CWD, 'Ubuntu')
const NOW = 1_000_000
describe('classifyLineEndingSample on captured checkouts', () => {
it.each([
['a plain Windows clone', PLAIN_WINDOWS_CLONE_SAMPLE],
['a Windows clone whose root files a formatter re-saved as LF', PRETTIER_ROOT_LF_SAMPLE],
['a repo with no top-level text files', NO_TOP_LEVEL_FILES_SAMPLE],
['root files pinned LF by attributes over CRLF sources', ATTRIBUTE_PINNED_ROOT_SAMPLE]
])('detects %s', (_name, sample) => {
expect(classifyLineEndingSample(sample, false)).toBe('windows')
})
it.each([
['a WSL clone after a Windows editor saved README as CRLF', WSL_CLONE_WITH_CRLF_README_SAMPLE],
['a Windows clone made with autocrlf=false', LOCAL_AUTOCRLF_FALSE_CLONE_SAMPLE]
])('does not flag %s', (_name, sample) => {
expect(classifyLineEndingSample(sample, false)).toBe('not-windows')
})
it('treats clipped or over-cap output as inconclusive', () => {
expect(classifyLineEndingSample(PLAIN_WINDOWS_CLONE_SAMPLE, true)).toBe('inconclusive')
const overCap = Array.from(
{ length: LINE_ENDING_SAMPLE_LIMIT + 1 },
(_, index) => `i/lf w/crlf attr/ \tsrc/f${index}.ts`
).join('\n')
expect(classifyLineEndingSample(overCap, false)).toBe('inconclusive')
})
it('needs a minimum amount of evidence', () => {
const fewFiles = PLAIN_WINDOWS_CLONE_SAMPLE.split('\n').slice(0, 4).join('\n')
expect(classifyLineEndingSample(fewFiles, false)).toBe('inconclusive')
expect(classifyLineEndingSample('', false)).toBe('inconclusive')
})
})
describe('parseHostAutocrlf', () => {
it('reads the effective value with the last scope winning', () => {
expect(parseHostAutocrlf(HOST_CONFIG_SYSTEM_AUTOCRLF)).toBe('true')
expect(parseHostAutocrlf(HOST_CONFIG_LOCAL_AUTOCRLF_FALSE)).toBe('other')
expect(parseHostAutocrlf('')).toBe('other')
})
it('ignores core.eol, which Windows Git overrides when autocrlf=true', () => {
expect(parseHostAutocrlf(HOST_CONFIG_LOCAL_EOL_LF)).toBe('true')
})
it('accepts every Git boolean spelling and the bare key', () => {
expect(parseHostAutocrlf('file:.git/config\tcore.autocrlf yes\n')).toBe('true')
expect(parseHostAutocrlf('file:.git/config\tcore.autocrlf\n')).toBe('true')
expect(parseHostAutocrlf('file:.git/config\tcore.autocrlf input\n')).toBe('other')
})
})
describe('which commands run with Windows Git settings', () => {
const read = ['-c', 'core.quotePath=false', 'status', '--porcelain=v2']
const write = ['checkout', '--', 'README.md']
it('flags nothing unless the host Git would write CRLF', () => {
recordHostAutocrlf(KEY, 'other', NOW)
recordLineEndingSample(KEY, 'windows', 'id', NOW)
expect(usesWindowsCheckoutGitConfig(read, undefined, NOW)).toBe(false)
expect(withWslWindowsCheckoutGitConfig(read, CWD, 'Ubuntu', 'win32', NOW)).toEqual(read)
})
it('flags reads as soon as the host answers, before the sample', () => {
recordHostAutocrlf(KEY, 'true', NOW)
expect(withWslWindowsCheckoutGitConfig(read, CWD, 'Ubuntu', 'win32', NOW)).toEqual([
...WINDOWS_CHECKOUT_GIT_CONFIG_ARGS,
...read
])
expect(withWslWindowsCheckoutGitConfig(write, CWD, 'Ubuntu', 'win32', NOW)).toEqual([
...UNPROVEN_CHECKOUT_WRITE_GIT_CONFIG_ARGS,
...write
])
})
it('stops flagging reads once the sample proves an LF checkout', () => {
recordHostAutocrlf(KEY, 'true', NOW)
recordLineEndingSample(KEY, 'not-windows', 'id', NOW)
expect(withWslWindowsCheckoutGitConfig(read, CWD, 'Ubuntu', 'win32', NOW)).toEqual(read)
})
it('flags writes only on a fresh Windows answer', () => {
recordHostAutocrlf(KEY, 'true', NOW)
recordLineEndingSample(KEY, 'windows', 'id', NOW)
expect(withWslWindowsCheckoutGitConfig(write, CWD, 'Ubuntu', 'win32', NOW)[1]).toBe(
'core.autocrlf=true'
)
const stale = NOW + WSL_WINDOWS_CHECKOUT_TTL_MS
expect(withWslWindowsCheckoutGitConfig(write, CWD, 'Ubuntu', 'win32', stale)).toEqual([
...UNPROVEN_CHECKOUT_WRITE_GIT_CONFIG_ARGS,
...write
])
expect(withWslWindowsCheckoutGitConfig(read, CWD, 'Ubuntu', 'win32', stale)[1]).toBe(
'core.autocrlf=true'
)
})
it('only normalizes staged content on an inconclusive sample, never writing CRLF', () => {
recordHostAutocrlf(KEY, 'true', NOW)
recordLineEndingSample(KEY, 'inconclusive', 'id', NOW)
expect(withWslWindowsCheckoutGitConfig(write, CWD, 'Ubuntu', 'win32', NOW)).toEqual([
'-c',
'core.autocrlf=input',
...write
])
})
it('leaves writes unflagged once the sample proves an LF checkout', () => {
recordHostAutocrlf(KEY, 'true', NOW)
recordLineEndingSample(KEY, 'not-windows', 'id', NOW)
expect(withWslWindowsCheckoutGitConfig(write, CWD, 'Ubuntu', 'win32', NOW)).toEqual(write)
})
it('never flags commands that create another checkout', () => {
recordHostAutocrlf(KEY, 'true', NOW)
recordLineEndingSample(KEY, 'windows', 'id', NOW)
const worktreeAdd = ['-c', 'core.longpaths=true', 'worktree', 'add', '-b', 'x', '../x']
expect(withWslWindowsCheckoutGitConfig(worktreeAdd, CWD, 'Ubuntu', 'win32', NOW)).toEqual(
worktreeAdd
)
const clone = ['clone', 'https://example.com/r.git']
expect(withWslWindowsCheckoutGitConfig(clone, CWD, 'Ubuntu', 'win32', NOW)).toEqual(clone)
})
it('keys case-insensitively and per distro, and ignores non-candidates', () => {
recordHostAutocrlf(KEY, 'true', NOW)
expect(
withWslWindowsCheckoutGitConfig(read, 'c:/users/DEV/repo', 'ubuntu', 'win32', NOW)[1]
).toBe('core.autocrlf=true')
expect(withWslWindowsCheckoutGitConfig(read, CWD, 'Debian', 'win32', NOW)).toEqual(read)
expect(withWslWindowsCheckoutGitConfig(read, CWD, 'Ubuntu', 'linux', NOW)).toEqual(read)
expect(withWslWindowsCheckoutGitConfig(read, CWD, undefined, 'win32', NOW)).toEqual(read)
})
})
@@ -0,0 +1,220 @@
import { win32 } from 'node:path'
import { findGitSubcommandIndex, isWslDirectGitReadCommand } from './wsl-direct-git-read-commands'
/**
* Per-command config that makes Linux Git read a Windows Git checkout the way Windows Git does.
*
* - core.autocrlf=true: CRLF working files compare equal to LF blobs, and staging writes LF;
* without it every rehashed file reads as modified and `add` commits CRLF.
* - core.checkStat=minimal: an index Windows Git wrote carries no inode/uid/ctime Linux Git
* agrees with, so every entry otherwise rehashes across drvfs (~70 s on 11k files). It also
* skips the ctime check, so `core.trustctime` is moot here.
*
* Both predate the Git 2.25 baseline, so no capability probe is needed.
*/
export const WINDOWS_CHECKOUT_GIT_CONFIG_ARGS: readonly string[] = [
'-c',
'core.autocrlf=true',
'-c',
'core.checkStat=minimal'
]
/**
* Writes while the sample has not proven which Git wrote the checkout: `input` stages LF like
* Windows Git would, yet never writes CRLF into what may be a Linux checkout.
*/
export const UNPROVEN_CHECKOUT_WRITE_GIT_CONFIG_ARGS: readonly string[] = [
'-c',
'core.autocrlf=input'
]
// Why: these write a new checkout whose `.git` points at /mnt/..., which Windows Git cannot open,
// so its probe answers not-repo and CRLF files written here would read as modified forever.
const OTHER_CHECKOUT_SUBCOMMANDS = new Set(['clone', 'worktree'])
export function writesAnotherCheckout(args: readonly string[]): boolean {
const subcommandIndex = findGitSubcommandIndex(args)
return subcommandIndex !== -1 && OTHER_CHECKOUT_SUBCOMMANDS.has(args[subcommandIndex])
}
/** Deliberate cap: ~200 eligible files decide the checkout without reading the whole tree over drvfs. */
export const LINE_ENDING_SAMPLE_LIMIT = 200
// Fewer eligible files than this is no evidence either way.
export const LINE_ENDING_MIN_EVIDENCE = 5
// Why a TTL: a re-clone at the same path can change the answer.
export const WSL_WINDOWS_CHECKOUT_TTL_MS = 10 * 60_000
const CACHE_LIMIT = 256
export type HostAutocrlfAnswer = 'true' | 'other' | 'not-repo'
export type LineEndingSampleAnswer = 'windows' | 'not-windows' | 'inconclusive'
type Timed<T> = { value: T; at: number }
type CheckoutState = {
host?: Timed<HostAutocrlfAnswer>
sample?: Timed<LineEndingSampleAnswer> & { identity: string | null }
}
const states = new Map<string, CheckoutState>()
export function isWslWindowsCheckoutCandidate(
cwd: string | undefined,
wslDistro: string | undefined,
platform: NodeJS.Platform = process.platform
): cwd is string {
return platform === 'win32' && Boolean(wslDistro) && Boolean(cwd && /^[A-Za-z]:[/\\]/.test(cwd))
}
export function wslWindowsCheckoutKey(cwd: string, wslDistro: string): string {
return `${wslDistro.toLowerCase()}\0${win32.resolve(cwd).toLowerCase()}`
}
export function getWslWindowsCheckoutState(key: string): Readonly<CheckoutState> | undefined {
return states.get(key)
}
function updateState(key: string, patch: CheckoutState): void {
const next = { ...states.get(key), ...patch }
states.delete(key)
if (states.size >= CACHE_LIMIT) {
const oldest = states.keys().next().value
if (oldest !== undefined) {
states.delete(oldest)
}
}
states.set(key, next)
}
export function recordHostAutocrlf(key: string, value: HostAutocrlfAnswer, now: number): void {
updateState(key, { host: { value, at: now } })
}
export function recordLineEndingSample(
key: string,
value: LineEndingSampleAnswer,
identity: string | null,
now: number
): void {
updateState(key, { sample: { value, at: now, identity } })
}
export function forgetLineEndingSample(key: string): void {
const state = states.get(key)
if (state?.sample) {
states.set(key, { ...state, sample: undefined })
}
}
export function isFresh(entry: { at: number } | undefined, now: number): boolean {
return entry !== undefined && now - entry.at < WSL_WINDOWS_CHECKOUT_TTL_MS
}
/**
* Effective `core.autocrlf` from `git config --show-origin --get-regexp`, the way Windows Git
* resolves it (last value wins). Only `true` makes Windows Git write CRLF.
*
* Why core.eol is ignored: with autocrlf=true Windows Git checks out CRLF regardless of core.eol
* (measured: local core.eol=lf still leaves Windows Git and the flagged WSL Git both clean).
*/
export function parseHostAutocrlf(showOriginOutput: string): 'true' | 'other' {
let value: string | null = null
for (const line of showOriginOutput.split(/\r?\n/)) {
const match = /\tcore\.autocrlf(?:\s+(.*))?$/i.exec(line)
if (match) {
value = (match[1] ?? 'true').trim().toLowerCase()
}
}
return value !== null && ['true', 'yes', 'on', '1'].includes(value) ? 'true' : 'other'
}
/**
* Classifies a bounded `ls-files --eol` sample of LF blobs with no eol attribute.
*
* Whole-tree rather than top-level: root files are the ones formatters re-save as LF, the ones
* attributes pin, and absent in some repos. Majority of CRLF working files means Windows Git
* (autocrlf=true) wrote this checkout; one CRLF README in a Linux checkout does not.
*/
export function classifyLineEndingSample(
output: string,
outputTruncated: boolean
): LineEndingSampleAnswer {
const lines = output.split('\n').filter((line) => line.length > 0)
if (outputTruncated || lines.length > LINE_ENDING_SAMPLE_LIMIT) {
return 'inconclusive'
}
let crlf = 0
let lf = 0
for (const line of lines) {
const match = /^i\/(\S*)\s+w\/(\S*)\s+attr\/([^\t]*)\t/.exec(line)
if (!match || match[1] !== 'lf' || match[3].trim() !== '') {
continue
}
if (match[2] === 'crlf') {
crlf += 1
} else if (match[2] === 'lf') {
lf += 1
}
}
if (crlf + lf < LINE_ENDING_MIN_EVIDENCE) {
return 'inconclusive'
}
return crlf * 2 > crlf + lf ? 'windows' : 'not-windows'
}
/**
* Whether this command runs with Windows Git's settings.
*
* Reads need only the host's autocrlf=true: the flags never make a read less correct, even on an
* LF checkout, so an unfinished sample must not leave status flashing every file as modified.
* Writes change bytes on disk or in the index, so they need a fresh sample proving CRLF; until then
* they only normalize what they stage.
*/
export function windowsCheckoutGitConfigArgs(
args: readonly string[],
state: Readonly<CheckoutState> | undefined,
now: number
): readonly string[] {
if (state?.host?.value !== 'true' || state.sample?.value === 'not-windows') {
return []
}
if (isWslDirectGitReadCommand(args)) {
return WINDOWS_CHECKOUT_GIT_CONFIG_ARGS
}
if (writesAnotherCheckout(args)) {
return []
}
return state.sample?.value === 'windows' && isFresh(state.sample, now)
? WINDOWS_CHECKOUT_GIT_CONFIG_ARGS
: UNPROVEN_CHECKOUT_WRITE_GIT_CONFIG_ARGS
}
/** Whether this command runs with Windows Git's full settings, including minimal stat. */
export function usesWindowsCheckoutGitConfig(
args: readonly string[],
state: Readonly<CheckoutState> | undefined,
now: number
): boolean {
return windowsCheckoutGitConfigArgs(args, state, now) === WINDOWS_CHECKOUT_GIT_CONFIG_ARGS
}
/** `args` with Windows Git's settings prepended when this WSL command needs them. */
export function withWslWindowsCheckoutGitConfig(
args: string[],
cwd: string | undefined,
wslDistro: string | undefined,
platform: NodeJS.Platform = process.platform,
now: number = Date.now()
): string[] {
if (!isWslWindowsCheckoutCandidate(cwd, wslDistro, platform) || !wslDistro) {
return args
}
const config = windowsCheckoutGitConfigArgs(
args,
states.get(wslWindowsCheckoutKey(cwd, wslDistro)),
now
)
return config.length > 0 ? [...config, ...args] : args
}
export function resetWslWindowsCheckoutGitConfigForTests(): void {
states.clear()
}
@@ -0,0 +1,264 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { WslResult } from '../wsl/wsl-runner'
import {
pendingWslWindowsCheckoutGitConfig,
resetWslWindowsCheckoutProbesForTests,
type HostGitResult,
type WslWindowsCheckoutProbeDeps
} from './wsl-windows-checkout-git-probe'
import {
resetWslWindowsCheckoutGitConfigForTests,
withWslWindowsCheckoutGitConfig,
WSL_WINDOWS_CHECKOUT_TTL_MS
} from './wsl-windows-checkout-git-config'
import {
HOST_CONFIG_LOCAL_AUTOCRLF_FALSE,
HOST_CONFIG_SYSTEM_AUTOCRLF,
PLAIN_WINDOWS_CLONE_SAMPLE,
WSL_CLONE_WITH_CRLF_README_SAMPLE
} from './wsl-windows-checkout-line-endings.test-fixture'
afterEach(() => {
resetWslWindowsCheckoutGitConfigForTests()
resetWslWindowsCheckoutProbesForTests()
})
const CWD = String.raw`C:\Users\dev\repo`
const READ = ['status', '--porcelain=v2']
const WRITE = ['checkout', '--', 'README.md']
function host(overrides: Partial<HostGitResult> = {}): HostGitResult {
return { code: 0, stdout: HOST_CONFIG_SYSTEM_AUTOCRLF, stderr: '', timedOut: false, ...overrides }
}
function wsl(overrides: Partial<WslResult> = {}): WslResult {
return {
environmentResolved: true,
code: 0,
stdout: PLAIN_WINDOWS_CLONE_SAMPLE,
stderr: '',
timedOut: false,
outputTruncated: false,
...overrides
}
}
type HostRunner = NonNullable<WslWindowsCheckoutProbeDeps['runHostGit']>
type WslRunner = NonNullable<WslWindowsCheckoutProbeDeps['runWsl']>
type IdentityReader = NonNullable<WslWindowsCheckoutProbeDeps['checkoutIdentity']>
function harness(
overrides: { runHostGit?: HostRunner; runWsl?: WslRunner; checkoutIdentity?: IdentityReader } = {}
) {
const clock = { now: 1_000_000 }
const deps = {
platform: 'win32' as const,
now: () => clock.now,
runHostGit: vi.fn<HostRunner>(overrides.runHostGit ?? (async () => host())),
runWsl: vi.fn<WslRunner>(overrides.runWsl ?? (async () => wsl())),
checkoutIdentity: vi.fn<IdentityReader>(overrides.checkoutIdentity ?? (async () => 'clone-1')),
admit: vi.fn(async () => ({ queueWaitMs: 0, release: vi.fn() }))
}
const run = (args: string[], signal?: AbortSignal) =>
pendingWslWindowsCheckoutGitConfig(args, { cwd: CWD, wslDistro: 'Ubuntu', signal }, deps)
const flagged = (args: string[]) =>
withWslWindowsCheckoutGitConfig(args, CWD, 'Ubuntu', 'win32', clock.now).includes(
'core.autocrlf=true'
)
return { clock, deps, run, flagged }
}
describe('pendingWslWindowsCheckoutGitConfig', () => {
it('ignores commands that are not WSL git on a Windows drive', () => {
const { deps } = harness()
expect(pendingWslWindowsCheckoutGitConfig(READ, { cwd: CWD }, deps)).toBeNull()
expect(
pendingWslWindowsCheckoutGitConfig(
READ,
{ cwd: String.raw`\\wsl.localhost\Ubuntu\home\dev\repo`, wslDistro: 'Ubuntu' },
deps
)
).toBeNull()
expect(deps.runHostGit).not.toHaveBeenCalled()
})
it('flags the first read from the host answer alone, without waiting for the distro', async () => {
const { deps, run, flagged } = harness({
runWsl: vi.fn(() => new Promise<WslResult>(() => {}))
})
await run(READ)
expect(flagged(READ)).toBe(true)
expect(flagged(WRITE)).toBe(false)
await vi.waitFor(() => expect(deps.runWsl).toHaveBeenCalledTimes(1))
})
it('never samples when host Git would not write CRLF', async () => {
const { deps, run, flagged } = harness({
runHostGit: vi.fn(async () => host({ stdout: HOST_CONFIG_LOCAL_AUTOCRLF_FALSE }))
})
await run(WRITE)
expect(flagged(READ)).toBe(false)
expect(deps.runWsl).not.toHaveBeenCalled()
})
it('treats an unset autocrlf (exit 1, quiet) as settled', async () => {
const { deps, run } = harness({ runHostGit: vi.fn(async () => host({ code: 1, stdout: '' })) })
await run(READ)
await run(READ)
expect(deps.runHostGit).toHaveBeenCalledTimes(1)
})
it('waits for a fresh sample before a write and flags it on a Windows checkout', async () => {
const { run, flagged } = harness()
await run(WRITE)
expect(flagged(WRITE)).toBe(true)
})
it('does not flag writes on a WSL checkout with one CRLF file', async () => {
const { run, flagged } = harness({
runWsl: vi.fn(async () => wsl({ stdout: WSL_CLONE_WITH_CRLF_README_SAMPLE }))
})
await run(WRITE)
expect(flagged(WRITE)).toBe(false)
expect(flagged(READ)).toBe(false)
})
it('treats clipped sample output as inconclusive', async () => {
const { run, flagged } = harness({ runWsl: vi.fn(async () => wsl({ outputTruncated: true })) })
await run(WRITE)
expect(flagged(WRITE)).toBe(false)
})
it('re-samples before a write once the answer is stale', async () => {
const { clock, deps, run, flagged } = harness()
await run(WRITE)
clock.now += WSL_WINDOWS_CHECKOUT_TTL_MS
deps.runWsl.mockResolvedValueOnce(wsl({ stdout: WSL_CLONE_WITH_CRLF_README_SAMPLE }))
await run(WRITE)
expect(deps.runWsl).toHaveBeenCalledTimes(2)
expect(flagged(WRITE)).toBe(false)
})
it('re-samples before a write when the checkout was re-cloned at the same path', async () => {
const { deps, run, flagged } = harness()
await run(WRITE)
deps.checkoutIdentity.mockResolvedValue('clone-2')
deps.runWsl.mockResolvedValueOnce(wsl({ stdout: WSL_CLONE_WITH_CRLF_README_SAMPLE }))
await run(WRITE)
expect(deps.runWsl).toHaveBeenCalledTimes(2)
expect(flagged(WRITE)).toBe(false)
})
it('does not trust a joined sample that began before the checkout was replaced', async () => {
let finishSample: (result: WslResult) => void = () => {}
const { deps, run, flagged } = harness({
runWsl: vi.fn(() => new Promise<WslResult>((resolve) => (finishSample = resolve)))
})
await run(READ)
await vi.waitFor(() => expect(deps.runWsl).toHaveBeenCalledTimes(1))
deps.checkoutIdentity.mockResolvedValue('clone-2')
const write = run(WRITE)
await vi.waitFor(() => expect(deps.checkoutIdentity).toHaveBeenCalledTimes(2))
finishSample(wsl())
await write
expect(flagged(WRITE)).toBe(false)
expect(withWslWindowsCheckoutGitConfig(WRITE, CWD, 'Ubuntu', 'win32', Date.now())).toEqual([
'-c',
'core.autocrlf=input',
...WRITE
])
})
it('keeps a sample taken of the checkout that replaced the one the write first saw', async () => {
const { deps, run, flagged } = harness({ checkoutIdentity: async () => 'clone-2' })
deps.checkoutIdentity.mockResolvedValueOnce('clone-1')
await run(WRITE)
expect(flagged(WRITE)).toBe(true)
})
it('refreshes a stale host answer before a write rather than in the background', async () => {
const { clock, deps, run, flagged } = harness()
await run(WRITE)
clock.now += WSL_WINDOWS_CHECKOUT_TTL_MS
deps.runHostGit.mockResolvedValue(host({ stdout: HOST_CONFIG_LOCAL_AUTOCRLF_FALSE }))
await run(WRITE)
expect(deps.runHostGit).toHaveBeenCalledTimes(2)
expect(flagged(WRITE)).toBe(false)
expect(flagged(READ)).toBe(false)
})
it('never probes for a command that creates another checkout', async () => {
const { deps, run } = harness()
await run(['worktree', 'add', '-b', 'feature', String.raw`C:\Users\dev\feature`])
expect(deps.runHostGit).not.toHaveBeenCalled()
expect(deps.runWsl).not.toHaveBeenCalled()
})
it('caches only a genuine "not a git repository" and backs off other fatal errors', async () => {
const notRepo = harness({
runHostGit: vi.fn(async () =>
host({ code: 128, stdout: '', stderr: 'fatal: not a git repository (or any parent)' })
)
})
await notRepo.run(READ)
await notRepo.run(READ)
expect(notRepo.deps.runHostGit).toHaveBeenCalledTimes(1)
resetWslWindowsCheckoutGitConfigForTests()
const dubious = harness({
runHostGit: vi.fn(async () =>
host({ code: 128, stdout: '', stderr: 'fatal: detected dubious ownership in repository' })
)
})
await dubious.run(READ)
expect(dubious.flagged(READ)).toBe(false)
await dubious.run(READ)
expect(dubious.deps.runHostGit).toHaveBeenCalledTimes(1)
dubious.clock.now += 30_000
dubious.deps.runHostGit.mockResolvedValueOnce(host())
await dubious.run(READ)
expect(dubious.flagged(READ)).toBe(true)
})
it('backs off a sample that fails for any reason other than "not a git repository"', async () => {
const { clock, deps, run, flagged } = harness({
runWsl: vi.fn(async () => wsl({ code: 3, stdout: '', stderr: 'fatal: unable to read index' }))
})
await run(WRITE)
await run(WRITE)
expect(deps.runWsl).toHaveBeenCalledTimes(1)
expect(flagged(WRITE)).toBe(false)
clock.now += 30_000
await run(WRITE)
expect(deps.runWsl).toHaveBeenCalledTimes(2)
})
it('admits every probe through the git scheduler and shares concurrent probes', async () => {
const { deps, run } = harness()
await Promise.all([run(WRITE), run(WRITE), run(READ)])
expect(deps.runHostGit).toHaveBeenCalledTimes(1)
expect(deps.runWsl).toHaveBeenCalledTimes(1)
expect(deps.admit).toHaveBeenCalledTimes(2)
expect(deps.admit).toHaveBeenCalledWith(expect.objectContaining({ wslDistro: 'Ubuntu' }))
})
it('stops waiting when the command aborts', async () => {
const { run } = harness({ runHostGit: vi.fn(() => new Promise<HostGitResult>(() => {})) })
const controller = new AbortController()
const waiting = run(READ, controller.signal)
controller.abort()
await expect(waiting).resolves.toBeUndefined()
})
it('samples the whole tree through argv, bounded by a deliberate cap', async () => {
const { deps, run } = harness()
await run(WRITE)
expect(deps.runWsl).toHaveBeenCalledWith(
expect.objectContaining({
distro: 'Ubuntu',
args: ['/mnt/c/Users/dev/repo', '200'],
loginPath: 'preferred'
})
)
})
})
@@ -0,0 +1,304 @@
import { stat } from 'node:fs/promises'
import { win32 } from 'node:path'
import { runProcess } from '@orca/process-host'
import { resolveSelectedLocalCommand } from '../ipc/command-path-resolver'
import { runWslProcess, type WslResult, type WslSpec } from '../wsl/wsl-runner'
import { translateArgForWsl } from './command-runner/wsl-path-translation'
import { acquireGitAdmission } from './command-runner/git-subprocess-admission'
import { nonInteractiveGitEnv } from './command-runner/git-process-env'
import type { GitAdmissionTier } from './command-runner/git-exec-options'
import { isWslDirectGitReadCommand } from './wsl-direct-git-read-commands'
import {
classifyLineEndingSample,
forgetLineEndingSample,
getWslWindowsCheckoutState,
isFresh,
isWslWindowsCheckoutCandidate,
LINE_ENDING_SAMPLE_LIMIT,
parseHostAutocrlf,
recordHostAutocrlf,
recordLineEndingSample,
writesAnotherCheckout,
wslWindowsCheckoutKey,
type HostAutocrlfAnswer,
type LineEndingSampleAnswer
} from './wsl-windows-checkout-git-config'
const HOST_PROBE_TIMEOUT_MS = 5_000
const SAMPLE_PROBE_TIMEOUT_MS = 15_000
const RETRY_BASE_MS = 30_000
const RETRY_MAX_MS = 10 * 60_000
const HOST_CONFIG_ARGS = ['config', '--show-origin', '--get-regexp', '^core\\.(autocrlf|eol)$']
// Exit 3 carries rev-parse's own diagnostic; the pipeline is bounded by head, not by git.
const SAMPLE_SCRIPT = [
'git -C "$1" rev-parse --is-inside-work-tree >/dev/null || exit 3',
"tab=$(printf '\\t')",
'git -C "$1" ls-files --eol | grep "^i/lf *w/[a-z]* *attr/ *$tab" | head -n "$2"'
].join('\n')
export type HostGitResult = {
code: number | null
stdout: string
stderr: string
timedOut: boolean
}
export type WslWindowsCheckoutProbeDeps = {
platform?: NodeJS.Platform
now?: () => number
runHostGit?: (cwd: string, args: string[]) => Promise<HostGitResult>
runWsl?: (spec: WslSpec) => Promise<WslResult>
checkoutIdentity?: (cwd: string) => Promise<string | null>
admit?: typeof acquireGitAdmission
}
type ProbeOptions = { cwd: string; wslDistro: string; tier?: GitAdmissionTier }
type Stage = 'host' | 'sample'
const pending = new Map<string, Promise<void>>()
const failures = new Map<string, { count: number; retryAfter: number }>()
function isNotARepository(stderr: string): boolean {
return /not a git repository/i.test(stderr)
}
async function defaultRunHostGit(cwd: string, args: string[]): Promise<HostGitResult> {
const env = nonInteractiveGitEnv(process.env)
const result = await runProcess({
program: resolveSelectedLocalCommand('git', { env, cwd }),
args: ['-C', cwd, ...args],
env,
timeoutMs: HOST_PROBE_TIMEOUT_MS,
maxOutputBytes: 64 * 1024
})
return {
code: result.code,
stdout: result.stdout,
stderr: result.stderr,
timedOut: result.timedOut
}
}
/** Changes when the checkout is deleted and re-cloned at the same path. */
async function defaultCheckoutIdentity(cwd: string): Promise<string | null> {
try {
const marker = await stat(win32.join(cwd, '.git'), { bigint: true })
return `${marker.ino}:${marker.birthtimeNs}`
} catch {
return null
}
}
function rememberFailure(key: string, now: number): void {
const count = (failures.get(key)?.count ?? 0) + 1
failures.set(key, {
count,
retryAfter: now + Math.min(RETRY_BASE_MS * 2 ** (count - 1), RETRY_MAX_MS)
})
}
async function admitted<T>(
deps: WslWindowsCheckoutProbeDeps,
request: { args: string[]; cwd: string; wslDistro?: string; tier?: GitAdmissionTier },
work: () => Promise<T>
): Promise<T> {
// Why: hundreds of cold worktrees must queue behind the git scheduler, not spawn at once.
const grant = await (deps.admit ?? acquireGitAdmission)(request)
try {
return await work()
} finally {
grant.release()
}
}
async function probeHost(
key: string,
options: ProbeOptions,
deps: WslWindowsCheckoutProbeDeps
): Promise<void> {
const now = deps.now ?? Date.now
const result = await admitted(
deps,
{ args: HOST_CONFIG_ARGS, cwd: options.cwd, tier: options.tier },
() => (deps.runHostGit ?? defaultRunHostGit)(options.cwd, HOST_CONFIG_ARGS)
).catch(() => null)
let answer: HostAutocrlfAnswer | null = null
if (result && !result.timedOut) {
// Exit 1 is `--get-regexp` finding no value: autocrlf unset, which is a settled answer.
if (result.code === 0 || (result.code === 1 && result.stderr.trim() === '')) {
answer = parseHostAutocrlf(result.stdout)
} else if (isNotARepository(result.stderr)) {
answer = 'not-repo'
}
}
if (answer === null) {
// Why: no host git, dubious ownership, or a timeout is "unknown", never "not Windows".
rememberFailure(`host\0${key}`, now())
return
}
failures.delete(`host\0${key}`)
recordHostAutocrlf(key, answer, now())
}
async function probeSample(
key: string,
options: ProbeOptions,
deps: WslWindowsCheckoutProbeDeps
): Promise<void> {
const now = deps.now ?? Date.now
const identity = await (deps.checkoutIdentity ?? defaultCheckoutIdentity)(options.cwd)
const result = await admitted(
deps,
{
args: ['ls-files', '--eol'],
cwd: options.cwd,
wslDistro: options.wslDistro,
tier: options.tier
},
() =>
(deps.runWsl ?? runWslProcess)({
distro: options.wslDistro,
script: SAMPLE_SCRIPT,
args: [translateArgForWsl(options.cwd), String(LINE_ENDING_SAMPLE_LIMIT)],
loginPath: 'preferred',
timeoutMs: SAMPLE_PROBE_TIMEOUT_MS,
maxOutputBytes: 1024 * 1024
})
).catch(() => null)
let answer: LineEndingSampleAnswer | null = null
if (result && !result.timedOut) {
if (result.code === 0) {
answer = classifyLineEndingSample(result.stdout, result.outputTruncated === true)
} else if (result.code === 3 && isNotARepository(result.stderr)) {
answer = 'not-windows'
}
}
if (answer === null) {
rememberFailure(`sample\0${key}`, now())
return
}
failures.delete(`sample\0${key}`)
recordLineEndingSample(key, answer, identity, now())
}
function runStage(
stage: Stage,
key: string,
options: ProbeOptions,
deps: WslWindowsCheckoutProbeDeps
): Promise<void> | null {
const pendingKey = `${stage}\0${key}`
const running = pending.get(pendingKey)
if (running) {
return running
}
const failure = failures.get(pendingKey)
if (failure && failure.retryAfter > (deps.now ?? Date.now)()) {
return null
}
const started = (stage === 'host' ? probeHost : probeSample)(key, options, deps).finally(() =>
pending.delete(pendingKey)
)
pending.set(pendingKey, started)
return started
}
function waitUnlessAborted(
work: Promise<void> | null,
signal: AbortSignal | undefined
): Promise<void> {
if (!work || !signal) {
return work ?? Promise.resolve()
}
// Why: an aborted command stops waiting; the shared probe keeps running for the next caller.
return new Promise<void>((resolve) => {
const done = (): void => {
signal.removeEventListener('abort', done)
resolve()
}
if (signal.aborted) {
resolve()
return
}
signal.addEventListener('abort', done, { once: true })
void work.then(done)
})
}
async function settle(
args: readonly string[],
key: string,
options: ProbeOptions,
signal: AbortSignal | undefined,
deps: WslWindowsCheckoutProbeDeps
): Promise<void> {
const now = deps.now ?? Date.now
const isRead = isWslDirectGitReadCommand(args)
if (!isRead && writesAnotherCheckout(args)) {
return
}
let state = getWslWindowsCheckoutState(key)
if (!state?.host || (!isRead && !isFresh(state.host, now()))) {
await waitUnlessAborted(runStage('host', key, options, deps), signal)
} else if (!isFresh(state.host, now())) {
void runStage('host', key, options, deps)
}
state = getWslWindowsCheckoutState(key)
if (state?.host?.value !== 'true') {
return
}
if (isRead) {
// Reads are already correct with the flags; the sample only has to catch up for later writes.
if (!isFresh(state.sample, now())) {
void runStage('sample', key, options, deps)
}
return
}
const identityOf = deps.checkoutIdentity ?? defaultCheckoutIdentity
const identity = await identityOf(options.cwd)
if (state.sample && identity !== state.sample.identity) {
forgetLineEndingSample(key)
}
if (!isFresh(getWslWindowsCheckoutState(key)?.sample, now())) {
await waitUnlessAborted(runStage('sample', key, options, deps), signal)
// Why: a joined sample may have started before the checkout was replaced at this path.
if (getWslWindowsCheckoutState(key)?.sample?.identity !== (await identityOf(options.cwd))) {
forgetLineEndingSample(key)
}
}
}
/**
* Settles what a WSL git command on a Windows drive needs to know about its checkout, or null when
* the command is not one. Never rejects; an unknown answer runs the command unflagged.
*/
export function pendingWslWindowsCheckoutGitConfig(
args: readonly string[],
options: {
cwd: string
wslDistro?: string
signal?: AbortSignal
admissionTier?: GitAdmissionTier
},
deps: WslWindowsCheckoutProbeDeps = {}
): Promise<void> | null {
const { cwd, wslDistro } = options
if (
!isWslWindowsCheckoutCandidate(cwd, wslDistro, deps.platform ?? process.platform) ||
!wslDistro
) {
return null
}
return settle(
args,
wslWindowsCheckoutKey(cwd, wslDistro),
{ cwd, wslDistro, tier: options.admissionTier },
options.signal,
deps
)
}
export function resetWslWindowsCheckoutProbesForTests(): void {
pending.clear()
failures.clear()
}
@@ -0,0 +1,30 @@
// Captured on Windows 11 + WSL 2 (Ubuntu, Git 2.53; Windows Git 2.55, system core.autocrlf=true).
// Samples are the probe's own output: `ls-files --eol` over the whole tree, filtered to LF blobs
// with no eol attribute. Each scenario repo has 40 nested files plus the root files named.
export const PRETTIER_ROOT_LF_SAMPLE =
'i/lf w/lf attr/ \t.prettierrc\ni/lf w/lf attr/ \tREADME.md\ni/lf w/lf attr/ \tpackage.json\ni/lf w/crlf attr/ \tsrc/mod1/file1.ts\ni/lf w/crlf attr/ \tsrc/mod10/file10.ts\ni/lf w/crlf attr/ \tsrc/mod11/file11.ts\ni/lf w/crlf attr/ \tsrc/mod12/file12.ts\ni/lf w/crlf attr/ \tsrc/mod13/file13.ts\ni/lf w/crlf attr/ \tsrc/mod14/file14.ts\ni/lf w/crlf attr/ \tsrc/mod15/file15.ts\ni/lf w/crlf attr/ \tsrc/mod16/file16.ts\ni/lf w/crlf attr/ \tsrc/mod17/file17.ts\ni/lf w/crlf attr/ \tsrc/mod18/file18.ts\ni/lf w/crlf attr/ \tsrc/mod19/file19.ts\ni/lf w/crlf attr/ \tsrc/mod2/file2.ts\ni/lf w/crlf attr/ \tsrc/mod20/file20.ts\ni/lf w/crlf attr/ \tsrc/mod21/file21.ts\ni/lf w/crlf attr/ \tsrc/mod22/file22.ts\ni/lf w/crlf attr/ \tsrc/mod23/file23.ts\ni/lf w/crlf attr/ \tsrc/mod24/file24.ts\ni/lf w/crlf attr/ \tsrc/mod25/file25.ts\ni/lf w/crlf attr/ \tsrc/mod26/file26.ts\ni/lf w/crlf attr/ \tsrc/mod27/file27.ts\ni/lf w/crlf attr/ \tsrc/mod28/file28.ts\ni/lf w/crlf attr/ \tsrc/mod29/file29.ts\ni/lf w/crlf attr/ \tsrc/mod3/file3.ts\ni/lf w/crlf attr/ \tsrc/mod30/file30.ts\ni/lf w/crlf attr/ \tsrc/mod31/file31.ts\ni/lf w/crlf attr/ \tsrc/mod32/file32.ts\ni/lf w/crlf attr/ \tsrc/mod33/file33.ts\ni/lf w/crlf attr/ \tsrc/mod34/file34.ts\ni/lf w/crlf attr/ \tsrc/mod35/file35.ts\ni/lf w/crlf attr/ \tsrc/mod36/file36.ts\ni/lf w/crlf attr/ \tsrc/mod37/file37.ts\ni/lf w/crlf attr/ \tsrc/mod38/file38.ts\ni/lf w/crlf attr/ \tsrc/mod39/file39.ts\ni/lf w/crlf attr/ \tsrc/mod4/file4.ts\ni/lf w/crlf attr/ \tsrc/mod40/file40.ts\ni/lf w/crlf attr/ \tsrc/mod5/file5.ts\ni/lf w/crlf attr/ \tsrc/mod6/file6.ts\ni/lf w/crlf attr/ \tsrc/mod7/file7.ts\ni/lf w/crlf attr/ \tsrc/mod8/file8.ts\ni/lf w/crlf attr/ \tsrc/mod9/file9.ts\ni/lf w/lf attr/ \ttsconfig.json\n'
export const NO_TOP_LEVEL_FILES_SAMPLE =
'i/lf w/crlf attr/ \tsrc/mod1/file1.ts\ni/lf w/crlf attr/ \tsrc/mod10/file10.ts\ni/lf w/crlf attr/ \tsrc/mod11/file11.ts\ni/lf w/crlf attr/ \tsrc/mod12/file12.ts\ni/lf w/crlf attr/ \tsrc/mod13/file13.ts\ni/lf w/crlf attr/ \tsrc/mod14/file14.ts\ni/lf w/crlf attr/ \tsrc/mod15/file15.ts\ni/lf w/crlf attr/ \tsrc/mod16/file16.ts\ni/lf w/crlf attr/ \tsrc/mod17/file17.ts\ni/lf w/crlf attr/ \tsrc/mod18/file18.ts\ni/lf w/crlf attr/ \tsrc/mod19/file19.ts\ni/lf w/crlf attr/ \tsrc/mod2/file2.ts\ni/lf w/crlf attr/ \tsrc/mod20/file20.ts\ni/lf w/crlf attr/ \tsrc/mod21/file21.ts\ni/lf w/crlf attr/ \tsrc/mod22/file22.ts\ni/lf w/crlf attr/ \tsrc/mod23/file23.ts\ni/lf w/crlf attr/ \tsrc/mod24/file24.ts\ni/lf w/crlf attr/ \tsrc/mod25/file25.ts\ni/lf w/crlf attr/ \tsrc/mod26/file26.ts\ni/lf w/crlf attr/ \tsrc/mod27/file27.ts\ni/lf w/crlf attr/ \tsrc/mod28/file28.ts\ni/lf w/crlf attr/ \tsrc/mod29/file29.ts\ni/lf w/crlf attr/ \tsrc/mod3/file3.ts\ni/lf w/crlf attr/ \tsrc/mod30/file30.ts\ni/lf w/crlf attr/ \tsrc/mod31/file31.ts\ni/lf w/crlf attr/ \tsrc/mod32/file32.ts\ni/lf w/crlf attr/ \tsrc/mod33/file33.ts\ni/lf w/crlf attr/ \tsrc/mod34/file34.ts\ni/lf w/crlf attr/ \tsrc/mod35/file35.ts\ni/lf w/crlf attr/ \tsrc/mod36/file36.ts\ni/lf w/crlf attr/ \tsrc/mod37/file37.ts\ni/lf w/crlf attr/ \tsrc/mod38/file38.ts\ni/lf w/crlf attr/ \tsrc/mod39/file39.ts\ni/lf w/crlf attr/ \tsrc/mod4/file4.ts\ni/lf w/crlf attr/ \tsrc/mod40/file40.ts\ni/lf w/crlf attr/ \tsrc/mod5/file5.ts\ni/lf w/crlf attr/ \tsrc/mod6/file6.ts\ni/lf w/crlf attr/ \tsrc/mod7/file7.ts\ni/lf w/crlf attr/ \tsrc/mod8/file8.ts\ni/lf w/crlf attr/ \tsrc/mod9/file9.ts\n'
export const ATTRIBUTE_PINNED_ROOT_SAMPLE =
'i/lf w/crlf attr/ \t.gitattributes\ni/lf w/crlf attr/ \tsrc/mod1/file1.ts\ni/lf w/crlf attr/ \tsrc/mod10/file10.ts\ni/lf w/crlf attr/ \tsrc/mod11/file11.ts\ni/lf w/crlf attr/ \tsrc/mod12/file12.ts\ni/lf w/crlf attr/ \tsrc/mod13/file13.ts\ni/lf w/crlf attr/ \tsrc/mod14/file14.ts\ni/lf w/crlf attr/ \tsrc/mod15/file15.ts\ni/lf w/crlf attr/ \tsrc/mod16/file16.ts\ni/lf w/crlf attr/ \tsrc/mod17/file17.ts\ni/lf w/crlf attr/ \tsrc/mod18/file18.ts\ni/lf w/crlf attr/ \tsrc/mod19/file19.ts\ni/lf w/crlf attr/ \tsrc/mod2/file2.ts\ni/lf w/crlf attr/ \tsrc/mod20/file20.ts\ni/lf w/crlf attr/ \tsrc/mod21/file21.ts\ni/lf w/crlf attr/ \tsrc/mod22/file22.ts\ni/lf w/crlf attr/ \tsrc/mod23/file23.ts\ni/lf w/crlf attr/ \tsrc/mod24/file24.ts\ni/lf w/crlf attr/ \tsrc/mod25/file25.ts\ni/lf w/crlf attr/ \tsrc/mod26/file26.ts\ni/lf w/crlf attr/ \tsrc/mod27/file27.ts\ni/lf w/crlf attr/ \tsrc/mod28/file28.ts\ni/lf w/crlf attr/ \tsrc/mod29/file29.ts\ni/lf w/crlf attr/ \tsrc/mod3/file3.ts\ni/lf w/crlf attr/ \tsrc/mod30/file30.ts\ni/lf w/crlf attr/ \tsrc/mod31/file31.ts\ni/lf w/crlf attr/ \tsrc/mod32/file32.ts\ni/lf w/crlf attr/ \tsrc/mod33/file33.ts\ni/lf w/crlf attr/ \tsrc/mod34/file34.ts\ni/lf w/crlf attr/ \tsrc/mod35/file35.ts\ni/lf w/crlf attr/ \tsrc/mod36/file36.ts\ni/lf w/crlf attr/ \tsrc/mod37/file37.ts\ni/lf w/crlf attr/ \tsrc/mod38/file38.ts\ni/lf w/crlf attr/ \tsrc/mod39/file39.ts\ni/lf w/crlf attr/ \tsrc/mod4/file4.ts\ni/lf w/crlf attr/ \tsrc/mod40/file40.ts\ni/lf w/crlf attr/ \tsrc/mod5/file5.ts\ni/lf w/crlf attr/ \tsrc/mod6/file6.ts\ni/lf w/crlf attr/ \tsrc/mod7/file7.ts\ni/lf w/crlf attr/ \tsrc/mod8/file8.ts\ni/lf w/crlf attr/ \tsrc/mod9/file9.ts\n'
export const WSL_CLONE_WITH_CRLF_README_SAMPLE =
'i/lf w/lf attr/ \t.prettierrc\ni/lf w/crlf attr/ \tREADME.md\ni/lf w/lf attr/ \tpackage.json\ni/lf w/lf attr/ \tsrc/mod1/file1.ts\ni/lf w/lf attr/ \tsrc/mod10/file10.ts\ni/lf w/lf attr/ \tsrc/mod11/file11.ts\ni/lf w/lf attr/ \tsrc/mod12/file12.ts\ni/lf w/lf attr/ \tsrc/mod13/file13.ts\ni/lf w/lf attr/ \tsrc/mod14/file14.ts\ni/lf w/lf attr/ \tsrc/mod15/file15.ts\ni/lf w/lf attr/ \tsrc/mod16/file16.ts\ni/lf w/lf attr/ \tsrc/mod17/file17.ts\ni/lf w/lf attr/ \tsrc/mod18/file18.ts\ni/lf w/lf attr/ \tsrc/mod19/file19.ts\ni/lf w/lf attr/ \tsrc/mod2/file2.ts\ni/lf w/lf attr/ \tsrc/mod20/file20.ts\ni/lf w/lf attr/ \tsrc/mod21/file21.ts\ni/lf w/lf attr/ \tsrc/mod22/file22.ts\ni/lf w/lf attr/ \tsrc/mod23/file23.ts\ni/lf w/lf attr/ \tsrc/mod24/file24.ts\ni/lf w/lf attr/ \tsrc/mod25/file25.ts\ni/lf w/lf attr/ \tsrc/mod26/file26.ts\ni/lf w/lf attr/ \tsrc/mod27/file27.ts\ni/lf w/lf attr/ \tsrc/mod28/file28.ts\ni/lf w/lf attr/ \tsrc/mod29/file29.ts\ni/lf w/lf attr/ \tsrc/mod3/file3.ts\ni/lf w/lf attr/ \tsrc/mod30/file30.ts\ni/lf w/lf attr/ \tsrc/mod31/file31.ts\ni/lf w/lf attr/ \tsrc/mod32/file32.ts\ni/lf w/lf attr/ \tsrc/mod33/file33.ts\ni/lf w/lf attr/ \tsrc/mod34/file34.ts\ni/lf w/lf attr/ \tsrc/mod35/file35.ts\ni/lf w/lf attr/ \tsrc/mod36/file36.ts\ni/lf w/lf attr/ \tsrc/mod37/file37.ts\ni/lf w/lf attr/ \tsrc/mod38/file38.ts\ni/lf w/lf attr/ \tsrc/mod39/file39.ts\ni/lf w/lf attr/ \tsrc/mod4/file4.ts\ni/lf w/lf attr/ \tsrc/mod40/file40.ts\ni/lf w/lf attr/ \tsrc/mod5/file5.ts\ni/lf w/lf attr/ \tsrc/mod6/file6.ts\ni/lf w/lf attr/ \tsrc/mod7/file7.ts\ni/lf w/lf attr/ \tsrc/mod8/file8.ts\ni/lf w/lf attr/ \tsrc/mod9/file9.ts\ni/lf w/lf attr/ \ttsconfig.json\n'
export const LOCAL_AUTOCRLF_FALSE_CLONE_SAMPLE =
'i/lf w/lf attr/ \t.prettierrc\ni/lf w/lf attr/ \tREADME.md\ni/lf w/lf attr/ \tpackage.json\ni/lf w/lf attr/ \tsrc/mod1/file1.ts\ni/lf w/lf attr/ \tsrc/mod10/file10.ts\ni/lf w/lf attr/ \tsrc/mod11/file11.ts\ni/lf w/lf attr/ \tsrc/mod12/file12.ts\ni/lf w/lf attr/ \tsrc/mod13/file13.ts\ni/lf w/lf attr/ \tsrc/mod14/file14.ts\ni/lf w/lf attr/ \tsrc/mod15/file15.ts\ni/lf w/lf attr/ \tsrc/mod16/file16.ts\ni/lf w/lf attr/ \tsrc/mod17/file17.ts\ni/lf w/lf attr/ \tsrc/mod18/file18.ts\ni/lf w/lf attr/ \tsrc/mod19/file19.ts\ni/lf w/lf attr/ \tsrc/mod2/file2.ts\ni/lf w/lf attr/ \tsrc/mod20/file20.ts\ni/lf w/lf attr/ \tsrc/mod21/file21.ts\ni/lf w/lf attr/ \tsrc/mod22/file22.ts\ni/lf w/lf attr/ \tsrc/mod23/file23.ts\ni/lf w/lf attr/ \tsrc/mod24/file24.ts\ni/lf w/lf attr/ \tsrc/mod25/file25.ts\ni/lf w/lf attr/ \tsrc/mod26/file26.ts\ni/lf w/lf attr/ \tsrc/mod27/file27.ts\ni/lf w/lf attr/ \tsrc/mod28/file28.ts\ni/lf w/lf attr/ \tsrc/mod29/file29.ts\ni/lf w/lf attr/ \tsrc/mod3/file3.ts\ni/lf w/lf attr/ \tsrc/mod30/file30.ts\ni/lf w/lf attr/ \tsrc/mod31/file31.ts\ni/lf w/lf attr/ \tsrc/mod32/file32.ts\ni/lf w/lf attr/ \tsrc/mod33/file33.ts\ni/lf w/lf attr/ \tsrc/mod34/file34.ts\ni/lf w/lf attr/ \tsrc/mod35/file35.ts\ni/lf w/lf attr/ \tsrc/mod36/file36.ts\ni/lf w/lf attr/ \tsrc/mod37/file37.ts\ni/lf w/lf attr/ \tsrc/mod38/file38.ts\ni/lf w/lf attr/ \tsrc/mod39/file39.ts\ni/lf w/lf attr/ \tsrc/mod4/file4.ts\ni/lf w/lf attr/ \tsrc/mod40/file40.ts\ni/lf w/lf attr/ \tsrc/mod5/file5.ts\ni/lf w/lf attr/ \tsrc/mod6/file6.ts\ni/lf w/lf attr/ \tsrc/mod7/file7.ts\ni/lf w/lf attr/ \tsrc/mod8/file8.ts\ni/lf w/lf attr/ \tsrc/mod9/file9.ts\ni/lf w/lf attr/ \ttsconfig.json\n'
export const PLAIN_WINDOWS_CLONE_SAMPLE =
'i/lf w/crlf attr/ \t.prettierrc\ni/lf w/crlf attr/ \tREADME.md\ni/lf w/crlf attr/ \tpackage.json\ni/lf w/crlf attr/ \tsrc/mod1/file1.ts\ni/lf w/crlf attr/ \tsrc/mod10/file10.ts\ni/lf w/crlf attr/ \tsrc/mod11/file11.ts\ni/lf w/crlf attr/ \tsrc/mod12/file12.ts\ni/lf w/crlf attr/ \tsrc/mod13/file13.ts\ni/lf w/crlf attr/ \tsrc/mod14/file14.ts\ni/lf w/crlf attr/ \tsrc/mod15/file15.ts\ni/lf w/crlf attr/ \tsrc/mod16/file16.ts\ni/lf w/crlf attr/ \tsrc/mod17/file17.ts\ni/lf w/crlf attr/ \tsrc/mod18/file18.ts\ni/lf w/crlf attr/ \tsrc/mod19/file19.ts\ni/lf w/crlf attr/ \tsrc/mod2/file2.ts\ni/lf w/crlf attr/ \tsrc/mod20/file20.ts\ni/lf w/crlf attr/ \tsrc/mod21/file21.ts\ni/lf w/crlf attr/ \tsrc/mod22/file22.ts\ni/lf w/crlf attr/ \tsrc/mod23/file23.ts\ni/lf w/crlf attr/ \tsrc/mod24/file24.ts\ni/lf w/crlf attr/ \tsrc/mod25/file25.ts\ni/lf w/crlf attr/ \tsrc/mod26/file26.ts\ni/lf w/crlf attr/ \tsrc/mod27/file27.ts\ni/lf w/crlf attr/ \tsrc/mod28/file28.ts\ni/lf w/crlf attr/ \tsrc/mod29/file29.ts\ni/lf w/crlf attr/ \tsrc/mod3/file3.ts\ni/lf w/crlf attr/ \tsrc/mod30/file30.ts\ni/lf w/crlf attr/ \tsrc/mod31/file31.ts\ni/lf w/crlf attr/ \tsrc/mod32/file32.ts\ni/lf w/crlf attr/ \tsrc/mod33/file33.ts\ni/lf w/crlf attr/ \tsrc/mod34/file34.ts\ni/lf w/crlf attr/ \tsrc/mod35/file35.ts\ni/lf w/crlf attr/ \tsrc/mod36/file36.ts\ni/lf w/crlf attr/ \tsrc/mod37/file37.ts\ni/lf w/crlf attr/ \tsrc/mod38/file38.ts\ni/lf w/crlf attr/ \tsrc/mod39/file39.ts\ni/lf w/crlf attr/ \tsrc/mod4/file4.ts\ni/lf w/crlf attr/ \tsrc/mod40/file40.ts\ni/lf w/crlf attr/ \tsrc/mod5/file5.ts\ni/lf w/crlf attr/ \tsrc/mod6/file6.ts\ni/lf w/crlf attr/ \tsrc/mod7/file7.ts\ni/lf w/crlf attr/ \tsrc/mod8/file8.ts\ni/lf w/crlf attr/ \tsrc/mod9/file9.ts\ni/lf w/crlf attr/ \ttsconfig.json\n'
export const HOST_CONFIG_SYSTEM_AUTOCRLF =
'file:C:/Program Files/Git/etc/gitconfig\tcore.autocrlf true\n'
export const HOST_CONFIG_LOCAL_AUTOCRLF_FALSE =
'file:C:/Program Files/Git/etc/gitconfig\tcore.autocrlf true\nfile:.git/config\tcore.autocrlf false\n'
export const HOST_CONFIG_LOCAL_EOL_LF =
'file:C:/Program Files/Git/etc/gitconfig\tcore.autocrlf true\nfile:.git/config\tcore.eol lf\n'
+4 -1
View File
@@ -92,6 +92,8 @@ export type WslResult = {
stdout: string
stderr: string
timedOut: boolean
/** True when stdout or stderr hit `maxOutputBytes` and was clipped. */
outputTruncated?: boolean
}
export const DEFAULT_WSL_TIMEOUT_MS = 30_000
@@ -260,6 +262,7 @@ export async function runWslProcess(spec: WslSpec): Promise<WslResult> {
code: result.code,
stdout: result.stdout,
stderr: result.stderr,
timedOut: result.timedOut
timedOut: result.timedOut,
outputTruncated: result.outputTruncated ?? false
}
}