diff --git a/packages/core/src/filesystem/location-watcher.ts b/packages/core/src/filesystem/location-watcher.ts index 6848933fee..a2f7fa6de2 100644 --- a/packages/core/src/filesystem/location-watcher.ts +++ b/packages/core/src/filesystem/location-watcher.ts @@ -4,7 +4,6 @@ import { makeLocationNode } from "@opencode-ai/util/effect/app-node" import { Context, Effect, Layer, Stream } from "effect" import { FileSystem } from "@opencode-ai/schema/filesystem" import { Document } from "@opencode-ai/schema/config" -import path from "path" import { Config } from "../config" import { Bus } from "../bus" import { FSUtil } from "@opencode-ai/util/fs-util" @@ -36,22 +35,29 @@ const layer = Layer.effect( .filter((entry): entry is Document => entry.type === "document") .flatMap((item) => item.info.watcher?.ignore ?? []) + const watch = (dir: string, keep: readonly string[]) => + Effect.gen(function* () { + const ignore = (yield* fs.readDirectoryEntries(dir).pipe(Effect.catch(() => Effect.succeed([])))).flatMap( + (entry) => (keep.includes(entry.name) ? [] : [entry.name]), + ) + const updates = yield* watcher.subscribe({ path: dir, type: "directory", ignore }) + yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped) + }) + if (location.vcs?.type === "git") { const resolved = (yield* git.repo.discover(location.directory))?.gitDirectory const vcs = resolved ? yield* fs.realPath(resolved).pipe(Effect.catch(() => Effect.succeed(resolved))) : undefined if (vcs && !config.includes(".git") && !config.includes(vcs) && (!resolved || !config.includes(resolved))) { - const updates = yield* watcher.subscribe({ path: path.join(vcs, "HEAD"), type: "file" }) - yield* updates.pipe(Stream.runForEach(publish), Effect.forkScoped) + yield* watch(vcs, ["HEAD", "HEAD.lock"]) } } 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) + yield* watch(vcs, ["branch"]) } } }).pipe( diff --git a/packages/core/src/vcs.ts b/packages/core/src/vcs.ts index db1f95d661..47daa67927 100644 --- a/packages/core/src/vcs.ts +++ b/packages/core/src/vcs.ts @@ -52,7 +52,10 @@ const layer = Layer.effect( 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) => { + const name = path.basename(file) + return (name === "HEAD" || name === "HEAD.lock") && 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)), diff --git a/packages/core/test/filesystem/watcher.test.ts b/packages/core/test/filesystem/watcher.test.ts index 7b60f7dce7..a450323cf3 100644 --- a/packages/core/test/filesystem/watcher.test.ts +++ b/packages/core/test/filesystem/watcher.test.ts @@ -195,7 +195,12 @@ describe("LocationWatcher subscriptions", () => { Effect.retry(Schedule.spaced("10 millis")), ) yield* Effect.sleep("10 millis") - expect(subscriptions).toEqual([{ path: path.join(directory, ".git", "HEAD"), type: "file" }]) + expect(subscriptions).toHaveLength(1) + const git = subscriptions[0] + if (git?.type !== "directory") throw new Error("expected a directory watch") + expect(git.path).toBe(path.join(directory, ".git")) + expect(git.ignore ?? []).not.toContain("HEAD") + expect(git.ignore ?? []).toContain("objects") }), { vcs: "git", watcher }, ) @@ -218,7 +223,7 @@ describe("LocationWatcher subscriptions", () => { Effect.retry(Schedule.spaced("10 millis")), ) yield* Effect.sleep("10 millis") - expect(subscriptions).toEqual([{ path: path.join(directory, ".hg", "branch"), type: "file" }]) + expect(subscriptions).toMatchObject([{ path: path.join(directory, ".hg"), type: "directory" }]) }), { vcs: "hg", watcher }, ) @@ -351,6 +356,23 @@ describeNative("LocationWatcher", () => { ), ) + it.live("publishes .git/HEAD events from git checkout", () => + withTmp( + (directory) => + Effect.gen(function* () { + const head = path.join(directory, ".git", "HEAD") + const branch = `watch-${Math.random().toString(36).slice(2)}` + yield* ready(head) + const event = yield* nextUpdate( + (item) => path.basename(item.file) === "HEAD" || path.basename(item.file) === "HEAD.lock", + Effect.promise(() => $`git checkout -q -b ${branch}`.cwd(directory).quiet()), + ) + expect(["HEAD", "HEAD.lock"]).toContain(path.basename(event.file)) + }), + { vcs: "git" }, + ), + ) + const describeSymlink = process.platform !== "win32" ? describe : describe.skip describeSymlink("symlinked .git", () => { it.live("publishes .git/HEAD events through a symlinked .git directory", () => diff --git a/packages/core/test/vcs.test.ts b/packages/core/test/vcs.test.ts index 677fb4f156..aa87a8ee8b 100644 --- a/packages/core/test/vcs.test.ts +++ b/packages/core/test/vcs.test.ts @@ -112,7 +112,10 @@ describe("Vcs", () => { 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" }) + yield* bus.publish(FileSystem.Event.Changed, { + file: path.join(directory, ".git", "HEAD.lock"), + event: "change", + }) expect(yield* Fiber.join(updated)).toMatchObject({ _tag: "Some", value: { location: { directory }, data: { branch: "feature" } },