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:
@@ -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;
|
||||
|
||||
@@ -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] = {};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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.")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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."
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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$|` +
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
197
cli/test/init_bind_flow.test.ts
Normal file
197
cli/test/init_bind_flow.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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:");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
|
||||
520
cli/test/workspaces_config.test.ts
Normal file
520
cli/test/workspaces_config.test.ts
Normal 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:");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user