feat: unify CLI config to workspaces, deprecate gitBranches/environments (#8767)

* refactor: unify CLI config to workspaces, deprecate gitBranches/environments

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore: update frontend examples and regenerate system prompts for workspaces config

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* test: update test files to use workspaces config instead of gitBranches

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: handle --branch with --base-url correctly in sync pull/push

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: warn when --workspace overrides auto-detected branch or misses config entry

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: show reason why workspace was selected in log message

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* docs: clarify specificItems file naming uses gitBranch as suffix

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: rename branch-specific to workspace-specific, use workspace name as file suffix

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: rename branch-specific to workspace-specific, add comprehensive integration tests

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: simplify bind and init to be workspace-centric

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: make bind/unbind interactive with --workspace and --branch flags

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: make bind interactive with profile selection, workspace name, and optional branch

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* refactor: init offers to bind workspace using same flow as wmill workspace bind

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: skip backend git-sync check in init when no workspace was bound

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: skip all API calls in init when no workspace was bound

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: log when RT namespace is skipped, offer to generate it after bind

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: warn when no workspace bound during init

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: init git-sync check uses bound workspace, not active profile

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: init uses selected profile directly, avoids re-resolving and duplicate prompt

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: init skips requireLogin, uses bound profile token directly

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: auto-pick or prompt workspace from config when no branch matches

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: show configured workspaces list and bind hint in resolution messages

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: cache bound profile to avoid duplicate profile selection prompts in init

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: hoist boundProfile scope, add 2 comprehensive integration tests covering all flows

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: rt.d.ts prompt defaults to no when file exists, better description

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: remove empty overrides from generated config, add specificItems hint

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: add inline comments for non-trivial fields, add overrides/promotionOverrides hints to bound workspaces

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* chore: regenerate system prompts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-04-09 15:46:34 -04:00
committed by GitHub
parent 29e7701972
commit 5b97092997
29 changed files with 2473 additions and 983 deletions

View File

@@ -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<string, any>;
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;

View File

@@ -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] = {};
}
}
}

View File

@@ -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<SyncOptions> = { ...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;
}
}
});

View File

@@ -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.")
);
}
}

View File

