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