From c08a2cd14a133fd19d07a62e73a4f8d823ec987f Mon Sep 17 00:00:00 2001 From: anduimagui Date: Wed, 18 Mar 2026 23:03:06 +0000 Subject: [PATCH 1/4] feat(app): add commit actions dialog Let users review branch state, include unstaged changes, and choose whether a commit should stop locally, push, or open a PR without leaving the app. --- packages/app/src/components/dialog-commit.tsx | 269 ++++++++++++++++++ .../src/components/session/session-header.tsx | 17 ++ .../context/global-sync/event-reducer.test.ts | 15 +- .../src/context/global-sync/event-reducer.ts | 2 +- packages/app/src/i18n/en.ts | 23 ++ packages/opencode/src/project/vcs.ts | 218 +++++++++++++- packages/opencode/src/server/server.ts | 38 ++- packages/sdk/js/src/v2/gen/sdk.gen.ts | 42 ++- packages/sdk/js/src/v2/gen/types.gen.ts | 50 ++++ 9 files changed, 652 insertions(+), 22 deletions(-) create mode 100644 packages/app/src/components/dialog-commit.tsx diff --git a/packages/app/src/components/dialog-commit.tsx b/packages/app/src/components/dialog-commit.tsx new file mode 100644 index 00000000000..f15b1c3996e --- /dev/null +++ b/packages/app/src/components/dialog-commit.tsx @@ -0,0 +1,269 @@ +import { Button } from "@opencode-ai/ui/button" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { Dialog } from "@opencode-ai/ui/dialog" +import { Icon } from "@opencode-ai/ui/icon" +import { Spinner } from "@opencode-ai/ui/spinner" +import { Switch } from "@opencode-ai/ui/switch" +import { TextField } from "@opencode-ai/ui/text-field" +import { showToast } from "@opencode-ai/ui/toast" +import { createMemo, For, onMount, Show } from "solid-js" +import { createStore } from "solid-js/store" +import { useLanguage } from "@/context/language" +import { useSDK } from "@/context/sdk" +import { useSync } from "@/context/sync" + +const err = (value: unknown) => { + if (typeof value === "object" && value && "error" in value) { + const next = value as { error?: { message?: string } } + if (next.error?.message) return next.error.message + } + if (value instanceof Error) return value.message + return "Request failed" +} + +export function CommitDialog() { + const dialog = useDialog() + const language = useLanguage() + const sdk = useSDK() + const sync = useSync() + + const [store, setStore] = createStore({ + status: "loading" as "loading" | "ready" | "error", + branch: "", + staged: 0, + unstaged: 0, + hasRemote: false, + githubAvailable: false, + githubAuthenticated: false, + files: 0, + added: 0, + removed: 0, + includeUnstaged: true, + action: "commit" as "commit" | "push" | "pr", + message: "", + submitting: false, + error: undefined as string | undefined, + }) + + onMount(() => { + Promise.all([ + sdk.client.vcs.get({ directory: sdk.directory }), + sdk.client.file.status({ directory: sdk.directory }), + ]) + .then(([vcs, files]) => { + const info = vcs.data + const list = files.data ?? [] + const branch = info?.branch ?? sync.data.vcs?.branch ?? "" + const unstaged = info?.unstaged ?? 0 + setStore({ + status: "ready", + branch, + staged: info?.staged ?? 0, + unstaged, + hasRemote: info?.hasRemote ?? false, + githubAvailable: info?.github.available ?? false, + githubAuthenticated: info?.github.authenticated ?? false, + files: list.length, + added: list.reduce((sum, item) => sum + (item?.added ?? 0), 0), + removed: list.reduce((sum, item) => sum + (item?.removed ?? 0), 0), + includeUnstaged: unstaged > 0, + message: branch, + }) + }) + .catch((value: unknown) => { + setStore({ status: "error", error: err(value) }) + }) + }) + + const summary = createMemo(() => { + if (store.includeUnstaged) { + return language.t("commit.dialog.summary.all", { + staged: String(store.staged), + unstaged: String(store.unstaged), + }) + } + + return language.t("commit.dialog.summary.staged", { + staged: String(store.staged), + }) + }) + + const ready = createMemo(() => store.staged + (store.includeUnstaged ? store.unstaged : 0) > 0) + + const options = createMemo(() => { + const pushDisabled = !store.branch || !store.hasRemote + const prDisabled = !store.branch || !store.hasRemote || !store.githubAvailable || !store.githubAuthenticated + return [ + { + id: "commit" as const, + label: language.t("commit.dialog.action.commit"), + disabled: false, + }, + { + id: "push" as const, + label: language.t("commit.dialog.action.push"), + disabled: pushDisabled, + }, + { + id: "pr" as const, + label: language.t("commit.dialog.action.pr"), + disabled: prDisabled, + }, + ] + }) + + const hint = createMemo(() => { + if (store.action === "push" && !store.hasRemote) return language.t("commit.dialog.hint.remote") + if (store.action !== "pr") return undefined + if (!store.hasRemote) return language.t("commit.dialog.hint.remote") + if (!store.githubAvailable) return language.t("commit.dialog.hint.github") + if (!store.githubAuthenticated) return language.t("commit.dialog.hint.auth") + }) + + const submit = async () => { + if (!store.message.trim() || store.submitting) return + setStore("submitting", true) + setStore("error", undefined) + + try { + const result = await sdk.client.vcs.commit({ + directory: sdk.directory, + vcsCommitInput: { + message: store.message.trim(), + includeUnstaged: store.includeUnstaged, + action: store.action, + }, + }) + const next = await sdk.client.vcs.get({ directory: sdk.directory }) + if (next.data) sync.set("vcs", next.data) + + showToast({ + variant: "success", + icon: "circle-check", + title: + store.action === "commit" + ? language.t("commit.dialog.toast.commit") + : store.action === "push" + ? language.t("commit.dialog.toast.push") + : language.t("commit.dialog.toast.pr"), + description: result.data?.url, + }) + dialog.close() + } catch (value: unknown) { + setStore("error", err(value)) + } finally { + setStore("submitting", false) + } + } + + return ( + +
+ +
+ + {language.t("commit.dialog.loading")} +
+
+ + + <> +
+ {language.t("commit.dialog.branch")} +
+ + + {store.branch || language.t("commit.dialog.branchUnknown")} + +
+
+ +
+ {language.t("commit.dialog.changes")} +
+ {language.t("commit.dialog.files", { count: String(store.files) })} + +{store.added} + -{store.removed} +
+
+ + setStore("includeUnstaged", value)} + > + {language.t("commit.dialog.includeUnstaged")} + + +

{summary()}

+ +
+ + setStore("message", e.currentTarget.value)} + placeholder={language.t("commit.dialog.placeholder")} + autofocus + onKeyDown={(e: KeyboardEvent) => { + if (e.key !== "Enter" || e.shiftKey) return + e.preventDefault() + submit() + }} + /> +
+ +
+ {language.t("commit.dialog.next")} +
+ + {(item) => ( + + )} + +
+
+ + +

