diff --git a/packages/core/src/vcs.ts b/packages/core/src/vcs.ts
index 643c4ae27ab..1d6c754cf4b 100644
--- a/packages/core/src/vcs.ts
+++ b/packages/core/src/vcs.ts
@@ -1,12 +1,16 @@
export * as Vcs from "./vcs"
-import { Context, Effect, Layer } from "effect"
+import path from "path"
+import { Context, Effect, Layer, Ref, Stream } from "effect"
import { FileDiff } from "@opencode-ai/schema/file-diff"
+import { FileSystem } from "@opencode-ai/schema/filesystem"
import { FileStatus, Info, Mode } from "@opencode-ai/schema/vcs"
+import { VcsEvent } from "@opencode-ai/schema/vcs-event"
import { makeLocationNode } from "@opencode-ai/util/effect/app-node"
import { FSUtil } from "@opencode-ai/util/fs-util"
import { Location } from "./location"
import { AppProcess } from "@opencode-ai/util/process"
+import { Bus } from "./bus"
import { VcsGit } from "./vcs/git"
import { VcsHg } from "./vcs/hg"
@@ -39,11 +43,44 @@ const layer = Layer.effect(
const proc = yield* AppProcess.Service
const fs = yield* FSUtil.Service
const location = yield* Location.Service
+ const bus = yield* Bus.Service
const impl = adapter(proc, fs, location)
+ const git = location.vcs?.type === "git" ? location.vcs : undefined
+ const cache = git && impl ? yield* Ref.make(yield* impl.info()) : undefined
+
+ if (cache && git && impl) {
+ const store = yield* fs.realPath(git.store).pipe(Effect.catch(() => Effect.succeed(git.store)))
+ yield* bus.subscribe(FileSystem.Event.Changed).pipe(
+ Stream.filter(
+ (event) => path.basename(event.data.file) === "HEAD" && FSUtil.contains(store, event.data.file),
+ ),
+ Stream.runForEach((event) =>
+ Effect.gen(function* () {
+ const previous = yield* Ref.get(cache)
+ const next = yield* impl.info()
+ yield* Ref.set(cache, next)
+ if (previous.branch.current === next.branch.current) return
+ yield* bus.publish(VcsEvent.BranchUpdated, { branch: next.branch.current })
+ }).pipe(Effect.withSpan("Vcs.refreshBranch", { attributes: { file: event.data.file } })),
+ ),
+ Effect.forkScoped({ startImmediately: true }),
+ )
+ }
+
+ const info = Effect.fnUntraced(function* () {
+ if (!impl) return { branch: {} }
+ if (!cache) return yield* impl.info()
+ const current = yield* Ref.get(cache)
+ if (current.branch.default !== undefined) return current
+ // An unborn repository can gain its first default branch without changing HEAD.
+ const next = yield* impl.info()
+ yield* Ref.set(cache, next)
+ return next
+ })
+
return Service.of({
info: Effect.fn("Vcs.info")(function* () {
- if (!impl) return { branch: {} }
- return yield* impl.info()
+ return yield* info()
}),
status: Effect.fn("Vcs.status")(function* () {
if (!impl) return []
@@ -60,5 +97,5 @@ const layer = Layer.effect(
export const node = makeLocationNode({
service: Service,
layer: layer,
- deps: [AppProcess.node, FSUtil.node, Location.node],
+ deps: [AppProcess.node, FSUtil.node, Location.node, Bus.node],
})
diff --git a/packages/core/test/vcs.test.ts b/packages/core/test/vcs.test.ts
index 010aaf54412..0f90d7090df 100644
--- a/packages/core/test/vcs.test.ts
+++ b/packages/core/test/vcs.test.ts
@@ -2,18 +2,21 @@ import { $ } from "bun"
import { describe, expect } from "bun:test"
import fs from "fs/promises"
import path from "path"
-import { Effect, Layer } from "effect"
+import { Effect, Fiber, Layer, Stream } from "effect"
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
+import { Bus } from "@opencode-ai/core/bus"
import { Location } from "@opencode-ai/core/location"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { Vcs } from "@opencode-ai/core/vcs"
+import { FileSystem } from "@opencode-ai/schema/filesystem"
+import { VcsEvent } from "@opencode-ai/schema/vcs-event"
import { location } from "./fixture/location"
import { tmpdir } from "./fixture/tmpdir"
import { it } from "./lib/effect"
const provide = (directory: string, input: { git?: boolean } = {}) =>
Effect.provide(
- LayerNode.compile(Vcs.node, [
+ LayerNode.compile(LayerNode.group([Vcs.node, Bus.node]), [
[
Location.node,
Layer.succeed(
@@ -35,6 +38,13 @@ const withTmp = (f: (directory: string) => Effect.Effect) =>
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(Effect.flatMap((tmp) => f(tmp.path)))
+const withGit = (f: (directory: string) => Effect.Effect) =>
+ withTmp((directory) =>
+ Effect.promise(() => initRepo(directory)).pipe(
+ Effect.andThen(f(directory).pipe(provide(directory, { git: true }))),
+ ),
+ )
+
async function initRepo(directory: string) {
await $`git init -b main`.cwd(directory).quiet()
await $`git config core.fsmonitor false`.cwd(directory).quiet()
@@ -62,10 +72,9 @@ describe("Vcs", () => {
)
it.live("reports modified, deleted, and untracked files", () =>
- withTmp((directory) =>
+ withGit((directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
- await initRepo(directory)
await fs.writeFile(path.join(directory, "keep.txt"), "one\ntwo\n")
await fs.writeFile(path.join(directory, "gone.txt"), "bye\n")
await commitAll(directory, "initial")
@@ -80,15 +89,45 @@ describe("Vcs", () => {
{ file: "keep.txt", additions: 1, deletions: 1, status: "modified" },
{ file: "new.txt", additions: 2, deletions: 0, status: "added" },
])
- }).pipe(provide(directory, { git: true })),
+ }),
+ ),
+ )
+
+ it.live("caches branch info and publishes HEAD changes", () =>
+ withGit((directory) =>
+ Effect.gen(function* () {
+ yield* Effect.promise(async () => {
+ await fs.writeFile(path.join(directory, "file.txt"), "one\n")
+ await commitAll(directory, "initial")
+ })
+ const vcs = yield* Vcs.Service
+ const bus = yield* Bus.Service
+ expect(yield* vcs.info()).toEqual({ branch: { current: "main", default: "main" } })
+
+ const updated = yield* bus.subscribe(VcsEvent.BranchUpdated).pipe(
+ Stream.take(1),
+ Stream.runHead,
+ Effect.forkScoped({ startImmediately: true }),
+ )
+ yield* Effect.promise(() => $`git checkout -q -b feature`.cwd(directory).quiet())
+
+ yield* bus.publish(FileSystem.Event.Changed, { file: path.join(directory, "HEAD"), event: "change" })
+ expect(yield* vcs.info()).toEqual({ branch: { current: "main", default: "main" } })
+
+ yield* bus.publish(FileSystem.Event.Changed, { file: path.join(directory, ".git", "HEAD"), event: "change" })
+ expect(yield* Fiber.join(updated)).toMatchObject({
+ _tag: "Some",
+ value: { location: { directory }, data: { branch: "feature" } },
+ })
+ expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: "main" } })
+ }),
),
)
it.live("diffs the working copy against HEAD with patches", () =>
- withTmp((directory) =>
+ withGit((directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
- await initRepo(directory)
await fs.writeFile(path.join(directory, "keep.txt"), "one\ntwo\n")
await commitAll(directory, "initial")
await fs.writeFile(path.join(directory, "keep.txt"), "one\nthree\n")
@@ -106,16 +145,15 @@ describe("Vcs", () => {
expect(diff[0].deletions).toBe(1)
expect(diff[1].patch).toContain("+hello")
expect(diff[1].additions).toBe(1)
- }).pipe(provide(directory, { git: true })),
+ }),
),
)
it.live("respects the context option", () =>
- withTmp((directory) =>
+ withGit((directory) =>
Effect.gen(function* () {
const body = Array.from({ length: 20 }, (_, index) => `line-${index}`).join("\n") + "\n"
yield* Effect.promise(async () => {
- await initRepo(directory)
await fs.writeFile(path.join(directory, "file.txt"), body)
await commitAll(directory, "initial")
await fs.writeFile(path.join(directory, "file.txt"), body.replace("line-10", "changed"))
@@ -127,15 +165,14 @@ describe("Vcs", () => {
const tight = yield* vcs.diff("working", { context: 1 })
expect(tight[0].patch).toContain("line-9")
expect(tight[0].patch).not.toContain("line-0")
- }).pipe(provide(directory, { git: true })),
+ }),
),
)
it.live("diffs before the first commit", () =>
- withTmp((directory) =>
+ withGit((directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
- await initRepo(directory)
await fs.writeFile(path.join(directory, "new.txt"), "hello\n")
})
const vcs = yield* Vcs.Service
@@ -143,15 +180,14 @@ describe("Vcs", () => {
const diff = yield* vcs.diff("working")
expect(diff).toHaveLength(1)
expect(diff[0].patch).toContain("+hello")
- }).pipe(provide(directory, { git: true })),
+ }),
),
)
it.live("diffs a feature branch against the default branch", () =>
- withTmp((directory) =>
+ withGit((directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
- await initRepo(directory)
await fs.writeFile(path.join(directory, "file.txt"), "one\n")
await commitAll(directory, "initial")
})
@@ -164,12 +200,11 @@ describe("Vcs", () => {
await commitAll(directory, "feature change")
})
const diff = yield* vcs.diff("branch")
- expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: "main" } })
expect(diff.map((item) => ({ file: item.file, status: item.status }))).toEqual([
{ file: "file.txt", status: "modified" },
])
expect(diff[0].patch).toContain("+two")
- }).pipe(provide(directory, { git: true })),
+ }),
),
)
})