/** * 真 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=@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 \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"); }); });