Skip to content
Source
Sign inSign up

sync-reference-repos.ts

382 lines11.8 KB

1#!/usr/bin/env node
2​
3import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
4import * as NodeServices from "@effect/platform-node/NodeServices";
5import * as Console from "effect/Console";
6import * as Effect from "effect/Effect";
7import * as FileSystem from "effect/FileSystem";
8import * as Option from "effect/Option";
9import * as Path from "effect/Path";
10import * as Schema from "effect/Schema";
11import * as Stream from "effect/Stream";
12import { Command, Flag } from "effect/cli";
13import { ChildProcess, ChildProcessSpawner } from "effect/process";
14import { fromYaml } from "@t3tools/shared/schemaYaml";
15​
16import { referenceRepos, type ReferenceRepo } from "./lib/reference-repos.ts";
17​
18export interface ReferenceRepoSyncOptions {
19 readonly rootDir?: string | undefined;
20 readonly repoId?: string | undefined;
21 readonly latest?: boolean | undefined;
22 readonly dryRun?: boolean | undefined;
24​
25export interface ReferenceRepoSyncPlan {
26 readonly repo: ReferenceRepo;
27 readonly ref: string;
28 /** `git clone` arguments, without the destination directory. */
29 readonly cloneArgs: ReadonlyArray<string>;
31​
32export class ReferenceRepoSelectionError extends Schema.TaggedError<ReferenceRepoSelectionError>()(
33 "ReferenceRepoSelectionError",
34 {
35 repoId: Schema.String,
36 expectedRepoIds: Schema.Array(Schema.String),
37 },
38) {
39 override get message(): string {
40 return `Unknown reference repo "${this.repoId}". Expected one of: ${this.expectedRepoIds.join(", ")}.`;
41 }
43​
44export class ReferenceRepoVersionSourceError extends Schema.TaggedError<ReferenceRepoVersionSourceError>()(
45 "ReferenceRepoVersionSourceError",
46 {
47 operation: Schema.Literals(["read", "parse"]),
48 repoId: Schema.String,
49 sourcePath: Schema.String,
50 cause: Schema.Defect(),
51 },
52) {
53 override get message(): string {
54 return `Reference repo "${this.repoId}" version source operation "${this.operation}" failed for ${this.sourcePath}.`;
55 }
57​
58export class ReferenceRepoVersionResolutionError extends Schema.TaggedError<ReferenceRepoVersionResolutionError>()(
59 "ReferenceRepoVersionResolutionError",
60 {
61 repoId: Schema.String,
62 sourcePath: Schema.String,
63 packageVersionPath: Schema.Array(Schema.String),
64 },
65) {
66 override get message(): string {
67 return `No version was found for reference repo "${this.repoId}" at ${this.sourcePath}:${this.packageVersionPath.join(".")}.`;
68 }
70​
71export class ReferenceRepoGitError extends Schema.TaggedError<ReferenceRepoGitError>()(
72 "ReferenceRepoGitError",
73 {
74 operation: Schema.Literals(["spawn", "communicate", "exit"]),
75 repoId: Schema.String,
76 command: Schema.String,
77 ref: Schema.String,
78 cwd: Schema.String,
79 exitCode: Schema.optional(Schema.Number),
80 stderr: Schema.optional(Schema.String),
81 cause: Schema.optional(Schema.Defect()),
82 },
83) {
84 override get message(): string {
85 const detail = this.stderr ? `: ${this.stderr.trim()}` : "";
86 return `git ${this.command} for reference repo "${this.repoId}" at ${this.ref} failed during "${this.operation}"${detail}`;
87 }
89​
90export class ReferenceRepoFileSystemError extends Schema.TaggedError<ReferenceRepoFileSystemError>()(
91 "ReferenceRepoFileSystemError",
92 {
93 repoId: Schema.String,
94 prefix: Schema.String,
95 cause: Schema.Defect(),
96 },
97) {
98 override get message(): string {
99 return `Replacing ${this.prefix} for reference repo "${this.repoId}" failed.`;
100 }
102​
103export type ReferenceRepoSyncError =
104 | ReferenceRepoSelectionError
105 | ReferenceRepoVersionSourceError
106 | ReferenceRepoVersionResolutionError
107 | ReferenceRepoGitError
108 | ReferenceRepoFileSystemError;
109​
110const decodeJsonSource = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown));
111const decodeYamlSource = Schema.decodeEffect(fromYaml(Schema.Unknown));
112​
113const collectStreamAsString = <E>(stream: Stream.Stream<Uint8Array, E>): Effect.Effect<string, E> =>
114 stream.pipe(
115 Stream.decodeText(),
116 Stream.runFold(
117 () => "",
118 (acc, chunk) => acc + chunk,
119 ),
120 );
121​
122function readNestedString(input: unknown, keys: ReadonlyArray<string>): string | undefined {
123 let value = input;
124 for (const key of keys) {
125 if (typeof value !== "object" || value === null || !(key in value)) {
126 return undefined;
127 }
128 value = (value as Record<string, unknown>)[key];
129 }
130 return typeof value === "string" && value.length > 0 ? value : undefined;
132​
133function decodeVersionSource(
134 repo: ReferenceRepo,
135 sourcePath: string,
136 content: string,
137): Effect.Effect<unknown, ReferenceRepoSyncError> {
138 const decode =
139 repo.versionSourcePath.endsWith(".yaml") || repo.versionSourcePath.endsWith(".yml")
140 ? decodeYamlSource
141 : decodeJsonSource;
142 return decode(content).pipe(
143 Effect.mapError(
144 (cause) =>
145 new ReferenceRepoVersionSourceError({
146 operation: "parse",
147 repoId: repo.id,
148 sourcePath,
149 cause,
150 }),
151 ),
152 );
154​
155function getSelectedRepos(
156 repoId: string | undefined,
157): Effect.Effect<ReadonlyArray<ReferenceRepo>, ReferenceRepoSyncError> {
158 if (!repoId) {
159 return Effect.succeed(referenceRepos);
160 }
161​
162 const repo = referenceRepos.find((candidate) => candidate.id === repoId);
163 return repo
164 ? Effect.succeed([repo])
165 : Effect.fail(
166 new ReferenceRepoSelectionError({
167 repoId,
168 expectedRepoIds: referenceRepos.map((candidate) => candidate.id),
169 }),
170 );
172​
173export const resolveReferenceRepoRef = Effect.fn("resolveReferenceRepoRef")(function* (
174 repo: ReferenceRepo,
175 rootDir: string,
176 latest: boolean,
177) {
178 if (latest) {
179 return repo.latestRef;
180 }
181​
182 const fs = yield* FileSystem.FileSystem;
183 const path = yield* Path.Path;
184 const versionSourcePath = path.join(rootDir, repo.versionSourcePath);
185 const versionSourceContent = yield* fs.readFileString(versionSourcePath).pipe(
186 Effect.mapError(
187 (cause) =>
188 new ReferenceRepoVersionSourceError({
189 operation: "read",
190 repoId: repo.id,
191 sourcePath: versionSourcePath,
192 cause,
193 }),
194 ),
195 );
196 const versionSource = yield* decodeVersionSource(repo, versionSourcePath, versionSourceContent);
197 const version = readNestedString(versionSource, repo.packageVersionPath);
198​
199 if (!version) {
200 return yield* new ReferenceRepoVersionResolutionError({
201 repoId: repo.id,
202 sourcePath: versionSourcePath,
203 packageVersionPath: repo.packageVersionPath,
204 });
205 }
206​
207 return `${repo.versionTagPrefix}${version}`;
208});
209​
210/**
211 * Shallow-clone the upstream ref into a temp dir and copy its files into `.repos/<id>`.
212 * Never fetch upstream into this repo: its tags and shallow boundaries break `git fetch`.
213 */
214export const planReferenceRepoSync = Effect.fn("planReferenceRepoSync")(function* (
215 repo: ReferenceRepo,
216 rootDir: string,
217 latest: boolean,
218) {
219 const ref = yield* resolveReferenceRepoRef(repo, rootDir, latest);
220​
221 return {
222 repo,
223 ref,
224 cloneArgs: [
225 "clone",
226 "--quiet",
227 "--depth=1",
228 "--no-tags",
229 "--single-branch",
230 `--branch=${ref}`,
231 repo.repository,
232 ],
233 } satisfies ReferenceRepoSyncPlan;
234});
235​
236const runGit = Effect.fn("runGit")(function* (
237 plan: ReferenceRepoSyncPlan,
238 cwd: string,
239 args: ReadonlyArray<string>,
240) {
241 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
242 const errorContext = {
243 repoId: plan.repo.id,
244 command: args[0] ?? "",
245 ref: plan.ref,
246 cwd,
247 } as const;
248 const child = yield* spawner
249 .spawn(ChildProcess.make("git", [...args], { cwd }))
250 .pipe(
251 Effect.mapError(
252 (cause) => new ReferenceRepoGitError({ ...errorContext, operation: "spawn", cause }),
253 ),
254 );
255 const [stdout, stderr, exitCode] = yield* Effect.all(
256 [
257 collectStreamAsString(child.stdout),
258 collectStreamAsString(child.stderr),
259 child.exitCode.pipe(Effect.map(Number)),
260 ],
261 { concurrency: "unbounded" },
262 ).pipe(
263 Effect.mapError(
264 (cause) => new ReferenceRepoGitError({ ...errorContext, operation: "communicate", cause }),
265 ),
266 );
267​
268 if (exitCode !== 0) {
269 return yield* new ReferenceRepoGitError({
270 ...errorContext,
271 operation: "exit",
272 exitCode,
273 stderr,
274 });
275 }
276​
277 return stdout;
278});
279​
280const applyReferenceRepoSync = Effect.fn("applyReferenceRepoSync")(function* (
281 rootDir: string,
282 plan: ReferenceRepoSyncPlan,
283) {
284 const fs = yield* FileSystem.FileSystem;
285 const path = yield* Path.Path;
286 const { repo } = plan;
287 const target = path.join(rootDir, repo.prefix);
288 const mapFsError = Effect.mapError(
289 (cause: unknown) =>
290 new ReferenceRepoFileSystemError({ repoId: repo.id, prefix: repo.prefix, cause }),
291 );
292​
293 // Clone next to the target so a rename moves it into place: same filesystem, and
294 // relative symlinks stay relative (`fs.copy` rewrites them to absolute temp paths).
295 yield* fs.makeDirectory(path.dirname(target), { recursive: true }).pipe(mapFsError);
296 const tempDir = yield* fs
297 .makeTempDirectoryScoped({ directory: path.dirname(target), prefix: `.sync-${repo.id}-` })
298 .pipe(mapFsError);
299 const checkout = path.join(tempDir, "checkout");
300 yield* runGit(plan, rootDir, [...plan.cloneArgs, checkout]);
301 const commit = (yield* runGit(plan, checkout, ["rev-parse", "HEAD"])).trim();
302​
303 yield* Effect.gen(function* () {
304 yield* fs.remove(path.join(checkout, ".git"), { recursive: true });
305 yield* fs.remove(target, { recursive: true, force: true });
306 yield* fs.rename(checkout, target);
307 }).pipe(mapFsError);
308​
309 yield* runGit(plan, rootDir, ["add", "--all", "--", repo.prefix]);
310 const status = yield* runGit(plan, rootDir, ["status", "--porcelain", "--", repo.prefix]);
311 if (status.trim().length === 0) {
312 yield* Console.log(`${repo.id} is already at ${plan.ref}.`);
313 return;
314 }
315 yield* runGit(plan, rootDir, [
316 "commit",
317 "--quiet",
318 "--message",
319 `chore(repos): sync ${repo.id} to ${plan.ref}\n\nUpstream: ${repo.repository} ${commit}`,
320 "--",
321 repo.prefix,
322 ]);
323 yield* Console.log(`Committed ${repo.prefix} at ${plan.ref} (${commit.slice(0, 12)}).`);
324});
325​
326export const syncReferenceRepos = Effect.fn("syncReferenceRepos")(function* (
327 options: ReferenceRepoSyncOptions = {},
328) {
329 const path = yield* Path.Path;
330 const rootDir = path.resolve(options.rootDir ?? process.cwd());
331 const repos = yield* getSelectedRepos(options.repoId);
332 const plans: Array<ReferenceRepoSyncPlan> = [];
333​
334 for (const repo of repos) {
335 const plan = yield* planReferenceRepoSync(repo, rootDir, options.latest ?? false);
336 plans.push(plan);
337 yield* Console.log(`Syncing ${repo.id} from ${plan.ref} (shallow clone, no history).`);
338 if (!(options.dryRun ?? false)) {
339 yield* applyReferenceRepoSync(rootDir, plan).pipe(Effect.scoped);
340 }
341 }
342​
343 return plans;
344});
345​
346export const syncReferenceReposCommand = Command.make(
347 "sync-reference-repos",
348 {
349 repo: Flag.String("repo").pipe(
350 Flag.withDescription("Sync only the named reference repo. Defaults to all configured repos."),
351 Flag.optional,
352 ),
353 latest: Flag.Boolean("latest").pipe(
354 Flag.withDescription(
355 "Sync each repo from its latest branch instead of the installed version.",
356 ),
357 Flag.withDefault(false),
358 ),
359 root: Flag.String("root").pipe(
360 Flag.withDescription("Workspace root used to resolve versions and .repos/ prefixes."),
361 Flag.optional,
362 ),
363 dryRun: Flag.Boolean("dry-run").pipe(
364 Flag.withDescription("Print the planned syncs without cloning or committing."),
365 Flag.withDefault(false),
366 ),
367 },
368 ({ repo, latest, root, dryRun }) =>
369 syncReferenceRepos({
370 repoId: Option.getOrUndefined(repo),
371 rootDir: Option.getOrUndefined(root),
372 latest,
373 dryRun,
374 }),
375).pipe(Command.withDescription("Sync vendored reference repositories under .repos/."));
376​
377if (import.meta.main) {
378 Command.run(syncReferenceReposCommand, { version: "0.0.0" }).pipe(
379 Effect.provide(NodeServices.layer),
380 NodeRuntime.runMain,
381 );