diff --git a/src/main/git/worktree-sparse-checkout.test.ts b/src/main/git/worktree-sparse-checkout.test.ts new file mode 100644 index 000000000..98d1ce07a --- /dev/null +++ b/src/main/git/worktree-sparse-checkout.test.ts @@ -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 { + 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>) { + 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 /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) + }) +}) diff --git a/src/main/git/worktree.ts b/src/main/git/worktree.ts index 08decaefc..f3f901820 100644 --- a/src/main/git/worktree.ts +++ b/src/main/git/worktree.ts @@ -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 { - // 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 + // /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 { + 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 { + 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 { + 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' +}