1/**
2 * THE PRIMARY SUITE — full local dev-mode coverage of git-service.
3 *
4 * `Test.make({ providers: Cloudflare.providers(), dev: true })` runs the
5 * whole service on the local RPC-sidecar topology (`alchemy dev`'s process
6 * shape): a local workerd Worker hosting the GitRepo/GitRegistry Durable
7 * Objects and the R2 simulator. No cloud API is touched — the worker serves
8 * from `http://localhost:<port>`.
9 *
10 * Coverage:
11 * (a) every REST endpooint of `GitApi` via the typed `HttpApiClient`
12 * (repos create/get/update/list/delete, refs list/get/update/remove
13 * with CAS, objects commit/log/tree/blob, tokens create/list/revoke,
14 * plus the raw blob/file streaming routes) including the typed
15 * 401/403/404/409/422 error taxonomy;
16 * (b) the real `git` CLI over the smart-HTTP wire endpoints — empty clone,
17 * first push, clone-back + `git fsck --strict`, incremental fetch,
18 * force-push CAS, branch + annotated-tag lifecycle, readOnly rejection,
19 * and the wire auth matrix.
20 */
21import * as Alchemy from "@/index.ts";
22import * as Cloudflare from "@/Cloudflare";
23import * as Test from "@/Test/Alchemy";
24import { expect } from "alchemy-test";
25import * as Data from "effect/Data";
26import * as Effect from "effect/Effect";
27import * as FileSystem from "effect/FileSystem";
28import * as Path from "effect/Path";
29import { MinimumLogLevel } from "effect/References";
30import * as Result from "effect/Result";
31import * as Schedule from "effect/Schedule";
32import * as Stream from "effect/Stream";
33import * as HttpClient from "effect/http/HttpClient";
34import * as HttpClientRequest from "effect/http/HttpClientRequest";
35import * as HttpApiClient from "effect/http-api/HttpApiClient";
36import * as ChildProcess from "effect/process/ChildProcess";
37import type { Oid } from "@/Git/Api.ts";
38import {
39 encodeCommit,
40 hashObject,
41 ObjectType,
42 parseCommit,
43 utf8Decode,
44 utf8Encode,
45} from "@/Git/Protocol/ObjectCodec.ts";
46import ProtectedGitHost from "./fixtures/protected-stack.ts";
47import TestGitHost, {
48 TEST_SECRET,
49 TEST_SECRET_DEV,
50 TestApi,
51} from "./fixtures/stack.ts";
52import { verifyPackResponse } from "./harness/pack.ts";
53
54// `dev: true` runs local providers behind the RPC sidecar proxy by default,
55// matching the process topology of the real `alchemy dev` command.
56const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
57 providers: Cloudflare.providers(),
58 dev: true,
59});
60
61const logLevel = Effect.provideService(
62 MinimumLogLevel,
63 process.env.DEBUG ? "Debug" : "Info",
64);
65
66// The local suite must not touch the cloud, so it composes its own Stack over
67// `Alchemy.localState()` — the same user pattern as production, just with a
68// local state store: deploy the fixture's building-block assembly worker.
69// Importing `./fixtures/stack.ts` above installed the TEST_SECRET into
70// the deployer env before this plan resolves
71// the suite fixture's `AuthenticatedTest`.
72const LocalStack = Alchemy.Stack(
73 "GitServiceLocalStack",
74 { providers: Cloudflare.providers(), state: Alchemy.localState() },
75 Effect.gen(function* () {
76 const host = yield* TestGitHost;
77 return { url: host.url.as<string>() };
78 }),
79);
80
81// ── readiness ───────────────────────────────────────────────────────────────
82
83class WorkerNotReady extends Data.TaggedError("WorkerNotReady")<{
84 status: number;
85}> {}
86
87const boundedReadiness = Schedule.max([
88 Schedule.min([
89 Schedule.exponential("500 millis"),
90 Schedule.spaced("2 seconds"),
91 ]),
92 Schedule.recurs(20),
93]);
94
95/** Wait until the local worker answers the admin repo listing with a 200. */
96const awaitReady = (url: string) =>
97 Effect.gen(function* () {
98 const client = yield* HttpClient.HttpClient;
99 yield* client
100 .get(`${url}/api/v1/repos`, {
101 headers: { authorization: `Bearer ${TEST_SECRET}` },
102 })
103 .pipe(
104 Effect.flatMap((res) =>
105 res.status === 200
106 ? Effect.void
107 : Effect.fail(new WorkerNotReady({ status: res.status })),
108 ),
109 // retries transport errors (connection refused during boot) too
110 Effect.retry({ schedule: boundedReadiness }),
111 );
112 }).pipe(Effect.orDie);
113
114const stack = beforeAll(
115 deploy(LocalStack).pipe(
116 Effect.tap(({ url }) =>
117 Effect.gen(function* () {
118 // proof the run is local: no cloud URL, a localhost port
119 expect(url).toMatch(/^http:\/\/localhost:\d+$/);
120 yield* awaitReady(url);
121 }),
122 ),
123 logLevel,
124 ),
125);
126afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(LocalStack));
127
128// ── typed REST client ───────────────────────────────────────────────────────
129
130const makeClient = (url: string, token: string) =>
131 HttpApiClient.make(TestApi, {
132 baseUrl: url,
133 transformClient: HttpClient.mapRequest((request) =>
134 request.pipe(HttpClientRequest.bearerToken(token)),
135 ),
136 });
137
138/** A client that sends NO Authorization header — the anonymous caller. */
139const makeAnonymousClient = (url: string) =>
140 HttpApiClient.make(TestApi, { baseUrl: url });
141
142const asOid = (oid: string): Oid => oid as Oid;
143
144/** Assert an effect fails with the given tagged error. */
145const expectTag = <A, E extends { readonly _tag: string }, R>(
146 effect: Effect.Effect<A, E, R>,
147 tag: string,
148) =>
149 Effect.gen(function* () {
150 const result = yield* Effect.result(effect);
151 expect(Result.isFailure(result)).toBe(true);
152 if (Result.isFailure(result)) {
153 expect(result.failure._tag).toBe(tag);
154 return result.failure;
155 }
156 return yield* Effect.die("unreachable");
157 });
158
159// ── git CLI plumbing ────────────────────────────────────────────────────────
160
161class GitError extends Data.TaggedError("GitError")<{
162 readonly args: ReadonlyArray<string>;
163 readonly exitCode: number;
164 readonly stdout: string;
165 readonly stderr: string;
166}> {
167 override get message() {
168 return `git ${this.args.join(" ")} → exit ${this.exitCode}\n${this.stderr.trim()}\n${this.stdout.trim()}`;
169 }
170}
171
172/** Run `git <args>` in `cwd`, capturing exit code and output. Bounded. */
173const git = Effect.fn(function* (cwd: string, ...args: Array<string>) {
174 const handle = yield* ChildProcess.make("git", args, {
175 cwd,
176 env: {
177 GIT_TERMINAL_PROMPT: "0",
178 GIT_CONFIG_NOSYSTEM: "1",
179 GIT_CONFIG_GLOBAL: "/dev/null",
180 GIT_AUTHOR_NAME: "Test User",
181 GIT_AUTHOR_EMAIL: "test@example.com",
182 GIT_COMMITTER_NAME: "Test User",
183 GIT_COMMITTER_EMAIL: "test@example.com",
184 },
185 extendEnv: true,
186 });
187 const [exitCode, stdout, stderr] = yield* Effect.all(
188 [
189 handle.exitCode,
190 Stream.mkString(Stream.decodeText(handle.stdout)),
191 Stream.mkString(Stream.decodeText(handle.stderr)),
192 ],
193 { concurrency: 3 },
194 );
195 return { args, exitCode, stdout: stdout.trim(), stderr };
196}, Effect.timeout("60 seconds"));
197
198/** Run git and require exit 0, failing with the captured output otherwise. */
199const mustGit = Effect.fn(function* (cwd: string, ...args: Array<string>) {
200 const result = yield* git(cwd, ...args);
201 if (result.exitCode !== 0) {
202 return yield* new GitError(result);
203 }
204 return result;
205});
206
207/** Run git and require a non-zero exit (an expected rejection). */
208const mustFailGit = Effect.fn(function* (cwd: string, ...args: Array<string>) {
209 const result = yield* git(cwd, ...args);
210 if (result.exitCode === 0) {
211 return yield* new GitError(result);
212 }
213 return result;
214});
215
216/** `http://x:<token>@localhost:<port>/<owner>/<repo>.git` */
217const authRemote = (url: string, token: string, owner: string, repo: string) =>
218 Effect.sync(() => {
219 const parsed = new URL(url);
220 return `${parsed.protocol}//x:${token}@${parsed.host}/${owner}/${repo}.git`;
221 });
222
223/** `git rev-parse <rev>` → the 40-hex oid. */
224const revParse = (cwd: string, rev: string) =>
225 Effect.map(mustGit(cwd, "rev-parse", rev), (result) => result.stdout);
226
227const tempDir = Effect.gen(function* () {
228 const fs = yield* FileSystem.FileSystem;
229 return yield* fs.makeTempDirectory({ prefix: "git-service-local-" });
230});
231
232/** Create a repo via the admin REST client, returning its bootstrap token. */
233/** Poll marker: repo still visible while its async purge drains. */
234class StillDeleting extends Data.TaggedError("StillDeleting")<{}> {}
235
236const createRepo = Effect.fn(function* (
237 url: string,
238 owner: string,
239 name: string,
240) {
241 const client = yield* makeClient(url, TEST_SECRET);
242 // Retry-safe: a previous (failed, runner-retried) attempt may have left
243 // the repo behind — delete it and wait out the async purge before
244 // creating, so the create below never 409s on our own leftovers.
245 yield* client.repos.delete({ params: { owner, repo: name } }).pipe(
246 Effect.flatMap(() =>
247 client.repos.get({ params: { owner, repo: name } }).pipe(
248 Effect.flatMap(() => Effect.fail(new StillDeleting())),
249 Effect.catchTag("RepoNotFound", () => Effect.void),
250 Effect.retry({
251 while: (error) => error instanceof StillDeleting,
252 schedule: Schedule.spaced("250 millis"),
253 times: 40,
254 }),
255 ),
256 ),
257 Effect.catchTag("RepoNotFound", () => Effect.void),
258 );
259 const created = yield* client.repos.create({ payload: { owner, name } });
260 return {
261 client,
262 created,
263 token: TEST_SECRET,
264 remote: yield* authRemote(url, TEST_SECRET, owner, name),
265 };
266});
267
268// ═══════════════════════════════════════════════════════════════════════════
269// (a) REST management plane
270// ═══════════════════════════════════════════════════════════════════════════
271
272test(
273 "repos: create, duplicate 409, get, list, update, typed auth failures",
274 Effect.gen(function* () {
275 const { url } = yield* stack;
276 const admin = yield* makeClient(url, TEST_SECRET);
277
278 const created = yield* admin.repos.create({
279 payload: { owner: "acme", name: "rest-repos", description: "d0" },
280 });
281 expect(created.repo.owner).toBe("acme");
282 expect(created.repo.name).toBe("rest-repos");
283 expect(created.repo.status).toBe("ready");
284 expect(created.repo.defaultBranch).toBe("main");
285 expect(created.repo.readOnly).toBe(false);
286 expect(created.remote).toContain("/acme/rest-repos.git");
287
288 // duplicate → typed 409
289 yield* expectTag(
290 admin.repos.create({ payload: { owner: "acme", name: "rest-repos" } }),
291 "RepoAlreadyExists",
292 );
293
294 const fetched = yield* admin.repos.get({
295 params: { owner: "acme", repo: "rest-repos" },
296 });
297 expect(fetched.repoId).toBe(created.repo.repoId);
298 expect(fetched.description).toBe("d0");
299
300 // list-all is admin-only and contains the repo
301 const listed = yield* admin.repos.list({ query: {} });
302 expect(
303 listed.items.some((r) => r.owner === "acme" && r.name === "rest-repos"),
304 ).toBe(true);
305
306 // PATCH description + readOnly round-trip
307 const patched = yield* admin.repos.update({
308 params: { owner: "acme", repo: "rest-repos" },
309 payload: { description: "d1", readOnly: true },
310 });
311 expect(patched.description).toBe("d1");
312 expect(patched.readOnly).toBe(true);
313 yield* admin.repos.update({
314 params: { owner: "acme", repo: "rest-repos" },
315 payload: { readOnly: false },
316 });
317
318 // missing repo → typed 404
319 yield* expectTag(
320 admin.repos.get({ params: { owner: "acme", repo: "does-not-exist" } }),
321 "RepoNotFound",
322 );
323
324 // no credentials → typed 401 from the fixture's middleware
325 const anonymous = yield* HttpApiClient.make(TestApi, { baseUrl: url });
326 yield* expectTag(
327 anonymous.repos.get({ params: { owner: "acme", repo: "rest-repos" } }),
328 "Unauthorized",
329 );
330
331 // garbage token → typed 401 (the middleware rejects it before any route)
332 const garbage = yield* makeClient(url, "gs_garbage-token");
333 yield* expectTag(
334 garbage.repos.get({ params: { owner: "acme", repo: "rest-repos" } }),
335 "Unauthorized",
336 );
337
338 // Anonymous callers may read one public repository at a time;
339 // listing is not that, so it 401s like any other anonymous call.
340 yield* expectTag(anonymous.repos.list({ query: {} }), "Unauthorized");
341 // The engine lists everything the Registry holds; `public: true`
342 // narrows it, so a private repo is not in that view.
343 const publicListing = yield* admin.repos.list({
344 query: { public: true },
345 });
346 expect(
347 publicListing.items.some(
348 (row) => row.owner === "acme" && row.name === "rest-repos",
349 ),
350 ).toBe(false);
351 }).pipe(logLevel),
352 {
353 tags: [
354 "provider:cloudflare",
355 "provider:cloudflare:r2",
356 "provider:cloudflare:worker",
357 "local",
358 ],
359 timeout: 120_000,
360 },
361);
362
363test(
364 "refs + objects: CAS writes, typed conflicts, commit/log/tree/blob reads, raw routes",
365 Effect.gen(function* () {
366 const { url } = yield* stack;
367 const { client: admin, remote } = yield* createRepo(
368 url,
369 "acme",
370 "rest-refs",
371 );
372 const params = { owner: "acme", repo: "rest-refs" };
373 const fs = yield* FileSystem.FileSystem;
374 const path = yield* Path.Path;
375
376 // refs on a fresh repo are empty
377 const empty = yield* admin.refs.list({ params, query: {} });
378 expect(empty.refs).toEqual([]);
379
380 // seed real objects over the wire (2 commits)
381 const tmp = yield* tempDir;
382 yield* mustGit(
383 tmp,
384 "-c",
385 "init.defaultBranch=main",
386 "clone",
387 remote,
388 "work",
389 );
390 const work = path.join(tmp, "work");
391 yield* fs.writeFileString(path.join(work, "hello.txt"), "hello local\n");
392 yield* mustGit(work, "add", "-A");
393 yield* mustGit(work, "commit", "-m", "c1: seed");
394 yield* fs.writeFileString(
395 path.join(work, "hello.txt"),
396 "hello local, v2\n",
397 );
398 yield* mustGit(work, "add", "-A");
399 yield* mustGit(work, "commit", "-m", "c2: bump");
400 yield* mustGit(work, "push", "origin", "main");
401 const head = (yield* mustGit(work, "rev-parse", "HEAD")).stdout;
402 const parent = (yield* mustGit(work, "rev-parse", "HEAD~1")).stdout;
403
404 // refs.list / refs.get agree with the CLI
405 const refs = yield* admin.refs.list({
406 params,
407 query: { prefix: "refs/heads/" },
408 });
409 expect(refs.refs.map((r) => r.name)).toEqual(["refs/heads/main"]);
410 expect(refs.refs[0]!.oid).toBe(head);
411 const mainRef = yield* admin.refs.get({
412 params,
413 query: { name: "refs/heads/main" },
414 });
415 expect(mainRef.oid).toBe(head);
416 yield* expectTag(
417 admin.refs.get({ params, query: { name: "refs/heads/nope" } }),
418 "RefNotFound",
419 );
420
421 // CAS create (expectedOid: null = must-not-exist)
422 const branch = yield* admin.refs.update({
423 params,
424 query: { name: "refs/heads/rest-branch" },
425 payload: { newOid: asOid(parent), expectedOid: null },
426 });
427 expect(branch.oid).toBe(parent);
428 // must-not-exist against an existing ref → typed 409
429 yield* expectTag(
430 admin.refs.update({
431 params,
432 query: { name: "refs/heads/rest-branch" },
433 payload: { newOid: asOid(head), expectedOid: null },
434 }),
435 "RefConflict",
436 );
437 // stale expectedOid → typed 409
438 yield* expectTag(
439 admin.refs.update({
440 params,
441 query: { name: "refs/heads/rest-branch" },
442 payload: { newOid: asOid(head), expectedOid: asOid(head) },
443 }),
444 "RefConflict",
445 );
446 // correct CAS moves the ref
447 const moved = yield* admin.refs.update({
448 params,
449 query: { name: "refs/heads/rest-branch" },
450 payload: { newOid: asOid(head), expectedOid: asOid(parent) },
451 });
452 expect(moved.oid).toBe(head);
453
454 // pointing a ref at an object we do not have → typed 404
455 yield* expectTag(
456 admin.refs.update({
457 params,
458 query: { name: "refs/heads/rest-branch" },
459 payload: { newOid: asOid("f".repeat(40)) },
460 }),
461 "ObjectNotFound",
462 );
463 // remove with a stale CAS → 409; with the right one → gone
464 yield* expectTag(
465 admin.refs.remove({
466 params,
467 query: { name: "refs/heads/rest-branch" },
468 payload: { expectedOid: asOid(parent) },
469 }),
470 "RefConflict",
471 );
472 yield* admin.refs.remove({
473 params,
474 query: { name: "refs/heads/rest-branch" },
475 payload: { expectedOid: asOid(head) },
476 });
477 yield* expectTag(
478 admin.refs.get({ params, query: { name: "refs/heads/rest-branch" } }),
479 "RefNotFound",
480 );
481
482 // readOnly gates REST ref writes with the typed 403
483 yield* admin.repos.update({ params, payload: { readOnly: true } });
484 yield* expectTag(
485 admin.refs.update({
486 params,
487 query: { name: "refs/heads/blocked" },
488 payload: { newOid: asOid(head) },
489 }),
490 "ReadOnlyRepo",
491 );
492 yield* admin.repos.update({ params, payload: { readOnly: false } });
493
494 // objects: commit → tree → blob agree with the CLI byte-for-byte
495 const commit = yield* admin.objects.commit({
496 params: { ...params, oid: asOid(head) },
497 });
498 expect(commit.message).toContain("c2: bump");
499 expect(commit.parents).toEqual([parent]);
500 expect(commit.author.email).toBe("test@example.com");
501
502 const tree = yield* admin.objects.tree({
503 params: { ...params, oid: commit.tree },
504 });
505 const helloEntry = tree.entries.find((e) => e.name === "hello.txt")!;
506 expect(helloEntry.type).toBe("blob");
507 expect(helloEntry.mode).toBe("100644");
508
509 const blob = yield* admin.objects.blob({
510 params: { ...params, oid: helloEntry.oid },
511 });
512 expect(blob.encoding).toBe("base64");
513 const blobText = yield* Effect.sync(() =>
514 Buffer.from(blob.content, "base64").toString("utf8"),
515 );
516 expect(blobText).toBe("hello local, v2\n");
517
518 // wrong-kind reads → typed 422
519 yield* expectTag(
520 admin.objects.tree({ params: { ...params, oid: helloEntry.oid } }),
521 "WrongObjectType",
522 );
523 yield* expectTag(
524 admin.objects.blob({ params: { ...params, oid: asOid(head) } }),
525 "WrongObjectType",
526 );
527 yield* expectTag(
528 admin.objects.commit({
529 params: { ...params, oid: asOid("e".repeat(40)) },
530 }),
531 "ObjectNotFound",
532 );
533
534 // log walks the first-parent chain
535 const log = yield* admin.objects.log({
536 params,
537 query: { ref: "refs/heads/main", limit: 10 },
538 });
539 expect(log.items.map((c) => c.oid)).toEqual([head, parent]);
540
541 // raw streaming routes (outside HttpApi schema-land)
542 const http = yield* HttpClient.HttpClient;
543 const rawBlob = yield* http.get(
544 `${url}/api/v1/repos/acme/rest-refs/blobs/${helloEntry.oid}/raw`,
545 { headers: { authorization: `Bearer ${TEST_SECRET}` } },
546 );
547 expect(rawBlob.status).toBe(200);
548 expect(yield* rawBlob.text).toBe("hello local, v2\n");
549
550 const rawFile = yield* http.get(
551 `${url}/api/v1/repos/acme/rest-refs/file?ref=refs/heads/main&path=hello.txt`,
552 { headers: { authorization: `Bearer ${TEST_SECRET}` } },
553 );
554 expect(rawFile.status).toBe(200);
555 expect(yield* rawFile.text).toBe("hello local, v2\n");
556 }).pipe(logLevel),
557 {
558 tags: [
559 "provider:cloudflare",
560 "provider:cloudflare:r2",
561 "provider:cloudflare:worker",
562 "local",
563 ],
564 timeout: 120_000,
565 },
566);
567
568test(
569 "repos.delete: 204 now, async purge to a typed 404",
570 Effect.gen(function* () {
571 const { url } = yield* stack;
572 const { client: admin } = yield* createRepo(url, "acme", "rest-delete");
573 const params = { owner: "acme", repo: "rest-delete" };
574
575 yield* admin.repos.delete({ params });
576
577 // status flips to 'deleting' and the alarm-driven purge ends in a 404
578 const gone = yield* admin.repos.get({ params }).pipe(
579 Effect.map((repo) => ({ deleted: false, status: repo.status })),
580 Effect.catchTag("RepoNotFound", () =>
581 Effect.succeed({ deleted: true as const, status: undefined }),
582 ),
583 Effect.repeat({
584 schedule: Schedule.spaced("1 second"),
585 until: (state) => state.deleted,
586 times: 30,
587 }),
588 );
589 expect(gone.deleted).toBe(true);
590 }).pipe(logLevel),
591 {
592 tags: [
593 "provider:cloudflare",
594 "provider:cloudflare:r2",
595 "provider:cloudflare:worker",
596 "local",
597 ],
598 timeout: 120_000,
599 },
600);
601
602// ═══════════════════════════════════════════════════════════════════════════
603// (b) real git CLI over the local wire
604// ═══════════════════════════════════════════════════════════════════════════
605
606test(
607 "git: empty clone, first push (exec bit + subdir), REST agreement",
608 Effect.gen(function* () {
609 const { url } = yield* stack;
610 const { client: admin, remote } = yield* createRepo(
611 url,
612 "acme",
613 "wire-basic",
614 );
615 const fs = yield* FileSystem.FileSystem;
616 const path = yield* Path.Path;
617 const tmp = yield* tempDir;
618
619 // empty-repo clone succeeds (unborn HEAD advertisement)
620 const clone = yield* mustGit(
621 tmp,
622 "-c",
623 "init.defaultBranch=main",
624 "clone",
625 remote,
626 "work",
627 );
628 expect(`${clone.stdout}\n${clone.stderr}`).toContain("empty repository");
629 const work = path.join(tmp, "work");
630
631 // three commits: plain file, subdirectory, executable
632 yield* fs.writeFileString(path.join(work, "hello.txt"), "hello wire\n");
633 yield* mustGit(work, "add", "-A");
634 yield* mustGit(work, "commit", "-m", "c1: hello");
635 yield* fs.makeDirectory(path.join(work, "sub"), { recursive: true });
636 yield* fs.writeFileString(path.join(work, "sub", "nested.txt"), "nested\n");
637 yield* mustGit(work, "add", "-A");
638 yield* mustGit(work, "commit", "-m", "c2: subdir");
639 yield* fs.writeFileString(
640 path.join(work, "run.sh"),
641 "#!/bin/sh\necho ok\n",
642 );
643 yield* fs.chmod(path.join(work, "run.sh"), 0o755);
644 yield* mustGit(work, "add", "-A");
645 yield* mustGit(work, "commit", "-m", "c3: executable");
646
647 yield* mustGit(work, "push", "origin", "main");
648 const head = (yield* mustGit(work, "rev-parse", "HEAD")).stdout;
649
650 // REST agrees with the CLI
651 const params = { owner: "acme", repo: "wire-basic" };
652 const refs = yield* admin.refs.list({ params, query: {} });
653 expect(refs.refs.find((r) => r.name === "refs/heads/main")?.oid).toBe(head);
654
655 const commit = yield* admin.objects.commit({
656 params: { ...params, oid: asOid(head) },
657 });
658 expect(commit.message).toContain("c3: executable");
659 const tree = yield* admin.objects.tree({
660 params: { ...params, oid: commit.tree },
661 });
662 expect(tree.entries.find((e) => e.name === "run.sh")?.mode).toBe("100755");
663 expect(tree.entries.find((e) => e.name === "sub")?.type).toBe("tree");
664
665 // ls-remote sees exactly what REST sees
666 const lsRemote = (yield* mustGit(work, "ls-remote", "origin")).stdout;
667 expect(lsRemote).toContain(`${head}\trefs/heads/main`);
668 }).pipe(logLevel),
669 {
670 tags: [
671 "provider:cloudflare",
672 "provider:cloudflare:r2",
673 "provider:cloudflare:worker",
674 "local",
675 ],
676 timeout: 120_000,
677 },
678);
679
680test(
681 "git: clone-back + fsck --strict, incremental fetch fast-forwards",
682 Effect.gen(function* () {
683 const { url } = yield* stack;
684 const { remote } = yield* createRepo(url, "acme", "wire-fetch");
685 const fs = yield* FileSystem.FileSystem;
686 const path = yield* Path.Path;
687 const tmp = yield* tempDir;
688
689 // seed from clone A
690 yield* mustGit(tmp, "-c", "init.defaultBranch=main", "clone", remote, "a");
691 const a = path.join(tmp, "a");
692 yield* fs.writeFileString(path.join(a, "file.txt"), "v1\n");
693 yield* mustGit(a, "add", "-A");
694 yield* mustGit(a, "commit", "-m", "c1");
695 yield* fs.writeFileString(path.join(a, "file.txt"), "v2\n");
696 yield* mustGit(a, "add", "-A");
697 yield* mustGit(a, "commit", "-m", "c2");
698 yield* mustGit(a, "push", "origin", "main");
699
700 // clone-back is fsck-clean and log-identical — transitively proves
701 // advertisement, negotiation, pack emission, and object round-tripping
702 yield* mustGit(tmp, "clone", remote, "b");
703 const b = path.join(tmp, "b");
704 yield* mustGit(b, "fsck", "--strict");
705 const logA = (yield* mustGit(a, "log", "--format=%H %s")).stdout;
706 const logB = (yield* mustGit(b, "log", "--format=%H %s")).stdout;
707 expect(logB).toBe(logA);
708
709 // two more commits in A → B fetches incrementally (the haves/ACK path)
710 yield* fs.writeFileString(path.join(a, "file.txt"), "v3\n");
711 yield* mustGit(a, "add", "-A");
712 yield* mustGit(a, "commit", "-m", "c3");
713 yield* fs.writeFileString(path.join(a, "extra.txt"), "extra\n");
714 yield* mustGit(a, "add", "-A");
715 yield* mustGit(a, "commit", "-m", "c4");
716 yield* mustGit(a, "push", "origin", "main");
717 const headA = (yield* mustGit(a, "rev-parse", "HEAD")).stdout;
718
719 yield* mustGit(b, "fetch", "origin");
720 yield* mustGit(b, "merge", "--ff-only", "origin/main");
721 expect((yield* mustGit(b, "rev-parse", "HEAD")).stdout).toBe(headA);
722 expect(yield* fs.readFileString(path.join(b, "file.txt"))).toBe("v3\n");
723 }).pipe(logLevel),
724 {
725 tags: [
726 "provider:cloudflare",
727 "provider:cloudflare:r2",
728 "provider:cloudflare:worker",
729 "local",
730 ],
731 timeout: 120_000,
732 },
733);
734
735test(
736 "git: force-push CAS, branch + annotated tag lifecycle",
737 Effect.gen(function* () {
738 const { url } = yield* stack;
739 const { client: admin, remote } = yield* createRepo(
740 url,
741 "acme",
742 "wire-refs",
743 );
744 const fs = yield* FileSystem.FileSystem;
745 const path = yield* Path.Path;
746 const tmp = yield* tempDir;
747 const params = { owner: "acme", repo: "wire-refs" };
748
749 yield* mustGit(tmp, "-c", "init.defaultBranch=main", "clone", remote, "a");
750 const a = path.join(tmp, "a");
751 yield* fs.writeFileString(path.join(a, "f.txt"), "one\n");
752 yield* mustGit(a, "add", "-A");
753 yield* mustGit(a, "commit", "-m", "c1");
754 yield* fs.writeFileString(path.join(a, "f.txt"), "two\n");
755 yield* mustGit(a, "add", "-A");
756 yield* mustGit(a, "commit", "-m", "c2");
757 yield* mustGit(a, "push", "origin", "main");
758
759 // B clones now, then A advances the remote — B's next push is stale
760 yield* mustGit(tmp, "clone", remote, "b");
761 const b = path.join(tmp, "b");
762 yield* fs.writeFileString(path.join(a, "f.txt"), "three\n");
763 yield* mustGit(a, "add", "-A");
764 yield* mustGit(a, "commit", "-m", "c3");
765 yield* mustGit(a, "push", "origin", "main");
766
767 // B pushes its own commit against the stale old-oid → server-side CAS ng
768 yield* fs.writeFileString(path.join(b, "g.txt"), "divergent\n");
769 yield* mustGit(b, "add", "-A");
770 yield* mustGit(b, "commit", "-m", "divergent");
771 const rejected = yield* mustFailGit(b, "push", "origin", "main");
772 expect(`${rejected.stdout}\n${rejected.stderr}`).toContain("rejected");
773
774 // fetch + force overwrites; A fetches the forced history cleanly
775 yield* mustGit(b, "fetch", "origin");
776 yield* mustGit(b, "push", "--force", "origin", "main");
777 const headB = (yield* mustGit(b, "rev-parse", "HEAD")).stdout;
778 yield* mustGit(a, "fetch", "origin");
779 expect((yield* mustGit(a, "rev-parse", "origin/main")).stdout).toBe(headB);
780
781 // annotated tag round-trips as a tag object with a peeled advertisement
782 yield* mustGit(b, "tag", "-a", "v1", "-m", "release v1");
783 yield* mustGit(b, "push", "origin", "v1");
784 const tagOid = (yield* mustGit(b, "rev-parse", "v1")).stdout;
785 const tagRef = yield* admin.refs.get({
786 params,
787 query: { name: "refs/tags/v1" },
788 });
789 expect(tagRef.oid).toBe(tagOid);
790 expect(tagRef.peeled).toBe(headB); // peeled to the commit it annotates
791 const lsRemote = (yield* mustGit(b, "ls-remote", "--tags", "origin"))
792 .stdout;
793 expect(lsRemote).toContain(`${tagOid}\trefs/tags/v1`);
794 expect(lsRemote).toContain(`${headB}\trefs/tags/v1^{}`);
795
796 // branch push + delete-refs for branch and tag
797 yield* mustGit(b, "push", "origin", "main:feature");
798 const feature = yield* admin.refs.get({
799 params,
800 query: { name: "refs/heads/feature" },
801 });
802 expect(feature.oid).toBe(headB);
803 yield* mustGit(b, "push", "origin", "--delete", "feature");
804 yield* mustGit(b, "push", "origin", "--delete", "v1");
805 yield* expectTag(
806 admin.refs.get({ params, query: { name: "refs/heads/feature" } }),
807 "RefNotFound",
808 );
809 yield* expectTag(
810 admin.refs.get({ params, query: { name: "refs/tags/v1" } }),
811 "RefNotFound",
812 );
813 }).pipe(logLevel),
814 {
815 tags: [
816 "provider:cloudflare",
817 "provider:cloudflare:r2",
818 "provider:cloudflare:worker",
819 "local",
820 ],
821 timeout: 120_000,
822 },
823);
824
825test(
826 "git: readOnly repos reject pushes in-band, then accept after unflag",
827 Effect.gen(function* () {
828 const { url } = yield* stack;
829 const { client: admin, remote } = yield* createRepo(
830 url,
831 "acme",
832 "wire-readonly",
833 );
834 const fs = yield* FileSystem.FileSystem;
835 const path = yield* Path.Path;
836 const tmp = yield* tempDir;
837 const params = { owner: "acme", repo: "wire-readonly" };
838
839 yield* mustGit(
840 tmp,
841 "-c",
842 "init.defaultBranch=main",
843 "clone",
844 remote,
845 "work",
846 );
847 const work = path.join(tmp, "work");
848 yield* fs.writeFileString(path.join(work, "f.txt"), "one\n");
849 yield* mustGit(work, "add", "-A");
850 yield* mustGit(work, "commit", "-m", "c1");
851 yield* mustGit(work, "push", "origin", "main");
852
853 yield* admin.repos.update({ params, payload: { readOnly: true } });
854 yield* fs.writeFileString(path.join(work, "f.txt"), "two\n");
855 yield* mustGit(work, "add", "-A");
856 yield* mustGit(work, "commit", "-m", "c2");
857 const rejected = yield* mustFailGit(work, "push", "origin", "main");
858 expect(`${rejected.stdout}\n${rejected.stderr}`).toContain("rejected");
859
860 yield* admin.repos.update({ params, payload: { readOnly: false } });
861 yield* mustGit(work, "push", "origin", "main");
862 const head = (yield* mustGit(work, "rev-parse", "HEAD")).stdout;
863 const mainRef = yield* admin.refs.get({
864 params,
865 query: { name: "refs/heads/main" },
866 });
867 expect(mainRef.oid).toBe(head);
868 }).pipe(logLevel),
869 {
870 tags: [
871 "provider:cloudflare",
872 "provider:cloudflare:r2",
873 "provider:cloudflare:worker",
874 "local",
875 ],
876 timeout: 120_000,
877 },
878);
879
880test(
881 "git: wire auth matrix — garbage token 401s, read token clones but cannot push",
882 Effect.gen(function* () {
883 const { url } = yield* stack;
884 const { client: admin, remote } = yield* createRepo(
885 url,
886 "acme",
887 "wire-auth",
888 );
889 const fs = yield* FileSystem.FileSystem;
890 const path = yield* Path.Path;
891 const tmp = yield* tempDir;
892 const params = { owner: "acme", repo: "wire-auth" };
893
894 // seed one commit with the bootstrap write token
895 yield* mustGit(
896 tmp,
897 "-c",
898 "init.defaultBranch=main",
899 "clone",
900 remote,
901 "seed",
902 );
903 const seed = path.join(tmp, "seed");
904 yield* fs.writeFileString(path.join(seed, "f.txt"), "seed\n");
905 yield* mustGit(seed, "add", "-A");
906 yield* mustGit(seed, "commit", "-m", "c1");
907 yield* mustGit(seed, "push", "origin", "main");
908
909 // garbage token → 401 before any pack bytes flow. git surfaces the 401
910 // as "Authentication failed" (it never echoes the literal status code).
911 const badRemote = yield* authRemote(url, "gs_garbage", "acme", "wire-auth");
912 const unauthorized = yield* mustFailGit(tmp, "clone", badRemote, "nope");
913 expect(unauthorized.stderr).toContain("Authentication failed");
914
915 // a read-scope token clones (token in the remote URL) but cannot push
916
917 // the write path still works for the bootstrap token
918 yield* fs.writeFileString(path.join(seed, "f.txt"), "seed2\n");
919 yield* mustGit(seed, "add", "-A");
920 yield* mustGit(seed, "commit", "-m", "c2");
921 yield* mustGit(seed, "push", "origin", "main");
922 }).pipe(logLevel),
923 {
924 tags: [
925 "provider:cloudflare",
926 "provider:cloudflare:r2",
927 "provider:cloudflare:worker",
928 "local",
929 ],
930 timeout: 120_000,
931 },
932);
933
934// ═══════════════════════════════════════════════════════════════════════════
935// v2 storage plane: compaction (DESIGN.md §12.1)
936// ═══════════════════════════════════════════════════════════════════════════
937
938test(
939 "compaction: objects move into an R2 pack and clones stay byte-identical",
940 Effect.gen(function* () {
941 const { url } = yield* stack;
942 const repo = yield* createRepo(url, "acme", "compaction");
943 const fs = yield* FileSystem.FileSystem;
944 const path = yield* Path.Path;
945 const tmp = yield* tempDir;
946
947 // A history with enough distinct blobs that a compaction run has real
948 // work to do, plus one binary file whose bytes must survive exactly.
949 yield* mustGit(
950 tmp,
951 "-c",
952 "init.defaultBranch=main",
953 "clone",
954 repo.remote,
955 "work",
956 );
957 const work = path.join(tmp, "work");
958 const binary = new Uint8Array(4096);
959 for (let i = 0; i < binary.length; i++) binary[i] = (i * 31 + 7) & 0xff;
960 yield* fs.writeFile(path.join(work, "blob.bin"), binary);
961 for (let i = 0; i < 12; i++) {
962 yield* fs.writeFileString(path.join(work, `f${i}.txt`), `content ${i}\n`);
963 }
964 yield* mustGit(work, "add", "-A");
965 yield* mustGit(work, "commit", "-m", "seed");
966 yield* mustGit(work, "push", "origin", "main");
967
968 // Compaction is normally armed by size thresholds; force a run now so
969 // the test does not depend on repo size.
970 yield* repo.client.repos.compact({
971 params: { owner: "acme", repo: "compaction" },
972 });
973
974 const stats = yield* repo.client.repos
975 .get({ params: { owner: "acme", repo: "compaction" } })
976 .pipe(
977 Effect.map((found) => found.objects),
978 Effect.repeat({
979 schedule: Schedule.spaced("500 millis"),
980 until: (objects: { loose: number; packed: number }) =>
981 objects.packed > 0 && objects.loose === 0,
982 times: 40,
983 }),
984 );
985 expect(stats.packed).toBeGreaterThan(0);
986 expect(stats.loose).toBe(0);
987 // Only blobs move to R2 (Compact.ts BLOB_TYPE): commits/trees/tags stay
988 // row-resident so the fetch closure never walks pack windows.
989 expect(stats.resident).toBeGreaterThan(0);
990
991 // Everything now reads through R2 ranged GETs: a fresh clone must be
992 // fsck-clean and byte-identical, and REST blob reads must still work.
993 yield* mustGit(tmp, "clone", repo.remote, "verify");
994 const verify = path.join(tmp, "verify");
995 yield* mustGit(verify, "fsck", "--strict");
996 const round = yield* fs.readFile(path.join(verify, "blob.bin"));
997 expect(Array.from(round)).toEqual(Array.from(binary));
998 for (let i = 0; i < 12; i++) {
999 const text = yield* fs.readFileString(path.join(verify, `f${i}.txt`));
1000 expect(text).toBe(`content ${i}\n`);
1001 }
1002
1003 // ...and an incremental push on top of a fully packed repo still works.
1004 yield* fs.writeFileString(path.join(work, "after.txt"), "after\n");
1005 yield* mustGit(work, "add", "-A");
1006 yield* mustGit(work, "commit", "-m", "after-compaction");
1007 yield* mustGit(work, "push", "origin", "main");
1008 yield* mustGit(verify, "pull", "origin", "main");
1009 expect(yield* fs.readFileString(path.join(verify, "after.txt"))).toBe(
1010 "after\n",
1011 );
1012 }).pipe(logLevel),
1013 {
1014 tags: [
1015 "provider:cloudflare",
1016 "provider:cloudflare:r2",
1017 "provider:cloudflare:worker",
1018 "local",
1019 ],
1020 timeout: 120_000,
1021 },
1022);
1023
1024test(
1025 "clone bundles: a full clone is served from the R2 bundle, byte-identically",
1026 Effect.gen(function* () {
1027 const { url } = yield* stack;
1028 const repo = yield* createRepo(url, "acme", "bundles");
1029 const fs = yield* FileSystem.FileSystem;
1030 const path = yield* Path.Path;
1031 const tmp = yield* tempDir;
1032
1033 yield* mustGit(
1034 tmp,
1035 "-c",
1036 "init.defaultBranch=main",
1037 "clone",
1038 repo.remote,
1039 "work",
1040 );
1041 const work = path.join(tmp, "work");
1042 yield* fs.writeFileString(path.join(work, "a.txt"), "one\n");
1043 yield* fs.makeDirectory(path.join(work, "sub"), { recursive: true });
1044 yield* fs.writeFileString(path.join(work, "sub", "b.txt"), "two\n");
1045 yield* mustGit(work, "add", "-A");
1046 yield* mustGit(work, "commit", "-m", "c1");
1047 yield* mustGit(work, "push", "origin", "main");
1048 const head = (yield* mustGit(work, "rev-parse", "HEAD")).stdout;
1049
1050 // The bundle is cut by the post-push alarm; wait for it to land, then
1051 // clone — that clone is served straight from the R2 bundle bytes.
1052 yield* Effect.sleep("4 seconds");
1053 yield* mustGit(tmp, "clone", repo.remote, "fromBundle");
1054 const fromBundle = path.join(tmp, "fromBundle");
1055 yield* mustGit(fromBundle, "fsck", "--strict");
1056 expect((yield* mustGit(fromBundle, "rev-parse", "HEAD")).stdout).toBe(head);
1057 expect(yield* fs.readFileString(path.join(fromBundle, "a.txt"))).toBe(
1058 "one\n",
1059 );
1060 expect(
1061 yield* fs.readFileString(path.join(fromBundle, "sub", "b.txt")),
1062 ).toBe("two\n");
1063
1064 // A push after the bundle was cut invalidates it: the next clone must
1065 // still see the NEW commit (served dynamically, or from a fresh bundle).
1066 yield* fs.writeFileString(path.join(work, "c.txt"), "three\n");
1067 yield* mustGit(work, "add", "-A");
1068 yield* mustGit(work, "commit", "-m", "c2");
1069 yield* mustGit(work, "push", "origin", "main");
1070 const head2 = (yield* mustGit(work, "rev-parse", "HEAD")).stdout;
1071 yield* mustGit(tmp, "clone", repo.remote, "afterPush");
1072 const afterPush = path.join(tmp, "afterPush");
1073 yield* mustGit(afterPush, "fsck", "--strict");
1074 expect((yield* mustGit(afterPush, "rev-parse", "HEAD")).stdout).toBe(head2);
1075
1076 // A single-branch clone is covered by the same (superset) bundle.
1077 yield* Effect.sleep("4 seconds");
1078 yield* mustGit(
1079 tmp,
1080 "clone",
1081 "--single-branch",
1082 "--branch",
1083 "main",
1084 repo.remote,
1085 "single",
1086 );
1087 yield* mustGit(path.join(tmp, "single"), "fsck", "--strict");
1088 }).pipe(logLevel),
1089 {
1090 tags: [
1091 "provider:cloudflare",
1092 "provider:cloudflare:r2",
1093 "provider:cloudflare:worker",
1094 "local",
1095 ],
1096 timeout: 120_000,
1097 },
1098);
1099
1100test(
1101 "clone bundles: the wire response proves the R2 fast path served it",
1102 Effect.gen(function* () {
1103 const { url } = yield* stack;
1104 const repo = yield* createRepo(url, "acme", "bundle-wire");
1105 const fs = yield* FileSystem.FileSystem;
1106 const path = yield* Path.Path;
1107 const tmp = yield* tempDir;
1108
1109 yield* mustGit(
1110 tmp,
1111 "-c",
1112 "init.defaultBranch=main",
1113 "clone",
1114 repo.remote,
1115 "work",
1116 );
1117 const work = path.join(tmp, "work");
1118 yield* fs.writeFileString(path.join(work, "f.txt"), "hello\n");
1119 yield* mustGit(work, "add", "-A");
1120 yield* mustGit(work, "commit", "-m", "c1");
1121 yield* mustGit(work, "push", "origin", "main");
1122 const head = (yield* mustGit(work, "rev-parse", "HEAD")).stdout;
1123
1124 yield* Effect.sleep("4 seconds");
1125
1126 // Hand-built v0 upload-pack request: one `want`, flush, `done` — the
1127 // shape a fresh clone sends. No capabilities, so the response is
1128 // `NAK` followed by the raw pack.
1129 const pkt = (line: string) =>
1130 `${(line.length + 4).toString(16).padStart(4, "0")}${line}`;
1131 const body = `${pkt(`want ${head}\n`)}0000${pkt("done\n")}`;
1132 const client = yield* HttpClient.HttpClient;
1133 const response = yield* client.execute(
1134 HttpClientRequest.post(
1135 `${url}/acme/bundle-wire.git/git-upload-pack`,
1136 ).pipe(
1137 HttpClientRequest.setHeaders({
1138 authorization: `Bearer ${repo.token}`,
1139 "content-type": "application/x-git-upload-pack-request",
1140 }),
1141 HttpClientRequest.bodyText(body),
1142 ),
1143 );
1144 expect(response.status).toBe(200);
1145 // The header is only set on the bundle path (DESIGN.md §12.2).
1146 expect(response.headers["x-git-bundle"]).toBeDefined();
1147
1148 const bytes = yield* response.arrayBuffer.pipe(
1149 Effect.map((buffer) => new Uint8Array(buffer)),
1150 );
1151 const text = new TextDecoder().decode(bytes.subarray(0, 8));
1152 expect(text).toBe("0008NAK\n");
1153 expect(new TextDecoder().decode(bytes.subarray(8, 12))).toBe("PACK");
1154 }).pipe(logLevel),
1155 {
1156 tags: [
1157 "provider:cloudflare",
1158 "provider:cloudflare:r2",
1159 "provider:cloudflare:worker",
1160 "local",
1161 ],
1162 timeout: 120_000,
1163 },
1164);
1165
1166test(
1167 "clone bundles: sideband clones are served from the pre-framed twin, raw from the bundle — both whole packs",
1168 Effect.gen(function* () {
1169 const { url } = yield* stack;
1170 const repo = yield* createRepo(url, "acme", "bundle-framed");
1171 const fs = yield* FileSystem.FileSystem;
1172 const path = yield* Path.Path;
1173 const tmp = yield* tempDir;
1174 yield* mustGit(
1175 tmp,
1176 "-c",
1177 "init.defaultBranch=main",
1178 "clone",
1179 repo.remote,
1180 "work",
1181 );
1182 const work = path.join(tmp, "work");
1183 for (let i = 0; i < 20; i++) {
1184 yield* fs.writeFileString(
1185 path.join(work, `f${i}.txt`),
1186 `${"line\n".repeat(200)}${i}\n`,
1187 );
1188 }
1189 yield* mustGit(work, "add", "-A");
1190 yield* mustGit(work, "commit", "-m", "c1");
1191 yield* mustGit(work, "push", "origin", "main");
1192 const head = (yield* mustGit(work, "rev-parse", "HEAD")).stdout;
1193
1194 const pkt = (line: string) =>
1195 `${(line.length + 4).toString(16).padStart(4, "0")}${line}`;
1196 const client = yield* HttpClient.HttpClient;
1197 const fetchPack = (caps: string) =>
1198 client
1199 .execute(
1200 HttpClientRequest.post(
1201 `${url}/acme/bundle-framed.git/git-upload-pack`,
1202 ).pipe(
1203 HttpClientRequest.setHeaders({
1204 authorization: `Bearer ${repo.token}`,
1205 "content-type": "application/x-git-upload-pack-request",
1206 }),
1207 HttpClientRequest.bodyText(
1208 `${pkt(`want ${head}${caps}\n`)}0000${pkt("done\n")}`,
1209 ),
1210 ),
1211 )
1212 .pipe(
1213 Effect.flatMap((response) =>
1214 response.arrayBuffer.pipe(
1215 Effect.map((buffer) => ({
1216 status: response.status,
1217 via: response.headers["x-git-served-by"],
1218 bytes: new Uint8Array(buffer),
1219 })),
1220 ),
1221 ),
1222 );
1223
1224 // The bundle job runs after the push; wait for it to cover HEAD.
1225 const sideband = yield* fetchPack(" side-band-64k").pipe(
1226 Effect.repeat({
1227 schedule: Schedule.spaced("500 millis"),
1228 until: (r) => r.via !== undefined,
1229 times: 30,
1230 }),
1231 );
1232 expect(sideband.status).toBe(200);
1233 // Access was decided in front of the route, so a credentialed clone
1234 // takes the same DO-less path as an anonymous one.
1235 expect(sideband.via).toBe("head-snapshot:bundle+framed");
1236 const framed = verifyPackResponse(sideband.bytes, true);
1237 expect(framed.error).toBeUndefined();
1238 expect(framed.objects).toBeGreaterThan(20);
1239
1240 const raw = yield* fetchPack("");
1241 expect(raw.via).toBe("head-snapshot:bundle");
1242 const plain = verifyPackResponse(raw.bytes, false);
1243 expect(plain.error).toBeUndefined();
1244 expect(plain.objects).toBe(framed.objects);
1245
1246 // And real git agrees.
1247 yield* mustGit(tmp, "clone", repo.remote, "again");
1248 yield* mustGit(path.join(tmp, "again"), "fsck", "--strict");
1249 }),
1250 {
1251 tags: [
1252 "provider:cloudflare",
1253 "provider:cloudflare:r2",
1254 "provider:cloudflare:worker",
1255 "local",
1256 ],
1257 timeout: 90_000,
1258 },
1259);
1260
1261test(
1262 "clone bundles: a stale bundle falls back cleanly to the dynamic path",
1263 Effect.gen(function* () {
1264 const { url } = yield* stack;
1265 const repo = yield* createRepo(url, "acme", "bundle-splice");
1266 const fs = yield* FileSystem.FileSystem;
1267 const path = yield* Path.Path;
1268 const tmp = yield* tempDir;
1269
1270 yield* mustGit(
1271 tmp,
1272 "-c",
1273 "init.defaultBranch=main",
1274 "clone",
1275 repo.remote,
1276 "work",
1277 );
1278 const work = path.join(tmp, "work");
1279 yield* fs.writeFileString(path.join(work, "f.txt"), "hello\n");
1280 yield* mustGit(work, "add", "-A");
1281 yield* mustGit(work, "commit", "-m", "c1");
1282 yield* mustGit(work, "push", "origin", "main");
1283
1284 // Let the bundle land for THIS ref snapshot...
1285 yield* Effect.sleep("4 seconds");
1286
1287 // ...then move main past it, so every clone wants an oid the bundle
1288 // does not carry.
1289 yield* fs.writeFileString(path.join(work, "g.txt"), "world\n");
1290 yield* mustGit(work, "add", "-A");
1291 yield* mustGit(work, "commit", "-m", "c2");
1292 yield* fs.writeFileString(path.join(work, "h.txt"), "again\n");
1293 yield* mustGit(work, "add", "-A");
1294 yield* mustGit(work, "commit", "-m", "c3");
1295 yield* mustGit(work, "push", "origin", "main");
1296 const head = (yield* mustGit(work, "rev-parse", "HEAD")).stdout;
1297
1298 // Raw wire request, immediately — before the bundle job re-cuts, so
1299 // the bundle on disk is definitively stale.
1300 const pkt = (line: string) =>
1301 `${(line.length + 4).toString(16).padStart(4, "0")}${line}`;
1302 const client = yield* HttpClient.HttpClient;
1303 const response = yield* client.execute(
1304 HttpClientRequest.post(
1305 `${url}/acme/bundle-splice.git/git-upload-pack`,
1306 ).pipe(
1307 HttpClientRequest.setHeaders({
1308 authorization: `Bearer ${repo.token}`,
1309 "content-type": "application/x-git-upload-pack-request",
1310 }),
1311 HttpClientRequest.bodyText(
1312 `${pkt(`want ${head}\n`)}0000${pkt("done\n")}`,
1313 ),
1314 ),
1315 );
1316 expect(response.status).toBe(200);
1317 // A stale bundle is NOT used: serving it would need a true delta, and
1318 // `computeClosure` deliberately does not subtract boundary trees (see
1319 // Closure.ts "accepted fat"), so the walk is the honest answer here.
1320 expect(response.headers["x-git-bundle"]).toBeUndefined();
1321 const bytes = yield* response.arrayBuffer.pipe(
1322 Effect.map((buffer) => new Uint8Array(buffer)),
1323 );
1324 expect(new TextDecoder().decode(bytes.subarray(0, 8))).toBe("0008NAK\n");
1325 expect(new TextDecoder().decode(bytes.subarray(8, 12))).toBe("PACK");
1326
1327 // And the clone is still correct end to end through that path.
1328 yield* mustGit(tmp, "clone", repo.remote, "spliced");
1329 const spliced = path.join(tmp, "spliced");
1330 yield* mustGit(spliced, "fsck", "--strict");
1331 expect((yield* mustGit(spliced, "rev-parse", "HEAD")).stdout).toBe(head);
1332 const logWork = (yield* mustGit(work, "log", "--format=%H %s")).stdout;
1333 const logSpliced = (yield* mustGit(spliced, "log", "--format=%H %s"))
1334 .stdout;
1335 expect(logSpliced).toBe(logWork);
1336 }).pipe(logLevel),
1337 {
1338 tags: [
1339 "provider:cloudflare",
1340 "provider:cloudflare:r2",
1341 "provider:cloudflare:worker",
1342 "local",
1343 ],
1344 timeout: 180_000,
1345 },
1346);
1347
1348// ═══════════════════════════════════════════════════════════════════════════
1349// (j) public repos — anonymous read access (GitHub model)
1350// ═══════════════════════════════════════════════════════════════════════════
1351
1352test(
1353 "public repos: anonymous REST reads + tokenless clone; writes still need a token",
1354 Effect.gen(function* () {
1355 const { url } = yield* stack;
1356 const admin = yield* makeClient(url, TEST_SECRET);
1357 const anonymous = yield* makeAnonymousClient(url);
1358 const tmp = yield* tempDir;
1359 const path = yield* Path.Path;
1360
1361 // A public repo with one commit pushed by its owner.
1362 const repo = yield* createRepo(url, "acme", "town-square");
1363 yield* admin.repos.update({
1364 params: { owner: "acme", repo: "town-square" },
1365 payload: { public: true },
1366 });
1367 const fs = yield* FileSystem.FileSystem;
1368 const pub = path.join(tmp, "pub");
1369 yield* fs.makeDirectory(pub, { recursive: true });
1370 yield* mustGit(pub, "init", "-q", "-b", "main");
1371 yield* fs.writeFileString(
1372 path.join(pub, "greeting.txt"),
1373 "hello anonymous\n",
1374 );
1375 yield* mustGit(pub, "add", "-A");
1376 yield* mustGit(pub, "commit", "-qm", "public commit");
1377 yield* mustGit(pub, "push", "-q", repo.remote, "main");
1378
1379 // Anonymous REST: repo meta, refs, log, tree, blob — no token anywhere.
1380 const meta = yield* anonymous.repos.get({
1381 params: { owner: "acme", repo: "town-square" },
1382 });
1383 expect(meta.public).toBe(true);
1384 const refs = yield* anonymous.refs.list({
1385 params: { owner: "acme", repo: "town-square" },
1386 query: {},
1387 });
1388 expect(refs.head).toBe("refs/heads/main");
1389 const head = refs.refs.find((ref) => ref.name === "refs/heads/main")!;
1390 const commit = yield* anonymous.objects.commit({
1391 params: { owner: "acme", repo: "town-square", oid: head.oid },
1392 });
1393 expect(commit.message).toContain("public commit");
1394 const log = yield* anonymous.objects.log({
1395 params: { owner: "acme", repo: "town-square" },
1396 query: {},
1397 });
1398 expect(log.items.length).toBe(1);
1399
1400 // The public-only listing shows it.
1401 const listing = yield* admin.repos.list({ query: { public: true } });
1402 expect(listing.items.some((row) => row.name === "town-square")).toBe(true);
1403
1404 // Tokenless clone over the wire.
1405 const parsed = new URL(url);
1406 const anonymousRemote = `${parsed.protocol}//${parsed.host}/acme/town-square.git`;
1407 yield* mustGit(tmp, `clone`, `-q`, anonymousRemote, `anon-clone`);
1408 const cloned = yield* mustGit(
1409 path.join(tmp, "anon-clone"),
1410 `show`,
1411 `HEAD:greeting.txt`,
1412 );
1413 expect(cloned.stdout.trim()).toBe("hello anonymous");
1414
1415 // Writes stay locked: anonymous push is rejected (401 → git fails).
1416 const anonWork = path.join(tmp, "anon-clone");
1417 yield* fs.writeFileString(
1418 path.join(anonWork, "greeting.txt"),
1419 "hello anonymous\nmore\n",
1420 );
1421 yield* mustGit(anonWork, "add", "-A");
1422 yield* mustGit(anonWork, "commit", "-qm", "anon write");
1423 const push = yield* Effect.result(
1424 mustGit(anonWork, "push", "-q", "origin", "main"),
1425 );
1426 expect(Result.isFailure(push)).toBe(true);
1427
1428 // Anonymous token management is rejected too.
1429 yield* expectTag(
1430 anonymous.repos.update({
1431 params: { owner: "acme", repo: "town-square" },
1432 payload: { description: "anon" },
1433 }),
1434 "Unauthorized",
1435 );
1436
1437 // Flipping back to private locks anonymous readers out again.
1438 yield* admin.repos.update({
1439 params: { owner: "acme", repo: "town-square" },
1440 payload: { public: false },
1441 });
1442 yield* expectTag(
1443 anonymous.repos.get({ params: { owner: "acme", repo: "town-square" } }),
1444 "Unauthorized",
1445 );
1446 const privateListing = yield* admin.repos.list({
1447 query: { public: true },
1448 });
1449 expect(privateListing.items.some((row) => row.name === "town-square")).toBe(
1450 false,
1451 );
1452 const privateClone = yield* Effect.result(
1453 mustGit(tmp, `clone`, `-q`, anonymousRemote, `anon-clone-private`),
1454 );
1455 expect(Result.isFailure(privateClone)).toBe(true);
1456 }),
1457 {
1458 tags: [
1459 "provider:cloudflare",
1460 "provider:cloudflare:r2",
1461 "provider:cloudflare:worker",
1462 "local",
1463 ],
1464 timeout: 120_000,
1465 },
1466);
1467
1468// ═══════════════════════════════════════════════════════════════════════════
1469// (k) diffs & compare — server-side changed-file lists
1470// ═══════════════════════════════════════════════════════════════════════════
1471
1472test(
1473 "diff: adds/mods/deletes/mode change across two commits; root commit vs empty tree",
1474 Effect.gen(function* () {
1475 const { url } = yield* stack;
1476 const fs = yield* FileSystem.FileSystem;
1477 const { client, token } = yield* createRepo(url, "acme", "diffs");
1478 const work = yield* tempDir;
1479 yield* mustGit(work, "init", "-q", "-b", "main");
1480
1481 // commit 1: a.txt, dir/b.txt, script.sh (0644)
1482 yield* fs.writeFileString(`${work}/a.txt`, "one\n");
1483 yield* fs.makeDirectory(`${work}/dir`);
1484 yield* fs.writeFileString(`${work}/dir/b.txt`, "bee\n");
1485 yield* fs.writeFileString(`${work}/script.sh`, "#!/bin/sh\n");
1486 yield* mustGit(work, "add", ".");
1487 yield* mustGit(work, "commit", "-qm", "c1");
1488 const c1 = yield* revParse(work, "HEAD");
1489
1490 // commit 2: modify a.txt, delete dir/b.txt, add c.txt, chmod +x script.sh
1491 yield* fs.writeFileString(`${work}/a.txt`, "one\ntwo\n");
1492 yield* mustGit(work, "rm", "-q", "dir/b.txt");
1493 yield* fs.writeFileString(`${work}/c.txt`, "sea\n");
1494 yield* mustGit(work, "update-index", "--chmod=+x", "script.sh");
1495 yield* mustGit(work, "add", "a.txt", "c.txt");
1496 yield* mustGit(work, "commit", "-qm", "c2");
1497 const c2 = yield* revParse(work, "HEAD");
1498
1499 const remote = yield* authRemote(url, token, "acme", "diffs");
1500 yield* mustGit(work, "push", "-q", remote, "main");
1501
1502 // c2 vs c1
1503 const d2 = yield* client.objects.diff({
1504 params: { owner: "acme", repo: "diffs", oid: asOid(c2) },
1505 });
1506 expect(d2.parent).toBe(c1);
1507 expect(d2.truncated).toBe(false);
1508 const byPath = new Map(d2.files.map((f) => [f.path, f]));
1509 expect(byPath.get("a.txt")?.status).toBe("modified");
1510 expect(byPath.get("a.txt")?.oldOid).not.toBe(byPath.get("a.txt")?.newOid);
1511 expect(byPath.get("dir/b.txt")?.status).toBe("removed");
1512 expect(byPath.get("dir/b.txt")?.oldOid).toBeDefined();
1513 expect(byPath.get("dir/b.txt")?.newOid).toBeUndefined();
1514 expect(byPath.get("c.txt")?.status).toBe("added");
1515 // mode-only change: same oid, different modes, still "modified"
1516 const script = byPath.get("script.sh")!;
1517 expect(script.status).toBe("modified");
1518 expect(script.oldMode).toBe("100644");
1519 expect(script.newMode).toBe("100755");
1520 expect(script.oldOid).toBe(script.newOid);
1521 expect(d2.files.length).toBe(4);
1522 // sizes present for text blobs
1523 expect(byPath.get("a.txt")?.newSize).toBe(8);
1524
1525 // root commit: parent null, everything added
1526 const d1 = yield* client.objects.diff({
1527 params: { owner: "acme", repo: "diffs", oid: asOid(c1) },
1528 });
1529 expect(d1.parent).toBeNull();
1530 expect(d1.files.every((f) => f.status === "added")).toBe(true);
1531 expect(d1.files.map((f) => f.path).sort()).toEqual([
1532 "a.txt",
1533 "dir/b.txt",
1534 "script.sh",
1535 ]);
1536
1537 // wrong object type: a tree oid on the diff endpoint → 422
1538 const treeOid = (yield* client.objects.commit({
1539 params: { owner: "acme", repo: "diffs", oid: asOid(c2) },
1540 })).tree;
1541 yield* expectTag(
1542 client.objects.diff({
1543 params: { owner: "acme", repo: "diffs", oid: treeOid },
1544 }),
1545 "WrongObjectType",
1546 );
1547 }),
1548 {
1549 tags: [
1550 "provider:cloudflare",
1551 "provider:cloudflare:r2",
1552 "provider:cloudflare:worker",
1553 "local",
1554 ],
1555 timeout: 120_000,
1556 },
1557);
1558
1559test(
1560 "diff: compare merge base across branches, ahead/behind, three-dot files, ref names",
1561 Effect.gen(function* () {
1562 const { url } = yield* stack;
1563 const fs = yield* FileSystem.FileSystem;
1564 const { client, token } = yield* createRepo(url, "acme", "compare");
1565 const work = yield* tempDir;
1566 yield* mustGit(work, "init", "-q", "-b", "main");
1567 yield* fs.writeFileString(`${work}/base.txt`, "base\n");
1568 yield* mustGit(work, "add", ".");
1569 yield* mustGit(work, "commit", "-qm", "base");
1570 const fork = yield* revParse(work, "HEAD");
1571
1572 // feature: 1 commit touching feature.txt
1573 yield* mustGit(work, "checkout", "-qb", "feature");
1574 yield* fs.writeFileString(`${work}/feature.txt`, "feat\n");
1575 yield* mustGit(work, "add", ".");
1576 yield* mustGit(work, "commit", "-qm", "feat 1");
1577 const featTip = yield* revParse(work, "HEAD");
1578
1579 // main: 2 more commits touching main-only files
1580 yield* mustGit(work, "checkout", "-q", "main");
1581 yield* fs.writeFileString(`${work}/m1.txt`, "m1\n");
1582 yield* mustGit(work, "add", ".");
1583 yield* mustGit(work, "commit", "-qm", "m1");
1584 yield* fs.writeFileString(`${work}/m2.txt`, "m2\n");
1585 yield* mustGit(work, "add", ".");
1586 yield* mustGit(work, "commit", "-qm", "m2");
1587
1588 const remote = yield* authRemote(url, token, "acme", "compare");
1589 yield* mustGit(work, "push", "-q", remote, "main", "feature");
1590
1591 const cmp = yield* client.objects.compare({
1592 params: { owner: "acme", repo: "compare" },
1593 query: { base: "main", head: "feature" }, // short ref names
1594 });
1595 expect(cmp.mergeBase).toBe(fork);
1596 expect(cmp.head).toBe(featTip);
1597 expect(cmp.aheadBy).toBe(1);
1598 expect(cmp.behindBy).toBe(2);
1599 expect(cmp.commits.length).toBe(1);
1600 expect(cmp.commits[0]!.message).toContain("feat 1");
1601 // three-dot: only the feature-side change, none of main's m1/m2
1602 expect(cmp.files.map((f) => f.path)).toEqual(["feature.txt"]);
1603 expect(cmp.files[0]!.status).toBe("added");
1604 expect(cmp.filesTruncated).toBe(false);
1605
1606 // reversed: head is behind → empty files, counts swap
1607 const rev = yield* client.objects.compare({
1608 params: { owner: "acme", repo: "compare" },
1609 query: { base: "feature", head: fork }, // oid accepted too
1610 });
1611 expect(rev.mergeBase).toBe(fork);
1612 expect(rev.aheadBy).toBe(0);
1613 expect(rev.files.length).toBe(0);
1614
1615 // unknown ref → RefNotFound (404)
1616 yield* expectTag(
1617 client.objects.compare({
1618 params: { owner: "acme", repo: "compare" },
1619 query: { base: "main", head: "no-such-branch" },
1620 }),
1621 "RefNotFound",
1622 );
1623
1624 // disjoint history → NoMergeBase (422)
1625 yield* mustGit(work, "checkout", "-q", "--orphan", "island");
1626 yield* mustGit(work, "rm", "-rfq", "--cached", ".");
1627 yield* fs.writeFileString(`${work}/island.txt`, "alone\n");
1628 yield* mustGit(work, "add", "island.txt");
1629 yield* mustGit(work, "commit", "-qm", "island");
1630 yield* mustGit(work, "push", "-q", remote, "island");
1631 yield* expectTag(
1632 client.objects.compare({
1633 params: { owner: "acme", repo: "compare" },
1634 query: { base: "main", head: "island" },
1635 }),
1636 "NoMergeBase",
1637 );
1638 }),
1639 {
1640 tags: [
1641 "provider:cloudflare",
1642 "provider:cloudflare:r2",
1643 "provider:cloudflare:worker",
1644 "local",
1645 ],
1646 timeout: 120_000,
1647 },
1648);
1649
1650test(
1651 "diff: anonymous read on public repos; 401 on private",
1652 Effect.gen(function* () {
1653 const { url } = yield* stack;
1654 const fs = yield* FileSystem.FileSystem;
1655 const anonymous = yield* makeAnonymousClient(url);
1656
1657 // private repo → anonymous is 401 on both endpoints
1658 const priv = yield* createRepo(url, "acme", "diff-private");
1659 const pw = yield* tempDir;
1660 yield* mustGit(pw, "init", "-q", "-b", "main");
1661 yield* fs.writeFileString(`${pw}/f.txt`, "x\n");
1662 yield* mustGit(pw, "add", ".");
1663 yield* mustGit(pw, "commit", "-qm", "c");
1664 const privTip = yield* revParse(pw, "HEAD");
1665 yield* mustGit(
1666 pw,
1667 "push",
1668 "-q",
1669 yield* authRemote(url, priv.token, "acme", "diff-private"),
1670 "main",
1671 );
1672 yield* expectTag(
1673 anonymous.objects.diff({
1674 params: { owner: "acme", repo: "diff-private", oid: asOid(privTip) },
1675 }),
1676 "Unauthorized",
1677 );
1678 yield* expectTag(
1679 anonymous.objects.compare({
1680 params: { owner: "acme", repo: "diff-private" },
1681 query: { base: "main", head: "main" },
1682 }),
1683 "Unauthorized",
1684 );
1685
1686 // public repo → anonymous reads succeed
1687 const pub = yield* createRepo(url, "acme", "diff-public");
1688 yield* pub.client.repos.update({
1689 params: { owner: "acme", repo: "diff-public" },
1690 payload: { public: true },
1691 });
1692 const ww = yield* tempDir;
1693 yield* mustGit(ww, "init", "-q", "-b", "main");
1694 yield* fs.writeFileString(`${ww}/hello.txt`, "hi\n");
1695 yield* mustGit(ww, "add", ".");
1696 yield* mustGit(ww, "commit", "-qm", "c1");
1697 yield* fs.writeFileString(`${ww}/hello.txt`, "hi there\n");
1698 yield* mustGit(ww, "add", ".");
1699 yield* mustGit(ww, "commit", "-qm", "c2");
1700 const pubTip = yield* revParse(ww, "HEAD");
1701 yield* mustGit(
1702 ww,
1703 "push",
1704 "-q",
1705 yield* authRemote(url, pub.token, "acme", "diff-public"),
1706 "main",
1707 );
1708
1709 const d = yield* anonymous.objects.diff({
1710 params: { owner: "acme", repo: "diff-public", oid: asOid(pubTip) },
1711 });
1712 expect(d.files).toEqual([
1713 expect.objectContaining({ path: "hello.txt", status: "modified" }),
1714 ]);
1715 const c = yield* anonymous.objects.compare({
1716 params: { owner: "acme", repo: "diff-public" },
1717 query: { base: d.parent!, head: "main" },
1718 });
1719 expect(c.aheadBy).toBe(1);
1720 expect(c.behindBy).toBe(0);
1721 expect(c.mergeBase).toBe(d.parent);
1722 }),
1723 {
1724 tags: [
1725 "provider:cloudflare",
1726 "provider:cloudflare:r2",
1727 "provider:cloudflare:worker",
1728 "local",
1729 ],
1730 timeout: 120_000,
1731 },
1732);
1733
1734// ═══════════════════════════════════════════════════════════════════════════
1735// (l) pull requests — lifecycle, live compare fields, merges
1736// ═══════════════════════════════════════════════════════════════════════════
1737
1738test(
1739 "pulls: open + numbering, typed create failures, list pagination, live detail, close/reopen/edit",
1740 Effect.gen(function* () {
1741 const { url } = yield* stack;
1742 const fs = yield* FileSystem.FileSystem;
1743 const { client, token } = yield* createRepo(url, "acme", "pulls-basic");
1744 const params = { owner: "acme", repo: "pulls-basic" };
1745 const work = yield* tempDir;
1746 yield* mustGit(work, "init", "-q", "-b", "main");
1747 yield* fs.writeFileString(`${work}/README.md`, "base\n");
1748 yield* mustGit(work, "add", ".");
1749 yield* mustGit(work, "commit", "-qm", "base");
1750 const mainTip = yield* revParse(work, "HEAD");
1751 yield* mustGit(work, "checkout", "-qb", "feature");
1752 yield* fs.writeFileString(`${work}/feature.txt`, "feat\n");
1753 yield* mustGit(work, "add", ".");
1754 yield* mustGit(work, "commit", "-qm", "feat");
1755 yield* mustGit(work, "checkout", "-qb", "feature-2", "main");
1756 yield* fs.writeFileString(`${work}/f2.txt`, "f2\n");
1757 yield* mustGit(work, "add", ".");
1758 yield* mustGit(work, "commit", "-qm", "f2");
1759 const remote = yield* authRemote(url, token, "acme", "pulls-basic");
1760 yield* mustGit(work, "push", "-q", remote, "main", "feature", "feature-2");
1761
1762 // open #1 (short names normalize to refs/heads/…)
1763 const pr1 = yield* client.pulls.create({
1764 params,
1765 payload: {
1766 title: "add feature",
1767 body: "the body",
1768 base: "main",
1769 head: "feature",
1770 },
1771 });
1772 expect(pr1.number).toBe(1);
1773 expect(pr1.state).toBe("open");
1774 expect(pr1.baseRef).toBe("refs/heads/main");
1775 expect(pr1.headRef).toBe("refs/heads/feature");
1776 expect(pr1.mergeCommit).toBeNull();
1777
1778 // typed create failures
1779 yield* expectTag(
1780 client.pulls.create({
1781 params,
1782 payload: { title: "same", base: "main", head: "main" },
1783 }),
1784 "ValidationError",
1785 );
1786 yield* expectTag(
1787 client.pulls.create({
1788 params,
1789 payload: { title: "missing", base: "main", head: "no-such-branch" },
1790 }),
1791 "BranchMissing",
1792 );
1793 yield* expectTag(
1794 client.pulls.create({
1795 params,
1796 payload: {
1797 title: "tags are not branches",
1798 base: "main",
1799 head: "refs/tags/v1",
1800 },
1801 }),
1802 "ValidationError",
1803 );
1804 const dup = yield* expectTag(
1805 client.pulls.create({
1806 params,
1807 payload: { title: "dup", base: "main", head: "feature" },
1808 }),
1809 "PullExists",
1810 );
1811 expect((dup as { number?: number }).number).toBe(1);
1812
1813 // #2 gets the next dense number
1814 const pr2 = yield* client.pulls.create({
1815 params,
1816 payload: { title: "second", base: "main", head: "feature-2" },
1817 });
1818 expect(pr2.number).toBe(2);
1819
1820 // list: newest first, keyset pagination
1821 const open = yield* client.pulls.list({ params, query: {} });
1822 expect(open.items.map((p) => p.number)).toEqual([2, 1]);
1823 expect(open.hasMore).toBe(false);
1824 const page1 = yield* client.pulls.list({ params, query: { limit: 1 } });
1825 expect(page1.items.map((p) => p.number)).toEqual([2]);
1826 expect(page1.hasMore).toBe(true);
1827 expect(page1.nextCursor).toBe("2");
1828 const page2 = yield* client.pulls.list({
1829 params,
1830 query: { limit: 1, cursor: page1.nextCursor! },
1831 });
1832 expect(page2.items.map((p) => p.number)).toEqual([1]);
1833 expect(page2.hasMore).toBe(false);
1834 const closedList = yield* client.pulls.list({
1835 params,
1836 query: { state: "closed" },
1837 });
1838 expect(closedList.items).toEqual([]);
1839
1840 // live detail: feature is 1 ahead of main and fast-forwardable
1841 const detail = yield* client.pulls.get({
1842 params: { ...params, number: 1 },
1843 });
1844 expect(detail.baseOid).toBe(mainTip);
1845 expect(detail.mergeBase).toBe(mainTip);
1846 expect(detail.aheadBy).toBe(1);
1847 expect(detail.behindBy).toBe(0);
1848 expect(detail.mergeable).toBe(true);
1849 expect(detail.mergeableReason).toBe("ff");
1850
1851 // unknown number → typed 404
1852 yield* expectTag(
1853 client.pulls.get({ params: { ...params, number: 999 } }),
1854 "PullNotFound",
1855 );
1856
1857 // close → detail still computed (both refs exist) → reopen → edit
1858 const closed = yield* client.pulls.update({
1859 params: { ...params, number: 1 },
1860 payload: { state: "closed" },
1861 });
1862 expect(closed.state).toBe("closed");
1863 const closedDetail = yield* client.pulls.get({
1864 params: { ...params, number: 1 },
1865 });
1866 expect(closedDetail.mergeable).toBe(true);
1867 const reopened = yield* client.pulls.update({
1868 params: { ...params, number: 1 },
1869 payload: { state: "open" },
1870 });
1871 expect(reopened.state).toBe("open");
1872 const edited = yield* client.pulls.update({
1873 params: { ...params, number: 1 },
1874 payload: { title: "renamed", body: null },
1875 });
1876 expect(edited.title).toBe("renamed");
1877 expect(edited.body).toBeNull();
1878 expect(edited.updatedAt).toBeGreaterThanOrEqual(edited.createdAt);
1879 }),
1880 {
1881 tags: [
1882 "provider:cloudflare",
1883 "provider:cloudflare:r2",
1884 "provider:cloudflare:worker",
1885 "local",
1886 ],
1887 timeout: 120_000,
1888 },
1889);
1890
1891test(
1892 "pulls: fast-forward merge moves the base ref; merged is terminal; up-to-date is 422; encodeCommit round-trips real git bytes",
1893 Effect.gen(function* () {
1894 const { url } = yield* stack;
1895 const fs = yield* FileSystem.FileSystem;
1896 const { client, token } = yield* createRepo(url, "acme", "pulls-ff");
1897 const params = { owner: "acme", repo: "pulls-ff" };
1898 const work = yield* tempDir;
1899 yield* mustGit(work, "init", "-q", "-b", "main");
1900 yield* fs.writeFileString(`${work}/README.md`, "hello\n");
1901 yield* mustGit(work, "add", ".");
1902 yield* mustGit(work, "commit", "-qm", "base");
1903 yield* mustGit(work, "checkout", "-qb", "feature");
1904 yield* fs.writeFileString(`${work}/feature.txt`, "feat\n");
1905 yield* mustGit(work, "add", ".");
1906 yield* mustGit(work, "commit", "-qm", "feat");
1907 const featureTip = yield* revParse(work, "HEAD");
1908 yield* mustGit(work, "checkout", "-q", "main");
1909 const remote = yield* authRemote(url, token, "acme", "pulls-ff");
1910 yield* mustGit(work, "push", "-q", remote, "main", "feature");
1911
1912 // pin encodeCommit against real git-produced bytes: `git cat-file
1913 // commit` output re-hashed must reproduce the oid (byte-exactness),
1914 // and parse→encode must reproduce those bytes.
1915 const catFile = (yield* mustGit(work, "cat-file", "commit", featureTip))
1916 .stdout;
1917 const rawCommit = `${catFile}\n`; // mustGit trims the trailing LF
1918 expect(yield* hashObject(ObjectType.commit, utf8Encode(rawCommit))).toBe(
1919 featureTip,
1920 );
1921 const parsed = yield* parseCommit(utf8Encode(rawCommit));
1922 const reEncoded = yield* encodeCommit({
1923 tree: parsed.tree,
1924 parents: parsed.parents,
1925 author: parsed.author,
1926 committer: parsed.committer,
1927 message: parsed.message,
1928 });
1929 expect(utf8Decode(reEncoded)).toBe(rawCommit);
1930 expect(yield* hashObject(ObjectType.commit, reEncoded)).toBe(featureTip);
1931
1932 // open + FF merge
1933 const pr = yield* client.pulls.create({
1934 params,
1935 payload: { title: "ff me", base: "main", head: "feature" },
1936 });
1937 const merged = yield* client.pulls.merge({
1938 params: { ...params, number: pr.number },
1939 payload: {},
1940 });
1941 expect(merged.method).toBe("ff");
1942 expect(merged.oid).toBe(featureTip);
1943 expect(merged.pull.state).toBe("merged");
1944 expect(merged.pull.mergeCommit).toBe(featureTip);
1945 expect(merged.pull.mergedAt).not.toBeNull();
1946
1947 // the base ref moved to the head tip
1948 const mainRef = yield* client.refs.get({
1949 params,
1950 query: { name: "refs/heads/main" },
1951 });
1952 expect(mainRef.oid).toBe(featureTip);
1953
1954 // the clone can FF-pull the merged base
1955 yield* mustGit(work, "pull", "-q", remote, "main");
1956 expect(yield* revParse(work, "HEAD")).toBe(featureTip);
1957
1958 // merged is terminal: live fields null, re-merge and reopen are 409s
1959 const detail = yield* client.pulls.get({
1960 params: { ...params, number: pr.number },
1961 });
1962 expect(detail.state).toBe("merged");
1963 expect(detail.baseOid).toBeNull();
1964 expect(detail.headOid).toBeNull();
1965 expect(detail.mergeable).toBeNull();
1966 yield* expectTag(
1967 client.pulls.merge({
1968 params: { ...params, number: pr.number },
1969 payload: {},
1970 }),
1971 "PullStateConflict",
1972 );
1973 yield* expectTag(
1974 client.pulls.update({
1975 params: { ...params, number: pr.number },
1976 payload: { state: "open" },
1977 }),
1978 "PullStateConflict",
1979 );
1980 yield* expectTag(
1981 client.pulls.merge({ params: { ...params, number: 42 }, payload: {} }),
1982 "PullNotFound",
1983 );
1984
1985 // up-to-date: a PR whose head is already reachable from base
1986 yield* mustGit(
1987 work,
1988 "push",
1989 "-q",
1990 remote,
1991 `${featureTip}:refs/heads/already-in`,
1992 );
1993 const upToDate = yield* client.pulls.create({
1994 params,
1995 payload: { title: "nothing to do", base: "main", head: "already-in" },
1996 });
1997 const upToDateDetail = yield* client.pulls.get({
1998 params: { ...params, number: upToDate.number },
1999 });
2000 expect(upToDateDetail.mergeable).toBe(false);
2001 expect(upToDateDetail.mergeableReason).toBe("up-to-date");
2002 yield* expectTag(
2003 client.pulls.merge({
2004 params: { ...params, number: upToDate.number },
2005 payload: {},
2006 }),
2007 "NothingToMerge",
2008 );
2009 }),
2010 {
2011 tags: [
2012 "provider:cloudflare",
2013 "provider:cloudflare:r2",
2014 "provider:cloudflare:worker",
2015 "local",
2016 ],
2017 timeout: 120_000,
2018 },
2019);
2020
2021test(
2022 "pulls: trivial merge commit joins disjoint changes (fsck-clean); conflicts are typed 409; stale expectedHeadOid is RefConflict",
2023 Effect.gen(function* () {
2024 const { url } = yield* stack;
2025 const fs = yield* FileSystem.FileSystem;
2026 const path = yield* Path.Path;
2027 const { client, token } = yield* createRepo(url, "acme", "pulls-merge");
2028 const params = { owner: "acme", repo: "pulls-merge" };
2029 const work = yield* tempDir;
2030 yield* mustGit(work, "init", "-q", "-b", "main");
2031 yield* fs.writeFileString(`${work}/README.md`, "base\n");
2032 yield* mustGit(work, "add", ".");
2033 yield* mustGit(work, "commit", "-qm", "base");
2034 const baseCommit = yield* revParse(work, "HEAD");
2035 // topic-a: adds feat-a.txt
2036 yield* mustGit(work, "checkout", "-qb", "topic-a");
2037 yield* fs.writeFileString(`${work}/feat-a.txt`, "a\n");
2038 yield* mustGit(work, "add", ".");
2039 yield* mustGit(work, "commit", "-qm", "feat a");
2040 const topicATip = yield* revParse(work, "HEAD");
2041 // main advances with a disjoint file
2042 yield* mustGit(work, "checkout", "-q", "main");
2043 yield* fs.writeFileString(`${work}/b.txt`, "b\n");
2044 yield* mustGit(work, "add", ".");
2045 yield* mustGit(work, "commit", "-qm", "main b");
2046 const mainTip = yield* revParse(work, "HEAD");
2047 const remote = yield* authRemote(url, token, "acme", "pulls-merge");
2048 yield* mustGit(work, "push", "-q", remote, "main", "topic-a");
2049
2050 const pr = yield* client.pulls.create({
2051 params,
2052 payload: { title: "merge topic-a", base: "main", head: "topic-a" },
2053 });
2054 const detail = yield* client.pulls.get({
2055 params: { ...params, number: pr.number },
2056 });
2057 expect(detail.mergeBase).toBe(baseCommit);
2058 expect(detail.mergeable).toBe(true);
2059 expect(detail.mergeableReason).toBe("merge-commit");
2060
2061 // stale expectedHeadOid (a race guard) → typed 409, PR stays open
2062 yield* expectTag(
2063 client.pulls.merge({
2064 params: { ...params, number: pr.number },
2065 payload: { expectedHeadOid: asOid(baseCommit) },
2066 }),
2067 "RefConflict",
2068 );
2069
2070 const merged = yield* client.pulls.merge({
2071 params: { ...params, number: pr.number },
2072 payload: { expectedHeadOid: asOid(topicATip) },
2073 });
2074 expect(merged.method).toBe("merge-commit");
2075 expect(merged.pull.state).toBe("merged");
2076 expect(merged.pull.mergeCommit).toBe(merged.oid);
2077 const mainRef = yield* client.refs.get({
2078 params,
2079 query: { name: "refs/heads/main" },
2080 });
2081 expect(mainRef.oid).toBe(merged.oid);
2082
2083 // clone back: fsck-clean, both sides present, exactly 2 parents in order
2084 const tmp = yield* tempDir;
2085 yield* mustGit(tmp, "clone", "-q", remote, "fresh");
2086 const fresh = path.join(tmp, "fresh");
2087 yield* mustGit(fresh, "fsck", "--strict");
2088 expect(yield* revParse(fresh, "HEAD")).toBe(merged.oid);
2089 expect(yield* fs.readFileString(path.join(fresh, "feat-a.txt"))).toBe(
2090 "a\n",
2091 );
2092 expect(yield* fs.readFileString(path.join(fresh, "b.txt"))).toBe("b\n");
2093 expect(yield* fs.readFileString(path.join(fresh, "README.md"))).toBe(
2094 "base\n",
2095 );
2096 const parents = (yield* mustGit(fresh, "log", "--format=%P", "-1")).stdout;
2097 expect(parents).toBe(`${mainTip} ${topicATip}`);
2098
2099 // conflict: both sides edit README.md relative to the merge base
2100 yield* mustGit(work, "checkout", "-qb", "topic-b", baseCommit);
2101 yield* fs.writeFileString(`${work}/README.md`, "topic-b version\n");
2102 yield* mustGit(work, "add", ".");
2103 yield* mustGit(work, "commit", "-qm", "topic-b readme");
2104 yield* mustGit(work, "checkout", "-q", "main");
2105 yield* mustGit(work, "pull", "-q", remote, "main");
2106 yield* fs.writeFileString(`${work}/README.md`, "main version\n");
2107 yield* mustGit(work, "add", ".");
2108 yield* mustGit(work, "commit", "-qm", "main readme");
2109 yield* mustGit(work, "push", "-q", remote, "main", "topic-b");
2110 const mainBefore = yield* revParse(work, "HEAD");
2111
2112 const conflictPr = yield* client.pulls.create({
2113 params,
2114 payload: { title: "conflicting", base: "main", head: "topic-b" },
2115 });
2116 const conflictDetail = yield* client.pulls.get({
2117 params: { ...params, number: conflictPr.number },
2118 });
2119 expect(conflictDetail.mergeable).toBe(false);
2120 expect(conflictDetail.mergeableReason).toBe("conflict");
2121 const conflict = yield* expectTag(
2122 client.pulls.merge({
2123 params: { ...params, number: conflictPr.number },
2124 payload: {},
2125 }),
2126 "MergeConflict",
2127 );
2128 expect((conflict as { paths?: ReadonlyArray<string> }).paths).toEqual([
2129 "README.md",
2130 ]);
2131 // the PR stays open and the base ref did not move
2132 const still = yield* client.pulls.get({
2133 params: { ...params, number: conflictPr.number },
2134 });
2135 expect(still.state).toBe("open");
2136 const mainAfter = yield* client.refs.get({
2137 params,
2138 query: { name: "refs/heads/main" },
2139 });
2140 expect(mainAfter.oid).toBe(mainBefore);
2141 }),
2142 {
2143 tags: [
2144 "provider:cloudflare",
2145 "provider:cloudflare:r2",
2146 "provider:cloudflare:worker",
2147 "local",
2148 ],
2149 timeout: 120_000,
2150 },
2151);
2152
2153test(
2154 "pulls: deleted head branch degrades to nulls and BranchMissing; anonymous reads on public; writes need a write token",
2155 Effect.gen(function* () {
2156 const { url } = yield* stack;
2157 const fs = yield* FileSystem.FileSystem;
2158 const anonymous = yield* makeAnonymousClient(url);
2159 const { client, token } = yield* createRepo(url, "acme", "pulls-auth");
2160 const params = { owner: "acme", repo: "pulls-auth" };
2161 const work = yield* tempDir;
2162 yield* mustGit(work, "init", "-q", "-b", "main");
2163 yield* fs.writeFileString(`${work}/README.md`, "hi\n");
2164 yield* mustGit(work, "add", ".");
2165 yield* mustGit(work, "commit", "-qm", "base");
2166 yield* mustGit(work, "checkout", "-qb", "topic-c");
2167 yield* fs.writeFileString(`${work}/c.txt`, "c\n");
2168 yield* mustGit(work, "add", ".");
2169 yield* mustGit(work, "commit", "-qm", "c");
2170 const remote = yield* authRemote(url, token, "acme", "pulls-auth");
2171 yield* mustGit(work, "push", "-q", remote, "main", "topic-c");
2172
2173 const pr = yield* client.pulls.create({
2174 params,
2175 payload: { title: "from topic-c", base: "main", head: "topic-c" },
2176 });
2177
2178 // delete the head branch: the PR row survives, live fields go null
2179 yield* client.refs.remove({
2180 params,
2181 query: { name: "refs/heads/topic-c" },
2182 payload: {},
2183 });
2184 const detail = yield* client.pulls.get({
2185 params: { ...params, number: pr.number },
2186 });
2187 expect(detail.state).toBe("open");
2188 expect(detail.headOid).toBeNull();
2189 expect(detail.baseOid).not.toBeNull();
2190 expect(detail.mergeBase).toBeNull();
2191 expect(detail.aheadBy).toBeNull();
2192 expect(detail.mergeable).toBeNull();
2193 const missing = yield* expectTag(
2194 client.pulls.merge({
2195 params: { ...params, number: pr.number },
2196 payload: {},
2197 }),
2198 "BranchMissing",
2199 );
2200 expect((missing as { ref?: string }).ref).toBe("refs/heads/topic-c");
2201
2202 // private repo: anonymous reads are 401
2203 yield* expectTag(
2204 anonymous.pulls.list({ params, query: {} }),
2205 "Unauthorized",
2206 );
2207
2208 // public repo: anonymous reads work, writes never do
2209 yield* client.repos.update({ params, payload: { public: true } });
2210 const publicList = yield* anonymous.pulls.list({ params, query: {} });
2211 expect(publicList.items.map((p) => p.number)).toEqual([pr.number]);
2212 const publicDetail = yield* anonymous.pulls.get({
2213 params: { ...params, number: pr.number },
2214 });
2215 expect(publicDetail.title).toBe("from topic-c");
2216 yield* expectTag(
2217 anonymous.pulls.create({
2218 params,
2219 payload: { title: "nope", base: "main", head: "topic-c" },
2220 }),
2221 "Unauthorized",
2222 );
2223 yield* expectTag(
2224 anonymous.pulls.merge({
2225 params: { ...params, number: pr.number },
2226 payload: {},
2227 }),
2228 "Unauthorized",
2229 );
2230 yield* expectTag(
2231 anonymous.pulls.update({
2232 params: { ...params, number: pr.number },
2233 payload: { state: "closed" },
2234 }),
2235 "Unauthorized",
2236 );
2237
2238 // a read-scoped token reads but cannot write → typed 403
2239 }),
2240 {
2241 tags: [
2242 "provider:cloudflare",
2243 "provider:cloudflare:r2",
2244 "provider:cloudflare:worker",
2245 "local",
2246 ],
2247 timeout: 120_000,
2248 },
2249);
2250
2251// ═══════════════════════════════════════════════════════════════════════════
2252// (k) GitHub REST v3 compatibility facade (/api/v3 — gh api / Octokit)
2253// ═══════════════════════════════════════════════════════════════════════════
2254
2255/** Raw fetch against the facade with GitHub's `token` auth scheme. */
2256const ghFetch = Effect.fn(function* (
2257 url: string,
2258 path: string,
2259 options?: {
2260 readonly method?: string;
2261 readonly token?: string | undefined;
2262 readonly body?: unknown;
2263 },
2264) {
2265 const client = yield* HttpClient.HttpClient;
2266 let request = HttpClientRequest.make((options?.method ?? "GET") as "GET")(
2267 `${url}/api/v3${path}`,
2268 );
2269 if (options?.token !== undefined) {
2270 request = HttpClientRequest.setHeader(
2271 request,
2272 "authorization",
2273 `token ${options.token}`,
2274 );
2275 }
2276 if (options?.body !== undefined) {
2277 request = HttpClientRequest.bodyJsonUnsafe(options.body)(request);
2278 }
2279 const response = yield* client.execute(request);
2280 const text = yield* response.text;
2281 return {
2282 status: response.status,
2283 headers: response.headers,
2284 json: text.length === 0 ? undefined : (JSON.parse(text) as any),
2285 };
2286});
2287
2288test(
2289 "ghapi: /user auth probe, repo + branches + commits + contents in GitHub shapes",
2290 Effect.gen(function* () {
2291 const { url } = yield* stack;
2292 const admin = yield* makeClient(url, TEST_SECRET);
2293 const repo = yield* createRepo(url, "acme", "gh-compat");
2294 yield* admin.repos.update({
2295 params: { owner: "acme", repo: "gh-compat" },
2296 payload: { public: true },
2297 });
2298
2299 // seed two commits on main
2300 const fs = yield* FileSystem.FileSystem;
2301 const path = yield* Path.Path;
2302 const tmp = yield* tempDir;
2303 const work = path.join(tmp, "ghc");
2304 yield* fs.makeDirectory(work, { recursive: true });
2305 yield* mustGit(work, "init", "-q", "-b", "main");
2306 yield* fs.writeFileString(path.join(work, "hello.txt"), "one\n");
2307 yield* mustGit(work, "add", "-A");
2308 yield* mustGit(work, "commit", "-qm", "c1");
2309 yield* fs.writeFileString(path.join(work, "hello.txt"), "two\n");
2310 yield* mustGit(work, "add", "-A");
2311 yield* mustGit(work, "commit", "-qm", "c2");
2312 yield* mustGit(work, "push", "-q", repo.remote, "main");
2313
2314 // /user: gh's probe — the fixture overrides the engine's route to
2315 // answer from the caller its middleware resolved (`token` scheme);
2316 // anonymous is the middleware's 401.
2317 const user = yield* ghFetch(url, "/user", { token: TEST_SECRET });
2318 expect(user.status).toBe(200);
2319 expect(user.json.login).toBe("Suite");
2320 const anonUser = yield* ghFetch(url, "/user");
2321 expect(anonUser.status).toBe(401);
2322
2323 // repo shape (anonymous — the repo is public)
2324 const repoJson = yield* ghFetch(url, "/repos/acme/gh-compat");
2325 expect(repoJson.status).toBe(200);
2326 expect(repoJson.json.full_name).toBe("acme/gh-compat");
2327 expect(repoJson.json.private).toBe(false);
2328 expect(repoJson.json.default_branch).toBe("main");
2329 expect(repoJson.json.clone_url).toContain("/acme/gh-compat.git");
2330 expect(typeof repoJson.json.id).toBe("number");
2331
2332 // branches
2333 const branches = yield* ghFetch(url, "/repos/acme/gh-compat/branches");
2334 expect(branches.status).toBe(200);
2335 expect(branches.json[0].name).toBe("main");
2336 expect(branches.json[0].commit.sha).toMatch(/^[0-9a-f]{40}$/);
2337
2338 // commits list + Link pagination
2339 const commits = yield* ghFetch(
2340 url,
2341 "/repos/acme/gh-compat/commits?per_page=1",
2342 );
2343 expect(commits.status).toBe(200);
2344 expect(commits.json.length).toBe(1);
2345 expect(commits.json[0].commit.message).toContain("c2");
2346 expect(commits.headers.link).toContain('rel="next"');
2347 // follow the Link cursor like gh --paginate does
2348 const nextUrl = /<([^>]+)>/.exec(commits.headers.link ?? "")![1]!;
2349 const pathPart = nextUrl.slice(nextUrl.indexOf("/api/v3") + 7);
2350 const page2 = yield* ghFetch(url, pathPart);
2351 expect(page2.json[0].commit.message).toContain("c1");
2352
2353 // single commit incl. files
2354 const head = commits.json[0].sha as string;
2355 const one = yield* ghFetch(url, `/repos/acme/gh-compat/commits/${head}`);
2356 expect(one.status).toBe(200);
2357 expect(one.json.files[0].filename).toBe("hello.txt");
2358 expect(one.json.files[0].status).toBe("modified");
2359
2360 // contents (base64)
2361 const contents = yield* ghFetch(
2362 url,
2363 "/repos/acme/gh-compat/contents/hello.txt",
2364 );
2365 expect(contents.status).toBe(200);
2366 expect(contents.json.encoding).toBe("base64");
2367 expect(atob(contents.json.content as string)).toBe("two\n");
2368
2369 // unknown repo → GitHub-shaped 404
2370 const missing = yield* ghFetch(url, "/repos/acme/nope");
2371 expect(missing.status).toBe(404);
2372 expect(missing.json.message).toBe("Not Found");
2373 }),
2374 {
2375 tags: [
2376 "provider:cloudflare",
2377 "provider:cloudflare:r2",
2378 "provider:cloudflare:worker",
2379 "local",
2380 ],
2381 timeout: 120_000,
2382 },
2383);
2384
2385test(
2386 "ghapi: pull lifecycle through the facade — create, list states, merge, files",
2387 Effect.gen(function* () {
2388 const { url } = yield* stack;
2389 const admin = yield* makeClient(url, TEST_SECRET);
2390 const repo = yield* createRepo(url, "acme", "gh-pulls");
2391 yield* admin.repos.update({
2392 params: { owner: "acme", repo: "gh-pulls" },
2393 payload: { public: true },
2394 });
2395
2396 const fs = yield* FileSystem.FileSystem;
2397 const path = yield* Path.Path;
2398 const tmp = yield* tempDir;
2399 const work = path.join(tmp, "ghp");
2400 yield* fs.makeDirectory(work, { recursive: true });
2401 yield* mustGit(work, "init", "-q", "-b", "main");
2402 yield* fs.writeFileString(path.join(work, "base.txt"), "base\n");
2403 yield* mustGit(work, "add", "-A");
2404 yield* mustGit(work, "commit", "-qm", "base");
2405 yield* mustGit(work, "checkout", "-qb", "topic");
2406 yield* fs.writeFileString(path.join(work, "topic.txt"), "topic\n");
2407 yield* mustGit(work, "add", "-A");
2408 yield* mustGit(work, "commit", "-qm", "topic work");
2409 yield* mustGit(work, "checkout", "-q", "main");
2410 yield* mustGit(work, "push", "-q", repo.remote, "main", "topic");
2411
2412 // POST /pulls with GitHub's field names (head/base short names)
2413 const created = yield* ghFetch(url, "/repos/acme/gh-pulls/pulls", {
2414 method: "POST",
2415 token: TEST_SECRET,
2416 body: {
2417 title: "Topic work",
2418 head: "topic",
2419 base: "main",
2420 body: "pr body",
2421 },
2422 });
2423 expect(created.status).toBe(201);
2424 expect(created.json.number).toBe(1);
2425 expect(created.json.state).toBe("open");
2426 expect(created.json.merged).toBe(false);
2427 expect(created.json.head.ref).toBe("topic");
2428 expect(created.json.base.ref).toBe("main");
2429
2430 // anonymous write → 401
2431 const anonCreate = yield* ghFetch(url, "/repos/acme/gh-pulls/pulls", {
2432 method: "POST",
2433 body: { title: "x", head: "topic", base: "main" },
2434 });
2435 expect(anonCreate.status).toBe(401);
2436
2437 // GET single: mergeable + shas live
2438 const single = yield* ghFetch(url, "/repos/acme/gh-pulls/pulls/1");
2439 expect(single.json.mergeable).toBe(true);
2440 expect(single.json.head.sha).toMatch(/^[0-9a-f]{40}$/);
2441
2442 // files (three-dot)
2443 const files = yield* ghFetch(url, "/repos/acme/gh-pulls/pulls/1/files");
2444 expect(files.json.map((f: any) => f.filename)).toEqual(["topic.txt"]);
2445 expect(files.json[0].status).toBe("added");
2446
2447 // unsupported merge method → 405
2448 const squash = yield* ghFetch(url, "/repos/acme/gh-pulls/pulls/1/merge", {
2449 method: "PUT",
2450 token: TEST_SECRET,
2451 body: { merge_method: "squash" },
2452 });
2453 expect(squash.status).toBe(405);
2454
2455 // PUT merge (fast-forward)
2456 const merged = yield* ghFetch(url, "/repos/acme/gh-pulls/pulls/1/merge", {
2457 method: "PUT",
2458 token: TEST_SECRET,
2459 body: {},
2460 });
2461 expect(merged.status).toBe(200);
2462 expect(merged.json.merged).toBe(true);
2463 expect(merged.json.sha).toMatch(/^[0-9a-f]{40}$/);
2464
2465 // GitHub semantics: merged PR is state=closed + merged=true
2466 const after = yield* ghFetch(url, "/repos/acme/gh-pulls/pulls/1");
2467 expect(after.json.state).toBe("closed");
2468 expect(after.json.merged).toBe(true);
2469 expect(after.json.merge_commit_sha).toBe(merged.json.sha);
2470
2471 // list state filters: open empty; closed includes the merged PR
2472 const open = yield* ghFetch(url, "/repos/acme/gh-pulls/pulls?state=open");
2473 expect(open.json.length).toBe(0);
2474 const closed = yield* ghFetch(
2475 url,
2476 "/repos/acme/gh-pulls/pulls?state=closed",
2477 );
2478 expect(closed.json.length).toBe(1);
2479 expect(closed.json[0].number).toBe(1);
2480
2481 // merged PR files fall back to the merge commit's diff
2482 const mergedFiles = yield* ghFetch(
2483 url,
2484 "/repos/acme/gh-pulls/pulls/1/files",
2485 );
2486 expect(mergedFiles.json.map((f: any) => f.filename)).toEqual(["topic.txt"]);
2487
2488 // PATCH validation
2489 const badState = yield* ghFetch(url, "/repos/acme/gh-pulls/pulls/1", {
2490 method: "PATCH",
2491 token: TEST_SECRET,
2492 body: { state: "merged" },
2493 });
2494 expect(badState.status).toBe(422);
2495 }),
2496 {
2497 tags: [
2498 "provider:cloudflare",
2499 "provider:cloudflare:r2",
2500 "provider:cloudflare:worker",
2501 "local",
2502 ],
2503 timeout: 120_000,
2504 },
2505);
2506
2507// ═══════════════════════════════════════════════════════════════════════════
2508// (c) application ref authorization
2509// ═══════════════════════════════════════════════════════════════════════════
2510
2511// A second assembly, with custom native HTTP handlers sharing
2512// one rule: `refs/heads/main` moves only for the repository's owner (the
2513// fixture's `protectMain`). Its own entry module: a Worker's generated
2514// entry serves its main module's DEFAULT export, so one Worker class per
2515// entry module. Deployed inside the test so the shared fixture stays one
2516// worker for the e2e suites.
2517const ProtectedLocalStack = Alchemy.Stack(
2518 "GitServiceProtectedLocalStack",
2519 { providers: Cloudflare.providers(), state: Alchemy.localState() },
2520 Effect.gen(function* () {
2521 const host = yield* ProtectedGitHost;
2522 return { url: host.url.as<string>() };
2523 }),
2524);
2525afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(ProtectedLocalStack));
2526
2527test(
2528 "policies: an application handler protects main — per-ref rule over the parsed updates",
2529 Effect.gen(function* () {
2530 const { url } = yield* deploy(ProtectedLocalStack);
2531 expect(url).toMatch(/^http:\/\/localhost:\d+$/);
2532 yield* awaitReady(url);
2533
2534 const fs = yield* FileSystem.FileSystem;
2535 const path = yield* Path.Path;
2536 const { created } = yield* createRepo(url, "e2e", "protected");
2537 // A second user, not the owner, does the pushing.
2538 const token = TEST_SECRET_DEV;
2539 const remote = yield* authRemote(url, token, "e2e", "protected");
2540
2541 const tmp = yield* tempDir;
2542 yield* mustGit(tmp, "-c", "init.defaultBranch=main", "clone", remote, "w");
2543 const w = path.join(tmp, "w");
2544 yield* fs.writeFileString(path.join(w, "file.txt"), "v1\n");
2545 yield* mustGit(w, "add", "-A");
2546 yield* mustGit(w, "commit", "-m", "c1");
2547
2548 // The middleware lets the user push; the policy lets any OTHER branch
2549 // through...
2550 yield* mustGit(w, "push", "origin", "HEAD:refs/heads/feature");
2551
2552 // ...but a direct push to main is refused PER-REF, after the pack is
2553 // parsed, with the reason the policy gave.
2554 const denied = yield* mustFailGit(
2555 w,
2556 "push",
2557 "origin",
2558 "HEAD:refs/heads/main",
2559 );
2560 expect(denied.stderr).toContain("not permitted");
2561
2562 // The owner passes the same policy.
2563 const adminRemote = yield* authRemote(url, TEST_SECRET, "e2e", "protected");
2564 yield* mustGit(w, "push", adminRemote, "HEAD:refs/heads/main");
2565
2566 // The push advertisement is a write probe: anonymous gets the 401
2567 // (with `WWW-Authenticate`) that makes git ask for credentials.
2568 const client = yield* HttpClient.HttpClient;
2569 const probe = yield* client.get(
2570 `${url}/e2e/protected/info/refs?service=git-receive-pack`,
2571 );
2572 expect(probe.status).toBe(401);
2573 expect(probe.headers["www-authenticate"]).toContain("Basic");
2574
2575 // The REST ref writes run the same policy: the user is refused on main
2576 // (a typed 403) and may still create a feature branch.
2577 const head = yield* revParse(w, "HEAD");
2578 const tokenClient = yield* makeClient(url, token);
2579 yield* expectTag(
2580 tokenClient.refs.update({
2581 params: { owner: "e2e", repo: "protected" },
2582 query: { name: "refs/heads/main" },
2583 payload: { newOid: asOid(head) },
2584 }),
2585 "PushDenied",
2586 );
2587 const moved = yield* tokenClient.refs.update({
2588 params: { owner: "e2e", repo: "protected" },
2589 query: { name: "refs/heads/feature2" },
2590 payload: { newOid: asOid(head), expectedOid: null },
2591 });
2592 expect(moved.oid).toBe(head);
2593
2594 // A policy can inspect the newly uploaded commit without publishing it.
2595 yield* fs.writeFileString(path.join(w, "new.txt"), "not published yet\n");
2596 yield* mustGit(w, "add", "-A");
2597 yield* mustGit(w, "commit", "-m", "[reject-content]");
2598 const rejectedOid = yield* revParse(w, "HEAD");
2599 const rejectedContent = yield* mustFailGit(
2600 w,
2601 "push",
2602 adminRemote,
2603 "HEAD:refs/heads/main",
2604 );
2605 expect(rejectedContent.stderr).toContain(
2606 "commit rejected by content policy",
2607 );
2608 const ownerClient = yield* makeClient(url, TEST_SECRET);
2609 yield* expectTag(
2610 ownerClient.objects.commit({
2611 params: { owner: "e2e", repo: "protected", oid: asOid(rejectedOid) },
2612 }),
2613 "ObjectNotFound",
2614 );
2615 expect(
2616 (yield* ownerClient.refs.get({
2617 params: { owner: "e2e", repo: "protected" },
2618 query: { name: "refs/heads/main" },
2619 })).oid,
2620 ).toBe(head);
2621 // Retry with an acceptable commit: aborted staging must not poison ingestion.
2622 yield* mustGit(w, "commit", "--amend", "-m", "accepted content");
2623 yield* mustGit(w, "push", adminRemote, "HEAD:refs/heads/main");
2624 }).pipe(logLevel),
2625 {
2626 tags: [
2627 "provider:cloudflare",
2628 "provider:cloudflare:r2",
2629 "provider:cloudflare:worker",
2630 "local",
2631 ],
2632 timeout: 240_000,
2633 },
2634);