feat(core): Promote folder and project renames alongside selected workflows (#40502)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Sandra Zollner 2026-10-09 08:51:21 +00:00 committed by GitHub
parent 1c84af2e96
commit a249e20290
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 414 additions and 152 deletions

View File

@ -1,6 +1,6 @@
import type { PackageManifest } from '@/modules/n8n-packages/spec/manifest.schema';
import { containerPlacement, pinPath, staleWorkflowTargets } from '../branch-placement';
import { containerMoves, remapPath, staleWorkflowTargets } from '../branch-placement';
const baseMetadata = {
packageFormatVersion: '1' as const,
@ -20,8 +20,8 @@ function entry(id: string, name = `name-${id}`, target = `target/${id}`) {
const P = 'projects/acme';
const acme = entry('p1', 'Acme', P);
describe('containerPlacement', () => {
it('pins a renamed project so the selection lands in the directory the branch holds', () => {
describe('containerMoves', () => {
it('moves a renamed project to the path the export uses, so the branch content rides along', () => {
const existing = makeManifest({
projects: [acme],
folders: [entry('f1', 'Sales', `${P}/folders/sales`)],
@ -31,13 +31,12 @@ describe('containerPlacement', () => {
workflows: [entry('w1', 'W1', 'projects/acme-corp/workflows/w1')],
});
const placement = containerPlacement(existing, staging);
const moves = containerMoves(existing, staging);
expect(pinPath('projects/acme-corp/workflows/w1', placement.pins)).toBe(`${P}/workflows/w1`);
expect(placement.keptFiles.has('projects/acme-corp/project.json')).toBe(true);
expect(moves).toEqual([{ kind: 'projects', from: P, to: 'projects/acme-corp' }]);
});
it('applies the deepest pin when a parent and a child were both renamed', () => {
it('orders the deepest container first when a parent and a child were both renamed', () => {
const existing = makeManifest({
folders: [entry('f1', 'A', `${P}/folders/a`), entry('f2', 'C', `${P}/folders/a/c`)],
});
@ -46,14 +45,14 @@ describe('containerPlacement', () => {
workflows: [entry('w1', 'W1', `${P}/folders/b/d/workflows/w1`)],
});
const placement = containerPlacement(existing, staging);
expect(pinPath(`${P}/folders/b/d/workflows/w1`, placement.pins)).toBe(
`${P}/folders/a/c/workflows/w1`,
);
// The order is the parking order: the child must leave before the parent moves.
expect(containerMoves(existing, staging)).toEqual([
{ kind: 'folders', from: `${P}/folders/a/c`, to: `${P}/folders/b/d` },
{ kind: 'folders', from: `${P}/folders/a`, to: `${P}/folders/b` },
]);
});
it('creates no pin for a folder the branch lacks', () => {
it('creates no move for a container the branch lacks', () => {
const existing = makeManifest({
folders: [entry('f1', 'A', `${P}/folders/a`)],
});
@ -61,11 +60,37 @@ describe('containerPlacement', () => {
folders: [entry('f1', 'B', `${P}/folders/b`), entry('f2', 'New', `${P}/folders/b/new`)],
});
const placement = containerPlacement(existing, staging);
const moves = containerMoves(existing, staging);
expect(pinPath(`${P}/folders/b/new/workflows/w1`, placement.pins)).toBe(
`${P}/folders/a/new/workflows/w1`,
);
expect(moves).toEqual([{ kind: 'folders', from: `${P}/folders/a`, to: `${P}/folders/b` }]);
});
it('creates no move for a container whose path is unchanged', () => {
const existing = makeManifest({ projects: [acme] });
const staging = makeManifest({
projects: [entry('p1', 'Acme', P)],
workflows: [entry('w1', 'W1', `${P}/workflows/w1`)],
});
expect(containerMoves(existing, staging)).toEqual([]);
});
});
describe('remapPath', () => {
const moves = [
{ kind: 'folders' as const, from: `${P}/folders/a`, to: `${P}/folders/b` },
{ kind: 'folders' as const, from: `${P}/folders/a/c`, to: `${P}/folders/b/d` },
];
it('rewrites through the deepest covering move regardless of move order', () => {
const target = `${P}/folders/a/c/workflows/w1`;
expect(remapPath(target, moves)).toBe(`${P}/folders/b/d/workflows/w1`);
expect(remapPath(target, [...moves].reverse())).toBe(`${P}/folders/b/d/workflows/w1`);
});
it('leaves a path no move covers, and does not match a sibling by prefix', () => {
expect(remapPath(`${P}/folders/ab/workflows/w1`, moves)).toBe(`${P}/folders/ab/workflows/w1`);
expect(remapPath(`${P}/variables/v1`, moves)).toBe(`${P}/variables/v1`);
});
});
@ -95,7 +120,7 @@ describe('staleWorkflowTargets', () => {
]);
});
it('leaves a renamed folder in place and only replaces the selected workflow', () => {
it('reports only the selected workflow old path when its folder was renamed', () => {
const before = makeManifest({
folders: [entry('f1', 'sales', 'projects/p/folders/sales')],
workflows: [

View File

@ -101,6 +101,37 @@ describe('import-manifest-bridge', () => {
]);
});
it('keeps an unselected variable whose project directory a container move relocated', async () => {
// The leftover manifest still names the old project path; the file is at the new one.
await writeTree({
'manifest.json': leftoverManifest({
variables: [{ id: 'v-old', name: 'API_KEY', target: 'projects/alpha/variables/api-key' }],
}),
'projects/renamed/project.json': JSON.stringify({ id: 'p1', name: 'Renamed' }),
'projects/renamed/variables/api-key/variable.json': JSON.stringify({ name: 'API_KEY' }),
});
await writeImportManifest({
exportFolder,
staging: packageManifestSchema.parse({
packageFormatVersion: '1',
exportedAt: '2026-01-01T00:00:00.000Z',
sourceN8nVersion: '1.0.0',
sourceId: 'inst-1',
projects: [{ id: 'p1', name: 'Renamed', target: 'projects/renamed' }],
}),
sourceId: 'inst-test',
containerMoves: [{ kind: 'projects', from: 'projects/alpha', to: 'projects/renamed' }],
});
const written = packageManifestSchema.parse(
JSON.parse(await readFile(path.join(exportFolder, 'manifest.json'), 'utf-8')),
);
expect(written.variables).toEqual([
{ id: 'v-old', name: 'API_KEY', target: 'projects/renamed/variables/api-key' },
]);
});
it('unions workflow consumers when leftover and staging share a requirement key', async () => {
await writeTree({
'manifest.json': leftoverManifest({

View File

@ -2108,7 +2108,7 @@ describe('Promote a project selection — branch effects', () => {
expect(onBranch).toHaveLength(2);
});
it('leaves a renamed folder alone, so unselected workflows keep their place', async () => {
it('moves a renamed folder, so unselected workflows ride along unchanged', async () => {
const remote = await createRemote();
const connection = await createInstanceConnection(remote.bareDir);
await service.clone(connection.id, 'promote');
@ -2147,16 +2147,12 @@ describe('Promote a project selection — branch effects', () => {
const selectedEntry = workflows.find((w) => w.id === selected.id)!;
const unselectedEntry = workflows.find((w) => w.id === unselected.id)!;
expect(folderEntry).toMatchObject({ name: 'Sales', target: folderBefore.target });
expect(selectedEntry.target).toBe(`${folderEntry.target}/workflows/selected-${selected.id}`);
expect(unselectedEntry.target).toBe(
`${folderEntry.target}/workflows/unselected-${unselected.id}`,
);
await expect(
stat(path.join(dir, 'n8n-export', unselectedEntry.target, 'workflow.json')),
).resolves.toBeDefined();
const renamedTarget = folderEntry.target.replace(/[^/]+$/, `revenue-${folder.id}`);
await expect(stat(path.join(dir, 'n8n-export', renamedTarget))).rejects.toThrow();
const renamedTarget = folderBefore.target.replace(/[^/]+$/, `revenue-${folder.id}`);
expect(folderEntry).toMatchObject({ name: 'Revenue', target: renamedTarget });
expect(selectedEntry.target).toBe(`${renamedTarget}/workflows/selected-${selected.id}`);
expect(unselectedEntry.target).toBe(`${renamedTarget}/workflows/unselected-${unselected.id}`);
expect(unselectedEntry).toMatchObject({ name: 'Unselected' });
await expect(stat(path.join(dir, 'n8n-export', folderBefore.target))).rejects.toThrow();
const workflowRepository = Container.get(WorkflowRepository);
await workflowRepository.delete(unselected.id);

View File

@ -432,7 +432,7 @@ describe('WorkingCopyUpdater', () => {
);
});
it('keeps a renamed folder where the branch has it, so unselected workflows stay put', async () => {
it('renames the folder on the branch and rides unselected workflows along', async () => {
await apply(
{
manifest: makeManifest({
@ -465,16 +465,17 @@ describe('WorkingCopyUpdater', () => {
{ workflowIds: ['w1'], deletedWorkflowIds: ['w3'] },
);
await expectAbsent('projects/alpha/folders/revenue');
await expectAbsent('projects/alpha/folders/sales/workflows/w3');
// The folder was renamed on the instance, but nobody selected that change.
expect(await readExported('projects/alpha/folders/sales/folder.json')).toBe(
folderFile('f1', 'Sales'),
// The old folder directory is gone; the rename rode along the selection.
await expectAbsent('projects/alpha/folders/sales');
await expectAbsent('projects/alpha/folders/revenue/workflows/w3');
expect(await readExported('projects/alpha/folders/revenue/folder.json')).toBe(
folderFile('f1', 'Revenue'),
);
expect(await readExported('projects/alpha/folders/sales/workflows/w1/workflow.json')).toBe(
expect(await readExported('projects/alpha/folders/revenue/workflows/w1/workflow.json')).toBe(
workflowFile('w1', { v: 2 }),
);
expect(await readExported('projects/alpha/folders/sales/workflows/w2/workflow.json')).toBe(
// w2 was not selected, but it moves to the new folder path unchanged.
expect(await readExported('projects/alpha/folders/revenue/workflows/w2/workflow.json')).toBe(
workflowFile('w2'),
);
expect((await readWrittenManifest()).workflows?.map((entry) => entry.id).sort()).toEqual([
@ -482,13 +483,13 @@ describe('WorkingCopyUpdater', () => {
'w2',
]);
const branch = await updater.readBranchLayout(exportFolder);
expect(branch.folders).toEqual([folder('f1', 'Sales', 'sales')]);
expect(branch.folders).toEqual([folder('f1', 'Revenue', 'revenue')]);
expect(branch.workflows).toEqual(
expect.arrayContaining([inFolder('w2', 'sales'), inFolder('w1', 'sales')]),
expect.arrayContaining([inFolder('w2', 'revenue'), inFolder('w1', 'revenue')]),
);
});
it('keeps both folders where the branch has them when their names swap', async () => {
it('swaps two folder directories when their names swap', async () => {
// f1 is now named B (slug b) and f2 is named A (slug a); w1 and w3 are selected.
await apply(
{
@ -532,15 +533,15 @@ describe('WorkingCopyUpdater', () => {
{ workflowIds: ['w1', 'w3'] },
);
// Each selected workflow lands in the directory its folder has on the
// branch, next to the siblings nobody selected.
// The folders swap: f1 (now B) takes slug b and f2 (now A) takes slug a.
// Each folder's unselected workflow rides along to the new path.
const tree: Record<string, string> = {
'projects/alpha/folders/a/folder.json': folderFile('f1', 'A'),
'projects/alpha/folders/b/folder.json': folderFile('f2', 'B'),
'projects/alpha/folders/a/workflows/w1/workflow.json': workflowFile('w1', { moved: true }),
'projects/alpha/folders/a/workflows/w2/workflow.json': workflowFile('w2'),
'projects/alpha/folders/b/workflows/w3/workflow.json': workflowFile('w3', { moved: true }),
'projects/alpha/folders/b/workflows/w4/workflow.json': workflowFile('w4'),
'projects/alpha/folders/b/folder.json': folderFile('f1', 'B'),
'projects/alpha/folders/a/folder.json': folderFile('f2', 'A'),
'projects/alpha/folders/b/workflows/w1/workflow.json': workflowFile('w1', { moved: true }),
'projects/alpha/folders/b/workflows/w2/workflow.json': workflowFile('w2'),
'projects/alpha/folders/a/workflows/w3/workflow.json': workflowFile('w3', { moved: true }),
'projects/alpha/folders/a/workflows/w4/workflow.json': workflowFile('w4'),
};
for (const [file, content] of Object.entries(tree)) {
expect(await readExported(file), file).toBe(content);
@ -553,6 +554,167 @@ describe('WorkingCopyUpdater', () => {
]);
});
it('refuses a rename when a manually changed branch path occupies its target', async () => {
await expect(
apply(
{
manifest: makeManifest({
projects: [alpha],
folders: [folder('f1', 'A', 'a-f1'), folder('f2', 'B', 'b-f1')],
workflows: [inFolder('w1', 'a-f1'), inFolder('w2', 'b-f1')],
}),
files: {
'projects/alpha/project.json': projectFile,
'projects/alpha/folders/a-f1/folder.json': folderFile('f1', 'A'),
// f2 was manually moved to the path that a renamed f1 will use.
'projects/alpha/folders/b-f1/folder.json': folderFile('f2', 'B'),
'projects/alpha/folders/a-f1/workflows/w1/workflow.json': workflowFile('w1'),
'projects/alpha/folders/b-f1/workflows/w2/workflow.json': workflowFile('w2'),
},
},
{
manifest: makeManifest({
projects: [alpha],
folders: [folder('f1', 'B', 'b-f1')],
workflows: [inFolder('w1', 'b-f1')],
}),
files: {
'projects/alpha/folders/b-f1/folder.json': folderFile('f1', 'B'),
'projects/alpha/folders/b-f1/workflows/w1/workflow.json': workflowFile('w1'),
},
},
{ workflowIds: ['w1'] },
),
).rejects.toThrow(
'The branch path "projects/alpha/folders/b-f1" is already in use. Remove it and retry.',
);
expect(await readExported('projects/alpha/folders/a-f1/folder.json')).toBe(
folderFile('f1', 'A'),
);
expect(await readExported('projects/alpha/folders/b-f1/folder.json')).toBe(
folderFile('f2', 'B'),
);
expect(await readExported('projects/alpha/folders/a-f1/workflows/w1/workflow.json')).toBe(
workflowFile('w1'),
);
expect(await readExported('projects/alpha/folders/b-f1/workflows/w2/workflow.json')).toBe(
workflowFile('w2'),
);
});
it('keeps an unselected variable in the manifest after a project rename moves its file', async () => {
const renamed = { id: alpha.id, name: 'Renamed', target: 'projects/renamed' };
await apply(
{
manifest: makeManifest({
projects: [alpha],
workflows: [wf('w1'), wf('w2')],
variables: [{ id: 'v1', name: 'V1', target: 'projects/alpha/variables/v1' }],
}),
files: {
'projects/alpha/project.json': projectFile,
'projects/alpha/workflows/w1/workflow.json': workflowFile('w1'),
'projects/alpha/workflows/w2/workflow.json': workflowFile('w2'),
'projects/alpha/variables/v1/variable.json': variableFile('v1', 'V1'),
},
},
{
manifest: makeManifest({
projects: [renamed],
workflows: [{ id: 'w1', name: 'W1', target: 'projects/renamed/workflows/w1' }],
}),
files: {
'projects/renamed/project.json': JSON.stringify({ id: alpha.id, name: 'Renamed' }),
'projects/renamed/workflows/w1/workflow.json': workflowFile('w1', { v: 2 }),
},
},
{ workflowIds: ['w1'] },
);
expect(await readExported('projects/renamed/variables/v1/variable.json')).toBe(
variableFile('v1', 'V1'),
);
expect((await readWrittenManifest()).variables).toEqual([
{ id: 'v1', name: 'V1', target: 'projects/renamed/variables/v1' },
]);
});
it('refuses to pull a folder out of another project when only a new workflow is selected', async () => {
// f1 moved from beta to alpha on the instance and gained w-new. w-new is
// not on the branch, so the selected-workflow check cannot see the move.
const beta = { id: 'p2', name: 'Beta', target: 'projects/beta' };
await expect(
apply(
{
manifest: makeManifest({
projects: [alpha, beta],
folders: [{ id: 'f1', name: 'F1', target: 'projects/beta/folders/f1' }],
workflows: [
{
id: 'w-beta',
name: 'WBeta',
target: 'projects/beta/folders/f1/workflows/w-beta',
},
],
}),
files: {
'projects/alpha/project.json': projectFile,
'projects/beta/project.json': JSON.stringify({ id: beta.id, name: beta.name }),
'projects/beta/folders/f1/folder.json': folderFile('f1', 'F1'),
'projects/beta/folders/f1/workflows/w-beta/workflow.json': workflowFile('w-beta'),
},
},
{
manifest: makeManifest({
projects: [alpha],
folders: [folder('f1', 'F1', 'f1')],
workflows: [inFolder('w-new', 'f1')],
}),
files: {
'projects/alpha/folders/f1/folder.json': folderFile('f1', 'F1'),
'projects/alpha/folders/f1/workflows/w-new/workflow.json': workflowFile('w-new'),
},
},
{ workflowIds: ['w-new'] },
),
).rejects.toThrow('belongs to another project on the branch');
expect(await readExported('projects/beta/folders/f1/workflows/w-beta/workflow.json')).toBe(
workflowFile('w-beta'),
);
});
it('refuses to move a project whose entity file sits at the export root', async () => {
// A malformed branch scans the root project.json as target `.`.
await expect(
apply(
{
manifest: makeManifest({
projects: [{ ...alpha, target: '.' }],
workflows: [{ id: 'w1', name: 'W1', target: 'workflows/w1' }],
}),
files: {
'project.json': projectFile,
'workflows/w1/workflow.json': workflowFile('w1'),
},
},
{
manifest: makeManifest({ projects: [alpha], workflows: [wf('w2')] }),
files: {
'projects/alpha/project.json': projectFile,
'projects/alpha/workflows/w2/workflow.json': workflowFile('w2'),
},
},
{ workflowIds: ['w2'] },
),
).rejects.toThrow('Container target "." is not a managed leaf directory');
expect(await readExported('project.json')).toBe(projectFile);
expect(await readExported('workflows/w1/workflow.json')).toBe(workflowFile('w1'));
await expectAbsent('projects/alpha');
});
it('does not write leftover requirements a selected workflow no longer uses', async () => {
await apply(
{

View File

@ -1,5 +1,4 @@
import { entityFilePath } from '@/modules/n8n-packages/io/manifest-entry';
import type { ManifestEntry, PackageManifest } from '@/modules/n8n-packages/spec/manifest.schema';
import type { PackageManifest } from '@/modules/n8n-packages/spec/manifest.schema';
/**
* Where each project, folder and workflow lives on the branch, read from the
@ -11,56 +10,45 @@ export type BranchLayout = Pick<PackageManifest, 'projects' | 'folders' | 'workf
/** Entry kinds whose target is a directory that holds other entries. */
const CONTAINER_KINDS = ['projects', 'folders'] as const;
type ContainerKind = (typeof CONTAINER_KINDS)[number];
export type ContainerKind = (typeof CONTAINER_KINDS)[number];
/** A staging directory and the branch directory it lands in. */
interface Pin {
/** A renamed branch container directory and the path its rename moves it to. */
export interface ContainerMove {
kind: ContainerKind;
from: string;
to: string;
}
/** Where the staging files land on the branch. */
export interface Placement {
/** Staging paths that land elsewhere, longest first. */
pins: Pin[];
/** Staging files the branch already holds and keeps unchanged. */
keptFiles: Set<string>;
}
const entriesOf = (
state: Partial<Pick<PackageManifest, ContainerKind | 'workflows'>>,
kind: ContainerKind | 'workflows',
): ManifestEntry[] => state[kind] ?? [];
export const isUnder = (target: string, prefix: string) => target.startsWith(`${prefix}/`);
/**
* Where the staging files land. A project or folder the branch holds keeps its
* directory, because renaming it would move every unselected workflow inside.
* Paths under it are pinned back, so a selection lands next to its siblings.
* Containers whose branch path differs from the export path, deepest `from`
* first. The exporter only writes ancestors of selected workflows, so no
* unrelated container moves.
*/
export function containerPlacement(existing: BranchLayout, staging: PackageManifest): Placement {
const pins: Pin[] = [];
const keptFiles = new Set<string>();
export function containerMoves(existing: BranchLayout, staging: PackageManifest): ContainerMove[] {
const moves: ContainerMove[] = [];
for (const kind of CONTAINER_KINDS) {
const onBranch = new Map(entriesOf(existing, kind).map((e) => [e.id, e.target]));
for (const entry of entriesOf(staging, kind)) {
const to = onBranch.get(entry.id);
if (to === undefined) continue;
keptFiles.add(entityFilePath(kind, entry.target));
if (to !== entry.target) pins.push({ from: entry.target, to });
const onBranch = new Map((existing[kind] ?? []).map((e) => [e.id, e.target]));
for (const entry of staging[kind] ?? []) {
const from = onBranch.get(entry.id);
if (from === undefined || from === entry.target) continue;
moves.push({ kind, from, to: entry.target });
}
}
// Longest first, so a pinned folder wins over its pinned project.
return { pins: pins.sort((a, b) => b.from.length - a.from.length), keptFiles };
return moves.sort((a, b) => b.from.length - a.from.length);
}
/** Rewrite a staging path through the longest pin that covers it. */
export function pinPath(target: string, pins: readonly Pin[]): string {
const pin = pins.find((p) => target === p.from || isUnder(target, p.from));
return pin ? `${pin.to}${target.slice(pin.from.length)}` : target;
/** Rewrite a branch path through the deepest move that covers it, in any move order. */
export function remapPath(target: string, moves: readonly ContainerMove[]): string {
let move: ContainerMove | undefined;
for (const m of moves) {
if (target !== m.from && !isUnder(target, m.from)) continue;
if (move === undefined || m.from.length > move.from.length) move = m;
}
return move ? `${move.to}${target.slice(move.from.length)}` : target;
}
/**

View File

@ -12,6 +12,8 @@ import type {
PackageRequirements,
} from '@/modules/n8n-packages/spec/requirements.schema';
import { remapPath, type ContainerMove } from './branch-placement';
/**
* TEMPORARY bridge: import still inventories a directory package from
* `manifest.json`. Apply writes that file after overlay.
@ -89,14 +91,22 @@ export async function writeImportManifest(options: {
staging: PackageManifest;
sourceId: string;
selectedWorkflowIds?: readonly string[];
/** Container renames already applied to the export, so leftover targets resolve. */
containerMoves?: readonly ContainerMove[];
}): Promise<void> {
const { exportFolder, staging, sourceId, selectedWorkflowIds = [] } = options;
const {
exportFolder,
staging,
sourceId,
selectedWorkflowIds = [],
containerMoves = [],
} = options;
const selected = [...selectedWorkflowIds, ...(staging.workflows ?? []).map((entry) => entry.id)];
const leftover = dropSelectedRequirementUsers(await readLeftoverManifest(exportFolder), selected);
const collections = await walkSnapshotCollections(exportFolder);
const remainingWorkflowIds = new Set((collections.workflows ?? []).map((entry) => entry.id));
const selectedSet = new Set(selected);
const variables = await collectVariables(exportFolder, leftover, staging);
const variables = await collectVariables(exportFolder, leftover, staging, containerMoves);
const manifest = packageManifestSchema.parse({
packageFormatVersion: '1',
@ -174,10 +184,11 @@ async function collectVariables(
exportFolder: string,
leftover: PackageManifest | undefined,
staging: PackageManifest,
containerMoves: readonly ContainerMove[],
): Promise<ManifestEntry[]> {
const byId = new Map<string, ManifestEntry>();
for (const entry of leftover?.variables ?? []) {
byId.set(entry.id, entry);
byId.set(entry.id, { ...entry, target: remapPath(entry.target, containerMoves) });
}
for (const entry of staging.variables ?? []) {
byId.set(entry.id, entry);

View File

@ -249,9 +249,9 @@ export class PromotionsService {
/**
* Selective promote against an already-resolved connection. Unselected workflows
* stay as-is, and so do the projects and folders the branch already holds: a
* selection creates a container, never renames one, so nothing moves that the
* user did not select. A promotion branch is always new, so force never applies.
* keep their content. A renamed project or folder moves its branch directory,
* so the unselected workflows inside it move with it. A promotion branch is
* always new, so force never applies.
*/
private async promoteSelectionResolved(
input: PromotionOperationInput,

View File

@ -11,8 +11,8 @@ import { BadRequestError, InternalServerError } from '@n8n/errors';
import { MANIFEST_FILE } from '@/modules/n8n-packages/spec/constants';
import type { ManifestEntry, PackageManifest } from '@/modules/n8n-packages/spec/manifest.schema';
import { containerPlacement, isUnder, pinPath, staleWorkflowTargets } from './branch-placement';
import type { BranchLayout, Placement } from './branch-placement';
import { containerMoves, isUnder, staleWorkflowTargets } from './branch-placement';
import type { BranchLayout, ContainerMove } from './branch-placement';
import { writeImportManifest } from './import-manifest-bridge';
import { PromotionsWorkflowsMovedCrossProjectError } from './promotions-selective-push.error';
@ -315,6 +315,28 @@ export class WorkingCopyUpdater {
}
}
/**
* A container the branch holds under another project moved between projects.
* Moving it would carry that project's workflows along, so a selective push
* refuses it. A project's own directory may move (a rename).
*/
private assertNoCrossProjectContainerMoves(
branch: BranchLayout,
projectId: string,
moves: readonly ContainerMove[],
): void {
const projectTarget = branch.projects?.find((p) => p.id === projectId)?.target;
const foreign = moves.find(
({ from }) =>
projectTarget === undefined || !(from === projectTarget || isUnder(from, projectTarget)),
);
if (foreign) {
throw new BadRequestError(
`"${foreign.from}" belongs to another project on the branch. A selective push cannot move it. Push all projects instead.`,
);
}
}
/**
* Overlay the staging export onto `exportFolder`. Pass `branch` from
* `assertSelectionFitsBranch` for the same folder and selection.
@ -345,10 +367,11 @@ export class WorkingCopyUpdater {
try {
const { state: existing, dependencies } = branch;
const placement = containerPlacement(existing, staging);
const otherProjectTargets = (existing.projects ?? [])
.filter((p) => p.id !== selection.projectId)
.map((p) => p.target);
const moves = containerMoves(existing, staging);
this.assertNoCrossProjectContainerMoves(existing, selection.projectId, moves);
const remaining: BranchLayout = {
...existing,
workflows: (existing.workflows ?? []).filter(
@ -389,19 +412,21 @@ export class WorkingCopyUpdater {
otherProjectTargets,
);
for (const { collection, target } of renamedInScope) {
const dir = await this.resolveDependencyDir(workFolder, target);
const dir = await this.resolveLeafTarget(workFolder, target, 'Dependency');
await fs.rm(path.join(dir, DEPENDENCY_FILE_BY_COLLECTION[collection]), { force: true });
if ((await fs.readdir(dir)).length === 0) await fs.rmdir(dir);
}
for (const target of outOfScopeInStaging) {
placement.keptFiles.add(target);
}
await this.overlayDirectory(stagingFolder, workFolder, placement);
// After the cleanups above, which address branch paths.
await this.relocateBranchContainers(workFolder, moves);
await this.overlayDirectory(stagingFolder, workFolder, outOfScopeInStaging);
await writeImportManifest({
exportFolder: workFolder,
staging,
sourceId: this.instanceSettings.instanceId,
selectedWorkflowIds: selection.workflowIds,
containerMoves: moves,
});
const backupPath = path.join(tempBase, `.${path.basename(exportFolder)}-bak-${randomUUID()}`);
@ -464,19 +489,7 @@ export class WorkingCopyUpdater {
remaining: BranchLayout,
staging: PackageManifest,
): Promise<string> {
const segments = target.split(/[\\/]/).filter(Boolean);
if (segments.length === 0 || segments.includes('.') || segments.includes('..')) {
throw new BadRequestError(
`Manifest target "${target}" is not a managed leaf directory. Remove it and retry.`,
);
}
const resolved = await this.resolveContained(exportFolder, target);
if (resolved === path.resolve(exportFolder)) {
throw new BadRequestError(
`Manifest target "${target}" is not a managed leaf directory. Remove it and retry.`,
);
}
const resolved = await this.resolveLeafTarget(exportFolder, target, 'Manifest');
const writtenIds = new Set((staging.workflows ?? []).map((entry) => entry.id));
for (const entry of remaining.workflows ?? []) {
@ -524,7 +537,7 @@ export class WorkingCopyUpdater {
otherProjectTargets: string[],
): {
renamedInScope: Array<{ collection: DependencyCollection; target: string }>;
outOfScopeInStaging: string[];
outOfScopeInStaging: Set<string>;
} {
const stagingTargets = new Map<string, string>();
for (const collection of DEPENDENCY_COLLECTIONS) {
@ -534,11 +547,7 @@ export class WorkingCopyUpdater {
}
const renamedInScope: Array<{ collection: DependencyCollection; target: string }> = [];
const outOfScopeInStaging: string[] = [];
if (stagingTargets.size === 0) {
return { renamedInScope, outOfScopeInStaging };
}
const outOfScopeInStaging = new Set<string>();
for (const dependency of dependencies) {
const newTarget = stagingTargets.get(`${dependency.collection}:${dependency.id}`);
@ -546,7 +555,7 @@ export class WorkingCopyUpdater {
const underOtherProject = otherProjectTargets.some((pt) => isUnder(dependency.target, pt));
if (underOtherProject) {
outOfScopeInStaging.push(newTarget);
outOfScopeInStaging.add(newTarget);
} else {
renamedInScope.push({ collection: dependency.collection, target: dependency.target });
}
@ -573,34 +582,78 @@ export class WorkingCopyUpdater {
return undefined;
}
/**
* Resolve a dependency directory to remove a file from, rejecting a non-leaf
* target, an escape, or a symlinked path (via `resolveContained`).
*/
private async resolveDependencyDir(exportFolder: string, target: string): Promise<string> {
const segments = target.split(/[\\/]/).filter(Boolean);
if (segments.length === 0 || segments.includes('.') || segments.includes('..')) {
throw new BadRequestError(
`Dependency target "${target}" is not a managed leaf directory. Remove it and retry.`,
/** Resolve a target that will be removed or moved. Rejects the export root itself. */
private async resolveLeafTarget(
exportFolder: string,
target: string,
label: 'Manifest' | 'Dependency' | 'Container',
): Promise<string> {
const reject = () =>
new BadRequestError(
`${label} target "${target}" is not a managed leaf directory. Remove it and retry.`,
);
}
const segments = target.split(/[\\/]/).filter(Boolean);
if (segments.length === 0 || segments.includes('.') || segments.includes('..')) throw reject();
const resolved = await this.resolveContained(exportFolder, target);
if (resolved === path.resolve(exportFolder)) {
throw new BadRequestError(
`Dependency target "${target}" is not a managed leaf directory. Remove it and retry.`,
);
}
if (resolved === path.resolve(exportFolder)) throw reject();
return resolved;
}
/**
* Copy the staging export into `dest`, each file where `placement` puts it.
* A file the branch keeps is skipped, so a rename on the instance leaves the
* `project.json` or `folder.json` the branch holds alone.
* Move each renamed container directory to its export path, so unselected
* workflows inside it ride along. All sources are parked in a holding
* directory before any is placed, so a swap (A<->B) never clobbers a source.
* Park deepest `from` first; place shallowest `to` first.
*/
private async overlayDirectory(src: string, dest: string, placement: Placement): Promise<void> {
private async relocateBranchContainers(
workFolder: string,
moves: readonly ContainerMove[],
): Promise<void> {
if (moves.length === 0) return;
// A root entity file scans as target `.`, which would park the whole copy.
const sources = await Promise.all(
moves.map(async ({ from }) => await this.resolveLeafTarget(workFolder, from, 'Container')),
);
const destinations = await Promise.all(
moves.map(async ({ to }) => await this.resolveLeafTarget(workFolder, to, 'Container')),
);
const stash = await fs.mkdtemp(path.join(path.dirname(workFolder), '.relocate-'));
try {
const parked: Array<{ holding: string; dest: string; move: ContainerMove }> = [];
for (const [index, move] of moves.entries()) {
const holding = path.join(stash, String(index));
await fs.rename(sources[index], holding);
parked.push({ holding, dest: destinations[index], move });
}
parked.sort((a, b) => a.move.to.length - b.move.to.length);
for (const { holding, dest, move } of parked) {
// After parking, so a case-only rename does not collide with its own source.
if (await fs.lstat(dest).catch(() => null)) {
throw new BadRequestError(
`The branch path "${move.to}" is already in use. Remove it and retry.`,
);
}
await fs.mkdir(path.dirname(dest), { recursive: true });
await fs.rename(holding, dest);
}
} finally {
await fs.rm(stash, { recursive: true, force: true });
}
}
/**
* Copy the staging export into `dest`. Skips `keptFiles`: out-of-scope
* dependencies the branch owns elsewhere.
*/
private async overlayDirectory(
src: string,
dest: string,
keptFiles: ReadonlySet<string>,
): Promise<void> {
const verified = new Set<string>();
const walk = async (dir: string): Promise<void> => {
const entries = await fs.readdir(dir, { withFileTypes: true });
@ -609,13 +662,9 @@ export class WorkingCopyUpdater {
const fullPath = path.join(dir, entry.name);
const relative = path.relative(src, fullPath).split(path.sep).join('/');
if (relative === MANIFEST_FILE || placement.keptFiles.has(relative)) continue;
if (relative === MANIFEST_FILE || keptFiles.has(relative)) continue;
const destPath = await this.resolveContained(
dest,
pinPath(relative, placement.pins),
verified,
);
const destPath = await this.resolveContained(dest, relative, verified);
if (entry.isDirectory()) {
await fs.mkdir(destPath, { recursive: true });
await walk(fullPath);

View File

@ -20,14 +20,14 @@ export function getPromoteErrorMessage(
return undefined;
}
const meta = parsePromotionsWorkflowsMovedCrossProjectMeta(error.meta);
if (!meta) {
return undefined;
const movedMeta = parsePromotionsWorkflowsMovedCrossProjectMeta(error.meta);
if (movedMeta) {
return i18n.baseText('promotions.modal.promoteError.workflowsMovedCrossProject', {
interpolate: {
workflows: resolveWorkflowTitles(movedMeta.workflowIds, changes),
},
});
}
return i18n.baseText('promotions.modal.promoteError.workflowsMovedCrossProject', {
interpolate: {
workflows: resolveWorkflowTitles(meta.workflowIds, changes),
},
});
return undefined;
}