import type { ChangeSource, GitChangeContext, ResolvedChangeSource } from "../types.ts"; import { createHash } from "node:crypto"; import { createReadStream } from "node:fs"; import { lstat, open, readlink } from "node:fs/promises"; import { dirname, isAbsolute, join, relative, resolve } from "node:path"; import { mapWithConcurrency } from "../utils/map-concurrency.ts"; import { buildPromptChangeContent, splitTrackedDiff, truncateTextToBudget, type ChangeSummarySection, type RawFileDiff, } from "./build-prompt-content.ts"; import { formatGitError, GitClient } from "./git-client.ts"; interface UntrackedDiffResult { entries: RawFileDiff[]; truncated: boolean; } interface UntrackedFileDiffResult { entry: RawFileDiff; truncated: boolean; } interface FileExcerpt { content: Buffer; truncated: boolean; } interface RepositoryState { root: string; requestedSource: ChangeSource; resolvedSource: ResolvedChangeSource; stagedFiles: string[]; worktreeFiles: string[]; untrackedFiles: string[]; statusPorcelain: string; } export interface RepositoryFingerprintSnapshot { resolvedSource: ResolvedChangeSource; fingerprint: string; } const FILE_IO_CONCURRENCY = 6; const MIN_UNTRACKED_FILE_CHARS = 256; /** 固定被解析 diff 的输出格式,避免 color、noprefix、mnemonicPrefix 等用户配置破坏解析。 */ const PARSED_DIFF_ARGS = [ "--no-color", "--no-ext-diff", "--no-textconv", "--src-prefix=a/", "--dst-prefix=b/", ] as const; /** 将 NUL 分隔的 Git 输出解析为路径数组。 */ function parseNulList(output: string): string[] { return output.split("\0").filter((value) => value.length > 0); } /** 提取文件系统错误的字符串错误码;非文件系统错误返回 undefined。 */ function getFsErrorCode(error: unknown): string | undefined { if (error instanceof Error && "code" in error) { const code = (error as NodeJS.ErrnoException).code; return typeof code === "string" ? code : undefined; } return undefined; } /** 判断错误是否为文件不存在。 */ function isMissingFileError(error: unknown): boolean { return getFsErrorCode(error) === "ENOENT"; } /** 判断当前目录或任一父目录是否存在 Git 工作树标记。 */ async function hasGitWorktreeMarker(cwd: string): Promise { let current = resolve(cwd); // 先验证 cwd 本身可访问;目录缺失或无权限属于环境故障,不能静默当作“非 Git 仓库”。 await lstat(current); for (;;) { try { await lstat(join(current, ".git")); return true; } catch (error) { if (!isMissingFileError(error)) { throw error; } } const parent = dirname(current); if (parent === current) { return false; } current = parent; } } /** 将仓库相对路径解析为仓库内的绝对路径。 */ function resolveRepositoryPath(root: string, path: string): string | undefined { const absolutePath = resolve(root, path); const relativePath = relative(root, absolutePath); if ( relativePath === ".." || relativePath.startsWith(`..${process.platform === "win32" ? "\\" : "/"}`) || isAbsolute(relativePath) ) { return undefined; } return absolutePath; } /** 对普通文件做流式 SHA-256,避免把大文件完整读入内存。 */ async function hashFile(path: string): Promise { const hash = createHash("sha256"); const stream = createReadStream(path); for await (const chunk of stream) { hash.update(chunk); } return hash.digest("hex"); } /** 为一个仓库相对路径创建可重复的内容快照。 */ async function snapshotPath(root: string, path: string): Promise { const absolutePath = resolveRepositoryPath(root, path); if (!absolutePath) { return `${JSON.stringify(path)}:outside-repository`; } try { const stat = await lstat(absolutePath); if (stat.isSymbolicLink()) { return `${JSON.stringify(path)}:symlink:${await readlink(absolutePath)}`; } if (stat.isFile()) { return `${JSON.stringify(path)}:file:${await hashFile(absolutePath)}`; } if (stat.isDirectory()) { return `${JSON.stringify(path)}:directory`; } return `${JSON.stringify(path)}:special:${stat.mode}`; } catch (error) { if (isMissingFileError(error)) { return `${JSON.stringify(path)}:missing`; } const code = getFsErrorCode(error); if (code !== undefined) { // Windows 上文件可能被编辑器或杀毒软件锁定(EPERM/EBUSY),降级为可重复的标记而不是中断整个命令。 return `${JSON.stringify(path)}:unreadable:${code}`; } throw error; } } /** 获取当前 HEAD 标识;未创建首个提交时返回占位值。 */ async function getHead(git: GitClient, root: string, signal?: AbortSignal): Promise { const result = await git.run(["rev-parse", "HEAD"], { cwd: root, signal, acceptedExitCodes: [0, 128], }); return result.code === 0 ? result.stdout.trim() : ""; } /** 获取适合展示的当前分支名称。 */ async function getBranch(git: GitClient, root: string, signal?: AbortSignal): Promise { const symbolic = await git.run(["symbolic-ref", "--short", "-q", "HEAD"], { cwd: root, signal, acceptedExitCodes: [0, 1], }); if (symbolic.stdout.trim()) { return symbolic.stdout.trim(); } const detached = await git.run(["rev-parse", "--short", "HEAD"], { cwd: root, signal, acceptedExitCodes: [0, 128], }); return detached.code === 0 ? `HEAD:${detached.stdout.trim()}` : ""; } /** 在固定字节预算内读取文件首尾,避免把大文件完整载入内存。 */ async function readFileExcerpt( path: string, maxBytes: number, signal?: AbortSignal, ): Promise { signal?.throwIfAborted(); const handle = await open(path, "r"); try { const stat = await handle.stat(); const budget = Math.max(1, Math.floor(maxBytes)); if (stat.size <= budget) { const content = Buffer.alloc(stat.size); const { bytesRead } = await handle.read(content, 0, content.length, 0); signal?.throwIfAborted(); return { content: content.subarray(0, bytesRead), truncated: false }; } const headSize = Math.ceil(budget / 2); const tailSize = Math.floor(budget / 2); const head = Buffer.alloc(headSize); const tail = Buffer.alloc(tailSize); const [headRead, tailRead] = await Promise.all([ handle.read(head, 0, head.length, 0), handle.read(tail, 0, tail.length, Math.max(0, stat.size - tail.length)), ]); signal?.throwIfAborted(); return { content: Buffer.concat([ head.subarray(0, headRead.bytesRead), Buffer.from("\n[UNTRACKED_FILE_MIDDLE_OMITTED]\n"), tail.subarray(0, tailRead.bytesRead), ]), truncated: true, }; } finally { await handle.close(); } } /** 为一个未跟踪路径构建跨平台、受预算约束的文本证据。 */ async function createUntrackedFileDiff( root: string, path: string, maxChars: number, signal?: AbortSignal, ): Promise { signal?.throwIfAborted(); const absolutePath = resolveRepositoryPath(root, path); if (!absolutePath) { return { entry: { source: "Untracked file", path, diff: "[Untracked path is outside the repository; content was not read]", }, truncated: true, }; } try { const stat = await lstat(absolutePath); if (stat.isSymbolicLink()) { const target = await readlink(absolutePath); return { entry: { source: "Untracked file", path, diff: `new symbolic link\n+${target}`, }, truncated: false, }; } if (!stat.isFile()) { return { entry: { source: "Untracked file", path, diff: `[Untracked path is not a regular file; mode=${stat.mode}]`, }, truncated: true, }; } const excerpt = await readFileExcerpt(absolutePath, maxChars, signal); if (excerpt.content.includes(0)) { return { entry: { source: "Untracked file", path, diff: `Binary files (empty) and ${JSON.stringify(path)} differ`, }, truncated: excerpt.truncated, }; } const additions = excerpt.content .toString("utf8") .replaceAll("\r\n", "\n") .split("\n") .map((line) => `+${line}`) .join("\n"); const rawDiff = ["new untracked file", `path: ${JSON.stringify(path)}`, additions || "+"].join( "\n", ); const limited = truncateTextToBudget( rawDiff, maxChars, "\n[Middle of this untracked file omitted]\n", ); return { entry: { source: "Untracked file", path, diff: limited.text, }, truncated: excerpt.truncated || limited.truncated, }; } catch (error) { if (isMissingFileError(error)) { return { entry: { source: "Untracked file", path, diff: "[Untracked file disappeared during collection]", }, truncated: true, }; } const code = getFsErrorCode(error); if (code !== undefined) { // 文件被锁定或权限不足时降级为证据行,保留其余文件的收集结果。 return { entry: { source: "Untracked file", path, diff: `[Untracked file could not be read (${code}); content was not read]`, }, truncated: true, }; } throw error; } } /** 并发收集未跟踪文件证据,并按文件数收紧原始读取预算。 */ async function collectUntrackedDiffs( root: string, files: string[], maxChars: number, signal?: AbortSignal, ): Promise { const perFileMaxChars = Math.max( MIN_UNTRACKED_FILE_CHARS, Math.floor(maxChars / Math.max(1, files.length)), ); const results = await mapWithConcurrency(files, FILE_IO_CONCURRENCY, (path) => createUntrackedFileDiff(root, path, perFileMaxChars, signal), ); return { entries: results.map((result) => result.entry), truncated: results.some((result) => result.truncated), }; } /** 收集一个 tracked 变更来源的文件状态、统计和特殊操作摘要。 */ async function collectTrackedSummary( git: GitClient, root: string, title: string, cached: boolean, signal?: AbortSignal, ): Promise { const baseArgs = cached ? ["diff", "--cached"] : ["diff"]; const [nameStatus, statSummary] = await Promise.all([ git.run([...baseArgs, "--name-status", "--no-color", "--no-ext-diff", "--no-textconv", "--"], { cwd: root, signal, timeout: 30_000, }), git.run( [...baseArgs, "--stat", "--summary", "--no-color", "--no-ext-diff", "--no-textconv", "--"], { cwd: root, signal, timeout: 30_000 }, ), ]); return { title, nameStatus: nameStatus.stdout, statSummary: statSummary.stdout, }; } /** 为未跟踪文件构建不依赖文件内容的完整状态摘要。 */ function createUntrackedSummary(files: string[]): ChangeSummarySection { return { title: "Untracked files summary", nameStatus: files.map((path) => `A\t${JSON.stringify(path)}`).join("\n"), statSummary: `${files.length} untracked file(s); details are shown within per-file budgets.`, }; } /** 根据配置和暂存状态解析本次实际使用的变更来源。 */ function resolveChangeSource( requestedSource: ChangeSource, hasStagedChanges: boolean, ): ResolvedChangeSource { if (requestedSource === "auto") { return hasStagedChanges ? "staged" : "all"; } return requestedSource; } /** 返回参与当前来源指纹计算的去重路径。 */ function getChangedPaths(state: RepositoryState): string[] { return state.resolvedSource === "staged" ? state.stagedFiles : [...state.stagedFiles, ...state.worktreeFiles, ...state.untrackedFiles]; } /** 解析当前目录所属 Git 仓库的规范根目录。 */ export async function resolveGitRepositoryRoot( git: GitClient, cwd: string, signal?: AbortSignal, ): Promise { const result = await git.run(["rev-parse", "--show-toplevel"], { cwd, signal, }); return result.stdout.trim(); } /** 解析仓库根目录;仅在确实没有工作树标记时返回 undefined,真实 Git 故障继续上抛。 */ export async function tryResolveGitRepositoryRoot( git: GitClient, cwd: string, ): Promise { const args = ["rev-parse", "--show-toplevel"]; const result = await git.run(args, { cwd, acceptedExitCodes: [0, 128], }); if (result.code === 0) { return result.stdout.trim(); } const usesExplicitGitEnvironment = Boolean(process.env.GIT_DIR || process.env.GIT_WORK_TREE); if (usesExplicitGitEnvironment || (await hasGitWorktreeMarker(cwd))) { throw new Error(`Git command git rev-parse failed: ${formatGitError(result)}`); } return undefined; } /** 收集来源解析和指纹计算共同依赖的轻量仓库状态。 */ async function collectRepositoryState( git: GitClient, cwd: string, requestedSource: ChangeSource, signal?: AbortSignal, ): Promise { const root = await resolveGitRepositoryRoot(git, cwd, signal); const conflicts = parseNulList( ( await git.run(["diff", "--name-only", "--diff-filter=U", "-z", "--"], { cwd: root, signal, }) ).stdout, ); if (conflicts.length > 0) { throw new Error( `The repository has unresolved conflicts: ${conflicts.map((path) => JSON.stringify(path)).join(", ")}.`, ); } const [stagedResult, worktreeResult, untrackedResult, statusResult] = await Promise.all([ git.run(["diff", "--cached", "--name-only", "-z", "--"], { cwd: root, signal, }), git.run(["diff", "--name-only", "-z", "--"], { cwd: root, signal, }), git.run(["ls-files", "--others", "--exclude-standard", "-z"], { cwd: root, signal, }), git.run(["status", "--porcelain=v1", "-z", "--untracked-files=all"], { cwd: root, signal, }), ]); const stagedFiles = parseNulList(stagedResult.stdout); const worktreeFiles = parseNulList(worktreeResult.stdout); const untrackedFiles = parseNulList(untrackedResult.stdout); const resolvedSource = resolveChangeSource(requestedSource, stagedFiles.length > 0); if (resolvedSource === "staged" && stagedFiles.length === 0) { throw new Error( "The staging area has no changes to commit. Run git add first, or set changeSource to auto/all.", ); } if ( resolvedSource === "all" && stagedFiles.length === 0 && worktreeFiles.length === 0 && untrackedFiles.length === 0 ) { throw new Error("The current Git repository has no changes to commit."); } return { root, requestedSource, resolvedSource, stagedFiles, worktreeFiles, untrackedFiles, statusPorcelain: statusResult.stdout, }; } /** 创建能检测生成期间仓库变化的快照指纹。 */ async function createFingerprint( git: GitClient, state: RepositoryState, signal?: AbortSignal, ): Promise { const hash = createHash("sha256"); hash.update(state.resolvedSource); hash.update("\0"); if (state.resolvedSource === "staged") { const [head, indexTree] = await Promise.all([ getHead(git, state.root, signal), git.run(["write-tree"], { cwd: state.root, signal }), ]); hash.update(head); hash.update("\0"); hash.update(indexTree.stdout.trim()); return hash.digest("hex"); } const paths = [...new Set(getChangedPaths(state))].sort((left, right) => left.localeCompare(right), ); const [head, snapshots] = await Promise.all([ getHead(git, state.root, signal), mapWithConcurrency(paths, FILE_IO_CONCURRENCY, (path) => snapshotPath(state.root, path)), ]); hash.update(head); hash.update("\0"); hash.update(state.statusPorcelain); for (const snapshot of snapshots) { hash.update("\0"); hash.update(snapshot); } return hash.digest("hex"); } /** 只收集提交前一致性检查需要的来源和仓库指纹。 */ export async function collectRepositoryFingerprint( git: GitClient, cwd: string, requestedSource: ChangeSource, signal?: AbortSignal, ): Promise { const state = await collectRepositoryState(git, cwd, requestedSource, signal); return { resolvedSource: state.resolvedSource, fingerprint: await createFingerprint(git, state, signal), }; } /** 收集指定来源的 Git 变化、提示词差异以及提交前一致性指纹。 */ export async function collectGitChanges( git: GitClient, cwd: string, requestedSource: ChangeSource, maxDiffChars: number, signal?: AbortSignal, ): Promise { const state = await collectRepositoryState(git, cwd, requestedSource, signal); const branchPromise = getBranch(git, state.root, signal); // 立即标记拒绝已被处理,避免后续步骤先抛错时产生未处理的 Promise 拒绝。 void branchPromise.catch(() => {}); // 先固化指纹再收集 diff,此后的任何仓库改动都会在提交前复查中暴露。 const fingerprint = await createFingerprint(git, state, signal); const summarySections: ChangeSummarySection[] = []; const fileDiffs: RawFileDiff[] = []; let sourceWasTruncated = false; if (state.stagedFiles.length > 0) { const [stagedDiff, stagedSummary] = await Promise.all([ git.run(["diff", "--cached", ...PARSED_DIFF_ARGS, "--"], { cwd: state.root, signal, timeout: 30_000, }), collectTrackedSummary(git, state.root, "Staged changes summary", true, signal), ]); summarySections.push(stagedSummary); fileDiffs.push(...splitTrackedDiff("Staged changes", state.stagedFiles, stagedDiff.stdout)); } if (state.resolvedSource === "all") { if (state.worktreeFiles.length > 0) { const [worktreeDiff, worktreeSummary] = await Promise.all([ git.run(["diff", ...PARSED_DIFF_ARGS, "--"], { cwd: state.root, signal, timeout: 30_000, }), collectTrackedSummary(git, state.root, "Unstaged worktree changes summary", false, signal), ]); summarySections.push(worktreeSummary); fileDiffs.push( ...splitTrackedDiff("Unstaged worktree changes", state.worktreeFiles, worktreeDiff.stdout), ); } if (state.untrackedFiles.length > 0) { summarySections.push(createUntrackedSummary(state.untrackedFiles)); const untrackedDiffs = await collectUntrackedDiffs( state.root, state.untrackedFiles, maxDiffChars, signal, ); fileDiffs.push(...untrackedDiffs.entries); sourceWasTruncated = untrackedDiffs.truncated; } } const promptContent = buildPromptChangeContent(summarySections, fileDiffs, maxDiffChars); return { root: state.root, branch: await branchPromise, requestedSource: state.requestedSource, resolvedSource: state.resolvedSource, stagedFiles: state.stagedFiles, worktreeFiles: state.worktreeFiles, untrackedFiles: state.untrackedFiles, promptSummary: promptContent.summary, promptDiff: promptContent.diff, diffTruncated: sourceWasTruncated || promptContent.truncated, fingerprint, }; }