mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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'
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user