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>
This commit is contained in:
Ruben Fiszel
2026-01-19 17:20:17 +00:00
co-authored by Claude Opus 4.5
parent c04eb371cc
commit 77d59d7371
2 changed files with 135 additions and 11 deletions
+42 -11
View File
@@ -8,6 +8,7 @@ export interface SpecificItemsConfig {
variables?: string[];
resources?: string[];
triggers?: string[];
folders?: string[];
}
// Define all branch-specific file types (computed lazily)
@@ -142,6 +143,16 @@ 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 resource files using the standard detection function
if (isFileResource(path)) {
// Extract the base path without the file extension to match against patterns
@@ -159,6 +170,20 @@ 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 resource file pattern (e.g., .resource.file.ini)
const resourceFileMatch = basePath.match(/^(.+?)(\.resource\.file\..+)$/);
@@ -178,14 +203,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 +214,13 @@ 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 resource file pattern
const resourceFilePattern = new RegExp(`\\.${escapedBranchName}(\\.resource\\.file\\..+)$`);
const resourceFileMatch = branchSpecificPath.match(resourceFilePattern);
@@ -283,7 +306,11 @@ 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$`
);
branchPatternCache.set(currentBranch, pattern);
}
@@ -296,5 +323,9 @@ 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$`
).test(path);
}
+93
View File
@@ -336,3 +336,96 @@ 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);
});