From 781274ed1ae0a54f2f355817d97a346db411f909 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 5 Aug 2026 18:20:16 +0200 Subject: [PATCH] Skip Git-ignored paths in workspace suggestions (#2902) --- packages/server/src/server/session.ts | 1 + .../src/utils/directory-suggestions.test.ts | 45 ++++++++++++ .../server/src/utils/directory-suggestions.ts | 71 ++++++++++++++++++- 3 files changed, 116 insertions(+), 1 deletion(-) diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 0ae81a21c..ff154a112 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -3898,6 +3898,7 @@ export class Session { ? WORKSPACE_SEARCH_HIDDEN_DIRECTORIES : [], confidentResultScanThreshold: searchesWorkspace ? undefined : 5_000, + respectGitIgnore: searchesWorkspace, includeFiles, includeDirectories, matchMode, diff --git a/packages/server/src/utils/directory-suggestions.test.ts b/packages/server/src/utils/directory-suggestions.test.ts index 309232814..932e1b005 100644 --- a/packages/server/src/utils/directory-suggestions.test.ts +++ b/packages/server/src/utils/directory-suggestions.test.ts @@ -1,3 +1,4 @@ +import { execFileSync } from "node:child_process"; import { mkdtempSync, mkdirSync, @@ -11,6 +12,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { isPlatform } from "../test-utils/platform.js"; +import { startGitCommandMetrics, stopGitCommandMetrics } from "./run-git-command.js"; import { searchDirectoryEntries, WORKSPACE_SEARCH_HIDDEN_DIRECTORIES, @@ -117,6 +119,49 @@ describe("searchDirectoryEntries", () => { }); }); + it("prunes directories ignored by Git and caches concurrent lookups", async () => { + execFileSync("git", ["init", "-q"], { cwd: searchRoot }); + writeFileSync(path.join(searchRoot, ".gitignore"), "data/\n"); + mkdirSync(path.join(searchRoot, "data", "matching-ignored-directory"), { + recursive: true, + }); + mkdirSync(path.join(searchRoot, "visible", "matching-visible-directory"), { + recursive: true, + }); + + startGitCommandMetrics(); + const results = await Promise.all([ + searchDirectoryEntries({ + root: searchRoot, + query: "matching", + pathFormat: "relative", + includeFiles: false, + includeDirectories: true, + respectGitIgnore: true, + }), + searchDirectoryEntries({ + root: searchRoot, + query: "visible", + pathFormat: "relative", + includeFiles: false, + includeDirectories: true, + respectGitIgnore: true, + }), + ]); + const gitMetrics = stopGitCommandMetrics(); + + expect(results).toEqual([ + [{ path: "visible/matching-visible-directory", kind: "directory" }], + [ + { path: "visible", kind: "directory" }, + { path: "visible/matching-visible-directory", kind: "directory" }, + ], + ]); + expect(gitMetrics.commands.filter((command) => command.args.includes("ls-files"))).toHaveLength( + 1, + ); + }); + 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 081c36ede..c5daa09cf 100644 --- a/packages/server/src/utils/directory-suggestions.ts +++ b/packages/server/src/utils/directory-suggestions.ts @@ -2,6 +2,7 @@ import type { Dirent, Stats } from "node:fs"; import { readdir, realpath, stat } from "node:fs/promises"; import path from "node:path"; import { isPathInsideRoot } from "./path.js"; +import { runGitCommand } from "./run-git-command.js"; export type DirectorySuggestionKind = "file" | "directory"; export type DirectorySuggestionPathFormat = "absolute" | "relative"; @@ -29,6 +30,7 @@ export interface SearchDirectoryEntriesOptions { maxDepth?: number; maxEntriesScanned?: number; confidentResultScanThreshold?: number; + respectGitIgnore?: boolean; } interface QueryPlan { @@ -70,12 +72,19 @@ interface DirectoryListCacheEntry { entries: RawChildEntry[]; } +interface GitIgnoredPathsCacheEntry { + expiresAt: number; + paths: Promise>; +} + const DEFAULT_LIMIT = 30; const MAX_LIMIT = 100; const DEFAULT_MAX_DEPTH = 12; const DEFAULT_MAX_ENTRIES_SCANNED = 20_000; const DIRECTORY_LIST_CACHE_TTL_MS = 8_000; const DIRECTORY_LIST_CACHE_MAX_ENTRIES = 4_000; +const GIT_IGNORED_PATHS_CACHE_TTL_MS = 8_000; +const GIT_IGNORED_PATHS_CACHE_MAX_ENTRIES = 256; // Windows does not reliably update directory mtime/ctime when children change, // so metadata cannot safely validate a cross-request listing cache there. const CAN_VALIDATE_DIRECTORY_CACHE_FROM_METADATA = process.platform !== "win32"; @@ -108,6 +117,7 @@ const IGNORED_DIRECTORY_NAMES = new Set([ ".git", ]); const directoryListCache = new Map(); +const gitIgnoredPathsCache = new Map(); export async function searchDirectoryEntries( options: SearchDirectoryEntriesOptions, @@ -115,7 +125,10 @@ export async function searchDirectoryEntries( const root = await resolveDirectory(options.root); if (!root) return []; - const input = buildSearchInput(options, root); + const gitIgnoredPaths = options.respectGitIgnore + ? await loadGitIgnoredPaths(root) + : new Set(); + const input = buildSearchInput(options, root, gitIgnoredPaths); if (!input) return []; const exact = @@ -138,6 +151,7 @@ export async function searchDirectoryEntries( function buildSearchInput( options: SearchDirectoryEntriesOptions, root: string, + gitIgnoredPaths: Set, ): SearchInput | null { const includeDirectories = options.includeDirectories ?? true; const includeFiles = options.includeFiles ?? false; @@ -165,6 +179,7 @@ function buildSearchInput( maxDepth: options.maxDepth ?? DEFAULT_MAX_DEPTH, maxEntriesScanned: options.maxEntriesScanned ?? DEFAULT_MAX_ENTRIES_SCANNED, confidentResultScanThreshold: options.confidentResultScanThreshold, + gitIgnoredPaths, }; } @@ -173,6 +188,7 @@ async function findExactEntry(input: SearchInput): Promise null); if (!resolvedPath || !isPathInsideRoot(input.root, resolvedPath)) return null; + if (isGitIgnoredPath(resolvedPath, input)) return null; const info = await stat(resolvedPath).catch(() => null); const kind = getEntryKind(info); if ( @@ -196,12 +212,14 @@ interface SearchInput { maxDepth: number; maxEntriesScanned: number; confidentResultScanThreshold: number | undefined; + gitIgnoredPaths: Set; } async function searchChildren(input: SearchInput): Promise { const visibleParent = path.resolve(input.root, input.plan.parentPart || "."); const parent = await realpath(visibleParent).catch(() => null); if (!parent || !isPathInsideRoot(input.root, parent)) return []; + if (isGitIgnoredPath(parent, input)) return []; const entries = await readChildren(parent); return entries.flatMap((entry) => { if (!isPathInsideRoot(input.root, entry.resolvedPath) || !shouldDiscover(entry, input)) @@ -297,6 +315,7 @@ async function* roundRobin(branches: Array>): AsyncGenerato } function shouldDiscover(entry: ChildEntry, input: SearchInput): boolean { + if (isGitIgnoredPath(entry.resolvedPath, input)) return false; if (entry.kind === "file") { return input.includeFiles && !entry.name.startsWith("."); } @@ -305,6 +324,15 @@ function shouldDiscover(entry: ChildEntry, input: SearchInput): boolean { return input.hiddenDirectoryNames.has(entry.name); } +function isGitIgnoredPath(absolutePath: string, input: SearchInput): boolean { + let candidate = absolutePath; + while (candidate !== input.root && isPathInsideRoot(input.root, candidate)) { + if (input.gitIgnoredPaths.has(candidate)) return true; + candidate = path.dirname(candidate); + } + return false; +} + function shouldSuggest(entry: TraversedEntry, input: SearchInput): boolean { if (entry.name.startsWith(".")) return false; if (entry.kind === "directory" && !input.includeDirectories) return false; @@ -540,6 +568,47 @@ async function readChildren(directory: string): Promise { .sort((left, right) => left.name.localeCompare(right.name)); } +async function loadGitIgnoredPaths(root: string): Promise> { + const now = Date.now(); + const cached = gitIgnoredPathsCache.get(root); + if (cached && cached.expiresAt > now) return cached.paths; + + const paths = runGitCommand(["ls-files", "-o", "-i", "--directory", "--exclude-standard", "-z"], { + cwd: root, + envOverlay: { GIT_OPTIONAL_LOCKS: "0" }, + timeout: 10_000, + }) + .then( + (result) => + new Set( + result.stdout + .split("\0") + .filter(Boolean) + .map((relativePath) => path.resolve(root, relativePath.replace(/\/$/, ""))), + ), + ) + .catch(() => new Set()); + + gitIgnoredPathsCache.set(root, { + expiresAt: now + GIT_IGNORED_PATHS_CACHE_TTL_MS, + paths, + }); + pruneGitIgnoredPathsCache(now); + return paths; +} + +function pruneGitIgnoredPathsCache(now: number): void { + if (gitIgnoredPathsCache.size <= GIT_IGNORED_PATHS_CACHE_MAX_ENTRIES) return; + for (const [key, entry] of gitIgnoredPathsCache) { + if (entry.expiresAt <= now) gitIgnoredPathsCache.delete(key); + } + while (gitIgnoredPathsCache.size > GIT_IGNORED_PATHS_CACHE_MAX_ENTRIES) { + const key = gitIgnoredPathsCache.keys().next().value; + if (!key) return; + gitIgnoredPathsCache.delete(key); + } +} + function toRawChildEntry(dirent: Dirent): RawChildEntry | null { if (dirent.isDirectory()) return { name: dirent.name, kind: "directory" }; if (dirent.isFile()) return { name: dirent.name, kind: "file" };