Files
windmill/cli/gitsync-settings.ts
Alexander Petric 27bf4e34d8 feat: git sync improvements (#6182)
* init checkpoint

* ui second pass...

* round 1 backend + saving settings + detecting changes...

* checkpoint

* fix openapi

* saving + correct wmill.yaml diff

* cli refactor

* cli and tests refactor done

* cli multi workspace support

* cli support skip core types to align with ui

* new test framework

* sqlx

* openapi spec

* frontend

* sync + settings changes

* some fixes

* some fixes

* security: Remove hardcoded EE license key, use environment variable only

- Remove hardcoded license key from containerized test backend
- Environment variable EE_LICENSE_KEY now required for EE features
- License key no longer stored in database during tests

* sqlx

* tests

* fixing tests

* fix tests

* checkpoint

* checkpoint

* cli build

* frontend - cli exchange

* settings match

* ee repo ref

* npm check

* openapi

* tests

* checkpoint

* cli + tests

* reset to preview on changes

* merge issue ee

* cleanup

* hubscript

* simplifications

* ee repo ref

* cli fixes

* fix sync and add tests

* extra test

* git sync settings / key change aware

* ee-repo ref

* ee-repo ref

* ee repo ref

* ee ref

* review 1

* ee ref

* Update frontend/src/lib/components/PullGitRepoPopover.svelte

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

* Update frontend/src/routes/(root)/(logged)/workspace_settings/+page.svelte

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

* ee ref

* remove extra includes from ui

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-07-15 16:25:31 +00:00

1125 lines
34 KiB
TypeScript

import { colors, Command, log, yamlStringify } from "./deps.ts";
import { GlobalOptions } from "./types.ts";
import { requireLogin, 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"),
};
}
// 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 pullGitSyncSettings(
opts: GlobalOptions & {
repository?: string;
workspaceLevel?: boolean;
default?: boolean;
diff?: boolean;
jsonOutput?: boolean;
replace?: boolean;
override?: boolean;
withBackendSettings?: string;
},
) {
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: 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,
);
}
// 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 made:");
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 && Deno.stdin.isTerminal()) {
// 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) {
// 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;
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;
// 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;
},
) {
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: 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,
);
}
// 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 || [],
};
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);
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:");
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;
}
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)",
)
.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)",
)
.action(pushGitSyncSettings as any);
export { pullGitSyncSettings, pushGitSyncSettings };
export default command;