fix(worktree): don't flag a disabled sparse checkout as sparse (#9922)

`git sparse-checkout disable` restores the full working tree and sets
core.sparseCheckout=false, but deliberately leaves <gitdir>/info/sparse-checkout
in place so the checkout can be re-enabled with the same patterns.
detectSparseCheckout treated the mere presence of that pattern file as "sparse",
so a fully-populated worktree kept showing the sparse badge and the misleading
"Partial checkout. Files outside these paths are not on disk." tooltip.

Gate the fast-path fs.stat behind a config read that confirms core.sparseCheckout
is actually enabled (shared repo config or per-worktree config.worktree, honoring
git's precedence). The config read runs only when a non-empty pattern file
exists, so it does not reintroduce the per-poll subprocess fan-out PR #1290
removed, and it reads git's config files directly (no subprocess).

Adds a real-git regression test (enable -> disable leaves file -> not sparse) and
unit tests for the git-config boolean parser.
This commit is contained in:
Taras Kornichuk 2026-07-24 10:26:34 +03:00 committed by GitHub
parent a4f42ad427
commit e3cc08f185
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 222 additions and 5 deletions

View File

@ -0,0 +1,116 @@
import { execFileSync } from 'node:child_process'
import { mkdtemp, mkdir, realpath, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import * as path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { listWorktrees, parseCoreSparseCheckoutFlag } from './worktree'
const tempRoots: string[] = []
function git(cwd: string, args: string[]): string {
return execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] })
}
async function createRepoWithTwoDirs(): Promise<string> {
const root = await mkdtemp(path.join(tmpdir(), 'orca-sparse-checkout-'))
tempRoots.push(root)
const repoPath = path.join(root, 'repo')
execFileSync('git', ['init', '--quiet', repoPath])
git(repoPath, ['symbolic-ref', 'HEAD', 'refs/heads/main'])
git(repoPath, ['config', 'user.email', 'test@example.com'])
git(repoPath, ['config', 'user.name', 'Test User'])
await mkdir(path.join(repoPath, 'keep'), { recursive: true })
await writeFile(path.join(repoPath, 'keep', 'file.txt'), 'keep\n')
await mkdir(path.join(repoPath, 'drop'), { recursive: true })
await writeFile(path.join(repoPath, 'drop', 'file.txt'), 'drop\n')
git(repoPath, ['add', '-A'])
git(repoPath, ['commit', '--quiet', '-m', 'initial'])
return realpath(repoPath)
}
function mainWorktree(worktrees: Awaited<ReturnType<typeof listWorktrees>>) {
const found = worktrees.find((worktree) => worktree.isMainWorktree)
if (!found) {
throw new Error('expected a main worktree in the listing')
}
return found
}
afterEach(async () => {
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
describe('sparse-checkout detection', () => {
it.skipIf(process.platform === 'win32')(
'reports isSparse while sparse checkout is enabled',
async () => {
const repoPath = await createRepoWithTwoDirs()
git(repoPath, ['sparse-checkout', 'set', 'keep'])
expect(mainWorktree(await listWorktrees(repoPath)).isSparse).toBe(true)
}
)
it.skipIf(process.platform === 'win32')(
'does not report isSparse after disable leaves the pattern file behind',
async () => {
const repoPath = await createRepoWithTwoDirs()
git(repoPath, ['sparse-checkout', 'set', 'keep'])
git(repoPath, ['sparse-checkout', 'disable'])
// Regression guard: `git sparse-checkout disable` restores the full
// working tree but deliberately keeps <gitdir>/info/sparse-checkout so the
// checkout can be re-enabled. Detection must not treat the leftover file
// as "still sparse" (that produced a false "files are not on disk" badge).
const patternFile = path.join(repoPath, '.git', 'info', 'sparse-checkout')
await expect(stat(patternFile)).resolves.toMatchObject({})
expect(mainWorktree(await listWorktrees(repoPath)).isSparse).toBeFalsy()
}
)
})
describe('parseCoreSparseCheckoutFlag', () => {
it('reads an enabled flag from the [core] section', () => {
expect(parseCoreSparseCheckoutFlag('[core]\n\tsparseCheckout = true\n')).toBe(true)
})
it('reads a disabled flag written by `sparse-checkout disable`', () => {
expect(parseCoreSparseCheckoutFlag('[core]\n\tsparseCheckout = false\n')).toBe(false)
})
it('returns undefined when the flag is absent', () => {
expect(parseCoreSparseCheckoutFlag('[core]\n\tbare = false\n')).toBeUndefined()
expect(parseCoreSparseCheckoutFlag('')).toBeUndefined()
})
it('honors the last assignment when the key repeats', () => {
expect(
parseCoreSparseCheckoutFlag('[core]\n\tsparseCheckout = true\n\tsparseCheckout = false\n')
).toBe(false)
})
it('is case-insensitive for the section and key names', () => {
expect(parseCoreSparseCheckoutFlag('[CORE]\n\tSPARSECHECKOUT = TRUE\n')).toBe(true)
})
it('treats a valueless boolean as true', () => {
expect(parseCoreSparseCheckoutFlag('[core]\n\tsparseCheckout\n')).toBe(true)
})
it('ignores a [core "subsection"] header', () => {
expect(parseCoreSparseCheckoutFlag('[core "sub"]\n\tsparseCheckout = true\n')).toBeUndefined()
})
it('ignores a matching key outside the [core] section', () => {
expect(parseCoreSparseCheckoutFlag('[other]\n\tsparseCheckout = true\n')).toBeUndefined()
})
it('ignores an inline comment after the value', () => {
expect(parseCoreSparseCheckoutFlag('[core]\n\tsparseCheckout = true # on\n')).toBe(true)
})
})

View File

@ -1,6 +1,6 @@
/* eslint-disable max-lines -- Why: this file keeps git worktree create/remove behavior together so local cleanup and creation invariants stay in one place. */
import { stat } from 'node:fs/promises'
import { join, posix, win32 } from 'node:path'
import { readFile, stat } from 'node:fs/promises'
import { isAbsolute, join, posix, resolve, win32 } from 'node:path'
import {
branchHasNoUnmergedChangesOnAnyTarget,
getBranchCleanupTargetRefs,
@ -1393,13 +1393,114 @@ function translateWorktreePath(
}
async function detectSparseCheckout(worktreePath: string): Promise<boolean> {
// Why: fs.stat the per-worktree gitdir's sparse-checkout config instead of a per-poll `git sparse-checkout list` subprocess that regressed responsiveness (PR #1290);
// the file's presence is the per-worktree signal because core.sparseCheckout is shared across all worktrees.
// 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'))
return stats.isFile() && stats.size > 0
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 sharedFlag = await readCoreSparseCheckoutFlag(join(commonDir, 'config'))
const worktreeFlag = await readCoreSparseCheckoutFlag(join(gitDir, 'config.worktree'))
return worktreeFlag ?? sharedFlag ?? false
}
async function readCoreSparseCheckoutFlag(configPath: string): Promise<boolean | undefined> {
try {
return parseCoreSparseCheckoutFlag(await readFile(configPath, 'utf-8'))
} catch {
return undefined
}
}
// 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 {
let inCoreSection = false
let value: boolean | undefined
for (const rawLine of configContent.split(/\r?\n/)) {
const line = stripGitConfigComment(rawLine).trim()
if (line.length === 0) {
continue
}
const sectionHeader = line.match(/^\[\s*([A-Za-z0-9.-]+)(\s+"(?:[^"\\]|\\.)*")?\s*\]$/)
if (sectionHeader) {
inCoreSection = sectionHeader[1].toLowerCase() === 'core' && sectionHeader[2] === undefined
continue
}
if (!inCoreSection) {
continue
}
const assignment = line.match(/^([A-Za-z][A-Za-z0-9-]*)\s*(?:=\s*(.*))?$/)
if (!assignment || assignment[1].toLowerCase() !== 'sparsecheckout') {
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'
}