windmill-cli 1.607.0 → 1.610.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/esm/deps/jsr.io/@windmill-labs/shared-utils/{1.0.11 → 1.0.12}/lib.es.js +202 -35
  2. package/esm/deps.js +5 -6
  3. package/esm/gen/core/OpenAPI.js +1 -1
  4. package/esm/gen/services.gen.js +101 -0
  5. package/esm/src/commands/app/dev.js +182 -4
  6. package/esm/src/commands/app/wmillTsDev.js +40 -1
  7. package/esm/src/commands/script/script.js +2 -1
  8. package/esm/src/commands/sync/sync.js +34 -32
  9. package/esm/src/core/conf.js +47 -45
  10. package/esm/src/core/context.js +34 -22
  11. package/esm/src/core/specific_items.js +28 -10
  12. package/esm/src/main.js +1 -1
  13. package/esm/src/utils/resource_folders.js +3 -1
  14. package/package.json +1 -1
  15. package/types/deps/jsr.io/@windmill-labs/shared-utils/{1.0.11 → 1.0.12}/lib.es.d.ts.map +1 -1
  16. package/types/deps.d.ts +2 -2
  17. package/types/deps.d.ts.map +1 -1
  18. package/types/gen/services.gen.d.ts +50 -1
  19. package/types/gen/services.gen.d.ts.map +1 -1
  20. package/types/gen/types.gen.d.ts +73 -0
  21. package/types/gen/types.gen.d.ts.map +1 -1
  22. package/types/src/commands/app/dev.d.ts.map +1 -1
  23. package/types/src/commands/app/metadata.d.ts +1 -1
  24. package/types/src/commands/app/metadata.d.ts.map +1 -1
  25. package/types/src/commands/app/wmillTsDev.d.ts.map +1 -1
  26. package/types/src/commands/script/script.d.ts.map +1 -1
  27. package/types/src/commands/sync/sync.d.ts +5 -1
  28. package/types/src/commands/sync/sync.d.ts.map +1 -1
  29. package/types/src/core/conf.d.ts +1 -1
  30. package/types/src/core/conf.d.ts.map +1 -1
  31. package/types/src/core/context.d.ts +2 -2
  32. package/types/src/core/context.d.ts.map +1 -1
  33. package/types/src/core/specific_items.d.ts +3 -3
  34. package/types/src/core/specific_items.d.ts.map +1 -1
  35. package/types/src/main.d.ts +1 -1
  36. package/types/src/utils/resource_folders.d.ts.map +1 -1
  37. package/types/windmill-utils-internal/src/gen/types.gen.d.ts +73 -0
  38. package/types/windmill-utils-internal/src/gen/types.gen.d.ts.map +1 -1
  39. /package/types/deps/jsr.io/@windmill-labs/shared-utils/{1.0.11 → 1.0.12}/lib.es.d.ts +0 -0
@@ -17,9 +17,41 @@ import { replaceInlineScripts } from "./app.js";
17
17
  import { APP_BACKEND_FOLDER, inferRunnableSchemaFromFile, } from "./app_metadata.js";
18
18
  import { loadRunnablesFromBackend } from "./raw_apps.js";
19
19
  import { regenerateAgentDocs } from "./generate_agents.js";
20
- import { hasFolderSuffix, getFolderSuffix } from "../../utils/resource_folders.js";
20
+ import { getFolderSuffix, hasFolderSuffix, setNonDottedPaths, } from "../../utils/resource_folders.js";
21
21
  const DEFAULT_PORT = 4000;
22
22
  const DEFAULT_HOST = "localhost";
23
+ /**
24
+ * Search for wmill.yaml by traversing upward from the current directory.
25
+ * Unlike the standard findWmillYaml() in conf.ts, this does not stop at
26
+ * the git root - it continues searching until the filesystem root.
27
+ * This is needed for `app dev` which runs from inside a raw_app folder
28
+ * that may be deeply nested within a larger git repository.
29
+ */
30
+ async function findAndLoadNonDottedPathsSetting() {
31
+ let currentDir = process.cwd();
32
+ while (true) {
33
+ const wmillYamlPath = path.join(currentDir, "wmill.yaml");
34
+ if (fs.existsSync(wmillYamlPath)) {
35
+ try {
36
+ const config = await yamlParseFile(wmillYamlPath);
37
+ setNonDottedPaths(config?.nonDottedPaths ?? false);
38
+ log.debug(`Found wmill.yaml at ${wmillYamlPath}, nonDottedPaths=${config?.nonDottedPaths ?? false}`);
39
+ }
40
+ catch (e) {
41
+ log.debug(`Failed to parse wmill.yaml at ${wmillYamlPath}: ${e}`);
42
+ }
43
+ return;
44
+ }
45
+ // Check if we've reached the filesystem root
46
+ const parentDir = path.dirname(currentDir);
47
+ if (parentDir === currentDir) {
48
+ // Reached filesystem root without finding wmill.yaml
49
+ log.debug("No wmill.yaml found, using default dotted paths");
50
+ return;
51
+ }
52
+ currentDir = parentDir;
53
+ }
54
+ }
23
55
  // HTML template with live reload and SQL migration modal
