From 219ac71fe4c5ed40d6aa2401c3e250cbd0815da5 Mon Sep 17 00:00:00 2001 From: Ruben Fiszel Date: Fri, 16 Jan 2026 20:55:01 +0000 Subject: [PATCH] feat: cli branch override (#7592) * fix(cli): prevent duplicate 'Using non-dotted paths' log message Add a flag to track whether the message has already been logged, so it only prints once even if setNonDottedPaths is called multiple times. Co-Authored-By: Claude Opus 4.5 * feat(cli): add --branch option to sync pull/push commands Add a --branch argument that allows overriding the current git branch for sync operations. This enables: - Using branch-specific settings even when not in a git repository - Overriding the detected git branch when needed The branch override is applied to: - getEffectiveSettings() for branch-specific config overrides - getSpecificItemsForCurrentBranch() for branch-specific items Co-Authored-By: Claude Opus 4.5 * fix(cli): correct log message for workspace fork branches Use rawGitBranch instead of currentBranch in the log message when showing the origin of a workspace fork branch. Co-Authored-By: Claude Opus 4.5 * feat(cli): extend --branch support to specificItems functionality Update getBranchSpecificPath and isCurrentBranchFile to accept optional branchOverride parameter. This ensures that branch-specific file handling (for variables, resources, triggers) works correctly with --branch flag. Updated functions: - getBranchSpecificPath(): now accepts branchOverride - isCurrentBranchFile(): now accepts branchOverride - elementsToMap(): now accepts branchOverride - compareDynFSElement(): now accepts branchOverride - prettyChanges(): now accepts branchOverride All call sites updated to pass opts.branch through the call chain. Co-Authored-By: Claude Opus 4.5 * fix(cli): resolve TypeScript type errors - Fix Timeout type in dev.ts using ReturnType - Add proper type casts for unknown error types - Cast FlowModule to any to resolve generated type mismatch - Cast Uint8Array to BlobPart for Blob constructor compatibility Co-Authored-By: Claude Opus 4.5 * test(cli): add unit tests for branch detection and --branch override - Add specific_items.test.ts with 35 tests covering: - toBranchSpecificPath and fromBranchSpecificPath conversions - isSpecificItem pattern matching - isBranchSpecificFile detection - Round-trip path conversions - branchOverride parameter functionality - Add conf_branch_override.test.ts with 6 tests covering: - getEffectiveSettings with branchOverride parameter - Branch-specific overrides application - promotionOverrides precedence - Fallback to top-level settings - Fix containerized_backend.ts to use dynamic paths instead of hardcoded user home directories Co-Authored-By: Claude Opus 4.5 * chore(cli): add conf.ts barrel file for test imports Re-exports from src/core/conf.ts to support existing test imports. Co-Authored-By: Claude Opus 4.5 * all * fix(cli): pass --branch override to workspace resolution Previously, the --branch flag was only used for config resolution but not for workspace resolution. This caused confusing log messages that showed the git branch (e.g., master) before showing the override branch. Changes: - Add branchOverride parameter to tryResolveBranchWorkspace() - Add branchOverride parameter to resolveWorkspace() - Pass opts.branch from sync pull/push to resolveWorkspace() - Log "Using branch override" early in workspace resolution - Remove duplicate log from getEffectiveSettings() Now when using --branch foobar, the logs show: Using branch override: foobar Applied settings for Git branch: foobar ... Instead of the confusing previous output that mentioned both master and foobar. Co-Authored-By: Claude Opus 4.5 * refactor(cli): remove redundant branch detection in elementsToMap isCurrentBranchFile() already validates that a branch exists (via branchOverride or git detection) before returning true. No need to pre-compute currentBranch before calling it. Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- cli/deno.lock | 6 + cli/src/commands/app/dev.ts | 4 +- cli/src/commands/init/init.ts | 2 +- cli/src/commands/schedule/schedule.ts | 4 +- cli/src/commands/script/script.ts | 4 +- cli/src/commands/sync/sync.ts | 63 +++-- cli/src/commands/trigger/trigger.ts | 4 +- cli/src/commands/workspace/fork.ts | 2 +- cli/src/commands/workspace/workspace.ts | 2 +- cli/src/core/conf.ts | 111 ++++---- cli/src/core/context.ts | 67 +++-- cli/src/core/specific_items.ts | 38 ++- cli/src/utils/resource_folders.ts | 4 +- cli/test/conf_branch_override.test.ts | 144 ++++++++++ cli/test/containerized_backend.ts | 13 +- cli/test/specific_items.test.ts | 338 ++++++++++++++++++++++++ 16 files changed, 680 insertions(+), 126 deletions(-) create mode 100644 cli/test/conf_branch_override.test.ts create mode 100644 cli/test/specific_items.test.ts diff --git a/cli/deno.lock b/cli/deno.lock index 53d3deaa04..3bbbf4a00a 100644 --- a/cli/deno.lock +++ b/cli/deno.lock @@ -26,6 +26,7 @@ "jsr:@std/internal@^1.0.12": "1.0.12", "jsr:@std/io@*": "0.225.2", "jsr:@std/io@0.223": "0.223.0", + "jsr:@std/io@~0.224.2": "0.224.9", "jsr:@std/io@~0.225.2": "0.225.2", "jsr:@std/log@*": "0.224.14", "jsr:@std/path@*": "1.1.3", @@ -169,6 +170,9 @@ "jsr:@std/bytes@0.223" ] }, + "@std/io@0.224.9": { + "integrity": "4414664b6926f665102e73c969cfda06d2c4c59bd5d0c603fd4f1b1c840d6ee3" + }, "@std/io@0.225.2": { "integrity": "3c740cd4ee4c082e6cfc86458f47e2ab7cb353dc6234d5e9b1f91a2de5f4d6c7", "dependencies": [ @@ -231,6 +235,7 @@ "dependencies": [ "jsr:@std/encoding@1.0.0-rc.2", "jsr:@std/fmt@~0.225.4", + "jsr:@std/io@~0.224.2", "jsr:@windmill-labs/cliffy-internal" ] }, @@ -261,6 +266,7 @@ "dependencies": [ "jsr:@std/assert@1.0.0-rc.2", "jsr:@std/fmt@~0.225.4", + "jsr:@std/io@~0.224.2", "jsr:@std/path@1.0.0-rc.2", "jsr:@std/text", "jsr:@windmill-labs/cliffy-ansi", diff --git a/cli/src/commands/app/dev.ts b/cli/src/commands/app/dev.ts index aa279cc779..c2412fe282 100644 --- a/cli/src/commands/app/dev.ts +++ b/cli/src/commands/app/dev.ts @@ -545,7 +545,7 @@ async function dev(opts: DevOptions) { runnablesWatcher = Deno.watchFs(runnablesPath); // Per-file debounce timeouts for schema inference (longer debounce for typing) - const schemaInferenceTimeouts: Record = {}; + const schemaInferenceTimeouts: Record> = {}; const SCHEMA_DEBOUNCE_MS = 500; // Wait 500ms after last change before inferring schema // Handle runnables file changes in the background @@ -1221,7 +1221,7 @@ async function dev(opts: DevOptions) { sqlWatcher = Deno.watchFs(sqlToApplyPath); // Debounce timeout for SQL file changes - const sqlDebounceTimeouts: Record = {}; + const sqlDebounceTimeouts: Record> = {}; const SQL_DEBOUNCE_MS = 300; // Handle SQL file changes in the background diff --git a/cli/src/commands/init/init.ts b/cli/src/commands/init/init.ts index c18457464d..471cc858f3 100644 --- a/cli/src/commands/init/init.ts +++ b/cli/src/commands/init/init.ts @@ -231,7 +231,7 @@ async function initAction(opts: InitOptions) { } catch (error) { // If there's an error checking backend settings, just continue with defaults log.warn( - `Could not check backend for git-sync settings: ${error.message}` + `Could not check backend for git-sync settings: ${(error as Error).message}` ); log.info("Continuing with default settings"); } diff --git a/cli/src/commands/schedule/schedule.ts b/cli/src/commands/schedule/schedule.ts index 6eea0de934..bebf37aa9e 100644 --- a/cli/src/commands/schedule/schedule.ts +++ b/cli/src/commands/schedule/schedule.ts @@ -86,7 +86,7 @@ export async function pushSchedule( }); } } catch (e) { - console.error(e.body); + console.error((e as any).body); throw e; } } else { @@ -100,7 +100,7 @@ export async function pushSchedule( }, }); } catch (e) { - console.error(e.body); + console.error((e as any).body); throw e; } } diff --git a/cli/src/commands/script/script.ts b/cli/src/commands/script/script.ts index 7a0b61618d..0bca28c294 100644 --- a/cli/src/commands/script/script.ts +++ b/cli/src/commands/script/script.ts @@ -314,7 +314,7 @@ export async function handleFile( continue; } log.info(`Adding file: ${file.path.substring(1)}`); - const fil = new File([file.contents], file.path.substring(1)); + const fil = new File([file.contents as BlobPart], file.path.substring(1)); tarball.append(fil); } const endTime = performance.now(); @@ -542,7 +542,7 @@ async function streamToBlob(stream: ReadableStream): Promise { } // Create a Blob from the chunks - const blob = new Blob(chunks); + const blob = new Blob(chunks as BlobPart[]); return blob; } diff --git a/cli/src/commands/sync/sync.ts b/cli/src/commands/sync/sync.ts index 753f871296..e2df1f5679 100644 --- a/cli/src/commands/sync/sync.ts +++ b/cli/src/commands/sync/sync.ts @@ -56,7 +56,7 @@ import { isSpecificItem, SpecificItemsConfig, } from "../../core/specific_items.ts"; -import { getCurrentGitBranch } from "../../utils/git.ts"; +import { getCurrentGitBranch, isGitRepository } from "../../utils/git.ts"; import { Workspace } from "../workspace/workspace.ts"; import { removePathPrefix } from "../../types.ts"; import { listSyncCodebases, SyncCodebase } from "../../utils/codebase.ts"; @@ -109,8 +109,9 @@ async function resolveEffectiveSyncOptions( workspace: Workspace, localConfig: SyncOptions, promotion?: string, + branchOverride?: string, ): Promise { - return await getEffectiveSettings(localConfig, promotion); + return await getEffectiveSettings(localConfig, promotion, false, false, branchOverride); } type DynFSElement = { @@ -767,7 +768,7 @@ function ZipFSElement( let inlineScripts; try { inlineScripts = extractInlineScriptsForFlows( - flow.value.modules, + flow.value.modules as any, {}, SEP, defaultTs, @@ -1248,6 +1249,7 @@ export async function elementsToMap( json: boolean, skips: Skips, specificItems?: SpecificItemsConfig, + branchOverride?: string, ): Promise<{ [key: string]: string }> { const map: { [key: string]: string } = {}; const processedBasePaths = new Set(); @@ -1350,8 +1352,7 @@ export async function elementsToMap( // Handle branch-specific files - skip files for other branches if (specificItems && isBranchSpecificFile(path)) { - const currentBranch = getCurrentGitBranch(); - if (!currentBranch || !isCurrentBranchFile(path)) { + if (!isCurrentBranchFile(path, branchOverride)) { // Skip branch-specific files for other branches continue; } @@ -1387,9 +1388,10 @@ export async function elementsToMap( // Handle branch-specific path mapping after all filtering if (specificItems) { - const currentBranch = getCurrentGitBranch(); - if (currentBranch && isCurrentBranchFile(path)) { + if (isCurrentBranchFile(path, branchOverride)) { // This is a branch-specific file for current branch + // Safe to compute branch here since isCurrentBranchFile already validated it exists + const currentBranch = branchOverride || getCurrentGitBranch()!; const basePath = fromBranchSpecificPath(path, currentBranch); if (isSpecificItem(basePath, specificItems)) { // Map to base path for push operations @@ -1444,13 +1446,14 @@ async function compareDynFSElement( codebases: SyncCodebase[], ignoreCodebaseChanges: boolean, specificItems?: SpecificItemsConfig, + branchOverride?: string, ): Promise { const [m1, m2] = els2 ? await Promise.all([ - elementsToMap(els1, ignore, json, skips, specificItems), - elementsToMap(els2, ignore, json, skips, specificItems), + elementsToMap(els1, ignore, json, skips, specificItems, branchOverride), + elementsToMap(els2, ignore, json, skips, specificItems, branchOverride), ]) - : [await elementsToMap(els1, ignore, json, skips, specificItems), {}]; + : [await elementsToMap(els1, ignore, json, skips, specificItems, branchOverride), {}]; const changes: Change[] = []; @@ -1858,7 +1861,7 @@ async function buildTracker(changes: Change[]) { export async function pull( opts: GlobalOptions & - SyncOptions & { repository?: string; promotion?: string }, + SyncOptions & { repository?: string; promotion?: string; branch?: string }, ) { const originalCliOpts = { ...opts }; opts = await mergeConfigWithConfigFile(opts); @@ -1878,7 +1881,7 @@ export async function pull( await ensureDir(path.join(Deno.cwd(), ".wmill")); } - const workspace = await resolveWorkspace(opts); + const workspace = await resolveWorkspace(opts, opts.branch); await requireLogin(opts); // Resolve effective sync options with branch awareness @@ -1886,10 +1889,11 @@ export async function pull( workspace, opts, opts.promotion, + opts.branch, ); // Extract specific items configuration before merging overwrites gitBranches - const specificItems = getSpecificItemsForCurrentBranch(opts); + const specificItems = getSpecificItemsForCurrentBranch(opts, opts.branch); // Merge CLI flags with resolved settings (CLI flags take precedence only for explicit overrides) opts = mergeCliWithEffectiveOptions(originalCliOpts, effectiveOpts); @@ -1947,6 +1951,7 @@ export async function pull( codebases, true, specificItems, + opts.branch, ); log.info( @@ -1969,6 +1974,7 @@ export async function pull( branch_specific_path: getBranchSpecificPath( change.path, specificItems, + opts.branch, ), } : {}), @@ -1981,7 +1987,7 @@ export async function pull( if (changes.length > 0) { if (!opts.jsonOutput) { - prettyChanges(changes, specificItems); + prettyChanges(changes, specificItems, opts.branch); } if (opts.dryRun) { log.info(colors.gray(`Dry run complete.`)); @@ -2007,6 +2013,7 @@ export async function pull( const branchSpecificPath = getBranchSpecificPath( change.path, specificItems, + opts.branch, ); if (branchSpecificPath) { targetPath = branchSpecificPath; @@ -2216,6 +2223,7 @@ export async function pull( branch_specific_path: getBranchSpecificPath( change.path, specificItems, + opts.branch, ), } : {}), @@ -2241,7 +2249,7 @@ export async function pull( } } -function prettyChanges(changes: Change[], specificItems?: SpecificItemsConfig) { +function prettyChanges(changes: Change[], specificItems?: SpecificItemsConfig, branchOverride?: string) { for (const change of changes) { let displayPath = change.path; let branchNote = ""; @@ -2251,6 +2259,7 @@ function prettyChanges(changes: Change[], specificItems?: SpecificItemsConfig) { const branchSpecificPath = getBranchSpecificPath( change.path, specificItems, + branchOverride, ); if (branchSpecificPath) { displayPath = branchSpecificPath; @@ -2334,7 +2343,7 @@ function removeSuffix(str: string, suffix: string) { } export async function push( - opts: GlobalOptions & SyncOptions & { repository?: string }, + opts: GlobalOptions & SyncOptions & { repository?: string; branch?: string }, ) { // Save original CLI options before merging with config file const originalCliOpts = { ...opts }; @@ -2353,7 +2362,7 @@ export async function push( throw error; } - const workspace = await resolveWorkspace(opts); + const workspace = await resolveWorkspace(opts, opts.branch); await requireLogin(opts); // Resolve effective sync options with branch awareness @@ -2361,10 +2370,11 @@ export async function push( workspace, opts, opts.promotion, + opts.branch, ); // Extract specific items configuration BEFORE merging overwrites gitBranches - const specificItems = getSpecificItemsForCurrentBranch(opts); + const specificItems = getSpecificItemsForCurrentBranch(opts, opts.branch); // Merge CLI flags with resolved settings (CLI flags take precedence only for explicit overrides) opts = mergeCliWithEffectiveOptions(originalCliOpts, effectiveOpts); @@ -2432,6 +2442,7 @@ export async function push( codebases, false, specificItems, + opts.branch, ); const rawWorkspaceDependencies = await getRawWorkspaceDependencies(); @@ -2555,6 +2566,7 @@ export async function push( branch_specific_path: getBranchSpecificPath( change.path, specificItems, + opts.branch, ), } : {}), @@ -2567,7 +2579,7 @@ export async function push( if (changes.length > 0) { if (!opts.jsonOutput) { - prettyChanges(changes, specificItems); + prettyChanges(changes, specificItems, opts.branch); } if (opts.dryRun) { log.info(colors.gray(`Dry run complete.`)); @@ -2709,7 +2721,7 @@ export async function push( // For branch-specific resources, push to the base path on the workspace server // This ensures branch-specific files are stored with their base names in the workspace let serverPath = resourceFilePath; - const currentBranch = getCurrentGitBranch(); + const currentBranch = opts.branch || (isGitRepository() ? getCurrentGitBranch() : null); if (currentBranch && isBranchSpecificFile(resourceFilePath)) { serverPath = fromBranchSpecificPath( @@ -2740,6 +2752,7 @@ export async function push( originalBranchSpecificPath = getBranchSpecificPath( change.path, specificItems, + opts.branch, ); } @@ -2793,6 +2806,7 @@ export async function push( const branchSpecificPath = getBranchSpecificPath( change.path, specificItems, + opts.branch, ); if (branchSpecificPath) { localFilePath = branchSpecificPath; @@ -3029,6 +3043,7 @@ export async function push( branch_specific_path: getBranchSpecificPath( change.path, specificItems, + opts.branch, ), } : {}), @@ -3118,6 +3133,10 @@ const command = new Command() "--promotion ", "Use promotionOverrides from the specified branch instead of regular overrides", ) + .option( + "--branch ", + "Override the current git branch (works even outside a git repository)", + ) // deno-lint-ignore no-explicit-any .action(pull as any) .command("push") @@ -3171,6 +3190,10 @@ const command = new Command() "--repository ", "Specify repository path (e.g., u/user/repo) when multiple repositories exist", ) + .option( + "--branch ", + "Override the current git branch (works even outside a git repository)", + ) // deno-lint-ignore no-explicit-any .action(push as any); diff --git a/cli/src/commands/trigger/trigger.ts b/cli/src/commands/trigger/trigger.ts index ecad4ed35e..560acd2f7f 100644 --- a/cli/src/commands/trigger/trigger.ts +++ b/cli/src/commands/trigger/trigger.ts @@ -162,7 +162,7 @@ export async function pushTrigger( path, } as Trigger[K]); } catch (e) { - console.error(e.body); + console.error((e as any).body); throw e; } } else { @@ -175,7 +175,7 @@ export async function pushTrigger( path, } as Trigger[K]); } catch (e) { - console.error(e.body); + console.error((e as any).body); throw e; } } diff --git a/cli/src/commands/workspace/fork.ts b/cli/src/commands/workspace/fork.ts index 5a73021d43..0bd3b9eec6 100644 --- a/cli/src/commands/workspace/fork.ts +++ b/cli/src/commands/workspace/fork.ts @@ -118,7 +118,7 @@ async function createWorkspaceFork( } catch (error) { // If workspace creation fails, we should clean up the git branch log.error( - colors.red(`Failed to create forked workspace: ${error.message}`), + colors.red(`Failed to create forked workspace: ${(error as Error).message}`), ); throw error; } diff --git a/cli/src/commands/workspace/workspace.ts b/cli/src/commands/workspace/workspace.ts index f5878f1251..021e43fea1 100644 --- a/cli/src/commands/workspace/workspace.ts +++ b/cli/src/commands/workspace/workspace.ts @@ -482,7 +482,7 @@ async function bind( try { await Deno.writeTextFile("wmill.yaml", yamlStringify(config)); } catch (error) { - log.error(colors.red(`Failed to save configuration: ${error.message}`)); + log.error(colors.red(`Failed to save configuration: ${(error as Error).message}`)); return; } } diff --git a/cli/src/core/conf.ts b/cli/src/core/conf.ts index d2edc8569c..9de4f8f6e3 100644 --- a/cli/src/core/conf.ts +++ b/cli/src/core/conf.ts @@ -487,73 +487,80 @@ export async function getEffectiveSettings( config: SyncOptions, promotion?: string, skipBranchValidation?: boolean, - suppressLogs?: boolean + suppressLogs?: boolean, + branchOverride?: string ): Promise { // Start with top-level settings from config const { gitBranches, ...topLevelSettings } = config; const effective = { ...topLevelSettings }; - if (isGitRepository()) { - const branch = getCurrentGitBranch(); + // Determine the branch to use: branchOverride takes precedence, then git detection + let currentBranch: string | null = null; + let originalBranchIfForked: string | null = null; + let rawGitBranch: string | null = null; - const originalBranchIfForked = getOriginalBranchForWorkspaceForks(branch); + if (branchOverride) { + currentBranch = branchOverride; + // Note: "Using branch override" is logged in context.ts when resolving workspace + } else if (isGitRepository()) { + rawGitBranch = getCurrentGitBranch(); + originalBranchIfForked = getOriginalBranchForWorkspaceForks(rawGitBranch); - let currentBranch: string | null; if (originalBranchIfForked) { log.info( `Using overrides from original branch \`${originalBranchIfForked}\`` ); currentBranch = originalBranchIfForked; } else { - currentBranch = branch; - } - - // If promotion is specified, use that branch's promotionOverrides or overrides - if (promotion && gitBranches && gitBranches[promotion]) { - const targetBranch = gitBranches[promotion]; - - // First try promotionOverrides, then fall back to overrides - if (targetBranch.promotionOverrides) { - Object.assign(effective, targetBranch.promotionOverrides); - if (!suppressLogs) { - log.info(`Applied promotion settings from branch: ${promotion}`); - } - } else if (targetBranch.overrides) { - Object.assign(effective, targetBranch.overrides); - if (!suppressLogs) { - log.info( - `Applied settings from branch: ${promotion} (no promotionOverrides found)` - ); - } - } else { - log.debug( - `No promotion or regular overrides found for branch '${promotion}', using top-level settings` - ); - } - } - // Otherwise use current branch overrides (existing behavior) - else if ( - currentBranch && - gitBranches && - gitBranches[currentBranch] && - gitBranches[currentBranch].overrides - ) { - Object.assign(effective, gitBranches[currentBranch].overrides); - if (!suppressLogs) { - const extraLog = originalBranchIfForked - ? ` (because it is the origin of the workspace fork branch \`${branch}\`)` - : ""; - log.info( - `Applied settings for Git branch: ${currentBranch}${extraLog}` - ); - } - } else if (currentBranch) { - log.debug( - `No branch-specific overrides found for '${currentBranch}', using top-level settings` - ); + currentBranch = rawGitBranch; } } else { - log.debug("Not in a Git repository, using top-level settings"); + log.debug("Not in a Git repository and no branch override provided, using top-level settings"); + } + + // If promotion is specified, use that branch's promotionOverrides or overrides + if (promotion && gitBranches && gitBranches[promotion]) { + const targetBranch = gitBranches[promotion]; + + // First try promotionOverrides, then fall back to overrides + if (targetBranch.promotionOverrides) { + Object.assign(effective, targetBranch.promotionOverrides); + if (!suppressLogs) { + log.info(`Applied promotion settings from branch: ${promotion}`); + } + } else if (targetBranch.overrides) { + Object.assign(effective, targetBranch.overrides); + if (!suppressLogs) { + log.info( + `Applied settings from branch: ${promotion} (no promotionOverrides found)` + ); + } + } else { + log.debug( + `No promotion or regular overrides found for branch '${promotion}', using top-level settings` + ); + } + } + // Otherwise use current branch overrides (existing behavior) + else if ( + currentBranch && + gitBranches && + gitBranches[currentBranch] && + gitBranches[currentBranch].overrides + ) { + Object.assign(effective, gitBranches[currentBranch].overrides); + if (!suppressLogs) { + const extraLog = originalBranchIfForked + ? ` (because it is the origin of the workspace fork branch \`${rawGitBranch}\`)` + : ""; + log.info( + `Applied settings for Git branch: ${currentBranch}${extraLog}` + ); + } + } else if (currentBranch) { + log.debug( + `No branch-specific overrides found for '${currentBranch}', using top-level settings` + ); } return effective; diff --git a/cli/src/core/context.ts b/cli/src/core/context.ts index ad8f34a30e..714b613cdd 100644 --- a/cli/src/core/context.ts +++ b/cli/src/core/context.ts @@ -221,29 +221,40 @@ async function tryResolveWorkspace( } export async function tryResolveBranchWorkspace( - opts: GlobalOptions + opts: GlobalOptions, + branchOverride?: string ): Promise { - // Only try branch-based resolution if in a Git repository - if (!isGitRepository()) { - return undefined; - } - - const rawBranch = getCurrentGitBranch(); - if (!rawBranch) { - return undefined; - } - + let rawBranch: string | null = null; let currentBranch: string; - const originalBranchIfForked = getOriginalBranchForWorkspaceForks(rawBranch); - const workspaceIdIfForked = - getWorkspaceIdForWorkspaceForkFromBranchName(rawBranch); - if (originalBranchIfForked) { - log.info( - `Using original branch \`${originalBranchIfForked}\` for finding workspace profile from gitBranches section in wmill.yaml` - ); - currentBranch = originalBranchIfForked; + let originalBranchIfForked: string | null = null; + let workspaceIdIfForked: string | null = null; + + if (branchOverride) { + // Use branch override directly + currentBranch = branchOverride; + log.info(`Using branch override: ${branchOverride}`); } else { - currentBranch = rawBranch; + // Only try branch-based resolution if in a Git repository + if (!isGitRepository()) { + return undefined; + } + + rawBranch = getCurrentGitBranch(); + if (!rawBranch) { + return undefined; + } + + originalBranchIfForked = getOriginalBranchForWorkspaceForks(rawBranch); + workspaceIdIfForked = + getWorkspaceIdForWorkspaceForkFromBranchName(rawBranch); + if (originalBranchIfForked) { + log.info( + `Using original branch \`${originalBranchIfForked}\` for finding workspace profile from gitBranches section in wmill.yaml` + ); + currentBranch = originalBranchIfForked; + } else { + currentBranch = rawBranch; + } } // Read wmill.yaml to check for branch workspace configuration @@ -284,7 +295,7 @@ export async function tryResolveBranchWorkspace( workspaceId, currentBranch, opts, - { rawBranch, isForked: !!originalBranchIfForked } + { rawBranch: rawBranch ?? currentBranch, isForked: !!originalBranchIfForked } ); } @@ -358,7 +369,8 @@ export async function tryResolveBranchWorkspace( } export async function resolveWorkspace( - opts: GlobalOptions + opts: GlobalOptions, + branchOverride?: string ): Promise { const cache = (opts as any).__secret_workspace; if (cache) return cache; @@ -438,13 +450,13 @@ export async function resolveWorkspace( } } - const branch = getCurrentGitBranch(); + const branch = branchOverride ?? getCurrentGitBranch(); // Try explicit workspace flag first (should override branch-based resolution). Unless it's a - // forked workspace, that we detect through the branch name + // forked workspace, that we detect through the branch name (only when not using branchOverride) const res = await tryResolveWorkspace(opts); if (!res.isError) { - if (!branch || !branch.startsWith(WM_FORK_PREFIX)) { + if (branchOverride || !branch || !branch.startsWith(WM_FORK_PREFIX)) { return res.value; } else { log.info( @@ -454,11 +466,12 @@ export async function resolveWorkspace( } // Try branch-based resolution (medium priority) - const branchWorkspace = await tryResolveBranchWorkspace(opts); + const branchWorkspace = await tryResolveBranchWorkspace(opts, branchOverride); if (branchWorkspace) { (opts as any).__secret_workspace = branchWorkspace; return branchWorkspace; - } else { + } else if (!branchOverride) { + // Only check for fork errors when not using branchOverride const originalBranch = getOriginalBranchForWorkspaceForks(branch); if (originalBranch) { log.error( diff --git a/cli/src/core/specific_items.ts b/cli/src/core/specific_items.ts index 2e9b5052f0..bef871b598 100644 --- a/cli/src/core/specific_items.ts +++ b/cli/src/core/specific_items.ts @@ -60,12 +60,19 @@ function buildYamlTypePattern(): string { * Get the specific items configuration for the current git branch * Merges commonSpecificItems with branch-specific specificItems */ -export function getSpecificItemsForCurrentBranch(config: SyncOptions): SpecificItemsConfig | undefined { - if (!isGitRepository() || !config.gitBranches) { +export function getSpecificItemsForCurrentBranch(config: SyncOptions, branchOverride?: string): SpecificItemsConfig | undefined { + if (!config.gitBranches) { return undefined; } - const currentBranch = getCurrentGitBranch(); + // Use branch override if provided, otherwise detect from git + let currentBranch: string | null = null; + if (branchOverride) { + currentBranch = branchOverride; + } else if (isGitRepository()) { + currentBranch = getCurrentGitBranch(); + } + if (!currentBranch) { return undefined; } @@ -224,13 +231,21 @@ export function fromBranchSpecificPath(branchSpecificPath: string, branchName: s */ export function getBranchSpecificPath( basePath: string, - specificItems: SpecificItemsConfig | undefined + specificItems: SpecificItemsConfig | undefined, + branchOverride?: string ): string | undefined { - if (!isGitRepository() || !specificItems) { + if (!specificItems) { return undefined; } - const currentBranch = getCurrentGitBranch(); + // Use branch override if provided, otherwise detect from git + let currentBranch: string | null = null; + if (branchOverride) { + currentBranch = branchOverride; + } else if (isGitRepository()) { + currentBranch = getCurrentGitBranch(); + } + if (!currentBranch) { return undefined; } @@ -248,12 +263,15 @@ const branchPatternCache = new Map(); /** * Check if a path is a branch-specific file for the current branch */ -export function isCurrentBranchFile(path: string): boolean { - if (!isGitRepository()) { - return false; +export function isCurrentBranchFile(path: string, branchOverride?: string): boolean { + // Use branch override if provided, otherwise detect from git + let currentBranch: string | null = null; + if (branchOverride) { + currentBranch = branchOverride; + } else if (isGitRepository()) { + currentBranch = getCurrentGitBranch(); } - const currentBranch = getCurrentGitBranch(); if (!currentBranch) { return false; } diff --git a/cli/src/utils/resource_folders.ts b/cli/src/utils/resource_folders.ts index 96beb91635..b939e15be2 100644 --- a/cli/src/utils/resource_folders.ts +++ b/cli/src/utils/resource_folders.ts @@ -34,6 +34,7 @@ export type FolderSuffixes = // Global state for nonDottedPaths configuration let _nonDottedPaths = false; +let _nonDottedPathsLogged = false; /** * Set whether to use non-dotted paths (__flow, __app, __raw_app) @@ -41,8 +42,9 @@ let _nonDottedPaths = false; * This should be called once at startup based on wmill.yaml configuration. */ export function setNonDottedPaths(value: boolean): void { - if (value) { + if (value && !_nonDottedPathsLogged) { log.info("Using non-dotted paths (__flow, __app, __raw_app)"); + _nonDottedPathsLogged = true; } _nonDottedPaths = value; } diff --git a/cli/test/conf_branch_override.test.ts b/cli/test/conf_branch_override.test.ts new file mode 100644 index 0000000000..e0976710bb --- /dev/null +++ b/cli/test/conf_branch_override.test.ts @@ -0,0 +1,144 @@ +import { assertEquals, assertExists } from "https://deno.land/std@0.224.0/assert/mod.ts"; +import { getEffectiveSettings, type SyncOptions } from "../src/core/conf.ts"; + +// ============================================================================= +// CONF.TS BRANCH OVERRIDE TESTS +// Tests for getEffectiveSettings with branchOverride parameter +// ============================================================================= + +Deno.test("getEffectiveSettings: applies branch overrides when branchOverride is provided", async () => { + const config: SyncOptions = { + defaultTs: "bun", + includes: ["f/**"], + gitBranches: { + staging: { + overrides: { + includes: ["staging/**"], + skipVariables: true, + }, + }, + production: { + overrides: { + includes: ["prod/**"], + skipSecrets: true, + }, + }, + }, + }; + + // Test with staging branch override + const stagingSettings = await getEffectiveSettings(config, undefined, true, true, "staging"); + assertEquals(stagingSettings.includes, ["staging/**"]); + assertEquals(stagingSettings.skipVariables, true); + assertEquals(stagingSettings.skipSecrets, undefined); + + // Test with production branch override + const prodSettings = await getEffectiveSettings(config, undefined, true, true, "production"); + assertEquals(prodSettings.includes, ["prod/**"]); + assertEquals(prodSettings.skipSecrets, true); + assertEquals(prodSettings.skipVariables, undefined); +}); + +Deno.test("getEffectiveSettings: uses top-level settings when branchOverride has no overrides", async () => { + const config: SyncOptions = { + defaultTs: "bun", + includes: ["f/**"], + skipVariables: true, + gitBranches: { + staging: { + // No overrides defined + }, + }, + }; + + const settings = await getEffectiveSettings(config, undefined, true, true, "staging"); + assertEquals(settings.includes, ["f/**"]); + assertEquals(settings.skipVariables, true); + assertEquals(settings.defaultTs, "bun"); +}); + +Deno.test("getEffectiveSettings: uses top-level settings for unknown branch", async () => { + const config: SyncOptions = { + defaultTs: "bun", + includes: ["f/**"], + gitBranches: { + staging: { + overrides: { + includes: ["staging/**"], + }, + }, + }, + }; + + const settings = await getEffectiveSettings(config, undefined, true, true, "nonexistent"); + assertEquals(settings.includes, ["f/**"]); + assertEquals(settings.defaultTs, "bun"); +}); + +Deno.test("getEffectiveSettings: promotionOverrides take precedence when promotion specified", async () => { + const config: SyncOptions = { + defaultTs: "bun", + includes: ["f/**"], + gitBranches: { + production: { + overrides: { + includes: ["prod/**"], + }, + promotionOverrides: { + includes: ["promoted/**"], + skipVariables: true, + }, + }, + }, + }; + + // Test without promotion flag - should use regular overrides + const normalSettings = await getEffectiveSettings(config, undefined, true, true, "production"); + assertEquals(normalSettings.includes, ["prod/**"]); + assertEquals(normalSettings.skipVariables, undefined); + + // Test with promotion flag - should use promotionOverrides + const promoSettings = await getEffectiveSettings(config, "production", true, true); + assertEquals(promoSettings.includes, ["promoted/**"]); + assertEquals(promoSettings.skipVariables, true); +}); + +Deno.test("getEffectiveSettings: branchOverride works without gitBranches config", async () => { + const config: SyncOptions = { + defaultTs: "bun", + includes: ["f/**"], + }; + + // Should not throw even with branchOverride but no gitBranches + const settings = await getEffectiveSettings(config, undefined, true, true, "staging"); + assertEquals(settings.includes, ["f/**"]); + assertEquals(settings.defaultTs, "bun"); +}); + +Deno.test("getEffectiveSettings: preserves all top-level settings in merged result", async () => { + const config: SyncOptions = { + defaultTs: "bun", + includes: ["f/**"], + excludes: ["*.test.ts"], + skipVariables: false, + skipResources: false, + skipFlows: false, + parallel: 4, + gitBranches: { + staging: { + overrides: { + skipVariables: true, // Override just this one + }, + }, + }, + }; + + const settings = await getEffectiveSettings(config, undefined, true, true, "staging"); + assertEquals(settings.defaultTs, "bun"); + assertEquals(settings.includes, ["f/**"]); + assertEquals(settings.excludes, ["*.test.ts"]); + assertEquals(settings.skipVariables, true); // Overridden + assertEquals(settings.skipResources, false); + assertEquals(settings.skipFlows, false); + assertEquals(settings.parallel, 4); +}); diff --git a/cli/test/containerized_backend.ts b/cli/test/containerized_backend.ts index b6facf945d..8ee7e064f6 100644 --- a/cli/test/containerized_backend.ts +++ b/cli/test/containerized_backend.ts @@ -20,7 +20,7 @@ export class ContainerizedBackend { constructor(config: Partial = {}) { this.config = { - composeFile: config.composeFile || '/home/alex/windmill/windmill/cli/test/docker-compose.test.yml', + composeFile: config.composeFile || new URL('./docker-compose.test.yml', import.meta.url).pathname, baseUrl: config.baseUrl || 'http://localhost:8001', workspace: config.workspace || 'test', // Use test workspace token: config.token || '', @@ -1023,10 +1023,13 @@ export async function main( ...args ]; - console.log('🔧 CLI Command:', ['/home/alex/.deno/bin/deno', 'run', '-A', '/home/alex/windmill/windmill/cli/src/main.ts', ...fullArgs].join(' ')); - - return new Deno.Command('/home/alex/.deno/bin/deno', { - args: ['run', '-A', '/home/alex/windmill/windmill/cli/src/main.ts', ...fullArgs], + const denoPath = Deno.execPath(); + const cliMainPath = new URL('../src/main.ts', import.meta.url).pathname; + + console.log('🔧 CLI Command:', [denoPath, 'run', '-A', cliMainPath, ...fullArgs].join(' ')); + + return new Deno.Command(denoPath, { + args: ['run', '-A', cliMainPath, ...fullArgs], cwd: workingDir, stdout: 'piped', stderr: 'piped', diff --git a/cli/test/specific_items.test.ts b/cli/test/specific_items.test.ts new file mode 100644 index 0000000000..4448f32db7 --- /dev/null +++ b/cli/test/specific_items.test.ts @@ -0,0 +1,338 @@ +import { assertEquals, assertExists, assert } from "https://deno.land/std@0.224.0/assert/mod.ts"; + +// ============================================================================= +// SPECIFIC ITEMS UNIT TESTS +// Tests for branch-specific file path functions (no Docker required) +// ============================================================================= + +// Import the functions we need to test +import { + isSpecificItem, + toBranchSpecificPath, + fromBranchSpecificPath, + isBranchSpecificFile, +} from "../src/core/specific_items.ts"; + +import type { SpecificItemsConfig } from "../src/core/specific_items.ts"; + +// ============================================================================= +// toBranchSpecificPath TESTS +// ============================================================================= + +Deno.test("toBranchSpecificPath: converts variable path to branch-specific", () => { + const result = toBranchSpecificPath("f/test.variable.yaml", "main"); + assertEquals(result, "f/test.main.variable.yaml"); +}); + +Deno.test("toBranchSpecificPath: converts resource path to branch-specific", () => { + const result = toBranchSpecificPath("u/admin/db.resource.yaml", "develop"); + assertEquals(result, "u/admin/db.develop.resource.yaml"); +}); + +Deno.test("toBranchSpecificPath: converts trigger path to branch-specific", () => { + const result = toBranchSpecificPath("f/my_trigger.http_trigger.yaml", "feature-x"); + assertEquals(result, "f/my_trigger.feature-x.http_trigger.yaml"); +}); + +Deno.test("toBranchSpecificPath: sanitizes branch names with slashes", () => { + const result = toBranchSpecificPath("f/test.variable.yaml", "feature/my-feature"); + assertEquals(result, "f/test.feature_my-feature.variable.yaml"); +}); + +Deno.test("toBranchSpecificPath: sanitizes branch names with dots", () => { + const result = toBranchSpecificPath("f/test.variable.yaml", "release.1.0"); + assertEquals(result, "f/test.release_1_0.variable.yaml"); +}); + +Deno.test("toBranchSpecificPath: leaves non-specific files unchanged", () => { + const result = toBranchSpecificPath("f/script.ts", "main"); + assertEquals(result, "f/script.ts"); +}); + +Deno.test("toBranchSpecificPath: handles resource files with extensions", () => { + const result = toBranchSpecificPath("f/config.resource.file.json", "main"); + assertEquals(result, "f/config.main.resource.file.json"); +}); + +// ============================================================================= +// fromBranchSpecificPath TESTS +// ============================================================================= + +Deno.test("fromBranchSpecificPath: converts branch-specific variable back to base", () => { + const result = fromBranchSpecificPath("f/test.main.variable.yaml", "main"); + assertEquals(result, "f/test.variable.yaml"); +}); + +Deno.test("fromBranchSpecificPath: converts branch-specific resource back to base", () => { + const result = fromBranchSpecificPath("u/admin/db.develop.resource.yaml", "develop"); + assertEquals(result, "u/admin/db.resource.yaml"); +}); + +Deno.test("fromBranchSpecificPath: converts branch-specific trigger back to base", () => { + const result = fromBranchSpecificPath("f/my_trigger.feature-x.http_trigger.yaml", "feature-x"); + assertEquals(result, "f/my_trigger.http_trigger.yaml"); +}); + +Deno.test("fromBranchSpecificPath: handles sanitized branch names", () => { + const result = fromBranchSpecificPath("f/test.feature_my-feature.variable.yaml", "feature/my-feature"); + assertEquals(result, "f/test.variable.yaml"); +}); + +Deno.test("fromBranchSpecificPath: returns unchanged if not branch-specific", () => { + const result = fromBranchSpecificPath("f/test.variable.yaml", "main"); + assertEquals(result, "f/test.variable.yaml"); +}); + +Deno.test("fromBranchSpecificPath: handles resource files with extensions", () => { + const result = fromBranchSpecificPath("f/config.main.resource.file.json", "main"); + assertEquals(result, "f/config.resource.file.json"); +}); + +// ============================================================================= +// isSpecificItem TESTS +// ============================================================================= + +Deno.test("isSpecificItem: returns false when specificItems is undefined", () => { + const result = isSpecificItem("f/test.variable.yaml", undefined); + assertEquals(result, false); +}); + +Deno.test("isSpecificItem: matches variable paths with glob pattern", () => { + const config: SpecificItemsConfig = { + variables: ["f/**"], + }; + assertEquals(isSpecificItem("f/test.variable.yaml", config), true); + assertEquals(isSpecificItem("u/admin/test.variable.yaml", config), false); +}); + +Deno.test("isSpecificItem: matches resource paths with glob pattern", () => { + const config: SpecificItemsConfig = { + resources: ["u/admin/**"], + }; + assertEquals(isSpecificItem("u/admin/db.resource.yaml", config), true); + assertEquals(isSpecificItem("f/db.resource.yaml", config), false); +}); + +Deno.test("isSpecificItem: matches trigger paths with glob pattern", () => { + const config: SpecificItemsConfig = { + triggers: ["f/triggers/**"], + }; + assertEquals(isSpecificItem("f/triggers/my.http_trigger.yaml", config), true); + assertEquals(isSpecificItem("u/admin/my.http_trigger.yaml", config), false); +}); + +Deno.test("isSpecificItem: matches multiple patterns", () => { + const config: SpecificItemsConfig = { + variables: ["f/**", "g/**"], + }; + assertEquals(isSpecificItem("f/test.variable.yaml", config), true); + assertEquals(isSpecificItem("g/test.variable.yaml", config), true); + assertEquals(isSpecificItem("u/admin/test.variable.yaml", config), false); +}); + +Deno.test("isSpecificItem: handles exact path patterns", () => { + const config: SpecificItemsConfig = { + variables: ["f/specific.variable.yaml"], + }; + assertEquals(isSpecificItem("f/specific.variable.yaml", config), true); + assertEquals(isSpecificItem("f/other.variable.yaml", config), false); +}); + +// ============================================================================= +// isBranchSpecificFile TESTS +// ============================================================================= + +Deno.test("isBranchSpecificFile: detects branch-specific variable files", () => { + assertEquals(isBranchSpecificFile("f/test.main.variable.yaml"), true); + assertEquals(isBranchSpecificFile("f/test.develop.variable.yaml"), true); + assertEquals(isBranchSpecificFile("f/test.feature_branch.variable.yaml"), true); +}); + +Deno.test("isBranchSpecificFile: detects branch-specific resource files", () => { + assertEquals(isBranchSpecificFile("u/admin/db.main.resource.yaml"), true); + assertEquals(isBranchSpecificFile("u/admin/db.staging.resource.yaml"), true); +}); + +Deno.test("isBranchSpecificFile: detects branch-specific trigger files", () => { + assertEquals(isBranchSpecificFile("f/my.main.http_trigger.yaml"), true); + assertEquals(isBranchSpecificFile("f/my.develop.kafka_trigger.yaml"), true); + assertEquals(isBranchSpecificFile("f/my.main.websocket_trigger.yaml"), true); +}); + +Deno.test("isBranchSpecificFile: returns false for non-branch-specific files", () => { + assertEquals(isBranchSpecificFile("f/test.variable.yaml"), false); + assertEquals(isBranchSpecificFile("u/admin/db.resource.yaml"), false); + assertEquals(isBranchSpecificFile("f/my.http_trigger.yaml"), false); + assertEquals(isBranchSpecificFile("f/script.ts"), false); +}); + +Deno.test("isBranchSpecificFile: handles resource files with extensions", () => { + assertEquals(isBranchSpecificFile("f/config.main.resource.file.json"), true); + assertEquals(isBranchSpecificFile("f/config.resource.file.json"), false); +}); + +// ============================================================================= +// ROUND-TRIP TESTS +// ============================================================================= + +Deno.test("round-trip: variable file path conversion", () => { + const original = "f/my/nested/config.variable.yaml"; + const branch = "feature/test-branch"; + const branchSpecific = toBranchSpecificPath(original, branch); + const restored = fromBranchSpecificPath(branchSpecific, branch); + assertEquals(restored, original); +}); + +Deno.test("round-trip: resource file path conversion", () => { + const original = "u/admin/database.resource.yaml"; + const branch = "develop"; + const branchSpecific = toBranchSpecificPath(original, branch); + const restored = fromBranchSpecificPath(branchSpecific, branch); + assertEquals(restored, original); +}); + +Deno.test("round-trip: trigger file path conversion", () => { + const original = "f/webhooks/handler.http_trigger.yaml"; + const branch = "main"; + const branchSpecific = toBranchSpecificPath(original, branch); + const restored = fromBranchSpecificPath(branchSpecific, branch); + assertEquals(restored, original); +}); + +Deno.test("round-trip: resource file with extension", () => { + const original = "f/configs/settings.resource.file.ini"; + const branch = "release/v1.0"; + const branchSpecific = toBranchSpecificPath(original, branch); + const restored = fromBranchSpecificPath(branchSpecific, branch); + assertEquals(restored, original); +}); + +// ============================================================================= +// BRANCH OVERRIDE TESTS (for --branch flag functionality) +// These tests validate that functions work correctly with explicit branch override +// ============================================================================= + +import { + getBranchSpecificPath, + isCurrentBranchFile, + getSpecificItemsForCurrentBranch, +} from "../src/core/specific_items.ts"; + +Deno.test("branchOverride: getBranchSpecificPath with override returns branch-specific path", () => { + // This test verifies that when branchOverride is provided, the function uses it + // instead of detecting the current git branch + const config: SpecificItemsConfig = { + variables: ["f/**"], + }; + + // When override is provided, it should return the branch-specific path even outside git repo + const result = getBranchSpecificPath("f/test.variable.yaml", config, "staging"); + assertEquals(result, "f/test.staging.variable.yaml"); +}); + +Deno.test("branchOverride: getBranchSpecificPath without override and not in git repo returns undefined", () => { + const config: SpecificItemsConfig = { + variables: ["f/**"], + }; + + // Without override and outside git repo (or if git returns null), should return undefined + // Note: This test's behavior depends on whether we're in a git repo + const result = getBranchSpecificPath("f/test.variable.yaml", config); + // In a git repo, this would return a branch-specific path; outside, it would be undefined + // We test the override case above which is deterministic +}); + +Deno.test("branchOverride: isCurrentBranchFile with override uses provided branch", () => { + // Test that isCurrentBranchFile uses the override branch instead of git detection + const result = isCurrentBranchFile("f/test.staging.variable.yaml", "staging"); + assertEquals(result, true); + + // Should return false for different branch + const resultOther = isCurrentBranchFile("f/test.staging.variable.yaml", "production"); + assertEquals(resultOther, false); + + // Should return false for non-branch-specific file + const resultNonSpecific = isCurrentBranchFile("f/test.variable.yaml", "staging"); + assertEquals(resultNonSpecific, false); +}); + +Deno.test("branchOverride: isCurrentBranchFile with override handles sanitized branch names", () => { + // Test with branch names that get sanitized + const result = isCurrentBranchFile("f/test.feature_my-branch.variable.yaml", "feature/my-branch"); + assertEquals(result, true); + + // Different sanitized branch should return false + const resultOther = isCurrentBranchFile("f/test.feature_my-branch.variable.yaml", "feature/other-branch"); + assertEquals(resultOther, false); +}); + +Deno.test("branchOverride: getSpecificItemsForCurrentBranch with override returns correct config", () => { + // Test that getSpecificItemsForCurrentBranch uses the override branch + const config = { + gitBranches: { + staging: { + specificItems: { + variables: ["f/**"], + resources: ["u/admin/**"], + }, + }, + production: { + specificItems: { + variables: ["g/**"], + }, + }, + commonSpecificItems: { + triggers: ["f/webhooks/**"], + }, + }, + }; + + const stagingItems = getSpecificItemsForCurrentBranch(config as any, "staging"); + assertEquals(stagingItems?.variables, ["f/**"]); + assertEquals(stagingItems?.resources, ["u/admin/**"]); + assertEquals(stagingItems?.triggers, ["f/webhooks/**"]); // From common + + const productionItems = getSpecificItemsForCurrentBranch(config as any, "production"); + assertEquals(productionItems?.variables, ["g/**"]); + assertEquals(productionItems?.resources, undefined); + assertEquals(productionItems?.triggers, ["f/webhooks/**"]); // From common +}); + +Deno.test("branchOverride: getSpecificItemsForCurrentBranch with non-existent branch returns undefined", () => { + const config = { + gitBranches: { + staging: { + specificItems: { + variables: ["f/**"], + }, + }, + }, + }; + + // When the branch doesn't have specific items (and there's no common), should return undefined + const result = getSpecificItemsForCurrentBranch(config as any, "nonexistent"); + assertEquals(result, undefined); +}); + +Deno.test("branchOverride: getSpecificItemsForCurrentBranch merges common and branch items", () => { + const config = { + gitBranches: { + commonSpecificItems: { + variables: ["common/**"], + resources: ["shared/**"], + }, + develop: { + specificItems: { + variables: ["dev/**"], + triggers: ["dev/triggers/**"], + }, + }, + }, + }; + + const result = getSpecificItemsForCurrentBranch(config as any, "develop"); + // Should merge common and branch-specific + assertEquals(result?.variables, ["common/**", "dev/**"]); + assertEquals(result?.resources, ["shared/**"]); + assertEquals(result?.triggers, ["dev/triggers/**"]); +});