diff --git a/packages/cli/src/environments.ee/source-control/__tests__/source-control-export.service.test.ts b/packages/cli/src/environments.ee/source-control/__tests__/source-control-export.service.test.ts index 016b25e802c..68984dfc394 100644 --- a/packages/cli/src/environments.ee/source-control/__tests__/source-control-export.service.test.ts +++ b/packages/cli/src/environments.ee/source-control/__tests__/source-control-export.service.test.ts @@ -1,5 +1,6 @@ import type { SourceControlledFile } from '@n8n/api-types'; import type { + Folder, FolderRepository, Project, ProjectRepository, @@ -57,6 +58,7 @@ describe('SourceControlExportService', () => { ); const fsWriteFile = jest.spyOn(fsp, 'writeFile'); + const fsReadFile = jest.spyOn(fsp, 'readFile'); beforeEach(() => jest.clearAllMocks()); @@ -273,6 +275,63 @@ describe('SourceControlExportService', () => { expect(result.count).toBe(0); expect(result.files).toHaveLength(0); }); + + it('should not duplicate folders on push', async () => { + // Arrange + const newFolders = [ + { + id: 'folder-id', + name: 'Folder Name', + parentFolderId: null, + homeProject: { id: 'project-id' }, + createdAt: new Date(), + updatedAt: new Date(), + } as Folder, + ]; + folderRepository.find.mockResolvedValue(newFolders); + workflowRepository.find.mockResolvedValue([mock()]); + const existingFolders = [ + { + id: 'folder-id', + name: 'Folder Name', + parentFolderId: null, + homeProjectId: 'project-id', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + ]; + fsReadFile.mockResolvedValue( + JSON.stringify({ + folders: existingFolders, + }), + ); + + // Act + const result = await service.exportFoldersToWorkFolder(globalAdminContext); + + // Assert + // new json file should contain only the new folders + expect(fsWriteFile).toHaveBeenCalledWith( + '/mock/n8n/git/folders.json', + JSON.stringify( + { + folders: newFolders.map((f) => ({ + id: f.id, + name: f.name, + parentFolderId: f.parentFolderId, + homeProjectId: f.homeProject.id, + createdAt: f.createdAt.toISOString(), + updatedAt: f.updatedAt.toISOString(), + })), + }, + null, + 2, + ), + ); + + expect(result.count).toBe(1); + expect(result.files).toHaveLength(1); + }); }); describe('exportVariablesToWorkFolder', () => { diff --git a/packages/cli/src/environments.ee/source-control/source-control-export.service.ee.ts b/packages/cli/src/environments.ee/source-control/source-control-export.service.ee.ts index 77d9ec98d88..6c0000398ea 100644 --- a/packages/cli/src/environments.ee/source-control/source-control-export.service.ee.ts +++ b/packages/cli/src/environments.ee/source-control/source-control-export.service.ee.ts @@ -20,7 +20,6 @@ import { UnexpectedError, type ICredentialDataDecryptedObject } from 'n8n-workfl import { rm as fsRm, writeFile as fsWriteFile } from 'node:fs/promises'; import path from 'path'; -import { VariablesService } from '../variables/variables.service.ee'; import { SOURCE_CONTROL_CREDENTIAL_EXPORT_FOLDER, SOURCE_CONTROL_GIT_FOLDER, @@ -40,14 +39,15 @@ import { stringContainsExpression, } from './source-control-helper.ee'; import { SourceControlScopedService } from './source-control-scoped.service'; +import { VariablesService } from '../variables/variables.service.ee'; import type { ExportResult } from './types/export-result'; import type { ExportableCredential } from './types/exportable-credential'; +import { ExportableProject } from './types/exportable-project'; import type { ExportableWorkflow } from './types/exportable-workflow'; import type { RemoteResourceOwner } from './types/resource-owner'; import type { SourceControlContext } from './types/source-control-context'; import { formatWorkflow } from '@/workflows/workflow.formatter'; -import { ExportableProject } from './types/exportable-project'; @Service() export class SourceControlExportService { @@ -269,7 +269,7 @@ export class SourceControlExportService { // keep all folders that are not accessible by the current user // if allowedProjects is undefined, all folders are accessible by the current user const foldersToKeepUnchanged = context.hasAccessToAllProjects() - ? existingFolders.folders + ? [] : existingFolders.folders.filter((folder) => { return !allowedProjects.some((project) => project.id === folder.homeProjectId); });