diff --git a/src/application/ReferenceSourceGitDirectory.ts b/src/application/ReferenceSourceGitDirectory.ts index 78927935ff..a2543d51cf 100644 --- a/src/application/ReferenceSourceGitDirectory.ts +++ b/src/application/ReferenceSourceGitDirectory.ts @@ -1,4 +1,4 @@ -import { lstat } from "node:fs/promises"; +import { lstat, realpath } from "node:fs/promises"; import { dirname, join } from "node:path"; import { absoluteCallerPath } from "../filesystem/callerPath.js"; @@ -29,20 +29,29 @@ export const locateReferenceSourceGitDirectory = async ( repository = parent; continue; } - let gitDirectory = dotgit; + let selectedGitDirectory = dotgit; if (metadata.isFile()) { const pointer = (await readText(dotgit)).replace(/\r?\n$/u, ""); if (!pointer.startsWith("gitdir: ") || pointer.length === 8) throw new Error("Invalid Git directory pointer"); - gitDirectory = absoluteCallerPath(pointer.slice(8), { cwd: repository }); + selectedGitDirectory = absoluteCallerPath(pointer.slice(8), { + cwd: repository, + }); } + // Derived joins and the Git library normalize paths lexically. Admit the + // physical directory first, while the source pointer stays in its file. + const gitDirectory = await realpath(selectedGitDirectory); let commonDirectory = gitDirectory; try { const common = (await readText(join(gitDirectory, "commondir"))).replace( /\r?\n$/u, "", ); - commonDirectory = absoluteCallerPath(common, { cwd: gitDirectory }); + if (common.length === 0) + throw new Error("Invalid Git common-directory pointer"); + commonDirectory = await realpath( + absoluteCallerPath(common, { cwd: gitDirectory }), + ); } catch (cause: unknown) { if (!hasCode(cause, "ENOENT")) throw cause; } diff --git a/tests/boundary/filesystem/referenceSourceIgnorePolicy.test.ts b/tests/boundary/filesystem/referenceSourceIgnorePolicy.test.ts index bc347e55d6..565ddf9088 100644 --- a/tests/boundary/filesystem/referenceSourceIgnorePolicy.test.ts +++ b/tests/boundary/filesystem/referenceSourceIgnorePolicy.test.ts @@ -1,4 +1,4 @@ -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { mkdir, readFile, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect, it } from "vitest"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; @@ -124,3 +124,34 @@ it.each([false, true])( ); }, ); + +it.skipIf(process.platform === "win32")( + "binds Git pointer/configuration reads to physical symlink-parent selection", + async () => { + const root = await createTestTempDirectory("rea-reference-git-parent-"); + const source = join(root, "source"); + const physical = join(root, "physical"); + await mkdir(source); + await mkdir(join(physical, "child"), { recursive: true }); + await mkdir(join(physical, "metadata")); + await mkdir(join(source, "metadata")); + await symlink(join(physical, "child"), join(source, "alias")); + const intendedHead = "1".repeat(40); + for (const [directory, head, ignoreCase] of [ + [join(physical, "metadata"), intendedHead, false], + [join(source, "metadata"), "2".repeat(40), true], + ] as const) { + await writeFile(join(directory, "HEAD"), `${head}\n`); + await writeFile( + join(directory, "config"), + `[core]\nignorecase = ${String(ignoreCase)}\n`, + ); + } + await writeFile(join(source, ".git"), "gitdir: alias/../metadata\n"); + await writeFile(join(source, ".gitignore"), "*.TMP\n"); + await writeFile(join(source, "lower.tmp"), "selected policy\n"); + const graph = await importTree(source); + expect(graph.entries.some(({ path }) => path === "lower.tmp")).toBe(true); + expect(graph.vcs).toMatchObject({ kind: "git", head: intendedHead }); + }, +);