Files
windmill/cli/gitsync-settings.ts
T
Alexander Petric ec1ed0ba6b 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
2025-07-17 22:14:40 +00:00

1354 lines
42 KiB
TypeScript

import { colors, Command, Confirm, log, yamlStringify } from "./deps.ts";
import { GlobalOptions } from "./types.ts";
import { requireLogin } from "./auth.ts";
import { resolveWorkspace } from "./context.ts";
import * as wmill from "./gen/services.gen.ts";
import {
DEFAULT_SYNC_OPTIONS,
getEffectiveSettings,
readConfigFile,
SyncOptions,
} from "./conf.ts";
import { deepEqual, Repository, selectRepository } from "./utils.ts";
// Constants for git-sync fields to avoid duplication
const GIT_SYNC_FIELDS = [
"includes",
"excludes",
"extraIncludes",
"skipScripts",
"skipFlows",
"skipApps",
"skipFolders",
"skipVariables",
"skipResources",
"skipResourceTypes",
"skipSecrets",
"includeSchedules",
"includeTriggers",
"includeUsers",
"includeGroups",
"includeSettings",
"includeKey",
] as const;
// Helper to normalize repository path by removing $res: prefix
function normalizeRepoPath(path: string): string {
return path.replace(/^\$res:/, "");
}
// Helper to get typed field value from SyncOptions
function getFieldValue(opts: SyncOptions, field: string): any {
return (opts as any)[field];
}
// Construct override key using the single format: baseUrl:workspaceId:repo
function constructOverrideKey(
baseUrl: string,
workspaceId: string,
repoPath: string,
workspaceLevel = false,
): string {
// Validate that components don't contain colons to avoid key collisions
if (baseUrl.includes(':') && !baseUrl.startsWith('http')) {
throw new Error(`Invalid baseUrl contains colon: ${baseUrl}`);
}
if (workspaceId.includes(':')) {
throw new Error(`Invalid workspaceId contains colon: ${workspaceId}`);
}
if (repoPath.includes(':') && !repoPath.startsWith('$res:')) {
throw new Error(`Invalid repoPath contains colon: ${repoPath}`);
}
if (workspaceLevel) {
return `${baseUrl}:${workspaceId}:*`;
}
return `${baseUrl}:${workspaceId}:${repoPath}`;
}
// Helper to compare string arrays (used for includes/excludes/extraIncludes)
function arraysEqual(arr1: string[], arr2: string[]): boolean {
if (arr1.length !== arr2.length) {
return false;
}
const sorted1 = [...arr1].sort();
const sorted2 = [...arr2].sort();
return sorted1.every((item, index) => item === sorted2[index]);
}
// Normalize SyncOptions for semantic comparison - treat undefined arrays as empty arrays
function normalizeSyncOptions(opts: SyncOptions): SyncOptions {
return {
...opts,
includes: opts.includes || [],
excludes: opts.excludes || [],
extraIncludes: opts.extraIncludes || [],
};
}
// Extract only git-sync relevant fields for comparison
function extractGitSyncFields(opts: SyncOptions): Partial<SyncOptions> {
return {
includes: opts.includes || [],
excludes: opts.excludes || [],
extraIncludes: opts.extraIncludes || [],
skipScripts: opts.skipScripts,
skipFlows: opts.skipFlows,
skipApps: opts.skipApps,
skipFolders: opts.skipFolders,
skipVariables: opts.skipVariables,
skipResources: opts.skipResources,
skipResourceTypes: opts.skipResourceTypes,
skipSecrets: opts.skipSecrets,
includeSchedules: opts.includeSchedules,
includeTriggers: opts.includeTriggers,
includeUsers: opts.includeUsers,
includeGroups: opts.includeGroups,
includeSettings: opts.includeSettings,
includeKey: opts.includeKey,
};
}
// Helper function to determine current settings based on write mode and conflicts
function getCurrentSettings(
localConfig: SyncOptions,
writeMode: "override" | "replace",
overrideKey?: string,
): SyncOptions {
if (
writeMode === "override" &&
overrideKey &&
localConfig.overrides?.[overrideKey]
) {
return {
...DEFAULT_SYNC_OPTIONS,
...localConfig,
...localConfig.overrides[overrideKey],
};
} else {
// For "replace" mode, exclude overrides since they're never accessed
const { overrides, ...configWithoutOverrides } = localConfig;
return { ...DEFAULT_SYNC_OPTIONS, ...configWithoutOverrides };
}
}
// Interface for git-sync repository settings from backend
interface GitSyncRepository extends Repository {
settings: {
include_path: string[];
include_type: string[];
exclude_path?: string[];
extra_include_path?: string[];
};
}
// Convert backend include_type array to SyncOptions boolean flags
function includeTypeToSyncOptions(
includeTypes: string[],
): Partial<SyncOptions> {
return {
skipScripts: !includeTypes.includes("script"),
skipFlows: !includeTypes.includes("flow"),
skipApps: !includeTypes.includes("app"),
skipFolders: !includeTypes.includes("folder"),
skipVariables: !includeTypes.includes("variable"),
skipResources: !includeTypes.includes("resource"),
skipResourceTypes: !includeTypes.includes("resourcetype"),
skipSecrets: !includeTypes.includes("secret"),
includeSchedules: includeTypes.includes("schedule"),
includeTriggers: includeTypes.includes("trigger"),
includeUsers: includeTypes.includes("user"),
includeGroups: includeTypes.includes("group"),
includeSettings: includeTypes.includes("settings"),
includeKey: includeTypes.includes("key"),
};
}
// 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[] = [];
if (!opts.skipScripts) includeTypes.push("script");
if (!opts.skipFlows) includeTypes.push("flow");
if (!opts.skipApps) includeTypes.push("app");
if (!opts.skipFolders) includeTypes.push("folder");
if (!opts.skipVariables) includeTypes.push("variable");
if (!opts.skipResources) includeTypes.push("resource");
if (!opts.skipResourceTypes) includeTypes.push("resourcetype");
if (!opts.skipSecrets) includeTypes.push("secret");
if (opts.includeSchedules) includeTypes.push("schedule");
if (opts.includeTriggers) includeTypes.push("trigger");
if (opts.includeUsers) includeTypes.push("user");
if (opts.includeGroups) includeTypes.push("group");
if (opts.includeSettings) includeTypes.push("settings");
if (opts.includeKey) includeTypes.push("key");
return includeTypes;
}
// Convert SyncOptions to backend format used by both Windmill backend and UI
function syncOptionsToBackendFormat(opts: SyncOptions): {
include_path: string[];
exclude_path: string[];
extra_include_path: string[];
include_type: string[];
} {
return {
include_path: opts.includes || [],
exclude_path: opts.excludes || [],
extra_include_path: opts.extraIncludes || [],
include_type: syncOptionsToIncludeType(opts),
};
}
// Select repository interactively if multiple exist
// Generate structured diff showing field changes
function generateStructuredDiff(
current: any,
backend: any,
): { [key: string]: { from: any; to: any } } {
const diff: { [key: string]: { from: any; to: any } } = {};
// Get all unique keys from both objects
const allKeys = new Set([...Object.keys(current), ...Object.keys(backend)]);
for (const key of allKeys) {
const currentValue = current[key];
const backendValue = backend[key];
if (!deepEqual(currentValue, backendValue)) {
diff[key] = {
from: currentValue,
to: backendValue,
};
}
}
return diff;
}
// Helper to generate changes between two normalized SyncOptions objects
function generateChanges(
normalizedCurrent: SyncOptions,
normalizedNew: SyncOptions,
): { [key: string]: { from: any; to: any } } {
const changes: { [key: string]: { from: any; to: any } } = {};
for (const field of GIT_SYNC_FIELDS) {
const currentValue = getFieldValue(normalizedCurrent, field);
const newValue = getFieldValue(normalizedNew, field);
if (!deepEqual(currentValue, newValue)) {
changes[field] = {
from: currentValue,
to: newValue,
};
}
}
return changes;
}
// Helper to display changes in human-readable format
function displayChanges(
changes: { [key: string]: { from: any; to: any } },
): void {
for (const [field, change] of Object.entries(changes)) {
if (
Array.isArray(change.from) ||
Array.isArray(change.to)
) {
console.log(colors.yellow(` ${field}:`));
console.log(
colors.red(
` - ${JSON.stringify(change.from)}`,
),
);
console.log(
colors.green(
` + ${JSON.stringify(change.to)}`,
),
);
} else {
console.log(
colors.yellow(` ${field}: `) +
colors.red(`${change.from}`) +
" → " +
colors.green(`${change.to}`),
);
}
}
}
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;
workspaceLevel?: boolean;
default?: boolean;
diff?: boolean;
jsonOutput?: boolean;
replace?: boolean;
override?: boolean;
withBackendSettings?: string;
yes?: boolean;
},
) {
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
try {
// Parse and validate --with-backend-settings if provided
let settings: any;
if (opts.withBackendSettings) {
try {
const parsedSettings = JSON.parse(opts.withBackendSettings);
// Validate the structure matches expected test format (raw settings object)
if (
!parsedSettings.include_path ||
!Array.isArray(parsedSettings.include_path)
) {
throw new Error(
"Invalid settings format. Expected include_path array",
);
}
if (
!parsedSettings.include_type ||
!Array.isArray(parsedSettings.include_type)
) {
throw new Error(
"Invalid settings format. Expected include_type array",
);
}
// Create mock backend response with single repository using provided settings
const mockRepositoryPath = opts.repository || "u/mock/repo";
settings = {
git_sync: {
repositories: [{
git_repo_resource_path: mockRepositoryPath,
settings: {
include_path: parsedSettings.include_path,
include_type: parsedSettings.include_type,
exclude_path: parsedSettings.exclude_path || [],
extra_include_path: parsedSettings.extra_include_path || [],
},
}],
},
};
} catch (parseError) {
const errorMessage = parseError instanceof Error ? parseError.message : String(parseError);
if (opts.jsonOutput) {
console.log(
JSON.stringify({
success: false,
error:
`Failed to parse --with-backend-settings JSON: ${errorMessage}`,
}),
);
} else {
log.error(
colors.red(
`Failed to parse --with-backend-settings JSON: ${errorMessage}`,
),
);
}
return;
}
} else {
// Fetch workspace settings to get git-sync configuration
try {
settings = await wmill.getSettings({
workspace: workspace.workspaceId,
});
} catch (apiError) {
const errorMessage = apiError instanceof Error ? apiError.message : String(apiError);
if (opts.jsonOutput) {
console.log(
JSON.stringify({
success: false,
error: `Failed to fetch workspace settings: ${errorMessage}`,
}),
);
} else {
log.error(
colors.red(
`Failed to fetch workspace settings: ${errorMessage}`,
),
);
}
return;
}
}
if (
!settings.git_sync?.repositories ||
settings.git_sync.repositories.length === 0
) {
if (opts.jsonOutput) {
console.log(
JSON.stringify({
success: false,
error: "No git-sync repositories configured",
}),
);
} else {
log.error(
colors.red(
"No git-sync repositories configured in workspace",
),
);
}
return;
}
// Find the repository to work with
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 = {
includes: selectedRepo.settings.include_path || [],
excludes: selectedRepo.settings.exclude_path || [],
extraIncludes: selectedRepo.settings.extra_include_path || [],
...includeTypeToSyncOptions(
selectedRepo.settings.include_type || [],
),
};
// Check if wmill.yaml exists - require it for git-sync settings commands
try {
await Deno.stat("wmill.yaml");
} catch (error) {
log.error(
colors.red(
"No wmill.yaml file found. Please run 'wmill init' first to create the configuration file.",
),
);
Deno.exit(1);
}
// Read current local configuration
const localConfig = await readConfigFile();
// Determine where to write the settings for diff display
let overrideKey: string | undefined;
let writeMode: "override" | "replace" = "replace";
// For diff mode, determine what the write mode would be without interactive prompts
if (opts.default) {
writeMode = "replace";
} else if (opts.replace) {
writeMode = "replace";
} else if (opts.override || opts.workspaceLevel) {
writeMode = "override";
if (opts.workspaceLevel) {
overrideKey = constructOverrideKey(
workspace.remote,
workspace.workspaceId,
"",
true,
);
} else {
const repoPath = normalizeRepoPath(selectedRepo.git_repo_resource_path);
overrideKey = constructOverrideKey(
workspace.remote,
workspace.workspaceId,
repoPath,
);
}
} else {
// Default behavior for existing files with no explicit flags
// Use same logic as diff to determine if there's a real conflict
const currentSettings = getCurrentSettings(
localConfig,
"replace", // Check against replace mode
undefined,
);
const gitSyncBackend = extractGitSyncFields(
normalizeSyncOptions(backendSyncOptions),
);
const gitSyncCurrent = extractGitSyncFields(
normalizeSyncOptions(currentSettings),
);
const hasConflict = !deepEqual(gitSyncBackend, gitSyncCurrent);
if (hasConflict) {
// For diff mode, show what override would look like
writeMode = "override";
const repoPath = normalizeRepoPath(selectedRepo.git_repo_resource_path);
overrideKey = constructOverrideKey(
workspace.remote,
workspace.workspaceId,
repoPath,
);
} else {
writeMode = "replace";
}
}
if (opts.diff) {
// Show differences between local and backend
const currentSettings = getCurrentSettings(
localConfig,
writeMode,
overrideKey,
);
const normalizedCurrent = normalizeSyncOptions(currentSettings);
const normalizedBackend = normalizeSyncOptions(backendSyncOptions);
const gitSyncCurrent = extractGitSyncFields(normalizedCurrent);
const gitSyncBackend = extractGitSyncFields(normalizedBackend);
const hasChanges = !deepEqual(gitSyncBackend, gitSyncCurrent);
if (opts.jsonOutput) {
const repoPath = normalizeRepoPath(selectedRepo.git_repo_resource_path);
// Generate structured diff using the same normalized objects
const structuredDiff = hasChanges
? generateStructuredDiff(gitSyncCurrent, gitSyncBackend)
: {};
console.log(
JSON.stringify({
success: true,
hasChanges,
local: syncOptionsToBackendFormat(normalizedCurrent),
backend: syncOptionsToBackendFormat(normalizedBackend),
repository: selectedRepo.git_repo_resource_path,
diff: structuredDiff,
}),
);
} else {
if (hasChanges) {
log.info("Changes that would be applied locally:");
const changes = generateChanges(normalizedCurrent, normalizedBackend);
if (Object.keys(changes).length === 0) {
log.info(colors.green("No differences found"));
} else {
displayChanges(changes);
}
} else {
log.info(colors.green("No differences found"));
}
}
return;
}
// For non-diff mode, handle interactive logic if needed
// Only show interactive prompts for existing files with conflicts
if (
!opts.diff &&
!opts.default &&
!opts.replace &&
!opts.override &&
!opts.workspaceLevel
) {
// Use the same logic as diff to determine current settings
const currentSettings = getCurrentSettings(
localConfig,
writeMode,
overrideKey,
);
const gitSyncBackend = extractGitSyncFields(
normalizeSyncOptions(backendSyncOptions),
);
const gitSyncCurrent = extractGitSyncFields(
normalizeSyncOptions(currentSettings),
);
const hasConflict = !deepEqual(gitSyncBackend, gitSyncCurrent);
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({
message: "Settings conflict detected. How would you like to proceed?",
options: [
{
name: "Replace existing settings",
value: "replace",
},
{
name: "Add repository-specific override",
value: "override",
},
{ name: "Cancel", value: "cancel" },
],
});
if (choice === "cancel") {
log.info("Operation cancelled");
return;
}
writeMode = choice as "replace" | "override";
if (writeMode === "override") {
const repoPath = normalizeRepoPath(
selectedRepo.git_repo_resource_path,
);
overrideKey = constructOverrideKey(
workspace.remote,
workspace.workspaceId,
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) {
console.log(
JSON.stringify({
success: false,
error:
"Settings conflict detected. Use --replace or --override flags to resolve.",
hasConflict: true,
}),
);
} else {
log.error(colors.red("Settings conflict detected."));
log.info(
"Use --replace to overwrite existing settings or --override to add repository-specific override.",
);
}
return;
}
}
// Check if there are actually any changes before writing
const currentSettingsForCheck = getCurrentSettings(
localConfig,
writeMode,
overrideKey,
);
const gitSyncBackend = extractGitSyncFields(
normalizeSyncOptions(backendSyncOptions),
);
const gitSyncCurrent = extractGitSyncFields(
normalizeSyncOptions(currentSettingsForCheck),
);
const hasActualChanges = !deepEqual(gitSyncBackend, gitSyncCurrent);
if (!hasActualChanges) {
if (opts.jsonOutput) {
console.log(
JSON.stringify({
success: true,
message: "No changes needed",
repository: selectedRepo.git_repo_resource_path,
}),
);
} else {
log.info(
colors.green(
"No changes needed - settings are already up to date",
),
);
}
return;
}
// 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 };
// 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) {
// Add repository-specific override
updatedConfig = { ...localConfig };
if (!updatedConfig.overrides) {
updatedConfig.overrides = {};
}
// Only store the delta - settings that differ from current effective settings
const currentEffective = getCurrentSettings(localConfig, "replace");
const deltaSettings: Partial<SyncOptions> = {};
// Compare each setting and only include differences
for (const [key, value] of Object.entries(backendSyncOptions)) {
if (key === "overrides") continue; // Skip overrides field
const currentValue = (currentEffective as any)[key];
const newValue = value;
// Compare arrays by content, primitives by value
const isDifferent =
Array.isArray(currentValue) && Array.isArray(newValue)
? !arraysEqual(currentValue, newValue)
: currentValue !== newValue;
if (isDifferent) {
(deltaSettings as any)[key] = newValue;
}
}
updatedConfig.overrides[overrideKey] = deltaSettings;
} else {
// Replace top-level settings
updatedConfig = { ...localConfig };
// Copy all backend settings to top level, excluding overrides
const { overrides, ...topLevelSettings } = backendSyncOptions;
Object.assign(updatedConfig, topLevelSettings);
}
// Write updated configuration
await Deno.writeTextFile("wmill.yaml", yamlStringify(updatedConfig));
if (opts.jsonOutput) {
console.log(
JSON.stringify({
success: true,
message: `Git-sync settings pulled successfully`,
repository: selectedRepo.git_repo_resource_path,
overrideKey,
}),
);
} else {
log.info(
colors.green(
`Git-sync settings pulled successfully from ${selectedRepo.git_repo_resource_path}`,
),
);
if (writeMode === "override" && overrideKey) {
log.info(
colors.gray(
`Settings written to override key: ${overrideKey}`,
),
);
} else if (writeMode === "replace") {
log.info(colors.gray(`Settings written as simple configuration`));
} else {
log.info(colors.gray(`Settings written to top-level defaults`));
}
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
if (opts.jsonOutput) {
console.log(
JSON.stringify({ success: false, error: errorMessage }),
);
} else {
log.error(
colors.red(
`Failed to pull git-sync settings: ${errorMessage}`,
),
);
}
}
}
async function pushGitSyncSettings(
opts: GlobalOptions & {
repository?: string;
diff?: boolean;
jsonOutput?: boolean;
withBackendSettings?: string;
yes?: boolean;
},
) {
const workspace = await resolveWorkspace(opts);
await requireLogin(opts);
try {
// Check if wmill.yaml exists - require it for git-sync settings commands
try {
await Deno.stat("wmill.yaml");
} catch (error) {
log.error(
colors.red(
"No wmill.yaml file found. Please run 'wmill init' first to create the configuration file.",
),
);
Deno.exit(1);
}
// Read local configuration
const localConfig = await readConfigFile();
// Parse and validate --with-backend-settings if provided, otherwise fetch from backend
let settings: any;
if (opts.withBackendSettings) {
try {
const parsedSettings = JSON.parse(opts.withBackendSettings);
// Validate the structure matches expected test format (raw settings object)
if (
!parsedSettings.include_path ||
!Array.isArray(parsedSettings.include_path)
) {
throw new Error(
"Invalid settings format. Expected include_path array",
);
}
if (
!parsedSettings.include_type ||
!Array.isArray(parsedSettings.include_type)
) {
throw new Error(
"Invalid settings format. Expected include_type array",
);
}
// Create mock backend response with single repository using provided settings
const mockRepositoryPath = opts.repository || "u/mock/repo";
settings = {
git_sync: {
repositories: [{
git_repo_resource_path: mockRepositoryPath,
settings: {
include_path: parsedSettings.include_path,
include_type: parsedSettings.include_type,
exclude_path: parsedSettings.exclude_path || [],
extra_include_path: parsedSettings.extra_include_path || [],
},
}],
},
};
} catch (parseError) {
const errorMessage = parseError instanceof Error ? parseError.message : String(parseError);
if (opts.jsonOutput) {
console.log(
JSON.stringify({
success: false,
error:
`Failed to parse --with-backend-settings JSON: ${errorMessage}`,
}),
);
} else {
log.error(
colors.red(
`Failed to parse --with-backend-settings JSON: ${errorMessage}`,
),
);
}
return;
}
} else {
// Fetch current backend settings
try {
settings = await wmill.getSettings({
workspace: workspace.workspaceId,
});
} catch (apiError) {
const errorMessage = apiError instanceof Error ? apiError.message : String(apiError);
if (opts.jsonOutput) {
console.log(
JSON.stringify({
success: false,
error: `Failed to fetch workspace settings: ${errorMessage}`,
}),
);
} else {
log.error(
colors.red(
`Failed to fetch workspace settings: ${errorMessage}`,
),
);
}
return;
}
}
if (
!settings.git_sync?.repositories ||
settings.git_sync.repositories.length === 0
) {
if (opts.jsonOutput) {
console.log(
JSON.stringify({
success: false,
error: "No git-sync repositories configured",
}),
);
} else {
log.error(
colors.red(
"No git-sync repositories configured in workspace",
),
);
}
return;
}
// Find the repository to work with
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
const repoPath = normalizeRepoPath(selectedRepo.git_repo_resource_path);
const effectiveSettings = getEffectiveSettings(
localConfig,
workspace.remote,
workspace.workspaceId,
repoPath,
);
// Convert to backend format
const backendFormat = {
include_path: effectiveSettings.includes || [],
include_type: syncOptionsToIncludeType(effectiveSettings),
exclude_path: effectiveSettings.excludes || [],
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) {
// --diff flag: show differences and exit
if (opts.jsonOutput) {
// Generate structured diff using the same normalized objects
const structuredDiff = hasChanges
? generateStructuredDiff(gitSyncCurrent, gitSyncEffective)
: {};
console.log(
JSON.stringify({
success: true,
hasChanges,
local: syncOptionsToBackendFormat(normalizedEffective),
backend: syncOptionsToBackendFormat(normalizedCurrent),
repository: selectedRepo.git_repo_resource_path,
diff: structuredDiff,
}),
);
} else {
if (hasChanges) {
log.info("Changes that would be pushed to Windmill:");
const changes = generateChanges(
normalizedCurrent,
normalizedEffective,
);
if (Object.keys(changes).length === 0) {
log.info(colors.green("No changes to push"));
} else {
displayChanges(changes);
}
} else {
log.info(colors.green("No changes to push"));
}
}
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) {
console.log(
JSON.stringify({
success: true,
message:
`Git-sync settings push simulated (--with-backend-settings used)`,
repository: selectedRepo.git_repo_resource_path,
simulated: true,
}),
);
} else {
log.info(
colors.green(
`Git-sync settings push simulated for ${selectedRepo.git_repo_resource_path} (--with-backend-settings used)`,
),
);
}
} else {
// Update the specific repository settings
const updatedRepos = settings.git_sync.repositories.map(
(repo: GitSyncRepository) => {
if (
repo.git_repo_resource_path ===
selectedRepo.git_repo_resource_path
) {
return {
...repo,
settings: backendFormat,
};
}
return repo;
},
);
// Push updated settings to backend
try {
await wmill.editWorkspaceGitSyncConfig({
workspace: workspace.workspaceId,
requestBody: {
git_sync_settings: {
repositories: updatedRepos,
},
},
});
} catch (apiError) {
const errorMessage = apiError instanceof Error ? apiError.message : String(apiError);
if (opts.jsonOutput) {
console.log(
JSON.stringify({
success: false,
error: `Failed to update workspace git-sync config: ${errorMessage}`,
}),
);
} else {
log.error(
colors.red(
`Failed to update workspace git-sync config: ${errorMessage}`,
),
);
}
return;
}
if (opts.jsonOutput) {
console.log(
JSON.stringify({
success: true,
message: `Git-sync settings pushed successfully`,
repository: selectedRepo.git_repo_resource_path,
}),
);
} else {
log.info(
colors.green(
`Git-sync settings pushed successfully to ${selectedRepo.git_repo_resource_path}`,
),
);
}
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
if (opts.jsonOutput) {
console.log(
JSON.stringify({ success: false, error: errorMessage }),
);
} else {
log.error(
colors.red(
`Failed to push git-sync settings: ${errorMessage}`,
),
);
}
}
}
const command = new Command()
.description(
"Manage git-sync settings between local wmill.yaml and Windmill backend",
)
.command("pull")
.description(
"Pull git-sync settings from Windmill backend to local wmill.yaml",
)
.option(
"--repository <repo:string>",
"Specify repository path (e.g., u/user/repo)",
)
.option(
"--workspace-level",
"Write settings to workspace:* override instead of workspace:repo",
)
.option(
"--default",
"Write settings to top-level defaults instead of overrides",
)
.option("--replace", "Replace existing settings (non-interactive mode)")
.option(
"--override",
"Add repository-specific override (non-interactive mode)",
)
.option("--diff", "Show differences without applying changes")
.option("--json-output", "Output in JSON format")
.option(
"--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(
"Push git-sync settings from local wmill.yaml to Windmill backend",
)
.option(
"--repository <repo:string>",
"Specify repository path (e.g., u/user/repo)",
)
.option("--diff", "Show what would be pushed without applying changes")
.option("--json-output", "Output in JSON format")
.option(
"--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 };
export default command;