Skip to content

Commit 1ac3f09

Browse files
fix(watcher): resolve symlinked .git path before subscribing (#27016)
Co-authored-by: Simon Klee <hello@simonklee.dk>
1 parent ca8f578 commit 1ac3f09

2 files changed

Lines changed: 59 additions & 3 deletions

File tree

packages/opencode/src/file/watcher.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { Cause, Effect, Layer, Context, Schema } from "effect"
22
// @ts-ignore
33
import { createWrapper } from "@parcel/watcher/wrapper"
44
import type ParcelWatcher from "@parcel/watcher"
5-
import { readdir } from "fs/promises"
5+
import { readdir, realpath } from "fs/promises"
66
import path from "path"
77
import { Bus } from "@/bus"
88
import { BusEvent } from "@/bus/bus-event"
@@ -131,8 +131,11 @@ export const layer = Layer.effect(
131131
const result = yield* git.run(["rev-parse", "--git-dir"], {
132132
cwd: ctx.worktree,
133133
})
134-
const vcsDir = result.exitCode === 0 ? path.resolve(ctx.worktree, result.text().trim()) : undefined
135-
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir)) {
134+
const resolved = result.exitCode === 0 ? path.resolve(ctx.worktree, result.text().trim()) : undefined
135+
const vcsDir = resolved
136+
? yield* Effect.promise(() => realpath(resolved).catch(() => resolved))
137+
: undefined
138+
if (vcsDir && !cfgIgnores.includes(".git") && !cfgIgnores.includes(vcsDir) && (!resolved || !cfgIgnores.includes(resolved))) {
136139
const ignore = (yield* Effect.promise(() => readdir(vcsDir).catch(() => []))).filter(
137140
(entry) => entry !== "HEAD",
138141
)

packages/opencode/test/file/watcher.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,4 +260,57 @@ describeWatcher("FileWatcher", () => {
260260
}),
261261
{ git: true },
262262
)
263+
264+
// Symlink support varies by platform; skip where unavailable
265+
const describeSymlink = process.platform !== "win32" ? describe : describe.skip
266+
267+
describeSymlink("symlinked .git", () => {
268+
it.instance(
269+
"publishes .git/HEAD events through a symlinked .git directory",
270+
() =>
271+
Effect.gen(function* () {
272+
const test = yield* TestInstance
273+
const fs = yield* AppFileSystem.Service
274+
const git = yield* Git.Service
275+
const dir = test.directory
276+
const actualGit = path.join(dir, "..", "tmp_actual_git_" + Math.random().toString(36).slice(2))
277+
278+
// Move .git to a sibling directory and replace with a symlink
279+
yield* Effect.promise(() => import("fs")).pipe(
280+
Effect.flatMap((nodeFs) =>
281+
Effect.all([
282+
Effect.promise(() => nodeFs.promises.rename(path.join(dir, ".git"), actualGit)),
283+
Effect.promise(() => nodeFs.promises.symlink(actualGit, path.join(dir, ".git"))),
284+
]),
285+
),
286+
)
287+
288+
yield* Effect.acquireRelease(
289+
Effect.succeed(actualGit),
290+
(p) => Effect.promise(() => import("fs").then((f) => f.promises.rm(p, { recursive: true, force: true }).catch(() => undefined))),
291+
)
292+
293+
const head = path.join(dir, ".git", "HEAD")
294+
const branch = `watch-${Math.random().toString(36).slice(2)}`
295+
yield* git.run(["branch", branch], { cwd: dir })
296+
297+
yield* withWatcher(
298+
dir,
299+
nextUpdate(
300+
dir,
301+
(evt) => evt.file === path.join(actualGit, "HEAD") && evt.event !== "unlink",
302+
fs.writeFileString(head, `ref: refs/heads/${branch}\n`),
303+
).pipe(
304+
Effect.tap((evt) =>
305+
Effect.sync(() => {
306+
expect(evt.file).toBe(path.join(actualGit, "HEAD"))
307+
expect(["add", "change"]).toContain(evt.event)
308+
}),
309+
),
310+
),
311+
)
312+
}),
313+
{ git: true },
314+
)
315+
})
263316
})

0 commit comments

Comments
 (0)