diff --git a/packages/opencode/src/cli/cmd/tui/app.tsx b/packages/opencode/src/cli/cmd/tui/app.tsx
index f72dc8365..13b02154e 100644
--- a/packages/opencode/src/cli/cmd/tui/app.tsx
+++ b/packages/opencode/src/cli/cmd/tui/app.tsx
@@ -478,7 +478,10 @@ function App() {
v{Installation.VERSION}
- {process.cwd().replace(Global.Path.home, "~")}
+
+ {process.cwd().replace(Global.Path.home, "~")}
+ {sync.data.vcs?.vcs?.branch ? `:${sync.data.vcs.vcs.branch}` : ""}
+
diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx
index c718f7700..adedfad09 100644
--- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx
+++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx
@@ -14,6 +14,7 @@ import type {
SessionStatus,
ProviderListResponse,
ProviderAuthMethod,
+ VcsInfo,
} from "@opencode-ai/sdk"
import { createStore, produce, reconcile } from "solid-js/store"
import { useSDK } from "@tui/context/sdk"
@@ -59,6 +60,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
[key: string]: McpStatus
}
formatter: FormatterStatus[]
+ vcs: VcsInfo | undefined
}>({
provider_next: {
all: [],
@@ -82,6 +84,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
lsp: [],
mcp: {},
formatter: [],
+ vcs: undefined,
})
const sdk = useSDK()
@@ -238,6 +241,11 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
sdk.client.lsp.status().then((x) => setStore("lsp", x.data!))
break
}
+
+ case "vcs.changed": {
+ setStore("vcs", "vcs", { branch: event.properties.branch })
+ break
+ }
}
})
@@ -276,6 +284,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
sdk.client.formatter.status().then((x) => setStore("formatter", x.data!)),
sdk.client.session.status().then((x) => setStore("session_status", x.data!)),
sdk.client.provider.auth().then((x) => setStore("provider_auth", x.data ?? {})),
+ sdk.client.vcs.get().then((x) => setStore("vcs", x.data)),
]).then(() => {
setStore("status", "complete")
})
diff --git a/packages/opencode/src/project/bootstrap.ts b/packages/opencode/src/project/bootstrap.ts
index 5840c9768..56fe4d13e 100644
--- a/packages/opencode/src/project/bootstrap.ts
+++ b/packages/opencode/src/project/bootstrap.ts
@@ -4,11 +4,11 @@ import { Format } from "../format"
import { LSP } from "../lsp"
import { FileWatcher } from "../file/watcher"
import { File } from "../file"
-import { Flag } from "../flag/flag"
import { Project } from "./project"
import { Bus } from "../bus"
import { Command } from "../command"
import { Instance } from "./instance"
+import { Vcs } from "./vcs"
import { Log } from "@/util/log"
import { ShareNext } from "@/share/share-next"
@@ -21,6 +21,7 @@ export async function InstanceBootstrap() {
await LSP.init()
FileWatcher.init()
File.init()
+ Vcs.init()
Bus.subscribe(Command.Event.Executed, async (payload) => {
if (payload.properties.name === Command.Default.INIT) {
diff --git a/packages/opencode/src/project/vcs.ts b/packages/opencode/src/project/vcs.ts
new file mode 100644
index 000000000..153583564
--- /dev/null
+++ b/packages/opencode/src/project/vcs.ts
@@ -0,0 +1,86 @@
+import { $ } from "bun"
+import { watch, type FSWatcher } from "fs"
+import path from "path"
+import z from "zod"
+import { Log } from "@/util/log"
+import { Bus } from "@/bus"
+import { Instance } from "./instance"
+
+const log = Log.create({ service: "vcs" })
+
+export namespace Vcs {
+ export const Event = {
+ Changed: Bus.event(
+ "vcs.changed",
+ z.object({
+ branch: z.string().optional(),
+ }),
+ ),
+ }
+
+ async function currentBranch() {
+ return $`git rev-parse --abbrev-ref HEAD`
+ .quiet()
+ .nothrow()
+ .cwd(Instance.worktree)
+ .text()
+ .then((x) => x.trim())
+ .catch(() => undefined)
+ }
+
+ const state = Instance.state(
+ async () => {
+ if (Instance.project.vcs !== "git") {
+ return { branch: async () => undefined, watcher: undefined }
+ }
+ let current = await currentBranch()
+ log.info("initialized", { branch: current })
+
+ const gitDir = await $`git rev-parse --git-dir`
+ .quiet()
+ .nothrow()
+ .cwd(Instance.worktree)
+ .text()
+ .then((x) => x.trim())
+ .catch(() => undefined)
+ if (!gitDir) {
+ log.warn("failed to resolve git directory")
+ return { branch: async () => current, watcher: undefined }
+ }
+
+ const gitHead = path.join(gitDir, "HEAD")
+ let watcher: FSWatcher | undefined
+ // we should probably centralize file watching (see watcher.ts)
+ // but parcel still marked experimental rn
+ try {
+ watcher = watch(gitHead, async () => {
+ const next = await currentBranch()
+ if (next !== current) {
+ log.info("branch changed", { from: current, to: next })
+ current = next
+ Bus.publish(Event.Changed, { branch: next })
+ }
+ })
+ log.info("watching", { path: gitHead })
+ } catch (e) {
+ log.warn("failed to watch git HEAD", { error: e })
+ }
+
+ return {
+ branch: async () => current,
+ watcher,
+ }
+ },
+ async (state) => {
+ state.watcher?.close()
+ },
+ )
+
+ export async function init() {
+ return state()
+ }
+
+ export async function branch() {
+ return await state().then((s) => s.branch())
+ }
+}
diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts
index 65c635ee1..1c8d2a003 100644
--- a/packages/opencode/src/server/server.ts
+++ b/packages/opencode/src/server/server.ts
@@ -20,6 +20,7 @@ import { MessageV2 } from "../session/message-v2"
import { TuiRoute } from "./tui"
import { Permission } from "../permission"
import { Instance } from "../project/instance"
+import { Vcs } from "../project/vcs"
import { Agent } from "../agent/agent"
import { Auth } from "../auth"
import { Command } from "../command"
@@ -365,6 +366,47 @@ export namespace Server {
})
},
)
+ .get(
+ "/vcs",
+ describeRoute({
+ description: "Get VCS info for the current instance",
+ operationId: "vcs.get",
+ responses: {
+ 200: {
+ description: "VCS info",
+ content: {
+ "application/json": {
+ schema: resolver(
+ z
+ .object({
+ worktree: z.string(),
+ directory: z.string(),
+ projectID: z.string(),
+ vcs: z
+ .object({
+ branch: z.string(),
+ })
+ .optional(),
+ })
+ .meta({
+ ref: "VcsInfo",
+ }),
+ ),
+ },
+ },
+ },
+ },
+ }),
+ async (c) => {
+ const branch = await Vcs.branch()
+ return c.json({
+ worktree: Instance.worktree,
+ directory: Instance.directory,
+ projectID: Instance.project.id,
+ vcs: Instance.project.vcs ? { branch } : undefined,
+ })
+ },
+ )
.get(
"/session",
describeRoute({
diff --git a/packages/sdk/js/src/gen/sdk.gen.ts b/packages/sdk/js/src/gen/sdk.gen.ts
index afc9696f1..0dc470566 100644
--- a/packages/sdk/js/src/gen/sdk.gen.ts
+++ b/packages/sdk/js/src/gen/sdk.gen.ts
@@ -23,6 +23,8 @@ import type {
InstanceDisposeResponses,
PathGetData,
PathGetResponses,
+ VcsGetData,
+ VcsGetResponses,
SessionListData,
SessionListResponses,
SessionCreateData,
@@ -311,6 +313,18 @@ class Path extends _HeyApiClient {
}
}
+class Vcs extends _HeyApiClient {
+ /**
+ * Get VCS info for the current instance
+ */
+ public get(options?: Options) {
+ return (options?.client ?? this._client).get({
+ url: "/vcs",
+ ...options,
+ })
+ }
+}
+
class Session extends _HeyApiClient {
/**
* List all sessions
@@ -995,6 +1009,7 @@ export class OpencodeClient extends _HeyApiClient {
tool = new Tool({ client: this._client })
instance = new Instance({ client: this._client })
path = new Path({ client: this._client })
+ vcs = new Vcs({ client: this._client })
session = new Session({ client: this._client })
command = new Command({ client: this._client })
provider = new Provider({ client: this._client })
diff --git a/packages/sdk/js/src/gen/types.gen.ts b/packages/sdk/js/src/gen/types.gen.ts
index 6e3b8c071..7e211148f 100644
--- a/packages/sdk/js/src/gen/types.gen.ts
+++ b/packages/sdk/js/src/gen/types.gen.ts
@@ -589,6 +589,13 @@ export type EventSessionError = {
}
}
+export type EventVcsChanged = {
+ type: "vcs.changed"
+ properties: {
+ branch?: string
+ }
+}
+
export type EventTuiPromptAppend = {
type: "tui.prompt.append"
properties: {
@@ -670,6 +677,7 @@ export type Event =
| EventSessionDeleted
| EventSessionDiff
| EventSessionError
+ | EventVcsChanged
| EventTuiPromptAppend
| EventTuiCommandExecute
| EventTuiToastShow
@@ -1251,6 +1259,15 @@ export type Path = {
directory: string
}
+export type VcsInfo = {
+ worktree: string
+ directory: string
+ projectID: string
+ vcs?: {
+ branch: string
+ }
+}
+
export type NotFoundError = {
name: "NotFoundError"
data: {
@@ -1687,6 +1704,24 @@ export type PathGetResponses = {
export type PathGetResponse = PathGetResponses[keyof PathGetResponses]
+export type VcsGetData = {
+ body?: never
+ path?: never
+ query?: {
+ directory?: string
+ }
+ url: "/vcs"
+}
+
+export type VcsGetResponses = {
+ /**
+ * VCS info
+ */
+ 200: VcsInfo
+}
+
+export type VcsGetResponse = VcsGetResponses[keyof VcsGetResponses]
+
export type SessionListData = {
body?: never
path?: never