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/**"]); +});