// @effect-diagnostics nodeBuiltinImport:off import * as NodePath from "node:path"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { it } from "@effect/vitest"; import { ThreadId, type VcsError } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as PlatformError from "effect/PlatformError"; import * as Scope from "effect/Scope"; import { describe, expect } from "vite-plus/test"; import { checkpointRefForThreadTurn } from "./Utils.ts"; import { parseTurnDiffFilesFromNumstat } from "./Diffs.ts"; import * as CheckpointStore from "./CheckpointStore.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as ServerConfig from "../config.ts"; const ServerConfigLayer = ServerConfig.ServerConfig.layerTest(process.cwd(), { prefix: "t3-checkpoint-store-test-", }); const VcsProcessTestLayer = VcsProcess.layer.pipe(Layer.provide(NodeServices.layer)); const VcsDriverTestLayer = VcsDriverRegistry.layer.pipe(Layer.provide(VcsProcessTestLayer)); const CheckpointStoreTestLayer = CheckpointStore.layer.pipe( Layer.provideMerge(VcsDriverTestLayer), Layer.provideMerge(NodeServices.layer), ); const TestLayer = CheckpointStoreTestLayer.pipe( Layer.provideMerge(VcsProcessTestLayer), Layer.provideMerge(VcsDriverTestLayer), Layer.provideMerge(ServerConfigLayer), Layer.provideMerge(NodeServices.layer), ); function makeTmpDir( prefix = "checkpoint-store-test-", ): Effect.Effect { return Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; return yield* fileSystem.makeTempDirectoryScoped({ prefix }); }); } function writeTextFile( filePath: string, contents: string, ): Effect.Effect { return Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; yield* fileSystem.writeFileString(filePath, contents); }); } function git( cwd: string, args: ReadonlyArray, ): Effect.Effect { return Effect.gen(function* () { const process = yield* VcsProcess.VcsProcess; const result = yield* process.run({ operation: "CheckpointStore.test.git", command: "git", cwd, args, timeoutMs: 10_000, }); return result.stdout.trim(); }); } function initRepoWithCommit( cwd: string, ): Effect.Effect< void, VcsError | PlatformError.PlatformError, VcsProcess.VcsProcess | FileSystem.FileSystem > { return Effect.gen(function* () { yield* git(cwd, ["init"]); yield* git(cwd, ["config", "user.email", "test@test.com"]); yield* git(cwd, ["config", "user.name", "Test"]); yield* writeTextFile(NodePath.join(cwd, "README.md"), "# test\n"); yield* git(cwd, ["add", "."]); yield* git(cwd, ["commit", "-m", "initial commit"]); }); } function buildLargeText(lineCount = 5_000): string { return Array.from({ length: lineCount }, (_, index) => `line ${String(index).padStart(5, "0")}`) .join("\n") .concat("\n"); } it.layer(TestLayer)("CheckpointStore.layer", (it) => { describe("isGitRepository", () => { it.effect("returns false when no Git repository is detected", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); const checkpointStore = yield* CheckpointStore.CheckpointStore; expect(yield* checkpointStore.isGitRepository(tmp)).toBe(false); }), ); it.effect("returns true when a Git repository is detected", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); const checkpointStore = yield* CheckpointStore.CheckpointStore; expect(yield* checkpointStore.isGitRepository(tmp)).toBe(true); }), ); }); describe("diffCheckpoints", () => { it.effect("returns full oversized checkpoint diffs without truncation", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); const checkpointStore = yield* CheckpointStore.CheckpointStore; const threadId = ThreadId.make("thread-checkpoint-store"); const fromCheckpointRef = checkpointRefForThreadTurn(threadId, 0); const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: fromCheckpointRef, }); yield* writeTextFile(NodePath.join(tmp, "README.md"), buildLargeText()); yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: toCheckpointRef, }); const diff = yield* checkpointStore.diffCheckpoints({ cwd: tmp, fromCheckpointRef, toCheckpointRef, ignoreWhitespace: true, }); expect(diff).toContain("diff --git"); expect(diff).not.toContain("[truncated]"); expect(diff).toContain("+line 04999"); }), ); it.effect("keeps a/ and b/ patch prefixes when the repository disables them", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); yield* git(tmp, ["config", "diff.noprefix", "true"]); const checkpointStore = yield* CheckpointStore.CheckpointStore; const threadId = ThreadId.make("thread-checkpoint-store-noprefix"); const fromCheckpointRef = checkpointRefForThreadTurn(threadId, 0); const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: fromCheckpointRef, }); yield* writeTextFile(NodePath.join(tmp, "README.md"), "# changed\n"); yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: toCheckpointRef, }); const diff = yield* checkpointStore.diffCheckpoints({ cwd: tmp, fromCheckpointRef, toCheckpointRef, ignoreWhitespace: false, }); expect(diff).toContain("diff --git a/README.md b/README.md"); }), ); it.effect("can hide indentation churn when changes wrap existing lines", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); const checkpointStore = yield* CheckpointStore.CheckpointStore; const threadId = ThreadId.make("thread-checkpoint-store-whitespace"); const fromCheckpointRef = checkpointRefForThreadTurn(threadId, 0); const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); const componentPath = NodePath.join(tmp, "Component.tsx"); yield* writeTextFile( componentPath, [ "export function View() {", " return (", "
", "

