| 1 | import * as Context from "effect/Context"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as FileSystem from "effect/FileSystem"; |
| 4 | import * as Layer from "effect/Layer"; |
| 5 | import * as Path from "effect/Path"; |
| 6 | import * as Schedule from "effect/Schedule"; |
| 7 | import * as Schema from "effect/Schema"; |
| 8 | |
| 9 | import { |
| 10 | SourceControlProviderError, |
| 11 | SourceControlRepositoryError, |
| 12 | type SourceControlCloneRepositoryInput, |
| 13 | type SourceControlCloneRepositoryResult, |
| 14 | type SourceControlCloneProtocol, |
| 15 | SourceControlProviderKind, |
| 16 | type SourceControlPublishRepositoryInput, |
| 17 | type SourceControlPublishRepositoryResult, |
| 18 | type SourceControlRepositoryCloneUrls, |
| 19 | type SourceControlRepositoryInfo, |
| 20 | type SourceControlRepositoryLookupInput, |
| 21 | } from "@t3tools/contracts"; |
| 22 | |
| 23 | import * as ServerConfig from "../config.ts"; |
| 24 | import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; |
| 25 | import { |
| 26 | parseGitCloneProgressLine, |
| 27 | type GitCloneProgressLine, |
| 28 | } from "../project/gitCloneProgress.ts"; |
| 29 | import * as GitVcsDriver from "../vcs/GitVcsDriver.ts"; |
| 30 | import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi"; |
| 31 | import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts"; |
| 32 | import * as HostProcess from "@t3tools/shared/HostProcess"; |
| 33 | const isSourceControlRepositoryError = Schema.is(SourceControlRepositoryError); |
| 34 | const isSourceControlProviderError = Schema.is(SourceControlProviderError); |
| 35 | const isBitbucketRepositoryLocatorError = Schema.is(BitbucketApi.BitbucketRepositoryLocatorError); |
| 36 | |
| 37 | export class SourceControlRepositoryService extends Context.Service< |
| 38 | SourceControlRepositoryService, |
| 39 | { |
| 40 | readonly lookupRepository: ( |
| 41 | input: SourceControlRepositoryLookupInput, |
| 42 | ) => Effect.Effect<SourceControlRepositoryInfo, SourceControlRepositoryError>; |
| 43 | /** |
| 44 | * Everything `cloneRepository` checks before running git: the resolved |
| 45 | * remote, the normalized destination, and that the destination is empty. |
| 46 | * Lets a caller create the project first and clone afterwards. |
| 47 | */ |
| 48 | readonly prepareClone: ( |
| 49 | input: SourceControlCloneRepositoryInput, |
| 50 | ) => Effect.Effect<SourceControlPreparedClone, SourceControlRepositoryError>; |
| 51 | readonly cloneRepository: ( |
| 52 | input: SourceControlCloneRepositoryInput, |
| 53 | options?: SourceControlCloneOptions, |
| 54 | ) => Effect.Effect<SourceControlCloneRepositoryResult, SourceControlRepositoryError>; |
| 55 | /** Removes a partial or failed clone so the destination is empty again. */ |
| 56 | readonly discardClone: ( |
| 57 | destinationPath: string, |
| 58 | ) => Effect.Effect<void, SourceControlRepositoryError>; |
| 59 | readonly publishRepository: ( |
| 60 | input: SourceControlPublishRepositoryInput, |
| 61 | ) => Effect.Effect<SourceControlPublishRepositoryResult, SourceControlRepositoryError>; |
| 62 | } |
| 63 | >()("t3/sourceControl/SourceControlRepositoryService") {} |
| 64 | |
| 65 | export interface SourceControlPreparedClone { |
| 66 | readonly destinationPath: string; |
| 67 | /** Credential-free; safe to show and to store in snapshots. */ |
| 68 | readonly remoteUrl: string; |
| 69 | /** What git is given; may carry embedded credentials. */ |
| 70 | readonly cloneUrl: string; |
| 71 | readonly repository: SourceControlRepositoryInfo | null; |
| 72 | } |
| 73 | |
| 74 | export interface SourceControlCloneOptions { |
| 75 | readonly onProgress?: (line: GitCloneProgressLine) => Effect.Effect<void>; |
| 76 | /** Overrides the default clone budget; `null` disables the deadline. */ |
| 77 | readonly timeoutMs?: number | null; |
| 78 | } |
| 79 | |
| 80 | // The synchronous RPC (older clients, mobile) keeps a deadline: nothing else |
| 81 | // tells the user a clone stalled. The tracked path passes null and relies on |
| 82 | // progress and Cancel instead. |
| 83 | const CLONE_TIMEOUT_MS = 120_000; |
| 84 | const CLONE_ENV = { |
| 85 | // `--progress` forces the transfer counters through the pipe; the delay env |
| 86 | // makes the checkout counter start immediately. No tty means a credential |
| 87 | // prompt would hang forever, so tell git to fail instead. |
| 88 | GIT_PROGRESS_DELAY: "0", |
| 89 | GIT_TERMINAL_PROMPT: "0", |
| 90 | LC_ALL: "C", |
| 91 | } satisfies NodeJS.ProcessEnv; |
| 92 | |
| 93 | function mapRepositoryError(operation: string, provider: SourceControlProviderKind) { |
| 94 | return Effect.mapError((cause: unknown) => |
| 95 | isSourceControlRepositoryError(cause) |
| 96 | ? cause |
| 97 | : new SourceControlRepositoryError({ |
| 98 | operation, |
| 99 | provider, |
| 100 | detail: |
| 101 | isSourceControlProviderError(cause) && |
| 102 | cause.provider === "bitbucket" && |
| 103 | isBitbucketRepositoryLocatorError(cause.cause) |
| 104 | ? BitbucketApi.BitbucketRepositoryLocatorError.detail |
| 105 | : "The source control operation could not be completed.", |
| 106 | cause, |
| 107 | }), |
| 108 | ); |
| 109 | } |
| 110 | |
| 111 | function toRepositoryInfo( |
| 112 | provider: SourceControlProviderKind, |
| 113 | urls: SourceControlRepositoryCloneUrls, |
| 114 | ): SourceControlRepositoryInfo { |
| 115 | return { |
| 116 | provider, |
| 117 | nameWithOwner: urls.nameWithOwner, |
| 118 | url: urls.url, |
| 119 | sshUrl: urls.sshUrl, |
| 120 | }; |
| 121 | } |
| 122 | |
| 123 | /** |
| 124 | * The URL clients see. A pasted `https://user:token@host/…` must not travel |
| 125 | * back over `subscribeProjectClones` to every reader; git still gets the |
| 126 | * original. |
| 127 | */ |
| 128 | function redactRemoteUrl(remoteUrl: string): string { |
| 129 | try { |
| 130 | const url = new URL(remoteUrl); |
| 131 | // Clone URLs have no legitimate query; when one is present it is a token. |
| 132 | if (url.username.length === 0 && url.password.length === 0 && url.search.length === 0) { |
| 133 | return remoteUrl; |
| 134 | } |
| 135 | url.username = ""; |
| 136 | url.password = ""; |
| 137 | url.search = ""; |
| 138 | return url.toString(); |
| 139 | } catch { |
| 140 | return remoteUrl; |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | // Userinfo may itself contain `@`; everything up to the last one before the |
| 145 | // host boundary goes. |
| 146 | const URL_WITH_USERINFO = /\b([a-z][a-z0-9+.-]*:\/\/)[^\s/]+@/gi; |
| 147 | |
| 148 | /** Drops `user:token@` from any URL embedded in free text. */ |
| 149 | function redactUrlCredentials(text: string): string { |
| 150 | return text.replace(URL_WITH_USERINFO, "$1"); |
| 151 | } |
| 152 | |
| 153 | function selectRemoteUrl( |
| 154 | urls: SourceControlRepositoryCloneUrls, |
| 155 | protocol: SourceControlCloneProtocol | undefined, |
| 156 | ): string { |
| 157 | switch (protocol ?? "auto") { |
| 158 | case "https": |
| 159 | return urls.url; |
| 160 | case "ssh": |
| 161 | case "auto": |
| 162 | return urls.sshUrl; |
| 163 | } |
| 164 | } |
| 165 | |
| 166 | /** @public Service construction is part of the canonical Effect module API. */ |
| 167 | export const make = Effect.gen(function* () { |
| 168 | const config = yield* ServerConfig.ServerConfig; |
| 169 | const fileSystem = yield* FileSystem.FileSystem; |
| 170 | const git = yield* GitVcsDriver.GitVcsDriver; |
| 171 | const path = yield* Path.Path; |
| 172 | const providers = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; |
| 173 | |
| 174 | const ensureConcreteProvider = (input: { |
| 175 | readonly operation: string; |
| 176 | readonly provider: SourceControlProviderKind; |
| 177 | }) => { |
| 178 | if (input.provider !== "unknown") { |
| 179 | return Effect.succeed(input.provider); |
| 180 | } |
| 181 | |
| 182 | return Effect.fail( |
| 183 | new SourceControlRepositoryError({ |
| 184 | operation: input.operation, |
| 185 | provider: input.provider, |
| 186 | detail: "Choose a source control provider before continuing.", |
| 187 | }), |
| 188 | ); |
| 189 | }; |
| 190 | |
| 191 | const lookupRepository = Effect.fn("SourceControlRepositoryService.lookupRepository")(function* ( |
| 192 | input: SourceControlRepositoryLookupInput, |
| 193 | ) { |
| 194 | const providerKind = yield* ensureConcreteProvider({ |
| 195 | operation: "lookupRepository", |
| 196 | provider: input.provider, |
| 197 | }); |
| 198 | const provider = yield* providers.get(providerKind); |
| 199 | const urls = yield* provider.getRepositoryCloneUrls({ |
| 200 | cwd: input.cwd ?? config.cwd, |
| 201 | repository: input.repository.trim(), |
| 202 | }); |
| 203 | return toRepositoryInfo(providerKind, urls); |
| 204 | }); |
| 205 | |
| 206 | const normalizeDestinationPath = Effect.fn("SourceControlRepositoryService.normalizeDestination")( |
| 207 | function* (destinationPath: string) { |
| 208 | const trimmed = destinationPath.trim(); |
| 209 | if (trimmed.length === 0) { |
| 210 | return yield* new SourceControlRepositoryError({ |
| 211 | operation: "cloneRepository", |
| 212 | provider: SourceControlProviderKind.make("unknown"), |
| 213 | detail: "Choose a destination path before cloning.", |
| 214 | }); |
| 215 | } |
| 216 | |
| 217 | return path.resolve(expandHomePath(trimmed, yield* HostProcess.HomeDirectory)); |
| 218 | }, |
| 219 | ); |
| 220 | |
| 221 | const prepareDestination = Effect.fn("SourceControlRepositoryService.prepareDestination")( |
| 222 | function* (destinationPath: string) { |
| 223 | const normalizedDestination = yield* normalizeDestinationPath(destinationPath); |
| 224 | if (yield* fileSystem.exists(normalizedDestination)) { |
| 225 | const entries = yield* fileSystem |
| 226 | .readDirectory(normalizedDestination, { recursive: false }) |
| 227 | .pipe( |
| 228 | Effect.mapError( |
| 229 | (cause) => |
| 230 | new SourceControlRepositoryError({ |
| 231 | operation: "cloneRepository", |
| 232 | provider: SourceControlProviderKind.make("unknown"), |
| 233 | detail: "Destination path already exists and is not a directory.", |
| 234 | cause, |
| 235 | }), |
| 236 | ), |
| 237 | ); |
| 238 | if (entries.length > 0) { |
| 239 | return yield* new SourceControlRepositoryError({ |
| 240 | operation: "cloneRepository", |
| 241 | provider: SourceControlProviderKind.make("unknown"), |
| 242 | detail: "Destination path already exists and is not empty.", |
| 243 | }); |
| 244 | } |
| 245 | } else { |
| 246 | yield* fileSystem.makeDirectory(path.dirname(normalizedDestination), { recursive: true }); |
| 247 | } |
| 248 | |
| 249 | return { |
| 250 | destinationPath: normalizedDestination, |
| 251 | parentPath: path.dirname(normalizedDestination), |
| 252 | directoryName: path.basename(normalizedDestination), |
| 253 | }; |
| 254 | }, |
| 255 | ); |
| 256 | |
| 257 | const prepareClone = Effect.fn("SourceControlRepositoryService.prepareClone")(function* ( |
| 258 | input: SourceControlCloneRepositoryInput, |
| 259 | ) { |
| 260 | const preparedDestination = yield* prepareDestination(input.destinationPath); |
| 261 | let repository: SourceControlRepositoryInfo | null = null; |
| 262 | let remoteUrl = input.remoteUrl?.trim() ?? null; |
| 263 | let provider: SourceControlProviderKind = |
| 264 | input.provider ?? SourceControlProviderKind.make("unknown"); |
| 265 | |
| 266 | if (input.provider && input.repository) { |
| 267 | repository = yield* lookupRepository({ |
| 268 | provider: input.provider, |
| 269 | repository: input.repository, |
| 270 | cwd: preparedDestination.parentPath, |
| 271 | }); |
| 272 | remoteUrl = selectRemoteUrl(repository, input.protocol); |
| 273 | provider = input.provider; |
| 274 | } |
| 275 | |
| 276 | if (!remoteUrl) { |
| 277 | return yield* new SourceControlRepositoryError({ |
| 278 | operation: "cloneRepository", |
| 279 | provider, |
| 280 | detail: "Enter a repository path or clone URL before cloning.", |
| 281 | }); |
| 282 | } |
| 283 | |
| 284 | return { |
| 285 | destinationPath: preparedDestination.destinationPath, |
| 286 | remoteUrl: redactRemoteUrl(remoteUrl), |
| 287 | cloneUrl: remoteUrl, |
| 288 | repository, |
| 289 | } satisfies SourceControlPreparedClone; |
| 290 | }); |
| 291 | |
| 292 | const cloneRepository = Effect.fn("SourceControlRepositoryService.cloneRepository")(function* ( |
| 293 | input: SourceControlCloneRepositoryInput, |
| 294 | options?: SourceControlCloneOptions, |
| 295 | ) { |
| 296 | const prepared = yield* prepareClone(input); |
| 297 | const onProgress = options?.onProgress; |
| 298 | // Git interleaves progress redraws with its real messages on stderr. The |
| 299 | // last non-progress lines are what explain a failure ("Repository not |
| 300 | // found", "Permission denied"), so keep them for the error detail. |
| 301 | const stderrTail: Array<string> = []; |
| 302 | const onStderrLine = (line: string) => { |
| 303 | const parsed = parseGitCloneProgressLine(line); |
| 304 | if (parsed) return onProgress ? onProgress(parsed) : Effect.void; |
| 305 | return Effect.sync(() => { |
| 306 | const trimmed = line.trim(); |
| 307 | if (trimmed.length === 0 || trimmed.startsWith("Cloning into")) return; |
| 308 | // Git echoes the remote in some failures; the tail becomes user-facing text. |
| 309 | stderrTail.push(redactUrlCredentials(trimmed)); |
| 310 | if (stderrTail.length > 4) stderrTail.shift(); |
| 311 | }); |
| 312 | }; |
| 313 | yield* git |
| 314 | .execute({ |
| 315 | operation: "SourceControlRepositoryService.cloneRepository", |
| 316 | cwd: path.dirname(prepared.destinationPath), |
| 317 | args: [ |
| 318 | "clone", |
| 319 | "--progress", |
| 320 | "--", |
| 321 | prepared.cloneUrl, |
| 322 | path.basename(prepared.destinationPath), |
| 323 | ], |
| 324 | timeoutMs: options?.timeoutMs === undefined ? CLONE_TIMEOUT_MS : options.timeoutMs, |
| 325 | // Progress redraws add up on a slow multi-GB clone. The buffered copy |
| 326 | // is never read (the tail is kept by hand above), so keep it small |
| 327 | // and let the line callbacks keep flowing past the cap. |
| 328 | maxOutputBytes: 256 * 1024, |
| 329 | appendTruncationMarker: true, |
| 330 | keepLineCallbacksAfterTruncation: true, |
| 331 | env: CLONE_ENV, |
| 332 | progress: { onStderrLine }, |
| 333 | }) |
| 334 | .pipe( |
| 335 | Effect.mapError( |
| 336 | (cause) => |
| 337 | new SourceControlRepositoryError({ |
| 338 | operation: "cloneRepository", |
| 339 | provider: input.provider ?? SourceControlProviderKind.make("unknown"), |
| 340 | detail: |
| 341 | stderrTail.length > 0 |
| 342 | ? stderrTail.join(" ") |
| 343 | : "The repository could not be cloned.", |
| 344 | cause, |
| 345 | }), |
| 346 | ), |
| 347 | ); |
| 348 | |
| 349 | return { |
| 350 | cwd: prepared.destinationPath, |
| 351 | remoteUrl: prepared.remoteUrl, |
| 352 | repository: prepared.repository, |
| 353 | }; |
| 354 | }); |
| 355 | |
| 356 | const discardClone = Effect.fn("SourceControlRepositoryService.discardClone")(function* ( |
| 357 | destinationPath: string, |
| 358 | ) { |
| 359 | const normalized = yield* normalizeDestinationPath(destinationPath); |
| 360 | // Only what git left behind may go. The destination was empty when the |
| 361 | // clone started, so anything without a `.git` inside was put there by |
| 362 | // someone else since; refuse rather than delete their files. |
| 363 | // A missing destination is already discarded; any other read failure |
| 364 | // (a file in its place, permissions) is not something to remove through. |
| 365 | const entries = yield* fileSystem.readDirectory(normalized).pipe( |
| 366 | Effect.catchIf( |
| 367 | (cause) => cause.reason._tag === "NotFound", |
| 368 | () => Effect.succeed<ReadonlyArray<string>>([]), |
| 369 | ), |
| 370 | Effect.mapError( |
| 371 | (cause) => |
| 372 | new SourceControlRepositoryError({ |
| 373 | operation: "discardClone", |
| 374 | provider: SourceControlProviderKind.make("unknown"), |
| 375 | detail: "The clone destination could not be inspected.", |
| 376 | cause, |
| 377 | }), |
| 378 | ), |
| 379 | ); |
| 380 | if (entries.length > 0 && !entries.includes(".git")) { |
| 381 | return yield* new SourceControlRepositoryError({ |
| 382 | operation: "discardClone", |
| 383 | provider: SourceControlProviderKind.make("unknown"), |
| 384 | detail: "Destination path contains files that are not from the clone.", |
| 385 | }); |
| 386 | } |
| 387 | // The directory itself is the project's workspace root and must stay; |
| 388 | // only git's partial contents go. An interrupted git may still be closing |
| 389 | // files, so removal retries briefly. |
| 390 | yield* fileSystem.remove(normalized, { recursive: true, force: true }).pipe( |
| 391 | Effect.andThen(fileSystem.makeDirectory(normalized, { recursive: true })), |
| 392 | Effect.retry({ schedule: Schedule.spaced("200 millis"), times: 5 }), |
| 393 | Effect.mapError( |
| 394 | (cause) => |
| 395 | new SourceControlRepositoryError({ |
| 396 | operation: "discardClone", |
| 397 | provider: SourceControlProviderKind.make("unknown"), |
| 398 | detail: "The partial clone could not be removed.", |
| 399 | cause, |
| 400 | }), |
| 401 | ), |
| 402 | ); |
| 403 | }); |
| 404 | |
| 405 | const publishRepository = Effect.fn("SourceControlRepositoryService.publishRepository")( |
| 406 | function* (input: SourceControlPublishRepositoryInput) { |
| 407 | const providerKind = yield* ensureConcreteProvider({ |
| 408 | operation: "publishRepository", |
| 409 | provider: input.provider, |
| 410 | }); |
| 411 | const provider = yield* providers.get(providerKind); |
| 412 | const urls = yield* provider.createRepository({ |
| 413 | cwd: input.cwd, |
| 414 | repository: input.repository.trim(), |
| 415 | visibility: input.visibility, |
| 416 | }); |
| 417 | const remoteUrl = selectRemoteUrl(urls, input.protocol); |
| 418 | const remoteName = yield* git.ensureRemote({ |
| 419 | cwd: input.cwd, |
| 420 | preferredName: input.remoteName?.trim() || "origin", |
| 421 | url: remoteUrl, |
| 422 | }); |
| 423 | |
| 424 | // An empty local repo (no commits) would make `git push HEAD:...` fail |
| 425 | // with an opaque "src refspec HEAD does not match any". Treat this as a |
| 426 | // partial success: the remote was created and wired up, but there is |
| 427 | // nothing to push yet. |
| 428 | const hasCommits = yield* git |
| 429 | .execute({ |
| 430 | operation: "SourceControlRepositoryService.publishRepository.headCheck", |
| 431 | cwd: input.cwd, |
| 432 | args: ["rev-parse", "--verify", "HEAD"], |
| 433 | }) |
| 434 | .pipe( |
| 435 | Effect.map(() => true), |
| 436 | Effect.orElseSucceed(() => false), |
| 437 | ); |
| 438 | if (!hasCommits) { |
| 439 | const details = yield* git.statusDetails(input.cwd).pipe(Effect.orElseSucceed(() => null)); |
| 440 | return { |
| 441 | repository: toRepositoryInfo(providerKind, urls), |
| 442 | remoteName, |
| 443 | remoteUrl, |
| 444 | branch: details?.branch ?? "main", |
| 445 | status: "remote_added" as const, |
| 446 | }; |
| 447 | } |
| 448 | |
| 449 | const pushResult = yield* git.pushCurrentBranch(input.cwd, null, { remoteName }); |
| 450 | |
| 451 | return { |
| 452 | repository: toRepositoryInfo(providerKind, urls), |
| 453 | remoteName, |
| 454 | remoteUrl, |
| 455 | branch: pushResult.branch, |
| 456 | ...(pushResult.upstreamBranch ? { upstreamBranch: pushResult.upstreamBranch } : {}), |
| 457 | status: "pushed" as const, |
| 458 | }; |
| 459 | }, |
| 460 | ); |
| 461 | |
| 462 | return SourceControlRepositoryService.of({ |
| 463 | lookupRepository: (input) => |
| 464 | lookupRepository(input).pipe(mapRepositoryError("lookupRepository", input.provider)), |
| 465 | prepareClone: (input) => |
| 466 | prepareClone(input).pipe( |
| 467 | mapRepositoryError( |
| 468 | "cloneRepository", |
| 469 | input.provider ?? SourceControlProviderKind.make("unknown"), |
| 470 | ), |
| 471 | ), |
| 472 | cloneRepository: (input, options) => |
| 473 | cloneRepository(input, options).pipe( |
| 474 | mapRepositoryError( |
| 475 | "cloneRepository", |
| 476 | input.provider ?? SourceControlProviderKind.make("unknown"), |
| 477 | ), |
| 478 | ), |
| 479 | discardClone: (destinationPath) => |
| 480 | discardClone(destinationPath).pipe( |
| 481 | mapRepositoryError("discardClone", SourceControlProviderKind.make("unknown")), |
| 482 | ), |
| 483 | publishRepository: (input) => |
| 484 | publishRepository(input).pipe(mapRepositoryError("publishRepository", input.provider)), |
| 485 | }); |
| 486 | }); |
| 487 | |
| 488 | export const layer = Layer.effect(SourceControlRepositoryService, make); |