Open History to see the last commit
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import {
3 DEFAULT_SERVER_SETTINGS,
4 GitCommandError,
5 type ServerSettings,
6 type ServerSettingsPatch,
7 VcsProcessExitError,
8 VcsProcessSpawnError,
9} from "@t3tools/contracts";
10import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";
11import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
12import * as Context from "effect/Context";
13import * as Effect from "effect/Effect";
14import * as Layer from "effect/Layer";
15import * as ChildProcess from "effect/process/ChildProcess";
16import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
17import * as Ref from "effect/Ref";
18import * as Stream from "effect/Stream";
19
20export interface TestSourceControlHostOptions {
21 readonly settings?: ServerSettings;
22 readonly process?: Partial<SourceControlHost.SourceControlHost["Service"]["process"]>;
23 readonly git?: Partial<SourceControlHost.SourceControlHost["Service"]["git"]>;
24}
25
26export class TestSourceControlHostSettings extends Context.Service<
27 TestSourceControlHostSettings,
28 { readonly update: (patch: ServerSettingsPatch) => Effect.Effect<void> }
29>()("@t3tools/source-control-testing/TestSourceControlHost/TestSourceControlHostSettings") {}
30
31type Git = SourceControlHost.SourceControlHost["Service"]["git"];
32
33const unexpectedGit = (operation: string) => () =>
34 Effect.die(`Unexpected git ${operation} in a test that supplied none.`);
35
36const failingGit: Git = {
37 execute: unexpectedGit("execute"),
38 resolveCommit: unexpectedGit("resolveCommit"),
39 remotes: unexpectedGit("remotes"),
40 readConfigValue: unexpectedGit("readConfigValue"),
41 resolvePrimaryRemoteName: unexpectedGit("resolvePrimaryRemoteName"),
42 ensureRemote: unexpectedGit("ensureRemote"),
43 listLocalBranchNames: unexpectedGit("listLocalBranchNames"),
44 fetchRemoteBranch: unexpectedGit("fetchRemoteBranch"),
45 fetchRemoteTrackingBranch: unexpectedGit("fetchRemoteTrackingBranch"),
46 setBranchUpstream: unexpectedGit("setBranchUpstream"),
47 createBranch: unexpectedGit("createBranch"),
48 writeConfigValue: unexpectedGit("writeConfigValue"),
49 switchRef: unexpectedGit("switchRef"),
50};
51
52export const layer = (
53 options: TestSourceControlHostOptions = {},
54): Layer.Layer<SourceControlHost.SourceControlHost | TestSourceControlHostSettings> =>
55 Layer.effectContext(
56 Effect.gen(function* () {
57 const settings = yield* Ref.make(options.settings ?? DEFAULT_SERVER_SETTINGS);
58 const update = (patch: ServerSettingsPatch) =>
59 Ref.updateAndGet(settings, (current) => applyServerSettingsPatch(current, patch));
60 const host = SourceControlHost.SourceControlHost.of({
61 settings: { get: Ref.get(settings), getWithSecrets: () => Ref.get(settings), update },
62 process: {
63 run:
64 options.process?.run ??
65 ((input) => Effect.die(`Unexpected ${input.command} run in ${input.operation}.`)),
66 },
67 git: { ...failingGit, ...options.git },
68 });
69 return Context.make(SourceControlHost.SourceControlHost, host).pipe(
70 Context.add(TestSourceControlHostSettings, {
71 update: (patch) => Effect.asVoid(update(patch)),
72 }),
73 );
74 }),
75 );
76
77export const processOutput = (
78 stdout: string,
79 options?: {
80 readonly stderr?: string;
81 readonly exitCode?: ChildProcessSpawner.ExitCode;
82 },
83): SourceControlHost.SourceControlProcessOutput => ({
84 exitCode: options?.exitCode ?? ChildProcessSpawner.ExitCode(0),
85 stdout,
86 stderr: options?.stderr ?? "",
87 stdoutTruncated: false,
88 stderrTruncated: false,
89});
90
91const TRUNCATION_MARKER = "\n\n[truncated]";
92const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;
93
94const collectText = <E>(stream: Stream.Stream<Uint8Array, E>, maxBytes: number, marker: string) =>
95 Stream.runCollect(stream).pipe(
96 Effect.map((chunks) => {
97 const bytes = Buffer.concat(Array.from(chunks));
98 const truncated = bytes.byteLength > maxBytes;
99 const text = bytes.subarray(0, maxBytes).toString("utf8");
100 return { text: truncated ? `${text}${marker}` : text, truncated };
101 }),
102 );
103
104/**
105 * Runs commands for real, the way the server's host does in the parts a forge reads: output is
106 * capped and marked, and a non-zero exit fails unless allowed. It has none of the server's
107 * timeouts, concurrency limits, generic failure classification, or retries.
108 */
109const runForReal = (
110 spawner: ChildProcessSpawner.ChildProcessSpawner["Service"],
111 input: SourceControlHost.SourceControlProcessInput,
112): Effect.Effect<
113 SourceControlHost.SourceControlProcessOutput,
114 VcsProcessSpawnError | VcsProcessExitError
115> => {
116 const context = {
117 operation: input.operation,
118 command: input.command,
119 cwd: input.cwd,
120 argumentCount: input.args.length,
121 };
122 const maxBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
123 const marker = input.appendTruncationMarker ? TRUNCATION_MARKER : "";
124 return Effect.gen(function* () {
125 const child = yield* spawner.spawn(
126 ChildProcess.make(input.command, input.args, {
127 cwd: input.spawnCwd ?? input.cwd,
128 ...(input.env === undefined ? {} : { env: input.env, extendEnv: true }),
129 }),
130 );
131 const [stdout, stderr] = yield* Effect.all(
132 [
133 collectText(
134 input.onStdoutChunk === undefined
135 ? child.stdout
136 : child.stdout.pipe(
137 Stream.tap((chunk) => Effect.sync(() => input.onStdoutChunk?.(chunk))),
138 ),
139 maxBytes,
140 marker,
141 ),
142 collectText(child.stderr, maxBytes, marker),
143 input.stdin === undefined
144 ? Effect.void
145 : Stream.run(Stream.encodeText(Stream.make(input.stdin)), child.stdin),
146 ],
147 { concurrency: "unbounded" },
148 );
149 const exitCode = yield* child.exitCode;
150 if (exitCode !== 0 && !input.allowNonZeroExit) {
151 return yield* VcsProcessExitError.fromProcessExit(
152 context,
153 { exitCode, stderr: stderr.text, stderrTruncated: stderr.truncated },
154 input.classifyFailure?.(stderr.text) ?? "command-failed",
155 input.notFoundDetail === undefined ? undefined : { notFoundDetail: input.notFoundDetail },
156 );
157 }
158 return {
159 exitCode,
160 stdout: stdout.text,
161 stderr: stderr.text,
162 stdoutTruncated: stdout.truncated,
163 stderrTruncated: stderr.truncated,
164 };
165 }).pipe(
166 Effect.scoped,
167 Effect.catchTags({
168 PlatformError: (cause) => Effect.fail(new VcsProcessSpawnError({ ...context, cause })),
169 }),
170 );
171};
172
173/**
174 * `layer` whose `process.run` and `git.execute` spawn real processes, for a forge test that needs
175 * a real repository. Every other git operation still dies unless supplied.
176 */
177export const layerWithRealGit = (
178 options: TestSourceControlHostOptions = {},
179): Layer.Layer<SourceControlHost.SourceControlHost | TestSourceControlHostSettings> =>
180 Layer.unwrap(
181 Effect.gen(function* () {
182 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
183 const run = (input: SourceControlHost.SourceControlProcessInput) =>
184 runForReal(spawner, input);
185 return layer({
186 ...options,
187 process: { run, ...options.process },
188 git: {
189 execute: (input) =>
190 run({ ...input, command: "git" }).pipe(
191 Effect.mapError(
192 (cause) =>
193 new GitCommandError({
194 operation: input.operation,
195 command: "git",
196 cwd: input.cwd,
197 argumentCount: input.args.length,
198 ...(cause._tag === "VcsProcessExitError" ? { exitCode: cause.exitCode } : {}),
199 detail: cause.message,
200 cause,
201 }),
202 ),
203 ),
204 ...options.git,
205 },
206 });
207 }),
208 ).pipe(Layer.provide(NodeServices.layer));