fix(migrate): preserve adopted workspace config

This commit is contained in:
Mohamed Boudra
2026-07-19 05:39:59 +02:00
parent 3fed2f2cd9
commit 7046b118e9
4 changed files with 57 additions and 29 deletions

View File

@@ -47,6 +47,7 @@ test("imports real catalog, config, and Git worktree shapes through observable P
},
});
expect(paseo.openedCheckouts).toEqual([fixture.liveWorktree]);
expect(paseo.configs.get(fixture.liveWorktree)).toEqual(paseo.configs.get(fixture.repo));
expect(paseo.createdCheckouts).toEqual([
{ rootPath: fixture.repo, refName: "create-branch", directoryName: "missing-create" },
]);
@@ -79,7 +80,7 @@ test("imports real catalog, config, and Git worktree shapes through observable P
output: (event) => secondEvents.push(event),
});
expect(second.notices.some((notice) => notice.code === "project-apply-failed")).toBe(false);
expect(paseo.configWrites).toBe(1);
expect(paseo.configWrites).toBe(2);
expect(secondEvents.map((event) => event.message)).toContain(
`Worktree ${path.join(fixture.repo, ".paseo", "missing-create")} already exists for create-branch.`,
);
@@ -97,7 +98,7 @@ test("reports a revision-stale config write without retrying or replacing existi
output: () => undefined,
});
expect(paseo.configWrites).toBe(1);
expect(paseo.configWrites).toBe(2);
expect(paseo.configs.get(fixture.repo)).toEqual({});
expect(paseo.openedCheckouts).toEqual([fixture.liveWorktree]);
expect(paseo.createdCheckouts).toEqual([

View File

@@ -62,9 +62,14 @@ export async function migrate(input: {
async function migrateProject(project: MigrationProject, context: MigrationContext): Promise<void> {
for (const notice of project.notices) context.emitNotice(notice);
let config = project.config;
if (context.dryRun) planProject(project, context);
else if (!(await applyProject(project, context))) return;
await migrateWorkspaces(project, context);
else {
const appliedConfig = await applyProject(project, context);
if (!appliedConfig) return;
config = appliedConfig;
}
await migrateWorkspaces(project, config, context);
}
function planProject(project: MigrationProject, context: MigrationContext): void {
@@ -80,52 +85,49 @@ function planProject(project: MigrationProject, context: MigrationContext): void
async function applyProject(
project: MigrationProject,
context: MigrationContext,
): Promise<boolean> {
): Promise<PaseoConfigRaw | null> {
try {
await context.paseo.addProject(project.rootPath);
context.output({ level: "info", message: `Registered project ${project.rootPath}.` });
} catch (error) {
context.emitNotice(applyFailure("project-apply-failed", project.rootPath, error));
return false;
return null;
}
try {
const current = await context.paseo.readProjectConfig(project.rootPath);
const merged = mergeExistingConfig(project.config, current.config);
if (isDeepStrictEqual(merged, current.config ?? {})) {
context.output({
level: "info",
message: `Project config already current for ${project.rootPath}.`,
});
return true;
}
await context.paseo.writeProjectConfig({
rootPath: project.rootPath,
config: merged,
expectedRevision: current.revision,
});
context.stats.configs += 1;
context.output({ level: "info", message: `Updated project config for ${project.rootPath}.` });
return await applyConfig(project.rootPath, project.config, context);
} catch (error) {
context.emitNotice(applyFailure("project-config-apply-failed", project.rootPath, error));
return project.config ?? {};
}
return true;
}
async function migrateWorkspaces(
project: MigrationProject,
config: PaseoConfigRaw | null,
context: MigrationContext,
): Promise<void> {
for (const workspace of project.workspaces) {
for (const notice of workspace.notices) context.emitNotice(notice);
if (context.dryRun) planWorkspace(workspace, context);
else await applyWorkspace(project.rootPath, workspace, context);
if (context.dryRun) planWorkspace(project, workspace, context);
else await applyWorkspace(project, config, workspace, context);
}
}
function planWorkspace(workspace: MigrationWorkspace, context: MigrationContext): void {
function planWorkspace(
project: MigrationProject,
workspace: MigrationWorkspace,
context: MigrationContext,
): void {
if (workspace.disposition === "adopt" && workspace.path) {
context.stats.adopted += 1;
context.output({ level: "info", message: `Would adopt worktree ${workspace.path}.` });
if (project.config) {
context.stats.configs += 1;
context.output({
level: "info",
message: `Would merge supported project config for ${workspace.path}.`,
});
}
} else if (workspace.disposition === "create" && workspace.branch) {
context.stats.created += 1;
context.output({
@@ -136,18 +138,20 @@ function planWorkspace(workspace: MigrationWorkspace, context: MigrationContext)
}
async function applyWorkspace(
rootPath: string,
project: MigrationProject,
config: PaseoConfigRaw | null,
workspace: MigrationWorkspace,
context: MigrationContext,
): Promise<void> {
try {
if (workspace.disposition === "adopt" && workspace.path) {
await context.paseo.openCheckout(workspace.path);
await applyConfig(workspace.path, config, context);
context.stats.adopted += 1;
context.output({ level: "info", message: `Adopted worktree ${workspace.path}.` });
} else if (workspace.disposition === "create" && workspace.branch) {
const ensured = await context.paseo.ensureCheckout({
rootPath,
rootPath: project.rootPath,
refName: workspace.branch,
directoryName: workspace.directoryName,
});
@@ -164,6 +168,27 @@ async function applyWorkspace(
}
}
async function applyConfig(
rootPath: string,
imported: PaseoConfigRaw | null,
context: MigrationContext,
): Promise<PaseoConfigRaw> {
const current = await context.paseo.readProjectConfig(rootPath);
const merged = mergeExistingConfig(imported, current.config);
if (isDeepStrictEqual(merged, current.config ?? {})) {
context.output({ level: "info", message: `Project config already current for ${rootPath}.` });
return current.config ?? {};
}
await context.paseo.writeProjectConfig({
rootPath,
config: merged,
expectedRevision: current.revision,
});
context.stats.configs += 1;
context.output({ level: "info", message: `Updated project config for ${rootPath}.` });
return merged;
}
export function mergeExistingConfig(
imported: PaseoConfigRaw | null,
existing: PaseoConfigRaw | null,

View File

@@ -162,6 +162,7 @@ test("preserves literal or escaped Conductor names and rewrites active variables
arithmeticBraced: { command: "serve --port $(( ${CONDUCTOR_PORT} ))" },
arithmeticOffset: { command: "serve --port $(( CONDUCTOR_PORT + 1 ))" },
prefixExpansion: { command: "serve --length ${#CONDUCTOR_PORT}" },
unicodePrefix: { command: 'printf 😀 "$CONDUCTOR_PORT"' },
},
},
});
@@ -175,6 +176,7 @@ test("preserves literal or escaped Conductor names and rewrites active variables
bare: { command: "printf CONDUCTOR_PORT" },
escaped: { command: "printf \\$CONDUCTOR_PORT" },
escapedDouble: { command: 'printf "\\$CONDUCTOR_PORT"' },
unicodePrefix: { command: 'printf 😀 "$PASEO_PORT"', type: "service" },
},
});
expect(inspected.notices).toContainEqual({

View File

@@ -628,7 +628,7 @@ function collectConductorVariables(command: string): string[] {
}
function activeShellMask(command: string): string {
const mask = [...command];
const mask = command.split("");
let quote: "single" | "double" | null = null;
for (let index = 0; index < command.length; index += 1) {
const character = command[index];