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>
This commit is contained in:
2026-10-02 23:43:01 -04:00
parent a1a2feb135
commit 6850c0a1d4
8 changed files with 500 additions and 5 deletions
+262
View File
@@ -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, 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);
});