From 7a9fc53a0f63ffddfc87cf806c9974e5035c2269 Mon Sep 17 00:00:00 2001 From: Farhan Yahaya Date: Mon, 20 Oct 2025 10:14:18 +0000 Subject: [PATCH 01/11] feat: workflow merge compatibility --- packages/project/src/Workflow.ts | 10 +++++ packages/project/test/workflow.test.ts | 59 ++++++++++++++++++++++++++ 2 files changed, 69 insertions(+) diff --git a/packages/project/src/Workflow.ts b/packages/project/src/Workflow.ts index 19a9b7574..06064bb50 100644 --- a/packages/project/src/Workflow.ts +++ b/packages/project/src/Workflow.ts @@ -20,6 +20,8 @@ class Workflow { id: string; openfn: OpenfnMeta; + history: string[] = []; + constructor(workflow: l.Workflow) { this.index = { steps: {}, // steps by id @@ -167,6 +169,14 @@ class Workflow { getVersionHash() { return generateHash(this); } + + // return true if the current workflow can be merged into the target workflow without losing any changes + canMergeInto(target: Workflow) { + if (!target.history.length) return true; + const targetHead = target.history[target.history.length - 1]; + if (this.history.indexOf(targetHead) > -1) return true; + return false; + } } export default Workflow; diff --git a/packages/project/test/workflow.test.ts b/packages/project/test/workflow.test.ts index 82e7b914a..c42853e2e 100644 --- a/packages/project/test/workflow.test.ts +++ b/packages/project/test/workflow.test.ts @@ -1,6 +1,7 @@ import { randomUUID } from 'node:crypto'; import test from 'ava'; import Workflow from '../src/Workflow'; +import { generateWorkflow } from '../src'; const simpleWorkflow = { id: 'my-workflow', @@ -205,3 +206,61 @@ test('map uuids to ids', (t) => { t.deepEqual(w.index.id[uuid_ac], 'a-c'); t.deepEqual(w.index.id[uuid_bc], 'b-c'); }); + +test('canMergeInto: empty histories', (t) => { + const sourceWf = generateWorkflow('trigger-x'); + const targetWf = generateWorkflow('trigger-y'); + t.deepEqual(sourceWf.history, []); + t.deepEqual(targetWf.history, []); + t.true(sourceWf.canMergeInto(targetWf)); // allowed +}); + +test('canMergeInto: empty target history', (t) => { + const sourceWf = generateWorkflow('trigger-x'); + const targetWf = generateWorkflow('trigger-y'); + sourceWf.history = ['history-1']; + t.is(sourceWf.history.length, 1); + t.deepEqual(targetWf.history, []); + t.true(sourceWf.canMergeInto(targetWf)); // allowed +}); + +test('canMergeInto: empty source history', (t) => { + const sourceWf = generateWorkflow('trigger-x'); + const targetWf = generateWorkflow('trigger-y'); + targetWf.history = ['history-1']; + t.is(targetWf.history.length, 1); + t.deepEqual(sourceWf.history, []); + t.false(sourceWf.canMergeInto(targetWf)); // not allowed +}); + +test('canMergeInto: target head in source', (t) => { + const sourceWf = generateWorkflow('trigger-x'); + const targetWf = generateWorkflow('trigger-y'); + targetWf.history = ['history-1', 'history-3', 'history-5']; + sourceWf.history = ['history-2', 'history-5', 'history-6']; + t.true(sourceWf.canMergeInto(targetWf)); // allowed +}); + +test('canMergeInto: target head not in source', (t) => { + const sourceWf = generateWorkflow('trigger-x'); + const targetWf = generateWorkflow('trigger-y'); + targetWf.history = ['history-1', 'history-3', 'history-5']; + sourceWf.history = ['history-2', 'history-4', 'history-6']; + t.false(sourceWf.canMergeInto(targetWf)); // not allowed +}); + +test('canMergeInto: source head in target', (t) => { + const sourceWf = generateWorkflow('trigger-x'); + const targetWf = generateWorkflow('trigger-y'); + targetWf.history = ['history-1', 'history-3', 'history-6', 'history-5']; + sourceWf.history = ['history-2', 'history-4', 'history-6']; + t.false(sourceWf.canMergeInto(targetWf)); // not allowed +}); + +test('canMergeInto: same heads', (t) => { + const sourceWf = generateWorkflow('trigger-x'); + const targetWf = generateWorkflow('trigger-y'); + targetWf.history = ['history-1', 'history-3', 'history-6']; + sourceWf.history = ['history-2', 'history-4', 'history-6']; + t.true(sourceWf.canMergeInto(targetWf)); // allowed +}); From d33ce0512fe9ac67799b9d8baac8d9f893186c76 Mon Sep 17 00:00:00 2001 From: Farhan Yahaya Date: Mon, 20 Oct 2025 12:29:55 +0000 Subject: [PATCH 02/11] feat: push version history --- packages/project/src/Workflow.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/project/src/Workflow.ts b/packages/project/src/Workflow.ts index 06064bb50..77fff338f 100644 --- a/packages/project/src/Workflow.ts +++ b/packages/project/src/Workflow.ts @@ -170,6 +170,10 @@ class Workflow { return generateHash(this); } + pushHistory(versionHash: string) { + this.history.push(versionHash); + } + // return true if the current workflow can be merged into the target workflow without losing any changes canMergeInto(target: Workflow) { if (!target.history.length) return true; From 0c2cc48ae70d8c09babef22f8ac047bd48292811 Mon Sep 17 00:00:00 2001 From: Farhan Yahaya Date: Mon, 20 Oct 2025 12:49:56 +0000 Subject: [PATCH 03/11] fix: log level when calling checkout from merge --- packages/cli/src/merge/handler.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/cli/src/merge/handler.ts b/packages/cli/src/merge/handler.ts index eed5f527e..4c767df83 100644 --- a/packages/cli/src/merge/handler.ts +++ b/packages/cli/src/merge/handler.ts @@ -65,6 +65,7 @@ const mergeHandler = async (options: MergeOptions, logger: Logger) => { command: 'checkout', projectPath: commandPath, projectName: final.name || '', + log: options.log, }, logger ); From 664dfbeab755ba66b494f3892f3685f53792a85c Mon Sep 17 00:00:00 2001 From: Farhan Yahaya Date: Mon, 20 Oct 2025 15:37:29 +0000 Subject: [PATCH 04/11] feat: add history to workflow property --- packages/lexicon/core.d.ts | 3 ++ packages/project/src/Workflow.ts | 14 ++++---- packages/project/test/gen/fixtures.ts | 1 + packages/project/test/gen/generator.test.ts | 7 ++++ .../project/test/parse/from-app-state.test.ts | 1 + packages/project/test/workflow.test.ts | 33 ++++++++++--------- 6 files changed, 37 insertions(+), 22 deletions(-) diff --git a/packages/lexicon/core.d.ts b/packages/lexicon/core.d.ts index 8d0ae075f..267acf6bb 100644 --- a/packages/lexicon/core.d.ts +++ b/packages/lexicon/core.d.ts @@ -83,6 +83,9 @@ export type Workflow = { globals?: string; openfn?: OpenFnMetadata; + + // holds history information of a workflow + history?: string[] }; export type StepId = string; diff --git a/packages/project/src/Workflow.ts b/packages/project/src/Workflow.ts index 77fff338f..cf787519a 100644 --- a/packages/project/src/Workflow.ts +++ b/packages/project/src/Workflow.ts @@ -20,8 +20,6 @@ class Workflow { id: string; openfn: OpenfnMeta; - history: string[] = []; - constructor(workflow: l.Workflow) { this.index = { steps: {}, // steps by id @@ -32,6 +30,9 @@ class Workflow { this.workflow = clone(workflow); + // history needs to be on workflow object. + this.workflow.history = workflow.history?.length ? workflow.history : []; + const { id, name, openfn, steps, ...options } = workflow; if (!(id || name)) { throw new Error('A Workflow MUST have a name or id'); @@ -171,14 +172,15 @@ class Workflow { } pushHistory(versionHash: string) { - this.history.push(versionHash); + this.workflow.history?.push(versionHash); } // return true if the current workflow can be merged into the target workflow without losing any changes canMergeInto(target: Workflow) { - if (!target.history.length) return true; - const targetHead = target.history[target.history.length - 1]; - if (this.history.indexOf(targetHead) > -1) return true; + if (!target.workflow.history.length) return true; + const targetHead = + target.workflow.history[target.workflow.history.length - 1]; + if (this.workflow.history.indexOf(targetHead) > -1) return true; return false; } } diff --git a/packages/project/test/gen/fixtures.ts b/packages/project/test/gen/fixtures.ts index e75597307..a33dd9725 100644 --- a/packages/project/test/gen/fixtures.ts +++ b/packages/project/test/gen/fixtures.ts @@ -4,6 +4,7 @@ export const ab = { id: 'workflow', name: 'Workflow', + history: [], steps: [ { id: 'a', diff --git a/packages/project/test/gen/generator.test.ts b/packages/project/test/gen/generator.test.ts index 794a0c59b..0c2ab6863 100644 --- a/packages/project/test/gen/generator.test.ts +++ b/packages/project/test/gen/generator.test.ts @@ -120,6 +120,7 @@ test('it should generate a simple workflow with any letter', (t) => { const expected = { id: 'workflow', name: 'Workflow', + history: [], steps: [ { id: 'x', @@ -152,6 +153,7 @@ test('it should generate a simple workflow with words, numbers and underscores', const expected = { id: 'workflow', name: 'Workflow', + history: [], steps: [ { id: 'node_1', @@ -184,6 +186,7 @@ test('it should generate two node pairs', (t) => { const expected = { id: 'workflow', name: 'Workflow', + history: [], steps: [ { id: 'a', @@ -231,6 +234,7 @@ test('it should generate two node pairs from one parent', (t) => { const expected = { id: 'workflow', name: 'Workflow', + history: [], steps: [ { id: 'a', @@ -276,6 +280,7 @@ test('it should generate several node pairs', (t) => { const expected = { id: 'workflow', name: 'Workflow', + history: [], steps: [ { id: 'a', @@ -439,6 +444,7 @@ test('it should generate a simple workflow with mapped uuids', (t) => { const expected = { id: 'workflow', name: 'Workflow', + history: [], steps: [ { id: 'a', @@ -481,6 +487,7 @@ test('it should generate a project with uuids', (t) => { const expected = { id: 'workflow', name: 'Workflow', + history: [], steps: [ { id: 'a', diff --git a/packages/project/test/parse/from-app-state.test.ts b/packages/project/test/parse/from-app-state.test.ts index 16fda2237..86c3ef6ed 100644 --- a/packages/project/test/parse/from-app-state.test.ts +++ b/packages/project/test/parse/from-app-state.test.ts @@ -114,6 +114,7 @@ test('should create a Project from prov state with a workflow', (t) => { t.deepEqual(project.workflows[0].toJSON(), { id: 'my-workflow', name: 'My Workflow', + history: [], steps: [ { id: 'trigger', diff --git a/packages/project/test/workflow.test.ts b/packages/project/test/workflow.test.ts index c42853e2e..b2ce3bdaa 100644 --- a/packages/project/test/workflow.test.ts +++ b/packages/project/test/workflow.test.ts @@ -5,6 +5,7 @@ import { generateWorkflow } from '../src'; const simpleWorkflow = { id: 'my-workflow', + history: [], name: 'My Workflow', steps: [ { @@ -210,57 +211,57 @@ test('map uuids to ids', (t) => { test('canMergeInto: empty histories', (t) => { const sourceWf = generateWorkflow('trigger-x'); const targetWf = generateWorkflow('trigger-y'); - t.deepEqual(sourceWf.history, []); - t.deepEqual(targetWf.history, []); + t.deepEqual(sourceWf.workflow.history, []); + t.deepEqual(targetWf.workflow.history, []); t.true(sourceWf.canMergeInto(targetWf)); // allowed }); test('canMergeInto: empty target history', (t) => { const sourceWf = generateWorkflow('trigger-x'); const targetWf = generateWorkflow('trigger-y'); - sourceWf.history = ['history-1']; - t.is(sourceWf.history.length, 1); - t.deepEqual(targetWf.history, []); + sourceWf.workflow.history = ['history-1']; + t.is(sourceWf.workflow.history.length, 1); + t.deepEqual(targetWf.workflow.history, []); t.true(sourceWf.canMergeInto(targetWf)); // allowed }); test('canMergeInto: empty source history', (t) => { const sourceWf = generateWorkflow('trigger-x'); const targetWf = generateWorkflow('trigger-y'); - targetWf.history = ['history-1']; - t.is(targetWf.history.length, 1); - t.deepEqual(sourceWf.history, []); + targetWf.workflow.history = ['history-1']; + t.is(targetWf.workflow.history.length, 1); + t.deepEqual(sourceWf.workflow.history, []); t.false(sourceWf.canMergeInto(targetWf)); // not allowed }); test('canMergeInto: target head in source', (t) => { const sourceWf = generateWorkflow('trigger-x'); const targetWf = generateWorkflow('trigger-y'); - targetWf.history = ['history-1', 'history-3', 'history-5']; - sourceWf.history = ['history-2', 'history-5', 'history-6']; + targetWf.workflow.history = ['history-1', 'history-3', 'history-5']; + sourceWf.workflow.history = ['history-2', 'history-5', 'history-6']; t.true(sourceWf.canMergeInto(targetWf)); // allowed }); test('canMergeInto: target head not in source', (t) => { const sourceWf = generateWorkflow('trigger-x'); const targetWf = generateWorkflow('trigger-y'); - targetWf.history = ['history-1', 'history-3', 'history-5']; - sourceWf.history = ['history-2', 'history-4', 'history-6']; + targetWf.workflow.history = ['history-1', 'history-3', 'history-5']; + sourceWf.workflow.history = ['history-2', 'history-4', 'history-6']; t.false(sourceWf.canMergeInto(targetWf)); // not allowed }); test('canMergeInto: source head in target', (t) => { const sourceWf = generateWorkflow('trigger-x'); const targetWf = generateWorkflow('trigger-y'); - targetWf.history = ['history-1', 'history-3', 'history-6', 'history-5']; - sourceWf.history = ['history-2', 'history-4', 'history-6']; + targetWf.workflow.history = ['history-1', 'history-3', 'history-6', 'history-5']; + sourceWf.workflow.history = ['history-2', 'history-4', 'history-6']; t.false(sourceWf.canMergeInto(targetWf)); // not allowed }); test('canMergeInto: same heads', (t) => { const sourceWf = generateWorkflow('trigger-x'); const targetWf = generateWorkflow('trigger-y'); - targetWf.history = ['history-1', 'history-3', 'history-6']; - sourceWf.history = ['history-2', 'history-4', 'history-6']; + targetWf.workflow.history = ['history-1', 'history-3', 'history-6']; + sourceWf.workflow.history = ['history-2', 'history-4', 'history-6']; t.true(sourceWf.canMergeInto(targetWf)); // allowed }); From b5f667acfe2e53dc1186a238a2ecdc39dcd6569a Mon Sep 17 00:00:00 2001 From: Farhan Yahaya Date: Tue, 21 Oct 2025 07:25:51 +0000 Subject: [PATCH 05/11] tests: update wording --- packages/project/test/workflow.test.ts | 31 +++++++++++++++++++------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/packages/project/test/workflow.test.ts b/packages/project/test/workflow.test.ts index b2ce3bdaa..1c5cedfee 100644 --- a/packages/project/test/workflow.test.ts +++ b/packages/project/test/workflow.test.ts @@ -208,60 +208,75 @@ test('map uuids to ids', (t) => { t.deepEqual(w.index.id[uuid_bc], 'b-c'); }); -test('canMergeInto: empty histories', (t) => { +test('canMergeInto: can merge empty histories', (t) => { const sourceWf = generateWorkflow('trigger-x'); const targetWf = generateWorkflow('trigger-y'); + t.deepEqual(sourceWf.workflow.history, []); t.deepEqual(targetWf.workflow.history, []); + t.true(sourceWf.canMergeInto(targetWf)); // allowed }); -test('canMergeInto: empty target history', (t) => { +test('canMergeInto: can merge empty target history', (t) => { const sourceWf = generateWorkflow('trigger-x'); const targetWf = generateWorkflow('trigger-y'); sourceWf.workflow.history = ['history-1']; + t.is(sourceWf.workflow.history.length, 1); t.deepEqual(targetWf.workflow.history, []); + t.true(sourceWf.canMergeInto(targetWf)); // allowed }); -test('canMergeInto: empty source history', (t) => { +test('canMergeInto: cannot merge empty source history', (t) => { const sourceWf = generateWorkflow('trigger-x'); const targetWf = generateWorkflow('trigger-y'); targetWf.workflow.history = ['history-1']; + t.is(targetWf.workflow.history.length, 1); t.deepEqual(sourceWf.workflow.history, []); + t.false(sourceWf.canMergeInto(targetWf)); // not allowed }); -test('canMergeInto: target head in source', (t) => { +test('canMergeInto: can merge target head in source', (t) => { const sourceWf = generateWorkflow('trigger-x'); const targetWf = generateWorkflow('trigger-y'); targetWf.workflow.history = ['history-1', 'history-3', 'history-5']; sourceWf.workflow.history = ['history-2', 'history-5', 'history-6']; + t.true(sourceWf.canMergeInto(targetWf)); // allowed }); -test('canMergeInto: target head not in source', (t) => { +test('canMergeInto: cannot merge target head not in source', (t) => { const sourceWf = generateWorkflow('trigger-x'); const targetWf = generateWorkflow('trigger-y'); targetWf.workflow.history = ['history-1', 'history-3', 'history-5']; sourceWf.workflow.history = ['history-2', 'history-4', 'history-6']; + t.false(sourceWf.canMergeInto(targetWf)); // not allowed }); -test('canMergeInto: source head in target', (t) => { +test('canMergeInto: cannot merge source head in target', (t) => { const sourceWf = generateWorkflow('trigger-x'); const targetWf = generateWorkflow('trigger-y'); - targetWf.workflow.history = ['history-1', 'history-3', 'history-6', 'history-5']; + targetWf.workflow.history = [ + 'history-1', + 'history-3', + 'history-6', + 'history-5', + ]; sourceWf.workflow.history = ['history-2', 'history-4', 'history-6']; + t.false(sourceWf.canMergeInto(targetWf)); // not allowed }); -test('canMergeInto: same heads', (t) => { +test('canMergeInto: can merge same heads', (t) => { const sourceWf = generateWorkflow('trigger-x'); const targetWf = generateWorkflow('trigger-y'); targetWf.workflow.history = ['history-1', 'history-3', 'history-6']; sourceWf.workflow.history = ['history-2', 'history-4', 'history-6']; + t.true(sourceWf.canMergeInto(targetWf)); // allowed }); From c6b3fcec082a3552213185968e4fcf48bba6b3fa Mon Sep 17 00:00:00 2001 From: Farhan Yahaya Date: Tue, 21 Oct 2025 14:27:29 +0000 Subject: [PATCH 06/11] feat: error when there are incompatible workflows --- packages/project/src/merge/merge-project.ts | 23 ++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/project/src/merge/merge-project.ts b/packages/project/src/merge/merge-project.ts index 09becaae5..2ee0ab9f9 100644 --- a/packages/project/src/merge/merge-project.ts +++ b/packages/project/src/merge/merge-project.ts @@ -1,4 +1,3 @@ -import { Workflow } from '@openfn/lexicon'; import { defaultsDeep, isEmpty } from 'lodash-es'; import { Project } from '../Project'; @@ -6,6 +5,7 @@ import { mergeWorkflows } from './merge-node'; import mapUuids from './map-uuids'; import baseMerge from '../util/base-merge'; import getDuplicates from '../util/get-duplicates'; +import Workflow from '../Workflow'; export type MergeProjectOptions = Partial<{ workflowMappings: Record; // @@ -56,6 +56,27 @@ export function merge( return !!options?.workflowMappings[w.id]; }); + // mergeability + const mergeMapping: Record = {}; + for (const sourceWorkflow of sourceWorkflows) { + const targetId = + options.workflowMappings?.[sourceWorkflow.id] ?? sourceWorkflow.id; + const targetWorkflow = target.getWorkflow(targetId); + if (!sourceWorkflow.canMergeInto(targetWorkflow)) { + mergeMapping[sourceWorkflow.name] = targetWorkflow?.name; + } + } + + if (Object.keys(mergeMapping).length) { + throw new Error( + `The below workflows can't merge directly without losing data.\n${Object.entries( + mergeMapping + ) + .map(([from, to]) => `${from} → ${to}`) + .join('\n')}` + ); + } + for (const sourceWorkflow of sourceWorkflows) { const targetId = options.workflowMappings?.[sourceWorkflow.id] ?? sourceWorkflow.id; From 6b3b377892a63029b040bf9450ce0268d7cb3186 Mon Sep 17 00:00:00 2001 From: Farhan Yahaya Date: Tue, 21 Oct 2025 14:34:42 +0000 Subject: [PATCH 07/11] feat: ignore incompatibility when force passed --- packages/cli/src/merge/command.ts | 2 ++ packages/cli/src/merge/handler.ts | 1 + packages/project/src/merge/merge-project.ts | 5 ++--- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/merge/command.ts b/packages/cli/src/merge/command.ts index f61db6750..f81c57645 100644 --- a/packages/cli/src/merge/command.ts +++ b/packages/cli/src/merge/command.ts @@ -12,6 +12,7 @@ export type MergeOptions = Required< | 'removeUnmapped' | 'workflowMappings' | 'log' + | 'force' > >; @@ -21,6 +22,7 @@ const options = [ o.removeUnmapped, o.workflowMappings, o.log, + o.force, ]; const mergeCommand: yargs.CommandModule = { diff --git a/packages/cli/src/merge/handler.ts b/packages/cli/src/merge/handler.ts index 4c767df83..fe3ed2283 100644 --- a/packages/cli/src/merge/handler.ts +++ b/packages/cli/src/merge/handler.ts @@ -55,6 +55,7 @@ const mergeHandler = async (options: MergeOptions, logger: Logger) => { const final = Project.merge(sourceProject, targetProject, { removeUnmapped: options.removeUnmapped, workflowMappings: options.workflowMappings, + force: options.force, }); const yaml = final.serialize('state', { format: 'yaml' }); await fs.writeFile(finalPath, yaml); diff --git a/packages/project/src/merge/merge-project.ts b/packages/project/src/merge/merge-project.ts index 2ee0ab9f9..ad0335d79 100644 --- a/packages/project/src/merge/merge-project.ts +++ b/packages/project/src/merge/merge-project.ts @@ -10,8 +10,7 @@ import Workflow from '../Workflow'; export type MergeProjectOptions = Partial<{ workflowMappings: Record; // removeUnmapped: boolean; - - force: boolean; // TODO not implemented yet + force: boolean; }>; /** @@ -67,7 +66,7 @@ export function merge( } } - if (Object.keys(mergeMapping).length) { + if (Object.keys(mergeMapping).length && !options?.force) { throw new Error( `The below workflows can't merge directly without losing data.\n${Object.entries( mergeMapping From ed0ea888fb1075f5d3ec4ce97446978ff1741bb0 Mon Sep 17 00:00:00 2001 From: Farhan Yahaya Date: Tue, 21 Oct 2025 14:42:09 +0000 Subject: [PATCH 08/11] tests: fix workflow check --- packages/project/src/merge/merge-project.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/project/src/merge/merge-project.ts b/packages/project/src/merge/merge-project.ts index ad0335d79..db78b4691 100644 --- a/packages/project/src/merge/merge-project.ts +++ b/packages/project/src/merge/merge-project.ts @@ -61,7 +61,7 @@ export function merge( const targetId = options.workflowMappings?.[sourceWorkflow.id] ?? sourceWorkflow.id; const targetWorkflow = target.getWorkflow(targetId); - if (!sourceWorkflow.canMergeInto(targetWorkflow)) { + if (targetWorkflow && !sourceWorkflow.canMergeInto(targetWorkflow)) { mergeMapping[sourceWorkflow.name] = targetWorkflow?.name; } } From 98bfe2a29ea5b8a9cc7ea6d71b9c20d5e3a9c368 Mon Sep 17 00:00:00 2001 From: Farhan Yahaya Date: Wed, 22 Oct 2025 12:01:17 +0000 Subject: [PATCH 09/11] tests: incompatibility merging via project class --- packages/project/test/project.test.ts | 33 ++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/packages/project/test/project.test.ts b/packages/project/test/project.test.ts index ac5475322..ba29c746e 100644 --- a/packages/project/test/project.test.ts +++ b/packages/project/test/project.test.ts @@ -2,7 +2,7 @@ import test from 'ava'; import type { Provisioner } from '@openfn/lexicon/lightning'; import { Project } from '../src/Project'; -import generateWorkflow from '../src/gen/generator'; +import generateWorkflow, { generateProject } from '../src/gen/generator'; // TODO move to fixtures and re-use? // Or use util function instead? @@ -154,3 +154,34 @@ test('should return UUIDs for everything', (t) => { }, }); }); + +test('incompatible-merge: should throw error when merge is incompatible', (t) => { + const source = generateWorkflow('trigger-x'); + source.pushHistory(source.getVersionHash()); + const target = generateWorkflow('trigger-y'); + target.pushHistory(target.getVersionHash()); + + t.false(source.canMergeInto(target)); + + const sourceProject = new Project({ workflows: [source] }); + const targetProject = new Project({ workflows: [target] }); + t.throws(() => Project.merge(sourceProject, targetProject), { + message: `The below workflows can't be merged directly without losing data.\nWorkflow → Workflow`, + }); +}); + +test('incompatible-merge-force: should ignore incompatiblity and merge when forced', (t) => { + // same as the above test with force + const source = generateWorkflow('trigger-x'); + source.pushHistory(source.getVersionHash()); + const target = generateWorkflow('trigger-y'); + target.pushHistory(target.getVersionHash()); + + t.false(source.canMergeInto(target)); + + const sourceProject = new Project({ workflows: [source] }); + const targetProject = new Project({ workflows: [target] }); + t.notThrows(() => + Project.merge(sourceProject, targetProject, { force: true }) + ); +}); From c35c87dfb2da2f3df06e1061b3dc68964dbe1576 Mon Sep 17 00:00:00 2001 From: Farhan Yahaya Date: Wed, 22 Oct 2025 12:07:04 +0000 Subject: [PATCH 10/11] tests: fix error wording --- packages/project/src/merge/merge-project.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/project/src/merge/merge-project.ts b/packages/project/src/merge/merge-project.ts index db78b4691..0ae87d4e8 100644 --- a/packages/project/src/merge/merge-project.ts +++ b/packages/project/src/merge/merge-project.ts @@ -68,7 +68,7 @@ export function merge( if (Object.keys(mergeMapping).length && !options?.force) { throw new Error( - `The below workflows can't merge directly without losing data.\n${Object.entries( + `The below workflows can't be merged directly without losing data.\n${Object.entries( mergeMapping ) .map(([from, to]) => `${from} → ${to}`) From 7a9943a6bc35ed83ee281783ac935ba63c35743b Mon Sep 17 00:00:00 2001 From: Farhan Yahaya Date: Fri, 24 Oct 2025 09:22:14 +0000 Subject: [PATCH 11/11] chore: updates --- packages/cli/src/merge/command.ts | 6 ++++-- packages/project/src/merge/merge-project.ts | 12 ++++++------ packages/project/test/project.test.ts | 2 +- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/merge/command.ts b/packages/cli/src/merge/command.ts index f81c57645..a07447e1d 100644 --- a/packages/cli/src/merge/command.ts +++ b/packages/cli/src/merge/command.ts @@ -1,6 +1,6 @@ import yargs from 'yargs'; import { Opts } from '../options'; -import { ensure, build } from '../util/command-builders'; +import { ensure, build, override } from '../util/command-builders'; import * as o from '../options'; export type MergeOptions = Required< @@ -22,7 +22,9 @@ const options = [ o.removeUnmapped, o.workflowMappings, o.log, - o.force, + override(o.force, { + description: 'Force a merge even when workflows are incompatible', + }), ]; const mergeCommand: yargs.CommandModule = { diff --git a/packages/project/src/merge/merge-project.ts b/packages/project/src/merge/merge-project.ts index 0ae87d4e8..9d3b7b1a6 100644 --- a/packages/project/src/merge/merge-project.ts +++ b/packages/project/src/merge/merge-project.ts @@ -56,23 +56,23 @@ export function merge( }); // mergeability - const mergeMapping: Record = {}; + const potentialConflicts: Record = {}; for (const sourceWorkflow of sourceWorkflows) { const targetId = options.workflowMappings?.[sourceWorkflow.id] ?? sourceWorkflow.id; const targetWorkflow = target.getWorkflow(targetId); if (targetWorkflow && !sourceWorkflow.canMergeInto(targetWorkflow)) { - mergeMapping[sourceWorkflow.name] = targetWorkflow?.name; + potentialConflicts[sourceWorkflow.name] = targetWorkflow?.name; } } - if (Object.keys(mergeMapping).length && !options?.force) { + if (Object.keys(potentialConflicts).length && !options?.force) { throw new Error( - `The below workflows can't be merged directly without losing data.\n${Object.entries( - mergeMapping + `The below workflows can't be merged directly without losing data\n${Object.entries( + potentialConflicts ) .map(([from, to]) => `${from} → ${to}`) - .join('\n')}` + .join('\n')}\nPass --force to force the merge anyway` ); } diff --git a/packages/project/test/project.test.ts b/packages/project/test/project.test.ts index ba29c746e..8a0ac6bf1 100644 --- a/packages/project/test/project.test.ts +++ b/packages/project/test/project.test.ts @@ -166,7 +166,7 @@ test('incompatible-merge: should throw error when merge is incompatible', (t) => const sourceProject = new Project({ workflows: [source] }); const targetProject = new Project({ workflows: [target] }); t.throws(() => Project.merge(sourceProject, targetProject), { - message: `The below workflows can't be merged directly without losing data.\nWorkflow → Workflow`, + message: `The below workflows can't be merged directly without losing data\nWorkflow → Workflow\nPass --force to force the merge anyway`, }); });