Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
369 changes: 368 additions & 1 deletion apps/server/src/checkpointing/CheckpointStore.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
// @effect-diagnostics nodeBuiltinImport:off
import * as NodeFS from "node:fs";
import * as NodePath from "node:path";

import * as NodeServices from "@effect/platform-node/NodeServices";
Expand All @@ -13,7 +14,7 @@ import * as Scope from "effect/Scope";
import { describe, expect } from "vite-plus/test";

import { checkpointRefForThreadTurn } from "./Utils.ts";
import { parseTurnDiffFilesFromNumstat } from "./Diffs.ts";
import { isGitImport, parseTurnDiffFilesFromNumstat } from "./Diffs.ts";
import * as CheckpointStore from "./CheckpointStore.ts";
import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
import * as VcsProcess from "../vcs/VcsProcess.ts";
Expand All @@ -28,6 +29,43 @@ const layerCheckpointStoreTest = CheckpointStore.layer.pipe(
Layer.provideMerge(layerVcsDriverTest),
Layer.provideMerge(NodeServices.layer),
);
// A read may return fewer bytes than asked for. "short" returns at most 160 bytes per read, and
// "stop" also ends the file after its first read, as a truncation racing the read would.
let reflogReadFault: "short" | "stop" | "none" = "none";
const layerShortReadFileSystem = Layer.effect(
FileSystem.FileSystem,
Effect.map(FileSystem.FileSystem, (fileSystem) =>
FileSystem.FileSystem.of({
...fileSystem,
open: (path, options) =>
Effect.map(fileSystem.open(path, options), (file) => {
let reads = 0;
return new Proxy(file, {
get: (target, key) => {
if (key === "readAlloc") {
return (size: number) =>
reflogReadFault === "stop" && reads++ > 0
? Effect.succeedNone
: target.readAlloc(reflogReadFault === "none" ? size : Math.min(size, 160));
}
const value = Reflect.get(target, key, target);
return typeof value === "function" ? value.bind(target) : value;
},
});
}),
}),
),
).pipe(Layer.provide(NodeServices.layer));
const layerShortReadCheckpointStore = Layer.fresh(CheckpointStore.layer).pipe(
Layer.provide(
Layer.fresh(VcsDriverRegistry.layer).pipe(
Layer.provide(layerVcsProcessTest),
Layer.provide(layerShortReadFileSystem),
Layer.provide(NodeServices.layer),
),
),
Layer.provide(NodeServices.layer),
);
const layerTest = layerCheckpointStoreTest.pipe(
Layer.provideMerge(layerVcsProcessTest),
Layer.provideMerge(layerVcsDriverTest),
Expand Down Expand Up @@ -510,5 +548,334 @@ it.layer(layerTest)("CheckpointStore.layer", (it) => {
);
}),
);

// A turn that switches branches starts from the branch it switched to, while a reset or
// rename keeps the branch it had.
const captureTurnAcrossHeadMove = (
threadId: string,
moveHead: (
cwd: string,
) => Effect.Effect<
void,
VcsError | PlatformError.PlatformError,
VcsProcess.VcsProcess | FileSystem.FileSystem
>,
) =>
Effect.gen(function* () {
const tmp = yield* makeTmpDir();
yield* initRepoWithCommit(tmp);
const checkpointStore = yield* CheckpointStore.CheckpointStore;
const id = ThreadId.make(threadId);
const refs = {
cwd: tmp,
fromCheckpointRef: checkpointRefForThreadTurn(id, 1),
toCheckpointRef: checkpointRefForThreadTurn(id, 2),
};
yield* writeTextFile(NodePath.join(tmp, "shared.txt"), "base\n");
yield* git(tmp, ["add", "."]);
yield* git(tmp, ["commit", "-m", "base"]);
yield* git(tmp, ["branch", "branch-b"]);
yield* git(tmp, ["checkout", "-b", "branch-a"]);
yield* writeTextFile(NodePath.join(tmp, "shared.txt"), "A content\n");
yield* writeTextFile(NodePath.join(tmp, "a.txt"), "A only\n");
yield* git(tmp, ["add", "."]);
yield* git(tmp, ["commit", "-m", "branch A change"]);
yield* checkpointStore.captureCheckpoint({
cwd: tmp,
checkpointRef: refs.fromCheckpointRef,
});

yield* moveHead(tmp);
yield* checkpointStore.captureCheckpoint({
cwd: tmp,
checkpointRef: refs.toCheckpointRef,
previousCheckpointRef: refs.fromCheckpointRef,
});

const patch = yield* checkpointStore.diffCheckpoints({ ...refs, ignoreWhitespace: false });
const numstat = yield* checkpointStore.diffCheckpoints({
...refs,
ignoreWhitespace: false,
format: "numstat",
});
const authoredPaths = yield* checkpointStore.listAuthoredPaths(refs);
return {
authoredPaths,
changedLines: patch
.split("\n")
.filter((line) => /^[-+]/.test(line) && !/^(---|\+\+\+) /.test(line)),
files: parseTurnDiffFilesFromNumstat(numstat).filter(
(file) => !isGitImport(file, authoredPaths),
),
};
});

