fix(projects): derive exact cwd from matched path identity

This commit is contained in:
Mohamed Boudra
2026-07-16 18:35:17 +00:00
parent 076d8bb2f7
commit f77fdf2a2f
7 changed files with 107 additions and 27 deletions

View File

@@ -178,7 +178,7 @@ import {
matchesAgentUpdatesFilter,
type AgentUpdatesService,
} from "./session/agent-updates/agent-updates-service.js";
import { areEquivalentPaths, expandTilde } from "../utils/path.js";
import { createRealpathAwarePathMatcher, expandTilde } from "../utils/path.js";
import {
searchDirectoryEntries,
WORKSPACE_SEARCH_HIDDEN_DIRECTORIES,
@@ -4083,7 +4083,7 @@ export class Session {
workspaceCwd: workspace.cwd,
targetWorktreePath: result.worktreePath,
});
if (!areEquivalentPaths(recreatedWorkspacePath, workspace.cwd)) {
if (!createRealpathAwarePathMatcher(workspace.cwd)(recreatedWorkspacePath)) {
throw new WorktreeRequestError({
code: "unknown",
message: `Recreated worktree diverged from ${workspace.cwd}: ${recreatedWorkspacePath}`,

View File

@@ -33,7 +33,7 @@ import type {
AgentStreamEvent,
} from "./agent/agent-sdk-types.js";
import { createWorktree } from "../utils/worktree.js";
import { areEquivalentPaths } from "../utils/path.js";
import { createRealpathAwarePathMatcher } from "../utils/path.js";
import {
readPaseoWorktreeMetadata,
writePaseoWorktreeFirstAgentBranchAutoNameMetadata,
@@ -1024,9 +1024,8 @@ test("create_agent_request launches from an exact subdirectory in a created work
.toString()
.trim();
expect(
areEquivalentPaths(
createRealpathAwarePathMatcher(path.join(createdWorktreeRoot, "packages", "app"))(
createdAgent?.cwd ?? "",
path.join(createdWorktreeRoot, "packages", "app"),
),
).toBe(true);
expect(findByType(emitted, "status")?.payload).toMatchObject({

View File

@@ -14,7 +14,7 @@ import {
} from "../utils/worktree.js";
import type { TerminalManager } from "../terminal/terminal-manager.js";
import type { PersistedWorkspaceRecord, WorkspaceRegistry } from "./workspace-registry.js";
import { areEquivalentPaths, createRealpathAwarePathMatcher } from "../utils/path.js";
import { createRealpathAwarePathMatcher } from "../utils/path.js";
export interface ActiveWorkspaceRef {
workspaceId: string;
@@ -189,6 +189,7 @@ async function resolveArchiveTargets(
paseoWorktreesBaseRoot,
);
const targetDir = resolve(targetPath);
const matchesTargetDir = createRealpathAwarePathMatcher(targetDir);
const targetWorkspaceIds = (
await Promise.all(
activeWorkspaces.map(async (workspace) => {
@@ -197,7 +198,7 @@ async function resolveArchiveTargets(
dependencies,
paseoWorktreesBaseRoot,
);
return areEquivalentPaths(backingDirectory, targetDir) ? workspace.workspaceId : null;
return matchesTargetDir(backingDirectory) ? workspace.workspaceId : null;
}),
)
).filter((workspaceId): workspaceId is string => workspaceId !== null);
@@ -373,6 +374,7 @@ async function isDirectoryUnreferenced(
request: Pick<ArchiveByScopeRequest, "paseoWorktreesBaseRoot">,
): Promise<boolean> {
const target = resolve(targetDir);
const matchesTarget = createRealpathAwarePathMatcher(target);
for (const workspace of activeWorkspaces) {
if (archivedWorkspaceIds.has(workspace.workspaceId)) continue;
const backingDirectory = await resolveBackingWorktreeDirectory(
@@ -380,7 +382,7 @@ async function isDirectoryUnreferenced(
dependencies,
request.paseoWorktreesBaseRoot,
);
if (areEquivalentPaths(backingDirectory, target)) return false;
if (matchesTarget(backingDirectory)) return false;
}
return true;
}

View File

@@ -1,6 +1,14 @@
import { mkdtempSync, mkdirSync, rmSync, symlinkSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test } from "vitest";
import { areEquivalentPaths, createPathEquivalenceMatcher, isPathInsideRoot } from "./path.js";
import {
areEquivalentPaths,
createPathEquivalenceMatcher,
getRealpathAwareRelativePath,
isPathInsideRoot,
} from "./path.js";
describe("path equivalence", () => {
test.each([
@@ -33,4 +41,23 @@ describe("path equivalence", () => {
false,
);
});
test.skipIf(process.platform === "win32")(
"derives the contained suffix from a realpath-equivalent root",
() => {
const tempDir = mkdtempSync(join(tmpdir(), "paseo-path-"));
try {
const realRoot = join(tempDir, "real-root");
const nestedPath = join(realRoot, "packages", "app");
const aliasRoot = join(tempDir, "root-alias");
mkdirSync(nestedPath, { recursive: true });
symlinkSync(realRoot, aliasRoot, "dir");
expect(getRealpathAwareRelativePath(aliasRoot, nestedPath)).toBe(join("packages", "app"));
expect(getRealpathAwareRelativePath(aliasRoot, tempDir)).toBeNull();
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
},
);
});

View File

@@ -59,22 +59,44 @@ export function createRealpathAwarePathMatcher(target: string): (candidate: stri
}
export function isPathInsideRoot(root: string, candidate: string): boolean {
return getRelativePathInsideRoot(root, candidate) !== null;
}
/**
* 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 relative = platformPath.relative(normalizedRoot, normalizedCandidate);
return relative === "" || (!relative.startsWith("..") && !platformPath.isAbsolute(relative));
}
export function isRealpathInsideRoot(root: string, candidate: string): boolean {
const rootVariants = collectPathVariants(root);
const candidateVariants = collectPathVariants(candidate);
return rootVariants.some((rootVariant) =>
candidateVariants.some((candidateVariant) => isPathInsideRoot(rootVariant, candidateVariant)),
);
return relative === "" || (!relative.startsWith("..") && !platformPath.isAbsolute(relative))
? relative
: null;
}
function collectPathVariants(value: string): string[] {

View File

@@ -10,7 +10,15 @@ import {
type WorktreeConfig,
} from "./worktree";
import { execFileSync } from "child_process";
import { mkdtempSync, mkdirSync, rmSync, existsSync, realpathSync, writeFileSync } from "fs";
import {
mkdtempSync,
mkdirSync,
rmSync,
existsSync,
realpathSync,
symlinkSync,
writeFileSync,
} from "fs";
import { join } from "path";
import { tmpdir } from "os";
@@ -132,6 +140,26 @@ describe("paseo worktree manager", () => {
).toThrow("outside its source worktree");
});
it.skipIf(process.platform === "win32")(
"maps a realpath-equivalent source workspace into the matching target subdirectory",
() => {
const sourceWorktreePath = join(tempDir, "source-worktree");
const workspaceCwd = join(sourceWorktreePath, "packages", "app");
const sourceAlias = join(tempDir, "source-alias");
const targetWorktreePath = join(tempDir, "target-worktree");
mkdirSync(workspaceCwd, { recursive: true });
symlinkSync(sourceWorktreePath, sourceAlias, "dir");
expect(
mapWorkspaceCwdToWorktree({
sourceWorktreePath: sourceAlias,
workspaceCwd,
targetWorktreePath,
}),
).toBe(join(targetWorktreePath, "packages", "app"));
},
);
it("rejects the worktrees root itself and the per-repo hash dir", async () => {
const projectHash = await deriveWorktreeProjectHash(repoDir);
const worktreesRoot = join(paseoHome, "worktrees");

View File

@@ -2,7 +2,7 @@ import { execFile } from "child_process";
import { promisify } from "util";
import { existsSync, mkdirSync, realpathSync, rmSync, statSync } from "fs";
import { copyFile, rm, stat } from "fs/promises";
import { join, basename, dirname, isAbsolute, relative, resolve, sep } from "path";
import { join, basename, dirname, isAbsolute, resolve, sep } from "path";
import net from "node:net";
import { createHash } from "node:crypto";
import stripAnsi from "strip-ansi";
@@ -34,7 +34,7 @@ import { resolvePaseoHome } from "../server/paseo-home.js";
import { createExternalProcessEnv } from "../server/paseo-env.js";
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
import { validateBranchSlug } from "@getpaseo/protocol/branch-slug";
import { expandTilde, isRealpathInsideRoot } from "./path.js";
import { expandTilde, getRealpathAwareRelativePath, isPathInsideRoot } from "./path.js";
export { slugify, validateBranchSlug } from "@getpaseo/protocol/branch-slug";
@@ -830,14 +830,16 @@ export function mapWorkspaceCwdToWorktree(input: {
workspaceCwd: string;
targetWorktreePath: string;
}): string {
if (!isRealpathInsideRoot(input.sourceWorktreePath, input.workspaceCwd)) {
const relativeWorkspaceCwd = getRealpathAwareRelativePath(
input.sourceWorktreePath,
input.workspaceCwd,
);
if (relativeWorkspaceCwd === null) {
throw new Error(`Workspace cwd is outside its source worktree: ${input.workspaceCwd}`);
}
const sourceWorktreePath = normalizePathForOwnership(input.sourceWorktreePath);
const workspaceCwd = normalizePathForOwnership(input.workspaceCwd);
const mappedCwd = resolve(input.targetWorktreePath, relative(sourceWorktreePath, workspaceCwd));
if (!isRealpathInsideRoot(input.targetWorktreePath, mappedCwd)) {
const mappedCwd = resolve(input.targetWorktreePath, relativeWorkspaceCwd);
if (!isPathInsideRoot(input.targetWorktreePath, mappedCwd)) {
throw new Error(`Workspace cwd escapes its target worktree: ${input.workspaceCwd}`);
}
return mappedCwd;