feat: add project git init api (#16383)
This commit is contained in:
@@ -18,24 +18,60 @@ const disposal = {
|
|||||||
all: undefined as Promise<void> | undefined,
|
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 = {
|
export const Instance = {
|
||||||
async provide<R>(input: { directory: string; init?: () => Promise<any>; fn: () => R }): Promise<R> {
|
async provide<R>(input: { directory: string; init?: () => Promise<any>; fn: () => R }): Promise<R> {
|
||||||
let existing = cache.get(input.directory)
|
let existing = cache.get(input.directory)
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
Log.Default.info("creating instance", { directory: input.directory })
|
Log.Default.info("creating instance", { directory: input.directory })
|
||||||
existing = iife(async () => {
|
existing = track(
|
||||||
const { project, sandbox } = await Project.fromDirectory(input.directory)
|
input.directory,
|
||||||
const ctx = {
|
boot({
|
||||||
directory: input.directory,
|
directory: input.directory,
|
||||||
worktree: sandbox,
|
init: input.init,
|
||||||
project,
|
}),
|
||||||
}
|
)
|
||||||
await context.provide(ctx, async () => {
|
|
||||||
await input.init?.()
|
|
||||||
})
|
|
||||||
return ctx
|
|
||||||
})
|
|
||||||
cache.set(input.directory, existing)
|
|
||||||
}
|
}
|
||||||
const ctx = await existing
|
const ctx = await existing
|
||||||
return context.provide(ctx, async () => {
|
return context.provide(ctx, async () => {
|
||||||
@@ -66,19 +102,19 @@ export const Instance = {
|
|||||||
state<S>(init: () => S, dispose?: (state: Awaited<S>) => Promise<void>): () => S {
|
state<S>(init: () => S, dispose?: (state: Awaited<S>) => Promise<void>): () => S {
|
||||||
return State.create(() => Instance.directory, init, dispose)
|
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() {
|
async dispose() {
|
||||||
Log.Default.info("disposing instance", { directory: Instance.directory })
|
Log.Default.info("disposing instance", { directory: Instance.directory })
|
||||||
await State.dispose(Instance.directory)
|
await State.dispose(Instance.directory)
|
||||||
cache.delete(Instance.directory)
|
cache.delete(Instance.directory)
|
||||||
GlobalBus.emit("event", {
|
emit(Instance.directory)
|
||||||
directory: Instance.directory,
|
|
||||||
payload: {
|
|
||||||
type: "server.instance.disposed",
|
|
||||||
properties: {
|
|
||||||
directory: Instance.directory,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
})
|
|
||||||
},
|
},
|
||||||
async disposeAll() {
|
async disposeAll() {
|
||||||
if (disposal.all) return disposal.all
|
if (disposal.all) return disposal.all
|
||||||
|
|||||||
@@ -347,6 +347,21 @@ export namespace Project {
|
|||||||
return fromRow(row)
|
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(
|
export const update = fn(
|
||||||
z.object({
|
z.object({
|
||||||
projectID: z.string(),
|
projectID: z.string(),
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { Project } from "../../project/project"
|
|||||||
import z from "zod"
|
import z from "zod"
|
||||||
import { errors } from "../error"
|
import { errors } from "../error"
|
||||||
import { lazy } from "../../util/lazy"
|
import { lazy } from "../../util/lazy"
|
||||||
|
import { InstanceBootstrap } from "../../project/bootstrap"
|
||||||
|
|
||||||
export const ProjectRoutes = lazy(() =>
|
export const ProjectRoutes = lazy(() =>
|
||||||
new Hono()
|
new Hono()
|
||||||
@@ -52,6 +53,40 @@ export const ProjectRoutes = lazy(() =>
|
|||||||
return c.json(Instance.project)
|
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(
|
.patch(
|
||||||
"/:projectID",
|
"/:projectID",
|
||||||
describeRoute({
|
describeRoute({
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -77,6 +77,7 @@ import type {
|
|||||||
PermissionRespondResponses,
|
PermissionRespondResponses,
|
||||||
PermissionRuleset,
|
PermissionRuleset,
|
||||||
ProjectCurrentResponses,
|
ProjectCurrentResponses,
|
||||||
|
ProjectInitGitResponses,
|
||||||
ProjectListResponses,
|
ProjectListResponses,
|
||||||
ProjectUpdateErrors,
|
ProjectUpdateErrors,
|
||||||
ProjectUpdateResponses,
|
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
|
* Update project
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -2087,6 +2087,25 @@ export type ProjectCurrentResponses = {
|
|||||||
|
|
||||||
export type ProjectCurrentResponse = ProjectCurrentResponses[keyof 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 = {
|
export type ProjectUpdateData = {
|
||||||
body?: {
|
body?: {
|
||||||
name?: string
|
name?: string
|
||||||
|
|||||||
Reference in New Issue
Block a user