it.effect.each([
["switch", "branch-b"],
["switch", "--detach", "branch-b"],
])("diffs from the destination after git %s", (switchArgs) =>
Effect.gen(function* () {
const turn = yield* captureTurnAcrossHeadMove("checkpoint-switch", (cwd) =>
Effect.gen(function* () {
yield* git(cwd, switchArgs);
yield* writeTextFile(NodePath.join(cwd, "shared.txt"), "B edit\n");
}),
);

expect(turn.changedLines).toEqual(["-base", "+B edit"]);
expect(turn.files).toEqual([{ path: "shared.txt", additions: 1, deletions: 1 }]);
expect(turn.authoredPaths).toEqual(new Set(["a.txt", "shared.txt"]));
}),
);

it.effect("keeps a reset's deletions when the turn then switches branches", () =>
Effect.gen(function* () {
const turn = yield* captureTurnAcrossHeadMove("checkpoint-reset-switch", (cwd) =>
Effect.gen(function* () {
yield* git(cwd, ["reset", "--hard", "HEAD~1"]);
yield* git(cwd, ["switch", "branch-b"]);
yield* writeTextFile(NodePath.join(cwd, "b.txt"), "B only\n");
}),
);

expect(turn.changedLines).toEqual(["-A only", "+B only", "-A content", "+base"]);
expect(turn.files).toEqual([
{ path: "a.txt", additions: 0, deletions: 1 },
{ path: "b.txt", additions: 1, deletions: 0 },
{ path: "shared.txt", additions: 1, deletions: 1 },
]);
}),
);

it.effect("does not replay a switch that an earlier checkpoint already followed", () =>
Effect.gen(function* () {
const tmp = yield* makeTmpDir();
yield* initRepoWithCommit(tmp);
const checkpointStore = yield* CheckpointStore.CheckpointStore;
const id = ThreadId.make("checkpoint-switch-then-reset");
const [first, second, third] = [1, 2, 3].map((turn) =>
checkpointRefForThreadTurn(id, turn),
);
yield* git(tmp, ["branch", "branch-b"]);
yield* git(tmp, ["checkout", "-b", "branch-a"]);
yield* writeTextFile(NodePath.join(tmp, "a.txt"), "A only\n");
yield* git(tmp, ["add", "."]);
yield* git(tmp, ["commit", "-m", "branch A change"]);
yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: first! });
yield* git(tmp, ["switch", "branch-b"]);
yield* writeTextFile(NodePath.join(tmp, "b.txt"), "B only\n");
yield* git(tmp, ["add", "."]);
yield* git(tmp, ["commit", "-m", "branch B change"]);
yield* checkpointStore.captureCheckpoint({
cwd: tmp,
checkpointRef: second!,
previousCheckpointRef: first!,
});

yield* git(tmp, ["reset", "--hard", "HEAD~1"]);
yield* checkpointStore.captureCheckpoint({
cwd: tmp,
checkpointRef: third!,
previousCheckpointRef: second!,
});

const refs = { cwd: tmp, fromCheckpointRef: second!, toCheckpointRef: third! };
const numstat = yield* checkpointStore.diffCheckpoints({
...refs,
ignoreWhitespace: false,
format: "numstat",
});
const authoredPaths = yield* checkpointStore.listAuthoredPaths(refs);
expect(
parseTurnDiffFilesFromNumstat(numstat).filter(
(file) => !isGitImport(file, authoredPaths),
),
).toEqual([{ path: "b.txt", additions: 0, deletions: 1 }]);
}),
);

