From 527c9aa54b4d9567a7acefb4c9cfedd6722e9537 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Wed, 3 Jun 2026 16:43:49 +0800 Subject: [PATCH 1/3] fix(watcher): realpath-resolve git dirs before subscribing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The VCS watcher resolved each `git rev-parse --git-dir/--git-common-dir` path with `path.resolve` only. When `.git` is a symlink (a symlinked git dir, or a worktree reached through a symlinked path such as macOS `/tmp` -> `/private/tmp`), parcel emits events at the canonical realpath, so `shouldPublishVcsWatcherPath` runs `path.relative(symlinkPath, realPath)`, gets a `..` traversal, and drops every HEAD/ref event — branch detection never fires. realpath()-resolve each git dir before subscribing and filtering, with a `.catch` fallback to the unresolved path when realpath fails (dir may be absent). The cfg `watcher.ignore` check now matches either the resolved or the realpath'd dir, and a `subscribedVcsDirs` set preserves the existing single-subscription dedupe across the new realpath step. The test harness `ready()` probe is now symlink-aware (a real `.git` dir resolves to itself, so existing cases are unchanged), and a new symlinked-`.git` regression test asserts HEAD events still publish. Re-implements the fix from upstream anomalyco/opencode #27016 (1ac3f09468), adapted to PawWork's dual git-dir/git-common-dir subscription loop. Thanks to the upstream authors. --- packages/opencode/src/file/watcher.ts | 18 ++++++-- packages/opencode/test/file/watcher.test.ts | 46 ++++++++++++++++++++- 2 files changed, 58 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index 60d57c74a..7feb3dafa 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -2,7 +2,7 @@ import { Cause, Effect, Layer, Scope, Context } from "effect" // @ts-ignore import { createWrapper } from "@parcel/watcher/wrapper" import type ParcelWatcher from "@parcel/watcher" -import { readdir, stat } from "fs/promises" +import { readdir, realpath, stat } from "fs/promises" import path from "path" import z from "zod" import { Bus } from "@/bus" @@ -781,7 +781,7 @@ export namespace FileWatcher { const result = yield* git.run(["rev-parse", "--git-dir", "--git-common-dir"], { cwd: ctx.directory, }) - const vcsDirs = + const resolvedVcsDirs = result.exitCode === 0 ? [ ...new Set( @@ -795,8 +795,18 @@ export namespace FileWatcher { ), ] : [] - for (const vcsDir of vcsDirs) { - if (cfgIgnores.includes(".git") || cfgIgnores.includes(vcsDir)) continue + const subscribedVcsDirs = new Set() + for (const resolvedVcsDir of resolvedVcsDirs) { + // Canonicalize a possibly-symlinked git dir (a symlinked .git, or a worktree + // reached through a symlinked path such as macOS /tmp -> /private/tmp): parcel + // emits events at the realpath, so an unresolved vcsDir makes path.relative in + // shouldPublishVcsWatcherPath traverse out (..) and drop every HEAD/ref event. + // Fall back to the unresolved path when realpath fails (dir may be absent). + const vcsDir = yield* Effect.promise(() => realpath(resolvedVcsDir).catch(() => resolvedVcsDir)) + if (cfgIgnores.includes(".git") || cfgIgnores.includes(resolvedVcsDir) || cfgIgnores.includes(vcsDir)) + continue + if (subscribedVcsDirs.has(vcsDir)) continue + subscribedVcsDirs.add(vcsDir) const ignore = vcsWatcherIgnoreEntries(yield* Effect.promise(() => readdir(vcsDir).catch(() => []))) log.info( "watcher subscription configured", diff --git a/packages/opencode/test/file/watcher.test.ts b/packages/opencode/test/file/watcher.test.ts index 5bd2a2a2e..c2c916be6 100644 --- a/packages/opencode/test/file/watcher.test.ts +++ b/packages/opencode/test/file/watcher.test.ts @@ -159,7 +159,6 @@ function noUpdate( function ready(directory: string) { const file = path.join(directory, `.watcher-${Math.random().toString(36).slice(2)}`) - const head = path.join(directory, ".git", "HEAD") return Effect.gen(function* () { yield* nextUpdate( @@ -168,6 +167,13 @@ function ready(directory: string) { Effect.promise(() => fs.writeFile(file, "ready")), ).pipe(Effect.ensuring(Effect.promise(() => fs.rm(file, { force: true }).catch(() => undefined))), Effect.asVoid) + // Resolve a possibly-symlinked .git so the HEAD readiness probe waits on the same + // realpath'd path parcel emits events at (a real .git dir resolves to itself). + const gitDir = yield* Effect.promise(() => + fs.realpath(path.join(directory, ".git")).catch(() => path.join(directory, ".git")), + ) + const head = path.join(gitDir, "HEAD") + const git = yield* Effect.promise(() => fs .stat(head) @@ -182,7 +188,7 @@ function ready(directory: string) { directory, (evt) => evt.file === head && evt.event !== "unlink", Effect.promise(async () => { - await fs.writeFile(path.join(directory, ".git", "refs", "heads", branch), hash.trim() + "\n") + await fs.writeFile(path.join(gitDir, "refs", "heads", branch), hash.trim() + "\n") await fs.writeFile(head, `ref: refs/heads/${branch}\n`) }), ).pipe(Effect.asVoid) @@ -641,4 +647,40 @@ describeWatcher("FileWatcher", () => { ), ) }) + + // Symlink creation needs privileges on Windows; skip there (matches upstream #27016). + const symlinkTest = process.platform === "win32" ? test.skip : test + symlinkTest("publishes .git/HEAD events through a symlinked .git directory", async () => { + await using tmp = await tmpdir({ git: true }) + const dir = tmp.path + // Move .git to a sibling and replace it with a symlink. git rev-parse still reports ".git", + // so without realpath the watcher subscribes to the symlink and drops parcel's realpath'd events. + const actualGit = path.join(dir, "..", `actual-git-${Math.random().toString(36).slice(2)}`) + await fs.rename(path.join(dir, ".git"), actualGit) + await fs.symlink(actualGit, path.join(dir, ".git")) + + try { + const head = path.join(actualGit, "HEAD") + const branch = `watch-${Math.random().toString(36).slice(2)}` + await $`git branch ${branch}`.cwd(dir).quiet() + + await withWatcher( + dir, + nextUpdate( + dir, + (evt) => evt.file === head && evt.event !== "unlink", + Effect.promise(() => fs.writeFile(head, `ref: refs/heads/${branch}\n`)), + ).pipe( + Effect.tap((evt) => + Effect.sync(() => { + expect(evt.file).toBe(head) + expect(["add", "change"]).toContain(evt.event) + }), + ), + ), + ) + } finally { + await fs.rm(actualGit, { recursive: true, force: true }).catch(() => undefined) + } + }) }) From 2a37e92027c67ea31f052280d59a912f3f69cb54 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Wed, 3 Jun 2026 16:53:57 +0800 Subject: [PATCH 2/3] test(watcher): CI-gate the realpath fix via resolveVcsWatchDirs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address Codex review: the native symlinked-.git integration test runs only under the native watcher suite (CI skips it), so a regression of this fix could merge unnoticed. Extract the git-dir resolution (resolve + realpath + watcher.ignore + dedupe) into a pure, exported resolveVcsWatchDirs() and add a CI-runnable unit test that exercises a real symlinked dir, the ignore semantics, and the realpath-failure fallback — no native binding required. Also shrinks the layer's inline subscription loop. --- packages/opencode/src/file/watcher.ts | 71 +++++++++++++-------- packages/opencode/test/file/watcher.test.ts | 29 +++++++++ 2 files changed, 74 insertions(+), 26 deletions(-) diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index 7feb3dafa..2c97f4656 100644 --- a/packages/opencode/src/file/watcher.ts +++ b/packages/opencode/src/file/watcher.ts @@ -505,6 +505,39 @@ export namespace FileWatcher { return VCS_REFRESH_PREFIXES.some((prefix) => relative.startsWith(prefix)) } + // Turn raw `git rev-parse --git-dir/--git-common-dir` lines into the canonical directories to + // subscribe. Each line is resolved against `directory` (rev-parse may print relative paths like + // ".git"), then realpath-canonicalized: a possibly-symlinked git dir (a symlinked .git, or a + // worktree reached through a symlinked path such as macOS /tmp -> /private/tmp) makes parcel emit + // events at the realpath, so an unresolved vcsDir makes path.relative in shouldPublishVcsWatcherPath + // traverse out (..) and drop every HEAD/ref event. Fall back to the unresolved path when realpath + // fails (dir may be absent). The `watcher.ignore` config skips a dir matched by either form, and + // the result is deduped (--git-dir and --git-common-dir coincide in a normal repo). + export async function resolveVcsWatchDirs(input: { + directory: string + gitDirs: string[] + cfgIgnores: string[] + resolveLink?: (target: string) => Promise + }): Promise { + const resolveLink = input.resolveLink ?? ((target) => realpath(target)) + const resolved = [ + ...new Set( + input.gitDirs + .map((line) => line.trim()) + .filter(Boolean) + .map((line) => path.resolve(input.directory, line)), + ), + ] + const subscribed = new Set() + for (const resolvedVcsDir of resolved) { + const vcsDir = await resolveLink(resolvedVcsDir).catch(() => resolvedVcsDir) + if (input.cfgIgnores.includes(".git") || input.cfgIgnores.includes(resolvedVcsDir) || input.cfgIgnores.includes(vcsDir)) + continue + subscribed.add(vcsDir) + } + return [...subscribed] + } + export interface Interface { readonly init: () => Effect.Effect } @@ -776,37 +809,23 @@ export namespace FileWatcher { // checkout. A linked worktree keeps HEAD/index in the per-worktree --git-dir while // packed-refs/refs live in the shared --git-common-dir; watch both so branch and ref // events fire. They coincide in a normal repo, so dedupe to a single subscription. - // rev-parse may print relative paths (e.g. ".git"), so resolve each against - // ctx.directory rather than depending on --path-format=absolute (Git 2.31+). + // rev-parse may print relative paths (e.g. ".git"); resolveVcsWatchDirs resolves each + // against ctx.directory (rather than depending on --path-format=absolute, Git 2.31+), + // realpath-canonicalizes symlinked git dirs, applies watcher.ignore, and dedupes. const result = yield* git.run(["rev-parse", "--git-dir", "--git-common-dir"], { cwd: ctx.directory, }) - const resolvedVcsDirs = + const vcsDirs = result.exitCode === 0 - ? [ - ...new Set( - result - .text() - .trim() - .split("\n") - .map((line) => line.trim()) - .filter(Boolean) - .map((line) => path.resolve(ctx.directory, line)), - ), - ] + ? yield* Effect.promise(() => + resolveVcsWatchDirs({ + directory: ctx.directory, + gitDirs: result.text().trim().split("\n"), + cfgIgnores, + }), + ) : [] - const subscribedVcsDirs = new Set() - for (const resolvedVcsDir of resolvedVcsDirs) { - // Canonicalize a possibly-symlinked git dir (a symlinked .git, or a worktree - // reached through a symlinked path such as macOS /tmp -> /private/tmp): parcel - // emits events at the realpath, so an unresolved vcsDir makes path.relative in - // shouldPublishVcsWatcherPath traverse out (..) and drop every HEAD/ref event. - // Fall back to the unresolved path when realpath fails (dir may be absent). - const vcsDir = yield* Effect.promise(() => realpath(resolvedVcsDir).catch(() => resolvedVcsDir)) - if (cfgIgnores.includes(".git") || cfgIgnores.includes(resolvedVcsDir) || cfgIgnores.includes(vcsDir)) - continue - if (subscribedVcsDirs.has(vcsDir)) continue - subscribedVcsDirs.add(vcsDir) + for (const vcsDir of vcsDirs) { const ignore = vcsWatcherIgnoreEntries(yield* Effect.promise(() => readdir(vcsDir).catch(() => []))) log.info( "watcher subscription configured", diff --git a/packages/opencode/test/file/watcher.test.ts b/packages/opencode/test/file/watcher.test.ts index c2c916be6..ea11f5d84 100644 --- a/packages/opencode/test/file/watcher.test.ts +++ b/packages/opencode/test/file/watcher.test.ts @@ -456,6 +456,35 @@ describe("FileWatcher git metadata filtering", () => { expect(FileWatcher.shouldPublishVcsWatcherPath(path.join(gitDir, "refs", "stash"), gitDir)).toBe(false) expect(FileWatcher.shouldPublishVcsWatcherPath(path.join(gitDir, "MERGE_HEAD"), gitDir)).toBe(false) }) + + // CI-runnable gate for the symlinked-.git fix: no native watcher binding needed. + // Symlink creation needs privileges on Windows; skip there (matches upstream #27016). + const resolveDirsTest = process.platform === "win32" ? test.skip : test + resolveDirsTest("resolves symlinked git dirs to their realpath before subscribing", async () => { + await using tmp = await tmpdir() + const realGit = path.join(tmp.path, "real-git") + const linkGit = path.join(tmp.path, ".git") + await fs.mkdir(realGit) + await fs.symlink(realGit, linkGit) + + // --git-dir and --git-common-dir both report ".git" in a normal repo: resolve + realpath + dedupe to one. + expect( + await FileWatcher.resolveVcsWatchDirs({ directory: tmp.path, gitDirs: [".git", ".git"], cfgIgnores: [] }), + ).toEqual([realGit]) + + // watcher.ignore matching either the unresolved symlink path or the realpath'd dir skips it. + expect( + await FileWatcher.resolveVcsWatchDirs({ directory: tmp.path, gitDirs: [".git"], cfgIgnores: [linkGit] }), + ).toEqual([]) + expect( + await FileWatcher.resolveVcsWatchDirs({ directory: tmp.path, gitDirs: [".git"], cfgIgnores: [realGit] }), + ).toEqual([]) + + // realpath failure (absent dir) falls back to the unresolved resolved path. + expect( + await FileWatcher.resolveVcsWatchDirs({ directory: tmp.path, gitDirs: ["missing"], cfgIgnores: [] }), + ).toEqual([path.join(tmp.path, "missing")]) + }) }) describeWatcher("FileWatcher", () => { From 31b704703161f29f8345a846572b8513d53c9d6c Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Wed, 3 Jun 2026 17:26:27 +0800 Subject: [PATCH 3/3] test(watcher): isolate symlink target in a second tmpdir Address gemini-code-assist review: move the symlinked-.git target into a second `await using tmpdir()` instead of writing it to the parent of the repo tmpdir. Auto-cleans the target (no manual try/finally) and avoids parent-directory writes that could fail in sandboxed environments. --- packages/opencode/test/file/watcher.test.ts | 44 ++++++++++----------- 1 file changed, 21 insertions(+), 23 deletions(-) diff --git a/packages/opencode/test/file/watcher.test.ts b/packages/opencode/test/file/watcher.test.ts index ea11f5d84..abe1cf171 100644 --- a/packages/opencode/test/file/watcher.test.ts +++ b/packages/opencode/test/file/watcher.test.ts @@ -681,35 +681,33 @@ describeWatcher("FileWatcher", () => { const symlinkTest = process.platform === "win32" ? test.skip : test symlinkTest("publishes .git/HEAD events through a symlinked .git directory", async () => { await using tmp = await tmpdir({ git: true }) + await using tmpGit = await tmpdir() const dir = tmp.path - // Move .git to a sibling and replace it with a symlink. git rev-parse still reports ".git", - // so without realpath the watcher subscribes to the symlink and drops parcel's realpath'd events. - const actualGit = path.join(dir, "..", `actual-git-${Math.random().toString(36).slice(2)}`) + // Move .git into a separate tmpdir and replace it with a symlink. git rev-parse still reports + // ".git", so without realpath the watcher subscribes to the symlink and drops parcel's + // realpath'd events. The second tmpdir auto-cleans the symlink target (no parent-dir writes). + const actualGit = path.join(tmpGit.path, ".git") await fs.rename(path.join(dir, ".git"), actualGit) await fs.symlink(actualGit, path.join(dir, ".git")) - try { - const head = path.join(actualGit, "HEAD") - const branch = `watch-${Math.random().toString(36).slice(2)}` - await $`git branch ${branch}`.cwd(dir).quiet() + const head = path.join(actualGit, "HEAD") + const branch = `watch-${Math.random().toString(36).slice(2)}` + await $`git branch ${branch}`.cwd(dir).quiet() - await withWatcher( + await withWatcher( + dir, + nextUpdate( dir, - nextUpdate( - dir, - (evt) => evt.file === head && evt.event !== "unlink", - Effect.promise(() => fs.writeFile(head, `ref: refs/heads/${branch}\n`)), - ).pipe( - Effect.tap((evt) => - Effect.sync(() => { - expect(evt.file).toBe(head) - expect(["add", "change"]).toContain(evt.event) - }), - ), + (evt) => evt.file === head && evt.event !== "unlink", + Effect.promise(() => fs.writeFile(head, `ref: refs/heads/${branch}\n`)), + ).pipe( + Effect.tap((evt) => + Effect.sync(() => { + expect(evt.file).toBe(head) + expect(["add", "change"]).toContain(evt.event) + }), ), - ) - } finally { - await fs.rm(actualGit, { recursive: true, force: true }).catch(() => undefined) - } + ), + ) }) })