fix: detect valid repos when git rev-parse can't confirm (#6173)
* fix: detect valid repos when git rev-parse can't confirm isGitRepo() has depended entirely on a successful `git rev-parse` since18ed7b27d, with catch blocks that collapse every failure into "not a git repository". When that subprocess fails for a reason unrelated to repo-ness — a transient spawn / git-shim hiccup in the packaged app, main-process resource pressure, or a config-level error — a real repository is silently downgraded to a plain folder. The folder scanner already tolerates this via a `.git` marker, so the scan reports "git_repo" but the subsequent addRepo throws, producing the spurious "Open as Folder" prompt for a valid repo. Keep `git rev-parse` as the authoritative positive signal, but on any non-positive result fall back to a validated `.git` marker instead of returning false: `.git` dir must contain HEAD, a `.git` file must point at a gitdir, and bare roots need HEAD + objects/ + refs/. A garbage `.git` file and an empty `.git/` are still rejected, preserving the validation18ed7b27dadded. Logs a warning when recovery via the marker happens so the underlying probe failure stays visible. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: address review feedback on repo detection - isGitRepo: warn at most once per session when recovering a repo via the .git marker, so a broken-git scan can't flood main-process logs. - repo-detection test: delete PATH instead of assigning "undefined" when it was originally unset, avoiding a corrupted PATH for later tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix: harden git repo marker fallback Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Omar Shahine <10343873+omarshahine@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com> Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
dc8161353e
commit
91e7e1d91c
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<typeof statSync>
|
||||
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.
|
||||
*/
|
||||
|
|
|
|||
Loading…
Reference in New Issue