From dfb5a478c7b97de217c54602e702bfb8b3bf6599 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:06:43 +0200 Subject: [PATCH] fix(server): keep the Git-ignore ancestor walk for symlinked entries A symlink resolves to a path whose ancestors were never checked during traversal, so a link into an ignored directory was discovered. Named children keep the single-entry check. --- .../src/utils/directory-suggestions.test.ts | 20 +++++++++++++++++++ .../server/src/utils/directory-suggestions.ts | 13 ++++++++---- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/packages/server/src/utils/directory-suggestions.test.ts b/packages/server/src/utils/directory-suggestions.test.ts index 57b06602e2..9611ae7732 100644 --- a/packages/server/src/utils/directory-suggestions.test.ts +++ b/packages/server/src/utils/directory-suggestions.test.ts @@ -170,6 +170,26 @@ describe("searchDirectoryEntries", () => { ); }); + it.skipIf(isWindows)( + "prunes a symlink whose target is inside a Git-ignored directory", + async () => { + initGitRepo(searchRoot, "generated/\n"); + mkdirSync(path.join(searchRoot, "generated", "output"), { recursive: true }); + symlinkSync(path.join(searchRoot, "generated", "output"), path.join(searchRoot, "linked")); + + await expect( + searchDirectoryEntries({ + root: searchRoot, + query: "linked", + pathFormat: "relative", + includeFiles: false, + includeDirectories: true, + respectGitIgnore: true, + }), + ).resolves.toEqual([]); + }, + ); + it("configures raw blank queries independently from explicit root aliases", async () => { const rootEntries = [ { path: "projects", kind: "directory" as const }, diff --git a/packages/server/src/utils/directory-suggestions.ts b/packages/server/src/utils/directory-suggestions.ts index ee673ded22..35e8f5154b 100644 --- a/packages/server/src/utils/directory-suggestions.ts +++ b/packages/server/src/utils/directory-suggestions.ts @@ -337,10 +337,7 @@ function staysInsideRoot(entry: ChildEntry, root: string): boolean { } function shouldDiscover(entry: ChildEntry, input: SearchInput): boolean { - // Traversal only descends through entries that already passed this check, so every ancestor - // is known discoverable and only the entry itself can be newly ignored. Walking the ancestor - // chain here would re-derive that answer once per level, for every entry scanned. - if (input.gitIgnoredPaths.has(entry.resolvedPath)) return false; + if (isNewlyGitIgnored(entry, input)) return false; if (entry.kind === "file") { return input.includeFiles && !entry.name.startsWith("."); } @@ -349,6 +346,14 @@ function shouldDiscover(entry: ChildEntry, input: SearchInput): boolean { return input.hiddenDirectoryNames.has(entry.name); } +// Traversal only descends through entries that already passed this check, so every ancestor of +// a named child is known discoverable and only the entry itself can be newly ignored. A symlink +// resolves to a path with different ancestors, so it still needs the full walk. +function isNewlyGitIgnored(entry: ChildEntry, input: SearchInput): boolean { + if (entry.viaSymlink) return isGitIgnoredPath(entry.resolvedPath, input); + return input.gitIgnoredPaths.has(entry.resolvedPath); +} + function isGitIgnoredPath(absolutePath: string, input: SearchInput): boolean { let candidate = absolutePath; while (candidate !== input.root && isPathInsideRoot(input.root, candidate)) {