fix(worktrees): make lifecycle operations transactional

This commit is contained in:
Mohamed Boudra
2026-07-17 11:08:34 +00:00
parent 50af1a0d54
commit cd8a91c77e
9 changed files with 343 additions and 84 deletions

View File

@@ -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);
});

View File

@@ -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");
}
}),
);
}

View File

@@ -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);

View File

@@ -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> {

View File

@@ -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({

View File

@@ -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 };

View File

@@ -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 () => {

View File

@@ -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"

View File

@@ -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({