Carry local files and shared state into new worktrees (#2419)
* feat(server): support symlink worktree includes * refactor(server): simplify worktree include handling * fix(server): constrain worktree include traversal * fix(server): clean up failed worktree branches * fix(server): skip missing worktree include entries * fix(server): make worktree includes best effort * fix(server): skip failed worktree includes * fix(server): harden worktree include planning * fix(server): preserve reused worktree result shape Materialization reports describe one creation attempt, not durable worktree identity. Carry them beside the worktree so newly created and reused results keep the same stable shape. * fix(server): harden worktree include boundaries Replace directory snapshots exactly, keep canonical Git metadata protected, report recovery skips, and only roll back branches owned before worktree creation. * fix(server): follow safe include aliases Traverse canonical in-checkout directory links during glob planning and treat coded revalidation failures as per-entry skips. * fix(server): make include preflight race safe Create fetched checkout refs atomically, retain overlapping copy entries for independent fallback, and protect the full managed-worktree base. * fix(server): preserve staged recovery state Validate completed directory snapshots, retain backups after failed restoration, and derive atomic ref guards from the repository object ID width. * fix(server): preserve partial include progress Keep safe glob matches, enforce recursive-directory types, validate staged roots, and retain OID guards through rollback. --------- Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
co-authored by
Mohamed Boudra
parent
89c2fac3c6
commit
cbbf6c1684
@@ -28,6 +28,7 @@ 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;
|
||||
@@ -36,6 +37,7 @@ export interface CreatePaseoWorktreeInput extends CreateWorktreeCoreInput {
|
||||
|
||||
export interface CreatePaseoWorktreeResult {
|
||||
worktree: WorktreeConfig;
|
||||
worktreeIncludeSummary?: WorktreeIncludeSummary;
|
||||
intent: WorktreeCreationIntent;
|
||||
workspace: PersistedWorkspaceRecord;
|
||||
repoRoot: string;
|
||||
@@ -98,6 +100,7 @@ export async function createPaseoWorktree(
|
||||
|
||||
return {
|
||||
worktree: createdWorktree.worktree,
|
||||
worktreeIncludeSummary: createdWorktree.worktreeIncludeSummary,
|
||||
intent: createdWorktree.intent,
|
||||
workspace,
|
||||
repoRoot: createdWorktree.repoRoot,
|
||||
|
||||
@@ -745,6 +745,7 @@ export class Session {
|
||||
logger: this.sessionLogger,
|
||||
});
|
||||
this.workspaceRecovery = createWorkspaceRecoveryService({
|
||||
logger: this.sessionLogger,
|
||||
paseoHome: this.paseoHome,
|
||||
worktreesRoot: this.worktreesRoot,
|
||||
getWorkspace: (workspaceId) => this.workspaceRegistry.get(workspaceId),
|
||||
|
||||
+14
@@ -76,6 +76,7 @@ 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) =>
|
||||
@@ -135,6 +136,7 @@ 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" });
|
||||
@@ -170,7 +172,9 @@ 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) =>
|
||||
@@ -189,6 +193,15 @@ 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 () => {
|
||||
@@ -220,6 +233,7 @@ describe("workspace recovery", () => {
|
||||
});
|
||||
const unarchived: string[] = [];
|
||||
const service = createWorkspaceRecoveryService({
|
||||
logger: { warn: () => undefined } as never,
|
||||
paseoHome,
|
||||
worktreesRoot,
|
||||
getWorkspace: async (workspaceId) =>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { basename } from "node:path";
|
||||
import type { Logger } from "pino";
|
||||
|
||||
import { createRealpathAwarePathMatcher } from "../../../utils/path.js";
|
||||
import { runGitCommand } from "../../../utils/run-git-command.js";
|
||||
@@ -59,6 +60,7 @@ type RecoveryPlan =
|
||||
type UnavailableRecoveryState = Extract<WorkspaceRecoveryState, { kind: "unavailable" }>;
|
||||
|
||||
export function createWorkspaceRecoveryService(deps: {
|
||||
logger: Logger;
|
||||
paseoHome: string;
|
||||
worktreesRoot?: string;
|
||||
getWorkspace: (workspaceId: string) => Promise<PersistedWorkspaceRecord | null>;
|
||||
@@ -195,6 +197,16 @@ 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,6 +8,7 @@ import {
|
||||
validateBranchSlug,
|
||||
type WorktreeConfig,
|
||||
} from "../utils/worktree.js";
|
||||
import type { WorktreeIncludeSummary } from "../utils/worktree-include.js";
|
||||
import {
|
||||
resolveWorktreeCreationIntent,
|
||||
type ResolveWorktreeCreationIntentInput,
|
||||
@@ -42,6 +43,7 @@ export interface CreateWorktreeCoreDeps {
|
||||
|
||||
export interface CreateWorktreeCoreResult {
|
||||
worktree: WorktreeConfig;
|
||||
worktreeIncludeSummary?: WorktreeIncludeSummary;
|
||||
intent: WorktreeCreationIntent;
|
||||
repoRoot: string;
|
||||
created: boolean;
|
||||
@@ -120,15 +122,17 @@ 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: await createWorktree({
|
||||
cwd: repoRoot,
|
||||
worktreeSlug: normalizedSlug,
|
||||
source: intent,
|
||||
runSetup: input.runSetup ?? true,
|
||||
paseoHome: input.paseoHome,
|
||||
worktreesRoot: input.worktreesRoot,
|
||||
}),
|
||||
worktree,
|
||||
worktreeIncludeSummary,
|
||||
intent,
|
||||
repoRoot,
|
||||
created: true,
|
||||
|
||||
@@ -605,6 +605,17 @@ 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({
|
||||
|
||||
@@ -0,0 +1,635 @@
|
||||
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,6 +34,7 @@ import {
|
||||
writeFileSync,
|
||||
readFileSync,
|
||||
chmodSync,
|
||||
lstatSync,
|
||||
} from "fs";
|
||||
import { delimiter, dirname, join } from "path";
|
||||
import { tmpdir } from "os";
|
||||
@@ -362,6 +363,68 @@ describe.skipIf(isPlatform("win32"))("worktree POSIX-only", () => {
|
||||
expect(metadata).toMatchObject({ baseRefName: "main" });
|
||||
});
|
||||
|
||||
it("removes fetched branches when include planning fails", async () => {
|
||||
const remoteDir = join(tempDir, "remote.git");
|
||||
const remoteCloneDir = join(tempDir, "remote-clone");
|
||||
execFileSync("git", ["clone", "--bare", repoDir, remoteDir]);
|
||||
execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: repoDir });
|
||||
|
||||
execFileSync("git", ["clone", remoteDir, remoteCloneDir]);
|
||||
execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: remoteCloneDir });
|
||||
execFileSync("git", ["config", "user.name", "Test"], { cwd: remoteCloneDir });
|
||||
execFileSync("git", ["checkout", "-b", "contributor/cleanup"], { cwd: remoteCloneDir });
|
||||
writeFileSync(join(remoteCloneDir, "file.txt"), "from-pr\n");
|
||||
execFileSync("git", ["add", "file.txt"], { cwd: remoteCloneDir });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "pr branch"], {
|
||||
cwd: remoteCloneDir,
|
||||
});
|
||||
const prHead = execFileSync("git", ["rev-parse", "HEAD"], { cwd: remoteCloneDir })
|
||||
.toString()
|
||||
.trim();
|
||||
execFileSync("git", ["push", "origin", "contributor/cleanup"], { cwd: remoteCloneDir });
|
||||
execFileSync("git", [`--git-dir=${remoteDir}`, "update-ref", "refs/pull/44/head", prHead]);
|
||||
mkdirSync(join(repoDir, ".worktreeinclude"));
|
||||
|
||||
await expect(
|
||||
createLegacyWorktreeForTest({
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "pr-44-cleanup",
|
||||
source: {
|
||||
kind: "checkout-github-pr",
|
||||
githubPrNumber: 44,
|
||||
headRef: "contributor/cleanup",
|
||||
baseRefName: "main",
|
||||
},
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "EISDIR" });
|
||||
|
||||
expect(() =>
|
||||
execFileSync("git", ["show-ref", "--verify", "--quiet", "refs/heads/contributor/cleanup"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
}),
|
||||
).toThrow();
|
||||
|
||||
await expect(
|
||||
createLegacyWorktreeForTest({
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "branch-cleanup",
|
||||
source: { kind: "checkout-branch", branchName: "contributor/cleanup" },
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "EISDIR" });
|
||||
|
||||
expect(() =>
|
||||
execFileSync("git", ["show-ref", "--verify", "--quiet", "refs/heads/contributor/cleanup"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
|
||||
it("fetches a GitHub PR branch when the head ref contains uppercase letters and dots", async () => {
|
||||
const remoteDir = join(tempDir, "remote.git");
|
||||
const remoteCloneDir = join(tempDir, "remote-clone");
|
||||
@@ -1062,6 +1125,241 @@ 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,
|
||||
|
||||
@@ -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 } from "node:crypto";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import stripAnsi from "strip-ansi";
|
||||
import {
|
||||
buildStringCommandShellInvocation,
|
||||
@@ -36,6 +36,11 @@ import { createExternalProcessEnv } from "../server/paseo-env.js";
|
||||
import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js";
|
||||
import { validateBranchSlug } from "@getpaseo/protocol/branch-slug";
|
||||
import { expandTilde, getRealpathAwareRelativePath, isPathInsideRoot } from "./path.js";
|
||||
import {
|
||||
materializeWorktreeIncludePlan,
|
||||
readWorktreeIncludePlan,
|
||||
type WorktreeIncludeSummary,
|
||||
} from "./worktree-include.js";
|
||||
|
||||
export { slugify, validateBranchSlug } from "@getpaseo/protocol/branch-slug";
|
||||
|
||||
@@ -49,6 +54,10 @@ export interface WorktreeConfig {
|
||||
worktreePath: string;
|
||||
}
|
||||
|
||||
export interface CreatedWorktreeConfig extends WorktreeConfig {
|
||||
worktreeIncludeSummary: WorktreeIncludeSummary;
|
||||
}
|
||||
|
||||
export interface WorktreeRuntimeEnv {
|
||||
[key: string]: string;
|
||||
PASEO_SOURCE_CHECKOUT_PATH: string;
|
||||
@@ -1161,13 +1170,67 @@ 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: DeletePaseoWorktreeOptions,
|
||||
options: RollbackCreatedPaseoWorktreeOptions,
|
||||
cause: unknown,
|
||||
): Promise<never> {
|
||||
let cleanupError: unknown;
|
||||
try {
|
||||
await deletePaseoWorktree(options);
|
||||
await removeCreatedWorktreeBranch(options);
|
||||
} catch (error) {
|
||||
cleanupError = error;
|
||||
}
|
||||
@@ -1233,50 +1296,100 @@ export const createWorktree = async ({
|
||||
runSetup,
|
||||
paseoHome,
|
||||
worktreesRoot,
|
||||
}: CreateWorktreeOptions): Promise<WorktreeConfig> => {
|
||||
}: CreateWorktreeOptions): Promise<CreatedWorktreeConfig> => {
|
||||
const sourcePlan = await resolveWorktreeSourcePlan({ cwd, source, desiredSlug: worktreeSlug });
|
||||
let worktreePath = join(await getPaseoWorktreesRoot(cwd, paseoHome, worktreesRoot), worktreeSlug);
|
||||
mkdirSync(dirname(worktreePath), { recursive: true });
|
||||
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 });
|
||||
|
||||
// Also handle worktree path collision
|
||||
let finalWorktreePath = worktreePath;
|
||||
let pathSuffix = 1;
|
||||
while (existsSync(finalWorktreePath)) {
|
||||
finalWorktreePath = `${worktreePath}-${pathSuffix}`;
|
||||
pathSuffix++;
|
||||
}
|
||||
// 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,
|
||||
});
|
||||
worktreePath = normalizePathForOwnership(finalWorktreePath);
|
||||
// Primitive owner for `git worktree add`; callers route through createWorktreeCore.
|
||||
await runGitCommand(["worktree", "add", finalWorktreePath, ...sourcePlan.addArguments], {
|
||||
cwd,
|
||||
timeout: 120_000,
|
||||
});
|
||||
|
||||
if (sourcePlan.pushRemote) {
|
||||
await configureWorktreePushRemote({
|
||||
cwd,
|
||||
branchName: sourcePlan.branchName,
|
||||
remote: sourcePlan.pushRemote,
|
||||
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 }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
if (sourcePlan.trackingRemote) {
|
||||
await configureWorktreeTrackingRemote({
|
||||
cwd,
|
||||
branchName: sourcePlan.branchName,
|
||||
remote: sourcePlan.trackingRemote,
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
writePaseoWorktreeMetadata(worktreePath, {
|
||||
baseRefName: sourcePlan.metadataBaseRefName,
|
||||
...(sourcePlan.changeRequestLookupTarget
|
||||
? { changeRequestLookupTarget: sourcePlan.changeRequestLookupTarget }
|
||||
: {}),
|
||||
});
|
||||
|
||||
await seedPaseoConfigFile({ sourceCwd: cwd, targetCwd: worktreePath });
|
||||
|
||||
if (runSetup) {
|
||||
await runWorktreeSetupCommands({
|
||||
worktreePath,
|
||||
@@ -1287,6 +1400,7 @@ export const createWorktree = async ({
|
||||
|
||||
return {
|
||||
branchName: sourcePlan.branchName,
|
||||
worktreeIncludeSummary,
|
||||
worktreePath,
|
||||
};
|
||||
};
|
||||
@@ -1299,6 +1413,9 @@ interface ResolveWorktreeSourcePlanOptions {
|
||||
|
||||
interface WorktreeSourcePlan {
|
||||
branchName: string;
|
||||
createdBranchName?: string;
|
||||
createdBranchNameBeforeWorktreeAdd?: string;
|
||||
createdBranchOidBeforeWorktreeAdd?: string;
|
||||
metadataBaseRefName: string;
|
||||
changeRequestLookupTarget?: PaseoWorktreeChangeRequestLookupTarget;
|
||||
addArguments: string[];
|
||||
@@ -1314,6 +1431,11 @@ interface WorktreeSourcePlan {
|
||||
};
|
||||
}
|
||||
|
||||
type ChangeRequestWorktreeSource = Extract<
|
||||
WorktreeSource,
|
||||
{ kind: "checkout-change-request" | "checkout-github-pr" }
|
||||
>;
|
||||
|
||||
async function resolveWorktreeSourcePlan({
|
||||
cwd,
|
||||
source,
|
||||
@@ -1332,94 +1454,144 @@ async function resolveWorktreeSourcePlan({
|
||||
|
||||
return {
|
||||
branchName: newBranchName,
|
||||
createdBranchName: newBranchName,
|
||||
metadataBaseRefName: normalizedBaseBranch,
|
||||
addArguments: ["-b", newBranchName, "--no-track", base],
|
||||
};
|
||||
}
|
||||
case "checkout-branch": {
|
||||
await validateExistingWorktreeBranchName(cwd, source.branchName);
|
||||
if (!(await localBranchExists(cwd, source.branchName))) {
|
||||
try {
|
||||
await runGitCommand(["fetch", "origin", `${source.branchName}:${source.branchName}`], {
|
||||
cwd,
|
||||
timeout: 120_000,
|
||||
});
|
||||
} catch {
|
||||
throw new UnknownBranchError({ branchName: source.branchName, cwd });
|
||||
}
|
||||
}
|
||||
if (await isBranchCheckedOut(cwd, source.branchName)) {
|
||||
throw new BranchAlreadyCheckedOutError(source.branchName);
|
||||
}
|
||||
|
||||
return {
|
||||
branchName: source.branchName,
|
||||
metadataBaseRefName: source.branchName,
|
||||
addArguments: [source.branchName],
|
||||
};
|
||||
}
|
||||
case "checkout-branch":
|
||||
return resolveCheckoutBranchWorktreeSourcePlan({ cwd, branchName: source.branchName });
|
||||
case "checkout-change-request":
|
||||
case "checkout-github-pr": {
|
||||
const localBranchCandidate = source.localBranchName ?? source.headRef;
|
||||
await validateExistingWorktreeBranchName(cwd, localBranchCandidate);
|
||||
const localBranchName = await resolveUniqueLocalBranchName(cwd, localBranchCandidate);
|
||||
const normalizedBaseRefName = normalizeRequiredBaseBranch(source.baseRefName);
|
||||
const changeRequestNumber =
|
||||
source.kind === "checkout-github-pr" ? source.githubPrNumber : source.changeRequestNumber;
|
||||
await fetchWorktreeCheckoutRefs({
|
||||
cwd,
|
||||
localBranchName,
|
||||
checkoutRefs: source.checkoutRefs ?? [
|
||||
{ remoteName: "origin", remoteRef: `refs/pull/${changeRequestNumber}/head` },
|
||||
],
|
||||
});
|
||||
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;
|
||||
}
|
||||
case "checkout-github-pr":
|
||||
return resolveChangeRequestWorktreeSourcePlan({ cwd, source });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
branchName: localBranchName,
|
||||
metadataBaseRefName: normalizedBaseRefName,
|
||||
changeRequestLookupTarget: {
|
||||
headRef: source.headRef,
|
||||
...(source.headRepositoryOwner
|
||||
? { headRepositoryOwner: source.headRepositoryOwner }
|
||||
: {}),
|
||||
changeRequestNumber,
|
||||
},
|
||||
addArguments: [localBranchName],
|
||||
...remotePlan,
|
||||
};
|
||||
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,
|
||||
}
|
||||
: {}),
|
||||
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;
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function configureWorktreePushRemote(options: {
|
||||
@@ -1471,27 +1643,22 @@ async function fetchWorktreeCheckoutRefs(options: {
|
||||
cwd: string;
|
||||
localBranchName: string;
|
||||
checkoutRefs: WorktreeCheckoutRef[];
|
||||
}): Promise<void> {
|
||||
}): Promise<string> {
|
||||
let lastResult:
|
||||
| Awaited<ReturnType<typeof runGitCommand>>
|
||||
| { stderr: string; stdout: string; exitCode: number | null }
|
||||
| null = null;
|
||||
for (const checkoutRef of options.checkoutRefs) {
|
||||
lastResult = await runGitCommand(
|
||||
[
|
||||
"fetch",
|
||||
checkoutRef.remoteName ?? "origin",
|
||||
`+${checkoutRef.remoteRef}:refs/heads/${options.localBranchName}`,
|
||||
"--force",
|
||||
],
|
||||
{
|
||||
try {
|
||||
return await fetchNewLocalBranchAtomically({
|
||||
cwd: options.cwd,
|
||||
timeout: 120_000,
|
||||
acceptExitCodes: [0, 1, 128],
|
||||
},
|
||||
);
|
||||
if (lastResult.exitCode === 0) {
|
||||
return;
|
||||
localBranchName: options.localBranchName,
|
||||
remoteName: checkoutRef.remoteName ?? "origin",
|
||||
remoteRef: checkoutRef.remoteRef,
|
||||
});
|
||||
} catch (error) {
|
||||
lastResult =
|
||||
error instanceof Error ? { stderr: error.message, stdout: "", exitCode: 1 } : null;
|
||||
}
|
||||
}
|
||||
const attemptedRefs = options.checkoutRefs
|
||||
@@ -1502,6 +1669,35 @@ 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;
|
||||
|
||||
Reference in New Issue
Block a user