mirror of
https://gitea.toothfairyai.com/ToothFairyAI/tf_code.git
synced 2026-04-05 08:33:10 +00:00
feat: add project git init api (#16383)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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
packages/opencode/test/server/project-init-git.test.ts
Normal file
123
packages/opencode/test/server/project-init-git.test.ts
Normal file
@@ -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,
|
||||
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
|
||||
*
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user