diff --git a/packages/core/test/npm.test.ts b/packages/core/test/npm.test.ts index 8ba1eb025f7..ad39b0187b2 100644 --- a/packages/core/test/npm.test.ts +++ b/packages/core/test/npm.test.ts @@ -319,6 +319,31 @@ describe("Npm.add", () => { expect(result.pinned).toContain("root: true") expect(result.current).toBeFalse() }, 30_000) + + // Symlink creation needs elevated privileges on Windows. + test.skipIf(win)("records Git revisions when the cache directory is reached through a symlink", async () => { + await using tmp = await tmpdir() + const fixture = await createGitFixture(tmp.path) + await fs.mkdir(path.join(tmp.path, "cache")) + await fs.symlink(path.join(tmp.path, "cache"), path.join(tmp.path, "link")) + const mutable = `git+${pathToFileURL(fixture.repository).href}#fixture-branch` + + const result = await Effect.gen(function* () { + const npm = yield* Npm.Service + const added = yield* npm.add(mutable) + const current = yield* npm.check(mutable) + yield* Effect.promise(async () => { + await Bun.write(path.join(fixture.repository, "index.js"), 'export default { root: "second" }\n') + await Bun.$`git -C ${fixture.repository} add .` + await Bun.$`git -C ${fixture.repository} -c user.name=fixture -c user.email=fixture@example.com commit -qm second` + }) + return { added, current, outdated: yield* npm.check(mutable) } + }).pipe(Effect.scoped, Effect.provide(npmLayer(path.join(tmp.path, "link"))), Effect.runPromise) + + expect(result.added.version).toBe(fixture.commit) + expect(result.current).toBeFalse() + expect(result.outdated).toBeTrue() + }, 30_000) }) describe("Npm.resolve", () => { diff --git a/packages/util/src/npm.ts b/packages/util/src/npm.ts index 461958fe97d..61d7c209058 100644 --- a/packages/util/src/npm.ts +++ b/packages/util/src/npm.ts @@ -286,7 +286,13 @@ const layer = Layer.effect( yield* mkdir(dir) const startedAt = yield* Clock.currentTimeMillis - const staging = path.join(dir, `.staging-${startedAt}-${randomUUID()}`) + // Arborist keys lockfile entries relative to the root's real path. When the cache + // directory is reached through a symlink (macOS `/var` → `/private/var`, a linked + // XDG cache), the keys become `../../…` paths that installedRevision never finds, + // so Git checks report "not installed" and updates go undetected. Stage under the + // resolved directory so the root path and real path agree. + const root = yield* fs.realPath(dir).pipe(Effect.mapError((cause) => new InstallFailedError({ dir, cause }))) + const staging = path.join(root, `.staging-${startedAt}-${randomUUID()}`) const staged = yield* Effect.gen(function* () { const tree = yield* reify({ dir: staging, config: dir, add: [pkg], update }) const installed = tree.edgesOut.values().next().value?.to