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 @@
1import OS from "node:os";
2import { Context, Effect, FileSystem, Layer, Path, Schema } from "effect";
3
4import {
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
17import { ServerConfig } from "../config.ts";
18import { GitVcsDriver } from "../vcs/GitVcsDriver.ts";
19import { SourceControlProviderRegistry } from "./SourceControlProviderRegistry.ts";
20
21export 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
33export class SourceControlRepositoryService extends Context.Service<
34 SourceControlRepositoryService,
35 SourceControlRepositoryServiceShape
36>()("t3/source-control/SourceControlRepositoryService") {}
37
38function 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
51function 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
65function 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
78function 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
90function 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
103function 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
113export 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
320export const layer = Layer.effect(SourceControlRepositoryService, make());