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>
This commit is contained in:
Ruben Fiszel
2026-01-19 17:32:52 +00:00
parent 77d59d7371
commit 23cf2834b3
4 changed files with 134 additions and 8 deletions

View File

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

View File

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

View File

@@ -9,6 +9,7 @@ export interface SpecificItemsConfig {
resources?: string[];
triggers?: string[];
folders?: string[];
settings?: boolean;
}
// Define all branch-specific file types (computed lazily)
@@ -99,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) {
@@ -110,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;
}
@@ -153,6 +167,11 @@ export function isSpecificItem(path: string, specificItems: SpecificItemsConfig
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
@@ -184,6 +203,11 @@ export function toBranchSpecificPath(basePath: string, branchName: string): stri
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\..+)$/);
@@ -220,6 +244,12 @@ export function fromBranchSpecificPath(branchSpecificPath: string, branchName: s
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);
@@ -309,7 +339,8 @@ export function isCurrentBranchFile(path: string, branchOverride?: string): bool
pattern = new RegExp(
`\\.${escapedBranchName}\\.${buildYamlTypePattern()}\\.yaml$|` +
`\\.${escapedBranchName}\\.resource\\.file\\..+$|` +
`/folder\\.${escapedBranchName}\\.meta\\.yaml$`
`/folder\\.${escapedBranchName}\\.meta\\.yaml$|` +
`^settings\\.${escapedBranchName}\\.yaml$`
);
branchPatternCache.set(currentBranch, pattern);
}
@@ -326,6 +357,7 @@ export function isBranchSpecificFile(path: string): boolean {
return new RegExp(
`\\.[^.]+\\.${yamlTypePattern}\\.yaml$|` +
`\\.[^.]+\\.resource\\.file\\..+$|` +
`/folder\\.[^.]+\\.meta\\.yaml$`
`/folder\\.[^.]+\\.meta\\.yaml$|` +
`^settings\\.[^.]+\\.yaml$`
).test(path);
}

View File

@@ -429,3 +429,87 @@ Deno.test("round-trip: folder meta with sanitized branch", () => {
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);
});