, err: unknown
export function SessionHeader() {
const layout = useLayout()
const command = useCommand()
+ const dialog = useDialog()
const server = useServer()
const platform = usePlatform()
const language = useLanguage()
@@ -224,6 +227,7 @@ export function SessionHeader() {
const tint = createMemo(() =>
messageAgentColor(params.id ? sync.data.message[params.id] : undefined, sync.data.agent),
)
+ const canCommit = createMemo(() => !!sync.data.vcs?.branch)
const selectApp = (app: OpenApp) => {
if (!options().some((item) => item.id === app)) return
@@ -435,6 +439,19 @@ export function SessionHeader() {
+
+
+
+
+
+
{
})
test("updates vcs branch in store and cache", () => {
- const [store, setStore] = createStore(baseState())
- const [cacheStore, setCacheStore] = createStore({ value: undefined as State["vcs"] })
+ const vcs = {
+ branch: "main",
+ staged: 1,
+ unstaged: 2,
+ hasRemote: true,
+ github: { available: true, authenticated: true },
+ }
+ const [store, setStore] = createStore(baseState({ vcs }))
+ const [cacheStore, setCacheStore] = createStore({ value: vcs as State["vcs"] })
applyDirectoryEvent({
event: { type: "vcs.branch.updated", properties: { branch: "feature/test" } },
@@ -511,8 +518,8 @@ describe("applyDirectoryEvent", () => {
},
})
- expect(store.vcs).toEqual({ branch: "feature/test" })
- expect(cacheStore.value).toEqual({ branch: "feature/test" })
+ expect(store.vcs).toEqual({ ...vcs, branch: "feature/test" })
+ expect(cacheStore.value).toEqual({ ...vcs, branch: "feature/test" })
})
test("routes disposal and lsp events to side-effect handlers", () => {
diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts
index b8eda0573f7..0e911d731bd 100644
--- a/packages/app/src/context/global-sync/event-reducer.ts
+++ b/packages/app/src/context/global-sync/event-reducer.ts
@@ -270,7 +270,7 @@ export function applyDirectoryEvent(input: {
case "vcs.branch.updated": {
const props = event.properties as { branch: string }
if (input.store.vcs?.branch === props.branch) break
- const next = { branch: props.branch }
+ const next = { ...(input.store.vcs ?? {}), branch: props.branch }
input.setStore("vcs", next)
if (input.vcsCache) input.vcsCache.setStore("value", next)
break
diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts
index 7f6816de9e3..e969fdaba8a 100644
--- a/packages/app/src/i18n/en.ts
+++ b/packages/app/src/i18n/en.ts
@@ -2,6 +2,29 @@ export const dict = {
"command.category.suggested": "Suggested",
"command.category.view": "View",
"command.category.project": "Project",
+ "commit.dialog.title": "Commit your changes",
+ "commit.dialog.loading": "Loading changes...",
+ "commit.dialog.branch": "Branch",
+ "commit.dialog.branchUnknown": "Unavailable",
+ "commit.dialog.changes": "Changes",
+ "commit.dialog.files": "{{count}} files",
+ "commit.dialog.includeUnstaged": "Include unstaged",
+ "commit.dialog.summary.all": "Will commit {{staged}} staged and {{unstaged}} unstaged changes.",
+ "commit.dialog.summary.staged": "Will commit {{staged}} staged changes only.",
+ "commit.dialog.message": "Commit message",
+ "commit.dialog.placeholder": "Describe your changes",
+ "commit.dialog.next": "Next steps",
+ "commit.dialog.action.commit": "Commit",
+ "commit.dialog.action.push": "Commit and push",
+ "commit.dialog.action.pr": "Commit and create PR",
+ "commit.dialog.hint.remote": "Add a remote to enable push and PR actions.",
+ "commit.dialog.hint.github": "GitHub pull requests are only available for GitHub remotes.",
+ "commit.dialog.hint.auth": "Authenticate the GitHub CLI to enable PR creation.",
+ "commit.dialog.continue": "Continue",
+ "commit.dialog.submitting": "Working...",
+ "commit.dialog.toast.commit": "Changes committed",
+ "commit.dialog.toast.push": "Changes committed and pushed",
+ "commit.dialog.toast.pr": "Changes committed and PR created",
"command.category.provider": "Provider",
"command.category.server": "Server",
"command.category.session": "Session",
diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts
index 9e85571c497..ec7c018d271 100644
--- a/packages/opencode/src/project/vcs.ts
+++ b/packages/opencode/src/project/vcs.ts
@@ -1,3 +1,4 @@
+import { NamedError } from "@opencode-ai/util/error"
import { Effect, Layer, ServiceMap } from "effect"
import { Bus } from "@/bus"
import { BusEvent } from "@/bus/bus-event"
@@ -5,12 +6,52 @@ import { InstanceContext } from "@/effect/instance-context"
import { FileWatcher } from "@/file/watcher"
import { Log } from "@/util/log"
import { git } from "@/util/git"
+import { Process } from "@/util/process"
import { Instance } from "./instance"
import z from "zod"
export namespace Vcs {
const log = Log.create({ service: "vcs" })
+ export const CommitFailedError = NamedError.create(
+ "VcsCommitFailedError",
+ z.object({
+ message: z.string(),
+ }),
+ )
+
+ export const Github = z
+ .object({
+ available: z.boolean(),
+ authenticated: z.boolean(),
+ })
+ .meta({
+ ref: "VcsGithubCapability",
+ })
+
+ export const CommitAction = z.enum(["commit", "push", "pr"]).meta({
+ ref: "VcsCommitAction",
+ })
+
+ export const CommitInput = z
+ .object({
+ message: z.string().trim().min(1),
+ includeUnstaged: z.boolean().default(true),
+ action: CommitAction.default("commit"),
+ })
+ .meta({
+ ref: "VcsCommitInput",
+ })
+
+ export const CommitResult = z
+ .object({
+ ok: z.boolean(),
+ url: z.string().optional(),
+ })
+ .meta({
+ ref: "VcsCommitResult",
+ })
+
export const Event = {
BranchUpdated: BusEvent.define(
"vcs.branch.updated",
@@ -23,6 +64,10 @@ export namespace Vcs {
export const Info = z
.object({
branch: z.string(),
+ staged: z.number().int(),
+ unstaged: z.number().int(),
+ hasRemote: z.boolean(),
+ github: Github,
})
.meta({
ref: "VcsInfo",
@@ -35,6 +80,166 @@ export namespace Vcs {
export class Service extends ServiceMap.Service()("@opencode/Vcs") {}
+ const text = (buf: Uint8Array) => Buffer.from(buf).toString().trim()
+
+ const current = async () => {
+ const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], {
+ cwd: Instance.worktree,
+ })
+ if (result.exitCode !== 0) return undefined
+ const value = result.text().trim()
+ if (!value || value === "HEAD") return undefined
+ return value
+ }
+
+ const remote = async () => {
+ const result = await git(["remote", "get-url", "origin"], {
+ cwd: Instance.worktree,
+ })
+ if (result.exitCode !== 0) return undefined
+ const value = result.text().trim()
+ return value || undefined
+ }
+
+ const changes = async () => {
+ const result = await git(["status", "--porcelain=v1", "--untracked-files=all"], {
+ cwd: Instance.worktree,
+ })
+ if (result.exitCode !== 0) return { staged: 0, unstaged: 0 }
+
+ return result
+ .text()
+ .split(/\r?\n/)
+ .filter(Boolean)
+ .reduce(
+ (acc, line) => {
+ const x = line[0]
+ const y = line[1]
+ if (x && x !== " " && x !== "?") acc.staged += 1
+ if (y && y !== " ") acc.unstaged += 1
+ if (x === "?" && y === "?") acc.unstaged += 1
+ return acc
+ },
+ { staged: 0, unstaged: 0 },
+ )
+ }
+
+ const parseRemote = (url: string) => {
+ const ssh = url.match(/^git@([^:]+):([^/]+)\/(.+?)(?:\.git)?$/)
+ if (ssh) {
+ return {
+ host: ssh[1],
+ }
+ }
+
+ const https = url.match(/^https?:\/\/([^/]+)\/([^/]+)\/(.+?)(?:\.git)?$/)
+ if (https) {
+ return {
+ host: https[1],
+ }
+ }
+ }
+
+ const github = async (url?: string) => {
+ const parsed = url ? parseRemote(url) : undefined
+ if (!parsed || !parsed.host.toLowerCase().includes("github")) {
+ return { available: false, authenticated: false }
+ }
+
+ const result = await Process.run(["gh", "auth", "status", "-h", parsed.host], {
+ cwd: Instance.worktree,
+ stdin: "ignore",
+ nothrow: true,
+ timeout: 30_000,
+ })
+
+ return {
+ available: true,
+ authenticated: result.code === 0,
+ }
+ }
+
+ const fail = (message: string): never => {
+ throw new CommitFailedError({ message })
+ }
+
+ const check = async (cmd: string[], label: string) => {
+ const result = await Process.run(cmd, {
+ cwd: Instance.worktree,
+ stdin: "ignore",
+ nothrow: true,
+ timeout: 30_000,
+ })
+ if (result.code === 0) return text(result.stdout)
+ fail(`${label}: ${text(result.stderr) || text(result.stdout) || "unknown error"}`)
+ }
+
+ const createPr = async (message: string) => {
+ const view = await Process.run(["gh", "pr", "view", "--json", "url", "--jq", ".url"], {
+ cwd: Instance.worktree,
+ stdin: "ignore",
+ nothrow: true,
+ timeout: 30_000,
+ })
+ if (view.code === 0) {
+ const url = text(view.stdout)
+ if (url) return url
+ }
+
+ const created = await check(["gh", "pr", "create", "--title", message, "--body", message], "gh pr create failed")
+ if (created) return created
+ return await check(["gh", "pr", "view", "--json", "url", "--jq", ".url"], "gh pr view failed")
+ }
+
+ export async function info(): Promise {
+ const [branch, url, state] = await Promise.all([current(), remote(), changes()])
+ return {
+ branch: branch ?? "",
+ staged: state.staged,
+ unstaged: state.unstaged,
+ hasRemote: !!url,
+ github: await github(url),
+ }
+ }
+
+ export async function commit(input: z.infer): Promise> {
+ const message = input.message.trim()
+ if (!message) fail("Commit message is required")
+
+ if (input.includeUnstaged) {
+ await check(["git", "add", "-A"], "git add failed")
+ }
+
+ const state = await changes()
+ if (state.staged === 0) {
+ fail(input.includeUnstaged ? "No changes to commit" : "No staged changes to commit")
+ }
+
+ await check(["git", "commit", "-m", message], "git commit failed")
+
+ if (input.action === "commit") {
+ return { ok: true }
+ }
+
+ const url = await remote()
+ if (!url) fail("No git remote configured")
+
+ await check(["git", "push", "-u", "origin", "HEAD"], "git push failed")
+
+ if (input.action === "push") {
+ return { ok: true }
+ }
+
+ const gh = await github(url)
+ if (!gh.available) fail("GitHub pull requests are not available for this repository")
+ if (!gh.authenticated) fail("GitHub CLI is not authenticated")
+
+ return {
+ ok: true,
+ url: await createPr(message),
+ }
+ }
+
export const layer = Layer.effect(
Service,
Effect.gen(function* () {
@@ -42,16 +247,7 @@ export namespace Vcs {
let currentBranch: string | undefined
if (instance.project.vcs === "git") {
- const getCurrentBranch = async () => {
- const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], {
- cwd: instance.project.worktree,
- })
- if (result.exitCode !== 0) return undefined
- const text = result.text().trim()
- return text || undefined
- }
-
- currentBranch = yield* Effect.promise(() => getCurrentBranch())
+ currentBranch = yield* Effect.promise(() => current())
log.info("initialized", { branch: currentBranch })
yield* Effect.acquireRelease(
@@ -60,7 +256,7 @@ export namespace Vcs {
FileWatcher.Event.Updated,
Instance.bind(async (evt) => {
if (!evt.properties.file.endsWith("HEAD")) return
- const next = await getCurrentBranch()
+ const next = await current()
if (next !== currentBranch) {
log.info("branch changed", { from: currentBranch, to: next })
currentBranch = next
diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts
index c485654fdf8..b3850305f49 100644
--- a/packages/opencode/src/server/server.ts
+++ b/packages/opencode/src/server/server.ts
@@ -318,7 +318,8 @@ export namespace Server {
"/vcs",
describeRoute({
summary: "Get VCS info",
- description: "Retrieve version control system (VCS) information for the current project, such as git branch.",
+ description:
+ "Retrieve version control system (VCS) information for the current project, including branch and change counts.",
operationId: "vcs.get",
responses: {
200: {
@@ -332,10 +333,37 @@ export namespace Server {
},
}),
async (c) => {
- const branch = await runPromiseInstance(Vcs.Service.use((s) => s.branch()))
- return c.json({
- branch,
- })
+ return c.json(await Vcs.info())
+ },
+ )
+ .post(
+ "/vcs/commit",
+ describeRoute({
+ summary: "Commit workspace changes",
+ description: "Commit staged changes, optionally include unstaged files, then optionally push or create a PR.",
+ operationId: "vcs.commit",
+ responses: {
+ 200: {
+ description: "Commit result",
+ content: {
+ "application/json": {
+ schema: resolver(Vcs.CommitResult),
+ },
+ },
+ },
+ ...errors(400),
+ },
+ }),
+ validator("json", Vcs.CommitInput),
+ async (c) => {
+ try {
+ return c.json(await Vcs.commit(c.req.valid("json")))
+ } catch (err) {
+ if (err instanceof Vcs.CommitFailedError) {
+ return c.json({ code: "COMMIT_FAILED", message: err.data.message }, { status: 400 })
+ }
+ throw err
+ }
},
)
.get(
diff --git a/packages/opencode/test/project/vcs.test.ts b/packages/opencode/test/project/vcs.test.ts
index 90f445ed782..140c7bc2948 100644
--- a/packages/opencode/test/project/vcs.test.ts
+++ b/packages/opencode/test/project/vcs.test.ts
@@ -62,6 +62,58 @@ function nextBranchUpdate(directory: string, timeout = 10_000) {
})
}
+async function remote(dir: string, github?: boolean) {
+ const root = path.join(dir, "remote")
+ const repo = path.join(root, "repo.git")
+ await fs.mkdir(root, { recursive: true })
+ await $`git init --bare ${repo}`.quiet()
+ if (!github) {
+ await $`git remote add origin ${repo}`.cwd(dir).quiet()
+ return repo
+ }
+
+ const url = "git@github.com:test/repo.git"
+ await $`git remote add origin ${url}`.cwd(dir).quiet()
+ await $`git remote set-url --push origin ${repo}`.cwd(dir).quiet()
+ return repo
+}
+
+async function seed(dir: string) {
+ await Bun.write(path.join(dir, "a.txt"), "old-a\n")
+ await Bun.write(path.join(dir, "b.txt"), "old-b\n")
+ await $`git add a.txt b.txt`.cwd(dir).quiet()
+ await $`git commit -m seed`.cwd(dir).quiet()
+}
+
+async function stage(dir: string) {
+ await Bun.write(path.join(dir, "a.txt"), "new-a\n")
+ await Bun.write(path.join(dir, "b.txt"), "new-b\n")
+ await $`git add a.txt`.cwd(dir).quiet()
+}
+
+async function withGh(dir: string, body: string, run: () => Promise) {
+ const bin = path.join(dir, "bin")
+ const js = path.join(bin, "gh.js")
+ await fs.mkdir(bin, { recursive: true })
+ const win = process.platform === "win32"
+ const file = path.join(bin, win ? "gh.cmd" : "gh")
+ await Bun.write(js, body)
+ const cmd = win
+ ? `@echo off\r\n"${process.execPath}" "${js}" %*\r\n`
+ : `#!/bin/sh
+exec "${process.execPath}" "${js}" "$@"
+`
+ await Bun.write(file, cmd)
+ if (!win) await fs.chmod(file, 0o755)
+ const prev = process.env.PATH
+ process.env.PATH = `${bin}${path.delimiter}${prev ?? ""}`
+ try {
+ await run()
+ } finally {
+ process.env.PATH = prev
+ }
+}
+
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
@@ -121,3 +173,116 @@ describeVcs("Vcs", () => {
})
})
})
+
+describe("Vcs.commit", () => {
+ afterEach(() => Instance.disposeAll())
+
+ test("commits staged changes without including unstaged files", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await seed(tmp.path)
+ await stage(tmp.path)
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: () => Vcs.commit({ message: "staged only", includeUnstaged: false, action: "commit" }),
+ })
+
+ expect(await $`git show HEAD:a.txt`.cwd(tmp.path).text()).toBe("new-a\n")
+ expect(await $`git show HEAD:b.txt`.cwd(tmp.path).text()).toBe("old-b\n")
+ expect(await $`git status --short`.cwd(tmp.path).text()).toContain(" M b.txt")
+ })
+
+ test("commits staged and unstaged changes when requested", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await seed(tmp.path)
+ await stage(tmp.path)
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: () => Vcs.commit({ message: "all changes", includeUnstaged: true, action: "commit" }),
+ })
+
+ expect(await $`git show HEAD:a.txt`.cwd(tmp.path).text()).toBe("new-a\n")
+ expect(await $`git show HEAD:b.txt`.cwd(tmp.path).text()).toBe("new-b\n")
+ expect(await $`git status --short`.cwd(tmp.path).text()).toBe("")
+ })
+
+ test("pushes after commit when action is push", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await seed(tmp.path)
+ await stage(tmp.path)
+ const repo = await remote(tmp.path)
+
+ await Instance.provide({
+ directory: tmp.path,
+ fn: () => Vcs.commit({ message: "push changes", includeUnstaged: true, action: "push" }),
+ })
+
+ expect((await $`git --git-dir=${repo} log -1 --format=%s`.text()).trim()).toBe("push changes")
+ })
+
+ test("creates a PR after push when action is pr", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await seed(tmp.path)
+ await stage(tmp.path)
+ const repo = await remote(tmp.path, true)
+
+ await withGh(
+ tmp.path,
+ `const args = process.argv.slice(2)
+if (args[0] === "auth" && args[1] === "status") process.exit(0)
+if (args[0] === "pr" && args[1] === "view") process.exit(1)
+if (args[0] === "pr" && args[1] === "create") {
+ process.stdout.write("https://github.com/test/repo/pull/1\\n")
+ process.exit(0)
+}
+process.exit(1)
+`,
+ async () => {
+ const result = await Instance.provide({
+ directory: tmp.path,
+ fn: () => Vcs.commit({ message: "open pr", includeUnstaged: true, action: "pr" }),
+ })
+
+ expect(result.url).toBe("https://github.com/test/repo/pull/1")
+ expect((await $`git --git-dir=${repo} log -1 --format=%s`.text()).trim()).toBe("open pr")
+ },
+ )
+ })
+
+ test("fails when staged-only commit has no staged changes", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await seed(tmp.path)
+ await Bun.write(path.join(tmp.path, "b.txt"), "new-b\n")
+
+ const err = await Instance.provide({
+ directory: tmp.path,
+ fn: () => Vcs.commit({ message: "staged only", includeUnstaged: false, action: "commit" }),
+ }).catch((err) => err)
+
+ expect(err).toBeInstanceOf(Vcs.CommitFailedError)
+ expect(err.data.message).toBe("No staged changes to commit")
+ })
+
+ test("fails PR creation when GitHub CLI is not authenticated", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await seed(tmp.path)
+ await stage(tmp.path)
+ await remote(tmp.path, true)
+
+ await withGh(
+ tmp.path,
+ `process.exit(1)
+`,
+ async () => {
+ const err = await Instance.provide({
+ directory: tmp.path,
+ fn: () => Vcs.commit({ message: "open pr", includeUnstaged: true, action: "pr" }),
+ }).catch((err) => err)
+
+ expect(err).toBeInstanceOf(Vcs.CommitFailedError)
+ expect(err.data.message).toBe("GitHub CLI is not authenticated")
+ },
+ )
+ })
+})
diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts
index aa759bb1e09..8412720c8d8 100644
--- a/packages/sdk/js/src/v2/gen/sdk.gen.ts
+++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts
@@ -172,6 +172,9 @@ import type {
TuiSelectSessionResponses,
TuiShowToastResponses,
TuiSubmitPromptResponses,
+ VcsCommitErrors,
+ VcsCommitInput,
+ VcsCommitResponses,
VcsGetResponses,
WorktreeCreateErrors,
WorktreeCreateInput,
@@ -3635,7 +3638,7 @@ export class Vcs extends HeyApiClient {
/**
* Get VCS info
*
- * Retrieve version control system (VCS) information for the current project, such as git branch.
+ * Retrieve version control system (VCS) information for the current project, including branch and change counts.
*/
public get(
parameters?: {
@@ -3661,6 +3664,43 @@ export class Vcs extends HeyApiClient {
...params,
})
}
+
+ /**
+ * Commit workspace changes
+ *
+ * Commit staged changes, optionally include unstaged files, then optionally push or create a PR.
+ */
+ public commit(
+ parameters?: {
+ directory?: string
+ workspace?: string
+ vcsCommitInput?: VcsCommitInput
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "query", key: "directory" },
+ { in: "query", key: "workspace" },
+ { key: "vcsCommitInput", map: "body" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).post({
+ url: "/vcs/commit",
+ ...options,
+ ...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
+ },
+ })
+ }
}
export class Command extends HeyApiClient {
diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts
index 41aa248171c..073eebca75c 100644
--- a/packages/sdk/js/src/v2/gen/types.gen.ts
+++ b/packages/sdk/js/src/v2/gen/types.gen.ts
@@ -1888,8 +1888,30 @@ export type Path = {
directory: string
}
+export type VcsGithubCapability = {
+ available: boolean
+ authenticated: boolean
+}
+
export type VcsInfo = {
branch: string
+ staged: number
+ unstaged: number
+ hasRemote: boolean
+ github: VcsGithubCapability
+}
+
+export type VcsCommitResult = {
+ ok: boolean
+ url?: string
+}
+
+export type VcsCommitAction = "commit" | "push" | "pr"
+
+export type VcsCommitInput = {
+ message: string
+ includeUnstaged?: boolean
+ action?: VcsCommitAction
}
export type Command = {
@@ -4833,6 +4855,34 @@ export type VcsGetResponses = {
export type VcsGetResponse = VcsGetResponses[keyof VcsGetResponses]
+export type VcsCommitData = {
+ body?: VcsCommitInput
+ path?: never
+ query?: {
+ directory?: string
+ workspace?: string
+ }
+ url: "/vcs/commit"
+}
+
+export type VcsCommitErrors = {
+ /**
+ * Bad request
+ */
+ 400: BadRequestError
+}
+
+export type VcsCommitError = VcsCommitErrors[keyof VcsCommitErrors]
+
+export type VcsCommitResponses = {
+ /**
+ * Commit result
+ */
+ 200: VcsCommitResult
+}
+
+export type VcsCommitResponse = VcsCommitResponses[keyof VcsCommitResponses]
+
export type CommandListData = {
body?: never
path?: never