diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index b59ed729..5f5bc947 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -1,3 +1,5 @@ +import { realpathSync } from "node:fs"; + import { Cache, Data, @@ -34,6 +36,14 @@ import { decodeJsonResult } from "@okcode/shared/schemaJson"; import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts"; import { resolveRuntimeEnvironment } from "../../runtimeEnvironment.ts"; +function safeRealpath(value: string): string { + try { + return realpathSync(value); + } catch { + return value; + } +} + const DEFAULT_TIMEOUT_MS = 30_000; const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000; const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15); @@ -1850,6 +1860,37 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute" ), ); + const cloneRepository: GitCoreShape["cloneRepository"] = (input) => + Effect.gen(function* () { + // Extract repo name from URL for the target directory name + const urlPath = input.url.replace(/\.git$/, ""); + const repoName = urlPath.split("/").pop() ?? "repo"; + const clonePath = path.join(input.targetDir, repoName); + + const args = ["clone", input.url, clonePath]; + if (input.branch) { + args.push("--branch", input.branch); + } + + yield* executeGit("GitCore.cloneRepository", input.targetDir, args, { + timeoutMs: 5 * 60_000, // 5 minutes for large repos + fallbackErrorMessage: "git clone failed", + }); + + // Read the current branch from the cloned repo + const branchOutput = yield* runGitStdout("GitCore.cloneRepository.branch", clonePath, [ + "rev-parse", + "--abbrev-ref", + "HEAD", + ]); + const branch = branchOutput.trim() || "main"; + + // Resolve to real path in case of symlinks + const resolvedPath = safeRealpath(clonePath); + + return { path: resolvedPath, branch }; + }); + return { execute, status, @@ -1872,6 +1913,7 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute" checkoutBranch, initRepo, listLocalBranchNames, + cloneRepository, } satisfies GitCoreShape; }); diff --git a/apps/server/src/git/Services/GitCore.ts b/apps/server/src/git/Services/GitCore.ts index 875d88df..20460c9e 100644 --- a/apps/server/src/git/Services/GitCore.ts +++ b/apps/server/src/git/Services/GitCore.ts @@ -10,6 +10,8 @@ import { ServiceMap } from "effect"; import type { Effect, Scope } from "effect"; import type { GitCheckoutInput, + GitCloneRepositoryInput, + GitCloneRepositoryResult, GitCreateBranchInput, GitCreateWorktreeInput, GitCreateWorktreeResult, @@ -267,6 +269,13 @@ export interface GitCoreShape { * List local branch names (short format). */ readonly listLocalBranchNames: (cwd: string) => Effect.Effect; + + /** + * Clone a remote repository into a target directory. + */ + readonly cloneRepository: ( + input: GitCloneRepositoryInput, + ) => Effect.Effect; } /** diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index a23170f2..a4503855 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -1082,6 +1082,11 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< return yield* git.initRepo(body); } + case WS_METHODS.gitCloneRepository: { + const body = stripRequestTag(request.body); + return yield* git.cloneRepository(body); + } + case WS_METHODS.terminalOpen: { const body = stripRequestTag(request.body); const snapshot = yield* projectionReadModelQuery.getSnapshot(); diff --git a/apps/web/src/components/ChatHomeEmptyState.tsx b/apps/web/src/components/ChatHomeEmptyState.tsx index 8ffbfd91..c2c0ad8d 100644 --- a/apps/web/src/components/ChatHomeEmptyState.tsx +++ b/apps/web/src/components/ChatHomeEmptyState.tsx @@ -4,6 +4,7 @@ import { useNavigate } from "@tanstack/react-router"; import { FolderOpenIcon, FolderIcon, + GitBranchIcon, GitMergeIcon, GitPullRequestIcon, SettingsIcon, @@ -18,6 +19,7 @@ import { serverConfigQueryOptions } from "../lib/serverReactQuery"; import { newCommandId, newProjectId } from "../lib/utils"; import { readNativeApi } from "../nativeApi"; import { useStore } from "../store"; +import { CloneRepositoryDialog } from "./CloneRepositoryDialog"; import { sortProjectsForSidebar } from "./Sidebar.logic"; import { ProviderSetupCard } from "./chat/ProviderSetupCard"; import { Button } from "./ui/button"; @@ -34,6 +36,7 @@ export function ChatHomeEmptyState() { const threads = useStore((store) => store.threads); const { handleNewThread } = useHandleNewThread(); const [isOpeningProject, setIsOpeningProject] = useState(false); + const [cloneDialogOpen, setCloneDialogOpen] = useState(false); const recentProjects = useMemo( () => @@ -113,6 +116,47 @@ export function ChatHomeEmptyState() { setIsOpeningProject(false); }, [appSettings.defaultThreadEnvMode, handleNewThread, isOpeningProject, projects]); + const handleCloned = useCallback( + async (result: { path: string; branch: string; repoName: string }) => { + const api = readNativeApi(); + if (!api) return; + + const existingProject = projects.find((project) => project.cwd === result.path); + if (existingProject) { + await handleNewThread(existingProject.id, { + envMode: appSettings.defaultThreadEnvMode, + }).catch(() => undefined); + return; + } + + try { + const projectId = newProjectId(); + await api.orchestration.dispatchCommand({ + type: "project.create", + commandId: newCommandId(), + projectId, + title: result.repoName, + workspaceRoot: result.path, + defaultModel: DEFAULT_MODEL_BY_PROVIDER.codex, + createdAt: new Date().toISOString(), + }); + await handleNewThread(projectId, { + envMode: appSettings.defaultThreadEnvMode, + }).catch(() => undefined); + } catch (error) { + toastManager.add({ + type: "error", + title: "Failed to add project", + description: + error instanceof Error + ? error.message + : "An unexpected error occurred while adding the project.", + }); + } + }, + [appSettings.defaultThreadEnvMode, handleNewThread, projects], + ); + const startLatestThread = useCallback(async () => { if (!latestProject) { await openProjectFolder(); @@ -178,6 +222,14 @@ export function ChatHomeEmptyState() { {isOpeningProject ? "Opening…" : "Open project folder"} + + + {parsed && targetDir ? ( +

+ Will clone to: {targetDir}/{parsed.repo} +

+ ) : null} + + + {cloneMutation.isPending ? ( +
+ + Cloning repository... +
+ ) : null} + + {errorMessage ?

{errorMessage}

: null} + + + + + + + + ); +} diff --git a/apps/web/src/githubRepositoryUrl.test.ts b/apps/web/src/githubRepositoryUrl.test.ts new file mode 100644 index 00000000..ec60564b --- /dev/null +++ b/apps/web/src/githubRepositoryUrl.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from "vitest"; +import { parseGitHubRepositoryUrl } from "./githubRepositoryUrl"; + +describe("parseGitHubRepositoryUrl", () => { + it("returns null for empty input", () => { + expect(parseGitHubRepositoryUrl("")).toBeNull(); + expect(parseGitHubRepositoryUrl(" ")).toBeNull(); + }); + + it("returns null for invalid input", () => { + expect(parseGitHubRepositoryUrl("not a url")).toBeNull(); + expect(parseGitHubRepositoryUrl("https://google.com")).toBeNull(); + expect(parseGitHubRepositoryUrl("https://gitlab.com/owner/repo")).toBeNull(); + }); + + it("parses basic HTTPS URL", () => { + const result = parseGitHubRepositoryUrl("https://github.com/owner/repo"); + expect(result).toEqual({ + cloneUrl: "https://github.com/owner/repo.git", + owner: "owner", + repo: "repo", + branch: null, + }); + }); + + it("parses HTTPS URL with .git suffix", () => { + const result = parseGitHubRepositoryUrl("https://github.com/owner/repo.git"); + expect(result).toEqual({ + cloneUrl: "https://github.com/owner/repo.git", + owner: "owner", + repo: "repo", + branch: null, + }); + }); + + it("parses HTTPS URL with tree/branch path", () => { + const result = parseGitHubRepositoryUrl("https://github.com/owner/repo/tree/feature-branch"); + expect(result).toEqual({ + cloneUrl: "https://github.com/owner/repo.git", + owner: "owner", + repo: "repo", + branch: "feature-branch", + }); + }); + + it("parses HTTPS URL with tree/branch/path", () => { + const result = parseGitHubRepositoryUrl( + "https://github.com/owner/repo/tree/main/src/components", + ); + expect(result).toEqual({ + cloneUrl: "https://github.com/owner/repo.git", + owner: "owner", + repo: "repo", + branch: "main", + }); + }); + + it("parses HTTPS URL with blob path", () => { + const result = parseGitHubRepositoryUrl("https://github.com/owner/repo/blob/main/README.md"); + expect(result).toEqual({ + cloneUrl: "https://github.com/owner/repo.git", + owner: "owner", + repo: "repo", + branch: "main", + }); + }); + + it("parses SSH URL", () => { + const result = parseGitHubRepositoryUrl("git@github.com:owner/repo.git"); + expect(result).toEqual({ + cloneUrl: "https://github.com/owner/repo.git", + owner: "owner", + repo: "repo", + branch: null, + }); + }); + + it("parses SSH URL without .git suffix", () => { + const result = parseGitHubRepositoryUrl("git@github.com:owner/repo"); + expect(result).toEqual({ + cloneUrl: "https://github.com/owner/repo.git", + owner: "owner", + repo: "repo", + branch: null, + }); + }); + + it("parses owner/repo shorthand", () => { + const result = parseGitHubRepositoryUrl("owner/repo"); + expect(result).toEqual({ + cloneUrl: "https://github.com/owner/repo.git", + owner: "owner", + repo: "repo", + branch: null, + }); + }); + + it("trims whitespace", () => { + const result = parseGitHubRepositoryUrl(" https://github.com/owner/repo "); + expect(result).toEqual({ + cloneUrl: "https://github.com/owner/repo.git", + owner: "owner", + repo: "repo", + branch: null, + }); + }); + + it("handles URL with query params", () => { + const result = parseGitHubRepositoryUrl("https://github.com/owner/repo?tab=readme"); + expect(result).toEqual({ + cloneUrl: "https://github.com/owner/repo.git", + owner: "owner", + repo: "repo", + branch: null, + }); + }); + + it("handles URL with fragment", () => { + const result = parseGitHubRepositoryUrl("https://github.com/owner/repo#readme"); + expect(result).toEqual({ + cloneUrl: "https://github.com/owner/repo.git", + owner: "owner", + repo: "repo", + branch: null, + }); + }); + + it("is case-insensitive for the domain", () => { + const result = parseGitHubRepositoryUrl("https://GitHub.com/Owner/Repo"); + expect(result).toEqual({ + cloneUrl: "https://github.com/Owner/Repo.git", + owner: "Owner", + repo: "Repo", + branch: null, + }); + }); + + it("handles repos with dots and hyphens", () => { + const result = parseGitHubRepositoryUrl("https://github.com/my-org/my.repo-name"); + expect(result).toEqual({ + cloneUrl: "https://github.com/my-org/my.repo-name.git", + owner: "my-org", + repo: "my.repo-name", + branch: null, + }); + }); + + it("handles shorthand with dots and hyphens", () => { + const result = parseGitHubRepositoryUrl("my-org/my.repo"); + expect(result).toEqual({ + cloneUrl: "https://github.com/my-org/my.repo.git", + owner: "my-org", + repo: "my.repo", + branch: null, + }); + }); +}); diff --git a/apps/web/src/githubRepositoryUrl.ts b/apps/web/src/githubRepositoryUrl.ts new file mode 100644 index 00000000..95d06676 --- /dev/null +++ b/apps/web/src/githubRepositoryUrl.ts @@ -0,0 +1,80 @@ +/** + * Parses a GitHub repository URL into its components. + * + * Supports: + * - `https://github.com/owner/repo` + * - `https://github.com/owner/repo.git` + * - `https://github.com/owner/repo/tree/branch` + * - `https://github.com/owner/repo/tree/branch/path/to/dir` + * - `git@github.com:owner/repo.git` + * - `owner/repo` (shorthand) + */ + +export interface ParsedGitHubUrl { + /** Full HTTPS clone URL */ + cloneUrl: string; + /** Repository owner (user or org) */ + owner: string; + /** Repository name (without .git suffix) */ + repo: string; + /** Branch name if specified in the URL */ + branch: string | null; +} + +const GITHUB_HTTPS_URL_PATTERN = + /^https:\/\/github\.com\/([^/\s]+)\/([^/\s]+?)(?:\.git)?(?:\/(?:tree|blob)\/([^?\s]+?))?(?:[?#].*)?$/i; + +const GITHUB_SSH_URL_PATTERN = /^git@github\.com:([^/\s]+)\/([^/\s]+?)(?:\.git)?$/i; + +const GITHUB_SHORTHAND_PATTERN = /^([a-zA-Z0-9._-]+)\/([a-zA-Z0-9._-]+)$/; + +export function parseGitHubRepositoryUrl(input: string): ParsedGitHubUrl | null { + const trimmed = input.trim(); + if (trimmed.length === 0) { + return null; + } + + // Try HTTPS URL + const httpsMatch = GITHUB_HTTPS_URL_PATTERN.exec(trimmed); + if (httpsMatch) { + const owner = httpsMatch[1]!; + const repo = httpsMatch[2]!; + const branchAndPath = httpsMatch[3]?.trim() ?? null; + // The branch is the first segment of the tree/blob path + const branch = branchAndPath?.split("/")[0] ?? null; + return { + cloneUrl: `https://github.com/${owner}/${repo}.git`, + owner, + repo, + branch: branch && branch.length > 0 ? branch : null, + }; + } + + // Try SSH URL + const sshMatch = GITHUB_SSH_URL_PATTERN.exec(trimmed); + if (sshMatch) { + const owner = sshMatch[1]!; + const repo = sshMatch[2]!; + return { + cloneUrl: `https://github.com/${owner}/${repo}.git`, + owner, + repo, + branch: null, + }; + } + + // Try shorthand (owner/repo) + const shorthandMatch = GITHUB_SHORTHAND_PATTERN.exec(trimmed); + if (shorthandMatch) { + const owner = shorthandMatch[1]!; + const repo = shorthandMatch[2]!; + return { + cloneUrl: `https://github.com/${owner}/${repo}.git`, + owner, + repo, + branch: null, + }; + } + + return null; +} diff --git a/apps/web/src/lib/gitReactQuery.ts b/apps/web/src/lib/gitReactQuery.ts index 9df28562..f06ddac7 100644 --- a/apps/web/src/lib/gitReactQuery.ts +++ b/apps/web/src/lib/gitReactQuery.ts @@ -231,6 +231,31 @@ export function gitRemoveWorktreeMutationOptions(input: { queryClient: QueryClie }); } +export function gitCloneRepositoryMutationOptions(input: { queryClient: QueryClient }) { + return mutationOptions({ + mutationKey: ["git", "mutation", "clone-repository"] as const, + mutationFn: async ({ + url, + targetDir, + branch, + }: { + url: string; + targetDir: string; + branch?: string; + }) => { + const api = ensureNativeApi(); + return api.git.cloneRepository({ + url, + targetDir, + ...(branch ? { branch } : {}), + }); + }, + onSettled: async () => { + await invalidateGitQueries(input.queryClient); + }, + }); +} + export function gitPreparePullRequestThreadMutationOptions(input: { cwd: string | null; queryClient: QueryClient; diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index 4c706d36..b319b3b0 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -215,6 +215,8 @@ export function createWsNativeApi(): NativeApi { }, }, git: { + cloneRepository: (input) => + transport.request(WS_METHODS.gitCloneRepository, input, { timeoutMs: null }), pull: (input) => transport.request(WS_METHODS.gitPull, input), status: (input) => transport.request(WS_METHODS.gitStatus, input), runStackedAction: (input) => diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index a3aa63b0..51e21ae8 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -191,6 +191,24 @@ export const GitInitInput = Schema.Struct({ }); export type GitInitInput = typeof GitInitInput.Type; +export const GitCloneRepositoryInput = Schema.Struct({ + /** HTTPS clone URL (e.g. `https://github.com/owner/repo.git`) */ + url: TrimmedNonEmptyStringSchema, + /** Absolute path to the parent directory where the repo will be cloned */ + targetDir: TrimmedNonEmptyStringSchema, + /** Optional branch to checkout after cloning */ + branch: Schema.optional(TrimmedNonEmptyStringSchema), +}); +export type GitCloneRepositoryInput = typeof GitCloneRepositoryInput.Type; + +export const GitCloneRepositoryResult = Schema.Struct({ + /** Absolute path to the cloned repository */ + path: TrimmedNonEmptyStringSchema, + /** The checked-out branch after cloning */ + branch: TrimmedNonEmptyStringSchema, +}); +export type GitCloneRepositoryResult = typeof GitCloneRepositoryResult.Type; + // RPC Results const GitStatusPr = Schema.Struct({ diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index e776622d..04bf0e32 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1,6 +1,8 @@ import type { GitCheckoutInput, GitActionProgressEvent, + GitCloneRepositoryInput, + GitCloneRepositoryResult, GitCreateBranchInput, GitPreparePullRequestThreadInput, GitPreparePullRequestThreadResult, @@ -268,6 +270,8 @@ export interface NativeApi { openExternal: (url: string) => Promise; }; git: { + // Clone + cloneRepository: (input: GitCloneRepositoryInput) => Promise; // Existing branch/worktree API listBranches: (input: GitListBranchesInput) => Promise; createWorktree: (input: GitCreateWorktreeInput) => Promise; diff --git a/packages/contracts/src/ws.ts b/packages/contracts/src/ws.ts index 5ae01262..9f874443 100644 --- a/packages/contracts/src/ws.ts +++ b/packages/contracts/src/ws.ts @@ -14,6 +14,7 @@ import { import { GitActionProgressEvent, GitCheckoutInput, + GitCloneRepositoryInput, GitCreateBranchInput, GitPreparePullRequestThreadInput, GitCreateWorktreeInput, @@ -99,6 +100,7 @@ export const WS_METHODS = { gitCreateBranch: "git.createBranch", gitCheckout: "git.checkout", gitInit: "git.init", + gitCloneRepository: "git.cloneRepository", gitResolvePullRequest: "git.resolvePullRequest", gitPreparePullRequestThread: "git.preparePullRequestThread", gitListPullRequests: "git.listPullRequests", @@ -189,6 +191,7 @@ const WebSocketRequestBody = Schema.Union([ tagRequestBody(WS_METHODS.gitCreateBranch, GitCreateBranchInput), tagRequestBody(WS_METHODS.gitCheckout, GitCheckoutInput), tagRequestBody(WS_METHODS.gitInit, GitInitInput), + tagRequestBody(WS_METHODS.gitCloneRepository, GitCloneRepositoryInput), tagRequestBody(WS_METHODS.gitResolvePullRequest, GitPullRequestRefInput), tagRequestBody(WS_METHODS.gitPreparePullRequestThread, GitPreparePullRequestThreadInput), tagRequestBody(WS_METHODS.gitListPullRequests, GitListPullRequestsInput),