From 7f2dc707b095156d99e80cfd7c737f2a2a4f9914 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 19 Jan 2026 17:35:52 +0000 Subject: [PATCH] feat(cli): add branch-specific items for folders and settings (#7611) * 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 * feat(cli): add settings as branch-specific item and skip validation with --branch - Add settings.yaml as a branch-specific item (settings: true in config) - settings.yaml -> settings.branchName.yaml conversion - Skip "Create empty branch configuration" prompt when using --branch flag - User explicitly specifies branch, so skip validation prompts - Add folders and settings fields to gitBranches type definitions Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- cli/src/commands/sync/sync.ts | 8 +- cli/src/core/conf.ts | 14 ++- cli/src/core/specific_items.ts | 85 +++++++++++++-- cli/test/specific_items.test.ts | 177 ++++++++++++++++++++++++++++++++ 4 files changed, 267 insertions(+), 17 deletions(-) diff --git a/cli/src/commands/sync/sync.ts b/cli/src/commands/sync/sync.ts index e2df1f5679..5429d4f8de 100644 --- a/cli/src/commands/sync/sync.ts +++ b/cli/src/commands/sync/sync.ts @@ -1866,9 +1866,9 @@ export async function pull( const originalCliOpts = { ...opts }; opts = await mergeConfigWithConfigFile(opts); - // Validate branch configuration early + // Validate branch configuration early (skipped when --branch is used) try { - await validateBranchConfiguration(opts); + await validateBranchConfiguration(opts, opts.branch); } catch (error) { if (error instanceof Error && error.message.includes("overrides")) { log.error(error.message); @@ -2351,9 +2351,9 @@ export async function push( // Load configuration from wmill.yaml and merge with CLI options opts = await mergeConfigWithConfigFile(opts); - // Validate branch configuration early + // Validate branch configuration early (skipped when --branch is used) try { - await validateBranchConfiguration(opts); + await validateBranchConfiguration(opts, opts.branch); } catch (error) { if (error instanceof Error && error.message.includes("overrides")) { log.error(error.message); diff --git a/cli/src/core/conf.ts b/cli/src/core/conf.ts index 9de4f8f6e3..5fc9c1b452 100644 --- a/cli/src/core/conf.ts +++ b/cli/src/core/conf.ts @@ -53,6 +53,8 @@ export interface SyncOptions { variables?: string[]; resources?: string[]; triggers?: string[]; + folders?: string[]; + settings?: boolean; }; } & { [branchName: string]: SyncOptions & { @@ -64,6 +66,8 @@ export interface SyncOptions { variables?: string[]; resources?: string[]; triggers?: string[]; + folders?: string[]; + settings?: boolean; }; }; }; @@ -73,6 +77,8 @@ export interface SyncOptions { variables?: string[]; resources?: string[]; triggers?: string[]; + folders?: string[]; + settings?: boolean; }; } & { [branchName: string]: SyncOptions & { @@ -84,6 +90,8 @@ export interface SyncOptions { variables?: string[]; resources?: string[]; triggers?: string[]; + folders?: string[]; + settings?: boolean; }; }; }; @@ -370,9 +378,11 @@ export async function mergeConfigWithConfigFile( // Validate branch configuration early in the process export async function validateBranchConfiguration( - opts: Pick + opts: Pick, + branchOverride?: string ): Promise { - if (opts.skipBranchValidation || !isGitRepository()) { + // When branch override is provided, skip validation - user is explicitly specifying the branch + if (opts.skipBranchValidation || branchOverride || !isGitRepository()) { return; } diff --git a/cli/src/core/specific_items.ts b/cli/src/core/specific_items.ts index bef871b598..30ac828375 100644 --- a/cli/src/core/specific_items.ts +++ b/cli/src/core/specific_items.ts @@ -8,6 +8,8 @@ export interface SpecificItemsConfig { variables?: string[]; resources?: string[]; triggers?: string[]; + folders?: string[]; + settings?: boolean; } // Define all branch-specific file types (computed lazily) @@ -98,6 +100,12 @@ export function getSpecificItemsForCurrentBranch(config: SyncOptions, branchOver if (commonItems?.triggers) { merged.triggers = [...commonItems.triggers]; } + if (commonItems?.folders) { + merged.folders = [...commonItems.folders]; + } + if (commonItems?.settings !== undefined) { + merged.settings = commonItems.settings; + } // Add branch-specific items (extending common items) if (branchItems?.variables) { @@ -109,6 +117,13 @@ export function getSpecificItemsForCurrentBranch(config: SyncOptions, branchOver if (branchItems?.triggers) { merged.triggers = [...(merged.triggers || []), ...branchItems.triggers]; } + if (branchItems?.folders) { + merged.folders = [...(merged.folders || []), ...branchItems.folders]; + } + // For settings (boolean), branch-specific overrides common + if (branchItems?.settings !== undefined) { + merged.settings = branchItems.settings; + } return merged; } @@ -142,6 +157,21 @@ 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 settings.yaml (root-level file) + if (path === 'settings.yaml') { + return specificItems.settings === true; + } + // 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 +189,25 @@ 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 settings.yaml: settings.yaml -> settings.branchName.yaml + if (basePath === 'settings.yaml') { + return `settings.${sanitizedBranchName}.yaml`; + } + // Check for resource file pattern (e.g., .resource.file.ini) const resourceFileMatch = basePath.match(/^(.+?)(\.resource\.file\..+)$/); @@ -178,14 +227,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 +238,19 @@ 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 settings file pattern: settings.branchName.yaml -> settings.yaml + const settingsPattern = new RegExp(`^settings\\.${escapedBranchName}\\.yaml$`); + if (settingsPattern.test(branchSpecificPath)) { + return 'settings.yaml'; + } + + // Check for resource file pattern const resourceFilePattern = new RegExp(`\\.${escapedBranchName}(\\.resource\\.file\\..+)$`); const resourceFileMatch = branchSpecificPath.match(resourceFilePattern); @@ -283,7 +336,12 @@ 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$|` + + `^settings\\.${escapedBranchName}\\.yaml$` + ); branchPatternCache.set(currentBranch, pattern); } @@ -296,5 +354,10 @@ 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$|` + + `^settings\\.[^.]+\\.yaml$` + ).test(path); } diff --git a/cli/test/specific_items.test.ts b/cli/test/specific_items.test.ts index 4448f32db7..e0d6782fa6 100644 --- a/cli/test/specific_items.test.ts +++ b/cli/test/specific_items.test.ts @@ -336,3 +336,180 @@ 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); +}); + +// ============================================================================= +// SETTINGS BRANCH-SPECIFIC TESTS +// ============================================================================= + +Deno.test("toBranchSpecificPath: converts settings.yaml to branch-specific", () => { + const result = toBranchSpecificPath("settings.yaml", "main"); + assertEquals(result, "settings.main.yaml"); +}); + +Deno.test("toBranchSpecificPath: sanitizes branch name in settings path", () => { + const result = toBranchSpecificPath("settings.yaml", "feature/test"); + assertEquals(result, "settings.feature_test.yaml"); +}); + +Deno.test("fromBranchSpecificPath: converts branch-specific settings back to base", () => { + const result = fromBranchSpecificPath("settings.main.yaml", "main"); + assertEquals(result, "settings.yaml"); +}); + +Deno.test("fromBranchSpecificPath: handles sanitized branch names for settings", () => { + const result = fromBranchSpecificPath("settings.feature_test.yaml", "feature/test"); + assertEquals(result, "settings.yaml"); +}); + +Deno.test("isSpecificItem: matches settings.yaml when settings is true", () => { + const config: SpecificItemsConfig = { + settings: true, + }; + assertEquals(isSpecificItem("settings.yaml", config), true); +}); + +Deno.test("isSpecificItem: does not match settings.yaml when settings is false", () => { + const config: SpecificItemsConfig = { + settings: false, + }; + assertEquals(isSpecificItem("settings.yaml", config), false); +}); + +Deno.test("isSpecificItem: does not match settings.yaml when settings is undefined", () => { + const config: SpecificItemsConfig = { + variables: ["f/**"], + }; + assertEquals(isSpecificItem("settings.yaml", config), false); +}); + +Deno.test("isBranchSpecificFile: detects branch-specific settings files", () => { + assertEquals(isBranchSpecificFile("settings.main.yaml"), true); + assertEquals(isBranchSpecificFile("settings.develop.yaml"), true); + assertEquals(isBranchSpecificFile("settings.feature_test.yaml"), true); +}); + +Deno.test("isBranchSpecificFile: returns false for non-branch-specific settings", () => { + assertEquals(isBranchSpecificFile("settings.yaml"), false); +}); + +Deno.test("isCurrentBranchFile: detects branch-specific settings for current branch", () => { + assertEquals(isCurrentBranchFile("settings.staging.yaml", "staging"), true); + assertEquals(isCurrentBranchFile("settings.staging.yaml", "production"), false); + assertEquals(isCurrentBranchFile("settings.yaml", "staging"), false); +}); + +Deno.test("isCurrentBranchFile: handles sanitized branch for settings", () => { + assertEquals(isCurrentBranchFile("settings.feature_test.yaml", "feature/test"), true); + assertEquals(isCurrentBranchFile("settings.feature_test.yaml", "feature/other"), false); +}); + +Deno.test("round-trip: settings path conversion", () => { + const original = "settings.yaml"; + const branch = "main"; + const branchSpecific = toBranchSpecificPath(original, branch); + assertEquals(branchSpecific, "settings.main.yaml"); + const restored = fromBranchSpecificPath(branchSpecific, branch); + assertEquals(restored, original); +}); + +Deno.test("round-trip: settings with sanitized branch", () => { + const original = "settings.yaml"; + const branch = "release/v1.0"; + const branchSpecific = toBranchSpecificPath(original, branch); + assertEquals(branchSpecific, "settings.release_v1_0.yaml"); + const restored = fromBranchSpecificPath(branchSpecific, branch); + assertEquals(restored, original); +});