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