diff --git a/src/main/git/repo-detection.test.ts b/src/main/git/repo-detection.test.ts index 56ae4a0fd..ff76b7e12 100644 --- a/src/main/git/repo-detection.test.ts +++ b/src/main/git/repo-detection.test.ts @@ -1,5 +1,13 @@ import { execFileSync } from 'node:child_process' -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { + mkdirSync, + mkdtempSync, + readFileSync, + realpathSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' import { tmpdir } from 'node:os' import * as path from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' @@ -35,6 +43,262 @@ describe('isGitRepo', () => { expect(isGitRepo(bareRepo)).toBe(true) }) + it('accepts a real repository when git itself cannot be run', () => { + // Why: regression guard for the spurious "Open as Folder" prompt. When the + // `git rev-parse` probe fails for an environmental reason (here simulated by + // making `git` unresolvable), a directory carrying valid Git metadata must + // still be recognized rather than silently downgraded to a plain folder. + const realRepo = path.join(tmpDir, 'real') + mkdirSync(realRepo) + git(realRepo, ['init', '--quiet']) + + withGitUnavailable(() => { + expect(isGitRepo(realRepo)).toBe(true) + }) + }) + + it('accepts a nested directory in a repository when git itself cannot be run', () => { + const realRepo = path.join(tmpDir, 'nested-real') + const nestedDir = path.join(realRepo, 'packages', 'web') + mkdirSync(nestedDir, { recursive: true }) + git(realRepo, ['init', '--quiet']) + + withGitUnavailable(() => { + expect(isGitRepo(nestedDir)).toBe(true) + }) + }) + + it('resolves a nested directory to the repo root when git itself cannot be run', () => { + const realRepo = path.join(tmpDir, 'nested-root-real') + const nestedDir = path.join(realRepo, 'packages', 'web') + mkdirSync(nestedDir, { recursive: true }) + git(realRepo, ['init', '--quiet']) + + withGitUnavailable(() => { + expect(getGitRepoRoot(nestedDir)).toBe(realRepo) + }) + }) + + it('accepts a symlinked nested directory in a repository when git itself cannot be run', () => { + const realRepo = path.join(tmpDir, 'symlink-real') + const nestedDir = path.join(realRepo, 'packages', 'web') + const symlinkedNestedDir = path.join(tmpDir, 'linked-nested') + mkdirSync(nestedDir, { recursive: true }) + git(realRepo, ['init', '--quiet']) + symlinkSync(nestedDir, symlinkedNestedDir, process.platform === 'win32' ? 'junction' : 'dir') + + withGitUnavailable(() => { + expect(isGitRepo(symlinkedNestedDir)).toBe(true) + expect(getGitRepoRoot(symlinkedNestedDir)).toBe( + realpathSync.native(realRepo).replace(/\\/g, '/') + ) + }) + }) + + it('rejects a symlink inside a repository that points outside when git cannot be run', () => { + const realRepo = path.join(tmpDir, 'symlink-parent-real') + const outsideDir = path.join(tmpDir, 'outside-target') + const symlinkedOutsideDir = path.join(realRepo, 'links', 'outside') + mkdirSync(path.dirname(symlinkedOutsideDir), { recursive: true }) + mkdirSync(outsideDir) + git(realRepo, ['init', '--quiet']) + symlinkSync(outsideDir, symlinkedOutsideDir, process.platform === 'win32' ? 'junction' : 'dir') + + withGitUnavailable(() => { + expect(isGitRepo(symlinkedOutsideDir)).toBe(false) + }) + }) + + it('resolves a symlink from one repository into another to the real target repo', () => { + const sourceRepo = path.join(tmpDir, 'symlink-source-real') + const targetRepo = path.join(tmpDir, 'symlink-target-real') + const targetNestedDir = path.join(targetRepo, 'packages', 'web') + const symlinkedTargetDir = path.join(sourceRepo, 'links', 'target') + mkdirSync(path.dirname(symlinkedTargetDir), { recursive: true }) + mkdirSync(targetNestedDir, { recursive: true }) + git(sourceRepo, ['init', '--quiet']) + git(targetRepo, ['init', '--quiet']) + symlinkSync( + targetNestedDir, + symlinkedTargetDir, + process.platform === 'win32' ? 'junction' : 'dir' + ) + + const expectedRoot = git(symlinkedTargetDir, ['rev-parse', '--show-toplevel']) + .trim() + .replace(/\\/g, '/') + + withGitUnavailable(() => { + expect(isGitRepo(symlinkedTargetDir)).toBe(true) + expect(getGitRepoRoot(symlinkedTargetDir)).toBe(expectedRoot) + }) + }) + + it('accepts a linked worktree when git itself cannot be run', () => { + const realRepo = path.join(tmpDir, 'linked-main') + const linkedWorktree = path.join(tmpDir, 'linked-worktree') + mkdirSync(realRepo) + git(realRepo, ['init', '--quiet']) + git(realRepo, [ + '-c', + 'user.name=Orca Test', + '-c', + 'user.email=orca@example.com', + 'commit', + '--allow-empty', + '--message', + 'initial' + ]) + git(realRepo, ['worktree', 'add', '--quiet', '-b', 'offline-linked', linkedWorktree]) + + withGitUnavailable(() => { + expect(isGitRepo(linkedWorktree)).toBe(true) + }) + }) + + it('rejects a plain folder when git cannot be run', () => { + const plain = path.join(tmpDir, 'plain') + mkdirSync(plain) + + withGitUnavailable(() => { + expect(isGitRepo(plain)).toBe(false) + }) + }) + + it('rejects a garbage .git file even when git cannot be run', () => { + const fakeRepo = path.join(tmpDir, 'fake-offline') + mkdirSync(fakeRepo) + writeFileSync(path.join(fakeRepo, '.git'), 'not a gitdir file') + + withGitUnavailable(() => { + expect(isGitRepo(fakeRepo)).toBe(false) + }) + }) + + it('rejects a nested folder with an invalid .git marker even inside a valid repo', () => { + const realRepo = path.join(tmpDir, 'outer-real') + const nestedDir = path.join(realRepo, 'packages', 'web') + mkdirSync(nestedDir, { recursive: true }) + git(realRepo, ['init', '--quiet']) + writeFileSync(path.join(nestedDir, '.git'), 'not a gitdir file') + + withGitUnavailable(() => { + expect(isGitRepo(nestedDir)).toBe(false) + }) + }) + + it('rejects a .git file that points at a missing gitdir when git cannot be run', () => { + const fakeRepo = path.join(tmpDir, 'missing-gitdir') + mkdirSync(fakeRepo) + writeFileSync(path.join(fakeRepo, '.git'), 'gitdir: /missing/orca/gitdir') + + withGitUnavailable(() => { + expect(isGitRepo(fakeRepo)).toBe(false) + }) + }) + + it('rejects an empty .git directory', () => { + const emptyGitDir = path.join(tmpDir, 'empty-gitdir') + mkdirSync(path.join(emptyGitDir, '.git'), { recursive: true }) + + withGitUnavailable(() => { + expect(isGitRepo(emptyGitDir)).toBe(false) + }) + }) + + it('rejects an incomplete .git directory with only HEAD', () => { + const incompleteGitDir = path.join(tmpDir, 'incomplete-gitdir') + mkdirSync(path.join(incompleteGitDir, '.git'), { recursive: true }) + writeFileSync(path.join(incompleteGitDir, '.git', 'HEAD'), 'ref: refs/heads/main\n') + + withGitUnavailable(() => { + expect(isGitRepo(incompleteGitDir)).toBe(false) + }) + }) + + it('rejects a regular repository admin directory when git itself cannot be run', () => { + const realRepo = path.join(tmpDir, 'admin-dir') + mkdirSync(realRepo) + git(realRepo, ['init', '--quiet']) + + withGitUnavailable(() => { + expect(isGitRepo(path.join(realRepo, '.git'))).toBe(false) + }) + }) + + it('rejects a case-insensitive .git admin directory alias when git itself cannot be run', () => { + const realRepo = path.join(tmpDir, 'admin-dir-uppercase') + mkdirSync(realRepo) + git(realRepo, ['init', '--quiet']) + const uppercaseAdminDir = path.join(realRepo, '.GIT') + try { + realpathSync.native(uppercaseAdminDir) + } catch { + return + } + + withGitUnavailable(() => { + expect(isGitRepo(uppercaseAdminDir)).toBe(false) + }) + }) + + it('rejects a regular repository admin directory when core.bare uses alternate false spelling', () => { + const realRepo = path.join(tmpDir, 'admin-dir-no') + mkdirSync(realRepo) + git(realRepo, ['init', '--quiet']) + git(realRepo, ['config', 'core.bare', 'no']) + + withGitUnavailable(() => { + expect(isGitRepo(path.join(realRepo, '.git'))).toBe(false) + }) + }) + + it('rejects a regular repository admin directory when core.bare is empty false', () => { + const realRepo = path.join(tmpDir, 'admin-dir-empty-false') + mkdirSync(realRepo) + git(realRepo, ['init', '--quiet']) + const configPath = path.join(realRepo, '.git', 'config') + writeFileSync(configPath, readFileSync(configPath, 'utf8').replace(/bare = false/, 'bare =')) + + withGitUnavailable(() => { + expect(isGitRepo(path.join(realRepo, '.git'))).toBe(false) + }) + }) + + it('rejects a regular repository admin directory when core.bare false has inline comments', () => { + const realRepo = path.join(tmpDir, 'admin-dir-commented-false') + mkdirSync(realRepo) + git(realRepo, ['init', '--quiet']) + const configPath = path.join(realRepo, '.git', 'config') + const config = readFileSync(configPath, 'utf8') + writeFileSync(configPath, config.replace(/bare = false/, 'bare = false # regular worktree')) + + withGitUnavailable(() => { + expect(isGitRepo(path.join(realRepo, '.git'))).toBe(false) + }) + + writeFileSync(configPath, config.replace(/bare = false/, 'bare = false ; regular worktree')) + + withGitUnavailable(() => { + expect(isGitRepo(path.join(realRepo, '.git'))).toBe(false) + }) + }) + + it('rejects a regular repository admin directory when core.bare is quoted false', () => { + const realRepo = path.join(tmpDir, 'admin-dir-quoted-false') + mkdirSync(realRepo) + git(realRepo, ['init', '--quiet']) + const configPath = path.join(realRepo, '.git', 'config') + writeFileSync( + configPath, + readFileSync(configPath, 'utf8').replace(/bare = false/, String.raw`bare = \"false\"`) + ) + + withGitUnavailable(() => { + expect(isGitRepo(path.join(realRepo, '.git'))).toBe(false) + }) + }) + it('resolves a contained path to the worktree root', () => { const repoRoot = path.join(tmpDir, 'repo') const nestedDir = path.join(repoRoot, 'packages', 'web') @@ -64,3 +328,26 @@ describe('isGitRepo', () => { expect(getGitRepoRoot(bareRepo)).toBe(bareRepo) }) }) + +/** + * Run `fn` with `git` removed from PATH so the in-process git probe fails the + * same way a transient spawn failure would, exercising the `.git`-marker + * fallback path. PATH is restored afterward. + */ +function withGitUnavailable(fn: () => void): void { + const originalPath = process.env.PATH + // An empty PATH leaves no directory to resolve the bare `git` binary, so the + // probe throws ENOENT — the indeterminate failure the fallback exists for. + process.env.PATH = '' + try { + fn() + } finally { + // Why: restoring an originally-unset PATH via assignment would write the + // string "undefined", corrupting PATH for later tests in this process. + if (originalPath === undefined) { + delete process.env.PATH + } else { + process.env.PATH = originalPath + } + } +} diff --git a/src/main/git/repo.ts b/src/main/git/repo.ts index c2219c4f6..0c8f1efbe 100644 --- a/src/main/git/repo.ts +++ b/src/main/git/repo.ts @@ -1,7 +1,7 @@ /* oxlint-disable max-lines */ import { execSync } from 'node:child_process' -import { existsSync, statSync } from 'node:fs' -import { basename } from 'node:path' +import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs' +import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path' import { gitExecFileSync, gitExecFileAsync } from './runner' import type { BaseRefSearchResult } from '../../shared/types' import { parseGitRevListAheadBehindCounts } from '../../shared/git-rev-list-output' @@ -21,6 +21,9 @@ type LocalGitExecOptions = { wslDistro?: string } +type GitRepoProbeResult = 'repo' | 'not-repo' | 'indeterminate' +type GitMarkerScanResult = { status: 'valid'; rootPath: string } | { status: 'absent' | 'invalid' } + function gitExecOptions( cwd: string, options: LocalGitExecOptions = {} @@ -72,24 +75,80 @@ export function isGitRepo(path: string): boolean { if (!existsSync(path) || !statSync(path).isDirectory()) { return false } + } catch { + return false + } + + // Authoritative positive signal: ask git directly. Covers regular work + // trees, linked worktrees (gitfile), submodules, and bare repos. + const gitProbeResult = probeGitRepo(path) + if (gitProbeResult === 'repo') { + return true + } + if (gitProbeResult === 'not-repo') { + return false + } + + // Why: `git rev-parse` can fail to produce a clean answer for reasons + // unrelated to repo-ness — a transient spawn failure or git-shim hiccup in + // the packaged app, resource pressure in the Electron main process, or a + // repo whose config errors out. Treating every such failure as "not a repo" + // silently downgrades a real repository to a plain folder (worktrees, SCM, + // PRs all disappear) and is the regression behind the spurious "Open as + // Folder" prompt. Fall back to a validated `.git` marker so a directory that + // genuinely carries Git metadata is still recognized; a directory with only + // a garbage `.git` file has no valid marker and is correctly rejected. + const markerScan = scanGitMarkerSync(path) + if (markerScan.status === 'valid' && !warnedMarkerFallbackThisSession) { + // Why: warn only once per session. The folder scanner calls isGitRepo for + // many paths; if git is genuinely unavailable, warning per path would flood + // the main-process logs without adding signal beyond the first occurrence. + warnedMarkerFallbackThisSession = true + console.warn('[isGitRepo] git rev-parse could not confirm repo; accepted via .git marker', { + path + }) + } + return markerScan.status === 'valid' +} + +let warnedMarkerFallbackThisSession = false + +/** + * Tri-state git probe: only a clean pair of negative answers is a definitive + * non-repo. Spawn/config failures stay indeterminate so marker fallback can run. + */ +function probeGitRepo(path: string): GitRepoProbeResult { + let sawFailure = false + + try { const insideWorkTree = gitExecFileSync(['rev-parse', '--is-inside-work-tree'], { cwd: path }).trim() if (insideWorkTree === 'true') { - return true + return 'repo' + } + if (insideWorkTree !== 'false') { + return 'indeterminate' } } catch { - // Fall through to the bare-repo probe below. + sawFailure = true } try { const bareRepo = gitExecFileSync(['rev-parse', '--is-bare-repository'], { cwd: path }).trim() - return bareRepo === 'true' + if (bareRepo === 'true') { + return 'repo' + } + if (bareRepo !== 'false') { + return 'indeterminate' + } } catch { - return false + sawFailure = true } + + return sawFailure ? 'indeterminate' : 'not-repo' } export function getGitRepoRoot(path: string): string { @@ -109,6 +168,10 @@ export function getGitRepoRoot(path: string): string { } catch { // Fall through to preserving the original path. } + const markerScan = scanGitMarkerSync(path) + if (markerScan.status === 'valid') { + return normalizeGitRepoRootForInputPath(path, markerScan.rootPath) + } return path } @@ -122,6 +185,259 @@ export function normalizeGitRepoRootForInputPath(inputPath: string, rootPath: st return normalizeRuntimePathSeparators(rootPath) } +/** + * Filesystem-only check for genuine Git metadata, used as a fallback when git + * cannot give a clean answer. Strict enough to reject a directory whose `.git` + * is a garbage file (preserving the validation added in 18ed7b27d): + * - `.git` directory: accepted only if it has real common or linked-worktree + * gitdir shape, so empty/incomplete `.git/` folders are rejected. + * - `.git` file: accepted only if its `gitdir:` target resolves to valid Git + * metadata, covering linked worktrees and submodules. + * - bare repo root: accepted when HEAD + objects/ + refs/ are present and the + * config does not mark it as a regular worktree admin dir. + */ +function scanGitMarkerSync(path: string): GitMarkerScanResult { + const realPath = resolveRealPathSync(path) + if (realPath && realPath !== path) { + const lexicalScan = scanGitMarkerAncestorsSync(path) + const realPathScan = scanGitMarkerAncestorsSync(realPath) + if ( + lexicalScan.status === 'valid' && + realPathScan.status === 'valid' && + pathsReferToSameEntry(lexicalScan.rootPath, realPathScan.rootPath) + ) { + // Why: preserve lexical spellings such as /var vs /private/var, but let a + // symlink from one repo into another bind to the real target repo like git. + return lexicalScan + } + return realPathScan + } + return scanGitMarkerAncestorsSync(path) +} + +function resolveRealPathSync(path: string): string | null { + try { + return realpathSync.native(path) + } catch { + try { + return realpathSync(path) + } catch { + return null + } + } +} + +function scanGitMarkerAncestorsSync(path: string): GitMarkerScanResult { + for (const candidate of ancestorDirectories(path)) { + if (!isInsideDotGitMarker(candidate, path)) { + const worktreeMarker = scanWorktreeMarkerSync(candidate) + if (worktreeMarker.status !== 'absent') { + return worktreeMarker + } + } + if (hasValidBareRepoMarkerSync(candidate)) { + return { status: 'valid', rootPath: candidate } + } + } + return { status: 'absent' } +} + +function ancestorDirectories(path: string): string[] { + const directories: string[] = [] + let current = path + while (true) { + directories.push(current) + const parent = dirname(current) + if (parent === current) { + return directories + } + current = parent + } +} + +function isInsideDotGitMarker(rootPath: string, targetPath: string): boolean { + const relativePath = relative(rootPath, targetPath) + if (!relativePath || relativePath.startsWith('..') || isAbsolute(relativePath)) { + return false + } + const firstSegment = relativePath.split(/[\\/]+/)[0] + if (firstSegment === '.git') { + return true + } + if (firstSegment.toLowerCase() !== '.git') { + return false + } + return pathsReferToSameEntry(join(rootPath, firstSegment), join(rootPath, '.git')) +} + +function pathsReferToSameEntry(leftPath: string, rightPath: string): boolean { + try { + const leftStat = statSync(leftPath) + const rightStat = statSync(rightPath) + if (leftStat.ino !== 0 && leftStat.dev === rightStat.dev && leftStat.ino === rightStat.ino) { + return true + } + const leftRealPath = normalizeRuntimePathSeparators(realpathSync.native(leftPath)) + const rightRealPath = normalizeRuntimePathSeparators(realpathSync.native(rightPath)) + return process.platform === 'win32' + ? leftRealPath.toLowerCase() === rightRealPath.toLowerCase() + : leftRealPath === rightRealPath + } catch { + return false + } +} + +function scanWorktreeMarkerSync(worktreePath: string): GitMarkerScanResult { + const dotGit = join(worktreePath, '.git') + let marker: ReturnType + try { + marker = statSync(dotGit) + } catch { + return { status: 'absent' } + } + + if (marker.isDirectory()) { + return hasValidGitDirectorySync(dotGit) + ? { status: 'valid', rootPath: worktreePath } + : { status: 'invalid' } + } + if (marker.isFile()) { + let gitDir: string | null + try { + gitDir = parseGitdirFile(worktreePath, readFileSync(dotGit, 'utf8')) + } catch { + return { status: 'invalid' } + } + return gitDir !== null && hasValidGitDirectorySync(gitDir) + ? { status: 'valid', rootPath: worktreePath } + : { status: 'invalid' } + } + return { status: 'invalid' } +} + +function parseGitdirFile(basePath: string, content: string): string | null { + const firstLine = content.split(/\r?\n/, 1)[0] ?? '' + const match = firstLine.match(/^gitdir:\s*(.+?)\s*$/i) + if (!match) { + return null + } + return resolveGitMetadataPath(basePath, match[1]) +} + +function resolveGitMetadataPath(basePath: string, rawPath: string): string | null { + const value = rawPath.trim() + if (!value) { + return null + } + const baseWsl = parseWslUncPath(basePath) + if (baseWsl && value.startsWith('/')) { + return toWindowsWslPath(value, baseWsl.distro) + } + return isAbsolute(value) ? value : resolve(basePath, value) +} + +function hasValidGitDirectorySync(gitDir: string): boolean { + return hasValidCommonGitDirectorySync(gitDir) || hasValidLinkedWorktreeGitDirectorySync(gitDir) +} + +function hasValidCommonGitDirectorySync(gitDir: string): boolean { + try { + return ( + statSync(join(gitDir, 'HEAD')).isFile() && + statSync(join(gitDir, 'objects')).isDirectory() && + statSync(join(gitDir, 'refs')).isDirectory() + ) + } catch { + return false + } +} + +function hasValidLinkedWorktreeGitDirectorySync(gitDir: string): boolean { + try { + if (!statSync(join(gitDir, 'HEAD')).isFile() || !statSync(join(gitDir, 'commondir')).isFile()) { + return false + } + const commonDir = resolveGitMetadataPath( + gitDir, + readFileSync(join(gitDir, 'commondir'), 'utf8') + ) + return commonDir !== null && hasValidCommonGitDirectorySync(commonDir) + } catch { + return false + } +} + +function hasValidBareRepoMarkerSync(path: string): boolean { + return hasValidCommonGitDirectorySync(path) && !gitConfigDeclaresNonBare(path) +} + +function gitConfigDeclaresNonBare(gitDir: string): boolean { + try { + const config = readFileSync(join(gitDir, 'config'), 'utf8') + let inCoreSection = false + for (const line of config.split(/\r?\n/)) { + const section = line.match(/^\s*\[([^\]]+)\]/) + if (section) { + inCoreSection = section[1].trim().toLowerCase() === 'core' + continue + } + const bare = line.match(/^\s*bare\s*=\s*(.*?)\s*$/i) + if (inCoreSection && bare) { + return isGitBooleanFalse(normalizeGitConfigValue(bare[1])) + } + } + return false + } catch { + return false + } +} + +function normalizeGitConfigValue(value: string): string { + const unescaped = stripGitConfigInlineComment(value).trim().replace(/\\"/g, '"') + if ( + unescaped.length >= 2 && + ((unescaped.startsWith('"') && unescaped.endsWith('"')) || + (unescaped.startsWith("'") && unescaped.endsWith("'"))) + ) { + return unescaped.slice(1, -1) + } + return unescaped +} + +function stripGitConfigInlineComment(value: string): string { + let quote: '"' | "'" | null = null + let escaped = false + for (let i = 0; i < value.length; i++) { + const char = value[i] + if (escaped) { + escaped = false + continue + } + if (char === '\\') { + escaped = true + continue + } + if (quote) { + if (char === quote) { + quote = null + } + continue + } + if (char === '"' || char === "'") { + quote = char + continue + } + if (char === '#' || char === ';') { + return value.slice(0, i) + } + } + return value +} + +function isGitBooleanFalse(value: string): boolean { + return ['', 'false', 'no', 'off', '0'].includes(value.toLowerCase()) +} + /** * Get a human-readable name for the repo from its path. */