fix(wsl): bound the line-ending sample's index scan, not just its matches (#29552)

* fix(wsl): bound the line-ending sample's index scan, not just its matches

The WSL-on-Windows-drive line-ending sample piped ls-files --eol through grep
then head, so when no entry was eligible (* text=auto) head never closed the
pipe and every file was read over drvfs until the 15 s timeout, which stage,
discard and commit waited on after each backoff. Cap the raw ls-files output
before the grep.

Follow-up to #27778.

* docs(wsl): describe the line-ending sample as the first index entries
This commit is contained in:
Neil
2026-10-16 02:27:24 -07:00
committed by GitHub
parent e3f42d76a0
commit 088fefbb5a
4 changed files with 92 additions and 9 deletions
@@ -37,8 +37,11 @@ export function writesAnotherCheckout(args: readonly string[]): boolean {
return subcommandIndex !== -1 && OTHER_CHECKOUT_SUBCOMMANDS.has(args[subcommandIndex])
}
/** Deliberate cap: ~200 eligible files decide the checkout without reading the whole tree over drvfs. */
/** Deliberate cap: ~200 eligible files decide the checkout. */
export const LINE_ENDING_SAMPLE_LIMIT = 200
// Why: `ls-files --eol` reads every file it lists over drvfs, so capping matches alone still reads
// the whole tree when few files qualify (`* text=auto`); the scan stops after this many entries.
export const LINE_ENDING_SCAN_LIMIT = 10 * LINE_ENDING_SAMPLE_LIMIT
// 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.
@@ -129,8 +132,8 @@ export function parseHostAutocrlf(showOriginOutput: string): '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
* The first index entries, not just top-level files: 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(
@@ -250,13 +250,13 @@ describe('pendingWslWindowsCheckoutGitConfig', () => {
await expect(waiting).resolves.toBeUndefined()
})
it('samples the whole tree through argv, bounded by a deliberate cap', async () => {
it('passes the sample cap and the index-entry scan cap through argv', async () => {
const { deps, run } = harness()
await run(WRITE)
expect(deps.runWsl).toHaveBeenCalledWith(
expect.objectContaining({
distro: 'Ubuntu',
args: ['/mnt/c/Users/dev/repo', '200'],
args: ['/mnt/c/Users/dev/repo', '200', '2000'],
loginPath: 'preferred'
})
)
+10 -4
View File
@@ -15,6 +15,7 @@ import {
isFresh,
isWslWindowsCheckoutCandidate,
LINE_ENDING_SAMPLE_LIMIT,
LINE_ENDING_SCAN_LIMIT,
parseHostAutocrlf,
recordHostAutocrlf,
recordLineEndingSample,
@@ -29,11 +30,12 @@ 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 = [
// Exit 3 carries rev-parse's own diagnostic. The first head bounds what git reads even when grep
// matches nothing; the second bounds the sample.
export 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"'
'git -C "$1" ls-files --eol | head -n "$3" | grep "^i/lf *w/[a-z]* *attr/ *$tab" | head -n "$2"'
].join('\n')
export type HostGitResult = {
@@ -159,7 +161,11 @@ async function probeSample(
(deps.runWsl ?? runWslProcess)({
distro: options.wslDistro,
script: SAMPLE_SCRIPT,
args: [translateArgForWsl(options.cwd), String(LINE_ENDING_SAMPLE_LIMIT)],
args: [
translateArgForWsl(options.cwd),
String(LINE_ENDING_SAMPLE_LIMIT),
String(LINE_ENDING_SCAN_LIMIT)
],
loginPath: 'preferred',
timeoutMs: SAMPLE_PROBE_TIMEOUT_MS,
maxOutputBytes: 1024 * 1024
@@ -0,0 +1,74 @@
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { delimiter, join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { runProcess } from '@orca/process-host'
import { SAMPLE_SCRIPT } from './wsl-windows-checkout-git-probe'
import { LINE_ENDING_SAMPLE_LIMIT, LINE_ENDING_SCAN_LIMIT } from './wsl-windows-checkout-git-config'
const TAB = '\t'
const TEXT_AUTO_LINE = `i/lf w/lf attr/text=auto ${TAB}file.txt`
const ELIGIBLE_LINE = `i/lf w/crlf attr/ ${TAB}file.txt`
// Runs the probe's real shell pipeline against a stand-in git whose `ls-files --eol` never ends,
// like a huge drvfs tree: only a bounded scan lets the pipeline finish.
describe.skipIf(process.platform === 'win32')('line-ending sample pipeline', () => {
let binDir: string
beforeEach(() => {
binDir = mkdtempSync(join(tmpdir(), 'orca-eol-sample-'))
})
afterEach(() => {
rmSync(binDir, { recursive: true, force: true })
})
function installEndlessGit(line: string): void {
const fakeGit = join(binDir, 'git')
writeFileSync(
fakeGit,
[
'#!/bin/sh',
'case "$3" in',
' rev-parse) exit 0 ;;',
` ls-files) exec yes '${line}' ;;`,
'esac',
'exit 1'
].join('\n')
)
chmodSync(fakeGit, 0o755)
}
function runSample() {
return runProcess({
program: '/bin/sh',
args: [
'-c',
SAMPLE_SCRIPT,
'sh',
'/repo',
String(LINE_ENDING_SAMPLE_LIMIT),
String(LINE_ENDING_SCAN_LIMIT)
],
env: { ...process.env, PATH: `${binDir}${delimiter}${process.env.PATH ?? ''}` },
timeoutMs: 10_000,
maxOutputBytes: 1024 * 1024
})
}
it('stops reading the index when no entry is eligible (`* text=auto`)', async () => {
installEndlessGit(TEXT_AUTO_LINE)
const result = await runSample()
expect(result.timedOut).toBe(false)
expect(result.code).toBe(0)
expect(result.stdout).toBe('')
})
it('still returns a full sample when entries are eligible', async () => {
installEndlessGit(ELIGIBLE_LINE)
const result = await runSample()
expect(result.timedOut).toBe(false)
expect(result.code).toBe(0)
expect(result.stdout.split('\n').filter(Boolean)).toHaveLength(LINE_ENDING_SAMPLE_LIMIT)
})
})