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`
since 18ed7b27d, 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
validation 18ed7b27d added. 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:
Omar Shahine 2026-07-01 15:10:35 -07:00 committed by GitHub
parent dc8161353e
commit 91e7e1d91c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 610 additions and 7 deletions

View File

@ -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
}
}
}

View File

@ -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.
*/