mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-25 10:42:45 +00:00
* fix(server): stop re-deriving path state for every scanned directory Directory suggestion scans were CPU-bound in path bookkeeping rather than in reading the tree. A 20,000-entry scan of a 197,000-directory home took 6.9s, of which readdir accounted for 50ms; the rest went to repeated path normalization. Three redundant passes are removed: - Discovery re-derived Git-ignore state by walking every ancestor of every entry. Traversal only descends through entries that already passed that check, so only the entry itself can be newly ignored. - Discovery re-derived root containment for every child. Only a symlink can resolve outside the tree being walked; every other child is its parent's path plus a name, and the parent was already proved inside. - The directory listing cache swept itself for expired keys on every miss once it reached its cap, an O(cache size) pass per directory read. Reads already reject stale entries by expiry and by directory metadata. The same scan now takes 1.1s. The new test expresses the budget against a plain readdir walk of the same tree, so it means the same thing on CI as on a developer machine: the ratio was 12x and is now under 3x. Refs #4879 * test(server): skip the scan-cost budget on Windows The tree it needs is deeper than MAX_PATH allows, and the directory listing cache it measures is disabled on Windows because directory metadata cannot validate it there. * test(server): count containment derivations instead of timing the scan The previous budget compared wall-clock scan time against a readdir walk of the same tree. packages/server/AGENTS.md requires tests to be deterministic, and a ratio between an I/O-bound walk and a CPU-bound scan moves with filesystem caching and runner load. isPathInsideRoot now counts its calls behind start/stopPathContainmentMetrics, matching the existing git command metrics. Scanning a symlink-free tree of 320 entries derived containment 1,760 times before this branch and derives it none now, which is the defect stated exactly rather than inferred from a stopwatch. A second test covers the other direction: a symlink that leaves the root is still checked, and still excluded from results. * 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. * test(server): declare the scan helper as a function --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
214 lines
7.7 KiB
TypeScript
214 lines
7.7 KiB
TypeScript
import os from "node:os";
|
|
import { realpathSync } from "node:fs";
|
|
import nodePath from "node:path";
|
|
|
|
/**
|
|
* Expand tilde in path to home directory
|
|
*/
|
|
export function expandTilde(path: string): string {
|
|
if (path.startsWith("~/")) {
|
|
const homeDir = process.env.HOME || os.homedir();
|
|
return path.replace("~", homeDir);
|
|
}
|
|
if (path === "~") {
|
|
return process.env.HOME || os.homedir();
|
|
}
|
|
return path;
|
|
}
|
|
|
|
/**
|
|
* Compare two path strings as filesystem-equivalent for cwd filtering.
|
|
*
|
|
* This is a string-only comparison: it normalizes separators and dot segments,
|
|
* ignores trailing separators, strips Windows namespace prefixes, and
|
|
* case-folds only when comparing as Windows. It does not resolve symlinks or
|
|
* check whether either path exists.
|
|
*/
|
|
export function areEquivalentPaths(left: string, right: string): boolean {
|
|
const compareAsWindows = shouldCompareAsWindows(left, right);
|
|
return (
|
|
normalizePathForComparison(left, compareAsWindows) ===
|
|
normalizePathForComparison(right, compareAsWindows)
|
|
);
|
|
}
|
|
|
|
export function createPathEquivalenceMatcher(target: string): (candidate: string) => boolean {
|
|
const targetLooksWindows = looksLikeDefiniteWindowsPath(target);
|
|
const compareAsWindows = targetLooksWindows;
|
|
const normalizedTarget = normalizePathForComparison(target, compareAsWindows);
|
|
|
|
return (candidate) => {
|
|
const candidateCompareAsWindows = compareAsWindows || looksLikeDefiniteWindowsPath(candidate);
|
|
const comparableTarget =
|
|
candidateCompareAsWindows === compareAsWindows
|
|
? normalizedTarget
|
|
: normalizePathForComparison(target, candidateCompareAsWindows);
|
|
return normalizePathForComparison(candidate, candidateCompareAsWindows) === comparableTarget;
|
|
};
|
|
}
|
|
|
|
export function createRealpathAwarePathMatcher(target: string): (candidate: string) => boolean {
|
|
const targetMatchers = collectPathVariants(target).map((variant) =>
|
|
createPathEquivalenceMatcher(variant),
|
|
);
|
|
|
|
return (candidate) => {
|
|
const candidateVariants = collectPathVariants(candidate);
|
|
return candidateVariants.some((variant) => targetMatchers.some((matches) => matches(variant)));
|
|
};
|
|
}
|
|
|
|
let pathContainmentChecks: number | null = null;
|
|
|
|
// Containment is re-derived from scratch on every call. A tree walk that asks it per entry, or
|
|
// per ancestor of every entry, costs more than reading the tree does, so tests count the calls.
|
|
export function startPathContainmentMetrics(): void {
|
|
pathContainmentChecks = 0;
|
|
}
|
|
|
|
export function stopPathContainmentMetrics(): number {
|
|
const checks = pathContainmentChecks ?? 0;
|
|
pathContainmentChecks = null;
|
|
return checks;
|
|
}
|
|
|
|
export function isPathInsideRoot(root: string, candidate: string): boolean {
|
|
if (pathContainmentChecks !== null) pathContainmentChecks += 1;
|
|
return getRelativePathInsideRoot(root, candidate) !== null;
|
|
}
|
|
|
|
export function normalizePathForIdentity(value: string): string {
|
|
const canonicalPath = resolveRealpathVariants(value)[0] ?? value;
|
|
return normalizePathForComparison(canonicalPath, looksLikeDefiniteWindowsPath(canonicalPath));
|
|
}
|
|
|
|
/**
|
|
* Returns the candidate's relative suffix when it is inside root.
|
|
*
|
|
* The suffix is derived from the same lexical path pair used to prove
|
|
* containment. Callers that map an existing filesystem path into a new root
|
|
* must keep those two operations coupled.
|
|
*/
|
|
export function getRealpathAwareRelativePath(root: string, candidate: string): string | null {
|
|
const rootVariants = collectPathVariants(root);
|
|
const candidateVariants = collectPathVariants(candidate);
|
|
|
|
for (const rootVariant of rootVariants) {
|
|
for (const candidateVariant of candidateVariants) {
|
|
const relativePath = getRelativePathInsideRoot(rootVariant, candidateVariant);
|
|
if (relativePath !== null) return relativePath;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export function isRealpathInsideRoot(root: string, candidate: string): boolean {
|
|
return getRealpathAwareRelativePath(root, candidate) !== null;
|
|
}
|
|
|
|
function getRelativePathInsideRoot(root: string, candidate: string): string | null {
|
|
const compareAsWindows = shouldCompareAsWindows(root, candidate);
|
|
const platformPath = compareAsWindows ? nodePath.win32 : nodePath.posix;
|
|
const normalizedRoot = normalizePathForComparison(root, compareAsWindows);
|
|
const normalizedCandidate = normalizePathForComparison(candidate, compareAsWindows);
|
|
const comparableRelative = platformPath.relative(normalizedRoot, normalizedCandidate);
|
|
|
|
if (
|
|
comparableRelative !== "" &&
|
|
(comparableRelative.startsWith("..") || platformPath.isAbsolute(comparableRelative))
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const casePreservingRoot = normalizePathPreservingCase(root, compareAsWindows);
|
|
const casePreservingCandidate = normalizePathPreservingCase(candidate, compareAsWindows);
|
|
return platformPath.relative(casePreservingRoot, casePreservingCandidate);
|
|
}
|
|
|
|
function collectPathVariants(value: string): string[] {
|
|
const variants = new Set<string>([value]);
|
|
for (const realpath of resolveRealpathVariants(value)) {
|
|
variants.add(realpath);
|
|
}
|
|
return Array.from(variants);
|
|
}
|
|
|
|
function resolveRealpathVariants(value: string): string[] {
|
|
const variants: string[] = [];
|
|
try {
|
|
variants.push(realpathSync.native(value));
|
|
} catch {
|
|
// Path may not exist, or the platform may reject this link flavor.
|
|
}
|
|
try {
|
|
variants.push(realpathSync(value));
|
|
} catch {
|
|
// Keep string-only comparison as the fallback.
|
|
}
|
|
return variants;
|
|
}
|
|
|
|
function shouldCompareAsWindows(left: string, right: string): boolean {
|
|
return looksLikeDefiniteWindowsPath(left) || looksLikeDefiniteWindowsPath(right);
|
|
}
|
|
|
|
/**
|
|
* True when `value`'s shape identifies it as a Windows path (drive letter,
|
|
* `\\?\` device namespace, or UNC), independent of the host platform. Callers
|
|
* that fold case for Windows-looking paths (this module's own comparisons,
|
|
* and `pruneKnownDirectories` in `workspace-git-service.ts`, which needs the
|
|
* same decision without paying for a realpath syscall) must all agree on this
|
|
* one rule — do not reimplement the pattern.
|
|
*/
|
|
export function looksLikeDefiniteWindowsPath(value: string): boolean {
|
|
return (
|
|
/^[a-zA-Z]:[\\/]/u.test(value) ||
|
|
/^[/\\]{2}\?[/\\]/u.test(value) ||
|
|
/^\\{2}[^/\\]+[/\\][^/\\]+/u.test(value)
|
|
);
|
|
}
|
|
|
|
function normalizePathForComparison(value: string, compareAsWindows: boolean): string {
|
|
const normalized = normalizePathPreservingCase(value, compareAsWindows);
|
|
return compareAsWindows ? normalized.toLowerCase() : normalized;
|
|
}
|
|
|
|
function normalizePathPreservingCase(value: string, compareAsWindows: boolean): string {
|
|
const platformPath = compareAsWindows ? nodePath.win32 : nodePath.posix;
|
|
const comparableValue = compareAsWindows ? stripWindowsNamespacePrefix(value) : value;
|
|
const platformNormalized = platformPath.normalize(comparableValue);
|
|
return stripTrailingSeparators(
|
|
platformNormalized,
|
|
platformPath.parse(platformNormalized).root,
|
|
compareAsWindows,
|
|
);
|
|
}
|
|
|
|
function stripWindowsNamespacePrefix(value: string): string {
|
|
const driveMatch = value.match(/^[/\\]{2}\?[/\\]([a-zA-Z]:)[/\\](.*)$/u);
|
|
const drivePrefix = driveMatch?.[1];
|
|
if (drivePrefix) {
|
|
return `${drivePrefix}\\${driveMatch[2] ?? ""}`;
|
|
}
|
|
|
|
const uncMatch = value.match(/^[/\\]{2}\?[/\\]UNC[/\\]([^/\\]+)[/\\]([^/\\]+)(?:[/\\](.*))?$/iu);
|
|
const uncServer = uncMatch?.[1];
|
|
const uncShare = uncMatch?.[2];
|
|
if (uncServer && uncShare) {
|
|
const uncRest = uncMatch[3];
|
|
return `\\\\${uncServer}\\${uncShare}${uncRest !== undefined ? `\\${uncRest}` : ""}`;
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
function stripTrailingSeparators(value: string, root: string, compareAsWindows: boolean): string {
|
|
const separatorPattern = compareAsWindows ? /[\\/]/u : /\//u;
|
|
let result = value;
|
|
while (result.length > root.length && separatorPattern.test(result.at(-1) ?? "")) {
|
|
result = result.slice(0, -1);
|
|
}
|
|
return result;
|
|
}
|