1/**
2 * Tier-3 — the money suite (DESIGN.md §9 steps 1–14): the real `git` binary
3 * driven via the Effect `ChildProcess` service against a deployed
4 * git-service stack, work-trees under `FileSystem.makeTempDirectory`.
5 *
6 * Every git invocation is bounded (60s command timeout inside ≤120s tests);
7 * slow cases are `skipIf(process.env.FAST)`. `NO_DESTROY=1` keeps the
8 * deployment between local iterations.
9 */
10import * as Cloudflare from "@/Cloudflare";
11import * as Test from "@/Test/Alchemy";
12import { expect } from "alchemy-test";
13import * as Data from "effect/Data";
14import * as Effect from "effect/Effect";
15import * as FileSystem from "effect/FileSystem";
16import * as Path from "effect/Path";
17import { MinimumLogLevel } from "effect/References";
18import * as Schedule from "effect/Schedule";
19import * as Stream from "effect/Stream";
20import * as HttpClient from "effect/http/HttpClient";
21import * as HttpClientRequest from "effect/http/HttpClientRequest";
22import * as HttpApiClient from "effect/http-api/HttpApiClient";
23import * as ChildProcess from "effect/process/ChildProcess";
24import { GitApi, type Oid } from "@/Git/Api.ts";
25import { makeTestStack, TEST_SECRET } from "./fixtures/stack.ts";
26
27const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
28 providers: Cloudflare.providers(),
29});
30
31const logLevel = Effect.provideService(
32 MinimumLogLevel,
33 process.env.DEBUG ? "Debug" : "Info",
34);
35
36const Stack = makeTestStack("GitServiceE2EStack");
37
38// ── REST plumbing ───────────────────────────────────────────────────────────
39
40const makeClient = (url: string, token: string) =>
41 HttpApiClient.make(GitApi, {
42 baseUrl: url,
43 transformClient: HttpClient.mapRequest((request) =>
44 request.pipe(HttpClientRequest.bearerToken(token)),
45 ),
46 });
47
48const asOid = (oid: string): Oid => oid as Oid;
49
50const edgeRetry = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
51 effect.pipe(
52 Effect.timeout("10 seconds"),
53 Effect.retry({
54 while: (error: unknown) =>
55 (error as { _tag?: string })._tag === "TimeoutError" ||
56 ((error as { _tag?: string })._tag === "HttpClientError" &&
57 (!(error as { response?: { status: number } }).response ||
58 (error as { response: { status: number } }).response.status ===
59 404 ||
60 (error as { response: { status: number } }).response.status >=
61 500)),
62 schedule: Schedule.spaced("1500 millis"),
63 times: 40,
64 }),
65 );
66
67/** Delete-if-exists + wait until the deterministic name is free again. */
68const purgeRepo = Effect.fn(function* (
69 url: string,
70 owner: string,
71 repo: string,
72) {
73 const admin = yield* makeClient(url, TEST_SECRET);
74 // edgeRetry on every step: a freshly deployed workers.dev route serves
75 // transient 5xx/1042s for a few seconds (typed 404s decode fine and are
76 // NOT retried — they end the poll).
77 yield* admin.repos.delete({ params: { owner, repo } }).pipe(
78 Effect.catchTag("RepoNotFound", () => Effect.void),
79 edgeRetry,
80 );
81 yield* admin.repos.get({ params: { owner, repo } }).pipe(
82 edgeRetry,
83 Effect.as(false),
84 Effect.catchTag("RepoNotFound", () => Effect.succeed(true)),
85 Effect.repeat({
86 schedule: Schedule.spaced("1 second"),
87 until: (gone) => gone,
88 times: 60,
89 }),
90 );
91});
92
93// ── git CLI plumbing ────────────────────────────────────────────────────────
94
95class GitError extends Data.TaggedError("GitError")<{
96 readonly args: ReadonlyArray<string>;
97 readonly exitCode: number;
98 readonly stdout: string;
99 readonly stderr: string;
100}> {
101 override get message(): string {
102 return `git ${this.args.join(" ")} exited ${this.exitCode}\n--- stdout ---\n${this.stdout}\n--- stderr ---\n${this.stderr}`;
103 }
104}
105
106const git = Effect.fn(function* (cwd: string, ...args: Array<string>) {
107 const handle = yield* ChildProcess.make("git", args, {
108 cwd,
109 env: {
110 GIT_TERMINAL_PROMPT: "0",
111 GIT_CONFIG_NOSYSTEM: "1",
112 GIT_CONFIG_GLOBAL: "/dev/null",
113 GIT_AUTHOR_NAME: "Test User",
114 GIT_AUTHOR_EMAIL: "test@example.com",
115 GIT_COMMITTER_NAME: "Test User",
116 GIT_COMMITTER_EMAIL: "test@example.com",
117 },
118 extendEnv: true,
119 });
120 const [exitCode, stdout, stderr] = yield* Effect.all(
121 [
122 handle.exitCode,
123 Stream.mkString(Stream.decodeText(handle.stdout)),
124 Stream.mkString(Stream.decodeText(handle.stderr)),
125 ],
126 { concurrency: 3 },
127 );
128 return { args, exitCode, stdout: stdout.trim(), stderr };
129}, Effect.timeout("60 seconds"));
130
131const mustGit = Effect.fn(function* (cwd: string, ...args: Array<string>) {
132 const result = yield* git(cwd, ...args);
133 if (result.exitCode !== 0) {
134 return yield* new GitError(result);
135 }
136 return result;
137});
138
139const mustFailGit = Effect.fn(function* (cwd: string, ...args: Array<string>) {
140 const result = yield* git(cwd, ...args);
141 if (result.exitCode === 0) {
142 return yield* new GitError(result);
143 }
144 return result;
145});
146
147/** Bounded retry of a git command through transient edge/contention windows. */
148const retryGit = (cwd: string, ...args: Array<string>) =>
149 mustGit(cwd, ...args).pipe(
150 Effect.retry({
151 while: (error) =>
152 error._tag === "GitError" || error._tag === "TimeoutError",
153 schedule: Schedule.spaced("3 seconds"),
154 times: 5,
155 }),
156 );
157
158const authRemote = (url: string, token: string, owner: string, repo: string) =>
159 Effect.sync(() => {
160 const parsed = new URL(url);
161 return `${parsed.protocol}//x:${token}@${parsed.host}/${owner}/${repo}.git`;
162 });
163
164const tempDir = Effect.gen(function* () {
165 const fs = yield* FileSystem.FileSystem;
166 return yield* fs.makeTempDirectory({ prefix: "git-service-e2e-" });
167});
168
169/** Purge + create a repo, returning the admin client and an authed remote. */
170const freshRepo = Effect.fn(function* (
171 url: string,
172 owner: string,
173 name: string,
174) {
175 const admin = yield* makeClient(url, TEST_SECRET);
176 // Retry the whole purge -> create CYCLE, never the bare POST: a create
177 // that commits server-side but loses its response (edge 5xx mid-rollout)
178 // leaves the name taken, so retrying just the POST would 409 forever.
179 const created = yield* Effect.gen(function* () {
180 yield* purgeRepo(url, owner, name);
181 return yield* admin.repos
182 .create({ payload: { owner, name } })
183 .pipe(edgeRetry);
184 }).pipe(
185 Effect.retry({
186 while: (error: { readonly _tag?: string }) =>
187 error._tag === "RepoAlreadyExists",
188 schedule: Schedule.spaced("1 second"),
189 times: 3,
190 }),
191 );
192 return {
193 admin,
194 created,
195 token: TEST_SECRET,
196 remote: yield* authRemote(url, TEST_SECRET, owner, name),
197 };
198});
199
200/** Deterministic pseudo-random bytes (xorshift32) — stable across runs. */
201const deterministicBytes = (length: number, seed: number) =>
202 Effect.sync(() => {
203 const bytes = new Uint8Array(length);
204 let state = seed >>> 0 || 1;
205 for (let i = 0; i < length; i++) {
206 state ^= state << 13;
207 state ^= state >>> 17;
208 state ^= state << 5;
209 bytes[i] = state & 0xff;
210 }
211 return bytes;
212 });
213
214const stack = beforeAll(
215 deploy(Stack).pipe(
216 Effect.tap(({ url }) =>
217 Effect.gen(function* () {
218 // Printed so a failing live run can be probed by hand (curl/git).
219 yield* Effect.logInfo(`git-service deployed at ${url}`);
220 const admin = yield* makeClient(url, TEST_SECRET);
221 // Readiness probe: retry on ANY failure, not just transport errors.
222 // Right after a rollout the edge briefly serves the PREVIOUS worker
223 // version, whose responses decode against the old schema — that
224 // surfaces as a SchemaError on a 200, which `edgeRetry` ignores by
225 // design. Waiting here means "the deployed version answers the
226 // schema this test binary was built against".
227 yield* admin.repos.list({ query: {} }).pipe(
228 edgeRetry,
229 Effect.retry({
230 schedule: Schedule.spaced("1500 millis"),
231 times: 40,
232 }),
233 );
234 }),
235 ),
236 logLevel,
237 ),
238);
239afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack));
240
241// ── steps 1 + 2: empty clone, first push, REST agreement ────────────────────
242
243test(
244 "empty-repo clone, first push (exec bit, symlink, subdir), REST agreement",
245 Effect.gen(function* () {
246 const { url } = yield* stack;
247 const { admin, remote } = yield* freshRepo(url, "e2e", "proto-basic");
248 const fs = yield* FileSystem.FileSystem;
249 const path = yield* Path.Path;
250 const tmp = yield* tempDir;
251 const params = { owner: "e2e", repo: "proto-basic" };
252
253 // step 1: empty clone succeeds with the empty-repo warning
254 const clone = yield* retryGit(
255 tmp,
256 "-c",
257 "init.defaultBranch=main",
258 "clone",
259 remote,
260 "work",
261 );
262 expect(`${clone.stdout}\n${clone.stderr}`).toContain("empty repository");
263 const work = path.join(tmp, "work");
264
265 // step 2: three commits — plain file, subdir, executable + symlink
266 yield* fs.writeFileString(path.join(work, "hello.txt"), "hello e2e\n");
267 yield* mustGit(work, "add", "-A");
268 yield* mustGit(work, "commit", "-m", "c1: hello");
269 yield* fs.makeDirectory(path.join(work, "sub"), { recursive: true });
270 yield* fs.writeFileString(path.join(work, "sub", "nested.txt"), "nested\n");
271 yield* mustGit(work, "add", "-A");
272 yield* mustGit(work, "commit", "-m", "c2: subdir");
273 yield* fs.writeFileString(
274 path.join(work, "run.sh"),
275 "#!/bin/sh\necho ok\n",
276 );
277 yield* fs.chmod(path.join(work, "run.sh"), 0o755);
278 yield* fs.symlink("hello.txt", path.join(work, "link.txt"));
279 yield* mustGit(work, "add", "-A");
280 yield* mustGit(work, "commit", "-m", "c3: modes");
281 // A fresh workers.dev rollout can still return its HTML 404 on
282 // receive-pack discovery after upload-pack (the empty clone) succeeds.
283 yield* retryGit(work, "push", "origin", "main");
284 const head = (yield* mustGit(work, "rev-parse", "HEAD")).stdout;
285
286 // REST agrees byte-for-byte with the CLI's view
287 const refs = yield* admin.refs.list({ params, query: {} });
288 expect(refs.refs.find((r) => r.name === "refs/heads/main")?.oid).toBe(head);
289 const commit = yield* admin.objects.commit({
290 params: { ...params, oid: asOid(head) },
291 });
292 expect(commit.message).toContain("c3: modes");
293 const tree = yield* admin.objects.tree({
294 params: { ...params, oid: commit.tree },
295 });
296 expect(tree.entries.find((e) => e.name === "run.sh")?.mode).toBe("100755");
297 expect(tree.entries.find((e) => e.name === "link.txt")?.mode).toBe(
298 "120000",
299 );
300 expect(tree.entries.find((e) => e.name === "sub")?.type).toBe("tree");
301
302 // the /file raw route serves the working-tree bytes
303 const http = yield* HttpClient.HttpClient;
304 const file = yield* http.get(
305 `${url}/api/v1/repos/e2e/proto-basic/file?ref=refs/heads/main&path=sub/nested.txt`,
306 { headers: { authorization: `Bearer ${TEST_SECRET}` } },
307 );
308 expect(file.status).toBe(200);
309 expect(yield* file.text).toBe("nested\n");
310 }).pipe(logLevel),
311 {
312 tags: [
313 "provider:cloudflare",
314 "provider:cloudflare:r2",
315 "provider:cloudflare:worker",
316 "live",
317 ],
318 timeout: 120_000,
319 },
320);
321
322// ── steps 3 + 4: clone-back + fsck, incremental fetch ───────────────────────
323
324test(
325 "clone-back is fsck-clean; incremental fetch rides the haves/ACK path",
326 Effect.gen(function* () {
327 const { url } = yield* stack;
328 const { remote } = yield* freshRepo(url, "e2e", "proto-fetch");
329 const fs = yield* FileSystem.FileSystem;
330 const path = yield* Path.Path;
331 const tmp = yield* tempDir;
332
333 yield* retryGit(tmp, "-c", "init.defaultBranch=main", "clone", remote, "a");
334 const a = path.join(tmp, "a");
335 yield* fs.writeFileString(path.join(a, "file.txt"), "v1\n");
336 yield* mustGit(a, "add", "-A");
337 yield* mustGit(a, "commit", "-m", "c1");
338 yield* fs.writeFileString(path.join(a, "file.txt"), "v2\n");
339 yield* mustGit(a, "add", "-A");
340 yield* mustGit(a, "commit", "-m", "c2");
341 yield* retryGit(a, "push", "origin", "main");
342
343 // step 3: fresh clone, strict fsck, identical log
344 yield* retryGit(tmp, "clone", remote, "b");
345 const b = path.join(tmp, "b");
346 yield* mustGit(b, "fsck", "--strict");
347 const logA = (yield* mustGit(a, "log", "--format=%H %s")).stdout;
348 const logB = (yield* mustGit(b, "log", "--format=%H %s")).stdout;
349 expect(logB).toBe(logA);
350
351 // step 4: two more commits from A; B fetches incrementally + ff-merges
352 yield* fs.writeFileString(path.join(a, "file.txt"), "v3\n");
353 yield* mustGit(a, "add", "-A");
354 yield* mustGit(a, "commit", "-m", "c3");
355 yield* fs.writeFileString(path.join(a, "extra.txt"), "extra\n");
356 yield* mustGit(a, "add", "-A");
357 yield* mustGit(a, "commit", "-m", "c4");
358 yield* mustGit(a, "push", "origin", "main");
359 const headA = (yield* mustGit(a, "rev-parse", "HEAD")).stdout;
360
361 yield* mustGit(b, "fetch", "origin");
362 yield* mustGit(b, "merge", "--ff-only", "origin/main");
363 expect((yield* mustGit(b, "rev-parse", "HEAD")).stdout).toBe(headA);
364 expect(yield* fs.readFileString(path.join(b, "file.txt"))).toBe("v3\n");
365 }).pipe(logLevel),
366 {
367 tags: [
368 "provider:cloudflare",
369 "provider:cloudflare:r2",
370 "provider:cloudflare:worker",
371 "live",
372 ],
373 timeout: 120_000,
374 },
375);
376
377// ── step 5: CAS, force push, concurrent CAS race, --atomic ──────────────────
378
379test(
380 "non-FF push rejected, force push wins, concurrent CAS race has one winner",
381 Effect.gen(function* () {
382 const { url } = yield* stack;
383 const { remote } = yield* freshRepo(url, "e2e", "proto-cas");
384 const fs = yield* FileSystem.FileSystem;
385 const path = yield* Path.Path;
386 const tmp = yield* tempDir;
387
388 yield* retryGit(tmp, "-c", "init.defaultBranch=main", "clone", remote, "a");
389 const a = path.join(tmp, "a");
390 yield* fs.writeFileString(path.join(a, "f.txt"), "base\n");
391 yield* mustGit(a, "add", "-A");
392 yield* mustGit(a, "commit", "-m", "base");
393 yield* mustGit(a, "push", "origin", "main");
394
395 // B clones at base; A advances; B's stale push gets the server-side ng
396 yield* retryGit(tmp, "clone", remote, "b");
397 const b = path.join(tmp, "b");
398 yield* fs.writeFileString(path.join(a, "f.txt"), "a2\n");
399 yield* mustGit(a, "add", "-A");
400 yield* mustGit(a, "commit", "-m", "a2");
401 yield* mustGit(a, "push", "origin", "main");
402
403 yield* fs.writeFileString(path.join(b, "g.txt"), "b2\n");
404 yield* mustGit(b, "add", "-A");
405 yield* mustGit(b, "commit", "-m", "b2");
406 const rejected = yield* mustFailGit(b, "push", "origin", "main");
407 expect(`${rejected.stdout}\n${rejected.stderr}`).toContain("rejected");
408
409 // force push after fetch overwrites; the other clone fetches it cleanly
410 yield* mustGit(b, "fetch", "origin");
411 yield* mustGit(b, "push", "--force", "origin", "main");
412 const headB = (yield* mustGit(b, "rev-parse", "HEAD")).stdout;
413 yield* mustGit(a, "fetch", "origin");
414 expect((yield* mustGit(a, "rev-parse", "origin/main")).stdout).toBe(headB);
415
416 // CAS race: both clones at the same base push divergent tips concurrently;
417 // the Repo DO serializes — exactly one wins, the other reports ng/rejected
418 yield* mustGit(a, "reset", "--hard", "origin/main");
419 yield* fs.writeFileString(path.join(a, "race.txt"), "from-a\n");
420 yield* mustGit(a, "add", "-A");
421 yield* mustGit(a, "commit", "-m", "race-a");
422 yield* fs.writeFileString(path.join(b, "race.txt"), "from-b\n");
423 yield* mustGit(b, "add", "-A");
424 yield* mustGit(b, "commit", "-m", "race-b");
425 const [pushA, pushB] = yield* Effect.all(
426 [git(a, "push", "origin", "main"), git(b, "push", "origin", "main")],
427 { concurrency: 2 },
428 );
429 const succeeded = [pushA, pushB].filter((r) => r.exitCode === 0);
430 expect(succeeded.length).toBe(1);
431
432 // --atomic: a batch with one stale CAS is all-or-nothing
433 const loser = pushA.exitCode === 0 ? b : a;
434 yield* mustFailGit(
435 loser,
436 "push",
437 "--atomic",
438 "origin",
439 "main",
440 "HEAD:refs/heads/atomic-side",
441 );
442 const admin = yield* makeClient(url, TEST_SECRET);
443 const refs = yield* admin.refs.list({
444 params: { owner: "e2e", repo: "proto-cas" },
445 query: {},
446 });
447 // the side branch must NOT exist — the failed main CAS aborted the batch
448 expect(refs.refs.some((r) => r.name === "refs/heads/atomic-side")).toBe(
449 false,
450 );
451 }).pipe(logLevel),
452 {
453 tags: [
454 "provider:cloudflare",
455 "provider:cloudflare:r2",
456 "provider:cloudflare:worker",
457 "live",
458 ],
459 timeout: 120_000,
460 },
461);
462
463// ── step 6: concurrent push serialization ───────────────────────────────────
464
465test(
466 "two concurrent pushes to different branches both land (semaphore, no corruption)",
467 Effect.gen(function* () {
468 const { url } = yield* stack;
469 const { admin, remote } = yield* freshRepo(url, "e2e", "proto-concurrent");
470 const fs = yield* FileSystem.FileSystem;
471 const path = yield* Path.Path;
472 const tmp = yield* tempDir;
473
474 yield* retryGit(tmp, "-c", "init.defaultBranch=main", "clone", remote, "a");
475 const a = path.join(tmp, "a");
476 yield* fs.writeFileString(path.join(a, "f.txt"), "base\n");
477 yield* mustGit(a, "add", "-A");
478 yield* mustGit(a, "commit", "-m", "base");
479 yield* mustGit(a, "push", "origin", "main");
480
481 yield* retryGit(tmp, "clone", remote, "b");
482 const b = path.join(tmp, "b");
483 yield* fs.writeFileString(path.join(a, "a.txt"), "a\n");
484 yield* mustGit(a, "add", "-A");
485 yield* mustGit(a, "commit", "-m", "branch-a");
486 yield* fs.writeFileString(path.join(b, "b.txt"), "b\n");
487 yield* mustGit(b, "add", "-A");
488 yield* mustGit(b, "commit", "-m", "branch-b");
489
490 // different target refs: no CAS conflict — both must eventually succeed
491 // (second may wait on the push semaphore or see a 503 + clean retry)
492 yield* Effect.all(
493 [
494 retryGit(a, "push", "origin", "HEAD:refs/heads/side-a"),
495 retryGit(b, "push", "origin", "HEAD:refs/heads/side-b"),
496 ],
497 { concurrency: 2 },
498 );
499 const refs = yield* admin.refs.list({
500 params: { owner: "e2e", repo: "proto-concurrent" },
501 query: { prefix: "refs/heads/" },
502 });
503 const names = refs.refs.map((r) => r.name);
504 expect(names).toContain("refs/heads/side-a");
505 expect(names).toContain("refs/heads/side-b");
506
507 // and the repo is not corrupted
508 yield* retryGit(tmp, "clone", remote, "check");
509 yield* mustGit(path.join(tmp, "check"), "fsck", "--strict");
510 }).pipe(logLevel),
511 {
512 tags: [
513 "provider:cloudflare",
514 "provider:cloudflare:r2",
515 "provider:cloudflare:worker",
516 "live",
517 ],
518 timeout: 120_000,
519 },
520);
521
522// ── step 7: branch + annotated tag lifecycle ────────────────────────────────
523
524test(
525 "branch and annotated tag round-trip (peeled ^{} advertisement) and delete",
526 Effect.gen(function* () {
527 const { url } = yield* stack;
528 const { admin, remote } = yield* freshRepo(url, "e2e", "proto-tags");
529 const fs = yield* FileSystem.FileSystem;
530 const path = yield* Path.Path;
531 const tmp = yield* tempDir;
532 const params = { owner: "e2e", repo: "proto-tags" };
533
534 yield* retryGit(
535 tmp,
536 "-c",
537 "init.defaultBranch=main",
538 "clone",
539 remote,
540 "work",
541 );
542 const work = path.join(tmp, "work");
543 yield* fs.writeFileString(path.join(work, "f.txt"), "one\n");
544 yield* mustGit(work, "add", "-A");
545 yield* mustGit(work, "commit", "-m", "c1");
546 yield* mustGit(work, "push", "origin", "main");
547 const head = (yield* mustGit(work, "rev-parse", "HEAD")).stdout;
548
549 // annotated tag is stored and served as a real tag object (the ripgit
550 // silent-drop bug is this named regression)
551 yield* mustGit(work, "tag", "-a", "v1", "-m", "release v1");
552 yield* mustGit(work, "push", "origin", "v1");
553 const tagOid = (yield* mustGit(work, "rev-parse", "v1")).stdout;
554 expect(tagOid).not.toBe(head);
555 const tagRef = yield* admin.refs.get({
556 params,
557 query: { name: "refs/tags/v1" },
558 });
559 expect(tagRef.oid).toBe(tagOid);
560 expect(tagRef.peeled).toBe(head);
561 const lsRemote = (yield* mustGit(work, "ls-remote", "--tags", "origin"))
562 .stdout;
563 expect(lsRemote).toContain(`${tagOid}\trefs/tags/v1`);
564 expect(lsRemote).toContain(`${head}\trefs/tags/v1^{}`);
565
566 // a fresh clone materializes the annotated tag object intact
567 yield* retryGit(tmp, "clone", remote, "verify");
568 const verify = path.join(tmp, "verify");
569 expect((yield* mustGit(verify, "cat-file", "-t", tagOid)).stdout).toBe(
570 "tag",
571 );
572 expect((yield* mustGit(verify, "tag", "-l", "v1")).stdout).toBe("v1");
573
574 // branch push + delete-refs on both kinds
575 yield* mustGit(work, "push", "origin", "main:feature");
576 yield* mustGit(work, "push", "origin", "--delete", "feature");
577 yield* mustGit(work, "push", "origin", "--delete", "v1");
578 const refs = yield* admin.refs.list({ params, query: {} });
579 expect(refs.refs.map((r) => r.name)).toEqual(["refs/heads/main"]);
580 }).pipe(logLevel),
581 {
582 tags: [
583 "provider:cloudflare",
584 "provider:cloudflare:r2",
585 "provider:cloudflare:worker",
586 "live",
587 ],
588 timeout: 120_000,
589 },
590);
591
592// ── step 8: shallow clone + deepen ──────────────────────────────────────────
593
594test(
595 "shallow clone --depth 1 truncates history; fetch --depth 2 extends it",
596 Effect.gen(function* () {
597 const { url } = yield* stack;
598 const { remote } = yield* freshRepo(url, "e2e", "proto-shallow");
599 const fs = yield* FileSystem.FileSystem;
600 const path = yield* Path.Path;
601 const tmp = yield* tempDir;
602
603 yield* retryGit(
604 tmp,
605 "-c",
606 "init.defaultBranch=main",
607 "clone",
608 remote,
609 "full",
610 );
611 const full = path.join(tmp, "full");
612 for (const n of ["c1", "c2", "c3"]) {
613 yield* fs.writeFileString(path.join(full, "f.txt"), `${n}\n`);
614 yield* mustGit(full, "add", "-A");
615 yield* mustGit(full, "commit", "-m", n);
616 }
617 yield* mustGit(full, "push", "origin", "main");
618
619 // depth-1 clone sees exactly one commit and is marked shallow
620 yield* retryGit(tmp, "clone", "--depth", "1", remote, "shallow");
621 const shallow = path.join(tmp, "shallow");
622 const countShallow = (yield* mustGit(
623 shallow,
624 "rev-list",
625 "--count",
626 "HEAD",
627 )).stdout;
628 expect(countShallow).toBe("1");
629 expect(yield* fs.exists(path.join(shallow, ".git", "shallow"))).toBe(true);
630 expect(yield* fs.readFileString(path.join(shallow, "f.txt"))).toBe("c3\n");
631
632 // deepen by one commit — absolute --depth (v1 supports `deepen <n>`
633 // only; `--deepen` needs the deepen-relative capability, cut in v1)
634 yield* mustGit(shallow, "fetch", "--depth", "2", "origin");
635 const deepened = (yield* mustGit(shallow, "rev-list", "--count", "HEAD"))
636 .stdout;
637 expect(deepened).toBe("2");
638 }).pipe(logLevel),
639 {
640 tags: [
641 "provider:cloudflare",
642 "provider:cloudflare:r2",
643 "provider:cloudflare:worker",
644 "live",
645 ],
646 timeout: 120_000,
647 },
648);
649
650// ── step 9: large blob through R2 ───────────────────────────────────────────
651
652test.skipIf(!!process.env.FAST)(
653 "a 3 MiB deterministic binary round-trips byte-identically (location='r2')",
654 Effect.gen(function* () {
655 const { url } = yield* stack;
656 const { remote } = yield* freshRepo(url, "e2e", "proto-bigblob");
657 const fs = yield* FileSystem.FileSystem;
658 const path = yield* Path.Path;
659 const tmp = yield* tempDir;
660
661 yield* retryGit(tmp, "-c", "init.defaultBranch=main", "clone", remote, "a");
662 const a = path.join(tmp, "a");
663 // deterministic (seeded) noise: > 1 MiB compressed, forcing the R2 path
664 const noise = yield* deterministicBytes(3 * 1024 * 1024, 0xa1c4e57);
665 yield* fs.writeFile(path.join(a, "big.bin"), noise);
666 yield* mustGit(a, "add", "-A");
667 yield* mustGit(a, "commit", "-m", "big blob");
668 yield* mustGit(a, "push", "origin", "main");
669 const blobOid = (yield* mustGit(a, "rev-parse", "HEAD:big.bin")).stdout;
670
671 yield* retryGit(tmp, "clone", remote, "b");
672 const b = path.join(tmp, "b");
673 yield* mustGit(b, "fsck", "--strict");
674 expect((yield* mustGit(b, "rev-parse", "HEAD:big.bin")).stdout).toBe(
675 blobOid,
676 );
677 const roundTripped = yield* fs.readFile(path.join(b, "big.bin"));
678 expect(roundTripped.length).toBe(noise.length);
679 expect(Buffer.compare(Buffer.from(roundTripped), Buffer.from(noise))).toBe(
680 0,
681 );
682 }).pipe(logLevel),
683 {
684 tags: [
685 "provider:cloudflare",
686 "provider:cloudflare:r2",
687 "provider:cloudflare:worker",
688 "live",
689 ],
690 timeout: 120_000,
691 },
692);
693
694// ── step 10: big-ish push (deep delta chains) ───────────────────────────────
695
696test.skipIf(!!process.env.FAST)(
697 "a ~500-commit history pushes and clones back within budget",
698 Effect.gen(function* () {
699 const { url } = yield* stack;
700 const { remote } = yield* freshRepo(url, "e2e", "proto-many");
701 const path = yield* Path.Path;
702 const tmp = yield* tempDir;
703
704 yield* retryGit(tmp, "-c", "init.defaultBranch=main", "clone", remote, "a");
705 const a = path.join(tmp, "a");
706 // one sh child builds the history (500 spawns of `git commit` inside a
707 // single shell — deterministic content, growing file → deep delta chains)
708 const script = [
709 "set -e",
710 "for i in $(seq 1 500); do",
711 ' echo "line $i of the growing fixture payload" >> grow.txt',
712 " git add grow.txt",
713 ' git commit -q -m "c$i"',
714 "done",
715 ].join("\n");
716 const handle = yield* ChildProcess.make("sh", ["-c", script], {
717 cwd: a,
718 env: {
719 GIT_AUTHOR_NAME: "Test User",
720 GIT_AUTHOR_EMAIL: "test@example.com",
721 GIT_COMMITTER_NAME: "Test User",
722 GIT_COMMITTER_EMAIL: "test@example.com",
723 GIT_CONFIG_NOSYSTEM: "1",
724 GIT_CONFIG_GLOBAL: "/dev/null",
725 },
726 extendEnv: true,
727 });
728 expect(yield* handle.exitCode.pipe(Effect.timeout("60 seconds"))).toBe(0);
729
730 yield* mustGit(a, "push", "origin", "main");
731 const head = (yield* mustGit(a, "rev-parse", "HEAD")).stdout;
732
733 yield* retryGit(tmp, "clone", remote, "b");
734 const b = path.join(tmp, "b");
735 yield* mustGit(b, "fsck", "--strict");
736 expect((yield* mustGit(b, "rev-parse", "HEAD")).stdout).toBe(head);
737 expect((yield* mustGit(b, "rev-list", "--count", "HEAD")).stdout).toBe(
738 "500",
739 );
740 }).pipe(logLevel),
741 {
742 tags: [
743 "provider:cloudflare",
744 "provider:cloudflare:r2",
745 "provider:cloudflare:worker",
746 "live",
747 ],
748 timeout: 120_000,
749 },
750);
751
752// ── step 11: readOnly ───────────────────────────────────────────────────────
753
754test(
755 "readOnly repos reject pushes per-ref; unflag restores writes",
756 Effect.gen(function* () {
757 const { url } = yield* stack;
758 const { admin, remote } = yield* freshRepo(url, "e2e", "proto-readonly");
759 const fs = yield* FileSystem.FileSystem;
760 const path = yield* Path.Path;
761 const tmp = yield* tempDir;
762 const params = { owner: "e2e", repo: "proto-readonly" };
763
764 yield* retryGit(
765 tmp,
766 "-c",
767 "init.defaultBranch=main",
768 "clone",
769 remote,
770 "work",
771 );
772 const work = path.join(tmp, "work");
773 yield* fs.writeFileString(path.join(work, "f.txt"), "one\n");
774 yield* mustGit(work, "add", "-A");
775 yield* mustGit(work, "commit", "-m", "c1");
776 yield* mustGit(work, "push", "origin", "main");
777
778 yield* admin.repos.update({ params, payload: { readOnly: true } });
779 yield* fs.writeFileString(path.join(work, "f.txt"), "two\n");
780 yield* mustGit(work, "add", "-A");
781 yield* mustGit(work, "commit", "-m", "c2");
782 const rejected = yield* mustFailGit(work, "push", "origin", "main");
783 expect(`${rejected.stdout}\n${rejected.stderr}`).toContain("rejected");
784 // reads still work while readOnly
785 yield* mustGit(work, "fetch", "origin");
786
787 yield* admin.repos.update({ params, payload: { readOnly: false } });
788 yield* mustGit(work, "push", "origin", "main");
789 }).pipe(logLevel),
790 {
791 tags: [
792 "provider:cloudflare",
793 "provider:cloudflare:r2",
794 "provider:cloudflare:worker",
795 "live",
796 ],
797 timeout: 120_000,
798 },
799);
800
801// ── step 12: auth matrix ────────────────────────────────────────────────────
802
803test(
804 "auth matrix: an unknown credential is anonymous and 401s on a private repo",
805 Effect.gen(function* () {
806 const { url } = yield* stack;
807 const { admin, remote } = yield* freshRepo(url, "e2e", "proto-auth");
808 const fs = yield* FileSystem.FileSystem;
809 const path = yield* Path.Path;
810 const tmp = yield* tempDir;
811 const params = { owner: "e2e", repo: "proto-auth" };
812
813 yield* retryGit(
814 tmp,
815 "-c",
816 "init.defaultBranch=main",
817 "clone",
818 remote,
819 "seed",
820 );
821 const seed = path.join(tmp, "seed");
822 yield* fs.writeFileString(path.join(seed, "f.txt"), "seed\n");
823 yield* mustGit(seed, "add", "-A");
824 yield* mustGit(seed, "commit", "-m", "c1");
825 yield* mustGit(seed, "push", "origin", "main");
826
827 // garbage token → 401 (git surfaces the auth failure on stderr)
828 const badRemote = yield* authRemote(url, "gs_garbage", "e2e", "proto-auth");
829 const unauthorized = yield* mustFailGit(tmp, "clone", badRemote, "nope");
830 expect(unauthorized.stderr.toLowerCase()).toMatch(/401|authentication/);
831
832 // token embedded in the remote URL: read scope clones, cannot push
833 }).pipe(logLevel),
834 {
835 tags: [
836 "provider:cloudflare",
837 "provider:cloudflare:r2",
838 "provider:cloudflare:worker",
839 "live",
840 ],
841 timeout: 120_000,
842 },
843);
844
845// ── step 13: fork + import ──────────────────────────────────────────────────
846
847test.skipIf(!!process.env.FAST)(
848 "fork: identical history, divergent push isolation, parent delete keeps the fork alive",
849 Effect.gen(function* () {
850 const { url } = yield* stack;
851 const { admin, remote } = yield* freshRepo(url, "e2e", "proto-fork-src");
852 const fs = yield* FileSystem.FileSystem;
853 const path = yield* Path.Path;
854 const tmp = yield* tempDir;
855 yield* purgeRepo(url, "e2e", "proto-fork-dst");
856
857 // seed the parent
858 yield* retryGit(
859 tmp,
860 "-c",
861 "init.defaultBranch=main",
862 "clone",
863 remote,
864 "src",
865 );
866 const src = path.join(tmp, "src");
867 yield* fs.writeFileString(path.join(src, "f.txt"), "one\n");
868 yield* mustGit(src, "add", "-A");
869 yield* mustGit(src, "commit", "-m", "c1");
870 yield* fs.writeFileString(path.join(src, "f.txt"), "two\n");
871 yield* mustGit(src, "add", "-A");
872 yield* mustGit(src, "commit", "-m", "c2");
873 yield* mustGit(src, "push", "origin", "main");
874 const parentHead = (yield* mustGit(src, "rev-parse", "HEAD")).stdout;
875
876 // fork via REST, poll until the alarm job flips it ready
877 const forked = yield* admin.repos.fork({
878 params: { owner: "e2e", repo: "proto-fork-src" },
879 payload: { targetOwner: "e2e", targetName: "proto-fork-dst" },
880 });
881 expect(forked.repo.status).toBe("forking");
882 expect(forked.repo.forkOf).toBeTruthy();
883 const ready = yield* admin.repos
884 .get({ params: { owner: "e2e", repo: "proto-fork-dst" } })
885 .pipe(
886 Effect.repeat({
887 schedule: Schedule.spaced("2 seconds"),
888 until: (repo) => repo.status === "ready",
889 times: 45,
890 }),
891 );
892 expect(ready.status).toBe("ready");
893
894 // the fork clones with its own bootstrap token and matches the parent
895 const forkRemote = yield* authRemote(
896 url,
897 TEST_SECRET,
898 "e2e",
899 "proto-fork-dst",
900 );
901 yield* retryGit(tmp, "clone", forkRemote, "dst");
902 const dst = path.join(tmp, "dst");
903 yield* mustGit(dst, "fsck", "--strict");
904 expect((yield* mustGit(dst, "rev-parse", "HEAD")).stdout).toBe(parentHead);
905
906 // divergent push to the fork leaves the parent untouched
907 yield* fs.writeFileString(path.join(dst, "fork-only.txt"), "fork\n");
908 yield* mustGit(dst, "add", "-A");
909 yield* mustGit(dst, "commit", "-m", "fork-only");
910 yield* mustGit(dst, "push", "origin", "main");
911 const parentRefs = yield* admin.refs.list({
912 params: { owner: "e2e", repo: "proto-fork-src" },
913 query: {},
914 });
915 expect(parentRefs.refs.find((r) => r.name === "refs/heads/main")?.oid).toBe(
916 parentHead,
917 );
918
919 // delete the parent → the fork still clones (R2 fork-retention pin)
920 yield* admin.repos.delete({
921 params: { owner: "e2e", repo: "proto-fork-src" },
922 });
923 yield* admin.repos
924 .get({ params: { owner: "e2e", repo: "proto-fork-src" } })
925 .pipe(
926 Effect.as(false),
927 Effect.catchTag("RepoNotFound", () => Effect.succeed(true)),
928 Effect.repeat({
929 schedule: Schedule.spaced("2 seconds"),
930 until: (gone) => gone,
931 times: 45,
932 }),
933 );
934 yield* retryGit(tmp, "clone", forkRemote, "dst2");
935 yield* mustGit(path.join(tmp, "dst2"), "fsck", "--strict");
936
937 yield* purgeRepo(url, "e2e", "proto-fork-dst");
938 }).pipe(logLevel),
939 {
940 tags: [
941 "provider:cloudflare",
942 "provider:cloudflare:r2",
943 "provider:cloudflare:worker",
944 "live",
945 ],
946 timeout: 180_000,
947 },
948);
949
950test.skipIf(!!process.env.FAST)(
951 "import: depth-limited import from a public smart-HTTP source reaches ready",
952 Effect.gen(function* () {
953 const { url } = yield* stack;
954 const admin = yield* makeClient(url, TEST_SECRET);
955 yield* purgeRepo(url, "e2e", "proto-import");
956
957 // a tiny, stable public repo; depth-limited to stay under the 50 MiB cap.
958 // (Importing a sibling repo of the SAME deployment would make the worker
959 // fetch its own hostname, which Cloudflare blocks as worker recursion.)
960 const imported = yield* admin.repos.import({
961 payload: {
962 owner: "e2e",
963 name: "proto-import",
964 source: { url: "https://github.com/octocat/Hello-World.git", depth: 1 },
965 },
966 });
967 expect(imported.repo.status).toBe("importing");
968
969 const ready = yield* admin.repos
970 .get({ params: { owner: "e2e", repo: "proto-import" } })
971 .pipe(
972 Effect.repeat({
973 schedule: Schedule.spaced("2 seconds"),
974 until: (repo) => repo.status === "ready",
975 times: 45,
976 }),
977 );
978 expect(ready.status).toBe("ready");
979
980 const refs = yield* admin.refs.list({
981 params: { owner: "e2e", repo: "proto-import" },
982 query: {},
983 });
984 expect(refs.refs.length).toBeGreaterThan(0);
985
986 // and the imported history clones with a fresh token
987 const path = yield* Path.Path;
988 const tmp = yield* tempDir;
989 const importRemote = yield* authRemote(
990 url,
991 TEST_SECRET,
992 "e2e",
993 "proto-import",
994 );
995 // the import was depth-limited, so the repo's history is shallow —
996 // v0 can only serve that to a shallow-aware client (see handleUploadPack)
997 yield* retryGit(tmp, "clone", "--depth", "1", importRemote, "imported");
998 yield* mustGit(path.join(tmp, "imported"), "fsck", "--strict");
999
1000 yield* purgeRepo(url, "e2e", "proto-import");
1001 }).pipe(logLevel),
1002 {
1003 tags: [
1004 "provider:cloudflare",
1005 "provider:cloudflare:r2",
1006 "provider:cloudflare:worker",
1007 "live",
1008 ],
1009 timeout: 180_000,
1010 },
1011);
1012
1013// step 14 (cleanup pin) is the afterAll destroy above; out-of-band distilled
1014// verification of the R2 prefix + Registry rows is a coordinator-level check
1015// (this package does not depend on distilled).