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.
This commit is contained in:
Mohamed Boudra
2026-10-26 18:05:32 +01:00
parent d78de43e4d
commit 1a02425db4
2 changed files with 42 additions and 1 deletions
@@ -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<string>();
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);
}
@@ -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 });
}
});