diff --git a/cli/src/commands/gitsync-settings/pull.ts b/cli/src/commands/gitsync-settings/pull.ts index b02017af55..4bcd8900d0 100644 --- a/cli/src/commands/gitsync-settings/pull.ts +++ b/cli/src/commands/gitsync-settings/pull.ts @@ -3,11 +3,11 @@ 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 } from "../../core/conf.ts"; +import { SyncOptions, readConfigFile, getEffectiveSettings, DEFAULT_SYNC_OPTIONS, getWmillYamlPath } from "../../core/conf.ts"; import { deepEqual } from "../../utils/utils.ts"; import { getCurrentGitBranch, isGitRepository } from "../../utils/git.ts"; -import { GitSyncRepository, WriteMode } from "./types.ts"; +import { WriteMode } from "./types.ts"; import { GitSyncSettingsConverter } from "./converter.ts"; import { handleLegacyRepositoryMigration } from "./legacySettings.ts"; import { @@ -132,11 +132,9 @@ export async function pullGitSyncSettings( const backendSyncOptions: SyncOptions = GitSyncSettingsConverter.fromBackendFormat(selectedRepo.settings); // Check if wmill.yaml exists - create a default one if it doesn't exist - let wmillYamlExists = true; - try { - await Deno.stat("wmill.yaml"); - } catch (error) { - wmillYamlExists = false; + const wmillYamlPath = getWmillYamlPath(); + const wmillYamlExists = wmillYamlPath !== null; + if (!wmillYamlExists) { if (!opts.jsonOutput) { log.info( colors.yellow( @@ -165,11 +163,11 @@ export async function pullGitSyncSettings( if (isGitRepository()) { const currentBranch = getCurrentGitBranch(); if (currentBranch) { - if (!updatedConfig.git_branches) { - updatedConfig.git_branches = {}; + if (!updatedConfig.gitBranches) { + updatedConfig.gitBranches = {}; } - if (!updatedConfig.git_branches[currentBranch]) { - updatedConfig.git_branches[currentBranch] = { overrides: {} }; + if (!updatedConfig.gitBranches[currentBranch]) { + updatedConfig.gitBranches[currentBranch] = { overrides: {} }; } } } @@ -358,16 +356,16 @@ export async function pullGitSyncSettings( let needsBranchStructure = false; if (isGitRepository()) { const currentBranch = getCurrentGitBranch(); - if (currentBranch && (!localConfig.git_branches || !localConfig.git_branches[currentBranch])) { + if (currentBranch && (!localConfig.gitBranches || !localConfig.gitBranches[currentBranch])) { needsBranchStructure = true; // Create empty branch structure const updatedConfig = { ...localConfig }; - if (!updatedConfig.git_branches) { - updatedConfig.git_branches = {}; + if (!updatedConfig.gitBranches) { + updatedConfig.gitBranches = {}; } - if (!updatedConfig.git_branches[currentBranch]) { - updatedConfig.git_branches[currentBranch] = { overrides: {} }; + if (!updatedConfig.gitBranches[currentBranch]) { + updatedConfig.gitBranches[currentBranch] = { overrides: {} }; } // Write updated configuration @@ -429,11 +427,11 @@ export async function pullGitSyncSettings( const currentBranch = getCurrentGitBranch(); if (currentBranch) { log.info(`Detected Git repository, adding empty branch structure for: ${currentBranch}`); - if (!updatedConfig.git_branches) { - updatedConfig.git_branches = {}; + if (!updatedConfig.gitBranches) { + updatedConfig.gitBranches = {}; } - if (!updatedConfig.git_branches[currentBranch]) { - updatedConfig.git_branches[currentBranch] = { overrides: {} }; + if (!updatedConfig.gitBranches[currentBranch]) { + updatedConfig.gitBranches[currentBranch] = { overrides: {} }; } } } diff --git a/cli/src/commands/gitsync-settings/push.ts b/cli/src/commands/gitsync-settings/push.ts index 14f8a52d98..f2b5bf3d72 100644 --- a/cli/src/commands/gitsync-settings/push.ts +++ b/cli/src/commands/gitsync-settings/push.ts @@ -3,7 +3,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, validateBranchConfiguration, getEffectiveSettings } from "../../core/conf.ts"; +import { SyncOptions, readConfigFile, validateBranchConfiguration, getEffectiveSettings, getWmillYamlPath } from "../../core/conf.ts"; import { deepEqual } from "../../utils/utils.ts"; import { GitSyncRepository } from "./types.ts"; @@ -44,9 +44,8 @@ export async function pushGitSyncSettings( try { // Check if wmill.yaml exists - require it for git-sync settings commands - try { - await Deno.stat("wmill.yaml"); - } catch (error) { + const wmillYamlPath = getWmillYamlPath(); + if (!wmillYamlPath) { log.error( colors.red( "No wmill.yaml file found. Please run 'wmill init' first to create the configuration file.", diff --git a/cli/src/commands/gitsync-settings/utils.ts b/cli/src/commands/gitsync-settings/utils.ts index 47db2c8dfb..10b9cf351b 100644 --- a/cli/src/commands/gitsync-settings/utils.ts +++ b/cli/src/commands/gitsync-settings/utils.ts @@ -30,12 +30,12 @@ export function getOrCreateBranchConfig(config: SyncOptions, branchName: string) config: SyncOptions; branchKey: string; } { - if (!config.git_branches) { - config.git_branches = {}; + if (!config.gitBranches) { + config.gitBranches = {}; } - if (!config.git_branches[branchName]) { - config.git_branches[branchName] = {}; + if (!config.gitBranches[branchName]) { + config.gitBranches[branchName] = {}; } return { @@ -53,12 +53,12 @@ export function applyBackendSettingsToBranch( const { config: updatedConfig } = getOrCreateBranchConfig(config, branchName); // Get the base settings (top-level + defaults) to compare against - const { git_branches, ...topLevelSettings } = config; + const { gitBranches, ...topLevelSettings } = config; const baseSettings: Partial = { ...DEFAULT_SYNC_OPTIONS, ...topLevelSettings }; // Only store fields that differ from the base settings Object.keys(backendSettings).forEach(key => { - if (key !== 'git_branches' && backendSettings[key as keyof SyncOptions] !== undefined) { + if (key !== 'gitBranches' && backendSettings[key as keyof SyncOptions] !== undefined) { const backendValue = backendSettings[key as keyof SyncOptions]; const baseValue = baseSettings[key as keyof SyncOptions]; @@ -66,10 +66,10 @@ export function applyBackendSettingsToBranch( const isDifferent = GitSyncSettingsConverter.isDifferent(backendValue, baseValue); if (isDifferent) { - if (!updatedConfig.git_branches![branchName].overrides) { - updatedConfig.git_branches![branchName].overrides = {}; + if (!updatedConfig.gitBranches![branchName].overrides) { + updatedConfig.gitBranches![branchName].overrides = {}; } - (updatedConfig.git_branches![branchName].overrides as any)[key] = backendValue; + (updatedConfig.gitBranches![branchName].overrides as any)[key] = backendValue; } } }); diff --git a/cli/src/commands/init/init.ts b/cli/src/commands/init/init.ts index c150c4f4fc..53fe2835c2 100644 --- a/cli/src/commands/init/init.ts +++ b/cli/src/commands/init/init.ts @@ -43,14 +43,14 @@ async function initAction(opts: InitOptions) { if (isGitRepository()) { const currentBranch = getCurrentGitBranch(); if (currentBranch) { - initialConfig.git_branches = { + initialConfig.gitBranches = { [currentBranch]: { overrides: {} }, }; } else { - initialConfig.git_branches = {}; + initialConfig.gitBranches = {}; } } else { - initialConfig.git_branches = {}; + initialConfig.gitBranches = {}; } await Deno.writeTextFile("wmill.yaml", yamlStringify(initialConfig)); @@ -116,16 +116,16 @@ async function initAction(opts: InitOptions) { const currentConfig = await import("../../core/conf.ts").then((m) => m.readConfigFile() ); - if (!currentConfig.git_branches) { - currentConfig.git_branches = {}; + if (!currentConfig.gitBranches) { + currentConfig.gitBranches = {}; } - if (!currentConfig.git_branches[currentBranch]) { - currentConfig.git_branches[currentBranch] = { overrides: {} }; + if (!currentConfig.gitBranches[currentBranch]) { + currentConfig.gitBranches[currentBranch] = { overrides: {} }; } - currentConfig.git_branches[currentBranch].baseUrl = + currentConfig.gitBranches[currentBranch].baseUrl = activeWorkspace.remote; - currentConfig.git_branches[currentBranch].workspaceId = + currentConfig.gitBranches[currentBranch].workspaceId = activeWorkspace.workspaceId; await Deno.writeTextFile( diff --git a/cli/src/commands/sync/sync.ts b/cli/src/commands/sync/sync.ts index c17861a497..f7c4833273 100644 --- a/cli/src/commands/sync/sync.ts +++ b/cli/src/commands/sync/sync.ts @@ -42,7 +42,19 @@ import { readConfigFile, getEffectiveSettings, validateBranchConfiguration, + mergeConfigWithConfigFile, } from "../../core/conf.ts"; +import { + SpecificItemsConfig, + getSpecificItemsForCurrentBranch, + isSpecificItem, + getBranchSpecificPath, + fromBranchSpecificPath, + isCurrentBranchFile, + toBranchSpecificPath, + isBranchSpecificFile, +} from "../../core/specific_items.ts"; +import { getCurrentGitBranch } from "../../utils/git.ts"; import { Workspace } from "../workspace/workspace.ts"; import { removePathPrefix } from "../../types.ts"; import { SyncCodebase, listSyncCodebases } from "../../utils/codebase.ts"; @@ -67,9 +79,9 @@ function mergeCliWithEffectiveOptions< // Resolve effective sync options using branch-based configuration async function resolveEffectiveSyncOptions( workspace: Workspace, + localConfig: SyncOptions, promotion?: string ): Promise { - const localConfig = await readConfigFile(); return await getEffectiveSettings(localConfig, promotion); } @@ -631,9 +643,36 @@ export async function elementsToMap( els: DynFSElement, ignore: (path: string, isDirectory: boolean) => boolean, json: boolean, - skips: Skips + skips: Skips, + specificItems?: SpecificItemsConfig ): Promise<{ [key: string]: string }> { const map: { [key: string]: string } = {}; + const processedBasePaths = new Set(); + + // First pass: collect all file paths to identify branch-specific files + const allPaths: string[] = []; + for await (const entry of readDirRecursiveWithIgnore(ignore, els)) { + if (!entry.isDirectory && !entry.ignored) { + allPaths.push(entry.path); + } + } + + const branchSpecificExists = new Set(); + + if (specificItems) { + const currentBranch = getCurrentGitBranch(); + if (currentBranch) { + for (const path of allPaths) { + if (isCurrentBranchFile(path)) { + const basePath = fromBranchSpecificPath(path, currentBranch); + if (isSpecificItem(basePath, specificItems)) { + branchSpecificExists.add(basePath); + } + } + } + } + } + for await (const entry of readDirRecursiveWithIgnore(ignore, els)) { if (entry.isDirectory || entry.ignored) continue; const path = entry.path; @@ -695,11 +734,21 @@ export async function elementsToMap( "nu", "java", "rb", - // for related places search: ADD_NEW_LANG + // for related places search: ADD_NEW_LANG ].includes(path.split(".").pop() ?? "") && !isFileResource(path) ) continue; + + // Handle branch-specific files - skip files for other branches + if (specificItems && isBranchSpecificFile(path)) { + const currentBranch = getCurrentGitBranch(); + if (!currentBranch || !isCurrentBranchFile(path)) { + // Skip branch-specific files for other branches + continue; + } + } + const content = await entry.getContentText(); if (skips.skipSecrets && path.endsWith(".variable" + ext)) { @@ -727,7 +776,33 @@ export async function elementsToMap( log.warn(`Error reading variable ${path} to check for secrets`); } } - map[entry.path] = content; + + // Handle branch-specific path mapping after all filtering + if (specificItems) { + const currentBranch = getCurrentGitBranch(); + if (currentBranch && isCurrentBranchFile(path)) { + // This is a branch-specific file for current branch + const basePath = fromBranchSpecificPath(path, currentBranch); + if (isSpecificItem(basePath, specificItems)) { + // Map to base path for push operations + map[basePath] = content; + processedBasePaths.add(basePath); + } else { + // Branch-specific file doesn't match pattern, skip it + continue; + } + } else if (!isBranchSpecificFile(path)) { + // This is a regular base file, check if we should skip it + if (processedBasePaths.has(path)) { + // Skip base file, we already processed branch-specific version + continue; + } + map[path] = content; + } + } else { + // No specific items configuration, use regular path + map[entry.path] = content; + } } return map; } @@ -758,14 +833,15 @@ async function compareDynFSElement( skips: Skips, ignoreMetadataDeletion: boolean, codebases: SyncCodebase[], - ignoreCodebaseChanges: boolean + ignoreCodebaseChanges: boolean, + specificItems?: SpecificItemsConfig ): Promise { const [m1, m2] = els2 ? await Promise.all([ - elementsToMap(els1, ignore, json, skips), - elementsToMap(els2, ignore, json, skips), + elementsToMap(els1, ignore, json, skips, specificItems), + elementsToMap(els2, ignore, json, skips, specificItems), ]) - : [await elementsToMap(els1, ignore, json, skips), {}]; + : [await elementsToMap(els1, ignore, json, skips, specificItems), {}]; const changes: Change[] = []; @@ -1163,6 +1239,10 @@ export async function pull( opts: GlobalOptions & SyncOptions & { repository?: string; promotion?: string } ) { + + const originalCliOpts = { ...opts }; + opts = await mergeConfigWithConfigFile(opts); + // Validate branch configuration early try { await validateBranchConfiguration(false, opts.yes); @@ -1184,11 +1264,15 @@ export async function pull( // Resolve effective sync options with branch awareness const effectiveOpts = await resolveEffectiveSyncOptions( workspace, + opts, opts.promotion ); + // Extract specific items configuration before merging overwrites gitBranches + const specificItems = getSpecificItemsForCurrentBranch(opts); + // Merge CLI flags with resolved settings (CLI flags take precedence only for explicit overrides) - opts = mergeCliWithEffectiveOptions(opts, effectiveOpts); + opts = mergeCliWithEffectiveOptions(originalCliOpts, effectiveOpts); const codebases = await listSyncCodebases(opts); @@ -1238,7 +1322,8 @@ export async function pull( opts, false, codebases, - true + true, + specificItems ); log.info( @@ -1255,6 +1340,12 @@ export async function pull( ...(change.name === "edited" && change.codebase ? { codebase_changed: true } : {}), + ...(specificItems && isSpecificItem(change.path, specificItems) + ? { + branch_specific: true, + branch_specific_path: getBranchSpecificPath(change.path, specificItems) + } + : {}), })), total: changes.length, }; @@ -1264,7 +1355,7 @@ export async function pull( if (changes.length > 0) { if (!opts.jsonOutput) { - prettyChanges(changes); + prettyChanges(changes, specificItems); } if (opts.dryRun) { log.info(colors.gray(`Dry run complete.`)); @@ -1284,8 +1375,17 @@ export async function pull( log.info(colors.gray(`Applying changes to files ...`)); for await (const change of changes) { - const target = path.join(Deno.cwd(), change.path); - const stateTarget = path.join(Deno.cwd(), ".wmill", change.path); + // Determine if this file should be written to a branch-specific path + let targetPath = change.path; + if (specificItems && isSpecificItem(change.path, specificItems)) { + const branchSpecificPath = getBranchSpecificPath(change.path, specificItems); + if (branchSpecificPath) { + targetPath = branchSpecificPath; + } + } + + const target = path.join(Deno.cwd(), targetPath); + const stateTarget = path.join(Deno.cwd(), ".wmill", targetPath); if (change.name === "edited") { if (opts.stateful) { try { @@ -1328,12 +1428,12 @@ export async function pull( } } if (exts.some((e) => change.path.endsWith(e))) { - log.info(`Editing script content of ${change.path}`); + log.info(`Editing script content of ${targetPath}${targetPath !== change.path ? colors.gray(` (branch-specific override for ${change.path})`) : ""}`); } else if ( change.path.endsWith(".yaml") || change.path.endsWith(".json") ) { - log.info(`Editing ${getTypeStrFromPath(change.path)} ${change.path}`); + log.info(`Editing ${getTypeStrFromPath(change.path)} ${targetPath}${targetPath !== change.path ? colors.gray(` (branch-specific override for ${change.path})`) : ""}`); } await Deno.writeTextFile(target, change.after); @@ -1345,10 +1445,10 @@ export async function pull( await ensureDir(path.dirname(target)); if (opts.stateful) { await ensureDir(path.dirname(stateTarget)); - log.info(`Adding ${getTypeStrFromPath(change.path)} ${change.path}`); + log.info(`Adding ${getTypeStrFromPath(change.path)} ${targetPath}${targetPath !== change.path ? colors.gray(` (branch-specific override for ${change.path})`) : ""}`); } await Deno.writeTextFile(target, change.content); - log.info(`Writing ${getTypeStrFromPath(change.path)} ${change.path}`); + log.info(`Writing ${getTypeStrFromPath(change.path)} ${targetPath}${targetPath !== change.path ? colors.gray(` (branch-specific override for ${change.path})`) : ""}`); if (opts.stateful) { await Deno.copyFile(target, stateTarget); } @@ -1423,6 +1523,12 @@ export async function pull( ...(change.name === "edited" && change.codebase ? { codebase_changed: true } : {}), + ...(specificItems && isSpecificItem(change.path, specificItems) + ? { + branch_specific: true, + branch_specific_path: getBranchSpecificPath(change.path, specificItems) + } + : {}), })), total: changes.length, }; @@ -1445,21 +1551,33 @@ export async function pull( } } -function prettyChanges(changes: Change[]) { +function prettyChanges(changes: Change[], specificItems?: SpecificItemsConfig) { for (const change of changes) { + let displayPath = change.path; + let branchNote = ""; + + // Check if this will be written as a branch-specific file + if (specificItems && isSpecificItem(change.path, specificItems)) { + const branchSpecificPath = getBranchSpecificPath(change.path, specificItems); + if (branchSpecificPath) { + displayPath = branchSpecificPath; + branchNote = " (branch-specific)"; + } + } + if (change.name === "added") { log.info( - colors.green(`+ ${getTypeStrFromPath(change.path)} ` + change.path) + colors.green(`+ ${getTypeStrFromPath(change.path)} ` + displayPath + colors.gray(branchNote)) ); } else if (change.name === "deleted") { log.info( - colors.red(`- ${getTypeStrFromPath(change.path)} ` + change.path) + colors.red(`- ${getTypeStrFromPath(change.path)} ` + displayPath + colors.gray(branchNote)) ); } else if (change.name === "edited") { log.info( colors.yellow( `~ ${getTypeStrFromPath(change.path)} ` + - change.path + + displayPath + colors.gray(branchNote) + (change.codebase ? ` (codebase changed)` : "") ) ); @@ -1499,6 +1617,12 @@ function removeSuffix(str: string, suffix: string) { export async function push( opts: GlobalOptions & SyncOptions & { repository?: string } ) { + // Save original CLI options before merging with config file + const originalCliOpts = { ...opts }; + + // Load configuration from wmill.yaml and merge with CLI options + opts = await mergeConfigWithConfigFile(opts); + // Validate branch configuration early try { await validateBranchConfiguration(false, opts.yes); @@ -1516,11 +1640,15 @@ export async function push( // Resolve effective sync options with branch awareness const effectiveOpts = await resolveEffectiveSyncOptions( workspace, + opts, opts.promotion ); + // Extract specific items configuration BEFORE merging overwrites gitBranches + const specificItems = getSpecificItemsForCurrentBranch(opts); + // Merge CLI flags with resolved settings (CLI flags take precedence only for explicit overrides) - opts = mergeCliWithEffectiveOptions(opts, effectiveOpts); + opts = mergeCliWithEffectiveOptions(originalCliOpts, effectiveOpts); const codebases = await listSyncCodebases(opts); if (opts.raw) { @@ -1581,7 +1709,8 @@ export async function push( opts, true, codebases, - false + false, + specificItems ); const globalDeps = await findGlobalDeps(); @@ -1660,6 +1789,12 @@ export async function push( ...(change.name === "edited" && change.codebase ? { codebase_changed: true } : {}), + ...(specificItems && isSpecificItem(change.path, specificItems) + ? { + branch_specific: true, + branch_specific_path: getBranchSpecificPath(change.path, specificItems) + } + : {}), })), total: changes.length, }; @@ -1669,7 +1804,7 @@ export async function push( if (changes.length > 0) { if (!opts.jsonOutput) { - prettyChanges(changes); + prettyChanges(changes, specificItems); } if (opts.dryRun) { log.info(colors.gray(`Dry run complete.`)); @@ -2041,6 +2176,12 @@ export async function push( ...(change.name === "edited" && change.codebase ? { codebase_changed: true } : {}), + ...(specificItems && isSpecificItem(change.path, specificItems) + ? { + branch_specific: true, + branch_specific_path: getBranchSpecificPath(change.path, specificItems) + } + : {}), })), total: changes.length, duration_ms: Math.round(performance.now() - start), diff --git a/cli/src/commands/workspace/workspace.ts b/cli/src/commands/workspace/workspace.ts index fbf93aed76..335b91be8f 100644 --- a/cli/src/commands/workspace/workspace.ts +++ b/cli/src/commands/workspace/workspace.ts @@ -386,22 +386,22 @@ async function bind( } // For unbind, check if branch exists - if (!bindWorkspace && (!config.git_branches || !config.git_branches[branch])) { - log.error(colors.red(`Branch '${branch}' not found in wmill.yaml git_branches`)); + if (!bindWorkspace && (!config.gitBranches || !config.gitBranches[branch])) { + log.error(colors.red(`Branch '${branch}' not found in wmill.yaml gitBranches`)); return; } // Update the branch configuration with workspace binding - if (!config.git_branches) { - config.git_branches = {}; + if (!config.gitBranches) { + config.gitBranches = {}; } - if (!config.git_branches[branch]) { - config.git_branches[branch] = { overrides: {} }; + if (!config.gitBranches[branch]) { + config.gitBranches[branch] = { overrides: {} }; } if (bindWorkspace && activeWorkspace) { - config.git_branches[branch].baseUrl = activeWorkspace.remote; - config.git_branches[branch].workspaceId = activeWorkspace.workspaceId; + config.gitBranches[branch].baseUrl = activeWorkspace.remote; + config.gitBranches[branch].workspaceId = activeWorkspace.workspaceId; log.info(colors.green( `✓ Bound branch '${branch}' to workspace '${activeWorkspace.name}'\n` + @@ -409,8 +409,8 @@ async function bind( )); } else { // Unbind - delete config.git_branches[branch].baseUrl; - delete config.git_branches[branch].workspaceId; + delete config.gitBranches[branch].baseUrl; + delete config.gitBranches[branch].workspaceId; log.info(colors.green(`✓ Removed workspace binding from branch '${branch}'`)); } diff --git a/cli/src/core/conf.ts b/cli/src/core/conf.ts index 23f2fd2820..5f5c352862 100644 --- a/cli/src/core/conf.ts +++ b/cli/src/core/conf.ts @@ -1,5 +1,8 @@ import { log, yamlParseFile, Confirm, yamlStringify } from "../../deps.ts"; import { getCurrentGitBranch, isGitRepository } from "../utils/git.ts"; +import { join, dirname, resolve, relative } from "node:path"; +import { existsSync } from "node:fs"; +import { execSync } from "node:child_process"; export let showDiffs = false; export function setShowDiffs(value: boolean) { @@ -37,13 +40,40 @@ export interface SyncOptions { codebases?: Codebase[]; parallel?: number; jsonOutput?: boolean; - git_branches?: { + gitBranches?: { + commonSpecificItems?: { + variables?: string[]; + resources?: string[]; + }; + } & { [branchName: string]: SyncOptions & { overrides?: Partial; promotionOverrides?: Partial; baseUrl?: string; workspaceId?: string; - } + specificItems?: { + variables?: string[]; + resources?: string[]; + }; + }; + }; + // Legacy field - deprecated, use gitBranches instead + git_branches?: { + commonSpecificItems?: { + variables?: string[]; + resources?: string[]; + }; + } & { + [branchName: string]: SyncOptions & { + overrides?: Partial; + promotionOverrides?: Partial; + baseUrl?: string; + workspaceId?: string; + specificItems?: { + variables?: string[]; + resources?: string[]; + }; + }; }; promotion?: string; } @@ -62,9 +92,90 @@ export interface Codebase { inject?: string[]; } +function getGitRepoRoot(): string | null { + try { + const result = execSync("git rev-parse --show-toplevel", { + encoding: "utf8", + stdio: "pipe" + }); + return result.trim(); + } catch (error) { + return null; + } +} + +function findWmillYaml(): string | null { + const startDir = resolve(Deno.cwd()); + const isInGitRepo = isGitRepository(); + + // If not in git repo, only check current directory + if (!isInGitRepo) { + const wmillYamlPath = join(startDir, "wmill.yaml"); + return existsSync(wmillYamlPath) ? wmillYamlPath : null; + } + + // If in git repo, search up to git repository root + const gitRoot = getGitRepoRoot(); + let currentDir = startDir; + let foundPath: string | null = null; + + while (true) { + const wmillYamlPath = join(currentDir, "wmill.yaml"); + + if (existsSync(wmillYamlPath)) { + foundPath = wmillYamlPath; + break; + } + + // Check if we've reached the git repository root + if (gitRoot && resolve(currentDir) === resolve(gitRoot)) { + break; + } + + // Check if we've reached the filesystem root + const parentDir = dirname(currentDir); + if (parentDir === currentDir) { + break; + } + + currentDir = parentDir; + } + + // If wmill.yaml was found in a parent directory, warn the user and change working directory + if (foundPath && resolve(dirname(foundPath)) !== resolve(startDir)) { + const configDir = dirname(foundPath); + const relativePath = relative(startDir, foundPath); + log.warn(`âš ī¸ wmill.yaml found in parent directory: ${relativePath}`); + + // Change working directory to where wmill.yaml was found + Deno.chdir(configDir); + log.info(`📁 Changed working directory to: ${configDir}`); + } + + return foundPath; +} + +export function getWmillYamlPath(): string | null { + return findWmillYaml(); +} + export async function readConfigFile(): Promise { try { - const conf = (await yamlParseFile("wmill.yaml")) as SyncOptions; + // First, try to find wmill.yaml recursively + const wmillYamlPath = findWmillYaml(); + + if (!wmillYamlPath) { + log.warn( + "No wmill.yaml found. Use 'wmill init' to bootstrap it. Using 'bun' as default typescript runtime." + ); + return {}; + } + + 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) { @@ -78,18 +189,54 @@ export async function readConfigFile(): Promise { " Please delete your wmill.yaml and run 'wmill init' to recreate it with the new format." ); } else { - // Remove empty overrides with a note - log.info("â„šī¸ Removing empty 'overrides: {}' from wmill.yaml (migrated to git_branches format)"); + // Remove empty overrides delete conf.overrides; - // Write the updated config back to file - try { - await Deno.writeTextFile("wmill.yaml", yamlStringify(conf)); - } catch (error) { - log.warn(`Could not update wmill.yaml to remove empty overrides: ${error instanceof Error ? error.message : error}`); - } + needsConfigWrite = true; + migrationMessages.push("â„šī¸ Removing empty 'overrides: {}' from wmill.yaml (migrated to gitBranches format)"); } } + // 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'."); + } + // 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 Deno.writeTextFile(wmillYamlPath, yamlStringify(conf)); + // 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 (conf?.defaultTs == undefined) { log.warn( "No defaultTs defined in your wmill.yaml. Using 'bun' as default." @@ -100,10 +247,23 @@ export async function readConfigFile(): Promise { if (e instanceof Error && (e.message.includes("overrides") || e.message.includes("Obsolete configuration format"))) { throw e; // Re-throw the specific obsolete format error } - log.warn( - "No wmill.yaml found. Use 'wmill init' to bootstrap it. Using 'bun' as default typescript runtime." - ); - return {}; + + // Since we already found the file path, this is likely a parsing or access error + if (e instanceof Error && e.message.includes("Error parsing yaml")) { + const yamlError = e.cause instanceof Error ? e.cause.message : String(e.cause); + throw new Error( + "❌ YAML syntax error in wmill.yaml:\n" + + " " + yamlError + "\n" + + " Please fix the YAML syntax in wmill.yaml or delete the file to start fresh." + ); + } else { + // File exists but has other issues (permissions, etc.) + throw new Error( + "❌ Failed to read wmill.yaml:\n" + + " " + (e instanceof Error ? e.message : String(e)) + "\n" + + " Please check file permissions or fix the syntax." + ); + } } } @@ -148,26 +308,26 @@ export async function validateBranchConfiguration(skipValidation?: boolean, auto } const config = await readConfigFile(); - const { git_branches } = config; + const { gitBranches } = config; const currentBranch = getCurrentGitBranch(); - // In a git repository, git_branches section is recommended - if (!git_branches || Object.keys(git_branches).length === 0) { + // In a git repository, gitBranches section is recommended + if (!gitBranches || Object.keys(gitBranches).length === 0) { log.warn( - "âš ī¸ WARNING: In a Git repository, the 'git_branches' section is recommended in wmill.yaml.\n" + - " Consider adding a git_branches section with configuration for your Git branches.\n" + + "âš ī¸ 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." ); return; } - // Current branch must be defined in git_branches config - if (currentBranch && !git_branches[currentBranch]) { + // Current branch must be defined in gitBranches config + if (currentBranch && !gitBranches[currentBranch]) { // In interactive mode, offer to create the branch if (Deno.stdin.isTerminal()) { - const availableBranches = Object.keys(git_branches).join(', '); + const availableBranches = Object.keys(gitBranches).join(', '); log.info( - `Current Git branch '${currentBranch}' is not defined in the git_branches configuration.\n` + + `Current Git branch '${currentBranch}' is not defined in the gitBranches configuration.\n` + `Available branches: ${availableBranches}` ); @@ -177,13 +337,21 @@ export async function validateBranchConfiguration(skipValidation?: boolean, auto }); if (shouldCreate) { + // Warn if branch name contains filesystem-unsafe characters + if (/[\/\\:*?"<>|.]/.test(currentBranch)) { + const sanitizedBranchName = currentBranch.replace(/[\/\\:*?"<>|.]/g, '_'); + log.warn(`âš ī¸ WARNING: Branch name "${currentBranch}" contains filesystem-unsafe characters (/ \\ : * ? " < > | .).`); + log.warn(` Branch-specific files will be saved with sanitized name: "${sanitizedBranchName}"`); + log.warn(` Example: "file.variable.yaml" → "file.${sanitizedBranchName}.variable.yaml"`); + } + // Read current config, add branch, and write it back const currentConfig = await readConfigFile(); - if (!currentConfig.git_branches) { - currentConfig.git_branches = {}; + if (!currentConfig.gitBranches) { + currentConfig.gitBranches = {}; } - currentConfig.git_branches[currentBranch] = { overrides: {} }; + currentConfig.gitBranches[currentBranch] = { overrides: {} }; await Deno.writeTextFile("wmill.yaml", yamlStringify(currentConfig)); @@ -193,10 +361,17 @@ export async function validateBranchConfiguration(skipValidation?: boolean, auto return; } } else { + // Warn about filesystem-unsafe characters in branch name + if (/[\/\\:*?"<>|.]/.test(currentBranch)) { + const sanitizedBranchName = currentBranch.replace(/[\/\\:*?"<>|.]/g, '_'); + log.warn(`âš ī¸ WARNING: Branch name "${currentBranch}" contains filesystem-unsafe characters (/ \\ : * ? " < > | .).`); + log.warn(` Branch-specific files will use sanitized name: "${sanitizedBranchName}"`); + } + log.warn( - `âš ī¸ WARNING: Current Git branch '${currentBranch}' is not defined in the git_branches configuration.\n` + - ` Consider adding configuration for branch '${currentBranch}' in the git_branches section of wmill.yaml.\n` + - ` Available branches: ${Object.keys(git_branches).join(', ')}` + `âš ī¸ 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(', ')}` ); return; } @@ -206,15 +381,15 @@ export async function validateBranchConfiguration(skipValidation?: boolean, auto // Get effective settings by merging top-level settings with branch-specific overrides export async function getEffectiveSettings(config: SyncOptions, promotion?: string, skipBranchValidation?: boolean, suppressLogs?: boolean): Promise { // Start with top-level settings from config - const { git_branches, ...topLevelSettings } = config; + const { gitBranches, ...topLevelSettings } = config; let effective = { ...topLevelSettings }; if (isGitRepository()) { const currentBranch = getCurrentGitBranch(); // If promotion is specified, use that branch's promotionOverrides or overrides - if (promotion && git_branches && git_branches[promotion]) { - const targetBranch = git_branches[promotion]; + if (promotion && gitBranches && gitBranches[promotion]) { + const targetBranch = gitBranches[promotion]; // First try promotionOverrides, then fall back to overrides if (targetBranch.promotionOverrides) { @@ -232,8 +407,8 @@ export async function getEffectiveSettings(config: SyncOptions, promotion?: stri } } // Otherwise use current branch overrides (existing behavior) - else if (currentBranch && git_branches && git_branches[currentBranch] && git_branches[currentBranch].overrides) { - Object.assign(effective, git_branches[currentBranch].overrides); + else if (currentBranch && gitBranches && gitBranches[currentBranch] && gitBranches[currentBranch].overrides) { + Object.assign(effective, gitBranches[currentBranch].overrides); if (!suppressLogs) { log.info(`Applied settings for Git branch: ${currentBranch}`); } diff --git a/cli/src/core/context.ts b/cli/src/core/context.ts index 628471dcce..3fcfdc6510 100644 --- a/cli/src/core/context.ts +++ b/cli/src/core/context.ts @@ -135,7 +135,7 @@ async function tryResolveBranchWorkspace( // Read wmill.yaml to check for branch workspace configuration const config = await readConfigFile(); - const branchConfig = config.git_branches?.[currentBranch]; + const branchConfig = config.gitBranches?.[currentBranch]; // Check if branch has workspace configuration if (!branchConfig?.baseUrl || !branchConfig?.workspaceId) { diff --git a/cli/src/core/specific_items.ts b/cli/src/core/specific_items.ts new file mode 100644 index 0000000000..ac0696ccf0 --- /dev/null +++ b/cli/src/core/specific_items.ts @@ -0,0 +1,186 @@ +import { minimatch } from "../../deps.ts"; +import { getCurrentGitBranch, isGitRepository } from "../utils/git.ts"; +import { SyncOptions } from "./conf.ts"; + +export interface SpecificItemsConfig { + variables?: string[]; + resources?: string[]; +} + +/** + * Get the specific items configuration for the current git branch + * Merges commonSpecificItems with branch-specific specificItems + */ +export function getSpecificItemsForCurrentBranch(config: SyncOptions): SpecificItemsConfig | undefined { + if (!isGitRepository() || !config.gitBranches) { + return undefined; + } + + const currentBranch = getCurrentGitBranch(); + if (!currentBranch) { + 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 + const merged: SpecificItemsConfig = {}; + + // Add common items + if (commonItems?.variables) { + merged.variables = [...commonItems.variables]; + } + if (commonItems?.resources) { + merged.resources = [...commonItems.resources]; + } + + // Add branch-specific items (extending common items) + if (branchItems?.variables) { + merged.variables = [...(merged.variables || []), ...branchItems.variables]; + } + if (branchItems?.resources) { + merged.resources = [...(merged.resources || []), ...branchItems.resources]; + } + + 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)); +} + +/** + * Check if a file path should be treated as branch-specific + */ +export function isSpecificItem(path: string, specificItems: SpecificItemsConfig | undefined): boolean { + if (!specificItems) { + return false; + } + + // 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; + } + + return false; +} + +/** + * Convert a base path to a branch-specific path + */ +export function toBranchSpecificPath(basePath: string, branchName: string): string { + // Extract the extension (e.g., ".variable.yaml" or ".resource.yaml") + const extensionMatch = basePath.match(/(\.(variable|resource)\.yaml)$/); + if (!extensionMatch) { + return basePath; // Return unchanged if no recognized extension + } + + const extension = extensionMatch[1]; + const 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}`; +} + +/** + * Convert a branch-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, '_'); + + // Pattern: path.sanitizedBranchName.extension + const escapedBranchName = sanitizedBranchName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const pattern = new RegExp(`\\.${escapedBranchName}(\\.(variable|resource)\\.yaml)$`); + const match = branchSpecificPath.match(pattern); + + if (!match) { + return branchSpecificPath; // Return unchanged if not a branch-specific path + } + + const extension = match[1]; + const pathWithoutBranchAndExtension = branchSpecificPath.substring( + 0, + branchSpecificPath.length - `.${sanitizedBranchName}${extension}`.length + ); + + return `${pathWithoutBranchAndExtension}${extension}`; +} + +/** + * Get the branch-specific path for the current branch if the item should be branch-specific + */ +export function getBranchSpecificPath( + basePath: string, + specificItems: SpecificItemsConfig | undefined +): string | undefined { + if (!isGitRepository() || !specificItems) { + return undefined; + } + + const currentBranch = getCurrentGitBranch(); + if (!currentBranch) { + return undefined; + } + + if (isSpecificItem(basePath, specificItems)) { + return toBranchSpecificPath(basePath, currentBranch); + } + + return undefined; +} + +// Cache for compiled regex patterns to avoid recompilation +const branchPatternCache = new Map(); + +/** + * Check if a path is a branch-specific file for the current branch + */ +export function isCurrentBranchFile(path: string): boolean { + if (!isGitRepository()) { + return false; + } + + const currentBranch = getCurrentGitBranch(); + if (!currentBranch) { + return false; + } + + // Use cached pattern or create and cache new one + let pattern = branchPatternCache.get(currentBranch); + if (!pattern) { + pattern = new RegExp(`\\.${currentBranch}\\.(variable|resource)\\.yaml$`); + branchPatternCache.set(currentBranch, pattern); + } + + return pattern.test(path); +} + +/** + * Check if a path is a branch-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 { + // Pattern: *.branchName.variable.yaml or *.branchName.resource.yaml + return /\.[^.]+\.(variable|resource)\.yaml$/.test(path); +} diff --git a/frontend/src/lib/hubPaths.json b/frontend/src/lib/hubPaths.json index c62dc94fc9..b7d21a24ab 100644 --- a/frontend/src/lib/hubPaths.json +++ b/frontend/src/lib/hubPaths.json @@ -11,7 +11,8 @@ "gitSync_9": "hub/19738/sync-script-to-git-repo-windmill", "gitSync_10": "hub/19785/sync-script-to-git-repo-windmill", "gitSync_11": "hub/19789/sync-script-to-git-repo-windmill", - "gitSync": "hub/19798/sync-script-to-git-repo-windmill", + "gitSync_12": "hub/19798/sync-script-to-git-repo-windmill", + "gitSync": "hub/19801/sync-script-to-git-repo-windmill", "gitSyncTest_0": "hub/9073/git-repo-test-read-write-windmill", "gitSyncTest_1": "hub/11499/git-repo-test-read-write-windmill", "gitSyncTest_2": "hub/11667/git-repo-test-read-write-windmill",