ソースを参照

feat: add project git init api (#16383)

Shoubhit Dash 6 ヶ月 前
コミット
d6e0f47361

+ 57 - 21
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

+ 15 - 0
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(),

+ 35 - 0
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({

+ 123 - 0
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)
+    }
+  })
+})

+ 31 - 0
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<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
    *

+ 19 - 0
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