Files
orca/src/main/git/worktree-sparse-state.ts
T
Neil 26dfa46aa9 fix(wsl): resolve sparse-checkout and diff-stamp gitdirs through the caller's distro (#17932)
Two main-process `resolveGitDir` call sites dropped the WSL distro their caller
already held, so they could only resolve a gitdir pointer whose spelling carries
its own translation: a `//wsl.localhost/<Distro>/...` base, or a `/mnt/<letter>`
drvfs pointer that maps to a drive letter on its own.

The layout that needs the third case is a repo inside the distro's filesystem
with its worktrees on the Windows drive. `git worktree list` reports the worktree
as `/mnt/c/wt/x`, which the listing translates to `C:\wt\x` — a base that no
longer names a distro — while the `.git` gitfile beside it points at
`/home/me/repo/.git/worktrees/x`, which has no drive to derive. Win32 then treats
that pointer as absolute and reads a path that names nothing:

- `detectSparseCheckout` stats `info/sparse-checkout` under the fabricated path,
  always misses, and reports the worktree as non-sparse — no sparse badge, and
  the file list claims files that are not on disk.
- `readWorktreeDiffStamp` reads HEAD under the same path, gets nothing, and
  returns null. Null is the safe answer ("cannot prove unchanged"), but it
  retires the settled-diff cache for every file in that worktree, so each diff
  respawns Git.