24
56
  const createHTML = (jsPath, cssPath) => `
25
57
  <!DOCTYPE html>
@@ -275,6 +307,9 @@ const createHTML = (jsPath, cssPath) => `
275
307
  `;
276
308
  async function dev(opts) {
277
309
  GLOBAL_CONFIG_OPT.noCdToRoot = true;
310
+ // Search for wmill.yaml by traversing upward (without git root constraint)
311
+ // to initialize nonDottedPaths setting before using folder suffix functions
312
+ await findAndLoadNonDottedPathsSetting();
278
313
  // Validate that we're in a .raw_app folder
279
314
  const cwd = process.cwd();
280
315
  const currentDirName = path.basename(cwd);
@@ -356,9 +391,12 @@ async function dev(opts) {
356
391
  });
357
392
  // Provide the virtual module content
358
393
  build.onLoad({ filter: /.*/, namespace: "wmill-virtual" }, (args) => {
394
+ const contents = wmillTs(port);
359
395
  log.info(colors.yellow(`[wmill-virtual] Loading virtual module: ${args.path}`));
396
+ log.info(colors.gray(`[wmill-virtual] Exports: ${contents.match(/export (const|function) \w+/g)?.join(", ") ??
397
+ "none"}`));
360
398
  return {
361
- contents: wmillTs(port),
399
+ contents,
362
400
  loader: "ts",
363
401
  };
364
402
  });
@@ -745,6 +783,23 @@ async function dev(opts) {
745
783
  }
746
784
  break;
747
785
  }
786
+ case "streamJob": {
787
+ // Stream job results using SSE
788
+ log.info(colors.blue(`[streamJob] Streaming job: ${jobId}`));
789
+ try {
790
+ await streamJobWithSSE(workspaceId, jobId, reqId, ws, workspace.remote, workspace.token);
791
+ }
792
+ catch (error) {
793
+ log.error(colors.red(`[streamJob] Error: ${error.message}`));
794
+ ws.send(JSON.stringify({
795
+ type: "streamJobRes",
796
+ reqId,
797
+ error: true,
798
+ result: { message: error.message, stack: error.stack },
799
+ }));
800
+ }
801
+ break;
802
+ }
748
803
  case "applySqlMigration": {
749
804
  // Execute SQL migration against a datatable
750
805
  const { sql, datatable, fileName } = message;
@@ -992,6 +1047,25 @@ async function genRunnablesTs(schemaOverrides = {}) {
992
1047
  log.error(colors.red(`Failed to generate wmill.d.ts: ${error.message}`));
993
1048
  }
994
1049
  }
1050
+ /**
1051
+ * Convert runnables from file format to API format.
1052
+ * File format uses type: "script"|"hubscript"|"flow" for path-based runnables.
1053
+ * API format uses type: "path" with runType: "script"|"hubscript"|"flow".
1054
+ */
1055
+ function convertRunnablesToApiFormat(runnables) {
1056
+ for (const [runnableId, runnable] of Object.entries(runnables)) {
1057
+ if (runnable?.type === "script" ||
1058
+ runnable?.type === "hubscript" ||
1059
+ runnable?.type === "flow") {
1060
+ // Convert from file format to API format
1061
+ // { type: "script" } -> { type: "path", runType: "script" }
1062
+ const originalType = runnable.type;
1063
+ runnable.runType = originalType;
1064
+ runnable.type = "path";
1065
+ log.debug(`Converted runnable '${runnableId}' from type='${originalType}' to type='path', runType='${originalType}'`);
1066
+ }
1067
+ }
1068
+ }
995
1069
  async function loadRunnables() {
996
1070
  try {
997
1071
  const localPath = process.cwd();
@@ -1003,6 +1077,9 @@ async function loadRunnables() {
1003
1077
  const rawApp = (await yamlParseFile(path.join(localPath, "raw_app.yaml")));
1004
1078
  runnables = rawApp?.runnables ?? {};
1005
1079
  }
1080
+ // Always convert path-based runnables from file format to API format
1081
+ // This handles both backend folder runnables and raw_app.yaml runnables
1082
+ convertRunnablesToApiFormat(runnables);
1006
1083
  replaceInlineScripts(runnables, backendPath + SEP, true);
1007
1084
  return runnables;
1008
1085
  }
@@ -1044,13 +1121,24 @@ async function executeRunnable(runnable, workspace, appPath, runnableId, args) {
1044
1121
  cache_ttl: inlineScript.cache_ttl,
1045
1122
  };
1046
1123
  }
1047
- else if (runnable.type === "path" && runnable.runType && runnable.path) {
1048
- // Path-based runnables have type: "path" and runType: "script"|"hubscript"|"flow"
1124
+ else if ((runnable.type === "path" || runnable.type === "runnableByPath") &&
1125
+ runnable.runType &&
1126
+ runnable.path) {
1127
+ // Path-based runnables have type: "path" (or legacy "runnableByPath") and runType: "script"|"hubscript"|"flow"
1049
1128
  const prefix = runnable.runType;
1050
1129
  requestBody.path = prefix !== "hubscript"
1051
1130
  ? `${prefix}/${runnable.path}`
1052
1131
  : `script/${runnable.path}`;
1053
1132
  }
1133
+ else {
1134
+ // Neither inline script nor valid path-based runnable
1135
+ const debugInfo = `type=${runnable.type}, runType=${runnable.runType}, ` +
1136
+ `path=${runnable.path}, hasInlineScript=${!!runnable
1137
+ .inlineScript}`;
1138
+ log.error(colors.red(`[executeRunnable] Invalid runnable configuration for '${runnableId}': ${debugInfo}`));
1139
+ throw new Error(`Invalid runnable '${runnableId}': ${debugInfo}. ` +
1140
+ `Must have either inlineScript (for inline type) or type="path" with runType and path fields`);
1141
+ }
1054
1142
  const uuid = await wmill.executeComponent({
1055
1143
  workspace,
1056
1144
  path: appPath,
@@ -1108,3 +1196,93 @@ async function getJobStatus(workspace, jobId) {
1108
1196
  id: jobId,
1109
1197
  });
1110
1198
  }
1199
+ async function streamJobWithSSE(workspace, jobId, reqId, ws, baseUrl, token) {
1200
+ const sseUrl = `${baseUrl}api/w/${workspace}/jobs_u/getupdate_sse/${jobId}?fast=true`;
1201
+ const response = await fetch(sseUrl, {
1202
+ headers: {
1203
+ Accept: "text/event-stream",
1204
+ Authorization: `Bearer ${token}`,
1205
+ },
1206
+ });
1207
+ if (!response.ok) {
1208
+ throw new Error(`SSE request failed: ${response.status} ${response.statusText}`);
1209
+ }
1210
+ const reader = response.body?.getReader();
1211
+ if (!reader) {
1212
+ throw new Error("No response body for SSE stream");
1213
+ }
1214
+ const decoder = new TextDecoder();
1215
+ let buffer = "";
1216
+ try {
1217
+ while (true) {
1218
+ const { done, value } = await reader.read();
1219
+ if (done)
1220
+ break;
1221
+ buffer += decoder.decode(value, { stream: true });
1222
+ const lines = buffer.split("\n");
1223
+ buffer = lines.pop() || "";
1224
+ for (const line of lines) {
1225
+ if (line.startsWith("data: ")) {
1226
+ const data = line.slice(6);
1227
+ try {
1228
+ const update = JSON.parse(data);
1229
+ const type = update.type;
1230
+ if (type === "ping" || type === "timeout") {
1231
+ if (type === "timeout") {
1232
+ reader.cancel();
1233
+ return;
1234
+ }
1235
+ continue;
1236
+ }
1237
+ if (type === "error") {
1238
+ ws.send(JSON.stringify({
1239
+ type: "streamJobRes",
1240
+ reqId,
1241
+ error: true,
1242
+ result: { message: update.error || "SSE error" },
1243
+ }));
1244
+ reader.cancel();
1245
+ return;
1246
+ }
1247
+ if (type === "not_found") {
1248
+ ws.send(JSON.stringify({
1249
+ type: "streamJobRes",
1250
+ reqId,
1251
+ error: true,
1252
+ result: { message: "Job not found" },
1253
+ }));
1254
+ reader.cancel();
1255
+ return;
1256
+ }
1257
+ // Send stream update if there's new stream data
1258
+ if (update.new_result_stream !== undefined) {
1259
+ ws.send(JSON.stringify({
1260
+ type: "streamJobUpdate",
1261
+ reqId,
1262
+ new_result_stream: update.new_result_stream,
1263
+ stream_offset: update.stream_offset,
1264
+ }));
1265
+ }
1266
+ // Check if job is completed
1267
+ if (update.completed) {
1268
+ ws.send(JSON.stringify({
1269
+ type: "streamJobRes",
1270
+ reqId,
1271
+ error: false,
1272
+ result: update.only_result,
1273
+ }));
1274
+ reader.cancel();
1275
+ return;
1276
+ }
1277
+ }
1278
+ catch (parseErr) {
1279
+ log.warn(`Failed to parse SSE data: ${parseErr}`);
1280
+ }
1281
+ }
1282
+ }
1283
+ }
1284
+ }
1285
+ finally {
1286
+ reader.releaseLock();
1287
+ }
1288
+ }
@@ -20,7 +20,27 @@ function initWebSocket() {
20
20
 
21
21
  ws.onmessage = (event) => {
22
22
  const data = JSON.parse(event.data)
23
- if (data.type === 'backendRes' || data.type === 'backendAsyncRes') {
23
+ if (data.type === 'streamJobUpdate') {
24
+ // Handle streaming update
25
+ const job = reqs[data.reqId]
26
+ if (job && job.onUpdate) {
27
+ job.onUpdate({
28
+ new_result_stream: data.new_result_stream,
29
+ stream_offset: data.stream_offset
30
+ })
31
+ }
32
+ } else if (data.type === 'streamJobRes') {
33
+ // Handle stream completion
34
+ const job = reqs[data.reqId]
35
+ if (job) {
36
+ if (data.error) {
37
+ job.reject(new Error(data.result?.stack ?? data.result?.message ?? 'Stream error'))
38
+ } else {
39
+ job.resolve(data.result)
40
+ }
41
+ delete reqs[data.reqId]
42
+ }
43
+ } else if (data.type === 'backendRes' || data.type === 'backendAsyncRes') {
24
44
  console.log('Message from WebSocket backend', data)
25
45
  const job = reqs[data.reqId]
26
46
  if (job) {
@@ -85,5 +105,24 @@ export function waitJob(jobId: string) {
85
105
  export function getJob(jobId: string) {
86
106
  return doRequest('getJob', { jobId })
87
107
  }
108
+
109
+ /**
110
+ * Stream job results using SSE. Calls onUpdate for each stream update,
111
+ * and resolves with the final result when the job completes.
112
+ * @param jobId - The job ID to stream
113
+ * @param onUpdate - Callback for stream updates with new_result_stream data
114
+ * @returns Promise that resolves with the final job result
115
+ */
116
+ export function streamJob(
117
+ jobId: string,
118
+ onUpdate?: (data: { new_result_stream?: string; stream_offset?: number }) => void
119
+ ): Promise<any> {
120
+ return new Promise(async (resolve, reject) => {
121
+ await wsReady
122
+ const reqId = Math.random().toString(36)
123
+ reqs[reqId] = { resolve, reject, onUpdate }
124
+ ws?.send(JSON.stringify({ jobId, type: 'streamJob', reqId }))
125
+ })
126
+ }
88
127
  `;
