diff --git a/src/main/git/wsl-windows-checkout-git-config.ts b/src/main/git/wsl-windows-checkout-git-config.ts index b9ee9f32bb4..0e157ef3705 100644 --- a/src/main/git/wsl-windows-checkout-git-config.ts +++ b/src/main/git/wsl-windows-checkout-git-config.ts @@ -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( diff --git a/src/main/git/wsl-windows-checkout-git-probe.test.ts b/src/main/git/wsl-windows-checkout-git-probe.test.ts index d47f198294a..00c1300237b 100644 --- a/src/main/git/wsl-windows-checkout-git-probe.test.ts +++ b/src/main/git/wsl-windows-checkout-git-probe.test.ts @@ -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' }) ) diff --git a/src/main/git/wsl-windows-checkout-git-probe.ts b/src/main/git/wsl-windows-checkout-git-probe.ts index ca9aa81b19b..954beb1fe63 100644 --- a/src/main/git/wsl-windows-checkout-git-probe.ts +++ b/src/main/git/wsl-windows-checkout-git-probe.ts @@ -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 diff --git a/src/main/git/wsl-windows-checkout-sample-script.test.ts b/src/main/git/wsl-windows-checkout-sample-script.test.ts new file mode 100644 index 00000000000..5ee646b205f --- /dev/null +++ b/src/main/git/wsl-windows-checkout-sample-script.test.ts @@ -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) + }) +})