summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorShoubhit Dash <[email protected]>2026-03-07 01:09:50 +0530
committerGitHub <[email protected]>2026-03-06 13:39:50 -0600
commitd6e0f47361d2a5c38c012618895cbdc7a1af8960 (patch)
tree03b47fe9a70d6a452b231aa68d472a04d4f403f0
parent95385eb65249aa6def266968e75061abd0fb0f46 (diff)
downloadopencode-d6e0f47361d2a5c38c012618895cbdc7a1af8960.tar.gz
opencode-d6e0f47361d2a5c38c012618895cbdc7a1af8960.zip
feat: add project git init api (#16383)
-rw-r--r--packages/opencode/src/project/instance.ts78
-rw-r--r--packages/opencode/src/project/project.ts15
-rw-r--r--packages/opencode/src/server/routes/project.ts35
-rw-r--r--packages/opencode/test/server/project-init-git.test.ts123
-rw-r--r--packages/sdk/js/src/v2/gen/sdk.gen.ts31
-rw-r--r--packages/sdk/js/src/v2/gen/types.gen.ts19
6 files changed, 280 insertions, 21 deletions
diff --git a/packages/opencode/src/project/instance.ts b/packages/opencode/src/project/instance.ts
index 98031f18d..59a896e77 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<void> | undefined,
}
+function emit(directory: string) {
+ GlobalBus.emit("event", {
+ directory,
+ payload: {
+ type: "server.instance.disposed",
+ properties: {
+ directory,
+ },
+ },
+ })
+}
+
+function boot(input: { directory: string; init?: () => Promise<any>; 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<Context>) {
+ 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<R>(input: { directory: string; init?: () => Promise<any>; fn: () => R }): Promise<R> {
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<S>(init: () => S, dispose?: (state: Awaited<S>) => Promise<void>): () => S {
return State.create(() => Instance.directory, init, dispose)
},
+ async reload(input: { directory: string; init?: () => Promise<any>; 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 9cc12a0a4..e1fff1a14 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 81092284d..85314df93 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 000000000..a200cffa1
--- /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 1c1b31e46..22dcfec35 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,
@@ -426,6 +427,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<ThrowOnError extends boolean = false>(
+ parameters?: {
+ directory?: string
+ workspace?: string
+ },
+ options?: Options<never, ThrowOnError>,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).post<ProjectInitGitResponses, unknown, ThrowOnError>({
+ url: "/project/git/init",
+ ...options,
+ ...params,
+ })
+ }
+
+ /**
* Update project
*
* Update project properties such as name, icon, and commands.
diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts
index afb2224a7..71e075b39 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