Showing the changes to apps/server/src/sourceControl/SourceControlRepositoryService.ts (1 of 33 files). Show all
Unified · Split
| @@ -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()); | ||