| 1 | import * as Console from "effect/Console" |
| 2 | import * as Context from "effect/Context" |
| 3 | import * as Effect from "effect/Effect" |
| 4 | import * as FileSystem from "effect/FileSystem" |
| 5 | import * as Layer from "effect/Layer" |
| 6 | import * as Path from "effect/Path" |
| 7 | import * as ChildProcess from "effect/process/ChildProcess" |
| 8 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner" |
| 9 | import * as Stream from "effect/Stream" |
| 10 | import { ApiDiffError, isApiDiffError } from "./Error.ts" |
| 11 | import { decodeJson } from "./Json.ts" |
| 12 | import type { ApiSnapshot } from "./Model.ts" |
| 13 | import { snapshotCacheKey, Snapshotter } from "./Snapshot.ts" |
| 14 | |
| 15 | export interface PrepareSnapshotOptions { |
| 16 | readonly repoRoot: string |
| 17 | readonly cacheRoot: string |
| 18 | readonly worktreesRoot: string |
| 19 | readonly name: "base" | "head" |
| 20 | readonly ref: string |
| 21 | readonly sha: string |
| 22 | readonly modules?: ReadonlyArray<string> |
| 23 | } |
| 24 | |
| 25 | export class Worktrees extends Context.Service<Worktrees, { |
| 26 | readonly resolveRef: (repoRoot: string, ref: string) => Effect.Effect<string, ApiDiffError> |
| 27 | readonly prepareSnapshot: (options: PrepareSnapshotOptions) => Effect.Effect<ApiSnapshot, ApiDiffError> |
| 28 | }>()("@effect/api-diff/Worktrees") { |
| 29 | static readonly layerNoDependencies = Layer.effect( |
| 30 | Worktrees, |
| 31 | Effect.gen(function*() { |
| 32 | const fs = yield* FileSystem.FileSystem |
| 33 | const path = yield* Path.Path |
| 34 | const snapshotter = yield* Snapshotter |
| 35 | const spawner = yield* ChildProcessSpawner.ChildProcessSpawner |
| 36 | |
| 37 | const runCommandScoped = Effect.fnUntraced(function*( |
| 38 | command: string, |
| 39 | args: ReadonlyArray<string>, |
| 40 | cwd: string |
| 41 | ) { |
| 42 | const display = `${command} ${args.join(" ")}` |
| 43 | const handle = yield* spawner.spawn( |
| 44 | ChildProcess.make(command, args, { cwd }) |
| 45 | ).pipe( |
| 46 | Effect.mapError((cause) => |
| 47 | new ApiDiffError({ |
| 48 | message: `Could not start command (${display})`, |
| 49 | cause |
| 50 | }) |
| 51 | ) |
| 52 | ) |
| 53 | const [output, exitCode] = yield* Effect.all([ |
| 54 | Stream.mkString(Stream.decodeText(handle.all)), |
| 55 | handle.exitCode |
| 56 | ], { concurrency: "unbounded" }).pipe( |
| 57 | Effect.mapError((cause) => |
| 58 | new ApiDiffError({ |
| 59 | message: `Could not run command (${display})`, |
| 60 | cause |
| 61 | }) |
| 62 | ) |
| 63 | ) |
| 64 | return { display, exitCode, output } |
| 65 | }) |
| 66 | |
| 67 | const runCommand = ( |
| 68 | command: string, |
| 69 | args: ReadonlyArray<string>, |
| 70 | cwd: string |
| 71 | ): Effect.Effect<{ |
| 72 | readonly display: string |
| 73 | readonly exitCode: ChildProcessSpawner.ExitCode |
| 74 | readonly output: string |
| 75 | }, ApiDiffError> => Effect.scoped(runCommandScoped(command, args, cwd)) |
| 76 | |
| 77 | const runChecked = Effect.fnUntraced(function*( |
| 78 | command: string, |
| 79 | args: ReadonlyArray<string>, |
| 80 | cwd: string |
| 81 | ) { |
| 82 | const result = yield* runCommand(command, args, cwd) |
| 83 | if (result.exitCode !== ChildProcessSpawner.ExitCode(0)) { |
| 84 | return yield* new ApiDiffError({ |
| 85 | message: `Command failed (${result.display}):\n${result.output}`.trim() |
| 86 | }) |
| 87 | } |
| 88 | return result.output.trim() |
| 89 | }) |
| 90 | |
| 91 | const enableStripInternal = Effect.fnUntraced(function*(worktree: string) { |
| 92 | const baseConfig = path.join(worktree, "tsconfig.base.json") |
| 93 | if (!(yield* fs.exists(baseConfig))) { |
| 94 | return |
| 95 | } |
| 96 | const source = yield* fs.readFileString(baseConfig) |
| 97 | if (/"stripInternal"\s*:\s*true/.test(source)) { |
| 98 | return |
| 99 | } |
| 100 | const updated = source.replace(/("stripInternal"\s*:\s*)false/, "$1true") |
| 101 | if (updated !== source) { |
| 102 | yield* fs.writeFileString(baseConfig, updated) |
| 103 | } |
| 104 | }) |
| 105 | |
| 106 | const hasProductionStripInternal = Effect.fnUntraced(function*(worktree: string) { |
| 107 | const candidates = [ |
| 108 | path.join(worktree, "tsconfig.base.json"), |
| 109 | path.join(worktree, "tsconfig.build.json"), |
| 110 | path.join(worktree, "packages", "effect", "tsconfig.build.json") |
| 111 | ] |
| 112 | for (const candidate of candidates) { |
| 113 | if ((yield* fs.exists(candidate)) && /"stripInternal"\s*:\s*true/.test(yield* fs.readFileString(candidate))) { |
| 114 | return true |
| 115 | } |
| 116 | } |
| 117 | return false |
| 118 | }) |
| 119 | |
| 120 | const buildWorktree = Effect.fnUntraced(function*(worktree: string) { |
| 121 | yield* enableStripInternal(worktree) |
| 122 | if (!(yield* hasProductionStripInternal(worktree))) { |
| 123 | return yield* new ApiDiffError({ |
| 124 | message: `No production TypeScript configuration enables stripInternal in ${worktree}` |
| 125 | }) |
| 126 | } |
| 127 | // Native test-only dependencies in old branches may not build on the |
| 128 | // current Node runtime. Declaration emission does not require dependency lifecycle scripts. |
| 129 | yield* runChecked("pnpm", ["install", "--frozen-lockfile", "--ignore-scripts"], worktree) |
| 130 | yield* runChecked("pnpm", ["build"], worktree) |
| 131 | }) |
| 132 | |
| 133 | const removeWorktree = (repoRoot: string, worktree: string): Effect.Effect<void> => |
| 134 | runCommand("git", ["worktree", "remove", "--force", worktree], repoRoot).pipe( |
| 135 | Effect.flatMap((result) => |
| 136 | result.exitCode === ChildProcessSpawner.ExitCode(0) |
| 137 | ? Effect.void |
| 138 | : Console.error(result.output) |
| 139 | ), |
| 140 | Effect.catch((error) => Console.error(error.message)) |
| 141 | ) |
| 142 | |
| 143 | const resolveRef = Effect.fnUntraced(function*(repoRoot: string, ref: string) { |
| 144 | return yield* runChecked("git", ["rev-parse", "--verify", `${ref}^{commit}`], repoRoot) |
| 145 | }) |
| 146 | |
| 147 | const prepareSnapshotInternal = Effect.fnUntraced(function*(options: PrepareSnapshotOptions) { |
| 148 | const key = snapshotCacheKey(options.sha, options.modules) |
| 149 | const cacheLocation = path.join(options.cacheRoot, key, "snapshot.json") |
| 150 | if (yield* fs.exists(cacheLocation)) { |
| 151 | const source = yield* fs.readFileString(cacheLocation).pipe( |
| 152 | Effect.mapError((cause) => |
| 153 | new ApiDiffError({ |
| 154 | message: `Could not read cached snapshot ${cacheLocation}`, |
| 155 | cause |
| 156 | }) |
| 157 | ) |
| 158 | ) |
| 159 | const cached = (yield* decodeJson(source).pipe( |
| 160 | Effect.mapError((cause) => |
| 161 | new ApiDiffError({ |
| 162 | message: `Could not parse cached snapshot ${cacheLocation}`, |
| 163 | cause |
| 164 | }) |
| 165 | ) |
| 166 | )) as ApiSnapshot |
| 167 | return { ...cached, ref: options.ref, sha: options.sha } |
| 168 | } |
| 169 | |
| 170 | yield* fs.makeDirectory(options.worktreesRoot, { recursive: true }) |
| 171 | return yield* Effect.scoped(Effect.gen(function*() { |
| 172 | const runRoot = yield* fs.makeTempDirectoryScoped({ |
| 173 | directory: options.worktreesRoot, |
| 174 | prefix: `${options.name}-` |
| 175 | }) |
| 176 | const worktree = path.join(runRoot, "repo") |
| 177 | return yield* Effect.acquireUseRelease( |
| 178 | runChecked("git", ["worktree", "add", "--detach", worktree, options.sha], options.repoRoot), |
| 179 | () => |
| 180 | Effect.gen(function*() { |
| 181 | yield* buildWorktree(worktree) |
| 182 | const snapshot = yield* snapshotter.extract({ |
| 183 | repoRoot: worktree, |
| 184 | ref: options.ref, |
| 185 | sha: options.sha, |
| 186 | ...(options.modules === undefined ? {} : { modules: options.modules }) |
| 187 | }).pipe( |
| 188 | Effect.mapError((cause) => |
| 189 | isApiDiffError(cause) |
| 190 | ? cause |
| 191 | : new ApiDiffError({ |
| 192 | message: `Could not extract the ${options.name} API snapshot`, |
| 193 | cause |
| 194 | }) |
| 195 | ) |
| 196 | ) |
| 197 | let encoded: string | undefined |
| 198 | try { |
| 199 | encoded = JSON.stringify(snapshot) |
| 200 | } catch { |
| 201 | encoded = undefined |
| 202 | } |
| 203 | if (encoded !== undefined) { |
| 204 | yield* fs.makeDirectory(path.dirname(cacheLocation), { recursive: true }) |
| 205 | yield* fs.writeFileString(cacheLocation, encoded).pipe( |
| 206 | Effect.mapError((cause) => |
| 207 | new ApiDiffError({ |
| 208 | message: `Could not cache the ${options.name} API snapshot`, |
| 209 | cause |
| 210 | }) |
| 211 | ) |
| 212 | ) |
| 213 | } |
| 214 | return snapshot |
| 215 | }), |
| 216 | () => removeWorktree(options.repoRoot, worktree) |
| 217 | ) |
| 218 | })) |
| 219 | }) |
| 220 | |
| 221 | const prepareSnapshot = (options: PrepareSnapshotOptions): Effect.Effect<ApiSnapshot, ApiDiffError> => |
| 222 | prepareSnapshotInternal(options).pipe( |
| 223 | Effect.mapError((cause) => |
| 224 | isApiDiffError(cause) |
| 225 | ? cause |
| 226 | : new ApiDiffError({ |
| 227 | message: `Could not prepare the ${options.name} API snapshot`, |
| 228 | cause |
| 229 | }) |
| 230 | ) |
| 231 | ) |
| 232 | |
| 233 | return Worktrees.of({ |
| 234 | prepareSnapshot, |
| 235 | resolveRef |
| 236 | }) |
| 237 | }) |
| 238 | ) |
| 239 | |
| 240 | static readonly layer = this.layerNoDependencies.pipe( |
| 241 | Layer.provide(Snapshotter.layer) |
| 242 | ) |
| 243 | } |