89
128
  }
@@ -172,6 +172,7 @@ export async function handleFile(path, workspace, alreadySynced, message, opts,
172
172
  continue;
173
173
  }
174
174
  log.info(`Adding file: ${file.path.substring(1)}`);
175
+ // deno-lint-ignore no-explicit-any
175
176
  const fil = new File([file.contents], file.path.substring(1));
176
177
  tarball.append(fil);
177
178
  }
@@ -350,7 +351,7 @@ async function streamToBlob(stream) {
350
351
  // Push the chunk to the array
351
352
  chunks.push(value);
352
353
  }
353
- // Create a Blob from the chunks
354
+ // deno-lint-ignore no-explicit-any
354
355
  const blob = new Blob(chunks);
355
356
  return blob;
356
357
  }
@@ -10,7 +10,7 @@ import { handleFile } from "../script/script.js";
10
10
  import { deepEqual, fetchRemoteVersion, isFileResource, isRawAppFile, isWorkspaceDependencies, } from "../../utils/utils.js";
11
11
  import { getEffectiveSettings, mergeConfigWithConfigFile, validateBranchConfiguration, } from "../../core/conf.js";
12
12
  import { fromBranchSpecificPath, getBranchSpecificPath, getSpecificItemsForCurrentBranch, isBranchSpecificFile, isCurrentBranchFile, isSpecificItem, } from "../../core/specific_items.js";