{hint()}

+
+ + +
+ + + {store.error} + +
+
+ +
+ + +
+ +
+
+
+ ) +} diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 495b3234058..44c31ead24d 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -11,6 +11,7 @@ import { getFilename } from "@opencode-ai/util/path" import { createEffect, createMemo, For, onCleanup, Show } from "solid-js" import { createStore } from "solid-js/store" import { Portal } from "solid-js/web" +import { useDialog } from "@opencode-ai/ui/context/dialog" import { useCommand } from "@/context/command" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" @@ -23,6 +24,7 @@ import { useSessionLayout } from "@/pages/session/session-layout" import { messageAgentColor } from "@/utils/agent" import { decode64 } from "@/utils/base64" import { Persist, persisted } from "@/utils/persist" +import { CommitDialog } from "../dialog-commit" import { StatusPopover } from "../status-popover" const OPEN_APPS = [ @@ -131,6 +133,7 @@ const showRequestError = (language: ReturnType, 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() { - -

{hint()}

-
-
diff --git a/packages/opencode/test/project/vcs.test.ts b/packages/opencode/test/project/vcs.test.ts index 90f445ed782..3a33d0ecbb4 100644 --- a/packages/opencode/test/project/vcs.test.ts +++ b/packages/opencode/test/project/vcs.test.ts @@ -62,6 +62,50 @@ 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 file = path.join(bin, "gh") + await fs.mkdir(bin, { recursive: true }) + await Bun.write(file, body) + await fs.chmod(file, 0o755) + const prev = process.env.PATH + process.env.PATH = `${bin}:${prev ?? ""}` + try { + await run() + } finally { + process.env.PATH = prev + } +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -121,3 +165,124 @@ 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, + `#!/bin/sh +if [ "$1" = "auth" ] && [ "$2" = "status" ]; then + exit 0 +fi +if [ "$1" = "pr" ] && [ "$2" = "view" ]; then + exit 1 +fi +if [ "$1" = "pr" ] && [ "$2" = "create" ]; then + printf '%s\n' 'https://github.com/test/repo/pull/1' + exit 0 +fi +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, + `#!/bin/sh +if [ "$1" = "auth" ] && [ "$2" = "status" ]; then + exit 1 +fi +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") + }, + ) + }) +}) From f3337c262c3e13b4f58890840733c95be603c167 Mon Sep 17 00:00:00 2001 From: anduimagui Date: Wed, 18 Mar 2026 23:36:35 +0000 Subject: [PATCH 3/4] fix(opencode): make VCS PR tests cross-platform --- packages/opencode/test/project/vcs.test.ts | 28 +++++++++++++++++----- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/packages/opencode/test/project/vcs.test.ts b/packages/opencode/test/project/vcs.test.ts index 3a33d0ecbb4..8034ade4c0a 100644 --- a/packages/opencode/test/project/vcs.test.ts +++ b/packages/opencode/test/project/vcs.test.ts @@ -93,12 +93,13 @@ async function stage(dir: string) { async function withGh(dir: string, body: string, run: () => Promise) { const bin = path.join(dir, "bin") - const file = path.join(bin, "gh") await fs.mkdir(bin, { recursive: true }) - await Bun.write(file, body) - await fs.chmod(file, 0o755) + const win = process.platform === "win32" + const file = path.join(bin, win ? "gh.cmd" : "gh") + await Bun.write(file, win ? body.replaceAll("\n", "\r\n") : body) + if (!win) await fs.chmod(file, 0o755) const prev = process.env.PATH - process.env.PATH = `${bin}:${prev ?? ""}` + process.env.PATH = `${bin}${path.delimiter}${prev ?? ""}` try { await run() } finally { @@ -221,7 +222,17 @@ describe("Vcs.commit", () => { await withGh( tmp.path, - `#!/bin/sh + process.platform === "win32" + ? `@echo off +if "%1"=="auth" if "%2"=="status" exit /b 0 +if "%1"=="pr" if "%2"=="view" exit /b 1 +if "%1"=="pr" if "%2"=="create" ( + echo https://github.com/test/repo/pull/1 + exit /b 0 +) +exit /b 1 +` + : `#!/bin/sh if [ "$1" = "auth" ] && [ "$2" = "status" ]; then exit 0 fi @@ -268,7 +279,12 @@ exit 1 await withGh( tmp.path, - `#!/bin/sh + process.platform === "win32" + ? `@echo off +if "%1"=="auth" if "%2"=="status" exit /b 1 +exit /b 1 +` + : `#!/bin/sh if [ "$1" = "auth" ] && [ "$2" = "status" ]; then exit 1 fi From bc926b18fe33b7cf693cd38977290d5bd0053ea8 Mon Sep 17 00:00:00 2001 From: anduimagui Date: Wed, 18 Mar 2026 23:44:54 +0000 Subject: [PATCH 4/4] fix(opencode): stabilize VCS PR tests on Windows --- packages/opencode/test/project/vcs.test.ts | 50 ++++++++-------------- 1 file changed, 17 insertions(+), 33 deletions(-) diff --git a/packages/opencode/test/project/vcs.test.ts b/packages/opencode/test/project/vcs.test.ts index 8034ade4c0a..140c7bc2948 100644 --- a/packages/opencode/test/project/vcs.test.ts +++ b/packages/opencode/test/project/vcs.test.ts @@ -93,10 +93,17 @@ async function stage(dir: string) { 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(file, win ? body.replaceAll("\n", "\r\n") : body) + 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 ?? ""}` @@ -222,28 +229,14 @@ describe("Vcs.commit", () => { await withGh( tmp.path, - process.platform === "win32" - ? `@echo off -if "%1"=="auth" if "%2"=="status" exit /b 0 -if "%1"=="pr" if "%2"=="view" exit /b 1 -if "%1"=="pr" if "%2"=="create" ( - echo https://github.com/test/repo/pull/1 - exit /b 0 -) -exit /b 1 -` - : `#!/bin/sh -if [ "$1" = "auth" ] && [ "$2" = "status" ]; then - exit 0 -fi -if [ "$1" = "pr" ] && [ "$2" = "view" ]; then - exit 1 -fi -if [ "$1" = "pr" ] && [ "$2" = "create" ]; then - printf '%s\n' 'https://github.com/test/repo/pull/1' - exit 0 -fi -exit 1 + `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({ @@ -279,16 +272,7 @@ exit 1 await withGh( tmp.path, - process.platform === "win32" - ? `@echo off -if "%1"=="auth" if "%2"=="status" exit /b 1 -exit /b 1 -` - : `#!/bin/sh -if [ "$1" = "auth" ] && [ "$2" = "status" ]; then - exit 1 -fi -exit 1 + `process.exit(1) `, async () => { const err = await Instance.provide({