Skip to content

Commit 0e2d7fd

Browse files
Apply PR #17634: fix+refactor(vcs): fix HEAD filter bug and effectify VcsService
2 parents 1e108de + de8e82c commit 0e2d7fd

File tree

10 files changed

+253
-83
lines changed

10 files changed

+253
-83
lines changed
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { ServiceMap } from "effect"
2+
import type { Project } from "@/project/project"
3+
4+
export declare namespace InstanceContext {
5+
export interface Shape {
6+
readonly directory: string
7+
readonly project: Project.Info
8+
}
9+
}
10+
11+
export class InstanceContext extends ServiceMap.Service<InstanceContext, InstanceContext.Shape>()(
12+
"opencode/InstanceContext",
13+
) {}

packages/opencode/src/effect/instances.ts

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,21 @@
11
import { Effect, Layer, LayerMap, ServiceMap } from "effect"
22
import { registerDisposer } from "./instance-registry"
3+
import { InstanceContext } from "./instance-context"
34
import { ProviderAuthService } from "@/provider/auth-service"
45
import { QuestionService } from "@/question/service"
56
import { PermissionService } from "@/permission/service"
67
import { FileWatcherService } from "@/file/watcher"
8+
import { VcsService } from "@/project/vcs"
79
import { Instance } from "@/project/instance"
8-
import type { Project } from "@/project/project"
910

10-
export declare namespace InstanceContext {
11-
export interface Shape {
12-
readonly directory: string
13-
readonly project: Project.Info
14-
}
15-
}
16-
17-
export class InstanceContext extends ServiceMap.Service<InstanceContext, InstanceContext.Shape>()(
18-
"opencode/InstanceContext",
19-
) {}
11+
export { InstanceContext } from "./instance-context"
2012

21-
export type InstanceServices = QuestionService | PermissionService | ProviderAuthService | FileWatcherService
13+
export type InstanceServices =
14+
| QuestionService
15+
| PermissionService
16+
| ProviderAuthService
17+
| FileWatcherService
18+
| VcsService
2219

2320
function lookup(directory: string) {
2421
const project = Instance.project
@@ -28,6 +25,7 @@ function lookup(directory: string) {
2825
Layer.fresh(PermissionService.layer),
2926
Layer.fresh(ProviderAuthService.layer),
3027
Layer.fresh(FileWatcherService.layer),
28+
Layer.fresh(VcsService.layer),
3129
).pipe(Layer.provide(ctx))
3230
}
3331

packages/opencode/src/file/watcher.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { BusEvent } from "@/bus/bus-event"
22
import { Bus } from "@/bus"
3-
import { InstanceContext } from "@/effect/instances"
3+
import { InstanceContext } from "@/effect/instance-context"
44
import { Instance } from "@/project/instance"
55
import z from "zod"
66
import { Log } from "../util/log"

packages/opencode/src/permission/service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { Bus } from "@/bus"
22
import { BusEvent } from "@/bus/bus-event"
3-
import { InstanceContext } from "@/effect/instances"
3+
import { InstanceContext } from "@/effect/instance-context"
44
import { ProjectID } from "@/project/schema"
55
import { MessageID, SessionID } from "@/session/schema"
66
import { PermissionTable } from "@/session/session.sql"

packages/opencode/src/project/bootstrap.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { Project } from "./project"
77
import { Bus } from "../bus"
88
import { Command } from "../command"
99
import { Instance } from "./instance"
10-
import { Vcs } from "./vcs"
10+
import { VcsService } from "./vcs"
1111
import { Log } from "@/util/log"
1212
import { ShareNext } from "@/share/share-next"
1313
import { Snapshot } from "../snapshot"
@@ -22,7 +22,7 @@ export async function InstanceBootstrap() {
2222
await LSP.init()
2323
await runPromiseInstance(FileWatcherService.use((service) => service.init()))
2424
File.init()
25-
Vcs.init()
25+
await runPromiseInstance(VcsService.use((s) => s.init()))
2626
Snapshot.init()
2727
Truncate.init()
2828

Lines changed: 47 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { BusEvent } from "@/bus/bus-event"
22
import { Bus } from "@/bus"
3-
import path from "path"
43
import z from "zod"
54
import { Log } from "@/util/log"
65
import { Instance } from "./instance"
6+
import { InstanceContext } from "@/effect/instance-context"
77
import { FileWatcher } from "@/file/watcher"
88
import { git } from "@/util/git"
9+
import { Effect, Layer, ServiceMap } from "effect"
910

1011
const log = Log.create({ service: "vcs" })
1112

@@ -27,50 +28,57 @@ export namespace Vcs {
2728
ref: "VcsInfo",
2829
})
2930
export type Info = z.infer<typeof Info>
31+
}
3032

