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
@@ -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",