Skip to content

Commit f74cd73

Browse files
committed
Add GitHub clone flow
- Add GitHub URL parsing and clone dialog in the web app - Wire a new git.cloneRepository WebSocket/API path through server contracts
1 parent bfe2af6 commit f74cd73

12 files changed

Lines changed: 639 additions & 0 deletions

File tree

apps/server/src/git/Layers/GitCore.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { realpathSync } from "node:fs";
2+
13
import {
24
Cache,
35
Data,
@@ -34,6 +36,14 @@ import { decodeJsonResult } from "@okcode/shared/schemaJson";
3436
import { ProjectionSnapshotQuery } from "../../orchestration/Services/ProjectionSnapshotQuery.ts";
3537
import { resolveRuntimeEnvironment } from "../../runtimeEnvironment.ts";
3638

39+
function safeRealpath(value: string): string {
40+
try {
41+
return realpathSync(value);
42+
} catch {
43+
return value;
44+
}
45+
}
46+
3747
const DEFAULT_TIMEOUT_MS = 30_000;
3848
const DEFAULT_MAX_OUTPUT_BYTES = 1_000_000;
3949
const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15);
@@ -1850,6 +1860,37 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute"
18501860
),
18511861
);
18521862

1863+
const cloneRepository: GitCoreShape["cloneRepository"] = (input) =>
1864+
Effect.gen(function* () {
1865+
// Extract repo name from URL for the target directory name
1866+
const urlPath = input.url.replace(/\.git$/, "");
1867+
const repoName = urlPath.split("/").pop() ?? "repo";
1868+
const clonePath = path.join(input.targetDir, repoName);
1869+
1870+
const args = ["clone", input.url, clonePath];
1871+
if (input.branch) {
1872+
args.push("--branch", input.branch);
1873+
}
1874+
1875+
yield* executeGit("GitCore.cloneRepository", input.targetDir, args, {
1876+
timeoutMs: 5 * 60_000, // 5 minutes for large repos
1877+
fallbackErrorMessage: "git clone failed",
1878+
});
1879+
1880+
// Read the current branch from the cloned repo
1881+
const branchOutput = yield* runGitStdout("GitCore.cloneRepository.branch", clonePath, [
1882+
"rev-parse",
1883+
"--abbrev-ref",
1884+
"HEAD",
1885+
]);
1886+
const branch = branchOutput.trim() || "main";
1887+
1888+
// Resolve to real path in case of symlinks
1889+
const resolvedPath = safeRealpath(clonePath);
1890+
1891+
return { path: resolvedPath, branch };
1892+
});
1893+
18531894
return {
18541895
execute,
18551896
status,
@@ -1872,6 +1913,7 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute"
18721913
checkoutBranch,
18731914
initRepo,
18741915
listLocalBranchNames,
1916+
cloneRepository,
18751917
} satisfies GitCoreShape;
18761918
});
18771919