it.effect("keeps a reset's deletions when the turn renames its branch", () =>
Effect.gen(function* () {
const turn = yield* captureTurnAcrossHeadMove("checkpoint-rename-reset", (cwd) =>
Effect.gen(function* () {
yield* git(cwd, ["branch", "-m", "renamed"]);
yield* git(cwd, ["reset", "--hard", "HEAD~1"]);
}),
);

expect(turn.changedLines).toEqual(["-A only", "-A content", "+base"]);
expect(turn.files).toEqual([
{ path: "a.txt", additions: 0, deletions: 1 },
{ path: "shared.txt", additions: 1, deletions: 1 },
]);
}),
);

it.effect("follows a switch read from the reflog in short reads", () =>
Effect.gen(function* () {
const tmp = yield* makeTmpDir();
yield* initRepoWithCommit(tmp);
const checkpointStore = yield* CheckpointStore.CheckpointStore.pipe(
Effect.provide(layerShortReadCheckpointStore),
);
reflogReadFault = "short";
const id = ThreadId.make("checkpoint-switch-short-read");
const [first, second, third] = [1, 2, 3].map((turn) =>
checkpointRefForThreadTurn(id, turn),
);
yield* writeTextFile(NodePath.join(tmp, "shared.txt"), "base\n");
yield* git(tmp, ["add", "."]);
yield* git(tmp, ["commit", "-m", "base"]);
yield* git(tmp, ["branch", "branch-b"]);
yield* git(tmp, ["checkout", "-b", "branch-a"]);
yield* writeTextFile(NodePath.join(tmp, "shared.txt"), "A content\n");
yield* git(tmp, ["commit", "-am", "branch A change"]);
yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: first! });

yield* git(tmp, ["switch", "branch-b"]);
yield* writeTextFile(NodePath.join(tmp, "shared.txt"), "B edit\n");
yield* checkpointStore.captureCheckpoint({
cwd: tmp,
checkpointRef: second!,
previousCheckpointRef: first!,
});

yield* git(tmp, ["reset", "--hard", "HEAD"]);
yield* checkpointStore.captureCheckpoint({
cwd: tmp,
checkpointRef: third!,
previousCheckpointRef: second!,
});

const patch = (fromCheckpointRef: typeof first, toCheckpointRef: typeof first) =>
checkpointStore
.diffCheckpoints({
cwd: tmp,
fromCheckpointRef: fromCheckpointRef!,
toCheckpointRef: toCheckpointRef!,
ignoreWhitespace: false,
})
.pipe(
Effect.map((diff) =>
diff
.split("\n")
.filter((line) => /^[-+]/.test(line) && !/^(---|\+\+\+) /.test(line)),
),
);
expect(yield* patch(first, second)).toEqual(["-base", "+B edit"]);
expect(yield* patch(second, third)).toEqual(["-B edit", "+base"]);
}),
);

