1import * as Console from "effect/Console"
2import * as Context from "effect/Context"
3import * as Effect from "effect/Effect"
4import * as FileSystem from "effect/FileSystem"
5import * as Layer from "effect/Layer"
6import * as Path from "effect/Path"
7import * as ChildProcess from "effect/process/ChildProcess"
8import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"
9import * as Stream from "effect/Stream"
10import { ApiDiffError, isApiDiffError } from "./Error.ts"
11import { decodeJson } from "./Json.ts"
12import type { ApiSnapshot } from "./Model.ts"
13import { snapshotCacheKey, Snapshotter } from "./Snapshot.ts"
14
15export 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
25export 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}