apps/server/src/git/Services/GitCore.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import { ServiceMap } from "effect";
1010
import type { Effect, Scope } from "effect";
1111
import type {
1212
GitCheckoutInput,
13+
GitCloneRepositoryInput,
14+
GitCloneRepositoryResult,
1315
GitCreateBranchInput,
1416
GitCreateWorktreeInput,
1517
GitCreateWorktreeResult,
@@ -267,6 +269,13 @@ export interface GitCoreShape {
267269
* List local branch names (short format).
268270
*/
269271
readonly listLocalBranchNames: (cwd: string) => Effect.Effect<string[], GitCommandError>;
272+
273+
/**
274+
* Clone a remote repository into a target directory.
275+
*/
276+
readonly cloneRepository: (
277+
input: GitCloneRepositoryInput,
278+
) => Effect.Effect<GitCloneRepositoryResult, GitCommandError>;
270279
}
271280

272281
/**

apps/server/src/wsServer.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1082,6 +1082,11 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
10821082
return yield* git.initRepo(body);
10831083
}
10841084

1085+
case WS_METHODS.gitCloneRepository: {
1086+
const body = stripRequestTag(request.body);
1087+
return yield* git.cloneRepository(body);
1088+
}
1089+
10851090
case WS_METHODS.terminalOpen: {
10861091
const body = stripRequestTag(request.body);
10871092
const snapshot = yield* projectionReadModelQuery.getSnapshot();

apps/web/src/components/ChatHomeEmptyState.tsx

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { useNavigate } from "@tanstack/react-router";
44
import {
55
FolderOpenIcon,
66
FolderIcon,
7+
GitBranchIcon,
78
GitMergeIcon,
89
GitPullRequestIcon,
910
SettingsIcon,
@@ -18,6 +19,7 @@ import { serverConfigQueryOptions } from "../lib/serverReactQuery";
1819
import { newCommandId, newProjectId } from "../lib/utils";
1920
import { readNativeApi } from "../nativeApi";
2021
import { useStore } from "../store";
22+
import { CloneRepositoryDialog } from "./CloneRepositoryDialog";
2123
import { sortProjectsForSidebar } from "./Sidebar.logic";
2224
import { ProviderSetupCard } from "./chat/ProviderSetupCard";
2325
import { Button } from "./ui/button";
@@ -34,6 +36,7 @@ export function ChatHomeEmptyState() {
3436
const threads = useStore((store) => store.threads);
3537
const { handleNewThread } = useHandleNewThread();
3638
const [isOpeningProject, setIsOpeningProject] = useState(false);
39+
const [cloneDialogOpen, setCloneDialogOpen] = useState(false);
3740

3841
const recentProjects = useMemo(
3942
() =>
@@ -113,6 +116,47 @@ export function ChatHomeEmptyState() {
113116
setIsOpeningProject(false);
114117
}, [appSettings.defaultThreadEnvMode, handleNewThread, isOpeningProject, projects]);
115118

119+
const handleCloned = useCallback(
120+
async (result: { path: string; branch: string; repoName: string }) => {
121+
const api = readNativeApi();
122+
if (!api) return;
123+
124+
const existingProject = projects.find((project) => project.cwd === result.path);
125+
if (existingProject) {
126+
await handleNewThread(existingProject.id, {
127+
envMode: appSettings.defaultThreadEnvMode,
128+
}).catch(() => undefined);
129+
return;
130+
}
131+
132+
try {
133+
const projectId = newProjectId();
134+
await api.orchestration.dispatchCommand({
135+
type: "project.create",
136+
commandId: newCommandId(),
137+
projectId,
138+
title: result.repoName,
139+
workspaceRoot: result.path,
140+
defaultModel: DEFAULT_MODEL_BY_PROVIDER.codex,
141+
createdAt: new Date().toISOString(),
142+
});
143+
await handleNewThread(projectId, {
144+
envMode: appSettings.defaultThreadEnvMode,
145+
}).catch(() => undefined);
146+
} catch (error) {
147+
toastManager.add({
148+
type: "error",
149+
title: "Failed to add project",
150+
description:
151+
error instanceof Error
152+
? error.message
153+
: "An unexpected error occurred while adding the project.",
154+
});
155+
}
156+
},
157+
[appSettings.defaultThreadEnvMode, handleNewThread, projects],
158+
);
159+
116160
const startLatestThread = useCallback(async () => {
117161
if (!latestProject) {
118162
await openProjectFolder();
@@ -178,6 +222,14 @@ export function ChatHomeEmptyState() {
178222
<FolderOpenIcon className="size-4" />
179223
{isOpeningProject ? "Opening…" : "Open project folder"}
180224
</Button>
225+
<Button
226+
variant="outline"
227+
className="justify-start gap-2"
228+
onClick={() => setCloneDialogOpen(true)}
229+
>
230+
<GitBranchIcon className="size-4" />
231+
Clone from GitHub
232+
</Button>
181233
<Button
182234
variant="outline"
183235
className="justify-start gap-2"
@@ -253,6 +305,12 @@ export function ChatHomeEmptyState() {
253305
)}
254306
</div>
255307
</div>
308+
309+
<CloneRepositoryDialog
310+
open={cloneDialogOpen}
311+
onOpenChange={setCloneDialogOpen}
312+
onCloned={handleCloned}
313+
/>
256314
</div>
257315
);
258316
}

0 commit comments

Comments
 (0)