From 6683fb2bedcaf0483952dcbe15a2e835d2991d10 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Sat, 25 Jul 2026 10:31:53 +0900 Subject: [PATCH 1/8] feat(server): support symlink worktree includes --- SECURITY.md | 6 + docs/development.md | 1 + ...rkspace-create-worktree-source.e2e.test.ts | 51 +- .../server/src/utils/worktree-include.test.ts | 223 +++++ packages/server/src/utils/worktree-include.ts | 857 ++++++++++++++++++ .../server/src/utils/worktree.posix.test.ts | 147 +++ packages/server/src/utils/worktree.ts | 25 +- public-docs/worktrees.md | 42 + 8 files changed, 1350 insertions(+), 2 deletions(-) create mode 100644 packages/server/src/utils/worktree-include.test.ts create mode 100644 packages/server/src/utils/worktree-include.ts diff --git a/SECURITY.md b/SECURITY.md index b09425b688..135e8f0021 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -48,6 +48,12 @@ The daemon also supports an optional shared-secret password (set via `auth.passw Connected clients are trusted operators of the daemon user. File previews follow that authority: a preview request may read any regular file the daemon process can read, while keeping path normalization and symlink checks in the daemon file service. Workspace-relative paths remain a UI convenience, not a security boundary. +An explicit # @symlink entry in a repository's .worktreeinclude intentionally gives a +Paseo-created worktree live access to that source-checkout file or directory. It is useful for +local dependencies and caches, but it weakens the usual worktree isolation: agents and lifecycle +scripts can modify the source through the link. Paseo validates entries and refuses traversal or +destination-link escapes, but the linked source is a deliberate shared-data boundary. + If you expose the daemon beyond loopback, such as by binding to `0.0.0.0`, forwarding it through a tunnel or reverse proxy, or publishing it from a Docker container, you are responsible for restricting and securing that access. Setting a password is strongly recommended in that case. In Docker, the official image runs the daemon and agents as the non-root diff --git a/docs/development.md b/docs/development.md index 9eed78a00e..b948167cc7 100644 --- a/docs/development.md +++ b/docs/development.md @@ -29,6 +29,7 @@ Root checkout dev is intentionally split across terminals: - **Repo dev scripts** default to `$ROOT/.dev/paseo-home`, where `$ROOT` is the current checkout or worktree root. This keeps all dev state scoped to the checkout instead of the packaged desktop app. - **`npm run cli -- ...`** runs through the same dev-home wrapper as the dev scripts, so the in-repo CLI automatically targets the current checkout's `.dev/paseo-home` and configured dev daemon endpoint. - **Paseo-created worktrees** seed `$PASEO_WORKTREE_PATH/.dev/paseo-home` from `$PASEO_SOURCE_CHECKOUT_PATH/.dev/paseo-home` by copying durable JSON metadata. Runtime files like pid files, sockets, and logs are not copied. +- **Paseo-created worktrees** read `.worktreeinclude` from the live source checkout before creation. Legacy entries copy paths into the new worktree; a `# @symlink` directive makes its next path a source link. Materialization finishes before `worktree.setup` runs. - **This repo's worktree setup** also best-effort seeds `packages/app/ios` and the newest `.dev/ios-build` entry from the source checkout so iOS simulator services can reuse native project and Xcode cache state when it is safe enough to do so. Override knobs: diff --git a/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts b/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts index 2fbdc930cc..69802312a4 100644 --- a/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts +++ b/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts @@ -1,5 +1,5 @@ import { execFileSync } from "node:child_process"; -import { mkdtempSync, rmSync } from "node:fs"; +import { lstatSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { expect, test } from "vitest"; @@ -95,3 +95,52 @@ test("workspace.create keeps a branch-off name separate from its worktree slug", rmSync(tempRoot, { recursive: true, force: true }); } }, 180000); + +test("workspace.create materializes .worktreeinclude copies and symlinks", async () => { + const daemon = await createTestPaseoDaemon(); + const { repoDir, tempRoot } = createGitRepoWithBranch(); + const client = new DaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + appVersion: "0.1.82", + }); + + try { + writeFileSync( + path.join(repoDir, ".worktreeinclude"), + ["copy.env", "# @symlink", "linked.txt", ""].join("\n"), + ); + writeFileSync(path.join(repoDir, "copy.env"), "copy-v1\n"); + writeFileSync(path.join(repoDir, "linked.txt"), "linked-v1\n"); + await client.connect(); + + const result = await client.createWorkspace({ + source: { + kind: "worktree", + cwd: repoDir, + action: "branch-off", + branchName: "feature/include-e2e", + worktreeSlug: "include-e2e", + baseBranch: "main", + }, + }); + + expect(result.error).toBeNull(); + const worktreePath = result.workspace?.workspaceDirectory; + if (!worktreePath) { + throw new Error("workspace.create did not return a worktree directory"); + } + + expect(readFileSync(path.join(worktreePath, "copy.env"), "utf8")).toBe("copy-v1\n"); + expect(lstatSync(path.join(worktreePath, "linked.txt")).isSymbolicLink()).toBe(true); + + writeFileSync(path.join(repoDir, "copy.env"), "copy-v2\n"); + writeFileSync(path.join(repoDir, "linked.txt"), "linked-v2\n"); + + expect(readFileSync(path.join(worktreePath, "copy.env"), "utf8")).toBe("copy-v1\n"); + expect(readFileSync(path.join(worktreePath, "linked.txt"), "utf8")).toBe("linked-v2\n"); + } finally { + await client.close().catch(() => undefined); + await daemon.close(); + rmSync(tempRoot, { recursive: true, force: true }); + } +}, 180000); diff --git a/packages/server/src/utils/worktree-include.test.ts b/packages/server/src/utils/worktree-include.test.ts new file mode 100644 index 0000000000..8391ce031d --- /dev/null +++ b/packages/server/src/utils/worktree-include.test.ts @@ -0,0 +1,223 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + symlinkSync, + writeFileSync, +} from "fs"; +import { tmpdir } from "os"; +import { join } from "path"; +import { isPlatform } from "../test-utils/platform.js"; +import { materializeWorktreeIncludePlan, readWorktreeIncludePlan } from "./worktree-include.js"; + +describe("worktree include planning", () => { + let tempDir: string; + let sourceRoot: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "worktree-include-test-")); + sourceRoot = join(tempDir, "source"); + mkdirSync(sourceRoot); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it("keeps legacy entries as copies and applies a symlink directive to one entry", async () => { + writeFileSync( + join(sourceRoot, ".worktreeinclude"), + [ + ".env.local", + ".cache/**", + "# @symlink", + "shared-state", + "# @copy", + "packages/*/.runtime.env", + "", + ].join("\n"), + ); + writeFileSync(join(sourceRoot, ".env.local"), "source\n"); + mkdirSync(join(sourceRoot, ".cache"), { recursive: true }); + writeFileSync(join(sourceRoot, ".cache", "state.txt"), "cache\n"); + mkdirSync(join(sourceRoot, "shared-state"), { recursive: true }); + writeFileSync(join(sourceRoot, "shared-state", "state.txt"), "shared\n"); + mkdirSync(join(sourceRoot, "packages", "api"), { recursive: true }); + writeFileSync(join(sourceRoot, "packages", "api", ".runtime.env"), "api\n"); + + const plan = await readWorktreeIncludePlan({ sourceRoot }); + + expect(plan.materializations).toHaveLength(4); + expect(plan.materializations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ mode: "copy", relativePath: ".env.local", sourceKind: "file" }), + expect.objectContaining({ mode: "copy", relativePath: ".cache", sourceKind: "directory" }), + expect.objectContaining({ + mode: "symlink", + relativePath: "shared-state", + sourceKind: "directory", + }), + expect.objectContaining({ + mode: "copy", + relativePath: "packages/api/.runtime.env", + sourceKind: "file", + }), + ]), + ); + }); + + it("rejects unsafe, dangling, and conflicting entries before a worktree exists", async () => { + writeFileSync(join(sourceRoot, ".worktreeinclude"), "../outside\n"); + await expect(readWorktreeIncludePlan({ sourceRoot })).rejects.toThrow( + "parent-directory segments are not allowed", + ); + + writeFileSync(join(sourceRoot, ".worktreeinclude"), "# @symlink\n"); + await expect(readWorktreeIncludePlan({ sourceRoot })).rejects.toThrow("has no path entry"); + + writeFileSync(join(sourceRoot, "shared"), "source\n"); + writeFileSync( + join(sourceRoot, ".worktreeinclude"), + ["shared", "# @symlink", "shared", ""].join("\n"), + ); + await expect(readWorktreeIncludePlan({ sourceRoot })).rejects.toThrow( + "use both copy and symlink modes", + ); + }); + + it("reports unmatched literals and globs consistently", async () => { + writeFileSync(join(sourceRoot, ".worktreeinclude"), "missing/**\n"); + + await expect(readWorktreeIncludePlan({ sourceRoot })).rejects.toThrow( + "No paths matched .worktreeinclude entry 'missing/**'", + ); + + writeFileSync(join(sourceRoot, ".worktreeinclude"), "**/.runtime.env\n"); + await expect(readWorktreeIncludePlan({ sourceRoot })).rejects.toThrow( + "No paths matched .worktreeinclude entry '**/.runtime.env'", + ); + }); +}); + +describe.skipIf(isPlatform("win32"))("worktree include materialization", () => { + let tempDir: string; + let sourceRoot: string; + let worktreeRoot: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "worktree-include-materialize-test-")); + sourceRoot = join(tempDir, "source"); + worktreeRoot = join(tempDir, "worktree"); + mkdirSync(sourceRoot); + mkdirSync(worktreeRoot); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it("copies snapshots, links shared paths, and is idempotent", async () => { + writeFileSync( + join(sourceRoot, ".worktreeinclude"), + ["copy.txt", "copy-dir/**", "# @symlink", "linked.txt", "# @symlink", "linked-dir"].join( + "\n", + ), + ); + writeFileSync(join(sourceRoot, "copy.txt"), "copy-v1\n"); + mkdirSync(join(sourceRoot, "copy-dir"), { recursive: true }); + writeFileSync(join(sourceRoot, "copy-dir", "state.txt"), "copy-dir-v1\n"); + writeFileSync(join(sourceRoot, "linked.txt"), "linked-v1\n"); + mkdirSync(join(sourceRoot, "linked-dir"), { recursive: true }); + writeFileSync(join(sourceRoot, "linked-dir", "state.txt"), "linked-dir-v1\n"); + + const plan = await readWorktreeIncludePlan({ sourceRoot }); + await materializeWorktreeIncludePlan({ plan, worktreeRoot }); + + expect(lstatSync(join(worktreeRoot, "copy.txt")).isSymbolicLink()).toBe(false); + expect(lstatSync(join(worktreeRoot, "copy-dir")).isSymbolicLink()).toBe(false); + expect(lstatSync(join(worktreeRoot, "linked.txt")).isSymbolicLink()).toBe(true); + expect(lstatSync(join(worktreeRoot, "linked-dir")).isSymbolicLink()).toBe(true); + + writeFileSync(join(sourceRoot, "copy.txt"), "copy-v2\n"); + writeFileSync(join(sourceRoot, "copy-dir", "state.txt"), "copy-dir-v2\n"); + writeFileSync(join(sourceRoot, "linked.txt"), "linked-v2\n"); + writeFileSync(join(sourceRoot, "linked-dir", "state.txt"), "linked-dir-v2\n"); + + expect(readFileSync(join(worktreeRoot, "copy.txt"), "utf8")).toBe("copy-v1\n"); + expect(readFileSync(join(worktreeRoot, "copy-dir", "state.txt"), "utf8")).toBe("copy-dir-v1\n"); + expect(readFileSync(join(worktreeRoot, "linked.txt"), "utf8")).toBe("linked-v2\n"); + expect(readFileSync(join(worktreeRoot, "linked-dir", "state.txt"), "utf8")).toBe( + "linked-dir-v2\n", + ); + + await materializeWorktreeIncludePlan({ plan, worktreeRoot }); + expect(readFileSync(join(worktreeRoot, "copy.txt"), "utf8")).toBe("copy-v2\n"); + expect(readFileSync(join(worktreeRoot, "copy-dir", "state.txt"), "utf8")).toBe("copy-dir-v2\n"); + }); + + it("rejects a destination parent symlink without writing through it", async () => { + mkdirSync(join(sourceRoot, "config"), { recursive: true }); + writeFileSync(join(sourceRoot, "config", "local.json"), "{}\n"); + writeFileSync(join(sourceRoot, ".worktreeinclude"), "config/local.json\n"); + + const outsideRoot = join(tempDir, "outside"); + mkdirSync(outsideRoot); + symlinkSync(outsideRoot, join(worktreeRoot, "config")); + + const plan = await readWorktreeIncludePlan({ sourceRoot }); + await expect(materializeWorktreeIncludePlan({ plan, worktreeRoot })).rejects.toThrow( + "Refusing to materialize .worktreeinclude entry", + ); + expect(existsSync(join(outsideRoot, "local.json"))).toBe(false); + }); + + it("rejects a source symlink", async () => { + const outsidePath = join(tempDir, "outside.txt"); + writeFileSync(outsidePath, "outside\n"); + symlinkSync(outsidePath, join(sourceRoot, "linked.txt")); + writeFileSync(join(sourceRoot, ".worktreeinclude"), "linked.txt\n"); + + await expect(readWorktreeIncludePlan({ sourceRoot })).rejects.toThrow( + "resolves through a symbolic link", + ); + }); +}); + +describe.skipIf(!isPlatform("win32"))("worktree include Windows directory links", () => { + let tempDir: string; + let sourceRoot: string; + let worktreeRoot: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), "worktree-include-windows-test-")); + sourceRoot = join(tempDir, "source"); + worktreeRoot = join(tempDir, "worktree"); + mkdirSync(sourceRoot); + mkdirSync(worktreeRoot); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it("uses a live directory link and removes it without touching the source", async () => { + mkdirSync(join(sourceRoot, "shared-state")); + writeFileSync(join(sourceRoot, "shared-state", "state.txt"), "source-v1\n"); + writeFileSync(join(sourceRoot, ".worktreeinclude"), "# @symlink\nshared-state\n"); + + const plan = await readWorktreeIncludePlan({ sourceRoot }); + await materializeWorktreeIncludePlan({ plan, worktreeRoot }); + + writeFileSync(join(sourceRoot, "shared-state", "state.txt"), "source-v2\n"); + expect(readFileSync(join(worktreeRoot, "shared-state", "state.txt"), "utf8")).toBe( + "source-v2\n", + ); + + rmSync(worktreeRoot, { recursive: true, force: true }); + expect(readFileSync(join(sourceRoot, "shared-state", "state.txt"), "utf8")).toBe("source-v2\n"); + }); +}); diff --git a/packages/server/src/utils/worktree-include.ts b/packages/server/src/utils/worktree-include.ts new file mode 100644 index 0000000000..fa6dbe94db --- /dev/null +++ b/packages/server/src/utils/worktree-include.ts @@ -0,0 +1,857 @@ +import { type Stats } from "fs"; +import { copyFile, cp, lstat, mkdir, readFile, readdir, realpath, symlink } from "fs/promises"; +import { dirname, isAbsolute, join, relative, resolve, sep, win32 } from "path"; + +const WORKTREE_INCLUDE_FILE_NAME = ".worktreeinclude"; +const COPY_DIRECTIVE = "# @copy"; +const SYMLINK_DIRECTIVE = "# @symlink"; + +export type WorktreeIncludeMode = "copy" | "symlink"; +type WorktreeIncludeSourceKind = "file" | "directory"; +type WorktreeIncludeErrorCode = + | "conflict" + | "invalid_entry" + | "missing_source" + | "source_changed" + | "unsupported_source" + | "windows_symlink_unavailable"; + +interface WorktreeIncludeEntry { + lineNumber: number; + mode: WorktreeIncludeMode; + raw: string; + relativePath: string; +} + +export interface WorktreeIncludeMaterialization { + lineNumber: number; + mode: WorktreeIncludeMode; + relativePath: string; + sourceKind: WorktreeIncludeSourceKind; +} + +export interface WorktreeIncludePlan { + materializations: WorktreeIncludeMaterialization[]; + sourceRoot: string; +} + +export interface ReadWorktreeIncludePlanOptions { + excludedSourceRoots?: string[]; + sourceRoot: string; +} + +export interface MaterializeWorktreeIncludePlanOptions { + plan: WorktreeIncludePlan; + worktreeRoot: string; +} + +interface ResolvedWorktreeIncludeMaterialization { + materialization: WorktreeIncludeMaterialization; + sourcePath: string; +} + +export class WorktreeIncludeError extends Error { + constructor( + public readonly code: WorktreeIncludeErrorCode, + message: string, + ) { + super(message); + this.name = "WorktreeIncludeError"; + } +} + +export async function readWorktreeIncludePlan( + options: ReadWorktreeIncludePlanOptions, +): Promise { + const sourceRoot = await realpath(options.sourceRoot); + const entries = await readWorktreeIncludeEntries(sourceRoot); + if (entries.length === 0) { + return { sourceRoot, materializations: [] }; + } + + const excludedSourceRoots = (options.excludedSourceRoots ?? []).map((path) => resolve(path)); + const needsCandidates = entries.some( + (entry) => entry.relativePath.includes("*") && getRecursiveDirectoryPath(entry) === null, + ); + const candidates = needsCandidates + ? await collectWorktreeIncludeCandidates({ sourceRoot, excludedSourceRoots }) + : []; + const materializations: WorktreeIncludeMaterialization[] = []; + + for (const entry of entries) { + const matchedPaths = resolveEntryMatches({ entry, candidates }); + if (matchedPaths.length === 0) { + throw noMatchError(entry); + } + + let matched = false; + for (const relativePath of matchedPaths) { + const resolved = await resolveSourceMaterialization({ + sourceRoot, + entry, + relativePath, + }); + materializations.push(resolved.materialization); + matched = true; + } + + if (!matched) { + throw noMatchError(entry); + } + } + + return { + sourceRoot, + materializations: normalizeMaterializations(materializations), + }; +} + +export async function materializeWorktreeIncludePlan( + options: MaterializeWorktreeIncludePlanOptions, +): Promise { + if (options.plan.materializations.length === 0) { + return; + } + + const worktreeRoot = await realpath(options.worktreeRoot); + const resolvedMaterializations: ResolvedWorktreeIncludeMaterialization[] = []; + for (const materialization of options.plan.materializations) { + const resolved = await resolveSourceMaterialization({ + sourceRoot: options.plan.sourceRoot, + entry: { + lineNumber: materialization.lineNumber, + mode: materialization.mode, + raw: materialization.relativePath, + relativePath: materialization.relativePath, + }, + relativePath: materialization.relativePath, + }); + if (resolved.materialization.sourceKind !== materialization.sourceKind) { + throw new WorktreeIncludeError( + "source_changed", + `Source for .worktreeinclude entry '${materialization.relativePath}' changed type before it could be materialized`, + ); + } + resolvedMaterializations.push(resolved); + } + + for (const resolved of resolvedMaterializations) { + await preflightDestination({ + worktreeRoot, + resolved, + }); + } + + for (const resolved of resolvedMaterializations) { + const destinationPath = getDestinationPath({ + worktreeRoot, + relativePath: resolved.materialization.relativePath, + }); + await ensureDestinationParent({ + worktreeRoot, + relativePath: resolved.materialization.relativePath, + }); + + if (resolved.materialization.mode === "copy") { + await copyMaterialization({ sourcePath: resolved.sourcePath, destinationPath, resolved }); + continue; + } + + await createMaterializationSymlink({ + sourcePath: resolved.sourcePath, + destinationPath, + sourceKind: resolved.materialization.sourceKind, + entry: resolved.materialization, + }); + } +} + +async function readWorktreeIncludeEntries(sourceRoot: string): Promise { + let contents: string; + try { + contents = await readFile(join(sourceRoot, WORKTREE_INCLUDE_FILE_NAME), "utf8"); + } catch (error) { + if (getErrorCode(error) === "ENOENT") { + return []; + } + throw error; + } + + const entries: WorktreeIncludeEntry[] = []; + let pendingDirective: { lineNumber: number; mode: WorktreeIncludeMode } | null = null; + + for (const [index, sourceLine] of contents.split(/\r?\n/).entries()) { + const lineNumber = index + 1; + const line = sourceLine.trim(); + if (line.length === 0) { + continue; + } + + const directiveMode = getDirectiveMode(line); + if (directiveMode !== null) { + if (pendingDirective !== null) { + throw new WorktreeIncludeError( + "invalid_entry", + `Multiple .worktreeinclude directives apply before line ${lineNumber}`, + ); + } + pendingDirective = { lineNumber, mode: directiveMode }; + continue; + } + + if (line.startsWith("#")) { + continue; + } + + entries.push({ + lineNumber, + mode: pendingDirective?.mode ?? "copy", + raw: line, + relativePath: normalizeRelativePath({ entry: line, lineNumber }), + }); + pendingDirective = null; + } + + if (pendingDirective !== null) { + throw new WorktreeIncludeError( + "invalid_entry", + `.worktreeinclude directive on line ${pendingDirective.lineNumber} has no path entry`, + ); + } + + return entries; +} + +function getDirectiveMode(line: string): WorktreeIncludeMode | null { + if (line === COPY_DIRECTIVE) { + return "copy"; + } + if (line === SYMLINK_DIRECTIVE) { + return "symlink"; + } + return null; +} + +function normalizeRelativePath(options: { entry: string; lineNumber: number }): string { + const fail = (reason: string): never => { + throw new WorktreeIncludeError( + "invalid_entry", + `Invalid .worktreeinclude entry '${options.entry}' on line ${options.lineNumber}: ${reason}`, + ); + }; + + if ( + options.entry.includes("\0") || + options.entry.startsWith("/") || + options.entry.startsWith("\\") || + isAbsolute(options.entry) || + win32.isAbsolute(options.entry) || + /^[A-Za-z]:/.test(options.entry) + ) { + fail("absolute paths are not allowed"); + } + + const segments = options.entry + .split(/[\\/]+/) + .filter((segment) => segment.length > 0 && segment !== "."); + if (segments.length === 0) { + fail("path must not be empty"); + } + + for (const segment of segments) { + if (segment === "..") { + fail("parent-directory segments are not allowed"); + } + if (segment.toLowerCase() === ".git") { + fail("git metadata cannot be materialized"); + } + if (segment.includes(":") || /[. ]$/.test(segment) || isWindowsReservedSegment(segment)) { + fail("path is not portable to Windows"); + } + } + + return segments.join("/"); +} + +function isWindowsReservedSegment(segment: string): boolean { + const basename = segment.split(".", 1)[0]?.toLowerCase() ?? ""; + return /^(con|prn|aux|nul|com[1-9]|lpt[1-9])$/.test(basename); +} + +function getRecursiveDirectoryPath(entry: WorktreeIncludeEntry): string | null { + const segments = entry.relativePath.split("/"); + if ( + segments.length < 2 || + segments.at(-1) !== "**" || + segments.slice(0, -1).some((segment) => segment.includes("*")) + ) { + return null; + } + return segments.slice(0, -1).join("/"); +} + +async function collectWorktreeIncludeCandidates(options: { + excludedSourceRoots: string[]; + sourceRoot: string; +}): Promise { + const candidates: string[] = []; + + async function visit(directoryPath: string, directorySegments: string[]): Promise { + const entries = await readdir(directoryPath, { withFileTypes: true }); + for (const entry of entries) { + if (entry.name.toLowerCase() === ".git") { + continue; + } + + const pathSegments = [...directorySegments, entry.name]; + const sourcePath = join(options.sourceRoot, ...pathSegments); + if ( + options.excludedSourceRoots.some((excludedRoot) => + isPathInsideRoot(excludedRoot, sourcePath), + ) + ) { + continue; + } + + candidates.push(pathSegments.join("/")); + const stats = await lstat(sourcePath); + if (stats.isDirectory() && !stats.isSymbolicLink()) { + await visit(sourcePath, pathSegments); + } + } + } + + await visit(options.sourceRoot, []); + return candidates.sort(); +} + +function resolveEntryMatches(options: { + candidates: string[]; + entry: WorktreeIncludeEntry; +}): string[] { + if (!options.entry.relativePath.includes("*")) { + return [options.entry.relativePath]; + } + + const recursiveDirectoryPath = getRecursiveDirectoryPath(options.entry); + if (recursiveDirectoryPath !== null) { + return [recursiveDirectoryPath]; + } + + return options.candidates.filter((candidate) => + worktreeIncludeGlobMatches(options.entry.relativePath, candidate), + ); +} + +function worktreeIncludeGlobMatches(pattern: string, candidate: string): boolean { + const patternSegments = pattern.split("/"); + const candidateSegments = candidate.split("/"); + const cache = new Map(); + + function match(patternIndex: number, candidateIndex: number): boolean { + const cacheKey = `${patternIndex}:${candidateIndex}`; + const cached = cache.get(cacheKey); + if (cached !== undefined) { + return cached; + } + + const patternSegment = patternSegments[patternIndex]; + let result: boolean; + if (patternSegment === undefined) { + result = candidateIndex === candidateSegments.length; + } else if (patternSegment === "**") { + result = + match(patternIndex + 1, candidateIndex) || + (candidateIndex < candidateSegments.length && match(patternIndex, candidateIndex + 1)); + } else { + const candidateSegment = candidateSegments[candidateIndex]; + result = + candidateSegment !== undefined && + segmentGlobMatches(patternSegment, candidateSegment) && + match(patternIndex + 1, candidateIndex + 1); + } + + cache.set(cacheKey, result); + return result; + } + + return match(0, 0); +} + +function segmentGlobMatches(pattern: string, value: string): boolean { + const escaped = pattern.replace(/[|\\{}()[\]^$+?.]/g, "\\$&").replace(/\*/g, "[^/]*"); + return new RegExp(`^${escaped}$`).test(value); +} + +async function resolveSourceMaterialization(options: { + entry: WorktreeIncludeEntry; + relativePath: string; + sourceRoot: string; +}): Promise { + const sourcePath = getSourcePath({ + sourceRoot: options.sourceRoot, + relativePath: options.relativePath, + }); + const sourceKind = await getSourceKind({ + sourceRoot: options.sourceRoot, + sourcePath, + entry: options.entry, + }); + if (options.entry.mode === "copy" && sourceKind === "directory") { + await assertCopyTreeSafe({ + sourcePath, + entry: options.entry, + }); + } + + return { + materialization: { + lineNumber: options.entry.lineNumber, + mode: options.entry.mode, + relativePath: options.relativePath, + sourceKind, + }, + sourcePath, + }; +} + +function getSourcePath(options: { relativePath: string; sourceRoot: string }): string { + return join(options.sourceRoot, ...options.relativePath.split("/")); +} + +async function getSourceKind(options: { + entry: WorktreeIncludeEntry; + sourcePath: string; + sourceRoot: string; +}): Promise { + const segments = relative(options.sourceRoot, options.sourcePath).split(sep).filter(Boolean); + let currentPath = options.sourceRoot; + for (const segment of segments) { + currentPath = join(currentPath, segment); + const currentStats = await lstatSourcePath(currentPath, options.entry); + if (currentStats.isSymbolicLink()) { + throw new WorktreeIncludeError( + "unsupported_source", + `.worktreeinclude entry '${options.entry.raw}' on line ${options.entry.lineNumber} resolves through a symbolic link`, + ); + } + } + + const stats = await lstatSourcePath(options.sourcePath, options.entry); + if (stats.isSymbolicLink() || (!stats.isFile() && !stats.isDirectory())) { + throw new WorktreeIncludeError( + "unsupported_source", + `.worktreeinclude entry '${options.entry.raw}' on line ${options.entry.lineNumber} must match a regular file or directory`, + ); + } + + const canonicalSourcePath = await realpath(options.sourcePath); + if (!isPathInsideRoot(options.sourceRoot, canonicalSourcePath)) { + throw new WorktreeIncludeError( + "unsupported_source", + `.worktreeinclude entry '${options.entry.raw}' on line ${options.entry.lineNumber} resolves outside the source checkout`, + ); + } + + return stats.isDirectory() ? "directory" : "file"; +} + +async function lstatSourcePath(sourcePath: string, entry: WorktreeIncludeEntry): Promise { + try { + return await lstat(sourcePath); + } catch (error) { + if (getErrorCode(error) === "ENOENT" || getErrorCode(error) === "ENOTDIR") { + throw noMatchError(entry); + } + throw error; + } +} + +async function assertCopyTreeSafe(options: { + entry: WorktreeIncludeEntry; + sourcePath: string; +}): Promise { + const stats = await lstatSourcePath(options.sourcePath, options.entry); + if (stats.isSymbolicLink()) { + throw new WorktreeIncludeError( + "unsupported_source", + `.worktreeinclude entry '${options.entry.raw}' on line ${options.entry.lineNumber} contains a symbolic link`, + ); + } + if (stats.isFile()) { + return; + } + if (!stats.isDirectory()) { + throw new WorktreeIncludeError( + "unsupported_source", + `.worktreeinclude entry '${options.entry.raw}' on line ${options.entry.lineNumber} contains an unsupported file type`, + ); + } + + for (const name of await readdir(options.sourcePath)) { + if (name.toLowerCase() === ".git") { + throw new WorktreeIncludeError( + "unsupported_source", + `.worktreeinclude entry '${options.entry.raw}' on line ${options.entry.lineNumber} contains git metadata`, + ); + } + await assertCopyTreeSafe({ + sourcePath: join(options.sourcePath, name), + entry: options.entry, + }); + } +} + +function normalizeMaterializations( + materializations: WorktreeIncludeMaterialization[], +): WorktreeIncludeMaterialization[] { + const byPath = new Map(); + for (const materialization of materializations) { + const existing = byPath.get(materialization.relativePath); + if (existing === undefined) { + byPath.set(materialization.relativePath, materialization); + continue; + } + if (existing.mode !== materialization.mode) { + throw new WorktreeIncludeError( + "conflict", + `.worktreeinclude entries for '${materialization.relativePath}' use both copy and symlink modes`, + ); + } + } + + const sorted = [...byPath.values()].sort((left, right) => { + const depthDifference = + left.relativePath.split("/").length - right.relativePath.split("/").length; + return depthDifference === 0 + ? left.relativePath.localeCompare(right.relativePath) + : depthDifference; + }); + const normalized: WorktreeIncludeMaterialization[] = []; + + for (const materialization of sorted) { + const ancestor = normalized.find((candidate) => + isRelativePathAncestor(candidate.relativePath, materialization.relativePath), + ); + if (ancestor === undefined) { + normalized.push(materialization); + continue; + } + if (ancestor.mode === "copy" && materialization.mode === "copy") { + continue; + } + throw new WorktreeIncludeError( + "conflict", + `.worktreeinclude entries for '${ancestor.relativePath}' and '${materialization.relativePath}' overlap with a symlink`, + ); + } + + return normalized; +} + +function isRelativePathAncestor(ancestor: string, candidate: string): boolean { + return ancestor !== candidate && candidate.startsWith(`${ancestor}/`); +} + +async function preflightDestination(options: { + resolved: ResolvedWorktreeIncludeMaterialization; + worktreeRoot: string; +}): Promise { + const { materialization } = options.resolved; + await inspectDestinationParent({ + worktreeRoot: options.worktreeRoot, + relativePath: materialization.relativePath, + createMissing: false, + }); + + const destinationPath = getDestinationPath({ + worktreeRoot: options.worktreeRoot, + relativePath: materialization.relativePath, + }); + const destinationStats = await lstatIfExists(destinationPath); + if (destinationStats === null) { + return; + } + + if (destinationStats.isSymbolicLink()) { + if ( + materialization.mode === "symlink" && + (await isExpectedSymlink({ + destinationPath, + sourcePath: options.resolved.sourcePath, + })) + ) { + return; + } + throw destinationConflict(materialization); + } + + if (materialization.mode === "symlink") { + throw destinationConflict(materialization); + } + + if ( + (materialization.sourceKind === "file" && !destinationStats.isFile()) || + (materialization.sourceKind === "directory" && !destinationStats.isDirectory()) + ) { + throw destinationConflict(materialization); + } + + if (materialization.sourceKind === "directory") { + await assertCopyDestinationTreeSafe({ + sourcePath: options.resolved.sourcePath, + destinationPath, + materialization, + }); + } +} + +function getDestinationPath(options: { relativePath: string; worktreeRoot: string }): string { + const destinationPath = join(options.worktreeRoot, ...options.relativePath.split("/")); + if (!isPathInsideRoot(options.worktreeRoot, destinationPath)) { + throw new WorktreeIncludeError( + "invalid_entry", + `.worktreeinclude entry '${options.relativePath}' resolves outside the worktree`, + ); + } + return destinationPath; +} + +async function inspectDestinationParent(options: { + createMissing: boolean; + relativePath: string; + worktreeRoot: string; +}): Promise { + const parentSegments = options.relativePath.split("/").slice(0, -1); + let currentPath = options.worktreeRoot; + for (const segment of parentSegments) { + currentPath = join(currentPath, segment); + const stats = await lstatIfExists(currentPath); + if (stats === null) { + if (!options.createMissing) { + return; + } + await mkdir(currentPath); + continue; + } + if (stats.isSymbolicLink() || !stats.isDirectory()) { + throw new WorktreeIncludeError( + "conflict", + `Refusing to materialize .worktreeinclude entry '${options.relativePath}' through '${currentPath}'`, + ); + } + } +} + +async function ensureDestinationParent(options: { + relativePath: string; + worktreeRoot: string; +}): Promise { + await inspectDestinationParent({ ...options, createMissing: true }); +} + +async function assertCopyDestinationTreeSafe(options: { + destinationPath: string; + materialization: WorktreeIncludeMaterialization; + sourcePath: string; +}): Promise { + for (const name of await readdir(options.sourcePath)) { + const sourceChildPath = join(options.sourcePath, name); + const sourceStats = await lstat(sourceChildPath); + if (sourceStats.isSymbolicLink()) { + throw new WorktreeIncludeError( + "unsupported_source", + `.worktreeinclude entry '${options.materialization.relativePath}' contains a symbolic link`, + ); + } + + const destinationChildPath = join(options.destinationPath, name); + const destinationStats = await lstatIfExists(destinationChildPath); + if (destinationStats === null) { + continue; + } + if (destinationStats.isSymbolicLink()) { + throw destinationConflict(options.materialization); + } + if ( + (sourceStats.isFile() && !destinationStats.isFile()) || + (sourceStats.isDirectory() && !destinationStats.isDirectory()) + ) { + throw destinationConflict(options.materialization); + } + if (sourceStats.isDirectory()) { + await assertCopyDestinationTreeSafe({ + sourcePath: sourceChildPath, + destinationPath: destinationChildPath, + materialization: options.materialization, + }); + } + } +} + +async function copyMaterialization(options: { + destinationPath: string; + resolved: ResolvedWorktreeIncludeMaterialization; + sourcePath: string; +}): Promise { + if (options.resolved.materialization.sourceKind === "file") { + await copyFile(options.sourcePath, options.destinationPath); + return; + } + await cp(options.sourcePath, options.destinationPath, { + recursive: true, + force: true, + dereference: false, + }); +} + +async function createMaterializationSymlink(options: { + destinationPath: string; + entry: WorktreeIncludeMaterialization; + sourceKind: WorktreeIncludeSourceKind; + sourcePath: string; +}): Promise { + const existing = await lstatIfExists(options.destinationPath); + if (existing !== null) { + if ( + existing.isSymbolicLink() && + (await isExpectedSymlink({ + destinationPath: options.destinationPath, + sourcePath: options.sourcePath, + })) + ) { + return; + } + throw destinationConflict(options.entry); + } + + if (process.platform !== "win32") { + const target = relative(dirname(options.destinationPath), options.sourcePath); + await symlink(target, options.destinationPath); + return; + } + + if (options.sourceKind === "file") { + await createWindowsSymlink({ + target: options.sourcePath, + destinationPath: options.destinationPath, + type: "file", + entry: options.entry, + }); + return; + } + + try { + await symlink(options.sourcePath, options.destinationPath, "dir"); + } catch (error) { + if (isWindowsNetworkPath(options.sourcePath) || !isWindowsSymlinkPrivilegeError(error)) { + throw toWindowsSymlinkError({ error, entry: options.entry }); + } + try { + await symlink(options.sourcePath, options.destinationPath, "junction"); + } catch (fallbackError) { + throw toWindowsSymlinkError({ error: fallbackError, entry: options.entry }); + } + } +} + +async function createWindowsSymlink(options: { + destinationPath: string; + entry: WorktreeIncludeMaterialization; + target: string; + type: "dir" | "file" | "junction"; +}): Promise { + try { + await symlink(options.target, options.destinationPath, options.type); + } catch (error) { + throw toWindowsSymlinkError({ error, entry: options.entry }); + } +} + +function isWindowsSymlinkPrivilegeError(error: unknown): boolean { + const code = getErrorCode(error); + return code === "EACCES" || code === "EPERM" || code === "ENOTSUP"; +} + +function isWindowsNetworkPath(path: string): boolean { + return path.startsWith("\\\\"); +} + +function toWindowsSymlinkError(options: { + entry: WorktreeIncludeMaterialization; + error: unknown; +}): Error { + if (!isWindowsSymlinkPrivilegeError(options.error)) { + return options.error instanceof Error ? options.error : new Error(String(options.error)); + } + return new WorktreeIncludeError( + "windows_symlink_unavailable", + `Unable to create a Windows symlink for .worktreeinclude entry '${options.entry.relativePath}'. Enable Developer Mode or use # @copy.`, + ); +} + +async function isExpectedSymlink(options: { + destinationPath: string; + sourcePath: string; +}): Promise { + try { + const [destinationTarget, sourceTarget] = await Promise.all([ + realpath(options.destinationPath), + realpath(options.sourcePath), + ]); + return ( + normalizePathForComparison(destinationTarget) === normalizePathForComparison(sourceTarget) + ); + } catch { + return false; + } +} + +function normalizePathForComparison(path: string): string { + return process.platform === "win32" ? path.toLowerCase() : path; +} + +async function lstatIfExists(path: string): Promise { + try { + return await lstat(path); + } catch (error) { + if (getErrorCode(error) === "ENOENT") { + return null; + } + throw error; + } +} + +function destinationConflict( + materialization: WorktreeIncludeMaterialization, +): WorktreeIncludeError { + return new WorktreeIncludeError( + "conflict", + `.worktreeinclude entry '${materialization.relativePath}' on line ${materialization.lineNumber} conflicts with the new worktree`, + ); +} + +function noMatchError(entry: WorktreeIncludeEntry): WorktreeIncludeError { + return new WorktreeIncludeError( + "missing_source", + `No paths matched .worktreeinclude entry '${entry.raw}' on line ${entry.lineNumber}`, + ); +} + +function isPathInsideRoot(root: string, path: string): boolean { + const pathRelativeToRoot = relative(root, path); + return ( + pathRelativeToRoot === "" || + (!pathRelativeToRoot.startsWith(`..${sep}`) && + pathRelativeToRoot !== ".." && + !isAbsolute(pathRelativeToRoot)) + ); +} + +function getErrorCode(error: unknown): string | null { + if (typeof error !== "object" || error === null || !("code" in error)) { + return null; + } + const { code } = error; + return typeof code === "string" ? code : null; +} diff --git a/packages/server/src/utils/worktree.posix.test.ts b/packages/server/src/utils/worktree.posix.test.ts index d895df5395..9f002fcb2c 100644 --- a/packages/server/src/utils/worktree.posix.test.ts +++ b/packages/server/src/utils/worktree.posix.test.ts @@ -34,6 +34,7 @@ import { writeFileSync, readFileSync, chmodSync, + lstatSync, } from "fs"; import { delimiter, dirname, join } from "path"; import { tmpdir } from "os"; @@ -1062,6 +1063,152 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { }); }); + it("materializes copies and symlinks before setup, then removes only the new worktree links", async () => { + writeFileSync( + join(repoDir, ".gitignore"), + [".copy.env", "copy-cache/", "linked-file.txt", "linked-state", "setup.log", ""].join("\n"), + ); + writeFileSync( + join(repoDir, "paseo.json"), + JSON.stringify({ + worktree: { + setup: [ + "test -f .copy.env", + "test -L linked-file.txt", + "test -L linked-state", + "cat linked-state/state.txt > setup.log", + ], + }, + }), + ); + execFileSync("git", ["add", ".gitignore", "paseo.json"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add include fixture"], { + cwd: repoDir, + }); + + writeFileSync( + join(repoDir, ".worktreeinclude"), + [ + ".copy.env", + "copy-cache/**", + "# @symlink", + "linked-file.txt", + "# @symlink", + "linked-state", + "", + ].join("\n"), + ); + writeFileSync(join(repoDir, ".copy.env"), "copy-v1\n"); + mkdirSync(join(repoDir, "copy-cache"), { recursive: true }); + writeFileSync(join(repoDir, "copy-cache", "state.txt"), "copy-cache-v1\n"); + writeFileSync(join(repoDir, "linked-file.txt"), "linked-file-v1\n"); + mkdirSync(join(repoDir, "linked-state"), { recursive: true }); + writeFileSync(join(repoDir, "linked-state", "state.txt"), "linked-state-v1\n"); + + const result = await createLegacyWorktreeForTest({ + cwd: repoDir, + worktreeSlug: "include-links", + source: { kind: "branch-off", baseBranch: "main", branchName: "feature/include-links" }, + runSetup: true, + paseoHome, + }); + + expect(readFileSync(join(result.worktreePath, "setup.log"), "utf8")).toBe( + "linked-state-v1\n", + ); + expect(lstatSync(join(result.worktreePath, ".copy.env")).isSymbolicLink()).toBe(false); + expect(lstatSync(join(result.worktreePath, "copy-cache")).isSymbolicLink()).toBe(false); + expect(lstatSync(join(result.worktreePath, "linked-file.txt")).isSymbolicLink()).toBe(true); + expect(lstatSync(join(result.worktreePath, "linked-state")).isSymbolicLink()).toBe(true); + expect( + execFileSync("git", ["status", "--porcelain"], { + cwd: result.worktreePath, + encoding: "utf8", + }), + ).toBe(""); + + writeFileSync(join(repoDir, ".copy.env"), "copy-v2\n"); + writeFileSync(join(repoDir, "copy-cache", "state.txt"), "copy-cache-v2\n"); + writeFileSync(join(repoDir, "linked-file.txt"), "linked-file-v2\n"); + writeFileSync(join(repoDir, "linked-state", "state.txt"), "linked-state-v2\n"); + + expect(readFileSync(join(result.worktreePath, ".copy.env"), "utf8")).toBe("copy-v1\n"); + expect(readFileSync(join(result.worktreePath, "copy-cache", "state.txt"), "utf8")).toBe( + "copy-cache-v1\n", + ); + expect(readFileSync(join(result.worktreePath, "linked-file.txt"), "utf8")).toBe( + "linked-file-v2\n", + ); + expect(readFileSync(join(result.worktreePath, "linked-state", "state.txt"), "utf8")).toBe( + "linked-state-v2\n", + ); + + await deletePaseoWorktree({ + cwd: repoDir, + worktreePath: result.worktreePath, + paseoHome, + }); + + expect(existsSync(result.worktreePath)).toBe(false); + expect(readFileSync(join(repoDir, "linked-file.txt"), "utf8")).toBe("linked-file-v2\n"); + expect(readFileSync(join(repoDir, "linked-state", "state.txt"), "utf8")).toBe( + "linked-state-v2\n", + ); + }); + + it("rejects a missing include before creating a worktree", async () => { + const projectHash = await deriveWorktreeProjectHash(repoDir); + const expectedWorktreePath = join(paseoHome, "worktrees", projectHash, "missing-include"); + writeFileSync(join(repoDir, ".worktreeinclude"), ".missing-cache/**\n"); + + await expect( + createLegacyWorktreeForTest({ + cwd: repoDir, + worktreeSlug: "missing-include", + source: { kind: "branch-off", baseBranch: "main", branchName: "feature/missing-include" }, + runSetup: false, + paseoHome, + }), + ).rejects.toThrow("No paths matched .worktreeinclude entry '.missing-cache/**'"); + + expect(existsSync(expectedWorktreePath)).toBe(false); + expect( + execFileSync("git", ["worktree", "list", "--porcelain"], { + cwd: repoDir, + encoding: "utf8", + }), + ).not.toContain(expectedWorktreePath); + }); + + it("rolls back a created worktree when a symlink include conflicts", async () => { + const projectHash = await deriveWorktreeProjectHash(repoDir); + const expectedWorktreePath = join(paseoHome, "worktrees", projectHash, "include-conflict"); + writeFileSync(join(repoDir, "paseo.json"), JSON.stringify({ scripts: {} })); + writeFileSync(join(repoDir, ".worktreeinclude"), "# @symlink\npaseo.json\n"); + + await expect( + createLegacyWorktreeForTest({ + cwd: repoDir, + worktreeSlug: "include-conflict", + source: { + kind: "branch-off", + baseBranch: "main", + branchName: "feature/include-conflict", + }, + runSetup: false, + paseoHome, + }), + ).rejects.toThrow("conflicts with the new worktree"); + + expect(existsSync(expectedWorktreePath)).toBe(false); + expect( + execFileSync("git", ["worktree", "list", "--porcelain"], { + cwd: repoDir, + encoding: "utf8", + }), + ).not.toContain(expectedWorktreePath); + }); + it("creates a worktree without error when no paseo.json exists in the main repo", async () => { const result = await createLegacyWorktreeForTest({ cwd: repoDir, diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index 4ab641806b..211a400742 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -36,6 +36,7 @@ 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, getRealpathAwareRelativePath, isPathInsideRoot } from "./path.js"; +import { materializeWorktreeIncludePlan, readWorktreeIncludePlan } from "./worktree-include.js"; export { slugify, validateBranchSlug } from "@getpaseo/protocol/branch-slug"; @@ -1235,7 +1236,12 @@ export const createWorktree = async ({ worktreesRoot, }: CreateWorktreeOptions): Promise => { const sourcePlan = await resolveWorktreeSourcePlan({ cwd, source, desiredSlug: worktreeSlug }); - let worktreePath = join(await getPaseoWorktreesRoot(cwd, paseoHome, worktreesRoot), worktreeSlug); + const paseoWorktreesRoot = await getPaseoWorktreesRoot(cwd, paseoHome, worktreesRoot); + const worktreeIncludePlan = await readWorktreeIncludePlan({ + sourceRoot: cwd, + excludedSourceRoots: [paseoWorktreesRoot], + }); + let worktreePath = join(paseoWorktreesRoot, worktreeSlug); mkdirSync(dirname(worktreePath), { recursive: true }); // Also handle worktree path collision @@ -1276,6 +1282,23 @@ export const createWorktree = async ({ }); await seedPaseoConfigFile({ sourceCwd: cwd, targetCwd: worktreePath }); + try { + await materializeWorktreeIncludePlan({ + plan: worktreeIncludePlan, + worktreeRoot: worktreePath, + }); + } catch (error) { + await rollbackCreatedPaseoWorktree( + { + cwd, + worktreePath, + teardownCwds: [], + paseoHome, + worktreesBaseRoot: worktreesRoot, + }, + error, + ); + } if (runSetup) { await runWorktreeSetupCommands({ diff --git a/public-docs/worktrees.md b/public-docs/worktrees.md index bdc8a24f23..860abdfc8a 100644 --- a/public-docs/worktrees.md +++ b/public-docs/worktrees.md @@ -110,6 +110,48 @@ Both fields accept a multiline shell script or an array of commands; commands ru Commands run with the worktree as `cwd`. Use `$PASEO_SOURCE_CHECKOUT_PATH` to reach files in the original checkout (untracked config, local caches, etc). +## .worktreeinclude + +Use a root-level .worktreeinclude to materialize local source-checkout files before +worktree.setup runs. Each path is relative to the source checkout. + + # Copy is the default. + .env.local + .cache/** + + # This directive applies to the next path only. + # @symlink + node_modules + + # @copy is available when switching back explicitly. + # @copy + .tool-state/** + +Blank lines and whole-line comments are ignored. A single star matches within a path segment +and a double star matches recursively; a directory ending in /\*\* materializes that directory as +one recursive entry. Absolute paths, parent-directory paths, and .git paths are rejected. + +Copy entries are independent snapshots: a copied file is replaced on a later materialization, +and a copied directory merges into an existing directory. Symlink entries point directly at +the live source file or directory, so changes through either path affect the same data. Paseo +does not replace an existing file, directory, or different link with a symlink. + +Entries must resolve to regular files or directories. Copy entries reject source symbolic links +and nested symbolic links so Paseo never writes through an unexpected path. A symlinked +directory intentionally exposes its live source contents. + +Prefer paths ignored by the target branch. For a symlinked directory, use an ignore rule without +a trailing slash (for example, node_modules, not node_modules/), because Git treats the link +itself as a file. Unignored materialized paths appear in git status. + +On Windows, file links require Windows symbolic-link support. Paseo uses a directory symlink +when available and can use a local directory junction when the symbolic-link privilege is +unavailable. It never silently copies an explicit # @symlink entry; enable Developer Mode or +switch that entry to # @copy if link creation fails. + +Archiving removes only the worktree's links, not their source targets. If the source path is +later moved or deleted, a symlink becomes broken; Paseo does not repair it automatically. + ## Scripts and services `scripts` are named commands you can run inside a worktree on demand. Mark one as a _service_ and Paseo supervises it as a long-running process, assigns it a port, and routes HTTP traffic to it through the daemon's reverse proxy. From 961b8e07b4b4908f85b144795215f36415b5f6af Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Sat, 25 Jul 2026 11:13:52 +0900 Subject: [PATCH 2/8] refactor(server): simplify worktree include handling --- SECURITY.md | 2 +- docs/development.md | 2 +- ...rkspace-create-worktree-source.e2e.test.ts | 51 +--- .../server/src/utils/worktree-include.test.ts | 26 +- packages/server/src/utils/worktree-include.ts | 282 ++++++------------ .../server/src/utils/worktree.posix.test.ts | 14 +- public-docs/worktrees.md | 24 +- 7 files changed, 111 insertions(+), 290 deletions(-) diff --git a/SECURITY.md b/SECURITY.md index 135e8f0021..deb7bcab28 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -48,7 +48,7 @@ The daemon also supports an optional shared-secret password (set via `auth.passw Connected clients are trusted operators of the daemon user. File previews follow that authority: a preview request may read any regular file the daemon process can read, while keeping path normalization and symlink checks in the daemon file service. Workspace-relative paths remain a UI convenience, not a security boundary. -An explicit # @symlink entry in a repository's .worktreeinclude intentionally gives a +An explicit `symlink ` entry in a repository's .worktreeinclude intentionally gives a Paseo-created worktree live access to that source-checkout file or directory. It is useful for local dependencies and caches, but it weakens the usual worktree isolation: agents and lifecycle scripts can modify the source through the link. Paseo validates entries and refuses traversal or diff --git a/docs/development.md b/docs/development.md index b948167cc7..bbb462dd65 100644 --- a/docs/development.md +++ b/docs/development.md @@ -29,7 +29,7 @@ Root checkout dev is intentionally split across terminals: - **Repo dev scripts** default to `$ROOT/.dev/paseo-home`, where `$ROOT` is the current checkout or worktree root. This keeps all dev state scoped to the checkout instead of the packaged desktop app. - **`npm run cli -- ...`** runs through the same dev-home wrapper as the dev scripts, so the in-repo CLI automatically targets the current checkout's `.dev/paseo-home` and configured dev daemon endpoint. - **Paseo-created worktrees** seed `$PASEO_WORKTREE_PATH/.dev/paseo-home` from `$PASEO_SOURCE_CHECKOUT_PATH/.dev/paseo-home` by copying durable JSON metadata. Runtime files like pid files, sockets, and logs are not copied. -- **Paseo-created worktrees** read `.worktreeinclude` from the live source checkout before creation. Legacy entries copy paths into the new worktree; a `# @symlink` directive makes its next path a source link. Materialization finishes before `worktree.setup` runs. +- **Paseo-created worktrees** read `.worktreeinclude` from the live source checkout before creation. Bare paths and `copy ` copy into the new worktree; `symlink ` creates a source link. Materialization finishes before `worktree.setup` runs. - **This repo's worktree setup** also best-effort seeds `packages/app/ios` and the newest `.dev/ios-build` entry from the source checkout so iOS simulator services can reuse native project and Xcode cache state when it is safe enough to do so. Override knobs: diff --git a/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts b/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts index 69802312a4..2fbdc930cc 100644 --- a/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts +++ b/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts @@ -1,5 +1,5 @@ import { execFileSync } from "node:child_process"; -import { lstatSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { expect, test } from "vitest"; @@ -95,52 +95,3 @@ test("workspace.create keeps a branch-off name separate from its worktree slug", rmSync(tempRoot, { recursive: true, force: true }); } }, 180000); - -test("workspace.create materializes .worktreeinclude copies and symlinks", async () => { - const daemon = await createTestPaseoDaemon(); - const { repoDir, tempRoot } = createGitRepoWithBranch(); - const client = new DaemonClient({ - url: `ws://127.0.0.1:${daemon.port}/ws`, - appVersion: "0.1.82", - }); - - try { - writeFileSync( - path.join(repoDir, ".worktreeinclude"), - ["copy.env", "# @symlink", "linked.txt", ""].join("\n"), - ); - writeFileSync(path.join(repoDir, "copy.env"), "copy-v1\n"); - writeFileSync(path.join(repoDir, "linked.txt"), "linked-v1\n"); - await client.connect(); - - const result = await client.createWorkspace({ - source: { - kind: "worktree", - cwd: repoDir, - action: "branch-off", - branchName: "feature/include-e2e", - worktreeSlug: "include-e2e", - baseBranch: "main", - }, - }); - - expect(result.error).toBeNull(); - const worktreePath = result.workspace?.workspaceDirectory; - if (!worktreePath) { - throw new Error("workspace.create did not return a worktree directory"); - } - - expect(readFileSync(path.join(worktreePath, "copy.env"), "utf8")).toBe("copy-v1\n"); - expect(lstatSync(path.join(worktreePath, "linked.txt")).isSymbolicLink()).toBe(true); - - writeFileSync(path.join(repoDir, "copy.env"), "copy-v2\n"); - writeFileSync(path.join(repoDir, "linked.txt"), "linked-v2\n"); - - expect(readFileSync(path.join(worktreePath, "copy.env"), "utf8")).toBe("copy-v1\n"); - expect(readFileSync(path.join(worktreePath, "linked.txt"), "utf8")).toBe("linked-v2\n"); - } finally { - await client.close().catch(() => undefined); - await daemon.close(); - rmSync(tempRoot, { recursive: true, force: true }); - } -}, 180000); diff --git a/packages/server/src/utils/worktree-include.test.ts b/packages/server/src/utils/worktree-include.test.ts index 8391ce031d..360051e96a 100644 --- a/packages/server/src/utils/worktree-include.test.ts +++ b/packages/server/src/utils/worktree-include.test.ts @@ -28,18 +28,12 @@ describe("worktree include planning", () => { rmSync(tempDir, { recursive: true, force: true }); }); - it("keeps legacy entries as copies and applies a symlink directive to one entry", async () => { + it("copies bare entries and accepts explicit copy and symlink modes", async () => { writeFileSync( join(sourceRoot, ".worktreeinclude"), - [ - ".env.local", - ".cache/**", - "# @symlink", - "shared-state", - "# @copy", - "packages/*/.runtime.env", - "", - ].join("\n"), + [".env.local", ".cache/**", "symlink shared-state", "copy packages/*/.runtime.env", ""].join( + "\n", + ), ); writeFileSync(join(sourceRoot, ".env.local"), "source\n"); mkdirSync(join(sourceRoot, ".cache"), { recursive: true }); @@ -76,13 +70,13 @@ describe("worktree include planning", () => { "parent-directory segments are not allowed", ); - writeFileSync(join(sourceRoot, ".worktreeinclude"), "# @symlink\n"); - await expect(readWorktreeIncludePlan({ sourceRoot })).rejects.toThrow("has no path entry"); + writeFileSync(join(sourceRoot, ".worktreeinclude"), "symlink\n"); + await expect(readWorktreeIncludePlan({ sourceRoot })).rejects.toThrow("requires a path"); writeFileSync(join(sourceRoot, "shared"), "source\n"); writeFileSync( join(sourceRoot, ".worktreeinclude"), - ["shared", "# @symlink", "shared", ""].join("\n"), + ["shared", "symlink shared", ""].join("\n"), ); await expect(readWorktreeIncludePlan({ sourceRoot })).rejects.toThrow( "use both copy and symlink modes", @@ -123,9 +117,7 @@ describe.skipIf(isPlatform("win32"))("worktree include materialization", () => { it("copies snapshots, links shared paths, and is idempotent", async () => { writeFileSync( join(sourceRoot, ".worktreeinclude"), - ["copy.txt", "copy-dir/**", "# @symlink", "linked.txt", "# @symlink", "linked-dir"].join( - "\n", - ), + ["copy.txt", "copy-dir/**", "symlink linked.txt", "symlink linked-dir"].join("\n"), ); writeFileSync(join(sourceRoot, "copy.txt"), "copy-v1\n"); mkdirSync(join(sourceRoot, "copy-dir"), { recursive: true }); @@ -207,7 +199,7 @@ describe.skipIf(!isPlatform("win32"))("worktree include Windows directory links" it("uses a live directory link and removes it without touching the source", async () => { mkdirSync(join(sourceRoot, "shared-state")); writeFileSync(join(sourceRoot, "shared-state", "state.txt"), "source-v1\n"); - writeFileSync(join(sourceRoot, ".worktreeinclude"), "# @symlink\nshared-state\n"); + writeFileSync(join(sourceRoot, ".worktreeinclude"), "symlink shared-state\n"); const plan = await readWorktreeIncludePlan({ sourceRoot }); await materializeWorktreeIncludePlan({ plan, worktreeRoot }); diff --git a/packages/server/src/utils/worktree-include.ts b/packages/server/src/utils/worktree-include.ts index fa6dbe94db..0df373585a 100644 --- a/packages/server/src/utils/worktree-include.ts +++ b/packages/server/src/utils/worktree-include.ts @@ -1,10 +1,9 @@ import { type Stats } from "fs"; import { copyFile, cp, lstat, mkdir, readFile, readdir, realpath, symlink } from "fs/promises"; import { dirname, isAbsolute, join, relative, resolve, sep, win32 } from "path"; +import { areEquivalentPaths, isPathInsideRoot } from "./path.js"; const WORKTREE_INCLUDE_FILE_NAME = ".worktreeinclude"; -const COPY_DIRECTIVE = "# @copy"; -const SYMLINK_DIRECTIVE = "# @symlink"; export type WorktreeIncludeMode = "copy" | "symlink"; type WorktreeIncludeSourceKind = "file" | "directory"; @@ -84,7 +83,6 @@ export async function readWorktreeIncludePlan( throw noMatchError(entry); } - let matched = false; for (const relativePath of matchedPaths) { const resolved = await resolveSourceMaterialization({ sourceRoot, @@ -92,11 +90,6 @@ export async function readWorktreeIncludePlan( relativePath, }); materializations.push(resolved.materialization); - matched = true; - } - - if (!matched) { - throw noMatchError(entry); } } @@ -114,7 +107,6 @@ export async function materializeWorktreeIncludePlan( } const worktreeRoot = await realpath(options.worktreeRoot); - const resolvedMaterializations: ResolvedWorktreeIncludeMaterialization[] = []; for (const materialization of options.plan.materializations) { const resolved = await resolveSourceMaterialization({ sourceRoot: options.plan.sourceRoot, @@ -132,17 +124,6 @@ export async function materializeWorktreeIncludePlan( `Source for .worktreeinclude entry '${materialization.relativePath}' changed type before it could be materialized`, ); } - resolvedMaterializations.push(resolved); - } - - for (const resolved of resolvedMaterializations) { - await preflightDestination({ - worktreeRoot, - resolved, - }); - } - - for (const resolved of resolvedMaterializations) { const destinationPath = getDestinationPath({ worktreeRoot, relativePath: resolved.materialization.relativePath, @@ -151,17 +132,30 @@ export async function materializeWorktreeIncludePlan( worktreeRoot, relativePath: resolved.materialization.relativePath, }); + const needsMaterialization = await preflightDestination({ + worktreeRoot, + resolved, + }); + if (!needsMaterialization) { + continue; + } if (resolved.materialization.mode === "copy") { - await copyMaterialization({ sourcePath: resolved.sourcePath, destinationPath, resolved }); + if (resolved.materialization.sourceKind === "file") { + await copyFile(resolved.sourcePath, destinationPath); + } else { + await cp(resolved.sourcePath, destinationPath, { + recursive: true, + force: true, + dereference: false, + }); + } continue; } await createMaterializationSymlink({ - sourcePath: resolved.sourcePath, destinationPath, - sourceKind: resolved.materialization.sourceKind, - entry: resolved.materialization, + resolved, }); } } @@ -178,7 +172,6 @@ async function readWorktreeIncludeEntries(sourceRoot: string): Promise { - const sourcePath = getSourcePath({ - sourceRoot: options.sourceRoot, - relativePath: options.relativePath, - }); + const sourcePath = join(options.sourceRoot, ...options.relativePath.split("/")); const sourceKind = await getSourceKind({ sourceRoot: options.sourceRoot, sourcePath, entry: options.entry, }); if (options.entry.mode === "copy" && sourceKind === "directory") { - await assertCopyTreeSafe({ + await assertCopyDirectorySafe({ sourcePath, entry: options.entry, }); @@ -415,10 +400,6 @@ async function resolveSourceMaterialization(options: { }; } -function getSourcePath(options: { relativePath: string; sourceRoot: string }): string { - return join(options.sourceRoot, ...options.relativePath.split("/")); -} - async function getSourceKind(options: { entry: WorktreeIncludeEntry; sourcePath: string; @@ -426,7 +407,7 @@ async function getSourceKind(options: { }): Promise { const segments = relative(options.sourceRoot, options.sourcePath).split(sep).filter(Boolean); let currentPath = options.sourceRoot; - for (const segment of segments) { + for (const segment of segments.slice(0, -1)) { currentPath = join(currentPath, segment); const currentStats = await lstatSourcePath(currentPath, options.entry); if (currentStats.isSymbolicLink()) { @@ -438,7 +419,13 @@ async function getSourceKind(options: { } const stats = await lstatSourcePath(options.sourcePath, options.entry); - if (stats.isSymbolicLink() || (!stats.isFile() && !stats.isDirectory())) { + if (stats.isSymbolicLink()) { + throw new WorktreeIncludeError( + "unsupported_source", + `.worktreeinclude entry '${options.entry.raw}' on line ${options.entry.lineNumber} resolves through a symbolic link`, + ); + } + if (!stats.isFile() && !stats.isDirectory()) { throw new WorktreeIncludeError( "unsupported_source", `.worktreeinclude entry '${options.entry.raw}' on line ${options.entry.lineNumber} must match a regular file or directory`, @@ -467,27 +454,10 @@ async function lstatSourcePath(sourcePath: string, entry: WorktreeIncludeEntry): } } -async function assertCopyTreeSafe(options: { +async function assertCopyDirectorySafe(options: { entry: WorktreeIncludeEntry; sourcePath: string; }): Promise { - const stats = await lstatSourcePath(options.sourcePath, options.entry); - if (stats.isSymbolicLink()) { - throw new WorktreeIncludeError( - "unsupported_source", - `.worktreeinclude entry '${options.entry.raw}' on line ${options.entry.lineNumber} contains a symbolic link`, - ); - } - if (stats.isFile()) { - return; - } - if (!stats.isDirectory()) { - throw new WorktreeIncludeError( - "unsupported_source", - `.worktreeinclude entry '${options.entry.raw}' on line ${options.entry.lineNumber} contains an unsupported file type`, - ); - } - for (const name of await readdir(options.sourcePath)) { if (name.toLowerCase() === ".git") { throw new WorktreeIncludeError( @@ -495,10 +465,18 @@ async function assertCopyTreeSafe(options: { `.worktreeinclude entry '${options.entry.raw}' on line ${options.entry.lineNumber} contains git metadata`, ); } - await assertCopyTreeSafe({ - sourcePath: join(options.sourcePath, name), - entry: options.entry, - }); + + const sourcePath = join(options.sourcePath, name); + const stats = await lstatSourcePath(sourcePath, options.entry); + if (stats.isSymbolicLink() || (!stats.isFile() && !stats.isDirectory())) { + throw new WorktreeIncludeError( + "unsupported_source", + `.worktreeinclude entry '${options.entry.raw}' on line ${options.entry.lineNumber} contains an unsupported file type or symbolic link`, + ); + } + if (stats.isDirectory()) { + await assertCopyDirectorySafe({ sourcePath, entry: options.entry }); + } } } @@ -556,21 +534,15 @@ function isRelativePathAncestor(ancestor: string, candidate: string): boolean { async function preflightDestination(options: { resolved: ResolvedWorktreeIncludeMaterialization; worktreeRoot: string; -}): Promise { +}): Promise { const { materialization } = options.resolved; - await inspectDestinationParent({ - worktreeRoot: options.worktreeRoot, - relativePath: materialization.relativePath, - createMissing: false, - }); - const destinationPath = getDestinationPath({ worktreeRoot: options.worktreeRoot, relativePath: materialization.relativePath, }); const destinationStats = await lstatIfExists(destinationPath); if (destinationStats === null) { - return; + return true; } if (destinationStats.isSymbolicLink()) { @@ -581,7 +553,7 @@ async function preflightDestination(options: { sourcePath: options.resolved.sourcePath, })) ) { - return; + return false; } throw destinationConflict(materialization); } @@ -604,6 +576,7 @@ async function preflightDestination(options: { materialization, }); } + return true; } function getDestinationPath(options: { relativePath: string; worktreeRoot: string }): string { @@ -617,8 +590,7 @@ function getDestinationPath(options: { relativePath: string; worktreeRoot: strin return destinationPath; } -async function inspectDestinationParent(options: { - createMissing: boolean; +async function ensureDestinationParent(options: { relativePath: string; worktreeRoot: string; }): Promise { @@ -628,9 +600,6 @@ async function inspectDestinationParent(options: { currentPath = join(currentPath, segment); const stats = await lstatIfExists(currentPath); if (stats === null) { - if (!options.createMissing) { - return; - } await mkdir(currentPath); continue; } @@ -643,13 +612,6 @@ async function inspectDestinationParent(options: { } } -async function ensureDestinationParent(options: { - relativePath: string; - worktreeRoot: string; -}): Promise { - await inspectDestinationParent({ ...options, createMissing: true }); -} - async function assertCopyDestinationTreeSafe(options: { destinationPath: string; materialization: WorktreeIncludeMaterialization; @@ -689,82 +651,24 @@ async function assertCopyDestinationTreeSafe(options: { } } -async function copyMaterialization(options: { - destinationPath: string; - resolved: ResolvedWorktreeIncludeMaterialization; - sourcePath: string; -}): Promise { - if (options.resolved.materialization.sourceKind === "file") { - await copyFile(options.sourcePath, options.destinationPath); - return; - } - await cp(options.sourcePath, options.destinationPath, { - recursive: true, - force: true, - dereference: false, - }); -} - async function createMaterializationSymlink(options: { destinationPath: string; - entry: WorktreeIncludeMaterialization; - sourceKind: WorktreeIncludeSourceKind; - sourcePath: string; + resolved: ResolvedWorktreeIncludeMaterialization; }): Promise { - const existing = await lstatIfExists(options.destinationPath); - if (existing !== null) { - if ( - existing.isSymbolicLink() && - (await isExpectedSymlink({ - destinationPath: options.destinationPath, - sourcePath: options.sourcePath, - })) - ) { - return; - } - throw destinationConflict(options.entry); - } - if (process.platform !== "win32") { - const target = relative(dirname(options.destinationPath), options.sourcePath); + const target = relative(dirname(options.destinationPath), options.resolved.sourcePath); await symlink(target, options.destinationPath); return; } - if (options.sourceKind === "file") { - await createWindowsSymlink({ - target: options.sourcePath, - destinationPath: options.destinationPath, - type: "file", - entry: options.entry, - }); - return; - } - - try { - await symlink(options.sourcePath, options.destinationPath, "dir"); - } catch (error) { - if (isWindowsNetworkPath(options.sourcePath) || !isWindowsSymlinkPrivilegeError(error)) { - throw toWindowsSymlinkError({ error, entry: options.entry }); - } - try { - await symlink(options.sourcePath, options.destinationPath, "junction"); - } catch (fallbackError) { - throw toWindowsSymlinkError({ error: fallbackError, entry: options.entry }); - } + let type: "dir" | "file" | "junction" = "file"; + if (options.resolved.materialization.sourceKind === "directory") { + type = isWindowsNetworkPath(options.resolved.sourcePath) ? "dir" : "junction"; } -} - -async function createWindowsSymlink(options: { - destinationPath: string; - entry: WorktreeIncludeMaterialization; - target: string; - type: "dir" | "file" | "junction"; -}): Promise { try { - await symlink(options.target, options.destinationPath, options.type); + await symlink(options.resolved.sourcePath, options.destinationPath, type); } catch (error) { - throw toWindowsSymlinkError({ error, entry: options.entry }); + throw toWindowsSymlinkError({ error, entry: options.resolved.materialization }); } } @@ -786,7 +690,7 @@ function toWindowsSymlinkError(options: { } return new WorktreeIncludeError( "windows_symlink_unavailable", - `Unable to create a Windows symlink for .worktreeinclude entry '${options.entry.relativePath}'. Enable Developer Mode or use # @copy.`, + `Unable to create a Windows symlink for .worktreeinclude entry '${options.entry.relativePath}'. Enable Developer Mode or use copy ${options.entry.relativePath}.`, ); } @@ -799,18 +703,12 @@ async function isExpectedSymlink(options: { realpath(options.destinationPath), realpath(options.sourcePath), ]); - return ( - normalizePathForComparison(destinationTarget) === normalizePathForComparison(sourceTarget) - ); + return areEquivalentPaths(destinationTarget, sourceTarget); } catch { return false; } } -function normalizePathForComparison(path: string): string { - return process.platform === "win32" ? path.toLowerCase() : path; -} - async function lstatIfExists(path: string): Promise { try { return await lstat(path); @@ -838,16 +736,6 @@ function noMatchError(entry: WorktreeIncludeEntry): WorktreeIncludeError { ); } -function isPathInsideRoot(root: string, path: string): boolean { - const pathRelativeToRoot = relative(root, path); - return ( - pathRelativeToRoot === "" || - (!pathRelativeToRoot.startsWith(`..${sep}`) && - pathRelativeToRoot !== ".." && - !isAbsolute(pathRelativeToRoot)) - ); -} - function getErrorCode(error: unknown): string | null { if (typeof error !== "object" || error === null || !("code" in error)) { return null; diff --git a/packages/server/src/utils/worktree.posix.test.ts b/packages/server/src/utils/worktree.posix.test.ts index 9f002fcb2c..fe1baa1565 100644 --- a/packages/server/src/utils/worktree.posix.test.ts +++ b/packages/server/src/utils/worktree.posix.test.ts @@ -1088,15 +1088,9 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { writeFileSync( join(repoDir, ".worktreeinclude"), - [ - ".copy.env", - "copy-cache/**", - "# @symlink", - "linked-file.txt", - "# @symlink", - "linked-state", - "", - ].join("\n"), + [".copy.env", "copy-cache/**", "symlink linked-file.txt", "symlink linked-state", ""].join( + "\n", + ), ); writeFileSync(join(repoDir, ".copy.env"), "copy-v1\n"); mkdirSync(join(repoDir, "copy-cache"), { recursive: true }); @@ -1184,7 +1178,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { const projectHash = await deriveWorktreeProjectHash(repoDir); const expectedWorktreePath = join(paseoHome, "worktrees", projectHash, "include-conflict"); writeFileSync(join(repoDir, "paseo.json"), JSON.stringify({ scripts: {} })); - writeFileSync(join(repoDir, ".worktreeinclude"), "# @symlink\npaseo.json\n"); + writeFileSync(join(repoDir, ".worktreeinclude"), "symlink paseo.json\n"); await expect( createLegacyWorktreeForTest({ diff --git a/public-docs/worktrees.md b/public-docs/worktrees.md index 860abdfc8a..8f94e1626f 100644 --- a/public-docs/worktrees.md +++ b/public-docs/worktrees.md @@ -119,17 +119,14 @@ worktree.setup runs. Each path is relative to the source checkout. .env.local .cache/** - # This directive applies to the next path only. - # @symlink - node_modules + # Modes can also be explicit. + symlink node_modules + copy .tool-state/** - # @copy is available when switching back explicitly. - # @copy - .tool-state/** - -Blank lines and whole-line comments are ignored. A single star matches within a path segment -and a double star matches recursively; a directory ending in /\*\* materializes that directory as -one recursive entry. Absolute paths, parent-directory paths, and .git paths are rejected. +Each line is `[copy|symlink] `; the mode is optional and defaults to `copy`. Blank lines +and whole-line comments are ignored. A single star matches within a path segment and a double +star matches recursively; a directory ending in /\*\* materializes that directory as one +recursive entry. Absolute paths, parent-directory paths, and .git paths are rejected. Copy entries are independent snapshots: a copied file is replaced on a later materialization, and a copied directory merges into an existing directory. Symlink entries point directly at @@ -144,10 +141,9 @@ Prefer paths ignored by the target branch. For a symlinked directory, use an ign a trailing slash (for example, node_modules, not node_modules/), because Git treats the link itself as a file. Unignored materialized paths appear in git status. -On Windows, file links require Windows symbolic-link support. Paseo uses a directory symlink -when available and can use a local directory junction when the symbolic-link privilege is -unavailable. It never silently copies an explicit # @symlink entry; enable Developer Mode or -switch that entry to # @copy if link creation fails. +On Windows, Paseo uses junctions for local directories. File links and network-directory links +require Windows symbolic-link support. It never silently copies an explicit `symlink ` +entry; enable Developer Mode or switch that entry to `copy ` if link creation fails. Archiving removes only the worktree's links, not their source targets. If the source path is later moved or deleted, a symlink becomes broken; Paseo does not repair it automatically. From 7cc6cdb118f18d64814000362a279d181b618f2b Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Sat, 25 Jul 2026 13:02:12 +0900 Subject: [PATCH 3/8] fix(server): constrain worktree include traversal --- .../server/src/utils/worktree-include.test.ts | 35 ++++++++ packages/server/src/utils/worktree-include.ts | 87 +++++++++++++++++-- .../server/src/utils/worktree.posix.test.ts | 34 ++++++++ 3 files changed, 148 insertions(+), 8 deletions(-) diff --git a/packages/server/src/utils/worktree-include.test.ts b/packages/server/src/utils/worktree-include.test.ts index 360051e96a..a4628cc223 100644 --- a/packages/server/src/utils/worktree-include.test.ts +++ b/packages/server/src/utils/worktree-include.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { existsSync, + chmodSync, lstatSync, mkdirSync, mkdtempSync, @@ -95,6 +96,40 @@ describe("worktree include planning", () => { "No paths matched .worktreeinclude entry '**/.runtime.env'", ); }); + + it("rejects directory copies that overlap protected worktree paths", async () => { + const protectedWorktreeRoot = join(sourceRoot, ".dev", "paseo-home", "worktrees", "project"); + mkdirSync(protectedWorktreeRoot, { recursive: true }); + writeFileSync(join(sourceRoot, ".worktreeinclude"), ".dev/**\n"); + + await expect( + readWorktreeIncludePlan({ + sourceRoot, + excludedSourceRoots: [protectedWorktreeRoot], + }), + ).rejects.toThrow("overlaps with a protected worktree path"); + }); + + it.skipIf(isPlatform("win32"))( + "does not scan unrelated directories for bounded globs", + async () => { + writeFileSync(join(sourceRoot, ".worktreeinclude"), "packages/*/.runtime.env\n"); + mkdirSync(join(sourceRoot, "packages", "api"), { recursive: true }); + writeFileSync(join(sourceRoot, "packages", "api", ".runtime.env"), "api\n"); + const unreadableDirectory = join(sourceRoot, "unrelated"); + mkdirSync(unreadableDirectory); + chmodSync(unreadableDirectory, 0o000); + + try { + const plan = await readWorktreeIncludePlan({ sourceRoot }); + expect(plan.materializations).toEqual([ + expect.objectContaining({ relativePath: "packages/api/.runtime.env" }), + ]); + } finally { + chmodSync(unreadableDirectory, 0o700); + } + }, + ); }); describe.skipIf(isPlatform("win32"))("worktree include materialization", () => { diff --git a/packages/server/src/utils/worktree-include.ts b/packages/server/src/utils/worktree-include.ts index 0df373585a..d35a7d0476 100644 --- a/packages/server/src/utils/worktree-include.ts +++ b/packages/server/src/utils/worktree-include.ts @@ -69,12 +69,19 @@ export async function readWorktreeIncludePlan( } const excludedSourceRoots = (options.excludedSourceRoots ?? []).map((path) => resolve(path)); - const needsCandidates = entries.some( - (entry) => entry.relativePath.includes("*") && getRecursiveDirectoryPath(entry) === null, - ); - const candidates = needsCandidates - ? await collectWorktreeIncludeCandidates({ sourceRoot, excludedSourceRoots }) - : []; + const candidatePatterns = entries + .filter( + (entry) => entry.relativePath.includes("*") && getRecursiveDirectoryPath(entry) === null, + ) + .map((entry) => entry.relativePath); + const candidates = + candidatePatterns.length > 0 + ? await collectWorktreeIncludeCandidates({ + sourceRoot, + excludedSourceRoots, + patterns: candidatePatterns, + }) + : []; const materializations: WorktreeIncludeMaterialization[] = []; for (const entry of entries) { @@ -84,6 +91,11 @@ export async function readWorktreeIncludePlan( } for (const relativePath of matchedPaths) { + assertSourcePathDoesNotOverlapExcludedRoot({ + entry, + excludedSourceRoots, + sourcePath: join(sourceRoot, ...relativePath.split("/")), + }); const resolved = await resolveSourceMaterialization({ sourceRoot, entry, @@ -281,6 +293,7 @@ function getRecursiveDirectoryPath(entry: WorktreeIncludeEntry): string | null { async function collectWorktreeIncludeCandidates(options: { excludedSourceRoots: string[]; + patterns: string[]; sourceRoot: string; }): Promise { const candidates: string[] = []; @@ -302,8 +315,14 @@ async function collectWorktreeIncludeCandidates(options: { continue; } - candidates.push(pathSegments.join("/")); - if (entry.isDirectory()) { + const relativePath = pathSegments.join("/"); + if (options.patterns.some((pattern) => worktreeIncludeGlobMatches(pattern, relativePath))) { + candidates.push(relativePath); + } + if ( + entry.isDirectory() && + options.patterns.some((pattern) => canGlobMatchDescendant(pattern, pathSegments)) + ) { await visit(sourcePath, pathSegments); } } @@ -313,6 +332,38 @@ async function collectWorktreeIncludeCandidates(options: { return candidates.sort(); } +function canGlobMatchDescendant(pattern: string, directorySegments: string[]): boolean { + const patternSegments = pattern.split("/"); + const cache = new Map(); + + function match(patternIndex: number, directoryIndex: number): boolean { + const cacheKey = `${patternIndex}:${directoryIndex}`; + const cached = cache.get(cacheKey); + if (cached !== undefined) { + return cached; + } + + const patternSegment = patternSegments[patternIndex]; + let result: boolean; + if (directoryIndex === directorySegments.length) { + result = patternSegment !== undefined; + } else if (patternSegment === "**") { + result = match(patternIndex + 1, directoryIndex) || match(patternIndex, directoryIndex + 1); + } else { + const directorySegment = directorySegments[directoryIndex]; + result = + patternSegment !== undefined && + segmentGlobMatches(patternSegment, directorySegment) && + match(patternIndex + 1, directoryIndex + 1); + } + + cache.set(cacheKey, result); + return result; + } + + return match(0, 0); +} + function resolveEntryMatches(options: { candidates: string[]; entry: WorktreeIncludeEntry; @@ -400,6 +451,26 @@ async function resolveSourceMaterialization(options: { }; } +function assertSourcePathDoesNotOverlapExcludedRoot(options: { + entry: WorktreeIncludeEntry; + excludedSourceRoots: string[]; + sourcePath: string; +}): void { + const excludedRoot = options.excludedSourceRoots.find( + (candidate) => + isPathInsideRoot(options.sourcePath, candidate) || + isPathInsideRoot(candidate, options.sourcePath), + ); + if (excludedRoot === undefined) { + return; + } + + throw new WorktreeIncludeError( + "invalid_entry", + `.worktreeinclude entry '${options.entry.raw}' on line ${options.entry.lineNumber} overlaps with a protected worktree path`, + ); +} + async function getSourceKind(options: { entry: WorktreeIncludeEntry; sourcePath: string; diff --git a/packages/server/src/utils/worktree.posix.test.ts b/packages/server/src/utils/worktree.posix.test.ts index fe1baa1565..5584757c4f 100644 --- a/packages/server/src/utils/worktree.posix.test.ts +++ b/packages/server/src/utils/worktree.posix.test.ts @@ -1174,6 +1174,40 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { ).not.toContain(expectedWorktreePath); }); + it("rejects a recursive include of checkout-local worktree storage before creation", async () => { + const checkoutLocalPaseoHome = join(repoDir, ".dev", "paseo-home"); + const projectHash = await deriveWorktreeProjectHash(repoDir); + const expectedWorktreePath = join( + checkoutLocalPaseoHome, + "worktrees", + projectHash, + "protected-include", + ); + writeFileSync(join(repoDir, ".worktreeinclude"), ".dev/**\n"); + + await expect( + createLegacyWorktreeForTest({ + cwd: repoDir, + worktreeSlug: "protected-include", + source: { + kind: "branch-off", + baseBranch: "main", + branchName: "feature/protected-include", + }, + runSetup: false, + paseoHome: checkoutLocalPaseoHome, + }), + ).rejects.toThrow("overlaps with a protected worktree path"); + + expect(existsSync(expectedWorktreePath)).toBe(false); + expect( + execFileSync("git", ["worktree", "list", "--porcelain"], { + cwd: repoDir, + encoding: "utf8", + }), + ).not.toContain(expectedWorktreePath); + }); + it("rolls back a created worktree when a symlink include conflicts", async () => { const projectHash = await deriveWorktreeProjectHash(repoDir); const expectedWorktreePath = join(paseoHome, "worktrees", projectHash, "include-conflict"); From 07ef5cee675f66e7dedb859fd6cc31611a87c18a Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Sun, 26 Jul 2026 00:14:01 +0900 Subject: [PATCH 4/8] fix(server): clean up failed worktree branches --- .../server/src/utils/worktree-include.test.ts | 16 ++++++++ packages/server/src/utils/worktree-include.ts | 38 ++++++++++++++++++- .../server/src/utils/worktree.posix.test.ts | 6 +++ packages/server/src/utils/worktree.ts | 14 ++++++- 4 files changed, 71 insertions(+), 3 deletions(-) diff --git a/packages/server/src/utils/worktree-include.test.ts b/packages/server/src/utils/worktree-include.test.ts index a4628cc223..372a894463 100644 --- a/packages/server/src/utils/worktree-include.test.ts +++ b/packages/server/src/utils/worktree-include.test.ts @@ -110,6 +110,22 @@ describe("worktree include planning", () => { ).rejects.toThrow("overlaps with a protected worktree path"); }); + it.skipIf(isPlatform("win32"))( + "rejects protected paths reached through a symlink alias", + async () => { + const sourceAlias = join(tempDir, "source-alias"); + symlinkSync(sourceRoot, sourceAlias, "dir"); + writeFileSync(join(sourceRoot, ".worktreeinclude"), ".dev/**\n"); + + await expect( + readWorktreeIncludePlan({ + sourceRoot, + excludedSourceRoots: [join(sourceAlias, ".dev", "paseo-home", "worktrees", "project")], + }), + ).rejects.toThrow("overlaps with a protected worktree path"); + }, + ); + it.skipIf(isPlatform("win32"))( "does not scan unrelated directories for bounded globs", async () => { diff --git a/packages/server/src/utils/worktree-include.ts b/packages/server/src/utils/worktree-include.ts index d35a7d0476..c5f7895b94 100644 --- a/packages/server/src/utils/worktree-include.ts +++ b/packages/server/src/utils/worktree-include.ts @@ -1,6 +1,15 @@ import { type Stats } from "fs"; import { copyFile, cp, lstat, mkdir, readFile, readdir, realpath, symlink } from "fs/promises"; -import { dirname, isAbsolute, join, relative, resolve, sep, win32 } from "path"; +import { + basename as pathBasename, + dirname, + isAbsolute, + join, + relative, + resolve, + sep, + win32, +} from "path"; import { areEquivalentPaths, isPathInsideRoot } from "./path.js"; const WORKTREE_INCLUDE_FILE_NAME = ".worktreeinclude"; @@ -68,7 +77,9 @@ export async function readWorktreeIncludePlan( return { sourceRoot, materializations: [] }; } - const excludedSourceRoots = (options.excludedSourceRoots ?? []).map((path) => resolve(path)); + const excludedSourceRoots = await Promise.all( + (options.excludedSourceRoots ?? []).map(canonicalizeExistingPathPrefix), + ); const candidatePatterns = entries .filter( (entry) => entry.relativePath.includes("*") && getRecursiveDirectoryPath(entry) === null, @@ -111,6 +122,29 @@ export async function readWorktreeIncludePlan( }; } +async function canonicalizeExistingPathPrefix(path: string): Promise { + const absolutePath = resolve(path); + const missingSegments: string[] = []; + let existingPath = absolutePath; + + while (true) { + try { + return join(await realpath(existingPath), ...missingSegments); + } catch (error) { + if (getErrorCode(error) !== "ENOENT" && getErrorCode(error) !== "ENOTDIR") { + throw error; + } + + const parentPath = dirname(existingPath); + if (parentPath === existingPath) { + return absolutePath; + } + missingSegments.unshift(pathBasename(existingPath)); + existingPath = parentPath; + } + } +} + export async function materializeWorktreeIncludePlan( options: MaterializeWorktreeIncludePlanOptions, ): Promise { diff --git a/packages/server/src/utils/worktree.posix.test.ts b/packages/server/src/utils/worktree.posix.test.ts index 5584757c4f..d047638951 100644 --- a/packages/server/src/utils/worktree.posix.test.ts +++ b/packages/server/src/utils/worktree.posix.test.ts @@ -1206,6 +1206,12 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { encoding: "utf8", }), ).not.toContain(expectedWorktreePath); + expect( + execFileSync("git", ["branch", "--list", "feature/include-conflict"], { + cwd: repoDir, + encoding: "utf8", + }).trim(), + ).toBe(""); }); it("rolls back a created worktree when a symlink include conflicts", async () => { diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index 211a400742..141a353785 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -1162,13 +1162,22 @@ export async function deletePaseoWorktree({ } } +export interface RollbackCreatedPaseoWorktreeOptions extends DeletePaseoWorktreeOptions { + createdBranchName?: string; +} + export async function rollbackCreatedPaseoWorktree( - options: DeletePaseoWorktreeOptions, + options: RollbackCreatedPaseoWorktreeOptions, cause: unknown, ): Promise { let cleanupError: unknown; try { await deletePaseoWorktree(options); + if (options.createdBranchName && options.cwd) { + await runGitCommand(["branch", "--delete", "--force", options.createdBranchName], { + cwd: options.cwd, + }); + } } catch (error) { cleanupError = error; } @@ -1295,6 +1304,7 @@ export const createWorktree = async ({ teardownCwds: [], paseoHome, worktreesBaseRoot: worktreesRoot, + createdBranchName: sourcePlan.createdBranchName, }, error, ); @@ -1322,6 +1332,7 @@ interface ResolveWorktreeSourcePlanOptions { interface WorktreeSourcePlan { branchName: string; + createdBranchName?: string; metadataBaseRefName: string; changeRequestLookupTarget?: PaseoWorktreeChangeRequestLookupTarget; addArguments: string[]; @@ -1355,6 +1366,7 @@ async function resolveWorktreeSourcePlan({ return { branchName: newBranchName, + createdBranchName: newBranchName, metadataBaseRefName: normalizedBaseBranch, addArguments: ["-b", newBranchName, "--no-track", base], }; From 1a86a995a3ddba55289241c45ce4c50c71d356a8 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Sat, 25 Jul 2026 10:49:28 -0500 Subject: [PATCH 5/8] fix(server): skip missing worktree include entries --- docs/development.md | 2 +- .../server/src/utils/worktree-include.test.ts | 39 +++++++++++---- packages/server/src/utils/worktree-include.ts | 50 +++++++++++++------ .../server/src/utils/worktree.posix.test.ts | 31 +++++++----- 4 files changed, 83 insertions(+), 39 deletions(-) diff --git a/docs/development.md b/docs/development.md index bbb462dd65..266b2fe20b 100644 --- a/docs/development.md +++ b/docs/development.md @@ -29,7 +29,7 @@ Root checkout dev is intentionally split across terminals: - **Repo dev scripts** default to `$ROOT/.dev/paseo-home`, where `$ROOT` is the current checkout or worktree root. This keeps all dev state scoped to the checkout instead of the packaged desktop app. - **`npm run cli -- ...`** runs through the same dev-home wrapper as the dev scripts, so the in-repo CLI automatically targets the current checkout's `.dev/paseo-home` and configured dev daemon endpoint. - **Paseo-created worktrees** seed `$PASEO_WORKTREE_PATH/.dev/paseo-home` from `$PASEO_SOURCE_CHECKOUT_PATH/.dev/paseo-home` by copying durable JSON metadata. Runtime files like pid files, sockets, and logs are not copied. -- **Paseo-created worktrees** read `.worktreeinclude` from the live source checkout before creation. Bare paths and `copy ` copy into the new worktree; `symlink ` creates a source link. Materialization finishes before `worktree.setup` runs. +- **Paseo-created worktrees** read `.worktreeinclude` from the live source checkout before creation. Bare paths and `copy ` copy into the new worktree; `symlink ` creates a source link. Missing paths and patterns with no matches are ignored; invalid or unsafe entries still fail creation. Materialization finishes before `worktree.setup` runs. - **This repo's worktree setup** also best-effort seeds `packages/app/ios` and the newest `.dev/ios-build` entry from the source checkout so iOS simulator services can reuse native project and Xcode cache state when it is safe enough to do so. Override knobs: diff --git a/packages/server/src/utils/worktree-include.test.ts b/packages/server/src/utils/worktree-include.test.ts index 372a894463..2007b2ae68 100644 --- a/packages/server/src/utils/worktree-include.test.ts +++ b/packages/server/src/utils/worktree-include.test.ts @@ -84,17 +84,25 @@ describe("worktree include planning", () => { ); }); - it("reports unmatched literals and globs consistently", async () => { - writeFileSync(join(sourceRoot, ".worktreeinclude"), "missing/**\n"); - - await expect(readWorktreeIncludePlan({ sourceRoot })).rejects.toThrow( - "No paths matched .worktreeinclude entry 'missing/**'", + it("skips missing entries while retaining existing literal and glob matches", async () => { + writeFileSync( + join(sourceRoot, ".worktreeinclude"), + [".env", ".env.local", "config/*.env", "missing/**", ""].join("\n"), ); + writeFileSync(join(sourceRoot, ".env"), "source\n"); + mkdirSync(join(sourceRoot, "config"), { recursive: true }); + writeFileSync(join(sourceRoot, "config", "runtime.env"), "runtime\n"); - writeFileSync(join(sourceRoot, ".worktreeinclude"), "**/.runtime.env\n"); - await expect(readWorktreeIncludePlan({ sourceRoot })).rejects.toThrow( - "No paths matched .worktreeinclude entry '**/.runtime.env'", - ); + const plan = await readWorktreeIncludePlan({ sourceRoot }); + + expect(plan.materializations).toEqual([ + expect.objectContaining({ mode: "copy", relativePath: ".env", sourceKind: "file" }), + expect.objectContaining({ + mode: "copy", + relativePath: "config/runtime.env", + sourceKind: "file", + }), + ]); }); it("rejects directory copies that overlap protected worktree paths", async () => { @@ -228,6 +236,19 @@ describe.skipIf(isPlatform("win32"))("worktree include materialization", () => { "resolves through a symbolic link", ); }); + + it("skips a source removed after planning", async () => { + writeFileSync(join(sourceRoot, ".worktreeinclude"), "runtime.env\n"); + const sourcePath = join(sourceRoot, "runtime.env"); + writeFileSync(sourcePath, "source\n"); + + const plan = await readWorktreeIncludePlan({ sourceRoot }); + rmSync(sourcePath); + + await materializeWorktreeIncludePlan({ plan, worktreeRoot }); + + expect(existsSync(join(worktreeRoot, "runtime.env"))).toBe(false); + }); }); describe.skipIf(!isPlatform("win32"))("worktree include Windows directory links", () => { diff --git a/packages/server/src/utils/worktree-include.ts b/packages/server/src/utils/worktree-include.ts index c5f7895b94..23717bc618 100644 --- a/packages/server/src/utils/worktree-include.ts +++ b/packages/server/src/utils/worktree-include.ts @@ -98,7 +98,7 @@ export async function readWorktreeIncludePlan( for (const entry of entries) { const matchedPaths = resolveEntryMatches({ entry, candidates }); if (matchedPaths.length === 0) { - throw noMatchError(entry); + continue; } for (const relativePath of matchedPaths) { @@ -107,12 +107,18 @@ export async function readWorktreeIncludePlan( excludedSourceRoots, sourcePath: join(sourceRoot, ...relativePath.split("/")), }); - const resolved = await resolveSourceMaterialization({ - sourceRoot, - entry, - relativePath, - }); - materializations.push(resolved.materialization); + try { + const resolved = await resolveSourceMaterialization({ + sourceRoot, + entry, + relativePath, + }); + materializations.push(resolved.materialization); + } catch (error) { + if (!isMissingSourceError(error)) { + throw error; + } + } } } @@ -154,16 +160,24 @@ export async function materializeWorktreeIncludePlan( const worktreeRoot = await realpath(options.worktreeRoot); for (const materialization of options.plan.materializations) { - const resolved = await resolveSourceMaterialization({ - sourceRoot: options.plan.sourceRoot, - entry: { - lineNumber: materialization.lineNumber, - mode: materialization.mode, - raw: materialization.relativePath, + let resolved: ResolvedWorktreeIncludeMaterialization; + try { + resolved = await resolveSourceMaterialization({ + sourceRoot: options.plan.sourceRoot, + entry: { + lineNumber: materialization.lineNumber, + mode: materialization.mode, + raw: materialization.relativePath, + relativePath: materialization.relativePath, + }, relativePath: materialization.relativePath, - }, - relativePath: materialization.relativePath, - }); + }); + } catch (error) { + if (isMissingSourceError(error)) { + continue; + } + throw error; + } if (resolved.materialization.sourceKind !== materialization.sourceKind) { throw new WorktreeIncludeError( "source_changed", @@ -841,6 +855,10 @@ function noMatchError(entry: WorktreeIncludeEntry): WorktreeIncludeError { ); } +function isMissingSourceError(error: unknown): error is WorktreeIncludeError { + return error instanceof WorktreeIncludeError && error.code === "missing_source"; +} + function getErrorCode(error: unknown): string | null { if (typeof error !== "object" || error === null || !("code" in error)) { return null; diff --git a/packages/server/src/utils/worktree.posix.test.ts b/packages/server/src/utils/worktree.posix.test.ts index d047638951..ad396a6f82 100644 --- a/packages/server/src/utils/worktree.posix.test.ts +++ b/packages/server/src/utils/worktree.posix.test.ts @@ -1150,28 +1150,33 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { ); }); - it("rejects a missing include before creating a worktree", async () => { + it("skips missing includes and materializes paths that exist", async () => { const projectHash = await deriveWorktreeProjectHash(repoDir); const expectedWorktreePath = join(paseoHome, "worktrees", projectHash, "missing-include"); - writeFileSync(join(repoDir, ".worktreeinclude"), ".missing-cache/**\n"); + writeFileSync( + join(repoDir, ".worktreeinclude"), + [".env", ".env.local", ".sops.yaml", ""].join("\n"), + ); + writeFileSync(join(repoDir, ".env"), "present\n"); - await expect( - createLegacyWorktreeForTest({ - cwd: repoDir, - worktreeSlug: "missing-include", - source: { kind: "branch-off", baseBranch: "main", branchName: "feature/missing-include" }, - runSetup: false, - paseoHome, - }), - ).rejects.toThrow("No paths matched .worktreeinclude entry '.missing-cache/**'"); + const result = await createLegacyWorktreeForTest({ + cwd: repoDir, + worktreeSlug: "missing-include", + source: { kind: "branch-off", baseBranch: "main", branchName: "feature/missing-include" }, + runSetup: false, + paseoHome, + }); - expect(existsSync(expectedWorktreePath)).toBe(false); + expect(result.worktreePath).toBe(expectedWorktreePath); + expect(readFileSync(join(result.worktreePath, ".env"), "utf8")).toBe("present\n"); + expect(existsSync(join(result.worktreePath, ".env.local"))).toBe(false); + expect(existsSync(join(result.worktreePath, ".sops.yaml"))).toBe(false); expect( execFileSync("git", ["worktree", "list", "--porcelain"], { cwd: repoDir, encoding: "utf8", }), - ).not.toContain(expectedWorktreePath); + ).toContain(expectedWorktreePath); }); it("rejects a recursive include of checkout-local worktree storage before creation", async () => { From 45cb4bb57a124a40a9e1476ecaea515f6c5fe602 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Sat, 25 Jul 2026 11:19:08 -0500 Subject: [PATCH 6/8] fix(server): make worktree includes best effort --- docs/development.md | 2 +- .../server/src/server/worktree-session.ts | 11 + .../server/src/utils/worktree-include.test.ts | 223 ++++++++-- packages/server/src/utils/worktree-include.ts | 411 ++++++++++++++---- .../server/src/utils/worktree.posix.test.ts | 42 +- packages/server/src/utils/worktree.ts | 18 +- 6 files changed, 567 insertions(+), 140 deletions(-) diff --git a/docs/development.md b/docs/development.md index 266b2fe20b..0136aae5c5 100644 --- a/docs/development.md +++ b/docs/development.md @@ -29,7 +29,7 @@ Root checkout dev is intentionally split across terminals: - **Repo dev scripts** default to `$ROOT/.dev/paseo-home`, where `$ROOT` is the current checkout or worktree root. This keeps all dev state scoped to the checkout instead of the packaged desktop app. - **`npm run cli -- ...`** runs through the same dev-home wrapper as the dev scripts, so the in-repo CLI automatically targets the current checkout's `.dev/paseo-home` and configured dev daemon endpoint. - **Paseo-created worktrees** seed `$PASEO_WORKTREE_PATH/.dev/paseo-home` from `$PASEO_SOURCE_CHECKOUT_PATH/.dev/paseo-home` by copying durable JSON metadata. Runtime files like pid files, sockets, and logs are not copied. -- **Paseo-created worktrees** read `.worktreeinclude` from the live source checkout before creation. Bare paths and `copy ` copy into the new worktree; `symlink ` creates a source link. Missing paths and patterns with no matches are ignored; invalid or unsafe entries still fail creation. Materialization finishes before `worktree.setup` runs. +- **Paseo-created worktrees** read `.worktreeinclude` from the live source checkout before creation. Bare paths and `copy ` copy a snapshot into the new worktree; `symlink ` creates a live source link. Missing paths, malformed entries, unsafe paths, and incompatible include overlaps are skipped individually and reported in the daemon log, so the rest of the plan still runs. A source symlink is allowed only when its resolved target remains inside the source checkout and outside Paseo-managed worktree storage; `copy` snapshots that resolved target, while `symlink` links directly to it. Hard links are ordinary files. Destination conflicts, permission errors, and other write failures still abort creation and roll back the new worktree. Materialization finishes before `worktree.setup` runs. - **This repo's worktree setup** also best-effort seeds `packages/app/ios` and the newest `.dev/ios-build` entry from the source checkout so iOS simulator services can reuse native project and Xcode cache state when it is safe enough to do so. Override knobs: diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index 5573a945f8..0fdff84b91 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -605,6 +605,17 @@ export async function createPaseoWorktreeWorkflow( const workspace = createdWorktree.workspace; const setupContinuation = options?.setupContinuation ?? { kind: "workspace" }; + if (createdWorktree.created && createdWorktree.worktree.worktreeIncludeSummary?.skipped.length) { + dependencies.sessionLogger.warn( + { + materialized: createdWorktree.worktree.worktreeIncludeSummary.materialized, + skipped: createdWorktree.worktree.worktreeIncludeSummary.skipped, + worktreePath: createdWorktree.worktree.worktreePath, + }, + "Worktree include completed with skipped entries", + ); + } + setTimeout(() => { if (input.firstAgentContext) { dependencies.autoNameWorkspaceBranchForFirstAgent({ diff --git a/packages/server/src/utils/worktree-include.test.ts b/packages/server/src/utils/worktree-include.test.ts index 2007b2ae68..2f4c100a68 100644 --- a/packages/server/src/utils/worktree-include.test.ts +++ b/packages/server/src/utils/worktree-include.test.ts @@ -3,15 +3,18 @@ import { existsSync, chmodSync, lstatSync, + linkSync, mkdirSync, mkdtempSync, readFileSync, + readlinkSync, + realpathSync, rmSync, symlinkSync, writeFileSync, } from "fs"; import { tmpdir } from "os"; -import { join } from "path"; +import { dirname, join, relative } from "path"; import { isPlatform } from "../test-utils/platform.js"; import { materializeWorktreeIncludePlan, readWorktreeIncludePlan } from "./worktree-include.js"; @@ -65,22 +68,26 @@ describe("worktree include planning", () => { ); }); - it("rejects unsafe, dangling, and conflicting entries before a worktree exists", async () => { - writeFileSync(join(sourceRoot, ".worktreeinclude"), "../outside\n"); - await expect(readWorktreeIncludePlan({ sourceRoot })).rejects.toThrow( - "parent-directory segments are not allowed", - ); - - writeFileSync(join(sourceRoot, ".worktreeinclude"), "symlink\n"); - await expect(readWorktreeIncludePlan({ sourceRoot })).rejects.toThrow("requires a path"); - + it("skips invalid and conflicting entries while retaining safe entries", async () => { writeFileSync(join(sourceRoot, "shared"), "source\n"); + writeFileSync(join(sourceRoot, ".env"), "source\n"); writeFileSync( join(sourceRoot, ".worktreeinclude"), - ["shared", "symlink shared", ""].join("\n"), + ["../outside", "symlink", "shared", "symlink shared", ".env", ""].join("\n"), ); - await expect(readWorktreeIncludePlan({ sourceRoot })).rejects.toThrow( - "use both copy and symlink modes", + + const plan = await readWorktreeIncludePlan({ sourceRoot }); + + expect(plan.materializations).toEqual([ + expect.objectContaining({ mode: "copy", relativePath: ".env", sourceKind: "file" }), + ]); + expect(plan.skipped).toEqual( + expect.arrayContaining([ + expect.objectContaining({ lineNumber: 1, raw: "../outside", reason: "invalid" }), + expect.objectContaining({ lineNumber: 2, raw: "symlink", reason: "invalid" }), + expect.objectContaining({ lineNumber: 3, raw: "shared", reason: "conflict" }), + expect.objectContaining({ lineNumber: 4, raw: "symlink shared", reason: "conflict" }), + ]), ); }); @@ -103,34 +110,68 @@ describe("worktree include planning", () => { sourceKind: "file", }), ]); + expect(plan.skipped).toEqual( + expect.arrayContaining([ + expect.objectContaining({ raw: ".env.local", reason: "missing" }), + expect.objectContaining({ raw: "missing/**", reason: "missing" }), + ]), + ); }); - it("rejects directory copies that overlap protected worktree paths", async () => { + it("skips directory copies that overlap protected worktree paths", async () => { const protectedWorktreeRoot = join(sourceRoot, ".dev", "paseo-home", "worktrees", "project"); mkdirSync(protectedWorktreeRoot, { recursive: true }); writeFileSync(join(sourceRoot, ".worktreeinclude"), ".dev/**\n"); - await expect( - readWorktreeIncludePlan({ - sourceRoot, - excludedSourceRoots: [protectedWorktreeRoot], - }), - ).rejects.toThrow("overlaps with a protected worktree path"); + const plan = await readWorktreeIncludePlan({ + sourceRoot, + excludedSourceRoots: [protectedWorktreeRoot], + }); + + expect(plan.materializations).toEqual([]); + expect(plan.skipped).toEqual([expect.objectContaining({ raw: ".dev/**", reason: "unsafe" })]); }); it.skipIf(isPlatform("win32"))( - "rejects protected paths reached through a symlink alias", + "skips protected paths reached through a symlink alias", async () => { const sourceAlias = join(tempDir, "source-alias"); symlinkSync(sourceRoot, sourceAlias, "dir"); + mkdirSync(join(sourceRoot, ".dev", "paseo-home", "worktrees", "project"), { + recursive: true, + }); writeFileSync(join(sourceRoot, ".worktreeinclude"), ".dev/**\n"); - await expect( - readWorktreeIncludePlan({ - sourceRoot, - excludedSourceRoots: [join(sourceAlias, ".dev", "paseo-home", "worktrees", "project")], - }), - ).rejects.toThrow("overlaps with a protected worktree path"); + const plan = await readWorktreeIncludePlan({ + sourceRoot, + excludedSourceRoots: [join(sourceAlias, ".dev", "paseo-home", "worktrees", "project")], + }); + + expect(plan.materializations).toEqual([]); + expect(plan.skipped).toEqual([expect.objectContaining({ raw: ".dev/**", reason: "unsafe" })]); + }, + ); + + it.skipIf(isPlatform("win32"))( + "skips external source links while retaining safe entries", + async () => { + const outsidePath = join(tempDir, "outside.txt"); + writeFileSync(outsidePath, "outside\n"); + writeFileSync(join(sourceRoot, "safe.txt"), "safe\n"); + symlinkSync(outsidePath, join(sourceRoot, "linked.txt")); + writeFileSync( + join(sourceRoot, ".worktreeinclude"), + ["safe.txt", "linked.txt", ""].join("\n"), + ); + + const plan = await readWorktreeIncludePlan({ sourceRoot }); + + expect(plan.materializations).toEqual([ + expect.objectContaining({ relativePath: "safe.txt", sourceKind: "file" }), + ]); + expect(plan.skipped).toEqual([ + expect.objectContaining({ raw: "linked.txt", reason: "unsafe" }), + ]); }, ); @@ -226,14 +267,127 @@ describe.skipIf(isPlatform("win32"))("worktree include materialization", () => { expect(existsSync(join(outsideRoot, "local.json"))).toBe(false); }); - it("rejects a source symlink", async () => { + it("copies resolved source links and creates direct live links", async () => { + const targetPath = join(sourceRoot, "target.txt"); + const targetDirectoryPath = join(sourceRoot, "target-directory"); + writeFileSync(targetPath, "v1\n"); + mkdirSync(targetDirectoryPath); + writeFileSync(join(targetDirectoryPath, "state.txt"), "v1\n"); + symlinkSync(targetPath, join(sourceRoot, "copy-link.txt")); + symlinkSync(targetPath, join(sourceRoot, "live-link.txt")); + symlinkSync(targetDirectoryPath, join(sourceRoot, "copy-directory-link"), "dir"); + symlinkSync(targetDirectoryPath, join(sourceRoot, "live-directory-link"), "dir"); + writeFileSync( + join(sourceRoot, ".worktreeinclude"), + [ + "copy-link.txt", + "symlink live-link.txt", + "copy-directory-link", + "symlink live-directory-link", + "", + ].join("\n"), + ); + + const plan = await readWorktreeIncludePlan({ sourceRoot }); + const result = await materializeWorktreeIncludePlan({ plan, worktreeRoot }); + + const copiedPath = join(worktreeRoot, "copy-link.txt"); + const linkedPath = join(worktreeRoot, "live-link.txt"); + const copiedDirectoryPath = join(worktreeRoot, "copy-directory-link"); + const linkedDirectoryPath = join(worktreeRoot, "live-directory-link"); + const canonicalWorktreeRoot = realpathSync(worktreeRoot); + expect(result).toMatchObject({ materialized: 4, skipped: [] }); + expect(lstatSync(copiedPath).isSymbolicLink()).toBe(false); + expect(lstatSync(linkedPath).isSymbolicLink()).toBe(true); + expect(lstatSync(copiedDirectoryPath).isSymbolicLink()).toBe(false); + expect(lstatSync(linkedDirectoryPath).isSymbolicLink()).toBe(true); + expect(readlinkSync(linkedPath)).toBe( + relative(dirname(join(canonicalWorktreeRoot, "live-link.txt")), realpathSync(targetPath)), + ); + expect(readlinkSync(linkedDirectoryPath)).toBe( + relative( + dirname(join(canonicalWorktreeRoot, "live-directory-link")), + realpathSync(targetDirectoryPath), + ), + ); + expect(realpathSync(linkedPath)).toBe(realpathSync(targetPath)); + expect(realpathSync(linkedDirectoryPath)).toBe(realpathSync(targetDirectoryPath)); + + writeFileSync(targetPath, "v2\n"); + writeFileSync(join(targetDirectoryPath, "state.txt"), "v2\n"); + expect(readFileSync(copiedPath, "utf8")).toBe("v1\n"); + expect(readFileSync(linkedPath, "utf8")).toBe("v2\n"); + expect(readFileSync(join(copiedDirectoryPath, "state.txt"), "utf8")).toBe("v1\n"); + expect(readFileSync(join(linkedDirectoryPath, "state.txt"), "utf8")).toBe("v2\n"); + }); + + it("treats hard links as ordinary files", async () => { + const targetPath = join(sourceRoot, "target.txt"); + const hardLinkPath = join(sourceRoot, "hard-link.txt"); + writeFileSync(targetPath, "v1\n"); + linkSync(targetPath, hardLinkPath); + writeFileSync(join(sourceRoot, ".worktreeinclude"), "hard-link.txt\n"); + + const plan = await readWorktreeIncludePlan({ sourceRoot }); + await materializeWorktreeIncludePlan({ plan, worktreeRoot }); + + const copiedPath = join(worktreeRoot, "hard-link.txt"); + expect(lstatSync(copiedPath).isSymbolicLink()).toBe(false); + expect(readFileSync(copiedPath, "utf8")).toBe("v1\n"); + }); + + it("skips a source link retargeted outside the checkout after planning", async () => { + const insidePath = join(sourceRoot, "inside.txt"); + const linkedPath = join(sourceRoot, "linked.txt"); + const outsidePath = join(tempDir, "outside.txt"); + writeFileSync(insidePath, "inside\n"); + writeFileSync(outsidePath, "outside\n"); + symlinkSync(insidePath, linkedPath); + writeFileSync(join(sourceRoot, ".worktreeinclude"), "symlink linked.txt\n"); + + const plan = await readWorktreeIncludePlan({ sourceRoot }); + rmSync(linkedPath); + symlinkSync(outsidePath, linkedPath); + + const result = await materializeWorktreeIncludePlan({ plan, worktreeRoot }); + + expect(existsSync(join(worktreeRoot, "linked.txt"))).toBe(false); + expect(result.skipped).toEqual([ + expect.objectContaining({ raw: "symlink linked.txt", reason: "unsafe" }), + ]); + }); + + it("skips a linked directory that exposes an external nested link", async () => { + const sharedPath = join(sourceRoot, "shared"); const outsidePath = join(tempDir, "outside.txt"); + mkdirSync(sharedPath); writeFileSync(outsidePath, "outside\n"); - symlinkSync(outsidePath, join(sourceRoot, "linked.txt")); - writeFileSync(join(sourceRoot, ".worktreeinclude"), "linked.txt\n"); + symlinkSync(outsidePath, join(sharedPath, "outside.txt")); + writeFileSync(join(sourceRoot, ".worktreeinclude"), "symlink shared\n"); + + const plan = await readWorktreeIncludePlan({ sourceRoot }); - await expect(readWorktreeIncludePlan({ sourceRoot })).rejects.toThrow( - "resolves through a symbolic link", + expect(plan.materializations).toEqual([]); + expect(plan.skipped).toEqual([ + expect.objectContaining({ raw: "symlink shared", reason: "unsafe" }), + ]); + }); + + it("allows a linked directory with internal nested links", async () => { + const sharedPath = join(sourceRoot, "shared"); + const targetPath = join(sourceRoot, "shared-target"); + mkdirSync(sharedPath); + mkdirSync(targetPath); + writeFileSync(join(targetPath, "state.txt"), "source\n"); + symlinkSync(targetPath, join(sharedPath, "target"), "dir"); + writeFileSync(join(sourceRoot, ".worktreeinclude"), "symlink shared\n"); + + const plan = await readWorktreeIncludePlan({ sourceRoot }); + const result = await materializeWorktreeIncludePlan({ plan, worktreeRoot }); + + expect(result).toMatchObject({ materialized: 1, skipped: [] }); + expect(readFileSync(join(worktreeRoot, "shared", "target", "state.txt"), "utf8")).toBe( + "source\n", ); }); @@ -245,9 +399,12 @@ describe.skipIf(isPlatform("win32"))("worktree include materialization", () => { const plan = await readWorktreeIncludePlan({ sourceRoot }); rmSync(sourcePath); - await materializeWorktreeIncludePlan({ plan, worktreeRoot }); + const result = await materializeWorktreeIncludePlan({ plan, worktreeRoot }); expect(existsSync(join(worktreeRoot, "runtime.env"))).toBe(false); + expect(result.skipped).toEqual([ + expect.objectContaining({ raw: "runtime.env", reason: "missing" }), + ]); }); }); diff --git a/packages/server/src/utils/worktree-include.ts b/packages/server/src/utils/worktree-include.ts index 23717bc618..972580bad5 100644 --- a/packages/server/src/utils/worktree-include.ts +++ b/packages/server/src/utils/worktree-include.ts @@ -7,7 +7,6 @@ import { join, relative, resolve, - sep, win32, } from "path"; import { areEquivalentPaths, isPathInsideRoot } from "./path.js"; @@ -24,6 +23,13 @@ type WorktreeIncludeErrorCode = | "unsupported_source" | "windows_symlink_unavailable"; +export type WorktreeIncludeSkipReason = + | "conflict" + | "invalid" + | "missing" + | "source_changed" + | "unsafe"; + interface WorktreeIncludeEntry { lineNumber: number; mode: WorktreeIncludeMode; @@ -34,12 +40,27 @@ interface WorktreeIncludeEntry { export interface WorktreeIncludeMaterialization { lineNumber: number; mode: WorktreeIncludeMode; + raw: string; relativePath: string; sourceKind: WorktreeIncludeSourceKind; } +export interface WorktreeIncludeSkippedEntry { + lineNumber: number; + message: string; + raw: string; + reason: WorktreeIncludeSkipReason; +} + +export interface WorktreeIncludeSummary { + materialized: number; + skipped: WorktreeIncludeSkippedEntry[]; +} + export interface WorktreeIncludePlan { + excludedSourceRoots: string[]; materializations: WorktreeIncludeMaterialization[]; + skipped: WorktreeIncludeSkippedEntry[]; sourceRoot: string; } @@ -58,6 +79,16 @@ interface ResolvedWorktreeIncludeMaterialization { sourcePath: string; } +interface ParsedWorktreeIncludeEntries { + entries: WorktreeIncludeEntry[]; + skipped: WorktreeIncludeSkippedEntry[]; +} + +interface NormalizedWorktreeIncludeMaterializations { + materializations: WorktreeIncludeMaterialization[]; + skipped: WorktreeIncludeSkippedEntry[]; +} + export class WorktreeIncludeError extends Error { constructor( public readonly code: WorktreeIncludeErrorCode, @@ -72,9 +103,14 @@ export async function readWorktreeIncludePlan( options: ReadWorktreeIncludePlanOptions, ): Promise { const sourceRoot = await realpath(options.sourceRoot); - const entries = await readWorktreeIncludeEntries(sourceRoot); + const { entries, skipped } = await readWorktreeIncludeEntries(sourceRoot); if (entries.length === 0) { - return { sourceRoot, materializations: [] }; + return { + sourceRoot, + excludedSourceRoots: [], + materializations: [], + skipped, + }; } const excludedSourceRoots = await Promise.all( @@ -98,33 +134,35 @@ export async function readWorktreeIncludePlan( for (const entry of entries) { const matchedPaths = resolveEntryMatches({ entry, candidates }); if (matchedPaths.length === 0) { + skipped.push(toSkippedEntry(entry, noMatchError(entry))); continue; } for (const relativePath of matchedPaths) { - assertSourcePathDoesNotOverlapExcludedRoot({ - entry, - excludedSourceRoots, - sourcePath: join(sourceRoot, ...relativePath.split("/")), - }); try { const resolved = await resolveSourceMaterialization({ - sourceRoot, entry, + excludedSourceRoots, relativePath, + sourceRoot, }); materializations.push(resolved.materialization); } catch (error) { - if (!isMissingSourceError(error)) { + if (!isSkippableWorktreeIncludeError(error)) { throw error; } + skipped.push(toSkippedEntry(entry, error)); } } } + const normalized = normalizeMaterializations(materializations); + return { + excludedSourceRoots, + materializations: normalized.materializations, + skipped: [...skipped, ...normalized.skipped], sourceRoot, - materializations: normalizeMaterializations(materializations), }; } @@ -153,9 +191,11 @@ async function canonicalizeExistingPathPrefix(path: string): Promise { export async function materializeWorktreeIncludePlan( options: MaterializeWorktreeIncludePlanOptions, -): Promise { +): Promise { + const skipped: WorktreeIncludeSkippedEntry[] = []; + let materialized = 0; if (options.plan.materializations.length === 0) { - return; + return { materialized, skipped }; } const worktreeRoot = await realpath(options.worktreeRoot); @@ -163,26 +203,45 @@ export async function materializeWorktreeIncludePlan( let resolved: ResolvedWorktreeIncludeMaterialization; try { resolved = await resolveSourceMaterialization({ - sourceRoot: options.plan.sourceRoot, entry: { lineNumber: materialization.lineNumber, mode: materialization.mode, - raw: materialization.relativePath, + raw: materialization.raw, relativePath: materialization.relativePath, }, + excludedSourceRoots: options.plan.excludedSourceRoots, relativePath: materialization.relativePath, + sourceRoot: options.plan.sourceRoot, }); } catch (error) { - if (isMissingSourceError(error)) { + if (isSkippableWorktreeIncludeError(error)) { + skipped.push( + toSkippedEntry( + { + lineNumber: materialization.lineNumber, + raw: materialization.raw, + }, + error, + ), + ); continue; } throw error; } if (resolved.materialization.sourceKind !== materialization.sourceKind) { - throw new WorktreeIncludeError( - "source_changed", - `Source for .worktreeinclude entry '${materialization.relativePath}' changed type before it could be materialized`, + skipped.push( + toSkippedEntry( + { + lineNumber: materialization.lineNumber, + raw: materialization.raw, + }, + new WorktreeIncludeError( + "source_changed", + `Source for .worktreeinclude entry '${materialization.raw}' changed type before it could be materialized`, + ), + ), ); + continue; } const destinationPath = getDestinationPath({ worktreeRoot, @@ -192,11 +251,29 @@ export async function materializeWorktreeIncludePlan( worktreeRoot, relativePath: resolved.materialization.relativePath, }); - const needsMaterialization = await preflightDestination({ - worktreeRoot, - resolved, - }); + let needsMaterialization: boolean; + try { + needsMaterialization = await preflightDestination({ + worktreeRoot, + resolved, + }); + } catch (error) { + if (error instanceof WorktreeIncludeError && error.code === "unsupported_source") { + skipped.push( + toSkippedEntry( + { + lineNumber: materialization.lineNumber, + raw: materialization.raw, + }, + error, + ), + ); + continue; + } + throw error; + } if (!needsMaterialization) { + materialized++; continue; } @@ -210,6 +287,7 @@ export async function materializeWorktreeIncludePlan( dereference: false, }); } + materialized++; continue; } @@ -217,21 +295,27 @@ export async function materializeWorktreeIncludePlan( destinationPath, resolved, }); + materialized++; } + + return { materialized, skipped }; } -async function readWorktreeIncludeEntries(sourceRoot: string): Promise { +async function readWorktreeIncludeEntries( + sourceRoot: string, +): Promise { let contents: string; try { contents = await readFile(join(sourceRoot, WORKTREE_INCLUDE_FILE_NAME), "utf8"); } catch (error) { if (getErrorCode(error) === "ENOENT") { - return []; + return { entries: [], skipped: [] }; } throw error; } const entries: WorktreeIncludeEntry[] = []; + const skipped: WorktreeIncludeSkippedEntry[] = []; for (const [index, sourceLine] of contents.split(/\r?\n/).entries()) { const lineNumber = index + 1; @@ -244,10 +328,17 @@ async function readWorktreeIncludeEntries(sourceRoot: string): Promise { - const sourcePath = join(options.sourceRoot, ...options.relativePath.split("/")); - const sourceKind = await getSourceKind({ - sourceRoot: options.sourceRoot, + const requestedSourcePath = join(options.sourceRoot, ...options.relativePath.split("/")); + const sourcePath = await realpathSourcePath(requestedSourcePath, options.entry); + assertSourcePathIsSafe({ + entry: options.entry, + excludedSourceRoots: options.excludedSourceRoots, sourcePath, + sourceRoot: options.sourceRoot, + }); + + const sourceKind = await getCanonicalSourceKind({ entry: options.entry, + sourcePath, }); - if (options.entry.mode === "copy" && sourceKind === "directory") { - await assertCopyDirectorySafe({ - sourcePath, - entry: options.entry, - }); + if (sourceKind === "directory") { + if (options.entry.mode === "copy") { + await assertCopyDirectorySafe({ + entry: options.entry, + sourcePath, + }); + } else { + await assertSymlinkDirectorySafe({ + entry: options.entry, + excludedSourceRoots: options.excludedSourceRoots, + sourcePath, + sourceRoot: options.sourceRoot, + }); + } } return { materialization: { lineNumber: options.entry.lineNumber, mode: options.entry.mode, + raw: options.entry.raw, relativePath: options.relativePath, sourceKind, }, @@ -499,11 +608,19 @@ async function resolveSourceMaterialization(options: { }; } -function assertSourcePathDoesNotOverlapExcludedRoot(options: { +function assertSourcePathIsSafe(options: { entry: WorktreeIncludeEntry; excludedSourceRoots: string[]; sourcePath: string; + sourceRoot: string; }): void { + if (!isPathInsideRoot(options.sourceRoot, options.sourcePath)) { + throw new WorktreeIncludeError( + "unsupported_source", + `.worktreeinclude entry '${options.entry.raw}' on line ${options.entry.lineNumber} resolves outside the source checkout`, + ); + } + const excludedRoot = options.excludedSourceRoots.find( (candidate) => isPathInsideRoot(options.sourcePath, candidate) || @@ -514,34 +631,20 @@ function assertSourcePathDoesNotOverlapExcludedRoot(options: { } throw new WorktreeIncludeError( - "invalid_entry", + "unsupported_source", `.worktreeinclude entry '${options.entry.raw}' on line ${options.entry.lineNumber} overlaps with a protected worktree path`, ); } -async function getSourceKind(options: { +async function getCanonicalSourceKind(options: { entry: WorktreeIncludeEntry; sourcePath: string; - sourceRoot: string; }): Promise { - const segments = relative(options.sourceRoot, options.sourcePath).split(sep).filter(Boolean); - let currentPath = options.sourceRoot; - for (const segment of segments.slice(0, -1)) { - currentPath = join(currentPath, segment); - const currentStats = await lstatSourcePath(currentPath, options.entry); - if (currentStats.isSymbolicLink()) { - throw new WorktreeIncludeError( - "unsupported_source", - `.worktreeinclude entry '${options.entry.raw}' on line ${options.entry.lineNumber} resolves through a symbolic link`, - ); - } - } - const stats = await lstatSourcePath(options.sourcePath, options.entry); if (stats.isSymbolicLink()) { throw new WorktreeIncludeError( "unsupported_source", - `.worktreeinclude entry '${options.entry.raw}' on line ${options.entry.lineNumber} resolves through a symbolic link`, + `.worktreeinclude entry '${options.entry.raw}' on line ${options.entry.lineNumber} changed while its source path was resolved`, ); } if (!stats.isFile() && !stats.isDirectory()) { @@ -551,17 +654,30 @@ async function getSourceKind(options: { ); } - const canonicalSourcePath = await realpath(options.sourcePath); - if (!isPathInsideRoot(options.sourceRoot, canonicalSourcePath)) { - throw new WorktreeIncludeError( - "unsupported_source", - `.worktreeinclude entry '${options.entry.raw}' on line ${options.entry.lineNumber} resolves outside the source checkout`, - ); - } - return stats.isDirectory() ? "directory" : "file"; } +async function realpathSourcePath( + sourcePath: string, + entry: WorktreeIncludeEntry, +): Promise { + try { + return await realpath(sourcePath); + } catch (error) { + const code = getErrorCode(error); + if (code === "ENOENT" || code === "ENOTDIR") { + throw noMatchError(entry); + } + if (code === "ELOOP") { + throw new WorktreeIncludeError( + "unsupported_source", + `.worktreeinclude entry '${entry.raw}' on line ${entry.lineNumber} contains a symbolic-link loop`, + ); + } + throw error; + } +} + async function lstatSourcePath(sourcePath: string, entry: WorktreeIncludeEntry): Promise { try { return await lstat(sourcePath); @@ -599,31 +715,131 @@ async function assertCopyDirectorySafe(options: { } } +async function assertSymlinkDirectorySafe(options: { + entry: WorktreeIncludeEntry; + excludedSourceRoots: string[]; + sourcePath: string; + sourceRoot: string; +}): Promise { + const visitedDirectories = new Set(); + + async function visit(directoryPath: string): Promise { + if (visitedDirectories.has(directoryPath)) { + return; + } + visitedDirectories.add(directoryPath); + + for (const name of await readdir(directoryPath)) { + if (name.toLowerCase() === ".git") { + throw new WorktreeIncludeError( + "unsupported_source", + `.worktreeinclude entry '${options.entry.raw}' on line ${options.entry.lineNumber} contains git metadata`, + ); + } + + const childPath = join(directoryPath, name); + const childStats = await lstatSourcePath(childPath, options.entry); + if (childStats.isSymbolicLink()) { + const linkedPath = await realpathSourcePath(childPath, options.entry); + assertSourcePathIsSafe({ + entry: options.entry, + excludedSourceRoots: options.excludedSourceRoots, + sourcePath: linkedPath, + sourceRoot: options.sourceRoot, + }); + const linkedKind = await getCanonicalSourceKind({ + entry: options.entry, + sourcePath: linkedPath, + }); + if (linkedKind === "directory") { + await visit(linkedPath); + } + continue; + } + + if (childStats.isDirectory()) { + await visit(childPath); + continue; + } + if (!childStats.isFile()) { + throw new WorktreeIncludeError( + "unsupported_source", + `.worktreeinclude entry '${options.entry.raw}' on line ${options.entry.lineNumber} contains an unsupported file type`, + ); + } + } + } + + await visit(options.sourcePath); +} + function normalizeMaterializations( materializations: WorktreeIncludeMaterialization[], -): WorktreeIncludeMaterialization[] { - const byPath = new Map(); +): NormalizedWorktreeIncludeMaterializations { + const byPath = new Map(); for (const materialization of materializations) { - const existing = byPath.get(materialization.relativePath); - if (existing === undefined) { - byPath.set(materialization.relativePath, materialization); + const matches = byPath.get(materialization.relativePath) ?? []; + matches.push(materialization); + byPath.set(materialization.relativePath, matches); + } + + const skipped: WorktreeIncludeSkippedEntry[] = []; + const candidates: WorktreeIncludeMaterialization[] = []; + for (const [relativePath, matches] of byPath) { + if (new Set(matches.map((materialization) => materialization.mode)).size === 1) { + candidates.push(matches[0]!); continue; } - if (existing.mode !== materialization.mode) { - throw new WorktreeIncludeError( - "conflict", - `.worktreeinclude entries for '${materialization.relativePath}' use both copy and symlink modes`, - ); + + const message = `.worktreeinclude entries for '${relativePath}' use both copy and symlink modes`; + skipped.push( + ...matches.map((materialization) => + toSkippedEntry(materialization, new WorktreeIncludeError("conflict", message)), + ), + ); + } + + const conflicted = new Set(); + const skippedConflictEntries = new Set(); + const skipConflict = (materialization: WorktreeIncludeMaterialization, message: string): void => { + conflicted.add(materialization); + if (skippedConflictEntries.has(materialization)) { + return; + } + skippedConflictEntries.add(materialization); + skipped.push(toSkippedEntry(materialization, new WorktreeIncludeError("conflict", message))); + }; + + for (let leftIndex = 0; leftIndex < candidates.length; leftIndex++) { + const left = candidates[leftIndex]!; + for (let rightIndex = leftIndex + 1; rightIndex < candidates.length; rightIndex++) { + const right = candidates[rightIndex]!; + let ancestor: WorktreeIncludeMaterialization | null = null; + if (isRelativePathAncestor(left.relativePath, right.relativePath)) { + ancestor = left; + } else if (isRelativePathAncestor(right.relativePath, left.relativePath)) { + ancestor = right; + } + if (ancestor === null || (left.mode === "copy" && right.mode === "copy")) { + continue; + } + + const descendant = ancestor === left ? right : left; + const message = `.worktreeinclude entries for '${ancestor.relativePath}' and '${descendant.relativePath}' overlap with a symlink`; + skipConflict(left, message); + skipConflict(right, message); } } - const sorted = [...byPath.values()].sort((left, right) => { - const depthDifference = - left.relativePath.split("/").length - right.relativePath.split("/").length; - return depthDifference === 0 - ? left.relativePath.localeCompare(right.relativePath) - : depthDifference; - }); + const sorted = candidates + .filter((materialization) => !conflicted.has(materialization)) + .sort((left, right) => { + const depthDifference = + left.relativePath.split("/").length - right.relativePath.split("/").length; + return depthDifference === 0 + ? left.relativePath.localeCompare(right.relativePath) + : depthDifference; + }); const normalized: WorktreeIncludeMaterialization[] = []; for (const materialization of sorted) { @@ -637,13 +853,9 @@ function normalizeMaterializations( if (ancestor.mode === "copy" && materialization.mode === "copy") { continue; } - throw new WorktreeIncludeError( - "conflict", - `.worktreeinclude entries for '${ancestor.relativePath}' and '${materialization.relativePath}' overlap with a symlink`, - ); } - return normalized; + return { materializations: normalized, skipped }; } function isRelativePathAncestor(ancestor: string, candidate: string): boolean { @@ -855,8 +1067,37 @@ function noMatchError(entry: WorktreeIncludeEntry): WorktreeIncludeError { ); } -function isMissingSourceError(error: unknown): error is WorktreeIncludeError { - return error instanceof WorktreeIncludeError && error.code === "missing_source"; +function toSkippedEntry( + entry: Pick, + error: WorktreeIncludeError, +): WorktreeIncludeSkippedEntry { + return { + lineNumber: entry.lineNumber, + message: error.message, + raw: entry.raw, + reason: getSkipReason(error), + }; +} + +function getSkipReason(error: WorktreeIncludeError): WorktreeIncludeSkipReason { + switch (error.code) { + case "conflict": + return "conflict"; + case "invalid_entry": + return "invalid"; + case "missing_source": + return "missing"; + case "source_changed": + return "source_changed"; + case "unsupported_source": + return "unsafe"; + case "windows_symlink_unavailable": + return "unsafe"; + } +} + +function isSkippableWorktreeIncludeError(error: unknown): error is WorktreeIncludeError { + return error instanceof WorktreeIncludeError && error.code !== "windows_symlink_unavailable"; } function getErrorCode(error: unknown): string | null { diff --git a/packages/server/src/utils/worktree.posix.test.ts b/packages/server/src/utils/worktree.posix.test.ts index ad396a6f82..afc7994faf 100644 --- a/packages/server/src/utils/worktree.posix.test.ts +++ b/packages/server/src/utils/worktree.posix.test.ts @@ -1179,7 +1179,7 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { ).toContain(expectedWorktreePath); }); - it("rejects a recursive include of checkout-local worktree storage before creation", async () => { + it("skips checkout-local worktree storage and creates the remaining includes", async () => { const checkoutLocalPaseoHome = join(repoDir, ".dev", "paseo-home"); const projectHash = await deriveWorktreeProjectHash(repoDir); const expectedWorktreePath = join( @@ -1188,35 +1188,39 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { projectHash, "protected-include", ); - writeFileSync(join(repoDir, ".worktreeinclude"), ".dev/**\n"); + mkdirSync(join(checkoutLocalPaseoHome, "worktrees", projectHash), { recursive: true }); + writeFileSync(join(repoDir, ".worktreeinclude"), [".dev/**", ".env", ""].join("\n")); + writeFileSync(join(repoDir, ".env"), "present\n"); - await expect( - createLegacyWorktreeForTest({ - cwd: repoDir, - worktreeSlug: "protected-include", - source: { - kind: "branch-off", - baseBranch: "main", - branchName: "feature/protected-include", - }, - runSetup: false, - paseoHome: checkoutLocalPaseoHome, - }), - ).rejects.toThrow("overlaps with a protected worktree path"); + const result = await createLegacyWorktreeForTest({ + cwd: repoDir, + worktreeSlug: "protected-include", + source: { + kind: "branch-off", + baseBranch: "main", + branchName: "feature/protected-include", + }, + runSetup: false, + paseoHome: checkoutLocalPaseoHome, + }); - expect(existsSync(expectedWorktreePath)).toBe(false); + expect(result.worktreePath).toBe(expectedWorktreePath); + expect(readFileSync(join(result.worktreePath, ".env"), "utf8")).toBe("present\n"); + expect(result.worktreeIncludeSummary?.skipped).toEqual([ + expect.objectContaining({ raw: ".dev/**", reason: "unsafe" }), + ]); expect( execFileSync("git", ["worktree", "list", "--porcelain"], { cwd: repoDir, encoding: "utf8", }), - ).not.toContain(expectedWorktreePath); + ).toContain(expectedWorktreePath); expect( - execFileSync("git", ["branch", "--list", "feature/include-conflict"], { + execFileSync("git", ["branch", "--list", "feature/protected-include"], { cwd: repoDir, encoding: "utf8", }).trim(), - ).toBe(""); + ).toContain("feature/protected-include"); }); it("rolls back a created worktree when a symlink include conflicts", async () => { diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index 141a353785..e60d6dbb61 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -36,7 +36,11 @@ 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, getRealpathAwareRelativePath, isPathInsideRoot } from "./path.js"; -import { materializeWorktreeIncludePlan, readWorktreeIncludePlan } from "./worktree-include.js"; +import { + materializeWorktreeIncludePlan, + readWorktreeIncludePlan, + type WorktreeIncludeSummary, +} from "./worktree-include.js"; export { slugify, validateBranchSlug } from "@getpaseo/protocol/branch-slug"; @@ -47,6 +51,7 @@ const READ_ONLY_GIT_ENV = { export interface WorktreeConfig { branchName: string; + worktreeIncludeSummary?: WorktreeIncludeSummary; worktreePath: string; } @@ -1291,11 +1296,19 @@ export const createWorktree = async ({ }); await seedPaseoConfigFile({ sourceCwd: cwd, targetCwd: worktreePath }); + let worktreeIncludeSummary: WorktreeIncludeSummary = { + materialized: 0, + skipped: [...worktreeIncludePlan.skipped], + }; try { - await materializeWorktreeIncludePlan({ + const materialization = await materializeWorktreeIncludePlan({ plan: worktreeIncludePlan, worktreeRoot: worktreePath, }); + worktreeIncludeSummary = { + materialized: materialization.materialized, + skipped: [...worktreeIncludePlan.skipped, ...materialization.skipped], + }; } catch (error) { await rollbackCreatedPaseoWorktree( { @@ -1320,6 +1333,7 @@ export const createWorktree = async ({ return { branchName: sourcePlan.branchName, + worktreeIncludeSummary, worktreePath, }; }; From dce2ae5ffeef7b7daacee0adb03328103a47b661 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Sat, 25 Jul 2026 12:03:38 -0500 Subject: [PATCH 7/8] fix(server): skip failed worktree includes --- docs/development.md | 2 +- .../server/src/utils/worktree-include.test.ts | 19 +- packages/server/src/utils/worktree-include.ts | 337 ++++++++++++++---- .../server/src/utils/worktree.posix.test.ts | 41 ++- 4 files changed, 298 insertions(+), 101 deletions(-) diff --git a/docs/development.md b/docs/development.md index 0136aae5c5..f443e71e91 100644 --- a/docs/development.md +++ b/docs/development.md @@ -29,7 +29,7 @@ Root checkout dev is intentionally split across terminals: - **Repo dev scripts** default to `$ROOT/.dev/paseo-home`, where `$ROOT` is the current checkout or worktree root. This keeps all dev state scoped to the checkout instead of the packaged desktop app. - **`npm run cli -- ...`** runs through the same dev-home wrapper as the dev scripts, so the in-repo CLI automatically targets the current checkout's `.dev/paseo-home` and configured dev daemon endpoint. - **Paseo-created worktrees** seed `$PASEO_WORKTREE_PATH/.dev/paseo-home` from `$PASEO_SOURCE_CHECKOUT_PATH/.dev/paseo-home` by copying durable JSON metadata. Runtime files like pid files, sockets, and logs are not copied. -- **Paseo-created worktrees** read `.worktreeinclude` from the live source checkout before creation. Bare paths and `copy ` copy a snapshot into the new worktree; `symlink ` creates a live source link. Missing paths, malformed entries, unsafe paths, and incompatible include overlaps are skipped individually and reported in the daemon log, so the rest of the plan still runs. A source symlink is allowed only when its resolved target remains inside the source checkout and outside Paseo-managed worktree storage; `copy` snapshots that resolved target, while `symlink` links directly to it. Hard links are ordinary files. Destination conflicts, permission errors, and other write failures still abort creation and roll back the new worktree. Materialization finishes before `worktree.setup` runs. +- **Paseo-created worktrees** read `.worktreeinclude` from the live source checkout before creation. Bare paths and `copy ` copy a snapshot into the new worktree; `symlink ` creates a live source link. Missing paths, malformed entries, unsafe paths, incompatible include overlaps, destination conflicts, unavailable platform links, and ordinary read/write failures are skipped individually and reported in the daemon log, so the rest of the plan still runs. A source symlink is allowed only when its resolved target remains inside the source checkout and outside Paseo-managed worktree storage; `copy` snapshots that resolved target, while `symlink` links directly to it. Hard links are ordinary files. Each include is staged before it is committed; Paseo aborts creation only if it cannot safely clean up partial materialization state (or Git/worktree setup itself fails). Materialization finishes before `worktree.setup` runs. - **This repo's worktree setup** also best-effort seeds `packages/app/ios` and the newest `.dev/ios-build` entry from the source checkout so iOS simulator services can reuse native project and Xcode cache state when it is safe enough to do so. Override knobs: diff --git a/packages/server/src/utils/worktree-include.test.ts b/packages/server/src/utils/worktree-include.test.ts index 2f4c100a68..2eb49df5ec 100644 --- a/packages/server/src/utils/worktree-include.test.ts +++ b/packages/server/src/utils/worktree-include.test.ts @@ -6,6 +6,7 @@ import { linkSync, mkdirSync, mkdtempSync, + readdirSync, readFileSync, readlinkSync, realpathSync, @@ -251,20 +252,28 @@ describe.skipIf(isPlatform("win32"))("worktree include materialization", () => { expect(readFileSync(join(worktreeRoot, "copy-dir", "state.txt"), "utf8")).toBe("copy-dir-v2\n"); }); - it("rejects a destination parent symlink without writing through it", async () => { + it("skips a destination parent symlink without writing through it", async () => { mkdirSync(join(sourceRoot, "config"), { recursive: true }); writeFileSync(join(sourceRoot, "config", "local.json"), "{}\n"); - writeFileSync(join(sourceRoot, ".worktreeinclude"), "config/local.json\n"); + writeFileSync(join(sourceRoot, "safe.txt"), "safe\n"); + writeFileSync(join(sourceRoot, ".worktreeinclude"), "config/local.json\nsafe.txt\n"); const outsideRoot = join(tempDir, "outside"); mkdirSync(outsideRoot); symlinkSync(outsideRoot, join(worktreeRoot, "config")); const plan = await readWorktreeIncludePlan({ sourceRoot }); - await expect(materializeWorktreeIncludePlan({ plan, worktreeRoot })).rejects.toThrow( - "Refusing to materialize .worktreeinclude entry", - ); + const result = await materializeWorktreeIncludePlan({ plan, worktreeRoot }); + expect(existsSync(join(outsideRoot, "local.json"))).toBe(false); + expect(readFileSync(join(worktreeRoot, "safe.txt"), "utf8")).toBe("safe\n"); + expect(result).toMatchObject({ materialized: 1 }); + expect(result.skipped).toEqual([ + expect.objectContaining({ raw: "config/local.json", reason: "conflict" }), + ]); + expect(readdirSync(worktreeRoot)).not.toContain( + expect.stringMatching(/^\.paseo-worktreeinclude-/), + ); }); it("copies resolved source links and creates direct live links", async () => { diff --git a/packages/server/src/utils/worktree-include.ts b/packages/server/src/utils/worktree-include.ts index 972580bad5..03539cb331 100644 --- a/packages/server/src/utils/worktree-include.ts +++ b/packages/server/src/utils/worktree-include.ts @@ -1,5 +1,18 @@ import { type Stats } from "fs"; -import { copyFile, cp, lstat, mkdir, readFile, readdir, realpath, symlink } from "fs/promises"; +import { + copyFile, + cp, + lstat, + mkdir, + mkdtemp, + readFile, + readdir, + realpath, + rename, + rm, + rmdir, + symlink, +} from "fs/promises"; import { basename as pathBasename, dirname, @@ -26,6 +39,7 @@ type WorktreeIncludeErrorCode = export type WorktreeIncludeSkipReason = | "conflict" | "invalid" + | "materialization" | "missing" | "source_changed" | "unsafe"; @@ -79,6 +93,11 @@ interface ResolvedWorktreeIncludeMaterialization { sourcePath: string; } +interface StagedWorktreeIncludeMaterialization { + directoryPath: string; + entryPath: string; +} + interface ParsedWorktreeIncludeEntries { entries: WorktreeIncludeEntry[]; skipped: WorktreeIncludeSkippedEntry[]; @@ -99,6 +118,16 @@ export class WorktreeIncludeError extends Error { } } +class WorktreeIncludeCleanupError extends Error { + constructor( + message: string, + public readonly cleanupError: unknown, + ) { + super(message); + this.name = "WorktreeIncludeCleanupError"; + } +} + export async function readWorktreeIncludePlan( options: ReadWorktreeIncludePlanOptions, ): Promise { @@ -200,30 +229,23 @@ export async function materializeWorktreeIncludePlan( const worktreeRoot = await realpath(options.worktreeRoot); for (const materialization of options.plan.materializations) { + const entry = { + lineNumber: materialization.lineNumber, + mode: materialization.mode, + raw: materialization.raw, + relativePath: materialization.relativePath, + }; let resolved: ResolvedWorktreeIncludeMaterialization; try { resolved = await resolveSourceMaterialization({ - entry: { - lineNumber: materialization.lineNumber, - mode: materialization.mode, - raw: materialization.raw, - relativePath: materialization.relativePath, - }, + entry, excludedSourceRoots: options.plan.excludedSourceRoots, relativePath: materialization.relativePath, sourceRoot: options.plan.sourceRoot, }); } catch (error) { if (isSkippableWorktreeIncludeError(error)) { - skipped.push( - toSkippedEntry( - { - lineNumber: materialization.lineNumber, - raw: materialization.raw, - }, - error, - ), - ); + skipped.push(toSkippedEntry(entry, error)); continue; } throw error; @@ -231,10 +253,7 @@ export async function materializeWorktreeIncludePlan( if (resolved.materialization.sourceKind !== materialization.sourceKind) { skipped.push( toSkippedEntry( - { - lineNumber: materialization.lineNumber, - raw: materialization.raw, - }, + entry, new WorktreeIncludeError( "source_changed", `Source for .worktreeinclude entry '${materialization.raw}' changed type before it could be materialized`, @@ -243,62 +262,203 @@ export async function materializeWorktreeIncludePlan( ); continue; } - const destinationPath = getDestinationPath({ - worktreeRoot, - relativePath: resolved.materialization.relativePath, - }); - await ensureDestinationParent({ - worktreeRoot, - relativePath: resolved.materialization.relativePath, - }); - let needsMaterialization: boolean; + + let staged: StagedWorktreeIncludeMaterialization | null = null; + let createdDestinationParents: string[] = []; try { - needsMaterialization = await preflightDestination({ + const destinationPath = getDestinationPath({ worktreeRoot, + relativePath: resolved.materialization.relativePath, + }); + if ( + !(await preflightDestination({ + worktreeRoot, + resolved, + })) + ) { + materialized++; + continue; + } + + staged = await stageMaterialization({ + destinationPath, resolved, + worktreeRoot, }); + createdDestinationParents = await ensureDestinationParent({ + worktreeRoot, + relativePath: resolved.materialization.relativePath, + }); + if ( + !(await preflightDestination({ + worktreeRoot, + resolved, + })) + ) { + await cleanupStagingDirectory(staged.directoryPath); + staged = null; + await cleanupCreatedDestinationParents(createdDestinationParents); + materialized++; + continue; + } + + const destinationStats = await lstatIfExists(destinationPath); + if (resolved.materialization.mode === "copy" && destinationStats !== null) { + await copyStagedMaterializationToExistingDestination({ + destinationPath, + resolved, + staged, + }); + } else { + await rename(staged.entryPath, destinationPath); + } + + await cleanupStagingDirectory(staged.directoryPath); + staged = null; + materialized++; } catch (error) { - if (error instanceof WorktreeIncludeError && error.code === "unsupported_source") { - skipped.push( - toSkippedEntry( - { - lineNumber: materialization.lineNumber, - raw: materialization.raw, - }, - error, - ), - ); + if (staged !== null) { + await cleanupStagingDirectory(staged.directoryPath); + } + await cleanupCreatedDestinationParents(createdDestinationParents); + if (isWorktreeIncludeMaterializationError(error)) { + skipped.push(toSkippedEntry(entry, error)); continue; } throw error; } - if (!needsMaterialization) { - materialized++; - continue; + } + + return { materialized, skipped }; +} + +async function stageMaterialization(options: { + destinationPath: string; + resolved: ResolvedWorktreeIncludeMaterialization; + worktreeRoot: string; +}): Promise { + const directoryPath = await mkdtemp(join(options.worktreeRoot, ".paseo-worktreeinclude-")); + const entryPath = join(directoryPath, "entry"); + try { + if (options.resolved.materialization.mode === "copy") { + await copyMaterializationPath({ + destinationPath: entryPath, + sourceKind: options.resolved.materialization.sourceKind, + sourcePath: options.resolved.sourcePath, + }); + } else { + await createMaterializationSymlink({ + destinationPath: entryPath, + linkDestinationPath: options.destinationPath, + resolved: options.resolved, + }); } + return { directoryPath, entryPath }; + } catch (error) { + await cleanupStagingDirectory(directoryPath); + throw error; + } +} - if (resolved.materialization.mode === "copy") { - if (resolved.materialization.sourceKind === "file") { - await copyFile(resolved.sourcePath, destinationPath); - } else { - await cp(resolved.sourcePath, destinationPath, { - recursive: true, - force: true, - dereference: false, - }); +async function copyStagedMaterializationToExistingDestination(options: { + destinationPath: string; + resolved: ResolvedWorktreeIncludeMaterialization; + staged: StagedWorktreeIncludeMaterialization; +}): Promise { + const backupPath = join(options.staged.directoryPath, "backup"); + const sourceKind = options.resolved.materialization.sourceKind; + await copyMaterializationPath({ + destinationPath: backupPath, + sourceKind, + sourcePath: options.destinationPath, + }); + try { + await copyMaterializationPath({ + destinationPath: options.destinationPath, + sourceKind, + sourcePath: options.staged.entryPath, + }); + } catch (error) { + await restoreCopiedDestination({ + backupPath, + destinationPath: options.destinationPath, + sourceKind, + }); + throw error; + } +} + +async function copyMaterializationPath(options: { + destinationPath: string; + sourceKind: WorktreeIncludeSourceKind; + sourcePath: string; +}): Promise { + if (options.sourceKind === "file") { + await copyFile(options.sourcePath, options.destinationPath); + return; + } + await cp(options.sourcePath, options.destinationPath, { + recursive: true, + force: true, + dereference: false, + }); +} + +async function restoreCopiedDestination(options: { + backupPath: string; + destinationPath: string; + sourceKind: WorktreeIncludeSourceKind; +}): Promise { + try { + const destinationStats = await lstatIfExists(options.destinationPath); + if (destinationStats !== null) { + if ( + destinationStats.isSymbolicLink() || + (options.sourceKind === "file" && !destinationStats.isFile()) || + (options.sourceKind === "directory" && !destinationStats.isDirectory()) + ) { + throw new Error("destination changed while restoring a failed materialization"); } - materialized++; - continue; + await rm(options.destinationPath, { + recursive: destinationStats.isDirectory(), + force: true, + }); } + await rename(options.backupPath, options.destinationPath); + } catch (error) { + throw new WorktreeIncludeCleanupError( + `Unable to restore .worktreeinclude destination '${options.destinationPath}' after a failed materialization`, + error, + ); + } +} - await createMaterializationSymlink({ - destinationPath, - resolved, - }); - materialized++; +async function cleanupStagingDirectory(directoryPath: string): Promise { + try { + await rm(directoryPath, { recursive: true, force: true }); + } catch (error) { + throw new WorktreeIncludeCleanupError( + `Unable to clean up .worktreeinclude staging directory '${directoryPath}'`, + error, + ); } +} - return { materialized, skipped }; +async function cleanupCreatedDestinationParents(createdPaths: string[]): Promise { + for (const path of createdPaths.toReversed()) { + try { + await rmdir(path); + } catch (error) { + const code = getErrorCode(error); + if (code === "ENOENT" || code === "ENOTEMPTY" || code === "EEXIST") { + continue; + } + throw new WorktreeIncludeCleanupError( + `Unable to clean up .worktreeinclude destination directory '${path}'`, + error, + ); + } + } } async function readWorktreeIncludeEntries( @@ -924,23 +1084,31 @@ function getDestinationPath(options: { relativePath: string; worktreeRoot: strin async function ensureDestinationParent(options: { relativePath: string; worktreeRoot: string; -}): Promise { +}): Promise { const parentSegments = options.relativePath.split("/").slice(0, -1); let currentPath = options.worktreeRoot; - for (const segment of parentSegments) { - currentPath = join(currentPath, segment); - const stats = await lstatIfExists(currentPath); - if (stats === null) { - await mkdir(currentPath); - continue; - } - if (stats.isSymbolicLink() || !stats.isDirectory()) { - throw new WorktreeIncludeError( - "conflict", - `Refusing to materialize .worktreeinclude entry '${options.relativePath}' through '${currentPath}'`, - ); + const createdPaths: string[] = []; + try { + for (const segment of parentSegments) { + currentPath = join(currentPath, segment); + const stats = await lstatIfExists(currentPath); + if (stats === null) { + await mkdir(currentPath); + createdPaths.push(currentPath); + continue; + } + if (stats.isSymbolicLink() || !stats.isDirectory()) { + throw new WorktreeIncludeError( + "conflict", + `Refusing to materialize .worktreeinclude entry '${options.relativePath}' through '${currentPath}'`, + ); + } } + } catch (error) { + await cleanupCreatedDestinationParents(createdPaths); + throw error; } + return createdPaths; } async function assertCopyDestinationTreeSafe(options: { @@ -984,10 +1152,14 @@ async function assertCopyDestinationTreeSafe(options: { async function createMaterializationSymlink(options: { destinationPath: string; + linkDestinationPath?: string; resolved: ResolvedWorktreeIncludeMaterialization; }): Promise { if (process.platform !== "win32") { - const target = relative(dirname(options.destinationPath), options.resolved.sourcePath); + const target = relative( + dirname(options.linkDestinationPath ?? options.destinationPath), + options.resolved.sourcePath, + ); await symlink(target, options.destinationPath); return; } @@ -1069,30 +1241,33 @@ function noMatchError(entry: WorktreeIncludeEntry): WorktreeIncludeError { function toSkippedEntry( entry: Pick, - error: WorktreeIncludeError, + error: unknown, ): WorktreeIncludeSkippedEntry { return { lineNumber: entry.lineNumber, - message: error.message, + message: error instanceof Error ? error.message : String(error), raw: entry.raw, reason: getSkipReason(error), }; } -function getSkipReason(error: WorktreeIncludeError): WorktreeIncludeSkipReason { +function getSkipReason(error: unknown): WorktreeIncludeSkipReason { + if (!(error instanceof WorktreeIncludeError)) { + return "materialization"; + } switch (error.code) { case "conflict": return "conflict"; case "invalid_entry": return "invalid"; + case "windows_symlink_unavailable": + return "materialization"; case "missing_source": return "missing"; case "source_changed": return "source_changed"; case "unsupported_source": return "unsafe"; - case "windows_symlink_unavailable": - return "unsafe"; } } @@ -1100,6 +1275,10 @@ function isSkippableWorktreeIncludeError(error: unknown): error is WorktreeInclu return error instanceof WorktreeIncludeError && error.code !== "windows_symlink_unavailable"; } +function isWorktreeIncludeMaterializationError(error: unknown): boolean { + return error instanceof WorktreeIncludeError || getErrorCode(error) !== null; +} + function getErrorCode(error: unknown): string | null { if (typeof error !== "object" || error === null || !("code" in error)) { return null; diff --git a/packages/server/src/utils/worktree.posix.test.ts b/packages/server/src/utils/worktree.posix.test.ts index afc7994faf..15806acdbd 100644 --- a/packages/server/src/utils/worktree.posix.test.ts +++ b/packages/server/src/utils/worktree.posix.test.ts @@ -1223,33 +1223,42 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { ).toContain("feature/protected-include"); }); - it("rolls back a created worktree when a symlink include conflicts", async () => { + it("skips a symlink include conflict and keeps the new worktree", async () => { const projectHash = await deriveWorktreeProjectHash(repoDir); const expectedWorktreePath = join(paseoHome, "worktrees", projectHash, "include-conflict"); writeFileSync(join(repoDir, "paseo.json"), JSON.stringify({ scripts: {} })); writeFileSync(join(repoDir, ".worktreeinclude"), "symlink paseo.json\n"); - await expect( - createLegacyWorktreeForTest({ - cwd: repoDir, - worktreeSlug: "include-conflict", - source: { - kind: "branch-off", - baseBranch: "main", - branchName: "feature/include-conflict", - }, - runSetup: false, - paseoHome, - }), - ).rejects.toThrow("conflicts with the new worktree"); + const result = await createLegacyWorktreeForTest({ + cwd: repoDir, + worktreeSlug: "include-conflict", + source: { + kind: "branch-off", + baseBranch: "main", + branchName: "feature/include-conflict", + }, + runSetup: false, + paseoHome, + }); - expect(existsSync(expectedWorktreePath)).toBe(false); + expect(result.worktreePath).toBe(expectedWorktreePath); + expect(existsSync(expectedWorktreePath)).toBe(true); + expect(lstatSync(join(expectedWorktreePath, "paseo.json")).isSymbolicLink()).toBe(false); + expect(result.worktreeIncludeSummary?.skipped).toEqual([ + expect.objectContaining({ raw: "symlink paseo.json", reason: "conflict" }), + ]); expect( execFileSync("git", ["worktree", "list", "--porcelain"], { cwd: repoDir, encoding: "utf8", }), - ).not.toContain(expectedWorktreePath); + ).toContain(expectedWorktreePath); + expect( + execFileSync("git", ["branch", "--list", "feature/include-conflict"], { + cwd: repoDir, + encoding: "utf8", + }).trim(), + ).toContain("feature/include-conflict"); }); it("creates a worktree without error when no paseo.json exists in the main repo", async () => { From 0749c9a126ddd1b0164962d970ef0a66680cfe46 Mon Sep 17 00:00:00 2001 From: Michael Wu Date: Sat, 25 Jul 2026 12:35:00 -0500 Subject: [PATCH 8/8] fix(server): harden worktree include planning --- docs/development.md | 2 +- .../server/src/utils/worktree-include.test.ts | 49 +++ packages/server/src/utils/worktree-include.ts | 68 ++-- .../server/src/utils/worktree.posix.test.ts | 99 ++++++ packages/server/src/utils/worktree.ts | 334 +++++++++++------- 5 files changed, 407 insertions(+), 145 deletions(-) diff --git a/docs/development.md b/docs/development.md index f443e71e91..5960332584 100644 --- a/docs/development.md +++ b/docs/development.md @@ -29,7 +29,7 @@ Root checkout dev is intentionally split across terminals: - **Repo dev scripts** default to `$ROOT/.dev/paseo-home`, where `$ROOT` is the current checkout or worktree root. This keeps all dev state scoped to the checkout instead of the packaged desktop app. - **`npm run cli -- ...`** runs through the same dev-home wrapper as the dev scripts, so the in-repo CLI automatically targets the current checkout's `.dev/paseo-home` and configured dev daemon endpoint. - **Paseo-created worktrees** seed `$PASEO_WORKTREE_PATH/.dev/paseo-home` from `$PASEO_SOURCE_CHECKOUT_PATH/.dev/paseo-home` by copying durable JSON metadata. Runtime files like pid files, sockets, and logs are not copied. -- **Paseo-created worktrees** read `.worktreeinclude` from the live source checkout before creation. Bare paths and `copy ` copy a snapshot into the new worktree; `symlink ` creates a live source link. Missing paths, malformed entries, unsafe paths, incompatible include overlaps, destination conflicts, unavailable platform links, and ordinary read/write failures are skipped individually and reported in the daemon log, so the rest of the plan still runs. A source symlink is allowed only when its resolved target remains inside the source checkout and outside Paseo-managed worktree storage; `copy` snapshots that resolved target, while `symlink` links directly to it. Hard links are ordinary files. Each include is staged before it is committed; Paseo aborts creation only if it cannot safely clean up partial materialization state (or Git/worktree setup itself fails). Materialization finishes before `worktree.setup` runs. +- **Paseo-created worktrees** read `.worktreeinclude` from the live source checkout before creation. Bare paths and `copy ` copy a snapshot into the new worktree; `symlink ` creates a live source link. Missing paths, malformed entries, unsafe paths, incompatible include overlaps, destination conflicts, unavailable platform links, and ordinary read/write failures are skipped individually and reported in the daemon log, so the rest of the plan still runs. A source symlink is allowed only when its resolved target remains inside the active source checkout. If that checkout is itself Paseo-managed, its own paths remain eligible while other managed worktree paths stay protected; `copy` snapshots that resolved target, while `symlink` links directly to it. Hard links are ordinary files. Each include is staged before it is committed; Paseo aborts creation only if it cannot safely clean up partial materialization state (or Git/worktree setup itself fails). Materialization finishes before `worktree.setup` runs. - **This repo's worktree setup** also best-effort seeds `packages/app/ios` and the newest `.dev/ios-build` entry from the source checkout so iOS simulator services can reuse native project and Xcode cache state when it is safe enough to do so. Override knobs: diff --git a/packages/server/src/utils/worktree-include.test.ts b/packages/server/src/utils/worktree-include.test.ts index 2eb49df5ec..5327b79aad 100644 --- a/packages/server/src/utils/worktree-include.test.ts +++ b/packages/server/src/utils/worktree-include.test.ts @@ -133,6 +133,24 @@ describe("worktree include planning", () => { expect(plan.skipped).toEqual([expect.objectContaining({ raw: ".dev/**", reason: "unsafe" })]); }); + it("allows includes from a source worktree inside the protected worktree root", async () => { + const managedWorktreesRoot = join(tempDir, "paseo-home", "worktrees", "project"); + sourceRoot = join(managedWorktreesRoot, "source-worktree"); + mkdirSync(sourceRoot, { recursive: true }); + writeFileSync(join(sourceRoot, ".worktreeinclude"), ".env\n"); + writeFileSync(join(sourceRoot, ".env"), "source\n"); + + const plan = await readWorktreeIncludePlan({ + sourceRoot, + excludedSourceRoots: [managedWorktreesRoot], + }); + + expect(plan.materializations).toEqual([ + expect.objectContaining({ relativePath: ".env", sourceKind: "file" }), + ]); + expect(plan.skipped).toEqual([]); + }); + it.skipIf(isPlatform("win32"))( "skips protected paths reached through a symlink alias", async () => { @@ -196,6 +214,37 @@ describe("worktree include planning", () => { } }, ); + + it.skipIf(isPlatform("win32") || process.getuid?.() === 0)( + "skips inaccessible entries while retaining safe paths", + async () => { + const inaccessibleDirectory = join(sourceRoot, "private"); + mkdirSync(inaccessibleDirectory); + writeFileSync(join(inaccessibleDirectory, "secret.txt"), "secret\n"); + writeFileSync(join(sourceRoot, "safe.txt"), "safe\n"); + writeFileSync( + join(sourceRoot, ".worktreeinclude"), + ["private/**", "private/*", "safe.txt", ""].join("\n"), + ); + chmodSync(inaccessibleDirectory, 0o000); + + try { + const plan = await readWorktreeIncludePlan({ sourceRoot }); + + expect(plan.materializations).toEqual([ + expect.objectContaining({ relativePath: "safe.txt", sourceKind: "file" }), + ]); + expect(plan.skipped).toEqual( + expect.arrayContaining([ + expect.objectContaining({ raw: "private/**", reason: "materialization" }), + expect.objectContaining({ raw: "private/*", reason: "materialization" }), + ]), + ); + } finally { + chmodSync(inaccessibleDirectory, 0o700); + } + }, + ); }); describe.skipIf(isPlatform("win32"))("worktree include materialization", () => { diff --git a/packages/server/src/utils/worktree-include.ts b/packages/server/src/utils/worktree-include.ts index 03539cb331..c0631661ab 100644 --- a/packages/server/src/utils/worktree-include.ts +++ b/packages/server/src/utils/worktree-include.ts @@ -1,4 +1,4 @@ -import { type Stats } from "fs"; +import { type Dirent, type Stats } from "fs"; import { copyFile, cp, @@ -108,6 +108,11 @@ interface NormalizedWorktreeIncludeMaterializations { skipped: WorktreeIncludeSkippedEntry[]; } +interface WorktreeIncludeCandidateCollection { + candidates: string[]; + errorsByPattern: Map; +} + export class WorktreeIncludeError extends Error { constructor( public readonly code: WorktreeIncludeErrorCode, @@ -142,26 +147,33 @@ export async function readWorktreeIncludePlan( }; } - const excludedSourceRoots = await Promise.all( - (options.excludedSourceRoots ?? []).map(canonicalizeExistingPathPrefix), - ); + const excludedSourceRoots = ( + await Promise.all((options.excludedSourceRoots ?? []).map(canonicalizeExistingPathPrefix)) + ).filter((candidate) => !isPathInsideRoot(candidate, sourceRoot)); const candidatePatterns = entries .filter( (entry) => entry.relativePath.includes("*") && getRecursiveDirectoryPath(entry) === null, ) .map((entry) => entry.relativePath); - const candidates = + const candidateCollection = candidatePatterns.length > 0 ? await collectWorktreeIncludeCandidates({ sourceRoot, excludedSourceRoots, patterns: candidatePatterns, }) - : []; + : { candidates: [], errorsByPattern: new Map() }; const materializations: WorktreeIncludeMaterialization[] = []; for (const entry of entries) { - const matchedPaths = resolveEntryMatches({ entry, candidates }); + if (candidateCollection.errorsByPattern.has(entry.relativePath)) { + skipped.push( + toSkippedEntry(entry, candidateCollection.errorsByPattern.get(entry.relativePath)), + ); + continue; + } + + const matchedPaths = resolveEntryMatches({ entry, candidates: candidateCollection.candidates }); if (matchedPaths.length === 0) { skipped.push(toSkippedEntry(entry, noMatchError(entry))); continue; @@ -177,7 +189,7 @@ export async function readWorktreeIncludePlan( }); materializations.push(resolved.materialization); } catch (error) { - if (!isSkippableWorktreeIncludeError(error)) { + if (!isWorktreeIncludeMaterializationError(error)) { throw error; } skipped.push(toSkippedEntry(entry, error)); @@ -594,11 +606,27 @@ async function collectWorktreeIncludeCandidates(options: { excludedSourceRoots: string[]; patterns: string[]; sourceRoot: string; -}): Promise { +}): Promise { const candidates: string[] = []; - - async function visit(directoryPath: string, directorySegments: string[]): Promise { - const entries = await readdir(directoryPath, { withFileTypes: true }); + const errorsByPattern = new Map(); + + async function visit( + directoryPath: string, + directorySegments: string[], + patterns: string[], + ): Promise { + let entries: Dirent[]; + try { + entries = await readdir(directoryPath, { withFileTypes: true }); + } catch (error) { + if (getErrorCode(error) === null) { + throw error; + } + for (const pattern of patterns) { + errorsByPattern.set(pattern, error); + } + return; + } for (const entry of entries) { if (entry.name.toLowerCase() === ".git") { continue; @@ -615,20 +643,20 @@ async function collectWorktreeIncludeCandidates(options: { } const relativePath = pathSegments.join("/"); - if (options.patterns.some((pattern) => worktreeIncludeGlobMatches(pattern, relativePath))) { + if (patterns.some((pattern) => worktreeIncludeGlobMatches(pattern, relativePath))) { candidates.push(relativePath); } - if ( - entry.isDirectory() && - options.patterns.some((pattern) => canGlobMatchDescendant(pattern, pathSegments)) - ) { - await visit(sourcePath, pathSegments); + const descendantPatterns = patterns.filter((pattern) => + canGlobMatchDescendant(pattern, pathSegments), + ); + if (entry.isDirectory() && descendantPatterns.length > 0) { + await visit(sourcePath, pathSegments, descendantPatterns); } } } - await visit(options.sourceRoot, []); - return candidates.sort(); + await visit(options.sourceRoot, [], options.patterns); + return { candidates: candidates.sort(), errorsByPattern }; } function canGlobMatchDescendant(pattern: string, directorySegments: string[]): boolean { diff --git a/packages/server/src/utils/worktree.posix.test.ts b/packages/server/src/utils/worktree.posix.test.ts index 15806acdbd..53a555cf4d 100644 --- a/packages/server/src/utils/worktree.posix.test.ts +++ b/packages/server/src/utils/worktree.posix.test.ts @@ -363,6 +363,68 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { expect(metadata).toMatchObject({ baseRefName: "main" }); }); + it("removes fetched branches when include planning fails", async () => { + const remoteDir = join(tempDir, "remote.git"); + const remoteCloneDir = join(tempDir, "remote-clone"); + execFileSync("git", ["clone", "--bare", repoDir, remoteDir]); + execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir }); + + execFileSync("git", ["clone", remoteDir, remoteCloneDir]); + execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: remoteCloneDir }); + execFileSync("git", ["config", "user.name", "Test"], { cwd: remoteCloneDir }); + execFileSync("git", ["checkout", "-b", "contributor/cleanup"], { cwd: remoteCloneDir }); + writeFileSync(join(remoteCloneDir, "file.txt"), "from-pr\n"); + execFileSync("git", ["add", "file.txt"], { cwd: remoteCloneDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "pr branch"], { + cwd: remoteCloneDir, + }); + const prHead = execFileSync("git", ["rev-parse", "HEAD"], { cwd: remoteCloneDir }) + .toString() + .trim(); + execFileSync("git", ["push", "origin", "contributor/cleanup"], { cwd: remoteCloneDir }); + execFileSync("git", [`--git-dir=${remoteDir}`, "update-ref", "refs/pull/44/head", prHead]); + mkdirSync(join(repoDir, ".worktreeinclude")); + + await expect( + createLegacyWorktreeForTest({ + cwd: repoDir, + worktreeSlug: "pr-44-cleanup", + source: { + kind: "checkout-github-pr", + githubPrNumber: 44, + headRef: "contributor/cleanup", + baseRefName: "main", + }, + runSetup: false, + paseoHome, + }), + ).rejects.toMatchObject({ code: "EISDIR" }); + + expect(() => + execFileSync("git", ["show-ref", "--verify", "--quiet", "refs/heads/contributor/cleanup"], { + cwd: repoDir, + stdio: "pipe", + }), + ).toThrow(); + + await expect( + createLegacyWorktreeForTest({ + cwd: repoDir, + worktreeSlug: "branch-cleanup", + source: { kind: "checkout-branch", branchName: "contributor/cleanup" }, + runSetup: false, + paseoHome, + }), + ).rejects.toMatchObject({ code: "EISDIR" }); + + expect(() => + execFileSync("git", ["show-ref", "--verify", "--quiet", "refs/heads/contributor/cleanup"], { + cwd: repoDir, + stdio: "pipe", + }), + ).toThrow(); + }); + it("fetches a GitHub PR branch when the head ref contains uppercase letters and dots", async () => { const remoteDir = join(tempDir, "remote.git"); const remoteCloneDir = join(tempDir, "remote-clone"); @@ -1223,6 +1285,43 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => { ).toContain("feature/protected-include"); }); + it("keeps includes when branching from a Paseo-managed worktree", async () => { + writeFileSync(join(repoDir, ".gitignore"), ".env\n"); + writeFileSync(join(repoDir, ".worktreeinclude"), ".env\n"); + execFileSync("git", ["add", ".gitignore", ".worktreeinclude"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "add include config"], { + cwd: repoDir, + }); + writeFileSync(join(repoDir, ".env"), "source\n"); + + const sourceWorktree = await createLegacyWorktreeForTest({ + cwd: repoDir, + worktreeSlug: "include-source", + source: { + kind: "branch-off", + baseBranch: "main", + branchName: "feature/include-source", + }, + runSetup: false, + paseoHome, + }); + const nestedWorktree = await createLegacyWorktreeForTest({ + cwd: sourceWorktree.worktreePath, + worktreeSlug: "include-nested", + source: { + kind: "branch-off", + baseBranch: "feature/include-source", + branchName: "feature/include-nested", + }, + runSetup: false, + paseoHome, + }); + + expect(readFileSync(join(sourceWorktree.worktreePath, ".env"), "utf8")).toBe("source\n"); + expect(readFileSync(join(nestedWorktree.worktreePath, ".env"), "utf8")).toBe("source\n"); + expect(nestedWorktree.worktreeIncludeSummary?.skipped).toEqual([]); + }); + it("skips a symlink include conflict and keeps the new worktree", async () => { const projectHash = await deriveWorktreeProjectHash(repoDir); const expectedWorktreePath = join(paseoHome, "worktrees", projectHash, "include-conflict"); diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index e60d6dbb61..4592e28bf8 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -1171,6 +1171,45 @@ export interface RollbackCreatedPaseoWorktreeOptions extends DeletePaseoWorktree createdBranchName?: string; } +async function removeCreatedWorktreeBranch(options: { + createdBranchName?: string; + cwd?: string | null; +}): Promise { + if (!options.createdBranchName || !options.cwd) { + return; + } + if (!(await localBranchExists(options.cwd, options.createdBranchName))) { + return; + } + await runGitCommand(["branch", "--delete", "--force", options.createdBranchName], { + cwd: options.cwd, + }); +} + +async function rollbackCreatedWorktreeBranch( + options: { + createdBranchName?: string; + cwd: string; + }, + cause: unknown, +): Promise { + let cleanupError: unknown; + try { + await removeCreatedWorktreeBranch(options); + } catch (error) { + cleanupError = error; + } + if (cleanupError) { + const failure = new Error( + `${cause instanceof Error ? cause.message : "Worktree workflow failed"}; rollback also failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`, + { cause }, + ); + Object.assign(failure, { cleanupError }); + throw failure; + } + throw cause; +} + export async function rollbackCreatedPaseoWorktree( options: RollbackCreatedPaseoWorktreeOptions, cause: unknown, @@ -1178,11 +1217,7 @@ export async function rollbackCreatedPaseoWorktree( let cleanupError: unknown; try { await deletePaseoWorktree(options); - if (options.createdBranchName && options.cwd) { - await runGitCommand(["branch", "--delete", "--force", options.createdBranchName], { - cwd: options.cwd, - }); - } + await removeCreatedWorktreeBranch(options); } catch (error) { cleanupError = error; } @@ -1250,57 +1285,70 @@ export const createWorktree = async ({ worktreesRoot, }: CreateWorktreeOptions): Promise => { const sourcePlan = await resolveWorktreeSourcePlan({ cwd, source, desiredSlug: worktreeSlug }); - const paseoWorktreesRoot = await getPaseoWorktreesRoot(cwd, paseoHome, worktreesRoot); - const worktreeIncludePlan = await readWorktreeIncludePlan({ - sourceRoot: cwd, - excludedSourceRoots: [paseoWorktreesRoot], - }); - let worktreePath = join(paseoWorktreesRoot, worktreeSlug); - mkdirSync(dirname(worktreePath), { recursive: true }); - - // Also handle worktree path collision - let finalWorktreePath = worktreePath; - let pathSuffix = 1; - while (existsSync(finalWorktreePath)) { - finalWorktreePath = `${worktreePath}-${pathSuffix}`; - pathSuffix++; - } - - // Primitive owner for `git worktree add`; callers route through createWorktreeCore. - await runGitCommand(["worktree", "add", finalWorktreePath, ...sourcePlan.addArguments], { - cwd, - timeout: 120_000, - }); - worktreePath = normalizePathForOwnership(finalWorktreePath); + const { worktreeIncludePlan, worktreePath } = await (async () => { + try { + const paseoWorktreesRoot = await getPaseoWorktreesRoot(cwd, paseoHome, worktreesRoot); + const includePlan = await readWorktreeIncludePlan({ + sourceRoot: cwd, + excludedSourceRoots: [paseoWorktreesRoot], + }); + const requestedWorktreePath = join(paseoWorktreesRoot, worktreeSlug); + mkdirSync(dirname(requestedWorktreePath), { recursive: true }); + + // Also handle worktree path collision + let finalWorktreePath = requestedWorktreePath; + let pathSuffix = 1; + while (existsSync(finalWorktreePath)) { + finalWorktreePath = `${requestedWorktreePath}-${pathSuffix}`; + pathSuffix++; + } - if (sourcePlan.pushRemote) { - await configureWorktreePushRemote({ - cwd, - branchName: sourcePlan.branchName, - remote: sourcePlan.pushRemote, - }); - } - if (sourcePlan.trackingRemote) { - await configureWorktreeTrackingRemote({ - cwd, - branchName: sourcePlan.branchName, - remote: sourcePlan.trackingRemote, - }); - } + // Primitive owner for `git worktree add`; callers route through createWorktreeCore. + await runGitCommand(["worktree", "add", finalWorktreePath, ...sourcePlan.addArguments], { + cwd, + timeout: 120_000, + }); - writePaseoWorktreeMetadata(worktreePath, { - baseRefName: sourcePlan.metadataBaseRefName, - ...(sourcePlan.changeRequestLookupTarget - ? { changeRequestLookupTarget: sourcePlan.changeRequestLookupTarget } - : {}), - }); + return { + worktreeIncludePlan: includePlan, + worktreePath: normalizePathForOwnership(finalWorktreePath), + }; + } catch (error) { + return rollbackCreatedWorktreeBranch( + { cwd, createdBranchName: sourcePlan.createdBranchName }, + error, + ); + } + })(); - await seedPaseoConfigFile({ sourceCwd: cwd, targetCwd: worktreePath }); let worktreeIncludeSummary: WorktreeIncludeSummary = { materialized: 0, skipped: [...worktreeIncludePlan.skipped], }; try { + if (sourcePlan.pushRemote) { + await configureWorktreePushRemote({ + cwd, + branchName: sourcePlan.branchName, + remote: sourcePlan.pushRemote, + }); + } + if (sourcePlan.trackingRemote) { + await configureWorktreeTrackingRemote({ + cwd, + branchName: sourcePlan.branchName, + remote: sourcePlan.trackingRemote, + }); + } + + writePaseoWorktreeMetadata(worktreePath, { + baseRefName: sourcePlan.metadataBaseRefName, + ...(sourcePlan.changeRequestLookupTarget + ? { changeRequestLookupTarget: sourcePlan.changeRequestLookupTarget } + : {}), + }); + + await seedPaseoConfigFile({ sourceCwd: cwd, targetCwd: worktreePath }); const materialization = await materializeWorktreeIncludePlan({ plan: worktreeIncludePlan, worktreeRoot: worktreePath, @@ -1362,6 +1410,11 @@ interface WorktreeSourcePlan { }; } +type ChangeRequestWorktreeSource = Extract< + WorktreeSource, + { kind: "checkout-change-request" | "checkout-github-pr" } +>; + async function resolveWorktreeSourcePlan({ cwd, source, @@ -1385,89 +1438,122 @@ async function resolveWorktreeSourcePlan({ addArguments: ["-b", newBranchName, "--no-track", base], }; } - case "checkout-branch": { - await validateExistingWorktreeBranchName(cwd, source.branchName); - if (!(await localBranchExists(cwd, source.branchName))) { - try { - await runGitCommand(["fetch", "origin", `${source.branchName}:${source.branchName}`], { - cwd, - timeout: 120_000, - }); - } catch { - throw new UnknownBranchError({ branchName: source.branchName, cwd }); - } - } - if (await isBranchCheckedOut(cwd, source.branchName)) { - throw new BranchAlreadyCheckedOutError(source.branchName); - } - - return { - branchName: source.branchName, - metadataBaseRefName: source.branchName, - addArguments: [source.branchName], - }; - } + case "checkout-branch": + return resolveCheckoutBranchWorktreeSourcePlan({ cwd, branchName: source.branchName }); case "checkout-change-request": - case "checkout-github-pr": { - const localBranchCandidate = source.localBranchName ?? source.headRef; - await validateExistingWorktreeBranchName(cwd, localBranchCandidate); - const localBranchName = await resolveUniqueLocalBranchName(cwd, localBranchCandidate); - const normalizedBaseRefName = normalizeRequiredBaseBranch(source.baseRefName); - const changeRequestNumber = - source.kind === "checkout-github-pr" ? source.githubPrNumber : source.changeRequestNumber; - await fetchWorktreeCheckoutRefs({ - cwd, - localBranchName, - checkoutRefs: source.checkoutRefs ?? [ - { remoteName: "origin", remoteRef: `refs/pull/${changeRequestNumber}/head` }, - ], + case "checkout-github-pr": + return resolveChangeRequestWorktreeSourcePlan({ cwd, source }); + } +} + +async function resolveCheckoutBranchWorktreeSourcePlan(options: { + branchName: string; + cwd: string; +}): Promise { + await validateExistingWorktreeBranchName(options.cwd, options.branchName); + const needsFetch = !(await localBranchExists(options.cwd, options.branchName)); + if (needsFetch) { + try { + await runGitCommand(["fetch", "origin", `${options.branchName}:${options.branchName}`], { + cwd: options.cwd, + timeout: 120_000, }); - const shouldTrackOriginHead = source.trackOriginHead === true; - const trackingRemote = shouldTrackOriginHead - ? await tryFetchWorktreeTrackingRemote({ - cwd, - remoteName: "origin", - headRef: source.headRef, - }) - : undefined; - const remotePlan: Pick = {}; - if (source.pushRemoteUrl) { - const remoteName = `paseo-pr-${changeRequestNumber}`; + } catch { + return rollbackCreatedWorktreeBranch( + { cwd: options.cwd, createdBranchName: options.branchName }, + new UnknownBranchError({ branchName: options.branchName, cwd: options.cwd }), + ); + } + } + + try { + if (await isBranchCheckedOut(options.cwd, options.branchName)) { + throw new BranchAlreadyCheckedOutError(options.branchName); + } + } catch (error) { + if (!needsFetch) { + throw error; + } + return rollbackCreatedWorktreeBranch( + { cwd: options.cwd, createdBranchName: options.branchName }, + error, + ); + } + + return { + branchName: options.branchName, + ...(needsFetch ? { createdBranchName: options.branchName } : {}), + metadataBaseRefName: options.branchName, + addArguments: [options.branchName], + }; +} + +async function resolveChangeRequestWorktreeSourcePlan(options: { + cwd: string; + source: ChangeRequestWorktreeSource; +}): Promise { + const { cwd, source } = options; + const localBranchCandidate = source.localBranchName ?? source.headRef; + await validateExistingWorktreeBranchName(cwd, localBranchCandidate); + const localBranchName = await resolveUniqueLocalBranchName(cwd, localBranchCandidate); + const normalizedBaseRefName = normalizeRequiredBaseBranch(source.baseRefName); + const changeRequestNumber = + source.kind === "checkout-github-pr" ? source.githubPrNumber : source.changeRequestNumber; + + try { + await fetchWorktreeCheckoutRefs({ + cwd, + localBranchName, + checkoutRefs: source.checkoutRefs ?? [ + { remoteName: "origin", remoteRef: `refs/pull/${changeRequestNumber}/head` }, + ], + }); + const shouldTrackOriginHead = source.trackOriginHead === true; + const trackingRemote = shouldTrackOriginHead + ? await tryFetchWorktreeTrackingRemote({ + cwd, + remoteName: "origin", + headRef: source.headRef, + }) + : undefined; + const remotePlan: Pick = {}; + if (source.pushRemoteUrl) { + const remoteName = `paseo-pr-${changeRequestNumber}`; + remotePlan.pushRemote = { + name: remoteName, + url: source.pushRemoteUrl, + headRef: source.headRef, + track: true, + }; + } else if (shouldTrackOriginHead && localBranchName !== source.headRef) { + const originUrl = await getWorktreeRemotePushUrl(cwd, "origin"); + if (originUrl) { remotePlan.pushRemote = { - name: remoteName, - url: source.pushRemoteUrl, + name: `paseo-pr-${changeRequestNumber}`, + url: originUrl, headRef: source.headRef, - track: true, + track: false, }; - } else if (shouldTrackOriginHead && localBranchName !== source.headRef) { - const originUrl = await getWorktreeRemotePushUrl(cwd, "origin"); - if (originUrl) { - remotePlan.pushRemote = { - name: `paseo-pr-${changeRequestNumber}`, - url: originUrl, - headRef: source.headRef, - track: false, - }; - } } - if (trackingRemote) { - remotePlan.trackingRemote = trackingRemote; - } - - return { - branchName: localBranchName, - metadataBaseRefName: normalizedBaseRefName, - changeRequestLookupTarget: { - headRef: source.headRef, - ...(source.headRepositoryOwner - ? { headRepositoryOwner: source.headRepositoryOwner } - : {}), - changeRequestNumber, - }, - addArguments: [localBranchName], - ...remotePlan, - }; } + if (trackingRemote) { + remotePlan.trackingRemote = trackingRemote; + } + + return { + branchName: localBranchName, + createdBranchName: localBranchName, + metadataBaseRefName: normalizedBaseRefName, + changeRequestLookupTarget: { + headRef: source.headRef, + ...(source.headRepositoryOwner ? { headRepositoryOwner: source.headRepositoryOwner } : {}), + changeRequestNumber, + }, + addArguments: [localBranchName], + ...remotePlan, + }; + } catch (error) { + return rollbackCreatedWorktreeBranch({ cwd, createdBranchName: localBranchName }, error); } }