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); }); test("诊断程序(任务书 18):基线代码取出后放进只读的原始产物、改成诊断程序再存,产物被跳过,比较只看程序的改动,取出再存号不变", (t) => { const repo = newRepo(t); const baseline = save(repo, makeDir(t, { "initial.py": "a\nb\n", "evaluate.py": "e\n" }), []); const workDir = checkout(t, repo, baseline); // 原始产物放进实验员工作目录,设成只读(0444),与 tasks 的 placeBaselineArtifact 一样。 mkdirSync(join(workDir, "baseline-artifact")); writeFileSync(join(workDir, "baseline-artifact", "solution.json"), "{}"); chmodSync(join(workDir, "baseline-artifact", "solution.json"), 0o444); writeFileSync(join(workDir, "initial.py"), "a\nb\nexperiment\n"); const excluded = ["__pycache__", "solution.json", "baseline-artifact"]; const program = save(repo, workDir, excluded); assert.notEqual(program, baseline); const comparison = compareVersions(repo, baseline, program); assert.deepEqual(comparison.files.map((f) => f.path), ["initial.py"]); assert.deepEqual((comparison.files[0] as Extract).hunks, [{ from: { after: 2 }, to: { start: 3, end: 3 } }]); const again = checkout(t, repo, program); assert.equal(existsSync(join(again, "baseline-artifact")), false); assert.equal(save(repo, again, excluded), program); });