it.effect("publishes no reflog cursor from a read that ends early", () =>
Effect.gen(function* () {
const tmp = yield* makeTmpDir();
yield* initRepoWithCommit(tmp);
const checkpointStore = yield* CheckpointStore.CheckpointStore.pipe(
Effect.provide(layerShortReadCheckpointStore),
);
reflogReadFault = "none";
const id = ThreadId.make("checkpoint-switch-read-ends-early");
const [first, second, third] = [1, 2, 3].map((turn) =>
checkpointRefForThreadTurn(id, turn),
);
yield* writeTextFile(NodePath.join(tmp, "shared.txt"), "base\n");
yield* git(tmp, ["add", "."]);
yield* git(tmp, ["commit", "-m", "base"]);
yield* git(tmp, ["branch", "branch-b"]);
yield* git(tmp, ["checkout", "-b", "branch-a"]);
yield* writeTextFile(NodePath.join(tmp, "shared.txt"), "A content\n");
yield* git(tmp, ["commit", "-am", "branch A change"]);
yield* checkpointStore.captureCheckpoint({ cwd: tmp, checkpointRef: first! });

yield* git(tmp, ["switch", "branch-b"]);
yield* writeTextFile(NodePath.join(tmp, "shared.txt"), "B edit\n");
reflogReadFault = "stop";
yield* checkpointStore.captureCheckpoint({
cwd: tmp,
checkpointRef: second!,
previousCheckpointRef: first!,
});

reflogReadFault = "none";
yield* git(tmp, ["reset", "--hard", "HEAD"]);
yield* checkpointStore.captureCheckpoint({
cwd: tmp,
checkpointRef: third!,
previousCheckpointRef: second!,
});

const patch = (fromCheckpointRef: typeof first, toCheckpointRef: typeof first) =>
checkpointStore
.diffCheckpoints({
cwd: tmp,
fromCheckpointRef: fromCheckpointRef!,
toCheckpointRef: toCheckpointRef!,
ignoreWhitespace: false,
})
.pipe(
Effect.map((diff) =>
diff
.split("\n")
.filter((line) => /^[-+]/.test(line) && !/^(---|\+\+\+) /.test(line)),
),
);
expect(yield* patch(first, second)).toEqual(["-A content", "+B edit"]);
expect(yield* patch(second, third)).toEqual(["-B edit", "+base"]);
}),
);

// Linux stores any bytes as a file name, unlike macOS and Windows.
it.effect.skipIf(HostProcess.Platform.defaultValue() !== "linux")(
"keeps non-UTF-8 file names that a branch switch removes or replaces",
() =>
Effect.gen(function* () {
const tmp = yield* makeTmpDir();
yield* initRepoWithCommit(tmp);
const checkpointStore = yield* CheckpointStore.CheckpointStore;
const id = ThreadId.make("checkpoint-switch-non-utf8");
const refs = {
cwd: tmp,
fromCheckpointRef: checkpointRefForThreadTurn(id, 1),
toCheckpointRef: checkpointRefForThreadTurn(id, 2),
};
const rawPath = (name: string, byte: number) =>
Buffer.concat([Buffer.from(NodePath.join(tmp, name)), Buffer.from([byte])]);
yield* writeTextFile(NodePath.join(tmp, "shared.txt"), "base\n");
NodeFS.writeFileSync(rawPath("replaced-", 0xfe), "B bytes\n");
yield* git(tmp, ["add", "."]);
yield* git(tmp, ["commit", "-m", "base"]);
yield* git(tmp, ["branch", "branch-b"]);
yield* git(tmp, ["checkout", "-b", "branch-a"]);
NodeFS.writeFileSync(rawPath("replaced-", 0xfe), "A bytes\n");
NodeFS.writeFileSync(rawPath("removed-", 0xff), "A only\n");
yield* git(tmp, ["add", "."]);
yield* git(tmp, ["commit", "-m", "branch A non-UTF-8 names"]);
yield* checkpointStore.captureCheckpoint({
cwd: tmp,
checkpointRef: refs.fromCheckpointRef,
});

yield* git(tmp, ["switch", "branch-b"]);
yield* writeTextFile(NodePath.join(tmp, "shared.txt"), "B edit\n");
yield* checkpointStore.captureCheckpoint({
cwd: tmp,
checkpointRef: refs.toCheckpointRef,
previousCheckpointRef: refs.fromCheckpointRef,
});

const numstat = yield* checkpointStore.diffCheckpoints({
...refs,
ignoreWhitespace: false,
format: "numstat",
});
const authoredPaths = yield* checkpointStore.listAuthoredPaths(refs);
expect(
parseTurnDiffFilesFromNumstat(numstat).filter(
(file) => !isGitImport(file, authoredPaths),
),
).toEqual([{ path: "shared.txt", additions: 1, deletions: 1 }]);
}),
);
});
});
Loading
Loading