feat: move basic git sync from EE to CE with runtime user count gating (#8493)

* feat: move basic git sync from EE to CE with runtime user count gating

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref.txt for git sync CE migration

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: keep git sync impl in private repo, revert oss to stub

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref.txt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref.txt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref.txt after merge

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use LICENSE_KEY check instead of get_license_plan for runtime gating

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref.txt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: improve git sync CE UX — use "Community Edition" wording, mention user limit

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use "workspace members" instead of "users" in git sync messaging

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: lower CE git sync limit from 3 to 2 workspace members

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref.txt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref.txt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: simplify git sync CE alerts to warn about EE feature with member limit

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: add EE feature restrictions detail to CE git sync warning

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: show git sync settings even when >2 members, with disabled warning

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: show error alert when git sync settings exist but members exceed CE limit

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: mention CE git sync limit is for testing and hobbyist use

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to 79eeacccc0438010d7dfa60207a5cbdaf2eda08d

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

Previous ee-repo-ref: c4d69c6e700c16d44f909d9c7b6738b07043db98

New ee-repo-ref: 79eeacccc0438010d7dfa60207a5cbdaf2eda08d

Automated by sync-ee-ref workflow.

* chore: update sqlx cache

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: regenerate full sqlx cache after main merge

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update sqlx cache

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref and regenerate sqlx cache with private feature

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix: use LICENSE_KEY_VALID for EE check, allow delete without access check, extract helpers

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref.txt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* refactor: use compile-time cfg(enterprise) gating instead of runtime license checks

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref.txt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to 6171a91da38d6d16a88aeb1a3a4f4df78f995383

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

Previous ee-repo-ref: 52681940cda6d70f65aeeb7144288f060b4d736e

New ee-repo-ref: 6171a91da38d6d16a88aeb1a3a4f4df78f995383

Automated by sync-ee-ref workflow.

* chore: update ee-repo-ref.txt

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to b5c8af4df9ba2c39fdd494d7a40f9a92fbff8abc

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

Previous ee-repo-ref: 6e5b2741831468a7b30b26c0df1241e6141c6833

New ee-repo-ref: b5c8af4df9ba2c39fdd494d7a40f9a92fbff8abc

Automated by sync-ee-ref workflow.

* fix: gate CE_GIT_SYNC_MAX_USERS behind cfg(not(enterprise))

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
Ruben Fiszel
2026-03-25 08:41:29 +00:00
committed by GitHub
parent e3620e074e
commit 79d2bd51a0
17 changed files with 298 additions and 268 deletions

View File

@@ -1,12 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO app_version (id, app_id, value, created_by, created_at)\n VALUES (3001, 3001, '{\"grid\": []}', 'admin', NOW())",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "01c040b04b487e86b7f4ff38b0faacf6af2c284ae446860113c82bc4e1da08ab"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "UPDATE app SET versions = ARRAY[3001::bigint] WHERE id = 3001",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "1ad384eeb3946ef7c492124c69fbc47caebc25215a430d6b301b35e265888159"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO flow (workspace_id, path, summary, description, value, edited_by, edited_at, schema, extra_perms)\n VALUES ('test-workspace', 'u/operator/existing_flow', 'Existing flow', '', '{\"modules\": []}', 'admin', NOW(), '{}', '{}')",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "1fa27bd47ab66b3f301ca43ef93fb7cf5f2b76f013c274245af13d7d727ebf1f"
}

View File

