Files
curriculum-project-hub/hub/test/unit/filelib-git-version-store.test.ts
82241afb56 feat(filelib)!: VersionStore 改为真 git,一项目一仓库
内存 store 换成 gitVersionStore:init 建目录并 git init,VersionId 是
commit hash,某文件的版本取 `git log -1 -- <path>`(D16 文件级版本不因
别的文件提交而失效)。删除也是一个 commit,旧版本仍可读。决策见 ADR-0030。

git 用 execFile 调系统二进制,不引依赖。每次调用钉死 --git-dir/--work-tree
并禁 hooks、隔离全局 gitconfig:项目仓库是老师上传的数据,而 storage root
默认就在本 repo 内,不钉死会让命令落到外层仓库上。

同时:
- 单文件上限改为 HUB_FILELIB_MAX_FILE_BYTES(缺省 10MiB),前端从
  /database/config 读,不再两处硬编码
- commit 身份 name=displayName、email=<userId>@filelib.paradigm-edu.net;
  message 缺省为「【用户名】修改了【路径】」,调用方显式传则优先
- 上传改走弹窗,路径与 commit 信息可手填(原先 prompt 只能填路径)

BREAKING CHANGE: VersionId 由计数器(v1/v2)变为 commit hash;
CommitRequest.author 由字符串变为 { userId, displayName? }。
旧 .version-store.json 不迁移,此前建的项目报 repo_not_found。
2026-07-27 15:55:01 +08:00

304 lines
15 KiB
TypeScript

