From 23cf2834b3abd08d7cca7db682fc107e10eaeb6e Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Mon, 19 Jan 2026 17:32:52 +0000 Subject: [PATCH] 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 --- cli/src/commands/sync/sync.ts | 8 ++-- cli/src/core/conf.ts | 14 +++++- cli/src/core/specific_items.ts | 36 +++++++++++++- cli/test/specific_items.test.ts | 84 +++++++++++++++++++++++++++++++++ 4 files changed, 134 insertions(+), 8 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 cbda845864..30ac828375 100644 --- a/cli/src/core/specific_items.ts +++ b/cli/src/core/specific_items.ts @@ -9,6 +9,7 @@ export interface SpecificItemsConfig { resources?: string[]; triggers?: string[]; folders?: string[]; + settings?: boolean; } // Define all branch-specific file types (computed lazily) @@ -99,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) { @@ -110,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; } @@ -153,6 +167,11 @@ export function isSpecificItem(path: string, specificItems: SpecificItemsConfig 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 @@ -184,6 +203,11 @@ export function toBranchSpecificPath(basePath: string, branchName: string): stri 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\..+)$/); @@ -220,6 +244,12 @@ export function fromBranchSpecificPath(branchSpecificPath: string, branchName: s 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); @@ -309,7 +339,8 @@ export function isCurrentBranchFile(path: string, branchOverride?: string): bool pattern = new RegExp( `\\.${escapedBranchName}\\.${buildYamlTypePattern()}\\.yaml$|` + `\\.${escapedBranchName}\\.resource\\.file\\..+$|` + - `/folder\\.${escapedBranchName}\\.meta\\.yaml$` + `/folder\\.${escapedBranchName}\\.meta\\.yaml$|` + + `^settings\\.${escapedBranchName}\\.yaml$` ); branchPatternCache.set(currentBranch, pattern); } @@ -326,6 +357,7 @@ export function isBranchSpecificFile(path: string): boolean { return new RegExp( `\\.[^.]+\\.${yamlTypePattern}\\.yaml$|` + `\\.[^.]+\\.resource\\.file\\..+$|` + - `/folder\\.[^.]+\\.meta\\.yaml$` + `/folder\\.[^.]+\\.meta\\.yaml$|` + + `^settings\\.[^.]+\\.yaml$` ).test(path); } diff --git a/cli/test/specific_items.test.ts b/cli/test/specific_items.test.ts index f0f1a0e013..e0d6782fa6 100644 --- a/cli/test/specific_items.test.ts +++ b/cli/test/specific_items.test.ts @@ -429,3 +429,87 @@ Deno.test("round-trip: folder meta with sanitized branch", () => { 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); +});