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:
parent
a4f42ad427
commit
e3cc08f185
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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'
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue