diff --git a/SECURITY.md b/SECURITY.md index 4acb376b4..dacb14c41 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -50,12 +50,6 @@ 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 31b35d012..cd96239ba 100644 --- a/docs/development.md +++ b/docs/development.md @@ -29,7 +29,6 @@ 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 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/server/paseo-worktree-service.ts b/packages/server/src/server/paseo-worktree-service.ts index fa2e61cf0..f09a81338 100644 --- a/packages/server/src/server/paseo-worktree-service.ts +++ b/packages/server/src/server/paseo-worktree-service.ts @@ -28,7 +28,6 @@ import type { WorktreeCreationIntent } from "./resolve-worktree-creation-intent. import { resolveFirstAgentPromptTitle } from "./agent/create-agent-title.js"; import { buildAgentBranchNameSeed } from "./agent/prompt-attachments.js"; import type { FirstAgentContext } from "@getpaseo/protocol/messages"; -import type { WorktreeIncludeSummary } from "../utils/worktree-include.js"; export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput { projectId?: string; @@ -37,7 +36,6 @@ export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput { export interface CreatePaseoWorktreeResult { worktree: WorktreeConfig; - worktreeIncludeSummary?: WorktreeIncludeSummary; intent: WorktreeCreationIntent; workspace: PersistedWorkspaceRecord; repoRoot: string; @@ -100,7 +98,6 @@ export async function createPaseoWorktree( return { worktree: createdWorktree.worktree, - worktreeIncludeSummary: createdWorktree.worktreeIncludeSummary, intent: createdWorktree.intent, workspace, repoRoot: createdWorktree.repoRoot, diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index d010a47a1..a87aa42e6 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -746,7 +746,6 @@ export class Session { logger: this.sessionLogger, }); this.workspaceRecovery = createWorkspaceRecoveryService({ - logger: this.sessionLogger, paseoHome: this.paseoHome, worktreesRoot: this.worktreesRoot, getWorkspace: (workspaceId) => this.workspaceRegistry.get(workspaceId), diff --git a/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.test.ts b/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.test.ts index 2203e3072..91631a563 100644 --- a/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.test.ts +++ b/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.test.ts @@ -76,7 +76,6 @@ function createHarness(input?: { const directories = new Set(input?.directories ?? ["/repo"]); const unarchived: string[] = []; const service = createWorkspaceRecoveryService({ - logger: { warn: () => undefined } as never, paseoHome: input?.paseoHome ?? "/paseo-home", worktreesRoot: input?.worktreesRoot ?? "/worktrees", getWorkspace: async (workspaceId) => @@ -136,7 +135,6 @@ describe("workspace recovery", () => { const sourceSubdirectory = join(repoDir, "packages", "app"); mkdirSync(sourceSubdirectory, { recursive: true }); writeFileSync(join(sourceSubdirectory, "README.md"), "app\n"); - writeFileSync(join(repoDir, ".worktreeinclude"), "missing.local\n"); execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["commit", "-m", "add app"], { cwd: repoDir, stdio: "pipe" }); execFileSync("git", ["branch", branch], { cwd: repoDir, stdio: "pipe" }); @@ -172,9 +170,7 @@ describe("workspace recovery", () => { mainRepoRoot: repoDir, }); const unarchived: string[] = []; - const warnings: unknown[][] = []; const service = createWorkspaceRecoveryService({ - logger: { warn: (...args: unknown[]) => warnings.push(args) } as never, paseoHome, worktreesRoot, getWorkspace: async (workspaceId) => @@ -193,15 +189,6 @@ describe("workspace recovery", () => { expect(existsSync(worktreeRoot)).toBe(true); expect(existsSync(workspaceCwd)).toBe(true); expect(unarchived).toEqual([workspace.workspaceId]); - expect(warnings).toEqual([ - [ - expect.objectContaining({ - materialized: 0, - skipped: [expect.objectContaining({ raw: "missing.local", reason: "missing" })], - }), - "Worktree include completed with skipped entries during workspace recovery", - ], - ]); }); test("keeps an exact-subdirectory workspace archived when its branch lacks that directory", async () => { @@ -233,7 +220,6 @@ describe("workspace recovery", () => { }); const unarchived: string[] = []; const service = createWorkspaceRecoveryService({ - logger: { warn: () => undefined } as never, paseoHome, worktreesRoot, getWorkspace: async (workspaceId) => diff --git a/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.ts b/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.ts index 539dc218f..1d98a9128 100644 --- a/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.ts +++ b/packages/server/src/server/session/workspace-recovery/workspace-recovery-service.ts @@ -1,5 +1,4 @@ import { basename } from "node:path"; -import type { Logger } from "pino"; import { createRealpathAwarePathMatcher } from "../../../utils/path.js"; import { runGitCommand } from "../../../utils/run-git-command.js"; @@ -60,7 +59,6 @@ type RecoveryPlan = type UnavailableRecoveryState = Extract; export function createWorkspaceRecoveryService(deps: { - logger: Logger; paseoHome: string; worktreesRoot?: string; getWorkspace: (workspaceId: string) => Promise; @@ -197,16 +195,6 @@ export function createWorkspaceRecoveryService(deps: { worktreesRoot: deps.worktreesRoot, }); recreatedWorktreePath = result.worktreePath; - if (result.worktreeIncludeSummary.skipped.length > 0) { - deps.logger.warn( - { - materialized: result.worktreeIncludeSummary.materialized, - skipped: result.worktreeIncludeSummary.skipped, - worktreePath: result.worktreePath, - }, - "Worktree include completed with skipped entries during workspace recovery", - ); - } } catch (error) { throw toWorktreeRequestError(error); } diff --git a/packages/server/src/server/worktree-core.ts b/packages/server/src/server/worktree-core.ts index c83fdf7b6..90d078890 100644 --- a/packages/server/src/server/worktree-core.ts +++ b/packages/server/src/server/worktree-core.ts @@ -8,7 +8,6 @@ import { validateBranchSlug, type WorktreeConfig, } from "../utils/worktree.js"; -import type { WorktreeIncludeSummary } from "../utils/worktree-include.js"; import { resolveWorktreeCreationIntent, type ResolveWorktreeCreationIntentInput, @@ -43,7 +42,6 @@ export interface CreateWorktreeCoreDeps { export interface CreateWorktreeCoreResult { worktree: WorktreeConfig; - worktreeIncludeSummary?: WorktreeIncludeSummary; intent: WorktreeCreationIntent; repoRoot: string; created: boolean; @@ -122,17 +120,15 @@ export async function createWorktreeCore( return { worktree: existingWorktree, intent, repoRoot, created: false }; } - const { worktreeIncludeSummary, ...worktree } = await createWorktree({ - cwd: repoRoot, - worktreeSlug: normalizedSlug, - source: intent, - runSetup: input.runSetup ?? true, - paseoHome: input.paseoHome, - worktreesRoot: input.worktreesRoot, - }); return { - worktree, - worktreeIncludeSummary, + worktree: await createWorktree({ + cwd: repoRoot, + worktreeSlug: normalizedSlug, + source: intent, + runSetup: input.runSetup ?? true, + paseoHome: input.paseoHome, + worktreesRoot: input.worktreesRoot, + }), intent, repoRoot, created: true, diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index 3b558affc..5573a945f 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -605,17 +605,6 @@ export async function createPaseoWorktreeWorkflow( const workspace = createdWorktree.workspace; const setupContinuation = options?.setupContinuation ?? { kind: "workspace" }; - if (createdWorktree.created && createdWorktree.worktreeIncludeSummary?.skipped.length) { - dependencies.sessionLogger.warn( - { - materialized: createdWorktree.worktreeIncludeSummary.materialized, - skipped: createdWorktree.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 deleted file mode 100644 index b846b9b7c..000000000 --- a/packages/server/src/utils/worktree-include.test.ts +++ /dev/null @@ -1,635 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { - existsSync, - chmodSync, - lstatSync, - linkSync, - mkdirSync, - mkdtempSync, - readdirSync, - readFileSync, - readlinkSync, - realpathSync, - rmSync, - symlinkSync, - writeFileSync, -} from "fs"; -import { tmpdir } from "os"; -import { dirname, join, relative } 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("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", - ), - ); - 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("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"), - ["../outside", "symlink", "shared", "symlink shared", ".env", ""].join("\n"), - ); - - 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" }), - ]), - ); - }); - - 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"); - - 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", - }), - ]); - expect(plan.skipped).toEqual( - expect.arrayContaining([ - expect.objectContaining({ raw: ".env.local", reason: "missing" }), - expect.objectContaining({ raw: "missing/**", reason: "missing" }), - ]), - ); - }); - - it("requires the optimized trailing recursive form to resolve to a directory", async () => { - writeFileSync(join(sourceRoot, "cache"), "not-a-directory\n"); - writeFileSync(join(sourceRoot, ".worktreeinclude"), "cache/**\n"); - - const plan = await readWorktreeIncludePlan({ sourceRoot }); - - expect(plan.materializations).toEqual([]); - expect(plan.skipped).toEqual([expect.objectContaining({ raw: "cache/**", reason: "unsafe" })]); - }); - - 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"); - - const plan = await readWorktreeIncludePlan({ - sourceRoot, - excludedSourceRoots: [protectedWorktreeRoot], - }); - - expect(plan.materializations).toEqual([]); - expect(plan.skipped).toEqual([expect.objectContaining({ raw: ".dev/**", reason: "unsafe" })]); - }); - - it("protects every managed project beneath checkout-local worktree storage", async () => { - const managedWorktreesRoot = join(sourceRoot, ".dev", "paseo-home", "worktrees"); - const siblingWorktree = join(managedWorktreesRoot, "other-project", "sibling"); - mkdirSync(siblingWorktree, { recursive: true }); - writeFileSync(join(siblingWorktree, ".env"), "secret\n"); - writeFileSync( - join(sourceRoot, ".worktreeinclude"), - ".dev/paseo-home/worktrees/other-project/sibling/.env\n", - ); - - const plan = await readWorktreeIncludePlan({ - sourceRoot, - excludedSourceRoots: [managedWorktreesRoot], - }); - - expect(plan.materializations).toEqual([]); - expect(plan.skipped).toEqual([expect.objectContaining({ 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 () => { - 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"); - - 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" }), - ]); - }, - ); - - it.skipIf(isPlatform("win32"))("matches globs beneath a safe symlinked directory", async () => { - mkdirSync(join(sourceRoot, "actual-config")); - writeFileSync(join(sourceRoot, "actual-config", "runtime.env"), "runtime\n"); - symlinkSync(join(sourceRoot, "actual-config"), join(sourceRoot, "config"), "dir"); - writeFileSync(join(sourceRoot, ".worktreeinclude"), "config/*.env\n"); - - const plan = await readWorktreeIncludePlan({ sourceRoot }); - - expect(plan.materializations).toEqual([ - expect.objectContaining({ relativePath: "config/runtime.env", sourceKind: "file" }), - ]); - expect(plan.skipped).toEqual([]); - }); - - 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); - } - }, - ); - - 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); - } - }, - ); - - it.skipIf(isPlatform("win32") || process.getuid?.() === 0)( - "retains valid glob matches when a viable sibling is unreadable", - async () => { - mkdirSync(join(sourceRoot, "packages", "good"), { recursive: true }); - mkdirSync(join(sourceRoot, "packages", "private"), { recursive: true }); - writeFileSync(join(sourceRoot, "packages", "good", ".runtime.env"), "good\n"); - writeFileSync(join(sourceRoot, "packages", "private", ".runtime.env"), "private\n"); - writeFileSync(join(sourceRoot, ".worktreeinclude"), "packages/*/.runtime.env\n"); - chmodSync(join(sourceRoot, "packages", "private"), 0o000); - - try { - const plan = await readWorktreeIncludePlan({ sourceRoot }); - expect(plan.materializations).toEqual([ - expect.objectContaining({ relativePath: "packages/good/.runtime.env" }), - ]); - expect(plan.skipped).toEqual([ - expect.objectContaining({ raw: "packages/*/.runtime.env", reason: "materialization" }), - ]); - } finally { - chmodSync(join(sourceRoot, "packages", "private"), 0o700); - } - }, - ); -}); - -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("replaces an existing directory snapshot without retaining destination-only files", async () => { - mkdirSync(join(sourceRoot, "cache")); - writeFileSync(join(sourceRoot, "cache", "current.txt"), "current\n"); - writeFileSync(join(sourceRoot, ".worktreeinclude"), "cache/**\n"); - mkdirSync(join(worktreeRoot, "cache")); - writeFileSync(join(worktreeRoot, "cache", "stale.txt"), "stale\n"); - - const plan = await readWorktreeIncludePlan({ sourceRoot }); - const result = await materializeWorktreeIncludePlan({ plan, worktreeRoot }); - - expect(result).toMatchObject({ materialized: 1, skipped: [] }); - expect(readFileSync(join(worktreeRoot, "cache", "current.txt"), "utf8")).toBe("current\n"); - expect(existsSync(join(worktreeRoot, "cache", "stale.txt"))).toBe(false); - }); - - it("retains an explicit descendant when an overlapping directory copy is skipped", async () => { - mkdirSync(join(sourceRoot, "config", "nested"), { recursive: true }); - writeFileSync(join(sourceRoot, "config", "local.env"), "local\n"); - writeFileSync(join(sourceRoot, "config", "nested", "state.txt"), "state\n"); - writeFileSync(join(sourceRoot, ".worktreeinclude"), "config/**\nconfig/local.env\n"); - mkdirSync(join(worktreeRoot, "config")); - writeFileSync(join(worktreeRoot, "config", "nested"), "conflict\n"); - - const plan = await readWorktreeIncludePlan({ sourceRoot }); - const result = await materializeWorktreeIncludePlan({ plan, worktreeRoot }); - - expect(result).toMatchObject({ materialized: 1 }); - expect(result.skipped).toEqual([ - expect.objectContaining({ raw: "config/**", reason: "conflict" }), - ]); - expect(readFileSync(join(worktreeRoot, "config", "local.env"), "utf8")).toBe("local\n"); - }); - - 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, "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 }); - 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 () => { - 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("rejects source links that alias Git metadata", async () => { - mkdirSync(join(sourceRoot, ".git")); - writeFileSync(join(sourceRoot, ".git", "config"), "secret\n"); - symlinkSync(join(sourceRoot, ".git"), join(sourceRoot, "metadata"), "dir"); - writeFileSync(join(sourceRoot, ".worktreeinclude"), "metadata\n"); - - const plan = await readWorktreeIncludePlan({ sourceRoot }); - - expect(plan.materializations).toEqual([]); - expect(plan.skipped).toEqual([expect.objectContaining({ raw: "metadata", reason: "unsafe" })]); - }); - - 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(sharedPath, "outside.txt")); - writeFileSync(join(sourceRoot, ".worktreeinclude"), "symlink shared\n"); - - const plan = await readWorktreeIncludePlan({ sourceRoot }); - - 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", - ); - }); - - 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); - - 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" }), - ]); - }); - - it.skipIf(process.getuid?.() === 0)( - "skips ordinary filesystem failures during materialization revalidation", - async () => { - mkdirSync(join(sourceRoot, "private")); - writeFileSync(join(sourceRoot, "private", "state.txt"), "private\n"); - writeFileSync(join(sourceRoot, ".worktreeinclude"), "private/**\n"); - const plan = await readWorktreeIncludePlan({ sourceRoot }); - chmodSync(join(sourceRoot, "private"), 0o000); - - try { - const result = await materializeWorktreeIncludePlan({ plan, worktreeRoot }); - expect(result).toMatchObject({ materialized: 0 }); - expect(result.skipped).toEqual([ - expect.objectContaining({ raw: "private/**", reason: "materialization" }), - ]); - } finally { - chmodSync(join(sourceRoot, "private"), 0o700); - } - }, - ); -}); - -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 shared-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 deleted file mode 100644 index 32c00abbc..000000000 --- a/packages/server/src/utils/worktree-include.ts +++ /dev/null @@ -1,1359 +0,0 @@ -import { type Dirent, type Stats } from "fs"; -import { - copyFile, - cp, - lstat, - mkdir, - mkdtemp, - readFile, - readdir, - realpath, - rename, - rm, - rmdir, - symlink, -} from "fs/promises"; -import { - basename as pathBasename, - dirname, - isAbsolute, - join, - relative, - resolve, - win32, -} from "path"; -import { areEquivalentPaths, isPathInsideRoot } from "./path.js"; - -const WORKTREE_INCLUDE_FILE_NAME = ".worktreeinclude"; - -export type WorktreeIncludeMode = "copy" | "symlink"; -type WorktreeIncludeSourceKind = "file" | "directory"; -type WorktreeIncludeErrorCode = - | "conflict" - | "invalid_entry" - | "missing_source" - | "source_changed" - | "unsupported_source" - | "windows_symlink_unavailable"; - -export type WorktreeIncludeSkipReason = - | "conflict" - | "invalid" - | "materialization" - | "missing" - | "source_changed" - | "unsafe"; - -interface WorktreeIncludeEntry { - lineNumber: number; - mode: WorktreeIncludeMode; - raw: string; - relativePath: string; -} - -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; -} - -export interface ReadWorktreeIncludePlanOptions { - excludedSourceRoots?: string[]; - sourceRoot: string; -} - -export interface MaterializeWorktreeIncludePlanOptions { - plan: WorktreeIncludePlan; - worktreeRoot: string; -} - -interface ResolvedWorktreeIncludeMaterialization { - materialization: WorktreeIncludeMaterialization; - sourcePath: string; -} - -interface StagedWorktreeIncludeMaterialization { - directoryPath: string; - entryPath: string; -} - -interface ParsedWorktreeIncludeEntries { - entries: WorktreeIncludeEntry[]; - skipped: WorktreeIncludeSkippedEntry[]; -} - -interface NormalizedWorktreeIncludeMaterializations { - materializations: WorktreeIncludeMaterialization[]; - skipped: WorktreeIncludeSkippedEntry[]; -} - -interface WorktreeIncludeCandidateCollection { - candidates: string[]; - errorsByPattern: Map; -} - -export class WorktreeIncludeError extends Error { - constructor( - public readonly code: WorktreeIncludeErrorCode, - message: string, - ) { - super(message); - this.name = "WorktreeIncludeError"; - } -} - -class WorktreeIncludeCleanupError extends Error { - constructor( - message: string, - public readonly cleanupError: unknown, - ) { - super(message); - this.name = "WorktreeIncludeCleanupError"; - } -} - -export async function readWorktreeIncludePlan( - options: ReadWorktreeIncludePlanOptions, -): Promise { - const sourceRoot = await realpath(options.sourceRoot); - const { entries, skipped } = await readWorktreeIncludeEntries(sourceRoot); - if (entries.length === 0) { - return { - sourceRoot, - excludedSourceRoots: [], - materializations: [], - skipped, - }; - } - - 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 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: candidateCollection.candidates }); - const candidateError = candidateCollection.errorsByPattern.get(entry.relativePath); - if (candidateError !== undefined) { - skipped.push(toSkippedEntry(entry, candidateError)); - if (matchedPaths.length === 0) { - continue; - } - } - if (matchedPaths.length === 0) { - skipped.push(toSkippedEntry(entry, noMatchError(entry))); - continue; - } - - for (const relativePath of matchedPaths) { - try { - const resolved = await resolveSourceMaterialization({ - entry, - excludedSourceRoots, - relativePath, - sourceRoot, - }); - materializations.push(resolved.materialization); - } catch (error) { - if (!isWorktreeIncludeMaterializationError(error)) { - throw error; - } - skipped.push(toSkippedEntry(entry, error)); - } - } - } - - const normalized = normalizeMaterializations(materializations); - - return { - excludedSourceRoots, - materializations: normalized.materializations, - skipped: [...skipped, ...normalized.skipped], - sourceRoot, - }; -} - -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 { - const skipped: WorktreeIncludeSkippedEntry[] = []; - let materialized = 0; - if (options.plan.materializations.length === 0) { - return { materialized, skipped }; - } - - 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, - excludedSourceRoots: options.plan.excludedSourceRoots, - relativePath: materialization.relativePath, - sourceRoot: options.plan.sourceRoot, - }); - } catch (error) { - if (isSkippableWorktreeIncludeError(error) || getErrorCode(error) !== null) { - skipped.push(toSkippedEntry(entry, error)); - continue; - } - throw error; - } - if (resolved.materialization.sourceKind !== materialization.sourceKind) { - skipped.push( - toSkippedEntry( - entry, - new WorktreeIncludeError( - "source_changed", - `Source for .worktreeinclude entry '${materialization.raw}' changed type before it could be materialized`, - ), - ), - ); - continue; - } - - let staged: StagedWorktreeIncludeMaterialization | null = null; - let createdDestinationParents: string[] = []; - try { - 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 WorktreeIncludeCleanupError) { - throw error; - } - if (staged !== null) { - await cleanupStagingDirectory(staged.directoryPath); - } - await cleanupCreatedDestinationParents(createdDestinationParents); - if (isWorktreeIncludeMaterializationError(error)) { - skipped.push(toSkippedEntry(entry, error)); - continue; - } - throw error; - } - } - - 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, - }); - if (options.resolved.materialization.sourceKind === "directory") { - const stagedStats = await lstat(entryPath); - if (!stagedStats.isDirectory()) { - throw new WorktreeIncludeError( - "source_changed", - `.worktreeinclude entry '${options.resolved.materialization.raw}' changed type while it was staged`, - ); - } - await assertCopyDirectorySafe({ - entry: options.resolved.materialization, - sourcePath: entryPath, - }); - } - } else { - await createMaterializationSymlink({ - destinationPath: entryPath, - linkDestinationPath: options.destinationPath, - resolved: options.resolved, - }); - } - return { directoryPath, entryPath }; - } catch (error) { - await cleanupStagingDirectory(directoryPath); - throw error; - } -} - -async function copyStagedMaterializationToExistingDestination(options: { - destinationPath: string; - resolved: ResolvedWorktreeIncludeMaterialization; - staged: StagedWorktreeIncludeMaterialization; -}): Promise { - const backupPath = join(options.staged.directoryPath, "backup"); - await rename(options.destinationPath, backupPath); - try { - await rename(options.staged.entryPath, options.destinationPath); - } catch (error) { - await restoreCopiedDestination({ - backupPath, - destinationPath: options.destinationPath, - }); - 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; -}): Promise { - try { - const destinationStats = await lstatIfExists(options.destinationPath); - if (destinationStats !== null) { - if (destinationStats.isSymbolicLink()) { - throw new Error("destination changed while restoring a failed materialization"); - } - 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, - ); - } -} - -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, - ); - } -} - -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( - sourceRoot: string, -): Promise { - let contents: string; - try { - contents = await readFile(join(sourceRoot, WORKTREE_INCLUDE_FILE_NAME), "utf8"); - } catch (error) { - if (getErrorCode(error) === "ENOENT") { - 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; - const line = sourceLine.trim(); - if (line.length === 0) { - continue; - } - - if (line.startsWith("#")) { - continue; - } - - try { - entries.push(parseWorktreeIncludeEntry({ line, lineNumber })); - } catch (error) { - if (!isSkippableWorktreeIncludeError(error)) { - throw error; - } - skipped.push(toSkippedEntry({ lineNumber, raw: line }, error)); - } - } - - return { entries, skipped }; -} - -function parseWorktreeIncludeEntry(options: { - line: string; - lineNumber: number; -}): WorktreeIncludeEntry { - let mode: WorktreeIncludeMode = "copy"; - let path = options.line; - const separatorIndex = options.line.search(/\s/); - - if (separatorIndex === -1) { - if (options.line === "copy" || options.line === "symlink") { - throw new WorktreeIncludeError( - "invalid_entry", - `.worktreeinclude ${options.line} entry on line ${options.lineNumber} requires a path`, - ); - } - } else { - const verb = options.line.slice(0, separatorIndex); - if (verb === "copy" || verb === "symlink") { - mode = verb; - path = options.line.slice(separatorIndex).trim(); - } - } - - return { - lineNumber: options.lineNumber, - mode, - raw: options.line, - relativePath: normalizeRelativePath({ entry: path, lineNumber: options.lineNumber }), - }; -} - -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[]; - patterns: string[]; - sourceRoot: string; -}): Promise { - const candidates: string[] = []; - const errorsByPattern = new Map(); - - async function visit( - directoryPath: string, - directorySegments: string[], - patterns: string[], - ancestorCanonicalDirectories: ReadonlySet, - ): 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; - } - - const pathSegments = [...directorySegments, entry.name]; - const sourcePath = join(options.sourceRoot, ...pathSegments); - if ( - options.excludedSourceRoots.some((excludedRoot) => - isPathInsideRoot(excludedRoot, sourcePath), - ) - ) { - continue; - } - - const relativePath = pathSegments.join("/"); - if (patterns.some((pattern) => worktreeIncludeGlobMatches(pattern, relativePath))) { - candidates.push(relativePath); - } - const descendantPatterns = patterns.filter((pattern) => - canGlobMatchDescendant(pattern, pathSegments), - ); - if ((entry.isDirectory() || entry.isSymbolicLink()) && descendantPatterns.length > 0) { - try { - const canonicalDirectory = await realpath(sourcePath); - const canonicalStats = await lstat(canonicalDirectory); - const canonicalRelativePath = relative(options.sourceRoot, canonicalDirectory); - const isGitMetadata = canonicalRelativePath - .split(/[\\/]/) - .some((segment) => segment.toLowerCase() === ".git"); - const overlapsProtectedRoot = options.excludedSourceRoots.some( - (excludedRoot) => - isPathInsideRoot(excludedRoot, canonicalDirectory) || - isPathInsideRoot(canonicalDirectory, excludedRoot), - ); - if ( - !canonicalStats.isDirectory() || - !isPathInsideRoot(options.sourceRoot, canonicalDirectory) || - isGitMetadata || - overlapsProtectedRoot || - ancestorCanonicalDirectories.has(canonicalDirectory) - ) { - continue; - } - await visit( - sourcePath, - pathSegments, - descendantPatterns, - new Set([...ancestorCanonicalDirectories, canonicalDirectory]), - ); - } catch (error) { - if (getErrorCode(error) === null) { - throw error; - } - for (const pattern of descendantPatterns) { - errorsByPattern.set(pattern, error); - } - } - } - } - } - - await visit( - options.sourceRoot, - [], - options.patterns, - new Set([await realpath(options.sourceRoot)]), - ); - return { candidates: candidates.sort(), errorsByPattern }; -} - -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; -}): 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; - excludedSourceRoots: string[]; - relativePath: string; - sourceRoot: string; -}): Promise { - 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 ( - getRecursiveDirectoryPath(options.entry) === options.relativePath && - sourceKind !== "directory" - ) { - throw new WorktreeIncludeError( - "unsupported_source", - `.worktreeinclude entry '${options.entry.raw}' on line ${options.entry.lineNumber} requires a directory`, - ); - } - 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, - }, - sourcePath, - }; -} - -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 canonicalRelativePath = relative(options.sourceRoot, options.sourcePath); - if (canonicalRelativePath.split(/[\\/]/).some((segment) => segment.toLowerCase() === ".git")) { - throw new WorktreeIncludeError( - "unsupported_source", - `.worktreeinclude entry '${options.entry.raw}' on line ${options.entry.lineNumber} resolves into Git metadata`, - ); - } - - const excludedRoot = options.excludedSourceRoots.find( - (candidate) => - isPathInsideRoot(options.sourcePath, candidate) || - isPathInsideRoot(candidate, options.sourcePath), - ); - if (excludedRoot === undefined) { - return; - } - - throw new WorktreeIncludeError( - "unsupported_source", - `.worktreeinclude entry '${options.entry.raw}' on line ${options.entry.lineNumber} overlaps with a protected worktree path`, - ); -} - -async function getCanonicalSourceKind(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} changed while its source path was resolved`, - ); - } - 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`, - ); - } - - 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); - } catch (error) { - if (getErrorCode(error) === "ENOENT" || getErrorCode(error) === "ENOTDIR") { - throw noMatchError(entry); - } - throw error; - } -} - -async function assertCopyDirectorySafe(options: { - entry: WorktreeIncludeEntry; - sourcePath: string; -}): Promise { - 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`, - ); - } - - 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 }); - } - } -} - -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[], -): NormalizedWorktreeIncludeMaterializations { - const byPath = new Map(); - for (const materialization of materializations) { - 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; - } - - 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 = 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; - }); - return { materializations: sorted, skipped }; -} - -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; - const destinationPath = getDestinationPath({ - worktreeRoot: options.worktreeRoot, - relativePath: materialization.relativePath, - }); - const destinationStats = await lstatIfExists(destinationPath); - if (destinationStats === null) { - return true; - } - - if (destinationStats.isSymbolicLink()) { - if ( - materialization.mode === "symlink" && - (await isExpectedSymlink({ - destinationPath, - sourcePath: options.resolved.sourcePath, - })) - ) { - return false; - } - 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, - }); - } - return true; -} - -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 ensureDestinationParent(options: { - relativePath: string; - worktreeRoot: string; -}): Promise { - const parentSegments = options.relativePath.split("/").slice(0, -1); - let currentPath = options.worktreeRoot; - 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: { - 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 createMaterializationSymlink(options: { - destinationPath: string; - linkDestinationPath?: string; - resolved: ResolvedWorktreeIncludeMaterialization; -}): Promise { - if (process.platform !== "win32") { - const target = relative( - dirname(options.linkDestinationPath ?? options.destinationPath), - options.resolved.sourcePath, - ); - await symlink(target, options.destinationPath); - return; - } - - let type: "dir" | "file" | "junction" = "file"; - if (options.resolved.materialization.sourceKind === "directory") { - type = isWindowsNetworkPath(options.resolved.sourcePath) ? "dir" : "junction"; - } - try { - await symlink(options.resolved.sourcePath, options.destinationPath, type); - } catch (error) { - throw toWindowsSymlinkError({ error, entry: options.resolved.materialization }); - } -} - -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 ${options.entry.relativePath}.`, - ); -} - -async function isExpectedSymlink(options: { - destinationPath: string; - sourcePath: string; -}): Promise { - try { - const [destinationTarget, sourceTarget] = await Promise.all([ - realpath(options.destinationPath), - realpath(options.sourcePath), - ]); - return areEquivalentPaths(destinationTarget, sourceTarget); - } catch { - return false; - } -} - -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 toSkippedEntry( - entry: Pick, - error: unknown, -): WorktreeIncludeSkippedEntry { - return { - lineNumber: entry.lineNumber, - message: error instanceof Error ? error.message : String(error), - raw: entry.raw, - reason: getSkipReason(error), - }; -} - -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"; - } -} - -function isSkippableWorktreeIncludeError(error: unknown): error is WorktreeIncludeError { - 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; - } - 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 53a555cf4..d895df539 100644 --- a/packages/server/src/utils/worktree.posix.test.ts +++ b/packages/server/src/utils/worktree.posix.test.ts @@ -34,7 +34,6 @@ import { writeFileSync, readFileSync, chmodSync, - lstatSync, } from "fs"; import { delimiter, dirname, join } from "path"; import { tmpdir } from "os"; @@ -363,68 +362,6 @@ 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"); @@ -1125,241 +1062,6 @@ 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("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"), - [".env", ".env.local", ".sops.yaml", ""].join("\n"), - ); - writeFileSync(join(repoDir, ".env"), "present\n"); - - const result = await createLegacyWorktreeForTest({ - cwd: repoDir, - worktreeSlug: "missing-include", - source: { kind: "branch-off", baseBranch: "main", branchName: "feature/missing-include" }, - runSetup: false, - paseoHome, - }); - - 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", - }), - ).toContain(expectedWorktreePath); - }); - - 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( - checkoutLocalPaseoHome, - "worktrees", - projectHash, - "protected-include", - ); - mkdirSync(join(checkoutLocalPaseoHome, "worktrees", projectHash), { recursive: true }); - writeFileSync(join(repoDir, ".worktreeinclude"), [".dev/**", ".env", ""].join("\n")); - writeFileSync(join(repoDir, ".env"), "present\n"); - - const result = await createLegacyWorktreeForTest({ - cwd: repoDir, - worktreeSlug: "protected-include", - source: { - kind: "branch-off", - baseBranch: "main", - branchName: "feature/protected-include", - }, - runSetup: false, - paseoHome: checkoutLocalPaseoHome, - }); - - 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", - }), - ).toContain(expectedWorktreePath); - expect( - execFileSync("git", ["branch", "--list", "feature/protected-include"], { - cwd: repoDir, - encoding: "utf8", - }).trim(), - ).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"); - writeFileSync(join(repoDir, "paseo.json"), JSON.stringify({ scripts: {} })); - writeFileSync(join(repoDir, ".worktreeinclude"), "symlink paseo.json\n"); - - const result = await createLegacyWorktreeForTest({ - cwd: repoDir, - worktreeSlug: "include-conflict", - source: { - kind: "branch-off", - baseBranch: "main", - branchName: "feature/include-conflict", - }, - runSetup: false, - paseoHome, - }); - - 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", - }), - ).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 () => { const result = await createLegacyWorktreeForTest({ cwd: repoDir, diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index f46ae6c54..4ab641806 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -4,7 +4,7 @@ import { existsSync, mkdirSync, realpathSync, rmSync, statSync } from "fs"; import { copyFile, rm, stat } from "fs/promises"; import { join, basename, dirname, isAbsolute, resolve, sep } from "path"; import net from "node:net"; -import { createHash, randomUUID } from "node:crypto"; +import { createHash } from "node:crypto"; import stripAnsi from "strip-ansi"; import { buildStringCommandShellInvocation, @@ -36,11 +36,6 @@ 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, - type WorktreeIncludeSummary, -} from "./worktree-include.js"; export { slugify, validateBranchSlug } from "@getpaseo/protocol/branch-slug"; @@ -54,10 +49,6 @@ export interface WorktreeConfig { worktreePath: string; } -export interface CreatedWorktreeConfig extends WorktreeConfig { - worktreeIncludeSummary: WorktreeIncludeSummary; -} - export interface WorktreeRuntimeEnv { [key: string]: string; PASEO_SOURCE_CHECKOUT_PATH: string; @@ -1170,67 +1161,13 @@ export async function deletePaseoWorktree({ } } -export interface RollbackCreatedPaseoWorktreeOptions extends DeletePaseoWorktreeOptions { - createdBranchName?: string; - expectedOid?: string; -} - -async function removeCreatedWorktreeBranch(options: { - createdBranchName?: string; - expectedOid?: string; - cwd?: string | null; -}): Promise { - if (!options.createdBranchName || !options.cwd) { - return; - } - if (!(await localBranchExists(options.cwd, options.createdBranchName))) { - return; - } - if (options.expectedOid) { - await runGitCommand( - ["update-ref", "-d", `refs/heads/${options.createdBranchName}`, options.expectedOid], - { cwd: options.cwd }, - ); - } else { - await runGitCommand(["branch", "--delete", "--force", options.createdBranchName], { - cwd: options.cwd, - }); - } -} - -async function rollbackCreatedWorktreeBranch( - options: { - createdBranchName?: string; - expectedOid?: 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, + options: DeletePaseoWorktreeOptions, cause: unknown, ): Promise { let cleanupError: unknown; try { await deletePaseoWorktree(options); - await removeCreatedWorktreeBranch(options); } catch (error) { cleanupError = error; } @@ -1296,100 +1233,50 @@ export const createWorktree = async ({ runSetup, paseoHome, worktreesRoot, -}: CreateWorktreeOptions): Promise => { +}: CreateWorktreeOptions): Promise => { const sourcePlan = await resolveWorktreeSourcePlan({ cwd, source, desiredSlug: worktreeSlug }); - const { worktreeIncludePlan, worktreePath } = await (async () => { - try { - const paseoWorktreesBaseRoot = resolvePaseoWorktreesBaseRoot({ paseoHome, worktreesRoot }); - const paseoWorktreesRoot = await getPaseoWorktreesRoot(cwd, paseoHome, worktreesRoot); - const includePlan = await readWorktreeIncludePlan({ - sourceRoot: cwd, - excludedSourceRoots: [paseoWorktreesBaseRoot], - }); - const requestedWorktreePath = join(paseoWorktreesRoot, worktreeSlug); - mkdirSync(dirname(requestedWorktreePath), { recursive: true }); + let worktreePath = join(await getPaseoWorktreesRoot(cwd, paseoHome, worktreesRoot), worktreeSlug); + mkdirSync(dirname(worktreePath), { recursive: true }); - // Also handle worktree path collision - let finalWorktreePath = requestedWorktreePath; - let pathSuffix = 1; - while (existsSync(finalWorktreePath)) { - finalWorktreePath = `${requestedWorktreePath}-${pathSuffix}`; - pathSuffix++; - } - - // Primitive owner for `git worktree add`; callers route through createWorktreeCore. - await runGitCommand(["worktree", "add", finalWorktreePath, ...sourcePlan.addArguments], { - cwd, - timeout: 120_000, - }); - - return { - worktreeIncludePlan: includePlan, - worktreePath: normalizePathForOwnership(finalWorktreePath), - }; - } catch (error) { - return rollbackCreatedWorktreeBranch( - { - cwd, - createdBranchName: sourcePlan.createdBranchNameBeforeWorktreeAdd, - expectedOid: sourcePlan.createdBranchOidBeforeWorktreeAdd, - }, - error, - ); - } - })(); - - 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, - }); - worktreeIncludeSummary = { - materialized: materialization.materialized, - skipped: [...worktreeIncludePlan.skipped, ...materialization.skipped], - }; - } catch (error) { - await rollbackCreatedPaseoWorktree( - { - cwd, - worktreePath, - teardownCwds: [], - paseoHome, - worktreesBaseRoot: worktreesRoot, - createdBranchName: sourcePlan.createdBranchName, - expectedOid: sourcePlan.createdBranchOidBeforeWorktreeAdd, - }, - error, - ); + // 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); + + 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 }); + if (runSetup) { await runWorktreeSetupCommands({ worktreePath, @@ -1400,7 +1287,6 @@ export const createWorktree = async ({ return { branchName: sourcePlan.branchName, - worktreeIncludeSummary, worktreePath, }; }; @@ -1413,9 +1299,6 @@ interface ResolveWorktreeSourcePlanOptions { interface WorktreeSourcePlan { branchName: string; - createdBranchName?: string; - createdBranchNameBeforeWorktreeAdd?: string; - createdBranchOidBeforeWorktreeAdd?: string; metadataBaseRefName: string; changeRequestLookupTarget?: PaseoWorktreeChangeRequestLookupTarget; addArguments: string[]; @@ -1431,11 +1314,6 @@ interface WorktreeSourcePlan { }; } -type ChangeRequestWorktreeSource = Extract< - WorktreeSource, - { kind: "checkout-change-request" | "checkout-github-pr" } ->; - async function resolveWorktreeSourcePlan({ cwd, source, @@ -1454,143 +1332,93 @@ async function resolveWorktreeSourcePlan({ return { branchName: newBranchName, - createdBranchName: newBranchName, metadataBaseRefName: normalizedBaseBranch, addArguments: ["-b", newBranchName, "--no-track", base], }; } - case "checkout-branch": - return resolveCheckoutBranchWorktreeSourcePlan({ cwd, branchName: source.branchName }); - case "checkout-change-request": - 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)); - let createdBranchOid: string | undefined; - if (needsFetch) { - try { - createdBranchOid = await fetchNewLocalBranchAtomically({ - cwd: options.cwd, - localBranchName: options.branchName, - remoteName: "origin", - remoteRef: `refs/heads/${options.branchName}`, - }); - } catch { - throw 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, - expectedOid: createdBranchOid, - }, - error, - ); - } - - return { - branchName: options.branchName, - ...(needsFetch - ? { - createdBranchName: options.branchName, - createdBranchNameBeforeWorktreeAdd: options.branchName, - createdBranchOidBeforeWorktreeAdd: createdBranchOid, + 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 }); } - : {}), - 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; - - let createdBranchOid: string | undefined; - try { - createdBranchOid = 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: `paseo-pr-${changeRequestNumber}`, - url: originUrl, - headRef: source.headRef, - track: false, - }; } - } - if (trackingRemote) { - remotePlan.trackingRemote = trackingRemote; - } + if (await isBranchCheckedOut(cwd, source.branchName)) { + throw new BranchAlreadyCheckedOutError(source.branchName); + } - return { - branchName: localBranchName, - createdBranchName: localBranchName, - createdBranchNameBeforeWorktreeAdd: localBranchName, - createdBranchOidBeforeWorktreeAdd: createdBranchOid, - metadataBaseRefName: normalizedBaseRefName, - changeRequestLookupTarget: { - headRef: source.headRef, - ...(source.headRepositoryOwner ? { headRepositoryOwner: source.headRepositoryOwner } : {}), - changeRequestNumber, - }, - addArguments: [localBranchName], - ...remotePlan, - }; - } catch (error) { - return rollbackCreatedWorktreeBranch( - { cwd, createdBranchName: localBranchName, expectedOid: createdBranchOid }, - error, - ); + return { + branchName: source.branchName, + metadataBaseRefName: source.branchName, + addArguments: [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` }, + ], + }); + 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: `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, + }; + } } } @@ -1643,22 +1471,27 @@ async function fetchWorktreeCheckoutRefs(options: { cwd: string; localBranchName: string; checkoutRefs: WorktreeCheckoutRef[]; -}): Promise { +}): Promise { let lastResult: | Awaited> | { stderr: string; stdout: string; exitCode: number | null } | null = null; for (const checkoutRef of options.checkoutRefs) { - try { - return await fetchNewLocalBranchAtomically({ + lastResult = await runGitCommand( + [ + "fetch", + checkoutRef.remoteName ?? "origin", + `+${checkoutRef.remoteRef}:refs/heads/${options.localBranchName}`, + "--force", + ], + { cwd: options.cwd, - localBranchName: options.localBranchName, - remoteName: checkoutRef.remoteName ?? "origin", - remoteRef: checkoutRef.remoteRef, - }); - } catch (error) { - lastResult = - error instanceof Error ? { stderr: error.message, stdout: "", exitCode: 1 } : null; + timeout: 120_000, + acceptExitCodes: [0, 1, 128], + }, + ); + if (lastResult.exitCode === 0) { + return; } } const attemptedRefs = options.checkoutRefs @@ -1669,35 +1502,6 @@ async function fetchWorktreeCheckoutRefs(options: { ); } -async function fetchNewLocalBranchAtomically(options: { - cwd: string; - localBranchName: string; - remoteName: string; - remoteRef: string; -}): Promise { - const temporaryRef = `refs/paseo/worktree-fetch/${randomUUID()}`; - try { - await runGitCommand( - ["fetch", options.remoteName, `${options.remoteRef}:${temporaryRef}`, "--force"], - { cwd: options.cwd, timeout: 120_000 }, - ); - const { stdout } = await runGitCommand(["rev-parse", "--verify", temporaryRef], { - cwd: options.cwd, - }); - const oid = stdout.trim(); - const nullOid = "0".repeat(oid.length); - await runGitCommand(["update-ref", `refs/heads/${options.localBranchName}`, oid, nullOid], { - cwd: options.cwd, - }); - return oid; - } finally { - await runGitCommand(["update-ref", "-d", temporaryRef], { - cwd: options.cwd, - acceptExitCodes: [0, 1, 128], - }); - } -} - async function tryFetchWorktreeTrackingRemote(options: { cwd: string; remoteName: string; diff --git a/public-docs/worktrees.md b/public-docs/worktrees.md index 90b49b4b1..bdc8a24f2 100644 --- a/public-docs/worktrees.md +++ b/public-docs/worktrees.md @@ -110,46 +110,6 @@ 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/** - - # Modes can also be explicit. - symlink node_modules - copy .tool-state/** - -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 or directory replaces an existing path on -a later materialization. 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. A top-level source symbolic link is -dereferenced only when its canonical target remains inside the source checkout: `copy` snapshots -that target and `symlink` links directly to it. Directory snapshots reject 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, 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. - ## 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.