mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-04 01:06:16 -04:00
Compare commits
2 Commits
v2
...
vcs-branch-watch
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d0b9a68f7 | |||
| fda2187a35 |
@@ -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 })),
|
||||
|
||||
@@ -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,47 @@ 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 cache = vcs && impl ? yield* Ref.make(yield* impl.info()) : undefined
|
||||
|
||||
if (cache && vcs && impl) {
|
||||
const store = yield* fs.realPath(vcs.store).pipe(Effect.catch(() => Effect.succeed(vcs.store)))
|
||||
yield* bus.subscribe(FileSystem.Event.Changed).pipe(
|
||||
Stream.filter(
|
||||
(event) =>
|
||||
vcs.type === "git"
|
||||
? path.basename(event.data.file) === "HEAD" && FSUtil.contains(store, event.data.file)
|
||||
: path.resolve(event.data.file) === path.join(store, "branch"),
|
||||
),
|
||||
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.current !== undefined && current.branch.default !== undefined) return current
|
||||
// An unborn repository can gain its first branch without changing existing metadata.
|
||||
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 +100,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],
|
||||
})
|
||||
|
||||
@@ -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" },
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
@@ -100,6 +103,39 @@ describeHg("Vcs mercurial", () => {
|
||||
),
|
||||
)
|
||||
|
||||
it.live("caches branch info and publishes branch metadata changes", () =>
|
||||
withTmp((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")
|
||||
})
|
||||
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" } })
|
||||
}).pipe(provide(directory)),
|
||||
),
|
||||
)
|
||||
|
||||
it.live("respects the context option", () =>
|
||||
withTmp((directory) =>
|
||||
Effect.gen(function* () {
|
||||
|
||||
@@ -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: "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 })),
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user