feat: add force_branch parameter to git sync settings (#8089)

* feat: add force branch param to git sync settings

* update hub path

* chore: update ee-repo-ref to a797dd4d619cdab737e133ce593f2f8582ba21de

This commit updates the EE repository reference after PR #430 was merged in windmill-ee-private.

Previous ee-repo-ref: 373c5870ae5499c8c8a22cb92b2fd3a64a651183

New ee-repo-ref: a797dd4d619cdab737e133ce593f2f8582ba21de

Automated by sync-ee-ref workflow.

* UI nits

* nit

---------

Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
hugocasa
2026-02-26 18:23:44 +01:00
committed by GitHub
parent d12850d52e
commit 7d44ff4346
6 changed files with 223 additions and 139 deletions
+1 -1
View File
@@ -1 +1 @@
2fb7884849a563bae023574baa2d55fa1fab1176
a797dd4d619cdab737e133ce593f2f8582ba21de
+2
View File
@@ -23237,6 +23237,8 @@ components:
type: boolean
group_by_folder:
type: boolean
force_branch:
type: string
collapsed:
type: boolean
settings:
@@ -157,6 +157,8 @@ pub struct GitRepositorySettings {
pub use_individual_branch: Option<bool>,
pub group_by_folder: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub force_branch: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub settings: Option<GitSyncSettings>,
}
@@ -1,6 +1,9 @@
import { getContext, setContext } from 'svelte'
import { JobService, WorkspaceService, ResourceService } from '$lib/gen'
import type { GitRepositorySettings as BackendGitRepositorySettings, GitSyncObjectType } from '$lib/gen'
import type {
GitRepositorySettings as BackendGitRepositorySettings,
GitSyncObjectType
} from '$lib/gen'
import { jobManager } from '$lib/services/JobManager'
import hubPaths from '$lib/hubPaths.json'
import type { SettingsObject } from '$lib/git-sync'
@@ -37,9 +40,9 @@ export type GitSyncSettings = {
}
export type ModalState = {
push: { idx: number, repo: GitSyncRepository, open: boolean } | null
pull: { idx: number, repo: GitSyncRepository, open: boolean, settingsOnly?: boolean } | null
success: { open: boolean, savedWithoutInit?: boolean } | null
push: { idx: number; repo: GitSyncRepository; open: boolean } | null
pull: { idx: number; repo: GitSyncRepository; open: boolean; settingsOnly?: boolean } | null
success: { open: boolean; savedWithoutInit?: boolean } | null
}
export type ValidationState = {
@@ -101,10 +104,10 @@ export function createGitSyncContext(workspace: string) {
const validationStates = getValidationStates()
// Check if any individual repositories have changes
const individualChanges = validationStates.some(v => v.hasChanges)
const individualChanges = validationStates.some((v) => v.hasChanges)
// Check if any legacy repos were imported
const anyLegacyImported = repositories.some(r => r.legacyImported)
const anyLegacyImported = repositories.some((r) => r.legacyImported)
// Check if the set of repositories has changed (added/removed repos)
const repositorySetChanged = (() => {
@@ -113,31 +116,33 @@ export function createGitSyncContext(workspace: string) {
}
if (!initialRepositories || initialRepositories.length === 0) {
return repositories.filter((_,i) => validationStates[i]?.isValid).length > 0
return repositories.filter((_, i) => validationStates[i]?.isValid).length > 0
}
const initialValidPaths = new Set(
initialRepositories
.filter(r => r.git_repo_resource_path && r.git_repo_resource_path.trim() !== '')
.map(r => r.git_repo_resource_path)
.filter((r) => r.git_repo_resource_path && r.git_repo_resource_path.trim() !== '')
.map((r) => r.git_repo_resource_path)
)
const currentValidPaths = new Set(
repositories
.filter((_,i) => validationStates[i]?.isValid)
.map(r => r.git_repo_resource_path)
.filter((_, i) => validationStates[i]?.isValid)
.map((r) => r.git_repo_resource_path)
)
// Check if sets are different (repos added or removed)
return initialValidPaths.size !== currentValidPaths.size ||
[...initialValidPaths].some(path => !currentValidPaths.has(path)) ||
[...currentValidPaths].some(path => !initialValidPaths.has(path))
return (
initialValidPaths.size !== currentValidPaths.size ||
[...initialValidPaths].some((path) => !currentValidPaths.has(path)) ||
[...currentValidPaths].some((path) => !initialValidPaths.has(path))
)
})()
return individualChanges || anyLegacyImported || repositorySetChanged
}
const getAllRepositoriesValid = () => getValidationStates().every(v => v.isValid)
const getHasUnsavedConnections = () => repositories.some(repo => repo.isUnsavedConnection)
const getAllRepositoriesValid = () => getValidationStates().every((v) => v.isValid)
const getHasUnsavedConnections = () => repositories.some((repo) => repo.isUnsavedConnection)
function validateRepository(repo: GitSyncRepository, idx: number): boolean {
if (!repo.git_repo_resource_path) return false
@@ -146,7 +151,9 @@ export function createGitSyncContext(workspace: string) {
function checkDuplicate(repo: GitSyncRepository, idx: number): boolean {
if (!repo.git_repo_resource_path) return false
const firstIdx = repositories.findIndex(r => r.git_repo_resource_path === repo.git_repo_resource_path)
const firstIdx = repositories.findIndex(
(r) => r.git_repo_resource_path === repo.git_repo_resource_path
)
return firstIdx !== -1 && firstIdx < idx
}
@@ -157,7 +164,9 @@ export function createGitSyncContext(workspace: string) {
// Legacy repositories always have "changes" because they need migration
if (repo.legacyImported) return true
return JSON.stringify(serializeRepository(repo)) !== JSON.stringify(serializeRepository(initial))
return (
JSON.stringify(serializeRepository(repo)) !== JSON.stringify(serializeRepository(initial))
)
}
function serializeRepository(repo: GitSyncRepository) {
@@ -166,8 +175,9 @@ export function createGitSyncContext(workspace: string) {
script_path: repo.script_path,
use_individual_branch: repo.use_individual_branch,
group_by_folder: repo.group_by_folder,
force_branch: repo.force_branch,
settings: repo.settings,
exclude_types_override: repo.exclude_types_override,
exclude_types_override: repo.exclude_types_override
}
}
@@ -200,7 +210,7 @@ export function createGitSyncContext(workspace: string) {
// Check if this repository exists in the initial (saved) state
const existsInInitialState = initialRepositories.some(
initialRepo => initialRepo.git_repo_resource_path === repo.git_repo_resource_path
(initialRepo) => initialRepo.git_repo_resource_path === repo.git_repo_resource_path
)
// Only call backend API if repository exists in the saved state
@@ -214,7 +224,7 @@ export function createGitSyncContext(workspace: string) {
// Update initial state to remove the deleted repository
const initialIdx = initialRepositories.findIndex(
initialRepo => initialRepo.git_repo_resource_path === repo.git_repo_resource_path
(initialRepo) => initialRepo.git_repo_resource_path === repo.git_repo_resource_path
)
if (initialIdx !== -1) {
initialRepositories.splice(initialIdx, 1)
@@ -309,45 +319,42 @@ export function createGitSyncContext(workspace: string) {
repo._detectionTimestamp = detectionTimestamp
// Use JobManager for polling - result will be the actual job response
await jobManager.runWithProgress(
() => Promise.resolve(jobId),
{
workspace,
timeout: 60000,
timeoutMessage: 'Detection job timed out after 60s',
onProgress: (status) => {
// Only update state if this detection is still current
if (repo._detectionTimestamp !== detectionTimestamp) {
return
}
await jobManager.runWithProgress(() => Promise.resolve(jobId), {
workspace,
timeout: 60000,
timeoutMessage: 'Detection job timed out after 60s',
onProgress: (status) => {
// Only update state if this detection is still current
if (repo._detectionTimestamp !== detectionTimestamp) {
return
}
repo.detectionJobStatus = status.status
repo.detectionJobStatus = status.status
// Process successful detection result
if (status.status === 'success' && status.result) {
const response = status.result as any
if (response.isInitialSetup) {
repo.detectionState = 'no-wmill'
} else {
repo.detectionState = 'has-wmill'
// Apply extracted settings from the git repository
if (response.local) {
repo.extractedSettings = response.local
// Auto-apply the extracted settings
repo.settings = {
...response.local,
exclude_path: response.local.exclude_path || [],
extra_include_path: response.local.extra_include_path || []
}
// Process successful detection result
if (status.status === 'success' && status.result) {
const response = status.result as any
if (response.isInitialSetup) {
repo.detectionState = 'no-wmill'
} else {
repo.detectionState = 'has-wmill'
// Apply extracted settings from the git repository
if (response.local) {
repo.extractedSettings = response.local
// Auto-apply the extracted settings
repo.settings = {
...response.local,
exclude_path: response.local.exclude_path || [],
extra_include_path: response.local.extra_include_path || []
}
}
} else if (status.status === 'failure') {
repo.detectionState = 'error'
repo.detectionError = status.error || 'Detection failed'
}
} else if (status.status === 'failure') {
repo.detectionState = 'error'
repo.detectionError = status.error || 'Detection failed'
}
}
)
})
} catch (error: any) {
// Only set error if this detection is still current
if (repo._detectionTimestamp !== detectionTimestamp) {
@@ -369,68 +376,88 @@ export function createGitSyncContext(workspace: string) {
if (settings.git_sync !== undefined && settings.git_sync !== null) {
// Detect workspace-level legacy settings (outside repositories)
const workspaceLegacyIncludePath: string[] = (settings.git_sync as any)?.include_path ?? []
const workspaceLegacyIncludeTypeRaw: GitSyncObjectType[] = (settings.git_sync as any)?.include_type ?? []
const workspaceLegacyIncludeTypeRaw: GitSyncObjectType[] =
(settings.git_sync as any)?.include_type ?? []
const workspaceLegacyIncludeType: GitSyncObjectType[] = [...workspaceLegacyIncludeTypeRaw]
// Update legacy workspace state
legacyWorkspaceIncludePath.splice(0, legacyWorkspaceIncludePath.length, ...workspaceLegacyIncludePath)
legacyWorkspaceIncludeType.splice(0, legacyWorkspaceIncludeType.length, ...workspaceLegacyIncludeType)
legacyWorkspaceIncludePath.splice(
0,
legacyWorkspaceIncludePath.length,
...workspaceLegacyIncludePath
)
legacyWorkspaceIncludeType.splice(
0,
legacyWorkspaceIncludeType.length,
...workspaceLegacyIncludeType
)
if (settings.git_sync.repositories) {
repositories.splice(0, repositories.length, ...settings.git_sync.repositories.map(repo => {
// Check if this is a legacy repo (no nested settings object)
const isRepoLegacy = !repo.settings
const repoExcludeTypesOverride = repo.exclude_types_override ?? []
repositories.splice(
0,
repositories.length,
...settings.git_sync.repositories.map((repo) => {
// Check if this is a legacy repo (no nested settings object)
const isRepoLegacy = !repo.settings
const repoExcludeTypesOverride = repo.exclude_types_override ?? []
// Determine default types - use workspace legacy or fallback
const defaultTypes: GitSyncObjectType[] = workspaceLegacyIncludeType.length > 0
? [...workspaceLegacyIncludeType]
: ['script', 'flow', 'app', 'folder']
// Determine default types - use workspace legacy or fallback
const defaultTypes: GitSyncObjectType[] =
workspaceLegacyIncludeType.length > 0
? [...workspaceLegacyIncludeType]
: ['script', 'flow', 'app', 'folder']
let repoSettings: SettingsObject
if (isRepoLegacy) {
// Legacy repo: inherit from workspace-level settings and apply exclude_types_override
const inheritedIncludeType = repo.settings?.include_type ?? [...defaultTypes]
const effectiveIncludeType = repoExcludeTypesOverride.length > 0
? inheritedIncludeType.filter(type => !repoExcludeTypesOverride.includes(type))
: inheritedIncludeType
let repoSettings: SettingsObject
if (isRepoLegacy) {
// Legacy repo: inherit from workspace-level settings and apply exclude_types_override
const inheritedIncludeType = repo.settings?.include_type ?? [...defaultTypes]
const effectiveIncludeType =
repoExcludeTypesOverride.length > 0
? inheritedIncludeType.filter(
(type) => !repoExcludeTypesOverride.includes(type)
)
: inheritedIncludeType
repoSettings = {
include_path: repo.settings?.include_path ?? [...workspaceLegacyIncludePath],
exclude_path: repo.settings?.exclude_path ?? [],
extra_include_path: repo.settings?.extra_include_path ?? [],
include_type: effectiveIncludeType
repoSettings = {
include_path: repo.settings?.include_path ?? [...workspaceLegacyIncludePath],
exclude_path: repo.settings?.exclude_path ?? [],
extra_include_path: repo.settings?.extra_include_path ?? [],
include_type: effectiveIncludeType
}
} else {
// New format: use repo's own settings
repoSettings = {
include_path: repo.settings?.include_path ?? ['f/**'],
exclude_path: repo.settings?.exclude_path ?? [],
extra_include_path: repo.settings?.extra_include_path ?? [],
include_type: repo.settings?.include_type ?? ['script', 'flow', 'app']
}
}
} else {
// New format: use repo's own settings
repoSettings = {
include_path: repo.settings?.include_path ?? ['f/**'],
exclude_path: repo.settings?.exclude_path ?? [],
extra_include_path: repo.settings?.extra_include_path ?? [],
include_type: repo.settings?.include_type ?? ['script', 'flow', 'app']
}
}
return {
...repo,
git_repo_resource_path: repo.git_repo_resource_path.replace('$res:', ''),
settings: repoSettings,
exclude_types_override: repoExcludeTypesOverride,
// Mark legacy repos for UI handling
legacyImported: isRepoLegacy
}
}))
return {
...repo,
git_repo_resource_path: repo.git_repo_resource_path.replace('$res:', ''),
settings: repoSettings,
exclude_types_override: repoExcludeTypesOverride,
// Mark legacy repos for UI handling
legacyImported: isRepoLegacy
}
})
)
}
}
// Store initial state for change tracking
initialRepositories.splice(0, initialRepositories.length, ...repositories.map(repo => ({ ...repo })))
initialRepositories.splice(
0,
initialRepositories.length,
...repositories.map((repo) => ({ ...repo }))
)
} finally {
loading = false
}
}
// Migration utility for legacy repositories
function migrateLegacyRepository(repo: GitSyncRepository): GitSyncRepository {
if (!repo.legacyImported) {
@@ -465,6 +492,7 @@ export function createGitSyncContext(workspace: string) {
script_path: repoToSave.script_path,
use_individual_branch: repoToSave.use_individual_branch,
group_by_folder: repoToSave.group_by_folder,
force_branch: repoToSave.force_branch,
settings: repoToSave.settings,
exclude_types_override: repoToSave.exclude_types_override
}
@@ -485,8 +513,6 @@ export function createGitSyncContext(workspace: string) {
}
}
// Helper functions for original functionality
function revertRepository(idx: number) {
@@ -496,8 +522,6 @@ export function createGitSyncContext(workspace: string) {
}
}
// Reset detection state for a repository
function resetDetectionState(idx: number) {
const repo = repositories[idx]
@@ -515,7 +539,6 @@ export function createGitSyncContext(workspace: string) {
repo.detectionJobStatus = undefined
}
async function runTestJob(idx: number) {
const repo = repositories[idx]
if (!repo?.git_repo_resource_path || !repo?.script_path) {
@@ -539,24 +562,29 @@ export function createGitSyncContext(workspace: string) {
}
// Use JobManager for polling
await jobManager.runWithProgress(
() => Promise.resolve(jobId),
{
workspace,
timeout: 5000,
timeoutMessage: 'Git sync test job timed out after 5s',
onProgress: (status) => {
gitSyncTestJobs[idx].status = status.status === 'success' ? 'success' :
status.status === 'failure' ? 'failure' : 'running'
if (status.status === 'failure') {
gitSyncTestJobs[idx].error = status.error
}
await jobManager.runWithProgress(() => Promise.resolve(jobId), {
workspace,
timeout: 5000,
timeoutMessage: 'Git sync test job timed out after 5s',
onProgress: (status) => {
gitSyncTestJobs[idx].status =
status.status === 'success'
? 'success'
: status.status === 'failure'
? 'failure'
: 'running'
if (status.status === 'failure') {
gitSyncTestJobs[idx].error = status.error
}
}
)
})
} catch (error: any) {
// Initialize the job entry if it doesn't exist (e.g., job creation failed)
const errorMessage = (typeof error?.body === 'string' ? error.body : error?.body?.message) || error?.message || error?.toString() || 'Failed to run test job'
const errorMessage =
(typeof error?.body === 'string' ? error.body : error?.body?.message) ||
error?.message ||
error?.toString() ||
'Failed to run test job'
if (!gitSyncTestJobs[idx]) {
gitSyncTestJobs[idx] = {
jobId: '',
@@ -570,18 +598,18 @@ export function createGitSyncContext(workspace: string) {
}
}
function getPrimarySyncRepository(): { repo: GitSyncRepository, idx: number } | null {
const idx = repositories.findIndex(r => !r.use_individual_branch)
function getPrimarySyncRepository(): { repo: GitSyncRepository; idx: number } | null {
const idx = repositories.findIndex((r) => !r.use_individual_branch)
return idx !== -1 ? { repo: repositories[idx], idx } : null
}
function getPrimaryPromotionRepository(): { repo: GitSyncRepository, idx: number } | null {
const idx = repositories.findIndex(r => r.use_individual_branch)
function getPrimaryPromotionRepository(): { repo: GitSyncRepository; idx: number } | null {
const idx = repositories.findIndex((r) => r.use_individual_branch)
return idx !== -1 ? { repo: repositories[idx], idx } : null
}
function getSecondarySyncRepositories(): { repo: GitSyncRepository, idx: number }[] {
const result: { repo: GitSyncRepository, idx: number }[] = []
function getSecondarySyncRepositories(): { repo: GitSyncRepository; idx: number }[] {
const result: { repo: GitSyncRepository; idx: number }[] = []
let foundFirst = false
repositories.forEach((repo, idx) => {
if (!repo.use_individual_branch) {
@@ -595,8 +623,8 @@ export function createGitSyncContext(workspace: string) {
return result
}
function getSecondaryPromotionRepositories(): { repo: GitSyncRepository, idx: number }[] {
const result: { repo: GitSyncRepository, idx: number }[] = []
function getSecondaryPromotionRepositories(): { repo: GitSyncRepository; idx: number }[] {
const result: { repo: GitSyncRepository; idx: number }[] = []
let foundFirst = false
repositories.forEach((repo, idx) => {
if (repo.use_individual_branch) {
@@ -611,7 +639,7 @@ export function createGitSyncContext(workspace: string) {
}
async function removeRepositoryByPath(resourcePath: string) {
const idx = repositories.findIndex(r => r.git_repo_resource_path === resourcePath)
const idx = repositories.findIndex((r) => r.git_repo_resource_path === resourcePath)
if (idx !== -1) {
await removeRepository(idx)
}
@@ -697,20 +725,44 @@ export function createGitSyncContext(workspace: string) {
// Return context object
return {
// State (read-only access)
get repositories() { return repositories },
get loading() { return loading },
get activeModals() { return activeModals },
get gitSyncTestJobs() { return gitSyncTestJobs },
get initialRepositories() { return initialRepositories },
get legacyWorkspaceIncludePath() { return legacyWorkspaceIncludePath },
get legacyWorkspaceIncludeType() { return legacyWorkspaceIncludeType },
get repositories() {
return repositories
},
get loading() {
return loading
},
get activeModals() {
return activeModals
},
get gitSyncTestJobs() {
return gitSyncTestJobs
},
get initialRepositories() {
return initialRepositories
},
get legacyWorkspaceIncludePath() {
return legacyWorkspaceIncludePath
},
get legacyWorkspaceIncludeType() {
return legacyWorkspaceIncludeType
},
// Computed states - use getter functions that compute on access
get validationStates() { return getValidationStates() },
get hasAnyChanges() { return getHasAnyChanges() },
get allRepositoriesValid() { return getAllRepositoriesValid() },
get hasUnsavedConnections() { return getHasUnsavedConnections() },
get hasWorkspaceLevelSettings() { return hasWorkspaceLevelSettings },
get validationStates() {
return getValidationStates()
},
get hasAnyChanges() {
return getHasAnyChanges()
},
get allRepositoriesValid() {
return getAllRepositoriesValid()
},
get hasUnsavedConnections() {
return getHasUnsavedConnections()
},
get hasWorkspaceLevelSettings() {
return hasWorkspaceLevelSettings
},
// Methods
addRepository,
@@ -741,7 +793,7 @@ export function createGitSyncContext(workspace: string) {
getSecondaryPromotionRepositories,
// Helper methods
getTargetBranch,
getTargetBranch
}
}
@@ -11,11 +11,14 @@
Plus
} from 'lucide-svelte'
import { Button, Alert } from '$lib/components/common'
import TextInput from '$lib/components/text_input/TextInput.svelte'
import Section from '$lib/components/Section.svelte'
import { getGitSyncContext } from './GitSyncContext.svelte'
import ResourcePicker from '$lib/components/ResourcePicker.svelte'
import GitSyncFilterSettings from '$lib/components/workspaceSettings/GitSyncFilterSettings.svelte'
import DetectionFlow from './DetectionFlow.svelte'
import { sendUserToast } from '$lib/toast'
import Toggle from '$lib/components/Toggle.svelte'
import { fade } from 'svelte/transition'
import { workspaceStore } from '$lib/stores'
import hubPaths from '$lib/hubPaths.json'
@@ -527,6 +530,30 @@
</div>
{/if}
</div>
<!-- Advanced settings (collapsible) -->
<Section label="Advanced" small collapsable initiallyCollapsed={!repo.force_branch}>
<Toggle
checked={!!repo.force_branch}
on:change={(e) => {
if (e.detail) {
repo.force_branch = $workspaceStore ?? ''
} else {
repo.force_branch = undefined
}
}}
options={{
right: 'Environment (experimental)',
rightTooltip:
'Made for monobranch setups. Passes the value as --branch/--env to the wmill CLI, which selects the matching branch/env configuration from wmill.yaml and includes the branch/env in the item paths.'
}}
/>
{#if repo.force_branch != null && repo.force_branch !== undefined}
<div class="w-48 mt-2">
<TextInput size="sm" bind:value={repo.force_branch} />
</div>
{/if}
</Section>
{/if}
{/if}
{:else}
+2 -1
View File
@@ -21,7 +21,8 @@
"gitSync_19": "hub/28081/sync-script-to-git-repo-windmill",
"gitSync_20": "hub/28102/sync-script-to-git-repo-windmill",
"gitSync_21": "hub/28131/sync-script-to-git-repo-windmill",
"gitSync": "hub/28159/sync-script-to-git-repo-windmill",
"gitSync_22": "hub/28159/sync-script-to-git-repo-windmill",
"gitSync": "hub/28160/sync-script-to-git-repo-windmill",
"gitSyncTest": "hub/19799/git-repo-test-read-write-windmill",
"gitInitRepo_0": "hub/28134/git-sync%3A-init-repository-windmill",
"gitInitRepo": "hub/28158/git-sync%3A-init-repository-windmill",