Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions packages/opencode/src/git/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,12 @@ export interface Interface {
readonly patchUntracked: (cwd: string, file: string, options?: PatchOptions) => Effect.Effect<Patch>
readonly statUntracked: (cwd: string, file: string) => Effect.Effect<Stat | undefined>
readonly applyPatch: (cwd: string, patch: string) => Effect.Effect<Result>
readonly add: (cwd: string, paths: string[]) => Effect.Effect<Result>
readonly unstage: (cwd: string, paths: string[]) => Effect.Effect<Result>
readonly commit: (cwd: string, message: string) => Effect.Effect<Result>
readonly push: (cwd: string, remote: string, branch: string) => Effect.Effect<Result>
readonly pull: (cwd: string, remote: string, branch: string) => Effect.Effect<Result>
readonly log: (cwd: string, count?: number) => Effect.Effect<string>
}

const kind = (code: string): Kind => {
Expand Down Expand Up @@ -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,
Expand All @@ -338,6 +368,12 @@ export const layer = Layer.effect(
patchUntracked,
statUntracked,
applyPatch,
add,
unstage,
commit,
push,
pull,
log,
})
}),
)
Expand Down
85 changes: 85 additions & 0 deletions packages/opencode/src/project/vcs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,34 @@ export class PatchApplyError extends Schema.TaggedErrorClass<PatchApplyError>()(
reason: Schema.Literals(["non-git", "not-clean"]),
}) {}

export const CommitInput = Schema.Struct({
message: Schema.String,
})
export type CommitInput = Schema.Schema.Type<typeof CommitInput>

export const StageInput = Schema.Struct({
files: Schema.optional(Schema.Array(Schema.String)),
})
export type StageInput = Schema.Schema.Type<typeof StageInput>

export const PushPullInput = Schema.Struct({
remote: Schema.optional(Schema.String),
branch: Schema.optional(Schema.String),
})
export type PushPullInput = Schema.Schema.Type<typeof PushPullInput>

export const LogEntry = Schema.Struct({
hash: Schema.String,
message: Schema.String,
}).annotate({ identifier: "VcsLogEntry" })
export type LogEntry = Schema.Schema.Type<typeof LogEntry>

export const ActionResult = Schema.Struct({
success: Schema.Boolean,
output: Schema.optional(Schema.String),
}).annotate({ identifier: "VcsActionResult" })
export type ActionResult = Schema.Schema.Type<typeof ActionResult>

export interface Interface {
readonly init: () => Effect.Effect<void>
readonly branch: () => Effect.Effect<string | undefined>
Expand All @@ -267,6 +295,12 @@ export interface Interface {
readonly diff: (mode: Mode) => Effect.Effect<FileDiff[]>
readonly diffRaw: () => Effect.Effect<string>
readonly apply: (input: ApplyInput) => Effect.Effect<ApplyResult, PatchApplyError>
readonly stage: (input: StageInput) => Effect.Effect<ActionResult>
readonly unstage: (input: StageInput) => Effect.Effect<ActionResult>
readonly commit: (input: CommitInput) => Effect.Effect<ActionResult>
readonly push: (input: PushPullInput) => Effect.Effect<ActionResult>
readonly pull: (input: PushPullInput) => Effect.Effect<ActionResult>
readonly log: (count?: number) => Effect.Effect<LogEntry[]>
}

interface State {
Expand Down Expand Up @@ -396,6 +430,57 @@ export const layer: Layer.Layer<Service, never, Git.Service | Bus.Service> = 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),
}
})
}),
})
}),
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand All @@ -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)
Expand Down
30 changes: 30 additions & 0 deletions packages/opencode/test/server/httpapi-exercise/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
Loading