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 })), + }), ), ) })