Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -755,3 +755,62 @@ it.effect("publish succeeds with status remote_added when the local repo has no
),
);
});

it.effect("publishes over the protocol the provider's CLI prefers unless one was chosen", () =>
Effect.gen(function* () {
const cases = [
{ cli: "https", protocol: undefined, expected: CLONE_URLS.url },
{ cli: null, protocol: "auto", expected: CLONE_URLS.sshUrl },
{ cli: "https", protocol: "ssh", expected: CLONE_URLS.sshUrl },
] as const;
for (const { cli, protocol, expected } of cases) {
const asked: Array<{ cwd: string; url: string }> = [];
const provider = makeProvider({
preferredCloneProtocol: (input) =>
Effect.sync(() => {
asked.push(input);
return cli;
}),
});
const result = yield* Effect.gen(function* () {
const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
return yield* service.publishRepository({
cwd: "/workspace",
provider: SourceControlProviderKind.make("github"),
repository: "octocat/t3code",
visibility: "private",
...(protocol === undefined ? {} : { protocol }),
});
}).pipe(Effect.provide(layer({ provider })));

assert.strictEqual(result.remoteUrl, expected);
assert.deepStrictEqual(
asked,
protocol === undefined || protocol === "auto"
? [{ cwd: "/workspace", url: CLONE_URLS.url }]
: [],
);
}
}),
);

it.effect("clones over the protocol the provider's CLI prefers when asked for auto", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
const parent = yield* fs.makeTempDirectoryScoped({ prefix: "t3-clone-protocol-" });
const provider = makeProvider({ preferredCloneProtocol: () => Effect.succeed("https") });

const prepared = yield* Effect.gen(function* () {
const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
return yield* service.prepareClone({
provider: SourceControlProviderKind.make("github"),
repository: "octocat/t3code",
destinationPath: path.join(parent, "t3code"),
protocol: "auto",
});
}).pipe(Effect.provide(layer({ provider })));

assert.strictEqual(prepared.cloneUrl, CLONE_URLS.url);
}).pipe(Effect.provide(NodeServices.layer)),
);
25 changes: 23 additions & 2 deletions apps/server/src/sourceControl/SourceControlRepositoryService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
} from "../project/gitCloneProgress.ts";
import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
import * as BitbucketApi from "@t3tools/source-control-bitbucket/server/BitbucketApi";
import type * as SourceControlProvider from "@t3tools/source-control-core/server/SourceControlProvider";
import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
import * as HostProcess from "@t3tools/shared/HostProcess";
const isSourceControlRepositoryError = Schema.is(SourceControlRepositoryError);
Expand Down Expand Up @@ -182,6 +183,21 @@ function selectRemoteUrl(
}
}

/** An explicit protocol wins; `auto` follows the provider's CLI setting, else SSH. */
function resolveRemoteUrl(
provider: SourceControlProvider.SourceControlProvider["Service"],
cwd: string,
urls: SourceControlRepositoryCloneUrls,
protocol: SourceControlCloneProtocol | undefined,
) {
if ((protocol ?? "auto") !== "auto" || provider.preferredCloneProtocol === undefined) {
return Effect.succeed(selectRemoteUrl(urls, protocol));
}
return provider
.preferredCloneProtocol({ cwd, url: urls.url })
.pipe(Effect.map((preferred) => selectRemoteUrl(urls, preferred ?? "ssh")));
}

