import { lstat, readdir, readFile, realpath, stat } from "node:fs/promises"; import { isAbsolute, join, relative, resolve, sep } from "node:path"; import { checksum, topologyFingerprint } from "./encoding.ts"; import { GitRunError, GitRunner } from "./git-runner.ts"; import type { DiscoveryRoot } from "./model.ts"; import { allCompleted, checkOperation, OperationError, reportOperationProgress } from "./operation-context.ts"; const DIRECTORY_SCAN_CONCURRENCY = 16; const GIT_POINTER_MAX_BYTES = 4096; interface RepositoryInfo { readonly absoluteRoot: string; readonly commonGitDir: string; readonly sourceIdentity: string; readonly treeId: string | null; } type RepositoryInspection = | { readonly kind: "active"; readonly repository: RepositoryInfo } | { readonly kind: "broken"; readonly absoluteRoot: string } | { readonly kind: "stale"; readonly absoluteRoot: string } | { readonly kind: "absent" }; interface DiscoveredRoot { readonly absoluteRoot: string; readonly relativeRoot: string; readonly gitBacked: boolean; readonly state: DiscoveryRoot["state"]; readonly sourceIdentity: string; readonly privateRepositoryId: string; readonly treeId: string | null; readonly gitlinkOid?: string; } export interface RootTopology { readonly workspaceIdentity: string; readonly roots: readonly DiscoveryRoot[]; readonly fingerprint: string; } /** * discover 调用原因:区分安全快照、恢复前、可见路径枚举、恢复后与补偿路径。 * 扫描行为不依赖该标签,它只作为调用方诊断与调用次数回归的归属标记。 */ export type RootDiscoveryReason = | "safety-snapshot" | "restore-pre" | "visible-paths-pre" | "visible-paths-post" | "restore-post" | "compensation" | "unspecified"; export type RootDiscoveryErrorCode = "workspace_not_found" | "discovery_failed"; export class RootDiscoveryError extends Error { readonly code: RootDiscoveryErrorCode; constructor(code: RootDiscoveryErrorCode, message: string) { super(message); this.name = "RootDiscoveryError"; this.code = code; } } export interface RootDiscovery { discover(workspaceRoot: string, reason?: RootDiscoveryReason): Promise; } export class RootDiscovery { private readonly git: GitRunner; constructor(git = new GitRunner()) { this.git = git; } async discover(workspaceRoot: string, reason: RootDiscoveryReason = "unspecified"): Promise { checkOperation(); reportOperationProgress(`discover_roots:${reason}`); const workspaceIdentity = await canonicalWorkspaceRoot(workspaceRoot); checkOperation(); const activeRoots = new Map(); const outerRepository = await this.inspectRepository(workspaceIdentity, workspaceIdentity); if (outerRepository.kind === "active") { activeRoots.set( outerRepository.repository.absoluteRoot, this.activeRoot(workspaceIdentity, outerRepository.repository), ); } else if (outerRepository.kind === "broken" || outerRepository.kind === "stale") { activeRoots.set(outerRepository.absoluteRoot, brokenRoot(workspaceIdentity, outerRepository.absoluteRoot)); } else { activeRoots.set(workspaceIdentity, syntheticRoot(workspaceIdentity)); } await this.scanDirectory(workspaceIdentity, workspaceIdentity, activeRoots); checkOperation(); reportOperationProgress("discover_gitlinks"); const gitlinkRoots = await this.discoverGitlinks(workspaceIdentity, activeRoots); checkOperation(); const roots = buildRoots([...activeRoots.values(), ...gitlinkRoots.values()]); return { workspaceIdentity, roots, fingerprint: topologyFingerprint(workspaceIdentity, roots), }; } private async scanDirectory( workspaceIdentity: string, directory: string, activeRoots: Map, ): Promise { let level: Array<{ readonly path: string; readonly inspect: boolean }> = [{ path: directory, inspect: false }]; let scanned = 0; reportOperationProgress("scan_directories"); while (level.length > 0) { checkOperation(); const next: Array<{ readonly path: string; readonly inspect: true }> = []; for (let index = 0; index < level.length; index += DIRECTORY_SCAN_CONCURRENCY) { checkOperation(); const children = await allCompleted(level.slice(index, index + DIRECTORY_SCAN_CONCURRENCY).map( (candidate) => this.scanDirectoryNode(workspaceIdentity, candidate, activeRoots), )); for (const group of children) next.push(...group); scanned += children.length; reportOperationProgress(`scan_directories:${scanned}`); } level = next; } } private async scanDirectoryNode( workspaceIdentity: string, candidate: { readonly path: string; readonly inspect: boolean }, activeRoots: Map, ): Promise> { checkOperation(); if (!await isSafeDirectory(candidate.path, workspaceIdentity)) return []; if (candidate.inspect) { const inspection = await this.inspectRepository(candidate.path, workspaceIdentity); if (inspection.kind === "active") { activeRoots.set( inspection.repository.absoluteRoot, this.activeRoot(workspaceIdentity, inspection.repository), ); } else if (inspection.kind === "broken") { activeRoots.set(inspection.absoluteRoot, brokenRoot(workspaceIdentity, inspection.absoluteRoot)); } else if (inspection.kind === "stale") { activeRoots.set(inspection.absoluteRoot, staleWorktreeRoot(workspaceIdentity, inspection.absoluteRoot)); } if (!await isSafeDirectory(candidate.path, workspaceIdentity)) return []; } checkOperation(); const entries = await readdir(candidate.path, { withFileTypes: true }); if (!await isSafeDirectory(candidate.path, workspaceIdentity)) return []; return entries .filter((entry) => entry.name !== ".git" && !entry.isSymbolicLink() && entry.isDirectory()) .map((entry) => ({ path: join(candidate.path, entry.name), inspect: true as const })); } private async inspectRepository(candidate: string, workspaceIdentity: string): Promise { const marker = await gitMarkerState(candidate); if (marker === "absent") { return { kind: "absent" }; } let absoluteRoot: string; try { absoluteRoot = await realpath(candidate); } catch { return { kind: "broken", absoluteRoot: resolve(candidate) }; } if (!isWithin(workspaceIdentity, absoluteRoot)) { return { kind: "absent" }; } if (marker === "invalid") { return { kind: "broken", absoluteRoot }; } if (await deadWorktreePointer(absoluteRoot)) { return { kind: "stale", absoluteRoot }; } const details = await this.gitOutput([ "-C", absoluteRoot, "rev-parse", "--show-toplevel", "--git-dir", "--git-common-dir", ]); if (details === null) { return { kind: "broken", absoluteRoot }; } const lines = details.trimEnd().split("\n"); if (lines.length < 3) { return { kind: "broken", absoluteRoot }; } try { if ((await realpath(lines[0])) !== absoluteRoot) { return { kind: "broken", absoluteRoot }; } } catch { return { kind: "broken", absoluteRoot }; } const commonGitDir = resolve(absoluteRoot, lines[2]); const remote = await this.gitOutput(["-C", absoluteRoot, "config", "--get", "remote.origin.url"]); const head = await this.gitOutput(["-C", absoluteRoot, "rev-parse", "HEAD"]); return { kind: "active", repository: { absoluteRoot, commonGitDir, sourceIdentity: remote?.trim() || `git:${commonGitDir}`, treeId: head?.trim() || null, }, }; } private activeRoot(workspaceIdentity: string, repository: RepositoryInfo): DiscoveredRoot { return { absoluteRoot: repository.absoluteRoot, relativeRoot: workspaceRelativePath(workspaceIdentity, repository.absoluteRoot), gitBacked: true, state: "active", sourceIdentity: repository.sourceIdentity, privateRepositoryId: checksum(repository.sourceIdentity), treeId: repository.treeId, }; } private async discoverGitlinks( workspaceIdentity: string, activeRoots: Map, ): Promise> { const result = new Map(); for (const root of activeRoots.values()) { checkOperation(); if (!root.gitBacked || root.state !== "active") { continue; } const stage = await this.gitOutput(["-C", root.absoluteRoot, "ls-files", "--stage", "-z"]); if (stage === null) { throw new RootDiscoveryError("discovery_failed", "无法读取 Git index"); } for (const gitlink of parseGitlinks(stage)) { const absolutePath = resolve(root.absoluteRoot, gitlink.relativePath); if (!isWithin(workspaceIdentity, absolutePath)) { continue; } const relativeRoot = workspaceRelativePath(workspaceIdentity, absolutePath); const existing = [...activeRoots.entries()].find(([, candidate]) => candidate.relativeRoot === relativeRoot); if (existing !== undefined) { activeRoots.set(existing[0], { ...existing[1], gitlinkOid: gitlink.oid }); continue; } const state = await gitlinkState(absolutePath); const sourceIdentity = `${root.sourceIdentity}:${relativeRoot}`; result.set(relativeRoot, { absoluteRoot: absolutePath, relativeRoot, gitBacked: true, state, sourceIdentity, privateRepositoryId: checksum(sourceIdentity), treeId: null, gitlinkOid: gitlink.oid, }); } } return result; } private async gitOutput(args: readonly string[]): Promise { try { const result = await this.git.run(["-c", "core.fsmonitor=false", ...args], { env: cleanGitEnvironment(), }); if (result.aborted || result.timedOut) { // 取消/超时不应被当成"仓库损坏";有 context 时在这里重新抛出具体原因。 checkOperation(); } return result.killed ? null : result.stdout; } catch (error) { if (error instanceof GitRunError && error.code === "git_termination_failed") { // 无法证明子进程已停止:上层必须保留 lease 并走恢复,不能继续扫描。 throw error; } if (error instanceof OperationError) { throw error; } return null; } } } function syntheticRoot(workspaceIdentity: string): DiscoveredRoot { return { absoluteRoot: workspaceIdentity, relativeRoot: ".", gitBacked: false, state: "active", sourceIdentity: workspaceIdentity, privateRepositoryId: checksum(workspaceIdentity), treeId: null, }; } function brokenRoot(workspaceIdentity: string, absoluteRoot: string): DiscoveredRoot { const relativeRoot = workspaceRelativePath(workspaceIdentity, absoluteRoot); const sourceIdentity = `broken:${absoluteRoot}`; return { absoluteRoot, relativeRoot, gitBacked: false, state: "broken", sourceIdentity, privateRepositoryId: checksum(sourceIdentity), treeId: null, }; } // git worktree 的 .git 文件是 "gitdir: " 指针;项目从其他机器/位置搬移后, // 指针常指向本机不存在的 gitdir(例如虚拟机共享目录的绝对路径)。这种可证明失效的 // 指针不再代表可用仓库,按未初始化根处理:内容不进入快照,也不会被 restore 触碰。 async function deadWorktreePointer(absoluteRoot: string): Promise { const pointerPath = join(absoluteRoot, ".git"); let content: string; try { const marker = await lstat(pointerPath); if (!marker.isFile()) return false; content = await readFile(pointerPath, "utf8"); } catch { return false; } if (content.length > GIT_POINTER_MAX_BYTES) return false; const firstLine = content.split("\n", 1)[0] ?? ""; const match = /^gitdir: (.+)$/.exec(firstLine.trimEnd()); if (match === null) return false; const gitDirectory = isAbsolute(match[1]) ? resolve(match[1]) : resolve(absoluteRoot, match[1]); try { await stat(gitDirectory); return false; } catch (error) { return hasErrorCode(error, "ENOENT"); } } function staleWorktreeRoot(workspaceIdentity: string, absoluteRoot: string): DiscoveredRoot { const relativeRoot = workspaceRelativePath(workspaceIdentity, absoluteRoot); const sourceIdentity = `stale-worktree:${absoluteRoot}`; return { absoluteRoot, relativeRoot, gitBacked: true, state: "uninitialized", sourceIdentity, privateRepositoryId: checksum(sourceIdentity), treeId: null, }; } function buildRoots(discovered: readonly DiscoveredRoot[]): DiscoveryRoot[] { const unique = new Map(); for (const root of discovered) { unique.set(root.relativeRoot, root); } const paths = [...unique.keys()].sort(comparePaths); return paths.map((relativeRoot) => { const root = unique.get(relativeRoot) as DiscoveredRoot; const parentRoot = paths .filter((candidate) => isStrictAncestor(candidate, relativeRoot)) .sort((left, right) => right.length - left.length || comparePaths(left, right))[0] ?? null; return { relativeRoot, parentRoot, state: root.state, sourceIdentity: root.sourceIdentity, privateRepositoryId: root.privateRepositoryId, treeId: root.treeId, gitBacked: root.gitBacked, ...(root.gitlinkOid === undefined ? {} : { gitlinkOid: root.gitlinkOid }), }; }); } async function canonicalWorkspaceRoot(workspaceRoot: string): Promise { try { return await realpath(workspaceRoot); } catch { throw new RootDiscoveryError("workspace_not_found", "workspace 根目录不存在"); } } async function gitMarkerState(candidate: string): Promise<"absent" | "safe" | "invalid"> { try { const marker = await lstat(join(candidate, ".git")); if (marker.isSymbolicLink()) { return "invalid"; } return marker.isDirectory() || marker.isFile() ? "safe" : "invalid"; } catch (error) { return hasErrorCode(error, "ENOENT") ? "absent" : "invalid"; } } async function isSafeDirectory(directory: string, workspaceIdentity: string): Promise { try { const metadata = await lstat(directory); if (metadata.isSymbolicLink() || !metadata.isDirectory()) { return false; } return isWithin(workspaceIdentity, await realpath(directory)); } catch { return false; } } async function gitlinkState(absolutePath: string): Promise { try { await lstat(absolutePath); } catch { return "uninitialized"; } // git worktree / 部分 checkout 会为 gitlink 留下空目录骨架(可能含指向共享依赖的 symlink); // 内容寻址快照只跟踪文件,不含任何文件的目录与未初始化等价,避免整个 workspace 无法快照。 return (await directoryTreeContainsFile(absolutePath)) ? "broken" : "uninitialized"; } const SKELETON_NOISE_FILES = new Set([".DS_Store", "desktop.ini", "Thumbs.db"]); async function directoryTreeContainsFile(directory: string): Promise { checkOperation(); let entries; try { entries = await readdir(directory, { withFileTypes: true }); } catch { return true; } for (const entry of entries) { if (entry.isDirectory()) { if (await directoryTreeContainsFile(join(directory, entry.name))) return true; } else if (entry.isSymbolicLink() || SKELETON_NOISE_FILES.has(entry.name)) { // symlink 不适随也不计内容:uninitialized 根下的内容不被捕获也不会被 restore 触碰, // 原样保留不会丢失;OS 噪音文件不应让整个 workspace 无法快照。 continue; } else { return true; } } return false; } function cleanGitEnvironment(): Readonly> { return { GIT_DIR: undefined, GIT_WORK_TREE: undefined, GIT_INDEX_FILE: undefined, GIT_COMMON_DIR: undefined, GIT_OBJECT_DIRECTORY: undefined, GIT_ALTERNATE_OBJECT_DIRECTORIES: undefined, GIT_NAMESPACE: undefined, GIT_OPTIONAL_LOCKS: "0", GIT_CONFIG_COUNT: undefined, GIT_CONFIG_PARAMETERS: undefined, GIT_CONFIG_SYSTEM: undefined, GIT_CONFIG_GLOBAL: undefined, GIT_CONFIG_NOSYSTEM: undefined, GIT_ATTR_NOSYSTEM: undefined, }; } function hasErrorCode(error: unknown, code: string): error is NodeJS.ErrnoException { return typeof error === "object" && error !== null && "code" in error && error.code === code; } function parseGitlinks(stage: string): Array<{ oid: string; relativePath: string }> { const gitlinks: Array<{ oid: string; relativePath: string }> = []; for (const entry of stage.split("\0")) { if (entry.length === 0) { continue; } const separator = entry.indexOf("\t"); if (separator < 0) { continue; } const metadata = entry.slice(0, separator).split(" "); if (metadata[0] !== "160000" || metadata.length < 2) { continue; } gitlinks.push({ oid: metadata[1], relativePath: entry.slice(separator + 1) }); } return gitlinks; } function workspaceRelativePath(workspaceIdentity: string, absolutePath: string): string { const value = relative(workspaceIdentity, absolutePath); return value.length === 0 ? "." : value.split(sep).join("/"); } function isWithin(parent: string, candidate: string): boolean { const value = relative(parent, candidate); return value.length === 0 || (!value.startsWith(`..${sep}`) && value !== ".." && !isAbsolute(value)); } function isStrictAncestor(parent: string, child: string): boolean { return parent === "." ? child !== "." : child.startsWith(`${parent}/`); } function comparePaths(left: string, right: string): number { return left < right ? -1 : left > right ? 1 : 0; }