1/**
2 * Conformance workout — the real `git` CLI driven through the SHELL against
3 * a deployed git-service stack.
4 *
5 * There is no official conformance suite for git servers; the de-facto
6 * oracle is the git client itself plus its strict integrity checkers. This
7 * suite therefore treats conformance as: every everyday workflow a
8 * developer throws at a remote — clone, push, rebase, amend, merge,
9 * cherry-pick, revert, tags (both kinds), branch churn, prune, atomic
10 * multi-ref pushes, client-side gc — round-trips against the deployed
11 * service with `git fsck --strict` clean at every checkpoint, and the REST
12 * plane (token flow, refs) agrees with what the wire reports.
13 *
14 * Each phase is a literal `sh -c` script (bounded by a hard timeout), so a
15 * failure prints the exact shell block + stderr that broke.
16 */
17import * as Cloudflare from "@/Cloudflare";
18import * as Test from "@/Test/Alchemy";
19import { expect } from "alchemy-test";
20import * as Data from "effect/Data";
21import * as Effect from "effect/Effect";
22import * as FileSystem from "effect/FileSystem";
23import { MinimumLogLevel } from "effect/References";
24import * as Schedule from "effect/Schedule";
25import * as Stream from "effect/Stream";
26import * as HttpClient from "effect/http/HttpClient";
27import * as HttpClientRequest from "effect/http/HttpClientRequest";
28import * as HttpApiClient from "effect/http-api/HttpApiClient";
29import * as ChildProcess from "effect/process/ChildProcess";
30import { GitApi } from "@/Git/Api.ts";
31import { makeTestStack, TEST_SECRET } from "./fixtures/stack.ts";
32
33const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
34 providers: Cloudflare.providers(),
35});
36
37const logLevel = Effect.provideService(
38 MinimumLogLevel,
39 process.env.DEBUG ? "Debug" : "Info",
40);
41
42const Stack = makeTestStack("GitConformanceStack");
43
44// ── REST plumbing ───────────────────────────────────────────────────────────
45
46const makeClient = (url: string, token: string) =>
47 HttpApiClient.make(GitApi, {
48 baseUrl: url,
49 transformClient: HttpClient.mapRequest((request) =>
50 request.pipe(HttpClientRequest.bearerToken(token)),
51 ),
52 });
53
54const edgeRetry = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
55 effect.pipe(
56 Effect.timeout("10 seconds"),
57 Effect.retry({
58 while: (error: unknown) =>
59 (error as { _tag?: string })._tag === "TimeoutError" ||
60 ((error as { _tag?: string })._tag === "HttpClientError" &&
61 (!(error as { response?: { status: number } }).response ||
62 (error as { response: { status: number } }).response.status ===
63 404 ||
64 (error as { response: { status: number } }).response.status >=
65 500)),
66 schedule: Schedule.spaced("1500 millis"),
67 times: 40,
68 }),
69 );
70
71/** Delete-if-exists + wait until the deterministic name is free again. */
72const purgeRepo = Effect.fn(function* (
73 url: string,
74 owner: string,
75 repo: string,
76) {
77 const admin = yield* makeClient(url, TEST_SECRET);
78 // edgeRetry on every step: a freshly deployed workers.dev route serves
79 // transient 5xx/1042s for a few seconds (typed 404s decode fine and are
80 // NOT retried — they end the poll).
81 yield* admin.repos.delete({ params: { owner, repo } }).pipe(
82 Effect.catchTag("RepoNotFound", () => Effect.void),
83 edgeRetry,
84 );
85 yield* admin.repos.get({ params: { owner, repo } }).pipe(
86 edgeRetry,
87 Effect.as(false),
88 Effect.catchTag("RepoNotFound", () => Effect.succeed(true)),
89 Effect.repeat({
90 schedule: Schedule.spaced("1 second"),
91 until: (gone) => gone,
92 times: 60,
93 }),
94 );
95});
96
97/** Purge + create a repo, returning the admin client and an authed remote. */
98const freshRepo = Effect.fn(function* (
99 url: string,
100 owner: string,
101 name: string,
102) {
103 const admin = yield* makeClient(url, TEST_SECRET);
104 // Retry the whole purge -> create CYCLE, never the bare POST: a create
105 // that commits server-side but loses its response (edge 5xx mid-rollout)
106 // leaves the name taken, so retrying just the POST would 409 forever.
107 const created = yield* Effect.gen(function* () {
108 yield* purgeRepo(url, owner, name);
109 return yield* admin.repos
110 .create({ payload: { owner, name } })
111 .pipe(edgeRetry);
112 }).pipe(
113 Effect.retry({
114 while: (error: { readonly _tag?: string }) =>
115 error._tag === "RepoAlreadyExists",
116 schedule: Schedule.spaced("1 second"),
117 times: 3,
118 }),
119 );
120 const parsed = new URL(url);
121 return {
122 admin,
123 created,
124 token: TEST_SECRET,
125 host: parsed.host,
126 remoteFor: (token: string) =>
127 `${parsed.protocol}//x:${token}@${parsed.host}/${owner}/${name}.git`,
128 remote: `${parsed.protocol}//x:${TEST_SECRET}@${parsed.host}/${owner}/${name}.git`,
129 };
130});
131
132// ── shell plumbing ──────────────────────────────────────────────────────────
133
134class ShellError extends Data.TaggedError("ShellError")<{
135 readonly script: string;
136 readonly exitCode: number;
137 readonly stdout: string;
138 readonly stderr: string;
139}> {
140 override get message(): string {
141 return `shell script exited ${this.exitCode}\n--- script ---\n${this.script}\n--- stdout ---\n${this.stdout}\n--- stderr ---\n${this.stderr}`;
142 }
143}
144
145/**
146 * Runs one bounded `sh -c` script with hermetic git identity/config. The
147 * script's cwd is the given work dir; `set -eu` makes any failing command
148 * fail the whole block.
149 */
150const sh = Effect.fn(function* (cwd: string, script: string) {
151 const handle = yield* ChildProcess.make("sh", ["-ec", script], {
152 cwd,
153 env: {
154 GIT_TERMINAL_PROMPT: "0",
155 GIT_CONFIG_NOSYSTEM: "1",
156 GIT_CONFIG_GLOBAL: "/dev/null",
157 GIT_AUTHOR_NAME: "Conformance Bot",
158 GIT_AUTHOR_EMAIL: "conformance@example.com",
159 GIT_COMMITTER_NAME: "Conformance Bot",
160 GIT_COMMITTER_EMAIL: "conformance@example.com",
161 // deterministic commit timestamps make re-runs reproducible
162 GIT_AUTHOR_DATE: "2026-01-01T00:00:00Z",
163 GIT_COMMITTER_DATE: "2026-01-01T00:00:00Z",
164 },
165 extendEnv: true,
166 });
167 const [exitCode, stdout, stderr] = yield* Effect.all(
168 [
169 handle.exitCode,
170 Stream.mkString(Stream.decodeText(handle.stdout)),
171 Stream.mkString(Stream.decodeText(handle.stderr)),
172 ],
173 { concurrency: 3 },
174 );
175 return { script, exitCode, stdout: stdout.trim(), stderr };
176}, Effect.timeout("90 seconds"));
177
178/** Runs the script; non-zero exit is a typed failure carrying all output. */
179const mustSh = Effect.fn(function* (cwd: string, script: string) {
180 const result = yield* sh(cwd, script);
181 if (result.exitCode !== 0) {
182 return yield* new ShellError(result);
183 }
184 return result;
185});
186
187/** Runs the script; a ZERO exit is the failure (expected-to-fail blocks). */
188const mustFailSh = Effect.fn(function* (cwd: string, script: string) {
189 const result = yield* sh(cwd, script);
190 if (result.exitCode === 0) {
191 return yield* new ShellError(result);
192 }
193 return result;
194});
195
196/** First-contact clone with bounded retries through edge propagation. */
197const retrySh = (cwd: string, script: string) =>
198 mustSh(cwd, script).pipe(
199 Effect.retry({
200 while: (error) =>
201 error._tag === "ShellError" || error._tag === "TimeoutError",
202 schedule: Schedule.spaced("3 seconds"),
203 times: 5,
204 }),
205 );
206
207const tempDir = Effect.gen(function* () {
208 const fs = yield* FileSystem.FileSystem;
209 return yield* fs.makeTempDirectory({ prefix: "git-conformance-" });
210});
211
212// ── stack ───────────────────────────────────────────────────────────────────
213
214const stack = beforeAll(
215 deploy(Stack).pipe(
216 Effect.tap(({ url }) =>
217 Effect.gen(function* () {
218 // Printed so a failing live run can be probed by hand (curl/git).
219 yield* Effect.logInfo(`git-service deployed at ${url}`);
220 const admin = yield* makeClient(url, TEST_SECRET);
221 // Readiness probe: retry on ANY failure, not just transport errors.
222 // Right after a rollout the edge briefly serves the PREVIOUS worker
223 // version, whose responses decode against the old schema — that
224 // surfaces as a SchemaError on a 200, which `edgeRetry` ignores by
225 // design. Waiting here means "the deployed version answers the
226 // schema this test binary was built against".
227 yield* admin.repos.list({ query: {} }).pipe(
228 edgeRetry,
229 Effect.retry({
230 schedule: Schedule.spaced("1500 millis"),
231 times: 40,
232 }),
233 );
234 }),
235 ),
236 logLevel,
237 ),
238);
239afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack).pipe(logLevel));
240
241// ═══════════════════════════════════════════════════════════════════════════
242// 1. Token flow: bootstrap → scoped tokens → revocation, end to end
243// ═══════════════════════════════════════════════════════════════════════════
244
245// ═══════════════════════════════════════════════════════════════════════════
246// 2. History surgery: rebase, amend, merge, cherry-pick, revert, force-push
247// ═══════════════════════════════════════════════════════════════════════════
248
249test(
250 "history surgery: rebase + amend + merge --no-ff + cherry-pick + revert round-trip",
251 Effect.gen(function* () {
252 const { url } = yield* stack;
253 const repo = yield* freshRepo(url, "conf", "surgery");
254 const tmp = yield* tempDir;
255
256 yield* retrySh(
257 tmp,
258 `
259 rm -rf work
260 git -c init.defaultBranch=main clone '${repo.remote}' work
261 cd work
262 echo base > base.txt
263 git add -A && git commit -m base
264 git push origin main
265 `,
266 );
267
268 // build a feature branch, advance main underneath it, rebase, force-push
269 yield* mustSh(
270 tmp,
271 `
272 cd work
273 git checkout -b feature
274 echo f1 > f1.txt && git add -A && git commit -m feat-1
275 echo f2 > f2.txt && git add -A && git commit -m feat-2
276 git push origin feature
277
278 git checkout main
279 echo m1 > m1.txt && git add -A && git commit -m main-1
280 git push origin main
281
282 git checkout feature
283 git rebase main
284 git push --force origin feature
285 `,
286 );
287
288 // amend the feature tip and force-push again
289 yield* mustSh(
290 tmp,
291 `
292 cd work
293 git checkout feature
294 echo f2-amended > f2.txt
295 git add -A && git commit --amend -m feat-2-amended
296 git push --force origin feature
297 `,
298 );
299
300 // merge --no-ff (a real merge commit), then cherry-pick + revert on main
301 yield* mustSh(
302 tmp,
303 `
304 cd work
305 git checkout main
306 git merge --no-ff feature -m merge-feature
307 echo hot > hotfix.txt && git add -A && git commit -m hotfix
308 git revert --no-edit HEAD
309 git push origin main
310
311 git checkout -b picks main~2
312 git cherry-pick $(git rev-parse main~1)
313 git push origin picks
314 `,
315 );
316
317 // conformance oracle: a fresh clone is fsck-clean and histories agree
318 const check = yield* mustSh(
319 tmp,
320 `
321 git clone '${repo.remote}' verify
322 cd verify
323 git fsck --strict
324 git fetch origin '+refs/heads/*:refs/remotes/origin/*'
325 git rev-parse origin/main origin/feature origin/picks >/dev/null
326 git log --format=%s origin/main | tr '\\n' ' '
327 `,
328 );
329 expect(check.stdout).toContain("merge-feature");
330 expect(check.stdout).toContain("Revert");
331
332 // REST agrees with the wire: refs list carries all three branches
333 const refs = yield* repo.admin.refs.list({
334 params: { owner: "conf", repo: "surgery" },
335 query: { prefix: "refs/heads/" },
336 });
337 expect(refs.refs.map((ref) => ref.name).sort()).toEqual([
338 "refs/heads/feature",
339 "refs/heads/main",
340 "refs/heads/picks",
341 ]);
342 }).pipe(logLevel),
343 {
344 tags: [
345 "provider:cloudflare",
346 "provider:cloudflare:r2",
347 "provider:cloudflare:worker",
348 "live",
349 ],
350 timeout: 180_000,
351 },
352);
353
354// ═══════════════════════════════════════════════════════════════════════════
355// 3. Branch/tag churn: both tag kinds, deletes, prune, atomic multi-ref
356// ═══════════════════════════════════════════════════════════════════════════
357
358test(
359 "branch/tag churn: annotated + lightweight tags, deletes, fetch --prune, --atomic",
360 Effect.gen(function* () {
361 const { url } = yield* stack;
362 const repo = yield* freshRepo(url, "conf", "churn");
363 const tmp = yield* tempDir;
364
365 yield* retrySh(
366 tmp,
367 `
368 rm -rf a
369 git -c init.defaultBranch=main clone '${repo.remote}' a
370 cd a
371 echo one > f.txt && git add -A && git commit -m c1
372 git push origin main
373 `,
374 );
375
376 // one annotated + one lightweight tag; both round-trip with types intact
377 yield* mustSh(
378 tmp,
379 `
380 cd a
381 git tag -a v1.0.0 -m 'release v1'
382 git tag lightweight
383 git push origin v1.0.0 lightweight
384 `,
385 );
386 const tagTypes = yield* mustSh(
387 tmp,
388 `
389 git clone '${repo.remote}' b
390 cd b
391 git fetch --tags origin
392 printf '%s %s' $(git cat-file -t v1.0.0^{}) $(git cat-file -t $(git rev-parse v1.0.0))
393 `,
394 );
395 // peeled target is a commit; the annotated ref itself is a tag object
396 expect(tagTypes.stdout).toBe("commit tag");
397
398 // atomic multi-ref push: two branches land together
399 yield* mustSh(
400 tmp,
401 `
402 cd a
403 git branch side1 && git branch side2
404 git push --atomic origin side1 side2
405 `,
406 );
407
408 // remote deletes; a pruning fetch in clone b drops them
409 yield* mustSh(
410 tmp,
411 `
412 cd a
413 git push --delete origin side1 side2 lightweight v1.0.0
414 `,
415 );
416 const pruned = yield* mustSh(
417 tmp,
418 `
419 cd b
420 git fetch --prune origin
421 git fetch --prune --prune-tags origin '+refs/tags/*:refs/tags/*'
422 git for-each-ref refs/remotes/origin refs/tags | wc -l
423 `,
424 );
425 // only origin/main (HEAD alias may or may not be present; count real refs)
426 expect(Number(pruned.stdout.trim())).toBeLessThanOrEqual(2);
427
428 // REST agrees: only main remains
429 const refs = yield* repo.admin.refs.list({
430 params: { owner: "conf", repo: "churn" },
431 query: {},
432 });
433 expect(refs.refs.map((ref) => ref.name)).toEqual(["refs/heads/main"]);
434 }).pipe(logLevel),
435 {
436 tags: [
437 "provider:cloudflare",
438 "provider:cloudflare:r2",
439 "provider:cloudflare:worker",
440 "live",
441 ],
442 timeout: 180_000,
443 },
444);
445
446// ═══════════════════════════════════════════════════════════════════════════
447// 4. Client gc / repack interop + odd paths (unicode, exec bit, binary)
448// ═══════════════════════════════════════════════════════════════════════════
449
450test(
451 "client gc interop: aggressive repack then push; unicode/exec/binary survive byte-identically",
452 Effect.gen(function* () {
453 const { url } = yield* stack;
454 const repo = yield* freshRepo(url, "conf", "gc-interop");
455 const tmp = yield* tempDir;
456
457 // odd content: unicode filename, executable, raw bytes incl. NUL
458 yield* retrySh(
459 tmp,
460 `
461 rm -rf work
462 git -c init.defaultBranch=main clone '${repo.remote}' work
463 cd work
464 printf 'こんにちは' > 'naïve-файл.txt'
465 printf '#!/bin/sh\\necho ok\\n' > run.sh && chmod +x run.sh
466 head -c 4096 /dev/zero | tr '\\0' '\\377' > solid.bin
467 printf 'a\\0b\\0c' > nul.bin
468 git add -A && git commit -m odd-paths
469 git push origin main
470 `,
471 );
472
473 // grow some history, aggressively gc the client (max delta chains),
474 // then keep pushing — thin packs now delta against gc'd bases
475 yield* mustSh(
476 tmp,
477 `
478 cd work
479 i=1
480 while [ $i -le 30 ]; do
481 printf 'line %s\\n' $i >> grow.txt
482 git add grow.txt && git commit -q -m grow-$i
483 i=$((i+1))
484 done
485 git gc --aggressive --prune=now
486 git push origin main
487
488 echo after-gc >> grow.txt
489 git add grow.txt && git commit -q -m after-gc
490 git push origin main
491 `,
492 );
493
494 // oracle: fresh clone, strict fsck, and every odd file byte-identical
495 const verify = yield* mustSh(
496 tmp,
497 `
498 git clone '${repo.remote}' verify
499 cd verify
500 git fsck --strict
501 cmp 'naïve-файл.txt' '../work/naïve-файл.txt'
502 cmp run.sh ../work/run.sh
503 cmp solid.bin ../work/solid.bin
504 cmp nul.bin ../work/nul.bin
505 test -x run.sh
506 git log --oneline | wc -l
507 `,
508 );
509 expect(Number(verify.stdout.trim())).toBe(32);
510 }).pipe(logLevel),
511 {
512 tags: [
513 "provider:cloudflare",
514 "provider:cloudflare:r2",
515 "provider:cloudflare:worker",
516 "live",
517 ],
518 timeout: 180_000,
519 },
520);