Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 44 additions & 15 deletions packages/opencode/src/file/watcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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<string>
}): Promise<string[]> {
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<string>()
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<void>
}
Expand Down Expand Up @@ -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",
Expand Down
73 changes: 71 additions & 2 deletions packages/opencode/test/file/watcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,6 @@ function noUpdate<E>(

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(
Expand All @@ -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)
Expand All @@ -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)
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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)
}),
),
),
)
})
Comment thread
Astro-Han marked this conversation as resolved.
})
Loading