| 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 | */ |
| 17 | import * as Cloudflare from "@/Cloudflare"; |
| 18 | import * as Test from "@/Test/Alchemy"; |
| 19 | import { expect } from "alchemy-test"; |
| 20 | import * as Data from "effect/Data"; |
| 21 | import * as Effect from "effect/Effect"; |
| 22 | import * as FileSystem from "effect/FileSystem"; |
| 23 | import { MinimumLogLevel } from "effect/References"; |
| 24 | import * as Schedule from "effect/Schedule"; |
| 25 | import * as Stream from "effect/Stream"; |
| 26 | import * as HttpClient from "effect/http/HttpClient"; |
| 27 | import * as HttpClientRequest from "effect/http/HttpClientRequest"; |
| 28 | import * as HttpApiClient from "effect/http-api/HttpApiClient"; |
| 29 | import * as ChildProcess from "effect/process/ChildProcess"; |
| 30 | import { GitApi } from "@/Git/Api.ts"; |
| 31 | import { makeTestStack, TEST_SECRET } from "./fixtures/stack.ts"; |
| 32 | |
| 33 | const { test, beforeAll, afterAll, deploy, destroy } = Test.make({ |
| 34 | providers: Cloudflare.providers(), |
| 35 | }); |
| 36 | |
| 37 | const logLevel = Effect.provideService( |
| 38 | MinimumLogLevel, |
| 39 | process.env.DEBUG ? "Debug" : "Info", |
| 40 | ); |
| 41 | |
| 42 | const Stack = makeTestStack("GitConformanceStack"); |
| 43 | |
| 44 | // ── REST plumbing ─────────────────────────────────────────────────────────── |
| 45 | |
| 46 | const 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 | |
| 54 | const 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. */ |
| 72 | const 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. */ |
| 98 | const 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 | |
| 134 | class 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 | */ |
| 150 | const 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. */ |
| 179 | const 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). */ |
| 188 | const 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. */ |
| 197 | const 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 | |
| 207 | const tempDir = Effect.gen(function* () { |
| 208 | const fs = yield* FileSystem.FileSystem; |
| 209 | return yield* fs.makeTempDirectory({ prefix: "git-conformance-" }); |
| 210 | }); |
| 211 | |
| 212 | // ── stack ─────────────────────────────────────────────────────────────────── |
| 213 | |
| 214 | const 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 | ); |
| 239 | afterAll.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 | |
| 249 | test( |
| 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 | |
| 358 | test( |
| 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 | |
| 450 | test( |
| 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 | ); |