@@ -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 <file>.<workspaceName>.<type>.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<WorkspaceBinding, "baseUrl" | "workspaceId">;
/** 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.<branch>.*)
// Auto-expand additionalProperties (e.g., workspaces.<workspace>.*)
if (opt.additionalProperties && typeof opt.additionalProperties === "object" && opt.additionalProperties.properties) {
expandSchema(`${opt.name}.<branch>`, opt.additionalProperties as Record<string, any>, allRows);
expandSchema(`${opt.name}.<workspace>`, opt.additionalProperties as Record<string, any>, 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);
}

View File

@@ -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

View File

@@ -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
);

View File

@@ -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<SyncOptions> {
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<string>();
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 <branch:string>",
"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 <branch:string>",
"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(

View File

@@ -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);
}
}

View File

@@ -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}`;

View File

@@ -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."
);
}

View File

@@ -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 <branch:string>", "Specify branch/environment (defaults to current)")
.description("Create or update a workspace entry in wmill.yaml from the active profile")
.option("--workspace <name:string>", "Workspace name (default: current branch or workspaceId)")
.option("--branch <branch:string>", "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 <branch:string>", "Specify branch/environment (defaults to current)")
.description("Remove baseUrl and workspaceId from a workspace entry")
.option("--workspace <name:string>", "Workspace to unbind")
.action((opts) => bind(opts as any, false))
.command("fork")
.description("Create a forked workspace")

View File

@@ -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<SyncOptions>;
promotionOverrides?: Partial<SyncOptions>;
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<SyncOptions>;
promotionOverrides?: Partial<SyncOptions>;
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<SyncOptions>;
promotionOverrides?: Partial<SyncOptions>;
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<SyncOptions>;
promotionOverrides?: Partial<SyncOptions>;
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<SyncOptions> {
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<T>(
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<SyncOptions, "skipBranchValidation" | "yes">,
branchOverride?: string
workspaceNameOverride?: string
): Promise<void> {
// 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<string, string[]>();
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<SyncOptions> {
// 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<string, any> = {};
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;
}

View File

@@ -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<Workspace | undefined> {
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<Workspace> {
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);
}

View File

@@ -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<string, RegExp>();
const workspacePatternCache = new Map<string, RegExp>();
/**
* 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$|` +

View File

@@ -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 <patterns:file[]>\` - 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 <repo:string>\` - Specify repository path (e.g., u/user/repo) when multiple repositories exist
- \`--promotion <branch:string>\` - Use promotionOverrides from the specified branch instead of regular overrides
- \`--branch, --env <branch:string>\` - Override the current git branch/environment (works even outside a git repository)
- \`--branch, --env <branch:string>\` - [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 <message:string>\` - Include a message that will be added to all scripts/flows/apps updated during this push
- \`--parallel <number>\` - Number of changes to process in parallel
- \`--repository <repo:string>\` - Specify repository path (e.g., u/user/repo) when multiple repositories exist
- \`--branch, --env <branch:string>\` - Override the current git branch/environment (works even outside a git repository)
- \`--branch, --env <branch:string>\` - [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 <branch:string>\` - Specify branch/environment (defaults to current)
- \`workspace unbind\` - Remove workspace binding from the current Git branch
- \`--branch, --env <branch:string>\` - Specify branch/environment (defaults to current)
- \`workspace bind\` - Create or update a workspace entry in wmill.yaml from the active profile
- \`--workspace <name:string>\` - Workspace name (default: current branch or workspaceId)
- \`--branch <branch:string>\` - Git branch to associate (default: workspace name)
- \`workspace unbind\` - Remove baseUrl and workspaceId from a workspace entry
- \`--workspace <name:string>\` - Workspace to unbind
- \`workspace fork [workspace_name:string] [workspace_id:string]\` - Create a forked workspace
- \`--create-workspace-name <workspace_name:string>\` - Specify the workspace name. Ignored if --create is not specified or the workspace already exists. Will default to the workspace id.
- \`--color <color:string>\` - Workspace color (hex code, e.g. #ff0000)

View File

@@ -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

View File

@@ -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<void> {
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);
});
});

View File

@@ -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:");
});
});

View File

@@ -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.<branch>.baseUrl");
expect(output).toContain("gitBranches.<branch>.workspaceId");
expect(output).toContain("gitBranches.<branch>.specificItems.variables");
test("auto-expands workspaces sub-fields", () => {
expect(output).toContain("workspaces.<workspace>.gitBranch");
expect(output).toContain("workspaces.<workspace>.baseUrl");
expect(output).toContain("workspaces.<workspace>.workspaceId");
expect(output).toContain("workspaces.<workspace>.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.<workspace>");
});
});
@@ -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);

View File

@@ -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,

View File

@@ -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);
});
// =============================================================================

View File

@@ -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<void> {
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:");
});
});

View File

@@ -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 <file>.<workspaceName>.<type>.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 <file>.<workspaceName>.<type>.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 <file>.<workspaceName>.<type>.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 <file>.<workspaceName>.<type>.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 <file>.<workspaceName>.<type>.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 <file>.<workspaceName>.<type>.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

View File

@@ -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}</pre
# 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}</pre
>
{#if currentGitSyncSettings?.repositories?.[repoIndex!]?.use_individual_branch && settingsOnly}
<div class="text-xs text-primary mt-3">
@@ -629,7 +628,7 @@ wmill gitsync-settings push --workspace {$workspaceStore} --repository {gitRepoR
>
</div>
<pre class="text-xs bg-surface p-2 rounded mt-2 overflow-x-auto"
>gitBranches:
>workspaces:
main:
promotionOverrides:
# Add your promotion-specific settings here

View File

@@ -424,7 +424,7 @@ git push
> file:</div
>
<pre class="text-xs bg-surface p-2 rounded mt-2 overflow-x-auto"
>gitBranches:
>workspaces:
main:
promotionOverrides:
# Add your promotion-specific settings here</pre

View File

@@ -54,6 +54,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
@@ -425,7 +429,7 @@ sync local with a remote workspaces or the opposite (push or pull)
- `--extra-includes <patterns:file[]>` - 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 <repo:string>` - Specify repository path (e.g., u/user/repo) when multiple repositories exist
- `--promotion <branch:string>` - Use promotionOverrides from the specified branch instead of regular overrides
- `--branch, --env <branch:string>` - Override the current git branch/environment (works even outside a git repository)
- `--branch, --env <branch:string>` - [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 <message:string>` - Include a message that will be added to all scripts/flows/apps updated during this push
- `--parallel <number>` - Number of changes to process in parallel
- `--repository <repo:string>` - Specify repository path (e.g., u/user/repo) when multiple repositories exist
- `--branch, --env <branch:string>` - Override the current git branch/environment (works even outside a git repository)
- `--branch, --env <branch:string>` - [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 <branch:string>` - Specify branch/environment (defaults to current)
- `workspace unbind` - Remove workspace binding from the current Git branch
- `--branch, --env <branch:string>` - Specify branch/environment (defaults to current)
- `workspace bind` - Create or update a workspace entry in wmill.yaml from the active profile
- `--workspace <name:string>` - Workspace name (default: current branch or workspaceId)
- `--branch <branch:string>` - Git branch to associate (default: workspace name)
- `workspace unbind` - Remove baseUrl and workspaceId from a workspace entry
- `--workspace <name:string>` - Workspace to unbind
- `workspace fork [workspace_name:string] [workspace_id:string]` - Create a forked workspace
- `--create-workspace-name <workspace_name:string>` - Specify the workspace name. Ignored if --create is not specified or the workspace already exists. Will default to the workspace id.
- `--color <color:string>` - Workspace color (hex code, e.g. #ff0000)

View File

@@ -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 <patterns:file[]>\` - 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 <repo:string>\` - Specify repository path (e.g., u/user/repo) when multiple repositories exist
- \`--promotion <branch:string>\` - Use promotionOverrides from the specified branch instead of regular overrides
- \`--branch, --env <branch:string>\` - Override the current git branch/environment (works even outside a git repository)
- \`--branch, --env <branch:string>\` - [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 <message:string>\` - Include a message that will be added to all scripts/flows/apps updated during this push
- \`--parallel <number>\` - Number of changes to process in parallel
- \`--repository <repo:string>\` - Specify repository path (e.g., u/user/repo) when multiple repositories exist
- \`--branch, --env <branch:string>\` - Override the current git branch/environment (works even outside a git repository)
- \`--branch, --env <branch:string>\` - [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 <branch:string>\` - Specify branch/environment (defaults to current)
- \`workspace unbind\` - Remove workspace binding from the current Git branch
- \`--branch, --env <branch:string>\` - Specify branch/environment (defaults to current)
- \`workspace bind\` - Create or update a workspace entry in wmill.yaml from the active profile
- \`--workspace <name:string>\` - Workspace name (default: current branch or workspaceId)
- \`--branch <branch:string>\` - Git branch to associate (default: workspace name)
- \`workspace unbind\` - Remove baseUrl and workspaceId from a workspace entry
- \`--workspace <name:string>\` - Workspace to unbind
- \`workspace fork [workspace_name:string] [workspace_id:string]\` - Create a forked workspace
- \`--create-workspace-name <workspace_name:string>\` - Specify the workspace name. Ignored if --create is not specified or the workspace already exists. Will default to the workspace id.
- \`--color <color:string>\` - Workspace color (hex code, e.g. #ff0000)

View File

@@ -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 <patterns:file[]>` - 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 <repo:string>` - Specify repository path (e.g., u/user/repo) when multiple repositories exist
- `--promotion <branch:string>` - Use promotionOverrides from the specified branch instead of regular overrides
- `--branch, --env <branch:string>` - Override the current git branch/environment (works even outside a git repository)
- `--branch, --env <branch:string>` - [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 <message:string>` - Include a message that will be added to all scripts/flows/apps updated during this push
- `--parallel <number>` - Number of changes to process in parallel
- `--repository <repo:string>` - Specify repository path (e.g., u/user/repo) when multiple repositories exist
- `--branch, --env <branch:string>` - Override the current git branch/environment (works even outside a git repository)
- `--branch, --env <branch:string>` - [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 <branch:string>` - Specify branch/environment (defaults to current)
- `workspace unbind` - Remove workspace binding from the current Git branch
- `--branch, --env <branch:string>` - Specify branch/environment (defaults to current)
- `workspace bind` - Create or update a workspace entry in wmill.yaml from the active profile
- `--workspace <name:string>` - Workspace name (default: current branch or workspaceId)
- `--branch <branch:string>` - Git branch to associate (default: workspace name)
- `workspace unbind` - Remove baseUrl and workspaceId from a workspace entry
- `--workspace <name:string>` - Workspace to unbind
- `workspace fork [workspace_name:string] [workspace_id:string]` - Create a forked workspace
- `--create-workspace-name <workspace_name:string>` - Specify the workspace name. Ignored if --create is not specified or the workspace already exists. Will default to the workspace id.
- `--color <color:string>` - Workspace color (hex code, e.g. #ff0000)