diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index ecbf76424c55..a8b6159f9d96 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -2,7 +2,7 @@ import { Cause, Effect, Layer, Context, Schema } from "effect" // @ts-ignore import { createWrapper } from "@parcel/watcher/wrapper" import type ParcelWatcher from "@parcel/watcher" -import { readdir } from "fs/promises" +import { readdir, realpath } from "fs/promises" import path from "path" import { Bus } from "@/bus" import { BusEvent } from "@/bus/bus-event" @@ -131,8 +131,11 @@ export const layer = Layer.effect( const result = yield* git.run(["rev-parse", "--git-dir"], { cwd: ctx.worktree, }) - const vcsDir = result.exitCode === 0 ? path.resolve(ctx.worktree, result.text().trim()) : undefined - if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) { + const resolved = result.exitCode === 0 ? path.resolve(ctx.worktree, result.text().trim()) : undefined + const vcsDir = resolved + ? yield* Effect.promise(() => realpath(resolved).catch(() => resolved)) + : undefined + if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir) && (!resolved || !cfgIgnores.includes(resolved))) { const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter( (entry) => entry !== "HEAD", ) diff --git a/packages/opencode/test/file/watcher.test.ts b/packages/opencode/test/file/watcher.test.ts index 6276e58f2991..3b7ae4a61b43 100644 --- a/packages/opencode/test/file/watcher.test.ts +++ b/packages/opencode/test/file/watcher.test.ts @@ -260,4 +260,57 @@ describeWatcher("FileWatcher", () => { }), { git: true }, ) + + // Symlink support varies by platform; skip where unavailable + const describeSymlink = process.platform !== "win32" ? describe : describe.skip + + describeSymlink("symlinked .git", () => { + it.instance( + "publishes .git/HEAD events through a symlinked .git directory", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const fs = yield* AppFileSystem.Service + const git = yield* Git.Service + const dir = test.directory + const actualGit = path.join(dir, "..", "tmp_actual_git_" + Math.random().toString(36).slice(2)) + + // Move .git to a sibling directory and replace with a symlink + yield* Effect.promise(() => import("fs")).pipe( + Effect.flatMap((nodeFs) => + Effect.all([ + Effect.promise(() => nodeFs.promises.rename(path.join(dir, ".git"), actualGit)), + Effect.promise(() => nodeFs.promises.symlink(actualGit, path.join(dir, ".git"))), + ]), + ), + ) + + yield* Effect.acquireRelease( + Effect.succeed(actualGit), + (p) => Effect.promise(() => import("fs").then((f) => f.promises.rm(p, { recursive: true, force: true }).catch(() => undefined))), + ) + + const head = path.join(dir, ".git", "HEAD") + const branch = `watch-${Math.random().toString(36).slice(2)}` + yield* git.run(["branch", branch], { cwd: dir }) + + yield* withWatcher( + dir, + nextUpdate( + dir, + (evt) => evt.file === path.join(actualGit, "HEAD") && evt.event !== "unlink", + fs.writeFileString(head, `ref: refs/heads/${branch}\n`), + ).pipe( + Effect.tap((evt) => + Effect.sync(() => { + expect(evt.file).toBe(path.join(actualGit, "HEAD")) + expect(["add", "change"]).toContain(evt.event) + }), + ), + ), + ) + }), + { git: true }, + ) + }) })