diff --git a/cli/src/commands/config/config.ts b/cli/src/commands/config/config.ts index 500e65a113..0d9d4b6fa3 100644 --- a/cli/src/commands/config/config.ts +++ b/cli/src/commands/config/config.ts @@ -1,9 +1,17 @@ import { Command } from "@cliffy/command"; import * as log from "../../core/log.ts"; +import { colors } from "@cliffy/ansi/colors"; +import { readFile, writeFile } from "node:fs/promises"; +import { stringify as yamlStringify } from "yaml"; import { formatConfigReference, formatConfigReferenceJson, } from "../init/template.ts"; +import { + getWmillYamlPath, + convertGitBranchesToWorkspaces, +} from "../../core/conf.ts"; +import { yamlParseFile } from "../../utils/yaml.ts"; interface ConfigOptions { json?: boolean; @@ -17,10 +25,90 @@ async function configAction(opts: ConfigOptions) { } } +async function migrateAction() { + const wmillYamlPath = getWmillYamlPath(); + if (!wmillYamlPath) { + log.error("No wmill.yaml found. Nothing to migrate."); + return; + } + + const conf = (await yamlParseFile(wmillYamlPath)) as Record; + if (!conf) { + log.error("wmill.yaml is empty. Nothing to migrate."); + return; + } + + // Collect all legacy keys present + const legacyKeys: string[] = []; + for (const key of ["gitBranches", "environments", "git_branches"]) { + if (key in conf && conf[key]) { + legacyKeys.push(key); + } + } + + // If no legacy keys, check if already migrated + if (legacyKeys.length === 0) { + if ("workspaces" in conf) { + log.info("Already using 'workspaces' format. No migration needed."); + } else { + log.info( + "No gitBranches/environments/git_branches found. Nothing to migrate." + ); + } + return; + } + + // Use the first legacy key found (priority order) + const legacyKey = legacyKeys[0]; + const legacyData = conf[legacyKey]; + + // Convert + const workspaces = convertGitBranchesToWorkspaces(legacyData); + const wsNames = Object.keys(workspaces).filter( + (k) => k !== "commonSpecificItems" + ); + + // Build new config: remove ALL legacy keys, add/merge workspaces + const newConf = { ...conf }; + for (const key of legacyKeys) { + delete newConf[key]; + } + if (newConf.workspaces) { + // Merge: legacy entries into existing workspaces (don't overwrite existing) + for (const [name, entry] of Object.entries(workspaces)) { + if (!(newConf.workspaces as any)[name]) { + (newConf.workspaces as any)[name] = entry; + } + } + } else { + newConf.workspaces = workspaces; + } + + await writeFile(wmillYamlPath, yamlStringify(newConf), "utf-8"); + log.info( + colors.green( + `✅ Migrated '${legacyKey}' to 'workspaces' in ${wmillYamlPath}` + ) + ); + if (wsNames.length > 0) { + log.info(` Workspace entries: ${wsNames.join(", ")}`); + } + if (legacyKeys.length > 1) { + log.info( + ` Also removed additional legacy keys: ${legacyKeys.slice(1).join(", ")}` + ); + } +} + const command = new Command() .name("config") .description("Show all available wmill.yaml configuration options") .option("--json", "Output as JSON for programmatic consumption") - .action(configAction as any); + .action(configAction as any) + .command("migrate") + .description( + "Migrate wmill.yaml from gitBranches/environments to workspaces format" + ) + .action(migrateAction as any); export default command; diff --git a/cli/src/commands/gitsync-settings/pull.ts b/cli/src/commands/gitsync-settings/pull.ts index bea37743a4..910ba17942 100644 --- a/cli/src/commands/gitsync-settings/pull.ts +++ b/cli/src/commands/gitsync-settings/pull.ts @@ -6,7 +6,7 @@ import { GlobalOptions } from "../../types.ts"; import { requireLogin } from "../../core/auth.ts"; import { resolveWorkspace } from "../../core/context.ts"; import * as wmill from "../../../gen/services.gen.ts"; -import { SyncOptions, readConfigFile, getEffectiveSettings, DEFAULT_SYNC_OPTIONS, getWmillYamlPath } from "../../core/conf.ts"; +import { SyncOptions, readConfigFile, getEffectiveSettings, DEFAULT_SYNC_OPTIONS, getWmillYamlPath, findWorkspaceByGitBranch, WorkspacesConfig } from "../../core/conf.ts"; import { yamlOptions } from "../sync/sync.ts"; import { deepEqual } from "../../utils/utils.ts"; import { getCurrentGitBranch, isGitRepository } from "../../utils/git.ts"; @@ -167,11 +167,11 @@ export async function pullGitSyncSettings( if (isGitRepository()) { const currentBranch = getCurrentGitBranch(); if (currentBranch) { - if (!updatedConfig.gitBranches) { - updatedConfig.gitBranches = {}; + if (!updatedConfig.workspaces) { + updatedConfig.workspaces = {}; } - if (!updatedConfig.gitBranches[currentBranch]) { - updatedConfig.gitBranches[currentBranch] = { overrides: {} }; + if (!updatedConfig.workspaces[currentBranch]) { + updatedConfig.workspaces[currentBranch] = {}; } } } @@ -360,16 +360,16 @@ export async function pullGitSyncSettings( let needsBranchStructure = false; if (isGitRepository()) { const currentBranch = getCurrentGitBranch(); - if (currentBranch && (!localConfig.gitBranches || !localConfig.gitBranches[currentBranch])) { + if (currentBranch && (!localConfig.workspaces || !findWorkspaceByGitBranch(localConfig.workspaces, currentBranch))) { needsBranchStructure = true; // Create empty branch structure const updatedConfig = { ...localConfig }; - if (!updatedConfig.gitBranches) { - updatedConfig.gitBranches = {}; + if (!updatedConfig.workspaces) { + updatedConfig.workspaces = {}; } - if (!updatedConfig.gitBranches[currentBranch]) { - updatedConfig.gitBranches[currentBranch] = { overrides: {} }; + if (!updatedConfig.workspaces[currentBranch]) { + updatedConfig.workspaces[currentBranch] = {}; } // Write updated configuration @@ -431,11 +431,11 @@ export async function pullGitSyncSettings( const currentBranch = getCurrentGitBranch(); if (currentBranch) { log.info(`Detected Git repository, adding empty branch structure for: ${currentBranch}`); - if (!updatedConfig.gitBranches) { - updatedConfig.gitBranches = {}; + if (!updatedConfig.workspaces) { + updatedConfig.workspaces = {}; } - if (!updatedConfig.gitBranches[currentBranch]) { - updatedConfig.gitBranches[currentBranch] = { overrides: {} }; + if (!updatedConfig.workspaces[currentBranch]) { + updatedConfig.workspaces[currentBranch] = {}; } } } diff --git a/cli/src/commands/gitsync-settings/utils.ts b/cli/src/commands/gitsync-settings/utils.ts index 8d255d9f92..3119f2bcda 100644 --- a/cli/src/commands/gitsync-settings/utils.ts +++ b/cli/src/commands/gitsync-settings/utils.ts @@ -26,17 +26,17 @@ export function normalizeRepoPath(path: string): string { return path.replace(/^\$res:/, ""); } -// Helper to get or create branch configuration +// Helper to get or create workspace configuration entry for a branch export function getOrCreateBranchConfig(config: SyncOptions, branchName: string): { config: SyncOptions; branchKey: string; } { - if (!config.gitBranches) { - config.gitBranches = {}; + if (!config.workspaces) { + config.workspaces = {} as any; } - if (!config.gitBranches[branchName]) { - config.gitBranches[branchName] = {}; + if (!(config.workspaces as any)[branchName]) { + (config.workspaces as any)[branchName] = {}; } return { @@ -54,12 +54,12 @@ export function applyBackendSettingsToBranch( const { config: updatedConfig } = getOrCreateBranchConfig(config, branchName); // Get the base settings (top-level + defaults) to compare against - const { gitBranches, ...topLevelSettings } = config; + const { workspaces, ...topLevelSettings } = config; const baseSettings: Partial = { ...DEFAULT_SYNC_OPTIONS, ...topLevelSettings }; // Only store fields that differ from the base settings Object.keys(backendSettings).forEach(key => { - if (key !== 'gitBranches' && backendSettings[key as keyof SyncOptions] !== undefined) { + if (key !== 'workspaces' && key !== 'gitBranches' && backendSettings[key as keyof SyncOptions] !== undefined) { const backendValue = backendSettings[key as keyof SyncOptions]; const baseValue = baseSettings[key as keyof SyncOptions]; @@ -67,10 +67,10 @@ export function applyBackendSettingsToBranch( const isDifferent = GitSyncSettingsConverter.isDifferent(backendValue, baseValue); if (isDifferent) { - if (!updatedConfig.gitBranches![branchName].overrides) { - updatedConfig.gitBranches![branchName].overrides = {}; + if (!(updatedConfig.workspaces as any)![branchName].overrides) { + (updatedConfig.workspaces as any)![branchName].overrides = {}; } - (updatedConfig.gitBranches![branchName].overrides as any)[key] = backendValue; + ((updatedConfig.workspaces as any)![branchName].overrides as any)[key] = backendValue; } } }); diff --git a/cli/src/commands/init/init.ts b/cli/src/commands/init/init.ts index db4950575b..345eda8e77 100644 --- a/cli/src/commands/init/init.ts +++ b/cli/src/commands/init/init.ts @@ -2,11 +2,19 @@ import { stat, writeFile, rm, mkdir } from "node:fs/promises"; import { colors } from "@cliffy/ansi/colors"; import { Command } from "@cliffy/command"; import { Confirm } from "@cliffy/prompt/confirm"; +import { Select } from "@cliffy/prompt/select"; +import { Input } from "@cliffy/prompt/input"; import * as log from "../../core/log.ts"; -import { type BranchBinding } from "./template.ts"; +import { type WorkspaceBinding } from "./template.ts"; import { GlobalOptions } from "../../types.ts"; import { readLockfile } from "../../utils/metadata.ts"; -import { getActiveWorkspaceOrFallback } from "../workspace/workspace.ts"; +import { + getActiveWorkspaceOrFallback, + getActiveWorkspace, + allWorkspaces, + add as addWorkspaceProfile, + type Workspace, +} from "../workspace/workspace.ts"; import { generateRTNamespace } from "../resource-type/resource-type.ts"; import { SKILLS, SKILL_CONTENT, SCHEMAS, SCHEMA_MAPPINGS } from "../../guidance/skills.ts"; import { generateAgentsMdContent } from "../../guidance/core.ts"; @@ -42,6 +50,9 @@ export interface InitOptions { * Bootstrap a windmill project with a wmill.yaml file */ async function initAction(opts: InitOptions) { + let didBindWorkspace = false; + let boundProfile: Workspace | undefined; + if (await stat("wmill.yaml").catch(() => null)) { log.info("wmill.yaml already exists, skipping config generation"); } else { @@ -50,171 +61,180 @@ async function initAction(opts: InitOptions) { "../../utils/git.ts" ); let branchName: string | undefined; - let binding: BranchBinding | undefined; - if (isGitRepository()) { + let wsBindings: WorkspaceBinding[] | undefined; + // boundProfile is set during bind (hoisted to function scope) + const inGitRepo = isGitRepository(); + if (inGitRepo) { branchName = getCurrentGitBranch() ?? undefined; } - // Determine workspace binding before writing the template - if (isGitRepository() && branchName) { + const isInteractive = !!process.stdin.isTTY && !opts.useDefault; + + if (isInteractive && opts.bindProfile !== false) { + const shouldBind = opts.bindProfile === true || await Confirm.prompt({ + message: "Bind a workspace?", + default: true, + }); + + if (shouldBind) { + // Step 1: Pick workspace profile (same as wmill workspace bind) + let profiles = await allWorkspaces(opts.configDir); + let selectedProfile: Workspace | undefined; + + if (profiles.length === 0) { + log.info(colors.yellow("No workspace profiles found. Let's create one.")); + await addWorkspaceProfile(opts as any, undefined, undefined, undefined); + profiles = await allWorkspaces(opts.configDir); + selectedProfile = profiles.length > 0 + ? await getActiveWorkspace(opts as GlobalOptions) + : undefined; + } else { + const activeProfile = await getActiveWorkspace(opts as GlobalOptions); + const selectedName = await Select.prompt({ + message: "Select workspace profile", + options: profiles.map((p) => ({ + name: `${p.name} (${p.workspaceId} on ${p.remote})`, + value: p.name, + })), + default: activeProfile?.name, + }); + selectedProfile = profiles.find((p) => p.name === selectedName); + } + + if (selectedProfile) { + // Step 2: Pick workspace name + const wsName = await Input.prompt({ + message: "Workspace name (key in wmill.yaml)", + default: selectedProfile.workspaceId, + }); + + // Step 3: Pick git branch (only in git repos) + let gitBranch: string | undefined; + if (inGitRepo) { + const branchInput = await Input.prompt({ + message: "Git branch to associate", + default: branchName ?? wsName, + }); + if (branchInput !== wsName) { + gitBranch = branchInput; + } + } + + wsBindings = [{ + name: wsName, + baseUrl: selectedProfile.remote, + workspaceId: selectedProfile.workspaceId !== wsName ? selectedProfile.workspaceId : wsName, + gitBranch, + }]; + boundProfile = selectedProfile; + } + } + } else if (opts.bindProfile === true) { + // Non-interactive bind: create a single workspace entry from active profile const activeWorkspace = await getActiveWorkspaceOrFallback( opts as GlobalOptions ); if (activeWorkspace) { - const shouldBind = opts.bindProfile === true; - const shouldPrompt = - opts.bindProfile === undefined && - !!process.stdin.isTTY && - !opts.useDefault; - const shouldSkip = - opts.bindProfile != true && - (opts.useDefault || !process.stdin.isTTY); - - if (!shouldSkip) { - if (shouldBind || shouldPrompt) { - log.info( - colors.yellow(`\nCurrent Git branch: ${colors.bold(branchName)}`) - ); - log.info( - colors.yellow( - `Active workspace profile: ${colors.bold(activeWorkspace.name)}` - ) - ); - log.info( - colors.yellow( - ` ${activeWorkspace.workspaceId} on ${activeWorkspace.remote}` - ) - ); - } - - if ( - shouldBind || - (shouldPrompt && - (await Confirm.prompt({ - message: "Bind workspace profile to current Git branch?", - default: true, - }))) - ) { - log.info( - `binding branch ${branchName} to workspace ${activeWorkspace.name} on ${activeWorkspace.remote}` - ); - binding = { - baseUrl: activeWorkspace.remote, - workspaceId: activeWorkspace.workspaceId, - }; - } - } + const wsName = branchName ?? activeWorkspace.workspaceId; + wsBindings = [{ + name: wsName, + baseUrl: activeWorkspace.remote, + workspaceId: activeWorkspace.workspaceId !== wsName ? activeWorkspace.workspaceId : wsName, + }]; + boundProfile = activeWorkspace; } } - await writeFile("wmill.yaml", generateCommentedTemplate(branchName, binding), "utf-8"); + await writeFile("wmill.yaml", generateCommentedTemplate(branchName, undefined, wsBindings), "utf-8"); log.info(colors.green("wmill.yaml created with default settings")); - if (binding) { + if (wsBindings && wsBindings.length > 0) { + didBindWorkspace = true; log.info( colors.green( - `✓ Bound branch '${branchName}' to workspace` + `✓ Bound workspace '${wsBindings[0].name}' → ${wsBindings[0].workspaceId} on ${wsBindings[0].baseUrl}` ) ); + log.info( + colors.gray("To bind additional workspaces, run: wmill workspace bind") + ); + } else { + log.warn( + "⚠️ No workspace bound. Sync commands will not work without a workspace.\n" + + " Run 'wmill workspace bind' to bind a workspace to this project." + ); } // Create lock file await readLockfile(); - // Check for backend git-sync settings unless --use-default is specified - if (!opts.useDefault) { + // Check for backend git-sync settings — only if a workspace was bound and not --use-default + if (!opts.useDefault && didBindWorkspace && boundProfile) { try { - const { requireLogin } = await import("../../core/auth.ts"); - const { resolveWorkspace } = await import("../../core/context.ts"); + const { setClient } = await import("../../core/client.ts"); - // Check if user has workspace configured - const { getActiveWorkspace } = await import( - "../workspace/workspace.ts" - ); - const activeWorkspace = await getActiveWorkspace(opts as GlobalOptions); + // Use the bound profile directly — skip requireLogin which would resolve to the active profile + setClient(boundProfile.token, boundProfile.remote.replace(/\/$/, "")); - if (!activeWorkspace) { - log.info("No workspace configured. Using default settings."); - log.info( - "You can configure a workspace later with 'wmill workspace add'" - ); - } else { - await requireLogin(opts as GlobalOptions); - const workspace = await resolveWorkspace(opts as GlobalOptions); + const wmill = await import("../../../gen/services.gen.ts"); + const settings = await wmill.getSettings({ + workspace: boundProfile.workspaceId, + }); - const wmill = await import("../../../gen/services.gen.ts"); - const settings = await wmill.getSettings({ - workspace: workspace.workspaceId, - }); + if ( + settings.git_sync?.repositories && + settings.git_sync.repositories.length > 0 + ) { + let useBackendSettings = opts.useBackend; - if ( - settings.git_sync?.repositories && - settings.git_sync.repositories.length > 0 - ) { - let useBackendSettings = opts.useBackend; + // If repository is specified, implicitly use backend settings + if (opts.repository && !opts.useDefault) { + useBackendSettings = true; + } - // If repository is specified, implicitly use backend settings - if (opts.repository && !opts.useDefault) { - useBackendSettings = true; - } + if (useBackendSettings === undefined) { + const choice = await Select.prompt({ + message: + "Git-sync settings found on backend. What would you like to do?", + options: [ + { name: "Use backend git-sync settings", value: "backend" }, + { name: "Use default settings", value: "default" }, + { name: "Cancel", value: "cancel" }, + ], + }); - if (useBackendSettings === undefined) { - // Interactive prompt - const { Select } = await import("@cliffy/prompt/select"); - const choice = await Select.prompt({ - message: - "Git-sync settings found on backend. What would you like to do?", - options: [ - { - name: "Use backend git-sync settings", - value: "backend", - }, - { - name: "Use default settings", - value: "default", - }, - { - name: "Cancel", - value: "cancel", - }, - ], - }); - - if (choice === "cancel") { - // Clean up the created files - try { - await rm("wmill.yaml"); - await rm("wmill-lock.yaml"); - } catch (e) { - // Ignore cleanup errors - } - log.info("Init cancelled"); - process.exit(0); + if (choice === "cancel") { + try { + await rm("wmill.yaml"); + await rm("wmill-lock.yaml"); + } catch { + // Ignore cleanup errors } - - useBackendSettings = choice === "backend"; + log.info("Init cancelled"); + process.exit(0); } - if (useBackendSettings) { - log.info("Applying git-sync settings from backend..."); + useBackendSettings = choice === "backend"; + } - // Import and run the pull git-sync settings logic - const { pullGitSyncSettings } = await import( - "../gitsync-settings/gitsync-settings.ts" - ); - await pullGitSyncSettings({ - ...(opts as GlobalOptions), - repository: opts.repository, - jsonOutput: false, - diff: false, - replace: true, // Auto-replace when using backend settings during init - }); - - log.info(colors.green("Git-sync settings applied from backend")); - } + if (useBackendSettings) { + log.info("Applying git-sync settings from backend..."); + const { pullGitSyncSettings } = await import( + "../gitsync-settings/gitsync-settings.ts" + ); + const gsOpts = { + ...(opts as GlobalOptions), + workspace: boundProfile.name, + repository: opts.repository, + jsonOutput: false, + diff: false, + replace: true, + }; + (gsOpts as any).__secret_workspace = boundProfile; + await pullGitSyncSettings(gsOpts); + log.info(colors.green("Git-sync settings applied from backend")); } } } 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 as Error).message}` ); @@ -325,14 +345,23 @@ async function initAction(opts: InitOptions) { } } - // Generate resource type namespace - try { - await generateRTNamespace(opts as GlobalOptions); - } catch (error) { - log.warn( - `Could not pull resource types and generate TypeScript namespace: ${ - error instanceof Error ? error.message : error - }` + // Generate resource type namespace (only if a workspace was bound) + if (didBindWorkspace && boundProfile) { + try { + // Cache the bound profile so resolveWorkspace doesn't re-resolve and prompt again + const rtOpts = { ...opts } as GlobalOptions; + (rtOpts as any).__secret_workspace = boundProfile; + await generateRTNamespace(rtOpts); + } catch (error) { + log.warn( + `Could not pull resource types and generate TypeScript namespace: ${ + error instanceof Error ? error.message : error + }` + ); + } + } else { + log.info( + colors.gray("Skipped resource type namespace generation (no workspace bound). Run 'wmill workspace bind' then 'wmill init' to generate it.") ); } } diff --git a/cli/src/commands/init/template.ts b/cli/src/commands/init/template.ts index 0684b7ca14..046667b62c 100644 --- a/cli/src/commands/init/template.ts +++ b/cli/src/commands/init/template.ts @@ -34,35 +34,37 @@ export interface ConfigOption { example?: string; inlineComment?: string; groupNote?: string; + skipInTemplate?: boolean; } /** Keys to strip from ConfigOption entries when generating JSON Schema. */ const NON_SCHEMA_KEYS = new Set([ "name", "default", "section", "sectionNote", "commented", "templateValue", - "example", "inlineComment", "groupNote", + "example", "inlineComment", "groupNote", "skipInTemplate", ]); // Reusable sub-schemas for nested types const SPECIFIC_ITEMS_SCHEMA = { type: "object", - description: "Sync only specific items", + description: "Items to sync per-workspace (stored as ...yaml on disk)", properties: { - variables: { type: "array", items: { type: "string" }, description: "Specific variable paths to sync" }, - resources: { type: "array", items: { type: "string" }, description: "Specific resource paths to sync" }, - triggers: { type: "array", items: { type: "string" }, description: "Specific trigger paths to sync" }, - folders: { type: "array", items: { type: "string" }, description: "Specific folder paths to sync" }, - settings: { type: "boolean", description: "Whether to sync settings" }, + variables: { type: "array", items: { type: "string" }, description: "Variable path patterns to sync per-workspace" }, + resources: { type: "array", items: { type: "string" }, description: "Resource path patterns to sync per-workspace" }, + triggers: { type: "array", items: { type: "string" }, description: "Trigger path patterns to sync per-workspace" }, + folders: { type: "array", items: { type: "string" }, description: "Folder path patterns to sync per-workspace" }, + settings: { type: "boolean", description: "Whether to sync settings per-workspace" }, }, additionalProperties: false, } as const; -const BRANCH_CONFIG_SCHEMA = { +const WORKSPACE_CONFIG_SCHEMA = { type: "object", properties: { - baseUrl: { type: "string", description: "Windmill instance URL for this branch" }, - workspaceId: { type: "string", description: "Workspace ID to sync with for this branch" }, - overrides: { type: "object", description: "Override any top-level sync option for this branch" }, + gitBranch: { type: "string", description: "Git branch name (defaults to the workspace name/key)" }, + workspaceId: { type: "string", description: "Workspace ID to sync with (defaults to the workspace name/key)" }, + baseUrl: { type: "string", description: "Windmill instance URL for this workspace" }, + overrides: { type: "object", description: "Override any top-level sync option for this workspace" }, promotionOverrides: { type: "object", description: "Overrides applied when using --promotion flag" }, specificItems: SPECIFIC_ITEMS_SCHEMA, }, @@ -75,10 +77,11 @@ const BRANCH_CONFIG_SCHEMA = { */ export const CONFIG_REFERENCE: ConfigOption[] = [ // ── Core ────────────────────────────────────────────────────────────── - { name: "defaultTs", type: "string", enum: ["bun", "deno"], default: "bun", description: "Default TypeScript runtime for new scripts" }, + { name: "defaultTs", type: "string", enum: ["bun", "deno"], default: "bun", description: "Default TypeScript runtime for new scripts", + inlineComment: "bun or deno" }, { name: "includes", type: "array", items: { type: "string" }, default: '["f/**"]', description: "Glob patterns for files to include in sync", - templateValue: '\n - "f/**"' }, - { name: "extraIncludes", type: "array", items: { type: "string" }, default: "[]", description: "Additional glob patterns merged with includes (useful in branch overrides)", + templateValue: '\n - "f/**"', inlineComment: 'use ** for all, f/** for folder-scoped' }, + { name: "extraIncludes", type: "array", items: { type: "string" }, default: "[]", description: "Additional glob patterns merged with includes (useful in workspace overrides)", commented: true }, { name: "excludes", type: "array", items: { type: "string" }, default: "[]", description: "Glob patterns for files to exclude from sync" }, @@ -123,7 +126,8 @@ export const CONFIG_REFERENCE: ConfigOption[] = [ commented: true, templateValue: "staging" }, { name: "skipBranchValidation", type: "boolean", default: "false", description: "Skip validation that current git branch matches a configured branch", commented: true }, - { name: "nonDottedPaths", type: "boolean", default: "true", description: "Use __flow/__app/__raw_app suffixes instead of .flow/.app/.raw_app" }, + { name: "nonDottedPaths", type: "boolean", default: "true", description: "Use __flow/__app/__raw_app suffixes instead of .flow/.app/.raw_app", + inlineComment: "recommended for new projects" }, // ── Codebase bundling ───────────────────────────────────────────────── { name: "codebases", type: "array", default: "[]", description: "Codebase bundling configurations for shared libraries", @@ -168,34 +172,36 @@ export const CONFIG_REFERENCE: ConfigOption[] = [ ].join("\n"), }, - // ── Git branches ────────────────────────────────────────────────────── - { name: "gitBranches", type: "object", default: "{}", description: "Map git branches to workspaces and per-branch sync overrides", + // ── Workspace bindings ───────────────────────────────────────────────── + { name: "workspaces", type: "object", default: "{}", description: "Map workspace names to Windmill instances and per-workspace sync overrides", properties: { commonSpecificItems: SPECIFIC_ITEMS_SCHEMA }, - additionalProperties: BRANCH_CONFIG_SCHEMA, - section: "Git branch / environment bindings", - sectionNote: "Map git branches to Windmill workspaces and override settings per branch.\nUse \"environments\" as an alias if you prefer environment-based terminology.", - templateValue: "\n {{BRANCH}}:\n overrides: {}", + additionalProperties: WORKSPACE_CONFIG_SCHEMA, + section: "Workspace bindings", + sectionNote: "Map workspace names to Windmill instances and override settings per workspace.\nThe key is a human-friendly workspace name. gitBranch and workspaceId default to the key name.", + templateValue: "\n {{BRANCH}}: {}", example: [ "{{BASEURL_LINE}}", "{{WORKSPACE_ID_LINE}}", - " # promotionOverrides: # overrides applied during --promotion", + " # gitBranch: main # git branch (defaults to workspace name)", + " # promotionOverrides: # overrides applied during --promotion", " # skipSecrets: false", - " # specificItems: # only sync these specific items", + " # specificItems: # items stored per-workspace on disk", + " # # e.g. file.staging.variable.yaml (suffix = workspace name)", ' # variables: ["f/my_folder/my_var"]', ' # resources: ["f/my_folder/my_res"]', ' # triggers: ["f/my_folder/my_trigger"]', ' # folders: ["my_folder"]', " # settings: true", "", - " # Example: staging branch bound to a different workspace", + " # Example: staging workspace on a different instance", " # staging:", " # baseUrl: https://staging.windmill.dev", - " # workspaceId: staging-workspace", + " # workspaceId: staging-workspace # defaults to 'staging' if omitted", " # overrides:", " # skipSecrets: false", " # includeSchedules: true", "", - " # Items shared across ALL branches", + " # Items shared across ALL workspaces", " # commonSpecificItems:", ' # variables: ["f/shared/api_key"]', ' # resources: ["f/shared/db_conn"]', @@ -203,19 +209,28 @@ export const CONFIG_REFERENCE: ConfigOption[] = [ ].join("\n"), }, - { name: "environments", type: "object", default: "{}", description: "Alias for gitBranches — use if you prefer environment-based terminology", + { name: "gitBranches", type: "object", default: "{}", description: "[Deprecated] Use 'workspaces' instead. Map git branches to workspaces.", properties: { commonSpecificItems: SPECIFIC_ITEMS_SCHEMA }, - additionalProperties: BRANCH_CONFIG_SCHEMA, - commented: true }, + additionalProperties: WORKSPACE_CONFIG_SCHEMA, + commented: true, skipInTemplate: true }, + { name: "environments", type: "object", default: "{}", description: "[Deprecated] Use 'workspaces' instead.", + properties: { commonSpecificItems: SPECIFIC_ITEMS_SCHEMA }, + additionalProperties: WORKSPACE_CONFIG_SCHEMA, + commented: true, skipInTemplate: true }, ]; // ─── Template generator ───────────────────────────────────────────────────── -export interface BranchBinding { +export interface WorkspaceBinding { + name: string; baseUrl: string; workspaceId: string; + gitBranch?: string; } +/** @deprecated Use WorkspaceBinding instead */ +export type BranchBinding = Pick; + /** Quote a string for use as a YAML key if it contains special characters. */ function yamlKey(s: string): string { if ( @@ -228,7 +243,7 @@ function yamlKey(s: string): string { return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`; } -export function generateCommentedTemplate(branchName?: string, binding?: BranchBinding): string { +export function generateCommentedTemplate(branchName?: string, binding?: BranchBinding, bindings?: WorkspaceBinding[]): string { const branch = yamlKey(branchName ?? "main"); const lines: string[] = [ "# yaml-language-server: $schema=wmill.schema.json", @@ -238,6 +253,8 @@ export function generateCommentedTemplate(branchName?: string, binding?: BranchB ]; for (const opt of CONFIG_REFERENCE) { + if (opt.skipInTemplate) continue; + if (opt.section) { const ruler = "-".repeat(Math.max(0, 65 - opt.section.length)); lines.push(`# --- ${opt.section} ${ruler}`); @@ -253,6 +270,38 @@ export function generateCommentedTemplate(branchName?: string, binding?: BranchB lines.push(`# ${opt.groupNote}`); } + // For the workspaces section, generate from bindings if available + if (opt.name === "workspaces" && bindings && bindings.length > 0) { + lines.push(`# ${opt.description}`); + lines.push("workspaces:"); + for (const ws of bindings) { + const key = yamlKey(ws.name); + lines.push(` ${key}:`); + lines.push(` baseUrl: ${ws.baseUrl}`); + if (ws.workspaceId !== ws.name) { + lines.push(` workspaceId: ${ws.workspaceId}${" ".repeat(Math.max(1, 30 - ws.workspaceId.length))}# windmill workspace id (defaults to key name)`); + } + if (ws.gitBranch && ws.gitBranch !== ws.name) { + lines.push(` gitBranch: ${ws.gitBranch}${" ".repeat(Math.max(1, 32 - ws.gitBranch.length))}# git branch to auto-detect this workspace (defaults to key name)`); + } + lines.push(` # overrides: # override top-level sync options for this workspace`); + lines.push(` # skipSecrets: false`); + lines.push(` # includeSchedules: true`); + lines.push(` # promotionOverrides: # overrides when using --promotion`); + lines.push(` # skipSecrets: false`); + lines.push(` # specificItems: # items synced per-workspace (file suffix = workspace name)`); + lines.push(` # variables: []`); + lines.push(` # resources: []`); + lines.push(` # triggers: []`); + lines.push(` # folders: []`); + lines.push(` # settings: false`); + + } + lines.push(""); + // Skip the default template/example rendering for this opt + continue; + } + const value = opt.templateValue ?? opt.default; const resolvedValue = value.replace("{{BRANCH}}", branch); @@ -278,8 +327,8 @@ export function generateCommentedTemplate(branchName?: string, binding?: BranchB .replace("{{WORKSPACE_ID_LINE}}", ` workspaceId: ${binding.workspaceId}`); } else { resolvedExample = resolvedExample - .replace("{{BASEURL_LINE}}", " # baseUrl: https://app.windmill.dev # Windmill instance URL for this branch") - .replace("{{WORKSPACE_ID_LINE}}", " # workspaceId: my-workspace # workspace to sync with"); + .replace("{{BASEURL_LINE}}", " # baseUrl: https://app.windmill.dev # Windmill instance URL") + .replace("{{WORKSPACE_ID_LINE}}", " # workspaceId: my-workspace # defaults to workspace name"); } for (const exLine of resolvedExample.split("\n")) { lines.push(exLine); @@ -328,15 +377,18 @@ export function formatConfigReference(): string { for (const opt of CONFIG_REFERENCE) { allRows.push({ name: opt.name, description: opt.description, default: opt.default }); + // Don't expand deprecated entries (they duplicate the primary entry's schema) + if (opt.skipInTemplate) continue; + // Auto-expand array item properties (e.g., codebases[].*) if (opt.items?.properties) { expandSchema(`${opt.name}[]`, opt.items, allRows); } - // Auto-expand additionalProperties (e.g., gitBranches..*) + // Auto-expand additionalProperties (e.g., workspaces..*) if (opt.additionalProperties && typeof opt.additionalProperties === "object" && opt.additionalProperties.properties) { - expandSchema(`${opt.name}.`, opt.additionalProperties as Record, allRows); + expandSchema(`${opt.name}.`, opt.additionalProperties as Record, allRows); } - // Auto-expand named properties (e.g., gitBranches.commonSpecificItems) + // Auto-expand named properties (e.g., workspaces.commonSpecificItems) if (opt.properties) { expandSchema(opt.name, opt, allRows); } diff --git a/cli/src/commands/resource/resource.ts b/cli/src/commands/resource/resource.ts index e1c9052261..054ed91d06 100644 --- a/cli/src/commands/resource/resource.ts +++ b/cli/src/commands/resource/resource.ts @@ -18,7 +18,7 @@ import { sep as SEP } from "node:path"; import * as wmill from "../../../gen/services.gen.ts"; import { Resource } from "../../../gen/types.gen.ts"; import { readInlinePathSync } from "../../utils/utils.ts"; -import { isBranchSpecificFile } from "../../core/specific_items.ts"; +import { isWorkspaceSpecificFile } from "../../core/specific_items.ts"; import { getCurrentGitBranch } from "../../utils/git.ts"; export interface ResourceFile { @@ -75,7 +75,7 @@ export async function pushResource( let pathToRead = basePath; - if (originalLocalPath && isBranchSpecificFile(originalLocalPath)) { + if (originalLocalPath && isWorkspaceSpecificFile(originalLocalPath)) { const currentBranch = getCurrentGitBranch(); if (currentBranch) { // Directly construct branch-specific resource file path diff --git a/cli/src/commands/script/script.ts b/cli/src/commands/script/script.ts index 9a88da9df0..6baceff758 100644 --- a/cli/src/commands/script/script.ts +++ b/cli/src/commands/script/script.ts @@ -185,11 +185,11 @@ export async function findResourceFile(path: string) { if (currentBranch) { // Add branch-specific candidates at the beginning (higher priority) - const branchSpecificJSON = specificItems.toBranchSpecificPath( + const branchSpecificJSON = specificItems.toWorkspaceSpecificPath( contentBasePathJSON, currentBranch ); - const branchSpecificYAML = specificItems.toBranchSpecificPath( + const branchSpecificYAML = specificItems.toWorkspaceSpecificPath( contentBasePathYAML, currentBranch ); diff --git a/cli/src/commands/sync/sync.ts b/cli/src/commands/sync/sync.ts index a6f526804d..6e55d53e9a 100644 --- a/cli/src/commands/sync/sync.ts +++ b/cli/src/commands/sync/sync.ts @@ -48,13 +48,15 @@ import { mergeConfigWithConfigFile, SyncOptions, validateBranchConfiguration, + findWorkspaceByGitBranch, + WorkspaceEntryConfig, } from "../../core/conf.ts"; import { - fromBranchSpecificPath, - getBranchSpecificPath, + fromWorkspaceSpecificPath, + getWorkspaceSpecificPath, getSpecificItemsForCurrentBranch, - isBranchSpecificFile, - isCurrentBranchFile, + isWorkspaceSpecificFile, + isCurrentWorkspaceFile, isItemTypeConfigured, isSpecificItem, SpecificItemsConfig, @@ -107,6 +109,75 @@ import { hasWrongFormatSuffix, } from "../../utils/resource_folders.ts"; +let branchDeprecationWarned = false; + +// Resolve workspace name from a --branch override (git branch → workspace name). +// Falls back to using the branch value as-is (backward compat: old key = branch name). +function resolveWsNameFromBranch(opts: SyncOptions, branchName: string): string { + const match = findWorkspaceByGitBranch(opts.workspaces, branchName); + return match ? match[0] : branchName; +} + +// Warn if --workspace overrides auto-detected branch or if workspace not in config. +function warnWorkspaceOverride(opts: SyncOptions, wsNameForConfig: string | undefined): void { + if (!wsNameForConfig || !opts.workspaces) return; + + // Check if workspace exists in config + const wsEntry = (opts.workspaces as any)?.[wsNameForConfig] as WorkspaceEntryConfig | undefined; + if (!wsEntry) { + const wsNames = Object.keys(opts.workspaces).filter((k) => k !== "commonSpecificItems"); + if (wsNames.length > 0) { + log.warn( + `⚠️ Workspace '${wsNameForConfig}' is not defined in the 'workspaces' section of wmill.yaml.\n` + + ` No workspace-specific overrides will be applied. Available workspaces: ${wsNames.join(", ")}` + ); + } + return; + } + + // Check if current git branch maps to a different workspace + if (isGitRepository()) { + const currentBranch = getCurrentGitBranch(); + if (currentBranch) { + const autoMatch = findWorkspaceByGitBranch(opts.workspaces, currentBranch); + if (autoMatch && autoMatch[0] !== wsNameForConfig) { + log.info( + `Current git branch '${currentBranch}' maps to workspace '${autoMatch[0]}', ` + + `but --workspace overrides to '${wsNameForConfig}'.` + ); + } + } + } +} + +// The workspace name is used as the file suffix for workspace-specific files. +// This is a pass-through — the workspace name (config key) IS the suffix. +function resolveWsNameForFiles(_opts: SyncOptions, wsName: string): string { + return wsName; +} + +// After resolveWorkspace, infer the workspace config name from the resolved profile +// by matching baseUrl + workspaceId against the workspaces config entries. +function inferWsNameFromProfile(opts: SyncOptions, profile: { remote: string; workspaceId: string }): string | undefined { + if (!opts.workspaces) return undefined; + const wsNames = Object.keys(opts.workspaces).filter((k) => k !== "commonSpecificItems"); + for (const name of wsNames) { + const entry = (opts.workspaces as any)[name] as WorkspaceEntryConfig; + if (!entry?.baseUrl) continue; + try { + const entryUrl = new URL(entry.baseUrl).toString(); + const profileUrl = new URL(profile.remote).toString(); + const entryWsId = entry.workspaceId ?? name; + if (entryUrl === profileUrl && entryWsId === profile.workspaceId) { + return name; + } + } catch { + continue; + } + } + return undefined; +} + // Merge CLI options with effective settings, preserving CLI flags as overrides function mergeCliWithEffectiveOptions< T extends GlobalOptions & SyncOptions & { repository?: string }, @@ -115,14 +186,14 @@ function mergeCliWithEffectiveOptions< return Object.assign({}, effectiveOpts, cliOpts) as T; } -// Resolve effective sync options using branch-based configuration +// Resolve effective sync options using workspace-based configuration async function resolveEffectiveSyncOptions( workspace: Workspace, localConfig: SyncOptions, promotion?: string, - branchOverride?: string, + workspaceNameOverride?: string, ): Promise { - return await getEffectiveSettings(localConfig, promotion, false, false, branchOverride); + return await getEffectiveSettings(localConfig, promotion, false, false, workspaceNameOverride); } type DynFSElement = { @@ -1266,7 +1337,7 @@ export async function elementsToMap( const processedBasePaths = new Set(); const wrongFormatPaths: string[] = []; // Cache git branch at the start to avoid repeated execSync calls per file - const cachedBranch = branchOverride ?? getCurrentGitBranch() ?? undefined; + const cachedWsName = branchOverride ?? getCurrentGitBranch() ?? undefined; for await (const entry of readDirRecursiveWithIgnore(ignore, els)) { // console.log("FOO", entry.path, entry.ignored, entry.isDirectory) if (entry.isDirectory) { @@ -1383,10 +1454,10 @@ export async function elementsToMap( // If getTypeStrFromPath can't determine the type, continue processing the file } - // Handle branch-specific files - skip files for other branches - if (specificItems && isBranchSpecificFile(path)) { - if (!isCurrentBranchFile(path, cachedBranch)) { - // Skip branch-specific files for other branches + // Handle workspace-specific files - skip files for other branches + if (specificItems && isWorkspaceSpecificFile(path)) { + if (!isCurrentWorkspaceFile(path, cachedWsName)) { + // Skip workspace-specific files for other branches continue; } } @@ -1419,13 +1490,13 @@ export async function elementsToMap( } } - // Handle branch-specific path mapping after all filtering - if (cachedBranch && isCurrentBranchFile(path, cachedBranch)) { - // This is a branch-specific file for current branch - const currentBranch = cachedBranch; - const basePath = fromBranchSpecificPath(path, currentBranch); + // Handle workspace-specific path mapping after all filtering + if (cachedWsName && isCurrentWorkspaceFile(path, cachedWsName)) { + // This is a workspace-specific file for current branch + const currentBranch = cachedWsName; + const basePath = fromWorkspaceSpecificPath(path, currentBranch); - // Only use branch-specific files if the item type IS configured as branch-specific + // Only use workspace-specific files if the item type IS configured as branch-specific // AND matches the pattern. Otherwise, skip and use base file instead. if (!isItemTypeConfigured(basePath, specificItems)) { // Type not configured as branch-specific - skip, use base file instead @@ -1439,10 +1510,10 @@ export async function elementsToMap( // Type configured AND matches - map to base path map[basePath] = content; processedBasePaths.add(basePath); - } else if (!isBranchSpecificFile(path)) { + } else if (!isWorkspaceSpecificFile(path)) { // This is a regular base file if (processedBasePaths.has(path)) { - // Skip base file, we already processed branch-specific version + // Skip base file, we already processed workspace-specific version continue; } // Skip base file if it's configured as branch-specific (expect branch version) @@ -1452,7 +1523,7 @@ export async function elementsToMap( } map[path] = content; } - // Note: branch-specific files for other branches are already filtered out earlier + // Note: workspace-specific files for other branches are already filtered out earlier } if (wrongFormatPaths.length > 0) { @@ -2034,9 +2105,29 @@ export async function pull( opts.skipSecrets = false; } - // Validate branch configuration early (skipped when --branch is used) + // Resolve workspace name for config lookups. + // --branch resolves git branch → workspace name (deprecated but still supported). + // --workspace (without --base-url) selects a workspace config entry by name. + // When --base-url is used with --workspace, --workspace is a profile selector only; + // --branch should still drive config lookups. + const hasExplicitCredentials = !!opts.baseUrl; + let wsNameForConfig: string | undefined; + + if (opts.branch) { + if (!hasExplicitCredentials && !branchDeprecationWarned) { + log.warn("⚠️ --branch/--env is deprecated. Use --workspace instead."); + branchDeprecationWarned = true; + } + wsNameForConfig = resolveWsNameFromBranch(opts, opts.branch); + } else if (opts.workspace && !hasExplicitCredentials) { + // --workspace without --base-url: use as workspace config name + wsNameForConfig = opts.workspace; + warnWorkspaceOverride(opts, wsNameForConfig); + } + + // Validate workspace configuration early (skipped when override is used) try { - await validateBranchConfiguration(opts, opts.branch); + await validateBranchConfiguration(opts, wsNameForConfig); } catch (error) { if (error instanceof Error && error.message.includes("overrides")) { log.error(error.message); @@ -2049,19 +2140,27 @@ export async function pull( await mkdir(path.join(process.cwd(), ".wmill"), { recursive: true }); } - const workspace = await resolveWorkspace(opts, opts.branch); + const workspace = await resolveWorkspace(opts, wsNameForConfig); await requireLogin(opts); - // Resolve effective sync options with branch awareness + // If wsNameForConfig wasn't set from flags, infer from the resolved profile + if (!wsNameForConfig) { + wsNameForConfig = inferWsNameFromProfile(opts, workspace); + } + + // Resolve effective sync options with workspace awareness const effectiveOpts = await resolveEffectiveSyncOptions( workspace, opts, opts.promotion, - opts.branch, + wsNameForConfig, ); - // Extract specific items configuration before merging overwrites gitBranches - const specificItems = getSpecificItemsForCurrentBranch(opts, opts.branch); + // Extract specific items configuration + const specificItems = getSpecificItemsForCurrentBranch(opts, wsNameForConfig); + + // Compute the workspace name for file naming + const wsNameForFiles = wsNameForConfig ? resolveWsNameForFiles(opts, wsNameForConfig) : undefined; // Merge CLI flags with resolved settings (CLI flags take precedence only for explicit overrides) opts = mergeCliWithEffectiveOptions(originalCliOpts, effectiveOpts); @@ -2127,7 +2226,7 @@ export async function pull( codebases, true, specificItems, - opts.branch, + wsNameForFiles, true, // els1 (remote) is the remote source ); @@ -2147,11 +2246,11 @@ export async function pull( : {}), ...(specificItems && isSpecificItem(change.path, specificItems) ? { - branch_specific: true, - branch_specific_path: getBranchSpecificPath( + workspace_specific: true, + workspace_specific_path: getWorkspaceSpecificPath( change.path, specificItems, - opts.branch, + wsNameForFiles, ), } : {}), @@ -2164,7 +2263,7 @@ export async function pull( if (changes.length > 0) { if (!opts.jsonOutput) { - prettyChanges(changes, specificItems, opts.branch); + prettyChanges(changes, specificItems, wsNameForFiles); } if (opts.dryRun) { log.info(colors.gray(`Dry run complete.`)); @@ -2184,16 +2283,16 @@ export async function pull( log.info(colors.gray(`Applying changes to files ...`)); for await (const change of changes) { - // Determine if this file should be written to a branch-specific path + // Determine if this file should be written to a workspace-specific path let targetPath = change.path; if (specificItems && isSpecificItem(change.path, specificItems)) { - const branchSpecificPath = getBranchSpecificPath( + const workspaceSpecificPath = getWorkspaceSpecificPath( change.path, specificItems, - opts.branch, + wsNameForFiles, ); - if (branchSpecificPath) { - targetPath = branchSpecificPath; + if (workspaceSpecificPath) { + targetPath = workspaceSpecificPath; } } @@ -2244,7 +2343,7 @@ export async function pull( log.info( `Editing script content of ${targetPath}${ targetPath !== change.path - ? colors.gray(` (branch-specific override for ${change.path})`) + ? colors.gray(` (workspace-specific override for ${change.path})`) : "" }`, ); @@ -2255,7 +2354,7 @@ export async function pull( log.info( `Editing ${getTypeStrFromPath(change.path)} ${targetPath}${ targetPath !== change.path - ? colors.gray(` (branch-specific override for ${change.path})`) + ? colors.gray(` (workspace-specific override for ${change.path})`) : "" }`, ); @@ -2273,7 +2372,7 @@ export async function pull( log.info( `Adding ${getTypeStrFromPath(change.path)} ${targetPath}${ targetPath !== change.path - ? colors.gray(` (branch-specific override for ${change.path})`) + ? colors.gray(` (workspace-specific override for ${change.path})`) : "" }`, ); @@ -2282,7 +2381,7 @@ export async function pull( log.info( `Writing ${getTypeStrFromPath(change.path)} ${targetPath}${ targetPath !== change.path - ? colors.gray(` (branch-specific override for ${change.path})`) + ? colors.gray(` (workspace-specific override for ${change.path})`) : "" }`, ); @@ -2396,11 +2495,11 @@ export async function pull( : {}), ...(specificItems && isSpecificItem(change.path, specificItems) ? { - branch_specific: true, - branch_specific_path: getBranchSpecificPath( + workspace_specific: true, + workspace_specific_path: getWorkspaceSpecificPath( change.path, specificItems, - opts.branch, + wsNameForFiles, ), } : {}), @@ -2429,18 +2528,18 @@ export async function pull( function prettyChanges(changes: Change[], specificItems?: SpecificItemsConfig, branchOverride?: string) { for (const change of changes) { let displayPath = change.path; - let branchNote = ""; + let wsNote = ""; - // Check if this will be written as a branch-specific file + // Check if this will be written as a workspace-specific file if (specificItems && isSpecificItem(change.path, specificItems)) { - const branchSpecificPath = getBranchSpecificPath( + const workspaceSpecificPath = getWorkspaceSpecificPath( change.path, specificItems, branchOverride, ); - if (branchSpecificPath) { - displayPath = branchSpecificPath; - branchNote = " (branch-specific)"; + if (workspaceSpecificPath) { + displayPath = workspaceSpecificPath; + wsNote = " (workspace-specific)"; } } @@ -2449,7 +2548,7 @@ function prettyChanges(changes: Change[], specificItems?: SpecificItemsConfig, b colors.green( `+ ${getTypeStrFromPath(change.path)} ` + displayPath + - colors.gray(branchNote), + colors.gray(wsNote), ), ); } else if (change.name === "deleted") { @@ -2457,7 +2556,7 @@ function prettyChanges(changes: Change[], specificItems?: SpecificItemsConfig, b colors.red( `- ${getTypeStrFromPath(change.path)} ` + displayPath + - colors.gray(branchNote), + colors.gray(wsNote), ), ); } else if (change.name === "edited") { @@ -2465,7 +2564,7 @@ function prettyChanges(changes: Change[], specificItems?: SpecificItemsConfig, b colors.yellow( `~ ${getTypeStrFromPath(change.path)} ` + displayPath + - colors.gray(branchNote) + + colors.gray(wsNote) + (change.codebase ? ` (codebase changed)` : ""), ), ); @@ -2534,9 +2633,24 @@ export async function push( opts.skipSecrets = false; } - // Validate branch configuration early (skipped when --branch is used) + // Resolve workspace name for config lookups (same logic as pull) + const hasExplicitCredentials = !!opts.baseUrl; + let wsNameForConfig: string | undefined; + + if (opts.branch) { + if (!hasExplicitCredentials && !branchDeprecationWarned) { + log.warn("⚠️ --branch/--env is deprecated. Use --workspace instead."); + branchDeprecationWarned = true; + } + wsNameForConfig = resolveWsNameFromBranch(opts, opts.branch); + } else if (opts.workspace && !hasExplicitCredentials) { + wsNameForConfig = opts.workspace; + warnWorkspaceOverride(opts, wsNameForConfig); + } + + // Validate workspace configuration early (skipped when override is used) try { - await validateBranchConfiguration(opts, opts.branch); + await validateBranchConfiguration(opts, wsNameForConfig); } catch (error) { if (error instanceof Error && error.message.includes("overrides")) { log.error(error.message); @@ -2545,19 +2659,27 @@ export async function push( throw error; } - const workspace = await resolveWorkspace(opts, opts.branch); + const workspace = await resolveWorkspace(opts, wsNameForConfig); await requireLogin(opts); - // Resolve effective sync options with branch awareness + // If wsNameForConfig wasn't set from flags, infer from the resolved profile + if (!wsNameForConfig) { + wsNameForConfig = inferWsNameFromProfile(opts, workspace); + } + + // Resolve effective sync options with workspace awareness const effectiveOpts = await resolveEffectiveSyncOptions( workspace, opts, opts.promotion, - opts.branch, + wsNameForConfig, ); - // Extract specific items configuration BEFORE merging overwrites gitBranches - const specificItems = getSpecificItemsForCurrentBranch(opts, opts.branch); + // Extract specific items configuration + const specificItems = getSpecificItemsForCurrentBranch(opts, wsNameForConfig); + + // Compute the workspace name for file naming + const wsNameForFiles = wsNameForConfig ? resolveWsNameForFiles(opts, wsNameForConfig) : undefined; // Merge CLI flags with resolved settings (CLI flags take precedence only for explicit overrides) opts = mergeCliWithEffectiveOptions(originalCliOpts, effectiveOpts); @@ -2659,7 +2781,7 @@ export async function push( codebases, false, specificItems, - opts.branch, + wsNameForFiles, false, // els1 (local) is not the remote source ); @@ -2828,10 +2950,10 @@ export async function push( } for (const folderName of folderNames) { const basePath = path.join("f", folderName, "folder.meta.yaml"); - const branchPath = getBranchSpecificPath( + const branchPath = getWorkspaceSpecificPath( `f/${folderName}/folder.meta.yaml`, specificItems, - opts.branch, + wsNameForFiles, ); let found = false; // Check branch-specific variant first (e.g. folder.dev.meta.yaml) @@ -2890,11 +3012,11 @@ export async function push( : {}), ...(specificItems && isSpecificItem(change.path, specificItems) ? { - branch_specific: true, - branch_specific_path: getBranchSpecificPath( + workspace_specific: true, + workspace_specific_path: getWorkspaceSpecificPath( change.path, specificItems, - opts.branch, + wsNameForFiles, ), } : {}), @@ -2907,7 +3029,7 @@ export async function push( if (changes.length > 0) { if (!opts.jsonOutput) { - prettyChanges(changes, specificItems, opts.branch); + prettyChanges(changes, specificItems, wsNameForFiles); } if (opts.dryRun) { @@ -2967,7 +3089,7 @@ export async function push( const pool = new Set(); const queue = [...groupedChangesArray]; // Cache git branch at the start to avoid repeated execSync calls per change - const cachedBranchForPush = opts.branch || (isGitRepository() ? getCurrentGitBranch() : null); + const cachedWsNameForPush = wsNameForFiles || (isGitRepository() ? getCurrentGitBranch() : null); while (queue.length > 0 || pool.size > 0) { // Fill the pool until we reach parallelizationFactor @@ -3066,12 +3188,12 @@ 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 + // This ensures workspace-specific files are stored with their base names in the workspace let serverPath = resourceFilePath; - const currentBranch = cachedBranchForPush; + const currentBranch = cachedWsNameForPush; - if (currentBranch && isBranchSpecificFile(resourceFilePath)) { - serverPath = fromBranchSpecificPath( + if (currentBranch && isWorkspaceSpecificFile(resourceFilePath)) { + serverPath = fromWorkspaceSpecificPath( resourceFilePath, currentBranch, ); @@ -3101,10 +3223,10 @@ export async function push( ); let serverPath = resourceFilePath; - const currentBranch = cachedBranchForPush; + const currentBranch = cachedWsNameForPush; - if (currentBranch && isBranchSpecificFile(resourceFilePath)) { - serverPath = fromBranchSpecificPath( + if (currentBranch && isWorkspaceSpecificFile(resourceFilePath)) { + serverPath = fromWorkspaceSpecificPath( resourceFilePath, currentBranch, ); @@ -3126,13 +3248,13 @@ export async function push( const oldObj = parseFromPath(change.path, change.before); const newObj = parseFromPath(change.path, change.after); - // Check if this is a branch-specific item and get the original branch-specific path - let originalBranchSpecificPath: string | undefined; + // Check if this is a branch-specific item and get the original workspace-specific path + let originalWorkspaceSpecificPath: string | undefined; if (specificItems && isSpecificItem(change.path, specificItems)) { - originalBranchSpecificPath = getBranchSpecificPath( + originalWorkspaceSpecificPath = getWorkspaceSpecificPath( change.path, specificItems, - opts.branch, + wsNameForFiles, ); } @@ -3144,7 +3266,7 @@ export async function push( opts.plainSecrets ?? false, alreadySynced, opts.message, - originalBranchSpecificPath, + originalWorkspaceSpecificPath, ); if (stateTarget) { @@ -3192,16 +3314,16 @@ export async function push( const obj = parseFromPath(change.path, change.content); // Determine the actual local file path for this change - // For branch-specific items, we read from branch-specific files but push to base server paths + // For branch-specific items, we read from workspace-specific files but push to base server paths let localFilePath = change.path; if (specificItems && isSpecificItem(change.path, specificItems)) { - const branchSpecificPath = getBranchSpecificPath( + const workspaceSpecificPath = getWorkspaceSpecificPath( change.path, specificItems, - opts.branch, + wsNameForFiles, ); - if (branchSpecificPath) { - localFilePath = branchSpecificPath; + if (workspaceSpecificPath) { + localFilePath = workspaceSpecificPath; } } @@ -3588,11 +3710,11 @@ export async function push( : {}), ...(specificItems && isSpecificItem(change.path, specificItems) ? { - branch_specific: true, - branch_specific_path: getBranchSpecificPath( + workspace_specific: true, + workspace_specific_path: getWorkspaceSpecificPath( change.path, specificItems, - opts.branch, + wsNameForFiles, ), } : {}), @@ -3685,7 +3807,7 @@ const command = new Command() ) .option( "--branch, --env ", - "Override the current git branch/environment (works even outside a git repository)", + "[Deprecated: use --workspace] Override the current git branch/environment", ) .action(pull as any) .command("push") @@ -3742,7 +3864,7 @@ const command = new Command() ) .option( "--branch, --env ", - "Override the current git branch/environment (works even outside a git repository)", + "[Deprecated: use --workspace] Override the current git branch/environment", ) .option("--lint", "Run lint validation before pushing") .option( diff --git a/cli/src/commands/trigger/trigger.ts b/cli/src/commands/trigger/trigger.ts index e8a310a102..79132f7842 100644 --- a/cli/src/commands/trigger/trigger.ts +++ b/cli/src/commands/trigger/trigger.ts @@ -31,8 +31,8 @@ import { extractNativeTriggerInfo, } from "../../types.ts"; import { - fromBranchSpecificPath, - isBranchSpecificFile, + fromWorkspaceSpecificPath, + isWorkspaceSpecificFile, } from "../../core/specific_items.ts"; import { getCurrentGitBranch } from "../../utils/git.ts"; import { requireLogin } from "../../core/auth.ts"; @@ -553,10 +553,10 @@ function extractTriggerKindFromPath(filePath: string): string | undefined { let pathToAnalyze = filePath; // If this is a branch-specific file, convert it to the base path first - if (isBranchSpecificFile(filePath)) { + if (isWorkspaceSpecificFile(filePath)) { const currentBranch = getCurrentGitBranch(); if (currentBranch) { - pathToAnalyze = fromBranchSpecificPath(filePath, currentBranch); + pathToAnalyze = fromWorkspaceSpecificPath(filePath, currentBranch); } } diff --git a/cli/src/commands/workspace/fork.ts b/cli/src/commands/workspace/fork.ts index 21b5cf5a26..bc6cf57fe1 100644 --- a/cli/src/commands/workspace/fork.ts +++ b/cli/src/commands/workspace/fork.ts @@ -249,7 +249,7 @@ async function createWorkspaceFork( \t`+colors.white(`git checkout -b ${newBranchName}`) + ` -When doing operations on the forked workspace, it will use the remote setup in gitBranches for the branch it was forked from. +When doing operations on the forked workspace, it will use the remote setup in the workspaces section for the branch it was forked from. To merge changes back to the parent workspace, you can: - Use the CLI: ` + colors.white(`git checkout ${newBranchName} && wmill workspace merge`) + ` @@ -289,7 +289,7 @@ async function deleteWorkspaceFork( const parentWorkspace = await tryResolveBranchWorkspace(opts); if (!parentWorkspace) { throw new Error( - "Could not resolve parent workspace. Make sure you are in a git repo with gitBranches configured in wmill.yaml, or create a local workspace profile for the fork.", + "Could not resolve parent workspace. Make sure you are in a git repo with 'workspaces' configured in wmill.yaml, or create a local workspace profile for the fork.", ); } forkWorkspaceId = name.startsWith(`${WM_FORK_PREFIX}-`) ? name : `${WM_FORK_PREFIX}-${name}`; diff --git a/cli/src/commands/workspace/merge.ts b/cli/src/commands/workspace/merge.ts index c2b6532254..73e77efb07 100644 --- a/cli/src/commands/workspace/merge.ts +++ b/cli/src/commands/workspace/merge.ts @@ -78,7 +78,7 @@ async function mergeWorkspaces( const workspace = await tryResolveBranchWorkspace(opts); if (!workspace) { throw new Error( - "Could not resolve workspace from branch name. Make sure you are in a git repo with gitBranches configured." + "Could not resolve workspace from branch name. Make sure you are in a git repo with 'workspaces' configured in wmill.yaml." ); } diff --git a/cli/src/commands/workspace/workspace.ts b/cli/src/commands/workspace/workspace.ts index 6cd0eeb3fd..7683e8b740 100644 --- a/cli/src/commands/workspace/workspace.ts +++ b/cli/src/commands/workspace/workspace.ts @@ -519,81 +519,194 @@ export async function getActiveWorkspaceOrFallback(opts: GlobalOptions) { return activeWorkspace; } async function bind( - opts: GlobalOptions & { branch?: string }, - bindWorkspace?: boolean + opts: GlobalOptions & { workspace?: string; branch?: string }, + doBind: boolean ) { const { isGitRepository, getCurrentGitBranch } = await import( "../../utils/git.ts" ); - - if (!isGitRepository()) { - log.error(colors.red("Not in a Git repository")); - return; - } - - const branch = opts.branch || getCurrentGitBranch(); - if (!branch) { - log.error(colors.red("Could not determine current Git branch")); - return; - } - - const { readConfigFile } = await import("../../core/conf.ts"); + const { readConfigFile, findWorkspaceByGitBranch, getWorkspaceNames } = await import("../../core/conf.ts"); + const { stringify: yamlStringify } = await import("yaml"); const config = await readConfigFile(); - const activeWorkspace = await getActiveWorkspaceOrFallback(opts); - if (!activeWorkspace && bindWorkspace) { - log.error( - colors.red( - "No active workspace. Use 'wmill workspace add' or 'wmill workspace switch' first" - ) - ); - return; + if (!config.workspaces) { + config.workspaces = {} as any; } - // For unbind, check if branch exists - if (!bindWorkspace && (!config.gitBranches || !config.gitBranches[branch])) { - log.error( - colors.red(`Branch '${branch}' not found in wmill.yaml gitBranches`) - ); - return; - } + const isInteractive = !!process.stdin.isTTY; + const inGitRepo = isGitRepository(); + const currentBranch = inGitRepo ? getCurrentGitBranch() : null; - // Update the branch configuration with workspace binding - if (!config.gitBranches) { - config.gitBranches = {}; - } - if (!config.gitBranches[branch]) { - config.gitBranches[branch] = { overrides: {} }; - } + if (doBind) { + // ── BIND ────────────────────────────────────────────────── - if (bindWorkspace && activeWorkspace) { - config.gitBranches[branch].baseUrl = activeWorkspace.remote; - config.gitBranches[branch].workspaceId = activeWorkspace.workspaceId; + // Step 1: Pick workspace profile (the source of baseUrl/workspaceId/token) + const profiles = await allWorkspaces(opts.configDir); + let selectedProfile: Workspace | undefined; + + if (profiles.length === 0) { + // No profiles — run wmill workspace add flow + log.info(colors.yellow("No workspace profiles found. Let's create one.")); + await add(opts as any, undefined, undefined, undefined); + // Re-read profiles after add + const updatedProfiles = await allWorkspaces(opts.configDir); + selectedProfile = updatedProfiles.length > 0 + ? await getActiveWorkspace(opts) + : undefined; + if (!selectedProfile) { + log.error(colors.red("Profile creation failed or was cancelled.")); + return; + } + } else if (opts.workspace && profiles.find((p) => p.name === opts.workspace)) { + // --workspace flag matches a profile name: use it directly + selectedProfile = profiles.find((p) => p.name === opts.workspace); + } else if (isInteractive) { + const { Select } = await import("@cliffy/prompt/select"); + const activeProfile = await getActiveWorkspace(opts); + const selectedName = await Select.prompt({ + message: "Select workspace profile to bind", + options: profiles.map((p) => ({ + name: `${p.name} (${p.workspaceId} on ${p.remote})`, + value: p.name, + })), + default: activeProfile?.name, + }); + selectedProfile = profiles.find((p) => p.name === selectedName); + } else { + // Non-interactive: use active profile + selectedProfile = await getActiveWorkspaceOrFallback(opts); + } + + if (!selectedProfile) { + log.error(colors.red("No workspace profile selected. Aborting.")); + return; + } + + // Step 2: Pick workspace name (the key in wmill.yaml workspaces section) + let wsName: string; + if (opts.workspace) { + wsName = opts.workspace; + } else if (isInteractive) { + const { Input } = await import("@cliffy/prompt/input"); + wsName = await Input.prompt({ + message: "Workspace name (key in wmill.yaml)", + default: selectedProfile.workspaceId, + }); + } else { + wsName = selectedProfile.workspaceId; + } + + // Step 3: Pick git branch (only in git repos) + let gitBranch: string | undefined; + if (inGitRepo) { + if (opts.branch) { + if (opts.branch !== wsName) { + gitBranch = opts.branch; + } + } else if (isInteractive) { + const { Input } = await import("@cliffy/prompt/input"); + const branchInput = await Input.prompt({ + message: "Git branch to associate", + default: currentBranch ?? wsName, + }); + if (branchInput !== wsName) { + gitBranch = branchInput; + } + } else if (currentBranch && currentBranch !== wsName) { + gitBranch = currentBranch; + } + } + + // Step 4: Write the entry + const entry = (config.workspaces as any)[wsName] ?? {}; + entry.baseUrl = selectedProfile.remote; + if (selectedProfile.workspaceId !== wsName) { + entry.workspaceId = selectedProfile.workspaceId; + } else { + delete entry.workspaceId; // clean up if it matches + } + if (gitBranch) { + entry.gitBranch = gitBranch; + } else { + delete entry.gitBranch; // clean up if it matches + } + (config.workspaces as any)[wsName] = entry; log.info( colors.green( - `✓ Bound branch '${branch}' to workspace '${activeWorkspace.name}'\n` + - ` ${activeWorkspace.workspaceId} on ${activeWorkspace.remote}` + `✓ Bound workspace '${wsName}'` + + (gitBranch ? ` (gitBranch: ${gitBranch})` : "") + + ` → ${selectedProfile.workspaceId} on ${selectedProfile.remote}` ) ); } else { - // Unbind - delete config.gitBranches[branch].baseUrl; - delete config.gitBranches[branch].workspaceId; + // ── UNBIND ──────────────────────────────────────────────── + let wsName: string | undefined; - log.info( - colors.green(`✓ Removed workspace binding from branch '${branch}'`) - ); + if (opts.workspace) { + wsName = opts.workspace; + } else if (currentBranch) { + const match = findWorkspaceByGitBranch(config.workspaces, currentBranch); + wsName = match?.[0]; + } + + if (!wsName && isInteractive) { + const names = getWorkspaceNames(config.workspaces); + if (names.length === 0) { + log.error(colors.red("No workspaces configured in wmill.yaml.")); + return; + } + const { Select } = await import("@cliffy/prompt/select"); + wsName = await Select.prompt({ + message: "Select workspace to unbind", + options: names, + }); + } + + if (!wsName || !(config.workspaces as any)[wsName]) { + log.error(colors.red( + wsName + ? `Workspace '${wsName}' not found in wmill.yaml.` + : "Could not determine workspace. Use --workspace to specify." + )); + return; + } + + const entry = (config.workspaces as any)[wsName]; + delete entry.baseUrl; + delete entry.workspaceId; + log.info(colors.green(`✓ Removed binding from workspace '${wsName}'`)); } - // Write back the updated config - const { stringify: yamlStringify } = await import("yaml"); try { await writeFile("wmill.yaml", yamlStringify(config), "utf-8"); } catch (error) { log.error(colors.red(`Failed to save configuration: ${(error as Error).message}`)); return; } + + // After a successful bind, offer to generate resource type namespace + if (doBind && isInteractive) { + const { stat: statFile } = await import("node:fs/promises"); + const rtExists = await statFile("rt.d.ts").then(() => true, () => false); + const { Confirm } = await import("@cliffy/prompt/confirm"); + const generate = await Confirm.prompt({ + message: "Generate rt.d.ts? (TypeScript types for your workspace's resource types, useful for autocompletion)", + default: !rtExists, + }); + if (generate) { + try { + const { generateRTNamespace } = await import("../resource-type/resource-type.ts"); + await generateRTNamespace(opts); + } catch (error) { + log.warn( + `Could not generate resource type namespace: ${ + error instanceof Error ? error.message : error + }` + ); + } + } + } } const command = new Command() @@ -638,12 +751,13 @@ const command = new Command() .description("List forked workspaces on the remote server") .action(listForks as any) .command("bind") - .description("Bind the current Git branch to the active workspace. This adds the branch to gitBranches in wmill.yaml so sync operations use the correct workspace for each branch.") - .option("--branch, --env ", "Specify branch/environment (defaults to current)") + .description("Create or update a workspace entry in wmill.yaml from the active profile") + .option("--workspace ", "Workspace name (default: current branch or workspaceId)") + .option("--branch ", "Git branch to associate (default: workspace name)") .action((opts) => bind(opts as any, true)) .command("unbind") - .description("Remove workspace binding from the current Git branch") - .option("--branch, --env ", "Specify branch/environment (defaults to current)") + .description("Remove baseUrl and workspaceId from a workspace entry") + .option("--workspace ", "Workspace to unbind") .action((opts) => bind(opts as any, false)) .command("fork") .description("Create a forked workspace") diff --git a/cli/src/core/conf.ts b/cli/src/core/conf.ts index 826b207323..bebef5cb00 100644 --- a/cli/src/core/conf.ts +++ b/cli/src/core/conf.ts @@ -18,6 +18,43 @@ export function setShowDiffs(value: boolean) { showDiffs = value; } +export interface SpecificItemsConfig_Yaml { + variables?: string[]; + resources?: string[]; + triggers?: string[]; + schedules?: string[]; + folders?: string[]; + settings?: boolean; +} + +export interface WorkspaceEntryConfig extends SyncOptions { + gitBranch?: string; + workspaceId?: string; + baseUrl?: string; + overrides?: Partial; + promotionOverrides?: Partial; + specificItems?: SpecificItemsConfig_Yaml; +} + +export type WorkspacesConfig = { + commonSpecificItems?: SpecificItemsConfig_Yaml; +} & { + [workspaceName: string]: WorkspaceEntryConfig; +}; + +// Legacy type alias for backward compat +type LegacyBranchesConfig = { + commonSpecificItems?: SpecificItemsConfig_Yaml; +} & { + [branchName: string]: SyncOptions & { + overrides?: Partial; + promotionOverrides?: Partial; + baseUrl?: string; + workspaceId?: string; + specificItems?: SpecificItemsConfig_Yaml; + }; +}; + export interface SyncOptions { stateful?: boolean; raw?: boolean; @@ -52,59 +89,12 @@ export interface SyncOptions { parallel?: number; jsonOutput?: boolean; nonDottedPaths?: boolean; - gitBranches?: { - commonSpecificItems?: { - variables?: string[]; - resources?: string[]; - triggers?: string[]; - schedules?: string[]; - folders?: string[]; - settings?: boolean; - }; - } & { - [branchName: string]: SyncOptions & { - overrides?: Partial; - promotionOverrides?: Partial; - baseUrl?: string; - workspaceId?: string; - specificItems?: { - variables?: string[]; - resources?: string[]; - triggers?: string[]; - schedules?: string[]; - folders?: string[]; - settings?: boolean; - }; - }; - }; - // Alias for gitBranches - for users who prefer environment-based terminology - environments?: SyncOptions["gitBranches"]; - // Legacy field - deprecated, use gitBranches instead - git_branches?: { - commonSpecificItems?: { - variables?: string[]; - resources?: string[]; - triggers?: string[]; - schedules?: string[]; - folders?: string[]; - settings?: boolean; - }; - } & { - [branchName: string]: SyncOptions & { - overrides?: Partial; - promotionOverrides?: Partial; - baseUrl?: string; - workspaceId?: string; - specificItems?: { - variables?: string[]; - resources?: string[]; - triggers?: string[]; - schedules?: string[]; - folders?: string[]; - settings?: boolean; - }; - }; - }; + // Primary config key — maps workspace names to workspace configurations + workspaces?: WorkspacesConfig; + // Deprecated aliases — normalized to `workspaces` in readConfigFile + gitBranches?: LegacyBranchesConfig; + environments?: LegacyBranchesConfig; + git_branches?: LegacyBranchesConfig; promotion?: string; lint?: boolean; locksRequired?: boolean; @@ -195,6 +185,8 @@ export function getWmillYamlPath(): string | null { return findWmillYaml(); } +let legacyConfigWarned = false; + export async function readConfigFile(opts?: { warnIfMissing?: boolean }): Promise { const warnIfMissing = opts?.warnIfMissing ?? true; try { @@ -212,10 +204,6 @@ export async function readConfigFile(opts?: { warnIfMissing?: boolean }): Promis const conf = (await yamlParseFile(wmillYamlPath)) as SyncOptions; - // Handle legacy format migrations (combine overrides and git_branches) - let needsConfigWrite = false; - const migrationMessages: string[] = []; - // Handle obsolete overrides format if (conf && "overrides" in conf) { const overrides = conf.overrides as any; @@ -227,81 +215,55 @@ export async function readConfigFile(opts?: { warnIfMissing?: boolean }): Promis if (hasSettings) { throw new Error( "❌ The 'overrides' field is no longer supported.\n" + - " The configuration system now uses Git branch-based configuration only.\n" + + " The configuration system now uses workspace-based configuration.\n" + " Please delete your wmill.yaml and run 'wmill init' to recreate it with the new format." ); } else { - // Remove empty overrides delete conf.overrides; - needsConfigWrite = true; - migrationMessages.push( - "ℹ️ Removing empty 'overrides: {}' from wmill.yaml (migrated to gitBranches format)" - ); } } - // Handle environments -> gitBranches alias (permanent alias, not a deprecation) - if (conf && "environments" in conf) { - if (!conf.gitBranches) { - conf.gitBranches = conf.environments as any; - } else { - log.warn( - "⚠️ Both 'environments' and 'gitBranches' found in wmill.yaml. Using 'gitBranches' and ignoring 'environments'." - ); - } - delete (conf as any).environments; - } + // Normalize legacy formats to `workspaces` in memory (no file rewrite). + // Priority: workspaces > gitBranches > environments > git_branches + if (conf && !conf.workspaces) { + let legacyKey: string | null = null; + let legacyData: LegacyBranchesConfig | undefined; - // Handle git_branches to gitBranches migration - if (conf && "git_branches" in conf) { - if (!conf.gitBranches) { - // Deep copy git_branches to gitBranches (even if empty) - conf.gitBranches = JSON.parse(JSON.stringify(conf.git_branches)); - needsConfigWrite = true; - migrationMessages.push( - "⚠️ Migrating 'git_branches' to 'gitBranches' (camelCase). The snake_case format is deprecated." - ); - migrationMessages.push( - "✅ Successfully migrated 'git_branches' to 'gitBranches' in wmill.yaml" - ); - } else { - migrationMessages.push( - "⚠️ Both 'git_branches' and 'gitBranches' found in wmill.yaml. Using 'gitBranches' and ignoring 'git_branches'." - ); + if (conf.gitBranches) { + legacyKey = "gitBranches"; + legacyData = conf.gitBranches; + } else if (conf.environments) { + legacyKey = "environments"; + legacyData = conf.environments; + } else if (conf.git_branches) { + legacyKey = "git_branches"; + legacyData = conf.git_branches; } - // Always remove the old field from config object (both file and memory) - delete conf.git_branches; - } - // Perform single atomic write if any migrations are needed - if (needsConfigWrite) { - try { - await writeFile(wmillYamlPath, yamlStringify(conf), "utf-8"); - // Log all migration messages after successful write - migrationMessages.forEach((msg) => { - if (msg.startsWith("⚠️")) { - log.warn(msg); - } else { - log.info(msg); - } - }); - } catch (error) { - log.warn( - `Could not update wmill.yaml to apply migrations: ${ - error instanceof Error ? error.message : error - }` - ); - } - } else if (migrationMessages.length > 0) { - // Log messages for non-write cases (like "both found") - migrationMessages.forEach((msg) => { - if (msg.startsWith("⚠️")) { - log.warn(msg); - } else { - log.info(msg); + if (legacyKey && legacyData) { + conf.workspaces = convertGitBranchesToWorkspaces(legacyData); + if (!legacyConfigWarned) { + log.warn( + `⚠️ '${legacyKey}' in wmill.yaml is deprecated. Use 'workspaces' instead.\n` + + ` Run 'wmill config migrate' to update your configuration automatically.` + ); + legacyConfigWarned = true; } - }); + } + } else if (conf?.workspaces) { + // If both workspaces and any legacy key exist, warn and use workspaces + for (const legacyKey of ["gitBranches", "environments", "git_branches"] as const) { + if ((conf as any)[legacyKey]) { + log.warn( + `⚠️ Both 'workspaces' and '${legacyKey}' found in wmill.yaml. Using 'workspaces' and ignoring '${legacyKey}'.` + ); + } + } } + // Clean legacy keys from in-memory config + delete conf?.gitBranches; + delete (conf as any)?.environments; + delete conf?.git_branches; if (conf?.defaultTs == undefined) { log.warn( @@ -403,18 +365,18 @@ export async function mergeConfigWithConfigFile( return Object.assign(configFile ?? {}, opts); } -// Validate branch configuration early in the process +// Validate workspace configuration early in the process export async function validateBranchConfiguration( opts: Pick, - branchOverride?: string + workspaceNameOverride?: string ): Promise { - // When branch override is provided, skip validation - user is explicitly specifying the branch - if (opts.skipBranchValidation || branchOverride || !isGitRepository()) { + // When workspace override is provided, skip validation - user is explicitly specifying the workspace + if (opts.skipBranchValidation || workspaceNameOverride || !isGitRepository()) { return; } const config = await readConfigFile(); - const { gitBranches } = config; + const { workspaces } = config; const rawBranch = getCurrentGitBranch(); @@ -423,37 +385,66 @@ export async function validateBranchConfiguration( let currentBranch: string | null; if (originalBranchIfForked) { log.info( - `Workspace fork detected from branch name \`${rawBranch}\`. Validating branch configuration using original branch \`${originalBranchIfForked}\`` + `Workspace fork detected from branch name \`${rawBranch}\`. Validating workspace configuration using original branch \`${originalBranchIfForked}\`` ); currentBranch = originalBranchIfForked; } else { currentBranch = rawBranch; } - // In a git repository, gitBranches section is recommended - if (!gitBranches || Object.keys(gitBranches).length === 0) { + // In a git repository, workspaces section is recommended + if (!workspaces || getWorkspaceNames(workspaces).length === 0) { log.warn( - "⚠️ WARNING: In a Git repository, the 'gitBranches' section is recommended in wmill.yaml.\n" + - " Consider adding a gitBranches section with configuration for your Git branches.\n" + - " Run 'wmill init' to recreate the configuration file with proper branch setup." + "⚠️ WARNING: In a Git repository, the 'workspaces' section is recommended in wmill.yaml.\n" + + " Consider adding a workspaces section to map workspace names to Windmill instances.\n" + + " Run 'wmill init' to recreate the configuration file with proper workspace setup." ); return; } - // Current branch must be defined in gitBranches config - if (currentBranch && !gitBranches[currentBranch]) { - // In interactive mode, offer to create the branch + // Warn if multiple workspaces map to the same git branch + const branchToWsNames = new Map(); + for (const name of getWorkspaceNames(workspaces)) { + const entry = workspaces[name] as WorkspaceEntryConfig; + const branch = getEffectiveGitBranch(name, entry); + const existing = branchToWsNames.get(branch); + if (existing) { + existing.push(name); + } else { + branchToWsNames.set(branch, [name]); + } + } + for (const [branch, names] of branchToWsNames) { + if (names.length > 1) { + log.warn( + `⚠️ WARNING: Multiple workspaces map to git branch '${branch}': ${names.join(", ")}.\n` + + ` Only the first ('${names[0]}') will be used during auto-detection. Use --workspace to select explicitly.` + ); + } + } + + // Current branch must match a configured workspace's gitBranch + if (currentBranch && !findWorkspaceByGitBranch(workspaces, currentBranch)) { + const wsNames = getWorkspaceNames(workspaces); + const availableInfo = wsNames + .map((n) => { + const entry = workspaces[n] as WorkspaceEntryConfig; + const branch = getEffectiveGitBranch(n, entry); + return branch !== n ? `${n} (gitBranch: ${branch})` : n; + }) + .join(", "); + + // In interactive mode, offer to create a workspace entry if (!!process.stdin.isTTY) { - const availableBranches = Object.keys(gitBranches).join(", "); log.info( - `Current Git branch '${currentBranch}' is not defined in the gitBranches configuration.\n` + - `Available branches: ${availableBranches}` + `Current Git branch '${currentBranch}' does not match any workspace in the configuration.\n` + + `Available workspaces: ${availableInfo}` ); const shouldCreate = opts.yes || (await Confirm.prompt({ - message: `Create empty branch configuration for '${currentBranch}'?`, + message: `Create empty workspace configuration for branch '${currentBranch}'?`, default: true, })); @@ -475,22 +466,22 @@ export async function validateBranchConfiguration( ); } - // Read current config, add branch, and write it back + // Read current config, add workspace entry, and write it back const currentConfig = await readConfigFile(); - if (!currentConfig.gitBranches) { - currentConfig.gitBranches = {}; + if (!currentConfig.workspaces) { + currentConfig.workspaces = {} as WorkspacesConfig; } - currentConfig.gitBranches[currentBranch] = { overrides: {} }; + (currentConfig.workspaces as any)[currentBranch] = {}; await writeFile("wmill.yaml", yamlStringify(currentConfig), "utf-8"); log.info( - `✅ Created empty branch configuration for '${currentBranch}'` + `✅ Created empty workspace configuration for '${currentBranch}'` ); } else { log.warn( - "⚠️ WARNING: Branch creation cancelled. You can manually add the branch to wmill.yaml or use 'wmill gitsync-settings pull' to pull configuration from an existing windmill workspace git-sync configuration." + "⚠️ WARNING: Workspace creation cancelled. You can manually add a workspace to the 'workspaces' section in wmill.yaml or use 'wmill gitsync-settings pull' to pull configuration from an existing windmill workspace git-sync configuration." ); return; } @@ -510,95 +501,171 @@ export async function validateBranchConfiguration( } log.warn( - `⚠️ WARNING: Current Git branch '${currentBranch}' is not defined in the gitBranches configuration.\n` + - ` Consider adding configuration for branch '${currentBranch}' in the gitBranches section of wmill.yaml.\n` + - ` Available branches: ${Object.keys(gitBranches).join(", ")}` + `⚠️ WARNING: Current Git branch '${currentBranch}' does not match any workspace in the configuration.\n` + + ` Consider adding a workspace entry for branch '${currentBranch}' in the 'workspaces' section of wmill.yaml.\n` + + ` Available workspaces: ${availableInfo}` ); return; } } } -// Get effective settings by merging top-level settings with branch-specific overrides +// Get effective settings by merging top-level settings with workspace-specific overrides. +// workspaceNameOverride selects a workspace by name directly. +// When not provided, auto-detects from the current git branch. export async function getEffectiveSettings( config: SyncOptions, promotion?: string, skipBranchValidation?: boolean, suppressLogs?: boolean, - branchOverride?: string + workspaceNameOverride?: string ): Promise { // Start with top-level settings from config - const { gitBranches, ...topLevelSettings } = config; + const { workspaces, ...topLevelSettings } = config; const effective = { ...topLevelSettings }; - // Determine the branch to use: branchOverride takes precedence, then git detection - let currentBranch: string | null = null; + // Resolve the workspace entry to use + let resolvedWsName: string | null = null; + let resolvedWsEntry: WorkspaceEntryConfig | null = null; let originalBranchIfForked: string | null = null; let rawGitBranch: string | null = null; - if (branchOverride) { - currentBranch = branchOverride; - // Note: "Using branch override" is logged in context.ts when resolving workspace + if (workspaceNameOverride) { + // Direct lookup by workspace name + if (workspaces && (workspaces as any)[workspaceNameOverride]) { + resolvedWsName = workspaceNameOverride; + resolvedWsEntry = (workspaces as any)[workspaceNameOverride] as WorkspaceEntryConfig; + } } else if (isGitRepository()) { rawGitBranch = getCurrentGitBranch(); originalBranchIfForked = getOriginalBranchForWorkspaceForks(rawGitBranch); + const branch = originalBranchIfForked ?? rawGitBranch; if (originalBranchIfForked) { log.info( `Using overrides from original branch \`${originalBranchIfForked}\`` ); - currentBranch = originalBranchIfForked; - } else { - currentBranch = rawGitBranch; + } + + if (branch) { + const match = findWorkspaceByGitBranch(workspaces, branch); + if (match) { + [resolvedWsName, resolvedWsEntry] = match; + } } } else { - log.debug("Not in a Git repository and no branch override provided, using top-level settings"); + log.debug("Not in a Git repository and no workspace 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]; + if (promotion && workspaces) { + const promotionMatch = findWorkspaceByGitBranch(workspaces, promotion); + if (promotionMatch) { + const [, targetWs] = promotionMatch; - // 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)` + // First try promotionOverrides, then fall back to overrides + if (targetWs.promotionOverrides) { + Object.assign(effective, targetWs.promotionOverrides); + if (!suppressLogs) { + log.info(`Applied promotion settings from workspace for branch: ${promotion}`); + } + } else if (targetWs.overrides) { + Object.assign(effective, targetWs.overrides); + if (!suppressLogs) { + log.info( + `Applied settings from workspace for branch: ${promotion} (no promotionOverrides found)` + ); + } + } else { + log.debug( + `No promotion or regular overrides found for '${promotion}', using top-level settings` ); } - } 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); + // Otherwise use resolved workspace's overrides + else if (resolvedWsEntry?.overrides) { + Object.assign(effective, resolvedWsEntry.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}` + `Applied settings for workspace '${resolvedWsName}'${extraLog}` ); } - } else if (currentBranch) { + } else if (resolvedWsName) { log.debug( - `No branch-specific overrides found for '${currentBranch}', using top-level settings` + `No overrides found for workspace '${resolvedWsName}', using top-level settings` ); } return effective; } + +const RESERVED_WORKSPACE_KEYS = new Set(["commonSpecificItems"]); + +/** + * Find a workspace config entry whose effective git branch matches branchName. + * Returns [workspaceName, config] or undefined. + */ +export function findWorkspaceByGitBranch( + workspaces: WorkspacesConfig | undefined, + branchName: string +): [string, WorkspaceEntryConfig] | undefined { + if (!workspaces) return undefined; + for (const [name, entry] of Object.entries(workspaces)) { + if (RESERVED_WORKSPACE_KEYS.has(name)) continue; + const effectiveBranch = (entry as WorkspaceEntryConfig).gitBranch ?? name; + if (effectiveBranch === branchName) { + return [name, entry as WorkspaceEntryConfig]; + } + } + return undefined; +} + +/** Get the effective workspaceId for a workspace entry (defaults to workspace name). */ +export function getEffectiveWorkspaceId( + workspaceName: string, + config: WorkspaceEntryConfig +): string { + return config.workspaceId ?? workspaceName; +} + +/** Get the effective git branch for a workspace entry (defaults to workspace name). */ +export function getEffectiveGitBranch( + workspaceName: string, + config: WorkspaceEntryConfig +): string { + return config.gitBranch ?? workspaceName; +} + +/** Get all workspace names from config, excluding reserved keys like commonSpecificItems. */ +export function getWorkspaceNames( + workspaces: WorkspacesConfig | undefined +): string[] { + if (!workspaces) return []; + return Object.keys(workspaces).filter((k) => !RESERVED_WORKSPACE_KEYS.has(k)); +} + +/** + * Convert legacy gitBranches/environments format to the new workspaces format. + * Since old keys were branch names, workspace name = branch name. + * gitBranch is not set (defaults to key name). All existing fields are preserved. + */ +export function convertGitBranchesToWorkspaces( + gitBranches: LegacyBranchesConfig +): WorkspacesConfig { + const workspaces: Record = {}; + for (const [key, value] of Object.entries(gitBranches)) { + if (key === "commonSpecificItems") { + workspaces.commonSpecificItems = value; + continue; + } + // Copy the entire entry as-is. Old format keys = branch names, so gitBranch + // doesn't need to be set (defaults to key name). All SyncOptions fields, + // overrides, promotionOverrides, specificItems, baseUrl, workspaceId are preserved. + workspaces[key] = { ...value }; + } + return workspaces as WorkspacesConfig; +} diff --git a/cli/src/core/context.ts b/cli/src/core/context.ts index b65cdcab49..dc822e5b98 100644 --- a/cli/src/core/context.ts +++ b/cli/src/core/context.ts @@ -17,7 +17,12 @@ import { addWorkspace, } from "../commands/workspace/workspace.ts"; import { getLastUsedProfile, setLastUsedProfile } from "./branch-profiles.ts"; -import { readConfigFile } from "./conf.ts"; +import { + readConfigFile, + findWorkspaceByGitBranch, + getEffectiveWorkspaceId, + WorkspaceEntryConfig, +} from "./conf.ts"; import { getCurrentGitBranch, getOriginalBranchForWorkspaceForks, @@ -207,11 +212,71 @@ async function tryResolveWorkspace( if (cache) return { isError: false, value: cache }; if (opts.workspace) { + // First try: look up workspace by name in wmill.yaml workspaces config + const config = await readConfigFile({ warnIfMissing: false }); + const wsEntry = config.workspaces?.[opts.workspace] as WorkspaceEntryConfig | undefined; + if (wsEntry?.baseUrl) { + const workspaceId = getEffectiveWorkspaceId(opts.workspace, wsEntry); + let normalizedBaseUrl: string; + try { + normalizedBaseUrl = new URL(wsEntry.baseUrl).toString(); + } catch { + return { + isError: true, + error: colors.red.underline(`Invalid baseUrl in workspace '${opts.workspace}' configuration: ${wsEntry.baseUrl}`), + }; + } + + // Find matching profile by baseUrl + workspaceId + const allProfs = await allWorkspaces(opts.configDir); + const matching = allProfs.filter( + (w) => w.remote === normalizedBaseUrl && w.workspaceId === workspaceId + ); + + if (matching.length >= 1) { + const selected = matching.length === 1 + ? matching[0] + : await selectFromMultipleProfiles( + matching, + normalizedBaseUrl, + workspaceId, + `workspace '${opts.workspace}'`, + opts.configDir + ); + log.info( + colors.green( + `Using workspace profile '${selected.name}' for workspace '${opts.workspace}' (${workspaceId} on ${normalizedBaseUrl})` + ) + ); + (opts as any).__secret_workspace = selected; + return { isError: false, value: selected }; + } + + // No matching profile — offer to create one + log.info( + `No profile found for workspace '${opts.workspace}' (${workspaceId} on ${normalizedBaseUrl})` + ); + const ws = await createWorkspaceProfileInteractively( + normalizedBaseUrl, + workspaceId, + opts.workspace, + opts, + { rawBranch: opts.workspace, isForked: false } + ); + if (ws) { + (opts as any).__secret_workspace = ws; + return { isError: false, value: ws }; + } + } + + // Fall back: look up profile by name directly (old behavior) const e = await getWorkspaceByName(opts.workspace, opts.configDir); if (!e) { return { isError: true, - error: colors.red.underline("Given workspace does not exist."), + error: colors.red.underline( + `Workspace '${opts.workspace}' not found in wmill.yaml config or local profiles.` + ), }; } (opts as any).__secret_workspace = e; @@ -227,17 +292,24 @@ async function tryResolveWorkspace( export async function tryResolveBranchWorkspace( opts: GlobalOptions, - branchOverride?: string + workspaceNameOverride?: string ): Promise { let rawBranch: string | null = null; - let currentBranch: string; + let wsName: string | undefined; + let wsEntry: WorkspaceEntryConfig | undefined; let originalBranchIfForked: string | null = null; let workspaceIdIfForked: string | null = null; - if (branchOverride) { - // Use branch override directly - currentBranch = branchOverride; - log.info(`Using branch override: ${branchOverride}`); + // Read wmill.yaml (silent — just probing) + const config = await readConfigFile({ warnIfMissing: false }); + + if (workspaceNameOverride) { + // Direct lookup by workspace name + wsEntry = config.workspaces?.[workspaceNameOverride] as WorkspaceEntryConfig | undefined; + if (wsEntry) { + wsName = workspaceNameOverride; + log.info(`Using workspace override: ${workspaceNameOverride}`); + } } else { // Only try branch-based resolution if in a Git repository if (!isGitRepository()) { @@ -252,37 +324,61 @@ export async function tryResolveBranchWorkspace( originalBranchIfForked = getOriginalBranchForWorkspaceForks(rawBranch); workspaceIdIfForked = getWorkspaceIdForWorkspaceForkFromBranchName(rawBranch); + + const branchToLookup = originalBranchIfForked ?? rawBranch; if (originalBranchIfForked) { log.info( - `Using original branch \`${originalBranchIfForked}\` for finding workspace profile from gitBranches section in wmill.yaml` + `Using original branch \`${originalBranchIfForked}\` for finding workspace from workspaces section in wmill.yaml` ); - currentBranch = originalBranchIfForked; - } else { - currentBranch = rawBranch; + } + + const match = findWorkspaceByGitBranch(config.workspaces, branchToLookup); + if (match) { + [wsName, wsEntry] = match; } } - // Read wmill.yaml to check for branch workspace configuration (silent — just probing) - const config = await readConfigFile({ warnIfMissing: false }); - const branchConfig = config.gitBranches?.[currentBranch]; - - // Check if branch has workspace configuration - if (!branchConfig?.baseUrl || !branchConfig?.workspaceId) { + if (!wsName || !wsEntry) { return undefined; } - log.info( - `Using branch configuration for branch \`${currentBranch}\` set in gitBranches` - ); + if (!wsEntry.baseUrl) { + if (workspaceNameOverride) { + // User explicitly asked for this workspace but it has no baseUrl + log.warn( + `⚠️ Workspace '${wsName}' has no baseUrl configured. Cannot resolve a profile.\n` + + ` Add baseUrl to workspace '${wsName}' in wmill.yaml, or use --base-url flag.` + ); + } + return undefined; + } - const { baseUrl, workspaceId } = branchConfig; + const workspaceId = getEffectiveWorkspaceId(wsName, wsEntry); + const effectiveGitBranch = (wsEntry as any).gitBranch ?? wsName; + const { baseUrl } = wsEntry; + + // Explain why this workspace was selected + let reason: string; + if (workspaceNameOverride) { + reason = `selected via --workspace`; + } else if (originalBranchIfForked) { + reason = `matched via fork branch '${rawBranch}' → original branch '${originalBranchIfForked}'`; + } else if (effectiveGitBranch !== wsName) { + reason = `matched git branch '${effectiveGitBranch}' on current branch '${rawBranch}'`; + } else { + reason = `matched current git branch '${rawBranch}'`; + } + + log.info( + `Using workspace '${wsName}' (${reason}) → ${workspaceId} on ${baseUrl}` + ); let normalizedBaseUrl: string; try { normalizedBaseUrl = new URL(baseUrl).toString(); } catch (error) { log.error( - colors.red(`Invalid baseUrl in branch configuration: ${baseUrl}`) + colors.red(`Invalid baseUrl in workspace '${wsName}' configuration: ${baseUrl}`) ); return undefined; } @@ -298,26 +394,25 @@ export async function tryResolveBranchWorkspace( return await createWorkspaceProfileInteractively( normalizedBaseUrl, workspaceId, - currentBranch, + wsName, opts, - { rawBranch: rawBranch ?? currentBranch, isForked: !!originalBranchIfForked } + { rawBranch: rawBranch ?? wsName, isForked: !!originalBranchIfForked } ); } - // Handle multiple profiles - use special branch-aware logic + // Handle multiple profiles let selectedProfile: Workspace; if (matchingProfiles.length === 1) { selectedProfile = matchingProfiles[0]; log.info( colors.green( - `Using workspace profile '${selectedProfile.name}' for branch '${currentBranch}' with workspace id \`${workspaceId}\`` + `Using workspace profile '${selectedProfile.name}' for workspace '${wsName}' with workspace id \`${workspaceId}\`` ) ); } else { - // For multiple profiles, check branch-specific last used first const lastUsedName = await getLastUsedProfile( - currentBranch, + wsName, normalizedBaseUrl, workspaceId, opts.configDir @@ -330,25 +425,23 @@ export async function tryResolveBranchWorkspace( if (lastUsedProfile) { log.info( colors.green( - `Using workspace profile '${lastUsedProfile.name}' for branch '${currentBranch}' (last used)` + `Using workspace profile '${lastUsedProfile.name}' for workspace '${wsName}' (last used)` ) ); return lastUsedProfile; } } - // Fall back to general selection logic selectedProfile = await selectFromMultipleProfiles( matchingProfiles, normalizedBaseUrl, workspaceId, - `branch '${currentBranch}'`, + `workspace '${wsName}'`, opts.configDir ); - // Save branch-specific selection await setLastUsedProfile( - currentBranch, + wsName, normalizedBaseUrl, workspaceId, selectedProfile.name, @@ -357,7 +450,7 @@ export async function tryResolveBranchWorkspace( log.info( colors.green( - `Using workspace profile '${selectedProfile.name}' for branch '${currentBranch}'` + `Using workspace profile '${selectedProfile.name}' for workspace '${wsName}'` ) ); } @@ -375,7 +468,7 @@ export async function tryResolveBranchWorkspace( export async function resolveWorkspace( opts: GlobalOptions, - branchOverride?: string + workspaceNameOverride?: string ): Promise { const cache = (opts as any).__secret_workspace; if (cache) return cache; @@ -384,7 +477,7 @@ export async function resolveWorkspace( if (opts.workspace && opts.token) { let normalizedBaseUrl: string; try { - normalizedBaseUrl = new URL(opts.baseUrl).toString(); // add trailing slash if not present + normalizedBaseUrl = new URL(opts.baseUrl).toString(); } catch (error) { log.info(colors.red(`Invalid base URL: ${opts.baseUrl}`)); return process.exit(-1); @@ -392,19 +485,17 @@ export async function resolveWorkspace( // Try to find existing workspace profile by name, then by workspaceId + remote if (opts.workspace) { - // Try by workspace name first let existingWorkspace = await getWorkspaceByName( opts.workspace, opts.configDir ); - // If not found by name, try to find by workspaceId + remote match if (!existingWorkspace) { const { allWorkspaces } = await import( "../commands/workspace/workspace.ts" ); - const workspaces = await allWorkspaces(opts.configDir); - const matchingWorkspaces = workspaces.filter( + const profiles = await allWorkspaces(opts.configDir); + const matchingWorkspaces = profiles.filter( (w) => w.workspaceId === opts.workspace && w.remote === normalizedBaseUrl ); @@ -421,7 +512,6 @@ export async function resolveWorkspace( } if (existingWorkspace) { - // Validate that the base URL matches the profile's remote if (existingWorkspace.remote !== normalizedBaseUrl) { log.info( colors.red( @@ -430,15 +520,13 @@ export async function resolveWorkspace( ); return process.exit(-1); } - // Use the existing workspace profile (preserves workspace name) return { ...existingWorkspace, - token: opts.token, // Use the provided token + token: opts.token, }; } } - // No existing profile found, create temporary workspace return { remote: normalizedBaseUrl, workspaceId: opts.workspace, @@ -455,15 +543,15 @@ export async function resolveWorkspace( } } - const branch = branchOverride ?? getCurrentGitBranch(); + const branch = workspaceNameOverride ? null : getCurrentGitBranch(); // Try explicit workspace flag first (should override branch-based resolution). Unless it's a - // forked workspace, that we detect through the branch name (only when not using branchOverride + // forked workspace, that we detect through the branch name (only when not using workspaceNameOverride // and --workspace was not explicitly provided) const res = await tryResolveWorkspace(opts); if (!res.isError) { const workspace = (res as { isError: false; value: Workspace }).value; - if (branchOverride || opts.workspace || !branch || !branch.startsWith(WM_FORK_PREFIX)) { + if (workspaceNameOverride || opts.workspace || !branch || !branch.startsWith(WM_FORK_PREFIX)) { return workspace; } else { log.info( @@ -472,8 +560,8 @@ export async function resolveWorkspace( } } else if (opts.workspace) { // --workspace was explicitly provided but not found — fail immediately - const workspaces = await allWorkspaces(opts.configDir); - const names = workspaces.map((w) => w.name); + const profiles = await allWorkspaces(opts.configDir); + const names = profiles.map((w) => w.name); let msg = `Workspace "${opts.workspace}" not found.`; const suggestions = names .map((n) => ({ name: n, dist: levenshteinDistance(opts.workspace!, n) })) @@ -484,37 +572,91 @@ export async function resolveWorkspace( msg += ` Did you mean: ${suggestions.map((s) => `"${s.name}"`).join(", ")}?`; } log.info(colors.red.bold(msg)); - if (workspaces.length > 0) { + if (profiles.length > 0) { log.info("\nAvailable workspaces:"); new Table() .header(["name", "remote", "workspace id"]) .padding(2) .border(true) - .body(workspaces.map((w) => [w.name, w.remote, w.workspaceId])) + .body(profiles.map((w) => [w.name, w.remote, w.workspaceId])) .render(); } return process.exit(-1); } // Try branch-based resolution (medium priority) - const branchWorkspace = await tryResolveBranchWorkspace(opts, branchOverride); + const branchWorkspace = await tryResolveBranchWorkspace(opts, workspaceNameOverride); if (branchWorkspace) { (opts as any).__secret_workspace = branchWorkspace; return branchWorkspace; - } else if (!branchOverride) { - // Only check for fork errors when not using branchOverride + } else if (!workspaceNameOverride) { + // Only check for fork errors when not using workspaceNameOverride const originalBranch = getOriginalBranchForWorkspaceForks(branch); if (originalBranch) { log.error( colors.red.bold( - `Failed to resolve workspace profile for workspace fork. This most likely means that the original branch \`${originalBranch}\` where \`${branch}\` is originally forked from, is not setup in the wmill.yaml. You need to update the \`gitBranches\` section for \`${originalBranch}\` to include workspaceId and baseUrl.` + `Failed to resolve workspace profile for workspace fork. The original branch \`${originalBranch}\` (forked from \`${branch}\`) is not configured in wmill.yaml.\n` + + ` Add to the 'workspaces' section: \`${originalBranch}: { baseUrl: "https://...", workspaceId: "..." }\`` ) ); return process.exit(-1); } } - // Fall back to active workspace + // If workspaces config exists, use it rather than falling back to active profile + const config = await readConfigFile({ warnIfMissing: false }); + const { getWorkspaceNames } = await import("./conf.ts"); + const wsNames = getWorkspaceNames(config.workspaces); + + if (wsNames.length > 0) { + let pickedWsName: string; + + const wsListStr = wsNames.map((n) => { + const entry = (config.workspaces as any)[n]; + const info = entry.baseUrl ? ` (${entry.workspaceId ?? n} on ${entry.baseUrl})` : ""; + return ` - ${n}${info}`; + }).join("\n"); + + if (wsNames.length === 1) { + pickedWsName = wsNames[0]; + log.info( + `Auto-selected workspace '${pickedWsName}' (only workspace in config).\n` + + `Use --workspace to override or 'wmill workspace bind' to add more workspaces.` + ); + } else if (process.stdin.isTTY) { + log.info( + `Multiple workspaces configured but none matched the current context.\n` + + `Configured workspaces:\n${wsListStr}\n` + + `Use --workspace to skip this prompt.` + ); + pickedWsName = await Select.prompt({ + message: "Select workspace", + options: wsNames.map((n) => { + const entry = (config.workspaces as any)[n]; + const info = entry.baseUrl ? ` (${entry.workspaceId ?? n} on ${entry.baseUrl})` : ""; + return { name: `${n}${info}`, value: n }; + }), + }); + } else { + log.error( + colors.red.bold( + `Multiple workspaces configured but none matched the current context.\n` + + `Configured workspaces:\n${wsListStr}\n` + + `Use --workspace to select one.` + ) + ); + return process.exit(-1); + } + + // Resolve the picked workspace via config + const pickedResult = await tryResolveBranchWorkspace(opts, pickedWsName); + if (pickedResult) { + (opts as any).__secret_workspace = pickedResult; + return pickedResult; + } + } + + // Fall back to active workspace (only when no workspaces config) const activeWorkspace = await getActiveWorkspace(opts); if (activeWorkspace) { (opts as any).__secret_workspace = activeWorkspace; @@ -522,7 +664,6 @@ export async function resolveWorkspace( } // Last resort: auto-configure from Windmill environment variables - // (set by the worker for bash/script execution) const envWorkspace = process.env["WM_WORKSPACE"]; const envToken = process.env["WM_TOKEN"]; const envBaseUrl = @@ -549,7 +690,6 @@ export async function resolveWorkspace( return ws; } - // If everything failed, show error log.info(colors.red.bold("No workspace given and no default set. Run 'wmill workspace add' to configure one.")); return process.exit(-1); } diff --git a/cli/src/core/specific_items.ts b/cli/src/core/specific_items.ts index dca403e905..3d0d9df248 100644 --- a/cli/src/core/specific_items.ts +++ b/cli/src/core/specific_items.ts @@ -1,7 +1,11 @@ import { minimatch } from "minimatch"; import { getCurrentGitBranch, isGitRepository } from "../utils/git.ts"; import { isFileResource, isFilesetResource } from "../utils/utils.ts"; -import { SyncOptions } from "./conf.ts"; +import { + SyncOptions, + findWorkspaceByGitBranch, + WorkspaceEntryConfig, +} from "./conf.ts"; import { TRIGGER_TYPES } from "../types.ts"; export interface SpecificItemsConfig { @@ -13,8 +17,8 @@ export interface SpecificItemsConfig { settings?: boolean; } -// Define all branch-specific file types (computed lazily) -function getBranchSpecificTypes() { +// Define all workspace-specific file types (computed lazily) +function getWorkspaceSpecificTypes() { return { variable: '.variable.yaml', resource: '.resource.yaml', @@ -44,7 +48,7 @@ function isScheduleFile(path: string): boolean { * Extract the file type suffix from a path */ function getFileTypeSuffix(path: string): string | null { - for (const [_, suffix] of Object.entries(getBranchSpecificTypes())) { + for (const [_, suffix] of Object.entries(getWorkspaceSpecificTypes())) { if (path.endsWith(suffix)) { return suffix; } @@ -68,35 +72,39 @@ function buildYamlTypePattern(): string { } /** - * Get the specific items configuration for the current git branch - * Merges commonSpecificItems with branch-specific specificItems + * Get the specific items configuration for the current workspace. + * workspaceNameOverride selects by workspace name (O(1)). + * When not provided, auto-detects from the current git branch. + * Merges commonSpecificItems with workspace-specific specificItems. */ -export function getSpecificItemsForCurrentBranch(config: SyncOptions, branchOverride?: string): SpecificItemsConfig | undefined { - if (!config.gitBranches) { +export function getSpecificItemsForCurrentBranch(config: SyncOptions, workspaceNameOverride?: string): SpecificItemsConfig | undefined { + if (!config.workspaces) { return undefined; } - // Use branch override if provided, otherwise detect from git - let currentBranch: string | null = null; - if (branchOverride) { - currentBranch = branchOverride; + let wsEntry: WorkspaceEntryConfig | undefined; + + if (workspaceNameOverride) { + wsEntry = config.workspaces[workspaceNameOverride] as WorkspaceEntryConfig | undefined; } else if (isGitRepository()) { - currentBranch = getCurrentGitBranch(); + const currentWorkspace = getCurrentGitBranch(); + if (currentWorkspace) { + const match = findWorkspaceByGitBranch(config.workspaces, currentWorkspace); + if (match) { + wsEntry = match[1]; + } + } } - if (!currentBranch) { + const commonItems = config.workspaces.commonSpecificItems; + const wsItems = wsEntry?.specificItems; + + // If neither common nor workspace-specific items exist, return undefined + if (!commonItems && !wsItems) { return undefined; } - const commonItems = config.gitBranches.commonSpecificItems; - const branchItems = config.gitBranches[currentBranch]?.specificItems; - - // If neither common nor branch-specific items exist, return undefined - if (!commonItems && !branchItems) { - return undefined; - } - - // Merge common and branch-specific items + // Merge common and workspace-specific items const merged: SpecificItemsConfig = {}; // Add common items @@ -119,25 +127,25 @@ export function getSpecificItemsForCurrentBranch(config: SyncOptions, branchOver merged.settings = commonItems.settings; } - // Add branch-specific items (extending common items) - if (branchItems?.variables) { - merged.variables = [...(merged.variables || []), ...branchItems.variables]; + // Add workspace-specific items (extending common items) + if (wsItems?.variables) { + merged.variables = [...(merged.variables || []), ...wsItems.variables]; } - if (branchItems?.resources) { - merged.resources = [...(merged.resources || []), ...branchItems.resources]; + if (wsItems?.resources) { + merged.resources = [...(merged.resources || []), ...wsItems.resources]; } - if (branchItems?.triggers) { - merged.triggers = [...(merged.triggers || []), ...branchItems.triggers]; + if (wsItems?.triggers) { + merged.triggers = [...(merged.triggers || []), ...wsItems.triggers]; } - if (branchItems?.schedules) { - merged.schedules = [...(merged.schedules || []), ...branchItems.schedules]; + if (wsItems?.schedules) { + merged.schedules = [...(merged.schedules || []), ...wsItems.schedules]; } - if (branchItems?.folders) { - merged.folders = [...(merged.folders || []), ...branchItems.folders]; + if (wsItems?.folders) { + merged.folders = [...(merged.folders || []), ...wsItems.folders]; } - // For settings (boolean), branch-specific overrides common - if (branchItems?.settings !== undefined) { - merged.settings = branchItems.settings; + // For settings (boolean), workspace-specific overrides common + if (wsItems?.settings !== undefined) { + merged.settings = wsItems.settings; } return merged; @@ -153,7 +161,7 @@ function matchesPatterns(path: string, patterns: string[]): boolean { /** * Check if the item type for a given path is configured in specificItems. * This checks if the TYPE is configured, not whether it matches the pattern. - * Used to determine if branch-specific files should be used for this type. + * Used to determine if workspace-specific files should be used for this type. */ export function isItemTypeConfigured(path: string, specificItems: SpecificItemsConfig | undefined): boolean { if (!specificItems) { @@ -192,7 +200,7 @@ export function isItemTypeConfigured(path: string, specificItems: SpecificItemsC } /** - * Check if a file path should be treated as branch-specific + * Check if a file path should be treated as workspace-specific */ export function isSpecificItem(path: string, specificItems: SpecificItemsConfig | undefined): boolean { if (!specificItems) { @@ -255,26 +263,26 @@ export function isSpecificItem(path: string, specificItems: SpecificItemsConfig } /** - * Convert a base path to a branch-specific path + * Convert a base path to a workspace-specific path */ -export function toBranchSpecificPath(basePath: string, branchName: string): string { +export function toWorkspaceSpecificPath(basePath: string, workspaceName: string): string { // Sanitize branch name to be filesystem-safe - const sanitizedBranchName = branchName.replace(/[\/\\:*?"<>|.]/g, '_'); + const sanitizedName = workspaceName.replace(/[\/\\:*?"<>|.]/g, '_'); // Warn about potential collisions if sanitization occurred - if (sanitizedBranchName !== branchName) { - console.warn(`Warning: Branch name "${branchName}" contains filesystem-unsafe characters (/ \\ : * ? " < > | .) and was sanitized to "${sanitizedBranchName}". This may cause collisions with other similarly named branches.`); + if (sanitizedName !== workspaceName) { + console.warn(`Warning: Workspace name "${workspaceName}" contains filesystem-unsafe characters (/ \\ : * ? " < > | .) and was sanitized to "${sanitizedName}". This may cause collisions with other similarly named branches.`); } - // Check for folder meta file pattern: folder.meta.yaml -> folder.branchName.meta.yaml + // Check for folder meta file pattern: folder.meta.yaml -> folder.workspaceName.meta.yaml if (basePath.endsWith('/folder.meta.yaml')) { const pathWithoutMeta = basePath.substring(0, basePath.length - '/folder.meta.yaml'.length); - return `${pathWithoutMeta}/folder.${sanitizedBranchName}.meta.yaml`; + return `${pathWithoutMeta}/folder.${sanitizedName}.meta.yaml`; } - // Check for settings.yaml: settings.yaml -> settings.branchName.yaml + // Check for settings.yaml: settings.yaml -> settings.workspaceName.yaml if (basePath === 'settings.yaml') { - return `settings.${sanitizedBranchName}.yaml`; + return `settings.${sanitizedName}.yaml`; } // Check for resource file pattern (e.g., .resource.file.ini) @@ -296,132 +304,133 @@ export function toBranchSpecificPath(basePath: string, branchName: string): stri pathWithoutExtension = basePath.substring(0, basePath.length - extension.length); } - return `${pathWithoutExtension}.${sanitizedBranchName}${extension}`; + return `${pathWithoutExtension}.${sanitizedName}${extension}`; } /** - * Convert a branch-specific path back to a base path + * Convert a workspace-specific path back to a base path */ -export function fromBranchSpecificPath(branchSpecificPath: string, branchName: string): string { - // Sanitize branch name the same way as in toBranchSpecificPath - const sanitizedBranchName = branchName.replace(/[\/\\:*?"<>|.]/g, '_'); - const escapedBranchName = sanitizedBranchName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +export function fromWorkspaceSpecificPath(workspaceSpecificPath: string, workspaceName: string): string { + // Sanitize branch name the same way as in toWorkspaceSpecificPath + const sanitizedName = workspaceName.replace(/[\/\\:*?"<>|.]/g, '_'); + const escapedName = sanitizedName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - // Check for folder meta file pattern: /folder.branchName.meta.yaml -> /folder.meta.yaml - const folderPattern = new RegExp(`/folder\\.${escapedBranchName}\\.meta\\.yaml$`); - if (folderPattern.test(branchSpecificPath)) { - return branchSpecificPath.replace(folderPattern, '/folder.meta.yaml'); + // Check for folder meta file pattern: /folder.workspaceName.meta.yaml -> /folder.meta.yaml + const folderPattern = new RegExp(`/folder\\.${escapedName}\\.meta\\.yaml$`); + if (folderPattern.test(workspaceSpecificPath)) { + return workspaceSpecificPath.replace(folderPattern, '/folder.meta.yaml'); } - // Check for settings file pattern: settings.branchName.yaml -> settings.yaml - const settingsPattern = new RegExp(`^settings\\.${escapedBranchName}\\.yaml$`); - if (settingsPattern.test(branchSpecificPath)) { + // Check for settings file pattern: settings.workspaceName.yaml -> settings.yaml + const settingsPattern = new RegExp(`^settings\\.${escapedName}\\.yaml$`); + if (settingsPattern.test(workspaceSpecificPath)) { return 'settings.yaml'; } // Check for resource file pattern - const resourceFilePattern = new RegExp(`\\.${escapedBranchName}(\\.resource\\.file\\..+)$`); - const resourceFileMatch = branchSpecificPath.match(resourceFilePattern); + const resourceFilePattern = new RegExp(`\\.${escapedName}(\\.resource\\.file\\..+)$`); + const resourceFileMatch = workspaceSpecificPath.match(resourceFilePattern); if (resourceFileMatch) { const extension = resourceFileMatch[1]; - const pathWithoutBranchAndExtension = branchSpecificPath.substring( + const pathWithoutBranchAndExtension = workspaceSpecificPath.substring( 0, - branchSpecificPath.length - `.${sanitizedBranchName}${extension}`.length + workspaceSpecificPath.length - `.${sanitizedName}${extension}`.length ); return `${pathWithoutBranchAndExtension}${extension}`; } - const yamlPattern = new RegExp(`\\.${escapedBranchName}(\\.${buildYamlTypePattern()}\\.yaml)$`); - const yamlMatch = branchSpecificPath.match(yamlPattern); + const yamlPattern = new RegExp(`\\.${escapedName}(\\.${buildYamlTypePattern()}\\.yaml)$`); + const yamlMatch = workspaceSpecificPath.match(yamlPattern); if (!yamlMatch) { - return branchSpecificPath; // Return unchanged if not a branch-specific path + return workspaceSpecificPath; // Return unchanged if not a workspace-specific path } const extension = yamlMatch[1]; - const pathWithoutBranchAndExtension = branchSpecificPath.substring( + const pathWithoutBranchAndExtension = workspaceSpecificPath.substring( 0, - branchSpecificPath.length - `.${sanitizedBranchName}${extension}`.length + workspaceSpecificPath.length - `.${sanitizedName}${extension}`.length ); return `${pathWithoutBranchAndExtension}${extension}`; } /** - * Get the branch-specific path for the current branch if the item should be branch-specific + * Get the workspace-specific path if the item should be workspace-specific. + * workspaceNameOverride is the workspace name used as file suffix. + * Falls back to current git branch when no override (backward compat: old key = branch name). */ -export function getBranchSpecificPath( +export function getWorkspaceSpecificPath( basePath: string, specificItems: SpecificItemsConfig | undefined, - branchOverride?: string + workspaceNameOverride?: string ): string | undefined { if (!specificItems) { return undefined; } - // Use branch override if provided, otherwise detect from git - let currentBranch: string | null = null; - if (branchOverride) { - currentBranch = branchOverride; + let currentWorkspace: string | null = null; + if (workspaceNameOverride) { + currentWorkspace = workspaceNameOverride; } else if (isGitRepository()) { - currentBranch = getCurrentGitBranch(); + currentWorkspace = getCurrentGitBranch(); } - if (!currentBranch) { + if (!currentWorkspace) { return undefined; } if (isSpecificItem(basePath, specificItems)) { - return toBranchSpecificPath(basePath, currentBranch); + return toWorkspaceSpecificPath(basePath, currentWorkspace); } return undefined; } // Cache for compiled regex patterns to avoid recompilation -const branchPatternCache = new Map(); +const workspacePatternCache = new Map(); /** - * Check if a path is a branch-specific file for the current branch + * Check if a path is a workspace-specific file for the current branch. + * workspaceNameOverride is the effective git branch name (for file naming on disk). */ -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; +export function isCurrentWorkspaceFile(path: string, workspaceNameOverride?: string): boolean { + let currentWorkspace: string | null = null; + if (workspaceNameOverride) { + currentWorkspace = workspaceNameOverride; } else if (isGitRepository()) { - currentBranch = getCurrentGitBranch(); + currentWorkspace = getCurrentGitBranch(); } - if (!currentBranch) { + if (!currentWorkspace) { return false; } // Sanitize branch name to match what would be used in file naming - const sanitizedBranchName = currentBranch.replace(/[\/\\:*?"<>|.]/g, '_'); - const escapedBranchName = sanitizedBranchName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const sanitizedName = currentWorkspace.replace(/[\/\\:*?"<>|.]/g, '_'); + const escapedName = sanitizedName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // Use cached pattern or create and cache new one - let pattern = branchPatternCache.get(currentBranch); + let pattern = workspacePatternCache.get(currentWorkspace); if (!pattern) { pattern = new RegExp( - `\\.${escapedBranchName}\\.${buildYamlTypePattern()}\\.yaml$|` + - `\\.${escapedBranchName}\\.resource\\.file\\..+$|` + - `/folder\\.${escapedBranchName}\\.meta\\.yaml$|` + - `^settings\\.${escapedBranchName}\\.yaml$` + `\\.${escapedName}\\.${buildYamlTypePattern()}\\.yaml$|` + + `\\.${escapedName}\\.resource\\.file\\..+$|` + + `/folder\\.${escapedName}\\.meta\\.yaml$|` + + `^settings\\.${escapedName}\\.yaml$` ); - branchPatternCache.set(currentBranch, pattern); + workspacePatternCache.set(currentWorkspace, pattern); } return pattern.test(path); } /** - * Check if a path is a branch-specific file for ANY branch (not necessarily current) + * Check if a path is a workspace-specific file for ANY branch (not necessarily current) * Used to identify and skip files from other branches during sync operations */ -export function isBranchSpecificFile(path: string): boolean { +export function isWorkspaceSpecificFile(path: string): boolean { const yamlTypePattern = buildYamlTypePattern(); return new RegExp( `\\.[^.]+\\.${yamlTypePattern}\\.yaml$|` + diff --git a/cli/src/guidance/skills.ts b/cli/src/guidance/skills.ts index b1a6dcff09..5eb785faa0 100644 --- a/cli/src/guidance/skills.ts +++ b/cli/src/guidance/skills.ts @@ -5127,6 +5127,10 @@ Show all available wmill.yaml configuration options **Options:** - \`--json\` - Output as JSON for programmatic consumption +**Subcommands:** + +- \`config migrate\` - Migrate wmill.yaml from gitBranches/environments to workspaces format + ### dependencies workspace dependencies related commands @@ -5498,7 +5502,7 @@ sync local with a remote workspaces or the opposite (push or pull) - \`--extra-includes \` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string). Useful to still take wmill.yaml into account and act as a second pattern to satisfy - \`--repository \` - Specify repository path (e.g., u/user/repo) when multiple repositories exist - \`--promotion \` - Use promotionOverrides from the specified branch instead of regular overrides - - \`--branch, --env \` - Override the current git branch/environment (works even outside a git repository) + - \`--branch, --env \` - [Deprecated: use --workspace] Override the current git branch/environment - \`sync push\` - Push any local changes and apply them remotely. - \`--yes\` - Push without needing confirmation - \`--dry-run\` - Show changes that would be pushed without actually pushing @@ -5529,7 +5533,7 @@ sync local with a remote workspaces or the opposite (push or pull) - \`--message \` - Include a message that will be added to all scripts/flows/apps updated during this push - \`--parallel \` - Number of changes to process in parallel - \`--repository \` - Specify repository path (e.g., u/user/repo) when multiple repositories exist - - \`--branch, --env \` - Override the current git branch/environment (works even outside a git repository) + - \`--branch, --env \` - [Deprecated: use --workspace] Override the current git branch/environment - \`--lint\` - Run lint validation before pushing - \`--locks-required\` - Fail if scripts or flow inline scripts that need locks have no locks - \`--auto-metadata\` - Automatically regenerate stale metadata (locks and schemas) before pushing @@ -5648,10 +5652,11 @@ workspace related commands - \`workspace list\` - List local workspace profiles - \`workspace list-remote\` - List workspaces on the remote server that you have access to - \`workspace list-forks\` - List forked workspaces on the remote server -- \`workspace bind\` - Bind the current Git branch to the active workspace. This adds the branch to gitBranches in wmill.yaml so sync operations use the correct workspace for each branch. - - \`--branch, --env \` - Specify branch/environment (defaults to current) -- \`workspace unbind\` - Remove workspace binding from the current Git branch - - \`--branch, --env \` - Specify branch/environment (defaults to current) +- \`workspace bind\` - Create or update a workspace entry in wmill.yaml from the active profile + - \`--workspace \` - Workspace name (default: current branch or workspaceId) + - \`--branch \` - Git branch to associate (default: workspace name) +- \`workspace unbind\` - Remove baseUrl and workspaceId from a workspace entry + - \`--workspace \` - Workspace to unbind - \`workspace fork [workspace_name:string] [workspace_id:string]\` - Create a forked workspace - \`--create-workspace-name \` - Specify the workspace name. Ignored if --create is not specified or the workspace already exists. Will default to the workspace id. - \`--color \` - Workspace color (hex code, e.g. #ff0000) diff --git a/cli/test/conf_branch_override_unit.test.ts b/cli/test/conf_branch_override_unit.test.ts index 1bacea0636..95c1c447aa 100644 --- a/cli/test/conf_branch_override_unit.test.ts +++ b/cli/test/conf_branch_override_unit.test.ts @@ -2,15 +2,15 @@ import { expect, test } from "bun:test"; import { getEffectiveSettings, type SyncOptions } from "../src/core/conf.ts"; // ============================================================================= -// CONF.TS BRANCH OVERRIDE TESTS -// Tests for getEffectiveSettings with branchOverride parameter +// CONF.TS WORKSPACE OVERRIDE TESTS +// Tests for getEffectiveSettings with workspaceNameOverride parameter // ============================================================================= -test("getEffectiveSettings: applies branch overrides when branchOverride is provided", async () => { +test("getEffectiveSettings: applies workspace overrides when workspaceNameOverride is provided", async () => { const config: SyncOptions = { defaultTs: "bun", includes: ["f/**"], - gitBranches: { + workspaces: { staging: { overrides: { includes: ["staging/**"], @@ -26,25 +26,25 @@ test("getEffectiveSettings: applies branch overrides when branchOverride is prov }, }; - // Test with staging branch override + // Test with staging workspace override const stagingSettings = await getEffectiveSettings(config, undefined, true, true, "staging"); expect(stagingSettings.includes).toEqual(["staging/**"]); expect(stagingSettings.skipVariables).toEqual(true); expect(stagingSettings.skipSecrets).toEqual(undefined); - // Test with production branch override + // Test with production workspace override const prodSettings = await getEffectiveSettings(config, undefined, true, true, "production"); expect(prodSettings.includes).toEqual(["prod/**"]); expect(prodSettings.skipSecrets).toEqual(true); expect(prodSettings.skipVariables).toEqual(undefined); }); -test("getEffectiveSettings: uses top-level settings when branchOverride has no overrides", async () => { +test("getEffectiveSettings: uses top-level settings when workspace has no overrides", async () => { const config: SyncOptions = { defaultTs: "bun", includes: ["f/**"], skipVariables: true, - gitBranches: { + workspaces: { staging: { // No overrides defined }, @@ -57,11 +57,11 @@ test("getEffectiveSettings: uses top-level settings when branchOverride has no o expect(settings.defaultTs).toEqual("bun"); }); -test("getEffectiveSettings: uses top-level settings for unknown branch", async () => { +test("getEffectiveSettings: uses top-level settings for unknown workspace", async () => { const config: SyncOptions = { defaultTs: "bun", includes: ["f/**"], - gitBranches: { + workspaces: { staging: { overrides: { includes: ["staging/**"], @@ -79,7 +79,7 @@ test("getEffectiveSettings: promotionOverrides take precedence when promotion sp const config: SyncOptions = { defaultTs: "bun", includes: ["f/**"], - gitBranches: { + workspaces: { production: { overrides: { includes: ["prod/**"], @@ -103,13 +103,13 @@ test("getEffectiveSettings: promotionOverrides take precedence when promotion sp expect(promoSettings.skipVariables).toEqual(true); }); -test("getEffectiveSettings: branchOverride works without gitBranches config", async () => { +test("getEffectiveSettings: workspaceNameOverride works without workspaces config", async () => { const config: SyncOptions = { defaultTs: "bun", includes: ["f/**"], }; - // Should not throw even with branchOverride but no gitBranches + // Should not throw even with workspaceNameOverride but no workspaces const settings = await getEffectiveSettings(config, undefined, true, true, "staging"); expect(settings.includes).toEqual(["f/**"]); expect(settings.defaultTs).toEqual("bun"); @@ -124,7 +124,7 @@ test("getEffectiveSettings: preserves all top-level settings in merged result", skipResources: false, skipFlows: false, parallel: 4, - gitBranches: { + workspaces: { staging: { overrides: { skipVariables: true, // Override just this one diff --git a/cli/test/init_bind_flow.test.ts b/cli/test/init_bind_flow.test.ts new file mode 100644 index 0000000000..dedb6f7df4 --- /dev/null +++ b/cli/test/init_bind_flow.test.ts @@ -0,0 +1,197 @@ +import { expect, test } from "bun:test"; +import { writeFile, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { withTestBackend } from "./test_backend.ts"; +import { addWorkspace } from "../workspace.ts"; +import { parseJsonFromCLIOutput } from "./test_config_helpers.ts"; + +async function setupProfile(backend: any, name: string): Promise { + await addWorkspace({ + remote: backend.baseUrl, + workspaceId: backend.workspace, + name, + token: backend.token, + }, { force: true, configDir: backend.testConfigDir }); +} + +// ============================================================================= +// Test 1: Full sync flow with new workspaces config — overrides, gitBranch ≠ key, +// workspace-specific file suffix, legacy back-compat, and promotion +// ============================================================================= + +test("Full workspaces config: overrides, gitBranch mapping, specific items suffix, legacy compat, promotion", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupProfile(backend, "test_profile"); + + // --- Part A: New workspaces config with gitBranch ≠ key --- + await writeFile(join(tempDir, "wmill.yaml"), `defaultTs: bun +includes: + - "**" +skipVariables: false + +workspaces: + production: + gitBranch: prod_branch + baseUrl: ${backend.baseUrl} + workspaceId: ${backend.workspace} + overrides: + skipVariables: true + promotionOverrides: + skipVariables: true + skipResources: true + specificItems: + variables: + - "f/**" + development: + gitBranch: dev_branch + baseUrl: ${backend.baseUrl} + workspaceId: ${backend.workspace} + overrides: + skipVariables: false`, "utf-8"); + + // A1: --branch prod_branch → resolves to workspace "production", skipVariables: true + const prodResult = await backend.runCLICommand([ + 'sync', 'pull', '--branch', 'prod_branch', '--dry-run', '--json-output', + ], tempDir); + expect(prodResult.code).toEqual(0); + const prodChanges = parseJsonFromCLIOutput(prodResult.stdout).changes || []; + const prodPaths = prodChanges.map((c: any) => c.path); + expect(prodPaths.some((p: string) => p.includes('.variable.yaml'))).toEqual(false); + + // A2: --branch dev_branch → resolves to workspace "development", skipVariables: false + const devResult = await backend.runCLICommand([ + 'sync', 'pull', '--branch', 'dev_branch', '--dry-run', '--json-output', + ], tempDir); + expect(devResult.code).toEqual(0); + const devPaths = (parseJsonFromCLIOutput(devResult.stdout).changes || []).map((c: any) => c.path); + expect(devPaths.some((p: string) => p.includes('.variable.yaml'))).toEqual(true); + + // A3: workspace-specific file suffix uses workspace name "production", not "prod_branch" + const wsSpecificPaths = prodChanges + .filter((c: any) => c.workspace_specific_path) + .map((c: any) => c.workspace_specific_path); + for (const p of wsSpecificPaths) { + expect(p).toContain(".production."); + expect(p).not.toContain(".prod_branch."); + } + + // A4: --promotion prod_branch applies promotionOverrides (skipResources: true) + const promoResult = await backend.runCLICommand([ + 'sync', 'pull', '--branch', 'prod_branch', '--promotion', 'prod_branch', + '--dry-run', '--json-output', + ], tempDir); + expect(promoResult.code).toEqual(0); + const promoPaths = (parseJsonFromCLIOutput(promoResult.stdout).changes || []).map((c: any) => c.path); + expect(promoPaths.some((p: string) => p.includes('.variable.yaml'))).toEqual(false); + expect(promoPaths.some((p: string) => p.includes('.resource.yaml'))).toEqual(false); + + // --- Part B: Legacy gitBranches config works via normalization --- + await writeFile(join(tempDir, "wmill.yaml"), `defaultTs: bun +includes: + - "**" +skipVariables: false + +gitBranches: + legacy_branch: + overrides: + skipVariables: true`, "utf-8"); + + const legacyResult = await backend.runCLICommand([ + 'sync', 'pull', '--branch', 'legacy_branch', '--dry-run', '--json-output', + ], tempDir); + expect(legacyResult.code).toEqual(0); + const legacyPaths = (parseJsonFromCLIOutput(legacyResult.stdout).changes || []).map((c: any) => c.path); + expect(legacyPaths.some((p: string) => p.includes('.variable.yaml'))).toEqual(false); + + // --- Part C: Legacy environments config --- + await writeFile(join(tempDir, "wmill.yaml"), `defaultTs: bun +includes: + - "**" +skipVariables: false + +environments: + env_branch: + overrides: + skipVariables: true`, "utf-8"); + + const envResult = await backend.runCLICommand([ + 'sync', 'pull', '--branch', 'env_branch', '--dry-run', '--json-output', + ], tempDir); + expect(envResult.code).toEqual(0); + const envPaths = (parseJsonFromCLIOutput(envResult.stdout).changes || []).map((c: any) => c.path); + expect(envPaths.some((p: string) => p.includes('.variable.yaml'))).toEqual(false); + }); +}); + +// ============================================================================= +// Test 2: Config migrate and workspace resolution fallbacks +// ============================================================================= + +test("Config migrate and workspace resolution fallbacks", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupProfile(backend, "fallback_test"); + + // --- Part A: config migrate converts gitBranches → workspaces --- + await writeFile(join(tempDir, "wmill.yaml"), `defaultTs: bun +includes: + - "f/**" +gitBranches: + main: + baseUrl: https://app.windmill.dev + workspaceId: production + overrides: + skipSecrets: false + commonSpecificItems: + variables: + - "f/shared/**"`, "utf-8"); + + const migrateResult = await backend.runCLICommand(['config', 'migrate'], tempDir); + expect(migrateResult.code).toEqual(0); + + const migrated = await readFile(join(tempDir, "wmill.yaml"), "utf-8"); + expect(migrated).toContain("workspaces:"); + expect(migrated).not.toContain("gitBranches:"); + expect(migrated).toContain("production"); + expect(migrated).toContain("commonSpecificItems"); + + // config migrate is idempotent + const migrateAgain = await backend.runCLICommand(['config', 'migrate'], tempDir); + expect(migrateAgain.code).toEqual(0); + + // --- Part B: single workspace auto-selected --- + await writeFile(join(tempDir, "wmill.yaml"), `defaultTs: bun +includes: + - "**" +skipVariables: false +workspaces: + only_ws: + baseUrl: ${backend.baseUrl} + workspaceId: ${backend.workspace} + overrides: + skipVariables: true`, "utf-8"); + + // No --branch, no --workspace: should auto-select "only_ws" + const singleResult = await backend.runCLICommand([ + 'sync', 'pull', '--dry-run', '--json-output', + ], tempDir); + expect(singleResult.code).toEqual(0); + const singlePaths = (parseJsonFromCLIOutput(singleResult.stdout).changes || []).map((c: any) => c.path); + // skipVariables: true should be applied from auto-selected workspace + expect(singlePaths.some((p: string) => p.includes('.variable.yaml'))).toEqual(false); + + // --- Part C: no workspaces config falls back to active profile --- + await writeFile(join(tempDir, "wmill.yaml"), `defaultTs: bun +includes: + - "**"`, "utf-8"); + + const noWsResult = await backend.runCLICommand([ + 'sync', 'pull', '--dry-run', '--json-output', + ], tempDir); + expect(noWsResult.code).toEqual(0); + // Should succeed using active profile, no overrides applied (all defaults) + const noWsPaths = (parseJsonFromCLIOutput(noWsResult.stdout).changes || []).map((c: any) => c.path); + expect(noWsPaths.length).toBeGreaterThan(0); + // Variables should be included (no skipVariables override) + expect(noWsPaths.some((p: string) => p.includes('.variable.yaml'))).toEqual(true); + }); +}); diff --git a/cli/test/init_no_git_sync.test.ts b/cli/test/init_no_git_sync.test.ts index a5914cd7ce..d16c9e771f 100644 --- a/cli/test/init_no_git_sync.test.ts +++ b/cli/test/init_no_git_sync.test.ts @@ -129,7 +129,7 @@ test.skipIf(shouldSkipOnCI())("Init: --use-backend flag applies git-sync setting expect(wmillYaml).toContain("g/**"); // Should have empty overrides section for consistency - expect(wmillYaml).toContain("gitBranches: {}"); + expect(wmillYaml).toContain("workspaces:"); }); }); @@ -182,6 +182,6 @@ test.skipIf(shouldSkipOnCI())("Init: --use-default bypasses backend settings che // Should NOT have backend-specific settings expect(wmillYaml.includes("f/should-be-ignored/**")).toEqual(false); - expect(wmillYaml).toContain("gitBranches: {}"); + expect(wmillYaml).toContain("workspaces:"); }); }); diff --git a/cli/test/init_template_unit.test.ts b/cli/test/init_template_unit.test.ts index efb6d09891..e7d69c2184 100644 --- a/cli/test/init_template_unit.test.ts +++ b/cli/test/init_template_unit.test.ts @@ -25,15 +25,14 @@ describe("generateCommentedTemplate", () => { expect(typeof config).toBe("object"); }); - test("uses provided branch name in gitBranches", () => { + test("uses provided branch name in workspaces", () => { const config = parse(generateCommentedTemplate("my-feature")); - expect(config.gitBranches["my-feature"]).toBeDefined(); - expect(config.gitBranches["my-feature"].overrides).toEqual({}); + expect(config.workspaces["my-feature"]).toBeDefined(); }); test("defaults to 'main' when no branch name given", () => { const config = parse(generateCommentedTemplate()); - expect(config.gitBranches["main"]).toBeDefined(); + expect(config.workspaces["main"]).toBeDefined(); }); test("quotes branch names with YAML-special characters", () => { @@ -41,7 +40,7 @@ describe("generateCommentedTemplate", () => { for (const branch of specialBranches) { const yaml = generateCommentedTemplate(branch); const config = parse(yaml); - expect(config.gitBranches[branch]).toBeDefined(); + expect(config.workspaces[branch]).toBeDefined(); } }); @@ -50,19 +49,19 @@ describe("generateCommentedTemplate", () => { expect(yaml.startsWith("# yaml-language-server: $schema=wmill.schema.json")).toBe(true); }); - test("includes all non-commented CONFIG_REFERENCE entries as active YAML keys", () => { + test("includes all non-commented, non-skipped CONFIG_REFERENCE entries as active YAML keys", () => { const config = parse(generateCommentedTemplate("main")); for (const opt of CONFIG_REFERENCE) { - if (!opt.commented) { + if (!opt.commented && !opt.skipInTemplate) { expect(config).toHaveProperty(opt.name); } } }); - test("does not include commented entries as active YAML keys", () => { + test("does not include commented or skipped entries as active YAML keys", () => { const config = parse(generateCommentedTemplate("main")); for (const opt of CONFIG_REFERENCE) { - if (opt.commented && opt.name !== "environments") { + if (opt.commented || opt.skipInTemplate) { expect(config[opt.name]).toBeUndefined(); } } @@ -123,23 +122,31 @@ describe("generateJsonSchema", () => { expect(schema.properties.codebases.items.required).toContain("relative_path"); }); - test("includes gitBranches with branch config schema", () => { - const branchSchema = schema.properties.gitBranches.additionalProperties; - expect(branchSchema.properties.baseUrl).toBeDefined(); - expect(branchSchema.properties.workspaceId).toBeDefined(); - expect(branchSchema.properties.specificItems).toBeDefined(); - expect(branchSchema.properties.specificItems.properties.variables).toBeDefined(); + test("includes workspaces with workspace config schema", () => { + const wsSchema = schema.properties.workspaces.additionalProperties; + expect(wsSchema.properties.gitBranch).toBeDefined(); + expect(wsSchema.properties.baseUrl).toBeDefined(); + expect(wsSchema.properties.workspaceId).toBeDefined(); + expect(wsSchema.properties.specificItems).toBeDefined(); + expect(wsSchema.properties.specificItems.properties.variables).toBeDefined(); }); - test("includes environments as alias for gitBranches", () => { + test("includes gitBranches as deprecated alias for workspaces", () => { + expect(schema.properties.gitBranches).toBeDefined(); + expect(schema.properties.gitBranches.additionalProperties).toEqual( + schema.properties.workspaces.additionalProperties + ); + }); + + test("includes environments as deprecated alias for workspaces", () => { expect(schema.properties.environments).toBeDefined(); expect(schema.properties.environments.additionalProperties).toEqual( - schema.properties.gitBranches.additionalProperties + schema.properties.workspaces.additionalProperties ); }); test("does not contain template-only keys in schema output", () => { - const templateKeys = ["section", "sectionNote", "commented", "templateValue", "example", "inlineComment", "groupNote"]; + const templateKeys = ["section", "sectionNote", "commented", "templateValue", "example", "inlineComment", "groupNote", "skipInTemplate"]; const json = JSON.stringify(schema); for (const key of templateKeys) { expect(json).not.toContain(`"${key}"`); @@ -172,15 +179,23 @@ describe("formatConfigReference", () => { expect(output).toContain("codebases[].external"); }); - test("auto-expands gitBranches sub-fields", () => { - expect(output).toContain("gitBranches..baseUrl"); - expect(output).toContain("gitBranches..workspaceId"); - expect(output).toContain("gitBranches..specificItems.variables"); + test("auto-expands workspaces sub-fields", () => { + expect(output).toContain("workspaces..gitBranch"); + expect(output).toContain("workspaces..baseUrl"); + expect(output).toContain("workspaces..workspaceId"); + expect(output).toContain("workspaces..specificItems.variables"); }); test("auto-expands commonSpecificItems sub-fields", () => { - expect(output).toContain("gitBranches.commonSpecificItems.variables"); - expect(output).toContain("gitBranches.commonSpecificItems.settings"); + expect(output).toContain("workspaces.commonSpecificItems.variables"); + expect(output).toContain("workspaces.commonSpecificItems.settings"); + }); + + test("deprecated entries are listed but not expanded", () => { + expect(output).toContain("gitBranches"); + expect(output).toContain("[Deprecated]"); + // Should NOT have expanded sub-fields for deprecated entries + expect(output).not.toContain("gitBranches."); }); }); @@ -207,7 +222,7 @@ describe("formatConfigReferenceJson", () => { test("does not contain template-only keys", () => { const parsed = JSON.parse(formatConfigReferenceJson()); - const templateKeys = ["section", "sectionNote", "commented", "templateValue", "example", "inlineComment", "groupNote"]; + const templateKeys = ["section", "sectionNote", "commented", "templateValue", "example", "inlineComment", "groupNote", "skipInTemplate"]; for (const entry of parsed) { for (const key of templateKeys) { expect(entry).not.toHaveProperty(key); diff --git a/cli/test/override_settings_behavior.test.ts b/cli/test/override_settings_behavior.test.ts index bbbcd75bff..0de8b7f305 100644 --- a/cli/test/override_settings_behavior.test.ts +++ b/cli/test/override_settings_behavior.test.ts @@ -17,7 +17,7 @@ test("Override Settings: branch override inherits non-overridden settings from b skipResources: true, // Base has this as true skipApps: false, // Base has this as false defaultTs: "bun" as const, - gitBranches: { + workspaces: { main: { overrides: { includes: ["override/**"], @@ -49,7 +49,7 @@ test("Override Settings: branch-specific settings take precedence", async () => const config = { includes: ["default/**"], skipVariables: false, - gitBranches: { + workspaces: { main: { overrides: { skipVariables: true, diff --git a/cli/test/specific_items_unit.test.ts b/cli/test/specific_items_unit.test.ts index 3b72861f88..ae94603dbc 100644 --- a/cli/test/specific_items_unit.test.ts +++ b/cli/test/specific_items_unit.test.ts @@ -2,93 +2,93 @@ import { expect, test } from "bun:test"; // ============================================================================= // SPECIFIC ITEMS UNIT TESTS -// Tests for branch-specific file path functions (no Docker required) +// Tests for workspace-specific file path functions (no Docker required) // ============================================================================= // Import the functions we need to test import { isSpecificItem, isItemTypeConfigured, - toBranchSpecificPath, - fromBranchSpecificPath, - isBranchSpecificFile, - isCurrentBranchFile, - getBranchSpecificPath, + toWorkspaceSpecificPath, + fromWorkspaceSpecificPath, + isWorkspaceSpecificFile, + isCurrentWorkspaceFile, + getWorkspaceSpecificPath, getSpecificItemsForCurrentBranch, } from "../src/core/specific_items.ts"; import type { SpecificItemsConfig } from "../src/core/specific_items.ts"; // ============================================================================= -// toBranchSpecificPath TESTS +// toWorkspaceSpecificPath TESTS // ============================================================================= -test("toBranchSpecificPath: converts variable path to branch-specific", () => { - const result = toBranchSpecificPath("f/test.variable.yaml", "main"); +test("toWorkspaceSpecificPath: converts variable path to workspace-specific", () => { + const result = toWorkspaceSpecificPath("f/test.variable.yaml", "main"); expect(result).toEqual("f/test.main.variable.yaml"); }); -test("toBranchSpecificPath: converts resource path to branch-specific", () => { - const result = toBranchSpecificPath("u/admin/db.resource.yaml", "develop"); +test("toWorkspaceSpecificPath: converts resource path to workspace-specific", () => { + const result = toWorkspaceSpecificPath("u/admin/db.resource.yaml", "develop"); expect(result).toEqual("u/admin/db.develop.resource.yaml"); }); -test("toBranchSpecificPath: converts trigger path to branch-specific", () => { - const result = toBranchSpecificPath("f/my_trigger.http_trigger.yaml", "feature-x"); +test("toWorkspaceSpecificPath: converts trigger path to workspace-specific", () => { + const result = toWorkspaceSpecificPath("f/my_trigger.http_trigger.yaml", "feature-x"); expect(result).toEqual("f/my_trigger.feature-x.http_trigger.yaml"); }); -test("toBranchSpecificPath: sanitizes branch names with slashes", () => { - const result = toBranchSpecificPath("f/test.variable.yaml", "feature/my-feature"); +test("toWorkspaceSpecificPath: sanitizes branch names with slashes", () => { + const result = toWorkspaceSpecificPath("f/test.variable.yaml", "feature/my-feature"); expect(result).toEqual("f/test.feature_my-feature.variable.yaml"); }); -test("toBranchSpecificPath: sanitizes branch names with dots", () => { - const result = toBranchSpecificPath("f/test.variable.yaml", "release.1.0"); +test("toWorkspaceSpecificPath: sanitizes branch names with dots", () => { + const result = toWorkspaceSpecificPath("f/test.variable.yaml", "release.1.0"); expect(result).toEqual("f/test.release_1_0.variable.yaml"); }); -test("toBranchSpecificPath: leaves non-specific files unchanged", () => { - const result = toBranchSpecificPath("f/script.ts", "main"); +test("toWorkspaceSpecificPath: leaves non-specific files unchanged", () => { + const result = toWorkspaceSpecificPath("f/script.ts", "main"); expect(result).toEqual("f/script.ts"); }); -test("toBranchSpecificPath: handles resource files with extensions", () => { - const result = toBranchSpecificPath("f/config.resource.file.json", "main"); +test("toWorkspaceSpecificPath: handles resource files with extensions", () => { + const result = toWorkspaceSpecificPath("f/config.resource.file.json", "main"); expect(result).toEqual("f/config.main.resource.file.json"); }); // ============================================================================= -// fromBranchSpecificPath TESTS +// fromWorkspaceSpecificPath TESTS // ============================================================================= -test("fromBranchSpecificPath: converts branch-specific variable back to base", () => { - const result = fromBranchSpecificPath("f/test.main.variable.yaml", "main"); +test("fromWorkspaceSpecificPath: converts workspace-specific variable back to base", () => { + const result = fromWorkspaceSpecificPath("f/test.main.variable.yaml", "main"); expect(result).toEqual("f/test.variable.yaml"); }); -test("fromBranchSpecificPath: converts branch-specific resource back to base", () => { - const result = fromBranchSpecificPath("u/admin/db.develop.resource.yaml", "develop"); +test("fromWorkspaceSpecificPath: converts workspace-specific resource back to base", () => { + const result = fromWorkspaceSpecificPath("u/admin/db.develop.resource.yaml", "develop"); expect(result).toEqual("u/admin/db.resource.yaml"); }); -test("fromBranchSpecificPath: converts branch-specific trigger back to base", () => { - const result = fromBranchSpecificPath("f/my_trigger.feature-x.http_trigger.yaml", "feature-x"); +test("fromWorkspaceSpecificPath: converts workspace-specific trigger back to base", () => { + const result = fromWorkspaceSpecificPath("f/my_trigger.feature-x.http_trigger.yaml", "feature-x"); expect(result).toEqual("f/my_trigger.http_trigger.yaml"); }); -test("fromBranchSpecificPath: handles sanitized branch names", () => { - const result = fromBranchSpecificPath("f/test.feature_my-feature.variable.yaml", "feature/my-feature"); +test("fromWorkspaceSpecificPath: handles sanitized branch names", () => { + const result = fromWorkspaceSpecificPath("f/test.feature_my-feature.variable.yaml", "feature/my-feature"); expect(result).toEqual("f/test.variable.yaml"); }); -test("fromBranchSpecificPath: returns unchanged if not branch-specific", () => { - const result = fromBranchSpecificPath("f/test.variable.yaml", "main"); +test("fromWorkspaceSpecificPath: returns unchanged if not workspace-specific", () => { + const result = fromWorkspaceSpecificPath("f/test.variable.yaml", "main"); expect(result).toEqual("f/test.variable.yaml"); }); -test("fromBranchSpecificPath: handles resource files with extensions", () => { - const result = fromBranchSpecificPath("f/config.main.resource.file.json", "main"); +test("fromWorkspaceSpecificPath: handles resource files with extensions", () => { + const result = fromWorkspaceSpecificPath("f/config.main.resource.file.json", "main"); expect(result).toEqual("f/config.resource.file.json"); }); @@ -143,36 +143,36 @@ test("isSpecificItem: handles exact path patterns", () => { }); // ============================================================================= -// isBranchSpecificFile TESTS +// isWorkspaceSpecificFile TESTS // ============================================================================= -test("isBranchSpecificFile: detects branch-specific variable files", () => { - expect(isBranchSpecificFile("f/test.main.variable.yaml")).toEqual(true); - expect(isBranchSpecificFile("f/test.develop.variable.yaml")).toEqual(true); - expect(isBranchSpecificFile("f/test.feature_branch.variable.yaml")).toEqual(true); +test("isWorkspaceSpecificFile: detects workspace-specific variable files", () => { + expect(isWorkspaceSpecificFile("f/test.main.variable.yaml")).toEqual(true); + expect(isWorkspaceSpecificFile("f/test.develop.variable.yaml")).toEqual(true); + expect(isWorkspaceSpecificFile("f/test.feature_branch.variable.yaml")).toEqual(true); }); -test("isBranchSpecificFile: detects branch-specific resource files", () => { - expect(isBranchSpecificFile("u/admin/db.main.resource.yaml")).toEqual(true); - expect(isBranchSpecificFile("u/admin/db.staging.resource.yaml")).toEqual(true); +test("isWorkspaceSpecificFile: detects workspace-specific resource files", () => { + expect(isWorkspaceSpecificFile("u/admin/db.main.resource.yaml")).toEqual(true); + expect(isWorkspaceSpecificFile("u/admin/db.staging.resource.yaml")).toEqual(true); }); -test("isBranchSpecificFile: detects branch-specific trigger files", () => { - expect(isBranchSpecificFile("f/my.main.http_trigger.yaml")).toEqual(true); - expect(isBranchSpecificFile("f/my.develop.kafka_trigger.yaml")).toEqual(true); - expect(isBranchSpecificFile("f/my.main.websocket_trigger.yaml")).toEqual(true); +test("isWorkspaceSpecificFile: detects workspace-specific trigger files", () => { + expect(isWorkspaceSpecificFile("f/my.main.http_trigger.yaml")).toEqual(true); + expect(isWorkspaceSpecificFile("f/my.develop.kafka_trigger.yaml")).toEqual(true); + expect(isWorkspaceSpecificFile("f/my.main.websocket_trigger.yaml")).toEqual(true); }); -test("isBranchSpecificFile: returns false for non-branch-specific files", () => { - expect(isBranchSpecificFile("f/test.variable.yaml")).toEqual(false); - expect(isBranchSpecificFile("u/admin/db.resource.yaml")).toEqual(false); - expect(isBranchSpecificFile("f/my.http_trigger.yaml")).toEqual(false); - expect(isBranchSpecificFile("f/script.ts")).toEqual(false); +test("isWorkspaceSpecificFile: returns false for non-workspace-specific files", () => { + expect(isWorkspaceSpecificFile("f/test.variable.yaml")).toEqual(false); + expect(isWorkspaceSpecificFile("u/admin/db.resource.yaml")).toEqual(false); + expect(isWorkspaceSpecificFile("f/my.http_trigger.yaml")).toEqual(false); + expect(isWorkspaceSpecificFile("f/script.ts")).toEqual(false); }); -test("isBranchSpecificFile: handles resource files with extensions", () => { - expect(isBranchSpecificFile("f/config.main.resource.file.json")).toEqual(true); - expect(isBranchSpecificFile("f/config.resource.file.json")).toEqual(false); +test("isWorkspaceSpecificFile: handles resource files with extensions", () => { + expect(isWorkspaceSpecificFile("f/config.main.resource.file.json")).toEqual(true); + expect(isWorkspaceSpecificFile("f/config.resource.file.json")).toEqual(false); }); // ============================================================================= @@ -182,32 +182,32 @@ test("isBranchSpecificFile: handles resource files with extensions", () => { 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); + const branchSpecific = toWorkspaceSpecificPath(original, branch); + const restored = fromWorkspaceSpecificPath(branchSpecific, branch); expect(restored).toEqual(original); }); 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); + const branchSpecific = toWorkspaceSpecificPath(original, branch); + const restored = fromWorkspaceSpecificPath(branchSpecific, branch); expect(restored).toEqual(original); }); 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); + const branchSpecific = toWorkspaceSpecificPath(original, branch); + const restored = fromWorkspaceSpecificPath(branchSpecific, branch); expect(restored).toEqual(original); }); 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); + const branchSpecific = toWorkspaceSpecificPath(original, branch); + const restored = fromWorkspaceSpecificPath(branchSpecific, branch); expect(restored).toEqual(original); }); @@ -216,58 +216,58 @@ test("round-trip: resource file with extension", () => { // These tests validate that functions work correctly with explicit branch override // ============================================================================= -test("branchOverride: getBranchSpecificPath with override returns branch-specific path", () => { +test("branchOverride: getWorkspaceSpecificPath with override returns workspace-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"); + // When override is provided, it should return the workspace-specific path even outside git repo + const result = getWorkspaceSpecificPath("f/test.variable.yaml", config, "staging"); expect(result).toEqual("f/test.staging.variable.yaml"); }); -test("branchOverride: getBranchSpecificPath without override and not in git repo returns undefined", () => { +test("branchOverride: getWorkspaceSpecificPath 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 + const result = getWorkspaceSpecificPath("f/test.variable.yaml", config); + // In a git repo, this would return a workspace-specific path; outside, it would be undefined // We test the override case above which is deterministic }); -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"); +test("branchOverride: isCurrentWorkspaceFile with override uses provided branch", () => { + // Test that isCurrentWorkspaceFile uses the override branch instead of git detection + const result = isCurrentWorkspaceFile("f/test.staging.variable.yaml", "staging"); expect(result).toEqual(true); // Should return false for different branch - const resultOther = isCurrentBranchFile("f/test.staging.variable.yaml", "production"); + const resultOther = isCurrentWorkspaceFile("f/test.staging.variable.yaml", "production"); expect(resultOther).toEqual(false); - // Should return false for non-branch-specific file - const resultNonSpecific = isCurrentBranchFile("f/test.variable.yaml", "staging"); + // Should return false for non-workspace-specific file + const resultNonSpecific = isCurrentWorkspaceFile("f/test.variable.yaml", "staging"); expect(resultNonSpecific).toEqual(false); }); -test("branchOverride: isCurrentBranchFile with override handles sanitized branch names", () => { +test("branchOverride: isCurrentWorkspaceFile 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"); + const result = isCurrentWorkspaceFile("f/test.feature_my-branch.variable.yaml", "feature/my-branch"); expect(result).toEqual(true); // Different sanitized branch should return false - const resultOther = isCurrentBranchFile("f/test.feature_my-branch.variable.yaml", "feature/other-branch"); + const resultOther = isCurrentWorkspaceFile("f/test.feature_my-branch.variable.yaml", "feature/other-branch"); expect(resultOther).toEqual(false); }); test("branchOverride: getSpecificItemsForCurrentBranch with override returns correct config", () => { // Test that getSpecificItemsForCurrentBranch uses the override branch const config = { - gitBranches: { + workspaces: { staging: { specificItems: { variables: ["f/**"], @@ -298,7 +298,7 @@ test("branchOverride: getSpecificItemsForCurrentBranch with override returns cor test("branchOverride: getSpecificItemsForCurrentBranch with non-existent branch returns undefined", () => { const config = { - gitBranches: { + workspaces: { staging: { specificItems: { variables: ["f/**"], @@ -314,7 +314,7 @@ test("branchOverride: getSpecificItemsForCurrentBranch with non-existent branch test("branchOverride: getSpecificItemsForCurrentBranch merges common and branch items", () => { const config = { - gitBranches: { + workspaces: { commonSpecificItems: { variables: ["common/**"], resources: ["shared/**"], @@ -329,7 +329,7 @@ test("branchOverride: getSpecificItemsForCurrentBranch merges common and branch }; const result = getSpecificItemsForCurrentBranch(config as any, "develop"); - // Should merge common and branch-specific + // Should merge common and workspace-specific expect(result?.variables).toEqual(["common/**", "dev/**"]); expect(result?.resources).toEqual(["shared/**"]); expect(result?.triggers).toEqual(["dev/triggers/**"]); @@ -340,34 +340,34 @@ test("branchOverride: getSpecificItemsForCurrentBranch merges common and branch // Format: f/folder/folder.branchName.meta.yaml // ============================================================================= -test("toBranchSpecificPath: converts folder meta path to branch-specific", () => { +test("toWorkspaceSpecificPath: converts folder meta path to workspace-specific", () => { // f/my_folder/folder.meta.yaml -> f/my_folder/folder.main.meta.yaml - const result = toBranchSpecificPath("f/my_folder/folder.meta.yaml", "main"); + const result = toWorkspaceSpecificPath("f/my_folder/folder.meta.yaml", "main"); expect(result).toEqual("f/my_folder/folder.main.meta.yaml"); }); -test("toBranchSpecificPath: converts nested folder meta path to branch-specific", () => { - const result = toBranchSpecificPath("f/parent/child/folder.meta.yaml", "develop"); +test("toWorkspaceSpecificPath: converts nested folder meta path to workspace-specific", () => { + const result = toWorkspaceSpecificPath("f/parent/child/folder.meta.yaml", "develop"); expect(result).toEqual("f/parent/child/folder.develop.meta.yaml"); }); -test("toBranchSpecificPath: sanitizes branch name in folder path", () => { - const result = toBranchSpecificPath("f/env/folder.meta.yaml", "feature/test"); +test("toWorkspaceSpecificPath: sanitizes branch name in folder path", () => { + const result = toWorkspaceSpecificPath("f/env/folder.meta.yaml", "feature/test"); expect(result).toEqual("f/env/folder.feature_test.meta.yaml"); }); -test("fromBranchSpecificPath: converts branch-specific folder back to base", () => { - const result = fromBranchSpecificPath("f/my_folder/folder.main.meta.yaml", "main"); +test("fromWorkspaceSpecificPath: converts workspace-specific folder back to base", () => { + const result = fromWorkspaceSpecificPath("f/my_folder/folder.main.meta.yaml", "main"); expect(result).toEqual("f/my_folder/folder.meta.yaml"); }); -test("fromBranchSpecificPath: handles nested branch-specific folder", () => { - const result = fromBranchSpecificPath("f/parent/child/folder.develop.meta.yaml", "develop"); +test("fromWorkspaceSpecificPath: handles nested workspace-specific folder", () => { + const result = fromWorkspaceSpecificPath("f/parent/child/folder.develop.meta.yaml", "develop"); expect(result).toEqual("f/parent/child/folder.meta.yaml"); }); -test("fromBranchSpecificPath: handles sanitized branch names for folders", () => { - const result = fromBranchSpecificPath("f/env/folder.feature_test.meta.yaml", "feature/test"); +test("fromWorkspaceSpecificPath: handles sanitized branch names for folders", () => { + const result = fromWorkspaceSpecificPath("f/env/folder.feature_test.meta.yaml", "feature/test"); expect(result).toEqual("f/env/folder.meta.yaml"); }); @@ -388,43 +388,43 @@ test("isSpecificItem: matches folder paths with exact pattern", () => { expect(isSpecificItem("f/other/folder.meta.yaml", config)).toEqual(false); }); -test("isBranchSpecificFile: detects branch-specific folder files", () => { - expect(isBranchSpecificFile("f/my_folder/folder.main.meta.yaml")).toEqual(true); - expect(isBranchSpecificFile("f/my_folder/folder.develop.meta.yaml")).toEqual(true); - expect(isBranchSpecificFile("f/nested/path/folder.staging.meta.yaml")).toEqual(true); +test("isWorkspaceSpecificFile: detects workspace-specific folder files", () => { + expect(isWorkspaceSpecificFile("f/my_folder/folder.main.meta.yaml")).toEqual(true); + expect(isWorkspaceSpecificFile("f/my_folder/folder.develop.meta.yaml")).toEqual(true); + expect(isWorkspaceSpecificFile("f/nested/path/folder.staging.meta.yaml")).toEqual(true); }); -test("isBranchSpecificFile: returns false for non-branch-specific folder files", () => { - expect(isBranchSpecificFile("f/my_folder/folder.meta.yaml")).toEqual(false); - expect(isBranchSpecificFile("f/nested/path/folder.meta.yaml")).toEqual(false); +test("isWorkspaceSpecificFile: returns false for non-workspace-specific folder files", () => { + expect(isWorkspaceSpecificFile("f/my_folder/folder.meta.yaml")).toEqual(false); + expect(isWorkspaceSpecificFile("f/nested/path/folder.meta.yaml")).toEqual(false); }); -test("isCurrentBranchFile: detects branch-specific folder for current branch", () => { - expect(isCurrentBranchFile("f/my_folder/folder.staging.meta.yaml", "staging")).toEqual(true); - expect(isCurrentBranchFile("f/my_folder/folder.staging.meta.yaml", "production")).toEqual(false); - expect(isCurrentBranchFile("f/my_folder/folder.meta.yaml", "staging")).toEqual(false); +test("isCurrentWorkspaceFile: detects workspace-specific folder for current branch", () => { + expect(isCurrentWorkspaceFile("f/my_folder/folder.staging.meta.yaml", "staging")).toEqual(true); + expect(isCurrentWorkspaceFile("f/my_folder/folder.staging.meta.yaml", "production")).toEqual(false); + expect(isCurrentWorkspaceFile("f/my_folder/folder.meta.yaml", "staging")).toEqual(false); }); -test("isCurrentBranchFile: handles sanitized branch for folders", () => { - expect(isCurrentBranchFile("f/env/folder.feature_test.meta.yaml", "feature/test")).toEqual(true); - expect(isCurrentBranchFile("f/env/folder.feature_test.meta.yaml", "feature/other")).toEqual(false); +test("isCurrentWorkspaceFile: handles sanitized branch for folders", () => { + expect(isCurrentWorkspaceFile("f/env/folder.feature_test.meta.yaml", "feature/test")).toEqual(true); + expect(isCurrentWorkspaceFile("f/env/folder.feature_test.meta.yaml", "feature/other")).toEqual(false); }); test("round-trip: folder meta path conversion", () => { const original = "f/configs/env_folder/folder.meta.yaml"; const branch = "main"; - const branchSpecific = toBranchSpecificPath(original, branch); + const branchSpecific = toWorkspaceSpecificPath(original, branch); expect(branchSpecific).toEqual("f/configs/env_folder/folder.main.meta.yaml"); - const restored = fromBranchSpecificPath(branchSpecific, branch); + const restored = fromWorkspaceSpecificPath(branchSpecific, branch); expect(restored).toEqual(original); }); test("round-trip: folder meta with sanitized branch", () => { const original = "f/env/folder.meta.yaml"; const branch = "feature/new-env"; - const branchSpecific = toBranchSpecificPath(original, branch); + const branchSpecific = toWorkspaceSpecificPath(original, branch); expect(branchSpecific).toEqual("f/env/folder.feature_new-env.meta.yaml"); - const restored = fromBranchSpecificPath(branchSpecific, branch); + const restored = fromWorkspaceSpecificPath(branchSpecific, branch); expect(restored).toEqual(original); }); @@ -432,23 +432,23 @@ test("round-trip: folder meta with sanitized branch", () => { // SETTINGS BRANCH-SPECIFIC TESTS // ============================================================================= -test("toBranchSpecificPath: converts settings.yaml to branch-specific", () => { - const result = toBranchSpecificPath("settings.yaml", "main"); +test("toWorkspaceSpecificPath: converts settings.yaml to workspace-specific", () => { + const result = toWorkspaceSpecificPath("settings.yaml", "main"); expect(result).toEqual("settings.main.yaml"); }); -test("toBranchSpecificPath: sanitizes branch name in settings path", () => { - const result = toBranchSpecificPath("settings.yaml", "feature/test"); +test("toWorkspaceSpecificPath: sanitizes branch name in settings path", () => { + const result = toWorkspaceSpecificPath("settings.yaml", "feature/test"); expect(result).toEqual("settings.feature_test.yaml"); }); -test("fromBranchSpecificPath: converts branch-specific settings back to base", () => { - const result = fromBranchSpecificPath("settings.main.yaml", "main"); +test("fromWorkspaceSpecificPath: converts workspace-specific settings back to base", () => { + const result = fromWorkspaceSpecificPath("settings.main.yaml", "main"); expect(result).toEqual("settings.yaml"); }); -test("fromBranchSpecificPath: handles sanitized branch names for settings", () => { - const result = fromBranchSpecificPath("settings.feature_test.yaml", "feature/test"); +test("fromWorkspaceSpecificPath: handles sanitized branch names for settings", () => { + const result = fromWorkspaceSpecificPath("settings.feature_test.yaml", "feature/test"); expect(result).toEqual("settings.yaml"); }); @@ -473,49 +473,49 @@ test("isSpecificItem: does not match settings.yaml when settings is undefined", expect(isSpecificItem("settings.yaml", config)).toEqual(false); }); -test("isBranchSpecificFile: detects branch-specific settings files", () => { - expect(isBranchSpecificFile("settings.main.yaml")).toEqual(true); - expect(isBranchSpecificFile("settings.develop.yaml")).toEqual(true); - expect(isBranchSpecificFile("settings.feature_test.yaml")).toEqual(true); +test("isWorkspaceSpecificFile: detects workspace-specific settings files", () => { + expect(isWorkspaceSpecificFile("settings.main.yaml")).toEqual(true); + expect(isWorkspaceSpecificFile("settings.develop.yaml")).toEqual(true); + expect(isWorkspaceSpecificFile("settings.feature_test.yaml")).toEqual(true); }); -test("isBranchSpecificFile: returns false for non-branch-specific settings", () => { - expect(isBranchSpecificFile("settings.yaml")).toEqual(false); +test("isWorkspaceSpecificFile: returns false for non-workspace-specific settings", () => { + expect(isWorkspaceSpecificFile("settings.yaml")).toEqual(false); }); -test("isCurrentBranchFile: detects branch-specific settings for current branch", () => { - expect(isCurrentBranchFile("settings.staging.yaml", "staging")).toEqual(true); - expect(isCurrentBranchFile("settings.staging.yaml", "production")).toEqual(false); - expect(isCurrentBranchFile("settings.yaml", "staging")).toEqual(false); +test("isCurrentWorkspaceFile: detects workspace-specific settings for current branch", () => { + expect(isCurrentWorkspaceFile("settings.staging.yaml", "staging")).toEqual(true); + expect(isCurrentWorkspaceFile("settings.staging.yaml", "production")).toEqual(false); + expect(isCurrentWorkspaceFile("settings.yaml", "staging")).toEqual(false); }); -test("isCurrentBranchFile: handles sanitized branch for settings", () => { - expect(isCurrentBranchFile("settings.feature_test.yaml", "feature/test")).toEqual(true); - expect(isCurrentBranchFile("settings.feature_test.yaml", "feature/other")).toEqual(false); +test("isCurrentWorkspaceFile: handles sanitized branch for settings", () => { + expect(isCurrentWorkspaceFile("settings.feature_test.yaml", "feature/test")).toEqual(true); + expect(isCurrentWorkspaceFile("settings.feature_test.yaml", "feature/other")).toEqual(false); }); test("round-trip: settings path conversion", () => { const original = "settings.yaml"; const branch = "main"; - const branchSpecific = toBranchSpecificPath(original, branch); + const branchSpecific = toWorkspaceSpecificPath(original, branch); expect(branchSpecific).toEqual("settings.main.yaml"); - const restored = fromBranchSpecificPath(branchSpecific, branch); + const restored = fromWorkspaceSpecificPath(branchSpecific, branch); expect(restored).toEqual(original); }); test("round-trip: settings with sanitized branch", () => { const original = "settings.yaml"; const branch = "release/v1.0"; - const branchSpecific = toBranchSpecificPath(original, branch); + const branchSpecific = toWorkspaceSpecificPath(original, branch); expect(branchSpecific).toEqual("settings.release_v1_0.yaml"); - const restored = fromBranchSpecificPath(branchSpecific, branch); + const restored = fromWorkspaceSpecificPath(branchSpecific, branch); expect(restored).toEqual(original); }); // ============================================================================= // isItemTypeConfigured TESTS // This function checks if the TYPE is configured, not whether it matches pattern. -// Used to determine if branch-specific files should be used for this type. +// Used to determine if workspace-specific files should be used for this type. // ============================================================================= test("isItemTypeConfigured: returns false when specificItems is undefined", () => { @@ -628,11 +628,11 @@ test("isItemTypeConfigured: returns false for resource files when resources is N // ============================================================================= // BRANCH-SPECIFIC FILE FILTERING TESTS // These tests verify the expected filtering behavior: -// - When type IS configured: use branch-specific files, skip base files -// - When type is NOT configured: skip branch-specific files, use base files +// - When type IS configured: use workspace-specific files, skip base files +// - When type is NOT configured: skip workspace-specific files, use base files // ============================================================================= -test("filtering logic: folders - when NOT configured, branch-specific should be ignored", () => { +test("filtering logic: folders - when NOT configured, workspace-specific should be ignored", () => { // Config has variables but NOT folders const config: SpecificItemsConfig = { variables: ["f/**"], @@ -644,15 +644,15 @@ test("filtering logic: folders - when NOT configured, branch-specific should be // Folder type is NOT configured expect(isItemTypeConfigured(basePath, config)).toEqual(false); - // Therefore, branch-specific file detection should not apply to this type + // Therefore, workspace-specific file detection should not apply to this type // The sync logic should: - // 1. Skip branch-specific folder files (isBranchSpecificFile returns true) + // 1. Skip workspace-specific folder files (isWorkspaceSpecificFile returns true) // 2. Use the base file - expect(isBranchSpecificFile(branchSpecificPath)).toEqual(true); - expect(isBranchSpecificFile(basePath)).toEqual(false); + expect(isWorkspaceSpecificFile(branchSpecificPath)).toEqual(true); + expect(isWorkspaceSpecificFile(basePath)).toEqual(false); }); -test("filtering logic: folders - when IS configured and matches, use branch-specific", () => { +test("filtering logic: folders - when IS configured and matches, use workspace-specific", () => { const config: SpecificItemsConfig = { folders: ["f/my_folder"], }; @@ -667,15 +667,15 @@ test("filtering logic: folders - when IS configured and matches, use branch-spec expect(isSpecificItem(basePath, config)).toEqual(true); // The sync logic should: - // 1. Use branch-specific folder file (map to base path) + // 1. Use workspace-specific folder file (map to base path) // 2. Skip the base file - expect(isBranchSpecificFile(branchSpecificPath)).toEqual(true); - expect(fromBranchSpecificPath(branchSpecificPath, "main")).toEqual(basePath); + expect(isWorkspaceSpecificFile(branchSpecificPath)).toEqual(true); + expect(fromWorkspaceSpecificPath(branchSpecificPath, "main")).toEqual(basePath); }); -test("filtering logic: folders - when IS configured but doesn't match, skip branch-specific", () => { +test("filtering logic: folders - when IS configured but doesn't match, skip workspace-specific", () => { const config: SpecificItemsConfig = { - folders: ["f/env_*"], // Only env_ folders are branch-specific + folders: ["f/env_*"], // Only env_ folders are workspace-specific }; const basePath = "f/other_folder/folder.meta.yaml"; @@ -688,11 +688,11 @@ test("filtering logic: folders - when IS configured but doesn't match, skip bran expect(isSpecificItem(basePath, config)).toEqual(false); // The sync logic should: - // 1. Skip the branch-specific file (type configured but doesn't match) + // 1. Skip the workspace-specific file (type configured but doesn't match) // 2. Use the base file }); -test("filtering logic: settings - when NOT configured, branch-specific should be ignored", () => { +test("filtering logic: settings - when NOT configured, workspace-specific should be ignored", () => { // Config has variables but NOT settings const config: SpecificItemsConfig = { variables: ["f/**"], @@ -704,12 +704,12 @@ test("filtering logic: settings - when NOT configured, branch-specific should be // Settings type is NOT configured expect(isItemTypeConfigured(basePath, config)).toEqual(false); - // Therefore, branch-specific file detection should not apply to this type - expect(isBranchSpecificFile(branchSpecificPath)).toEqual(true); - expect(isBranchSpecificFile(basePath)).toEqual(false); + // Therefore, workspace-specific file detection should not apply to this type + expect(isWorkspaceSpecificFile(branchSpecificPath)).toEqual(true); + expect(isWorkspaceSpecificFile(basePath)).toEqual(false); }); -test("filtering logic: settings - when IS configured (true), use branch-specific", () => { +test("filtering logic: settings - when IS configured (true), use workspace-specific", () => { const config: SpecificItemsConfig = { settings: true, }; @@ -723,12 +723,12 @@ test("filtering logic: settings - when IS configured (true), use branch-specific // And settings: true means it matches expect(isSpecificItem(basePath, config)).toEqual(true); - // The sync logic should use branch-specific file - expect(isBranchSpecificFile(branchSpecificPath)).toEqual(true); - expect(fromBranchSpecificPath(branchSpecificPath, "main")).toEqual(basePath); + // The sync logic should use workspace-specific file + expect(isWorkspaceSpecificFile(branchSpecificPath)).toEqual(true); + expect(fromWorkspaceSpecificPath(branchSpecificPath, "main")).toEqual(basePath); }); -test("filtering logic: settings - when IS configured (false), skip branch-specific", () => { +test("filtering logic: settings - when IS configured (false), skip workspace-specific", () => { // settings: false means type is configured but explicitly disabled const config: SpecificItemsConfig = { settings: false, @@ -743,10 +743,10 @@ test("filtering logic: settings - when IS configured (false), skip branch-specif // But settings: false means it doesn't match (not a specific item) expect(isSpecificItem(basePath, config)).toEqual(false); - // The sync logic should skip branch-specific file and use base + // The sync logic should skip workspace-specific file and use base }); -test("filtering logic: variables - when NOT configured, branch-specific should be ignored", () => { +test("filtering logic: variables - when NOT configured, workspace-specific should be ignored", () => { // Config has folders but NOT variables const config: SpecificItemsConfig = { folders: ["f/env_*"], @@ -759,11 +759,11 @@ test("filtering logic: variables - when NOT configured, branch-specific should b expect(isItemTypeConfigured(basePath, config)).toEqual(false); // Branch-specific variable files should be ignored - expect(isBranchSpecificFile(branchSpecificPath)).toEqual(true); - expect(isBranchSpecificFile(basePath)).toEqual(false); + expect(isWorkspaceSpecificFile(branchSpecificPath)).toEqual(true); + expect(isWorkspaceSpecificFile(basePath)).toEqual(false); }); -test("filtering logic: resources - when NOT configured, branch-specific should be ignored", () => { +test("filtering logic: resources - when NOT configured, workspace-specific should be ignored", () => { // Config has folders but NOT resources const config: SpecificItemsConfig = { folders: ["f/env_*"], @@ -775,11 +775,11 @@ test("filtering logic: resources - when NOT configured, branch-specific should b // Resource type is NOT configured expect(isItemTypeConfigured(basePath, config)).toEqual(false); - expect(isBranchSpecificFile(branchSpecificPath)).toEqual(true); - expect(isBranchSpecificFile(basePath)).toEqual(false); + expect(isWorkspaceSpecificFile(branchSpecificPath)).toEqual(true); + expect(isWorkspaceSpecificFile(basePath)).toEqual(false); }); -test("filtering logic: triggers - when NOT configured, branch-specific should be ignored", () => { +test("filtering logic: triggers - when NOT configured, workspace-specific should be ignored", () => { // Config has folders but NOT triggers const config: SpecificItemsConfig = { folders: ["f/env_*"], @@ -791,8 +791,8 @@ test("filtering logic: triggers - when NOT configured, branch-specific should be // Trigger type is NOT configured expect(isItemTypeConfigured(basePath, config)).toEqual(false); - expect(isBranchSpecificFile(branchSpecificPath)).toEqual(true); - expect(isBranchSpecificFile(basePath)).toEqual(false); + expect(isWorkspaceSpecificFile(branchSpecificPath)).toEqual(true); + expect(isWorkspaceSpecificFile(basePath)).toEqual(false); }); // ============================================================================= diff --git a/cli/test/workspaces_config.test.ts b/cli/test/workspaces_config.test.ts new file mode 100644 index 0000000000..cda8f5bc70 --- /dev/null +++ b/cli/test/workspaces_config.test.ts @@ -0,0 +1,520 @@ +import { expect, test, describe } from "bun:test"; +import { writeFile, mkdir, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { withTestBackend } from "./test_backend.ts"; +import { addWorkspace } from "../workspace.ts"; +import { parseJsonFromCLIOutput } from "./test_config_helpers.ts"; +import { getEffectiveSettings, readConfigFile, convertGitBranchesToWorkspaces, findWorkspaceByGitBranch, getEffectiveWorkspaceId, getEffectiveGitBranch, getWorkspaceNames, type SyncOptions } from "../src/core/conf.ts"; +import { getSpecificItemsForCurrentBranch } from "../src/core/specific_items.ts"; + +// ============================================================================= +// Helper +// ============================================================================= + +async function setupWorkspaceProfile(backend: any, workspaceName: string): Promise { + await addWorkspace({ + remote: backend.baseUrl, + workspaceId: backend.workspace, + name: workspaceName, + token: backend.token, + }, { force: true, configDir: backend.testConfigDir }); +} + +// ============================================================================= +// UNIT TESTS: workspaces config helpers +// ============================================================================= + +describe("findWorkspaceByGitBranch", () => { + test("finds workspace by key when gitBranch not set (default)", () => { + const ws = { staging: { baseUrl: "https://staging.wm.dev" } }; + const match = findWorkspaceByGitBranch(ws as any, "staging"); + expect(match).toBeDefined(); + expect(match![0]).toEqual("staging"); + }); + + test("finds workspace by explicit gitBranch", () => { + const ws = { production: { gitBranch: "main", baseUrl: "https://app.wm.dev" } }; + const match = findWorkspaceByGitBranch(ws as any, "main"); + expect(match).toBeDefined(); + expect(match![0]).toEqual("production"); + }); + + test("does not find workspace when gitBranch differs", () => { + const ws = { production: { gitBranch: "main", baseUrl: "https://app.wm.dev" } }; + const match = findWorkspaceByGitBranch(ws as any, "production"); + expect(match).toBeUndefined(); + }); + + test("returns undefined for undefined workspaces", () => { + expect(findWorkspaceByGitBranch(undefined, "main")).toBeUndefined(); + }); + + test("skips commonSpecificItems", () => { + const ws = { commonSpecificItems: { variables: ["f/**"] }, staging: { baseUrl: "x" } }; + const match = findWorkspaceByGitBranch(ws as any, "commonSpecificItems"); + expect(match).toBeUndefined(); + }); + + test("returns first match when multiple map to same gitBranch", () => { + const ws = { + alpha: { gitBranch: "develop", baseUrl: "a" }, + beta: { gitBranch: "develop", baseUrl: "b" }, + }; + const match = findWorkspaceByGitBranch(ws as any, "develop"); + expect(match![0]).toEqual("alpha"); + }); +}); + +describe("getEffectiveWorkspaceId", () => { + test("returns workspaceId when set", () => { + expect(getEffectiveWorkspaceId("prod", { workspaceId: "production" })).toEqual("production"); + }); + + test("defaults to workspace name when workspaceId not set", () => { + expect(getEffectiveWorkspaceId("staging", {})).toEqual("staging"); + }); +}); + +describe("getEffectiveGitBranch", () => { + test("returns gitBranch when set", () => { + expect(getEffectiveGitBranch("production", { gitBranch: "main" })).toEqual("main"); + }); + + test("defaults to workspace name when gitBranch not set", () => { + expect(getEffectiveGitBranch("staging", {})).toEqual("staging"); + }); +}); + +describe("getWorkspaceNames", () => { + test("excludes commonSpecificItems", () => { + const ws = { staging: {}, production: {}, commonSpecificItems: { variables: [] } }; + expect(getWorkspaceNames(ws as any)).toEqual(["staging", "production"]); + }); + + test("returns empty for undefined", () => { + expect(getWorkspaceNames(undefined)).toEqual([]); + }); +}); + +// ============================================================================= +// UNIT TESTS: convertGitBranchesToWorkspaces +// ============================================================================= + +describe("convertGitBranchesToWorkspaces", () => { + test("preserves all fields from old branch entries", () => { + const old = { + main: { + baseUrl: "https://app.wm.dev", + workspaceId: "production", + overrides: { skipSecrets: false }, + promotionOverrides: { skipSecrets: true }, + specificItems: { variables: ["f/**"] }, + stateful: true, + message: "auto", + }, + }; + const ws = convertGitBranchesToWorkspaces(old as any); + const entry = (ws as any).main; + expect(entry.baseUrl).toEqual("https://app.wm.dev"); + expect(entry.workspaceId).toEqual("production"); + expect(entry.overrides).toEqual({ skipSecrets: false }); + expect(entry.promotionOverrides).toEqual({ skipSecrets: true }); + expect(entry.specificItems).toEqual({ variables: ["f/**"] }); + expect(entry.stateful).toEqual(true); + expect(entry.message).toEqual("auto"); + }); + + test("preserves commonSpecificItems", () => { + const old = { + commonSpecificItems: { variables: ["f/shared/**"], settings: true }, + main: { overrides: {} }, + }; + const ws = convertGitBranchesToWorkspaces(old as any); + expect(ws.commonSpecificItems).toEqual({ variables: ["f/shared/**"], settings: true }); + }); + + test("handles empty entries", () => { + const ws = convertGitBranchesToWorkspaces({ main: {} } as any); + expect((ws as any).main).toEqual({}); + }); + + test("does not set gitBranch (defaults to key name)", () => { + const ws = convertGitBranchesToWorkspaces({ main: { baseUrl: "x" } } as any); + expect((ws as any).main.gitBranch).toBeUndefined(); + }); +}); + +// ============================================================================= +// UNIT TESTS: getEffectiveSettings with workspaces config +// ============================================================================= + +describe("getEffectiveSettings with workspaces", () => { + test("applies overrides by workspace name", async () => { + const config: SyncOptions = { + includes: ["f/**"], + workspaces: { + staging: { overrides: { includes: ["staging/**"], skipVariables: true } }, + production: { overrides: { skipSecrets: false } }, + }, + }; + const s = await getEffectiveSettings(config, undefined, true, true, "staging"); + expect(s.includes).toEqual(["staging/**"]); + expect(s.skipVariables).toEqual(true); + }); + + test("returns top-level settings for unknown workspace", async () => { + const config: SyncOptions = { includes: ["f/**"], workspaces: { staging: { overrides: {} } } }; + const s = await getEffectiveSettings(config, undefined, true, true, "nonexistent"); + expect(s.includes).toEqual(["f/**"]); + }); + + test("promotion resolves by gitBranch", async () => { + const config: SyncOptions = { + workspaces: { + production: { + gitBranch: "main", + promotionOverrides: { skipSecrets: false }, + }, + }, + }; + // --promotion main should find workspace "production" via gitBranch match + const s = await getEffectiveSettings(config, "main", true, true); + expect(s.skipSecrets).toEqual(false); + }); +}); + +// ============================================================================= +// UNIT TESTS: getSpecificItemsForCurrentBranch with workspaces +// ============================================================================= + +describe("getSpecificItemsForCurrentBranch with workspaces", () => { + test("looks up by workspace name override", () => { + const config: SyncOptions = { + workspaces: { + staging: { specificItems: { variables: ["f/staging/**"] } }, + production: { specificItems: { variables: ["f/prod/**"] } }, + commonSpecificItems: { resources: ["f/shared/**"] }, + }, + }; + const items = getSpecificItemsForCurrentBranch(config, "staging"); + expect(items?.variables).toEqual(["f/staging/**"]); + expect(items?.resources).toEqual(["f/shared/**"]); + }); + + test("returns undefined for unknown workspace", () => { + const config: SyncOptions = { + workspaces: { staging: { specificItems: { variables: ["f/**"] } } }, + }; + const items = getSpecificItemsForCurrentBranch(config, "nonexistent"); + expect(items).toBeUndefined(); + }); + + test("returns undefined when no workspaces config", () => { + expect(getSpecificItemsForCurrentBranch({}, "staging")).toBeUndefined(); + }); + + test("merges common and workspace-specific items", () => { + const config: SyncOptions = { + workspaces: { + commonSpecificItems: { variables: ["common/**"], resources: ["shared/**"] }, + dev: { specificItems: { variables: ["dev/**"], triggers: ["dev/triggers/**"] } }, + }, + }; + const items = getSpecificItemsForCurrentBranch(config, "dev"); + expect(items?.variables).toEqual(["common/**", "dev/**"]); + expect(items?.resources).toEqual(["shared/**"]); + expect(items?.triggers).toEqual(["dev/triggers/**"]); + }); +}); + +// ============================================================================= +// INTEGRATION TESTS: new workspaces config format +// ============================================================================= + +test("Integration: workspaces config with --branch applies correct overrides", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend, "ws_test"); + + // New workspaces config with workspace name ≠ gitBranch + await writeFile(`${tempDir}/wmill.yaml`, `defaultTs: bun +includes: + - "**" +skipVariables: false + +workspaces: + production: + gitBranch: prod_branch + overrides: + skipVariables: true + staging: + overrides: + skipVariables: false`, "utf-8"); + + // --branch prod_branch should resolve to workspace "production" and skip variables + const result = await backend.runCLICommand([ + 'sync', 'pull', + '--branch', 'prod_branch', + '--dry-run', + '--json-output' + ], tempDir); + + expect(result.code).toEqual(0); + const output = parseJsonFromCLIOutput(result.stdout); + const paths = (output.changes || []).map((c: any) => c.path); + const hasVariables = paths.some((p: string) => p.includes('.variable.yaml')); + expect(hasVariables).toEqual(false); + }); +}); + +test("Integration: workspaces config with --branch resolves default gitBranch", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend, "ws_test"); + + // Workspace name = "staging", gitBranch defaults to "staging" + await writeFile(`${tempDir}/wmill.yaml`, `defaultTs: bun +includes: + - "**" +skipVariables: false + +workspaces: + staging: + overrides: + skipVariables: true`, "utf-8"); + + // --branch staging resolves to workspace "staging" + const result = await backend.runCLICommand([ + 'sync', 'pull', + '--branch', 'staging', + '--dry-run', + '--json-output' + ], tempDir); + + expect(result.code).toEqual(0); + const output = parseJsonFromCLIOutput(result.stdout); + const paths = (output.changes || []).map((c: any) => c.path); + const hasVariables = paths.some((p: string) => p.includes('.variable.yaml')); + expect(hasVariables).toEqual(false); + }); +}); + +test("Integration: different workspaces have different overrides", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend, "ws_test"); + + await writeFile(`${tempDir}/wmill.yaml`, `defaultTs: bun +includes: + - "**" +skipVariables: false +skipResources: false + +workspaces: + production: + gitBranch: prod + overrides: + skipVariables: true + skipResources: true + development: + gitBranch: dev + overrides: + skipVariables: false + skipResources: false`, "utf-8"); + + // prod should skip both + const prodResult = await backend.runCLICommand([ + 'sync', 'pull', '--branch', 'prod', '--dry-run', '--json-output' + ], tempDir); + expect(prodResult.code).toEqual(0); + const prodPaths = (parseJsonFromCLIOutput(prodResult.stdout).changes || []).map((c: any) => c.path); + expect(prodPaths.some((p: string) => p.includes('.variable.yaml'))).toEqual(false); + expect(prodPaths.some((p: string) => p.includes('.resource.yaml'))).toEqual(false); + + // dev should include both + const devResult = await backend.runCLICommand([ + 'sync', 'pull', '--branch', 'dev', '--dry-run', '--json-output' + ], tempDir); + expect(devResult.code).toEqual(0); + const devPaths = (parseJsonFromCLIOutput(devResult.stdout).changes || []).map((c: any) => c.path); + expect(devPaths.some((p: string) => p.includes('.variable.yaml'))).toEqual(true); + expect(devPaths.some((p: string) => p.includes('.resource.yaml'))).toEqual(true); + }); +}); + +test("Integration: legacy gitBranches config still works via normalization", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend, "legacy_test"); + + // Old gitBranches format on disk + await writeFile(`${tempDir}/wmill.yaml`, `defaultTs: bun +includes: + - "**" +skipVariables: false + +gitBranches: + legacy_branch: + overrides: + skipVariables: true`, "utf-8"); + + const result = await backend.runCLICommand([ + 'sync', 'pull', '--branch', 'legacy_branch', '--dry-run', '--json-output' + ], tempDir); + expect(result.code).toEqual(0); + const paths = (parseJsonFromCLIOutput(result.stdout).changes || []).map((c: any) => c.path); + expect(paths.some((p: string) => p.includes('.variable.yaml'))).toEqual(false); + }); +}); + +test("Integration: legacy environments config still works via normalization", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend, "env_test"); + + // Old environments format on disk + await writeFile(`${tempDir}/wmill.yaml`, `defaultTs: bun +includes: + - "**" +skipVariables: false + +environments: + env_branch: + overrides: + skipVariables: true`, "utf-8"); + + const result = await backend.runCLICommand([ + 'sync', 'pull', '--branch', 'env_branch', '--dry-run', '--json-output' + ], tempDir); + expect(result.code).toEqual(0); + const paths = (parseJsonFromCLIOutput(result.stdout).changes || []).map((c: any) => c.path); + expect(paths.some((p: string) => p.includes('.variable.yaml'))).toEqual(false); + }); +}); + +test("Integration: workspace-specific files use workspace name as suffix", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend, "specific_test"); + + // Workspace "production" with gitBranch "main" and specificItems + await writeFile(`${tempDir}/wmill.yaml`, `defaultTs: bun +includes: + - "**" + +workspaces: + production: + gitBranch: prod_branch + specificItems: + variables: + - "f/**"`, "utf-8"); + + // Pull with --branch prod_branch → resolves to workspace "production" + // workspace-specific files should use "production" as suffix (the workspace name) + const result = await backend.runCLICommand([ + 'sync', 'pull', '--branch', 'prod_branch', '--dry-run', '--json-output' + ], tempDir); + expect(result.code).toEqual(0); + + const output = parseJsonFromCLIOutput(result.stdout); + const changes = output.changes || []; + + // Check that workspace-specific paths use the workspace name "production" as suffix + const wsSpecificPaths = changes + .filter((c: any) => c.workspace_specific_path) + .map((c: any) => c.workspace_specific_path); + + for (const p of wsSpecificPaths) { + expect(p).toContain(".production."); + expect(p).not.toContain(".prod_branch."); + } + }); +}); + +test("Integration: promotion with workspaces config", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend, "promo_test"); + + await writeFile(`${tempDir}/wmill.yaml`, `defaultTs: bun +includes: + - "**" +skipVariables: false + +workspaces: + staging: + gitBranch: staging_branch + overrides: + skipVariables: false + promotionOverrides: + skipVariables: true`, "utf-8"); + + // --promotion staging_branch should find workspace "staging" via gitBranch + // and apply promotionOverrides (skipVariables: true) + const result = await backend.runCLICommand([ + 'sync', 'pull', + '--branch', 'staging_branch', + '--promotion', 'staging_branch', + '--dry-run', + '--json-output' + ], tempDir); + expect(result.code).toEqual(0); + const paths = (parseJsonFromCLIOutput(result.stdout).changes || []).map((c: any) => c.path); + expect(paths.some((p: string) => p.includes('.variable.yaml'))).toEqual(false); + }); +}); + +test("Integration: config migrate converts gitBranches to workspaces", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend, "migrate_test"); + + // Write old format + const yamlPath = `${tempDir}/wmill.yaml`; + await writeFile(yamlPath, `defaultTs: bun +includes: + - "f/**" + +gitBranches: + main: + baseUrl: https://app.windmill.dev + workspaceId: production + overrides: + skipSecrets: false + staging: + baseUrl: https://staging.windmill.dev + overrides: + includeSchedules: true + commonSpecificItems: + variables: + - "f/shared/**"`, "utf-8"); + + // Run migrate + const result = await backend.runCLICommand([ + 'config', 'migrate', + ], tempDir); + expect(result.code).toEqual(0); + + // Read back and verify + const migrated = await readFile(yamlPath, "utf-8"); + expect(migrated).toContain("workspaces:"); + expect(migrated).not.toContain("gitBranches:"); + expect(migrated).toContain("production"); + expect(migrated).toContain("staging"); + expect(migrated).toContain("commonSpecificItems"); + expect(migrated).toContain("f/shared/**"); + }); +}); + +test("Integration: config migrate is idempotent", async () => { + await withTestBackend(async (backend, tempDir) => { + await setupWorkspaceProfile(backend, "idempotent_test"); + + const yamlPath = `${tempDir}/wmill.yaml`; + await writeFile(yamlPath, `defaultTs: bun +workspaces: + staging: + baseUrl: https://staging.wm.dev + overrides: {}`, "utf-8"); + + const result = await backend.runCLICommand(['config', 'migrate'], tempDir); + expect(result.code).toEqual(0); + + // File should be unchanged + const content = await readFile(yamlPath, "utf-8"); + expect(content).toContain("workspaces:"); + expect(content).not.toContain("gitBranches:"); + }); +}); diff --git a/cli/wmill.schema.json b/cli/wmill.schema.json index 7129563f3f..6acbd4794b 100644 --- a/cli/wmill.schema.json +++ b/cli/wmill.schema.json @@ -222,45 +222,45 @@ "additionalProperties": false } }, - "gitBranches": { + "workspaces": { "type": "object", - "description": "Map git branches to workspaces and per-branch sync overrides", + "description": "Map workspace names to Windmill instances and per-workspace sync overrides", "properties": { "commonSpecificItems": { "type": "object", - "description": "Sync only specific items", + "description": "Items to sync per-workspace (stored as ...yaml on disk)", "properties": { "variables": { "type": "array", "items": { "type": "string" }, - "description": "Specific variable paths to sync" + "description": "Variable path patterns to sync per-workspace" }, "resources": { "type": "array", "items": { "type": "string" }, - "description": "Specific resource paths to sync" + "description": "Resource path patterns to sync per-workspace" }, "triggers": { "type": "array", "items": { "type": "string" }, - "description": "Specific trigger paths to sync" + "description": "Trigger path patterns to sync per-workspace" }, "folders": { "type": "array", "items": { "type": "string" }, - "description": "Specific folder paths to sync" + "description": "Folder path patterns to sync per-workspace" }, "settings": { "type": "boolean", - "description": "Whether to sync settings" + "description": "Whether to sync settings per-workspace" } }, "additionalProperties": false @@ -269,17 +269,21 @@ "additionalProperties": { "type": "object", "properties": { - "baseUrl": { + "gitBranch": { "type": "string", - "description": "Windmill instance URL for this branch" + "description": "Git branch name (defaults to the workspace name/key)" }, "workspaceId": { "type": "string", - "description": "Workspace ID to sync with for this branch" + "description": "Workspace ID to sync with (defaults to the workspace name/key)" + }, + "baseUrl": { + "type": "string", + "description": "Windmill instance URL for this workspace" }, "overrides": { "type": "object", - "description": "Override any top-level sync option for this branch" + "description": "Override any top-level sync option for this workspace" }, "promotionOverrides": { "type": "object", @@ -287,39 +291,149 @@ }, "specificItems": { "type": "object", - "description": "Sync only specific items", + "description": "Items to sync per-workspace (stored as ...yaml on disk)", "properties": { "variables": { "type": "array", "items": { "type": "string" }, - "description": "Specific variable paths to sync" + "description": "Variable path patterns to sync per-workspace" }, "resources": { "type": "array", "items": { "type": "string" }, - "description": "Specific resource paths to sync" + "description": "Resource path patterns to sync per-workspace" }, "triggers": { "type": "array", "items": { "type": "string" }, - "description": "Specific trigger paths to sync" + "description": "Trigger path patterns to sync per-workspace" }, "folders": { "type": "array", "items": { "type": "string" }, - "description": "Specific folder paths to sync" + "description": "Folder path patterns to sync per-workspace" }, "settings": { "type": "boolean", - "description": "Whether to sync settings" + "description": "Whether to sync settings per-workspace" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + }, + "gitBranches": { + "type": "object", + "description": "[Deprecated] Use 'workspaces' instead. Map git branches to workspaces.", + "properties": { + "commonSpecificItems": { + "type": "object", + "description": "Items to sync per-workspace (stored as ...yaml on disk)", + "properties": { + "variables": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Variable path patterns to sync per-workspace" + }, + "resources": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Resource path patterns to sync per-workspace" + }, + "triggers": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Trigger path patterns to sync per-workspace" + }, + "folders": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Folder path patterns to sync per-workspace" + }, + "settings": { + "type": "boolean", + "description": "Whether to sync settings per-workspace" + } + }, + "additionalProperties": false + } + }, + "additionalProperties": { + "type": "object", + "properties": { + "gitBranch": { + "type": "string", + "description": "Git branch name (defaults to the workspace name/key)" + }, + "workspaceId": { + "type": "string", + "description": "Workspace ID to sync with (defaults to the workspace name/key)" + }, + "baseUrl": { + "type": "string", + "description": "Windmill instance URL for this workspace" + }, + "overrides": { + "type": "object", + "description": "Override any top-level sync option for this workspace" + }, + "promotionOverrides": { + "type": "object", + "description": "Overrides applied when using --promotion flag" + }, + "specificItems": { + "type": "object", + "description": "Items to sync per-workspace (stored as ...yaml on disk)", + "properties": { + "variables": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Variable path patterns to sync per-workspace" + }, + "resources": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Resource path patterns to sync per-workspace" + }, + "triggers": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Trigger path patterns to sync per-workspace" + }, + "folders": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Folder path patterns to sync per-workspace" + }, + "settings": { + "type": "boolean", + "description": "Whether to sync settings per-workspace" } }, "additionalProperties": false @@ -330,43 +444,43 @@ }, "environments": { "type": "object", - "description": "Alias for gitBranches — use if you prefer environment-based terminology", + "description": "[Deprecated] Use 'workspaces' instead.", "properties": { "commonSpecificItems": { "type": "object", - "description": "Sync only specific items", + "description": "Items to sync per-workspace (stored as ...yaml on disk)", "properties": { "variables": { "type": "array", "items": { "type": "string" }, - "description": "Specific variable paths to sync" + "description": "Variable path patterns to sync per-workspace" }, "resources": { "type": "array", "items": { "type": "string" }, - "description": "Specific resource paths to sync" + "description": "Resource path patterns to sync per-workspace" }, "triggers": { "type": "array", "items": { "type": "string" }, - "description": "Specific trigger paths to sync" + "description": "Trigger path patterns to sync per-workspace" }, "folders": { "type": "array", "items": { "type": "string" }, - "description": "Specific folder paths to sync" + "description": "Folder path patterns to sync per-workspace" }, "settings": { "type": "boolean", - "description": "Whether to sync settings" + "description": "Whether to sync settings per-workspace" } }, "additionalProperties": false @@ -375,17 +489,21 @@ "additionalProperties": { "type": "object", "properties": { - "baseUrl": { + "gitBranch": { "type": "string", - "description": "Windmill instance URL for this branch" + "description": "Git branch name (defaults to the workspace name/key)" }, "workspaceId": { "type": "string", - "description": "Workspace ID to sync with for this branch" + "description": "Workspace ID to sync with (defaults to the workspace name/key)" + }, + "baseUrl": { + "type": "string", + "description": "Windmill instance URL for this workspace" }, "overrides": { "type": "object", - "description": "Override any top-level sync option for this branch" + "description": "Override any top-level sync option for this workspace" }, "promotionOverrides": { "type": "object", @@ -393,39 +511,39 @@ }, "specificItems": { "type": "object", - "description": "Sync only specific items", + "description": "Items to sync per-workspace (stored as ...yaml on disk)", "properties": { "variables": { "type": "array", "items": { "type": "string" }, - "description": "Specific variable paths to sync" + "description": "Variable path patterns to sync per-workspace" }, "resources": { "type": "array", "items": { "type": "string" }, - "description": "Specific resource paths to sync" + "description": "Resource path patterns to sync per-workspace" }, "triggers": { "type": "array", "items": { "type": "string" }, - "description": "Specific trigger paths to sync" + "description": "Trigger path patterns to sync per-workspace" }, "folders": { "type": "array", "items": { "type": "string" }, - "description": "Specific folder paths to sync" + "description": "Folder path patterns to sync per-workspace" }, "settings": { "type": "boolean", - "description": "Whether to sync settings" + "description": "Whether to sync settings per-workspace" } }, "additionalProperties": false diff --git a/frontend/src/lib/components/git_sync/PullWorkspaceModal.svelte b/frontend/src/lib/components/git_sync/PullWorkspaceModal.svelte index 0c7abbb921..37af944362 100644 --- a/frontend/src/lib/components/git_sync/PullWorkspaceModal.svelte +++ b/frontend/src/lib/components/git_sync/PullWorkspaceModal.svelte @@ -606,17 +606,16 @@ git pull {#if !settingsOnly}# Push from git repository to workspace wmill sync push --workspace {$workspaceStore} --repository {gitRepoResourcePath} -{:else}# Edit wmill.yaml file + {:else}# Edit wmill.yaml file vim wmill.yaml git add wmill.yaml - -{/if}# Commit changes + {/if}# Commit changes git commit git push {#if settingsOnly} -# Push settings only from git repository or click the pull settings button above{#if currentGitSyncSettings?.repositories?.[repoIndex!]?.use_individual_branch} -wmill gitsync-settings push --workspace {$workspaceStore} --repository {gitRepoResourcePath} --promotion main{:else} -wmill gitsync-settings push --workspace {$workspaceStore} --repository {gitRepoResourcePath}{/if}{/if} {#if currentGitSyncSettings?.repositories?.[repoIndex!]?.use_individual_branch && settingsOnly}
@@ -629,7 +628,7 @@ wmill gitsync-settings push --workspace {$workspaceStore} --repository {gitRepoR >
gitBranches:
+								>workspaces:
   main:
     promotionOverrides:
       # Add your promotion-specific settings here
diff --git a/frontend/src/lib/components/workspaceSettings/GitSyncFilterSettings.svelte b/frontend/src/lib/components/workspaceSettings/GitSyncFilterSettings.svelte
index e5da7bb417..1e2a0aaa75 100644
--- a/frontend/src/lib/components/workspaceSettings/GitSyncFilterSettings.svelte
+++ b/frontend/src/lib/components/workspaceSettings/GitSyncFilterSettings.svelte
@@ -424,7 +424,7 @@ git push
 										> file:
 									
gitBranches:
+										>workspaces:
   main:
     promotionOverrides:
       # Add your promotion-specific settings here
` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string). Useful to still take wmill.yaml into account and act as a second pattern to satisfy - `--repository ` - Specify repository path (e.g., u/user/repo) when multiple repositories exist - `--promotion ` - Use promotionOverrides from the specified branch instead of regular overrides - - `--branch, --env ` - Override the current git branch/environment (works even outside a git repository) + - `--branch, --env ` - [Deprecated: use --workspace] Override the current git branch/environment - `sync push` - Push any local changes and apply them remotely. - `--yes` - Push without needing confirmation - `--dry-run` - Show changes that would be pushed without actually pushing @@ -456,7 +460,7 @@ sync local with a remote workspaces or the opposite (push or pull) - `--message ` - Include a message that will be added to all scripts/flows/apps updated during this push - `--parallel ` - Number of changes to process in parallel - `--repository ` - Specify repository path (e.g., u/user/repo) when multiple repositories exist - - `--branch, --env ` - Override the current git branch/environment (works even outside a git repository) + - `--branch, --env ` - [Deprecated: use --workspace] Override the current git branch/environment - `--lint` - Run lint validation before pushing - `--locks-required` - Fail if scripts or flow inline scripts that need locks have no locks - `--auto-metadata` - Automatically regenerate stale metadata (locks and schemas) before pushing @@ -575,10 +579,11 @@ workspace related commands - `workspace list` - List local workspace profiles - `workspace list-remote` - List workspaces on the remote server that you have access to - `workspace list-forks` - List forked workspaces on the remote server -- `workspace bind` - Bind the current Git branch to the active workspace. This adds the branch to gitBranches in wmill.yaml so sync operations use the correct workspace for each branch. - - `--branch, --env ` - Specify branch/environment (defaults to current) -- `workspace unbind` - Remove workspace binding from the current Git branch - - `--branch, --env ` - Specify branch/environment (defaults to current) +- `workspace bind` - Create or update a workspace entry in wmill.yaml from the active profile + - `--workspace ` - Workspace name (default: current branch or workspaceId) + - `--branch ` - Git branch to associate (default: workspace name) +- `workspace unbind` - Remove baseUrl and workspaceId from a workspace entry + - `--workspace ` - Workspace to unbind - `workspace fork [workspace_name:string] [workspace_id:string]` - Create a forked workspace - `--create-workspace-name ` - Specify the workspace name. Ignored if --create is not specified or the workspace already exists. Will default to the workspace id. - `--color ` - Workspace color (hex code, e.g. #ff0000) diff --git a/system_prompts/auto-generated/prompts.ts b/system_prompts/auto-generated/prompts.ts index 9e2b01a6df..3ef60f6cd0 100644 --- a/system_prompts/auto-generated/prompts.ts +++ b/system_prompts/auto-generated/prompts.ts @@ -1588,6 +1588,10 @@ Show all available wmill.yaml configuration options **Options:** - \`--json\` - Output as JSON for programmatic consumption +**Subcommands:** + +- \`config migrate\` - Migrate wmill.yaml from gitBranches/environments to workspaces format + ### dependencies workspace dependencies related commands @@ -1959,7 +1963,7 @@ sync local with a remote workspaces or the opposite (push or pull) - \`--extra-includes \` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string). Useful to still take wmill.yaml into account and act as a second pattern to satisfy - \`--repository \` - Specify repository path (e.g., u/user/repo) when multiple repositories exist - \`--promotion \` - Use promotionOverrides from the specified branch instead of regular overrides - - \`--branch, --env \` - Override the current git branch/environment (works even outside a git repository) + - \`--branch, --env \` - [Deprecated: use --workspace] Override the current git branch/environment - \`sync push\` - Push any local changes and apply them remotely. - \`--yes\` - Push without needing confirmation - \`--dry-run\` - Show changes that would be pushed without actually pushing @@ -1990,7 +1994,7 @@ sync local with a remote workspaces or the opposite (push or pull) - \`--message \` - Include a message that will be added to all scripts/flows/apps updated during this push - \`--parallel \` - Number of changes to process in parallel - \`--repository \` - Specify repository path (e.g., u/user/repo) when multiple repositories exist - - \`--branch, --env \` - Override the current git branch/environment (works even outside a git repository) + - \`--branch, --env \` - [Deprecated: use --workspace] Override the current git branch/environment - \`--lint\` - Run lint validation before pushing - \`--locks-required\` - Fail if scripts or flow inline scripts that need locks have no locks - \`--auto-metadata\` - Automatically regenerate stale metadata (locks and schemas) before pushing @@ -2109,10 +2113,11 @@ workspace related commands - \`workspace list\` - List local workspace profiles - \`workspace list-remote\` - List workspaces on the remote server that you have access to - \`workspace list-forks\` - List forked workspaces on the remote server -- \`workspace bind\` - Bind the current Git branch to the active workspace. This adds the branch to gitBranches in wmill.yaml so sync operations use the correct workspace for each branch. - - \`--branch, --env \` - Specify branch/environment (defaults to current) -- \`workspace unbind\` - Remove workspace binding from the current Git branch - - \`--branch, --env \` - Specify branch/environment (defaults to current) +- \`workspace bind\` - Create or update a workspace entry in wmill.yaml from the active profile + - \`--workspace \` - Workspace name (default: current branch or workspaceId) + - \`--branch \` - Git branch to associate (default: workspace name) +- \`workspace unbind\` - Remove baseUrl and workspaceId from a workspace entry + - \`--workspace \` - Workspace to unbind - \`workspace fork [workspace_name:string] [workspace_id:string]\` - Create a forked workspace - \`--create-workspace-name \` - Specify the workspace name. Ignored if --create is not specified or the workspace already exists. Will default to the workspace id. - \`--color \` - Workspace color (hex code, e.g. #ff0000) diff --git a/system_prompts/auto-generated/skills/cli-commands/SKILL.md b/system_prompts/auto-generated/skills/cli-commands/SKILL.md index dadd6ce5aa..3da4f46f26 100644 --- a/system_prompts/auto-generated/skills/cli-commands/SKILL.md +++ b/system_prompts/auto-generated/skills/cli-commands/SKILL.md @@ -59,6 +59,10 @@ Show all available wmill.yaml configuration options **Options:** - `--json` - Output as JSON for programmatic consumption +**Subcommands:** + +- `config migrate` - Migrate wmill.yaml from gitBranches/environments to workspaces format + ### dependencies workspace dependencies related commands @@ -430,7 +434,7 @@ sync local with a remote workspaces or the opposite (push or pull) - `--extra-includes ` - Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string). Useful to still take wmill.yaml into account and act as a second pattern to satisfy - `--repository ` - Specify repository path (e.g., u/user/repo) when multiple repositories exist - `--promotion ` - Use promotionOverrides from the specified branch instead of regular overrides - - `--branch, --env ` - Override the current git branch/environment (works even outside a git repository) + - `--branch, --env ` - [Deprecated: use --workspace] Override the current git branch/environment - `sync push` - Push any local changes and apply them remotely. - `--yes` - Push without needing confirmation - `--dry-run` - Show changes that would be pushed without actually pushing @@ -461,7 +465,7 @@ sync local with a remote workspaces or the opposite (push or pull) - `--message ` - Include a message that will be added to all scripts/flows/apps updated during this push - `--parallel ` - Number of changes to process in parallel - `--repository ` - Specify repository path (e.g., u/user/repo) when multiple repositories exist - - `--branch, --env ` - Override the current git branch/environment (works even outside a git repository) + - `--branch, --env ` - [Deprecated: use --workspace] Override the current git branch/environment - `--lint` - Run lint validation before pushing - `--locks-required` - Fail if scripts or flow inline scripts that need locks have no locks - `--auto-metadata` - Automatically regenerate stale metadata (locks and schemas) before pushing @@ -580,10 +584,11 @@ workspace related commands - `workspace list` - List local workspace profiles - `workspace list-remote` - List workspaces on the remote server that you have access to - `workspace list-forks` - List forked workspaces on the remote server -- `workspace bind` - Bind the current Git branch to the active workspace. This adds the branch to gitBranches in wmill.yaml so sync operations use the correct workspace for each branch. - - `--branch, --env ` - Specify branch/environment (defaults to current) -- `workspace unbind` - Remove workspace binding from the current Git branch - - `--branch, --env ` - Specify branch/environment (defaults to current) +- `workspace bind` - Create or update a workspace entry in wmill.yaml from the active profile + - `--workspace ` - Workspace name (default: current branch or workspaceId) + - `--branch ` - Git branch to associate (default: workspace name) +- `workspace unbind` - Remove baseUrl and workspaceId from a workspace entry + - `--workspace ` - Workspace to unbind - `workspace fork [workspace_name:string] [workspace_id:string]` - Create a forked workspace - `--create-workspace-name ` - Specify the workspace name. Ignored if --create is not specified or the workspace already exists. Will default to the workspace id. - `--color ` - Workspace color (hex code, e.g. #ff0000)