Title

", "

Body

", "
", " );", "}", "", ].join("\n"), ); yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: fromCheckpointRef, }); yield* writeTextFile( componentPath, [ "export function View() {", " return (", "
", " {isReady ? (", "
", "

Title

", "

Body

", "
", " ) : null}", "
", " );", "}", "", ].join("\n"), ); yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: toCheckpointRef, }); const normalDiff = yield* checkpointStore.diffCheckpoints({ cwd: tmp, fromCheckpointRef, toCheckpointRef, ignoreWhitespace: false, }); const whitespaceIgnoredDiff = yield* checkpointStore.diffCheckpoints({ cwd: tmp, fromCheckpointRef, toCheckpointRef, ignoreWhitespace: true, }); expect(normalDiff).toContain("diff --git"); expect(normalDiff).toContain("-

Title

"); expect(normalDiff).toContain("+

Title

"); expect(whitespaceIgnoredDiff).toContain("diff --git"); expect(whitespaceIgnoredDiff).toContain("+ {isReady ? ("); expect(whitespaceIgnoredDiff).toContain("+
"); expect(whitespaceIgnoredDiff).not.toContain("-

Title

"); expect(whitespaceIgnoredDiff).not.toContain("+

Title

"); for (const ignoreWhitespace of [false, true]) { const numstat = yield* checkpointStore.diffCheckpoints({ cwd: tmp, fromCheckpointRef, toCheckpointRef, ignoreWhitespace, format: "numstat", }); expect(parseTurnDiffFilesFromNumstat(numstat)).toEqual([ { path: "Component.tsx", additions: ignoreWhitespace ? 4 : 6, deletions: ignoreWhitespace ? 0 : 2, }, ]); } }), ); }); describe("checkpoint file summaries", () => { it.effect("counts changes whose full patch exceeds the output limit", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); const checkpointStore = yield* CheckpointStore.CheckpointStore; const threadId = ThreadId.make("large-checkpoint-summary"); const fromCheckpointRef = checkpointRefForThreadTurn(threadId, 0); const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); const filePath = NodePath.join(tmp, "README.md"); const lineCount = 20_000; yield* writeTextFile(filePath, `${"before".repeat(50)}\n`.repeat(lineCount)); yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: fromCheckpointRef }); yield* writeTextFile(filePath, `${"after".repeat(60)}\n`.repeat(lineCount)); yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: toCheckpointRef }); const numstat = yield* checkpointStore.diffCheckpoints({ cwd: tmp, fromCheckpointRef, toCheckpointRef, ignoreWhitespace: false, format: "numstat", }); expect(parseTurnDiffFilesFromNumstat(numstat)).toEqual([ { path: "README.md", additions: lineCount, deletions: lineCount }, ]); expect(numstat.length).toBeLessThan(100); }), ); it.effect("preserves file paths and turn ranges without changing the user index", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); yield* git(tmp, ["config", "diff.renames", "copies"]); const fileSystem = yield* FileSystem.FileSystem; const checkpointStore = yield* CheckpointStore.CheckpointStore; const threadId = ThreadId.make("checkpoint-summary-paths"); const baseline = checkpointRefForThreadTurn(threadId, 0); const firstTurn = checkpointRefForThreadTurn(threadId, 1); const secondTurn = checkpointRefForThreadTurn(threadId, 2); const copiedText = Array.from({ length: 20 }, (_, index) => `copy line ${index}\n`).join( "", ); const platform = yield* HostProcessPlatform; const renamedPath = platform === "win32" ? "renamed café.txt" : "renamed\tcafé\nname.txt"; const addedPath = platform === "win32" ? "new café.txt" : "new\tfile\n名.txt"; for (const [path, contents] of Object.entries({ "copy-source.txt": copiedText, "deleted.txt": "delete me\n", "rename-old.txt": "before\nkeep one\nkeep two\nkeep three\n", "binary.bin": "\0before", })) { yield* writeTextFile(NodePath.join(tmp, path), contents); } yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: baseline }); yield* fileSystem.rename( NodePath.join(tmp, "rename-old.txt"), NodePath.join(tmp, renamedPath), ); yield* fileSystem.remove(NodePath.join(tmp, "deleted.txt")); for (const [path, contents] of Object.entries({ "copy-source.txt": `${copiedText}one more\n`, "copied.txt": copiedText, [renamedPath]: "after\nkeep one\nkeep two\nkeep three\n", "binary.bin": "\0after", "empty.txt": "", [addedPath]: "first\nsecond\n", })) { yield* writeTextFile(NodePath.join(tmp, path), contents); } yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: firstTurn }); const userIndex = yield* fileSystem.readFile(NodePath.join(tmp, ".git/index")); const input = { cwd: tmp, fromCheckpointRef: baseline, toCheckpointRef: firstTurn, ignoreWhitespace: false, format: "numstat" as const, }; const firstSummary = parseTurnDiffFilesFromNumstat( yield* checkpointStore.diffCheckpoints(input), ); const expectedFiles = [ { path: "binary.bin", additions: 0, deletions: 0 }, { path: "copied.txt", additions: 0, deletions: 0 }, { path: "copy-source.txt", additions: 1, deletions: 0 }, { path: "deleted.txt", additions: 0, deletions: 1 }, { path: "empty.txt", additions: 0, deletions: 0 }, { path: addedPath, additions: 2, deletions: 0 }, { path: renamedPath, additions: 1, deletions: 1 }, ].toSorted((left, right) => left.path.localeCompare(right.path)); expect(firstSummary).toEqual(expectedFiles); yield* fileSystem.remove(NodePath.join(tmp, "empty.txt")); yield* writeTextFile(NodePath.join(tmp, "copy-source.txt"), "replacement\n"); yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: secondTurn }); const secondSummary = parseTurnDiffFilesFromNumstat( yield* checkpointStore.diffCheckpoints({ ...input, fromCheckpointRef: firstTurn, toCheckpointRef: secondTurn, }), ); expect(secondSummary).toEqual([ { path: "copy-source.txt", additions: 1, deletions: 21 }, { path: "empty.txt", additions: 0, deletions: 0 }, ]); const inclusiveSummary = parseTurnDiffFilesFromNumstat( yield* checkpointStore.diffCheckpoints({ ...input, toCheckpointRef: secondTurn }), ); expect(inclusiveSummary).toEqual( expectedFiles .filter((file) => file.path !== "empty.txt") .map((file) => file.path === "copy-source.txt" ? { ...file, additions: 1, deletions: 20 } : file, ), ); expect( yield* checkpointStore.diffCheckpoints({ ...input, toCheckpointRef: baseline }), ).toBe(""); expect(yield* fileSystem.readFile(NodePath.join(tmp, ".git/index"))).toEqual(userIndex); }), ); it.effect("uses HEAD for a missing baseline only when requested", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); const checkpointStore = yield* CheckpointStore.CheckpointStore; const threadId = ThreadId.make("checkpoint-summary-fallback"); const fromCheckpointRef = checkpointRefForThreadTurn(threadId, 0); const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1); yield* writeTextFile(NodePath.join(tmp, "README.md"), "changed\n"); yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: toCheckpointRef }); const input = { cwd: tmp, fromCheckpointRef, toCheckpointRef, ignoreWhitespace: false, format: "numstat" as const, }; const error = yield* Effect.flip(checkpointStore.diffCheckpoints(input)); expect(error._tag).toBe("VcsProcessExitError"); const numstat = yield* checkpointStore.diffCheckpoints({ ...input, fallbackFromToHead: true, }); expect(parseTurnDiffFilesFromNumstat(numstat)).toEqual([ { path: "README.md", additions: 1, deletions: 1 }, ]); }), ); }); });