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:
co-authored by
Mohamed Boudra
parent
bac302626f
commit
218097b7cc
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user