feat: CLI improvements (#6446)

* feat: branch specific items for cli

* error on wmill.yaml parsing errors

* also search for wmill.yaml in parent dirs when git

* git_branches -> gitBranches

* Update cli/src/core/specific_items.ts

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* sanitize branch name (regex + fs path)

* improve sanitatino

* robust relative paths

* hubpath

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
This commit is contained in:
Alexander Petric
2025-08-23 03:45:00 +02:00
committed by GitHub
parent c13747cda9
commit a41b9e47e2
10 changed files with 613 additions and 113 deletions
+18 -20
View File
@@ -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: {} };
}
}
}
+3 -4
View File
@@ -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.",
+9 -9
View File
@@ -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<SyncOptions> = { ...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;
}
}
});
+9 -9
View File
@@ -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(
+165 -24
View File
@@ -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<SyncOptions> {
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<string>();
// 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<string>();
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<Change[]> {
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),
+10 -10
View File
@@ -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}'`));
}
+210 -35
View File
@@ -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<SyncOptions>;
promotionOverrides?: Partial<SyncOptions>;
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<SyncOptions>;
promotionOverrides?: Partial<SyncOptions>;
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<SyncOptions> {
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<SyncOptions> {
" 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<SyncOptions> {
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<SyncOptions> {
// 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}`);
}
+1 -1
View File
@@ -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) {
+186
View File
@@ -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<string, RegExp>();
/**
* 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);
}
+2 -1
View File
@@ -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",