diff --git a/packages/opencode/src/git/index.ts b/packages/opencode/src/git/index.ts index 5e76b7f7313a..419e0f115349 100644 --- a/packages/opencode/src/git/index.ts +++ b/packages/opencode/src/git/index.ts @@ -87,6 +87,12 @@ export interface Interface { readonly patchUntracked: (cwd: string, file: string, options?: PatchOptions) => Effect.Effect readonly statUntracked: (cwd: string, file: string) => Effect.Effect readonly applyPatch: (cwd: string, patch: string) => Effect.Effect + readonly add: (cwd: string, paths: string[]) => Effect.Effect + readonly unstage: (cwd: string, paths: string[]) => Effect.Effect + readonly commit: (cwd: string, message: string) => Effect.Effect + readonly push: (cwd: string, remote: string, branch: string) => Effect.Effect + readonly pull: (cwd: string, remote: string, branch: string) => Effect.Effect + readonly log: (cwd: string, count?: number) => Effect.Effect } const kind = (code: string): Kind => { @@ -322,6 +328,30 @@ export const layer = Layer.effect( return yield* run(["apply", "-"], { cwd, stdin: stdin(patch) }) }) + const add = Effect.fn("Git.add")(function* (cwd: string, paths: string[]) { + return yield* run(["add", "--", ...paths], { cwd }) + }) + + const unstage = Effect.fn("Git.unstage")(function* (cwd: string, paths: string[]) { + return yield* run(["restore", "--staged", "--", ...paths], { cwd }) + }) + + const commit = Effect.fn("Git.commit")(function* (cwd: string, message: string) { + return yield* run(["commit", "-m", message], { cwd }) + }) + + const push = Effect.fn("Git.push")(function* (cwd: string, remote: string, branch: string) { + return yield* run(["push", remote, branch], { cwd }) + }) + + const pull = Effect.fn("Git.pull")(function* (cwd: string, remote: string, branch: string) { + return yield* run(["pull", remote, branch], { cwd }) + }) + + const log = Effect.fn("Git.log")(function* (cwd: string, count = 10) { + return yield* text(["log", `--max-count=${count}`, "--oneline", "--", "."], { cwd }) + }) + return Service.of({ run, branch, @@ -338,6 +368,12 @@ export const layer = Layer.effect( patchUntracked, statUntracked, applyPatch, + add, + unstage, + commit, + push, + pull, + log, }) }), ) diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts index a454cddbbb39..0f03aa3bb761 100644 --- a/packages/opencode/src/project/vcs.ts +++ b/packages/opencode/src/project/vcs.ts @@ -259,6 +259,34 @@ export class PatchApplyError extends Schema.TaggedErrorClass()( reason: Schema.Literals(["non-git", "not-clean"]), }) {} +export const CommitInput = Schema.Struct({ + message: Schema.String, +}) +export type CommitInput = Schema.Schema.Type + +export const StageInput = Schema.Struct({ + files: Schema.optional(Schema.Array(Schema.String)), +}) +export type StageInput = Schema.Schema.Type + +export const PushPullInput = Schema.Struct({ + remote: Schema.optional(Schema.String), + branch: Schema.optional(Schema.String), +}) +export type PushPullInput = Schema.Schema.Type + +export const LogEntry = Schema.Struct({ + hash: Schema.String, + message: Schema.String, +}).annotate({ identifier: "VcsLogEntry" }) +export type LogEntry = Schema.Schema.Type + +export const ActionResult = Schema.Struct({ + success: Schema.Boolean, + output: Schema.optional(Schema.String), +}).annotate({ identifier: "VcsActionResult" }) +export type ActionResult = Schema.Schema.Type + export interface Interface { readonly init: () => Effect.Effect readonly branch: () => Effect.Effect @@ -267,6 +295,12 @@ export interface Interface { readonly diff: (mode: Mode) => Effect.Effect readonly diffRaw: () => Effect.Effect readonly apply: (input: ApplyInput) => Effect.Effect + readonly stage: (input: StageInput) => Effect.Effect + readonly unstage: (input: StageInput) => Effect.Effect + readonly commit: (input: CommitInput) => Effect.Effect + readonly push: (input: PushPullInput) => Effect.Effect + readonly pull: (input: PushPullInput) => Effect.Effect + readonly log: (count?: number) => Effect.Effect } interface State { @@ -396,6 +430,57 @@ export const layer: Layer.Layer = Lay } return { applied: true } }), + stage: Effect.fn("Vcs.stage")(function* (input: StageInput) { + const ctx = yield* InstanceState.context + if (ctx.project.vcs !== "git") return { success: false, output: "Not a git repository" } + const files = input.files ?? ["."] + const result = yield* git.add(ctx.directory, files) + return { success: result.exitCode === 0, output: result.text() } + }), + unstage: Effect.fn("Vcs.unstage")(function* (input: StageInput) { + const ctx = yield* InstanceState.context + if (ctx.project.vcs !== "git") return { success: false, output: "Not a git repository" } + const files = input.files ?? ["."] + const result = yield* git.unstage(ctx.directory, files) + return { success: result.exitCode === 0, output: result.text() } + }), + commit: Effect.fn("Vcs.commit")(function* (input: CommitInput) { + const ctx = yield* InstanceState.context + if (ctx.project.vcs !== "git") return { success: false, output: "Not a git repository" } + const result = yield* git.commit(ctx.directory, input.message) + return { success: result.exitCode === 0, output: result.text() } + }), + push: Effect.fn("Vcs.push")(function* (input: PushPullInput) { + const ctx = yield* InstanceState.context + if (ctx.project.vcs !== "git") return { success: false, output: "Not a git repository" } + const remote = input.remote ?? "origin" + const branch = input.branch ?? (yield* git.branch(ctx.directory)) ?? "" + const result = yield* git.push(ctx.directory, remote, branch) + return { success: result.exitCode === 0, output: result.text() } + }), + pull: Effect.fn("Vcs.pull")(function* (input: PushPullInput) { + const ctx = yield* InstanceState.context + if (ctx.project.vcs !== "git") return { success: false, output: "Not a git repository" } + const remote = input.remote ?? "origin" + const branch = input.branch ?? (yield* git.branch(ctx.directory)) ?? "" + const result = yield* git.pull(ctx.directory, remote, branch) + return { success: result.exitCode === 0, output: result.text() } + }), + log: Effect.fn("Vcs.log")(function* (count = 10) { + const ctx = yield* InstanceState.context + if (ctx.project.vcs !== "git") return [] + const text = yield* git.log(ctx.directory, count) + return text + .split(/\r?\n/) + .filter(Boolean) + .map((line) => { + const space = line.indexOf(" ") + return { + hash: space === -1 ? line : line.slice(0, space), + message: space === -1 ? "" : line.slice(space + 1), + } + }) + }), }) }), ) diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts index ea8db35035da..0280b1055be9 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/instance.ts @@ -47,6 +47,12 @@ export const InstancePaths = { vcsDiff: "/vcs/diff", vcsDiffRaw: "/vcs/diff/raw", vcsApply: "/vcs/apply", + vcsStage: "/vcs/stage", + vcsUnstage: "/vcs/unstage", + vcsCommit: "/vcs/commit", + vcsPush: "/vcs/push", + vcsPull: "/vcs/pull", + vcsLog: "/vcs/log", command: "/command", agent: "/agent", skill: "/skill", @@ -135,6 +141,74 @@ export const InstanceApi = HttpApi.make("instance") description: "Apply a raw patch to the current working tree.", }), ), + HttpApiEndpoint.post("vcsStage", InstancePaths.vcsStage, { + query: WorkspaceRoutingQuery, + payload: Vcs.StageInput, + success: described(Vcs.ActionResult, "VCS stage result"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "vcs.stage", + summary: "Stage files", + description: "Stage files for commit.", + }), + ), + HttpApiEndpoint.post("vcsUnstage", InstancePaths.vcsUnstage, { + query: WorkspaceRoutingQuery, + payload: Vcs.StageInput, + success: described(Vcs.ActionResult, "VCS unstage result"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "vcs.unstage", + summary: "Unstage files", + description: "Unstage files from the index.", + }), + ), + HttpApiEndpoint.post("vcsCommit", InstancePaths.vcsCommit, { + query: WorkspaceRoutingQuery, + payload: Vcs.CommitInput, + success: described(Vcs.ActionResult, "VCS commit result"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "vcs.commit", + summary: "Commit staged changes", + description: "Commit all staged changes with a message.", + }), + ), + HttpApiEndpoint.post("vcsPush", InstancePaths.vcsPush, { + query: WorkspaceRoutingQuery, + payload: Vcs.PushPullInput, + success: described(Vcs.ActionResult, "VCS push result"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "vcs.push", + summary: "Push commits", + description: "Push commits to a remote repository.", + }), + ), + HttpApiEndpoint.post("vcsPull", InstancePaths.vcsPull, { + query: WorkspaceRoutingQuery, + payload: Vcs.PushPullInput, + success: described(Vcs.ActionResult, "VCS pull result"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "vcs.pull", + summary: "Pull from remote", + description: "Pull latest changes from a remote repository.", + }), + ), + HttpApiEndpoint.get("vcsLog", InstancePaths.vcsLog, { + query: Schema.Struct({ + ...WorkspaceRoutingQueryFields, + count: Schema.optional(Schema.NumberFromString.pipe(Schema.int())), + }), + success: described(Schema.Array(Vcs.LogEntry), "VCS log"), + }).annotateMerge( + OpenApi.annotations({ + identifier: "vcs.log", + summary: "Get commit log", + description: "Retrieve recent commit history.", + }), + ), HttpApiEndpoint.get("command", InstancePaths.command, { query: WorkspaceRoutingQuery, success: described(Schema.Array(Command.Info), "List of commands"), diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/instance.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/instance.ts index 4ae318ef21b4..66e27e75e37f 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/instance.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/instance.ts @@ -6,7 +6,7 @@ import { Global } from "@opencode-ai/core/global" import { LSP } from "@/lsp/lsp" import { Vcs } from "@/project/vcs" import { Skill } from "@/skill" -import { Effect } from "effect" +import { Schema, Effect } from "effect" import { HttpApiBuilder } from "effect/unstable/httpapi" import { InstanceHttpApi } from "../api" import { ApiVcsApplyError } from "../groups/instance" @@ -91,6 +91,30 @@ export const instanceHandlers = HttpApiBuilder.group(InstanceHttpApi, "instance" return yield* format.status() }) + const vcsStage = Effect.fn("InstanceHttpApi.vcsStage")(function* (ctx: { payload: Vcs.StageInput }) { + return yield* vcs.stage(ctx.payload) + }) + + const vcsUnstage = Effect.fn("InstanceHttpApi.vcsUnstage")(function* (ctx: { payload: Vcs.StageInput }) { + return yield* vcs.unstage(ctx.payload) + }) + + const vcsCommit = Effect.fn("InstanceHttpApi.vcsCommit")(function* (ctx: { payload: Vcs.CommitInput }) { + return yield* vcs.commit(ctx.payload) + }) + + const vcsPush = Effect.fn("InstanceHttpApi.vcsPush")(function* (ctx: { payload: Vcs.PushPullInput }) { + return yield* vcs.push(ctx.payload) + }) + + const vcsPull = Effect.fn("InstanceHttpApi.vcsPull")(function* (ctx: { payload: Vcs.PushPullInput }) { + return yield* vcs.pull(ctx.payload) + }) + + const vcsLog = Effect.fn("InstanceHttpApi.vcsLog")(function* (ctx: { query: { count?: number } }) { + return yield* vcs.log(ctx.query.count) + }) + return handlers .handle("dispose", dispose) .handle("path", getPath) @@ -99,6 +123,12 @@ export const instanceHandlers = HttpApiBuilder.group(InstanceHttpApi, "instance" .handle("vcsDiff", getVcsDiff) .handle("vcsDiffRaw", getVcsDiffRaw) .handle("vcsApply", applyVcs) + .handle("vcsStage", vcsStage) + .handle("vcsUnstage", vcsUnstage) + .handle("vcsCommit", vcsCommit) + .handle("vcsPush", vcsPush) + .handle("vcsPull", vcsPull) + .handle("vcsLog", vcsLog) .handle("command", getCommand) .handle("agent", getAgent) .handle("skill", getSkill) diff --git a/packages/opencode/test/server/httpapi-exercise/index.ts b/packages/opencode/test/server/httpapi-exercise/index.ts index 0c834e91b776..d0af5e00404a 100644 --- a/packages/opencode/test/server/httpapi-exercise/index.ts +++ b/packages/opencode/test/server/httpapi-exercise/index.ts @@ -124,6 +124,36 @@ const scenarios: Scenario[] = [ .inProject({ git: false }) .at((ctx) => ({ path: "/vcs/apply", headers: ctx.headers(), body: { patch: "" } })) .status(400, undefined, "status"), + http.protected + .post("/vcs/stage", "vcs.stage") + .inProject({ git: false }) + .at((ctx) => ({ path: "/vcs/stage", headers: ctx.headers(), body: { files: ["."] } })) + .json(200, (body) => { check(body.success === false, "non-git project should fail stage") }, "status"), + http.protected + .post("/vcs/unstage", "vcs.unstage") + .inProject({ git: false }) + .at((ctx) => ({ path: "/vcs/unstage", headers: ctx.headers(), body: { files: ["."] } })) + .json(200, (body) => { check(body.success === false, "non-git project should fail unstage") }, "status"), + http.protected + .post("/vcs/commit", "vcs.commit") + .inProject({ git: false }) + .at((ctx) => ({ path: "/vcs/commit", headers: ctx.headers(), body: { message: "test" } })) + .json(200, (body) => { check(body.success === false, "non-git project should fail commit") }, "status"), + http.protected + .post("/vcs/push", "vcs.push") + .inProject({ git: false }) + .at((ctx) => ({ path: "/vcs/push", headers: ctx.headers(), body: {} })) + .json(200, (body) => { check(body.success === false, "non-git project should fail push") }, "status"), + http.protected + .post("/vcs/pull", "vcs.pull") + .inProject({ git: false }) + .at((ctx) => ({ path: "/vcs/pull", headers: ctx.headers(), body: {} })) + .json(200, (body) => { check(body.success === false, "non-git project should fail pull") }, "status"), + http.protected + .get("/vcs/log", "vcs.log") + .inProject({ git: false }) + .at((ctx) => ({ path: "/vcs/log", headers: ctx.headers() })) + .json(200, array, "status"), http.protected.get("/command", "command.list").json(200, array, "status"), http.protected.get("/agent", "app.agents").json(200, array, "status"), http.protected.get("/skill", "app.skills").json(200, array, "status"),