mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
fix(worktrees): make lifecycle operations transactional
This commit is contained in:
@@ -23,3 +23,34 @@ test("reconciliation emits workspace updates when observer sync fails", async ()
|
||||
|
||||
expect(emittedWorkspaceIds).toEqual([["ws-reclassified"]]);
|
||||
});
|
||||
|
||||
test("reconciliation isolates workspace update failures between sessions", async () => {
|
||||
const emittedWorkspaceIds: string[][] = [];
|
||||
const warnings: unknown[] = [];
|
||||
|
||||
await fanOutReconciledWorkspaceUpdates({
|
||||
sessions: [
|
||||
{
|
||||
syncWorkspaceGitObserversForExternalWorkspaceIds: async () => {},
|
||||
emitWorkspaceUpdatesForExternalWorkspaceIds: async () => {
|
||||
throw new Error("session closed");
|
||||
},
|
||||
},
|
||||
{
|
||||
syncWorkspaceGitObserversForExternalWorkspaceIds: async () => {},
|
||||
emitWorkspaceUpdatesForExternalWorkspaceIds: async (workspaceIds) => {
|
||||
emittedWorkspaceIds.push(Array.from(workspaceIds));
|
||||
},
|
||||
},
|
||||
],
|
||||
workspaceIds: ["ws-reclassified"],
|
||||
logger: {
|
||||
warn: (context) => {
|
||||
warnings.push(context);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(emittedWorkspaceIds).toEqual([["ws-reclassified"]]);
|
||||
expect(warnings).toHaveLength(1);
|
||||
});
|
||||
|
||||
@@ -110,9 +110,13 @@ export async function fanOutReconciledWorkspaceUpdates(input: {
|
||||
"Failed to sync workspace Git observers after reconciliation",
|
||||
);
|
||||
}
|
||||
await session.emitWorkspaceUpdatesForExternalWorkspaceIds(input.workspaceIds, {
|
||||
skipReconcile: true,
|
||||
});
|
||||
try {
|
||||
await session.emitWorkspaceUpdatesForExternalWorkspaceIds(input.workspaceIds, {
|
||||
skipReconcile: true,
|
||||
});
|
||||
} catch (error) {
|
||||
input.logger.warn({ err: error }, "Failed to emit workspace updates after reconciliation");
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
@@ -223,6 +223,30 @@ test("creates a worktree workspace at the selected project subdirectory", async
|
||||
});
|
||||
});
|
||||
|
||||
test("seeds an uncommitted exact-project config into the mapped worktree directory", async () => {
|
||||
const { repoDir, tempDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
const sourceDir = path.join(repoDir, "packages", "app");
|
||||
mkdirSync(sourceDir, { recursive: true });
|
||||
writeFileSync(path.join(sourceDir, "package.json"), "{}\n");
|
||||
commitAll(repoDir, "add subproject");
|
||||
const config = JSON.stringify({ worktree: { setup: ["npm install"] } });
|
||||
writeFileSync(path.join(sourceDir, "paseo.json"), config);
|
||||
|
||||
const result = await createPaseoWorktree(
|
||||
{
|
||||
cwd: sourceDir,
|
||||
worktreeSlug: "seed-nested-config",
|
||||
runSetup: false,
|
||||
paseoHome: path.join(tempDir, ".paseo"),
|
||||
},
|
||||
createDeps(),
|
||||
);
|
||||
|
||||
expect(readFileSync(path.join(result.workspace.cwd, "paseo.json"), "utf8")).toBe(config);
|
||||
expect(existsSync(path.join(result.worktree.worktreePath, "paseo.json"))).toBe(false);
|
||||
});
|
||||
|
||||
test("removes a new worktree when its ref does not contain the selected project directory", async () => {
|
||||
const { repoDir, tempDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
@@ -261,6 +285,36 @@ test("removes a new worktree when its ref does not contain the selected project
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("removes a new worktree when workspace persistence fails", async () => {
|
||||
const { repoDir, tempDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const worktreePath = path.join(
|
||||
await getPaseoWorktreesRoot(repoDir, paseoHome),
|
||||
"persistence-failure",
|
||||
);
|
||||
|
||||
await expect(
|
||||
createPaseoWorktree(
|
||||
{
|
||||
cwd: repoDir,
|
||||
projectId: "missing-project",
|
||||
worktreeSlug: "persistence-failure",
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
},
|
||||
createDeps(),
|
||||
),
|
||||
).rejects.toThrow("Unknown project: missing-project");
|
||||
|
||||
expect(existsSync(worktreePath)).toBe(false);
|
||||
expect(
|
||||
execFileSync("git", ["worktree", "list", "--porcelain"], { cwd: repoDir, stdio: "pipe" })
|
||||
.toString()
|
||||
.includes("persistence-failure"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("maps a nested cwd from an existing Paseo worktree into the next worktree", async () => {
|
||||
const { repoDir, tempDir } = createGitRepo();
|
||||
cleanupPaths.push(tempDir);
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
} from "./worktree-core.js";
|
||||
import {
|
||||
mapWorkspaceRelativeCwdToWorktree,
|
||||
deletePaseoWorktree,
|
||||
rollbackCreatedPaseoWorktree,
|
||||
seedPaseoConfigFile,
|
||||
validateBranchSlug,
|
||||
type WorktreeConfig,
|
||||
} from "../utils/worktree.js";
|
||||
@@ -64,42 +65,57 @@ export async function createPaseoWorktree(
|
||||
): Promise<CreatePaseoWorktreeResult> {
|
||||
const workspaceCwdPlan = await planWorkspaceCwdForWorktree(input.cwd, deps.workspaceGitService);
|
||||
const createdWorktree = await createWorktreeCore(input, deps);
|
||||
maybeMarkFirstAgentBranchAutoNameEligible({ createdWorktree });
|
||||
const workspaceCwd = mapWorkspaceRelativeCwdToWorktree({
|
||||
relativeWorkspaceCwd: workspaceCwdPlan.relativeWorkspaceCwd,
|
||||
targetWorktreePath: createdWorktree.worktree.worktreePath,
|
||||
});
|
||||
if (!(await isDirectory(workspaceCwd))) {
|
||||
try {
|
||||
maybeMarkFirstAgentBranchAutoNameEligible({ createdWorktree });
|
||||
const workspaceCwd = mapWorkspaceRelativeCwdToWorktree({
|
||||
relativeWorkspaceCwd: workspaceCwdPlan.relativeWorkspaceCwd,
|
||||
targetWorktreePath: createdWorktree.worktree.worktreePath,
|
||||
});
|
||||
if (!(await isDirectory(workspaceCwd))) {
|
||||
throw new Error(`Selected project directory is missing from the worktree: ${workspaceCwd}`);
|
||||
}
|
||||
|
||||
if (createdWorktree.created) {
|
||||
await deletePaseoWorktree({
|
||||
cwd: createdWorktree.repoRoot,
|
||||
worktreePath: createdWorktree.worktree.worktreePath,
|
||||
paseoHome: input.paseoHome,
|
||||
worktreesBaseRoot: input.worktreesRoot,
|
||||
await seedPaseoConfigFile({
|
||||
sourceCwd: workspaceCwdPlan.inputCwd,
|
||||
targetCwd: workspaceCwd,
|
||||
});
|
||||
}
|
||||
throw new Error(`Selected project directory is missing from the worktree: ${workspaceCwd}`);
|
||||
const workspace = await deps.workspaceProvisioning.createWorkspaceForWorktree({
|
||||
sourceCwd: workspaceCwdPlan.inputCwd,
|
||||
projectId: input.projectId,
|
||||
repoRoot: createdWorktree.repoRoot,
|
||||
cwd: workspaceCwd,
|
||||
worktreeRoot: createdWorktree.worktree.worktreePath,
|
||||
branch: createdWorktree.worktree.branchName || null,
|
||||
baseBranch: resolveIntentBaseBranch(createdWorktree.intent),
|
||||
title: resolveFirstAgentPromptTitle(input.firstAgentContext),
|
||||
});
|
||||
|
||||
deps.github.invalidate({ cwd: createdWorktree.worktree.worktreePath });
|
||||
|
||||
return {
|
||||
worktree: createdWorktree.worktree,
|
||||
intent: createdWorktree.intent,
|
||||
workspace,
|
||||
repoRoot: createdWorktree.repoRoot,
|
||||
created: createdWorktree.created,
|
||||
};
|
||||
} catch (error) {
|
||||
if (!createdWorktree.created) {
|
||||
throw error;
|
||||
}
|
||||
return rollbackCreatedPaseoWorktree(
|
||||
{
|
||||
cwd: createdWorktree.repoRoot,
|
||||
worktreePath: createdWorktree.worktree.worktreePath,
|
||||
...(input.runSetup === false ? { teardownCwds: [] } : {}),
|
||||
paseoHome: input.paseoHome,
|
||||
worktreesBaseRoot: input.worktreesRoot,
|
||||
},
|
||||
error,
|
||||
);
|
||||
}
|
||||
const workspace = await deps.workspaceProvisioning.createWorkspaceForWorktree({
|
||||
sourceCwd: workspaceCwdPlan.inputCwd,
|
||||
projectId: input.projectId,
|
||||
repoRoot: createdWorktree.repoRoot,
|
||||
cwd: workspaceCwd,
|
||||
worktreeRoot: createdWorktree.worktree.worktreePath,
|
||||
branch: createdWorktree.worktree.branchName || null,
|
||||
baseBranch: resolveIntentBaseBranch(createdWorktree.intent),
|
||||
title: resolveFirstAgentPromptTitle(input.firstAgentContext),
|
||||
});
|
||||
|
||||
deps.github.invalidate({ cwd: createdWorktree.worktree.worktreePath });
|
||||
|
||||
return {
|
||||
worktree: createdWorktree.worktree,
|
||||
intent: createdWorktree.intent,
|
||||
workspace,
|
||||
repoRoot: createdWorktree.repoRoot,
|
||||
created: createdWorktree.created,
|
||||
};
|
||||
}
|
||||
|
||||
async function isDirectory(targetPath: string): Promise<boolean> {
|
||||
|
||||
@@ -191,6 +191,62 @@ describe("workspace recovery", () => {
|
||||
expect(unarchived).toEqual([workspace.workspaceId]);
|
||||
});
|
||||
|
||||
test("keeps an exact-subdirectory workspace archived when its branch lacks that directory", async () => {
|
||||
const { tempDir, repoDir } = createGitRepository();
|
||||
const branch = "feature/without-subproject";
|
||||
execFileSync("git", ["branch", branch], { cwd: repoDir, stdio: "pipe" });
|
||||
const paseoHome = join(tempDir, "paseo-home");
|
||||
const worktreesRoot = join(tempDir, "worktrees");
|
||||
const created = await createWorktree({
|
||||
cwd: repoDir,
|
||||
worktreeSlug: "without-subproject",
|
||||
source: { kind: "checkout-branch", branchName: branch },
|
||||
runSetup: false,
|
||||
paseoHome,
|
||||
worktreesRoot,
|
||||
});
|
||||
const worktreeRoot = realpathSync(created.worktreePath);
|
||||
const workspaceCwd = join(worktreeRoot, "packages", "app");
|
||||
rmSync(worktreeRoot, { recursive: true, force: true });
|
||||
execFileSync("git", ["worktree", "prune"], { cwd: repoDir, stdio: "pipe" });
|
||||
|
||||
const project = createProject({ rootPath: repoDir });
|
||||
const workspace = createWorkspace({
|
||||
workspaceId: "ws-missing-restored-subdirectory",
|
||||
cwd: workspaceCwd,
|
||||
branch,
|
||||
worktreeRoot,
|
||||
mainRepoRoot: repoDir,
|
||||
});
|
||||
const unarchived: string[] = [];
|
||||
const service = createWorkspaceRecoveryService({
|
||||
paseoHome,
|
||||
worktreesRoot,
|
||||
getWorkspace: async (workspaceId) =>
|
||||
workspaceId === workspace.workspaceId ? workspace : null,
|
||||
getProject: async (projectId) => (projectId === project.projectId ? project : null),
|
||||
isDirectory: async (targetPath) =>
|
||||
existsSync(targetPath) && statSync(targetPath).isDirectory(),
|
||||
unarchiveWorkspace: async (record) => {
|
||||
unarchived.push(record.workspaceId);
|
||||
},
|
||||
});
|
||||
|
||||
await expect(service.restore(workspace.workspaceId)).rejects.toThrow(
|
||||
"Selected project directory is missing from the restored worktree",
|
||||
);
|
||||
expect(unarchived).toEqual([]);
|
||||
expect(existsSync(worktreeRoot)).toBe(false);
|
||||
expect(
|
||||
execFileSync("git", ["worktree", "list", "--porcelain"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
})
|
||||
.toString()
|
||||
.includes("without-subproject"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
test("keeps the workspace archived when its persisted source repository is missing", async () => {
|
||||
const workspace = createWorkspace({ mainRepoRoot: "/missing-source" });
|
||||
const { service, unarchived } = createHarness({
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { mkdir } from "node:fs/promises";
|
||||
import { basename } from "node:path";
|
||||
|
||||
import { createRealpathAwarePathMatcher } from "../../../utils/path.js";
|
||||
@@ -7,6 +6,7 @@ import {
|
||||
createWorktree,
|
||||
isPaseoOwnedWorktreeCwd,
|
||||
mapWorkspaceCwdToWorktree,
|
||||
rollbackCreatedPaseoWorktree,
|
||||
} from "../../../utils/worktree.js";
|
||||
import { WorktreeRequestError, toWorktreeRequestError } from "../../worktree-errors.js";
|
||||
import {
|
||||
@@ -199,18 +199,36 @@ export function createWorkspaceRecoveryService(deps: {
|
||||
throw toWorktreeRequestError(error);
|
||||
}
|
||||
|
||||
const recreatedWorkspacePath = mapWorkspaceCwdToWorktree({
|
||||
sourceWorktreePath: previousWorktreePath,
|
||||
workspaceCwd: workspace.cwd,
|
||||
targetWorktreePath: recreatedWorktreePath,
|
||||
});
|
||||
if (!createRealpathAwarePathMatcher(workspace.cwd)(recreatedWorkspacePath)) {
|
||||
throw new WorktreeRequestError({
|
||||
code: "unknown",
|
||||
message: `Recreated worktree diverged from ${workspace.cwd}: ${recreatedWorkspacePath}`,
|
||||
try {
|
||||
const recreatedWorkspacePath = mapWorkspaceCwdToWorktree({
|
||||
sourceWorktreePath: previousWorktreePath,
|
||||
workspaceCwd: workspace.cwd,
|
||||
targetWorktreePath: recreatedWorktreePath,
|
||||
});
|
||||
if (!createRealpathAwarePathMatcher(workspace.cwd)(recreatedWorkspacePath)) {
|
||||
throw new WorktreeRequestError({
|
||||
code: "unknown",
|
||||
message: `Recreated worktree diverged from ${workspace.cwd}: ${recreatedWorkspacePath}`,
|
||||
});
|
||||
}
|
||||
if (!(await deps.isDirectory(recreatedWorkspacePath))) {
|
||||
throw new WorktreeRequestError({
|
||||
code: "unknown",
|
||||
message: `Selected project directory is missing from the restored worktree: ${recreatedWorkspacePath}`,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
return rollbackCreatedPaseoWorktree(
|
||||
{
|
||||
cwd: sourceRepoRoot,
|
||||
worktreePath: recreatedWorktreePath,
|
||||
teardownCwds: [],
|
||||
paseoHome: deps.paseoHome,
|
||||
worktreesBaseRoot: deps.worktreesRoot,
|
||||
},
|
||||
error,
|
||||
);
|
||||
}
|
||||
await mkdir(recreatedWorkspacePath, { recursive: true });
|
||||
}
|
||||
|
||||
return { inspect, restore };
|
||||
|
||||
@@ -388,13 +388,42 @@ describe("archiveByScope", () => {
|
||||
|
||||
test("worktree scope archives root and subdirectory workspaces before removing the backing worktree", async () => {
|
||||
const { tempDir, repoDir } = createGitRepo();
|
||||
const nestedRelative = path.join("packages", "app");
|
||||
const sourceNested = path.join(repoDir, nestedRelative);
|
||||
mkdirSync(sourceNested, { recursive: true });
|
||||
writeFileSync(
|
||||
path.join(repoDir, "paseo.json"),
|
||||
JSON.stringify({
|
||||
worktree: {
|
||||
teardown: [
|
||||
"node -e \"require('fs').appendFileSync(process.env.PASEO_SOURCE_CHECKOUT_PATH + '/root-scope-teardown.log', process.cwd() + '\\\\n')\"",
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
writeFileSync(
|
||||
path.join(sourceNested, "paseo.json"),
|
||||
JSON.stringify({
|
||||
worktree: {
|
||||
teardown: [
|
||||
"node -e \"require('fs').writeFileSync(process.env.PASEO_SOURCE_CHECKOUT_PATH + '/nested-scope-teardown.log', process.cwd())\"",
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
execFileSync("git", ["add", "."], { cwd: repoDir, stdio: "pipe" });
|
||||
execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "scope teardown"], {
|
||||
cwd: repoDir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
const paseoHome = path.join(tempDir, ".paseo");
|
||||
const worktree = await createPaseoOwnedWorktree(repoDir, paseoHome, "worktree-scope");
|
||||
const workspaceA = "ws-worktree-a";
|
||||
const workspaceB = "ws-worktree-b";
|
||||
const workspaceC = "ws-worktree-subdirectory";
|
||||
const subdirectory = path.join(worktree.worktreePath, "packages", "app");
|
||||
mkdirSync(subdirectory, { recursive: true });
|
||||
const subdirectory = path.join(worktree.worktreePath, nestedRelative);
|
||||
const matchesRoot = createRealpathAwarePathMatcher(worktree.worktreePath);
|
||||
const matchesSubdirectory = createRealpathAwarePathMatcher(subdirectory);
|
||||
|
||||
const result = await archiveByScope(
|
||||
createArchiveDeps({
|
||||
@@ -435,6 +464,14 @@ describe("archiveByScope", () => {
|
||||
expect(result.archivedWorkspaceIds).toHaveLength(3);
|
||||
expect(result.removedDirectory).toBe(true);
|
||||
expect(existsSync(worktree.worktreePath)).toBe(false);
|
||||
const rootTeardownCwds = readFileSync(path.join(repoDir, "root-scope-teardown.log"), "utf8")
|
||||
.trim()
|
||||
.split("\n");
|
||||
expect(rootTeardownCwds).toHaveLength(1);
|
||||
expect(matchesRoot(rootTeardownCwds[0] ?? "")).toBe(true);
|
||||
expect(
|
||||
matchesSubdirectory(readFileSync(path.join(repoDir, "nested-scope-teardown.log"), "utf8")),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("workspace scope never removes a non-Paseo-owned directory", async () => {
|
||||
|
||||
@@ -75,7 +75,7 @@ interface BackingDirectory {
|
||||
|
||||
interface ArchiveTarget {
|
||||
backing: BackingDirectory | null;
|
||||
teardownCwd: string | null;
|
||||
teardownCwds: string[];
|
||||
workspaceIds: string[];
|
||||
}
|
||||
|
||||
@@ -170,11 +170,11 @@ async function resolveArchiveTarget(
|
||||
{ workspaceId },
|
||||
"Workspace not found for archive-by-scope; skipping",
|
||||
);
|
||||
return { backing: null, teardownCwd: null, workspaceIds: [] };
|
||||
return { backing: null, teardownCwds: [], workspaceIds: [] };
|
||||
}
|
||||
return {
|
||||
backing: await resolveWorkspaceBackingDirectory(record, dependencies),
|
||||
teardownCwd: record.cwd,
|
||||
teardownCwds: [record.cwd],
|
||||
workspaceIds: [workspaceId],
|
||||
};
|
||||
}
|
||||
@@ -189,8 +189,6 @@ async function resolveArchiveTarget(
|
||||
}),
|
||||
)
|
||||
).filter((workspace): workspace is ActiveWorkspaceRef => workspace !== null);
|
||||
const exactTarget = createRealpathAwarePathMatcher(scope.targetPath);
|
||||
const teardownWorkspace = targetWorkspaces.find((workspace) => exactTarget(workspace.cwd));
|
||||
const persistedMainRepoRoot = targetWorkspaces.find(
|
||||
(workspace) => workspace.mainRepoRoot,
|
||||
)?.mainRepoRoot;
|
||||
@@ -199,7 +197,11 @@ async function resolveArchiveTarget(
|
||||
...backing,
|
||||
mainRepoRoot: persistedMainRepoRoot ?? backing.mainRepoRoot,
|
||||
},
|
||||
teardownCwd: teardownWorkspace?.cwd ?? scope.targetPath,
|
||||
teardownCwds: uniqueFilesystemPaths(
|
||||
targetWorkspaces.length > 0
|
||||
? targetWorkspaces.map((workspace) => workspace.cwd)
|
||||
: [scope.targetPath],
|
||||
),
|
||||
workspaceIds: targetWorkspaces.map((workspace) => workspace.workspaceId),
|
||||
};
|
||||
}
|
||||
@@ -311,7 +313,7 @@ async function maybeRemoveDirectory(
|
||||
await deletePaseoWorktree({
|
||||
cwd: backing.mainRepoRoot,
|
||||
worktreePath: backing.path,
|
||||
teardownCwd: target.teardownCwd ?? backing.path,
|
||||
teardownCwds: target.teardownCwds,
|
||||
worktreesRoot: backing.paseoWorktreesRoot ?? undefined,
|
||||
paseoHome: dependencies.paseoHome,
|
||||
worktreesBaseRoot: dependencies.paseoWorktreesBaseRoot,
|
||||
@@ -330,6 +332,16 @@ async function maybeRemoveDirectory(
|
||||
}
|
||||
}
|
||||
|
||||
function uniqueFilesystemPaths(paths: string[]): string[] {
|
||||
const unique: string[] = [];
|
||||
for (const candidate of paths) {
|
||||
if (!unique.some((existing) => createRealpathAwarePathMatcher(existing)(candidate))) {
|
||||
unique.push(candidate);
|
||||
}
|
||||
}
|
||||
return unique;
|
||||
}
|
||||
|
||||
export type ArchiveWorkspaceContentsDependencies = Pick<
|
||||
ArchiveDependencies,
|
||||
"agentManager" | "agentStorage" | "killTerminalsForWorkspace" | "sessionLogger"
|
||||
|
||||
@@ -776,6 +776,23 @@ export async function runWorktreeTeardownCommands(options: {
|
||||
return results;
|
||||
}
|
||||
|
||||
export async function seedPaseoConfigFile(options: {
|
||||
sourceCwd: string;
|
||||
targetCwd: string;
|
||||
}): Promise<void> {
|
||||
const sourceConfigPath = join(options.sourceCwd, "paseo.json");
|
||||
const targetConfigPath = join(options.targetCwd, "paseo.json");
|
||||
try {
|
||||
await stat(targetConfigPath);
|
||||
return;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
}
|
||||
await copyFile(sourceConfigPath, targetConfigPath).catch((error) => {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the git common directory (shared across worktrees) for a given cwd.
|
||||
* This is where refs, objects, etc. are stored.
|
||||
@@ -1050,23 +1067,25 @@ export async function resolveExistingWorktreeForSlug({
|
||||
};
|
||||
}
|
||||
|
||||
export async function deletePaseoWorktree({
|
||||
cwd,
|
||||
worktreePath,
|
||||
teardownCwd,
|
||||
worktreeSlug,
|
||||
worktreesRoot,
|
||||
paseoHome,
|
||||
worktreesBaseRoot,
|
||||
}: {
|
||||
export interface DeletePaseoWorktreeOptions {
|
||||
cwd: string | null;
|
||||
worktreePath?: string;
|
||||
teardownCwd?: string;
|
||||
teardownCwds?: string[];
|
||||
worktreeSlug?: string;
|
||||
worktreesRoot?: string;
|
||||
paseoHome?: string;
|
||||
worktreesBaseRoot?: string;
|
||||
}): Promise<void> {
|
||||
}
|
||||
|
||||
export async function deletePaseoWorktree({
|
||||
cwd,
|
||||
worktreePath,
|
||||
teardownCwds,
|
||||
worktreeSlug,
|
||||
worktreesRoot,
|
||||
paseoHome,
|
||||
worktreesBaseRoot,
|
||||
}: DeletePaseoWorktreeOptions): Promise<void> {
|
||||
if (!worktreePath && !worktreeSlug) {
|
||||
throw new Error("worktreePath or worktreeSlug is required");
|
||||
}
|
||||
@@ -1101,10 +1120,12 @@ export async function deletePaseoWorktree({
|
||||
}
|
||||
|
||||
if (await pathExists(resolvedWorktree)) {
|
||||
await runWorktreeTeardownCommands({
|
||||
worktreePath: resolvedWorktree,
|
||||
teardownCwd,
|
||||
});
|
||||
for (const teardownCwd of teardownCwds ?? [resolvedWorktree]) {
|
||||
await runWorktreeTeardownCommands({
|
||||
worktreePath: resolvedWorktree,
|
||||
teardownCwd,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (cwd) {
|
||||
@@ -1132,6 +1153,27 @@ export async function deletePaseoWorktree({
|
||||
}
|
||||
}
|
||||
|
||||
export async function rollbackCreatedPaseoWorktree(
|
||||
options: DeletePaseoWorktreeOptions,
|
||||
cause: unknown,
|
||||
): Promise<never> {
|
||||
let cleanupError: unknown;
|
||||
try {
|
||||
await deletePaseoWorktree(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;
|
||||
}
|
||||
|
||||
async function pathExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await stat(path);
|
||||
@@ -1225,18 +1267,7 @@ export const createWorktree = async ({
|
||||
: {}),
|
||||
});
|
||||
|
||||
// If paseo.json exists in the main repo but wasn't checked into the worktree
|
||||
// (e.g. uncommitted on first-time setup), seed the worktree with it so setup
|
||||
// commands and scripts pick up the user's intended config.
|
||||
const mainConfigPath = join(cwd, "paseo.json");
|
||||
const worktreeConfigPath = join(worktreePath, "paseo.json");
|
||||
try {
|
||||
await stat(worktreeConfigPath);
|
||||
} catch {
|
||||
await copyFile(mainConfigPath, worktreeConfigPath).catch((err) => {
|
||||
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
|
||||
});
|
||||
}
|
||||
await seedPaseoConfigFile({ sourceCwd: cwd, targetCwd: worktreePath });
|
||||
|
||||
if (runSetup) {
|
||||
await runWorktreeSetupCommands({
|
||||
|
||||
Reference in New Issue
Block a user