31-
async function currentBranch() {
32-
const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], {
33-
cwd: Instance.worktree,
34-
})
35-
if (result.exitCode !== 0) return
36-
const text = result.text().trim()
37-
if (!text) return
38-
return text
33+
export namespace VcsService {
34+
export interface Service {
35+
readonly init: () => Effect.Effect<void>
36+
readonly branch: () => Effect.Effect<string | undefined>
3937
}
38+
}
4039

41-
const state = Instance.state(
42-
async () => {
43-
if (Instance.project.vcs !== "git") {
44-
return { branch: async () => undefined, unsubscribe: undefined }
45-
}
46-
let current = await currentBranch()
47-
log.info("initialized", { branch: current })
40+
export class VcsService extends ServiceMap.Service<VcsService, VcsService.Service>()("@opencode/Vcs") {
41+
static readonly layer = Layer.effect(
42+
VcsService,
43+
Effect.gen(function* () {
44+
const instance = yield* InstanceContext
45+
let current: string | undefined
4846

49-
const unsubscribe = Bus.subscribe(FileWatcher.Event.Updated, async (evt) => {
50-
if (!evt.properties.file.endsWith("HEAD")) return
51-
const next = await currentBranch()
52-
if (next !== current) {
53-
log.info("branch changed", { from: current, to: next })
54-
current = next
55-
Bus.publish(Event.BranchUpdated, { branch: next })
47+
if (instance.project.vcs === "git") {
48+
const currentBranch = async () => {
49+
const result = await git(["rev-parse", "--abbrev-ref", "HEAD"], {
50+
cwd: instance.project.worktree,
51+
})
52+
if (result.exitCode !== 0) return undefined
53+
const text = result.text().trim()
54+
return text || undefined
5655
}
57-
})
5856

59-
return {
60-
branch: async () => current,
61-
unsubscribe,
62-
}
63-
},
64-
async (state) => {
65-
state.unsubscribe?.()
66-
},
67-
)
57+
current = yield* Effect.promise(() => currentBranch())
58+
log.info("initialized", { branch: current })
6859

69-
export async function init() {
70-
return state()
71-
}
60+
const unsubscribe = Bus.subscribe(
61+
FileWatcher.Event.Updated,
62+
Instance.bind(async (evt) => {
63+
if (!evt.properties.file.endsWith("HEAD")) return
64+
const next = await currentBranch()
65+
if (next !== current) {
66+
log.info("branch changed", { from: current, to: next })
67+
current = next
68+
Bus.publish(Vcs.Event.BranchUpdated, { branch: next })
69+
}
70+
}),
71+
)
7272

73-
export async function branch() {
74-
return await state().then((s) => s.branch())
75-
}
73+
yield* Effect.addFinalizer(() => Effect.sync(unsubscribe))
74+
}
75+
76+
return VcsService.of({
77+
init: Effect.fn("VcsService.init")(function* () {}),
78+
branch: Effect.fn("VcsService.branch")(function* () {
79+
return current
80+
}),
81+
})
82+
}),
83+
)
7684
}

packages/opencode/src/server/server.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ import { LSP } from "../lsp"
1414
import { Format } from "../format"
1515
import { TuiRoutes } from "./routes/tui"
1616
import { Instance } from "../project/instance"
17-
import { Vcs } from "../project/vcs"
17+
import { Vcs, VcsService } from "../project/vcs"
18+
import { runPromiseInstance } from "@/effect/runtime"
1819
import { Agent } from "../agent/agent"
1920
import { Skill } from "../skill/skill"
2021
import { Auth } from "../auth"
@@ -337,7 +338,7 @@ export namespace Server {
337338
},
338339
}),
339340
async (c) => {
340-
const branch = await Vcs.branch()
341+
const branch = await runPromiseInstance(VcsService.use((s) => s.branch()))
341342
return c.json({
342343
branch,
343344
})

packages/opencode/test/file/watcher.test.ts

Lines changed: 12 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@ import { $ } from "bun"
22
import { afterEach, describe, expect, test } from "bun:test"
33
import fs from "fs/promises"
44
import path from "path"
5-
import { ConfigProvider, Deferred, Effect, Fiber, Layer, ManagedRuntime, Option } from "effect"
5+
import { Deferred, Effect, Fiber, Option } from "effect"
66
import { tmpdir } from "../fixture/fixture"
7+
import { watcherConfigLayer, withServices } from "../fixture/instance"
78
import { FileWatcher, FileWatcherService } from "../../src/file/watcher"
8-
import { InstanceContext } from "../../src/effect/instances"
99
import { Instance } from "../../src/project/instance"
1010
import { GlobalBus } from "../../src/bus/global"
1111

@@ -16,35 +16,21 @@ const describeWatcher = FileWatcher.hasNativeBinding() && !process.env.CI ? desc
1616
// Helpers
1717
// ---------------------------------------------------------------------------
1818

19-
const configLayer = ConfigProvider.layer(
20-
ConfigProvider.fromUnknown({
21-
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
22-
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "false",
23-
}),
24-
)
25-
2619
type BusUpdate = { directory?: string; payload: { type: string; properties: WatcherEvent } }
2720
type WatcherEvent = { file: string; event: "add" | "change" | "unlink" }
2821

29-
/** Run `body` with a live FileWatcherService. Runtime is acquired/released via Effect.scoped. */
22+
/** Run `body` with a live FileWatcherService. */
3023
function withWatcher<E>(directory: string, body: Effect.Effect<void, E>) {
31-
return Instance.provide({
24+
return withServices(
3225
directory,
33-
fn: () =>
34-
Effect.gen(function* () {
35-
const ctx = Layer.sync(InstanceContext, () =>
36-
InstanceContext.of({ directory: Instance.directory, project: Instance.project }),
37-
)
38-
const layer = Layer.fresh(FileWatcherService.layer).pipe(Layer.provide(ctx), Layer.provide(configLayer))
39-
const rt = yield* Effect.acquireRelease(
40-
Effect.sync(() => ManagedRuntime.make(layer)),
41-
(rt) => Effect.promise(() => rt.dispose()),
42-
)
43-
yield* Effect.promise(() => rt.runPromise(FileWatcherService.use((s) => s.init())))
44-
yield* ready(directory)
45-
yield* body
46-
}).pipe(Effect.scoped, Effect.runPromise),
47-
})
26+
FileWatcherService.layer,
27+
async (rt) => {
28+
await rt.runPromise(FileWatcherService.use((s) => s.init()))
29+
await Effect.runPromise(ready(directory))
30+
await Effect.runPromise(body)
31+
},
32+
{ provide: [watcherConfigLayer] },
33+
)
4834
}
4935

5036
function listen(directory: string, check: (evt: WatcherEvent) => boolean, hit: (evt: WatcherEvent) => void) {
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { ConfigProvider, Layer, ManagedRuntime } from "effect"
2+
import { InstanceContext } from "../../src/effect/instance-context"
3+
import { Instance } from "../../src/project/instance"
4+
5+
/** ConfigProvider that enables the experimental file watcher. */
6+
export const watcherConfigLayer = ConfigProvider.layer(
7+
ConfigProvider.fromUnknown({
8+
OPENCODE_EXPERIMENTAL_FILEWATCHER: "true",
9+
OPENCODE_EXPERIMENTAL_DISABLE_FILEWATCHER: "false",
10+
}),
11+
)
12+
13+
/**
14+
* Boot an Instance with the given service layers and run `body` with
15+
* the ManagedRuntime. Cleanup is automatic — the runtime is disposed
16+
* and Instance context is torn down when `body` completes.
17+
*
18+
* Layers may depend on InstanceContext (provided automatically).
19+
* Pass extra layers via `options.provide` (e.g. ConfigProvider.layer).
20+
*/
21+
export function withServices<S>(
22+
directory: string,
23+
layer: Layer.Layer<S, any, InstanceContext>,
24+
body: (rt: ManagedRuntime.ManagedRuntime<S, never>) => Promise<void>,
25+
options?: { provide?: Layer.Layer<never>[] },
26+
) {
27+
return Instance.provide({
28+
directory,
29+
fn: async () => {
30+
const ctx = Layer.sync(InstanceContext, () =>
31+
InstanceContext.of({ directory: Instance.directory, project: Instance.project }),
32+
)
33+
let resolved: Layer.Layer<S> = Layer.fresh(layer).pipe(Layer.provide(ctx)) as any
34+
if (options?.provide) {
35+
for (const l of options.provide) {
36+
resolved = resolved.pipe(Layer.provide(l)) as any
37+
}
38+
}
39+
const rt = ManagedRuntime.make(resolved)
40+
try {
41+
await body(rt)
42+
} finally {
43+
await rt.dispose()
44+
}
45+
},
46+
})
47+
}

0 commit comments

Comments
 (0)