feat: clone GitHub repo into a workspace (#1331)

* feat: clone GitHub repo into a workspace

Add an end-to-end "clone a GitHub repo and register it as a Paseo
workspace" flow: a new workspace.github.clone RPC, daemon handler,
client method, CLI `paseo clone` command, and a GitHub-repo mode in
the project picker modal. Gated behind the workspaceGithubClone
server capability flag.

- protocol: workspace.github.clone request/response schemas + feature flag
- server: handleWorkspaceGithubCloneRequest, normalizeCloneRepository
- client: DaemonClient.cloneGithubWorkspace
- cli: `paseo clone <repo> --dir <path> [--protocol https|ssh]`
- app: GitHub-repo mode, clone-protocol picker, error surfacing

Review fixes:
- CLI clone now checks the workspaceGithubClone capability and fails
  fast with a clear "update the host" error instead of hanging for the
  full request timeout against an older daemon.
- Replace the duplicated client-side URL-detection regex (app + CLI)
  with a shared isCompleteGitRemote() in @getpaseo/protocol/git-remote,
  backed by parseGitRemoteLocation so clients classify remotes
  identically to the daemon (fixes confusing errors for git://, ftp://,
  file:// inputs).
- Add git-remote.test.ts covering the shared classifier.

* Fix GitHub clone failure handling

---------

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
Matt Cowger
2026-07-13 11:15:40 +00:00
committed by GitHub
co-authored by Mohamed Boudra
parent bac302626f
commit 218097b7cc
15 changed files with 1223 additions and 78 deletions
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import { isCompleteGitRemote, parseGitRemoteLocation } from "./git-remote.js";
describe("isCompleteGitRemote", () => {
it("treats supported URLs and scp-like addresses as complete remotes", () => {
expect(isCompleteGitRemote("https://github.com/owner/repo")).toBe(true);
expect(isCompleteGitRemote("http://internal/owner/repo.git")).toBe(true);
expect(isCompleteGitRemote("ssh://git@github.com/owner/repo")).toBe(true);
expect(isCompleteGitRemote("git@github.com:owner/repo.git")).toBe(true);
expect(isCompleteGitRemote(" https://github.com/owner/repo ")).toBe(true);
});
it("treats owner/repo shorthand as incomplete (needs a clone protocol)", () => {
expect(isCompleteGitRemote("owner/repo")).toBe(false);
expect(isCompleteGitRemote("owner/repo.git")).toBe(false);
expect(isCompleteGitRemote("")).toBe(false);
});
it("rejects schemes the daemon's parser does not accept, so clients agree with the server", () => {
// The old client-side regex matched any `scheme://`, classifying these as
// complete URLs while the daemon (parseGitRemoteLocation) rejected them —
// producing a confusing "use owner/repo format" error. The shared helper
// must classify them identically to the daemon.
for (const repo of ["git://github.com/owner/repo", "ftp://host/repo", "file:///tmp/repo"]) {
expect(isCompleteGitRemote(repo)).toBe(false);
expect(parseGitRemoteLocation(repo)).toBeNull();
}
});
});
+13
View File
@@ -24,6 +24,19 @@ export function parseGitHubRemoteUrl(remoteUrl: string): GitHubRemoteIdentity |
return parseGitHubRemoteIdentity(location.path);
}
/**
* Whether `repo` is already a complete git remote (a URL or scp-like address)
* rather than `owner/repo` shorthand that still needs a clone protocol picked.
*
* Clients (app + CLI) and the daemon must agree on this classification: the
* daemon treats `parseGitRemoteLocation(repo) !== null` as "complete remote"
* and everything else as shorthand, so reuse the same parser here instead of a
* separate regex that would drift (e.g. accepting `git://` the parser rejects).
*/
export function isCompleteGitRemote(repo: string): boolean {
return parseGitRemoteLocation(repo) !== null;
}
export function parseGitRemoteLocation(remoteUrl: string): GitRemoteLocation | null {
const trimmed = remoteUrl.trim();
if (!trimmed) return null;
+30
View File
@@ -1794,6 +1794,18 @@ export const ProjectAddRequestSchema = z.object({
requestId: z.string(),
});
// Smallest shorthand repo path is "a/b": owner, slash, repository.
const MIN_REPOSITORY_PATH_LENGTH = 3;
export const WorkspaceGithubCloneProtocolSchema = z.enum(["https", "ssh"]);
export const WorkspaceGithubCloneRequestSchema = z.object({
type: z.literal("workspace.github.clone.request"),
repo: z.string().trim().min(MIN_REPOSITORY_PATH_LENGTH),
cloneProtocol: WorkspaceGithubCloneProtocolSchema.optional(),
targetDirectory: z.string().trim().min(1),
requestId: z.string(),
});
export const ArchiveWorkspaceRequestSchema = z.object({
type: z.literal("archive_workspace_request"),
workspaceId: z.string(),
@@ -2178,6 +2190,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [
LegacyOpenInEditorRequestSchema,
OpenProjectRequestSchema,
ProjectAddRequestSchema,
WorkspaceGithubCloneRequestSchema,
ArchiveWorkspaceRequestSchema,
WorkspaceCreateRequestSchema,
WorkspaceClearAttentionRequestSchema,
@@ -2420,6 +2433,8 @@ export const ServerInfoStatusPayloadSchema = z
providerSubagents: z.boolean().optional(),
// COMPAT(workspacePinning): added in v0.1.107, remove gate after 2027-01-12.
workspacePinning: z.boolean().optional(),
// COMPAT(workspaceGithubClone): added in v0.1.108, remove gate after 2027-01-13.
workspaceGithubClone: z.boolean().optional(),
})
.optional(),
})
@@ -2918,6 +2933,17 @@ export const ProjectAddResponseSchema = z.object({
}),
});
export const WorkspaceGithubCloneResponseSchema = z.object({
type: z.literal("workspace.github.clone.response"),
payload: z.object({
requestId: z.string(),
repo: z.string().trim().min(MIN_REPOSITORY_PATH_LENGTH),
checkoutPath: z.string().nullable(),
workspace: WorkspaceDescriptorPayloadSchema.nullable(),
error: z.string().nullable(),
}),
});
export const StartWorkspaceScriptResponseMessageSchema = z.object({
type: z.literal("start_workspace_script_response"),
payload: z.object({
@@ -4338,6 +4364,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [
FetchWorkspacesResponseMessageSchema,
ProjectAddResponseSchema,
OpenProjectResponseMessageSchema,
WorkspaceGithubCloneResponseSchema,
StartWorkspaceScriptResponseMessageSchema,
LegacyListAvailableEditorsResponseMessageSchema,
LegacyOpenInEditorResponseMessageSchema,
@@ -4497,6 +4524,7 @@ export type FetchWorkspacesResponseMessage = z.infer<typeof FetchWorkspacesRespo
export type ProjectAddResponse = z.infer<typeof ProjectAddResponseSchema>;
export type ScriptStatusUpdateMessage = z.infer<typeof ScriptStatusUpdateMessageSchema>;
export type OpenProjectResponseMessage = z.infer<typeof OpenProjectResponseMessageSchema>;
export type WorkspaceGithubCloneResponse = z.infer<typeof WorkspaceGithubCloneResponseSchema>;
export type StartWorkspaceScriptResponseMessage = z.infer<
typeof StartWorkspaceScriptResponseMessageSchema
>;
@@ -4745,6 +4773,8 @@ export type LegacyListAvailableEditorsRequest = z.infer<
export type LegacyOpenInEditorRequest = z.infer<typeof LegacyOpenInEditorRequestSchema>;
export type OpenProjectRequest = z.infer<typeof OpenProjectRequestSchema>;
export type ProjectAddRequest = z.infer<typeof ProjectAddRequestSchema>;
export type WorkspaceGithubCloneRequest = z.infer<typeof WorkspaceGithubCloneRequestSchema>;
export type WorkspaceGithubCloneProtocol = z.infer<typeof WorkspaceGithubCloneProtocolSchema>;
export type ArchiveWorkspaceRequest = z.infer<typeof ArchiveWorkspaceRequestSchema>;
export type WorkspaceClearAttentionRequest = z.infer<typeof WorkspaceClearAttentionRequestSchema>;
export type FileExplorerRequest = z.infer<typeof FileExplorerRequestSchema>;
@@ -286,6 +286,67 @@ describe("workspace message schemas", () => {
expect(parsed.type).toBe("open_project_request");
});
test("parses workspace GitHub clone request and response repo paths", () => {
const request = SessionInboundMessageSchema.parse({
type: "workspace.github.clone.request",
repo: "a/b",
cloneProtocol: "https",
targetDirectory: "~/workspace",
requestId: "req-clone",
});
const response = SessionOutboundMessageSchema.parse({
type: "workspace.github.clone.response",
payload: {
requestId: "req-clone",
repo: "a/b",
checkoutPath: "/tmp/b",
workspace: null,
error: null,
},
});
expect(request.type).toBe("workspace.github.clone.request");
if (request.type !== "workspace.github.clone.request") {
throw new Error("expected workspace.github.clone.request");
}
expect(request.cloneProtocol).toBe("https");
expect(response.type).toBe("workspace.github.clone.response");
});
test("rejects invalid workspace GitHub clone protocols", () => {
const request = SessionInboundMessageSchema.safeParse({
type: "workspace.github.clone.request",
repo: "a/b",
cloneProtocol: "ftp",
targetDirectory: "~/workspace",
requestId: "req-clone",
});
expect(request.success).toBe(false);
});
test("rejects workspace GitHub clone repo paths shorter than owner slash repo", () => {
const request = SessionInboundMessageSchema.safeParse({
type: "workspace.github.clone.request",
repo: "ab",
targetDirectory: "~/workspace",
requestId: "req-clone",
});
const response = SessionOutboundMessageSchema.safeParse({
type: "workspace.github.clone.response",
payload: {
requestId: "req-clone",
repo: "ab",
checkoutPath: null,
workspace: null,
error: "failed",
},
});
expect(request.success).toBe(false);
expect(response.success).toBe(false);
});
test("parses legacy editor RPC messages for compatibility", () => {
const listRequest = SessionInboundMessageSchema.parse({
type: "list_available_editors_request",