cli: git sync improvements v2 (#6220)

* log when override is applied vs default taken in git sync

* simplify cli merging options + add explicit override test

* gitsync-settings pull/push ask for confirmation or --yes if tty

* cli legacy backend repo setting detected + interactive migration
This commit is contained in:
Alexander Petric
2025-07-17 18:14:40 -04:00
committed by GitHub
parent 08f640eda0
commit e34e42b436
9 changed files with 433 additions and 167 deletions
+20 -6
View File
@@ -108,31 +108,45 @@ export function getEffectiveSettings(
): SyncOptions {
// Start with empty object - no defaults
let effective = {} as SyncOptions;
// Merge top-level settings from config (which contains user's chosen defaults)
Object.keys(config).forEach(key => {
if (key !== 'overrides' && config[key as keyof SyncOptions] !== undefined) {
(effective as any)[key] = config[key as keyof SyncOptions];
}
});
if (!config.overrides) {
if (repo) {
log.info(`No overrides found in wmill.yaml, using top-level settings (repository flag ignored)`);
}
return effective;
}
// Construct override keys using the single format
const workspaceKey = `${baseUrl}:${workspaceId}:*`;
const repoKey = `${baseUrl}:${workspaceId}:${repo}`;
let appliedOverrides: string[] = [];
// Apply workspace-level overrides
if (config.overrides[workspaceKey]) {
Object.assign(effective, config.overrides[workspaceKey]);
appliedOverrides.push("workspace-level");
}
// Apply repository-specific overrides (overrides workspace-level)
if (config.overrides[repoKey]) {
Object.assign(effective, config.overrides[repoKey]);
appliedOverrides.push("repository-specific");
} else if (repo) {
// Repository was specified but no override found
log.info(`Repository override not found for "${repo}", using ${appliedOverrides.length > 0 ? appliedOverrides.join(" + ") : "top-level"} settings`);
}
if (appliedOverrides.length > 0) {
log.info(`Applied ${appliedOverrides.join(" + ")} overrides${repo ? ` for repository "${repo}"` : ""}`);
}
return effective;
}
+282 -54
View File
@@ -1,4 +1,4 @@
import { colors, Command, log, yamlStringify } from "./deps.ts";
import { colors, Command, Confirm, log, yamlStringify } from "./deps.ts";
import { GlobalOptions } from "./types.ts";
import { requireLogin } from "./auth.ts";
import { resolveWorkspace } from "./context.ts";
@@ -164,6 +164,147 @@ function includeTypeToSyncOptions(
};
}
// Shared migration function for legacy repositories
async function handleLegacyRepositoryMigration(
selectedRepo: any,
gitSyncSettings: any,
workspace: any,
opts: { yes?: boolean },
operationName: string = "operation"
): Promise<any> {
if (selectedRepo.settings) {
return selectedRepo; // Already migrated
}
// This repository is in legacy format - handle migration
if (!gitSyncSettings.include_path || !gitSyncSettings.include_type) {
throw new Error(
`Repository "${selectedRepo.git_repo_resource_path}" has legacy format but workspace-level include_path or include_type is missing. This indicates corrupted git-sync settings.`
);
}
const workspaceIncludePath = gitSyncSettings.include_path;
const workspaceIncludeType = gitSyncSettings.include_type;
if (Deno.stdout.isTerminal() && !opts.yes) {
// Interactive mode - show migration prompt
console.log(colors.yellow('\n⚠️ Legacy git-sync settings detected!'));
console.log(`\nRepository "${selectedRepo.git_repo_resource_path}" has legacy settings format.`);
console.log('The new format allows per-repository filter configuration.');
if (operationName === "push") {
console.log('This repository must be migrated before pushing settings.\n');
} else {
console.log('\n');
}
console.log(colors.bold('Current workspace-level settings:'));
console.log(` Include paths: ${workspaceIncludePath.join(', ')}`);
console.log(` Include types: ${workspaceIncludeType.join(', ')}\n`);
// Show what the migration will do
let finalIncludeType = [...workspaceIncludeType];
if (selectedRepo.exclude_types_override && selectedRepo.exclude_types_override.length > 0) {
const originalCount = finalIncludeType.length;
finalIncludeType = finalIncludeType.filter(
type => !selectedRepo.exclude_types_override.includes(type)
);
const excludedCount = originalCount - finalIncludeType.length;
console.log(colors.yellow(`Repository excludes ${excludedCount} types: ${selectedRepo.exclude_types_override.join(', ')}`));
}
console.log(colors.bold('\nAfter migration, repository will have:'));
console.log(` Include paths: ${workspaceIncludePath.join(', ')}`);
console.log(` Include types: ${finalIncludeType.join(', ')}\n`);
const confirm = await Confirm.prompt({
message: operationName === "push"
? 'Do you want to migrate this repository before pushing?'
: 'Do you want to migrate this repository?',
default: true
});
if (!confirm) {
const message = operationName === "push"
? '\n⚠️ Migration skipped. Cannot push to legacy repository.'
: '\n⚠️ Migration skipped. You can migrate later via the UI.';
console.log(colors.yellow(message));
if (operationName === "push") {
return null; // Signal to exit push operation
}
throw new Error('Migration cancelled by user');
}
// Perform the migration
let migratedIncludeType = [...workspaceIncludeType];
if (selectedRepo.exclude_types_override && selectedRepo.exclude_types_override.length > 0) {
migratedIncludeType = migratedIncludeType.filter(
type => !selectedRepo.exclude_types_override.includes(type)
);
}
const migratedRepo = {
...selectedRepo,
settings: {
include_path: [...workspaceIncludePath],
include_type: migratedIncludeType,
exclude_path: [],
extra_include_path: []
}
};
// Remove the old field
delete migratedRepo.exclude_types_override;
// Update the backend with migrated repository
const updatedRepositories = gitSyncSettings.repositories.map((repo: any) => {
if (repo.git_repo_resource_path === selectedRepo.git_repo_resource_path) {
return migratedRepo;
}
return repo;
});
await wmill.editWorkspaceGitSyncConfig({
workspace: workspace.workspaceId,
requestBody: {
git_sync_settings: {
repositories: updatedRepositories,
// Keep workspace-level settings if other repos are still legacy
...(gitSyncSettings.repositories.some((r: any) => r.git_repo_resource_path !== selectedRepo.git_repo_resource_path && !r.settings) && {
include_path: workspaceIncludePath,
include_type: workspaceIncludeType
})
}
}
});
console.log(colors.green('\n✓ Repository migration completed successfully!'));
if (operationName === "push") {
console.log('Now proceeding with push operation...\n');
}
return migratedRepo;
} else {
// Non-interactive mode - show error
console.error(colors.red('\n❌ Legacy git-sync settings detected!'));
console.error(`\nRepository "${selectedRepo.git_repo_resource_path}" has legacy settings format.`);
if (operationName === "push") {
console.error('This repository must be migrated before pushing settings.');
}
console.error('Please choose one of the following options:\n');
console.error('1. Go to the Windmill UI > Workspace Settings > Git Sync');
console.error(' Review and save this repository to migrate to the new format.\n');
console.error('2. Run this command in interactive mode (with TTY) to migrate.');
console.error(` Example: wmill gitsync-settings ${operationName}\n`);
if (operationName === "push") {
console.error('3. Pull settings first to migrate: wmill gitsync-settings pull\n');
} else {
console.error('3. Push local settings to override backend settings:');
console.error(' wmill gitsync-settings push\n');
}
Deno.exit(1);
}
}
// Convert SyncOptions boolean flags to backend include_type array
function syncOptionsToIncludeType(opts: SyncOptions): string[] {
const includeTypes: string[] = [];
@@ -279,6 +420,31 @@ function displayChanges(
}
}
async function selectAndLogRepository(
repositories: GitSyncRepository[],
repository?: string,
): Promise<GitSyncRepository> {
let selectedRepo: GitSyncRepository;
if (repository) {
const found = repositories.find(
(r: GitSyncRepository) =>
r.git_repo_resource_path === repository ||
r.git_repo_resource_path === `$res:${repository}`,
);
if (!found) {
throw new Error(`Repository ${repository} not found`);
}
selectedRepo = found;
const repoPath = selectedRepo.git_repo_resource_path.replace(/^\$res:/, "");
log.info(colors.cyan(`Using repository: ${colors.bold(repoPath)}`));
} else {
selectedRepo = await selectRepository(repositories);
}
return selectedRepo;
}
async function pullGitSyncSettings(
opts: GlobalOptions & {
repository?: string;
@@ -289,6 +455,7 @@ async function pullGitSyncSettings(
replace?: boolean;
override?: boolean;
withBackendSettings?: string;
yes?: boolean;
},
) {
const workspace = await resolveWorkspace(opts);
@@ -401,22 +568,19 @@ async function pullGitSyncSettings(
}
// Find the repository to work with
let selectedRepo: GitSyncRepository;
if (opts.repository) {
const found = settings.git_sync.repositories.find(
(r: GitSyncRepository) =>
r.git_repo_resource_path === opts.repository ||
r.git_repo_resource_path === `$res:${opts.repository}`,
);
if (!found) {
throw new Error(`Repository ${opts.repository} not found`);
}
selectedRepo = found;
} else {
selectedRepo = await selectRepository(
settings.git_sync.repositories,
);
}
let selectedRepo = await selectAndLogRepository(
settings.git_sync.repositories,
opts.repository,
);
// Check if the selected repository needs migration and handle it
selectedRepo = await handleLegacyRepositoryMigration(
selectedRepo,
settings.git_sync,
workspace,
opts,
"pull"
);
// Convert backend settings to SyncOptions format
const backendSyncOptions: SyncOptions = {
@@ -534,7 +698,7 @@ async function pullGitSyncSettings(
);
} else {
if (hasChanges) {
log.info("Changes that would be made:");
log.info("Changes that would be applied locally:");
const changes = generateChanges(normalizedCurrent, normalizedBackend);
if (Object.keys(changes).length === 0) {
@@ -573,7 +737,14 @@ async function pullGitSyncSettings(
);
const hasConflict = !deepEqual(gitSyncBackend, gitSyncCurrent);
if (hasConflict && Deno.stdin.isTerminal()) {
if (hasConflict && !opts.yes && Deno.stdin.isTerminal()) {
// Show the diff first
log.info("Changes that would be applied locally:");
const changes = generateChanges(currentSettings, backendSyncOptions);
if (Object.keys(changes).length > 0) {
displayChanges(changes);
}
// Interactive mode - ask user
const { Select } = await import("./deps.ts");
const choice = await Select.prompt({
@@ -607,6 +778,16 @@ async function pullGitSyncSettings(
repoPath,
);
}
} else if (hasConflict && opts.yes) {
// --yes flag: default to override behavior for conflicts
writeMode = "override";
const repoPath = normalizeRepoPath(selectedRepo.git_repo_resource_path);
overrideKey = constructOverrideKey(
workspace.remote,
workspace.workspaceId,
repoPath,
);
log.info(colors.yellow("Settings conflict detected. Using --override behavior (default for --yes)."));
} else if (hasConflict) {
// Non-interactive mode with conflicts - show message and exit
if (opts.jsonOutput) {
@@ -664,11 +845,19 @@ async function pullGitSyncSettings(
// Apply the settings based on write mode
let updatedConfig: SyncOptions;
// Log which settings mode is being used
const repoPath = selectedRepo.git_repo_resource_path.replace(/^\$res:/, "");
if (writeMode === "override") {
log.info(`Applied repository-specific overrides for repository "${repoPath}"`);
} else {
log.info(`Applied settings for repository "${repoPath}"`);
}
if (writeMode === "replace") {
// Preserve existing local config and update only git-sync fields
updatedConfig = { ...localConfig };
// Remove overrides since we're in replace mode
delete updatedConfig.overrides;
// Clear overrides since we're in replace mode, but keep empty object for consistency
updatedConfig.overrides = {};
// Update with backend git-sync settings
Object.assign(updatedConfig, backendSyncOptions);
} else if (writeMode === "override" && overrideKey) {
@@ -761,6 +950,7 @@ async function pushGitSyncSettings(
diff?: boolean;
jsonOutput?: boolean;
withBackendSettings?: string;
yes?: boolean;
},
) {
const workspace = await resolveWorkspace(opts);
@@ -888,21 +1078,23 @@ async function pushGitSyncSettings(
}
// Find the repository to work with
let selectedRepo: GitSyncRepository;
if (opts.repository) {
const found = settings.git_sync.repositories.find(
(r: GitSyncRepository) =>
r.git_repo_resource_path === opts.repository ||
r.git_repo_resource_path === `$res:${opts.repository}`,
);
if (!found) {
throw new Error(`Repository ${opts.repository} not found`);
}
selectedRepo = found;
} else {
selectedRepo = await selectRepository(
settings.git_sync.repositories,
);
let selectedRepo = await selectAndLogRepository(
settings.git_sync.repositories,
opts.repository,
);
// Check if the selected repository needs migration and handle it
selectedRepo = await handleLegacyRepositoryMigration(
selectedRepo,
settings.git_sync,
workspace,
opts,
"push"
);
// If migration was cancelled, exit
if (selectedRepo === null) {
return;
}
// Get effective settings for this workspace/repo
@@ -922,24 +1114,25 @@ async function pushGitSyncSettings(
extra_include_path: effectiveSettings.extraIncludes || [],
};
// Calculate diff for all modes
const currentBackend = selectedRepo.settings;
// Convert current backend settings to SyncOptions for user-friendly display
const currentSyncOptions: SyncOptions = {
includes: currentBackend.include_path || [],
excludes: currentBackend.exclude_path || [],
extraIncludes: currentBackend.extra_include_path || [],
...includeTypeToSyncOptions(currentBackend.include_type || []),
};
const normalizedCurrent = normalizeSyncOptions(currentSyncOptions);
const normalizedEffective = normalizeSyncOptions(effectiveSettings);
const gitSyncCurrent = extractGitSyncFields(normalizedCurrent);
const gitSyncEffective = extractGitSyncFields(normalizedEffective);
const hasChanges = !deepEqual(gitSyncEffective, gitSyncCurrent);
if (opts.diff) {
// Show what would be pushed
const currentBackend = selectedRepo.settings;
// Convert current backend settings to SyncOptions for user-friendly display
const currentSyncOptions: SyncOptions = {
includes: currentBackend.include_path || [],
excludes: currentBackend.exclude_path || [],
extraIncludes: currentBackend.extra_include_path || [],
...includeTypeToSyncOptions(currentBackend.include_type || []),
};
const normalizedCurrent = normalizeSyncOptions(currentSyncOptions);
const normalizedEffective = normalizeSyncOptions(effectiveSettings);
const gitSyncCurrent = extractGitSyncFields(normalizedCurrent);
const gitSyncEffective = extractGitSyncFields(normalizedEffective);
const hasChanges = !deepEqual(gitSyncEffective, gitSyncCurrent);
// --diff flag: show differences and exit
if (opts.jsonOutput) {
// Generate structured diff using the same normalized objects
const structuredDiff = hasChanges
@@ -958,7 +1151,7 @@ async function pushGitSyncSettings(
);
} else {
if (hasChanges) {
log.info("Changes that would be pushed:");
log.info("Changes that would be pushed to Windmill:");
const changes = generateChanges(
normalizedCurrent,
normalizedEffective,
@@ -976,6 +1169,39 @@ async function pushGitSyncSettings(
return;
}
// Default behavior: show changes and ask for confirmation (unless --yes is passed)
if (hasChanges) {
if (!opts.jsonOutput) {
const changes = generateChanges(
normalizedCurrent,
normalizedEffective,
);
if (Object.keys(changes).length === 0) {
log.info(colors.green("No changes to push"));
return;
} else {
log.info("Changes that would be pushed to Windmill:");
displayChanges(changes);
}
}
// Ask for confirmation unless --yes is passed or not in TTY
if (!opts.yes && Deno.stdin.isTerminal()) {
const confirmed = await Confirm.prompt({
message: `Do you want to apply these changes to the remote?`,
default: true,
});
if (!confirmed) {
return;
}
}
} else {
log.info(colors.green("No changes to push"));
return;
}
if (opts.withBackendSettings) {
// Skip backend update when using simulated settings
if (opts.jsonOutput) {
@@ -1104,6 +1330,7 @@ const command = new Command()
"--with-backend-settings <json:string>",
"Use provided JSON settings instead of querying backend (for testing)",
)
.option("--yes", "Skip interactive prompts and use default behavior")
.action(pullGitSyncSettings as any)
.command("push")
.description(
@@ -1119,6 +1346,7 @@ const command = new Command()
"--with-backend-settings <json:string>",
"Use provided JSON settings instead of querying backend (for testing)",
)
.option("--yes", "Skip interactive prompts and use default behavior")
.action(pushGitSyncSettings as any);
export { pullGitSyncSettings, pushGitSyncSettings };
+1
View File
@@ -143,6 +143,7 @@ const command = new Command()
skipFolders: DEFAULT_SYNC_OPTIONS.skipFolders,
includeSchedules: DEFAULT_SYNC_OPTIONS.includeSchedules,
includeTriggers: DEFAULT_SYNC_OPTIONS.includeTriggers,
overrides: {},
};
await Deno.writeTextFile(
+5 -38
View File
@@ -56,43 +56,8 @@ function mergeCliWithEffectiveOptions<T extends GlobalOptions & SyncOptions & {
cliOpts: T,
effectiveOpts: SyncOptions
): T {
// Start with effective options from config, then overlay only explicitly provided CLI flags
const mergedOpts = Object.assign({}, effectiveOpts) as T;
// Always preserve these operational CLI flags
if (cliOpts.dryRun !== undefined) mergedOpts.dryRun = cliOpts.dryRun;
if (cliOpts.yes !== undefined) mergedOpts.yes = cliOpts.yes;
if (cliOpts.stateful !== undefined) mergedOpts.stateful = cliOpts.stateful;
if (cliOpts.skipPull !== undefined) mergedOpts.skipPull = cliOpts.skipPull;
if (cliOpts.failConflicts !== undefined) mergedOpts.failConflicts = cliOpts.failConflicts;
if (cliOpts.plainSecrets !== undefined) mergedOpts.plainSecrets = cliOpts.plainSecrets;
if (cliOpts.json !== undefined) mergedOpts.json = cliOpts.json;
if (cliOpts.message !== undefined) mergedOpts.message = cliOpts.message;
if (cliOpts.parallel !== undefined) mergedOpts.parallel = cliOpts.parallel;
if (cliOpts.jsonOutput !== undefined) mergedOpts.jsonOutput = cliOpts.jsonOutput;
if (cliOpts.repository !== undefined) mergedOpts.repository = cliOpts.repository;
// Always preserve CLI include flags (they should override config file settings)
if (cliOpts.includeUsers !== undefined) mergedOpts.includeUsers = cliOpts.includeUsers;
if (cliOpts.includeGroups !== undefined) mergedOpts.includeGroups = cliOpts.includeGroups;
if (cliOpts.includeSettings !== undefined) mergedOpts.includeSettings = cliOpts.includeSettings;
if (cliOpts.includeKey !== undefined) mergedOpts.includeKey = cliOpts.includeKey;
if (cliOpts.includeSchedules !== undefined) mergedOpts.includeSchedules = cliOpts.includeSchedules;
if (cliOpts.includeTriggers !== undefined) mergedOpts.includeTriggers = cliOpts.includeTriggers;
// Always preserve CLI skip flags (they should override config file settings)
if (cliOpts.skipScripts !== undefined) mergedOpts.skipScripts = cliOpts.skipScripts;
if (cliOpts.skipFolders !== undefined) mergedOpts.skipFolders = cliOpts.skipFolders;
if (cliOpts.skipVariables !== undefined) mergedOpts.skipVariables = cliOpts.skipVariables;
if (cliOpts.skipResources !== undefined) mergedOpts.skipResources = cliOpts.skipResources;
if (cliOpts.skipResourceTypes !== undefined) mergedOpts.skipResourceTypes = cliOpts.skipResourceTypes;
if (cliOpts.skipSecrets !== undefined) mergedOpts.skipSecrets = cliOpts.skipSecrets;
if (cliOpts.skipFlows !== undefined) mergedOpts.skipFlows = cliOpts.skipFlows;
if (cliOpts.skipApps !== undefined) mergedOpts.skipApps = cliOpts.skipApps;
return mergedOpts;
// overlay CLI options on top (undefined cliOpts won't override effectiveOpts)
return Object.assign({}, effectiveOpts, cliOpts) as T;
}
// Resolve effective sync options with smart repository detection
@@ -164,12 +129,14 @@ async function resolveEffectiveSyncOptions(
} else {
// Non-interactive mode - list options and use top-level
log.warn(`Multiple repository overrides found: ${applicableRepos.join(', ')}`);
log.warn(`Use --repository flag to specify which one to use. Using top-level settings.`);
log.warn(`Running in non-interactive mode. Use --repository flag to specify which one to use.`);
log.info(`Falling back to top-level settings (no repository-specific overrides applied)`);
}
}
}
// No repository overrides found or selected - use top-level settings
log.info(`No repository overrides found, using top-level settings`);
return getEffectiveSettings(
localConfig,
workspace.remote,
+8 -8
View File
@@ -55,7 +55,7 @@ excludes: []`);
// Read updated config
const updatedConfig = await Deno.readTextFile(`${tempDir}/wmill.yaml`);
const backendUrl = new URL(backend.baseUrl).toString();
// Should create workspace wildcard override
assertStringIncludes(updatedConfig, `'${backendUrl}:${backend.workspace}:*':`);
assertStringIncludes(updatedConfig, "overrides:");
@@ -109,14 +109,14 @@ skipVariables: false`);
// Read updated config
const updatedConfig = await Deno.readTextFile(`${tempDir}/wmill.yaml`);
// Should update top-level settings, not create overrides
assertStringIncludes(updatedConfig, "includes:\n - f/special/**");
assertStringIncludes(updatedConfig, "excludes:\n - '*.test.ts'");
assertStringIncludes(updatedConfig, "extraIncludes:\n - g/**");
// Should NOT have overrides section
assertEquals(updatedConfig.includes("overrides:"), false, "Default mode should not create overrides");
// Should have empty overrides section for consistency
assertStringIncludes(updatedConfig, "overrides: {}", "Should have empty overrides section for consistency");
});
});
@@ -168,10 +168,10 @@ skipResources: false`);
], tempDir);
assertEquals(result.code, 0);
// Should show differences
assertStringIncludes(result.stdout, "Changes that would be made:");
assertStringIncludes(result.stdout, "Changes that would be applied locally:");
// Should show the change for skipResources (ignoring ANSI color codes)
assertStringIncludes(result.stdout, "skipResources:");
});
});
});
+3 -3
View File
@@ -126,8 +126,8 @@ Deno.test("Init: --use-backend flag applies git-sync settings", async () => {
assertStringIncludes(wmillYaml, "*.test.ts", "Should include backend's exclude_path");
assertStringIncludes(wmillYaml, "g/**", "Should include backend's extra_include_path");
// Should NOT have overrides section since we're starting fresh
assertEquals(wmillYaml.includes("overrides:"), false, "Init should not create overrides section");
// Should have empty overrides section for consistency
assertStringIncludes(wmillYaml, "overrides: {}");
});
});
@@ -180,6 +180,6 @@ Deno.test("Init: --use-default bypasses backend settings check", async () => {
// Should NOT have backend-specific settings
assertEquals(wmillYaml.includes("f/should-be-ignored/**"), false, "Should not include backend settings");
assertEquals(wmillYaml.includes("overrides:"), false, "Should not create overrides when using defaults");
assertStringIncludes(wmillYaml, "overrides: {}", "Should have empty overrides section for consistency");
});
});
+30 -30
View File
@@ -24,7 +24,7 @@ Deno.test("Multi-Instance: gitsync-settings pull with new format", async () => {
await withContainerizedBackend(async (backend, tempDir) => {
// Set up workspace profile
await setupWorkspaceProfile(backend, "multi_instance_test");
// Create wmill.yaml with new format overrides for different instances
const backendUrl = new URL(backend.baseUrl).toString(); // Normalize URL
await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun
@@ -38,22 +38,22 @@ overrides:
includeTriggers: true
includeSchedules: true
skipVariables: true
# Different instance (won't match)
"https://app.windmill.dev/:${backend.workspace}:u/test/test_repo":
includeTriggers: false
includeSchedules: false
skipVariables: false`);
// Pull settings - should use the matching instance override (skipVariables: true)
const pullResult = await backend.runCLICommand([
'gitsync-settings', 'pull',
'--repository', 'u/test/test_repo',
'--diff'
], tempDir, "multi_instance_test");
assertEquals(pullResult.code, 0);
assertStringIncludes(pullResult.stdout, "Changes that would be made:");
assertStringIncludes(pullResult.stdout, "Changes that would be applied locally:");
// includeSchedules should show as a change since backend default is false
assertStringIncludes(pullResult.stdout, "includeSchedules");
});
@@ -61,9 +61,9 @@ overrides:
Deno.test("Multi-Instance: gitsync-settings push with overrides", async () => {
await withContainerizedBackend(async (backend, tempDir) => {
// Set up workspace profile
// Set up workspace profile
await setupWorkspaceProfile(backend, "push_override_test");
// Create wmill.yaml with specific settings that differ from backend defaults
const backendUrl = new URL(backend.baseUrl).toString();
await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun
@@ -78,16 +78,16 @@ overrides:
"${backendUrl}:${backend.workspace}:u/test/test_repo":
includeSchedules: true
skipVariables: true`);
// Push settings - should show changes because includeSchedules differs from backend
const pushResult = await backend.runCLICommand([
'gitsync-settings', 'push',
'--repository', 'u/test/test_repo',
'--diff'
], tempDir, "push_override_test");
assertEquals(pushResult.code, 0);
assertStringIncludes(pushResult.stdout, "Changes that would be pushed:");
assertStringIncludes(pushResult.stdout, "Changes that would be pushed to Windmill:");
assertStringIncludes(pushResult.stdout, "includeSchedules");
});
});
@@ -95,7 +95,7 @@ overrides:
Deno.test("Multi-Instance: sync with repository-specific overrides", async () => {
await withContainerizedBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend, "my-workspace_123");
const backendUrl = new URL(backend.baseUrl).toString();
await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun
includes:
@@ -105,22 +105,22 @@ excludes: []
overrides:
"${backendUrl}:${backend.workspace}:u/test/test_repo":
skipApps: true`);
const result = await backend.runCLICommand([
'sync', 'pull',
'--repository', 'u/test/test_repo',
'--dry-run',
'--json-output'
], tempDir, "my-workspace_123");
assertEquals(result.code, 0);
const data = parseJsonFromCLIOutput(result.stdout);
// Test is designed to verify that the new format works correctly
// The test app should NOT appear in changes because skipApps: true
const hasTestApp = (data.changes || []).some((change: any) =>
const hasTestApp = (data.changes || []).some((change: any) =>
change.path?.includes('f/test_dashboard')
);
assertEquals(hasTestApp, false, "Test app should be skipped due to skipApps override");
@@ -130,7 +130,7 @@ overrides:
Deno.test("Multi-Instance: auto-detection of single repository override", async () => {
await withContainerizedBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend, "auto_detect_test");
const backendUrl = new URL(backend.baseUrl).toString();
await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun
includes:
@@ -142,21 +142,21 @@ overrides:
"${backendUrl}:${backend.workspace}:u/test/test_repo":
skipApps: true
includeSchedules: true`);
// Don't specify --repository, it should auto-detect
const result = await backend.runCLICommand([
'sync', 'pull',
'--dry-run',
'--json-output'
], tempDir, "auto_detect_test");
assertEquals(result.code, 0);
assertStringIncludes(result.stdout, "Auto-selected repository: u/test/test_repo");
const data = parseJsonFromCLIOutput(result.stdout);
// The test app should NOT appear because of auto-detected skipApps: true
const hasTestApp = (data.changes || []).some((change: any) =>
const hasTestApp = (data.changes || []).some((change: any) =>
change.path?.includes('f/test_dashboard')
);
assertEquals(hasTestApp, false, "Test app should be skipped due to auto-detected override");
@@ -166,7 +166,7 @@ overrides:
Deno.test("Multi-Instance: workspace wildcards with new format", async () => {
await withContainerizedBackend(async (backend, tempDir) => {
await setupWorkspaceProfile(backend, "wildcard_test");
const backendUrl = new URL(backend.baseUrl).toString();
await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun
includes:
@@ -178,22 +178,22 @@ overrides:
"${backendUrl}:${backend.workspace}:*":
skipVariables: true
skipResources: true`);
const result = await backend.runCLICommand([
'sync', 'pull',
'--repository', 'u/test/test_repo',
'--dry-run',
'--json-output'
], tempDir, "wildcard_test");
assertEquals(result.code, 0);
const data = parseJsonFromCLIOutput(result.stdout);
// Variables should be skipped due to wildcard override
const hasTestVariable = (data.changes || []).some((change: any) =>
const hasTestVariable = (data.changes || []).some((change: any) =>
change.path?.includes('u/admin/test_config.variable.yaml')
);
assertEquals(hasTestVariable, false, "Variables should be skipped due to wildcard override");
});
});
});
+82 -26
View File
@@ -30,7 +30,7 @@ Deno.test("Integration: wmill.yaml configuration produces expected results", asy
await withContainerizedBackend(async (backend, tempDir) => {
// Set up workspace profile with name "localhost_test"
await setupWorkspaceProfile(backend);
// Create wmill.yaml with settings
await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun
includes:
@@ -43,7 +43,7 @@ skipResources: true
includeSettings: true
includeSchedules: true
includeTriggers: true`);
// Test pull with wmill.yaml configuration
const yamlResult = await backend.runCLICommand(['sync', 'pull', '--dry-run', '--json-output'], tempDir);
if (yamlResult.code !== 0) {
@@ -53,21 +53,21 @@ includeTriggers: true`);
console.log("Stderr:", yamlResult.stderr);
}
assertEquals(yamlResult.code, 0);
// Extract JSON from CLI output (skip log messages)
const yamlData = parseJsonFromCLIOutput(yamlResult.stdout);
// Should include settings.yaml due to includeSettings: true
const hasSettings = (yamlData.changes || []).some((change: any) =>
const hasSettings = (yamlData.changes || []).some((change: any) =>
change.type === 'added' && change.path === 'settings.yaml'
);
assertEquals(hasSettings, true);
// Should NOT include resources or variables (due to skip flags)
const hasResources = (yamlData.changes || []).some((change: any) =>
const hasResources = (yamlData.changes || []).some((change: any) =>
change.type === 'added' && change.path?.includes('.resource.yaml')
);
const hasVariables = (yamlData.changes || []).some((change: any) =>
const hasVariables = (yamlData.changes || []).some((change: any) =>
change.type === 'added' && change.path?.includes('.variable.yaml')
);
assertEquals(hasResources, false);
@@ -79,35 +79,35 @@ Deno.test("Integration: settings.yaml inclusion respects includeSettings flag",
await withContainerizedBackend(async (backend, tempDir) => {
// Set up workspace profile with name "localhost_test"
await setupWorkspaceProfile(backend);
// Test 1: includeSettings: true should include settings.yaml
// Test 1: includeSettings: true should include settings.yaml
await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun
includes:
- "**"
includeSettings: true`);
const includeResult = await backend.runCLICommand(['sync', 'pull', '--dry-run', '--json-output'], tempDir);
assertEquals(includeResult.code, 0);
// Extract JSON from CLI output (skip log messages)
const includeData = parseJsonFromCLIOutput(includeResult.stdout);
const hasSettingsInclude = (includeData.changes || []).some((change: any) =>
const hasSettingsInclude = (includeData.changes || []).some((change: any) =>
change.type === 'added' && change.path === 'settings.yaml'
);
assertEquals(hasSettingsInclude, true);
// Test 2: includeSettings: false should NOT include settings.yaml
await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun
includes:
- "**"
includeSettings: false`);
const excludeResult = await backend.runCLICommand(['sync', 'pull', '--dry-run', '--json-output'], tempDir);
assertEquals(excludeResult.code, 0);
// Extract JSON from CLI output (skip log messages)
const excludeData = parseJsonFromCLIOutput(excludeResult.stdout);
const hasSettingsExclude = (excludeData.changes || []).some((change: any) =>
const hasSettingsExclude = (excludeData.changes || []).some((change: any) =>
change.type === 'added' && change.path === 'settings.yaml'
);
assertEquals(hasSettingsExclude, false);
@@ -118,30 +118,86 @@ Deno.test("Integration: resource/variable filtering respects skip flags", async
await withContainerizedBackend(async (backend, tempDir) => {
// Set up workspace profile with name "localhost_test"
await setupWorkspaceProfile(backend);
// Test skipResources: true
// Test skipResources: true
await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun
includes:
- "**"
skipResources: true
skipVariables: false`);
const result = await backend.runCLICommand(['sync', 'pull', '--dry-run', '--json-output'], tempDir);
assertEquals(result.code, 0);
// Extract JSON from CLI output (skip log messages)
const data = parseJsonFromCLIOutput(result.stdout);
// Should NOT include resources
const hasResources = (data.changes || []).some((change: any) =>
const hasResources = (data.changes || []).some((change: any) =>
change.type === 'added' && change.path?.includes('.resource.yaml')
);
assertEquals(hasResources, false);
// Should include variables (not skipped)
const hasVariables = (data.changes || []).some((change: any) =>
const hasVariables = (data.changes || []).some((change: any) =>
change.type === 'added' && change.path?.includes('.variable.yaml')
);
assertEquals(hasVariables, true);
});
});
});
// =============================================================================
// CLI FLAG OVERRIDE TESTS
// Tests for CLI flags overriding configuration file settings
// =============================================================================
Deno.test("CLI skip flags override wmill.yaml configuration", async () => {
await withContainerizedBackend(async (backend, tempDir) => {
// Set up workspace profile with name "localhost_test"
await setupWorkspaceProfile(backend);
// Create wmill.yaml that INCLUDES resources by default (skipResources: false)
await Deno.writeTextFile(`${tempDir}/wmill.yaml`, `defaultTs: bun
includes:
- f/**
- u/**
skipResources: false
skipResourceTypes: false
includeSettings: true`);
// Test 1: Without CLI flags - should respect wmill.yaml (include resources)
const configResult = await backend.runCLICommand(['sync', 'pull', '--dry-run', '--json-output'], tempDir);
assertEquals(configResult.code, 0);
const configData = parseJsonFromCLIOutput(configResult.stdout);
// Should include resources (not skipped by config)
const hasResources = (configData.changes || []).some((change: any) =>
change.type === 'added' && change.path?.includes('.resource.yaml')
);
assertEquals(hasResources, true, "Resources should be included by wmill.yaml config");
// Test 2: With CLI --skip-resources flag - should override wmill.yaml to skip resources
const overrideResult = await backend.runCLICommand([
'sync', 'pull', '--dry-run', '--json-output',
'--skip-resources', // CLI flag should override config to skip resources
'--skip-resource-types' // CLI flag should override config to skip resource types
], tempDir);
assertEquals(overrideResult.code, 0);
const overrideData = parseJsonFromCLIOutput(overrideResult.stdout);
// Should NOT include resources (CLI flag overrides config)
const hasResourcesOverride = (overrideData.changes || []).some((change: any) =>
change.type === 'added' && change.path?.includes('.resource.yaml')
);
assertEquals(hasResourcesOverride, false, "CLI --skip-resources flag should override wmill.yaml to exclude resources");
// Should NOT include resource types (CLI flag overrides config)
const hasResourceTypesOverride = (overrideData.changes || []).some((change: any) =>
change.type === 'added' && change.path?.includes('.resource-type.yaml')
);
assertEquals(hasResourceTypesOverride, false, "CLI --skip-resource-types flag should override wmill.yaml to exclude resource types");
});
});
+2 -2
View File
@@ -2,7 +2,7 @@
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-nocheck This file is copied from a JS project, so it's not type-safe.
import { log, encodeHex, SEP } from "./deps.ts";
import { colors, log, encodeHex, SEP } from "./deps.ts";
import crypto from "node:crypto";
export function deepEqual<T>(a: T, b: T): boolean {
@@ -167,7 +167,7 @@ export async function selectRepository<T extends Repository>(
if (repositories.length === 1) {
const repoPath = repositories[0].git_repo_resource_path.replace(/^\$res:/, "");
log.info(`Using repository: ${repoPath}`);
log.info(colors.cyan(`Auto-selected repository: ${colors.bold(repoPath)}`));
return repositories[0];
}