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
+156 -1
View File
@@ -1,6 +1,6 @@
import equal from "fast-deep-equal";
import { v4 as uuidv4 } from "uuid";
import { stat } from "node:fs/promises";
import { lstat, mkdir, mkdtemp, rename, rm, stat } from "node:fs/promises";
import { basename, normalize, resolve, sep } from "path";
import { homedir } from "node:os";
import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities";
@@ -214,6 +214,7 @@ import {
toWorktreeRequestError,
toWorktreeWireError,
} from "./worktree-errors.js";
import { parseGitRemoteLocation } from "@getpaseo/protocol/git-remote";
import { type WorktreeConfig, createWorktree } from "../utils/worktree.js";
import { runGitCommand } from "../utils/run-git-command.js";
import { CreateAgentLifecycleDispatch } from "./agent/create-agent-lifecycle-dispatch.js";
@@ -1408,6 +1409,7 @@ export class Session {
this.dispatchAgentConfigMessage(msg) ??
this.dispatchCheckoutMessage(msg) ??
this.dispatchWorkspaceAndProjectMessage(msg) ??
this.dispatchWorkspaceFileMessage(msg) ??
this.dispatchProviderMessage(msg) ??
this.dispatchTerminalMessage(msg) ??
this.dispatchChatScheduleLoopMessage(msg) ??
@@ -1663,6 +1665,8 @@ export class Session {
return this.handleOpenProjectRequest(msg);
case "project.add.request":
return this.handleProjectAddRequest(msg);
case "workspace.github.clone.request":
return this.handleWorkspaceGithubCloneRequest(msg);
case "archive_workspace_request":
return this.handleArchiveWorkspaceRequest(msg);
case "project.remove.request":
@@ -1675,6 +1679,13 @@ export class Session {
return this.handleWorkspaceTitleSetRequest(msg.workspaceId, msg.title, msg.requestId);
case "workspace.pin.set.request":
return this.handleWorkspacePinSetRequest(msg.workspaceId, msg.pinned, msg.requestId);
default:
return undefined;
}
}
private dispatchWorkspaceFileMessage(msg: SessionInboundMessage): Promise<void> | undefined {
switch (msg.type) {
case "file_explorer_request":
return this.workspaceFilesSession.handleFileExplorerRequest(msg);
case "project_icon_request":
@@ -4773,6 +4784,99 @@ export class Session {
}
}
private async handleWorkspaceGithubCloneRequest(
request: Extract<SessionInboundMessage, { type: "workspace.github.clone.request" }>,
): Promise<void> {
let normalizedRepo = request.repo;
let checkoutPath: string | null = null;
try {
const repo = normalizeCloneRepository({
repo: request.repo,
cloneProtocol: request.cloneProtocol,
});
normalizedRepo = repo.displayName;
const targetParent = resolve(expandTilde(request.targetDirectory.trim()));
checkoutPath = resolve(targetParent, repo.name);
if (!this.isPathWithinRoot(targetParent, checkoutPath)) {
throw new Error("Resolved checkout path must stay inside the target directory");
}
await mkdir(targetParent, { recursive: true });
try {
await lstat(checkoutPath);
throw new Error(`Checkout path already exists: ${checkoutPath}`);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;
}
}
const cloneStagingPath = await mkdtemp(resolve(targetParent, ".paseo-clone-"));
try {
await runGitCommand(["clone", repo.cloneUrl, cloneStagingPath], {
cwd: targetParent,
timeout: 5 * 60 * 1000,
maxOutputBytes: 1024 * 1024,
logger: this.sessionLogger,
});
await rename(cloneStagingPath, checkoutPath);
} catch (error) {
await rm(cloneStagingPath, { recursive: true, force: true }).catch((cleanupError) => {
this.sessionLogger.warn(
{ err: cleanupError, cloneStagingPath },
"Failed to clean up partial GitHub clone",
);
});
throw error;
}
const workspace =
await this.workspaceProvisioning.findOrCreateWorkspaceForDirectory(checkoutPath);
await this.syncWorkspaceGitObserverForWorkspace(workspace);
const descriptor = await this.describeWorkspaceRecord(workspace);
await this.emitWorkspaceUpdateForWorkspaceId(workspace.workspaceId);
void this.workspaceGitService
.getSnapshot(workspace.cwd, {
force: true,
includeGitHub: true,
reason: "open_project",
})
.catch((error) => {
this.sessionLogger.warn(
{ err: error, cwd: workspace.cwd },
"Background snapshot refresh failed after workspace.github.clone",
);
});
this.emit({
type: "workspace.github.clone.response",
payload: {
requestId: request.requestId,
repo: repo.displayName,
checkoutPath,
workspace: descriptor,
error: null,
},
});
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to clone GitHub repo";
this.sessionLogger.error(
{ err: error, repo: request.repo, targetDirectory: request.targetDirectory },
"Failed to clone GitHub workspace",
);
this.emit({
type: "workspace.github.clone.response",
payload: {
requestId: request.requestId,
repo: normalizedRepo,
checkoutPath,
workspace: null,
error: message,
},
});
}
}
// Named accessor: the workspace descriptor builder and the git-watch test both read a workspace's
// scripts snapshot through here; the workspace-scripts module owns the payload assembly.
private buildWorkspaceScriptPayloadSnapshot(
@@ -5727,3 +5831,54 @@ export class Session {
this.workspaceGitObserver.dispose();
}
}
interface CloneRepositoryInput {
name: string;
displayName: string;
cloneUrl: string;
}
function normalizeCloneRepository(input: {
repo: string;
cloneProtocol?: "https" | "ssh";
}): CloneRepositoryInput {
const trimmed = input.repo.trim();
if (!trimmed) {
throw new Error("Repository is required");
}
const remote = parseGitRemoteLocation(trimmed);
if (remote) {
const segments = remote.path.split("/").filter(Boolean);
const name = segments.at(-1);
if (!name || !isValidGitHubRepoSegment(name)) {
throw new Error("Repository name contains invalid characters");
}
return { name, displayName: remote.path, cloneUrl: trimmed };
}
const [owner, rawName, ...extra] = trimmed.split("/");
if (!owner || !rawName || extra.length > 0) {
throw new Error("Repository must use owner/repo format or a git remote URL");
}
const name = rawName.endsWith(".git") ? rawName.slice(0, -4) : rawName;
if (!isValidGitHubRepoSegment(owner) || !isValidGitHubRepoSegment(name)) {
throw new Error("Repository contains invalid characters");
}
if (!input.cloneProtocol) {
throw new Error("Clone protocol is required for owner/repo repository names");
}
const cloneUrl =
input.cloneProtocol === "ssh"
? `git@github.com:${owner}/${name}.git`
: `https://github.com/${owner}/${name}.git`;
return {
name,
displayName: `${owner}/${name}`,
cloneUrl,
};
}
function isValidGitHubRepoSegment(value: string): boolean {
return /^[A-Za-z0-9._-]+$/u.test(value);
}
@@ -1264,6 +1264,8 @@ export class VoiceAssistantWebSocketServer {
providerSubagents: true,
// COMPAT(workspacePinning): added in v0.1.107, remove gate after 2027-01-12.
workspacePinning: true,
// COMPAT(workspaceGithubClone): added in v0.1.108, remove gate after 2027-01-13.
workspaceGithubClone: true,
},
};
}