mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-31 00:13:53 +00:00
* fix(cli): keep a raw app's custom_path on sync pull The pull rendered raw_app.yaml without custom_path, so every pull removed the line and every push --dry-run listed the file as modified, while the deployed app still had it and push still sent it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(cli): send custom_path on app update only when it changed The backend requires admin for any update carrying custom_path, so the value a pull now writes into raw_app.yaml made every non-admin push of that app fail. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * feat(cli): sync an app's custom path as a workspace-specific file Unless app routes are workspace-prefixed, a custom path is unique across the instance, so a value carried in the app file every workspace shares can never be promoted. Sync now renders it as <app>.<workspace>.custom_path.yaml next to the app folder, always workspace-specific. A push applies only the target workspace's file and leaves the deployed custom path alone without one; a checkout that still has custom_path in its app file converges on push, and the next pull moves it out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * test(cli): drop the custom_path rationale repeated from changedCustomPath Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(cli): apply a custom path pushed with its raw app, and converge a cleared one The raw-app bundle collapse kept one change per group and dropped the custom-path file grouped with it, so a push editing both deployed only the app. App files and their custom-path file now group under the app path in both folder layouts, and the collapse keeps the custom-path change. A cleared custom path (custom_path: '') has no file in the export, so the push diff listed it as added forever; an empty value with nothing deployed is now in sync. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(cli): never read a file inside a flow or app folder as workspace-specific The workspace-name segment of the workspace-specific patterns spans '/', so a raw-app asset such as settings.prod.custom_path.json was taken for a workspace's file and dropped from the pull. Like fileset children, files inside a flow or app folder are content of that item, whatever their name. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(cli): recognize a custom-path file only beside its app isCustomPathFile excluded app and flow folders but still matched a fileset child such as data.fileset/settings.custom_path.json, so a push dropped edits to it and a pull renamed it. A custom-path file is now identified by where it sits: its name is <app>[.<workspace>].custom_path.<ext> and no part of its directory is an item's folder. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
521 lines
17 KiB
TypeScript
521 lines
17 KiB
TypeScript
import { minimatch } from "minimatch";
|
|
import { getCurrentGitBranch, isGitRepository } from "../utils/git.ts";
|
|
import { isFileResource, isFilesetResource } from "../utils/utils.ts";
|
|
import {
|
|
isFolderResourcePathAnyFormat,
|
|
isScriptModulePath,
|
|
} from "../utils/resource_folders.ts";
|
|
import {
|
|
SyncOptions,
|
|
findWorkspaceByGitBranch,
|
|
readConfigFile,
|
|
WorkspaceEntryConfig,
|
|
} from "./conf.ts";
|
|
import { TRIGGER_TYPES } from "../types.ts";
|
|
|
|
/**
|
|
* Resolve the effective workspace name (wmill.yaml config key) for a given
|
|
* git branch. Falls back to the branch name itself when no matching workspace
|
|
* entry exists (legacy behavior).
|
|
*/
|
|
export async function resolveWsNameForGitBranch(branchName: string): Promise<string> {
|
|
const config = await readConfigFile({ warnIfMissing: false });
|
|
const match = findWorkspaceByGitBranch(config.workspaces, branchName);
|
|
return match ? match[0] : branchName;
|
|
}
|
|
|
|
export interface SpecificItemsConfig {
|
|
variables?: string[];
|
|
resources?: string[];
|
|
triggers?: string[];
|
|
schedules?: string[];
|
|
folders?: string[];
|
|
settings?: boolean;
|
|
}
|
|
|
|
// Define all workspace-specific file type *kinds* (extension-less). The
|
|
// actual on-disk file is `<kind>.yaml` or `<kind>.json` depending on
|
|
// opts.json — every helper below treats both as equivalent.
|
|
function getWorkspaceSpecificTypeKinds(): string[] {
|
|
return [
|
|
'variable',
|
|
'resource',
|
|
'schedule',
|
|
'custom_path',
|
|
...TRIGGER_TYPES.map(t => `${t}_trigger`),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* An app's custom path is always workspace-specific, with or without
|
|
* specificItems: unless routes are workspace-prefixed it is unique across the
|
|
* instance, so one shared value can never be deployed to two workspaces.
|
|
*/
|
|
export function isCustomPathFile(path: string): boolean {
|
|
const p = path.replaceAll('\\', '/');
|
|
const slash = p.lastIndexOf('/');
|
|
const parts = p.slice(slash + 1).split('.');
|
|
return (
|
|
(parts.length == 3 || parts.length == 4) &&
|
|
parts[parts.length - 2] == 'custom_path' &&
|
|
(parts[parts.length - 1] == 'yaml' || parts[parts.length - 1] == 'json') &&
|
|
// It sits beside its app. A file inside an item's folder (app, flow,
|
|
// fileset, script module) is that item's content, whatever its name.
|
|
!p.slice(0, slash + 1).includes('.') &&
|
|
!isFolderResourcePathAnyFormat(p) &&
|
|
!isScriptModulePath(p)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Check if a path ends with any trigger type
|
|
*/
|
|
function isTriggerFile(path: string): boolean {
|
|
return TRIGGER_TYPES.some(type =>
|
|
path.endsWith(`.${type}_trigger.yaml`) || path.endsWith(`.${type}_trigger.json`)
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Check if a path is a schedule file
|
|
*/
|
|
function isScheduleFile(path: string): boolean {
|
|
return path.endsWith('.schedule.yaml') || path.endsWith('.schedule.json');
|
|
}
|
|
|
|
/**
|
|
* Extract the file type suffix from a path. Preserves the original `.yaml`
|
|
* or `.json` extension so callers like toWorkspaceSpecificPath round-trip
|
|
* correctly.
|
|
*/
|
|
function getFileTypeSuffix(path: string): string | null {
|
|
for (const kind of getWorkspaceSpecificTypeKinds()) {
|
|
if (path.endsWith(`.${kind}.yaml`)) return `.${kind}.yaml`;
|
|
if (path.endsWith(`.${kind}.json`)) return `.${kind}.json`;
|
|
}
|
|
|
|
const resourceFileMatch = path.match(/(\.resource\.file\..+)$/);
|
|
if (resourceFileMatch) {
|
|
return resourceFileMatch[1];
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Build regex pattern for all supported item-kind file types
|
|
*/
|
|
function buildItemTypePattern(): string {
|
|
const basicTypes = ['variable', 'resource', 'schedule', 'custom_path'];
|
|
const triggerTypes = TRIGGER_TYPES.map(t => `${t}_trigger`);
|
|
return `((${basicTypes.join('|')})|(${triggerTypes.join('|')}))`;
|
|
}
|
|
|
|
/**
|
|
* 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, workspaceNameOverride?: string): SpecificItemsConfig | undefined {
|
|
if (!config.workspaces) {
|
|
return undefined;
|
|
}
|
|
|
|
let wsEntry: WorkspaceEntryConfig | undefined;
|
|
|
|
if (workspaceNameOverride) {
|
|
wsEntry = config.workspaces[workspaceNameOverride] as WorkspaceEntryConfig | undefined;
|
|
} else if (isGitRepository()) {
|
|
const currentWorkspace = getCurrentGitBranch();
|
|
if (currentWorkspace) {
|
|
const match = findWorkspaceByGitBranch(config.workspaces, currentWorkspace);
|
|
if (match) {
|
|
wsEntry = match[1];
|
|
}
|
|
}
|
|
}
|
|
|
|
const commonItems = config.workspaces.commonSpecificItems;
|
|
const wsItems = wsEntry?.specificItems;
|
|
|
|
// If neither common nor workspace-specific items exist, return undefined
|
|
if (!commonItems && !wsItems) {
|
|
return undefined;
|
|
}
|
|
|
|
// Merge common and workspace-specific items
|
|
const merged: SpecificItemsConfig = {};
|
|
|
|
// Add common items
|
|
if (commonItems?.variables) {
|
|
merged.variables = [...commonItems.variables];
|
|
}
|
|
if (commonItems?.resources) {
|
|
merged.resources = [...commonItems.resources];
|
|
}
|
|
if (commonItems?.triggers) {
|
|
merged.triggers = [...commonItems.triggers];
|
|
}
|
|
if (commonItems?.schedules) {
|
|
merged.schedules = [...commonItems.schedules];
|
|
}
|
|
if (commonItems?.folders) {
|
|
merged.folders = [...commonItems.folders];
|
|
}
|
|
if (commonItems?.settings !== undefined) {
|
|
merged.settings = commonItems.settings;
|
|
}
|
|
|
|
// Add workspace-specific items (extending common items)
|
|
if (wsItems?.variables) {
|
|
merged.variables = [...(merged.variables || []), ...wsItems.variables];
|
|
}
|
|
if (wsItems?.resources) {
|
|
merged.resources = [...(merged.resources || []), ...wsItems.resources];
|
|
}
|
|
if (wsItems?.triggers) {
|
|
merged.triggers = [...(merged.triggers || []), ...wsItems.triggers];
|
|
}
|
|
if (wsItems?.schedules) {
|
|
merged.schedules = [...(merged.schedules || []), ...wsItems.schedules];
|
|
}
|
|
if (wsItems?.folders) {
|
|
merged.folders = [...(merged.folders || []), ...wsItems.folders];
|
|
}
|
|
// For settings (boolean), workspace-specific overrides common
|
|
if (wsItems?.settings !== undefined) {
|
|
merged.settings = wsItems.settings;
|
|
}
|
|
|
|
return merged;
|
|
}
|
|
|
|
/**
|
|
* Check if a path matches any of the patterns in the given list
|
|
*/
|
|
function matchesPatterns(path: string, patterns: string[]): boolean {
|
|
return patterns.some(pattern => minimatch(path, pattern));
|
|
}
|
|
|
|
/**
|
|
* Normalize a `.json` extension to `.yaml` for pattern matching. Patterns in
|
|
* wmill.yaml (and those appended by mergeWsSpecificFromServer) are expressed
|
|
* with `.yaml` regardless of opts.json, so a `.json` local file would never
|
|
* match without this. The rest of `isSpecificItem`/`isItemTypeConfigured`
|
|
* checks `endsWith('.X.yaml')`, so normalizing once at the entry point keeps
|
|
* both the helpers and the patterns single-extension.
|
|
*/
|
|
function normalizeJsonToYaml(path: string): string {
|
|
return path.endsWith('.json') ? path.slice(0, -'.json'.length) + '.yaml' : path;
|
|
}
|
|
|
|
/**
|
|
* 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 workspace-specific files should be used for this type.
|
|
*/
|
|
export function isItemTypeConfigured(path: string, specificItems: SpecificItemsConfig | undefined): boolean {
|
|
if (isCustomPathFile(path)) {
|
|
return true;
|
|
}
|
|
if (!specificItems) {
|
|
return false;
|
|
}
|
|
|
|
path = normalizeJsonToYaml(path);
|
|
|
|
if (path.endsWith('.variable.yaml')) {
|
|
return specificItems.variables !== undefined;
|
|
}
|
|
|
|
if (path.endsWith('.resource.yaml')) {
|
|
return specificItems.resources !== undefined;
|
|
}
|
|
|
|
if (isTriggerFile(path)) {
|
|
return specificItems.triggers !== undefined;
|
|
}
|
|
|
|
if (isScheduleFile(path)) {
|
|
return specificItems.schedules !== undefined;
|
|
}
|
|
|
|
if (path.endsWith('/folder.meta.yaml')) {
|
|
return specificItems.folders !== undefined;
|
|
}
|
|
|
|
if (path === 'settings.yaml') {
|
|
return specificItems.settings !== undefined;
|
|
}
|
|
|
|
if (isFileResource(path) || isFilesetResource(path)) {
|
|
return specificItems.resources !== undefined;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Check if a file path should be treated as workspace-specific
|
|
*/
|
|
export function isSpecificItem(path: string, specificItems: SpecificItemsConfig | undefined): boolean {
|
|
if (isCustomPathFile(path)) {
|
|
return true;
|
|
}
|
|
if (!specificItems) {
|
|
return false;
|
|
}
|
|
|
|
path = normalizeJsonToYaml(path);
|
|
|
|
// Determine the item type from the file path
|
|
if (path.endsWith('.variable.yaml')) {
|
|
return specificItems.variables ? matchesPatterns(path, specificItems.variables) : false;
|
|
}
|
|
|
|
if (path.endsWith('.resource.yaml')) {
|
|
return specificItems.resources ? matchesPatterns(path, specificItems.resources) : false;
|
|
}
|
|
|
|
// Check for any trigger type
|
|
if (isTriggerFile(path)) {
|
|
return specificItems.triggers ? matchesPatterns(path, specificItems.triggers) : false;
|
|
}
|
|
|
|
// Check for schedule files
|
|
if (isScheduleFile(path)) {
|
|
return specificItems.schedules ? matchesPatterns(path, specificItems.schedules) : 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
|
|
const basePathMatch = path.match(/^(.+?)\.resource\.file\./);
|
|
if (basePathMatch && specificItems.resources) {
|
|
const basePath = basePathMatch[1] + '.resource.yaml';
|
|
return matchesPatterns(basePath, specificItems.resources);
|
|
}
|
|
}
|
|
|
|
if (isFilesetResource(path)) {
|
|
const basePathMatch = path.match(/^(.+?)\.fileset[/\\]/);
|
|
if (basePathMatch && specificItems.resources) {
|
|
const basePath = basePathMatch[1] + '.resource.yaml';
|
|
return matchesPatterns(basePath, specificItems.resources);
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Convert a base path to a workspace-specific path
|
|
*/
|
|
export function toWorkspaceSpecificPath(basePath: string, workspaceName: string): string {
|
|
// Sanitize branch name to be filesystem-safe
|
|
const sanitizedName = workspaceName.replace(/[\/\\:*?"<>|.]/g, '_');
|
|
|
|
// Warn about potential collisions if sanitization occurred
|
|
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,json} -> folder.workspaceName.meta.{yaml,json}
|
|
const folderMetaMatch = basePath.match(/^(.*)\/folder\.meta\.(yaml|json)$/);
|
|
if (folderMetaMatch) {
|
|
return `${folderMetaMatch[1]}/folder.${sanitizedName}.meta.${folderMetaMatch[2]}`;
|
|
}
|
|
|
|
// Check for settings.{yaml,json}: settings.{ext} -> settings.workspaceName.{ext}
|
|
const settingsMatch = basePath.match(/^settings\.(yaml|json)$/);
|
|
if (settingsMatch) {
|
|
return `settings.${sanitizedName}.${settingsMatch[1]}`;
|
|
}
|
|
|
|
// Check for resource file pattern (e.g., .resource.file.ini)
|
|
const resourceFileMatch = basePath.match(/^(.+?)(\.resource\.file\..+)$/);
|
|
|
|
let extension: string;
|
|
let pathWithoutExtension: string;
|
|
|
|
if (resourceFileMatch) {
|
|
// Handle resource files
|
|
extension = resourceFileMatch[2];
|
|
pathWithoutExtension = resourceFileMatch[1];
|
|
} else {
|
|
const suffix = getFileTypeSuffix(basePath);
|
|
if (!suffix) {
|
|
return basePath;
|
|
}
|
|
extension = suffix;
|
|
pathWithoutExtension = basePath.substring(0, basePath.length - extension.length);
|
|
}
|
|
|
|
return `${pathWithoutExtension}.${sanitizedName}${extension}`;
|
|
}
|
|
|
|
/**
|
|
* Convert a workspace-specific path back to a base path
|
|
*/
|
|
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.workspaceName.meta.{yaml,json} -> /folder.meta.{ext}
|
|
const folderPattern = new RegExp(`/folder\\.${escapedName}\\.meta\\.(yaml|json)$`);
|
|
const folderMatch = workspaceSpecificPath.match(folderPattern);
|
|
if (folderMatch) {
|
|
return workspaceSpecificPath.replace(folderPattern, `/folder.meta.${folderMatch[1]}`);
|
|
}
|
|
|
|
// Check for settings file pattern: settings.workspaceName.{yaml,json} -> settings.{ext}
|
|
const settingsPattern = new RegExp(`^settings\\.${escapedName}\\.(yaml|json)$`);
|
|
const settingsMatch = workspaceSpecificPath.match(settingsPattern);
|
|
if (settingsMatch) {
|
|
return `settings.${settingsMatch[1]}`;
|
|
}
|
|
|
|
// Check for resource file pattern
|
|
const resourceFilePattern = new RegExp(`\\.${escapedName}(\\.resource\\.file\\..+)$`);
|
|
const resourceFileMatch = workspaceSpecificPath.match(resourceFilePattern);
|
|
|
|
if (resourceFileMatch) {
|
|
const extension = resourceFileMatch[1];
|
|
const pathWithoutBranchAndExtension = workspaceSpecificPath.substring(
|
|
0,
|
|
workspaceSpecificPath.length - `.${sanitizedName}${extension}`.length
|
|
);
|
|
return `${pathWithoutBranchAndExtension}${extension}`;
|
|
}
|
|
|
|
const typedPattern = new RegExp(`\\.${escapedName}(\\.${buildItemTypePattern()}\\.(yaml|json))$`);
|
|
const typedMatch = workspaceSpecificPath.match(typedPattern);
|
|
|
|
if (!typedMatch) {
|
|
return workspaceSpecificPath; // Return unchanged if not a workspace-specific path
|
|
}
|
|
|
|
const extension = typedMatch[1];
|
|
const pathWithoutBranchAndExtension = workspaceSpecificPath.substring(
|
|
0,
|
|
workspaceSpecificPath.length - `.${sanitizedName}${extension}`.length
|
|
);
|
|
|
|
return `${pathWithoutBranchAndExtension}${extension}`;
|
|
}
|
|
|
|
/**
|
|
* 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 getWorkspaceSpecificPath(
|
|
basePath: string,
|
|
specificItems: SpecificItemsConfig | undefined,
|
|
workspaceNameOverride?: string
|
|
): string | undefined {
|
|
if (!specificItems && !isCustomPathFile(basePath)) {
|
|
return undefined;
|
|
}
|
|
|
|
let currentWorkspace: string | null = null;
|
|
if (workspaceNameOverride) {
|
|
currentWorkspace = workspaceNameOverride;
|
|
} else if (isGitRepository()) {
|
|
currentWorkspace = getCurrentGitBranch();
|
|
}
|
|
|
|
if (!currentWorkspace) {
|
|
return undefined;
|
|
}
|
|
|
|
if (isSpecificItem(basePath, specificItems)) {
|
|
return toWorkspaceSpecificPath(basePath, currentWorkspace);
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
// Cache for compiled regex patterns to avoid recompilation
|
|
const workspacePatternCache = new Map<string, RegExp>();
|
|
|
|
/**
|
|
* 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 isCurrentWorkspaceFile(path: string, workspaceNameOverride?: string): boolean {
|
|
// Fileset children are never workspace-specific: only the parent's metadata
|
|
// file carries the suffix, children stay in the shared canonical directory.
|
|
// Their names are arbitrary, so one can look like typed metadata — and since
|
|
// the patterns match a workspace-name segment with `[^.]` (which spans `/`),
|
|
// an unguarded child is dropped from the diff or, worse, remapped onto a
|
|
// sibling's key and deployed over it. The same holds for the files inside a
|
|
// flow or app folder.
|
|
if (isFilesetResource(path) || isFolderResourcePathAnyFormat(path)) {
|
|
return false;
|
|
}
|
|
let currentWorkspace: string | null = null;
|
|
if (workspaceNameOverride) {
|
|
currentWorkspace = workspaceNameOverride;
|
|
} else if (isGitRepository()) {
|
|
currentWorkspace = getCurrentGitBranch();
|
|
}
|
|
|
|
if (!currentWorkspace) {
|
|
return false;
|
|
}
|
|
|
|
// Sanitize branch name to match what would be used in file naming
|
|
const sanitizedName = currentWorkspace.replace(/[\/\\:*?"<>|.]/g, '_');
|
|
const escapedName = sanitizedName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
|
|
// Use cached pattern or create and cache new one
|
|
let pattern = workspacePatternCache.get(currentWorkspace);
|
|
if (!pattern) {
|
|
pattern = new RegExp(
|
|
`\\.${escapedName}\\.${buildItemTypePattern()}\\.(yaml|json)$|` +
|
|
`\\.${escapedName}\\.resource\\.file\\..+$|` +
|
|
`/folder\\.${escapedName}\\.meta\\.(yaml|json)$|` +
|
|
`^settings\\.${escapedName}\\.(yaml|json)$`
|
|
);
|
|
workspacePatternCache.set(currentWorkspace, pattern);
|
|
}
|
|
|
|
return pattern.test(path);
|
|
}
|
|
|
|
/**
|
|
* 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 isWorkspaceSpecificFile(path: string): boolean {
|
|
// Same exemptions as isCurrentWorkspaceFile.
|
|
if (isFilesetResource(path) || isFolderResourcePathAnyFormat(path)) {
|
|
return false;
|
|
}
|
|
const typePattern = buildItemTypePattern();
|
|
return new RegExp(
|
|
`\\.[^.]+\\.${typePattern}\\.(yaml|json)$|` +
|
|
`\\.[^.]+\\.resource\\.file\\..+$|` +
|
|
`/folder\\.[^.]+\\.meta\\.(yaml|json)$|` +
|
|
`^settings\\.[^.]+\\.(yaml|json)$`
|
|
).test(path);
|
|
}
|