mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
fix(projects): derive exact cwd from matched path identity
This commit is contained in:
@@ -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}`,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user