From 1a02425db4f74e2bd8b877a53a0164c58ac946ea Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 26 Oct 2026 16:08:41 +0100 Subject: [PATCH] fix(server): retain literal symlink targets in Git event identity Canonical realpaths can erase a symlink target spelling when its parent is also a symlink, such as /var on macOS. Resolve these aliases once during setup so deleted and ignored event paths still match without per-event IO. --- .../src/server/git-observation-paths.ts | 19 ++++++++++++++- ...it-service.observation.integration.test.ts | 24 +++++++++++++++++++ 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/packages/server/src/server/git-observation-paths.ts b/packages/server/src/server/git-observation-paths.ts index 59573d108c..1ef0a4e571 100644 --- a/packages/server/src/server/git-observation-paths.ts +++ b/packages/server/src/server/git-observation-paths.ts @@ -1,7 +1,8 @@ import { realpath } from "node:fs"; +import { readlink } from "node:fs/promises"; import { promisify } from "node:util"; import pLimit from "p-limit"; -import { sep } from "node:path"; +import { dirname, resolve as resolvePath, sep } from "node:path"; import { looksLikeDefiniteWindowsPath, normalizePathPreservingCase } from "../utils/path.js"; const nativeRealpath = promisify(realpath.native); @@ -31,6 +32,22 @@ export class GitObservationPaths { /* Missing roots retain lexical identity. */ } } + // A link may name /var/... while realpath returns /private/var/... on macOS. + // Retain literal targets too, including chains, without following links in callbacks. + let current = root; + const visited = new Set(); + for (let remaining = 40; remaining > 0; remaining--) { + const alias = normalizePath(current); + if (visited.has(alias)) break; + visited.add(alias); + aliases.add(alias); + try { + const target = await identityIo(() => readlink(current)); + current = resolvePath(dirname(current), target); + } catch { + break; + } + } return new GitObservationPaths(root, [...aliases], windows); } diff --git a/packages/server/src/server/workspace-git-service.observation.integration.test.ts b/packages/server/src/server/workspace-git-service.observation.integration.test.ts index e2171886a7..efb00a938a 100644 --- a/packages/server/src/server/workspace-git-service.observation.integration.test.ts +++ b/packages/server/src/server/workspace-git-service.observation.integration.test.ts @@ -671,3 +671,27 @@ test("concurrent alias requests establish one working-tree subscription", async rmSync(root, { recursive: true, force: true }); } }); + +test("Git event identity retains a symlink's literal target before canonicalizing its parent", async () => { + const { mkdir, symlink } = await import("node:fs/promises"); + const { GitObservationPaths } = await import("./git-observation-paths.js"); + const root = realpathSync(mkdtempSync(path.join(tmpdir(), "git-observation-literal-target-"))); + const repository = path.join(root, "repository"); + const parentAlias = path.join(root, "parent-alias"); + const workspaceAlias = path.join(root, "workspace-alias"); + const literalTarget = path.join(parentAlias, "repository"); + const linkType = process.platform === "win32" ? "junction" : "dir"; + try { + await mkdir(repository); + await symlink(root, parentAlias, linkType); + await symlink(literalTarget, workspaceAlias, linkType); + const paths = await GitObservationPaths.create(workspaceAlias); + const accept = paths.createEventFilter([path.join(workspaceAlias, "ignored")]); + for (const spelling of [workspaceAlias, literalTarget, repository]) { + expect(paths.relative(path.join(spelling, "deleted.txt"))).toBe("deleted.txt"); + expect(accept(path.join(spelling, "ignored", "deleted.txt"))).toBe(false); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } +});