1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";
6import * as Redacted from "effect/Redacted";
7
8import * as GitHubApi from "@t3tools/source-control-github/server/GitHubApi";
9import { cascadeRebaseStack } from "@t3tools/source-control-github/server/githubStackRebase";
10
11import * as ServerSettings from "../serverSettings.ts";
12import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
13import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts";
14import * as VcsProcess from "../vcs/VcsProcess.ts";
15import * as ServerSourceControlHost from "./ServerSourceControlHost.ts";
16
17const layer = Layer.mergeAll(
18 Layer.mock(GitHubApi.GitHubApi)({
19 credential: () => Effect.succeed({ token: Redacted.make("token"), fingerprint: "fp" }),
20 }),
21 // The cascade runs real git through the server's host; the git driver itself is never asked.
22 ServerSourceControlHost.layer.pipe(
23 Layer.provide(ServerSettings.layerTest()),
24 Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})),
25 Layer.provide(Layer.mock(VcsDriverRegistry.VcsDriverRegistry)({})),
26 Layer.provideMerge(VcsProcess.layer),
27 ),
28).pipe(Layer.provideMerge(NodeServices.layer));
29
30/**
31 * A bare "GitHub" holding the live-run stack: `main` moved ahead after the stack was cut, and a
32 * second layer sits on the first. Returns each branch's head.
33 */
34const setup = Effect.gen(function* () {
35 const fs = yield* FileSystem.FileSystem;
36 const process = yield* VcsProcess.VcsProcess;
37 const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cascade-test-" });
38 const remote = `${root}/remote.git`;
39 const work = `${root}/work`;
40 const git = (cwd: string, ...args: string[]) =>
41 process
42 .run({
43 operation: "test",
44 command: "git",
45 args,
46 cwd,
47 env: {
48 GIT_AUTHOR_NAME: "t",
49 GIT_AUTHOR_EMAIL: "t@t",
50 GIT_COMMITTER_NAME: "t",
51 GIT_COMMITTER_EMAIL: "t@t",
52 },
53 })
54 .pipe(Effect.map((out) => out.stdout.trim()));
55 const write = (path: string, text: string) => fs.writeFileString(`${work}/${path}`, text);
56 yield* git(root, "init", "--quiet", "--bare", "-b", "main", remote);
57 yield* git(root, "clone", "--quiet", remote, work);
58 yield* write("index.ts", "export const answer = 41;\n");
59 yield* git(work, "add", "-A");
60 yield* git(work, "commit", "--quiet", "-m", "Initial");
61 yield* git(work, "switch", "--quiet", "-c", "feat/answer-42");
62 yield* write("index.ts", "export const answer = 42;\n");
63 yield* git(work, "commit", "--quiet", "-am", "A: set the answer to 42");
64 yield* git(work, "switch", "--quiet", "-c", "feat/answer-doc");
65 yield* write("index.ts", "/** The answer. */\nexport const answer = 42;\n");
66 yield* git(work, "commit", "--quiet", "-am", "B: document the answer");
67 yield* git(work, "switch", "--quiet", "main");
68 yield* write("README.md", "main moved ahead\n");
69 yield* git(work, "add", "-A");
70 yield* git(work, "commit", "--quiet", "-m", "Docs");
71 yield* git(work, "push", "--quiet", "origin", "main", "feat/answer-42", "feat/answer-doc");
72 const head = (branch: string) => git(remote, "rev-parse", `refs/heads/${branch}`);
73 return {
74 remote,
75 head,
76 git: (...args: string[]) => git(remote, ...args),
77 layers: [
78 { number: 1, headBranch: "feat/answer-42", headSha: yield* head("feat/answer-42") },
79 { number: 2, headBranch: "feat/answer-doc", headSha: yield* head("feat/answer-doc") },
80 ],
81 };
82});
83
84it.layer(layer)("cascadeRebaseStack", (it) => {
85 it.effect("moves each layer's own commits onto the rebased layer below it", () =>
86 Effect.gen(function* () {
87 const repo = yield* setup;
88 const completed = yield* cascadeRebaseStack({
89 host: "github.com",
90 repository: "acme/web",
91 base: "main",
92 layers: repo.layers,
93 remote: repo.remote,
94 });
95 assert.strictEqual(completed, 2);
96
97 const main = yield* repo.head("main");
98 const bottom = yield* repo.head("feat/answer-42");
99 const top = yield* repo.head("feat/answer-doc");
100 // Bottom sits on the new main; top sits on the new bottom with only its own commit.
101 expect(yield* repo.git("rev-parse", `${bottom}~1`)).toBe(main);
102 expect(yield* repo.git("rev-parse", `${top}~1`)).toBe(bottom);
103 expect(yield* repo.git("log", "--format=%s", `${bottom}..${top}`)).toBe(
104 "B: document the answer",
105 );
106 }).pipe(Effect.scoped),
107 );
108
109 it.effect("refuses to overwrite a layer pushed after it was reviewed", () =>
110 Effect.gen(function* () {
111 const repo = yield* setup;
112 const stale = [
113 repo.layers[0]!,
114 // Reviewed at the bottom layer's head, but the branch is really at its own commit.
115 { ...repo.layers[1]!, headSha: repo.layers[0]!.headSha },
116 ];
117 const error = yield* Effect.flip(
118 cascadeRebaseStack({
119 host: "github.com",
120 repository: "acme/web",
121 base: "main",
122 layers: stale,
123 remote: repo.remote,
124 }),
125 );
126 expect(error).toMatchObject({ _tag: "GitHubStackRebaseGitError", number: 2, completed: 1 });
127 expect(yield* repo.head("feat/answer-doc")).toBe(repo.layers[1]!.headSha);
128 }).pipe(Effect.scoped),
129 );
130
131 it.effect("stops at a conflicting layer and leaves it untouched", () =>
132 Effect.gen(function* () {
133 const repo = yield* setup;
134 // Make main conflict with the bottom layer's change to the same line.
135 const fs = yield* FileSystem.FileSystem;
136 const process = yield* VcsProcess.VcsProcess;
137 const scratch = yield* fs.makeTempDirectoryScoped({ prefix: "t3-cascade-conflict-" });
138 const run = (...args: string[]) =>
139 process.run({
140 operation: "test",
141 command: "git",
142 args,
143 cwd: scratch,
144 env: {
145 GIT_AUTHOR_NAME: "t",
146 GIT_AUTHOR_EMAIL: "t@t",
147 GIT_COMMITTER_NAME: "t",
148 GIT_COMMITTER_EMAIL: "t@t",
149 },
150 });
151 yield* run("clone", "--quiet", repo.remote, ".");
152 yield* fs.writeFileString(`${scratch}/index.ts`, "export const answer = 43;\n");
153 yield* run("commit", "--quiet", "-am", "Conflict");
154 yield* run("push", "--quiet", "origin", "main");
155
156 const error = yield* Effect.flip(
157 cascadeRebaseStack({
158 host: "github.com",
159 repository: "acme/web",
160 base: "main",
161 layers: repo.layers,
162 remote: repo.remote,
163 }),
164 );
165 expect(error).toMatchObject({
166 _tag: "GitHubStackRebaseConflictError",
167 number: 1,
168 completed: 0,
169 });
170 expect(yield* repo.head("feat/answer-42")).toBe(repo.layers[0]!.headSha);
171 }).pipe(Effect.scoped),
172 );
173});