// Repo-first discovery: walk bounded roots for Git repositories using only // Node's fs APIs. Electron owns this machine-local capability; the renderer // supplies the profile-scoped policy from Hermes config. import fs from 'node:fs' import os from 'node:os' import path from 'node:path' const fsp = fs.promises const DEFAULT_MAX_DEPTH = 3 const MAX_CONCURRENCY = 32 const JUNK_DIRS = new Set(['Applications', 'Library', 'node_modules', 'site-packages', 'vendor', 'venv']) export interface RepoScanOptions { maxDepth?: number enabled?: boolean excludePaths?: string[] // Platform override for the darwin-only TCC media-dir skip (tests force // 'darwin' on Linux CI; production omits it). platform?: NodeJS.Platform } export interface RepoScanPathOptions { homeDir?: string platform?: NodeJS.Platform } // Avoid macOS TCC prompts when the default home scan reaches protected media // folders. Nested names and explicitly supplied media roots remain scannable. const MEDIA_ROOT_DIRS = new Set(['Movies', 'Music', 'Pictures', 'Public']) // These packages look like directories but are TCC-protected and cannot contain // user repositories, including when stored outside the default media folders. const LIBRARY_PACKAGE_SUFFIXES = ['.photoslibrary', '.musiclibrary', '.tvlibrary', '.aplibrary'] function isLibraryPackage(name: string): boolean { const lower = String(name).toLowerCase() return LIBRARY_PACKAGE_SUFFIXES.some(suffix => lower.endsWith(suffix)) } interface NormalizedScanPath { key: string value: string } function pathApiFor(platform: NodeJS.Platform): typeof path.posix | typeof path.win32 { return platform === 'win32' ? path.win32 : path.posix } export function normalizeRepoScanPath(rawPath: string, options: RepoScanPathOptions = {}): NormalizedScanPath | null { const platform = options.platform ?? process.platform const homeDir = options.homeDir ?? os.homedir() const pathApi = pathApiFor(platform) const raw = String(rawPath ?? '').trim() if (!raw) { return null } let expanded = raw if (raw === '~') { expanded = homeDir } else if (raw.startsWith('~/') || raw.startsWith('~\\')) { expanded = pathApi.join(homeDir, raw.slice(2)) } const absolute = pathApi.isAbsolute(expanded) ? expanded : pathApi.resolve(homeDir, expanded) const value = pathApi.normalize(absolute) const key = platform === 'win32' ? value.toLocaleLowerCase('en-US') : value return { key, value } } export function repoScanPathIsWithin(candidate: string, parent: string, options: RepoScanPathOptions = {}): boolean { const platform = options.platform ?? process.platform const pathApi = pathApiFor(platform) const candidatePath = normalizeRepoScanPath(candidate, options) const parentPath = normalizeRepoScanPath(parent, options) if (!candidatePath || !parentPath) { return false } const relative = pathApi.relative(parentPath.key, candidatePath.key) return ( relative === '' || (relative !== '..' && !relative.startsWith(`..${pathApi.sep}`) && !pathApi.isAbsolute(relative)) ) } async function mapLimit(items: T[], limit: number, fn: (item: T) => Promise): Promise { let cursor = 0 async function worker(): Promise { while (cursor < items.length) { const index = cursor cursor += 1 await fn(items[index]) } } await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => worker())) } /** * Scan roots for Git repositories. An empty root list preserves the historical * home-directory scan. Disabled discovery returns before resolving home or * reading the filesystem. */ export async function scanGitRepos(roots: string[], options: RepoScanOptions = {}) { if (options.enabled === false) { return [] } const maxDepthValue = Number(options.maxDepth) const maxDepth = Number.isFinite(maxDepthValue) && maxDepthValue >= 0 ? maxDepthValue : DEFAULT_MAX_DEPTH const pathOptions: RepoScanPathOptions = options.platform ? { platform: options.platform } : {} const requestedRoots = Array.isArray(roots) && roots.length > 0 ? roots : [os.homedir()] const searchRoots = [ ...new Map( requestedRoots .map(root => normalizeRepoScanPath(root, pathOptions)) .filter((entry): entry is NormalizedScanPath => entry !== null) .map(entry => [entry.key, entry.value]) ).values() ] const exclusions = (options.excludePaths ?? []) .map(excluded => normalizeRepoScanPath(excluded, pathOptions)) .filter((entry): entry is NormalizedScanPath => entry !== null) const found = new Map() function isExcluded(candidate: string): boolean { return exclusions.some(excluded => repoScanPathIsWithin(candidate, excluded.value, pathOptions)) } async function walk(dir: string, depth: number): Promise { if (depth > maxDepth || isExcluded(dir)) { return } let entries: fs.Dirent[] try { entries = await fsp.readdir(dir, { withFileTypes: true }) } catch { return } const gitDir = entries.find(entry => entry.name === '.git' && entry.isDirectory()) if (gitDir) { try { await fsp.access(path.join(dir, '.git', 'HEAD'), fs.constants.R_OK) } catch { return } const normalized = normalizeRepoScanPath(dir, pathOptions) if (normalized) { found.set(normalized.key, { root: normalized.value, label: path.basename(normalized.value) || normalized.value }) } return } const skipTccProtectedPaths = (pathOptions.platform ?? process.platform) === 'darwin' const subdirs = entries .filter(entry => entry.isDirectory() && !entry.name.startsWith('.') && !JUNK_DIRS.has(entry.name)) .filter(entry => { if (!skipTccProtectedPaths) { return true } // Depth-0 children of a scan root only: a nested dir named "Music" // inside a project is fine, and an explicitly supplied media root // arrives AS a root (never as a depth-0 child), so it still scans. if (depth === 0 && MEDIA_ROOT_DIRS.has(entry.name)) { return false } return !isLibraryPackage(entry.name) }) .map(entry => path.join(dir, entry.name)) await mapLimit(subdirs, MAX_CONCURRENCY, subdir => walk(subdir, depth + 1)) } await mapLimit(searchRoots, MAX_CONCURRENCY, root => walk(root, 0)) return [...found.values()] }