@@ -1,15 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO token (token_hash, token_prefix, email, label, super_admin, owner, workspace_id)\n VALUES ($1, $2, 'charlie@windmill.dev', 'Charlie new token', false, 'u/charlie', 'test-workspace')",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar"
]
},
"nullable": []
},
"hash": "4e88aec662ebc70e0425a48a1b4e2e60e3183fa81a411622891caea6dc03fa90"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO app (id, workspace_id, path, summary, versions, policy, extra_perms)\n VALUES (3001, 'test-workspace', 'u/operator/existing_app', 'Existing app', '{}',\n '{\"on_behalf_of\": \"u/admin\", \"on_behalf_of_email\": \"admin@windmill.dev\", \"execution_mode\": \"viewer\"}', '{}')",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "5b140cb478aa32ac38fb831d6af960ffc33da5f31bf780e8fd6a66d5150b8027"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO usr_to_group (workspace_id, group_, usr) VALUES ('test-workspace', 'editors', 'charlie')",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "6c24b5c08341979e02b7968242369c87a9d29370ec985d2c8c28633cd078ffaf"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "INSERT INTO script (workspace_id, hash, path, content, language, kind, created_by, schema, summary, description, lock, extra_perms)\n VALUES ('test-workspace', 3001, 'u/operator/existing_script', 'export function main() { return \"original\"; }', 'deno', 'script', 'admin', '{}', 'Existing script', '', '', '{}')",
"describe": {
"columns": [],
"parameters": {
"Left": []
},
"nullable": []
},
"hash": "be35e85e94641c71620e5402dcf74da8c73120b3e16194904575f79a4e055002"
}

View File

@@ -1,22 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n INSERT INTO kafka_trigger (\n path, kafka_resource_path, topics, group_id,\n script_path, is_flow, workspace_id, edited_by, permissioned_as\n )\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)\n ",
"describe": {
"columns": [],
"parameters": {
"Left": [
"Varchar",
"Varchar",
"VarcharArray",
"Varchar",
"Varchar",
"Bool",
"Varchar",
"Varchar",
"Varchar"
]
},
"nullable": []
},
"hash": "ccd76be88fa9c11b3dc2e6d7711437ab3e02d8c3c10c53decc664533b8d04bc0"
}

View File

@@ -1 +1 @@
414202845a45e2a7c6a2d3e154bd8dfd0273cc14
b5c8af4df9ba2c39fdd494d7a40f9a92fbff8abc

View File

