History

Add remote repository publish and discovery support (#2482)

Julius Marminge committed 6 months ago

Commit d7969261 parent f7748a033 files · +2502 −157Browse files

Showing the changes to apps/server/src/sourceControl/SourceControlRepositoryService.ts (1 of 33 files). Show all

Unified · Split

apps/server/src/sourceControl/SourceControlRepositoryService.ts+320−0
@@ -0,0 +1,320 @@
1+import OS from "node:os";
2+import { Context, Effect, FileSystem, Layer, Path, Schema } from "effect";
3+
4+import {
5+ SourceControlRepositoryError,
6+ type SourceControlCloneRepositoryInput,
7+ type SourceControlCloneRepositoryResult,
8+ type SourceControlCloneProtocol,
9+ type SourceControlProviderKind,
10+ type SourceControlPublishRepositoryInput,
11+ type SourceControlPublishRepositoryResult,
12+ type SourceControlRepositoryCloneUrls,
13+ type SourceControlRepositoryInfo,
14+ type SourceControlRepositoryLookupInput,
15+} from "@t3tools/contracts";
16+
17+import { ServerConfig } from "../config.ts";
18+import { GitVcsDriver } from "../vcs/GitVcsDriver.ts";
19+import { SourceControlProviderRegistry } from "./SourceControlProviderRegistry.ts";
20+
21+export interface SourceControlRepositoryServiceShape {
22+ readonly lookupRepository: (
23+ input: SourceControlRepositoryLookupInput,
24+ ) => Effect.Effect<SourceControlRepositoryInfo, SourceControlRepositoryError>;
25+ readonly cloneRepository: (
26+ input: SourceControlCloneRepositoryInput,
27+ ) => Effect.Effect<SourceControlCloneRepositoryResult, SourceControlRepositoryError>;
28+ readonly publishRepository: (
29+ input: SourceControlPublishRepositoryInput,
30+ ) => Effect.Effect<SourceControlPublishRepositoryResult, SourceControlRepositoryError>;
31+}
32+
33+export class SourceControlRepositoryService extends Context.Service<
34+ SourceControlRepositoryService,
35+ SourceControlRepositoryServiceShape
36+>()("t3/source-control/SourceControlRepositoryService") {}
37+
38+function detailFromUnknown(cause: unknown): string {
39+ if (typeof cause === "object" && cause !== null) {
40+ if ("detail" in cause && typeof cause.detail === "string" && cause.detail.length > 0) {
41+ return cause.detail;
42+ }
43+ if ("message" in cause && typeof cause.message === "string" && cause.message.length > 0) {
44+ return cause.message;
45+ }
46+ }
47+
48+ return "An unexpected source control error occurred.";
49+}
50+
51+function repositoryError(input: {
52+ readonly operation: string;
53+ readonly provider: SourceControlProviderKind;
54+ readonly detail: string;
55+ readonly cause?: unknown;
56+}): SourceControlRepositoryError {
57+ return new SourceControlRepositoryError({
58+ provider: input.provider,
59+ operation: input.operation,
60+ detail: input.detail,
61+ ...(input.cause === undefined ? {} : { cause: input.cause }),
62+ });
63+}
64+
65+function mapRepositoryError(operation: string, provider: SourceControlProviderKind) {
66+ return Effect.mapError((cause: unknown) =>
67+ Schema.is(SourceControlRepositoryError)(cause)
68+ ? cause
69+ : repositoryError({
70+ operation,
71+ provider,
72+ detail: detailFromUnknown(cause),
73+ cause,
74+ }),
75+ );
76+}
77+
78+function toRepositoryInfo(
79+ provider: SourceControlProviderKind,
80+ urls: SourceControlRepositoryCloneUrls,
81+): SourceControlRepositoryInfo {
82+ return {
83+ provider,
84+ nameWithOwner: urls.nameWithOwner,
85+ url: urls.url,
86+ sshUrl: urls.sshUrl,
87+ };
88+}
89+
90+function selectRemoteUrl(
91+ urls: SourceControlRepositoryCloneUrls,
92+ protocol: SourceControlCloneProtocol | undefined,
93+): string {
94+ switch (protocol ?? "auto") {
95+ case "https":
96+ return urls.url;
97+ case "ssh":
98+ case "auto":
99+ return urls.sshUrl;
100+ }
101+}
102+
103+function expandHomePath(input: string, path: Path.Path): string {
104+ if (input === "~") {
105+ return OS.homedir();
106+ }
107+ if (input.startsWith("~/") || input.startsWith("~\\")) {
108+ return path.join(OS.homedir(), input.slice(2));
109+ }
110+ return input;
111+}
112+
113+export const make = Effect.fn("makeSourceControlRepositoryService")(function* () {
114+ const config = yield* ServerConfig;
115+ const fileSystem = yield* FileSystem.FileSystem;
116+ const git = yield* GitVcsDriver;
117+ const path = yield* Path.Path;
118+ const providers = yield* SourceControlProviderRegistry;
119+
120+ const ensureConcreteProvider = (input: {
121+ readonly operation: string;
122+ readonly provider: SourceControlProviderKind;
123+ }) => {
124+ if (input.provider !== "unknown") {
125+ return Effect.succeed(input.provider);
126+ }
127+
128+ return Effect.fail(
129+ repositoryError({
130+ operation: input.operation,
131+ provider: input.provider,
132+ detail: "Choose a source control provider before continuing.",
133+ }),
134+ );
135+ };
136+
137+ const lookupRepository = Effect.fn("SourceControlRepositoryService.lookupRepository")(function* (
138+ input: SourceControlRepositoryLookupInput,
139+ ) {
140+ const providerKind = yield* ensureConcreteProvider({
141+ operation: "lookupRepository",
142+ provider: input.provider,
143+ });
144+ const provider = yield* providers.get(providerKind);
145+ const urls = yield* provider.getRepositoryCloneUrls({
146+ cwd: input.cwd ?? config.cwd,
147+ repository: input.repository.trim(),
148+ });
149+ return toRepositoryInfo(providerKind, urls);
150+ });
151+
152+ const normalizeDestinationPath = Effect.fn("SourceControlRepositoryService.normalizeDestination")(
153+ function* (destinationPath: string) {
154+ const trimmed = destinationPath.trim();
155+ if (trimmed.length === 0) {
156+ return yield* Effect.fail(
157+ repositoryError({
158+ operation: "cloneRepository",
159+ provider: "unknown",
160+ detail: "Choose a destination path before cloning.",
161+ }),
162+ );
163+ }
164+
165+ return path.resolve(expandHomePath(trimmed, path));
166+ },
167+ );
168+
169+ const prepareDestination = Effect.fn("SourceControlRepositoryService.prepareDestination")(
170+ function* (destinationPath: string) {
171+ const normalizedDestination = yield* normalizeDestinationPath(destinationPath);
172+ if (yield* fileSystem.exists(normalizedDestination).pipe(Effect.orElseSucceed(() => false))) {
173+ const entries = yield* fileSystem
174+ .readDirectory(normalizedDestination, { recursive: false })
175+ .pipe(
176+ Effect.mapError((cause) =>
177+ repositoryError({
178+ operation: "cloneRepository",
179+ provider: "unknown",
180+ detail: "Destination path already exists and is not a directory.",
181+ cause,
182+ }),
183+ ),
184+ );
185+ if (entries.length > 0) {
186+ return yield* Effect.fail(
187+ repositoryError({
188+ operation: "cloneRepository",
189+ provider: "unknown",
190+ detail: "Destination path already exists and is not empty.",
191+ }),
192+ );
193+ }
194+ } else {
195+ yield* fileSystem.makeDirectory(path.dirname(normalizedDestination), { recursive: true });
196+ }
197+
198+ return {
199+ destinationPath: normalizedDestination,
200+ parentPath: path.dirname(normalizedDestination),
201+ directoryName: path.basename(normalizedDestination),
202+ };
203+ },
204+ );
205+
206+ const cloneRepository = Effect.fn("SourceControlRepositoryService.cloneRepository")(function* (
207+ input: SourceControlCloneRepositoryInput,
208+ ) {
209+ const preparedDestination = yield* prepareDestination(input.destinationPath);
210+ let repository: SourceControlRepositoryInfo | null = null;
211+ let remoteUrl = input.remoteUrl?.trim() ?? null;
212+ let provider: SourceControlProviderKind = input.provider ?? "unknown";
213+
214+ if (input.provider && input.repository) {
215+ repository = yield* lookupRepository({
216+ provider: input.provider,
217+ repository: input.repository,
218+ cwd: preparedDestination.parentPath,
219+ });
220+ remoteUrl = selectRemoteUrl(repository, input.protocol);
221+ provider = input.provider;
222+ }
223+
224+ if (!remoteUrl) {
225+ return yield* Effect.fail(
226+ repositoryError({
227+ operation: "cloneRepository",
228+ provider,
229+ detail: "Enter a repository path or clone URL before cloning.",
230+ }),
231+ );
232+ }
233+
234+ yield* git.execute({
235+ operation: "SourceControlRepositoryService.cloneRepository",
236+ cwd: preparedDestination.parentPath,
237+ args: ["clone", remoteUrl, preparedDestination.directoryName],
238+ timeoutMs: 120_000,
239+ maxOutputBytes: 256 * 1024,
240+ });
241+
242+ return {
243+ cwd: preparedDestination.destinationPath,
244+ remoteUrl,
245+ repository,
246+ };
247+ });
248+
249+ const publishRepository = Effect.fn("SourceControlRepositoryService.publishRepository")(
250+ function* (input: SourceControlPublishRepositoryInput) {
251+ const providerKind = yield* ensureConcreteProvider({
252+ operation: "publishRepository",
253+ provider: input.provider,
254+ });
255+ const provider = yield* providers.get(providerKind);
256+ const urls = yield* provider.createRepository({
257+ cwd: input.cwd,
258+ repository: input.repository.trim(),
259+ visibility: input.visibility,
260+ });
261+ const remoteUrl = selectRemoteUrl(urls, input.protocol);
262+ const remoteName = yield* git.ensureRemote({
263+ cwd: input.cwd,
264+ preferredName: input.remoteName?.trim() || "origin",
265+ url: remoteUrl,
266+ });
267+
268+ // An empty local repo (no commits) would make `git push HEAD:...` fail
269+ // with an opaque "src refspec HEAD does not match any". Treat this as a
270+ // partial success: the remote was created and wired up, but there is
271+ // nothing to push yet.
272+ const hasCommits = yield* git
273+ .execute({
274+ operation: "SourceControlRepositoryService.publishRepository.headCheck",
275+ cwd: input.cwd,
276+ args: ["rev-parse", "--verify", "HEAD"],
277+ })
278+ .pipe(
279+ Effect.map(() => true),
280+ Effect.catch(() => Effect.succeed(false)),
281+ );
282+ if (!hasCommits) {
283+ const details = yield* git
284+ .statusDetails(input.cwd)
285+ .pipe(Effect.catch(() => Effect.succeed(null)));
286+ return {
287+ repository: toRepositoryInfo(providerKind, urls),
288+ remoteName,
289+ remoteUrl,
290+ branch: details?.branch ?? "main",
291+ status: "remote_added" as const,
292+ };
293+ }
294+
295+ const pushResult = yield* git.pushCurrentBranch(input.cwd, null, { remoteName });
296+
297+ return {
298+ repository: toRepositoryInfo(providerKind, urls),
299+ remoteName,
300+ remoteUrl,
301+ branch: pushResult.branch,
302+ ...(pushResult.upstreamBranch ? { upstreamBranch: pushResult.upstreamBranch } : {}),
303+ status: "pushed" as const,
304+ };
305+ },
306+ );
307+
308+ return SourceControlRepositoryService.of({
309+ lookupRepository: (input) =>
310+ lookupRepository(input).pipe(mapRepositoryError("lookupRepository", input.provider)),
311+ cloneRepository: (input) =>
312+ cloneRepository(input).pipe(
313+ mapRepositoryError("cloneRepository", input.provider ?? "unknown"),
314+ ),
315+ publishRepository: (input) =>
316+ publishRepository(input).pipe(mapRepositoryError("publishRepository", input.provider)),
317+ });
318+});
319+
320+export const layer = Layer.effect(SourceControlRepositoryService, make());