diff --git a/packages/core/src/filesystem/location-watcher.ts b/packages/core/src/filesystem/location-watcher.ts
index 934e99f3bf2b..a8b1544c7752 100644
--- a/packages/core/src/filesystem/location-watcher.ts
+++ b/packages/core/src/filesystem/location-watcher.ts
@@ -71,6 +71,14 @@ const layer = Layer.effect(
yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped)
}
}
+ if (location.vcs?.type === "hg") {
+ const store = location.vcs.store
+ const vcs = yield* fs.realPath(store).pipe(Effect.catch(() => Effect.succeed(store)))
+ if (!config.includes(".hg") && !config.includes(vcs)) {
+ const updates = yield* watcher.subscribe({ path: path.join(vcs, "branch"), type: "file" })
+ yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped)
+ }
+ }
}).pipe(
Effect.withSpan("LocationWatcher.start", { attributes: { directory: location.directory } }),
Effect.catchCause((cause) => Effect.logError("failed to init location watcher service", { cause })),
diff --git a/packages/core/src/vcs.ts b/packages/core/src/vcs.ts
index 643c4ae27abb..2da6085103b7 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, 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,35 @@ 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 vcs = location.vcs
+ const state = { info: impl ? yield* impl.info() : { branch: {} } satisfies Info }
+
+ if (vcs && impl) {
+ const store = yield* fs.realPath(vcs.store).pipe(Effect.catch(() => Effect.succeed(vcs.store)))
+ const isBranchMetadata =
+ vcs.type === "git"
+ ? (file: string) => path.basename(file) === "HEAD" && FSUtil.contains(store, file)
+ : (file: string) => path.resolve(file) === path.join(store, "branch")
+ yield* bus.subscribe(FileSystem.Event.Changed).pipe(
+ Stream.filter((event) => isBranchMetadata(event.data.file)),
+ Stream.runForEach((event) =>
+ Effect.gen(function* () {
+ const next = yield* impl.info()
+ const changed = state.info.branch.current !== next.branch.current
+ state.info = next
+ if (!changed) return
+ yield* bus.publish(VcsEvent.BranchUpdated, { branch: next.branch.current })
+ }).pipe(Effect.withSpan("Vcs.refreshBranch", { attributes: { file: event.data.file } })),
+ ),
+ Effect.forkScoped({ startImmediately: true }),
+ )
+ }
+
return Service.of({
info: Effect.fn("Vcs.info")(function* () {
- if (!impl) return { branch: {} }
- return yield* impl.info()
+ return state.info
}),
status: Effect.fn("Vcs.status")(function* () {
if (!impl) return []
@@ -60,5 +88,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/filesystem/watcher.test.ts b/packages/core/test/filesystem/watcher.test.ts
index 22dddaf01788..002b918813e3 100644
--- a/packages/core/test/filesystem/watcher.test.ts
+++ b/packages/core/test/filesystem/watcher.test.ts
@@ -153,12 +153,16 @@ function provide(directory: string, vcs?: Location.Interface["vcs"]) {
function withTmp(
f: (directory: string, vcs?: Location.Interface["vcs"]) => Effect.Effect,
- options?: { git?: boolean; init?: (directory: string) => Promise },
+ options?: { vcs?: "git" | "hg"; init?: (directory: string) => Promise },
) {
return Effect.acquireRelease(
Effect.promise(async () => {
const tmp = await tmpdir()
- if (!options?.git) return { tmp, vcs: undefined }
+ if (options?.vcs === "hg") {
+ await fs.mkdir(path.join(tmp.path, ".hg"))
+ return { tmp, vcs: { type: "hg" as const, store: AbsolutePath.make(path.join(tmp.path, ".hg")) } }
+ }
+ if (options?.vcs !== "git") return { tmp, vcs: undefined }
await $`git init`.cwd(tmp.path).quiet()
await $`git config core.fsmonitor false`.cwd(tmp.path).quiet()
await $`git config commit.gpgsign false`.cwd(tmp.path).quiet()
@@ -292,7 +296,7 @@ describeWatcher("LocationWatcher", () => {
})
}
}),
- { git: true },
+ { vcs: "git" },
),
)
@@ -322,7 +326,7 @@ describeWatcher("LocationWatcher", () => {
}),
)
}),
- { git: true },
+ { vcs: "git" },
),
)
@@ -359,7 +363,7 @@ describeWatcher("LocationWatcher", () => {
.pipe(Effect.andThen(Effect.promise(() => $`git add .`.cwd(directory).quiet())), Effect.asVoid),
)
}),
- { git: true },
+ { vcs: "git" },
),
)
@@ -376,7 +380,7 @@ describeWatcher("LocationWatcher", () => {
yield* nextUpdate((event) => event.file === head, fs.writeFileString(head, `ref: refs/heads/${branch}\n`)),
).toMatchObject({ file: head })
}),
- { git: true },
+ { vcs: "git" },
),
)
@@ -401,7 +405,7 @@ describeWatcher("LocationWatcher", () => {
).toEqual({ file: path.join(actual, "HEAD"), event: "change" })
}),
{
- git: true,
+ vcs: "git",
init: async (directory) => {
const actual = path.join(directory, "..", `actual_${path.basename(directory)}`)
await fs.rename(path.join(directory, ".git"), actual)
@@ -411,4 +415,19 @@ describeWatcher("LocationWatcher", () => {
),
)
})
+
+ it.live("publishes .hg/branch events", () =>
+ withTmp(
+ (directory) =>
+ Effect.gen(function* () {
+ const fs = yield* FSUtil.Service
+ const branch = path.join(directory, ".hg", "branch")
+ yield* ready(directory)
+ expect(
+ yield* nextUpdate((event) => event.file === branch, fs.writeFileString(branch, "feature\n")),
+ ).toMatchObject({ file: branch })
+ }),
+ { vcs: "hg" },
+ ),
+ )
})
diff --git a/packages/core/test/vcs-hg.test.ts b/packages/core/test/vcs-hg.test.ts
index 270b23f113a3..33a6db3e817f 100644
--- a/packages/core/test/vcs-hg.test.ts
+++ b/packages/core/test/vcs-hg.test.ts
@@ -2,11 +2,14 @@ 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"
@@ -15,7 +18,7 @@ const describeHg = Bun.which("hg") ? describe : describe.skip
const provide = (directory: string) =>
Effect.provide(
- LayerNode.compile(Vcs.node, [
+ LayerNode.compile(LayerNode.group([Vcs.node, Bus.node]), [
[
Location.node,
Layer.succeed(
@@ -37,6 +40,11 @@ const withTmp = (f: (directory: string) => Effect.Effect) =>
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
).pipe(Effect.flatMap((tmp) => f(tmp.path)))
+const withHg = (f: (directory: string) => Effect.Effect) =>
+ withTmp((directory) =>
+ Effect.promise(() => hg(directory, "init")).pipe(Effect.andThen(f(directory).pipe(provide(directory)))),
+ )
+
async function hg(directory: string, ...args: string[]) {
await $`hg ${args}`.cwd(directory).env({ ...process.env, HGPLAIN: "1" }).quiet()
}
@@ -48,10 +56,9 @@ async function commitAll(directory: string, message: string) {
describeHg("Vcs mercurial", () => {
it.live("reports modified, missing, and untracked files", () =>
- withTmp((directory) =>
+ withHg((directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
- await hg(directory, "init")
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")
@@ -66,15 +73,14 @@ describeHg("Vcs mercurial", () => {
{ file: "keep.txt", additions: 1, deletions: 1, status: "modified" },
{ file: "new.txt", additions: 2, deletions: 0, status: "added" },
])
- }).pipe(provide(directory)),
+ }),
),
)
it.live("diffs the working copy with synthesized untracked and missing patches", () =>
- withTmp((directory) =>
+ withHg((directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
- await hg(directory, "init")
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")
@@ -96,16 +102,47 @@ describeHg("Vcs mercurial", () => {
expect(diff[1].deletions).toBe(1)
expect(diff[2].patch).toContain("+hello")
expect(diff[2].additions).toBe(1)
- }).pipe(provide(directory)),
+ }),
+ ),
+ )
+
+ it.live("caches branch info and publishes branch metadata changes", () =>
+ withHg((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: "default", default: "default" } })
+
+ const updated = yield* bus.subscribe(VcsEvent.BranchUpdated).pipe(
+ Stream.take(1),
+ Stream.runHead,
+ Effect.forkScoped({ startImmediately: true }),
+ )
+ yield* Effect.promise(() => hg(directory, "branch", "-q", "feature"))
+ expect(yield* vcs.info()).toEqual({ branch: { current: "default", default: "default" } })
+
+ yield* bus.publish(FileSystem.Event.Changed, {
+ file: path.join(directory, ".hg", "branch"),
+ 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: "default" } })
+ }),
),
)
it.live("respects the context option", () =>
- withTmp((directory) =>
+ withHg((directory) =>
Effect.gen(function* () {
const body = Array.from({ length: 20 }, (_, index) => `line-${index}`).join("\n") + "\n"
yield* Effect.promise(async () => {
- await hg(directory, "init")
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"))
@@ -117,15 +154,14 @@ describeHg("Vcs mercurial", () => {
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)),
+ }),
),
)
it.live("diffs before the first commit", () =>
- withTmp((directory) =>
+ withHg((directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
- await hg(directory, "init")
await fs.writeFile(path.join(directory, "tracked.txt"), "a\nb\n")
await hg(directory, "add", "-q", "tracked.txt")
await fs.writeFile(path.join(directory, "loose.txt"), "hello\n")
@@ -139,15 +175,14 @@ describeHg("Vcs mercurial", () => {
expect(diff).toHaveLength(2)
expect(diff[0].patch).toContain("+hello")
expect(diff[1].patch).toContain("+a")
- }).pipe(provide(directory)),
+ }),
),
)
it.live("diffs a named branch against the default branch", () =>
- withTmp((directory) =>
+ withHg((directory) =>
Effect.gen(function* () {
yield* Effect.promise(async () => {
- await hg(directory, "init")
await fs.writeFile(path.join(directory, "file.txt"), "one\n")
await commitAll(directory, "initial")
})
@@ -160,12 +195,11 @@ describeHg("Vcs mercurial", () => {
await commitAll(directory, "feature change")
})
const diff = yield* vcs.diff("branch")
- expect(yield* vcs.info()).toEqual({ branch: { current: "feature", default: "default" } })
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)),
+ }),
),
)
})
diff --git a/packages/core/test/vcs.test.ts b/packages/core/test/vcs.test.ts
index 010aaf544127..20e72807d0fc 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: undefined } })
+
+ 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: undefined } })
+
+ 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 })),
+ }),
),
)
})