Revert "Carry local files and shared state into new worktrees (#2419)"

This reverts commit cbbf6c1684.
This commit is contained in:
Mohamed Boudra
2026-07-30 00:37:52 +02:00
parent 20e46e3e3f
commit c6df5ffd4d
13 changed files with 145 additions and 2725 deletions
@@ -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,
-1
View File
@@ -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),
@@ -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) =>
@@ -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<WorkspaceRecoveryState, { kind: "unavailable" }>;
export function createWorkspaceRecoveryService(deps: {
logger: Logger;
paseoHome: string;
worktreesRoot?: string;
getWorkspace: (workspaceId: string) => Promise<PersistedWorkspaceRecord | null>;
@@ -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);
}
+8 -12
View File
@@ -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,
@@ -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({
@@ -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");
});
});
File diff suppressed because it is too large Load Diff
@@ -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,
+137 -333
View File
@@ -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<void> {
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<never> {
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<never> {
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<CreatedWorktreeConfig> => {
}: CreateWorktreeOptions): Promise<WorktreeConfig> => {
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<WorktreeSourcePlan> {
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<WorktreeSourcePlan> {
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<WorktreeSourcePlan, "pushRemote" | "trackingRemote"> = {};
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<WorktreeSourcePlan, "pushRemote" | "trackingRemote"> = {};
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<string> {
}): Promise<void> {
let lastResult:
| Awaited<ReturnType<typeof runGitCommand>>
| { 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<string> {
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;