| 1 | import * as Effect from "effect/Effect"; |
| 2 | import * as FileSystem from "effect/FileSystem"; |
| 3 | import * as Redacted from "effect/Redacted"; |
| 4 | import * as Schema from "effect/Schema"; |
| 5 | import * as Base64 from "effect/encoding/Base64"; |
| 6 | |
| 7 | import * as GitHubApi from "./GitHubApi.ts"; |
| 8 | import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost"; |
| 9 | |
| 10 | /** One open layer of a stack, bottom to top, with the head the reader reviewed. */ |
| 11 | export interface CascadeLayer { |
| 12 | readonly number: number; |
| 13 | readonly headBranch: string; |
| 14 | readonly headSha: string; |
| 15 | } |
| 16 | |
| 17 | /** A layer's rebase hit a conflict; the layers below it are already rebased on GitHub. */ |
| 18 | export class GitHubStackRebaseConflictError extends Schema.TaggedError<GitHubStackRebaseConflictError>()( |
| 19 | "GitHubStackRebaseConflictError", |
| 20 | { number: Schema.Int, completed: Schema.Int }, |
| 21 | ) { |
| 22 | override get message(): string { |
| 23 | return `PR #${this.number} conflicts with the layer below it. ${this.completed} layers were rebased; resolve #${this.number} with \`gh stack rebase\` and push.`; |
| 24 | } |
| 25 | } |
| 26 | |
| 27 | /** Git failed for a reason other than a conflict: the fetch, or a push GitHub refused. */ |
| 28 | export class GitHubStackRebaseGitError extends Schema.TaggedError<GitHubStackRebaseGitError>()( |
| 29 | "GitHubStackRebaseGitError", |
| 30 | { step: Schema.String, number: Schema.Int, completed: Schema.Int, cause: Schema.Defect() }, |
| 31 | ) { |
| 32 | override get message(): string { |
| 33 | return `Stack rebase stopped at PR #${this.number} while ${this.step}. ${this.completed} layers were rebased.`; |
| 34 | } |
| 35 | } |
| 36 | |
| 37 | /** The git URL of a repository on a GitHub host, which is also where its token is good. */ |
| 38 | function remoteUrl(host: string, repository: string): string { |
| 39 | return `https://${host.trim().toLowerCase()}/${repository}.git`; |
| 40 | } |
| 41 | |
| 42 | /** |
| 43 | * GitHub's own "Rebase stack": every open layer, bottom to top, onto the new head of the layer |
| 44 | * below it (the bottom one onto the stack's base). Each layer moves only its own commits — |
| 45 | * `git rebase --onto <new parent> <old parent>` — so a rebased lower layer is never replayed into |
| 46 | * the one above it, which is what GitHub's per-PR "update branch" does and why it conflicts. |
| 47 | * |
| 48 | * It works in a throwaway clone, so the environment's checkout never moves, and pushes each |
| 49 | * layer with a lease on the head that was reviewed, so a push that landed meanwhile is refused |
| 50 | * rather than overwritten. The stacks API has no rebase endpoint to call instead. |
| 51 | */ |
| 52 | export const cascadeRebaseStack = Effect.fn("cascadeRebaseStack")(function* (input: { |
| 53 | readonly host: string; |
| 54 | readonly repository: string; |
| 55 | readonly base: string; |
| 56 | readonly layers: ReadonlyArray<CascadeLayer>; |
| 57 | /** Where to fetch and push; the repository's own URL on its host unless a test points elsewhere. */ |
| 58 | readonly remote?: string; |
| 59 | }) { |
| 60 | const api = yield* GitHubApi.GitHubApi; |
| 61 | const { process } = yield* SourceControlHost.SourceControlHost; |
| 62 | const fileSystem = yield* FileSystem.FileSystem; |
| 63 | const { token } = yield* api.credential(input.host); |
| 64 | const first = input.layers[0]; |
| 65 | if (first === undefined) return 0; |
| 66 | const directory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-stack-rebase-" }).pipe( |
| 67 | Effect.mapError( |
| 68 | (cause) => |
| 69 | new GitHubStackRebaseGitError({ |
| 70 | step: "preparing", |
| 71 | number: first.number, |
| 72 | completed: 0, |
| 73 | cause, |
| 74 | }), |
| 75 | ), |
| 76 | ); |
| 77 | // The token rides in a header for this process only: never in the URL, argv, or git config. |
| 78 | const remote = input.remote ?? remoteUrl(input.host, input.repository); |
| 79 | const authorization = `AUTHORIZATION: basic ${Base64.encode(`x-access-token:${Redacted.value(token)}`)}`; |
| 80 | const env = { |
| 81 | GIT_TERMINAL_PROMPT: "0", |
| 82 | GIT_CONFIG_COUNT: "3", |
| 83 | GIT_CONFIG_KEY_0: `http.${remote}.extraheader`, |
| 84 | GIT_CONFIG_VALUE_0: authorization, |
| 85 | GIT_CONFIG_KEY_1: "user.name", |
| 86 | GIT_CONFIG_VALUE_1: "T3 Code", |
| 87 | GIT_CONFIG_KEY_2: "user.email", |
| 88 | GIT_CONFIG_VALUE_2: "noreply@t3.codes", |
| 89 | }; |
| 90 | const git = (args: ReadonlyArray<string>, allowNonZeroExit = false) => |
| 91 | process.run({ |
| 92 | operation: "cascadeRebaseStack", |
| 93 | command: "git", |
| 94 | args, |
| 95 | cwd: directory, |
| 96 | env, |
| 97 | timeoutMs: 120_000, |
| 98 | ...(allowNonZeroExit ? { allowNonZeroExit: true } : {}), |
| 99 | }); |
| 100 | const failed = (step: string, number: number, completed: number) => (cause: unknown) => |
| 101 | new GitHubStackRebaseGitError({ step, number, completed, cause }); |
| 102 | |
| 103 | yield* git(["init", "--quiet"]).pipe(Effect.mapError(failed("preparing", first.number, 0))); |
| 104 | yield* git(["remote", "add", "origin", remote]).pipe( |
| 105 | Effect.mapError(failed("preparing", first.number, 0)), |
| 106 | ); |
| 107 | yield* git([ |
| 108 | "fetch", |
| 109 | "--quiet", |
| 110 | "--no-tags", |
| 111 | "origin", |
| 112 | `+refs/heads/${input.base}:refs/remotes/origin/${input.base}`, |
| 113 | ...input.layers.map( |
| 114 | (layer) => `+refs/heads/${layer.headBranch}:refs/remotes/origin/${layer.headBranch}`, |
| 115 | ), |
| 116 | ]).pipe(Effect.mapError(failed("fetching", first.number, 0))); |
| 117 | |
| 118 | let parentOld = `origin/${input.base}`; |
| 119 | let parentNew = `origin/${input.base}`; |
| 120 | for (const [index, layer] of input.layers.entries()) { |
| 121 | // The bottom layer's old parent is where it forked from the base; above that it is the |
| 122 | // reviewed head of the layer below, which is exactly the commits this layer must not replay. |
| 123 | const upstream = |
| 124 | index === 0 |
| 125 | ? (yield* git(["merge-base", parentOld, layer.headSha]).pipe( |
| 126 | Effect.mapError(failed("reading the fork point", layer.number, index)), |
| 127 | )).stdout.trim() |
| 128 | : parentOld; |
| 129 | yield* git(["checkout", "--quiet", "--detach", layer.headSha]).pipe( |
| 130 | Effect.mapError(failed("checking out", layer.number, index)), |
| 131 | ); |
| 132 | const rebase = yield* git(["rebase", "--quiet", "--onto", parentNew, upstream], true).pipe( |
| 133 | Effect.mapError(failed("rebasing", layer.number, index)), |
| 134 | ); |
| 135 | if (rebase.exitCode !== 0) { |
| 136 | yield* git(["rebase", "--abort"], true).pipe(Effect.ignore); |
| 137 | return yield* new GitHubStackRebaseConflictError({ number: layer.number, completed: index }); |
| 138 | } |
| 139 | const rebased = (yield* git(["rev-parse", "HEAD"]).pipe( |
| 140 | Effect.mapError(failed("rebasing", layer.number, index)), |
| 141 | )).stdout.trim(); |
| 142 | if (rebased !== layer.headSha) { |
| 143 | yield* git([ |
| 144 | "push", |
| 145 | "--quiet", |
| 146 | `--force-with-lease=refs/heads/${layer.headBranch}:${layer.headSha}`, |
| 147 | "origin", |
| 148 | `${rebased}:refs/heads/${layer.headBranch}`, |
| 149 | ]).pipe(Effect.mapError(failed("pushing", layer.number, index))); |
| 150 | } |
| 151 | parentOld = layer.headSha; |
| 152 | parentNew = rebased; |
| 153 | } |
| 154 | return input.layers.length; |
| 155 | }, Effect.scoped); |