mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
fix(coding-agent): compare paths case-insensitively on Windows (#11588)
Fixes #10488 ## Problem On Windows, `c:\Users\me` and `C:\Users\me` are the same directory, but pi compared paths as exact strings (`===`, `startsWith`, map and set keys). `realpathSync` keeps the input casing, so canonicalizing does not help. The reported case, with cwd `c:\Users\me\project` and home `C:\Users\me`: 1. Ancestor discovery finds `c:\Users\me\.agents\skills`. The exclusion of the user dir `C:\Users\me\.agents\skills` does not match, so global skills load as project skills. 2. The same dir loads again as user skills. Dedup does not match either, so every skill is reported as a name collision. 3. `hasTrustRequiringProjectResources` treats `~/.agents/skills` as a project resource that requires trust. The same pattern affected other places: - Saved trust decisions in `trust.json` were not found when cwd casing differed, so pi asked for trust again. - With a custom session dir, `--continue`, resume by id, and the session list hid sessions recorded with different cwd casing. - Git worktree `AGENTS.md` shadowing failed when cwd casing differed from git's paths. - Local package matching (`pi remove c:\pkg` vs configured `C:\pkg`) and package identity across global and project settings. - Extension preload matching, skill scope classification, resource source info, the config and session selector keys, and the session import self-check. Separately, home-to-`~` shortening used an unanchored `startsWith(home)` in four places, so `/home/me2/x` was shown as `~2/x`. ## Fix New helpers in `utils/paths.ts`, all based on one comparison key: - `getPathComparisonKey(path)`: the resolved path, lowercased on Windows. It does not touch the filesystem. - `pathsEqual(a, b)` - `isPathWithin(parent, child)`: true for equal paths or paths inside `parent`, matching whole path segments. - `shortenHomePath(path, home?)`: replaces a leading home dir with `~`. The helpers pick `path.win32` or `path.posix` from `process.platform` at call time, so tests can exercise real Windows paths (drive letters, UNC). `getResourcePathKey(path)` in `source-info.ts` is the dedup key for resources. Synthetic identifiers (`builtin:<name>`, `<inline:...>`) stay unchanged, because they are not filesystem paths and their lookup is case-sensitive. File paths go through realpath plus the comparison key. The helpers are used only for comparison; stored and displayed paths are unchanged. Trust store lookups and `set` match keys case-insensitively on Windows. Unchanged on purpose: - The package install-root guards (`resolveManagedPath`, `pruneEmptyGitParents`, the `node_modules` dependency check) keep exact string checks. Both sides come from the same root string, and exact is the stricter option. - Checks based on `path.relative()` (`getCwdRelativePath`, the changelog containment check) were already correct: Node's `path.win32.relative` ignores case. NTFS can enable per-directory case sensitivity, but it is rarely used outside WSL interop, so it is ignored (see the comment on `getPathComparisonKey`). ## Tests - `test/paths.test.ts`: Windows drive-letter, UNC and root cases, POSIX case sensitivity, segment-boundary containment, and home shortening, including root and UNC homes. - `test/source-info.test.ts`: file paths fold case on Windows; builtin and inline identifiers keep their case. - `test/trust-manager.test.ts`: the `.agents/skills` exclusion with a lowercase `HOME`, plus saved trust decisions matched and replaced across casing (simulated win32). An end-to-end test of the reported loader scenario is not feasible on Linux: the loader resolves paths with POSIX `node:path`, and the filesystem is case-sensitive. `npm run check` passes. The coding-agent suite passes, except for 13 failures that also occur without this change: clipboard tool detection in this environment, and one test that needs built `dist/` output.
This commit is contained in:
parent
1a09925d39
commit
1be312b46a
18 changed files with 262 additions and 129 deletions
|
|
@ -35,6 +35,8 @@
|
|||
- Fixed `tui.select.pageUp` and `tui.select.pageDown` being ignored in slash command and file autocomplete, `/settings`, and the model, scoped models, login, fork, `/mcp`, and llama.cpp selectors. In fullscreen mode, PageUp/PageDown now move an open list or autocomplete menu instead of scrolling the transcript, and a page key bound to Ctrl+V pages autocomplete instead of pasting an image ([#7629](https://github.com/earendil-works/pi/issues/7629), based on [#7680](https://github.com/earendil-works/pi/pull/7680) by [@muyiyr](https://github.com/muyiyr))
|
||||
- Fixed tool output and `!` command output showing the parameters of control sequences with a `<`, `=` or `>` prefix as text, for example `35;59;1M` from a mouse report `ESC[<35;59;1M` or `0;276;0c` from a terminal reply `ESC[>0;276;0c`. Text after the keypad-mode sequences `ESC=` and `ESC>` is still kept ([#11377](https://github.com/earendil-works/pi/issues/11377))
|
||||
- Fixed `pi -e npm:<pkg>` staying on the version from the first download. On each start and `/reload`, pi now reinstalls an `-e` npm package without an exact version when the registry has a newer matching version. `pi -e git:<repo>@<branch>` likewise stayed on its first checkout and is now fetched and reset to the branch; a commit-hash ref that is already checked out is not fetched. Offline mode and failed checks keep the cached copy ([#11375](https://github.com/earendil-works/pi/issues/11375), [#11590](https://github.com/earendil-works/pi/pull/11590))
|
||||
- Fixed Windows paths being compared case-sensitively. When the working directory and home directory differed only in drive-letter casing (`c:\Users\me` vs `C:\Users\me`), skills in `~/.agents/skills` loaded twice, as project and user skills, and were reported as name collisions. The same mismatch also re-asked for saved project trust, hid sessions in a custom session directory from `--continue` and `/resume`, and stopped `pi remove` from matching configured local packages ([#10488](https://github.com/earendil-works/pi/issues/10488))
|
||||
- Fixed paths next to the home directory being shown with a broken `~` prefix, for example `/home/me2/project` shown as `~2/project` when home is `/home/me`
|
||||
|
||||
## [1.4.0] - 2026-10-21
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { constants, copyFileSync, existsSync, mkdirSync } from "node:fs";
|
||||
import { basename, join, parse, resolve } from "node:path";
|
||||
import { resolvePath } from "../utils/paths.ts";
|
||||
import { basename, join, parse } from "node:path";
|
||||
import { pathsEqual, resolvePath } from "../utils/paths.ts";
|
||||
import type { AgentSession } from "./agent-session.ts";
|
||||
import type { AgentSessionRuntimeDiagnostic, AgentSessionServices } from "./agent-session-services.ts";
|
||||
import type {
|
||||
|
|
@ -368,7 +368,7 @@ export class AgentSessionRuntime {
|
|||
}
|
||||
|
||||
let destinationPath = join(sessionDir, basename(resolvedPath));
|
||||
const sourceAlreadyStored = resolve(destinationPath) === resolvedPath;
|
||||
const sourceAlreadyStored = pathsEqual(destinationPath, resolvedPath);
|
||||
if (!sourceAlreadyStored) {
|
||||
const { name, ext } = parse(destinationPath);
|
||||
let suffix = 1;
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import type { Provider } from "@earendil-works/pi-ai";
|
|||
import type { KeyId } from "@earendil-works/pi-tui";
|
||||
import type { createJiti } from "jiti";
|
||||
import { CONFIG_DIR_NAME, getAgentDir, isBunBinary, isBundledNode } from "../../config.ts";
|
||||
import { resolvePath } from "../../utils/paths.ts";
|
||||
import { pathsEqual, resolvePath } from "../../utils/paths.ts";
|
||||
import { createEventBus, type EventBus } from "../event-bus.ts";
|
||||
import type { ExecOptions } from "../exec.ts";
|
||||
import { execCommand } from "../exec.ts";
|
||||
|
|
@ -146,7 +146,7 @@ export function clearExtensionCache(): void {
|
|||
|
||||
function useExtensionCacheCwd(cwd: string): ExtensionCacheToken {
|
||||
const resolvedCwd = resolvePath(cwd);
|
||||
if (extensionCacheCwd !== undefined && extensionCacheCwd !== resolvedCwd) {
|
||||
if (extensionCacheCwd !== undefined && !pathsEqual(extensionCacheCwd, resolvedCwd)) {
|
||||
clearExtensionCache();
|
||||
}
|
||||
extensionCacheCwd = resolvedCwd;
|
||||
|
|
|
|||
|
|
@ -43,16 +43,18 @@ import { spawnProcess, spawnProcessSync } from "../utils/child-process.ts";
|
|||
import { type GitSource, parseGitUrl } from "../utils/git.ts";
|
||||
import {
|
||||
canonicalizePath,
|
||||
getPathComparisonKey,
|
||||
isLocalPath,
|
||||
markPathIgnoredByCloudSync,
|
||||
normalizePath,
|
||||
pathsEqual,
|
||||
resolvePath,
|
||||
} from "../utils/paths.ts";
|
||||
import { stripBom } from "../utils/text.ts";
|
||||
import { isStdoutTakenOver } from "./output-guard.ts";
|
||||
import { type PiManifest, readPiManifest } from "./pi-manifest.ts";
|
||||
import type { PackageSource, SettingsManager } from "./settings-manager.ts";
|
||||
import { BUILTIN_PATH_PREFIX } from "./source-info.ts";
|
||||
import { BUILTIN_PATH_PREFIX, getResourcePathKey } from "./source-info.ts";
|
||||
|
||||
const NETWORK_TIMEOUT_MS = 10000;
|
||||
const UPDATE_CHECK_CONCURRENCY = 4;
|
||||
|
|
@ -1522,7 +1524,7 @@ export class DefaultPackageManager implements PackageManager {
|
|||
if (parsed.type === "git") {
|
||||
return `git:${parsed.host}/${parsed.path}`;
|
||||
}
|
||||
return `local:${this.resolvePath(parsed.path)}`;
|
||||
return `local:${getPathComparisonKey(this.resolvePath(parsed.path))}`;
|
||||
}
|
||||
|
||||
private getSourceMatchKeyForSettings(source: string, scope: SourceScope): string {
|
||||
|
|
@ -1534,7 +1536,7 @@ export class DefaultPackageManager implements PackageManager {
|
|||
return `git:${parsed.host}/${parsed.path}`;
|
||||
}
|
||||
const baseDir = this.getBaseDirForScope(scope);
|
||||
return `local:${this.resolvePathFromBase(parsed.path, baseDir)}`;
|
||||
return `local:${getPathComparisonKey(this.resolvePathFromBase(parsed.path, baseDir))}`;
|
||||
}
|
||||
|
||||
private buildNoMatchingPackageMessage(source: string, configuredPackages: PackageSource[]): string {
|
||||
|
|
@ -1839,9 +1841,9 @@ export class DefaultPackageManager implements PackageManager {
|
|||
}
|
||||
if (scope) {
|
||||
const baseDir = this.getBaseDirForScope(scope);
|
||||
return `local:${this.resolvePathFromBase(parsed.path, baseDir)}`;
|
||||
return `local:${getPathComparisonKey(this.resolvePathFromBase(parsed.path, baseDir))}`;
|
||||
}
|
||||
return `local:${this.resolvePath(parsed.path)}`;
|
||||
return `local:${getPathComparisonKey(this.resolvePath(parsed.path))}`;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -2591,7 +2593,7 @@ export class DefaultPackageManager implements PackageManager {
|
|||
const userAgentsSkillsDir = join(getHomeDir(), ".agents", "skills");
|
||||
const projectTrusted = this.settingsManager.isProjectTrusted();
|
||||
const projectAgentsSkillDirs = projectTrusted
|
||||
? collectAncestorAgentsSkillDirs(this.cwd).filter((dir) => resolve(dir) !== resolve(userAgentsSkillsDir))
|
||||
? collectAncestorAgentsSkillDirs(this.cwd).filter((dir) => !pathsEqual(dir, userAgentsSkillsDir))
|
||||
: [];
|
||||
|
||||
const addResources = (
|
||||
|
|
@ -2780,9 +2782,9 @@ export class DefaultPackageManager implements PackageManager {
|
|||
|
||||
const seen = new Set<string>();
|
||||
return resolved.filter((entry) => {
|
||||
const canonicalPath = canonicalizePath(entry.path);
|
||||
if (seen.has(canonicalPath)) return false;
|
||||
seen.add(canonicalPath);
|
||||
const pathKey = getResourcePathKey(entry.path);
|
||||
if (seen.has(pathKey)) return false;
|
||||
seen.add(pathKey);
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import type { ResourceDiagnostic } from "./diagnostics.ts";
|
|||
|
||||
export type { ResourceCollision, ResourceDiagnostic } from "./diagnostics.ts";
|
||||
|
||||
import { canonicalizePath, isLocalPath, resolvePath } from "../utils/paths.ts";
|
||||
import { canonicalizePath, isLocalPath, isPathWithin, pathsEqual, resolvePath } from "../utils/paths.ts";
|
||||
import { stripBom } from "../utils/text.ts";
|
||||
import { createEventBus, type EventBus } from "./event-bus.ts";
|
||||
import {
|
||||
|
|
@ -39,6 +39,7 @@ import { loadSkills } from "./skills.ts";
|
|||
import {
|
||||
BUILTIN_PATH_PREFIX,
|
||||
createSourceInfo,
|
||||
getResourcePathKey,
|
||||
getSyntheticPathSource,
|
||||
isSyntheticPath,
|
||||
type SourceInfo,
|
||||
|
|
@ -246,12 +247,12 @@ function findShadowedContextFile(cwd: string): string | undefined {
|
|||
const mainRepoRoot = dirname(commonGitDir);
|
||||
// False for an ordinary repo, where the two are the same dir, and for a sibling
|
||||
// worktree (`git worktree add ../feat`), whose main repo is not an ancestor.
|
||||
if (!worktreeRoot.startsWith(`${mainRepoRoot}${sep}`)) return undefined;
|
||||
if (!isPathWithin(mainRepoRoot, worktreeRoot) || pathsEqual(worktreeRoot, mainRepoRoot)) return undefined;
|
||||
// dirname of the common git dir is the main worktree root only when that dir is
|
||||
// itself checked out from the same repo. In a bare layout (`proj/.bare` +
|
||||
// `proj/main`) it is just the directory holding `.bare`, which tracks nothing; a
|
||||
// submodule's gitdir has no `commondir`, so it lands under `.git/modules`.
|
||||
if (canonicalizePath(join(mainRepoRoot, ".git")) !== commonGitDir) return undefined;
|
||||
if (!pathsEqual(canonicalizePath(join(mainRepoRoot, ".git")), commonGitDir)) return undefined;
|
||||
const worktreeContextFile = loadContextFileFromDir(worktreeRoot);
|
||||
return worktreeContextFile ? join(mainRepoRoot, basename(worktreeContextFile.path)) : undefined;
|
||||
}
|
||||
|
|
@ -284,7 +285,7 @@ export function loadProjectContextFiles(options: {
|
|||
const isShadowed =
|
||||
shadowedContextFile !== undefined &&
|
||||
contextFile !== null &&
|
||||
join(canonicalizePath(currentDir), basename(contextFile.path)) === shadowedContextFile;
|
||||
pathsEqual(join(canonicalizePath(currentDir), basename(contextFile.path)), shadowedContextFile);
|
||||
if (contextFile && !isShadowed && !seenPaths.has(contextFile.path)) {
|
||||
ancestorContextFiles.unshift(contextFile);
|
||||
seenPaths.add(contextFile.path);
|
||||
|
|
@ -750,6 +751,10 @@ export class DefaultResourceLoader implements ResourceLoader {
|
|||
return isSyntheticPath(path) ? path : resolvePath(path, this.cwd, { normalizeUnicodeSpaces: true });
|
||||
}
|
||||
|
||||
private getExtensionPathKey(path: string): string {
|
||||
return getResourcePathKey(this.resolveExtensionLoadPath(path));
|
||||
}
|
||||
|
||||
/** Load extension paths: files from disk and `builtin:<name>` paths from the built-in extensions. */
|
||||
private async loadExtensionPaths(paths: string[], runtime?: ExtensionRuntime): Promise<LoadExtensionsResult> {
|
||||
const isBuiltinPath = (path: string) => path.startsWith(BUILTIN_PATH_PREFIX);
|
||||
|
|
@ -792,26 +797,26 @@ export class DefaultResourceLoader implements ResourceLoader {
|
|||
const preloadedByPath = new Map(
|
||||
preloaded
|
||||
.filter((extension) => !extension.path.startsWith("<inline:"))
|
||||
.map((extension) => [extension.resolvedPath, extension]),
|
||||
.map((extension) => [getResourcePathKey(extension.resolvedPath), extension]),
|
||||
);
|
||||
const failedPreloadPaths = new Set(
|
||||
(preTrustExtensions?.errors ?? []).map((error) => this.resolveExtensionLoadPath(error.path)),
|
||||
(preTrustExtensions?.errors ?? []).map((error) => this.getExtensionPathKey(error.path)),
|
||||
);
|
||||
const remainingPaths = extensionPaths.filter((path) => {
|
||||
const resolvedPath = this.resolveExtensionLoadPath(path);
|
||||
return !preloadedByPath.has(resolvedPath) && !failedPreloadPaths.has(resolvedPath);
|
||||
const pathKey = this.getExtensionPathKey(path);
|
||||
return !preloadedByPath.has(pathKey) && !failedPreloadPaths.has(pathKey);
|
||||
});
|
||||
const remainingExtensions = await this.loadExtensionPaths(remainingPaths, preTrustExtensions?.runtime);
|
||||
const loadedByPath = new Map(preloadedByPath);
|
||||
for (const extension of remainingExtensions.extensions) {
|
||||
loadedByPath.set(extension.resolvedPath, extension);
|
||||
loadedByPath.set(getResourcePathKey(extension.resolvedPath), extension);
|
||||
}
|
||||
|
||||
const inlineExtensions = preTrustExtensions
|
||||
? { extensions: preloaded.filter((extension) => extension.path.startsWith("<inline:")), errors: [] }
|
||||
: await this.loadExtensionFactories(remainingExtensions.runtime);
|
||||
const orderedExtensions = extensionPaths
|
||||
.map((path) => loadedByPath.get(this.resolveExtensionLoadPath(path)))
|
||||
.map((path) => loadedByPath.get(this.getExtensionPathKey(path)))
|
||||
.filter((extension): extension is Extension => extension !== undefined);
|
||||
orderedExtensions.push(...inlineExtensions.extensions);
|
||||
|
||||
|
|
@ -980,11 +985,7 @@ export class DefaultResourceLoader implements ResourceLoader {
|
|||
const normalizedResourcePath = resolve(resourcePath);
|
||||
if (extraSourceInfos) {
|
||||
for (const [sourcePath, sourceInfo] of extraSourceInfos.entries()) {
|
||||
const normalizedSourcePath = resolve(sourcePath);
|
||||
if (
|
||||
normalizedResourcePath === normalizedSourcePath ||
|
||||
normalizedResourcePath.startsWith(`${normalizedSourcePath}${sep}`)
|
||||
) {
|
||||
if (isPathWithin(sourcePath, normalizedResourcePath)) {
|
||||
return { ...sourceInfo, path: resourcePath };
|
||||
}
|
||||
}
|
||||
|
|
@ -997,11 +998,7 @@ export class DefaultResourceLoader implements ResourceLoader {
|
|||
}
|
||||
|
||||
for (const [sourcePath, metadata] of metadataByPath.entries()) {
|
||||
const normalizedSourcePath = resolve(sourcePath);
|
||||
if (
|
||||
normalizedResourcePath === normalizedSourcePath ||
|
||||
normalizedResourcePath.startsWith(`${normalizedSourcePath}${sep}`)
|
||||
) {
|
||||
if (isPathWithin(sourcePath, normalizedResourcePath)) {
|
||||
return createSourceInfo(resourcePath, metadata);
|
||||
}
|
||||
}
|
||||
|
|
@ -1062,9 +1059,9 @@ export class DefaultResourceLoader implements ResourceLoader {
|
|||
|
||||
for (const p of [...primary, ...additional]) {
|
||||
const resolved = this.resolveResourcePath(p);
|
||||
const canonicalPath = canonicalizePath(resolved);
|
||||
if (seen.has(canonicalPath)) continue;
|
||||
seen.add(canonicalPath);
|
||||
const pathKey = getResourcePathKey(resolved);
|
||||
if (seen.has(pathKey)) continue;
|
||||
seen.add(pathKey);
|
||||
merged.push(resolved);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ import { basename, join, resolve } from "path";
|
|||
import { createInterface } from "readline";
|
||||
import { StringDecoder } from "string_decoder";
|
||||
import { APP_NAME, getAgentDir as getDefaultAgentDir, getSessionsDir } from "../config.ts";
|
||||
import { normalizePath, resolvePath } from "../utils/paths.ts";
|
||||
import { normalizePath, pathsEqual, resolvePath } from "../utils/paths.ts";
|
||||
import {
|
||||
type BashExecutionMessage,
|
||||
type CustomMessage,
|
||||
|
|
@ -742,7 +742,7 @@ function getSessionHeaderCwd(header: SessionHeader): string | undefined {
|
|||
}
|
||||
|
||||
function sessionCwdMatches(cwd: string | undefined, resolvedCwd: string): boolean {
|
||||
return cwd !== undefined && cwd !== "" && resolvePath(cwd) === resolvedCwd;
|
||||
return cwd !== undefined && cwd !== "" && pathsEqual(resolvePath(cwd), resolvedCwd);
|
||||
}
|
||||
|
||||
/** Exported for testing */
|
||||
|
|
@ -1146,7 +1146,7 @@ export class SessionManager {
|
|||
}
|
||||
|
||||
usesDefaultSessionDir(): boolean {
|
||||
return this.sessionDir === getDefaultSessionDirPath(this.cwd);
|
||||
return pathsEqual(this.sessionDir, getDefaultSessionDirPath(this.cwd));
|
||||
}
|
||||
|
||||
getSessionId(): string {
|
||||
|
|
@ -1802,7 +1802,7 @@ export class SessionManager {
|
|||
*/
|
||||
static continueRecent(cwd: string, sessionDir?: string): SessionManager {
|
||||
const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd);
|
||||
const filterCwd = sessionDir !== undefined && dir !== getDefaultSessionDirPath(cwd);
|
||||
const filterCwd = sessionDir !== undefined && !pathsEqual(dir, getDefaultSessionDirPath(cwd));
|
||||
const mostRecent = findMostRecentSession(dir, filterCwd ? cwd : undefined);
|
||||
if (mostRecent) {
|
||||
return new SessionManager(cwd, dir, mostRecent, true);
|
||||
|
|
@ -1888,7 +1888,7 @@ export class SessionManager {
|
|||
*/
|
||||
static findById(cwd: string, id: string, sessionDir?: string): string | undefined {
|
||||
const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd);
|
||||
const filterCwd = sessionDir !== undefined && dir !== getDefaultSessionDirPath(cwd);
|
||||
const filterCwd = sessionDir !== undefined && !pathsEqual(dir, getDefaultSessionDirPath(cwd));
|
||||
const resolvedCwd = resolvePath(cwd);
|
||||
|
||||
try {
|
||||
|
|
@ -1919,7 +1919,7 @@ export class SessionManager {
|
|||
signal?: AbortSignal,
|
||||
): Promise<SessionInfo[]> {
|
||||
const dir = sessionDir ? normalizePath(sessionDir) : getDefaultSessionDir(cwd);
|
||||
const filterCwd = sessionDir !== undefined && dir !== getDefaultSessionDirPath(cwd);
|
||||
const filterCwd = sessionDir !== undefined && !pathsEqual(dir, getDefaultSessionDirPath(cwd));
|
||||
const resolvedCwd = resolvePath(cwd);
|
||||
const includeSession = (session: SessionInfo) => !filterCwd || sessionCwdMatches(session.cwd, resolvedCwd);
|
||||
const progress: SessionListProgress | undefined = onProgress
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@ import ignore from "ignore";
|
|||
import { basename, dirname, join, relative, resolve, sep } from "path";
|
||||
import { CONFIG_DIR_NAME, getAgentDir } from "../config.ts";
|
||||
import { parseFrontmatter } from "../utils/frontmatter.ts";
|
||||
import { canonicalizePath, resolvePath } from "../utils/paths.ts";
|
||||
import { isPathWithin, resolvePath } from "../utils/paths.ts";
|
||||
import type { ResourceDiagnostic } from "./diagnostics.ts";
|
||||
import { createSyntheticSourceInfo, type SourceInfo } from "./source-info.ts";
|
||||
import { createSyntheticSourceInfo, getResourcePathKey, type SourceInfo } from "./source-info.ts";
|
||||
|
||||
/** Max name length per spec */
|
||||
const MAX_NAME_LENGTH = 64;
|
||||
|
|
@ -427,7 +427,7 @@ export function loadSkills(options: LoadSkillsOptions): LoadSkillsResult {
|
|||
allDiagnostics.push(...result.diagnostics);
|
||||
for (const skill of result.skills) {
|
||||
// Resolve symlinks to detect duplicate files
|
||||
const realPath = canonicalizePath(skill.filePath);
|
||||
const realPath = getResourcePathKey(skill.filePath);
|
||||
|
||||
// Skip silently if we've already loaded this exact file (via symlink)
|
||||
if (realPathSet.has(realPath)) {
|
||||
|
|
@ -462,19 +462,10 @@ export function loadSkills(options: LoadSkillsOptions): LoadSkillsResult {
|
|||
const userSkillsDir = join(resolvedAgentDir, "skills");
|
||||
const projectSkillsDir = resolve(resolvedCwd, CONFIG_DIR_NAME, "skills");
|
||||
|
||||
const isUnderPath = (target: string, root: string): boolean => {
|
||||
const normalizedRoot = resolve(root);
|
||||
if (target === normalizedRoot) {
|
||||
return true;
|
||||
}
|
||||
const prefix = normalizedRoot.endsWith(sep) ? normalizedRoot : `${normalizedRoot}${sep}`;
|
||||
return target.startsWith(prefix);
|
||||
};
|
||||
|
||||
const getSource = (resolvedPath: string): "user" | "project" | "path" => {
|
||||
if (!includeDefaults) {
|
||||
if (isUnderPath(resolvedPath, userSkillsDir)) return "user";
|
||||
if (isUnderPath(resolvedPath, projectSkillsDir)) return "project";
|
||||
if (isPathWithin(userSkillsDir, resolvedPath)) return "user";
|
||||
if (isPathWithin(projectSkillsDir, resolvedPath)) return "project";
|
||||
}
|
||||
return "path";
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { canonicalizePath, getPathComparisonKey } from "../utils/paths.ts";
|
||||
import type { PathMetadata } from "./package-manager.ts";
|
||||
|
||||
export type SourceScope = "user" | "project" | "temporary";
|
||||
|
|
@ -41,6 +42,11 @@ export function isSyntheticPath(path: string): boolean {
|
|||
return path.startsWith(BUILTIN_PATH_PREFIX) || path.startsWith("<");
|
||||
}
|
||||
|
||||
/** Key for deduplicating resource paths: synthetic paths as-is, file paths via realpath and platform casing. */
|
||||
export function getResourcePathKey(path: string): string {
|
||||
return isSyntheticPath(path) ? path : getPathComparisonKey(canonicalizePath(path));
|
||||
}
|
||||
|
||||
export function createSourceInfo(path: string, metadata: PathMetadata): SourceInfo {
|
||||
return {
|
||||
path,
|
||||
|
|
|
|||
|
|
@ -1,20 +1,15 @@
|
|||
import * as os from "node:os";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import type { ImageContent, TextContent } from "@earendil-works/pi-ai";
|
||||
import { getCapabilities, getImageDimensions, hyperlink, imageFallback } from "@earendil-works/pi-tui";
|
||||
import type { Theme } from "../../modes/interactive/theme/theme.ts";
|
||||
import { stripAnsi } from "../../utils/ansi.ts";
|
||||
import { resolvePath } from "../../utils/paths.ts";
|
||||
import { resolvePath, shortenHomePath } from "../../utils/paths.ts";
|
||||
import { sanitizeBinaryOutput } from "../../utils/shell.ts";
|
||||
import type { ToolRenderContext } from "../extensions/types.ts";
|
||||
|
||||
export function shortenPath(path: unknown): string {
|
||||
if (typeof path !== "string") return "";
|
||||
const home = os.homedir();
|
||||
if (path.startsWith(home)) {
|
||||
return `~${path.slice(home.length)}`;
|
||||
}
|
||||
return path;
|
||||
return shortenHomePath(path);
|
||||
}
|
||||
|
||||
export function linkPath(styledText: string, rawPath: string, cwd: string): string {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { homedir } from "node:os";
|
|||
import { dirname, join } from "node:path";
|
||||
import lockfile from "proper-lockfile";
|
||||
import { CONFIG_DIR_NAME } from "../config.ts";
|
||||
import { canonicalizePath, resolvePath } from "../utils/paths.ts";
|
||||
import { canonicalizePath, pathsEqual, resolvePath } from "../utils/paths.ts";
|
||||
import { stripBom } from "../utils/text.ts";
|
||||
|
||||
export type ProjectTrustDecision = boolean | null;
|
||||
|
|
@ -42,12 +42,19 @@ function normalizeCwd(cwd: string): string {
|
|||
return canonicalizePath(resolvePath(cwd));
|
||||
}
|
||||
|
||||
/** Trust file keys naming `path`. Usually one; several on Windows when saved with different casing. */
|
||||
function findTrustFileKeys(data: TrustFile, path: string): string[] {
|
||||
return Object.keys(data).filter((key) => pathsEqual(key, path));
|
||||
}
|
||||
|
||||
function findNearestTrustEntry(data: TrustFile, cwd: string): ProjectTrustStoreEntry | null {
|
||||
let currentDir = normalizeCwd(cwd);
|
||||
while (true) {
|
||||
const value = data[currentDir];
|
||||
if (value === true || value === false) {
|
||||
return { path: currentDir, decision: value };
|
||||
for (const key of findTrustFileKeys(data, currentDir)) {
|
||||
const value = data[key];
|
||||
if (value === true || value === false) {
|
||||
return { path: key, decision: value };
|
||||
}
|
||||
}
|
||||
|
||||
const parentDir = dirname(currentDir);
|
||||
|
|
@ -195,7 +202,7 @@ export function hasTrustRequiringProjectResources(cwd: string): boolean {
|
|||
|
||||
while (true) {
|
||||
const agentsSkillsDir = join(currentDir, ".agents", "skills");
|
||||
if (agentsSkillsDir !== userAgentsSkillsDir && existsSync(agentsSkillsDir)) {
|
||||
if (!pathsEqual(agentsSkillsDir, userAgentsSkillsDir) && existsSync(agentsSkillsDir)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
@ -234,9 +241,10 @@ export class ProjectTrustStore {
|
|||
const data = readTrustFile(this.trustPath);
|
||||
for (const { path, decision } of decisions) {
|
||||
const key = normalizeCwd(path);
|
||||
if (decision === null) {
|
||||
delete data[key];
|
||||
} else {
|
||||
for (const existingKey of findTrustFileKeys(data, key)) {
|
||||
delete data[existingKey];
|
||||
}
|
||||
if (decision !== null) {
|
||||
data[key] = decision;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@
|
|||
* TUI component for managing package resources (enable/disable)
|
||||
*/
|
||||
|
||||
import { homedir } from "node:os";
|
||||
import { basename, dirname, join, relative } from "node:path";
|
||||
import {
|
||||
type Component,
|
||||
|
|
@ -18,8 +17,8 @@ import {
|
|||
import { CONFIG_DIR_NAME } from "../../../config.ts";
|
||||
import type { PathMetadata, ResolvedPaths, ResolvedResource } from "../../../core/package-manager.ts";
|
||||
import type { PackageSource, SettingsManager } from "../../../core/settings-manager.ts";
|
||||
import { BUILTIN_PATH_PREFIX } from "../../../core/source-info.ts";
|
||||
import { canonicalizePath, isLocalPath, resolvePath } from "../../../utils/paths.ts";
|
||||
import { BUILTIN_PATH_PREFIX, getResourcePathKey } from "../../../core/source-info.ts";
|
||||
import { isLocalPath, resolvePath, shortenHomePath } from "../../../utils/paths.ts";
|
||||
import { theme } from "../theme/theme.ts";
|
||||
import { DynamicBorder } from "./dynamic-border.ts";
|
||||
import { keyHint, rawKeyHint } from "./keybinding-hints.ts";
|
||||
|
|
@ -65,19 +64,7 @@ interface ResourceGroup {
|
|||
}
|
||||
|
||||
function formatBaseDir(baseDir: string): string {
|
||||
const homeDir = homedir();
|
||||
let displayPath: string;
|
||||
|
||||
if (baseDir === homeDir) {
|
||||
displayPath = "~";
|
||||
} else if (baseDir.startsWith(homeDir)) {
|
||||
// Replace home prefix with ~, normalize separators for display
|
||||
const rest = baseDir.slice(homeDir.length);
|
||||
displayPath = `~${rest.replace(/\\/g, "/")}`;
|
||||
} else {
|
||||
displayPath = baseDir.replace(/\\/g, "/");
|
||||
}
|
||||
|
||||
const displayPath = shortenHomePath(baseDir).replace(/\\/g, "/");
|
||||
return displayPath.endsWith("/") ? displayPath : `${displayPath}/`;
|
||||
}
|
||||
|
||||
|
|
@ -848,7 +835,7 @@ class ResourceList implements Component, Focusable {
|
|||
}
|
||||
|
||||
private getResourceItemKey(item: ResourceItem): string {
|
||||
return `${item.resourceType}:${canonicalizePath(item.path)}`;
|
||||
return `${item.resourceType}:${getResourcePathKey(item.path)}`;
|
||||
}
|
||||
|
||||
private getItemScope(item: ResourceItem): SettingsScope {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { unlink } from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import {
|
||||
type Component,
|
||||
Container,
|
||||
|
|
@ -15,7 +14,7 @@ import {
|
|||
} from "@earendil-works/pi-tui";
|
||||
import { KeybindingsManager } from "../../../core/keybindings.ts";
|
||||
import type { SessionInfo, SessionListProgress } from "../../../core/session-manager.ts";
|
||||
import { canonicalizePath as _canonicalizePath } from "../../../utils/paths.ts";
|
||||
import { canonicalizePath, getPathComparisonKey, shortenHomePath } from "../../../utils/paths.ts";
|
||||
import { theme } from "../theme/theme.ts";
|
||||
import { DynamicBorder } from "./dynamic-border.ts";
|
||||
import { keyHint, keyText } from "./keybinding-hints.ts";
|
||||
|
|
@ -23,15 +22,6 @@ import { filterAndSortSessions, hasSessionName, type NameFilter, type SortMode }
|
|||
|
||||
type SessionScope = "current" | "all";
|
||||
|
||||
function shortenPath(path: string): string {
|
||||
const home = os.homedir();
|
||||
if (!path) return path;
|
||||
if (path.startsWith(home)) {
|
||||
return `~${path.slice(home.length)}`;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
function formatSessionDate(date: Date): string {
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
|
|
@ -48,9 +38,9 @@ function formatSessionDate(date: Date): string {
|
|||
return `${Math.floor(diffDays / 365)}y`;
|
||||
}
|
||||
|
||||
function canonicalizePath(path: string | undefined): string | undefined {
|
||||
if (!path) return path;
|
||||
return _canonicalizePath(path);
|
||||
/** Comparison key for a session file path; follows symlinks and ignores case on Windows. */
|
||||
function getSessionPathKey(path: string): string {
|
||||
return getPathComparisonKey(canonicalizePath(path));
|
||||
}
|
||||
|
||||
class SessionSelectorHeader implements Component {
|
||||
|
|
@ -210,16 +200,16 @@ function buildSessionTree(sessions: SessionInfo[]): SessionTreeNode[] {
|
|||
const byPath = new Map<string, SessionTreeNode>();
|
||||
|
||||
for (const session of sessions) {
|
||||
const sessionPath = canonicalizePath(session.path) ?? session.path;
|
||||
const sessionPath = getSessionPathKey(session.path);
|
||||
byPath.set(sessionPath, { session, children: [], latestActivity: session.modified.getTime() });
|
||||
}
|
||||
|
||||
const roots: SessionTreeNode[] = [];
|
||||
|
||||
for (const session of sessions) {
|
||||
const sessionPath = canonicalizePath(session.path) ?? session.path;
|
||||
const sessionPath = getSessionPathKey(session.path);
|
||||
const node = byPath.get(sessionPath)!;
|
||||
const parentPath = canonicalizePath(session.parentSessionPath);
|
||||
const parentPath = session.parentSessionPath ? getSessionPathKey(session.parentSessionPath) : undefined;
|
||||
|
||||
if (parentPath && byPath.has(parentPath)) {
|
||||
byPath.get(parentPath)!.children.push(node);
|
||||
|
|
@ -296,7 +286,7 @@ class SessionList implements Component, Focusable {
|
|||
private keybindings: KeybindingsManager;
|
||||
private showPath = false;
|
||||
private confirmingDeletePath: string | null = null;
|
||||
private currentSessionCanonicalPath?: string;
|
||||
private currentSessionPathKey?: string;
|
||||
public onSelect?: (sessionPath: string) => void;
|
||||
public onCancel?: () => void;
|
||||
public onExit: () => void = () => {};
|
||||
|
|
@ -335,7 +325,7 @@ class SessionList implements Component, Focusable {
|
|||
this.sortMode = sortMode;
|
||||
this.nameFilter = nameFilter;
|
||||
this.keybindings = keybindings;
|
||||
this.currentSessionCanonicalPath = canonicalizePath(currentSessionFilePath);
|
||||
this.currentSessionPathKey = currentSessionFilePath ? getSessionPathKey(currentSessionFilePath) : undefined;
|
||||
this.filterSessions("");
|
||||
|
||||
// Handle Enter in search input - select current item
|
||||
|
|
@ -413,8 +403,8 @@ class SessionList implements Component, Focusable {
|
|||
}
|
||||
|
||||
private isCurrentSessionPath(path: string): boolean {
|
||||
if (!this.currentSessionCanonicalPath) return false;
|
||||
return (canonicalizePath(path) ?? path) === this.currentSessionCanonicalPath;
|
||||
if (!this.currentSessionPathKey) return false;
|
||||
return getSessionPathKey(path) === this.currentSessionPathKey;
|
||||
}
|
||||
|
||||
invalidate(): void {}
|
||||
|
|
@ -474,10 +464,10 @@ class SessionList implements Component, Focusable {
|
|||
const msgCount = String(session.messageCount);
|
||||
let rightPart = `${msgCount} ${age}`;
|
||||
if (this.showCwd && session.cwd) {
|
||||
rightPart = `${shortenPath(session.cwd)} ${rightPart}`;
|
||||
rightPart = `${shortenHomePath(session.cwd)} ${rightPart}`;
|
||||
}
|
||||
if (this.showPath) {
|
||||
rightPart = `${shortenPath(session.path)} ${rightPart}`;
|
||||
rightPart = `${shortenHomePath(session.path)} ${rightPart}`;
|
||||
}
|
||||
|
||||
// Cursor
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import {
|
|||
} from "@earendil-works/pi-tui";
|
||||
import type { SessionTreeNode } from "../../../core/session-manager.ts";
|
||||
import { formatReadLineRange } from "../../../core/tools/render-utils.ts";
|
||||
import { shortenHomePath } from "../../../utils/paths.ts";
|
||||
import { theme } from "../theme/theme.ts";
|
||||
import { DynamicBorder } from "./dynamic-border.ts";
|
||||
import { formatKeyText, keyHint } from "./keybinding-hints.ts";
|
||||
|
|
@ -945,11 +946,7 @@ class TreeList implements Component {
|
|||
}
|
||||
|
||||
private formatToolCall(name: string, args: Record<string, unknown>): string {
|
||||
const shortenPath = (p: string): string => {
|
||||
const home = process.env.HOME || process.env.USERPROFILE || "";
|
||||
if (home && p.startsWith(home)) return `~${p.slice(home.length)}`;
|
||||
return p;
|
||||
};
|
||||
const shortenPath = (p: string): string => shortenHomePath(p, process.env.HOME || process.env.USERPROFILE || "");
|
||||
|
||||
switch (name) {
|
||||
case "read": {
|
||||
|
|
|
|||
|
|
@ -127,7 +127,7 @@ import { copyToClipboard, readClipboardFilePaths, readClipboardText } from "../.
|
|||
import { extensionForImageMimeType, readClipboardImage } from "../../utils/clipboard-image.ts";
|
||||
import { parseGitUrl } from "../../utils/git.ts";
|
||||
import { ensurePngTranscoder } from "../../utils/image-convert.ts";
|
||||
import { getCwdRelativePath } from "../../utils/paths.ts";
|
||||
import { getCwdRelativePath, shortenHomePath } from "../../utils/paths.ts";
|
||||
import { getPiUserAgent } from "../../utils/pi-user-agent.ts";
|
||||
import { killTrackedDetachedChildren } from "../../utils/shell.ts";
|
||||
import { loadAllHighlightLanguages } from "../../utils/syntax-highlight.ts";
|
||||
|
|
@ -1474,15 +1474,7 @@ export class InteractiveMode {
|
|||
// =========================================================================
|
||||
|
||||
private formatDisplayPath(p: string): string {
|
||||
const home = os.homedir();
|
||||
let result = p;
|
||||
|
||||
// Replace home directory with ~
|
||||
if (result.startsWith(home)) {
|
||||
result = `~${result.slice(home.length)}`;
|
||||
}
|
||||
|
||||
return result;
|
||||
return shortenHomePath(p);
|
||||
}
|
||||
|
||||
private formatExtensionDisplayPath(path: string): string {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { realpathSync, statSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { isAbsolute, join, resolve as nodeResolvePath, relative, sep } from "node:path";
|
||||
import { isAbsolute, join, resolve as nodeResolvePath, posix, relative, sep, win32 } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawnProcessSync } from "./child-process.ts";
|
||||
|
||||
|
|
@ -33,6 +33,50 @@ export function canonicalizePath(path: string): string {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Key for comparing filesystem paths: the resolved absolute path, case-folded
|
||||
* on Windows because Windows paths are case-insensitive (`c:\Users` and
|
||||
* `C:\Users` are the same directory). NTFS can enable per-directory case
|
||||
* sensitivity, but it is rarely used outside WSL interop, so it is ignored
|
||||
* here (https://learn.microsoft.com/en-us/windows/wsl/case-sensitivity).
|
||||
* Does not touch the filesystem; call `canonicalizePath` first when symlinks
|
||||
* must compare equal. Use only for comparison, never for display or
|
||||
* filesystem access.
|
||||
*/
|
||||
export function getPathComparisonKey(path: string): string {
|
||||
const pathApi = getPlatformPathApi();
|
||||
const resolvedPath = pathApi.resolve(path);
|
||||
return pathApi === win32 ? resolvedPath.toLowerCase() : resolvedPath;
|
||||
}
|
||||
|
||||
/** `node:path` rules for the current platform, read at call time so tests can simulate Windows. */
|
||||
function getPlatformPathApi(): typeof win32 {
|
||||
return process.platform === "win32" ? win32 : posix;
|
||||
}
|
||||
|
||||
/** True when both paths name the same location (see `getPathComparisonKey`). */
|
||||
export function pathsEqual(left: string, right: string): boolean {
|
||||
return getPathComparisonKey(left) === getPathComparisonKey(right);
|
||||
}
|
||||
|
||||
/** True when `child` is `parent` or inside it (see `getPathComparisonKey`). */
|
||||
export function isPathWithin(parent: string, child: string): boolean {
|
||||
const parentKey = getPathComparisonKey(parent);
|
||||
const childKey = getPathComparisonKey(child);
|
||||
if (childKey === parentKey) return true;
|
||||
// Filesystem roots (`/`, `C:\`) already end with a separator.
|
||||
const pathSep = getPlatformPathApi().sep;
|
||||
return childKey.startsWith(parentKey.endsWith(pathSep) ? parentKey : `${parentKey}${pathSep}`);
|
||||
}
|
||||
|
||||
/** Replace a leading home directory with `~` for display. Relative paths are returned unchanged. */
|
||||
export function shortenHomePath(path: string, homeDir: string = homedir()): string {
|
||||
const pathApi = getPlatformPathApi();
|
||||
if (!homeDir || !pathApi.isAbsolute(path) || !isPathWithin(homeDir, path)) return path;
|
||||
const relativePath = pathApi.relative(homeDir, path);
|
||||
return relativePath ? `~${pathApi.sep}${relativePath}` : "~";
|
||||
}
|
||||
|
||||
export function getFileRevision(path: string): string | undefined {
|
||||
try {
|
||||
const stats = statSync(path, { bigint: true });
|
||||
|
|
|
|||
|
|
@ -6,10 +6,14 @@ import { afterEach, describe, expect, it } from "vitest";
|
|||
import {
|
||||
canonicalizePath,
|
||||
getCwdRelativePath,
|
||||
getPathComparisonKey,
|
||||
isLocalPath,
|
||||
isPathWithin,
|
||||
normalizePath,
|
||||
normalizeWindowsShellPath,
|
||||
pathsEqual,
|
||||
resolvePath,
|
||||
shortenHomePath,
|
||||
} from "../src/utils/paths.ts";
|
||||
|
||||
let tempDir: string;
|
||||
|
|
@ -26,6 +30,64 @@ function createTempDir(): string {
|
|||
return tempDir;
|
||||
}
|
||||
|
||||
describe("path comparison", () => {
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
|
||||
afterEach(() => {
|
||||
if (originalPlatform) Object.defineProperty(process, "platform", originalPlatform);
|
||||
});
|
||||
|
||||
function simulatePlatform(platform: NodeJS.Platform): void {
|
||||
Object.defineProperty(process, "platform", { value: platform });
|
||||
}
|
||||
|
||||
// Regression test for https://github.com/earendil-works/pi/issues/10488
|
||||
it("treats drive letters and path components case-insensitively on Windows", () => {
|
||||
simulatePlatform("win32");
|
||||
expect(getPathComparisonKey("c:\\Users\\me\\.agents\\skills")).toBe(
|
||||
getPathComparisonKey("C:\\Users\\Me\\.agents\\skills"),
|
||||
);
|
||||
expect(pathsEqual("c:\\Users\\me", "C:/Users/Me/")).toBe(true);
|
||||
expect(pathsEqual("C:\\Users\\me", "D:\\Users\\me")).toBe(false);
|
||||
expect(isPathWithin("C:\\Users\\Me", "c:\\users\\me\\project")).toBe(true);
|
||||
expect(isPathWithin("C:\\Users\\Me", "C:\\Users\\Me2")).toBe(false);
|
||||
expect(isPathWithin("C:\\", "c:\\Users")).toBe(true);
|
||||
expect(isPathWithin("\\\\server\\share", "\\\\SERVER\\Share\\folder")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps casing on case-sensitive platforms", () => {
|
||||
simulatePlatform("linux");
|
||||
expect(pathsEqual("/home/me", "/home/Me")).toBe(false);
|
||||
expect(isPathWithin("/home/me", "/home/Me/project")).toBe(false);
|
||||
});
|
||||
|
||||
it("isPathWithin matches whole path segments", () => {
|
||||
simulatePlatform("linux");
|
||||
expect(isPathWithin("/home/me", "/home/me")).toBe(true);
|
||||
expect(isPathWithin("/home/me", "/home/me/project")).toBe(true);
|
||||
expect(isPathWithin("/home/me/", "/home/me/project")).toBe(true);
|
||||
expect(isPathWithin("/home/me", "/home/me2")).toBe(false);
|
||||
expect(isPathWithin("/", "/home")).toBe(true);
|
||||
});
|
||||
|
||||
it("shortenHomePath replaces only a leading home directory", () => {
|
||||
simulatePlatform("linux");
|
||||
expect(shortenHomePath("/home/me", "/home/me")).toBe("~");
|
||||
expect(shortenHomePath("/home/me/project", "/home/me")).toBe("~/project");
|
||||
expect(shortenHomePath("/home/me2/project", "/home/me")).toBe("/home/me2/project");
|
||||
expect(shortenHomePath("/home/Me/project", "/home/me")).toBe("/home/Me/project");
|
||||
expect(shortenHomePath("/etc/passwd", "/")).toBe("~/etc/passwd");
|
||||
expect(shortenHomePath("home/me/project", "/home/me")).toBe("home/me/project");
|
||||
expect(shortenHomePath("/home/me/project", "")).toBe("/home/me/project");
|
||||
});
|
||||
|
||||
it("shortenHomePath ignores casing and keeps separators on Windows", () => {
|
||||
simulatePlatform("win32");
|
||||
expect(shortenHomePath("c:\\users\\me\\project", "C:\\Users\\Me")).toBe("~\\project");
|
||||
expect(shortenHomePath("\\\\server\\share\\folder", "\\\\server\\share")).toBe("~\\folder");
|
||||
});
|
||||
});
|
||||
|
||||
describe("canonicalizePath", () => {
|
||||
it("returns the real path for a regular file", () => {
|
||||
const dir = createTempDir();
|
||||
|
|
|
|||
19
packages/coding-agent/test/source-info.test.ts
Normal file
19
packages/coding-agent/test/source-info.test.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { getResourcePathKey } from "../src/core/source-info.ts";
|
||||
|
||||
describe("getResourcePathKey", () => {
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
|
||||
afterEach(() => {
|
||||
if (originalPlatform) Object.defineProperty(process, "platform", originalPlatform);
|
||||
});
|
||||
|
||||
// Regression test for https://github.com/earendil-works/pi/issues/10488
|
||||
it("folds file path casing on Windows but keeps synthetic identifiers unchanged", () => {
|
||||
Object.defineProperty(process, "platform", { value: "win32" });
|
||||
expect(getResourcePathKey("C:\\Users\\Me\\ext.ts")).toBe(getResourcePathKey("c:\\users\\me\\ext.ts"));
|
||||
expect(getResourcePathKey("builtin:MCP")).toBe("builtin:MCP");
|
||||
expect(getResourcePathKey("builtin:MCP")).not.toBe(getResourcePathKey("builtin:mcp"));
|
||||
expect(getResourcePathKey("<inline:Name>")).toBe("<inline:Name>");
|
||||
});
|
||||
});
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
|
@ -64,4 +64,45 @@ describe("ProjectTrustStore", () => {
|
|||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Regression test for https://github.com/earendil-works/pi/issues/10488
|
||||
it("ignores the user .agents/skills dir when HOME differs only by case on Windows", () => {
|
||||
const originalHome = process.env.HOME;
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
const mixedCaseHome = join(tempDir, "Home");
|
||||
const projectDir = join(mixedCaseHome, "project");
|
||||
mkdirSync(join(mixedCaseHome, ".agents", "skills"), { recursive: true });
|
||||
mkdirSync(projectDir, { recursive: true });
|
||||
process.env.HOME = mixedCaseHome.toLowerCase();
|
||||
Object.defineProperty(process, "platform", { value: "win32" });
|
||||
try {
|
||||
expect(hasTrustRequiringProjectResources(projectDir)).toBe(false);
|
||||
} finally {
|
||||
if (originalPlatform) Object.defineProperty(process, "platform", originalPlatform);
|
||||
if (originalHome === undefined) {
|
||||
delete process.env.HOME;
|
||||
} else {
|
||||
process.env.HOME = originalHome;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Regression test for https://github.com/earendil-works/pi/issues/10488
|
||||
it("matches saved decisions regardless of casing on Windows", () => {
|
||||
const originalPlatform = Object.getOwnPropertyDescriptor(process, "platform");
|
||||
const projectDir = join(tempDir, "Project-Mixed");
|
||||
mkdirSync(projectDir, { recursive: true });
|
||||
const store = new ProjectTrustStore(agentDir);
|
||||
store.set(projectDir, true);
|
||||
Object.defineProperty(process, "platform", { value: "win32" });
|
||||
try {
|
||||
expect(store.get(projectDir.toLowerCase())).toBe(true);
|
||||
store.set(projectDir.toLowerCase(), false);
|
||||
const saved = JSON.parse(readFileSync(join(agentDir, "trust.json"), "utf-8")) as Record<string, boolean>;
|
||||
expect(Object.values(saved)).toEqual([false]);
|
||||
expect(store.get(projectDir)).toBe(false);
|
||||
} finally {
|
||||
if (originalPlatform) Object.defineProperty(process, "platform", originalPlatform);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue