Skip to content
Draft
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
32 changes: 21 additions & 11 deletions apps/server/src/sourceControl/GitHubCli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,18 +210,20 @@ describe("GitHubCli.layer", () => {

it.effect("reads repository clone URLs", () =>
Effect.gen(function* () {
mockRun.mockReturnValueOnce(
Effect.succeed(
processOutput(
// @effect-diagnostics-next-line preferSchemaOverJson:off
JSON.stringify({
nameWithOwner: "octocat/codething-mvp",
url: "https://github.com/octocat/codething-mvp",
sshUrl: "git@github.com:octocat/codething-mvp.git",
}),
mockRun
.mockReturnValueOnce(
Effect.succeed(
processOutput(
// @effect-diagnostics-next-line preferSchemaOverJson:off
JSON.stringify({
nameWithOwner: "octocat/codething-mvp",
url: "https://github.com/octocat/codething-mvp",
sshUrl: "git@github.com:octocat/codething-mvp.git",
}),
),
),
),
);
)
.mockReturnValueOnce(Effect.succeed(processOutput("https\n")));

const gh = yield* GitHubCli.GitHubCli;
const result = yield* gh.getRepositoryCloneUrls({
Expand All @@ -233,6 +235,14 @@ describe("GitHubCli.layer", () => {
nameWithOwner: "octocat/codething-mvp",
url: "https://github.com/octocat/codething-mvp",
sshUrl: "git@github.com:octocat/codething-mvp.git",
preferredProtocol: "https",
});
expect(mockRun).toHaveBeenNthCalledWith(2, {
operation: "GitHubCli.execute",
command: "gh",
args: ["config", "get", "git_protocol", "--host", "github.com"],
cwd: "/repo",
timeoutMs: 30_000,
});
}).pipe(Effect.provide(layer)),
);
Expand Down
31 changes: 31 additions & 0 deletions apps/server/src/sourceControl/GitHubCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,7 @@ export interface GitHubRepositoryCloneUrls {
readonly nameWithOwner: string;
readonly url: string;
readonly sshUrl: string;
readonly preferredProtocol?: "ssh" | "https";
}

export class GitHubCli extends Context.Service<
Expand Down Expand Up @@ -266,6 +267,22 @@ function normalizeRepositoryCloneUrls(
};
}

function repositoryHost(url: string): string {
try {
return new URL(url).hostname;
} catch {
return "github.com";
}
}

function parseConfiguredGitProtocol(stdout: string): "ssh" | "https" | undefined {
const protocol = stdout.trim().toLowerCase();
if (protocol === "ssh" || protocol === "https") {
return protocol;
}
return undefined;
}

/**
* `gh repo create` prints the canonical URL of the new repository on stdout
* (e.g. `https://github.com/owner/repo`). Reading it back here avoids a
Expand Down Expand Up @@ -409,6 +426,20 @@ export const make = Effect.gen(function* () {
),
),
Effect.map(normalizeRepositoryCloneUrls),
Effect.flatMap((urls) =>
execute({
cwd: input.cwd,
args: ["config", "get", "git_protocol", "--host", repositoryHost(urls.url)],
}).pipe(
Effect.map((result) => parseConfiguredGitProtocol(result.stdout)),
Effect.map((preferredProtocol) =>
preferredProtocol === undefined ? urls : { ...urls, preferredProtocol },
),
// Repository lookup should still work when older `gh` versions or
// incomplete host configuration cannot report a preference.
Effect.orElseSucceed(() => urls),
),
),
),
createRepository: (input) =>
execute({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -150,29 +150,77 @@ it.effect("preserves provider failures without deriving the repository message f
}).pipe(Effect.provide(makeLayer({ provider })));
});

it.effect("clones a looked-up repository into the requested destination", () =>
it.effect("clones a looked-up repository using the provider's HTTPS preference", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const parent = yield* fs.makeTempDirectoryScoped({
prefix: "t3-source-control-clone-parent-",
});
const destinationPath = `${parent}/t3code`;
const cloneCalls: Array<{ cwd: string; args: ReadonlyArray<string> }> = [];
const provider = makeProvider({
getRepositoryCloneUrls: () =>
Effect.succeed({ ...CLONE_URLS, preferredProtocol: "https" as const }),
});

yield* Effect.gen(function* () {
const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
const result = yield* service.cloneRepository({
provider: "github",
repository: "octocat/t3code",
destinationPath,
protocol: "https",
});

assert.deepStrictEqual(result, {
cwd: destinationPath,
remoteUrl: CLONE_URLS.url,
repository: { provider: "github", ...CLONE_URLS },
repository: {
provider: "github",
...CLONE_URLS,
preferredProtocol: "https",
},
});
assert.deepStrictEqual(cloneCalls, [
{
cwd: parent,
args: ["clone", CLONE_URLS.url, "t3code"],
},
]);
}).pipe(
Effect.provide(
makeLayer({
provider,
git: {
execute: (input) =>
Effect.sync(() => {
cloneCalls.push({ cwd: input.cwd, args: input.args });
return processOutput();
}),
},
}),
),
);
}).pipe(Effect.provide(NodeServices.layer)),
);

it.effect("uses HTTPS as the automatic fallback without a provider preference", () =>
Effect.gen(function* () {
const fs = yield* FileSystem.FileSystem;
const parent = yield* fs.makeTempDirectoryScoped({
prefix: "t3-source-control-clone-parent-",
});
const destinationPath = `${parent}/t3code`;
const cloneCalls: Array<{ cwd: string; args: ReadonlyArray<string> }> = [];

yield* Effect.gen(function* () {
const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
const result = yield* service.cloneRepository({
provider: "github",
repository: "octocat/t3code",
destinationPath,
});

assert.strictEqual(result.remoteUrl, CLONE_URLS.url);
assert.deepStrictEqual(cloneCalls, [
{
cwd: parent,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ function toRepositoryInfo(
nameWithOwner: urls.nameWithOwner,
url: urls.url,
sshUrl: urls.sshUrl,
...(urls.preferredProtocol ? { preferredProtocol: urls.preferredProtocol } : {}),
};
}

Expand All @@ -71,8 +72,9 @@ function selectRemoteUrl(
switch (protocol ?? "auto") {
case "https":
return urls.url;
case "ssh":
case "auto":
return urls.preferredProtocol === "ssh" ? urls.sshUrl : urls.url;
case "ssh":
return urls.sshUrl;
}
}
Expand Down
21 changes: 21 additions & 0 deletions apps/web/src/components/CommandPalette.logic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { Thread } from "../types";
import {
buildThreadActionItems,
filterCommandPaletteGroups,
getCloneSourceInput,
type CommandPaletteGroup,
} from "./CommandPalette.logic";

Expand Down Expand Up @@ -35,6 +36,26 @@ function makeThread(overrides: Partial<Thread> = {}): Thread {
};
}

it("uses provider-based automatic selection for looked-up repositories", () => {
expect(
getCloneSourceInput({
repository: {
provider: "github",
nameWithOwner: "octocat/t3code",
url: "https://github.com/octocat/t3code",
sshUrl: "git@github.com:octocat/t3code.git",
},
remoteUrl: "https://github.com/octocat/t3code",
}),
).toEqual({ provider: "github", repository: "octocat/t3code", protocol: "auto" });
});

it("passes explicit clone URLs through unchanged", () => {
expect(
getCloneSourceInput({ repository: null, remoteUrl: "git@example.com:org/repo.git" }),
).toEqual({ remoteUrl: "git@example.com:org/repo.git" });
});

describe("buildThreadActionItems", () => {
it("orders threads by most recent activity and formats timestamps from updatedAt", () => {
vi.useFakeTimers();
Expand Down
22 changes: 21 additions & 1 deletion apps/web/src/components/CommandPalette.logic.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { type KeybindingCommand, type FilesystemBrowseEntry } from "@t3tools/contracts";
import {
type FilesystemBrowseEntry,
type KeybindingCommand,
type SourceControlCloneRepositoryInput,
type SourceControlRepositoryInfo,
} from "@t3tools/contracts";
import type { SidebarThreadSortOrder } from "@t3tools/contracts/settings";
import * as Arr from "effect/Array";
import * as Result from "effect/Result";
Expand Down Expand Up @@ -54,6 +59,21 @@ export interface CommandPaletteView {

export type CommandPaletteMode = "root" | "root-browse" | "submenu" | "submenu-browse";

export function getCloneSourceInput(input: {
repository: SourceControlRepositoryInfo | null;
remoteUrl: string;
}): Pick<SourceControlCloneRepositoryInput, "provider" | "repository" | "remoteUrl" | "protocol"> {
if (input.repository) {
return {
provider: input.repository.provider,
repository: input.repository.nameWithOwner,
protocol: "auto",
};
}

return { remoteUrl: input.remoteUrl };
}

export function filterBrowseEntries(input: {
browseEntries: ReadonlyArray<FilesystemBrowseEntry>;
browseFilterQuery: string;
Expand Down
5 changes: 3 additions & 2 deletions apps/web/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ import {
filterBrowseEntries,
filterCommandPaletteGroups,
getCommandPaletteInputPlaceholder,
getCloneSourceInput,
getCommandPaletteMode,
ITEM_ICON_CLASS,
RECENT_THREAD_LIMIT,
Expand Down Expand Up @@ -1282,7 +1283,7 @@ function OpenCommandPaletteDialog(props: {
source: addProjectCloneFlow.source,
repositoryInput: rawRepository,
repository,
remoteUrl: repository.sshUrl,
remoteUrl: repository.url,
});
setHighlightedItemValue(null);
setQuery(destinationPath);
Expand Down Expand Up @@ -1329,7 +1330,7 @@ function OpenCommandPaletteDialog(props: {
const cloneResult = await cloneRepository({
environmentId: addProjectCloneFlow.environmentId,
input: {
remoteUrl: addProjectCloneFlow.remoteUrl,
...getCloneSourceInput(addProjectCloneFlow),
destinationPath,
},
});
Expand Down
2 changes: 2 additions & 0 deletions packages/contracts/src/sourceControl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ export const SourceControlRepositoryCloneUrls = Schema.Struct({
nameWithOwner: TrimmedNonEmptyString,
url: TrimmedNonEmptyString,
sshUrl: TrimmedNonEmptyString,
preferredProtocol: Schema.optional(Schema.Literals(["ssh", "https"])),
});
export type SourceControlRepositoryCloneUrls = typeof SourceControlRepositoryCloneUrls.Type;

Expand All @@ -54,6 +55,7 @@ export const SourceControlRepositoryInfo = Schema.Struct({
nameWithOwner: TrimmedNonEmptyString,
url: TrimmedNonEmptyString,
sshUrl: TrimmedNonEmptyString,
preferredProtocol: Schema.optional(Schema.Literals(["ssh", "https"])),
});
export type SourceControlRepositoryInfo = typeof SourceControlRepositoryInfo.Type;

Expand Down
Loading