diff --git a/packages/opencode/src/file/watcher.ts b/packages/opencode/src/file/watcher.ts index 60d57c74a..2c97f4656 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" @@ -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,27 +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 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, + }), + ) : [] for (const vcsDir of vcsDirs) { - if (cfgIgnores.includes(".git") || cfgIgnores.includes(vcsDir)) continue 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..abe1cf171 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) @@ -450,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", () => { @@ -641,4 +676,38 @@ 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 }) + await using tmpGit = await tmpdir() + const dir = tmp.path + // 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")) + + 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) + }), + ), + ), + ) + }) })