From 77d59d7371cd60fca03a72c42a18249876cbed00 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 19 Jan 2026 17:20:17 +0000 Subject: [PATCH] feat(cli): add folders as branch-specific items Folders can now be configured as branch-specific items in wmill.yaml: ```yaml gitBranches: staging: specificItems: folders: - "f/env_*" - "f/config" ``` Branch-specific folder format: f/folder/folder.branchName.meta.yaml (consistent with other item types where branch goes before the type suffix) Example: - Base: f/env_staging/folder.meta.yaml - Branch-specific: f/env_staging/folder.main.meta.yaml Changes: - Add `folders?: string[]` to SpecificItemsConfig - Add folder handling in toBranchSpecificPath() - Add folder handling in fromBranchSpecificPath() - Add folder pattern matching in isSpecificItem() - Add folder detection in isBranchSpecificFile() - Add folder detection in isCurrentBranchFile() - Add 13 new tests for folder functionality Co-Authored-By: Claude Opus 4.5 --- cli/src/core/specific_items.ts | 53 +++++++++++++++---- cli/test/specific_items.test.ts | 93 +++++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+), 11 deletions(-) diff --git a/cli/src/core/specific_items.ts b/cli/src/core/specific_items.ts index bef871b598..cbda845864 100644 --- a/cli/src/core/specific_items.ts +++ b/cli/src/core/specific_items.ts @@ -8,6 +8,7 @@ export interface SpecificItemsConfig { variables?: string[]; resources?: string[]; triggers?: string[]; + folders?: string[]; } // Define all branch-specific file types (computed lazily) @@ -142,6 +143,16 @@ export function isSpecificItem(path: string, specificItems: SpecificItemsConfig return specificItems.triggers ? matchesPatterns(path, specificItems.triggers) : false; } + // Check for folder meta files + if (path.endsWith('/folder.meta.yaml')) { + if (specificItems.folders) { + // Match against the folder path (without /folder.meta.yaml) + const folderPath = path.slice(0, -'/folder.meta.yaml'.length); + return matchesPatterns(folderPath, specificItems.folders); + } + return false; + } + // Check for resource files using the standard detection function if (isFileResource(path)) { // Extract the base path without the file extension to match against patterns @@ -159,6 +170,20 @@ export function isSpecificItem(path: string, specificItems: SpecificItemsConfig * Convert a base path to a branch-specific path */ export function toBranchSpecificPath(basePath: string, branchName: string): string { + // Sanitize branch name to be filesystem-safe + const sanitizedBranchName = branchName.replace(/[\/\\:*?"<>|.]/g, '_'); + + // Warn about potential collisions if sanitization occurred + if (sanitizedBranchName !== branchName) { + console.warn(`Warning: Branch name "${branchName}" contains filesystem-unsafe characters (/ \\ : * ? " < > | .) and was sanitized to "${sanitizedBranchName}". This may cause collisions with other similarly named branches.`); + } + + // Check for folder meta file pattern: folder.meta.yaml -> folder.branchName.meta.yaml + if (basePath.endsWith('/folder.meta.yaml')) { + const pathWithoutMeta = basePath.substring(0, basePath.length - '/folder.meta.yaml'.length); + return `${pathWithoutMeta}/folder.${sanitizedBranchName}.meta.yaml`; + } + // Check for resource file pattern (e.g., .resource.file.ini) const resourceFileMatch = basePath.match(/^(.+?)(\.resource\.file\..+)$/); @@ -178,14 +203,6 @@ export function toBranchSpecificPath(basePath: string, branchName: string): stri pathWithoutExtension = basePath.substring(0, basePath.length - extension.length); } - // Sanitize branch name to be filesystem-safe - const sanitizedBranchName = branchName.replace(/[\/\\:*?"<>|.]/g, '_'); - - // Warn about potential collisions if sanitization occurred - if (sanitizedBranchName !== branchName) { - console.warn(`Warning: Branch name "${branchName}" contains filesystem-unsafe characters (/ \\ : * ? " < > | .) and was sanitized to "${sanitizedBranchName}". This may cause collisions with other similarly named branches.`); - } - return `${pathWithoutExtension}.${sanitizedBranchName}${extension}`; } @@ -197,7 +214,13 @@ export function fromBranchSpecificPath(branchSpecificPath: string, branchName: s const sanitizedBranchName = branchName.replace(/[\/\\:*?"<>|.]/g, '_'); const escapedBranchName = sanitizedBranchName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - // Check for resource file pattern first + // Check for folder meta file pattern: /folder.branchName.meta.yaml -> /folder.meta.yaml + const folderPattern = new RegExp(`/folder\\.${escapedBranchName}\\.meta\\.yaml$`); + if (folderPattern.test(branchSpecificPath)) { + return branchSpecificPath.replace(folderPattern, '/folder.meta.yaml'); + } + + // Check for resource file pattern const resourceFilePattern = new RegExp(`\\.${escapedBranchName}(\\.resource\\.file\\..+)$`); const resourceFileMatch = branchSpecificPath.match(resourceFilePattern); @@ -283,7 +306,11 @@ export function isCurrentBranchFile(path: string, branchOverride?: string): bool // Use cached pattern or create and cache new one let pattern = branchPatternCache.get(currentBranch); if (!pattern) { - pattern = new RegExp(`\\.${escapedBranchName}\\.${buildYamlTypePattern()}\\.yaml$|\\.${escapedBranchName}\\.resource\\.file\\..+$`); + pattern = new RegExp( + `\\.${escapedBranchName}\\.${buildYamlTypePattern()}\\.yaml$|` + + `\\.${escapedBranchName}\\.resource\\.file\\..+$|` + + `/folder\\.${escapedBranchName}\\.meta\\.yaml$` + ); branchPatternCache.set(currentBranch, pattern); } @@ -296,5 +323,9 @@ export function isCurrentBranchFile(path: string, branchOverride?: string): bool */ export function isBranchSpecificFile(path: string): boolean { const yamlTypePattern = buildYamlTypePattern(); - return new RegExp(`\\.[^.]+\\.${yamlTypePattern}\\.yaml$|\\.[^.]+\\.resource\\.file\\..+$`).test(path); + return new RegExp( + `\\.[^.]+\\.${yamlTypePattern}\\.yaml$|` + + `\\.[^.]+\\.resource\\.file\\..+$|` + + `/folder\\.[^.]+\\.meta\\.yaml$` + ).test(path); } diff --git a/cli/test/specific_items.test.ts b/cli/test/specific_items.test.ts index 4448f32db7..f0f1a0e013 100644 --- a/cli/test/specific_items.test.ts +++ b/cli/test/specific_items.test.ts @@ -336,3 +336,96 @@ Deno.test("branchOverride: getSpecificItemsForCurrentBranch merges common and br assertEquals(result?.resources, ["shared/**"]); assertEquals(result?.triggers, ["dev/triggers/**"]); }); + +// ============================================================================= +// FOLDER BRANCH-SPECIFIC TESTS +// Format: f/folder/folder.branchName.meta.yaml +// ============================================================================= + +Deno.test("toBranchSpecificPath: converts folder meta path to branch-specific", () => { + // f/my_folder/folder.meta.yaml -> f/my_folder/folder.main.meta.yaml + const result = toBranchSpecificPath("f/my_folder/folder.meta.yaml", "main"); + assertEquals(result, "f/my_folder/folder.main.meta.yaml"); +}); + +Deno.test("toBranchSpecificPath: converts nested folder meta path to branch-specific", () => { + const result = toBranchSpecificPath("f/parent/child/folder.meta.yaml", "develop"); + assertEquals(result, "f/parent/child/folder.develop.meta.yaml"); +}); + +Deno.test("toBranchSpecificPath: sanitizes branch name in folder path", () => { + const result = toBranchSpecificPath("f/env/folder.meta.yaml", "feature/test"); + assertEquals(result, "f/env/folder.feature_test.meta.yaml"); +}); + +Deno.test("fromBranchSpecificPath: converts branch-specific folder back to base", () => { + const result = fromBranchSpecificPath("f/my_folder/folder.main.meta.yaml", "main"); + assertEquals(result, "f/my_folder/folder.meta.yaml"); +}); + +Deno.test("fromBranchSpecificPath: handles nested branch-specific folder", () => { + const result = fromBranchSpecificPath("f/parent/child/folder.develop.meta.yaml", "develop"); + assertEquals(result, "f/parent/child/folder.meta.yaml"); +}); + +Deno.test("fromBranchSpecificPath: handles sanitized branch names for folders", () => { + const result = fromBranchSpecificPath("f/env/folder.feature_test.meta.yaml", "feature/test"); + assertEquals(result, "f/env/folder.meta.yaml"); +}); + +Deno.test("isSpecificItem: matches folder paths with glob pattern", () => { + const config: SpecificItemsConfig = { + folders: ["f/env_*"], + }; + assertEquals(isSpecificItem("f/env_staging/folder.meta.yaml", config), true); + assertEquals(isSpecificItem("f/env_production/folder.meta.yaml", config), true); + assertEquals(isSpecificItem("f/other/folder.meta.yaml", config), false); +}); + +Deno.test("isSpecificItem: matches folder paths with exact pattern", () => { + const config: SpecificItemsConfig = { + folders: ["f/config"], + }; + assertEquals(isSpecificItem("f/config/folder.meta.yaml", config), true); + assertEquals(isSpecificItem("f/other/folder.meta.yaml", config), false); +}); + +Deno.test("isBranchSpecificFile: detects branch-specific folder files", () => { + assertEquals(isBranchSpecificFile("f/my_folder/folder.main.meta.yaml"), true); + assertEquals(isBranchSpecificFile("f/my_folder/folder.develop.meta.yaml"), true); + assertEquals(isBranchSpecificFile("f/nested/path/folder.staging.meta.yaml"), true); +}); + +Deno.test("isBranchSpecificFile: returns false for non-branch-specific folder files", () => { + assertEquals(isBranchSpecificFile("f/my_folder/folder.meta.yaml"), false); + assertEquals(isBranchSpecificFile("f/nested/path/folder.meta.yaml"), false); +}); + +Deno.test("isCurrentBranchFile: detects branch-specific folder for current branch", () => { + assertEquals(isCurrentBranchFile("f/my_folder/folder.staging.meta.yaml", "staging"), true); + assertEquals(isCurrentBranchFile("f/my_folder/folder.staging.meta.yaml", "production"), false); + assertEquals(isCurrentBranchFile("f/my_folder/folder.meta.yaml", "staging"), false); +}); + +Deno.test("isCurrentBranchFile: handles sanitized branch for folders", () => { + assertEquals(isCurrentBranchFile("f/env/folder.feature_test.meta.yaml", "feature/test"), true); + assertEquals(isCurrentBranchFile("f/env/folder.feature_test.meta.yaml", "feature/other"), false); +}); + +Deno.test("round-trip: folder meta path conversion", () => { + const original = "f/configs/env_folder/folder.meta.yaml"; + const branch = "main"; + const branchSpecific = toBranchSpecificPath(original, branch); + assertEquals(branchSpecific, "f/configs/env_folder/folder.main.meta.yaml"); + const restored = fromBranchSpecificPath(branchSpecific, branch); + assertEquals(restored, original); +}); + +Deno.test("round-trip: folder meta with sanitized branch", () => { + const original = "f/env/folder.meta.yaml"; + const branch = "feature/new-env"; + const branchSpecific = toBranchSpecificPath(original, branch); + assertEquals(branchSpecific, "f/env/folder.feature_new-env.meta.yaml"); + const restored = fromBranchSpecificPath(branchSpecific, branch); + assertEquals(restored, original); +});