mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 00:03:45 +00:00
fix(agent-history): match non-ASCII workspace paths to Claude sessions (#10841)
This commit is contained in:
@@ -0,0 +1,130 @@
|
||||
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { scanAiVaultSessions } from './session-scanner'
|
||||
import { resetProjectDirCwdCacheForTests } from './session-scanner-scope-discovery'
|
||||
import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures'
|
||||
|
||||
let tempRoots: string[] = []
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
|
||||
tempRoots = []
|
||||
resetProjectDirCwdCacheForTests()
|
||||
})
|
||||
|
||||
// Claude's own rule, deliberately spelled out rather than imported from the
|
||||
// production encoder: these tests exist to catch Orca drifting away from how
|
||||
// Claude actually names its directories, which a shared helper would hide.
|
||||
// Claude maps every non-alphanumeric character of the raw cwd to '-', keeping
|
||||
// case, and records the cwd it was launched with (NFC on macOS).
|
||||
function claudeProjectDirName(cwd: string): string {
|
||||
return cwd.normalize('NFC').replace(/[^a-zA-Z0-9]/g, '-')
|
||||
}
|
||||
|
||||
describe('scanAiVaultSessions — non-ASCII scope paths', () => {
|
||||
it('lists Claude sessions when the workspace path is NFD and the transcript is NFC', async () => {
|
||||
// Regression for #10832: macOS hands Orca decomposed (NFD) workspace paths
|
||||
// while Claude Code writes NFC in both the project dir name and the recorded
|
||||
// cwd, so every workspace with non-ASCII path segments listed zero sessions.
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-nfc-scope-'))
|
||||
tempRoots.push(root)
|
||||
const roots = isolatedScanRoots(root)
|
||||
|
||||
const workspaceNfc = '/Users/ada/내 드라이브/한국농어촌공사'
|
||||
const workspaceNfd = workspaceNfc.normalize('NFD')
|
||||
expect(workspaceNfd).not.toBe(workspaceNfc)
|
||||
|
||||
const projectDir = join(roots.claudeProjectsDir, claudeProjectDirName(workspaceNfc))
|
||||
await mkdir(projectDir, { recursive: true })
|
||||
await writeFile(
|
||||
join(projectDir, 'korean-session.jsonl'),
|
||||
jsonLines([
|
||||
{
|
||||
type: 'user',
|
||||
sessionId: 'korean-session',
|
||||
timestamp: '2026-05-01T10:00:00.000Z',
|
||||
cwd: workspaceNfc,
|
||||
gitBranch: 'main',
|
||||
message: { role: 'user', content: '안녕하세요' }
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
// A newer ASCII session plus limit:1 exhausts the recency cap, so the Korean
|
||||
// session can only surface through scope discovery — the path under test.
|
||||
await mkdir(join(roots.claudeProjectsDir, '-Users-ada-other'), { recursive: true })
|
||||
await writeFile(
|
||||
join(roots.claudeProjectsDir, '-Users-ada-other', 'recent-session.jsonl'),
|
||||
jsonLines([
|
||||
{
|
||||
type: 'user',
|
||||
sessionId: 'recent-session',
|
||||
timestamp: '2026-06-01T10:00:00.000Z',
|
||||
cwd: '/Users/ada/other',
|
||||
gitBranch: 'main',
|
||||
message: { role: 'user', content: 'newer' }
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
const result = await scanAiVaultSessions({
|
||||
...roots,
|
||||
platform: 'darwin',
|
||||
limit: 1,
|
||||
scopePaths: [workspaceNfd]
|
||||
})
|
||||
|
||||
expect(result.sessions.map((session) => session.sessionId)).toContain('korean-session')
|
||||
})
|
||||
|
||||
it('lists Claude sessions for a Windows workspace whose path has uppercase segments', async () => {
|
||||
// Claude derives the dir name from the raw cwd, so it keeps 'C--Users-Ada-repo'.
|
||||
// Encoding from the lowercased comparison key produced 'c--users-ada-repo',
|
||||
// which never matched — scoped discovery was dead on Windows entirely.
|
||||
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-win-scope-'))
|
||||
tempRoots.push(root)
|
||||
const roots = isolatedScanRoots(root)
|
||||
|
||||
const workspace = 'C:\\Users\\Ada\\repo'
|
||||
const projectDir = join(roots.claudeProjectsDir, claudeProjectDirName(workspace))
|
||||
await mkdir(projectDir, { recursive: true })
|
||||
await writeFile(
|
||||
join(projectDir, 'windows-session.jsonl'),
|
||||
jsonLines([
|
||||
{
|
||||
type: 'user',
|
||||
sessionId: 'windows-session',
|
||||
timestamp: '2026-05-01T10:00:00.000Z',
|
||||
cwd: workspace,
|
||||
gitBranch: 'main',
|
||||
message: { role: 'user', content: 'hello' }
|
||||
}
|
||||
])
|
||||
)
|
||||
await mkdir(join(roots.claudeProjectsDir, '-Users-ada-other'), { recursive: true })
|
||||
await writeFile(
|
||||
join(roots.claudeProjectsDir, '-Users-ada-other', 'recent-session.jsonl'),
|
||||
jsonLines([
|
||||
{
|
||||
type: 'user',
|
||||
sessionId: 'recent-session',
|
||||
timestamp: '2026-06-01T10:00:00.000Z',
|
||||
cwd: '/Users/ada/other',
|
||||
gitBranch: 'main',
|
||||
message: { role: 'user', content: 'newer' }
|
||||
}
|
||||
])
|
||||
)
|
||||
|
||||
const result = await scanAiVaultSessions({
|
||||
...roots,
|
||||
platform: 'win32',
|
||||
limit: 1,
|
||||
scopePaths: [workspace]
|
||||
})
|
||||
|
||||
expect(result.sessions.map((session) => session.sessionId)).toContain('windows-session')
|
||||
})
|
||||
})
|
||||
@@ -4,7 +4,7 @@ import { createInterface } from 'node:readline'
|
||||
import { extname, join } from 'node:path'
|
||||
import {
|
||||
isPathInsideOrEqual,
|
||||
normalizeRuntimePathForComparison
|
||||
normalizeRuntimePathSeparators
|
||||
} from '../../shared/cross-platform-path'
|
||||
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
|
||||
import { parseWslUncPath } from '../../shared/wsl-paths'
|
||||
@@ -91,7 +91,9 @@ function claudeProjectScopePrefixes(scopePaths: readonly string[]): Set<string>
|
||||
const prefixes = new Set<string>()
|
||||
for (const scopePath of scopePaths) {
|
||||
for (const candidate of scopePathCandidates(scopePath)) {
|
||||
prefixes.add(encodeClaudeProjectPath(candidate))
|
||||
for (const prefix of encodeClaudeProjectPaths(candidate)) {
|
||||
prefixes.add(prefix)
|
||||
}
|
||||
}
|
||||
}
|
||||
return prefixes
|
||||
@@ -102,8 +104,22 @@ function scopePathCandidates(scopePath: string): string[] {
|
||||
return wslScopePath ? [scopePath, wslScopePath.linuxPath] : [scopePath]
|
||||
}
|
||||
|
||||
/**
|
||||
* Why: Claude derives the directory name from the raw cwd, so encoding from the
|
||||
* comparison key would lowercase Windows paths and never match on disk. Encode
|
||||
* the raw path, plus its NFC spelling, since macOS hands us NFD (#10832).
|
||||
*/
|
||||
function encodeClaudeProjectPaths(pathValue: string): string[] {
|
||||
const raw = encodeClaudeProjectPath(pathValue)
|
||||
const composed = encodeClaudeProjectPath(pathValue.normalize('NFC'))
|
||||
return raw === composed ? [raw] : [raw, composed]
|
||||
}
|
||||
|
||||
function encodeClaudeProjectPath(pathValue: string): string {
|
||||
return normalizeRuntimePathForComparison(pathValue).replace(/[^a-zA-Z0-9]/g, '-')
|
||||
const separated = normalizeRuntimePathSeparators(pathValue)
|
||||
const trimmed =
|
||||
separated === '/' || /^[A-Za-z]:\/$/.test(separated) ? separated : separated.replace(/\/+$/, '')
|
||||
return trimmed.replace(/[^a-zA-Z0-9]/g, '-')
|
||||
}
|
||||
|
||||
function isClaudeProjectDirInScope(projectDirName: string, scopePrefixes: ReadonlySet<string>) {
|
||||
|
||||
@@ -39,7 +39,7 @@ function snapshotFrame(entries: [type: string, mtime: string, path: string][]):
|
||||
.join('')}${SNAPSHOT_END}`
|
||||
}
|
||||
|
||||
type ScheduleBatchFlush = (rootKey: string, root: WatchedRoot) => void
|
||||
type ScheduleBatchFlush = (root: WatchedRoot) => void
|
||||
type ScheduleBatchFlushMock = ReturnType<typeof vi.fn<ScheduleBatchFlush>>
|
||||
|
||||
function makeDeps(
|
||||
|
||||
@@ -29,12 +29,14 @@ export type WatchedRoot = {
|
||||
subscription: WatcherSubscription
|
||||
listeners: Map<number, WebContents>
|
||||
batch: DebouncedBatch
|
||||
rootPath?: string
|
||||
// Why: the real on-disk path. Never substitute the watcher's rootKey — that is
|
||||
// a comparison key (case/Unicode folded) and would reach the renderer as a path.
|
||||
rootPath: string
|
||||
}
|
||||
|
||||
export type WslWatcherDeps = {
|
||||
ignoreDirs: string[]
|
||||
scheduleBatchFlush: (rootKey: string, root: WatchedRoot) => void
|
||||
scheduleBatchFlush: (root: WatchedRoot) => void
|
||||
watchedRoots: Map<string, WatchedRoot>
|
||||
}
|
||||
|
||||
@@ -192,7 +194,7 @@ export async function createWslWatcher(
|
||||
}
|
||||
stopped = true
|
||||
markOverflowWithoutUncStat(root)
|
||||
deps.scheduleBatchFlush(rootKey, root)
|
||||
deps.scheduleBatchFlush(root)
|
||||
deps.watchedRoots.delete(rootKey)
|
||||
}
|
||||
|
||||
@@ -208,7 +210,7 @@ export async function createWslWatcher(
|
||||
|
||||
if (events.length > 0) {
|
||||
queueWatcherEvents(root.batch, events)
|
||||
deps.scheduleBatchFlush(rootKey, root)
|
||||
deps.scheduleBatchFlush(root)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -227,7 +229,7 @@ export async function createWslWatcher(
|
||||
if (streamBuffer.length > MAX_STREAM_BUFFER_CHARS) {
|
||||
streamBuffer = ''
|
||||
markOverflowWithoutUncStat(root)
|
||||
deps.scheduleBatchFlush(rootKey, root)
|
||||
deps.scheduleBatchFlush(root)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -118,7 +118,7 @@ describe('registerFilesystemWatcherHandlers', () => {
|
||||
const heldReservations = Array.from({ length: MAX_PHYSICAL_WATCHER_CHILDREN }, () =>
|
||||
reserveWatcherChild()
|
||||
)
|
||||
vi.mocked(createWslWatcher).mockImplementation(async () => {
|
||||
vi.mocked(createWslWatcher).mockImplementation(async (_rootKey, worktreePath) => {
|
||||
const release = reserveWatcherChild()
|
||||
if (!release) {
|
||||
throw new WatcherChildCapacityError()
|
||||
@@ -126,7 +126,8 @@ describe('registerFilesystemWatcherHandlers', () => {
|
||||
return {
|
||||
subscription: { unsubscribe: vi.fn(async () => release()) },
|
||||
listeners: new Map(),
|
||||
batch: { events: [], overflowed: false, timer: null, firstEventAt: 0 }
|
||||
batch: { events: [], overflowed: false, timer: null, firstEventAt: 0 },
|
||||
rootPath: worktreePath
|
||||
}
|
||||
})
|
||||
const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 }
|
||||
|
||||
@@ -274,8 +274,8 @@ async function tryStatIsDirectory(filePath: string): Promise<boolean | undefined
|
||||
|
||||
// ── Flush and emit ───────────────────────────────────────────────────
|
||||
|
||||
function emitOverflowPayload(rootKey: string, root: WatchedRoot): void {
|
||||
const rootPath = root.rootPath ?? rootKey
|
||||
function emitOverflowPayload(root: WatchedRoot): void {
|
||||
const { rootPath } = root
|
||||
const payload: FsChangedPayload = {
|
||||
worktreePath: rootPath,
|
||||
events: [{ kind: 'overflow', absolutePath: rootPath }]
|
||||
@@ -287,7 +287,7 @@ function emitOverflowPayload(rootKey: string, root: WatchedRoot): void {
|
||||
}
|
||||
}
|
||||
|
||||
async function flushBatch(rootKey: string, root: WatchedRoot): Promise<void> {
|
||||
async function flushBatch(root: WatchedRoot): Promise<void> {
|
||||
const overflowed = root.batch.overflowed
|
||||
const rawEvents = root.batch.events.splice(0)
|
||||
root.batch.overflowed = false
|
||||
@@ -300,7 +300,7 @@ async function flushBatch(rootKey: string, root: WatchedRoot): Promise<void> {
|
||||
|
||||
if (overflowed || rawEvents.length > MAX_BATCHED_WATCHER_EVENTS) {
|
||||
// Why: deletion storms can be too large to coalesce/stat per path; one overflow asks the renderer for the same conservative refresh.
|
||||
emitOverflowPayload(rootKey, root)
|
||||
emitOverflowPayload(root)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -320,7 +320,7 @@ async function flushBatch(rootKey: string, root: WatchedRoot): Promise<void> {
|
||||
)
|
||||
|
||||
const payload: FsChangedPayload = {
|
||||
worktreePath: root.rootPath ?? rootKey,
|
||||
worktreePath: root.rootPath,
|
||||
events
|
||||
}
|
||||
|
||||
@@ -331,7 +331,7 @@ async function flushBatch(rootKey: string, root: WatchedRoot): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleBatchFlush(rootKey: string, root: WatchedRoot): void {
|
||||
function scheduleBatchFlush(root: WatchedRoot): void {
|
||||
const now = Date.now()
|
||||
|
||||
if (root.batch.firstEventAt === 0) {
|
||||
@@ -343,7 +343,7 @@ function scheduleBatchFlush(rootKey: string, root: WatchedRoot): void {
|
||||
if (root.batch.timer) {
|
||||
clearTimeout(root.batch.timer)
|
||||
}
|
||||
void flushBatch(rootKey, root)
|
||||
void flushBatch(root)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -351,7 +351,7 @@ function scheduleBatchFlush(rootKey: string, root: WatchedRoot): void {
|
||||
if (root.batch.timer) {
|
||||
clearTimeout(root.batch.timer)
|
||||
}
|
||||
root.batch.timer = setTimeout(() => void flushBatch(rootKey, root), DEBOUNCE_TRAILING_MS)
|
||||
root.batch.timer = setTimeout(() => void flushBatch(root), DEBOUNCE_TRAILING_MS)
|
||||
}
|
||||
|
||||
// ── Watcher creation ─────────────────────────────────────────────────
|
||||
@@ -380,7 +380,7 @@ async function createWatcher(
|
||||
|
||||
const markWatcherInterrupted = (): void => {
|
||||
root.batch.overflowed = true
|
||||
scheduleBatchFlush(rootKey, root)
|
||||
scheduleBatchFlush(root)
|
||||
}
|
||||
|
||||
// Why: fork the watcher process (issue #7547 — watcher.node teardown races crash the host); onInterruption marks overflow to refresh past the gap.
|
||||
@@ -390,7 +390,7 @@ async function createWatcher(
|
||||
if (err) {
|
||||
// Why: treat watcher errors as overflow so the renderer conservatively refreshes rather than trusting possibly-invalid caches (§7.2, §7.3).
|
||||
console.error(`[filesystem-watcher] error for ${rootKey}:`, err)
|
||||
emitOverflowPayload(rootKey, root)
|
||||
emitOverflowPayload(root)
|
||||
// Why: after an error the native subscription may be invalid (deleted root); tear down the dead watcher so it doesn't dangle (§7.3).
|
||||
if (root.batch.timer) {
|
||||
clearTimeout(root.batch.timer)
|
||||
@@ -406,7 +406,7 @@ async function createWatcher(
|
||||
}
|
||||
|
||||
queueWatcherEvents(root.batch, events)
|
||||
scheduleBatchFlush(rootKey, root)
|
||||
scheduleBatchFlush(root)
|
||||
},
|
||||
watcherOptions,
|
||||
{
|
||||
|
||||
@@ -39,4 +39,13 @@ describe('getRelativePathInsideRoot', () => {
|
||||
it('keeps POSIX path checks case-sensitive', () => {
|
||||
expect(getRelativePathInsideRoot('/Repo/Docs/Plan.md', '/repo')).toBeNull()
|
||||
})
|
||||
|
||||
it('matches a decomposed root against a composed file path', () => {
|
||||
// Regression for #10832: a local copy of this comparison folded case but not
|
||||
// Unicode, so macOS NFD roots missed their own NFC files entirely.
|
||||
const composed = '/Users/ada/프로젝트'
|
||||
expect(getRelativePathInsideRoot(`${composed}/Docs/Plan.md`, composed.normalize('NFD'))).toBe(
|
||||
'Docs/Plan.md'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { relativePathInsideRoot } from '../../../shared/cross-platform-path'
|
||||
|
||||
function stripTrailingSeparators(path: string): string {
|
||||
return path.replace(/[\\/]+$/, '')
|
||||
}
|
||||
@@ -14,26 +16,6 @@ export function normalizeRelativePath(path: string): string {
|
||||
return stripLeadingSeparators(path).replace(/[\\/]+/g, '/')
|
||||
}
|
||||
|
||||
function normalizeAbsolutePath(path: string): string {
|
||||
const isUncPath = /^[\\/]{2}[^\\/]/.test(path)
|
||||
const normalized = path.replace(/[\\/]+/g, '/')
|
||||
return isUncPath && !normalized.startsWith('//') ? `/${normalized}` : normalized
|
||||
}
|
||||
|
||||
function stripTrailingAbsoluteSeparators(path: string): string {
|
||||
if (path === '/') {
|
||||
return path
|
||||
}
|
||||
if (/^[A-Za-z]:\/$/.test(path)) {
|
||||
return path
|
||||
}
|
||||
return path.replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
function isCaseInsensitiveAbsolutePath(path: string): boolean {
|
||||
return /^[A-Za-z]:(?:\/|$)/.test(path) || path.startsWith('//')
|
||||
}
|
||||
|
||||
export function getRelativePathInsideRoot(
|
||||
filePath: string,
|
||||
rootPath: string | null
|
||||
@@ -41,29 +23,9 @@ export function getRelativePathInsideRoot(
|
||||
if (!rootPath) {
|
||||
return null
|
||||
}
|
||||
|
||||
const normalizedFilePath = normalizeAbsolutePath(filePath)
|
||||
const normalizedRoot = stripTrailingAbsoluteSeparators(normalizeAbsolutePath(rootPath))
|
||||
const comparisonFilePath = isCaseInsensitiveAbsolutePath(normalizedFilePath)
|
||||
? normalizedFilePath.toLowerCase()
|
||||
: normalizedFilePath
|
||||
const comparisonRoot = isCaseInsensitiveAbsolutePath(normalizedRoot)
|
||||
? normalizedRoot.toLowerCase()
|
||||
: normalizedRoot
|
||||
|
||||
if (comparisonFilePath === comparisonRoot) {
|
||||
return ''
|
||||
}
|
||||
|
||||
// Why: POSIX and Windows drive roots already include their trailing separator.
|
||||
const isRootPath = normalizedRoot === '/' || /^[A-Za-z]:\/$/.test(normalizedRoot)
|
||||
const rootPrefix = isRootPath ? comparisonRoot : `${comparisonRoot}/`
|
||||
if (!comparisonFilePath.startsWith(rootPrefix)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const sliceStart = isRootPath ? normalizedRoot.length : normalizedRoot.length + 1
|
||||
return normalizeRelativePath(normalizedFilePath.slice(sliceStart))
|
||||
// Why: delegate so renderer containment folds Unicode the same way the rest of
|
||||
// the app does — a local copy silently missed NFD-vs-NFC matches (#10832).
|
||||
return relativePathInsideRoot(rootPath, filePath)
|
||||
}
|
||||
|
||||
export function basename(path: string): string {
|
||||
|
||||
@@ -70,6 +70,65 @@ describe('cross-platform path containment', () => {
|
||||
).toBe('line\nbreak')
|
||||
})
|
||||
|
||||
it('matches macOS NFD paths against agent-recorded NFC paths', () => {
|
||||
// Regression for #10832: macOS file pickers hand Orca decomposed (NFD) paths
|
||||
// while Claude Code records cwd and names its project dirs in NFC, so a
|
||||
// non-ASCII workspace never matched its own sessions.
|
||||
const nfc = '/Users/ada/내 드라이브/프로젝트'
|
||||
const nfd = nfc.normalize('NFD')
|
||||
expect(nfd).not.toBe(nfc)
|
||||
|
||||
expect(normalizeRuntimePathForComparison(nfd)).toBe(normalizeRuntimePathForComparison(nfc))
|
||||
expect(isPathInsideOrEqual(nfd, `${nfc}/src`)).toBe(true)
|
||||
expect(isPathInsideOrEqual(nfc, `${nfd}/src`)).toBe(true)
|
||||
|
||||
// WSL UNC keys return before the trailing fold, so they need NFC too.
|
||||
expect(
|
||||
normalizeRuntimePathForComparison(
|
||||
`\\\\wsl$\\Ubuntu\\home\\ada\\${'프로젝트'.normalize('NFD')}`
|
||||
)
|
||||
).toBe(normalizeRuntimePathForComparison(`\\\\wsl.localhost\\Ubuntu\\home\\ada\\프로젝트`))
|
||||
})
|
||||
|
||||
it('returns a byte-exact suffix when comparison folding changes length', () => {
|
||||
// Comparison folding (NFC, case) is not length-preserving, so slicing the raw
|
||||
// candidate by the folded root's length would cut mid-character and fabricate
|
||||
// a path — callers rejoin this suffix and hit the filesystem with it.
|
||||
const nfc = '/Users/ada/프로젝트'
|
||||
const nfd = nfc.normalize('NFD')
|
||||
for (const root of [nfc, nfd]) {
|
||||
for (const candidate of [nfc, nfd]) {
|
||||
expect(relativePathInsideRoot(root, `${candidate}/src/index.ts`)).toBe('src/index.ts')
|
||||
}
|
||||
}
|
||||
|
||||
// Pre-existing over-slice: toLowerCase expands U+0130 to two UTF-16 units.
|
||||
expect(relativePathInsideRoot('C:\\İş', 'C:\\İş\\src\\a.ts')).toBe('src/a.ts')
|
||||
|
||||
// U+212A KELVIN SIGN folds to 'K', so the root and candidate must agree on
|
||||
// Windows-ness or their segment counts desync and the suffix comes back ''.
|
||||
expect(relativePathInsideRoot('\u212A:/a\\b', '\u212A:/a\\b/c')).toBe(
|
||||
relativePathInsideRoot('K:/a\\b', 'K:/a\\b/c')
|
||||
)
|
||||
|
||||
// Astral characters must not be cut mid-surrogate-pair.
|
||||
expect(relativePathInsideRoot('/repo/🚀app', '/repo/🚀app/src/🎉file.ts')).toBe('src/🎉file.ts')
|
||||
|
||||
// A UNC-shaped candidate under POSIX root '/' used to yield a leading slash,
|
||||
// which is not a relative path.
|
||||
expect(relativePathInsideRoot('/', '//server/share/x')).toBe('server/share/x')
|
||||
|
||||
// WSL suffixes must stay decomposed: they name files on a Linux filesystem,
|
||||
// where NFD and NFC are distinct entries.
|
||||
const decomposed = '프로젝트'.normalize('NFD')
|
||||
expect(
|
||||
relativePathInsideRoot(
|
||||
'\\\\wsl$\\Ubuntu\\home\\ada\\repo',
|
||||
`\\\\wsl.localhost\\Ubuntu\\home\\ada\\repo\\${decomposed}\\a.ts`
|
||||
)
|
||||
).toBe(`${decomposed}/a.ts`)
|
||||
})
|
||||
|
||||
it('resolves POSIX relative paths without using the process cwd', () => {
|
||||
expect(resolveRuntimePath('/repos/app/repo', '../worktrees/feature')).toBe(
|
||||
'/repos/app/worktrees/feature'
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
const SLASH_CHAR_CODE = '/'.charCodeAt(0)
|
||||
|
||||
export function isWindowsAbsolutePathLike(value: string): boolean {
|
||||
return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith('\\\\') || value.startsWith('//')
|
||||
}
|
||||
@@ -10,7 +12,19 @@ export function normalizeRuntimePathSeparators(value: string): string {
|
||||
return normalized
|
||||
}
|
||||
|
||||
export function normalizeRuntimePathForComparison(value: string): string {
|
||||
/**
|
||||
* Comparison key only — never return this as, or splice it into, a real path.
|
||||
*
|
||||
* Why NFC: macOS file pickers and on-disk names yield NFD, while agents such as
|
||||
* Claude Code record cwd and encode their project directory names in NFC. Both
|
||||
* spell the same file, so a non-ASCII workspace otherwise never matches its own
|
||||
* sessions (#10832). Folding here knowingly treats canonically equivalent names
|
||||
* as one, which is exact on APFS but permissive on byte-exact Linux/SSH hosts —
|
||||
* an acceptable trade, since only comparison keys are affected.
|
||||
*/
|
||||
export function normalizeRuntimePathForComparison(rawValue: string): string {
|
||||
// Normalize before any folding so the WSL alias branch below is covered too.
|
||||
const value = rawValue.normalize('NFC')
|
||||
const isWindowsPath = isWindowsAbsolutePathLike(value)
|
||||
// Why: backslash is a valid POSIX filename character; fold it only when the
|
||||
// path itself proves Windows drive/UNC semantics.
|
||||
@@ -68,8 +82,12 @@ export function isPathInsideOrEqual(rootPath: string, candidatePath: string): bo
|
||||
}
|
||||
|
||||
export function relativePathInsideRoot(rootPath: string, candidatePath: string): string | null {
|
||||
// Why: decide Windows-ness on the same NFC form the comparison key uses, or the
|
||||
// two disagree (U+212A folds to 'K', making only one side a drive path) and the
|
||||
// segment counts desync. Only the branch test sees NFC — the sliced string stays
|
||||
// raw so the returned suffix remains byte-exact.
|
||||
const normalizedCandidate = trimRuntimePathTrailingSlash(
|
||||
isWindowsAbsolutePathLike(candidatePath)
|
||||
isWindowsAbsolutePathLike(candidatePath.normalize('NFC'))
|
||||
? normalizeRuntimePathSeparators(candidatePath)
|
||||
: candidatePath.replace(/\/+/g, '/')
|
||||
)
|
||||
@@ -84,11 +102,42 @@ export function relativePathInsideRoot(rootPath: string, candidatePath: string):
|
||||
if (!comparisonCandidate.startsWith(comparisonPrefix)) {
|
||||
return null
|
||||
}
|
||||
// WSL comparison keys fold the UNC alias but preserve Linux path casing, so
|
||||
// their suffix is both aligned across aliases and safe to return directly.
|
||||
return comparisonRoot.startsWith('//wsl/')
|
||||
? comparisonCandidate.slice(comparisonPrefix.length)
|
||||
: normalizedCandidate.slice(comparisonPrefix.length)
|
||||
return sliceCandidatePastRootSegments(comparisonRoot, normalizedCandidate)
|
||||
}
|
||||
|
||||
/**
|
||||
* Why: skip whole root segments rather than a character count. Comparison
|
||||
* folding (NFC, case, UNC alias) changes length, so a folded-prefix length would
|
||||
* cut the raw candidate mid-character and fabricate a path; segment positions
|
||||
* survive every fold and keep the suffix byte-exact. Scanning rather than
|
||||
* splitting keeps watcher event storms allocation-free.
|
||||
*/
|
||||
function sliceCandidatePastRootSegments(root: string, candidate: string): string {
|
||||
let remainingRootSegments = 0
|
||||
let inRootSegment = false
|
||||
for (let index = 0; index < root.length; index++) {
|
||||
if (root.charCodeAt(index) === SLASH_CHAR_CODE) {
|
||||
inRootSegment = false
|
||||
} else if (!inRootSegment) {
|
||||
inRootSegment = true
|
||||
remainingRootSegments++
|
||||
}
|
||||
}
|
||||
|
||||
let inSegment = false
|
||||
for (let index = 0; index < candidate.length; index++) {
|
||||
if (candidate.charCodeAt(index) === SLASH_CHAR_CODE) {
|
||||
inSegment = false
|
||||
continue
|
||||
}
|
||||
if (!inSegment) {
|
||||
inSegment = true
|
||||
if (remainingRootSegments-- === 0) {
|
||||
return candidate.slice(index)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function trimRuntimePathTrailingSlash(value: string): string {
|
||||
|
||||
Reference in New Issue
Block a user