Files
InfoRSI/tests/workspace.test.ts
T
iomgaa 6850c0a1d4 feat: 任务书 06,workspace 模块与 task.json 的"不存"清单
workspace 用一个裸仓库存代码版本,版本号是目录树编号,不造提交,每个版本挂 refs/versions/ 引用防清理。
存版本自己遍历目录、hash-object --no-filters 存,不用 git add;符号链接等特殊文件交回"存不了"。
取出用 ls-tree 加 cat-file 原样写出:实测 git archive 会按树里的 .gitattributes 丢文件、改换行符。
git 子进程不读系统和用户的配置,另关掉系统和用户的 attributes 文件:实测只关配置时 ~/.config/git/attributes 仍会被读。
比较交回文件级改动、新旧两边行号范围的块,以及带上下文的 diff 原文。
tasks:task.json 加必填的 unversionedNames,protectedFiles 删掉 solution.json。
全新上下文审阅一轮,采纳一条:注明块可以为空(空文件增删、只改可执行位)。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 23:43:01 -04:00

263 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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, string | Buffer>): 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);
});