Files
orca/src/main/git/worktree-sparse-state.ts
T
Neil 037cc2e531 Split Git worktree responsibilities (#17253)
* Split speech session lifecycle

* Split terminal output scheduler pipeline

* Split mobile browser pane modules

* Prune resolved max-lines suppressions

* Split pane tree equalization logic

* Extract mobile troubleshoot screen styles

* Split external automation manager

* Split main window service attachments

* Split hosted review creation checks

* Split automation dispatch event handling

* Split settings navigation metadata

* Split daemon initialization lifecycle

* Split GitLab item dialog

* Split relay dispatcher layers

* Split mobile host screen

* Retarget mobile view settings source test

* Split runtime file client layers

* Split ports panel layers

* Split runtime environments pane layers

* Split local PTY provider responsibilities

* Split CDP bridge responsibilities

* Split relay Git handler responsibilities

* Track moved relay Git fetch audit

* Split Linear item drawer responsibilities

* Split telemetry event schema responsibilities

* Split resource usage status responsibilities

* Split remote terminal multiplexer responsibilities

* Split Git worktree responsibilities

* Fix F3-speech for #17123

* Fix F1-cycle for #17131

* Fix F4-navtest for #17157

* Fix F2-allowlist for #17161
2026-08-29 20:20:55 -07:00

138 lines
6.0 KiB
TypeScript

import { readFile, stat } from 'node:fs/promises'
import { isAbsolute, join, resolve } from 'node:path'
import { resolveGitDir } from './status'
export async function detectSparseCheckout(worktreePath: string): 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)
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.
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'
}