From e5b576ce3a6e4510aa981c3018d8d7b98f14ea00 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 10 Aug 2026 17:35:50 -0500 Subject: [PATCH] fix(core): restore parcel watch for git HEAD Bun fs.watch misses git's atomic HEAD.lock rename, so checkout never refreshed the cached branch. Watch the git dir with Parcel like dev, keeping only HEAD and HEAD.lock. --- .../core/src/filesystem/location-watcher.ts | 16 ++++++++---- packages/core/src/vcs.ts | 5 +++- packages/core/test/filesystem/watcher.test.ts | 26 +++++++++++++++++-- packages/core/test/vcs.test.ts | 5 +++- 4 files changed, 43 insertions(+), 9 deletions(-) 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" } },