13
- import { getCurrentGitBranch } from "../../utils/git.js";
13
+ import { getCurrentGitBranch, isGitRepository } from "../../utils/git.js";
14
14
  import { removePathPrefix } from "../../types.js";
15
15
  import { listSyncCodebases } from "../../utils/codebase.js";
16
16
  import { generateScriptMetadataInternal, getRawWorkspaceDependencies, readLockfile, workspaceDependenciesPathToLanguageAndFilename, } from "../../utils/metadata.js";
@@ -27,8 +27,8 @@ function mergeCliWithEffectiveOptions(cliOpts, effectiveOpts) {
27
27
  return Object.assign({}, effectiveOpts, cliOpts);
28
28
  }
29
29
  // Resolve effective sync options using branch-based configuration
30
- async function resolveEffectiveSyncOptions(workspace, localConfig, promotion) {
31
- return await getEffectiveSettings(localConfig, promotion);
30
+ async function resolveEffectiveSyncOptions(workspace, localConfig, promotion, branchOverride) {
31
+ return await getEffectiveSettings(localConfig, promotion, false, false, branchOverride);
32
32
  }
33
33
  export function findCodebase(path, codebases) {
34
34
  if (!path.endsWith(".ts")) {
@@ -1014,7 +1014,7 @@ export async function* readDirRecursiveWithIgnore(ignore, root) {
1014
1014
  }
1015
1015
  }
1016
1016
  }
1017
- export async function elementsToMap(els, ignore, json, skips, specificItems) {
1017
+ export async function elementsToMap(els, ignore, json, skips, specificItems, branchOverride) {
1018
1018
  const map = {};
1019
1019
  const processedBasePaths = new Set();
1020
1020
  for await (const entry of readDirRecursiveWithIgnore(ignore, els)) {
@@ -1115,8 +1115,7 @@ export async function elementsToMap(els, ignore, json, skips, specificItems) {
1115
1115
  }
1116
1116
  // Handle branch-specific files - skip files for other branches
1117
1117
  if (specificItems && isBranchSpecificFile(path)) {
1118
- const currentBranch = getCurrentGitBranch();
1119
- if (!currentBranch || !isCurrentBranchFile(path)) {
1118
+ if (!isCurrentBranchFile(path, branchOverride)) {
1120
1119
  // Skip branch-specific files for other branches
1121
1120
  continue;
1122
1121
  }
@@ -1153,9 +1152,10 @@ export async function elementsToMap(els, ignore, json, skips, specificItems) {
1153
1152
  }
1154
1153
  // Handle branch-specific path mapping after all filtering
1155
1154
  if (specificItems) {
1156
- const currentBranch = getCurrentGitBranch();
1157
- if (currentBranch && isCurrentBranchFile(path)) {
1155
+ if (isCurrentBranchFile(path, branchOverride)) {
1158
1156
  // This is a branch-specific file for current branch
1157
+ // Safe to compute branch here since isCurrentBranchFile already validated it exists
1158
+ const currentBranch = branchOverride || getCurrentGitBranch();
1159
1159
  const basePath = fromBranchSpecificPath(path, currentBranch);
1160
1160
  if (isSpecificItem(basePath, specificItems)) {
1161
1161
  // Map to base path for push operations
@@ -1183,13 +1183,13 @@ export async function elementsToMap(els, ignore, json, skips, specificItems) {
1183
1183
  }
1184
1184
  return map;
1185
1185
  }
1186
- async function compareDynFSElement(els1, els2, ignore, json, skips, ignoreMetadataDeletion, codebases, ignoreCodebaseChanges, specificItems) {
1186
+ async function compareDynFSElement(els1, els2, ignore, json, skips, ignoreMetadataDeletion, codebases, ignoreCodebaseChanges, specificItems, branchOverride) {
1187
1187
  const [m1, m2] = els2
1188
1188
  ? await Promise.all([
1189
- elementsToMap(els1, ignore, json, skips, specificItems),
1190
- elementsToMap(els2, ignore, json, skips, specificItems),
1189
+ elementsToMap(els1, ignore, json, skips, specificItems, branchOverride),
1190
+ elementsToMap(els2, ignore, json, skips, specificItems, branchOverride),
1191
1191
  ])
1192
- : [await elementsToMap(els1, ignore, json, skips, specificItems), {}];
1192
+ : [await elementsToMap(els1, ignore, json, skips, specificItems, branchOverride), {}];
1193
1193
  const changes = [];
1194
1194
  function parseYaml(k, v) {
1195
1195
  if (k.endsWith(".script.yaml")) {
@@ -1573,12 +1573,12 @@ export async function pull(opts) {
1573
1573
  if (opts.stateful) {
1574
1574
  await ensureDir(path.join(dntShim.Deno.cwd(), ".wmill"));
1575
1575
  }
1576
- const workspace = await resolveWorkspace(opts);
1576
+ const workspace = await resolveWorkspace(opts, opts.branch);
1577
1577
  await requireLogin(opts);
1578
1578
  // Resolve effective sync options with branch awareness
1579
- const effectiveOpts = await resolveEffectiveSyncOptions(workspace, opts, opts.promotion);
1579
+ const effectiveOpts = await resolveEffectiveSyncOptions(workspace, opts, opts.promotion, opts.branch);
1580
1580
  // Extract specific items configuration before merging overwrites gitBranches
1581
- const specificItems = getSpecificItemsForCurrentBranch(opts);
1581
+ const specificItems = getSpecificItemsForCurrentBranch(opts, opts.branch);
1582
1582
  // Merge CLI flags with resolved settings (CLI flags take precedence only for explicit overrides)
1583
1583
  opts = mergeCliWithEffectiveOptions(originalCliOpts, effectiveOpts);
1584
1584
  const codebases = await listSyncCodebases(opts);
@@ -1597,7 +1597,7 @@ export async function pull(opts) {
1597
1597
  const local = !opts.stateful
1598
1598
  ? await FSFSElement(dntShim.Deno.cwd(), codebases, true)
1599
1599
  : await FSFSElement(path.join(dntShim.Deno.cwd(), ".wmill"), [], true);
1600
- const changes = await compareDynFSElement(remote, local, await ignoreF(opts), opts.json ?? false, opts, false, codebases, true, specificItems);
1600
+ const changes = await compareDynFSElement(remote, local, await ignoreF(opts), opts.json ?? false, opts, false, codebases, true, specificItems, opts.branch);
1601
1601
  log.info(`remote (${workspace.name}) -> local: ${changes.length} changes to apply`);
1602
1602
  // Handle JSON output for dry-run
1603
1603
  if (opts.dryRun && opts.jsonOutput) {
@@ -1612,7 +1612,7 @@ export async function pull(opts) {
1612
1612
  ...(specificItems && isSpecificItem(change.path, specificItems)
1613
1613
  ? {
1614
1614
  branch_specific: true,
1615
- branch_specific_path: getBranchSpecificPath(change.path, specificItems),
1615
+ branch_specific_path: getBranchSpecificPath(change.path, specificItems, opts.branch),
1616
1616
  }
1617
1617
  : {}),
1618
1618
  })),
@@ -1623,7 +1623,7 @@ export async function pull(opts) {
1623
1623
  }
1624
1624
  if (changes.length > 0) {
1625
1625
  if (!opts.jsonOutput) {
1626
- prettyChanges(changes, specificItems);
1626
+ prettyChanges(changes, specificItems, opts.branch);
1627
1627
  }
1628
1628
  if (opts.dryRun) {
1629
1629
  log.info(colors.gray(`Dry run complete.`));
@@ -1642,7 +1642,7 @@ export async function pull(opts) {
1642
1642
  // Determine if this file should be written to a branch-specific path
1643
1643
  let targetPath = change.path;
1644
1644
  if (specificItems && isSpecificItem(change.path, specificItems)) {
1645
- const branchSpecificPath = getBranchSpecificPath(change.path, specificItems);
1645
+ const branchSpecificPath = getBranchSpecificPath(change.path, specificItems, opts.branch);
1646
1646
  if (branchSpecificPath) {
1647
1647
  targetPath = branchSpecificPath;
1648
1648
  }
@@ -1776,7 +1776,7 @@ export async function pull(opts) {
1776
1776
  ...(specificItems && isSpecificItem(change.path, specificItems)
1777
1777
  ? {
1778
1778
  branch_specific: true,
1779
- branch_specific_path: getBranchSpecificPath(change.path, specificItems),
1779
+ branch_specific_path: getBranchSpecificPath(change.path, specificItems, opts.branch),
1780
1780
  }
1781
1781
  : {}),
1782
1782
  })),
@@ -1792,13 +1792,13 @@ export async function pull(opts) {
1792
1792
  console.log(JSON.stringify({ success: true, message: "No changes to apply", total: 0 }, null, 2));
1793
1793
  }
1794
1794
  }
1795
- function prettyChanges(changes, specificItems) {
1795
+ function prettyChanges(changes, specificItems, branchOverride) {
1796
1796
  for (const change of changes) {
1797
1797
  let displayPath = change.path;
1798
1798
  let branchNote = "";
1799
1799
  // Check if this will be written as a branch-specific file
1800
1800
  if (specificItems && isSpecificItem(change.path, specificItems)) {
1801
- const branchSpecificPath = getBranchSpecificPath(change.path, specificItems);
1801
+ const branchSpecificPath = getBranchSpecificPath(change.path, specificItems, branchOverride);
1802
1802
  if (branchSpecificPath) {
1803
1803
  displayPath = branchSpecificPath;
1804
1804
  branchNote = " (branch-specific)";
@@ -1875,12 +1875,12 @@ export async function push(opts) {
1875
1875
  }
1876
1876
  throw error;
1877
1877
  }
1878
- const workspace = await resolveWorkspace(opts);
1878
+ const workspace = await resolveWorkspace(opts, opts.branch);
1879
1879
  await requireLogin(opts);
1880
1880
  // Resolve effective sync options with branch awareness
1881
- const effectiveOpts = await resolveEffectiveSyncOptions(workspace, opts, opts.promotion);
1881
+ const effectiveOpts = await resolveEffectiveSyncOptions(workspace, opts, opts.promotion, opts.branch);
1882
1882
  // Extract specific items configuration BEFORE merging overwrites gitBranches
1883
- const specificItems = getSpecificItemsForCurrentBranch(opts);
1883
+ const specificItems = getSpecificItemsForCurrentBranch(opts, opts.branch);
1884
1884
  // Merge CLI flags with resolved settings (CLI flags take precedence only for explicit overrides)
1885
1885
  opts = mergeCliWithEffectiveOptions(originalCliOpts, effectiveOpts);
1886
1886
  const codebases = await listSyncCodebases(opts);
@@ -1907,7 +1907,7 @@ export async function push(opts) {
1907
1907
  }
1908
1908
  const remote = ZipFSElement((await downloadZip(workspace, opts.plainSecrets, opts.skipVariables, opts.skipResources, opts.skipResourceTypes, opts.skipSecrets, opts.includeSchedules, opts.includeTriggers, opts.includeUsers, opts.includeGroups, opts.includeSettings, opts.includeKey, opts.skipWorkspaceDependencies, opts.defaultTs)), !opts.json, opts.defaultTs ?? "bun", resourceTypeToFormatExtension, false);
1909
1909
  const local = await FSFSElement(path.join(dntShim.Deno.cwd(), ""), codebases, false);
1910
- const changes = await compareDynFSElement(local, remote, await ignoreF(opts), opts.json ?? false, opts, true, codebases, false, specificItems);
1910
+ const changes = await compareDynFSElement(local, remote, await ignoreF(opts), opts.json ?? false, opts, true, codebases, false, specificItems, opts.branch);
1911
1911
  const rawWorkspaceDependencies = await getRawWorkspaceDependencies();
1912
1912
  const tracker = await buildTracker(changes);
1913
1913
  const staleScripts = [];
@@ -1974,7 +1974,7 @@ export async function push(opts) {
1974
1974
  ...(specificItems && isSpecificItem(change.path, specificItems)
1975
1975
  ? {
1976
1976
  branch_specific: true,
1977
- branch_specific_path: getBranchSpecificPath(change.path, specificItems),
1977
+ branch_specific_path: getBranchSpecificPath(change.path, specificItems, opts.branch),
1978
1978
  }
1979
1979
  : {}),
1980
1980
  })),
@@ -1985,7 +1985,7 @@ export async function push(opts) {
1985
1985
  }
1986
1986
  if (changes.length > 0) {
1987
1987
  if (!opts.jsonOutput) {
1988
- prettyChanges(changes, specificItems);
1988
+ prettyChanges(changes, specificItems, opts.branch);
1989
1989
  }
1990
1990
  if (opts.dryRun) {
1991
1991
  log.info(colors.gray(`Dry run complete.`));
@@ -2083,7 +2083,7 @@ export async function push(opts) {
2083
2083
  // For branch-specific resources, push to the base path on the workspace server
2084
2084
  // This ensures branch-specific files are stored with their base names in the workspace
2085
2085
  let serverPath = resourceFilePath;
2086
- const currentBranch = getCurrentGitBranch();
2086
+ const currentBranch = opts.branch || (isGitRepository() ? getCurrentGitBranch() : null);
2087
2087
  if (currentBranch && isBranchSpecificFile(resourceFilePath)) {
2088
2088
  serverPath = fromBranchSpecificPath(resourceFilePath, currentBranch);
2089
2089
  }
@@ -2099,7 +2099,7 @@ export async function push(opts) {
2099
2099
  // Check if this is a branch-specific item and get the original branch-specific path
2100
2100
  let originalBranchSpecificPath;
2101
2101
  if (specificItems && isSpecificItem(change.path, specificItems)) {
2102
- originalBranchSpecificPath = getBranchSpecificPath(change.path, specificItems);
2102
+ originalBranchSpecificPath = getBranchSpecificPath(change.path, specificItems, opts.branch);
2103
2103
  }
2104
2104
  await pushObj(workspace.workspaceId, change.path, oldObj, newObj, opts.plainSecrets ?? false, alreadySynced, opts.message, originalBranchSpecificPath);
2105
2105
  if (stateTarget) {
@@ -2125,7 +2125,7 @@ export async function push(opts) {
2125
2125
  // For branch-specific items, we read from branch-specific files but push to base server paths
2126
2126
  let localFilePath = change.path;
2127
2127
  if (specificItems && isSpecificItem(change.path, specificItems)) {
2128
- const branchSpecificPath = getBranchSpecificPath(change.path, specificItems);
2128
+ const branchSpecificPath = getBranchSpecificPath(change.path, specificItems, opts.branch);
2129
2129
  if (branchSpecificPath) {
2130
2130
  localFilePath = branchSpecificPath;
2131
2131
  }
@@ -2330,7 +2330,7 @@ export async function push(opts) {
2330
2330
  ...(specificItems && isSpecificItem(change.path, specificItems)
2331
2331
  ? {
2332
2332
  branch_specific: true,
2333
- branch_specific_path: getBranchSpecificPath(change.path, specificItems),
2333
+ branch_specific_path: getBranchSpecificPath(change.path, specificItems, opts.branch),
2334
2334
  }
2335
2335
  : {}),
2336
2336
  })),
@@ -2379,6 +2379,7 @@ const command = new Command()
2379
2379
  .option("--extra-includes <patterns:file[]>", "Comma separated patterns to specify which file to take into account (among files that are compatible with windmill). Patterns can include * (any string until '/') and ** (any string). Useful to still take wmill.yaml into account and act as a second pattern to satisfy")
2380
2380
  .option("--repository <repo:string>", "Specify repository path (e.g., u/user/repo) when multiple repositories exist")
2381
2381
  .option("--promotion <branch:string>", "Use promotionOverrides from the specified branch instead of regular overrides")
2382
+ .option("--branch <branch:string>", "Override the current git branch (works even outside a git repository)")
2382
2383
  // deno-lint-ignore no-explicit-any
2383
2384
  .action(pull)
2384
2385
  .command("push")
@@ -2411,6 +2412,7 @@ const command = new Command()
2411
2412
  .option("--message <message:string>", "Include a message that will be added to all scripts/flows/apps updated during this push")
2412
2413
  .option("--parallel <number>", "Number of changes to process in parallel")
2413
2414
  .option("--repository <repo:string>", "Specify repository path (e.g., u/user/repo) when multiple repositories exist")
2415
+ .option("--branch <branch:string>", "Override the current git branch (works even outside a git repository)")
2414
2416
  // deno-lint-ignore no-explicit-any
2415
2417
  .action(push);
2416
2418
  export default command;