/**
* 真 git VersionStore 语义单测(ADR-0030)。
*
* 与 filelib-version-store.test.ts(内存实现)逐条对齐同一份 C1 语义:
* S1/S2 冲突走返回值、S7 init 幂等、D16 文件级版本互不影响、S4 同仓库写串行。
* 差别只在 VersionId 是 commit hash 而非计数器 —— 断言因此不写死字面量。
*
* 每个用例一个真临时仓库,跑真 git;没有 mock。
*/
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { mkdtemp, rm, readFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { execFileSync } from "node:child_process";
import { tmpdir } from "node:os";
import path from "node:path";
import { createGitVersionStore } from "../../src/database/filelib/gitVersionStore.js";
import { FileLibError } from "../../src/database/filelib/model.js";
const HEX40 = /^[0-9a-f]{40}$/;
let root: string;
let dir: string;
const store = createGitVersionStore();
beforeEach(async () => {
root = await mkdtemp(path.join(tmpdir(), "filelib-git-"));
dir = path.join(root, "11111111-2222-3333-4444-555555555555");
});
afterEach(async () => {
await rm(root, { recursive: true, force: true });
});
/** 取 ok 版本号;conflict 直接让用例失败(比 as 断言更早暴露问题)。 */
function okVersion(result: { status: string; version?: string }): string {
expect(result.status).toBe("ok");
return result.version as string;
}
describe("GitVersionStore · C1 语义(ADR-0030)", () => {
it("init 建目录 + git init;幂等(S7);未 init → repo_not_found", async () => {
await expect(store.head(dir, "a.md")).rejects.toThrowError(FileLibError);
await store.init(dir);
expect(existsSync(path.join(dir, ".git"))).toBe(true);
await store.init(dir); // 幂等
const v1 = okVersion(await store.commit(dir, "a.md", { baseVersion: null, content: "hello" }));
await store.init(dir); // 已有内容后再 init 不清空
expect(await store.head(dir, "a.md")).toBe(v1);
expect((await store.read(dir, "a.md")).toString()).toBe("hello");
});
it("VersionId 是 40 位 commit hash,且真的落到磁盘工作区", async () => {
await store.init(dir);
const v = okVersion(await store.commit(dir, "docs/a.md", { baseVersion: null, content: "内容" }));
expect(v).toMatch(HEX40);
// 工作区里是一个真文件(不只是 git 对象)。
expect(await readFile(path.join(dir, "docs/a.md"), "utf8")).toBe("内容");
});
it("对已存在文件再次『新建』 → conflict(S2)", async () => {
await store.init(dir);
const v1 = okVersion(await store.commit(dir, "a.md", { baseVersion: null, content: "1" }));
expect(await store.commit(dir, "a.md", { baseVersion: null, content: "2" })).toEqual({
status: "conflict",
currentVersion: v1,
});
});
it("baseVersion 落后于当前 → conflict 并带 currentVersion(S1)", async () => {
await store.init(dir);
const v1 = okVersion(await store.commit(dir, "a.md", { baseVersion: null, content: "1" }));
const v2 = okVersion(await store.commit(dir, "a.md", { baseVersion: v1, content: "2" }));
expect(await store.commit(dir, "a.md", { baseVersion: v1, content: "3" })).toEqual({
status: "conflict",
currentVersion: v2,
});
});
it("旧版本仍可按 hash 读回", async () => {
await store.init(dir);
const v1 = okVersion(await store.commit(dir, "a.md", { baseVersion: null, content: "一" }));
await store.commit(dir, "a.md", { baseVersion: v1, content: "二" });
expect((await store.read(dir, "a.md", v1)).toString()).toBe("一");
expect((await store.read(dir, "a.md")).toString()).toBe("二");
});
it("D16 文件级版本:a.md 的提交不推进 b.md 的 base", async () => {
await store.init(dir);
const a1 = okVersion(await store.commit(dir, "a.md", { baseVersion: null, content: "A1" }));
const b1 = okVersion(await store.commit(dir, "b.md", { baseVersion: null, content: "B1" }));
await store.commit(dir, "a.md", { baseVersion: a1, content: "A2" }); // 仓库 HEAD 前进
// b.md 仍以自己的版本为 base —— 这正是 commit 级 hash 做文件级版本的关键。
expect((await store.commit(dir, "b.md", { baseVersion: b1, content: "B2" })).status).toBe("ok");
expect(await store.head(dir, "b.md")).not.toBe(b1);
});
it("二进制内容原样往返(NUL 字节不被破坏)", async () => {
await store.init(dir);
const bytes = Buffer.from([0x00, 0x01, 0xff, 0x00, 0x7f]);
await store.commit(dir, "blob.bin", { baseVersion: null, content: bytes });
expect(Buffer.compare(await store.read(dir, "blob.bin"), bytes)).toBe(0);
});
it("remove:正确 base → ok;head 随后 404;stale/缺失 base → conflict/404", async () => {
await store.init(dir);
const v1 = okVersion(await store.commit(dir, "a.md", { baseVersion: null, content: "1" }));
const v2 = okVersion(await store.commit(dir, "a.md", { baseVersion: v1, content: "2" }));
expect(await store.remove(dir, "a.md", v1)).toEqual({ status: "conflict", currentVersion: v2 });
const v3 = okVersion(await store.remove(dir, "a.md", v2));
await expect(store.head(dir, "a.md")).rejects.toThrowError(FileLibError);
expect(existsSync(path.join(dir, "a.md"))).toBe(false);
// 删除是一个 commit:旧版本内容仍可取回(ADR-0030)。
expect((await store.read(dir, "a.md", v1)).toString()).toBe("1");
expect(v3).toMatch(HEX40);
await expect(store.remove(dir, "a.md", v3)).rejects.toThrowError(FileLibError);
});
it("history 新→旧,支持 limit,author 取 displayName", async () => {
await store.init(dir);
const v1 = okVersion(
await store.commit(dir, "docs/a.md", {
baseVersion: null,
content: "1",
message: "初版",
author: { userId: "u1", displayName: "张老师" },
}),
);
await store.commit(dir, "docs/a.md", {
baseVersion: v1,
content: "2",
message: "二版",
author: { userId: "u2", displayName: "李老师" },
});
await store.commit(dir, "other/b.md", { baseVersion: null, content: "x" });
const h = await store.history(dir, "docs/a.md");
expect(h.map((v) => v.message)).toEqual(["二版", "初版"]);
expect(h.map((v) => v.author)).toEqual(["李老师", "张老师"]);
expect(h[0]?.version).toMatch(HEX40);
expect((await store.history(dir, "docs/a.md", 1)).map((v) => v.message)).toEqual(["二版"]);
// other/b.md 的提交不出现在 docs/a.md 的历史里。
expect(h).toHaveLength(2);
});
it("list 排除已删、按前缀过滤、空仓库返回空", async () => {
await store.init(dir);
expect(await store.list(dir)).toEqual([]);
await store.commit(dir, "docs/a.md", { baseVersion: null, content: "1" });
const bv = okVersion(await store.commit(dir, "other/b.md", { baseVersion: null, content: "xy" }));
expect((await store.list(dir)).map((f) => f.path)).toEqual(["docs/a.md", "other/b.md"]);
expect((await store.list(dir, "docs/")).map((f) => f.path)).toEqual(["docs/a.md"]);
expect((await store.list(dir)).find((f) => f.path === "other/b.md")?.size).toBe(2);
await store.remove(dir, "other/b.md", bv);
expect((await store.list(dir)).map((f) => f.path)).toEqual(["docs/a.md"]);
});
it("S4 同仓库写串行:并发同 base 提交,恰好一成一冲突", async () => {
await store.init(dir);
const v1 = okVersion(await store.commit(dir, "a.md", { baseVersion: null, content: "1" }));
const [r1, r2] = await Promise.all([
store.commit(dir, "a.md", { baseVersion: v1, content: "x" }),
store.commit(dir, "a.md", { baseVersion: v1, content: "y" }),
]);
expect([r1.status, r2.status].sort()).toEqual(["conflict", "ok"]);
});
it("目录不存在 → repo_not_found,不该报 git_missing", async () => {
// 回归:spawn 的 ENOENT 有两种来源且报错一模一样 —— git 不在 PATH 上,或 cwd 不存在。
// 早先把两者一律归为 git_missing,于是“项目目录没建过”会被误报成“git 没装”。
// 内存 store 时代建的旧项目就是这个形态:DB 有 storageDir,磁盘上什么都没有。
const missing = path.join(root, "never-created");
for (const op of [
() => store.head(missing, "a.md"),
() => store.list(missing),
() => store.read(missing, "a.md"),
() => store.commit(missing, "a.md", { baseVersion: null, content: "x" }),
() => store.remove(missing, "a.md", "0".repeat(40)),
() => store.history(missing, "a.md"),
]) {
await expect(op()).rejects.toMatchObject({ code: "repo_not_found", statusCode: 404 });
}
});
it("目录存在但不是 git 仓库 → repo_not_found", async () => {
const { mkdir } = await import("node:fs/promises");
const plain = path.join(root, "plain-dir");
await mkdir(plain, { recursive: true });
await expect(store.head(plain, "a.md")).rejects.toMatchObject({ code: "repo_not_found" });
});
it("仍然能从零开始:init 会把缺失的目录建出来", async () => {
const deep = path.join(root, "a", "b", "c-uuid");
await store.init(deep);
expect((await store.commit(deep, "a.md", { baseVersion: null, content: "1" })).status).toBe("ok");
});
it("提交身份:name=displayName,email=<userId>@filelib.paradigm-edu.net", async () => {
await store.init(dir);
await store.commit(dir, "a.md", {
baseVersion: null,
content: "1",
author: { userId: "u_alice", displayName: "张老师" },
});
const ident = execFileSync("git", ["-C", dir, "log", "-1", "--format=%an|%ae|%cn|%ce"]).toString().trim();
expect(ident).toBe("张老师|u_alice@filelib.paradigm-edu.net|张老师|u_alice@filelib.paradigm-edu.net");
});
it("displayName 缺失时 name 回退 userId,email 仍用 userId", async () => {
await store.init(dir);
await store.commit(dir, "a.md", { baseVersion: null, content: "1", author: { userId: "u_bob" } });
expect(execFileSync("git", ["-C", dir, "log", "-1", "--format=%an|%ae"]).toString().trim())
.toBe("u_bob|u_bob@filelib.paradigm-edu.net");
});
it("displayName 里的 <>/换行不能破坏 git ident 行", async () => {
await store.init(dir);
await store.commit(dir, "a.md", {
baseVersion: null,
content: "1",
author: { userId: "u_x", displayName: "a <evil@e.com>\n换行" },
});
const out = execFileSync("git", ["-C", dir, "log", "-1", "--format=%an|%ae"]).toString().trim();
const [name, email] = out.split("|");
// 断言性质而不是具体空白(git 自己还会压缩 ident 里的空白)。
expect(name).not.toContain("<");
expect(name).not.toContain(">");
expect(name).not.toContain("\n");
expect(name).toContain("换行");
expect(email).toBe("u_x@filelib.paradigm-edu.net");
});
it("删除提交也带提交者身份", async () => {
await store.init(dir);
const v = okVersion(await store.commit(dir, "a.md", { baseVersion: null, content: "1" }));
okVersion(await store.remove(dir, "a.md", v, { userId: "u_del", displayName: "删除者" }));
expect(execFileSync("git", ["-C", dir, "log", "-1", "--format=%an|%ae"]).toString().trim())
.toBe("删除者|u_del@filelib.paradigm-edu.net");
});
it("storage root 在另一个 git 仓库内时,不得落到外层仓库上", async () => {
// 回归:早先用 `git rev-parse --git-dir` 判定“是否已 init”,而 git 会沿目录树
// **向上**找 `.git`。本地开发的默认 storage root 就是 `hub/.filelib-repos`,
// 在本 repo 内 —— 于是未 init 的项目目录被误判为“已是仓库”,后续命令全部
// 落到外层源码仓上(表现为 `git add` 报 “paths are ignored by .gitignore”)。
const outer = path.join(root, "outer");
const { mkdir, writeFile } = await import("node:fs/promises");
await mkdir(outer, { recursive: true });
execFileSync("git", ["-C", outer, "init", "--quiet"]);
await writeFile(path.join(outer, ".gitignore"), "repos/\n");
const inner = path.join(outer, "repos", "proj-uuid");
await mkdir(inner, { recursive: true });
// 未 init 的子目录:必须报 repo_not_found,而不是静默用外层仓库。
await expect(store.head(inner, "a.md")).rejects.toMatchObject({ code: "repo_not_found" });
// init 后的写入必须进自己的仓库,外层仓库保持干净。
await store.init(inner);
okVersion(await store.commit(inner, "a.md", { baseVersion: null, content: "1" }));
expect(existsSync(path.join(inner, ".git"))).toBe(true);
expect(execFileSync("git", ["-C", outer, "status", "--porcelain"]).toString().trim()).toBe("?? .gitignore");
expect(execFileSync("git", ["-C", inner, "log", "--format=%s"]).toString().trim()).toBe("create a.md");
});
it("显式 message 原样落到 commit;不传则用调用方给的默认值", async () => {
await store.init(dir);
okVersion(await store.commit(dir, "a.md", { baseVersion: null, content: "1", message: "手填的提交信息" }));
expect(execFileSync("git", ["-C", dir, "log", "-1", "--format=%s"]).toString().trim()).toBe("手填的提交信息");
// 以 `-` 开头的 message 不得被当成选项(execFile 数组形式 + `-m` 传入)。
okVersion(await store.commit(dir, "b.md", { baseVersion: null, content: "1", message: "--force 看着像选项" }));
expect(execFileSync("git", ["-C", dir, "log", "-1", "--format=%s"]).toString().trim()).toBe("--force 看着像选项");
});
it("diff 是真 unified diff,含增删行", async () => {
await store.init(dir);
const v1 = okVersion(await store.commit(dir, "a.md", { baseVersion: null, content: "keep\nold\n" }));
const v2 = okVersion(await store.commit(dir, "a.md", { baseVersion: v1, content: "keep\nnew\n" }));
const d = await store.diff(dir, "a.md", v1, v2);
expect(d).toContain("-old");
expect(d).toContain("+new");
expect(d).toContain(" keep");
});
it("不存在的 version → version_not_found;非法路径 → invalid_path", async () => {
await store.init(dir);
await store.commit(dir, "a.md", { baseVersion: null, content: "1" });
const bogus = "0".repeat(40);
await expect(store.read(dir, "a.md", bogus)).rejects.toMatchObject({ code: "version_not_found" });
for (const bad of ["../escape.md", "a/../../b.md", ".git/config", "/abs.md", "a\\b.md"]) {
await expect(store.read(dir, bad)).rejects.toMatchObject({ code: "invalid_path" });
}
});
it("仓库内的 hooks 不被执行(ADR-0030 加固)", async () => {
await store.init(dir);
// 装一个会失败的 pre-commit hook:若 hooks 生效,下面的 commit 就会失败。
const { mkdir, writeFile, chmod } = await import("node:fs/promises");
const hookDir = path.join(dir, ".git", "hooks");
await mkdir(hookDir, { recursive: true });
const hook = path.join(hookDir, "pre-commit");
await writeFile(hook, "#!/bin/sh\nexit 1\n");
await chmod(hook, 0o755);
expect((await store.commit(dir, "a.md", { baseVersion: null, content: "1" })).status).toBe("ok");
});
});