Skip to content

Commit eb996c2

Browse files
nexxelnqdddddd
authored andcommitted
feat: add project git init api (anomalyco#16383)
1 parent 777b4be commit eb996c2

6 files changed

Lines changed: 280 additions & 21 deletions

File tree

packages/opencode/src/project/instance.ts

Lines changed: 57 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -18,24 +18,60 @@ const disposal = {
1818
all: undefined as Promise<void> | undefined,
1919
}
2020

21+
function emit(directory: string) {
22+
GlobalBus.emit("event", {
23+
directory,
24+
payload: {
25+
type: "server.instance.disposed",
26+
properties: {
27+
directory,
28+
},
29+
},
30+
})
31+
}
32+
33+
function boot(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) {
34+
return iife(async () => {
35+
const ctx =
36+
input.project && input.worktree
37+
? {
38+
directory: input.directory,
39+
worktree: input.worktree,
40+
project: input.project,
41+
}
42+
: await Project.fromDirectory(input.directory).then(({ project, sandbox }) => ({
43+
directory: input.directory,
44+
worktree: sandbox,
45+
project,
46+
}))
47+
await context.provide(ctx, async () => {
48+
await input.init?.()
49+
})
50+
return ctx
51+
})
52+
}
53+
54+
function track(directory: string, next: Promise<Context>) {
55+
const task = next.catch((error) => {
56+
if (cache.get(directory) === task) cache.delete(directory)
57+
throw error
58+
})
59+
cache.set(directory, task)
60+
return task
61+
}
62+
2163
export const Instance = {
2264
async provide<R>(input: { directory: string; init?: () => Promise<any>; fn: () => R }): Promise<R> {
2365
let existing = cache.get(input.directory)
2466
if (!existing) {
2567
Log.Default.info("creating instance", { directory: input.directory })
26-
existing = iife(async () => {
27-
const { project, sandbox } = await Project.fromDirectory(input.directory)
28-
const ctx = {
68+
existing = track(
69+
input.directory,
70+
boot({
2971
directory: input.directory,
30-
worktree: sandbox,
31-
project,
32-
}
33-
await context.provide(ctx, async () => {
34-
await input.init?.()
35-
})
36-
return ctx
37-
})
38-
cache.set(input.directory, existing)
72+
init: input.init,
73+
}),
74+
)
3975
}
4076
const ctx = await existing
4177
return context.provide(ctx, async () => {
@@ -66,19 +102,19 @@ export const Instance = {
66102
state<S>(init: () => S, dispose?: (state: Awaited<S>) => Promise<void>): () => S {
67103
return State.create(() => Instance.directory, init, dispose)
68104
},
105+
async reload(input: { directory: string; init?: () => Promise<any>; project?: Project.Info; worktree?: string }) {
106+
Log.Default.info("reloading instance", { directory: input.directory })
107+
await State.dispose(input.directory)
108+
cache.delete(input.directory)
109+
const next = track(input.directory, boot(input))
110+
emit(input.directory)
111+
return await next
112+
},
69113
async dispose() {
70114
Log.Default.info("disposing instance", { directory: Instance.directory })
71115
await State.dispose(Instance.directory)
72116
cache.delete(Instance.directory)
73-
GlobalBus.emit("event", {
74-
directory: Instance.directory,
75-
payload: {
76-
type: "server.instance.disposed",
77-
properties: {
78-
directory: Instance.directory,
79-
},
80-
},
81-
})
117+
emit(Instance.directory)
82118
},
83119
async disposeAll() {
84120
if (disposal.all) return disposal.all

packages/opencode/src/project/project.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,21 @@ export namespace Project {
347347
return fromRow(row)
348348
}
349349

350+
export async function initGit(input: { directory: string; project: Info }) {
351+
if (input.project.vcs === "git") return input.project
352+
if (!which("git")) throw new Error("Git is not installed")
353+
354+
const result = await git(["init", "--quiet"], {
355+
cwd: input.directory,
356+
})
357+
if (result.exitCode !== 0) {
358+
const text = result.stderr.toString().trim() || result.text().trim()
359+
throw new Error(text || "Failed to initialize git repository")
360+
}
361+
362+
return (await fromDirectory(input.directory)).project
363+
}
364+
350365
export const update = fn(
351366
z.object({
352367
projectID: z.string(),

packages/opencode/src/server/routes/project.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { Project } from "../../project/project"
66
import z from "zod"
77
import { errors } from "../error"
88
import { lazy } from "../../util/lazy"
9+
import { InstanceBootstrap } from "../../project/bootstrap"
910

1011
export const ProjectRoutes = lazy(() =>
1112
new Hono()
@@ -52,6 +53,40 @@ export const ProjectRoutes = lazy(() =>
5253
return c.json(Instance.project)
5354
},
5455
)
56+
.post(
57+
"/git/init",
58+
describeRoute({
59+
summary: "Initialize git repository",
60+
description: "Create a git repository for the current project and return the refreshed project info.",
61+
operationId: "project.initGit",
62+
responses: {
63+
200: {
64+
description: "Project information after git initialization",
65+
content: {
66+
"application/json": {
67+
schema: resolver(Project.Info),
68+
},
69+
},
70+
},
71+
},
72+
}),
73+
async (c) => {
74+
const dir = Instance.directory
75+
const prev = Instance.project
76+
const next = await Project.initGit({
77+
directory: dir,
78+
project: prev,
79+
})
80+
if (next.id === prev.id && next.vcs === prev.vcs && next.worktree === prev.worktree) return c.json(next)
81+
await Instance.reload({
82+
directory: dir,
83+
worktree: dir,
84+
project: next,
85+
init: InstanceBootstrap,
86+
})
87+
return c.json(next)
88+
},
89+
)
5590
.patch(
5691
"/:projectID",
5792
describeRoute({
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { afterEach, describe, expect, spyOn, test } from "bun:test"
2+
import path from "path"
3+
import { GlobalBus } from "../../src/bus/global"
4+
import { Snapshot } from "../../src/snapshot"
5+
import { InstanceBootstrap } from "../../src/project/bootstrap"
6+
import { Instance } from "../../src/project/instance"
7+
import { Server } from "../../src/server/server"
8+
import { Filesystem } from "../../src/util/filesystem"
9+
import { Log } from "../../src/util/log"
10+
import { resetDatabase } from "../fixture/db"
11+
import { tmpdir } from "../fixture/fixture"
12+
13+
Log.init({ print: false })
14+
15+
afterEach(async () => {
16+
await resetDatabase()
17+
})
18+
19+
describe("project.initGit endpoint", () => {
20+
test("initializes git and reloads immediately", async () => {
21+
await using tmp = await tmpdir()
22+
const app = Server.App()
23+
const seen: { directory?: string; payload: { type: string } }[] = []
24+
const fn = (evt: { directory?: string; payload: { type: string } }) => {
25+
seen.push(evt)
26+
}
27+
const reload = Instance.reload
28+
const reloadSpy = spyOn(Instance, "reload").mockImplementation((input) => reload(input))
29+
GlobalBus.on("event", fn)
30+
31+
try {
32+
const init = await app.request("/project/git/init", {
33+
method: "POST",
34+
headers: {
35+
"x-opencode-directory": tmp.path,
36+
},
37+
})
38+
const body = await init.json()
39+
expect(init.status).toBe(200)
40+
expect(body).toMatchObject({
41+
id: "global",
42+
vcs: "git",
43+
worktree: tmp.path,
44+
})
45+
expect(reloadSpy).toHaveBeenCalledTimes(1)
46+
expect(reloadSpy.mock.calls[0]?.[0]?.init).toBe(InstanceBootstrap)
47+
expect(seen.some((evt) => evt.directory === tmp.path && evt.payload.type === "server.instance.disposed")).toBe(
48+
true,
49+
)
50+
expect(await Filesystem.exists(path.join(tmp.path, ".git", "opencode"))).toBe(false)
51+
52+
const current = await app.request("/project/current", {
53+
headers: {
54+
"x-opencode-directory": tmp.path,
55+
},
56+
})
57+
expect(current.status).toBe(200)
58+
expect(await current.json()).toMatchObject({
59+
id: "global",
60+
vcs: "git",
61+
worktree: tmp.path,
62+
})
63+
64+
await Instance.provide({
65+
directory: tmp.path,
66+
fn: async () => {
67+
expect(await Snapshot.track()).toBeTruthy()
68+
},
69+
})
70+
} finally {
71+
reloadSpy.mockRestore()
72+
GlobalBus.off("event", fn)
73+
}
74+
})
75+
76+
test("does not reload again when the project is already git", async () => {
77+
await using tmp = await tmpdir()
78+
const app = Server.App()
79+
const seen: { directory?: string; payload: { type: string } }[] = []
80+
const fn = (evt: { directory?: string; payload: { type: string } }) => {
81+
seen.push(evt)
82+
}
83+
const reload = Instance.reload
84+
const reloadSpy = spyOn(Instance, "reload").mockImplementation((input) => reload(input))
85+
GlobalBus.on("event", fn)
86+
87+
try {
88+
const first = await app.request("/project/git/init", {
89+
method: "POST",
90+
headers: {
91+
"x-opencode-directory": tmp.path,
92+
},
93+
})
94+
expect(first.status).toBe(200)
95+
const before = seen.filter(
96+
(evt) => evt.directory === tmp.path && evt.payload.type === "server.instance.disposed",
97+
).length
98+
expect(reloadSpy).toHaveBeenCalledTimes(1)
99+
100+
const second = await app.request("/project/git/init", {
101+
method: "POST",
102+
headers: {
103+
"x-opencode-directory": tmp.path,
104+
},
105+
})
106+
expect(second.status).toBe(200)
107+
expect(await second.json()).toMatchObject({
108+
id: "global",
109+
vcs: "git",
110+
worktree: tmp.path,
111+
})
112+
113+
const after = seen.filter(
114+
(evt) => evt.directory === tmp.path && evt.payload.type === "server.instance.disposed",
115+
).length
116+
expect(after).toBe(before)
117+
expect(reloadSpy).toHaveBeenCalledTimes(1)
118+
} finally {
119+
reloadSpy.mockRestore()
120+
GlobalBus.off("event", fn)
121+
}
122+
})
123+
})

packages/sdk/js/src/v2/gen/sdk.gen.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ import type {
7777
PermissionRespondResponses,
7878
PermissionRuleset,
7979
ProjectCurrentResponses,
80+
ProjectInitGitResponses,
8081
ProjectListResponses,
8182
ProjectUpdateErrors,
8283
ProjectUpdateResponses,
@@ -425,6 +426,36 @@ export class Project extends HeyApiClient {
425426
})
426427
}
427428

429+
/**
430+
* Initialize git repository
431+
*
432+
* Create a git repository for the current project and return the refreshed project info.
433+
*/
434+
public initGit<ThrowOnError extends boolean = false>(
435+
parameters?: {
436+
directory?: string
437+
workspace?: string
438+
},
439+
options?: Options<never, ThrowOnError>,
440+
) {
441+
const params = buildClientParams(
442+
[parameters],
443+
[
444+
{
445+
args: [
446+
{ in: "query", key: "directory" },
447+
{ in: "query", key: "workspace" },
448+
],
449+
},
450+
],
451+
)
452+
return (options?.client ?? this.client).post<ProjectInitGitResponses, unknown, ThrowOnError>({
453+
url: "/project/git/init",
454+
...options,
455+
...params,
456+
})
457+
}
458+
428459
/**
429460
* Update project
430461
*

packages/sdk/js/src/v2/gen/types.gen.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2087,6 +2087,25 @@ export type ProjectCurrentResponses = {
20872087

20882088
export type ProjectCurrentResponse = ProjectCurrentResponses[keyof ProjectCurrentResponses]
20892089

2090+
export type ProjectInitGitData = {
2091+
body?: never
2092+
path?: never
2093+
query?: {
2094+
directory?: string
2095+
workspace?: string
2096+
}
2097+
url: "/project/git/init"
2098+
}
2099+
2100+
export type ProjectInitGitResponses = {
2101+
/**
2102+
* Project information after git initialization
2103+
*/
2104+
200: Project
2105+
}
2106+
2107+
export type ProjectInitGitResponse = ProjectInitGitResponses[keyof ProjectInitGitResponses]
2108+
20902109
export type ProjectUpdateData = {
20912110
body?: {
20922111
name?: string

0 commit comments

Comments
 (0)