Skip Git-ignored paths in workspace suggestions (#2902)

This commit is contained in:
Mohamed Boudra
2026-08-05 18:20:16 +02:00
committed by GitHub
parent 1c43e4eea1
commit 781274ed1a
3 changed files with 116 additions and 1 deletions
+1
View File
@@ -3898,6 +3898,7 @@ export class Session {
? WORKSPACE_SEARCH_HIDDEN_DIRECTORIES ? WORKSPACE_SEARCH_HIDDEN_DIRECTORIES
: [], : [],
confidentResultScanThreshold: searchesWorkspace ? undefined : 5_000, confidentResultScanThreshold: searchesWorkspace ? undefined : 5_000,
respectGitIgnore: searchesWorkspace,
includeFiles, includeFiles,
includeDirectories, includeDirectories,
matchMode, matchMode,
@@ -1,3 +1,4 @@
import { execFileSync } from "node:child_process";
import { import {
mkdtempSync, mkdtempSync,
mkdirSync, mkdirSync,
@@ -11,6 +12,7 @@ import { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { isPlatform } from "../test-utils/platform.js"; import { isPlatform } from "../test-utils/platform.js";
import { startGitCommandMetrics, stopGitCommandMetrics } from "./run-git-command.js";
import { import {
searchDirectoryEntries, searchDirectoryEntries,
WORKSPACE_SEARCH_HIDDEN_DIRECTORIES, 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 () => { it("configures raw blank queries independently from explicit root aliases", async () => {
const rootEntries = [ const rootEntries = [
{ path: "projects", kind: "directory" as const }, { path: "projects", kind: "directory" as const },
@@ -2,6 +2,7 @@ import type { Dirent, Stats } from "node:fs";
import { readdir, realpath, stat } from "node:fs/promises"; import { readdir, realpath, stat } from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { isPathInsideRoot } from "./path.js"; import { isPathInsideRoot } from "./path.js";
import { runGitCommand } from "./run-git-command.js";
export type DirectorySuggestionKind = "file" | "directory"; export type DirectorySuggestionKind = "file" | "directory";
export type DirectorySuggestionPathFormat = "absolute" | "relative"; export type DirectorySuggestionPathFormat = "absolute" | "relative";
@@ -29,6 +30,7 @@ export interface SearchDirectoryEntriesOptions {
maxDepth?: number; maxDepth?: number;
maxEntriesScanned?: number; maxEntriesScanned?: number;
confidentResultScanThreshold?: number; confidentResultScanThreshold?: number;
respectGitIgnore?: boolean;
} }
interface QueryPlan { interface QueryPlan {
@@ -70,12 +72,19 @@ interface DirectoryListCacheEntry {
entries: RawChildEntry[]; entries: RawChildEntry[];
} }
interface GitIgnoredPathsCacheEntry {
expiresAt: number;
paths: Promise<Set<string>>;
}
const DEFAULT_LIMIT = 30; const DEFAULT_LIMIT = 30;
const MAX_LIMIT = 100; const MAX_LIMIT = 100;
const DEFAULT_MAX_DEPTH = 12; const DEFAULT_MAX_DEPTH = 12;
const DEFAULT_MAX_ENTRIES_SCANNED = 20_000; const DEFAULT_MAX_ENTRIES_SCANNED = 20_000;
const DIRECTORY_LIST_CACHE_TTL_MS = 8_000; const DIRECTORY_LIST_CACHE_TTL_MS = 8_000;
const DIRECTORY_LIST_CACHE_MAX_ENTRIES = 4_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, // Windows does not reliably update directory mtime/ctime when children change,
// so metadata cannot safely validate a cross-request listing cache there. // so metadata cannot safely validate a cross-request listing cache there.
const CAN_VALIDATE_DIRECTORY_CACHE_FROM_METADATA = process.platform !== "win32"; const CAN_VALIDATE_DIRECTORY_CACHE_FROM_METADATA = process.platform !== "win32";
@@ -108,6 +117,7 @@ const IGNORED_DIRECTORY_NAMES = new Set([
".git", ".git",
]); ]);
const directoryListCache = new Map<string, DirectoryListCacheEntry>(); const directoryListCache = new Map<string, DirectoryListCacheEntry>();
const gitIgnoredPathsCache = new Map<string, GitIgnoredPathsCacheEntry>();
export async function searchDirectoryEntries( export async function searchDirectoryEntries(
options: SearchDirectoryEntriesOptions, options: SearchDirectoryEntriesOptions,
@@ -115,7 +125,10 @@ export async function searchDirectoryEntries(
const root = await resolveDirectory(options.root); const root = await resolveDirectory(options.root);
if (!root) return []; if (!root) return [];
const input = buildSearchInput(options, root); const gitIgnoredPaths = options.respectGitIgnore
? await loadGitIgnoredPaths(root)
: new Set<string>();
const input = buildSearchInput(options, root, gitIgnoredPaths);
if (!input) return []; if (!input) return [];
const exact = const exact =
@@ -138,6 +151,7 @@ export async function searchDirectoryEntries(
function buildSearchInput( function buildSearchInput(
options: SearchDirectoryEntriesOptions, options: SearchDirectoryEntriesOptions,
root: string, root: string,
gitIgnoredPaths: Set<string>,
): SearchInput | null { ): SearchInput | null {
const includeDirectories = options.includeDirectories ?? true; const includeDirectories = options.includeDirectories ?? true;
const includeFiles = options.includeFiles ?? false; const includeFiles = options.includeFiles ?? false;
@@ -165,6 +179,7 @@ function buildSearchInput(
maxDepth: options.maxDepth ?? DEFAULT_MAX_DEPTH, maxDepth: options.maxDepth ?? DEFAULT_MAX_DEPTH,
maxEntriesScanned: options.maxEntriesScanned ?? DEFAULT_MAX_ENTRIES_SCANNED, maxEntriesScanned: options.maxEntriesScanned ?? DEFAULT_MAX_ENTRIES_SCANNED,
confidentResultScanThreshold: options.confidentResultScanThreshold, confidentResultScanThreshold: options.confidentResultScanThreshold,
gitIgnoredPaths,
}; };
} }
@@ -173,6 +188,7 @@ async function findExactEntry(input: SearchInput): Promise<DirectorySuggestionEn
const visiblePath = path.resolve(input.root, input.plan.normalizedQuery); const visiblePath = path.resolve(input.root, input.plan.normalizedQuery);
const resolvedPath = await realpath(visiblePath).catch(() => null); const resolvedPath = await realpath(visiblePath).catch(() => null);
if (!resolvedPath || !isPathInsideRoot(input.root, resolvedPath)) return null; if (!resolvedPath || !isPathInsideRoot(input.root, resolvedPath)) return null;
if (isGitIgnoredPath(resolvedPath, input)) return null;
const info = await stat(resolvedPath).catch(() => null); const info = await stat(resolvedPath).catch(() => null);
const kind = getEntryKind(info); const kind = getEntryKind(info);
if ( if (
@@ -196,12 +212,14 @@ interface SearchInput {
maxDepth: number; maxDepth: number;
maxEntriesScanned: number; maxEntriesScanned: number;
confidentResultScanThreshold: number | undefined; confidentResultScanThreshold: number | undefined;
gitIgnoredPaths: Set<string>;
} }
async function searchChildren(input: SearchInput): Promise<RankedEntry[]> { async function searchChildren(input: SearchInput): Promise<RankedEntry[]> {
const visibleParent = path.resolve(input.root, input.plan.parentPart || "."); const visibleParent = path.resolve(input.root, input.plan.parentPart || ".");
const parent = await realpath(visibleParent).catch(() => null); const parent = await realpath(visibleParent).catch(() => null);
if (!parent || !isPathInsideRoot(input.root, parent)) return []; if (!parent || !isPathInsideRoot(input.root, parent)) return [];
if (isGitIgnoredPath(parent, input)) return [];
const entries = await readChildren(parent); const entries = await readChildren(parent);
return entries.flatMap((entry) => { return entries.flatMap((entry) => {
if (!isPathInsideRoot(input.root, entry.resolvedPath) || !shouldDiscover(entry, input)) if (!isPathInsideRoot(input.root, entry.resolvedPath) || !shouldDiscover(entry, input))
@@ -297,6 +315,7 @@ async function* roundRobin<T>(branches: Array<AsyncGenerator<T>>): AsyncGenerato
} }
function shouldDiscover(entry: ChildEntry, input: SearchInput): boolean { function shouldDiscover(entry: ChildEntry, input: SearchInput): boolean {
if (isGitIgnoredPath(entry.resolvedPath, input)) return false;
if (entry.kind === "file") { if (entry.kind === "file") {
return input.includeFiles && !entry.name.startsWith("."); return input.includeFiles && !entry.name.startsWith(".");
} }
@@ -305,6 +324,15 @@ function shouldDiscover(entry: ChildEntry, input: SearchInput): boolean {
return input.hiddenDirectoryNames.has(entry.name); 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 { function shouldSuggest(entry: TraversedEntry, input: SearchInput): boolean {
if (entry.name.startsWith(".")) return false; if (entry.name.startsWith(".")) return false;
if (entry.kind === "directory" && !input.includeDirectories) return false; if (entry.kind === "directory" && !input.includeDirectories) return false;
@@ -540,6 +568,47 @@ async function readChildren(directory: string): Promise<ChildEntry[]> {
.sort((left, right) => left.name.localeCompare(right.name)); .sort((left, right) => left.name.localeCompare(right.name));
} }
async function loadGitIgnoredPaths(root: string): Promise<Set<string>> {
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<string>());
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 { function toRawChildEntry(dirent: Dirent): RawChildEntry | null {
if (dirent.isDirectory()) return { name: dirent.name, kind: "directory" }; if (dirent.isDirectory()) return { name: dirent.name, kind: "directory" };
if (dirent.isFile()) return { name: dirent.name, kind: "file" }; if (dirent.isFile()) return { name: dirent.name, kind: "file" };