Przeglądaj źródła

feat(vcs): publish branch updates (#40371)

Aiden Cline 1 tydzień temu
rodzic
commit
9bd99b4c91

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

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

+ 26 - 7
packages/core/test/filesystem/watcher.test.ts

@@ -153,12 +153,16 @@ function provide(directory: string, vcs?: Location.Interface["vcs"]) {
 
 function withTmp<A, E, R>(
   f: (directory: string, vcs?: Location.Interface["vcs"]) => Effect.Effect<A, E, R>,
-  options?: { git?: boolean; init?: (directory: string) => Promise<void> },
+  options?: { vcs?: "git" | "hg"; init?: (directory: string) => Promise<void> },
 ) {
   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" },
+    ),
+  )
 })

+ 52 - 18
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 = <A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) =>
     (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
   ).pipe(Effect.flatMap((tmp) => f(tmp.path)))
 
+const withHg = <A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) =>
+  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)),
+      }),
     ),
   )
 })

+ 53 - 18
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 = <A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) =>
     (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
   ).pipe(Effect.flatMap((tmp) => f(tmp.path)))
 
+const withGit = <A, E, R>(f: (directory: string) => Effect.Effect<A, E, R>) =>
+  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 })),
+      }),
     ),
   )
 })