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 (12 of 33 files). Show all

Unified · Split

12 files
  1. apps/server/src/sourceControl/GitHubCli.test.ts +52 −0
  2. apps/server/src/sourceControl/GitHubCli.ts +57 −1
  3. apps/server/src/sourceControl/GitHubSourceControlProvider.ts +4 −0
  4. apps/server/src/sourceControl/GitLabCli.test.ts +62 −1
  5. apps/server/src/sourceControl/GitLabCli.ts +79 −3
  6. apps/server/src/sourceControl/GitLabSourceControlProvider.ts +4 −0
  7. apps/server/src/sourceControl/SourceControlDiscovery.test.ts +6 −3
  8. apps/server/src/sourceControl/SourceControlDiscovery.ts +44 −50
  9. apps/server/src/sourceControl/SourceControlProvider.ts +6 −0
  10. apps/server/src/sourceControl/SourceControlProviderRegistry.ts +1 −0
  11. apps/server/src/sourceControl/SourceControlRepositoryService.test.ts +322 −0
  12. apps/server/src/sourceControl/SourceControlRepositoryService.ts +320 −0
apps/server/src/sourceControl/GitHubCli.test.ts+52−0
@@ -211,6 +211,58 @@ describe("GitHubCli.layer", () => { · 210 unmodified lines
211 }).pipe(Effect.provide(layer)),211 }).pipe(Effect.provide(layer)),
212 );212 );
213 213
214 it.effect("creates repositories and parses clone URLs from create output", () =>
215 Effect.gen(function* () {
216 mockRun.mockReturnValueOnce(
217 Effect.succeed(
218 processOutput(
219 "✓ Created repository octocat/codething-mvp on github.com\nhttps://github.com/octocat/codething-mvp\n",
220 ),
221 ),
222 );
223
224 const gh = yield* GitHubCli.GitHubCli;
225 const result = yield* gh.createRepository({
226 cwd: "/repo",
227 repository: "octocat/codething-mvp",
228 visibility: "private",
229 });
230
231 assert.deepStrictEqual(result, {
232 nameWithOwner: "octocat/codething-mvp",
233 url: "https://github.com/octocat/codething-mvp",
234 sshUrl: "git@github.com:octocat/codething-mvp.git",
235 });
236 expect(mockRun).toHaveBeenCalledTimes(1);
237 expect(mockRun).toHaveBeenNthCalledWith(1, {
238 operation: "GitHubCli.execute",
239 command: "gh",
240 args: ["repo", "create", "octocat/codething-mvp", "--private"],
241 cwd: "/repo",
242 timeoutMs: 30_000,
243 });
244 }).pipe(Effect.provide(layer)),
245 );
246
247 it.effect("falls back to constructed URLs when create output omits a URL", () =>
248 Effect.gen(function* () {
249 mockRun.mockReturnValueOnce(Effect.succeed(processOutput("")));
250
251 const gh = yield* GitHubCli.GitHubCli;
252 const result = yield* gh.createRepository({
253 cwd: "/repo",
254 repository: "octocat/codething-mvp",
255 visibility: "private",
256 });
257
258 assert.deepStrictEqual(result, {
259 nameWithOwner: "octocat/codething-mvp",
260 url: "https://github.com/octocat/codething-mvp",
261 sshUrl: "git@github.com:octocat/codething-mvp.git",
262 });
263 }).pipe(Effect.provide(layer)),
264 );
265
214 it.effect("surfaces a friendly error when the pull request is not found", () =>266 it.effect("surfaces a friendly error when the pull request is not found", () =>
215 Effect.gen(function* () {267 Effect.gen(function* () {
216 mockRun.mockReturnValueOnce(268 mockRun.mockReturnValueOnce(
apps/server/src/sourceControl/GitHubCli.ts+57−1
@@ -1,6 +1,10 @@
1import { Context, Effect, Layer, Result, Schema, SchemaIssue } from "effect";1import { Context, Effect, Layer, Result, Schema, SchemaIssue } from "effect";
2 2
3import { TrimmedNonEmptyString, type VcsError } from "@t3tools/contracts";3import {
4 TrimmedNonEmptyString,
5 type SourceControlRepositoryVisibility,
6 type VcsError,
7} from "@t3tools/contracts";
4 8
5import { VcsProcess, type VcsProcessOutput } from "../vcs/VcsProcess.ts";9import { VcsProcess, type VcsProcessOutput } from "../vcs/VcsProcess.ts";
6import {10import {
@@ -62,6 +66,12 @@ export interface GitHubCliShape { · 55 unmodified lines
62 readonly repository: string;66 readonly repository: string;
63 }) => Effect.Effect<GitHubRepositoryCloneUrls, GitHubCliError>;67 }) => Effect.Effect<GitHubRepositoryCloneUrls, GitHubCliError>;
64 68
69 readonly createRepository: (input: {
70 readonly cwd: string;
71 readonly repository: string;
72 readonly visibility: SourceControlRepositoryVisibility;
73 }) => Effect.Effect<GitHubRepositoryCloneUrls, GitHubCliError>;
74
65 readonly createPullRequest: (input: {75 readonly createPullRequest: (input: {
66 readonly cwd: string;76 readonly cwd: string;
67 readonly baseBranch: string;77 readonly baseBranch: string;
@@ -160,6 +170,43 @@ function normalizeRepositoryCloneUrls( · 92 unmodified lines
160 };170 };
161}171}
162 172
173/**
174 * `gh repo create` prints the canonical URL of the new repository on stdout
175 * (e.g. `https://github.com/owner/repo`). Reading it back here avoids a
176 * follow-up `gh repo view`, which can race GitHub's GraphQL eventual
177 * consistency window and falsely report the just-created repo as missing.
178 */
179function deriveRepositoryCloneUrlsFromCreateOutput(
180 stdout: string,
181 repository: string,
182): GitHubRepositoryCloneUrls {
183 const fallbackHost = "github.com";
184 const match = stdout.match(/https?:\/\/[^\s]+/);
185 if (match) {
186 const cleaned = match[0].replace(/\.git$/, "");
187 try {
188 const parsed = new URL(cleaned);
189 const pathname = parsed.pathname.replace(/^\/+|\/+$/g, "");
190 const segments = pathname.split("/").filter(Boolean);
191 if (segments.length === 2) {
192 const nameWithOwner = `${segments[0]}/${segments[1]}`;
193 return {
194 nameWithOwner,
195 url: `${parsed.origin}/${nameWithOwner}`,
196 sshUrl: `git@${parsed.host}:${nameWithOwner}.git`,
197 };
198 }
199 } catch {
200 // Fall through to the input-derived defaults below.
201 }
202 }
203 return {
204 nameWithOwner: repository,
205 url: `https://${fallbackHost}/${repository}`,
206 sshUrl: `git@${fallbackHost}:${repository}.git`,
207 };
208}
209
163function decodeGitHubJson<S extends Schema.Top>(210function decodeGitHubJson<S extends Schema.Top>(
164 raw: string,211 raw: string,
165 schema: S,212 schema: S,
@@ -281,6 +328,15 @@ export const make = Effect.fn("makeGitHubCli")(function* () { · 115 unmodified lines
281 ),328 ),
282 Effect.map(normalizeRepositoryCloneUrls),329 Effect.map(normalizeRepositoryCloneUrls),
283 ),330 ),
331 createRepository: (input) =>
332 execute({
333 cwd: input.cwd,
334 args: ["repo", "create", input.repository, `--${input.visibility}`],
335 }).pipe(
336 Effect.map((result) =>
337 deriveRepositoryCloneUrlsFromCreateOutput(result.stdout, input.repository),
338 ),
339 ),
284 createPullRequest: (input) =>340 createPullRequest: (input) =>
285 execute({341 execute({
286 cwd: input.cwd,342 cwd: input.cwd,
apps/server/src/sourceControl/GitHubSourceControlProvider.ts+4−0
@@ -130,6 +130,10 @@ export const make = Effect.fn("makeGitHubSourceControlProvider")(function* () { · 129 unmodified lines
130 github130 github
131 .getRepositoryCloneUrls(input)131 .getRepositoryCloneUrls(input)
132 .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))),132 .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))),
133 createRepository: (input) =>
134 github
135 .createRepository(input)
136 .pipe(Effect.mapError((error) => providerError("createRepository", error))),
133 getDefaultBranch: (input) =>137 getDefaultBranch: (input) =>
134 github138 github
135 .getDefaultBranch(input)139 .getDefaultBranch(input)
apps/server/src/sourceControl/GitLabCli.test.ts+62−1
@@ -175,7 +175,7 @@ layer("GitLabCli.layer", (it) => { · 174 unmodified lines
175 175
176 assert.deepStrictEqual(result, {176 assert.deepStrictEqual(result, {
177 nameWithOwner: "octocat/t3code",177 nameWithOwner: "octocat/t3code",
178 url: "https://gitlab.com/octocat/t3code.git",178 url: "https://gitlab.com/octocat/t3code",
179 sshUrl: "git@gitlab.com:octocat/t3code.git",179 sshUrl: "git@gitlab.com:octocat/t3code.git",
180 });180 });
181 }),181 }),
@@ -217,6 +217,67 @@ layer("GitLabCli.layer", (it) => { · 35 unmodified lines
217 }),217 }),
218 );218 );
219 219
220 it.effect("creates repositories under an explicit namespace", () =>
221 Effect.gen(function* () {
222 mockedRun
223 .mockReturnValueOnce(Effect.succeed(processOutput(JSON.stringify({ id: 1234 }))))
224 .mockReturnValueOnce(
225 Effect.succeed(
226 processOutput(
227 JSON.stringify({
228 path_with_namespace: "octocat/t3code",
229 web_url: "https://gitlab.com/octocat/t3code",
230 http_url_to_repo: "https://gitlab.com/octocat/t3code.git",
231 ssh_url_to_repo: "git@gitlab.com:octocat/t3code.git",
232 }),
233 ),
234 ),
235 );
236
237 const glab = yield* GitLabCli.GitLabCli;
238 const result = yield* glab.createRepository({
239 cwd: "/repo",
240 repository: "octocat/t3code",
241 visibility: "public",
242 });
243
244 assert.deepStrictEqual(result, {
245 nameWithOwner: "octocat/t3code",
246 url: "https://gitlab.com/octocat/t3code",
247 sshUrl: "git@gitlab.com:octocat/t3code.git",
248 });
249 expect(mockedRun).toHaveBeenNthCalledWith(
250 1,
251 expect.objectContaining({
252 command: "glab",
253 cwd: "/repo",
254 args: ["api", "namespaces/octocat"],
255 }),
256 );
257 expect(mockedRun).toHaveBeenNthCalledWith(
258 2,
259 expect.objectContaining({
260 command: "glab",
261 cwd: "/repo",
262 args: [
263 "api",
264 "--method",
265 "POST",
266 "projects",
267 "--raw-field",
268 "path=t3code",
269 "--raw-field",
270 "name=t3code",
271 "--raw-field",
272 "visibility=public",
273 "--raw-field",
274 "namespace_id=1234",
275 ],
276 }),
277 );
278 }),
279 );
280
220 it.effect("does not pass unsupported force flags when checking out merge requests", () =>281 it.effect("does not pass unsupported force flags when checking out merge requests", () =>
221 Effect.gen(function* () {282 Effect.gen(function* () {
222 mockedRun.mockReturnValueOnce(Effect.succeed(processOutput("")));283 mockedRun.mockReturnValueOnce(Effect.succeed(processOutput("")));
apps/server/src/sourceControl/GitLabCli.ts+79−3
@@ -1,6 +1,6 @@
1import { Context, Effect, Layer, Option, Result, Schema, SchemaIssue, type DateTime } from "effect";1import { Context, Effect, Layer, Option, Result, Schema, SchemaIssue, type DateTime } from "effect";
2 2
3import { TrimmedNonEmptyString } from "@t3tools/contracts";3import { TrimmedNonEmptyString, type SourceControlRepositoryVisibility } from "@t3tools/contracts";
4 4
5import {5import {
6 decodeGitLabMergeRequestJson,6 decodeGitLabMergeRequestJson,
@@ -66,6 +66,12 @@ export interface GitLabCliShape { · 59 unmodified lines
66 readonly repository: string;66 readonly repository: string;
67 }) => Effect.Effect<GitLabRepositoryCloneUrls, GitLabCliError>;67 }) => Effect.Effect<GitLabRepositoryCloneUrls, GitLabCliError>;
68 68
69 readonly createRepository: (input: {
70 readonly cwd: string;
71 readonly repository: string;
72 readonly visibility: SourceControlRepositoryVisibility;
73 }) => Effect.Effect<GitLabRepositoryCloneUrls, GitLabCliError>;
74
69 readonly createMergeRequest: (input: {75 readonly createMergeRequest: (input: {
70 readonly cwd: string;76 readonly cwd: string;
71 readonly baseBranch: string;77 readonly baseBranch: string;
@@ -161,12 +167,16 @@ const RawGitLabDefaultBranchSchema = Schema.Struct({ · 89 unmodified lines
161 default_branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),167 default_branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)),
162});168});
163 169
170const RawGitLabNamespaceSchema = Schema.Struct({
171 id: Schema.Number,
172});
173
164function normalizeRepositoryCloneUrls(174function normalizeRepositoryCloneUrls(
165 raw: Schema.Schema.Type<typeof RawGitLabRepositoryCloneUrlsSchema>,175 raw: Schema.Schema.Type<typeof RawGitLabRepositoryCloneUrlsSchema>,
166): GitLabRepositoryCloneUrls {176): GitLabRepositoryCloneUrls {
167 return {177 return {
168 nameWithOwner: raw.path_with_namespace,178 nameWithOwner: raw.path_with_namespace,
169 url: raw.http_url_to_repo || raw.web_url,179 url: raw.web_url,
170 sshUrl: raw.ssh_url_to_repo,180 sshUrl: raw.ssh_url_to_repo,
171 };181 };
172}182}
@@ -174,7 +184,7 @@ function normalizeRepositoryCloneUrls( · 1 unmodified line
174function decodeGitLabJson<S extends Schema.Top>(184function decodeGitLabJson<S extends Schema.Top>(
175 raw: string,185 raw: string,
176 schema: S,186 schema: S,
177 operation: "getRepositoryCloneUrls" | "getDefaultBranch",187 operation: "getRepositoryCloneUrls" | "getDefaultBranch" | "createRepository",
178 invalidDetail: string,188 invalidDetail: string,
179): Effect.Effect<S["Type"], GitLabCliError, S["DecodingServices"]> {189): Effect.Effect<S["Type"], GitLabCliError, S["DecodingServices"]> {
180 return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe(190 return Schema.decodeEffect(Schema.fromJsonString(schema))(raw).pipe(
@@ -228,6 +238,19 @@ function toSummaryWithOptionalUpdatedAt( · 47 unmodified lines
228 return Option.isSome(updatedAt) ? { ...summary, updatedAt } : summary;238 return Option.isSome(updatedAt) ? { ...summary, updatedAt } : summary;
229}239}
230 240
241function parseRepositoryPath(repository: string): {
242 readonly namespacePath: string | null;
243 readonly projectPath: string;
244} {
245 const parts = repository
246 .split("/")
247 .map((part) => part.trim())
248 .filter((part) => part.length > 0);
249 const projectPath = parts.at(-1) ?? repository.trim();
250 const namespacePath = parts.length > 1 ? parts.slice(0, -1).join("/") : null;
251 return { namespacePath, projectPath };
252}
253
231export const make = Effect.fn("makeGitLabCli")(function* () {254export const make = Effect.fn("makeGitLabCli")(function* () {
232 const process = yield* VcsProcess;255 const process = yield* VcsProcess;
233 256
@@ -320,6 +343,59 @@ export const make = Effect.fn("makeGitLabCli")(function* () { · 86 unmodified lines
320 ),343 ),
321 Effect.map(normalizeRepositoryCloneUrls),344 Effect.map(normalizeRepositoryCloneUrls),
322 ),345 ),
346 createRepository: (input) => {
347 const { namespacePath, projectPath } = parseRepositoryPath(input.repository);
348 const namespaceId: Effect.Effect<number | null, GitLabCliError> = namespacePath
349 ? execute({
350 cwd: input.cwd,
351 args: ["api", `namespaces/${encodeURIComponent(namespacePath)}`],
352 }).pipe(
353 Effect.map((result) => result.stdout.trim()),
354 Effect.flatMap((raw) =>
355 decodeGitLabJson(
356 raw,
357 RawGitLabNamespaceSchema,
358 "createRepository",
359 "GitLab CLI returned invalid namespace JSON.",
360 ),
361 ),
362 Effect.map((namespace) => namespace.id),
363 )
364 : Effect.succeed(null);
365
366 return namespaceId.pipe(
367 Effect.flatMap((resolvedNamespaceId) =>
368 execute({
369 cwd: input.cwd,
370 args: [
371 "api",
372 "--method",
373 "POST",
374 "projects",
375 "--raw-field",
376 `path=${projectPath}`,
377 "--raw-field",
378 `name=${projectPath}`,
379 "--raw-field",
380 `visibility=${input.visibility}`,
381 ...(resolvedNamespaceId === null
382 ? []
383 : ["--raw-field", `namespace_id=${resolvedNamespaceId}`]),
384 ],
385 }),
386 ),
387 Effect.map((result) => result.stdout.trim()),
388 Effect.flatMap((raw) =>
389 decodeGitLabJson(
390 raw,
391 RawGitLabRepositoryCloneUrlsSchema,
392 "createRepository",
393 "GitLab CLI returned invalid repository JSON.",
394 ),
395 ),
396 Effect.map(normalizeRepositoryCloneUrls),
397 );
398 },
323 createMergeRequest: (input) => {399 createMergeRequest: (input) => {
324 const sourceProject = sourceProjectIdentifier(input.source);400 const sourceProject = sourceProjectIdentifier(input.source);
325 return execute({401 return execute({
apps/server/src/sourceControl/GitLabSourceControlProvider.ts+4−0
@@ -92,6 +92,10 @@ export const make = Effect.fn("makeGitLabSourceControlProvider")(function* () { · 91 unmodified lines
92 gitlab92 gitlab
93 .getRepositoryCloneUrls(input)93 .getRepositoryCloneUrls(input)
94 .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))),94 .pipe(Effect.mapError((error) => providerError("getRepositoryCloneUrls", error))),
95 createRepository: (input) =>
96 gitlab
97 .createRepository(input)
98 .pipe(Effect.mapError((error) => providerError("createRepository", error))),
95 getDefaultBranch: (input) =>99 getDefaultBranch: (input) =>
96 gitlab100 gitlab
97 .getDefaultBranch(input)101 .getDefaultBranch(input)
apps/server/src/sourceControl/SourceControlDiscovery.test.ts+6−3
@@ -115,6 +115,9 @@ Logged in to github.com account juliusmarminge (keyring) · 114 unmodified lines
115 },115 },
116 ],116 ],
117 );117 );
118 const bitbucket = result.sourceControlProviders.find((item) => item.kind === "bitbucket");
119 assert.ok(bitbucket);
120 assert.strictEqual("executable" in bitbucket, false);
118 }).pipe(Effect.provide(testLayer));121 }).pipe(Effect.provide(testLayer));
119});122});
120 123
@@ -204,9 +207,9 @@ Logged in as bitbucket-user · 83 unmodified lines
204 },207 },
205 {208 {
206 kind: "bitbucket",209 kind: "bitbucket",
207 auth: "authenticated",210 auth: "unknown",
208 account: Option.some("bitbucket-user"),211 account: Option.none(),
209 detail: Option.none(),212 detail: Option.some("Bitbucket provider support is not available yet."),
210 },213 },
211 ],214 ],
212 );215 );
apps/server/src/sourceControl/SourceControlDiscovery.ts+44−50
@@ -13,20 +13,22 @@ import * as VcsProcess from "../vcs/VcsProcess.ts"; · 12 unmodified lines
13 13
14interface DiscoveryProbe {14interface DiscoveryProbe {
15 readonly label: string;15 readonly label: string;
16 readonly executable: string;16 readonly executable?: string;
17 readonly versionArgs: ReadonlyArray<string>;17 readonly versionArgs?: ReadonlyArray<string>;
18 readonly implemented: boolean;18 readonly implemented: boolean;
19 readonly installHint: string;19 readonly installHint: string;
20}20}
21 21
22type VcsProbe = DiscoveryProbe & {22type VcsProbe = DiscoveryProbe & {
23 readonly kind: VcsDriverKind;23 readonly kind: VcsDriverKind;
24 readonly executable: string;
25 readonly versionArgs: ReadonlyArray<string>;
24};26};
25 27
26type ProviderProbe = DiscoveryProbe & {28type ProviderProbe = DiscoveryProbe & {
27 readonly kind: SourceControlProviderKind;29 readonly kind: SourceControlProviderKind;
28 readonly authArgs: ReadonlyArray<string>;30 readonly authArgs?: ReadonlyArray<string>;
29 readonly parseAuth: (input: AuthProbeInput) => SourceControlProviderAuth;31 readonly parseAuth?: (input: AuthProbeInput) => SourceControlProviderAuth;
30};32};
31 33
32interface AuthProbeInput {34interface AuthProbeInput {
@@ -38,7 +40,7 @@ interface AuthProbeInput { · 5 unmodified lines
38interface DiscoveryProbeResult<Kind extends string> {40interface DiscoveryProbeResult<Kind extends string> {
39 readonly kind: Kind;41 readonly kind: Kind;
40 readonly label: string;42 readonly label: string;
41 readonly executable: string;43 readonly executable?: string;
42 readonly implemented: boolean;44 readonly implemented: boolean;
43 readonly status: "available" | "missing";45 readonly status: "available" | "missing";
44 readonly version: Option.Option<string>;46 readonly version: Option.Option<string>;
@@ -101,12 +103,8 @@ const SOURCE_CONTROL_PROVIDER_PROBES: ReadonlyArray<ProviderProbe> = [ · 56 unmodified lines
101 {103 {
102 kind: "bitbucket",104 kind: "bitbucket",
103 label: "Bitbucket",105 label: "Bitbucket",
104 executable: "bb",
105 versionArgs: ["--version"],
106 authArgs: ["auth", "status"],
107 parseAuth: parseBitbucketAuth,
108 implemented: false,106 implemented: false,
109 installHint: "Install a Bitbucket CLI (`bb`) and authenticate it for your Bitbucket workspace.",107 installHint: "Bitbucket provider support is not available yet.",
110 },108 },
111];109];
112 110
@@ -266,37 +264,6 @@ function parseAzureAuth(input: AuthProbeInput): SourceControlProviderAuth { · 153 unmodified lines
266 });264 });
267}265}
268 266
269function parseBitbucketAuth(input: AuthProbeInput): SourceControlProviderAuth {
270 const output = combinedAuthOutput(input);
271 const account = matchFirst(output, [
272 /Logged in to .* as\s+([^\s(]+)/iu,
273 /Logged in as\s+([^\s(]+)/iu,
274 /account:\s*([^\s(]+)/iu,
275 /user:\s*([^\s(]+)/iu,
276 /username:\s*([^\s(]+)/iu,
277 ]);
278 const host = parseCliHost(output) ?? "bitbucket.org";
279
280 if (input.exitCode !== 0) {
281 return providerAuth({
282 status: "unauthenticated",
283 host,
284 detail:
285 firstSafeAuthLine(output) ?? "Authenticate the Bitbucket CLI before enabling Bitbucket.",
286 });
287 }
288
289 if (account) {
290 return providerAuth({ status: "authenticated", account, host });
291 }
292
293 return providerAuth({
294 status: "unknown",
295 host,
296 detail: firstSafeAuthLine(output) ?? "Bitbucket CLI auth status could not be parsed.",
297 });
298}
299
300export interface SourceControlDiscoveryShape {267export interface SourceControlDiscoveryShape {
301 readonly discover: Effect.Effect<SourceControlDiscoveryResult>;268 readonly discover: Effect.Effect<SourceControlDiscoveryResult>;
302}269}
@@ -314,12 +281,27 @@ export const layer = Layer.effect( · 11 unmodified lines
314 281
315 const probe = <Kind extends VcsDriverKind | SourceControlProviderKind>(282 const probe = <Kind extends VcsDriverKind | SourceControlProviderKind>(
316 input: DiscoveryProbe & { readonly kind: Kind },283 input: DiscoveryProbe & { readonly kind: Kind },
317 ): Effect.Effect<DiscoveryProbeResult<Kind>> =>284 ): Effect.Effect<DiscoveryProbeResult<Kind>> => {
318 process285 const executable = input.executable;
286 const versionArgs = input.versionArgs;
287
288 if (!executable || !versionArgs) {
289 return Effect.succeed({
290 kind: input.kind,
291 label: input.label,
292 implemented: input.implemented,
293 status: "missing" as const,
294 version: Option.none<string>(),
295 installHint: input.installHint,
296 detail: Option.some(input.installHint),
297 } satisfies DiscoveryProbeResult<Kind>);
298 }
299
300 return process
319 .run({301 .run({
320 operation: "source-control.discovery.probe",302 operation: "source-control.discovery.probe",
321 command: input.executable,303 command: executable,
322 args: input.versionArgs,304 args: versionArgs,
323 cwd: config.cwd,305 cwd: config.cwd,
324 timeoutMs: 5_000,306 timeoutMs: 5_000,
325 maxOutputBytes: 8_000,307 maxOutputBytes: 8_000,
@@ -331,7 +313,7 @@ export const layer = Layer.effect( · 5 unmodified lines
331 ({313 ({
332 kind: input.kind,314 kind: input.kind,
333 label: input.label,315 label: input.label,
334 executable: input.executable,316 executable,
335 implemented: input.implemented,317 implemented: input.implemented,
336 status: "available" as const,318 status: "available" as const,
337 version: Option.orElse(firstNonEmptyLine(result.stdout), () =>319 version: Option.orElse(firstNonEmptyLine(result.stdout), () =>
@@ -345,7 +327,7 @@ export const layer = Layer.effect( · 7 unmodified lines
345 Effect.succeed({327 Effect.succeed({
346 kind: input.kind,328 kind: input.kind,
347 label: input.label,329 label: input.label,
348 executable: input.executable,330 executable,
349 implemented: input.implemented,331 implemented: input.implemented,
350 status: "missing" as const,332 status: "missing" as const,
351 version: Option.none<string>(),333 version: Option.none<string>(),
@@ -354,10 +336,22 @@ export const layer = Layer.effect( · 2 unmodified lines
354 } satisfies DiscoveryProbeResult<Kind>),336 } satisfies DiscoveryProbeResult<Kind>),
355 ),337 ),
356 );338 );
339 };
357 340
358 const probeProvider = (input: ProviderProbe) =>341 const probeProvider = (input: ProviderProbe) =>
359 probe(input).pipe(342 probe(input).pipe(
360 Effect.flatMap((item) => {343 Effect.flatMap((item) => {
344 const executable = input.executable;
345 const authArgs = input.authArgs;
346 const parseAuth = input.parseAuth;
347
348 if (!executable || !authArgs || !parseAuth) {
349 return Effect.succeed({
350 ...item,
351 auth: unknownAuth(input.installHint),
352 } satisfies SourceControlProviderDiscoveryItem);
353 }
354
361 if (item.status !== "available") {355 if (item.status !== "available") {
362 return Effect.succeed({356 return Effect.succeed({
363 ...item,357 ...item,
@@ -368,8 +362,8 @@ export const layer = Layer.effect( · 4 unmodified lines
368 return process362 return process
369 .run({363 .run({
370 operation: "source-control.discovery.auth",364 operation: "source-control.discovery.auth",
371 command: input.executable,365 command: executable,
372 args: input.authArgs,366 args: authArgs,
373 cwd: config.cwd,367 cwd: config.cwd,
374 allowNonZeroExit: true,368 allowNonZeroExit: true,
375 timeoutMs: 5_000,369 timeoutMs: 5_000,
@@ -381,7 +375,7 @@ export const layer = Layer.effect( · 5 unmodified lines
381 (result) =>375 (result) =>
382 ({376 ({
383 ...item,377 ...item,
384 auth: input.parseAuth(result),378 auth: parseAuth(result),
385 }) satisfies SourceControlProviderDiscoveryItem,379 }) satisfies SourceControlProviderDiscoveryItem,
386 ),380 ),
387 Effect.catch((cause) =>381 Effect.catch((cause) =>
apps/server/src/sourceControl/SourceControlProvider.ts+6−0
@@ -6,6 +6,7 @@ import type { · 5 unmodified lines
6 SourceControlProviderInfo,6 SourceControlProviderInfo,
7 SourceControlProviderKind,7 SourceControlProviderKind,
8 SourceControlRepositoryCloneUrls,8 SourceControlRepositoryCloneUrls,
9 SourceControlRepositoryVisibility,
9} from "@t3tools/contracts";10} from "@t3tools/contracts";
10 11
11export interface SourceControlProviderContext {12export interface SourceControlProviderContext {
@@ -50,6 +51,11 @@ export interface SourceControlProviderShape { · 38 unmodified lines
50 readonly context?: SourceControlProviderContext;51 readonly context?: SourceControlProviderContext;
51 readonly repository: string;52 readonly repository: string;
52 }) => Effect.Effect<SourceControlRepositoryCloneUrls, SourceControlProviderError>;53 }) => Effect.Effect<SourceControlRepositoryCloneUrls, SourceControlProviderError>;
54 readonly createRepository: (input: {
55 readonly cwd: string;
56 readonly repository: string;
57 readonly visibility: SourceControlRepositoryVisibility;
58 }) => Effect.Effect<SourceControlRepositoryCloneUrls, SourceControlProviderError>;
53 readonly getDefaultBranch: (input: {59 readonly getDefaultBranch: (input: {
54 readonly cwd: string;60 readonly cwd: string;
55 readonly context?: SourceControlProviderContext;61 readonly context?: SourceControlProviderContext;
apps/server/src/sourceControl/SourceControlProviderRegistry.ts+1−0
@@ -58,6 +58,7 @@ function unsupportedProvider(kind: SourceControlProviderKind): SourceControlProv · 57 unmodified lines
58 getChangeRequest: () => unsupported("getChangeRequest"),58 getChangeRequest: () => unsupported("getChangeRequest"),
59 createChangeRequest: () => unsupported("createChangeRequest"),59 createChangeRequest: () => unsupported("createChangeRequest"),
60 getRepositoryCloneUrls: () => unsupported("getRepositoryCloneUrls"),60 getRepositoryCloneUrls: () => unsupported("getRepositoryCloneUrls"),
61 createRepository: () => unsupported("createRepository"),
61 getDefaultBranch: () => unsupported("getDefaultBranch"),62 getDefaultBranch: () => unsupported("getDefaultBranch"),
62 checkoutChangeRequest: () => unsupported("checkoutChangeRequest"),63 checkoutChangeRequest: () => unsupported("checkoutChangeRequest"),
63 });64 });
apps/server/src/sourceControl/SourceControlRepositoryService.test.ts+322−0
@@ -0,0 +1,322 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { assert, it } from "@effect/vitest";
3import { Effect, FileSystem, Layer } from "effect";
4import { ChildProcessSpawner } from "effect/unstable/process";
5
6import { GitCommandError, type SourceControlProviderError } from "@t3tools/contracts";
7
8import { ServerConfig } from "../config.ts";
9import {
10 GitVcsDriver,
11 type ExecuteGitResult,
12 type GitVcsDriverShape,
13} from "../vcs/GitVcsDriver.ts";
14import { SourceControlProviderRegistry } from "./SourceControlProviderRegistry.ts";
15import type { SourceControlProviderShape } from "./SourceControlProvider.ts";
16import { SourceControlRepositoryService, layer } from "./SourceControlRepositoryService.ts";
17
18const CLONE_URLS = {
19 nameWithOwner: "octocat/t3code",
20 url: "https://github.com/octocat/t3code",
21 sshUrl: "git@github.com:octocat/t3code.git",
22};
23
24function makeProvider(
25 overrides: Partial<SourceControlProviderShape> = {},
26): SourceControlProviderShape {
27 const unsupported = (operation: string) =>
28 Effect.die(`unexpected provider operation ${operation}`) as Effect.Effect<
29 never,
30 SourceControlProviderError
31 >;
32
33 return {
34 kind: "github",
35 listChangeRequests: () => unsupported("listChangeRequests"),
36 getChangeRequest: () => unsupported("getChangeRequest"),
37 createChangeRequest: () => unsupported("createChangeRequest"),
38 getRepositoryCloneUrls: () => Effect.succeed(CLONE_URLS),
39 createRepository: () => Effect.succeed(CLONE_URLS),
40 getDefaultBranch: () => Effect.succeed(null),
41 checkoutChangeRequest: () => unsupported("checkoutChangeRequest"),
42 ...overrides,
43 };
44}
45
46function processOutput(): ExecuteGitResult {
47 return {
48 exitCode: ChildProcessSpawner.ExitCode(0),
49 stdout: "",
50 stderr: "",
51 stdoutTruncated: false,
52 stderrTruncated: false,
53 };
54}
55
56function makeLayer(input: {
57 readonly provider?: SourceControlProviderShape;
58 readonly git?: Partial<GitVcsDriverShape>;
59}) {
60 return layer.pipe(
61 Layer.provide(
62 Layer.mock(SourceControlProviderRegistry)({
63 get: () => Effect.succeed(input.provider ?? makeProvider()),
64 }),
65 ),
66 Layer.provide(
67 Layer.mock(GitVcsDriver)({
68 execute: () => Effect.succeed(processOutput()),
69 ensureRemote: () => Effect.succeed("origin"),
70 pushCurrentBranch: () =>
71 Effect.succeed({
72 status: "pushed" as const,
73 branch: "feature/remote-v1",
74 upstreamBranch: "origin/feature/remote-v1",
75 setUpstream: true,
76 }),
77 ...input.git,
78 }),
79 ),
80 Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-source-control-repos-" })),
81 Layer.provideMerge(NodeServices.layer),
82 );
83}
84
85it.effect("looks up repositories through the requested provider without search", () => {
86 const calls: Array<{ cwd: string; repository: string }> = [];
87 const provider = makeProvider({
88 getRepositoryCloneUrls: (input) =>
89 Effect.sync(() => {
90 calls.push({ cwd: input.cwd, repository: input.repository });
91 return CLONE_URLS;
92 }),
93 });
94
95 return Effect.gen(function* () {
96 const service = yield* SourceControlRepositoryService;
97 const result = yield* service.lookupRepository({
98 provider: "github",
99 repository: "octocat/t3code",
100 cwd: "/workspace",
101 });
102
103 assert.deepStrictEqual(result, { provider: "github", ...CLONE_URLS });
104 assert.deepStrictEqual(calls, [{ cwd: "/workspace", repository: "octocat/t3code" }]);
105 }).pipe(Effect.provide(makeLayer({ provider })));
106});
107
108it.effect("clones a looked-up repository into the requested destination", () =>
109 Effect.gen(function* () {
110 const fs = yield* FileSystem.FileSystem;
111 const parent = yield* fs.makeTempDirectoryScoped({
112 prefix: "t3-source-control-clone-parent-",
113 });
114 const destinationPath = `${parent}/t3code`;
115 const cloneCalls: Array<{ cwd: string; args: ReadonlyArray<string> }> = [];
116
117 yield* Effect.gen(function* () {
118 const service = yield* SourceControlRepositoryService;
119 const result = yield* service.cloneRepository({
120 provider: "github",
121 repository: "octocat/t3code",
122 destinationPath,
123 protocol: "https",
124 });
125
126 assert.deepStrictEqual(result, {
127 cwd: destinationPath,
128 remoteUrl: CLONE_URLS.url,
129 repository: { provider: "github", ...CLONE_URLS },
130 });
131 assert.deepStrictEqual(cloneCalls, [
132 {
133 cwd: parent,
134 args: ["clone", CLONE_URLS.url, "t3code"],
135 },
136 ]);
137 }).pipe(
138 Effect.provide(
139 makeLayer({
140 git: {
141 execute: (input) =>
142 Effect.sync(() => {
143 cloneCalls.push({ cwd: input.cwd, args: input.args });
144 return processOutput();
145 }),
146 },
147 }),
148 ),
149 );
150 }).pipe(Effect.provide(NodeServices.layer)),
151);
152
153it.effect("publishes by creating the repository, adding a remote, and pushing upstream", () => {
154 const createCalls: Array<{ cwd: string; repository: string; visibility: string }> = [];
155 const remoteCalls: Array<{ cwd: string; preferredName: string; url: string }> = [];
156 const pushCalls: Array<{ cwd: string; remoteName: string | null | undefined }> = [];
157 const provider = makeProvider({
158 createRepository: (input) =>
159 Effect.sync(() => {
160 createCalls.push({
161 cwd: input.cwd,
162 repository: input.repository,
163 visibility: input.visibility,
164 });
165 return CLONE_URLS;
166 }),
167 });
168
169 return Effect.gen(function* () {
170 const service = yield* SourceControlRepositoryService;
171 const result = yield* service.publishRepository({
172 cwd: "/workspace",
173 provider: "github",
174 repository: "octocat/t3code",
175 visibility: "private",
176 remoteName: "origin",
177 protocol: "ssh",
178 });
179
180 assert.deepStrictEqual(result, {
181 repository: { provider: "github", ...CLONE_URLS },
182 remoteName: "origin",
183 remoteUrl: CLONE_URLS.sshUrl,
184 branch: "feature/remote-v1",
185 upstreamBranch: "origin/feature/remote-v1",
186 status: "pushed",
187 });
188 assert.deepStrictEqual(createCalls, [
189 { cwd: "/workspace", repository: "octocat/t3code", visibility: "private" },
190 ]);
191 assert.deepStrictEqual(remoteCalls, [
192 { cwd: "/workspace", preferredName: "origin", url: CLONE_URLS.sshUrl },
193 ]);
194 assert.deepStrictEqual(pushCalls, [{ cwd: "/workspace", remoteName: "origin" }]);
195 }).pipe(
196 Effect.provide(
197 makeLayer({
198 provider,
199 git: {
200 ensureRemote: (input) =>
201 Effect.sync(() => {
202 remoteCalls.push(input);
203 return "origin";
204 }),
205 pushCurrentBranch: (cwd, _fallbackBranch, options) =>
206 Effect.sync(() => {
207 pushCalls.push({ cwd, remoteName: options?.remoteName });
208 return {
209 status: "pushed" as const,
210 branch: "feature/remote-v1",
211 upstreamBranch: "origin/feature/remote-v1",
212 setUpstream: true,
213 };
214 }),
215 },
216 }),
217 ),
218 );
219});
220
221it.effect("publishes to the remote name returned by ensureRemote", () => {
222 const pushCalls: Array<{ cwd: string; remoteName: string | null | undefined }> = [];
223
224 return Effect.gen(function* () {
225 const service = yield* SourceControlRepositoryService;
226 const result = yield* service.publishRepository({
227 cwd: "/workspace",
228 provider: "github",
229 repository: "octocat/t3code",
230 visibility: "private",
231 remoteName: "origin",
232 protocol: "ssh",
233 });
234
235 assert.equal(result.remoteName, "origin-1");
236 assert.deepStrictEqual(pushCalls, [{ cwd: "/workspace", remoteName: "origin-1" }]);
237 }).pipe(
238 Effect.provide(
239 makeLayer({
240 git: {
241 ensureRemote: () => Effect.succeed("origin-1"),
242 pushCurrentBranch: (cwd, _fallbackBranch, options) =>
243 Effect.sync(() => {
244 pushCalls.push({ cwd, remoteName: options?.remoteName });
245 return {
246 status: "pushed" as const,
247 branch: "feature/remote-v1",
248 upstreamBranch: `${options?.remoteName ?? "missing"}/feature/remote-v1`,
249 setUpstream: true,
250 };
251 }),
252 },
253 }),
254 ),
255 );
256});
257
258it.effect("publish succeeds with status remote_added when the local repo has no commits", () => {
259 let pushCalls = 0;
260 return Effect.gen(function* () {
261 const service = yield* SourceControlRepositoryService;
262 const result = yield* service.publishRepository({
263 cwd: "/workspace",
264 provider: "github",
265 repository: "octocat/t3code",
266 visibility: "private",
267 remoteName: "origin",
268 protocol: "ssh",
269 });
270
271 assert.deepStrictEqual(result, {
272 repository: { provider: "github", ...CLONE_URLS },
273 remoteName: "origin",
274 remoteUrl: CLONE_URLS.sshUrl,
275 branch: "main",
276 status: "remote_added",
277 });
278 assert.strictEqual(pushCalls, 0);
279 }).pipe(
280 Effect.provide(
281 makeLayer({
282 git: {
283 execute: (input) =>
284 input.args[0] === "rev-parse"
285 ? Effect.fail(
286 new GitCommandError({
287 operation: input.operation,
288 command: "git rev-parse --verify HEAD",
289 cwd: input.cwd,
290 detail: "fatal: Needed a single revision",
291 }),
292 )
293 : Effect.succeed(processOutput()),
294 statusDetails: () =>
295 Effect.succeed({
296 isRepo: true,
297 hasOriginRemote: true,
298 isDefaultBranch: true,
299 branch: "main",
300 upstreamRef: null,
301 hasWorkingTreeChanges: false,
302 workingTree: { files: [], insertions: 0, deletions: 0 },
303 hasUpstream: false,
304 aheadCount: 0,
305 behindCount: 0,
306 aheadOfDefaultCount: 0,
307 }),
308 pushCurrentBranch: () =>
309 Effect.sync(() => {
310 pushCalls += 1;
311 return {
312 status: "pushed" as const,
313 branch: "main",
314 upstreamBranch: "origin/main",
315 setUpstream: true,
316 };
317 }),
318 },
319 }),
320 ),
321 );
322});
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());