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
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user