1import * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import * as FileSystem from "effect/FileSystem";
4import * as Layer from "effect/Layer";
5import * as Path from "effect/Path";
6import * as Schedule from "effect/Schedule";
7import * as Schema from "effect/Schema";
8
9import {
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
23import * as ServerConfig from "../config.ts";
24import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
25import {
26 parseGitCloneProgressLine,
27 type GitCloneProgressLine,
28} from "../project/gitCloneProgress.ts";
29import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
30import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi";
31import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
32import * as HostProcess from "@t3tools/shared/HostProcess";
33const isSourceControlRepositoryError = Schema.is(SourceControlRepositoryError);
34const isSourceControlProviderError = Schema.is(SourceControlProviderError);
35const isBitbucketRepositoryLocatorError = Schema.is(BitbucketApi.BitbucketRepositoryLocatorError);
36
37export 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
65export 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
74export 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.
83const CLONE_TIMEOUT_MS = 120_000;
84const 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
93function 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
111function 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 */
128function 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.
146const URL_WITH_USERINFO = /\b([a-z][a-z0-9+.-]*:\/\/)[^\s/]+@/gi;
147
148/** Drops `user:token@` from any URL embedded in free text. */
149function redactUrlCredentials(text: string): string {
150 return text.replace(URL_WITH_USERINFO, "$1");
151}
152
153function 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. */
167export 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
488export const layer = Layer.effect(SourceControlRepositoryService, make);