Both callers already have the distro: the listing threads its
`GitWorktreeExecOptions` to `annotateSparseCheckoutStatus`, on through
`detectSparseCheckoutCached` (the annotation cache added by #17859) and its
background revalidation probe, and finally to `detectSparseCheckout`; and
`file-diff` already forwards its `GitRuntimeOptions` to `readWorktreeDiffStamp`,
which now forwards it to `resolveGitDir` as well.

`resolveGitMetadataPath` still prefers a UNC base's distro and still tries drvfs
before the caller-named distro, so nothing that resolved before resolves
differently.

The cache hop matters twice over. It is the only remaining caller of
`detectSparseCheckout`, so without threading it the fix would not reach the
probe at all. And the cache is where the bug turns sticky. #17859 keyed entries
on `repoPath` + `worktreePath` alone, on the reasoning that the distro is a
property of the repo and so every read for a given `repoPath` carries the same
one. That invariant does not hold. `listRepoWorktrees(repo)` is called with no
options at all from the filesystem-auth root rebuild
(`registered-worktree-roots-cache.ts`, reached from `ensureAuthorizedRootsCache`
on any auth check with a dirty cache) and from the local worktree-ownership
check in `filesystem-worktree-helpers.ts`. Both land on the *same* key as the
distro-carrying listing, because `translateWslOutputPaths` derives the distro
from the cwd spelling before falling back to `options.wslDistro`, so a
UNC-spelled repo path yields the identical `C:\...` worktree row either way.
Measured on Windows in one process, branch build: a distro-less read followed by
a distro-carrying read reported the sparse worktree as non-sparse both times.

So `wslDistro` now joins the cache key -- trimmed and lowercased, matching how
the rest of the codebase compares distro names, and appended last so the
repo-scoped prefix delete still matches every variant. The per-path invalidate
becomes a prefix delete for the same reason, dropping every distro variant of a
removed or moved worktree.

Keying on it closes both halves of the defect. A correct caller can no longer be
served an answer derived without the distro it supplied. And because the entry a
reader reaches is now selected by the same distro it would re-probe with,
`revalidateInBackground` can no longer re-derive a warm entry under weaker
options -- which mattered on its own: a distro-less reader crossing the
five-minute window would otherwise flip a correct `true` to `false`, and the
resulting change notification runs the registered invalidator, clearing the
whole repo's cache and re-probing every worktree cold, on a five-minute loop.

Cost of the extra key dimension is bounded by the number of distinct distros a
given repo is actually read under: one where a distro is threaded everywhere,
two while the distro-less callers above still exist. Entries are still
repo-scoped, and both clears already sweep by prefix.

Per-platform delta:
- macOS/Linux: no change. Guest-pointer translation is gated to win32 and a
  caller-named distro is ignored off Windows; no caller supplies one there, so
  the cache keys and probes exactly as before.
- native Windows, no WSL: no change. `wslDistro` is undefined, so the resolver
  takes exactly the branches it took before and every read keys on the same
  empty distro component, so the cache behaves exactly as it did.
- Windows + WSL, UNC-spelled worktree: no change. The base already names the
  distro and outranks the caller's.
- Windows + WSL, drvfs-spelled worktree with a drvfs pointer: no change. The
  drive-letter derivation still runs first.
- Windows + WSL, drvfs-spelled worktree with a non-drvfs pointer: the sparse
  badge appears and the settled-diff cache starts hitting. Both previously
  failed toward "not sparse" / "do not cache", so neither can now serve a stale
  answer, and the distro-less listings no longer share the badge's cache entry.
- SSH/relay: none. Those paths return through the provider branch before
  reaching either function.
- folder workspaces, GitLab: none. Neither is on these code paths.

Not in this change:
- `readRepoCommonDirFromDisk` (worktree-listing). Passing the distro there is
  inert: a repo root's `.git` is a directory, so the gitfile-pointer branch never
  runs, and when `repoPath` itself is guest-spelled the preceding `stat` already
  fails — which no `resolveGitDir` option can fix.
- The two `findExistingWorktreeSymlinkPaths` calls on the removal paths. Both
  receive `registeredWorktree.path` from `listWorktreesStrict`, which already
  translates every row out of the guest namespace, so the distro would be a
  no-op. The `removeWorktreeLinkedPaths` unlink beside them is untranslated too,
  so a half-threaded fix would only move the refusal from Orca's preflight to
  `git worktree remove`.
- An absolute `commondir` payload, which `resolveGitCommonDir` still resolves
  untranslated. Git writes that file relative in the layouts above, and the
  failure direction is unchanged.
- Giving the two distro-less `listRepoWorktrees(repo)` callers a distro. Neither
  reads `isSparse` -- both use only `worktree.path` -- so the distro would buy
  them nothing they consume, while resolving a project runtime inside the
  filesystem-auth rebuild would put a call that throws on `repair-required`
  behind a catch that skips the whole repo's authorized roots. The cache key
  makes their reads harmless; skipping the annotation for callers that never
  read it is a separate, larger change. The third no-options call in
  `hosted-review.ts` is inside the `repo.connectionId` branch and returns
  through the SSH provider, so it never reaches this cache.
2026-09-01 04:25:19 -07:00

144 lines
6.3 KiB
TypeScript

import { readFile, stat } from 'node:fs/promises'
import { isAbsolute, join, resolve } from 'node:path'
import type { GitRuntimeOptions } from './git-runtime-options'
import { resolveGitDir } from './status'
export async function detectSparseCheckout(
worktreePath: string,
// Why: git in a WSL distro reports the worktree, and writes its gitdir pointer, in the guest
// namespace; without the distro this stats a path Win32 fabricates and reads "not sparse".
options: Pick<GitRuntimeOptions, 'wslDistro'> = {}
): Promise<boolean> {
// Why: fs.stat the per-worktree gitdir's sparse-checkout pattern file instead of a per-poll `git sparse-checkout list` subprocess that regressed responsiveness (PR #1290);
// this is the cheap fast-path gate before the enabled check below.
try {
const gitDir = await resolveGitDir(worktreePath, options)
const stats = await stat(join(gitDir, 'info', 'sparse-checkout'))
if (!stats.isFile() || stats.size === 0) {
return false
}
// Why the extra config read: `git sparse-checkout disable` restores every file to the
// working tree and sets core.sparseCheckout=false, but it deliberately LEAVES
// <gitdir>/info/sparse-checkout in place so the checkout can be re-enabled with the same
// patterns. A non-empty pattern file is therefore necessary but not sufficient — without
// confirming core.sparseCheckout is actually on we would flag a fully-populated worktree as
// sparse and show a misleading "files are not on disk" badge. This runs only for the rare
// worktree that still has a non-empty pattern file, so it does not reintroduce the per-poll
// subprocess fan-out PR #1290 removed, and it reads git's config files directly (no
// subprocess) so it stays cheap and needs no exec options.
return await isSparseCheckoutEnabled(gitDir)
} catch {
return false
}
}
// Resolve the shared common gitdir for a (possibly linked) worktree gitdir. A linked worktree's
// gitdir holds a `commondir` file pointing at the repo's main `.git`; the main worktree's gitdir
// is itself the common dir.
export async function resolveGitCommonDir(gitDir: string): Promise<string> {
try {
const raw = (await readFile(join(gitDir, 'commondir'), 'utf-8')).trim()
if (raw.length > 0) {
return isAbsolute(raw) ? raw : resolve(gitDir, raw)
}
} catch {
// No `commondir` file: this gitdir is already the common dir.
}
return gitDir
}
// Whether core.sparseCheckout is actually enabled for this worktree. The value can live in the
// shared repo config or, when extensions.worktreeConfig is on, in the worktree-local
// `config.worktree`; later files override earlier ones, matching git's config precedence.
async function isSparseCheckoutEnabled(gitDir: string): Promise<boolean> {
const commonDir = await resolveGitCommonDir(gitDir)
const sharedConfig = await readGitConfigText(join(commonDir, 'config'))
const sharedFlag = parseCoreSparseCheckoutFlag(sharedConfig)
// Git reads `config.worktree` only while extensions.worktreeConfig is on; without that gate a
// stale worktree config left behind by an earlier sparse checkout overrides the real repo value.
if (parseGitConfigFlag(sharedConfig, 'extensions', 'worktreeconfig') !== true) {
return sharedFlag ?? false
}
const worktreeConfig = await readGitConfigText(join(gitDir, 'config.worktree'))
return parseCoreSparseCheckoutFlag(worktreeConfig) ?? sharedFlag ?? false
}
async function readGitConfigText(configPath: string): Promise<string> {
try {
return await readFile(configPath, 'utf-8')
} catch {
return ''
}
}
// Read the effective `core.sparseCheckout` boolean from one git config file's text, or `undefined`
// when the plain `[core]` section does not set it. Kept as a pure, exported function so the
// git-config parsing edge cases can be unit tested without touching the filesystem. Only the last
// assignment wins, and a `[core "subsection"]` header is intentionally not treated as `[core]`.
export function parseCoreSparseCheckoutFlag(configContent: string): boolean | undefined {
return parseGitConfigFlag(configContent, 'core', 'sparsecheckout')
}
// A section header may be followed on the same line by further headers and then one assignment
// (`[core] sparseCheckout = true` is legal git config); the value runs to end of line, so at most
// one assignment can share a line and the last header before it decides the section.
const GIT_CONFIG_SECTION_HEADER = /^\[\s*([A-Za-z0-9.-]+)(\s+"(?:[^"\\]|\\.)*")?\s*\]/
const GIT_CONFIG_ASSIGNMENT = /^([A-Za-z][A-Za-z0-9-]*)\s*(?:=\s*(.*))?$/
// `section` and `key` must be lowercase: git config names are case-insensitive.
function parseGitConfigFlag(
configContent: string,
section: string,
key: string
): boolean | undefined {
let inSection = false
let value: boolean | undefined
for (const rawLine of configContent.split(/\r?\n/)) {
let rest = stripGitConfigComment(rawLine).trim()
for (
let header = rest.match(GIT_CONFIG_SECTION_HEADER);
header;
header = rest.match(GIT_CONFIG_SECTION_HEADER)
) {
inSection = header[1].toLowerCase() === section && header[2] === undefined
rest = rest.slice(header[0].length).trim()
}
if (!inSection || rest.length === 0) {
continue
}
const assignment = rest.match(GIT_CONFIG_ASSIGNMENT)
if (!assignment || assignment[1].toLowerCase() !== key) {
continue
}
value = parseGitConfigBoolean(assignment[2])
}
return value
}
// Drop a trailing `#`/`;` comment that is not inside a double-quoted value.
function stripGitConfigComment(line: string): string {
let inQuotes = false
for (let index = 0; index < line.length; index += 1) {
const char = line[index]
if (char === '"' && line[index - 1] !== '\\') {
inQuotes = !inQuotes
} else if ((char === '#' || char === ';') && !inQuotes) {
return line.slice(0, index)
}
}
return line
}
// Git treats a valueless boolean (`sparseCheckout` with no `=`) as true and only true/yes/on/1 as
// true otherwise; everything else (including the disable-written `false`) is false.
function parseGitConfigBoolean(raw: string | undefined): boolean {
if (raw === undefined) {
return true
}
const value = raw
.trim()
.replace(/^"(.*)"$/, '$1')
.toLowerCase()
return value === 'true' || value === 'yes' || value === 'on' || value === '1'
}