mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-11 00:06:06 +00:00
feat(cli): add branch-specific items for folders and settings (#7611)
* feat(cli): add folders as branch-specific items
Folders can now be configured as branch-specific items in wmill.yaml:
```yaml
gitBranches:
staging:
specificItems:
folders:
- "f/env_*"
- "f/config"
```
Branch-specific folder format: f/folder/folder.branchName.meta.yaml
(consistent with other item types where branch goes before the type suffix)
Example:
- Base: f/env_staging/folder.meta.yaml
- Branch-specific: f/env_staging/folder.main.meta.yaml
Changes:
- Add `folders?: string[]` to SpecificItemsConfig
- Add folder handling in toBranchSpecificPath()
- Add folder handling in fromBranchSpecificPath()
- Add folder pattern matching in isSpecificItem()
- Add folder detection in isBranchSpecificFile()
- Add folder detection in isCurrentBranchFile()
- Add 13 new tests for folder functionality
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
* feat(cli): add settings as branch-specific item and skip validation with --branch
- Add settings.yaml as a branch-specific item (settings: true in config)
- settings.yaml -> settings.branchName.yaml conversion
- Skip "Create empty branch configuration" prompt when using --branch flag
- User explicitly specifies branch, so skip validation prompts
- Add folders and settings fields to gitBranches type definitions
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:
committed by
GitHub
co-authored by
Claude Opus 4.5
parent
91961b681c
commit
7f2dc707b0
@@ -1866,9 +1866,9 @@ export async function pull(
|
||||
const originalCliOpts = { ...opts };
|
||||
opts = await mergeConfigWithConfigFile(opts);
|
||||
|
||||
// Validate branch configuration early
|
||||
// Validate branch configuration early (skipped when --branch is used)
|
||||
try {
|
||||
await validateBranchConfiguration(opts);
|
||||
await validateBranchConfiguration(opts, opts.branch);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes("overrides")) {
|
||||
log.error(error.message);
|
||||
@@ -2351,9 +2351,9 @@ export async function push(
|
||||
// Load configuration from wmill.yaml and merge with CLI options
|
||||
opts = await mergeConfigWithConfigFile(opts);
|
||||
|
||||
// Validate branch configuration early
|
||||
// Validate branch configuration early (skipped when --branch is used)
|
||||
try {
|
||||
await validateBranchConfiguration(opts);
|
||||
await validateBranchConfiguration(opts, opts.branch);
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes("overrides")) {
|
||||
log.error(error.message);
|
||||
|
||||
+12
-2
@@ -53,6 +53,8 @@ export interface SyncOptions {
|
||||
variables?: string[];
|
||||
resources?: string[];
|
||||
triggers?: string[];
|
||||
folders?: string[];
|
||||
settings?: boolean;
|
||||
};
|
||||
} & {
|
||||
[branchName: string]: SyncOptions & {
|
||||
@@ -64,6 +66,8 @@ export interface SyncOptions {
|
||||
variables?: string[];
|
||||
resources?: string[];
|
||||
triggers?: string[];
|
||||
folders?: string[];
|
||||
settings?: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -73,6 +77,8 @@ export interface SyncOptions {
|
||||
variables?: string[];
|
||||
resources?: string[];
|
||||
triggers?: string[];
|
||||
folders?: string[];
|
||||
settings?: boolean;
|
||||
};
|
||||
} & {
|
||||
[branchName: string]: SyncOptions & {
|
||||
@@ -84,6 +90,8 @@ export interface SyncOptions {
|
||||
variables?: string[];
|
||||
resources?: string[];
|
||||
triggers?: string[];
|
||||
folders?: string[];
|
||||
settings?: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -370,9 +378,11 @@ export async function mergeConfigWithConfigFile<T>(
|
||||
|
||||
// Validate branch configuration early in the process
|
||||
export async function validateBranchConfiguration(
|
||||
opts: Pick<SyncOptions, "skipBranchValidation" | "yes">
|
||||
opts: Pick<SyncOptions, "skipBranchValidation" | "yes">,
|
||||
branchOverride?: string
|
||||
): Promise<void> {
|
||||
if (opts.skipBranchValidation || !isGitRepository()) {
|
||||
// When branch override is provided, skip validation - user is explicitly specifying the branch
|
||||
if (opts.skipBranchValidation || branchOverride || !isGitRepository()) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ export interface SpecificItemsConfig {
|
||||
variables?: string[];
|
||||
resources?: string[];
|
||||
triggers?: string[];
|
||||
folders?: string[];
|
||||
settings?: boolean;
|
||||
}
|
||||
|
||||
// Define all branch-specific file types (computed lazily)
|
||||
@@ -98,6 +100,12 @@ export function getSpecificItemsForCurrentBranch(config: SyncOptions, branchOver
|
||||
if (commonItems?.triggers) {
|
||||
merged.triggers = [...commonItems.triggers];
|
||||
}
|
||||
if (commonItems?.folders) {
|
||||
merged.folders = [...commonItems.folders];
|
||||
}
|
||||
if (commonItems?.settings !== undefined) {
|
||||
merged.settings = commonItems.settings;
|
||||
}
|
||||
|
||||
// Add branch-specific items (extending common items)
|
||||
if (branchItems?.variables) {
|
||||
@@ -109,6 +117,13 @@ export function getSpecificItemsForCurrentBranch(config: SyncOptions, branchOver
|
||||
if (branchItems?.triggers) {
|
||||
merged.triggers = [...(merged.triggers || []), ...branchItems.triggers];
|
||||
}
|
||||
if (branchItems?.folders) {
|
||||
merged.folders = [...(merged.folders || []), ...branchItems.folders];
|
||||
}
|
||||
// For settings (boolean), branch-specific overrides common
|
||||
if (branchItems?.settings !== undefined) {
|
||||
merged.settings = branchItems.settings;
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
@@ -142,6 +157,21 @@ export function isSpecificItem(path: string, specificItems: SpecificItemsConfig
|
||||
return specificItems.triggers ? matchesPatterns(path, specificItems.triggers) : false;
|
||||
}
|
||||
|
||||
// Check for folder meta files
|
||||
if (path.endsWith('/folder.meta.yaml')) {
|
||||
if (specificItems.folders) {
|
||||
// Match against the folder path (without /folder.meta.yaml)
|
||||
const folderPath = path.slice(0, -'/folder.meta.yaml'.length);
|
||||
return matchesPatterns(folderPath, specificItems.folders);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check for settings.yaml (root-level file)
|
||||
if (path === 'settings.yaml') {
|
||||
return specificItems.settings === true;
|
||||
}
|
||||
|
||||
// Check for resource files using the standard detection function
|
||||
if (isFileResource(path)) {
|
||||
// Extract the base path without the file extension to match against patterns
|
||||
@@ -159,6 +189,25 @@ export function isSpecificItem(path: string, specificItems: SpecificItemsConfig
|
||||
* Convert a base path to a branch-specific path
|
||||
*/
|
||||
export function toBranchSpecificPath(basePath: string, branchName: string): string {
|
||||
// Sanitize branch name to be filesystem-safe
|
||||
const sanitizedBranchName = branchName.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.`);
|
||||
}
|
||||
|
||||
// Check for folder meta file pattern: folder.meta.yaml -> folder.branchName.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`;
|
||||
}
|
||||
|
||||
// Check for settings.yaml: settings.yaml -> settings.branchName.yaml
|
||||
if (basePath === 'settings.yaml') {
|
||||
return `settings.${sanitizedBranchName}.yaml`;
|
||||
}
|
||||
|
||||
// Check for resource file pattern (e.g., .resource.file.ini)
|
||||
const resourceFileMatch = basePath.match(/^(.+?)(\.resource\.file\..+)$/);
|
||||
|
||||
@@ -178,14 +227,6 @@ export function toBranchSpecificPath(basePath: string, branchName: string): stri
|
||||
pathWithoutExtension = basePath.substring(0, basePath.length - extension.length);
|
||||
}
|
||||
|
||||
// Sanitize branch name to be filesystem-safe
|
||||
const sanitizedBranchName = branchName.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.`);
|
||||
}
|
||||
|
||||
return `${pathWithoutExtension}.${sanitizedBranchName}${extension}`;
|
||||
}
|
||||
|
||||
@@ -197,7 +238,19 @@ export function fromBranchSpecificPath(branchSpecificPath: string, branchName: s
|
||||
const sanitizedBranchName = branchName.replace(/[\/\\:*?"<>|.]/g, '_');
|
||||
const escapedBranchName = sanitizedBranchName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
|
||||
// Check for resource file pattern first
|
||||
// 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 settings file pattern: settings.branchName.yaml -> settings.yaml
|
||||
const settingsPattern = new RegExp(`^settings\\.${escapedBranchName}\\.yaml$`);
|
||||
if (settingsPattern.test(branchSpecificPath)) {
|
||||
return 'settings.yaml';
|
||||
}
|
||||
|
||||
// Check for resource file pattern
|
||||
const resourceFilePattern = new RegExp(`\\.${escapedBranchName}(\\.resource\\.file\\..+)$`);
|
||||
const resourceFileMatch = branchSpecificPath.match(resourceFilePattern);
|
||||
|
||||
@@ -283,7 +336,12 @@ export function isCurrentBranchFile(path: string, branchOverride?: string): bool
|
||||
// Use cached pattern or create and cache new one
|
||||
let pattern = branchPatternCache.get(currentBranch);
|
||||
if (!pattern) {
|
||||
pattern = new RegExp(`\\.${escapedBranchName}\\.${buildYamlTypePattern()}\\.yaml$|\\.${escapedBranchName}\\.resource\\.file\\..+$`);
|
||||
pattern = new RegExp(
|
||||
`\\.${escapedBranchName}\\.${buildYamlTypePattern()}\\.yaml$|` +
|
||||
`\\.${escapedBranchName}\\.resource\\.file\\..+$|` +
|
||||
`/folder\\.${escapedBranchName}\\.meta\\.yaml$|` +
|
||||
`^settings\\.${escapedBranchName}\\.yaml$`
|
||||
);
|
||||
branchPatternCache.set(currentBranch, pattern);
|
||||
}
|
||||
|
||||
@@ -296,5 +354,10 @@ export function isCurrentBranchFile(path: string, branchOverride?: string): bool
|
||||
*/
|
||||
export function isBranchSpecificFile(path: string): boolean {
|
||||
const yamlTypePattern = buildYamlTypePattern();
|
||||
return new RegExp(`\\.[^.]+\\.${yamlTypePattern}\\.yaml$|\\.[^.]+\\.resource\\.file\\..+$`).test(path);
|
||||
return new RegExp(
|
||||
`\\.[^.]+\\.${yamlTypePattern}\\.yaml$|` +
|
||||
`\\.[^.]+\\.resource\\.file\\..+$|` +
|
||||
`/folder\\.[^.]+\\.meta\\.yaml$|` +
|
||||
`^settings\\.[^.]+\\.yaml$`
|
||||
).test(path);
|
||||
}
|
||||
|
||||
@@ -336,3 +336,180 @@ Deno.test("branchOverride: getSpecificItemsForCurrentBranch merges common and br
|
||||
assertEquals(result?.resources, ["shared/**"]);
|
||||
assertEquals(result?.triggers, ["dev/triggers/**"]);
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// FOLDER BRANCH-SPECIFIC TESTS
|
||||
// Format: f/folder/folder.branchName.meta.yaml
|
||||
// =============================================================================
|
||||
|
||||
Deno.test("toBranchSpecificPath: converts folder meta path to branch-specific", () => {
|
||||
// f/my_folder/folder.meta.yaml -> f/my_folder/folder.main.meta.yaml
|
||||
const result = toBranchSpecificPath("f/my_folder/folder.meta.yaml", "main");
|
||||
assertEquals(result, "f/my_folder/folder.main.meta.yaml");
|
||||
});
|
||||
|
||||
Deno.test("toBranchSpecificPath: converts nested folder meta path to branch-specific", () => {
|
||||
const result = toBranchSpecificPath("f/parent/child/folder.meta.yaml", "develop");
|
||||
assertEquals(result, "f/parent/child/folder.develop.meta.yaml");
|
||||
});
|
||||
|
||||
Deno.test("toBranchSpecificPath: sanitizes branch name in folder path", () => {
|
||||
const result = toBranchSpecificPath("f/env/folder.meta.yaml", "feature/test");
|
||||
assertEquals(result, "f/env/folder.feature_test.meta.yaml");
|
||||
});
|
||||
|
||||
Deno.test("fromBranchSpecificPath: converts branch-specific folder back to base", () => {
|
||||
const result = fromBranchSpecificPath("f/my_folder/folder.main.meta.yaml", "main");
|
||||
assertEquals(result, "f/my_folder/folder.meta.yaml");
|
||||
});
|
||||
|
||||
Deno.test("fromBranchSpecificPath: handles nested branch-specific folder", () => {
|
||||
const result = fromBranchSpecificPath("f/parent/child/folder.develop.meta.yaml", "develop");
|
||||
assertEquals(result, "f/parent/child/folder.meta.yaml");
|
||||
});
|
||||
|
||||
Deno.test("fromBranchSpecificPath: handles sanitized branch names for folders", () => {
|
||||
const result = fromBranchSpecificPath("f/env/folder.feature_test.meta.yaml", "feature/test");
|
||||
assertEquals(result, "f/env/folder.meta.yaml");
|
||||
});
|
||||
|
||||
Deno.test("isSpecificItem: matches folder paths with glob pattern", () => {
|
||||
const config: SpecificItemsConfig = {
|
||||
folders: ["f/env_*"],
|
||||
};
|
||||
assertEquals(isSpecificItem("f/env_staging/folder.meta.yaml", config), true);
|
||||
assertEquals(isSpecificItem("f/env_production/folder.meta.yaml", config), true);
|
||||
assertEquals(isSpecificItem("f/other/folder.meta.yaml", config), false);
|
||||
});
|
||||
|
||||
Deno.test("isSpecificItem: matches folder paths with exact pattern", () => {
|
||||
const config: SpecificItemsConfig = {
|
||||
folders: ["f/config"],
|
||||
};
|
||||
assertEquals(isSpecificItem("f/config/folder.meta.yaml", config), true);
|
||||
assertEquals(isSpecificItem("f/other/folder.meta.yaml", config), false);
|
||||
});
|
||||
|
||||
Deno.test("isBranchSpecificFile: detects branch-specific folder files", () => {
|
||||
assertEquals(isBranchSpecificFile("f/my_folder/folder.main.meta.yaml"), true);
|
||||
assertEquals(isBranchSpecificFile("f/my_folder/folder.develop.meta.yaml"), true);
|
||||
assertEquals(isBranchSpecificFile("f/nested/path/folder.staging.meta.yaml"), true);
|
||||
});
|
||||
|
||||
Deno.test("isBranchSpecificFile: returns false for non-branch-specific folder files", () => {
|
||||
assertEquals(isBranchSpecificFile("f/my_folder/folder.meta.yaml"), false);
|
||||
assertEquals(isBranchSpecificFile("f/nested/path/folder.meta.yaml"), false);
|
||||
});
|
||||
|
||||
Deno.test("isCurrentBranchFile: detects branch-specific folder for current branch", () => {
|
||||
assertEquals(isCurrentBranchFile("f/my_folder/folder.staging.meta.yaml", "staging"), true);
|
||||
assertEquals(isCurrentBranchFile("f/my_folder/folder.staging.meta.yaml", "production"), false);
|
||||
assertEquals(isCurrentBranchFile("f/my_folder/folder.meta.yaml", "staging"), false);
|
||||
});
|
||||
|
||||
Deno.test("isCurrentBranchFile: handles sanitized branch for folders", () => {
|
||||
assertEquals(isCurrentBranchFile("f/env/folder.feature_test.meta.yaml", "feature/test"), true);
|
||||
assertEquals(isCurrentBranchFile("f/env/folder.feature_test.meta.yaml", "feature/other"), false);
|
||||
});
|
||||
|
||||
Deno.test("round-trip: folder meta path conversion", () => {
|
||||
const original = "f/configs/env_folder/folder.meta.yaml";
|
||||
const branch = "main";
|
||||
const branchSpecific = toBranchSpecificPath(original, branch);
|
||||
assertEquals(branchSpecific, "f/configs/env_folder/folder.main.meta.yaml");
|
||||
const restored = fromBranchSpecificPath(branchSpecific, branch);
|
||||
assertEquals(restored, original);
|
||||
});
|
||||
|
||||
Deno.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);
|
||||
assertEquals(branchSpecific, "f/env/folder.feature_new-env.meta.yaml");
|
||||
const restored = fromBranchSpecificPath(branchSpecific, branch);
|
||||
assertEquals(restored, original);
|
||||
});
|
||||
|
||||
// =============================================================================
|
||||
// SETTINGS BRANCH-SPECIFIC TESTS
|
||||
// =============================================================================
|
||||
|
||||
Deno.test("toBranchSpecificPath: converts settings.yaml to branch-specific", () => {
|
||||
const result = toBranchSpecificPath("settings.yaml", "main");
|
||||
assertEquals(result, "settings.main.yaml");
|
||||
});
|
||||
|
||||
Deno.test("toBranchSpecificPath: sanitizes branch name in settings path", () => {
|
||||
const result = toBranchSpecificPath("settings.yaml", "feature/test");
|
||||
assertEquals(result, "settings.feature_test.yaml");
|
||||
});
|
||||
|
||||
Deno.test("fromBranchSpecificPath: converts branch-specific settings back to base", () => {
|
||||
const result = fromBranchSpecificPath("settings.main.yaml", "main");
|
||||
assertEquals(result, "settings.yaml");
|
||||
});
|
||||
|
||||
Deno.test("fromBranchSpecificPath: handles sanitized branch names for settings", () => {
|
||||
const result = fromBranchSpecificPath("settings.feature_test.yaml", "feature/test");
|
||||
assertEquals(result, "settings.yaml");
|
||||
});
|
||||
|
||||
Deno.test("isSpecificItem: matches settings.yaml when settings is true", () => {
|
||||
const config: SpecificItemsConfig = {
|
||||
settings: true,
|
||||
};
|
||||
assertEquals(isSpecificItem("settings.yaml", config), true);
|
||||
});
|
||||
|
||||
Deno.test("isSpecificItem: does not match settings.yaml when settings is false", () => {
|
||||
const config: SpecificItemsConfig = {
|
||||
settings: false,
|
||||
};
|
||||
assertEquals(isSpecificItem("settings.yaml", config), false);
|
||||
});
|
||||
|
||||
Deno.test("isSpecificItem: does not match settings.yaml when settings is undefined", () => {
|
||||
const config: SpecificItemsConfig = {
|
||||
variables: ["f/**"],
|
||||
};
|
||||
assertEquals(isSpecificItem("settings.yaml", config), false);
|
||||
});
|
||||
|
||||
Deno.test("isBranchSpecificFile: detects branch-specific settings files", () => {
|
||||
assertEquals(isBranchSpecificFile("settings.main.yaml"), true);
|
||||
assertEquals(isBranchSpecificFile("settings.develop.yaml"), true);
|
||||
assertEquals(isBranchSpecificFile("settings.feature_test.yaml"), true);
|
||||
});
|
||||
|
||||
Deno.test("isBranchSpecificFile: returns false for non-branch-specific settings", () => {
|
||||
assertEquals(isBranchSpecificFile("settings.yaml"), false);
|
||||
});
|
||||
|
||||
Deno.test("isCurrentBranchFile: detects branch-specific settings for current branch", () => {
|
||||
assertEquals(isCurrentBranchFile("settings.staging.yaml", "staging"), true);
|
||||
assertEquals(isCurrentBranchFile("settings.staging.yaml", "production"), false);
|
||||
assertEquals(isCurrentBranchFile("settings.yaml", "staging"), false);
|
||||
});
|
||||
|
||||
Deno.test("isCurrentBranchFile: handles sanitized branch for settings", () => {
|
||||
assertEquals(isCurrentBranchFile("settings.feature_test.yaml", "feature/test"), true);
|
||||
assertEquals(isCurrentBranchFile("settings.feature_test.yaml", "feature/other"), false);
|
||||
});
|
||||
|
||||
Deno.test("round-trip: settings path conversion", () => {
|
||||
const original = "settings.yaml";
|
||||
const branch = "main";
|
||||
const branchSpecific = toBranchSpecificPath(original, branch);
|
||||
assertEquals(branchSpecific, "settings.main.yaml");
|
||||
const restored = fromBranchSpecificPath(branchSpecific, branch);
|
||||
assertEquals(restored, original);
|
||||
});
|
||||
|
||||
Deno.test("round-trip: settings with sanitized branch", () => {
|
||||
const original = "settings.yaml";
|
||||
const branch = "release/v1.0";
|
||||
const branchSpecific = toBranchSpecificPath(original, branch);
|
||||
assertEquals(branchSpecific, "settings.release_v1_0.yaml");
|
||||
const restored = fromBranchSpecificPath(branchSpecific, branch);
|
||||
assertEquals(restored, original);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user