import { spawnSync } from "node:child_process"; import { lstatSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; // 用 git 存代码版本、取出、比较(docs/结构.md 第二部分 workspace 一行)。 // 一份研究一个裸仓库;版本号是目录树编号(tree id),不造提交,"上一版是谁"由账本记。 // 只用 git 的底层命令:存版本自己列文件,不用 git add(它按 .gitignore 悄悄漏存); // 取出自己按清单写文件,不用 git archive(它按树里的 .gitattributes 丢文件、改换行符)。 /** 存版本的结果:版本号,或者存不了、是哪个路径、为什么。 */ export type SaveOutcome = { saved: true; version: string } | { saved: false; path: string; reason: string }; /** * 一个差异块在一侧的行号范围,行号从 1 起。 * 这一侧有行时是第 start 到第 end 行(含两端); * 这一侧没有行时(只删不加的块在新版本一侧、只加不删的块在旧版本一侧)写成"在第 after 行之后", * 与 `git diff -U0` 块头里行数为 0 时的写法相同,after 为 0 表示在文件开头。 */ export type LineRange = { start: number; end: number } | { after: number }; /** 一个差异块:不带上下文的一段连续改动,旧版本(from)和新版本(to)两侧的行号范围。 */ export interface Hunk { from: LineRange; to: LineRange; } interface ChangedFile { /** 相对代码根目录的路径,用 / 分隔。 */ path: string; change: "added" | "deleted" | "modified"; /** 内容没变,只改了可执行位。 */ modeOnly: boolean; } /** * 一个改动过的文件。二进制文件不拆块,当作整个文件改了。 * 块可以为空:空文件的新增、删除和只改可执行位都没有块,也按整个文件改了算。 */ export type FileChange = (ChangedFile & { binary: true }) | (ChangedFile & { binary: false; hunks: Hunk[] }); /** 比较两个版本的结果:改动过的文件(按路径排序),以及给模型看的带上下文的差异原文。 */ export interface Comparison { files: FileChange[]; diff: string; } const EXECUTABLE = "100755"; const REGULAR = "100644"; /** diff-tree 原始格式里的状态字母。只存普通文件、不查改名,所以只有这三种。 */ const CHANGES: Readonly> = { A: "added", D: "deleted", M: "modified" }; /** 在 repo 位置建一个空的裸仓库。 */ export function createRepository(repo: string): void { git(repo, ["init", "--bare", "--quiet"], "", null); } /** * 把 dir 现在的内容存成一个版本。名字在 excludedNames 里的文件或目录,在任何一层都跳过。 * 遇到既不是普通文件也不是目录的东西(符号链接、设备文件等)交回存不了:这个目录是实验员交的, * 不替它猜怎么存。空目录不进版本(git 的目录树只记文件)。 * 同样的内容永远得到同一个版本号,所以中断后重存一次得到的号和账本里的一样。 */ export function saveVersion(repo: string, dir: string, excludedNames: readonly string[]): SaveOutcome { const files: Array<{ path: string; mode: string }> = []; const refused = listFiles(dir, "", new Set(excludedNames), files); if (refused !== null) return { saved: false, ...refused }; // --no-filters:原样存字节,不按任何配置或 .gitattributes 转换换行符、走过滤器。 const paths = files.map((file) => `${join(dir, file.path)}\n`).join(""); const ids = text(git(repo, ["hash-object", "-w", "--no-filters", "--stdin-paths"], paths, null)).split("\n"); // 每次用自己的临时索引文件,不碰仓库里的索引。 const scratch = mkdtempSync(join(tmpdir(), "inforsi-index-")); const index = join(scratch, "index"); try { const entries = files.map((file, i) => `${file.mode} ${ids[i]}\t${file.path}\0`).join(""); git(repo, ["update-index", "-z", "--index-info"], entries, index); const version = text(git(repo, ["write-tree"], "", index)).trim(); // 挂一个引用:没有引用指着的对象,git gc 会把它清掉。 git(repo, ["update-ref", `refs/versions/${version}`, version], "", null); return { saved: true, version }; } finally { rmSync(scratch, { recursive: true, force: true }); } } /** 遍历 root 下的 rel 目录,把普通文件收进 files;遇到存不了的东西就停,交回它的路径和原因。 */ function listFiles( root: string, rel: string, excluded: ReadonlySet, files: Array<{ path: string; mode: string }>, ): { path: string; reason: string } | null { for (const entry of readdirSync(join(root, rel), { withFileTypes: true })) { if (excluded.has(entry.name)) continue; const path = rel === "" ? entry.name : `${rel}/${entry.name}`; if (entry.isDirectory()) { const refused = listFiles(root, path, excluded, files); if (refused !== null) return refused; } else if (entry.isFile()) { // 可执行位看文件主人的那一位,与 git 自己的判断相同。 const executable = (lstatSync(join(root, path)).mode & 0o100) !== 0; files.push({ path, mode: executable ? EXECUTABLE : REGULAR }); } else { const kind = entry.isSymbolicLink() ? "符号链接" : "既不是普通文件也不是目录"; return { path, reason: `${kind},代码版本只存普通文件和目录` }; } } return null; } /** 把一个版本的文件写进 dir(调用方建好的空目录),还原可执行位。dir 里不会有 .git。 */ export function checkoutVersion(repo: string, version: string, dir: string): void { // 每条是"模式 类型 编号路径",以 NUL 结尾。 const records = text(git(repo, ["ls-tree", "-r", "-z", version], "", null)).split("\0").slice(0, -1); for (const record of records) { const tab = record.indexOf("\t"); const [mode, , id] = record.slice(0, tab).split(" ") as [string, string, string]; const target = join(dir, record.slice(tab + 1)); mkdirSync(dirname(target), { recursive: true }); // cat-file 交回存进去的原始字节,不经过任何 .gitattributes 或配置的转换。 writeFileSync(target, git(repo, ["cat-file", "blob", id], "", null), { mode: mode === EXECUTABLE ? 0o755 : 0o644 }); } } /** * 比较 from 和 to 两个版本。 * 文件清单来自 `git diff-tree -r -z` 的原始格式;块来自 `-U0` 的补丁,它和清单按同样的路径顺序 * 每个文件一段,按位置对上,不必解析补丁里加了引号的路径。 */ export function compareVersions(repo: string, from: string, to: string): Comparison { const range = ["--no-renames", from, to]; const raw = text(git(repo, ["diff-tree", "-r", "-z", ...range], "", null)).split("\0").slice(0, -1); const sections = text(git(repo, ["diff-tree", "-r", "-p", "-U0", "--no-color", ...range], "", null)) .split(/^diff --git /m) .slice(1); const files: FileChange[] = []; for (let i = 0; i < raw.length; i += 2) { // 原始格式:":旧模式 新模式 旧编号 新编号 状态",NUL,路径。 const [, , oldId, newId, status] = (raw[i] as string).split(" ") as [string, string, string, string, string]; const section = sections[i / 2] as string; const file = { path: raw[i + 1] as string, change: CHANGES[status] as ChangedFile["change"], modeOnly: oldId === newId }; // 补丁里内容行都以 +、-、空格开头,以这几个字开头的只能是 git 写的说明行。 if (/^Binary files /m.test(section)) files.push({ ...file, binary: true }); else files.push({ ...file, binary: false, hunks: hunks(section) }); } const diff = text(git(repo, ["diff-tree", "-r", "-p", "--no-color", ...range], "", null)); return { files, diff }; } /** 从一个文件的 -U0 补丁里读出全部块头 `@@ -旧起始[,旧行数] +新起始[,新行数] @@`,行数省略时是 1。 */ function hunks(section: string): Hunk[] { return [...section.matchAll(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/gm)].map((m) => ({ from: lineRange(Number(m[1]), Number(m[2] ?? 1)), to: lineRange(Number(m[3]), Number(m[4] ?? 1)), })); } function lineRange(start: number, count: number): LineRange { return count === 0 ? { after: start } : { start, end: start + count - 1 }; } function text(output: Buffer): string { return output.toString("utf8"); } /** * 在 repo 仓库上跑一条 git 命令,交回标准输出。失败就报错,写明命令和 git 的报错原文。 * - GIT_DIR 只放进子进程的环境,不写进 process.env:插件进程的环境会被实验员的 shell 继承。 * - 不读系统和用户的配置,也不读系统和用户的 attributes 文件:那里的换行符转换、比较算法、 * 过滤器、"按二进制处理"会悄悄改掉存进去的内容或比较的结果。 * core.attributesFile 不设时 git 会去读 ~/.config/git/attributes,所以用环境变量把它指到 /dev/null。 */ function git(repo: string, args: string[], input: string, indexFile: string | null): Buffer { const env: NodeJS.ProcessEnv = { ...process.env, GIT_DIR: repo, GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null", GIT_ATTR_NOSYSTEM: "1", GIT_CONFIG_COUNT: "1", GIT_CONFIG_KEY_0: "core.attributesFile", GIT_CONFIG_VALUE_0: "/dev/null", }; if (indexFile !== null) env.GIT_INDEX_FILE = indexFile; const result = spawnSync("git", args, { env, input, maxBuffer: Infinity }); if (result.error !== undefined) throw new Error(`git ${args.join(" ")} 起不来:${result.error.message}`, { cause: result.error }); if (result.status !== 0) { throw new Error(`git ${args.join(" ")}(GIT_DIR=${repo})失败,退出码 ${result.status},信号 ${result.signal}:\n${text(result.stderr)}`); } return result.stdout; }