mirror of
https://github.com/stablyai/orca.git
synced 2026-10-27 08:02:51 +00:00
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.
144 lines
6.3 KiB
TypeScript
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'
|
|
}
|