1import * as Context from "effect/Context";
2import * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Layer from "effect/Layer";
6import * as Path from "effect/Path";
7import * as Schedule from "effect/Schedule";
8import * as Schema from "effect/Schema";
9
10import {
11 GitCommandError,
12 SourceControlProviderError,
13 SourceControlRepositoryError,
14 type GitCommandFailureReason,
15 type SourceControlCloneRepositoryInput,
16 type SourceControlCloneRepositoryResult,
17 type SourceControlCloneProtocol,
18 SourceControlProviderKind,
19 type SourceControlPublishRepositoryInput,
20 type SourceControlPublishRepositoryResult,
21 type SourceControlRepositoryCloneUrls,
22 type SourceControlRepositoryInfo,
23 type SourceControlRepositoryLookupInput,
24} from "@t3tools/contracts";
25
26import * as ServerConfig from "../config.ts";
27import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion";
28import * as SourceControlRateLimit from "@t3tools/source-control-core/server/SourceControlRateLimit";
29import {
30 parseGitCloneProgressLine,
31 type GitCloneProgressLine,
32} from "../project/gitCloneProgress.ts";
33import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
34import * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
35import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
36import * as HostProcess from "@t3tools/shared/HostProcess";
37import { cloneUrlForProtocol, cloneUrlProtocol } from "@t3tools/shared/cloneUrl";
38const isSourceControlRepositoryError = Schema.is(SourceControlRepositoryError);
39const isSourceControlProviderError = Schema.is(SourceControlProviderError);
40const isGitCommandError = Schema.is(GitCommandError);
41
42export class SourceControlRepositoryService extends Context.Service<
43 SourceControlRepositoryService,
44 {
45 readonly lookupRepository: (
46 input: SourceControlRepositoryLookupInput,
47 ) => Effect.Effect<SourceControlRepositoryInfo, SourceControlRepositoryError>;
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 readonly discardClone: (
56 destinationPath: string,
57 ) => Effect.Effect<void, SourceControlRepositoryError>;
58 readonly publishRepository: (
59 input: SourceControlPublishRepositoryInput,
60 ) => Effect.Effect<SourceControlPublishRepositoryResult, SourceControlRepositoryError>;
61 }
62>()("t3/sourceControl/SourceControlRepositoryService") {}
63
64export interface SourceControlPreparedClone {
65 readonly destinationPath: string;
66 readonly remoteUrl: string;
67 readonly cloneUrl: string;
68 readonly repository: SourceControlRepositoryInfo | null;
69}
70
71export interface SourceControlCloneOptions {
72 readonly onProgress?: (line: GitCloneProgressLine) => Effect.Effect<void>;
73 /** Overrides the default clone budget; `null` disables the deadline. */
74 readonly timeoutMs?: number | null;
75}
76
77// Older/mobile synchronous RPCs need deadlines; tracked clones use progress and Cancel instead.
78const CLONE_TIMEOUT_MS = 120_000;
79const CLONE_ENV = {
80 // Force piped progress immediately; refuse credential prompts that would hang without a tty.
81 GIT_PROGRESS_DELAY: "0",
82 GIT_TERMINAL_PROMPT: "0",
83 // English progress without overriding LC_CTYPE, which a global post-checkout hook inherits.
84 LANGUAGE: "C",
85} satisfies NodeJS.ProcessEnv;
86
87function mapRepositoryError(operation: string, provider: SourceControlProviderKind) {
88 return Effect.mapError((cause: unknown) =>
89 isSourceControlRepositoryError(cause)
90 ? cause
91 : new SourceControlRepositoryError({
92 operation,
93 provider,
94 detail:
95 (isSourceControlProviderError(cause)
96 ? SourceControlProvider.userFacingSourceControlDetail(cause.cause)
97 : undefined) ?? "The source control operation could not be completed.",
98 cause,
99 }),
100 );
101}
102
103function toRepositoryInfo(
104 provider: SourceControlProviderKind,
105 urls: SourceControlRepositoryCloneUrls,
106): SourceControlRepositoryInfo {
107 return {
108 provider,
109 nameWithOwner: urls.nameWithOwner,
110 url: urls.url,
111 sshUrl: urls.sshUrl,
112 };
113}
114
115// Redact credentials from client clone snapshots; git still receives the credentials.
116function redactRemoteUrl(remoteUrl: string): string {
117 try {
118 const url = new URL(remoteUrl);
119 // Clone URLs have no legitimate query; when one is present it is a token.
120 if (url.username.length === 0 && url.password.length === 0 && url.search.length === 0) {
121 return remoteUrl;
122 }
123 url.username = "";
124 url.password = "";
125 url.search = "";
126 return url.toString();
127 } catch {
128 return remoteUrl;
129 }
130}
131
132// Userinfo can contain @; remove through the last one before the host boundary.
133const URL_WITH_USERINFO = /\b([a-z][a-z0-9+.-]*:\/\/)[^\s/?#]+@/gi;
134// Git leaves quotes inside query tokens unescaped; consume through the closing quote.
135const QUOTED_URL_QUERY = /(['"])([a-z][a-z0-9+.-]*:\/\/[^\s?]*)\?.*\1(?=[\s:]|$)/gi;
136const URL_QUERY = /\b([a-z][a-z0-9+.-]*:\/\/[^\s?]*)\?[^\s]*/gi;
137
138function redactUrlCredentials(text: string): string {
139 return text
140 .replace(QUOTED_URL_QUERY, "$1$2$1")
141 .replace(URL_QUERY, "$1")
142 .replace(URL_WITH_USERINFO, "$1");
143}
144
145// The remote already exists after push failure; advise fixing access and pushing.
146// Use fixed text and provider URLs because git stderr can expose credentials.
147function publishPushFailureDetail(input: {
148 readonly urls: SourceControlRepositoryCloneUrls;
149 readonly remoteName: string;
150 readonly remoteUrl: string;
151 readonly reason: GitCommandFailureReason | undefined;
152}): string {
153 const created = `Created ${input.urls.nameWithOwner} and added it as remote "${input.remoteName}", but the push failed`;
154 const switchToHttps = `git remote set-url ${input.remoteName} ${input.urls.url}`;
155 const usesSsh = input.remoteUrl === input.urls.sshUrl;
156 switch (input.reason) {
157 case "authentication_failed":
158 return usesSsh
159 ? `${created} because SSH authentication was rejected. Add an SSH key to your account, or switch the remote to HTTPS with "${switchToHttps}", then push again.`
160 : `${created} because Git could not authenticate. Check your Git credentials, then push again.`;
161 case "host_key_unverified":
162 return `${created} because the host's SSH key is not trusted yet. Verify it by connecting once with ssh, or switch the remote to HTTPS with "${switchToHttps}", then push again.`;
163 default:
164 return `${created}. Fix the problem, then push again.`;
165 }
166}
167
168/**
169 * SSH rejection can be retried over HTTPS when the host's credential helper is available.
170 * Explicit SSH requests must not fall back; unknown URL shapes must not be guessed.
171 */
172function httpsFallbackUrl(
173 input: SourceControlCloneRepositoryInput,
174 prepared: SourceControlPreparedClone,
175): string | null {
176 if (input.protocol === "ssh" || cloneUrlProtocol(prepared.cloneUrl) !== "ssh") return null;
177 if (prepared.repository?.sshUrl === prepared.cloneUrl) return prepared.repository.url;
178 return cloneUrlForProtocol(prepared.cloneUrl, "https");
179}
180
181const isSshRejection = (error: SourceControlRepositoryError) =>
182 isGitCommandError(error.cause) &&
183 (error.cause.reason === "authentication_failed" || error.cause.reason === "host_key_unverified");
184
185function selectRemoteUrl(
186 urls: SourceControlRepositoryCloneUrls,
187 protocol: SourceControlCloneProtocol | undefined,
188): string {
189 switch (protocol ?? "auto") {
190 case "https":
191 return urls.url;
192 case "ssh":
193 case "auto":
194 return urls.sshUrl;
195 }
196}
197
198/** @public */
199export const make = Effect.gen(function* () {
200 const config = yield* ServerConfig.ServerConfig;
201 const fileSystem = yield* FileSystem.FileSystem;
202 const git = yield* GitVcsDriver.GitVcsDriver;
203 const path = yield* Path.Path;
204 const providers = yield* SourceControlProviderRegistry.SourceControlProviderRegistry;
205
206 const ensureConcreteProvider = (input: {
207 readonly operation: string;
208 readonly provider: SourceControlProviderKind;
209 }) => {
210 if (input.provider !== "unknown") {
211 return Effect.succeed(input.provider);
212 }
213
214 return Effect.fail(
215 new SourceControlRepositoryError({
216 operation: input.operation,
217 provider: input.provider,
218 detail: "Choose a source control provider before continuing.",
219 }),
220 );
221 };
222
223 const lookupRepository = Effect.fn("SourceControlRepositoryService.lookupRepository")(
224 function* (input: SourceControlRepositoryLookupInput) {
225 const providerKind = yield* ensureConcreteProvider({
226 operation: "lookupRepository",
227 provider: input.provider,
228 });
229 const provider = yield* providers.get(providerKind);
230 const urls = yield* provider.getRepositoryCloneUrls({
231 cwd: input.cwd ?? config.cwd,
232 repository: input.repository.trim(),
233 });
234 return toRepositoryInfo(providerKind, urls);
235 },
236 // Someone adding or cloning a repository is waiting, so a background rate-limit pause must not
237 // refuse the lookup.
238 Effect.provideService(SourceControlRateLimit.Interactive, true),
239 );
240
241 const normalizeDestinationPath = Effect.fn("SourceControlRepositoryService.normalizeDestination")(
242 function* (destinationPath: string) {
243 const trimmed = destinationPath.trim();
244 if (trimmed.length === 0) {
245 return yield* new SourceControlRepositoryError({
246 operation: "cloneRepository",
247 provider: SourceControlProviderKind.make("unknown"),
248 detail: "Choose a destination path before cloning.",
249 });
250 }
251
252 return path.resolve(expandHomePath(trimmed, yield* HostProcess.HomeDirectory));
253 },
254 );
255
256 const prepareDestination = Effect.fn("SourceControlRepositoryService.prepareDestination")(
257 function* (destinationPath: string) {
258 const normalizedDestination = yield* normalizeDestinationPath(destinationPath);
259 if (yield* fileSystem.exists(normalizedDestination)) {
260 const entries = yield* fileSystem
261 .readDirectory(normalizedDestination, { recursive: false })
262 .pipe(
263 Effect.mapError(
264 (cause) =>
265 new SourceControlRepositoryError({
266 operation: "cloneRepository",
267 provider: SourceControlProviderKind.make("unknown"),
268 detail: "Destination path already exists and is not a directory.",
269 cause,
270 }),
271 ),
272 );
273 if (entries.length > 0) {
274 return yield* new SourceControlRepositoryError({
275 operation: "cloneRepository",
276 provider: SourceControlProviderKind.make("unknown"),
277 detail: "Destination path already exists and is not empty.",
278 });
279 }
280 } else {
281 // Skip existing parents: recursive mkdir on a Windows drive root fails with EPERM.
282 const parentPath = path.dirname(normalizedDestination);
283 if (!(yield* fileSystem.exists(parentPath))) {
284 yield* fileSystem.makeDirectory(parentPath, { recursive: true });
285 }
286 }
287
288 return {
289 destinationPath: normalizedDestination,
290 parentPath: path.dirname(normalizedDestination),
291 directoryName: path.basename(normalizedDestination),
292 };
293 },
294 );
295
296 const prepareClone = Effect.fn("SourceControlRepositoryService.prepareClone")(function* (
297 input: SourceControlCloneRepositoryInput,
298 ) {
299 const preparedDestination = yield* prepareDestination(input.destinationPath);
300 let repository: SourceControlRepositoryInfo | null = null;
301 let remoteUrl = input.remoteUrl?.trim() ?? null;
302 let provider: SourceControlProviderKind =
303 input.provider ?? SourceControlProviderKind.make("unknown");
304
305 if (input.provider && input.repository) {
306 repository = yield* lookupRepository({
307 provider: input.provider,
308 repository: input.repository,
309 cwd: preparedDestination.parentPath,
310 });
311 remoteUrl = selectRemoteUrl(repository, input.protocol);
312 provider = input.provider;
313 }
314
315 if (!remoteUrl) {
316 return yield* new SourceControlRepositoryError({
317 operation: "cloneRepository",
318 provider,
319 detail: "Enter a repository path or clone URL before cloning.",
320 });
321 }
322
323 return {
324 destinationPath: preparedDestination.destinationPath,
325 remoteUrl: redactRemoteUrl(remoteUrl),
326 cloneUrl: remoteUrl,
327 repository,
328 } satisfies SourceControlPreparedClone;
329 });
330
331 const runClone = Effect.fn("SourceControlRepositoryService.runClone")(function* (
332 input: SourceControlCloneRepositoryInput,
333 destinationPath: string,
334 cloneUrl: string,
335 options?: SourceControlCloneOptions,
336 ) {
337 const onProgress = options?.onProgress;
338 // Git mixes progress and diagnostics on stderr; retain non-progress tail lines for failure details.
339 const stderrTail: Array<string> = [];
340 const onStderrLine = (line: string) => {
341 const parsed = parseGitCloneProgressLine(line);
342 if (parsed) return onProgress ? onProgress(parsed) : Effect.void;
343 return Effect.sync(() => {
344 const trimmed = line.trim();
345 if (trimmed.length === 0 || trimmed.startsWith("Cloning into")) return;
346 // Git echoes the remote in some failures; the tail becomes user-facing text.
347 stderrTail.push(redactUrlCredentials(trimmed));
348 if (stderrTail.length > 4) stderrTail.shift();
349 });
350 };
351 // Git misreads @ in a pathless URL's query as userinfo and echoes its suffix as the host.
352 const normalizedCloneUrl = cloneUrl.replace(/^([a-z][a-z0-9+.-]*:\/\/[^/?#]+)\?/i, "$1/?");
353 const provider = input.provider ?? SourceControlProviderKind.make("unknown");
354 const timeoutMs = options?.timeoutMs === undefined ? CLONE_TIMEOUT_MS : options.timeoutMs;
355 const clone = git
356 .execute({
357 operation: "SourceControlRepositoryService.cloneRepository",
358 cwd: path.dirname(destinationPath),
359 args: ["clone", "--progress", "--", normalizedCloneUrl, path.basename(destinationPath)],
360 // The deadline is applied below so a timeout is reported as one, not as git's last line.
361 timeoutMs: null,
362 // Buffered output is unused and progress can be huge; cap it while retaining line callbacks.
363 maxOutputBytes: 256 * 1024,
364 appendTruncationMarker: true,
365 keepLineCallbacksAfterTruncation: true,
366 env: CLONE_ENV,
367 progress: { onStderrLine },
368 })
369 .pipe(
370 Effect.mapError(
371 (cause) =>
372 new SourceControlRepositoryError({
373 operation: "cloneRepository",
374 provider,
375 detail:
376 stderrTail.length > 0
377 ? stderrTail.join(" ")
378 : "The repository could not be cloned.",
379 cause,
380 }),
381 ),
382 );
383 if (timeoutMs === null) return yield* clone;
384 // Interrupting the clone closes the scope that owns git, which kills it.
385 return yield* clone.pipe(
386 Effect.timeoutOrElse({
387 duration: timeoutMs,
388 orElse: () =>
389 Effect.fail(
390 new SourceControlRepositoryError({
391 operation: "cloneRepository",
392 provider,
393 detail: `The clone timed out after ${Duration.format(Duration.millis(timeoutMs))}.`,
394 }),
395 ),
396 }),
397 );
398 });
399
400 const cloneRepository = Effect.fn("SourceControlRepositoryService.cloneRepository")(function* (
401 input: SourceControlCloneRepositoryInput,
402 options?: SourceControlCloneOptions,
403 ) {
404 const prepared = yield* prepareClone(input);
405 const httpsUrl = httpsFallbackUrl(input, prepared);
406 const remoteUrl = yield* runClone(
407 input,
408 prepared.destinationPath,
409 prepared.cloneUrl,
410 options,
411 ).pipe(
412 Effect.as(prepared.remoteUrl),
413 // @effect-diagnostics-next-line catchConditionalRefailToCatchIf:off - The condition also narrows httpsUrl for the fallback closure.
414 Effect.catch((sshError) =>
415 httpsUrl === null || !isSshRejection(sshError)
416 ? Effect.fail(sshError)
417 : runClone(input, prepared.destinationPath, httpsUrl, options).pipe(
418 Effect.as(redactRemoteUrl(httpsUrl)),
419 Effect.mapError(
420 (httpsError) =>
421 new SourceControlRepositoryError({
422 operation: "cloneRepository",
423 provider: sshError.provider,
424 detail: `${sshError.detail} Cloning over HTTPS instead also failed: ${httpsError.detail}`,
425 cause: httpsError.cause,
426 }),
427 ),
428 ),
429 ),
430 );
431
432 return {
433 cwd: prepared.destinationPath,
434 remoteUrl,
435 repository: prepared.repository,
436 };
437 });
438
439 const discardClone = Effect.fn("SourceControlRepositoryService.discardClone")(function* (
440 destinationPath: string,
441 ) {
442 const normalized = yield* normalizeDestinationPath(destinationPath);
443 // Only .git-bearing partial clones may be deleted; other files arrived after the empty-directory check.
444 // Missing destinations are already discarded; other read failures must refuse removal.
445 const entries = yield* fileSystem.readDirectory(normalized).pipe(
446 Effect.catchIf(
447 (cause) => cause.reason._tag === "NotFound",
448 () => Effect.succeed<ReadonlyArray<string>>([]),
449 ),
450 Effect.mapError(
451 (cause) =>
452 new SourceControlRepositoryError({
453 operation: "discardClone",
454 provider: SourceControlProviderKind.make("unknown"),
455 detail: "The clone destination could not be inspected.",
456 cause,
457 }),
458 ),
459 );
460 if (entries.length > 0 && !entries.includes(".git")) {
461 return yield* new SourceControlRepositoryError({
462 operation: "discardClone",
463 provider: SourceControlProviderKind.make("unknown"),
464 detail: "Destination path contains files that are not from the clone.",
465 });
466 }
467 // Preserve the workspace directory after removing partial contents; retry while interrupted git closes files.
468 yield* fileSystem.remove(normalized, { recursive: true, force: true }).pipe(
469 Effect.andThen(fileSystem.makeDirectory(normalized, { recursive: true })),
470 Effect.retry({ schedule: Schedule.spaced("200 millis"), times: 5 }),
471 Effect.mapError(
472 (cause) =>
473 new SourceControlRepositoryError({
474 operation: "discardClone",
475 provider: SourceControlProviderKind.make("unknown"),
476 detail: "The partial clone could not be removed.",
477 cause,
478 }),
479 ),
480 );
481 });
482
483 const publishRepository = Effect.fn("SourceControlRepositoryService.publishRepository")(
484 function* (input: SourceControlPublishRepositoryInput) {
485 const providerKind = yield* ensureConcreteProvider({
486 operation: "publishRepository",
487 provider: input.provider,
488 });
489 const provider = yield* providers.get(providerKind);
490 const urls = yield* provider.createRepository({
491 cwd: input.cwd,
492 repository: input.repository.trim(),
493 visibility: input.visibility,
494 });
495 const remoteUrl = selectRemoteUrl(urls, input.protocol);
496 const remoteName = yield* git.ensureRemote({
497 cwd: input.cwd,
498 preferredName: input.remoteName?.trim() || "origin",
499 url: remoteUrl,
500 });
501
502 // Empty repos cannot push HEAD; report partial success because the remote already exists.
503 const hasCommits = yield* git
504 .execute({
505 operation: "SourceControlRepositoryService.publishRepository.headCheck",
506 cwd: input.cwd,
507 args: ["rev-parse", "--verify", "HEAD"],
508 })
509 .pipe(
510 Effect.as(true),
511 Effect.orElseSucceed(() => false),
512 );
513 if (!hasCommits) {
514 const details = yield* git.statusDetails(input.cwd).pipe(Effect.orElseSucceed(() => null));
515 return {
516 repository: toRepositoryInfo(providerKind, urls),
517 remoteName,
518 remoteUrl,
519 branch: details?.branch ?? "main",
520 status: "remote_added" as const,
521 };
522 }
523
524 const push = (pushUrl: string) =>
525 git.pushCurrentBranch(input.cwd, null, { remoteName }).pipe(
526 Effect.map((result) => ({ branch: result.branch, remoteUrl: pushUrl })),
527 Effect.mapError(
528 (cause) =>
529 new SourceControlRepositoryError({
530 operation: "publishRepository",
531 provider: providerKind,
532 detail: publishPushFailureDetail({
533 urls,
534 remoteName,
535 remoteUrl: pushUrl,
536 reason: cause.reason,
537 }),
538 cause,
539 }),
540 ),
541 );
542
543 // Like clone, an auto-selected SSH remote switches to HTTPS when SSH is refused;
544 // otherwise the remote keeps failing every later fetch and push.
545 const canSwitchToHttps = input.protocol !== "ssh" && remoteUrl === urls.sshUrl;
546 const pushed = yield* push(remoteUrl).pipe(
547 Effect.catchIf(
548 (error) => canSwitchToHttps && isSshRejection(error),
549 (sshError) =>
550 git
551 .execute({
552 operation: "SourceControlRepositoryService.publishRepository.switchToHttps",
553 cwd: input.cwd,
554 args: ["remote", "set-url", remoteName, urls.url],
555 })
556 .pipe(
557 Effect.mapError(() => sshError),
558 Effect.andThen(push(urls.url)),
559 ),
560 ),
561 );
562
563 return {
564 repository: toRepositoryInfo(providerKind, urls),
565 remoteName,
566 remoteUrl: pushed.remoteUrl,
567 branch: pushed.branch,
568 status: "pushed" as const,
569 };
570 },
571 );
572
573 // Forwarders retain the underlying operation spans.
574 return SourceControlRepositoryService.of({
575 lookupRepository: Effect.fnUntraced(function* (input) {
576 return yield* lookupRepository(input).pipe(
577 mapRepositoryError("lookupRepository", input.provider),
578 );
579 }),
580 prepareClone: Effect.fnUntraced(function* (input) {
581 return yield* prepareClone(input).pipe(
582 mapRepositoryError(
583 "cloneRepository",
584 input.provider ?? SourceControlProviderKind.make("unknown"),
585 ),
586 );
587 }),
588 cloneRepository: Effect.fnUntraced(function* (input, options) {
589 return yield* cloneRepository(input, options).pipe(
590 mapRepositoryError(
591 "cloneRepository",
592 input.provider ?? SourceControlProviderKind.make("unknown"),
593 ),
594 );
595 }),
596 discardClone: Effect.fnUntraced(function* (destinationPath) {
597 return yield* discardClone(destinationPath).pipe(
598 mapRepositoryError("discardClone", SourceControlProviderKind.make("unknown")),
599 );
600 }),
601 publishRepository: Effect.fnUntraced(function* (input) {
602 return yield* publishRepository(input).pipe(
603 mapRepositoryError("publishRepository", input.provider),
604 );
605 }),
606 });
607});
608
609export const layer = Layer.effect(SourceControlRepositoryService, make);