diff --git a/src/main/git/command-runner/git-command-resolution-windows-checkout.test.ts b/src/main/git/command-runner/git-command-resolution-windows-checkout.test.ts new file mode 100644 index 00000000000..74b64fc13ad --- /dev/null +++ b/src/main/git/command-runner/git-command-resolution-windows-checkout.test.ts @@ -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 + ) + } + }) +}) diff --git a/src/main/git/command-runner/git-command-resolution.ts b/src/main/git/command-runner/git-command-resolution.ts index b63f6699dcb..750034dc036 100644 --- a/src/main/git/command-runner/git-command-resolution.ts +++ b/src/main/git/command-runner/git-command-resolution.ts @@ -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 diff --git a/src/main/git/command-runner/git-exec-file.ts b/src/main/git/command-runner/git-exec-file.ts index aadff165f9c..4216f65198a 100644 --- a/src/main/git/command-runner/git-exec-file.ts +++ b/src/main/git/command-runner/git-exec-file.ts @@ -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) { diff --git a/src/main/git/command-runner/git-spawn.ts b/src/main/git/command-runner/git-spawn.ts index 53de3d7615f..f49aeb8c85a 100644 --- a/src/main/git/command-runner/git-spawn.ts +++ b/src/main/git/command-runner/git-spawn.ts @@ -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 } : {}), diff --git a/src/main/git/command-runner/git-stream-stdout.ts b/src/main/git/command-runner/git-stream-stdout.ts index 283459757ba..8cd3a6d1311 100644 --- a/src/main/git/command-runner/git-stream-stdout.ts +++ b/src/main/git/command-runner/git-stream-stdout.ts @@ -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, diff --git a/src/main/git/source-control/git-pathspec.ts b/src/main/git/source-control/git-pathspec.ts index 382e76f9bb8..7a31b8f3396 100644 --- a/src/main/git/source-control/git-pathspec.ts +++ b/src/main/git/source-control/git-pathspec.ts @@ -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 } : {}) }) diff --git a/src/main/git/wsl-drive-checkout-git-routing.ts b/src/main/git/wsl-drive-checkout-git-routing.ts new file mode 100644 index 00000000000..d6af4687b08 --- /dev/null +++ b/src/main/git/wsl-drive-checkout-git-routing.ts @@ -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 | 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) + })() +} diff --git a/src/main/git/wsl-windows-checkout-git-config.test.ts b/src/main/git/wsl-windows-checkout-git-config.test.ts new file mode 100644 index 00000000000..de84363997b --- /dev/null +++ b/src/main/git/wsl-windows-checkout-git-config.test.ts @@ -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) + }) +}) diff --git a/src/main/git/wsl-windows-checkout-git-config.ts b/src/main/git/wsl-windows-checkout-git-config.ts new file mode 100644 index 00000000000..b9ee9f32bb4 --- /dev/null +++ b/src/main/git/wsl-windows-checkout-git-config.ts @@ -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 = { value: T; at: number } +type CheckoutState = { + host?: Timed + sample?: Timed & { identity: string | null } +} + +const states = new Map() + +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 | 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 | 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 | 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() +} diff --git a/src/main/git/wsl-windows-checkout-git-probe.test.ts b/src/main/git/wsl-windows-checkout-git-probe.test.ts new file mode 100644 index 00000000000..d47f198294a --- /dev/null +++ b/src/main/git/wsl-windows-checkout-git-probe.test.ts @@ -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 { + return { code: 0, stdout: HOST_CONFIG_SYSTEM_AUTOCRLF, stderr: '', timedOut: false, ...overrides } +} + +function wsl(overrides: Partial = {}): WslResult { + return { + environmentResolved: true, + code: 0, + stdout: PLAIN_WINDOWS_CLONE_SAMPLE, + stderr: '', + timedOut: false, + outputTruncated: false, + ...overrides + } +} + +type HostRunner = NonNullable +type WslRunner = NonNullable +type IdentityReader = NonNullable + +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(overrides.runHostGit ?? (async () => host())), + runWsl: vi.fn(overrides.runWsl ?? (async () => wsl())), + checkoutIdentity: vi.fn(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(() => {})) + }) + 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((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(() => {})) }) + 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' + }) + ) + }) +}) diff --git a/src/main/git/wsl-windows-checkout-git-probe.ts b/src/main/git/wsl-windows-checkout-git-probe.ts new file mode 100644 index 00000000000..ca9aa81b19b --- /dev/null +++ b/src/main/git/wsl-windows-checkout-git-probe.ts @@ -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 + runWsl?: (spec: WslSpec) => Promise + checkoutIdentity?: (cwd: string) => Promise + admit?: typeof acquireGitAdmission +} + +type ProbeOptions = { cwd: string; wslDistro: string; tier?: GitAdmissionTier } +type Stage = 'host' | 'sample' + +const pending = new Map>() +const failures = new Map() + +function isNotARepository(stderr: string): boolean { + return /not a git repository/i.test(stderr) +} + +async function defaultRunHostGit(cwd: string, args: string[]): Promise { + 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 { + 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( + deps: WslWindowsCheckoutProbeDeps, + request: { args: string[]; cwd: string; wslDistro?: string; tier?: GitAdmissionTier }, + work: () => Promise +): Promise { + // 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 { + 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 { + 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 | 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 | null, + signal: AbortSignal | undefined +): Promise { + 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((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 { + 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 | 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() +} diff --git a/src/main/git/wsl-windows-checkout-line-endings.test-fixture.ts b/src/main/git/wsl-windows-checkout-line-endings.test-fixture.ts new file mode 100644 index 00000000000..479e74f43a8 --- /dev/null +++ b/src/main/git/wsl-windows-checkout-line-endings.test-fixture.ts @@ -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' diff --git a/src/main/wsl/wsl-runner.ts b/src/main/wsl/wsl-runner.ts index 1fef6373932..e04ea4c15e7 100644 --- a/src/main/wsl/wsl-runner.ts +++ b/src/main/wsl/wsl-runner.ts @@ -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 { code: result.code, stdout: result.stdout, stderr: result.stderr, - timedOut: result.timedOut + timedOut: result.timedOut, + outputTruncated: result.outputTruncated ?? false } }