/** @public Service construction is part of the canonical Effect module API. */
export const make = Effect.gen(function* () {
const config = yield* ServerConfig.ServerConfig;
Expand Down Expand Up @@ -288,7 +304,12 @@ export const make = Effect.gen(function* () {
repository: input.repository,
cwd: preparedDestination.parentPath,
});
remoteUrl = selectRemoteUrl(repository, input.protocol);
remoteUrl = yield* resolveRemoteUrl(
yield* providers.get(input.provider),
preparedDestination.parentPath,
repository,
input.protocol,
);
provider = input.provider;
}

Expand Down Expand Up @@ -433,7 +454,7 @@ export const make = Effect.gen(function* () {
repository: input.repository.trim(),
visibility: input.visibility,
});
const remoteUrl = selectRemoteUrl(urls, input.protocol);
const remoteUrl = yield* resolveRemoteUrl(provider, input.cwd, urls, input.protocol);
const remoteName = yield* git.ensureRemote({
cwd: input.cwd,
preferredName: input.remoteName?.trim() || "origin",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,14 @@ export class SourceControlProvider extends Context.Service<
readonly repository: string;
readonly visibility: SourceControlRepositoryVisibility;
}) => Effect.Effect<SourceControlRepositoryCloneUrls, SourceControlProviderError>;
/**
* The protocol an `auto` clone or publish of the repository at `url` uses, from the host
* CLI's own setting. Absent, or `null`, means SSH.
*/
readonly preferredCloneProtocol?: (input: {
readonly cwd: string;
readonly url: string;
}) => Effect.Effect<"ssh" | "https" | null>;
readonly getDefaultBranch: (input: {
readonly cwd: string;
readonly context?: SourceControlProviderContext;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { SourceControlProviderKind } from "@t3tools/contracts";
import { SourceControlProviderKind, VcsProcessSpawnError } from "@t3tools/contracts";
import * as HostProcess from "@t3tools/shared/HostProcess";
import { assert, describe, it } from "@effect/vitest";
import * as NodeServices from "@effect/platform-node/NodeServices";
Expand Down Expand Up @@ -172,6 +172,7 @@ function harness(input: {
readonly localBranches?: ReadonlyArray<string>;
readonly resolved?: string;
readonly gitFailure?: unknown;
readonly run?: SourceControlHost.SourceControlHost["Service"]["process"]["run"];
}) {
const git: Array<readonly [string, unknown]> = [];
const graphqlCalls: Array<GraphQlCall> = [];
Expand Down Expand Up @@ -207,12 +208,14 @@ function harness(input: {
const host = TestSourceControlHost.layer({
git: hostGit,
process: {
run: (args) =>
Effect.succeed(
args.args[0] === "remote"
? processOutput(input.remotes)
: processOutput(input.resolved ?? "", input.resolved ? 0 : 1),
),
run:
input.run ??
((args) =>
Effect.succeed(
args.args[0] === "remote"
? processOutput(input.remotes)
: processOutput(input.resolved ?? "", input.resolved ? 0 : 1),
)),
},
});
const graphql =
Expand Down Expand Up @@ -697,6 +700,35 @@ describe("GitHubSourceControlProvider writes", () => {
});
});

it.effect("prefers the protocol gh's git_protocol sets for the repository's host", () =>
Effect.gen(function* () {
const calls: Array<string> = [];
const missing = new VcsProcessSpawnError({ operation: "", command: "gh", cwd: "", cause: 0 });
const cases = [
[Effect.succeed(processOutput("https\n")), "https"],
[Effect.fail(missing), null],
] as const;
for (const [output, expected] of cases) {
const protocol = yield* Effect.gen(function* () {
const gh = yield* GitHubSourceControlProvider.make;
return yield* gh.preferredCloneProtocol!({ cwd: "/repo", url: "https://ghe.test/a/b" });
}).pipe(
Effect.provide(
harness({
remotes: "",
run: (input) => {
calls.push(`${input.command} ${input.args.join(" ")}`);
return output;
},
}).layer,
),
);
assert.strictEqual(protocol, expected);
}
assert.deepStrictEqual(new Set(calls), new Set(["gh config get -h ghe.test git_protocol"]));
}),
);

describe("GitHubSourceControlProvider.checkoutChangeRequest", () => {
const repository = (fullName: string, defaultBranch = "main") =>
restResponse({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -448,6 +448,14 @@ export function pullRequestCheckoutBranchName(input: {
const contextHost = (context: SourceControlProvider.SourceControlProviderContext | undefined) =>
context === undefined ? undefined : new URL(context.provider.baseUrl).host;

const isCloneProtocol = Schema.is(Schema.Literals(["ssh", "https"]));

/** `-h <host>` for a repository URL gh's per-host config can key on. */
const hostArgs = (url: string): ReadonlyArray<string> => {
const host = URL.parse(url)?.host;
return host ? ["-h", host] : [];
};

export const make = Effect.gen(function* () {
const api = yield* GitHubRest.GitHubRest;
const sourceControlHost = yield* SourceControlHost.SourceControlHost;
Expand Down Expand Up @@ -1011,6 +1019,23 @@ export const make = Effect.gen(function* () {
providerError("createRepository", input.cwd, { repository: input.repository }),
),
),
// gh prints its own default (https) when nothing is set, the protocol `gh repo clone` uses.
preferredCloneProtocol: (input) =>
process
.run({
operation: "GitHubSourceControlProvider.preferredCloneProtocol",
command: "gh",
args: ["config", "get", ...hostArgs(input.url), "git_protocol"],
cwd: input.cwd,
timeoutMs: 5_000,
})
.pipe(
Effect.map((output) => {
const protocol = output.stdout.trim();
return isCloneProtocol(protocol) ? protocol : null;
}),
Effect.orElseSucceed(() => null),
),
getDefaultBranch: (input) =>
Effect.gen(function* () {
const locator = yield* resolveRepository({
Expand Down
Loading