Skip Git-ignored paths in workspace suggestions (#2902)
This commit is contained in:
@@ -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" };
|
||||||
|
|||||||
Reference in New Issue
Block a user