From cae67c05e207790ac7aff9bf1a81bc40158eed30 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Fri, 6 Mar 2026 23:56:09 +0530 Subject: [PATCH] feat: add project git init api --- packages/opencode/src/project/instance.ts | 78 ++++++++--- packages/opencode/src/project/project.ts | 15 +++ .../opencode/src/server/routes/project.ts | 35 +++++ .../test/server/project-init-git.test.ts | 123 ++++++++++++++++++ packages/sdk/js/src/v2/gen/sdk.gen.ts | 31 +++++ packages/sdk/js/src/v2/gen/types.gen.ts | 19 +++ 6 files changed, 280 insertions(+), 21 deletions(-) create mode 100644 packages/opencode/test/server/project-init-git.test.ts diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts index 98031f18d3f..59a896e77bc 100644 --- a/packages/opencode/src/project/instance.ts +++ b/packages/opencode/src/project/instance.ts @@ -18,24 +18,60 @@ const disposal = { all: undefined as Promise | undefined, } +function emit(directory: string) { + GlobalBus.emit("event", { + directory, + payload: { + type: "server.instance.disposed", + properties: { + directory, + }, + }, + }) +} + +function boot(input: { directory: string; init?: () => Promise; project?: Project.Info; worktree?: string }) { + return iife(async () => { + const ctx = + input.project && input.worktree + ? { + directory: input.directory, + worktree: input.worktree, + project: input.project, + } + : await Project.fromDirectory(input.directory).then(({ project, sandbox }) => ({ + directory: input.directory, + worktree: sandbox, + project, + })) + await context.provide(ctx, async () => { + await input.init?.() + }) + return ctx + }) +} + +function track(directory: string, next: Promise) { + const task = next.catch((error) => { + if (cache.get(directory) === task) cache.delete(directory) + throw error + }) + cache.set(directory, task) + return task +} + export const Instance = { async provide(input: { directory: string; init?: () => Promise; fn: () => R }): Promise { let existing = cache.get(input.directory) if (!existing) { Log.Default.info("creating instance", { directory: input.directory }) - existing = iife(async () => { - const { project, sandbox } = await Project.fromDirectory(input.directory) - const ctx = { + existing = track( + input.directory, + boot({ directory: input.directory, - worktree: sandbox, - project, - } - await context.provide(ctx, async () => { - await input.init?.() - }) - return ctx - }) - cache.set(input.directory, existing) + init: input.init, + }), + ) } const ctx = await existing return context.provide(ctx, async () => { @@ -66,19 +102,19 @@ export const Instance = { state(init: () => S, dispose?: (state: Awaited) => Promise): () => S { return State.create(() => Instance.directory, init, dispose) }, + async reload(input: { directory: string; init?: () => Promise; project?: Project.Info; worktree?: string }) { + Log.Default.info("reloading instance", { directory: input.directory }) + await State.dispose(input.directory) + cache.delete(input.directory) + const next = track(input.directory, boot(input)) + emit(input.directory) + return await next + }, async dispose() { Log.Default.info("disposing instance", { directory: Instance.directory }) await State.dispose(Instance.directory) cache.delete(Instance.directory) - GlobalBus.emit("event", { - directory: Instance.directory, - payload: { - type: "server.instance.disposed", - properties: { - directory: Instance.directory, - }, - }, - }) + emit(Instance.directory) }, async disposeAll() { if (disposal.all) return disposal.all diff --git a/packages/opencode/src/project/project.ts b/packages/opencode/src/project/project.ts index 9cc12a0a4fc..e1fff1a1470 100644 --- a/packages/opencode/src/project/project.ts +++ b/packages/opencode/src/project/project.ts @@ -347,6 +347,21 @@ export namespace Project { return fromRow(row) } + export async function initGit(input: { directory: string; project: Info }) { + if (input.project.vcs === "git") return input.project + if (!which("git")) throw new Error("Git is not installed") + + const result = await git(["init", "--quiet"], { + cwd: input.directory, + }) + if (result.exitCode !== 0) { + const text = result.stderr.toString().trim() || result.text().trim() + throw new Error(text || "Failed to initialize git repository") + } + + return (await fromDirectory(input.directory)).project + } + export const update = fn( z.object({ projectID: z.string(), diff --git a/packages/opencode/src/server/routes/project.ts b/packages/opencode/src/server/routes/project.ts index 81092284de6..85314df9371 100644 --- a/packages/opencode/src/server/routes/project.ts +++ b/packages/opencode/src/server/routes/project.ts @@ -6,6 +6,7 @@ import { Project } from "../../project/project" import z from "zod" import { errors } from "../error" import { lazy } from "../../util/lazy" +import { InstanceBootstrap } from "../../project/bootstrap" export const ProjectRoutes = lazy(() => new Hono() @@ -52,6 +53,40 @@ export const ProjectRoutes = lazy(() => return c.json(Instance.project) }, ) + .post( + "/git/init", + describeRoute({ + summary: "Initialize git repository", + description: "Create a git repository for the current project and return the refreshed project info.", + operationId: "project.initGit", + responses: { + 200: { + description: "Project information after git initialization", + content: { + "application/json": { + schema: resolver(Project.Info), + }, + }, + }, + }, + }), + async (c) => { + const dir = Instance.directory + const prev = Instance.project + const next = await Project.initGit({ + directory: dir, + project: prev, + }) + if (next.id === prev.id && next.vcs === prev.vcs && next.worktree === prev.worktree) return c.json(next) + await Instance.reload({ + directory: dir, + worktree: dir, + project: next, + init: InstanceBootstrap, + }) + return c.json(next) + }, + ) .patch( "/:projectID", describeRoute({ diff --git a/packages/opencode/test/server/project-init-git.test.ts b/packages/opencode/test/server/project-init-git.test.ts new file mode 100644 index 00000000000..a200cffa194 --- /dev/null +++ b/packages/opencode/test/server/project-init-git.test.ts @@ -0,0 +1,123 @@ +import { afterEach, describe, expect, spyOn, test } from "bun:test" +import path from "path" +import { GlobalBus } from "../../src/bus/global" +import { Snapshot } from "../../src/snapshot" +import { InstanceBootstrap } from "../../src/project/bootstrap" +import { Instance } from "../../src/project/instance" +import { Server } from "../../src/server/server" +import { Filesystem } from "../../src/util/filesystem" +import { Log } from "../../src/util/log" +import { resetDatabase } from "../fixture/db" +import { tmpdir } from "../fixture/fixture" + +Log.init({ print: false }) + +afterEach(async () => { + await resetDatabase() +}) + +describe("project.initGit endpoint", () => { + test("initializes git and reloads immediately", async () => { + await using tmp = await tmpdir() + const app = Server.App() + const seen: { directory?: string; payload: { type: string } }[] = [] + const fn = (evt: { directory?: string; payload: { type: string } }) => { + seen.push(evt) + } + const reload = Instance.reload + const reloadSpy = spyOn(Instance, "reload").mockImplementation((input) => reload(input)) + GlobalBus.on("event", fn) + + try { + const init = await app.request("/project/git/init", { + method: "POST", + headers: { + "x-opencode-directory": tmp.path, + }, + }) + const body = await init.json() + expect(init.status).toBe(200) + expect(body).toMatchObject({ + id: "global", + vcs: "git", + worktree: tmp.path, + }) + expect(reloadSpy).toHaveBeenCalledTimes(1) + expect(reloadSpy.mock.calls[0]?.[0]?.init).toBe(InstanceBootstrap) + expect(seen.some((evt) => evt.directory === tmp.path && evt.payload.type === "server.instance.disposed")).toBe( + true, + ) + expect(await Filesystem.exists(path.join(tmp.path, ".git", "opencode"))).toBe(false) + + const current = await app.request("/project/current", { + headers: { + "x-opencode-directory": tmp.path, + }, + }) + expect(current.status).toBe(200) + expect(await current.json()).toMatchObject({ + id: "global", + vcs: "git", + worktree: tmp.path, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + expect(await Snapshot.track()).toBeTruthy() + }, + }) + } finally { + reloadSpy.mockRestore() + GlobalBus.off("event", fn) + } + }) + + test("does not reload again when the project is already git", async () => { + await using tmp = await tmpdir() + const app = Server.App() + const seen: { directory?: string; payload: { type: string } }[] = [] + const fn = (evt: { directory?: string; payload: { type: string } }) => { + seen.push(evt) + } + const reload = Instance.reload + const reloadSpy = spyOn(Instance, "reload").mockImplementation((input) => reload(input)) + GlobalBus.on("event", fn) + + try { + const first = await app.request("/project/git/init", { + method: "POST", + headers: { + "x-opencode-directory": tmp.path, + }, + }) + expect(first.status).toBe(200) + const before = seen.filter( + (evt) => evt.directory === tmp.path && evt.payload.type === "server.instance.disposed", + ).length + expect(reloadSpy).toHaveBeenCalledTimes(1) + + const second = await app.request("/project/git/init", { + method: "POST", + headers: { + "x-opencode-directory": tmp.path, + }, + }) + expect(second.status).toBe(200) + expect(await second.json()).toMatchObject({ + id: "global", + vcs: "git", + worktree: tmp.path, + }) + + const after = seen.filter( + (evt) => evt.directory === tmp.path && evt.payload.type === "server.instance.disposed", + ).length + expect(after).toBe(before) + expect(reloadSpy).toHaveBeenCalledTimes(1) + } finally { + reloadSpy.mockRestore() + GlobalBus.off("event", fn) + } + }) +}) diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 1c1b31e46f0..22dcfec3553 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -77,6 +77,7 @@ import type { PermissionRespondResponses, PermissionRuleset, ProjectCurrentResponses, + ProjectInitGitResponses, ProjectListResponses, ProjectUpdateErrors, ProjectUpdateResponses, @@ -425,6 +426,36 @@ export class Project extends HeyApiClient { }) } + /** + * Initialize git repository + * + * Create a git repository for the current project and return the refreshed project info. + */ + public initGit( + parameters?: { + directory?: string + workspace?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "query", key: "directory" }, + { in: "query", key: "workspace" }, + ], + }, + ], + ) + return (options?.client ?? this.client).post({ + url: "/project/git/init", + ...options, + ...params, + }) + } + /** * Update project * diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index afb2224a751..71e075b3916 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -2087,6 +2087,25 @@ export type ProjectCurrentResponses = { export type ProjectCurrentResponse = ProjectCurrentResponses[keyof ProjectCurrentResponses] +export type ProjectInitGitData = { + body?: never + path?: never + query?: { + directory?: string + workspace?: string + } + url: "/project/git/init" +} + +export type ProjectInitGitResponses = { + /** + * Project information after git initialization + */ + 200: Project +} + +export type ProjectInitGitResponse = ProjectInitGitResponses[keyof ProjectInitGitResponses] + export type ProjectUpdateData = { body?: { name?: string