1import * as Effect from "effect/Effect";
2import * as FileSystem from "effect/FileSystem";
3import * as Redacted from "effect/Redacted";
4import * as Schema from "effect/Schema";
5import * as Base64 from "effect/encoding/Base64";
6
7import * as GitHubApi from "./GitHubApi.ts";
8import * 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. */
11export 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. */
18export 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. */
28export 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. */
38function 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 */
52export 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);