Skip to content

Commit 26fe284

Browse files
authored
Add GitHub clone flow (#95)
- Add GitHub URL parsing and clone dialog in the web app - Wire a new git.cloneRepository WebSocket/API path through server contracts
1 parent 168df73 commit 26fe284

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);
@@ -1944,6 +1954,37 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute"
19441954
),
19451955
);
19461956

1957+
const cloneRepository: GitCoreShape["cloneRepository"] = (input) =>
1958+
Effect.gen(function* () {
1959+
// Extract repo name from URL for the target directory name
1960+
const urlPath = input.url.replace(/\.git$/, "");
1961+
const repoName = urlPath.split("/").pop() ?? "repo";
1962+
const clonePath = path.join(input.targetDir, repoName);
1963+
1964+
const args = ["clone", input.url, clonePath];
1965+
if (input.branch) {
1966+
args.push("--branch", input.branch);
1967+
}
1968+
1969+
yield* executeGit("GitCore.cloneRepository", input.targetDir, args, {
1970+
timeoutMs: 5 * 60_000, // 5 minutes for large repos
1971+
fallbackErrorMessage: "git clone failed",
1972+
});
1973+
1974+
// Read the current branch from the cloned repo
1975+
const branchOutput = yield* runGitStdout("GitCore.cloneRepository.branch", clonePath, [
1976+
"rev-parse",
1977+
"--abbrev-ref",
1978+
"HEAD",
1979+
]);
1980+
const branch = branchOutput.trim() || "main";
1981+
1982+
// Resolve to real path in case of symlinks
1983+
const resolvedPath = safeRealpath(clonePath);
1984+
1985+
return { path: resolvedPath, branch };
1986+
});
1987+
19471988
return {
19481989
execute,
19491990
status,
@@ -1966,6 +2007,7 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute"
19662007
checkoutBranch,
19672008
initRepo,
19682009
listLocalBranchNames,
2010+
cloneRepository,
19692011
} satisfies GitCoreShape;
19702012
});
19712013

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
@@ -1130,6 +1130,11 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return<
11301130
return yield* git.initRepo(body);
11311131
}
11321132

1133+
case WS_METHODS.gitCloneRepository: {
1134+
const body = stripRequestTag(request.body);
1135+
return yield* git.cloneRepository(body);
1136+
}
1137+
11331138
case WS_METHODS.terminalOpen: {
11341139
const body = stripRequestTag(request.body);
11351140
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)