@@ -35,7 +35,6 @@ use windmill_common::variables::{
build_crypt, decrypt, encrypt, SECRET_SALT, WORKSPACE_CRYPT_CACHE,
};
use windmill_common::worker::{to_raw_value, CLOUD_HOSTED};
#[cfg(feature = "enterprise")]
use windmill_common::workspaces::GitRepositorySettings;
#[cfg(feature = "enterprise")]
use windmill_common::workspaces::WorkspaceDeploymentUISettings;
@@ -115,6 +114,7 @@ pub fn workspaced_service() -> Router {
.route("/list_datatables", get(list_datatables))
.route("/list_datatable_schemas", get(list_datatable_schemas))
.route("/edit_datatable_config", post(edit_datatable_config))
.route("/git_sync_enabled", get(get_git_sync_enabled))
.route("/edit_git_sync_config", post(edit_git_sync_config))
.route("/edit_git_sync_repository", post(edit_git_sync_repository))
.route(
@@ -1595,24 +1595,20 @@ async fn edit_datatable_config(
#[derive(Deserialize)]
pub struct EditGitSyncConfig {
#[cfg(feature = "enterprise")]
pub git_sync_settings: Option<WorkspaceGitSyncSettings>,
}
#[cfg(feature = "enterprise")]
#[derive(Deserialize, Debug)]
pub struct EditGitSyncRepository {
pub git_repo_resource_path: String,
pub repository: GitRepositorySettings,
}
#[cfg(feature = "enterprise")]
#[derive(Deserialize, Debug)]
pub struct DeleteGitSyncRepositoryRequest {
pub git_repo_resource_path: String,
}
#[cfg(feature = "enterprise")]
fn validate_git_repo_resource_path(path: &str) -> Result<()> {
// Resource paths should follow the pattern: $res:f/<folder>/<name> or $res:u/<username>/<name>
if path.is_empty() {
@@ -1661,7 +1657,6 @@ fn validate_git_repo_resource_path(path: &str) -> Result<()> {
Ok(())
}
#[cfg(feature = "enterprise")]
fn cleanup_legacy_git_sync_settings_in_memory(
git_sync_settings: &mut windmill_common::workspaces::WorkspaceGitSyncSettings,
workspace_id: &str,
@@ -1688,18 +1683,72 @@ fn cleanup_legacy_git_sync_settings_in_memory(
}
#[cfg(not(feature = "enterprise"))]
async fn edit_git_sync_config(
_authed: ApiAuthed,
Extension(_db): Extension<DB>,
Path(_w_id): Path<String>,
Json(_new_config): Json<EditGitSyncConfig>,
) -> Result<String> {
return Err(Error::BadRequest(
"Git sync is only available on Windmill Enterprise Edition".to_string(),
));
const CE_GIT_SYNC_MAX_USERS: i64 = 2;
#[cfg(feature = "enterprise")]
async fn check_git_sync_access(_db: &DB, _w_id: &str) -> Result<()> {
Ok(())
}
#[cfg(not(feature = "enterprise"))]
async fn check_git_sync_access(db: &DB, w_id: &str) -> Result<()> {
let user_count: i64 = sqlx::query_scalar!(
"SELECT COUNT(*) FROM usr WHERE workspace_id = $1 AND disabled = false",
w_id
)
.fetch_one(db)
.await?
.unwrap_or(0);
if user_count > CE_GIT_SYNC_MAX_USERS {
return Err(Error::BadRequest(format!(
"Git sync is available for workspaces with up to {} members. \
Upgrade to Windmill Enterprise Edition for unlimited workspace members.",
CE_GIT_SYNC_MAX_USERS
)));
}
Ok(())
}
#[cfg(feature = "enterprise")]
async fn get_git_sync_enabled(
_authed: ApiAuthed,
Extension(_db): Extension<DB>,
Path(_w_id): Path<String>,
) -> JsonResult<serde_json::Value> {
Ok(Json(serde_json::json!({
"enabled": true,
"reason": "enterprise",
"max_repos": null,
"user_count": null,
"max_users": null,
})))
}
#[cfg(not(feature = "enterprise"))]
async fn get_git_sync_enabled(
_authed: ApiAuthed,
Extension(db): Extension<DB>,
Path(w_id): Path<String>,
) -> JsonResult<serde_json::Value> {
let user_count: i64 = sqlx::query_scalar!(
"SELECT COUNT(*) FROM usr WHERE workspace_id = $1 AND disabled = false",
&w_id
)
.fetch_one(&db)
.await?
.unwrap_or(0);
let enabled = user_count <= CE_GIT_SYNC_MAX_USERS;
Ok(Json(serde_json::json!({
"enabled": enabled,
"reason": if enabled { Some("free_tier") } else { None::<&str> },
"max_repos": if enabled { Some(1) } else { None::<i32> },
"user_count": user_count,
"max_users": CE_GIT_SYNC_MAX_USERS,
})))
}
async fn edit_git_sync_config(
authed: ApiAuthed,
Extension(db): Extension<DB>,
@@ -1708,6 +1757,7 @@ async fn edit_git_sync_config(
Json(new_config): Json<EditGitSyncConfig>,
) -> Result<String> {
require_admin(is_admin, &username)?;
check_git_sync_access(&db, &w_id).await?;
let mut tx = db.begin().await?;
@@ -1764,19 +1814,6 @@ async fn edit_git_sync_config(
Ok(format!("Edit git sync config for workspace {}", &w_id))
}
#[cfg(not(feature = "enterprise"))]
async fn edit_git_sync_repository(
_authed: ApiAuthed,
Extension(_db): Extension<DB>,
Path(_w_id): Path<String>,
Json(_new_config): Json<serde_json::Value>,
) -> Result<String> {
return Err(Error::BadRequest(
"Git sync is only available on Windmill Enterprise Edition".to_string(),
));
}
#[cfg(feature = "enterprise")]
async fn edit_git_sync_repository(
authed: ApiAuthed,
Extension(db): Extension<DB>,
@@ -1785,10 +1822,19 @@ async fn edit_git_sync_repository(
Json(new_config): Json<EditGitSyncRepository>,
) -> Result<String> {
require_admin(is_admin, &username)?;
check_git_sync_access(&db, &w_id).await?;
// Validate the resource path format
validate_git_repo_resource_path(&new_config.git_repo_resource_path)?;
// Promotion mode: EE only
#[cfg(not(feature = "enterprise"))]
if new_config.repository.use_individual_branch.unwrap_or(false) {
return Err(Error::BadRequest(
"Promotion mode is an Enterprise Edition feature".to_string(),
));
}
let mut tx = db.begin().await?;
// First, get the current git sync settings
@@ -1810,6 +1856,20 @@ async fn edit_git_sync_repository(
WorkspaceGitSyncSettings::default()
};
// Multi-repo: EE only
#[cfg(not(feature = "enterprise"))]
{
let is_new = !git_sync_settings
.repositories
.iter()
.any(|r| r.git_repo_resource_path == new_config.git_repo_resource_path);
if is_new && !git_sync_settings.repositories.is_empty() {
return Err(Error::BadRequest(
"Multiple git sync repositories is an Enterprise Edition feature".to_string(),
));
}
}
// Audit log before we move the repository
audit_log(
&mut *tx,
@@ -1893,19 +1953,6 @@ async fn edit_git_sync_repository(
))
}
#[cfg(not(feature = "enterprise"))]
async fn delete_git_sync_repository(
_authed: ApiAuthed,
Extension(_db): Extension<DB>,
Path(_w_id): Path<String>,
Json(_request): Json<serde_json::Value>,
) -> Result<String> {
return Err(Error::BadRequest(
"Git sync is only available on Windmill Enterprise Edition".to_string(),
));
}
#[cfg(feature = "enterprise")]
async fn delete_git_sync_repository(
authed: ApiAuthed,
Extension(db): Extension<DB>,
@@ -1915,7 +1962,7 @@ async fn delete_git_sync_repository(
) -> Result<String> {
require_admin(is_admin, &username)?;
// For deletion, only validate that path is not empty to allow cleanup of malformed entries
// No check_git_sync_access here — admins should always be able to delete/clean up repos
if request.git_repo_resource_path.is_empty() {
return Err(Error::BadRequest(
"Resource path cannot be empty".to_string(),

View File

@@ -3430,6 +3430,37 @@ paths:
application/json:
schema: {}
/w/{workspace}/workspaces/git_sync_enabled:
get:
summary: Check if git sync is available for this workspace
operationId: getGitSyncEnabled
tags:
- workspace
parameters:
- $ref: "#/components/parameters/WorkspaceId"
responses:
"200":
description: Git sync availability status
content:
application/json:
schema:
type: object
properties:
enabled:
type: boolean
reason:
type: string
nullable: true
max_repos:
type: integer
nullable: true
user_count:
type: integer
nullable: true
max_users:
type: integer
nullable: true
/w/{workspace}/workspaces/edit_git_sync_config:
post:
summary: edit workspace git sync settings

View File

@@ -23,6 +23,7 @@ lazy_static::lazy_static! {
}
#[cfg(not(feature = "private"))]
#[derive(PartialEq, Eq)]
pub enum LicensePlan {
Community,
Pro,

View File

@@ -9,7 +9,7 @@ name = "windmill_git_sync"
path = "./src/lib.rs"
[features]
private = []
private = ["windmill-common/private"]
enterprise = ["windmill-queue/enterprise", "windmill-common/enterprise"]
all_sqlx_features = ["enterprise"]
default = []

View File

@@ -272,7 +272,10 @@ mod tests {
path: "f/folder/script".to_string(),
parent_path: Some("f/folder/old_script".to_string()),
};
assert_eq!(obj.get_parent_path(), Some("f/folder/old_script".to_string()));
assert_eq!(
obj.get_parent_path(),
Some("f/folder/old_script".to_string())
);
}
#[test]
@@ -313,21 +316,13 @@ mod tests {
#[test]
fn test_get_kind_flow() {
let obj = DeployedObject::Flow {
path: "test".to_string(),
parent_path: None,
version: 1,
};
let obj = DeployedObject::Flow { path: "test".to_string(), parent_path: None, version: 1 };
assert_eq!(obj.get_kind(), "flow");
}
#[test]
fn test_get_kind_app() {
let obj = DeployedObject::App {
path: "test".to_string(),
version: 1,
parent_path: None,
};
let obj = DeployedObject::App { path: "test".to_string(), version: 1, parent_path: None };
assert_eq!(obj.get_kind(), "app");
}
@@ -346,7 +341,8 @@ mod tests {
"http_trigger"
);
assert_eq!(
DeployedObject::WebsocketTrigger { path: "t".to_string(), parent_path: None }.get_kind(),
DeployedObject::WebsocketTrigger { path: "t".to_string(), parent_path: None }
.get_kind(),
"websocket_trigger"
);
assert_eq!(

14
cli/package-lock.json generated
View File

@@ -25,10 +25,11 @@
"windmill-parser-wasm-nu": "*",
"windmill-parser-wasm-php": "*",
"windmill-parser-wasm-py": "*",
"windmill-parser-wasm-py-imports": "*",
"windmill-parser-wasm-regex": "*",
"windmill-parser-wasm-ruby": "*",
"windmill-parser-wasm-rust": "*",
"windmill-parser-wasm-ts": "*",
"windmill-parser-wasm-ts": "^1.659.1",
"windmill-parser-wasm-yaml": "*",
"windmill-yaml-validator": "1.1.1",
"ws": "8.18.0",
@@ -1414,6 +1415,11 @@
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-py/-/windmill-parser-wasm-py-1.628.3.tgz",
"integrity": "sha512-TlluqknZpg8cZ+A3m6JFLPseY2PpKtDsxdj26fAnCUzKPtse8TxQR+n0dwC80rfW5TwdWSulvNGRDgcNuf7CTw=="
},
"node_modules/windmill-parser-wasm-py-imports": {
"version": "1.659.1",
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-py-imports/-/windmill-parser-wasm-py-imports-1.659.1.tgz",
"integrity": "sha512-nfnf04WBRf8f/mNIwdvggYOgz3erxrFGjKqULYBH+bKFMlKA6V7eB19m6CXOBkq9rjTp0ZFG+rgsR+Us7JEkyQ=="
},
"node_modules/windmill-parser-wasm-regex": {
"version": "1.639.0",
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-regex/-/windmill-parser-wasm-regex-1.639.0.tgz",
@@ -1430,9 +1436,9 @@
"integrity": "sha512-9yGLYZX2Hn9TdTqGY/5Fp50ftzgUsrfBkSK9vJkKJd5Amyg+yXLBGzd8pz6Org+4uxMenz/16wpsgijvo6uhhQ=="
},
"node_modules/windmill-parser-wasm-ts": {
"version": "1.647.1",
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-ts/-/windmill-parser-wasm-ts-1.647.1.tgz",
"integrity": "sha512-64iSAUMU5W/WtePqE1vtDvglDqtkiZVndyieYBVDX0nl7UuovS+wPgH/P3TEoKbR+FwAPacki0CX3DsEzZ/Yxw=="
"version": "1.659.1",
"resolved": "https://registry.npmjs.org/windmill-parser-wasm-ts/-/windmill-parser-wasm-ts-1.659.1.tgz",
"integrity": "sha512-EmXMzOmazC5r29UZh+1TVF9g/N2X51pqK11qDL6xWGeWTIIonhfOZ5nWdGvKQMDUR650fGxehImZzW2v9hNy+w=="
},
"node_modules/windmill-parser-wasm-yaml": {
"version": "1.593.0",

View File

@@ -1,4 +1,7 @@
import { getContext, setContext } from 'svelte'
import { enterpriseLicense } from '$lib/stores'
import { get } from 'svelte/store'
import { sendUserToast } from '$lib/toast'
import { JobService, WorkspaceService, ResourceService } from '$lib/gen'
import type {
GitRepositorySettings as BackendGitRepositorySettings,
@@ -646,6 +649,10 @@ export function createGitSyncContext(workspace: string) {
}
function addSyncRepository() {
if (!get(enterpriseLicense) && repositories && repositories.length >= 1) {
sendUserToast('Multiple repositories requires Enterprise Edition', true)
return
}
repositories.push({
git_repo_resource_path: '',
script_path: undefined,
@@ -669,6 +676,10 @@ export function createGitSyncContext(workspace: string) {
}
function addPromotionRepository() {
if (!get(enterpriseLicense)) {
sendUserToast('Promotion mode requires Enterprise Edition', true)
return
}
repositories.push({
git_repo_resource_path: '',
script_path: undefined,

View File

@@ -6,12 +6,46 @@
import GitSyncRepositoryCard from './GitSyncRepositoryCard.svelte'
import GitSyncModalManager from './GitSyncModalManager.svelte'
import { enterpriseLicense, workspaceStore } from '$lib/stores'
import { WorkspaceService } from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import { untrack } from 'svelte'
// Create context reactively based on workspaceStore
const gitSyncContext = $derived($workspaceStore ? setGitSyncContext($workspaceStore) : null)
// Fetch git sync eligibility
let gitSyncStatus = $state<{
enabled: boolean
reason: string | null
max_repos: number | null
user_count: number | null
max_users: number | null
}>({ enabled: false, reason: null, max_repos: null, user_count: null, max_users: null })
$effect(() => {
if ($workspaceStore) {
WorkspaceService.getGitSyncEnabled({ workspace: $workspaceStore })
.then((status) => {
gitSyncStatus = status as typeof gitSyncStatus
})
.catch(() => {
gitSyncStatus = {
enabled: false,
reason: null,
max_repos: null,
user_count: null,
max_users: null
}
})
}
})
const gitSyncAllowed = $derived(gitSyncStatus.enabled)
const isFreeTier = $derived(gitSyncAllowed && !$enterpriseLicense)
const hasConfiguredRepos = $derived(
gitSyncContext?.repositories?.some((r) => r.git_repo_resource_path) ?? false
)
// Load settings when workspace context changes
$effect(() => {
if (gitSyncContext) {
@@ -58,7 +92,7 @@
link="https://www.windmill.dev/docs/advanced/git_sync"
>
{#snippet actions()}
{#if $enterpriseLicense && gitSyncContext.repositories != undefined}
{#if (gitSyncAllowed || gitSyncStatus.user_count != null) && gitSyncContext?.repositories != undefined}
<Button
variant="accent"
target="_blank"
@@ -74,15 +108,26 @@
Only new changes matching the filters will trigger a git sync. You still need to initialize the
repo to the desired state first.
</Alert>
{#if !$enterpriseLicense}
{#if !gitSyncAllowed}
<div class="mb-2"></div>
<Alert type="warning" title="Syncing workspace to Git is an EE feature">
Automatically saving scripts to a Git repository on each deploy is a Windmill EE feature.
<Alert type={hasConfiguredRepos ? 'error' : 'warning'} title="Git sync disabled">
Git sync is an EE feature provided in CE for testing and hobbyist use when workspace members
&le; {gitSyncStatus.max_users}. Your workspace has {gitSyncStatus.user_count} members. Settings
below are preserved but sync is inactive until membership is reduced or you upgrade to EE.
</Alert>
<div class="mb-2"></div>
{:else if isFreeTier}
<div class="mb-2"></div>
<Alert type="warning">
Git sync is an EE feature provided in CE for testing and hobbyist use when workspace members
&le; {gitSyncStatus.max_users}. Limited to a single repository. Upgrade to EE for multiple
repositories, promotion mode, and GitHub App authentication.
</Alert>
<div class="mb-2"></div>
{/if}
{#if $enterpriseLicense && gitSyncContext.repositories != undefined}
{#if (gitSyncAllowed || gitSyncStatus.user_count != null) && gitSyncContext?.repositories != undefined}
<!-- Primary Sync Repository -->
<div class="space-y-6 pt-6">
<GitSyncRepositoryCard
@@ -95,120 +140,46 @@
showEmptyState={primarySync?.repo === null}
/>
<!-- Secondary Sync Repositories -->
{#if primarySync && !primarySync.repo?.isUnsavedConnection}
{#if secondarySync.length > 0 || secondarySyncExpanded}
<div class="mt-4">
<button
class="flex items-center gap-2 text-sm text-secondary hover:text-primary transition-colors"
onclick={() => (secondarySyncExpanded = !secondarySyncExpanded)}
>
{#if secondarySyncExpanded}
<ChevronDown size={16} />
{:else}
<ChevronRight size={16} />
{/if}
Secondary sync repositories ({secondarySync.length})
</button>
{#if secondarySyncExpanded}
<div class="mt-3 space-y-3">
{#if secondarySync.length === 0}
<div class="text-sm text-secondary italic">
No secondary sync repositories configured
</div>
{:else}
{#each secondarySync as { repo, idx } (repo.git_repo_resource_path)}
<div class="pl-4">
<GitSyncRepositoryCard variant="secondary" {idx} isSecondary={true} />
</div>
{/each}
{/if}
{#if !hasUnsavedSecondary}
<div class="pl-4">
<Button
size="xs"
variant="default"
startIcon={{ icon: Plus }}
onclick={() => gitSyncContext.addSyncRepository()}
>
Add secondary sync
</Button>
</div>
{/if}
</div>
{/if}
</div>
{:else}
<!-- Collapsed state when no secondary repos exist -->
{#if !hasUnsavedSecondary}
<div class="mt-2">
<button
class="text-xs text-primary hover:text-secondary transition-colors"
onclick={() => {
secondarySyncExpanded = true
gitSyncContext.addSyncRepository()
}}
>
+ Add secondary sync repository
</button>
</div>
{/if}
{/if}
{/if}
<!-- Primary Promotion Repository -->
<div class="mt-6">
<GitSyncRepositoryCard
variant="primary-promotion"
mode="promotion"
idx={primaryPromotion?.idx ?? null}
repository={primaryPromotion?.repo ?? null}
onAdd={() => gitSyncContext.addPromotionRepository()}
isCollapsible={false}
showEmptyState={primaryPromotion?.repo === null}
/>
<!-- Secondary Promotion Repositories -->
{#if primaryPromotion && !primaryPromotion.repo?.isUnsavedConnection}
{#if secondaryPromotion.length > 0 || secondaryPromotionExpanded}
{#if $enterpriseLicense}
<!-- Secondary Sync Repositories (EE only) -->
{#if primarySync && !primarySync.repo?.isUnsavedConnection}
{#if secondarySync.length > 0 || secondarySyncExpanded}
<div class="mt-4">
<button
class="flex items-center gap-2 text-sm text-secondary hover:text-primary transition-colors"
onclick={() => (secondaryPromotionExpanded = !secondaryPromotionExpanded)}
onclick={() => (secondarySyncExpanded = !secondarySyncExpanded)}
>
{#if secondaryPromotionExpanded}
{#if secondarySyncExpanded}
<ChevronDown size={16} />
{:else}
<ChevronRight size={16} />
{/if}
Secondary promotion repositories ({secondaryPromotion.length})
Secondary sync repositories ({secondarySync.length})
</button>
{#if secondaryPromotionExpanded}
{#if secondarySyncExpanded}
<div class="mt-3 space-y-3">
{#if secondaryPromotion.length === 0}
{#if secondarySync.length === 0}
<div class="text-sm text-secondary italic">
No secondary promotion repositories configured
No secondary sync repositories configured
</div>
{:else}
{#each secondaryPromotion as { repo, idx } (repo.git_repo_resource_path)}
{#each secondarySync as { repo, idx } (repo.git_repo_resource_path)}
<div class="pl-4">
<GitSyncRepositoryCard variant="secondary" {idx} isSecondary={true} />
</div>
{/each}
{/if}
{#if !hasUnsavedSecondaryPromotion}
{#if !hasUnsavedSecondary}
<div class="pl-4">
<Button
size="xs"
variant="default"
startIcon={{ icon: Plus }}
onclick={() => gitSyncContext.addPromotionRepository()}
onclick={() => gitSyncContext.addSyncRepository()}
>
Add secondary promotion
Add secondary sync
</Button>
</div>
{/if}
@@ -216,23 +187,99 @@
{/if}
</div>
{:else}
<!-- Collapsed state when no secondary promotion repos exist -->
{#if !hasUnsavedSecondaryPromotion}
<!-- Collapsed state when no secondary repos exist -->
{#if !hasUnsavedSecondary}
<div class="mt-2">
<button
class="text-xs text-primary hover:text-secondary transition-colors"
onclick={() => {
secondaryPromotionExpanded = true
gitSyncContext.addPromotionRepository()
secondarySyncExpanded = true
gitSyncContext.addSyncRepository()
}}
>
+ Add secondary promotion repository
+ Add secondary sync repository
</button>
</div>
{/if}
{/if}
{/if}
</div>
<!-- Primary Promotion Repository (EE only) -->
<div class="mt-6">
<GitSyncRepositoryCard
variant="primary-promotion"
mode="promotion"
idx={primaryPromotion?.idx ?? null}
repository={primaryPromotion?.repo ?? null}
onAdd={() => gitSyncContext.addPromotionRepository()}
isCollapsible={false}
showEmptyState={primaryPromotion?.repo === null}
/>
<!-- Secondary Promotion Repositories -->
{#if primaryPromotion && !primaryPromotion.repo?.isUnsavedConnection}
{#if secondaryPromotion.length > 0 || secondaryPromotionExpanded}
<div class="mt-4">
<button
class="flex items-center gap-2 text-sm text-secondary hover:text-primary transition-colors"
onclick={() => (secondaryPromotionExpanded = !secondaryPromotionExpanded)}
>
{#if secondaryPromotionExpanded}
<ChevronDown size={16} />
{:else}
<ChevronRight size={16} />
{/if}
Secondary promotion repositories ({secondaryPromotion.length})
</button>
{#if secondaryPromotionExpanded}
<div class="mt-3 space-y-3">
{#if secondaryPromotion.length === 0}
<div class="text-sm text-secondary italic">
No secondary promotion repositories configured
</div>
{:else}
{#each secondaryPromotion as { repo, idx } (repo.git_repo_resource_path)}
<div class="pl-4">
<GitSyncRepositoryCard variant="secondary" {idx} isSecondary={true} />
</div>
{/each}
{/if}
{#if !hasUnsavedSecondaryPromotion}
<div class="pl-4">
<Button
size="xs"
variant="default"
startIcon={{ icon: Plus }}
onclick={() => gitSyncContext.addPromotionRepository()}
>
Add secondary promotion
</Button>
</div>
{/if}
</div>
{/if}
</div>
{:else}
<!-- Collapsed state when no secondary promotion repos exist -->
{#if !hasUnsavedSecondaryPromotion}
<div class="mt-2">
<button
class="text-xs text-primary hover:text-secondary transition-colors"
onclick={() => {
secondaryPromotionExpanded = true
gitSyncContext.addPromotionRepository()
}}
>
+ Add secondary promotion repository
</button>
</div>
{/if}
{/if}
{/if}
</div>
{/if}
</div>
<!-- Modals -->