import { existsSync } from "fs"; import { readdir, stat, realpath } from "fs/promises"; import { join, basename, sep, resolve } from "path"; import { createHash } from "crypto"; import { eq } from "drizzle-orm"; import type { Project, ProjectType, GitforestConfig, GitStatus, SubmoduleInfo } from "../types/index.ts"; import type { GitService } from "../services/git.ts"; import { bunGitService } from "../services/git.ts"; import { mergeStatus } from "../services/git.ts"; import { detectProjectMarker } from "./markers.ts"; import { getSubmoduleInfo, findSubmodules } from "./submodules.ts"; import { initDb, schema, clearCache as clearDbCache } from "../db/index.ts"; import { SCANNER } from "../constants.ts"; /** * Get the most recent file modification time in a directory (non-recursive, top-level only) */ async function getLatestModificationTime(dirPath: string): Promise { try { const entries = await readdir(dirPath); let latestTime: Date | null = null; for (const entry of entries) { // Skip hidden files and common non-source directories if (entry.startsWith('.') || entry === 'node_modules' || entry === 'dist' || entry === 'build') { continue; } try { const entryPath = join(dirPath, entry); const entryStat = await stat(entryPath); const mtime = entryStat.mtime; if (!latestTime || mtime > latestTime) { latestTime = mtime; } } catch { // Skip files we can't stat } } return latestTime; } catch { return null; } } // Re-export from utils to maintain backward compatibility export { sortProjects, filterProjects } from "../utils/project-utils.ts"; /** * Generate a unique ID for a project path */ function generateProjectId(path: string): string { return createHash("md5").update(path).digest("hex").slice(0, SCANNER.PROJECT_ID_LENGTH); } /** * Path containment check that respects path-segment boundaries so * "/foo/bar" does NOT match "/foo/bar2". Empty `dir` never matches. */ function isUnderDir(child: string, dir: string): boolean { if (!dir) return false; if (child === dir) return true; const normalized = dir.endsWith(sep) ? dir : dir + sep; return child.startsWith(normalized); } /** * Compare paths after resolving symlinks where possible. Git reports the * repository root, while configured scan paths may be symlinked or relative. */ async function pathsReferToSameDirectory(first: string, second: string): Promise { const resolvePath = async (path: string): Promise => { try { return await realpath(path); } catch { return resolve(path); } }; return (await resolvePath(first)) === (await resolvePath(second)); } const PROJECT_CACHE_METADATA_KEY = "project_scan_metadata"; const PROJECT_SNAPSHOT_BATCH_SIZE = 20; interface ProjectSnapshotState { lastPublishedProjectCount: number; } function publishProjectSnapshotIfNeeded( foundProjects: Project[], onProjects: ((projects: Project[]) => void) | undefined, snapshotState: ProjectSnapshotState, force = false, ): void { if (!onProjects || foundProjects.length === 0) return; const newProjects = foundProjects.length - snapshotState.lastPublishedProjectCount; if (!force && foundProjects.length !== 1 && newProjects < PROJECT_SNAPSHOT_BATCH_SIZE) return; if (foundProjects.length === snapshotState.lastPublishedProjectCount) return; snapshotState.lastPublishedProjectCount = foundProjects.length; onProjects([...foundProjects]); } interface ProjectCacheMetadata { directories: Array<{ path: string; maxDepth: number }>; scan: { ignore: string[]; includeHidden: boolean; }; display: { showSubmodules: boolean; showNonGitProjects: boolean; }; scannedAt: string; } function createProjectCacheMetadata(config: GitforestConfig): ProjectCacheMetadata { return { directories: config.directories .map((dir) => ({ path: dir.path, maxDepth: dir.maxDepth })) .sort((a, b) => a.path.localeCompare(b.path) || a.maxDepth - b.maxDepth), scan: { ignore: [...config.scan.ignore].sort(), includeHidden: config.scan.includeHidden, }, display: { showSubmodules: config.display.showSubmodules, showNonGitProjects: config.display.showNonGitProjects, }, scannedAt: new Date().toISOString(), }; } function parseProjectCacheMetadata(value: string | null): ProjectCacheMetadata | null { if (!value) return null; try { const parsed = JSON.parse(value) as Partial; if (!Array.isArray(parsed.directories) || !parsed.scan || !parsed.display || !parsed.scannedAt) { return null; } return parsed as ProjectCacheMetadata; } catch { return null; } } function metadataMatchesConfig(metadata: ProjectCacheMetadata | null, config: GitforestConfig): boolean { if (!metadata) return false; const expected = createProjectCacheMetadata(config); return ( JSON.stringify(metadata.directories) === JSON.stringify(expected.directories) && JSON.stringify(metadata.scan) === JSON.stringify(expected.scan) && JSON.stringify(metadata.display) === JSON.stringify(expected.display) ); } function getMetadataScannedAt(metadata: ProjectCacheMetadata | null): Date | null { if (!metadata) return null; const scannedAt = new Date(metadata.scannedAt); return Number.isNaN(scannedAt.getTime()) ? null : scannedAt; } /** * Check if a directory should be ignored */ function shouldIgnore(name: string, ignorePatterns: string[], includeHidden: boolean): boolean { // Always ignore these if (name === "." || name === "..") return true; // Ignore hidden directories unless configured if (!includeHidden && name.startsWith(".")) return true; // Check ignore patterns return ignorePatterns.includes(name); } /** * Scan a single directory for projects */ async function scanDirectory( dirPath: string, config: GitforestConfig, depth: number, maxDepth: number, foundProjects: Project[], processedPaths: Set, gitService: GitService, onProjects: ((projects: Project[]) => void) | undefined, snapshotState: ProjectSnapshotState, ): Promise { // Don't scan beyond max depth if (depth > maxDepth) return; // Skip if already processed if (processedPaths.has(dirPath)) return; processedPaths.add(dirPath); // Check if this directory exists if (!existsSync(dirPath)) return; // The configured root directory (depth 0) is always a container — we must // recurse into it even if it happens to have a project marker or .git. const isRootDir = depth === 0; // `git rev-parse --is-inside-work-tree` is true for every directory below a // repository. Only the directory returned by `--show-toplevel` is a // project; otherwise a scan of a configured repo root produces one duplicate // project for every descendant directory. const isInsideGitWorkTree = await gitService.isGitRepo(dirPath); const gitRoot = isInsideGitWorkTree ? await gitService.getGitRoot(dirPath) : null; const isGit = gitRoot !== null && await pathsReferToSameDirectory(dirPath, gitRoot); if (isGit) { // This is a git repository - add it as a project const project = await createProject(dirPath, "git"); foundProjects.push(project); publishProjectSnapshotIfNeeded(foundProjects, onProjects, snapshotState); // Check for submodules if configured if (config.display.showSubmodules) { const submodulePaths = await findSubmodules(dirPath, gitService); for (const subPath of submodulePaths) { if (!processedPaths.has(subPath)) { const subProject = await createProject(subPath, "git-submodule", undefined, gitService); foundProjects.push(subProject); publishProjectSnapshotIfNeeded(foundProjects, onProjects, snapshotState); processedPaths.add(subPath); } } } // Don't recurse into git repositories — except the root scan directory, // which the user explicitly configured as a container to scan inside. if (!isRootDir) return; } // A directory inside an ancestor repository is still part of that // repository, not an independent non-git project. Only inspect project // markers when the directory is outside any work tree. if (!isInsideGitWorkTree) { const marker = await detectProjectMarker(dirPath); if (marker) { // At the root level, skip adding it as a project — it's a container, not // a project the user wants to manage. Still recurse into children. if (!isRootDir && config.display.showNonGitProjects) { const project = await createProject(dirPath, "non-git", marker, gitService); foundProjects.push(project); publishProjectSnapshotIfNeeded(foundProjects, onProjects, snapshotState); } // Don't recurse into non-git projects (except the root scan directory) if (!isRootDir) return; } } // This is just a directory - scan children try { const entries = await readdir(dirPath); for (const entry of entries) { if (shouldIgnore(entry, config.scan.ignore, config.scan.includeHidden)) { continue; } const entryPath = join(dirPath, entry); try { const entryStat = await stat(entryPath); if (entryStat.isDirectory()) { await scanDirectory( entryPath, config, depth + 1, maxDepth, foundProjects, processedPaths, gitService, onProjects, snapshotState, ); } } catch { // Skip entries we can't stat } } } catch { // Skip directories we can't read } } /** * Create a Project object from a path */ async function createProject( path: string, type: ProjectType, marker?: string | null, gitService: GitService = bunGitService ): Promise { const id = generateProjectId(path); const name = basename(path); // Get project marker if not provided and not a git repo const projectMarker = marker ?? (type === "non-git" ? await detectProjectMarker(path) : null); // Get git status if this is a git project. Per ADR-0005, the scanner only // pays for local fields here; remote fields are zero-filled and populated // later by the background-fetch hook via UPDATE_PROJECT_REMOTE_STATUS. let status = null; if (type === "git" || type === "git-submodule") { try { const local = await gitService.getLocalStatus(path); status = mergeStatus(local, null); } catch { // Status unavailable } } // Get submodule info if applicable let submodule = null; if (type === "git-submodule") { submodule = await getSubmoduleInfo(path, gitService); } // Get last modified time for non-git projects (or git projects without commits) let lastModified: Date | null = null; if (type === "non-git" || (status && !status.hasCommits)) { lastModified = await getLatestModificationTime(path); } return { id, name, path, type, projectMarker, status, submodule, lastScanned: new Date(), lastModified, }; } /** * Scan all configured directories for projects * * Recursively scans directories looking for git repositories, submodules, * and non-git projects (identified by marker files like package.json). * * @param config - The gitforest configuration object containing directories to scan * @param options - Options for scanning * @param options.onProgress - Optional callback for progress updates * @param options.onProjects - Optional callback with bounded partial project snapshots * @param options.gitService - Git service implementation (defaults to bunGitService) * @returns Promise resolving to array of discovered projects * * @example * ```typescript * const projects = await scanAllDirectories(config, { * onProgress: (scanned, found) => { * console.log(`Scanned ${scanned} dirs, found ${found} projects`); * } * }); * ``` */ export async function scanAllDirectories( config: GitforestConfig, options: { onProgress?: (scanned: number, found: number) => void; onProjects?: (projects: Project[]) => void; gitService?: GitService; } = {} ): Promise { const { onProgress, onProjects, gitService = bunGitService } = options; const foundProjects: Project[] = []; const processedPaths = new Set(); const snapshotState: ProjectSnapshotState = { lastPublishedProjectCount: 0 }; const concurrency = Math.max(1, config.scan.concurrency); // Scan top-level configured directories in parallel batches sized by // config.scan.concurrency. Inner walks remain sequential so we don't // explode CPU/IO when a single directory has thousands of subprojects. for (let i = 0; i < config.directories.length; i += concurrency) { const batch = config.directories.slice(i, i + concurrency); await Promise.all( batch.map((dirConfig) => scanDirectory( dirConfig.path, config, 0, dirConfig.maxDepth, foundProjects, processedPaths, gitService, onProjects, snapshotState, ) ) ); publishProjectSnapshotIfNeeded(foundProjects, onProjects, snapshotState, true); onProgress?.(processedPaths.size, foundProjects.length); } return foundProjects; } /** * Convert a database row to a Project object */ function dbRowToProject(row: typeof schema.projects.$inferSelect): Project { return { id: row.id, name: row.name, path: row.path, type: row.type as ProjectType, projectMarker: row.projectMarker, status: row.statusJson ? JSON.parse(row.statusJson) as GitStatus : null, submodule: row.submoduleJson ? JSON.parse(row.submoduleJson) as SubmoduleInfo : null, lastScanned: row.lastScanned ?? new Date(), lastModified: row.lastModified ?? null, }; } /** * Convert a Project to database row format */ function projectToDbRow(project: Project) { return { id: project.id, name: project.name, path: project.path, type: project.type, projectMarker: project.projectMarker, statusJson: project.status ? JSON.stringify(project.status) : null, submoduleJson: project.submodule ? JSON.stringify(project.submodule) : null, lastScanned: project.lastScanned, lastModified: project.lastModified, }; } /** * Save projects to the cache database. * Replaces all cached projects so removed directories, depth changes, etc. * are reflected immediately. */ async function saveToCache(projects: Project[], config: GitforestConfig): Promise { const db = await initDb(); // Clear old entries then insert fresh scan results await db.delete(schema.projects).run(); for (const project of projects) { const row = projectToDbRow(project); await db.insert(schema.projects).values(row); } const metadata = createProjectCacheMetadata(config); await db .delete(schema.configCache) .where(eq(schema.configCache.key, PROJECT_CACHE_METADATA_KEY)) .run(); await db.insert(schema.configCache).values({ key: PROJECT_CACHE_METADATA_KEY, value: JSON.stringify(metadata), updatedAt: getMetadataScannedAt(metadata), }); } /** * Load all projects from cache */ async function loadFromCache(): Promise { const db = await initDb(); const rows = await db.select().from(schema.projects).all(); return rows.map(dbRowToProject); } async function loadProjectCacheMetadata(): Promise { const db = await initDb(); const rows = await db .select() .from(schema.configCache) .where(eq(schema.configCache.key, PROJECT_CACHE_METADATA_KEY)) .all(); return parseProjectCacheMetadata(rows[0]?.value ?? null); } /** * Check if cache is fresh based on TTL */ function isCacheFresh( projects: Project[], ttlSeconds: number, metadata: ProjectCacheMetadata | null = null ): boolean { const now = Date.now(); const ttlMs = ttlSeconds * 1000; const metadataScannedAt = getMetadataScannedAt(metadata); if (metadataScannedAt) { return now - metadataScannedAt.getTime() < ttlMs; } if (projects.length === 0) return false; // Cache is fresh if most recent scan is within TTL const mostRecent = Math.max(...projects.map(p => p.lastScanned.getTime())); return now - mostRecent < ttlMs; } export async function loadFreshProjectsFromCache(config: GitforestConfig): Promise { const [allCached, metadata] = await Promise.all([ loadFromCache(), loadProjectCacheMetadata(), ]); // Filter to only projects under currently configured directories. const cached = allCached.filter(p => config.directories.some(dir => isUnderDir(p.path, dir.path)) ); // Prefer scan metadata because an empty configured directory still needs // to count as covered. Fall back to project rows for legacy caches. const allDirsCovered = metadataMatchesConfig(metadata, config) || config.directories.every(dir => cached.some(p => isUnderDir(p.path, dir.path)) ); if (allDirsCovered && isCacheFresh(cached, config.cache.ttlSeconds, metadata)) { return cached; } return null; } export async function hasFreshProjectCache(config: GitforestConfig): Promise { try { return (await loadFreshProjectsFromCache(config)) !== null; } catch { return false; } } /** * Scan directories with caching support * * Uses cached data if fresh (within TTL), otherwise performs full scan. * Results are automatically cached for subsequent calls. * * @param config - The gitforest configuration object * @param options - Scanning options * @param options.forceRefresh - Force a fresh scan even if cache is valid * @param options.onProgress - Callback for scan progress updates * @param options.onProjects - Optional callback with bounded partial project snapshots * @param options.gitService - Git service implementation (defaults to bunGitService) * @returns Promise resolving to array of projects (cached or fresh) * * @example * ```typescript * // Use cache if available * const projects = await scanWithCache(config); * * // Force fresh scan * const freshProjects = await scanWithCache(config, { forceRefresh: true }); * ``` */ export async function scanWithCache( config: GitforestConfig, options: { forceRefresh?: boolean; onProgress?: (scanned: number, found: number) => void; onProjects?: (projects: Project[]) => void; gitService?: GitService; } = {} ): Promise { const { forceRefresh = false, onProgress, onProjects, gitService = bunGitService } = options; // Try to load from cache first if (!forceRefresh) { try { const cached = await loadFreshProjectsFromCache(config); if (cached !== null) { return cached; } } catch { // Cache read failed, continue with fresh scan } } // Perform full scan const projects = await scanAllDirectories(config, { onProgress, onProjects, gitService }); // Save to cache try { await saveToCache(projects, config); } catch { // Cache write failed, but we still have the projects } return projects; } /** * Clear the project cache */ export async function clearCache(): Promise { await clearDbCache(); } /** * Get cache statistics */ export async function getCacheStats(): Promise<{ projectCount: number; oldestScan: Date | null; newestScan: Date | null; }> { const db = await initDb(); const rows = await db.select().from(schema.projects).all(); if (rows.length === 0) { return { projectCount: 0, oldestScan: null, newestScan: null }; } const timestamps = rows .map(r => r.lastScanned?.getTime() ?? 0) .filter(t => t > 0); return { projectCount: rows.length, oldestScan: timestamps.length > 0 ? new Date(Math.min(...timestamps)) : null, newestScan: timestamps.length > 0 ? new Date(Math.max(...timestamps)) : null, }; }