diff --git a/WORK_LOG.md b/WORK_LOG.md index a2aeac9..0433a5c 100644 --- a/WORK_LOG.md +++ b/WORK_LOG.md @@ -16,6 +16,7 @@ - 任务书 03(ledger):src/ledger 5 个文件、584 行,`npm test` 226 条通过(ledger 34 条)。一张只追加的表,两条禁改禁删触发器,8 处唯一约束(阶段开、阶段关、运行登记、确认启动或确认没启动、运行结果、模型调用开始、结束、消融结算),写入时只查四种拒收,提案的作者自评拆成封存另存。按第一版写过可见性和通用唯一键,按新版全部清掉了。全新上下文的审阅做了三轮。结构.md 第八部分补了 ledger 对外内容。 - 任务书 04(config):src/config 4 个文件、453 行,`npm test` 246 条通过(config 19 条)。流程用联合类型(完整流程 / 朴素循环),规则名取自 rules 登记表,config 改为依赖 rules。ledger 顺手两处:结算按轮次唯一、笔记去掉版本号,表结构版本号升到 2,全新上下文审阅一轮无问题。结构.md 第八部分补了 config 对外内容。 - 任务书 05(tasks 与 runner):src/runner 2 个文件、271 行,src/tasks 6 个文件、691 行,`npm test` 274 条通过(runner 12 条、tasks 16 条,真实 Docker,整套约 12 秒)。任务目录搬到 `tasks/circle_packing/`,新增 task.json;`VERDICTS` 删掉"判不了"。基线从头到尾判为算数,分数 0.9597642169962064,两次逐位相同。全新上下文审阅两轮:第一轮发现候选能伪造计量结果(把超时记成正常退出)、运行目录里旧 metrics.json 会被当成本次产物,都已修(计量 shell 以容器内 root 跑、命令用 setpriv 降权、结果用 docker cp 取;harness 开跑前删旧输出);第二轮只审这次修复,另采纳一条:容器加 no-new-privileges,禁止候选靠 setuid 程序重新拿到 root。结构.md 第八部分补了 runner、tasks。 +- 任务书 06(workspace):src/workspace 2 个文件、205 行,`npm test` 284 条通过(workspace 10 条,真实 git)。版本号是目录树编号,存版本自己列文件加 hash-object,取出用 ls-tree 加 cat-file(实测 `git archive` 会按树里的 `.gitattributes` 丢文件、改换行符),git 子进程另关掉系统和用户的 attributes 文件(实测只关配置时 `~/.config/git/attributes` 仍会被读)。tasks 的 task.json 加必填的 `unversionedNames`,`protectedFiles` 删掉 solution.json。全新上下文审阅一轮:两处偏离都实测成立;采纳一条(写明空文件增删、只改可执行位没有块),其余不改。结构.md 第八部分补了 workspace。 ## 关键决定(用户原话见各提交说明) @@ -70,6 +71,10 @@ - 写 plugin 时实测:源码导入路径带 `.ts` 扩展名,Pi 加载插件能否接受。 - 给 submissions:研究者登记的假设专用指标编号不能和常规辅助指标重名(`metricDefinitions` 里的 7 个),否则候选自报的值会顶替宿主测的值(任务书 05 审阅意见)。 - 给 flow:容器名要在本机所有研究之间唯一(例如带研究编号);`docker run` 因重名失败时 runner 按"启动了"处理。日志目录每次运行一个新的。 +- 给以后算"没有归属的改动"的模块:workspace 比较结果里块可以为空(空文件增删、只改可执行位),要按整个文件算。 +- 给 flow:取出时目标目录必须是空的,workspace 不查。 +- 空目录不进版本(git 只记文件);候选依赖空目录时正式运行会失败。未见真实证据,只列为未处理的可能情况。 +- `tasks/circle_packing/TASK.md` 没告诉实验员 `unversionedNames` 里的东西不会存进版本(任务书 06 范围外,未改)。 - 给以后换任务镜像:镜像里必须有 `setpriv`(runner 用它把命令降到当前用户);容器里不要用 `python -I`,它会忽略 `PYTHON_CPU_COUNT`。 ## 工作方式(用户 2026-10-01 定) @@ -83,4 +88,4 @@ ## 下一步 -任务书 06(workspace,`任务书/06-workspace.md`)已写好,等用户另开新 Agent 执行;完成后监督者核对。 +任务书 06(workspace)已完成,等监督者核对。 diff --git a/docs/结构.md b/docs/结构.md index 45ac4b3..c035513 100644 --- a/docs/结构.md +++ b/docs/结构.md @@ -461,10 +461,10 @@ Jev 是一个判别器,和程序检查地位相同,不单独成一种记录 **任务事实** - `TASK_KINDS`、`TaskKind`:任务种类名单,现在只有 `circle_packing`。 -- `TaskFacts`(字段定义):task.json 的字段:任务种类、最终指标的编号和方向、ε、已知最好(没有写 null)、单次时限、核数、Docker 运行时、不许改的文件名单。全部必填,不认识的字段报错。 +- `TaskFacts`(字段定义):task.json 的字段:任务种类、最终指标的编号和方向、ε、已知最好(没有写 null)、单次时限、核数、Docker 运行时、不许改的文件名单、存代码版本时跳过的名字(`unversionedNames`,只写名字不带 `/`,在任何一层都跳过;填圆是 `__pycache__`、`.git`、`solution.json`、`metrics.json`)。全部必填,不认识的字段报错。 - `checkTaskFacts(value)`:严格检查一份已解析的 task.json,一次列出全部错误,每条写成"字段路径:原因"。 - `Task`(类型):读进来的一个任务:task.json 的内容、任务目录(Dockerfile 所在,给 runner 建镜像)、基线目录、TASK.md 全文。 -- `loadTask(taskDir)`:读任务目录,检查 task.json,交回 `Task`。 +- `loadTask(taskDir)`:读任务目录,检查 task.json,交回 `Task`;"不存"清单在 `task.facts.unversionedNames` 里,flow 存版本时把它交给 workspace。 - `metricDefinitions(task)`:最终指标和常规辅助指标的定义(墙钟、CPU 秒、峰值内存、最小间隙、原分数、原违反量、扣掉的分);辅助指标的定义写在填圆的代码里。 - `protectedChanges(task, changedFiles)`:改动过的文件里属于"不许改"名单的那些。 @@ -481,3 +481,15 @@ Jev 是一个判别器,和程序检查地位相同,不单独成一种记录 - `prepareJudging(task, runDir, judgeDir, imageDigest, formal, hypothesisMetrics)`:判分第一步。正式运行超时、从外面被杀、退出码非 0,或者没有产物,直接交回不算数的判分结果;否则把产物和宿主保管的校验器复制进判分目录,交回校验器的运行定义。 - `judge(task, runDir, judgeDir, formal, validator, hypothesisMetrics)`:判分第二步。读校验器写的判定和候选写的指标文件,加上正式运行的结果,交回 `JudgedResult`:校验器判合法(含合法化后合法)算数;判不合法、或"缺了整次作废"的假设专用指标测不到,不算数。合法化的三个数既进 `extra`,也是常规辅助指标的取值;测不到的指标记测不到并附原因。校验器自己没正常跑完、判定文件认不出、修后分数高于原分数时报错停下,不当成候选的失败。 - `JudgingStep`(类型):判分第一步的结果,已判定或要跑校验器。 + +### workspace + +下面是 `src/workspace/index.ts` 导出的全部内容。一份研究一个裸仓库,位置由调用方给;版本号是目录树编号(tree id),不造提交,每个版本挂一个 `refs/versions/<版本号>` 引用防 `git gc` 清理。"上一版是谁""现在的基线是哪个版本"都由 flow 记进账本,git 里不记。只用 git 的底层命令,每个 git 子进程都不读系统和用户的配置与 attributes 文件,`GIT_DIR` 只放进子进程的环境。git 命令失败直接报错,写明命令和 git 的报错原文,不重试。 + +- `createRepository(repo)`:在给定位置建一个空的裸仓库。 +- `saveVersion(repo, dir, excludedNames)`:把目录现在的内容存成一个版本,交回版本号;自己遍历目录列文件(不用 `git add`,`.gitignore` 不起作用),名字在清单里的文件或目录在任何一层都跳过,空目录不进版本,可执行位记成 100755、其余 100644。遇到符号链接、设备文件这类既不是普通文件也不是目录的东西,交回"存不了"和那个路径。同样的内容永远得到同一个版本号。 +- `checkoutVersion(repo, version, dir)`:把一个版本的文件原样写进调用方建好的空目录,还原可执行位;目录里没有 `.git`。 +- `compareVersions(repo, from, to)`:比较两个版本,交回改动过的文件(路径;新增、删除还是修改;是否只改了可执行位;是否二进制;非二进制文件每一块改动在新旧两边的行号范围),以及给模型看的带 3 行上下文的 `git diff` 原文,不加颜色。 +- `SaveOutcome`(类型):存好了和版本号,或者存不了、路径和原因。 +- `LineRange`、`Hunk`(类型):一块改动在一侧是"第几行到第几行";这一侧没有行时写成"在第几行之后"(0 表示文件开头),同 `git diff -U0` 的写法。 +- `FileChange`、`Comparison`(类型):一个改动过的文件,二进制文件不拆块;比较的全部结果。块可以为空:空文件的新增、删除和只改可执行位都没有块,按整个文件改了算。 diff --git a/src/tasks/facts.ts b/src/tasks/facts.ts index d7b51ff..debc3de 100644 --- a/src/tasks/facts.ts +++ b/src/tasks/facts.ts @@ -37,6 +37,11 @@ export const TaskFacts = Type.Object( protectedFiles: Type.Array(Type.String({ minLength: 1 }), { description: "不许改的文件,路径相对候选代码目录(方法.md 第十部分第 2 条)", }), + unversionedNames: Type.Array(Type.String({ pattern: "^[^/]+$" }), { + description: + "存代码版本时跳过的文件名或目录名,在任何一层都跳过:运行时自动生成的缓存、运行输出等不是代码的东西。" + + "只写名字,不带 /", + }), }, CLOSED, ); diff --git a/src/workspace/index.ts b/src/workspace/index.ts new file mode 100644 index 0000000..eb79e51 --- /dev/null +++ b/src/workspace/index.ts @@ -0,0 +1,13 @@ +// workspace:用 git 存代码版本、取出、比较两个版本(docs/结构.md 第二部分)。 +// 仓库位置由调用方给;不碰账本,版本之间的关系由 flow 记进账本。 +export { + checkoutVersion, + compareVersions, + createRepository, + saveVersion, + type Comparison, + type FileChange, + type Hunk, + type LineRange, + type SaveOutcome, +} from "./workspace.ts"; diff --git a/src/workspace/workspace.ts b/src/workspace/workspace.ts new file mode 100644 index 0000000..3424ca6 --- /dev/null +++ b/src/workspace/workspace.ts @@ -0,0 +1,192 @@ +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; +} diff --git a/tasks/circle_packing/task.json b/tasks/circle_packing/task.json index bee4aad..9e74a90 100644 --- a/tasks/circle_packing/task.json +++ b/tasks/circle_packing/task.json @@ -4,5 +4,6 @@ "epsilon": 0.01, "knownBest": 2.635983, "run": { "wallSeconds": 120, "cores": 1, "runtime": "runc" }, - "protectedFiles": ["harness.py", "validate.py", "check-candidate.py", "metrics-spec.json", "solution.json", "evaluate.py", "LICENSE", "SOURCES.md"] + "protectedFiles": ["harness.py", "validate.py", "check-candidate.py", "metrics-spec.json", "evaluate.py", "LICENSE", "SOURCES.md"], + "unversionedNames": ["__pycache__", ".git", "solution.json", "metrics.json"] } diff --git a/tests/tasks.test.ts b/tests/tasks.test.ts index c294709..027e8a5 100644 --- a/tests/tasks.test.ts +++ b/tests/tasks.test.ts @@ -148,6 +148,7 @@ test("读任务:task.json 的值、Dockerfile 所在目录、TASK.md 全文; assert.equal(task.facts.epsilon, 0.01); assert.equal(task.facts.knownBest, 2.635983); assert.deepEqual(task.facts.run, { wallSeconds: 120, cores: 1, runtime: "runc" }); + assert.deepEqual(task.facts.unversionedNames, ["__pycache__", ".git", "solution.json", "metrics.json"]); assert.ok(existsSync(join(task.dir, "Dockerfile"))); assert.match(task.guide, /^# 填圆任务/); const ids = metricDefinitions(task).map((d) => `${d.layer}:${d.id}`); @@ -173,12 +174,14 @@ test("task.json:缺字段、多字段、类型不对,一次全部列出, extra: true, } as Record; delete bad.protectedFiles; + delete bad.unversionedNames; assert.throws( () => checkTaskFacts(bad), (error: Error) => { const lines = error.message.split("\n").slice(1); - assert.match(error.message, /^task\.json 不合格,共 6 处/); + assert.match(error.message, /^task\.json 不合格,共 7 处/); assert.ok(lines.some((l) => l.startsWith("/protectedFiles:缺少")), error.message); + assert.ok(lines.some((l) => l.startsWith("/unversionedNames:缺少")), error.message); assert.ok(lines.some((l) => l.startsWith("/extra:不认识的字段")), error.message); assert.ok(lines.some((l) => l.startsWith("/epsilon:")), error.message); assert.ok(lines.some((l) => l.startsWith("/knownBest:应为数字,没有就写 null")), error.message); @@ -188,6 +191,8 @@ test("task.json:缺字段、多字段、类型不对,一次全部列出, }, ); assert.deepEqual(checkTaskFacts({ ...good, knownBest: null }).knownBest, null); + // "不存"清单只写名字:带 / 的写法在任何一层都对不上,报错。 + assert.throws(() => checkTaskFacts({ ...good, unversionedNames: ["lib/__pycache__"] }), /\/unversionedNames\/0:格式不对/); }); test("读任务:task.json 不是合法 JSON 时报错带文件路径", (t) => { diff --git a/tests/workspace.test.ts b/tests/workspace.test.ts new file mode 100644 index 0000000..d8aa720 --- /dev/null +++ b/tests/workspace.test.ts @@ -0,0 +1,262 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import { chmodSync, cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, symlinkSync, utimesSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { test, type TestContext } from "node:test"; +import { checkoutVersion, compareVersions, createRepository, saveVersion, type FileChange } from "../src/workspace/index.ts"; + +// workspace 的测试(任务书 06 第五部分),用真实 git,仓库建在每个测试自己的临时目录里。 +// 行号、目录树编号等预期值都是手算或按 git 公开的对象格式独立算的,不调实现。 + +function tempDir(t: TestContext, name: string): string { + const dir = mkdtempSync(join(tmpdir(), `inforsi-workspace-${name}-`)); + t.after(() => rmSync(dir, { recursive: true, force: true })); + return dir; +} + +function newRepo(t: TestContext): string { + const repo = join(tempDir(t, "repo"), "versions.git"); + createRepository(repo); + return repo; +} + +/** 按 { 相对路径: 内容 } 建一个目录;路径以 * 结尾的文件设成可执行。 */ +function makeDir(t: TestContext, files: Record): string { + const dir = tempDir(t, "code"); + for (const [name, content] of Object.entries(files)) { + const path = join(dir, name.replace(/\*$/, "")); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content); + chmodSync(path, name.endsWith("*") ? 0o755 : 0o644); + } + return dir; +} + +function save(repo: string, dir: string, excluded: string[]): string { + const outcome = saveVersion(repo, dir, excluded); + assert.ok(outcome.saved, JSON.stringify(outcome)); + return outcome.version; +} + +function checkout(t: TestContext, repo: string, version: string): string { + const dir = tempDir(t, "out"); + checkoutVersion(repo, version, dir); + return dir; +} + +/** 测试自己调 git 查仓库,同样不读用户配置。 */ +function gitIn(repo: string, args: string[], input: string): { status: number | null; stdout: string; stderr: string } { + return spawnSync("git", args, { + encoding: "utf8", + input, + env: { ...process.env, GIT_DIR: repo, GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: "/dev/null" }, + }); +} + +function sha1(data: Buffer): Buffer { + return createHash("sha1").update(data).digest(); +} + +/** 按 git 公开的对象格式独立算一个只含文件、不含子目录的目录树编号。 */ +function flatTreeId(files: Array<[name: string, mode: string, content: string]>): string { + const entries = [...files] + .sort(([a], [b]) => (a < b ? -1 : 1)) + .map(([name, mode, content]) => { + const blob = Buffer.from(content); + const blobId = sha1(Buffer.concat([Buffer.from(`blob ${blob.length}\0`), blob])); + return Buffer.concat([Buffer.from(`${mode} ${name}\0`), blobId]); + }); + const body = Buffer.concat(entries); + return sha1(Buffer.concat([Buffer.from(`tree ${body.length}\0`), body])).toString("hex"); +} + +// --------------------------------------------------------------------------- +// 存版本与取出 +// --------------------------------------------------------------------------- + +test("同内容同号:存两次、复制一份改了文件时间再存,都是同一个号,且等于独立算出的目录树编号", (t) => { + const repo = newRepo(t); + const dir = makeDir(t, { "run.sh*": "#!/bin/sh\necho hi\n", "main.py": "print(1)\n" }); + const first = save(repo, dir, []); + assert.equal(save(repo, dir, []), first); + const copy = join(tempDir(t, "copy"), "code"); + cpSync(dir, copy, { recursive: true }); + utimesSync(join(copy, "main.py"), new Date("2001-01-01"), new Date("2001-01-01")); + assert.equal(save(repo, copy, []), first); + assert.equal(first, flatTreeId([["main.py", "100644", "print(1)\n"], ["run.sh", "100755", "#!/bin/sh\necho hi\n"]])); +}); + +test("取出再存回来,号不变:内容逐字节、可执行位都还原,取出的目录里没有 .git", (t) => { + const repo = newRepo(t); + const binary = Buffer.from([0, 1, 2, 255, 13, 10]); + const dir = makeDir(t, { "a.py": "x = 1\r\ny = 2\n", "lib/b.py*": "#!/usr/bin/env python\n", "lib/deep/data.bin": binary, "empty.txt": "" }); + const version = save(repo, dir, []); + const out = checkout(t, repo, version); + assert.deepEqual(readFileSync(join(out, "a.py")), Buffer.from("x = 1\r\ny = 2\n")); + assert.deepEqual(readFileSync(join(out, "lib/deep/data.bin")), binary); + assert.equal(readFileSync(join(out, "empty.txt"), "utf8"), ""); + assert.equal(statSync(join(out, "lib/b.py")).mode & 0o100, 0o100); + assert.equal(statSync(join(out, "a.py")).mode & 0o100, 0); + assert.equal(existsSync(join(out, ".git")), false); + assert.equal(save(repo, out, []), version); +}); + +test(".gitignore 不起作用:写了 *.json,cfg.json 照样存进去", (t) => { + const repo = newRepo(t); + const dir = makeDir(t, { ".gitignore": "*.json\n", "cfg.json": "{}\n", "main.py": "pass\n" }); + const out = checkout(t, repo, save(repo, dir, [])); + assert.equal(readFileSync(join(out, "cfg.json"), "utf8"), "{}\n"); + assert.equal(readFileSync(join(out, ".gitignore"), "utf8"), "*.json\n"); +}); + +test("树里的 .gitattributes 不起作用:取出时不丢文件、不改换行符", (t) => { + const repo = newRepo(t); + const dir = makeDir(t, { ".gitattributes": "*.py export-ignore\n*.txt text eol=crlf\n", "a.py": "pass\n", "b.txt": "x\n" }); + const out = checkout(t, repo, save(repo, dir, [])); + assert.equal(readFileSync(join(out, "a.py"), "utf8"), "pass\n"); + assert.equal(readFileSync(join(out, "b.txt"), "utf8"), "x\n"); +}); + +test("不读用户配置:HOME 里有改行为的 git 配置和 attributes,存、取、比较的结果都不变", (t) => { + const files = { "a.py": "x = 1\r\ny = 2\n", "run.sh*": "#!/bin/sh\n", "数据.py": "1\n" }; + const changed = { "a.py": "x = 1\r\ny = 3\n", "run.sh*": "#!/bin/sh\n", "数据.py": "2\n" }; + const repo = newRepo(t); + const cleanFrom = save(repo, makeDir(t, files), []); + const cleanTo = save(repo, makeDir(t, changed), []); + const clean = compareVersions(repo, cleanFrom, cleanTo); + + // 只在这个测试里把 HOME 换成一份"有敌意"的配置;git 子进程继承 process.env。 + const home = tempDir(t, "home"); + writeFileSync( + join(home, ".gitconfig"), + "[core]\n\tautocrlf = true\n\tfileMode = false\n\tabbrev = 20\n\tquotePath = false\n[diff]\n\tnoprefix = true\n\talgorithm = histogram\n\tcontext = 10\n[color]\n\tui = always\n", + ); + mkdirSync(join(home, ".config", "git"), { recursive: true }); + writeFileSync(join(home, ".config", "git", "attributes"), "*.py binary\n* text eol=crlf\n"); + const saved = { HOME: process.env.HOME, XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME }; + process.env.HOME = home; + delete process.env.XDG_CONFIG_HOME; + t.after(() => { + process.env.HOME = saved.HOME; + if (saved.XDG_CONFIG_HOME !== undefined) process.env.XDG_CONFIG_HOME = saved.XDG_CONFIG_HOME; + }); + // 先确认这份配置对不加保护的 git 确实起作用,测试才有意义。 + assert.equal(spawnSync("git", ["config", "--global", "core.autocrlf"], { encoding: "utf8" }).stdout, "true\n"); + + const hostileRepo = newRepo(t); + const from = save(hostileRepo, makeDir(t, files), []); + const to = save(hostileRepo, makeDir(t, changed), []); + assert.equal(from, cleanFrom); + assert.equal(to, cleanTo); + const out = checkout(t, hostileRepo, from); + assert.deepEqual(readFileSync(join(out, "a.py")), Buffer.from("x = 1\r\ny = 2\n")); + const hostile = compareVersions(hostileRepo, from, to); + assert.deepEqual(hostile, clean); + assert.match(hostile.diff, /^diff --git a\/a\.py b\/a\.py$/m); + assert.doesNotMatch(hostile.diff, /\x1b/); + // 编号缩写取 git 的默认长度,不是配置里的 20 位;非 ASCII 路径照默认加引号转义。 + assert.match(hostile.diff, /^index [0-9a-f]{7}\.\.[0-9a-f]{7} 100644$/m); + assert.match(hostile.diff, /^diff --git "a\/\\346/m); +}); + +test("不存清单:清单里的名字在顶层和子目录里都跳过", (t) => { + const repo = newRepo(t); + const dir = makeDir(t, { + "main.py": "pass\n", + "solution.json": "{}", + "__pycache__/main.cpython-313.pyc": "x", + ".git/HEAD": "ref: refs/heads/main\n", + "lib/util.py": "pass\n", + "lib/__pycache__/util.cpython-313.pyc": "x", + "lib/metrics.json": "{}", + }); + const excluded = ["__pycache__", ".git", "solution.json", "metrics.json"]; + const listed = gitIn(repo, ["ls-tree", "-r", "--name-only", save(repo, dir, excluded)], ""); + assert.equal(listed.stdout, "lib/util.py\nmain.py\n"); +}); + +test("存不了:符号链接、命名管道交回存不了和那个路径", (t) => { + const repo = newRepo(t); + const linked = makeDir(t, { "main.py": "pass\n", "lib/real.py": "pass\n" }); + symlinkSync("real.py", join(linked, "lib", "link.py")); + const outcome = saveVersion(repo, linked, []); + assert.equal(outcome.saved, false); + assert.ok(!outcome.saved); + assert.equal(outcome.path, "lib/link.py"); + assert.match(outcome.reason, /符号链接/); + + const piped = makeDir(t, { "main.py": "pass\n" }); + assert.equal(spawnSync("mkfifo", [join(piped, "pipe")]).status, 0); + assert.deepEqual(saveVersion(repo, piped, []), { saved: false, path: "pipe", reason: "既不是普通文件也不是目录,代码版本只存普通文件和目录" }); + // 在清单里的名字不看类型,直接跳过。 + assert.ok(saveVersion(repo, piped, ["pipe"]).saved); +}); + +test("引用防清理:git gc --prune=now 之后版本照样能取出,没挂引用的对象被清掉", (t) => { + const repo = newRepo(t); + const version = save(repo, makeDir(t, { "main.py": "keep me\n" }), []); + const orphan = gitIn(repo, ["hash-object", "-w", "--stdin"], "nobody points at me\n").stdout.trim(); + assert.equal(gitIn(repo, ["cat-file", "-e", orphan], "").status, 0); + assert.equal(gitIn(repo, ["gc", "--prune=now", "--quiet"], "").status, 0); + assert.notEqual(gitIn(repo, ["cat-file", "-e", orphan], "").status, 0); + const out = checkout(t, repo, version); + assert.equal(readFileSync(join(out, "main.py"), "utf8"), "keep me\n"); +}); + +// --------------------------------------------------------------------------- +// 比较两个版本 +// --------------------------------------------------------------------------- + +test("比较:新增、删除、修改、只改可执行位、二进制,各一个文件", (t) => { + const repo = newRepo(t); + const from = save( + repo, + makeDir(t, { "gone.py": "a\nb\n", "edit.py": "one\ntwo\n", "tool.sh": "#!/bin/sh\n", "data.bin": Buffer.from([0, 1, 2]), "same.py": "x\n" }), + [], + ); + const to = save( + repo, + makeDir(t, { "new/added.py": "p\nq\n", "edit.py": "one\nTWO\n", "tool.sh*": "#!/bin/sh\n", "data.bin": Buffer.from([0, 1, 3]), "same.py": "x\n" }), + [], + ); + const expected: FileChange[] = [ + { path: "data.bin", change: "modified", modeOnly: false, binary: true }, + { path: "edit.py", change: "modified", modeOnly: false, binary: false, hunks: [{ from: { start: 2, end: 2 }, to: { start: 2, end: 2 } }] }, + { path: "gone.py", change: "deleted", modeOnly: false, binary: false, hunks: [{ from: { start: 1, end: 2 }, to: { after: 0 } }] }, + { path: "new/added.py", change: "added", modeOnly: false, binary: false, hunks: [{ from: { after: 0 }, to: { start: 1, end: 2 } }] }, + { path: "tool.sh", change: "modified", modeOnly: true, binary: false, hunks: [] }, + ]; + const result = compareVersions(repo, from, to); + assert.deepEqual(result.files, expected); + assert.match(result.diff, /^diff --git a\/tool\.sh b\/tool\.sh\nold mode 100644\nnew mode 100755$/m); + assert.match(result.diff, /^Binary files a\/data\.bin and b\/data\.bin differ$/m); + assert.deepEqual(compareVersions(repo, to, to), { files: [], diff: "" }); +}); + +test("比较的行号:改一行、加几行、只删不加,新旧两边的范围都对(预期值手算)", (t) => { + const repo = newRepo(t); + // 旧:a b c d e f g h i j(第 1–10 行)。 + // 新:第 2 行 b 改成 B;e(旧第 5 行)后面加 X Y Z;删掉 h(旧第 8 行)。 + // 新文件是 a B c d e X Y Z f g i j:B 在新第 2 行;X Y Z 在新第 6–8 行; + // h 删掉后,原来在它前面的 g 是新第 10 行,所以写成"在新第 10 行之后"。 + const from = save(repo, makeDir(t, { "f.txt": "a\nb\nc\nd\ne\nf\ng\nh\ni\nj\n" }), []); + const to = save(repo, makeDir(t, { "f.txt": "a\nB\nc\nd\ne\nX\nY\nZ\nf\ng\ni\nj\n" }), []); + const result = compareVersions(repo, from, to); + assert.deepEqual(result.files, [ + { + path: "f.txt", + change: "modified", + modeOnly: false, + binary: false, + hunks: [ + { from: { start: 2, end: 2 }, to: { start: 2, end: 2 } }, + { from: { after: 5 }, to: { start: 6, end: 8 } }, + { from: { start: 8, end: 8 }, to: { after: 10 } }, + ], + }, + ]); + // 给模型看的差异原文带 3 行上下文:三处改动相隔不到 6 行,合成一块。 + assert.match(result.diff, /^@@ -1,10 \+1,12 @@\n a\n-b\n\+B\n c\n d\n e\n\+X\n\+Y\n\+Z\n f\n g\n-h\n i\n j\n$/m); +});