Skip to content
Source
Sign inSign up

worktreeCheckoutCache.test.ts

344 lines15.4 KB

1// @effect-diagnostics nodeBuiltinImport:off - Fixtures drive git and cp directly and read raw stat data.
2import * as NodeChildProcess from "node:child_process";
3import * as NodeFS from "node:fs";
4import * as NodePath from "node:path";
5​
6import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
7import * as NodeServices from "@effect/platform-node/NodeServices";
8import * as HostProcess from "@t3tools/shared/HostProcess";
9import { assert, describe, it } from "@effect/vitest";
10import { symlinksSupported } from "@t3tools/shared/testing/symlinks";
11import * as Effect from "effect/Effect";
12import * as FileSystem from "effect/FileSystem";
13import * as Layer from "effect/Layer";
14import { ChildProcess, ChildProcessSpawner } from "effect/process";
15​
16import * as ServerConfig from "../config.ts";
17import * as CheckoutCloner from "./CheckoutCloner.ts";
18import { makeGitVcsDriverCore } from "./GitVcsDriverCore.ts";
19import { makeWorktreeCheckoutCache } from "./worktreeCheckoutCache.ts";
20​
21// Tests close their own scope first, so background cache builds finish before the temp folder goes.
22const layerTest = ServerConfig.layerTest(process.cwd(), {
23 prefix: "t3-worktree-checkout-cache-test-",
24}).pipe(Layer.provideMerge(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer)));
25​
26// macOS clones for real; elsewhere `cp -a` stands in, keeping mtimes like a clone does.
27const testCloner = Effect.gen(function* () {
28 const native = yield* CheckoutCloner.CheckoutCloner.pipe(Effect.provide(CheckoutCloner.layer));
29 return (
30 native ??
31 ((source: string, destination: string) =>
32 Effect.try({
33 try: () => void NodeChildProcess.execFileSync("cp", ["-a", source, destination]),
34 catch: (cause) => new CheckoutCloner.CheckoutCloneError({ source, destination, cause }),
35 }))
36 );
37});
38​
39const sh = (cwd: string, args: ReadonlyArray<string>) =>
40 NodeChildProcess.execFileSync("git", args, {
41 cwd,
42 encoding: "utf8",
43 env: { ...process.env, GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_NOSYSTEM: "1" },
44 }).trim();
45​
46const setup = Effect.gen(function* () {
47 const fileSystem = yield* FileSystem.FileSystem;
48 const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "worktree-checkout-cache-" });
49 const repo = NodePath.join(root, "repo");
50 NodeFS.mkdirSync(repo);
51 sh(repo, ["init", "-q", "-b", "main"]);
52 sh(repo, ["config", "user.name", "Test"]);
53 sh(repo, ["config", "user.email", "test@test.com"]);
54 sh(repo, ["config", "filter.probe.smudge", "cat"]);
55 sh(repo, ["config", "filter.probe.clean", "cat"]);
56 NodeFS.writeFileSync(
57 NodePath.join(repo, ".gitattributes"),
58 "*.bat text eol=crlf\n*.gen filter=probe\n",
59 );
60 NodeFS.writeFileSync(NodePath.join(repo, "README.md"), "# test\n");
61 NodeFS.writeFileSync(NodePath.join(repo, "run.bat"), "echo hi\r\n");
62 NodeFS.mkdirSync(NodePath.join(repo, "src"));
63 NodeFS.writeFileSync(NodePath.join(repo, "src", "tool.sh"), "#!/bin/sh\n", { mode: 0o755 });
64 NodeFS.symlinkSync("README.md", NodePath.join(repo, "readme-link"));
65 sh(repo, ["add", "."]);
66 sh(repo, ["commit", "-qm", "initial"]);
67​
68 const spawned: Array<ReadonlyArray<string>> = [];
69 const delegate = yield* ChildProcessSpawner.ChildProcessSpawner;
70 const spawner = ChildProcessSpawner.make((command) => {
71 if (ChildProcess.isStandardCommand(command)) spawned.push(command.args.map(String));
72 return delegate.spawn(command);
73 });
74 const clone = yield* testCloner;
75 const provide = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
76 effect.pipe(
77 Effect.provideService(CheckoutCloner.CheckoutCloner, clone),
78 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
79 Effect.provideService(HostProcess.Environment, {
80 ...process.env,
81 GIT_CONFIG_GLOBAL: "/dev/null",
82 GIT_CONFIG_NOSYSTEM: "1",
83 }),
84 );
85 const driver = yield* provide(makeGitVcsDriverCore());
86 const { worktreeCheckoutCacheDir } = yield* ServerConfig.ServerConfig;
87 const cache = (yield* provide(
88 makeWorktreeCheckoutCache({ cacheDir: worktreeCheckoutCacheDir, execute: driver.execute }),
89 ))!;
90 const create = (name: string, refName = "main", fastCheckout = true) =>
91 provide(
92 driver.createWorktree(
93 { cwd: repo, refName, newRefName: name, path: NodePath.join(root, name) },
94 { fastCheckout },
95 ),
96 ).pipe(Effect.map(({ worktree }) => worktree.path));
97 // Whether the last creation of `name` checked out with Git rather than cloning.
98 const checkedOutWithGit = (name: string) =>
99 spawned.some(
100 (args) =>
101 args.includes("add") &&
102 args.includes(NodePath.join(root, name)) &&
103 !args.includes("--no-checkout"),
104 );
105 return {
106 root,
107 repo,
108 create,
109 checkedOutWithGit,
110 cacheDir: worktreeCheckoutCacheDir,
111 build: (worktreePath: string) => provide(cache.build(worktreePath, 0)),
112 };
113});
114​
115// Every tracked file and its type, mode and contents, so two checkouts can be compared.
116const snapshot = (worktreePath: string) =>
117 sh(worktreePath, ["ls-files", "-z"])
118 .split("\0")
119 .filter(Boolean)
120 .map((name) => {
121 const file = NodePath.join(worktreePath, name);
122 const stat = NodeFS.lstatSync(file);
123 return [
124 name,
125 stat.mode,
126 stat.isSymbolicLink() ? NodeFS.readlinkSync(file) : NodeFS.readFileSync(file, "utf8"),
127 ];
128 });
129​
130// Git's recorded stat data matches each file, so status will not re-read them.
131const indexMatchesFiles = (worktreePath: string) => {
132 const debug = sh(worktreePath, ["ls-files", "--debug"]);
133 for (const block of debug.split(/\n(?=\S)/)) {
134 const name = block.split("\n")[0]!;
135 const ino = Number(block.match(/ino: (\d+)/)![1]);
136 const size = Number(block.match(/size: (\d+)/)![1]);
137 const stat = NodeFS.lstatSync(NodePath.join(worktreePath, name), { bigint: true });
138 if (ino !== Number(stat.ino & 0xffff_ffffn) || size !== Number(stat.size)) return false;
139 }
140 return true;
141};
142​
143// The feature is macOS-only; the fixtures need symlinks.
144describe.skipIf(!symlinksSupported)("worktree checkout cache", () => {
145 it.effect("clones a cached checkout that matches what git worktree add writes", () =>
146 Effect.gen(function* () {
147 const fixture = yield* setup;
148 const stock = yield* fixture.create("stock", "main", false);
149 yield* fixture.build(stock);
150​
151 const fast = yield* fixture.create("fast");
152 assert.isFalse(fixture.checkedOutWithGit("fast"));
153 assert.deepStrictEqual(snapshot(fast), snapshot(stock));
154 assert.equal(sh(fast, ["status", "--porcelain"]), "");
155 assert.equal(sh(fast, ["rev-parse", "--abbrev-ref", "HEAD"]), "fast");
156 assert.isTrue(indexMatchesFiles(fast));
157 // The clone has its own files: editing one leaves the cache and the other worktree alone.
158 NodeFS.writeFileSync(NodePath.join(fast, "README.md"), "changed\n");
159 assert.equal(sh(stock, ["status", "--porcelain"]), "");
160 }).pipe(Effect.scoped, Effect.provide(layerTest)),
161 );
162​
163 it.effect("moves an older cached checkout to the requested commit", () =>
164 Effect.gen(function* () {
165 const fixture = yield* setup;
166 yield* fixture.build(yield* fixture.create("base", "main", false));
167 NodeFS.writeFileSync(NodePath.join(fixture.repo, "README.md"), "# newer\n");
168 NodeFS.writeFileSync(NodePath.join(fixture.repo, "added.txt"), "new file\n");
169 NodeFS.rmSync(NodePath.join(fixture.repo, "run.bat"));
170 sh(fixture.repo, ["add", "-A"]);
171 sh(fixture.repo, ["commit", "-qm", "newer"]);
172​
173 const fast = yield* fixture.create("fast");
174 assert.isFalse(fixture.checkedOutWithGit("fast"));
175 const stock = yield* fixture.create("stock", "main", false);
176 assert.deepStrictEqual(snapshot(fast), snapshot(stock));
177 assert.equal(sh(fast, ["status", "--porcelain"]), "");
178 }).pipe(Effect.scoped, Effect.provide(layerTest)),
179 );
180​
181 it.effect("caches a far newer commit by moving the older cached checkout", () =>
182 Effect.gen(function* () {
183 const fixture = yield* setup;
184 yield* fixture.build(yield* fixture.create("base", "main", false));
185 NodeFS.mkdirSync(NodePath.join(fixture.repo, "many"));
186 for (let index = 0; index < 600; index++) {
187 NodeFS.writeFileSync(NodePath.join(fixture.repo, "many", `${index}.txt`), `${index}\n`);
188 }
189 NodeFS.writeFileSync(NodePath.join(fixture.repo, "README.md"), "# newer\n");
190 sh(fixture.repo, ["add", "-A"]);
191 sh(fixture.repo, ["commit", "-qm", "many files"]);
192 const stock = yield* fixture.create("stock", "main", false);
193 yield* fixture.build(stock);
194​
195 const fast = yield* fixture.create("fast");
196 assert.isFalse(fixture.checkedOutWithGit("fast"));
197 assert.deepStrictEqual(snapshot(fast), snapshot(stock));
198 assert.equal(sh(fast, ["status", "--porcelain"]), "");
199 assert.isTrue(indexMatchesFiles(fast));
200 }).pipe(Effect.scoped, Effect.provide(layerTest)),
201 );
202​
203 it.effect("runs the post-checkout hook as git worktree add would", () =>
204 Effect.gen(function* () {
205 const fixture = yield* setup;
206 yield* fixture.build(yield* fixture.create("base", "main", false));
207 const marker = NodePath.join(fixture.root, "hook-args");
208 const hook = NodePath.join(fixture.repo, ".git", "hooks", "post-checkout");
209 NodeFS.writeFileSync(hook, `#!/bin/sh\necho "$@" > "${marker}"\n`, { mode: 0o755 });
210​
211 yield* fixture.create("fast");
212 assert.isFalse(fixture.checkedOutWithGit("fast"));
213 const commit = sh(fixture.repo, ["rev-parse", "main"]);
214 assert.equal(NodeFS.readFileSync(marker, "utf8").trim(), `${"0".repeat(40)} ${commit} 1`);
215 }).pipe(Effect.scoped, Effect.provide(layerTest)),
216 );
217​
218 it.effect("checks out with Git when the checkout adds a post-index-change hook", () =>
219 Effect.gen(function* () {
220 const fixture = yield* setup;
221 const marker = NodePath.join(fixture.root, "index-hook-runs");
222 NodeFS.mkdirSync(NodePath.join(fixture.repo, ".hooks"));
223 const hook = NodePath.join(fixture.repo, ".hooks", "post-index-change");
224 NodeFS.writeFileSync(hook, `#!/bin/sh\necho ran >> "${marker}"\n`);
225 sh(fixture.repo, ["add", "--chmod=+x", hook]);
226 sh(fixture.repo, ["commit", "-qm", "tracked hooks"]);
227 yield* fixture.build(yield* fixture.create("base", "main", false));
228 sh(fixture.repo, ["config", "core.hooksPath", ".hooks"]);
229 NodeFS.rmSync(marker, { force: true });
230​
231 yield* fixture.create("fallback");
232 assert.isTrue(fixture.checkedOutWithGit("fallback"));
233 assert.isTrue(NodeFS.existsSync(marker));
234 }).pipe(Effect.scoped, Effect.provide(layerTest)),
235 );
236​
237 it.effect("clones the branch Git checks out when a tag has the same name", () =>
238 Effect.gen(function* () {
239 const fixture = yield* setup;
240 sh(fixture.repo, ["tag", "fast"]);
241 NodeFS.writeFileSync(NodePath.join(fixture.repo, "README.md"), "# newer\n");
242 sh(fixture.repo, ["commit", "-qam", "newer"]);
243 const stock = yield* fixture.create("stock", "main", false);
244 yield* fixture.build(stock);
245​
246 const fast = yield* fixture.create("fast");
247 assert.isFalse(fixture.checkedOutWithGit("fast"));
248 assert.deepStrictEqual(snapshot(fast), snapshot(stock));
249 assert.equal(sh(fast, ["status", "--porcelain"]), "");
250 }).pipe(Effect.scoped, Effect.provide(layerTest)),
251 );
252​
253 it.effect("does not cache checkouts whose attributes name a filter", () =>
254 Effect.gen(function* () {
255 const fixture = yield* setup;
256 NodeFS.writeFileSync(NodePath.join(fixture.repo, ".gitattributes"), "*.bin filter=lfs\n");
257 NodeFS.writeFileSync(NodePath.join(fixture.repo, "asset.bin"), "pointer\n");
258 sh(fixture.repo, ["add", "-A"]);
259 sh(fixture.repo, ["commit", "-qm", "lfs"]);
260 yield* fixture.build(yield* fixture.create("stock", "main", false));
261 yield* fixture.create("next");
262 assert.isTrue(fixture.checkedOutWithGit("next"));
263 }).pipe(Effect.scoped, Effect.provide(layerTest)),
264 );
265​
266 const cacheEntry = (fixture: { cacheDir: string }) => {
267 const [bucket] = NodeFS.readdirSync(fixture.cacheDir);
268 const [entry] = NodeFS.readdirSync(NodePath.join(fixture.cacheDir, bucket!));
269 return NodePath.join(fixture.cacheDir, bucket!, entry!, "files");
270 };
271​
272 it.effect.each([
273 {
274 name: "a cached file changed",
275 change: (fixture: { cacheDir: string; repo: string; root: string }) =>
276 NodeFS.writeFileSync(NodePath.join(cacheEntry(fixture), "README.md"), "tampered with\n"),
277 },
278 {
279 name: "a file was added to the cache",
280 change: (fixture: { cacheDir: string; repo: string; root: string }) =>
281 NodeFS.writeFileSync(NodePath.join(cacheEntry(fixture), "src", "extra.txt"), "extra\n"),
282 },
283 {
284 name: "the repository's checkout settings changed",
285 change: (fixture: { cacheDir: string; repo: string; root: string }) =>
286 sh(fixture.repo, ["config", "core.autocrlf", "true"]),
287 },
288 {
289 // Moving the cached checkout forward would keep README.md's old line endings.
290 name: "attributes changed since the cached commit",
291 change: (fixture: { cacheDir: string; repo: string; root: string }) => {
292 NodeFS.appendFileSync(
293 NodePath.join(fixture.repo, ".gitattributes"),
294 "*.md text eol=crlf\n",
295 );
296 sh(fixture.repo, ["commit", "-qam", "crlf markdown"]);
297 },
298 },
299 {
300 // On a case-insensitive volume the new file would land in the cached `src` folder.
301 name: "a new folder's name differs only in case",
302 change: (fixture: { cacheDir: string; repo: string; root: string }) => {
303 // Plumbing, because a case-insensitive volume cannot hold both folders.
304 NodeFS.writeFileSync(NodePath.join(fixture.root, "b.txt"), "b\n");
305 const blob = sh(fixture.repo, ["hash-object", "-w", NodePath.join(fixture.root, "b.txt")]);
306 sh(fixture.repo, ["update-index", "--add", "--cacheinfo", `100644,${blob},Src/b.txt`]);
307 sh(fixture.repo, ["commit", "-qm", "case folder"]);
308 },
309 },
310 {
311 // Git would run this hook during its own checkout, with its own environment.
312 name: "the move adds a post-index-change hook",
313 change: (fixture: { cacheDir: string; repo: string; root: string }) => {
314 const hook = NodePath.join(fixture.repo, ".hooks", "post-index-change");
315 NodeFS.mkdirSync(NodePath.dirname(hook));
316 NodeFS.writeFileSync(hook, "#!/bin/sh\n");
317 sh(fixture.repo, ["add", "--chmod=+x", hook]);
318 sh(fixture.repo, ["commit", "-qm", "hook"]);
319 sh(fixture.repo, ["config", "core.hooksPath", ".hooks"]);
320 },
321 },
322 {
323 // The cached tree has the rule but no matching file; the move would run the filter itself.
324 name: "the move adds a file that uses a filter",
325 change: (fixture: { cacheDir: string; repo: string; root: string }) => {
326 NodeFS.writeFileSync(NodePath.join(fixture.repo, "asset.gen"), "generated\n");
327 sh(fixture.repo, ["add", "-A"]);
328 sh(fixture.repo, ["commit", "-qm", "generated asset"]);
329 },
330 },
331 ])("checks out with Git when $name", ({ change }) =>
332 Effect.gen(function* () {
333 const fixture = yield* setup;
334 yield* fixture.build(yield* fixture.create("base", "main", false));
335 change(fixture);
336​
337 const created = yield* fixture.create("fallback");
338 assert.isTrue(fixture.checkedOutWithGit("fallback"));
339 const stock = yield* fixture.create("stock", "main", false);
340 assert.deepStrictEqual(snapshot(created), snapshot(stock));
341 assert.equal(sh(created, ["status", "--porcelain"]), "");
342 }).pipe(Effect.scoped, Effect.provide(layerTest)),
343 );
344});