0

Git-sync: init repository

by
Published Jul 8, 2025
Script windmill Verified

The script

Submitted by hugo697 Bun
Verified 1 hour ago
1
import * as wmillclient from "windmill-client";
2
import wmill from "[email protected]";
3
import { basename, join } from "node:path";
4
import { existsSync } from "fs";
5
const util = require("util");
6
const exec = util.promisify(require("child_process").exec);
7
import process from "process";
8

9
type GpgKey = {
10
  email: string;
11
  private_key: string;
12
  passphrase: string;
13
};
14

15
type GitRepository = {
16
  url: string;
17
  branch: string;
18
  folder: string;
19
  gpg_key: any;
20
  is_github_app: boolean;
21
};
22

23
const FORKED_WORKSPACE_PREFIX = "wm-fork-";
24
const FORKED_BRANCH_PREFIX = "wm-fork";
25

26
let gpgFingerprint: string | undefined = undefined;
27

28
export async function main(
29
  workspace_id: string,
30
  repo_url_resource_path: string,
31
  dry_run: boolean,
32
  only_wmill_yaml: boolean = false,
33
  pull: boolean = false,
34
  settings_json?: string, // JSON settings from UI for new CLI approach
35
  use_promotion_overrides?: boolean, // Use promotionOverrides from repo branch when "use separate branch" toggle is selected
36
  clone_ref?: string // Optional git ref (branch) to clone instead of the resource's configured branch, e.g. a PR head branch for diff previews
37
) {
38
  let safeDirectoryPath: string | undefined;
39
  console.log("DEBUG: Starting main function", {
40
    workspace_id,
41
    // repo_url_resource_path,
42
    dry_run,
43
    only_wmill_yaml,
44
    pull,
45
    settings_json: settings_json ? "PROVIDED" : "NOT_PROVIDED",
46
  });
47

48
  const repo_resource: GitRepository = await wmillclient.getResource(
49
    repo_url_resource_path
50
  );
51

52
  process.env.GIT_TERMINAL_PROMPT = "0";
53
  console.log("DEBUG: Set GIT_TERMINAL_PROMPT=0 to prevent interactive prompts");
54
  const safeUrl = repo_resource.url 
55
    ? repo_resource.url.replace(/\/\/[^@]+@/, '//***@') 
56
    : undefined;
57
  console.log("DEBUG: Retrieved repo resource", {
58
    url: safeUrl,
59
    branch: repo_resource.branch,
60
    folder: repo_resource.folder,
61
    is_github_app: repo_resource.is_github_app,
62
    has_gpg: !!repo_resource.gpg_key,
63
  });
64

65
  // Extract clean repository path for CLI commands (remove $res: prefix)
66
  const repository_path = repo_url_resource_path.startsWith("$res:")
67
    ? repo_url_resource_path.substring(5)
68
    : repo_url_resource_path;
69
  console.log("DEBUG: Repository path for CLI:", repository_path);
70

71
  // Extract promotion branch from git repository resource if use_promotion_overrides is enabled
72
  const promotion_branch = use_promotion_overrides ? repo_resource.branch : undefined;
73
  console.log("DEBUG: Promotion branch:", promotion_branch);
74

75
  const cwd = process.cwd();
76
  console.log("DEBUG: Current working directory:", cwd);
77
  process.env["HOME"] = ".";
78

79
  if (repo_resource.is_github_app) {
80
    console.log("DEBUG: Using GitHub App authentication");
81
    const token = await get_gh_app_token();
82
    console.log("DEBUG: Got GitHub App token:", token ? "SUCCESS" : "FAILED");
83
    const authRepoUrl = prependTokenToGitHubUrl(repo_resource.url, token);
84
    console.log("DEBUG: URL conversion:", {
85
      original: repo_resource.url,
86
      authenticated: authRepoUrl,
87
    });
88
    repo_resource.url = authRepoUrl;
89
  }
90

91
  console.log("DEBUG: Starting git clone...");
92
  const { repo_name, safeDirectoryPath: cloneSafeDirectoryPath, clonedBranchName } = await git_clone(cwd, repo_resource, pull, workspace_id, clone_ref);
93
  safeDirectoryPath = cloneSafeDirectoryPath;
94
  console.log("DEBUG: Git clone completed, repo name:", repo_name);
95

96
  const subfolder = repo_resource.folder ?? "";
97
  const fullPath = join(cwd, repo_name, subfolder);
98
  console.log("DEBUG: Full path:", fullPath);
99

100
  process.chdir(fullPath);
101
  console.log("DEBUG: Changed directory to:", process.cwd());
102

103
  // Set up workspace context for CLI commands
104
  console.log("DEBUG: Setting up workspace...");
105
  await wmill_run(
106
    6,
107
    "workspace",
108
    "add",
109
    workspace_id,
110
    workspace_id,
111
    process.env["BASE_URL"] + "/",
112
    "--token",
113
    process.env["WM_TOKEN"] ?? ""
114
  );
115
  console.log("DEBUG: Workspace setup completed");
116

117
  let result;
118
  try {
119
    console.log("DEBUG: Entering main execution branch", {
120
      only_wmill_yaml,
121
      pull,
122
      dry_run,
123
    });
124

125
    if (only_wmill_yaml) {
126
      // Settings-only operations (wmill.yaml)
127
      result = await executeSettingsOperation(
128
        workspace_id,
129
        repository_path,
130
        settings_json,
131
        fullPath,
132
        pull,
133
        dry_run,
134
        clonedBranchName,
135
        repo_resource,
136
        promotion_branch
137
      );
138
    } else {
139
      // Full sync operations
140
      result = await executeSyncOperation(
141
        workspace_id,
142
        repository_path,
143
        settings_json,
144
        fullPath,
145
        repo_resource,
146
        pull,
147
        dry_run,
148
        clonedBranchName,
149
        promotion_branch
150
      );
151
    }
152

153
    console.log("DEBUG: Main execution completed successfully", result);
154
  } catch (error) {
155
    console.log("DEBUG: Error in main execution:", error);
156
    throw error;
157
  } finally {
158
    // Cleanup: remove safe.directory config
159
    if (safeDirectoryPath) {
160
      try {
161
        await sh_run(undefined, "git", "config", "--global", "--unset", "safe.directory", safeDirectoryPath);
162
      } catch (e) {
163
        console.log(`Warning: Could not unset safe.directory config: ${e}`);
164
      }
165
    }
166
    console.log("DEBUG: Changing back to original directory:", cwd);
167
    process.chdir(cwd);
168
  }
169

170
  // Return the result directly from the CLI command
171
  return result;
172
}
173

174
async function executeSettingsOperation(
175
  workspace_id: string,
176
  repository_path: string,
177
  settings_json: string | undefined,
178
  fullPath: string,
179
  pull: boolean,
180
  dry_run: boolean,
181
  clonedBranchName: string,
182
  repo_resource?: any,
183
  promotion_branch?: string
184
) {
185
  if (pull) {
186
    console.log("DEBUG: Executing pull branch (wmill.yaml only)");
187
    // Frontend PULL = Git→Windmill = CLI settings push (push wmill.yaml from Git to Windmill)
188
    if (dry_run) {
189
      return await executeCliSettingsPushDryRun(
190
        workspace_id,
191
        repository_path,
192
        settings_json,
193
        fullPath,
194
        promotion_branch
195
      );
196
    } else {
197
      // For actual pull, we still just want to return the git repo settings_json
198
      return await executeCliSettingsPushDryRun(
199
        workspace_id,
200
        repository_path,
201
        settings_json,
202
        fullPath,
203
        promotion_branch
204
      );
205
    }
206
  } else {
207
    console.log("DEBUG: Executing push branch (wmill.yaml only)");
208
    // Frontend PUSH = Windmill→Git = CLI settings pull (pull from Windmill to generate wmill.yaml)
209
    if (dry_run) {
210
      return await executeCliSettingsPullDryRun(
211
        workspace_id,
212
        repository_path,
213
        settings_json,
214
        fullPath,
215
        promotion_branch
216
      );
217
    } else {
218
      if (!settings_json) throw Error("settings_json required in this mode");
219
      return await executeCliSettingsPull(
220
        workspace_id,
221
        repository_path,
222
        fullPath,
223
        settings_json,
224
        repo_resource,
225
        promotion_branch
226
      );
227
    }
228
  }
229
}
230

231
async function executeSyncOperation(
232
  workspace_id: string,
233
  repository_path: string,
234
  settings_json: string | undefined,
235
  fullPath: string,
236
  repo_resource: any,
237
  pull: boolean,
238
  dry_run: boolean,
239
  clonedBranchName: string,
240
  promotion_branch?: string
241
) {
242
  if (pull) {
243
    console.log("DEBUG: Executing sync pull", { dry_run });
244
    // Frontend PULL = Git→Windmill = CLI sync push
245
    if (dry_run) {
246
      return await executeCliSyncPushDryRun(
247
        workspace_id,
248
        repository_path,
249
        settings_json,
250
        fullPath,
251
        promotion_branch
252
      );
253
    } else {
254
      return await executeCliSyncPush(
255
        workspace_id,
256
        repository_path,
257
        repo_resource,
258
        settings_json
259
      );
260
    }
261
  } else {
262
    console.log("DEBUG: Executing sync push", { dry_run });
263
    // Frontend PUSH = Windmill→Git = CLI sync pull
264
    if (dry_run) {
265
      return await executeCliSyncPullDryRun(
266
        workspace_id,
267
        repository_path,
268
        settings_json,
269
        fullPath
270
      );
271
    } else {
272
      return await executeCliSyncPull(
273
        workspace_id,
274
        repository_path,
275
        repo_resource,
276
        clonedBranchName,
277
        settings_json
278
      );
279
    }
280
  }
281
}
282

283
// Use existing CLI settings pull --dry-run (from settings.ts)
284
async function executeCliSettingsPullDryRun(
285
  workspace_id: string,
286
  repository_path: string,
287
  settings_json?: string,
288
  repoPath?: string,
289
  promotion_branch?: string
290
) {
291
  try {
292
    // Check if wmill.yaml exists in the git repo
293
    let wmillYamlExists = existsSync("wmill.yaml");
294
    if (!wmillYamlExists) {
295
      console.log(
296
        "DEBUG: No wmill.yaml found, will create with repository settings"
297
      );
298

299
      // For new repositories, don't show a confusing diff between defaults and settings
300
      // Just return a simple success message indicating the file will be created
301
      return {
302
        success: true,
303
        hasChanges: true,
304
        message: "wmill.yaml will be created with repository settings",
305
        isInitialSetup: true,
306
        repository: repository_path
307
      };
308
    }
309

310
    // Use gitsync-settings diff for UI settings comparison
311
    // This shows what would change in Git if we pulled from Windmill
312
    const args = [
313
      undefined,
314
      "gitsync-settings",
315
      "pull",
316
      "--diff",
317
      "--repository",
318
      repository_path,
319
      "--workspace",
320
      workspace_id,
321
      "--override",
322
    ];
323

324
    if (settings_json) {
325
      args.push("--with-backend-settings", settings_json);
326
    }
327

328
    if (promotion_branch) {
329
      args.push("--promotion", promotion_branch);
330
    }
331

332
    args.push(
333
      "--token",
334
      process.env["WM_TOKEN"] ?? "",
335
      "--base-url",
336
      process.env["BASE_URL"] + "/",
337
      "--json-output"
338
    );
339

340
    return await wmill_run(...args);
341
  } catch (error) {
342
    const errorMessage = error.message || error.toString();
343
    // Check if this is an empty repository error (no commits/branches yet)
344
    if ((errorMessage.includes("src refspec") && errorMessage.includes("does not match any")) ||
345
        (errorMessage.includes("Remote branch") && errorMessage.includes("not found"))) {
346
      console.log("DEBUG: Empty repository detected - branch doesn't exist or no commits");
347
      return {
348
        success: true,
349
        hasChanges: true,
350
        message: "Empty repository detected - requires initialization",
351
        isInitialSetup: true,
352
        repository: repository_path
353
      };
354
    }
355
    throw new Error("Settings pull dry run failed: " + errorMessage);
356
  }
357
}
358

359
// Use existing CLI settings push --dry-run (from settings.ts)
360
async function executeCliSettingsPushDryRun(
361
  workspace_id: string,
362
  repository_path: string,
363
  settings_json?: string,
364
  repoPath?: string,
365
  promotion_branch?: string
366
) {
367
  try {
368
    console.log("DEBUG: Settings push dry run with JSON:", settings_json);
369

370
    // Check if wmill.yaml exists in the git repo
371
    if (!existsSync("wmill.yaml")) {
372
      console.log("DEBUG: No wmill.yaml found in git repository");
373
      throw new Error(
374
        "No wmill.yaml found in the git repository. Please initialize the repository first by pushing settings from Windmill to git."
375
      );
376
    }
377

378
    // Use gitsync-settings push for UI settings comparison
379
    const args = [
380
      undefined,
381
      "gitsync-settings",
382
      "push",
383
      "--diff",
384
      "--repository",
385
      repository_path,
386
      "--workspace",
387
      workspace_id,
388
    ];
389

390
    if (settings_json) {
391
      args.push("--with-backend-settings", settings_json);
392
    }
393

394
    if (promotion_branch) {
395
      args.push("--promotion", promotion_branch);
396
    }
397

398
    args.push(
399
      "--token",
400
      process.env["WM_TOKEN"] ?? "",
401
      "--base-url",
402
      process.env["BASE_URL"] + "/",
403
      "--json-output"
404
    );
405

406
    return await wmill_run(...args);
407
  } catch (error) {
408
    throw new Error("Settings push dry run failed: " + error.message);
409
  }
410
}
411

412
// Use existing CLI settings pull (from settings.ts)
413
async function executeCliSettingsPull(
414
  workspace_id: string,
415
  repository_path: string,
416
  repoPath: string,
417
  settings_json: string,
418
  clonedBranchName: string,
419
  repo_resource?: any,
420
  promotion_branch?: string
421
) {
422
  console.log("DEBUG: executeCliSettingsPull started", {
423
    workspace_id,
424
    repository_path,
425
    repoPath,
426
    settings_json: settings_json ? "PROVIDED" : "NOT_PROVIDED",
427
  });
428

429
  try {
430
    // Check if wmill.yaml exists in the git repo
431
    let wmillYamlExists = existsSync("wmill.yaml");
432
    if (!wmillYamlExists) {
433
      console.log(
434
        "DEBUG: No wmill.yaml found, initializing with default settings"
435
      );
436

437
      // Run wmill init with default settings
438
      await wmill_run(
439
        null,
440
        "init",
441
        "--use-default",
442
        "--token",
443
        process.env["WM_TOKEN"] ?? "",
444
        "--base-url",
445
        process.env["BASE_URL"] + "/",
446
        "--workspace",
447
        workspace_id
448
      );
449

450
      console.log("DEBUG: wmill.yaml initialized with defaults");
451
    }
452

453
    console.log("DEBUG: Running CLI gitsync-settings pull command...");
454
    const args = [
455
      null,
456
      "gitsync-settings",
457
      "pull",
458
      "--repository",
459
      repository_path,
460
      "--workspace",
461
      workspace_id,
462
      wmillYamlExists ? "--override" : "--replace",
463
    ];
464

465
    if (settings_json) {
466
      args.push("--with-backend-settings", settings_json);
467
    }
468

469
    if (promotion_branch) {
470
      args.push("--promotion", promotion_branch);
471
    }
472

473
    args.push(
474
      "--token",
475
      process.env["WM_TOKEN"] ?? "",
476
      "--base-url",
477
      process.env["BASE_URL"] + "/"
478
    );
479

480
    const res = await wmill_run(...args);
481
    console.log("DEBUG: CLI settings pull result:", res);
482

483
    console.log("DEBUG: Starting git push process...");
484
    const pushResult = await git_push(
485
      "Update wmill.yaml via settings",
486
      repo_resource || { gpg_key: null },
487
      clonedBranchName
488
    );
489
    console.log("DEBUG: Git push completed:", pushResult);
490

491
    return { success: true, message: "Settings pushed to git successfully" };
492
  } catch (error) {
493
    console.log("DEBUG: Error in executeCliSettingsPull:", error);
494
    const errorMessage = error.message || error.toString();
495
    throw new Error("Settings pull failed: " + errorMessage);
496
  }
497
}
498

499
// Use existing CLI sync pull --dry-run
500
async function executeCliSyncPullDryRun(
501
  workspace_id: string,
502
  repository_path: string,
503
  settings_json?: string,
504
  repoPath?: string
505
) {
506
  try {
507
    console.log("DEBUG: executeCliSyncPullDryRun started", {
508
      workspace_id,
509
      repository_path,
510
      settings_json: settings_json ? "PROVIDED" : "NOT_PROVIDED",
511
    });
512

513
    // Check if wmill.yaml exists in the git repo
514
    let wmillYamlExists = existsSync("wmill.yaml");
515
    let settingsDiffResult = {}
516
    if (!wmillYamlExists) {
517
      console.log(
518
        "DEBUG: No wmill.yaml found, initializing with default settings"
519
      );
520

521
      // Run wmill init with default settings
522
      await wmill_run(
523
        null,
524
        "init",
525
        "--use-default",
526
        "--token",
527
        process.env["WM_TOKEN"] ?? "",
528
        "--base-url",
529
        process.env["BASE_URL"] + "/",
530
        "--workspace",
531
        workspace_id
532
      );
533

534
      console.log("DEBUG: wmill.yaml initialized with defaults");
535

536

537
      // Step 1: Check if wmill.yaml settings would change with gitsync-settings pull --diff
538
      console.log("DEBUG: Checking wmill.yaml changes with gitsync-settings pull --diff");
539
      const settingsDiffArgs = [
540
        null,
541
        "gitsync-settings",
542
        "pull",
543
        "--diff",
544
        "--repository",
545
        repository_path,
546
        "--workspace",
547
        workspace_id,
548
        "--replace",
549
        "--json-output"
550
      ];
551

552
      if (settings_json) {
553
        settingsDiffArgs.push("--with-backend-settings", settings_json);
554
      }
555

556

557
      settingsDiffArgs.push(
558
        "--token",
559
        process.env["WM_TOKEN"] ?? "",
560
        "--base-url",
561
        process.env["BASE_URL"] + "/"
562
      );
563

564
      settingsDiffResult = await wmill_run(...settingsDiffArgs);
565
      console.log("DEBUG: Settings diff result:", settingsDiffResult);
566

567
      // Step 2: Pull settings from backend (actual update)
568
      console.log("DEBUG: Pulling git-sync settings from backend");
569
      const settingsArgs = [
570
        null,
571
        "gitsync-settings",
572
        "pull",
573
        "--repository",
574
        repository_path,
575
        "--workspace",
576
        workspace_id,
577
        "--replace",
578
      ];
579

580
      if (settings_json) {
581
        settingsArgs.push("--with-backend-settings", settings_json);
582
      }
583

584

585
      settingsArgs.push(
586
        "--token",
587
        process.env["WM_TOKEN"] ?? "",
588
        "--base-url",
589
        process.env["BASE_URL"] + "/"
590
      );
591

592
      await wmill_run(...settingsArgs);
593
      console.log("DEBUG: Git-sync settings pulled successfully");
594
    }
595

596
    const args = [
597
      "sync",
598
      "pull",
599
      "--dry-run",
600
      "--json-output",
601
      "--workspace",
602
      workspace_id,
603
      "--token",
604
      process.env["WM_TOKEN"] ?? "",
605
      "--base-url",
606
      process.env["BASE_URL"] + "/",
607
      "--repository",
608
      repository_path,
609
    ];
610

611
    const result = await wmill_run(null, ...args);
612

613
    // Step 3: Check for wmill.yaml changes using CLI hasChanges flag
614
    if (!result.changes) {
615
      result.changes = [];
616
    }
617

618
    const hasWmillYaml = result.changes.some(change => change.path === 'wmill.yaml');
619
    if (!hasWmillYaml) {
620
      if (!wmillYamlExists) {
621
        // We created it during init
622
        result.total = result.total + 1
623
        result.changes.push({ type: 'added', path: 'wmill.yaml' });
624
      } else if (settingsDiffResult?.hasChanges) {
625
        // Settings would change - add as modified using CLI detection
626
        console.log("DEBUG: Adding wmill.yaml as modified due to settings changes");
627
        result.total = result.total + 1
628
        result.changes.push({ type: 'edited', path: 'wmill.yaml' });
629
      }
630
    }
631

632
    return result;
633
  } catch (error) {
634
    throw new Error("Sync pull dry run failed: " + error.message);
635
  }
636
}
637

638
// Use existing CLI sync push --dry-run
639
async function executeCliSyncPushDryRun(
640
  workspace_id: string,
641
  repository_path: string,
642
  settings_json?: string,
643
  repoPath?: string,
644
  promotion_branch?: string
645
) {
646
  try {
647
    // Step 1: Check if wmill.yaml settings would change
648
    console.log("DEBUG: Checking wmill.yaml changes with gitsync-settings push --diff");
649
    const settingsArgs = [
650
      undefined,
651
      "gitsync-settings",
652
      "push",
653
      "--diff",
654
      "--repository",
655
      repository_path,
656
      "--workspace",
657
      workspace_id,
658
      "--json-output"
659
    ];
660

661
    if (settings_json) {
662
      settingsArgs.push("--with-backend-settings", settings_json);
663
    }
664

665
    if (promotion_branch) {
666
      settingsArgs.push("--promotion", promotion_branch);
667
    }
668

669
    settingsArgs.push(
670
      "--token",
671
      process.env["WM_TOKEN"] ?? "",
672
      "--base-url",
673
      process.env["BASE_URL"] + "/"
674
    );
675

676
    const settingsDiffResult = await wmill_run(...settingsArgs);
677
    console.log("DEBUG: Settings diff result:", settingsDiffResult);
678

679
    // Step 2: Check resource changes with sync push --dry-run
680
    console.log("DEBUG: Checking resource changes with sync push --dry-run");
681
    const syncArgs = [
682
      "sync",
683
      "push",
684
      "--dry-run",
685
      "--json-output",
686
      "--workspace",
687
      workspace_id,
688
      "--token",
689
      process.env["WM_TOKEN"] ?? "",
690
      "--base-url",
691
      process.env["BASE_URL"] + "/",
692
      "--repository",
693
      repository_path,
694
    ];
695

696
    const syncResult = await wmill_run(null, ...syncArgs);
697
    console.log("DEBUG: Sync result:", syncResult);
698

699
    // Step 3: Combine results - add wmill.yaml as modified if settings would change
700
    if (!syncResult.changes) {
701
      syncResult.changes = [];
702
    }
703

704
    if (settingsDiffResult?.hasChanges) {
705
      console.log("DEBUG: Adding wmill.yaml as modified due to settings changes");
706
      syncResult.settingsDiffResult = settingsDiffResult
707
    }
708

709
    return syncResult;
710
  } catch (error) {
711
    throw new Error("Sync push dry run failed: " + error.message);
712
  }
713
}
714

715
// Use existing CLI sync pull
716
async function executeCliSyncPull(
717
  workspace_id: string,
718
  repository_path: string,
719
  repo_resource: any,
720
  clonedBranchName: string,
721
  settings_json?: string
722
) {
723
  try {
724
    // Let the CLI handle cleanup - it knows best how to manage the local folder
725
    // Initialize wmill.yaml if needed
726
    console.log("DEBUG: Initializing with default settings");
727

728
    // Check if wmill.yaml exists in the git repo
729
    let wmillYamlExists = existsSync("wmill.yaml");
730
    let settingsDiffResult = {}
731
    if (!wmillYamlExists) {
732
      console.log(
733
        "DEBUG: No wmill.yaml found, initializing with default settings"
734
      );
735

736
      // Run wmill init with default settings
737
      await wmill_run(
738
        null,
739
        "init",
740
        "--use-default",
741
        "--token",
742
        process.env["WM_TOKEN"] ?? "",
743
        "--base-url",
744
        process.env["BASE_URL"] + "/",
745
        "--workspace",
746
        workspace_id
747
      );
748

749
      console.log("DEBUG: wmill.yaml initialized with defaults");
750

751

752
      // Step 1: Check if wmill.yaml settings would change with gitsync-settings pull --diff
753
      console.log("DEBUG: Checking wmill.yaml changes with gitsync-settings pull --diff");
754
      const settingsDiffArgs = [
755
        null,
756
        "gitsync-settings",
757
        "pull",
758
        "--diff",
759
        "--repository",
760
        repository_path,
761
        "--workspace",
762
        workspace_id,
763
        "--replace",
764
        "--json-output"
765
      ];
766

767
      if (settings_json) {
768
        settingsDiffArgs.push("--with-backend-settings", settings_json);
769
      }
770

771
      settingsDiffArgs.push(
772
        "--token",
773
        process.env["WM_TOKEN"] ?? "",
774
        "--base-url",
775
        process.env["BASE_URL"] + "/"
776
      );
777

778
      settingsDiffResult = await wmill_run(...settingsDiffArgs);
779
      console.log("DEBUG: Settings diff result:", settingsDiffResult);
780

781
      // Step 2: Pull settings from backend (actual update)
782
      console.log("DEBUG: Pulling git-sync settings from backend");
783
      const settingsArgs = [
784
        null,
785
        "gitsync-settings",
786
        "pull",
787
        "--repository",
788
        repository_path,
789
        "--workspace",
790
        workspace_id,
791
        "--replace",
792
      ];
793

794
      if (settings_json) {
795
        settingsArgs.push("--with-backend-settings", settings_json);
796
      }
797

798
      settingsArgs.push(
799
        "--token",
800
        process.env["WM_TOKEN"] ?? "",
801
        "--base-url",
802
        process.env["BASE_URL"] + "/"
803
      );
804

805
      await wmill_run(...settingsArgs);
806
      console.log("DEBUG: Git-sync settings pulled successfully");
807
    }
808

809
    const args = [
810
      "sync",
811
      "pull",
812
      "--yes",
813
      "--workspace",
814
      workspace_id,
815
      "--token",
816
      process.env["WM_TOKEN"] ?? "",
817
      "--base-url",
818
      process.env["BASE_URL"] + "/",
819
      "--repository",
820
      repository_path,
821
    ];
822

823
    await wmill_run(null, ...args);
824

825
    // Commit and push
826
    await git_push(
827
      "Initialize windmill sync repo",
828
      repo_resource,
829
      clonedBranchName
830
    );
831
    await delete_pgp_keys();
832

833
    return { success: true, message: "CLI sync pull completed" };
834
  } catch (error) {
835
    const errorMessage = error.message || error.toString();
836
    throw new Error("Sync pull failed: " + errorMessage);
837
  }
838
}
839

840
// Use existing CLI sync push
841
async function executeCliSyncPush(
842
  workspace_id: string,
843
  repository_path: string,
844
  repo_resource: any,
845
  settings_json?: string
846
) {
847
  try {
848
    // Step 1: Get git repo settings using gitsync-settings push --diff
849
    console.log("DEBUG: Getting git repo settings with gitsync-settings push --diff");
850
    const settingsArgs = [
851
      undefined,
852
      "gitsync-settings",
853
      "push",
854
      "--diff",
855
      "--repository",
856
      repository_path,
857
      "--workspace",
858
      workspace_id,
859
      "--json-output"
860
    ];
861

862
    settingsArgs.push(
863
      "--token",
864
      process.env["WM_TOKEN"] ?? "",
865
      "--base-url",
866
      process.env["BASE_URL"] + "/"
867
    );
868

869
    const settingsResult = await wmill_run(...settingsArgs);
870
    console.log("DEBUG: Settings result:", settingsResult);
871

872
    // Step 2: Run normal sync push
873
    console.log("DEBUG: Running sync push");
874
    const syncArgs = [
875
      "sync",
876
      "push",
877
      "--yes",
878
      "--json-output",
879
      "--workspace",
880
      workspace_id,
881
      "--token",
882
      process.env["WM_TOKEN"] ?? "",
883
      "--base-url",
884
      process.env["BASE_URL"] + "/",
885
      "--repository",
886
      repository_path,
887
    ];
888

889
    const syncResult = await wmill_run(null, ...syncArgs);
890
    console.log("DEBUG: Sync result:", syncResult);
891

892
    // Step 3: Return combined result with settings_json for UI application
893
    const result = {
894
      ...syncResult,
895
      success: true,
896
      message: "CLI sync push completed",
897
      settings_json: settingsResult?.local
898
    };
899

900
    console.log("DEBUG: Combined result with settings_json:", result);
901
    return result;
902
  } catch (error) {
903
    throw new Error("Sync push failed: " + error.message);
904
  }
905
}
906

907
function get_fork_branch_name(w_id: string, originalBranch: string): string {
908
  if (w_id.startsWith(FORKED_WORKSPACE_PREFIX)) {
909
    return w_id.replace(FORKED_WORKSPACE_PREFIX, `${FORKED_BRANCH_PREFIX}/${originalBranch}/`);
910
  }
911
  return w_id;
912
}
913

914
// Clone repo and optionally enter subfolder
915
async function git_clone(
916
  cwd: string,
917
  repo_resource: any,
918
  isPull: boolean,
919
  workspace_id: string,
920
  cloneRefOverride?: string
921
): Promise<{ repo_name: string; safeDirectoryPath: string; clonedBranchName: string }> {
922
  let repo_url = repo_resource.url;
923
  const subfolder = repo_resource.folder ?? "";
924
  // A clone-ref override (e.g. a PR head branch for diff previews) takes
925
  // precedence over the resource's configured branch.
926
  let branch = (cloneRefOverride && cloneRefOverride !== "") ? cloneRefOverride : (repo_resource.branch ?? "");
927
  const repo_name = basename(repo_url, ".git");
928

929
  const azureMatch = repo_url.match(/AZURE_DEVOPS_TOKEN\((?<url>.+)\)/);
930
  if (azureMatch) {
931
    console.log("Fetching Azure DevOps access token...");
932
    const azureResource = await wmillclient.getResource(azureMatch.groups.url);
933
    const response = await fetch(
934
      `https://login.microsoftonline.com/${azureResource.azureTenantId}/oauth2/token`,
935
      {
936
        method: "POST",
937
        body: new URLSearchParams({
938
          client_id: azureResource.azureClientId,
939
          client_secret: azureResource.azureClientSecret,
940
          grant_type: "client_credentials",
941
          resource: "499b84ac-1321-427f-aa17-267ca6975798/.default",
942
        }),
943
      }
944
    );
945
    const { access_token } = await response.json();
946
    repo_url = repo_url.replace(azureMatch[0], access_token);
947
  }
948

949
  const args = ["clone", "--quiet", "--depth", "1"];
950
  if (workspace_id.startsWith(FORKED_WORKSPACE_PREFIX)) args.push("--no-single-branch");
951
  if (subfolder !== "") args.push("--sparse");
952
  if (branch !== "") args.push("--branch", branch);
953
  args.push(repo_url, repo_name);
954

955
  try {
956
    await sh_run(-1, "git", ...args);
957
  } catch (error) {
958
    const errorString = error.toString();
959
    // If cloning failed because the branch doesn't exist (empty repo case)
960
    if (branch !== "" && errorString.includes("Remote branch") && errorString.includes("not found")) {
961
      console.log(`DEBUG: Branch ${branch} not found, cloning without branch specification for empty repo`);
962
      // Retry clone without branch specification
963
      const fallbackArgs = ["clone", "--quiet", "--depth", "1"];
964
      if (subfolder !== "") fallbackArgs.push("--sparse");
965
      fallbackArgs.push(repo_url, repo_name);
966
      await sh_run(-1, "git", ...fallbackArgs);
967
    } else {
968
      throw error;
969
    }
970
  }
971

972
  const fullPath = join(cwd, repo_name);
973
  process.chdir(fullPath);
974

975
  const safeDirectoryPath = fullPath;
976
  // Add safe.directory to handle dubious ownership in cloned repo
977
  try {
978
    await sh_run(undefined, "git", "config", "--global", "--add", "safe.directory", process.cwd());
979
  } catch (e) {
980
    console.log(`Warning: Could not add safe.directory config: ${e}`);
981
  }
982

983
  if (subfolder !== "") {
984
    await sh_run(undefined, "git", "sparse-checkout", "add", subfolder);
985
    const subfolderPath = join(fullPath, subfolder);
986

987
    if (!existsSync(subfolderPath)) {
988
      if (isPull) {
989
        // When pulling FROM git, subfolder must exist
990
        throw new Error(`Subfolder ${subfolder} does not exist.`);
991
      } else {
992
        // When pushing TO git, create subfolder if it doesn't exist
993
        console.log(
994
          `DEBUG: Creating subfolder ${subfolder} for push operation`
995
        );
996
        await sh_run(undefined, "mkdir", "-p", subfolderPath);
997
      }
998
    }
999

1000
    process.chdir(subfolderPath);
1001
  }
1002

1003
  let clonedBranchName: string;
1004
  try {
1005
    clonedBranchName = (await sh_run(undefined, "git", "rev-parse", "--abbrev-ref", "HEAD")).trim();
1006
  } catch (error) {
1007
    // Empty repository - no HEAD yet, use the branch we tried to clone or default
1008
    console.log("DEBUG: No HEAD found (empty repository), using target branch:", branch || "main");
1009
    clonedBranchName = branch || "main";
1010
  }
1011
  if (workspace_id.startsWith(FORKED_WORKSPACE_PREFIX)) {
1012
    clonedBranchName = get_fork_branch_name(workspace_id, clonedBranchName);
1013
    try {
1014
      await sh_run(undefined, "git", "checkout", "-b", clonedBranchName);
1015
    } catch {
1016
      console.info("Could not create branch, trying to switch to existing branch");
1017
      await sh_run(undefined, "git", "checkout", clonedBranchName);
1018
    }
1019
  }
1020

1021
  return { repo_name, safeDirectoryPath, clonedBranchName };
1022
}
1023

1024
// Shell runner with secret redaction
1025
async function sh_run(
1026
  secret_position: number | undefined,
1027
  cmd: string,
1028
  ...args: string[]
1029
) {
1030
  const nargs = secret_position != undefined ? args.slice() : args;
1031
  if (secret_position && secret_position < 0)
1032
    secret_position = nargs.length - 1 + secret_position;
1033

1034
  let secret: string | undefined = undefined;
1035
  if (secret_position != undefined) {
1036
    nargs[secret_position] = "***";
1037
    secret = args[secret_position];
1038
  }
1039

1040
  console.log(`DEBUG: Running shell command: '${cmd} ${nargs.join(" ")} ...'`);
1041
  try {
1042
    const { stdout, stderr } = await exec(`${cmd} ${args.join(" ")}`);
1043
    if (stdout.length > 0) {
1044
      console.log("DEBUG: Shell stdout:", stdout);
1045
    }
1046
    if (stderr.length > 0) {
1047
      console.log("DEBUG: Shell stderr:", stderr);
1048
    }
1049
    console.log(`DEBUG: Shell command completed successfully: ${cmd}`);
1050
    return stdout;
1051
  } catch (error: any) {
1052
    let errorString = error.toString();
1053
    if (secret) errorString = errorString.replace(secret, "***");
1054
    console.log(`DEBUG: Shell command FAILED: ${cmd}`, errorString);
1055
    throw new Error(
1056
      `SH command '${cmd} ${nargs.join(" ")}' failed: ${errorString}`
1057
    );
1058
  }
1059
}
1060

1061
async function wmill_run(
1062
  secret_position: number | undefined | null,
1063
  ...cmd: string[]
1064
) {
1065
  cmd = cmd.filter((elt) => elt !== "");
1066
  const cmd2 = cmd.slice();
1067
  if (secret_position) {
1068
    cmd2[secret_position] = "***";
1069
  }
1070
  console.log(`DEBUG: Running CLI command: 'wmill ${cmd2.join(" ")} ...'`);
1071

1072
  // Capture CLI output to parse JSON response
1073
  const originalLog = console.log;
1074
  let cliOutput = "";
1075
  console.log = (msg: string) => {
1076
    cliOutput += msg + "\n";
1077
    originalLog(msg);
1078
  };
1079

1080
  try {
1081
    await wmill.parse(cmd);
1082
    console.log = originalLog;
1083
    console.log("DEBUG: CLI command executed successfully");
1084
  } catch (error) {
1085
    console.log = originalLog;
1086
    console.log("DEBUG: CLI command execution failed:", error);
1087
    throw error;
1088
  }
1089
  // END capture log
1090

1091
  console.log("DEBUG: Captured CLI output length:", cliOutput.length);
1092
  console.log("DEBUG: Raw CLI output:", cliOutput);
1093

1094
  try {
1095
    console.log("DEBUG: Attempting to parse CLI output as JSON...");
1096

1097
    // Find the first occurrence of '{' which indicates the start of JSON
1098
    const jsonStartIndex = cliOutput.indexOf('{');
1099
    if (jsonStartIndex === -1) {
1100
      console.log("DEBUG: No JSON found in CLI output");
1101
      return {};
1102
    }
1103

1104
    // Extract everything from the first '{' to the end
1105
    const jsonString = cliOutput.substring(jsonStartIndex).trim();
1106
    console.log("DEBUG: Extracted JSON string:", jsonString);
1107

1108
    const res = JSON.parse(jsonString);
1109
    console.log("DEBUG: Successfully parsed JSON result:", res);
1110
    return res;
1111
  } catch (e) {
1112
    console.log("DEBUG: Failed to parse CLI output as JSON:", e);
1113
    console.log("DEBUG: Returning empty object");
1114
    return {};
1115
  }
1116
}
1117

1118
async function git_push(
1119
  commit_msg: string,
1120
  repo_resource: any,
1121
  target_branch: string
1122
) {
1123
  console.log("DEBUG: git_push started", {
1124
    commit_msg,
1125
    target_branch,
1126
    has_gpg_key: !!repo_resource.gpg_key,
1127
  });
1128

1129
  const user_email = process.env["WM_EMAIL"] ?? "";
1130
  const user_name = process.env["WM_USERNAME"] ?? "";
1131

1132
  if (repo_resource.gpg_key) {
1133
    console.log("DEBUG: Setting up GPG signing...");
1134
    await set_gpg_signing_secret(repo_resource.gpg_key);
1135
    // Configure git with GPG key email for signing
1136
    console.log("DEBUG: Setting git user config with GPG key email...");
1137
    await sh_run(
1138
      undefined,
1139
      "git",
1140
      "config",
1141
      "user.email",
1142
      repo_resource.gpg_key.email
1143
    );
1144
    await sh_run(undefined, "git", "config", "user.name", user_name);
1145
  } else {
1146
    console.log("DEBUG: Setting git user config...");
1147
    await sh_run(undefined, "git", "config", "user.email", user_email);
1148
    await sh_run(undefined, "git", "config", "user.name", user_name);
1149
  }
1150

1151
  try {
1152
    console.log("DEBUG: Adding files to git...");
1153
    await sh_run(undefined, "git", "add", "-A", ":!./.config");
1154
    console.log("DEBUG: Files added successfully");
1155
  } catch (error) {
1156
    console.log("DEBUG: Unable to stage files:", error);
1157
  }
1158

1159
  try {
1160
    console.log("DEBUG: Checking for changes to commit...");
1161
    await sh_run(undefined, "git", "diff", "--cached", "--quiet");
1162
    console.log("DEBUG: No changes detected, returning no changes status");
1163
    return { status: "no changes pushed" };
1164
  } catch {
1165
    console.log("DEBUG: Changes detected, proceeding with commit...");
1166
    // Always use --author to set consistent authorship (matching sync script behavior)
1167
    await sh_run(
1168
      undefined,
1169
      "git",
1170
      "commit",
1171
      "--author",
1172
      `"${user_name} <${user_email}>"`,
1173
      "-m",
1174
      `"${commit_msg}"`
1175
    );
1176
    console.log("DEBUG: Commit completed successfully");
1177

1178
    try {
1179
      console.log("DEBUG: Attempting first push...");
1180
      await sh_run(undefined, "git", "push", "--set-upstream", "origin", target_branch);
1181
      console.log("DEBUG: First push succeeded");
1182
      return { status: "changes pushed" };
1183
    } catch (e) {
1184
      const errorString = e.toString();
1185

1186
      // Check if this is an empty repository error (no commits/branches yet)
1187
      if (errorString.includes("src refspec") && errorString.includes("does not match any")) {
1188
        console.log("DEBUG: Empty repository detected - setting up initial branch and push");
1189
        try {
1190
          // For empty repositories, we need to set up the branch properly
1191
          // Set the current branch to the target branch name
1192
          await sh_run(undefined, "git", "branch", "-M", target_branch);
1193
          console.log(`DEBUG: Set branch to ${target_branch}`);
1194

1195
          // Push with upstream to create the initial branch
1196
          await sh_run(undefined, "git", "push", "-u", "origin", target_branch);
1197
          console.log(`DEBUG: Initial push to ${target_branch} branch succeeded`);
1198
          return { status: "changes pushed" };
1199
        } catch (initialPushError) {
1200
          console.log("DEBUG: Initial push setup failed:", initialPushError);
1201
          throw initialPushError;
1202
        }
1203
      }
1204

1205
      console.log("DEBUG: First push failed, attempting rebase and retry:", e);
1206
      try {
1207
        await sh_run(undefined, "git", "pull", "--rebase");
1208
        console.log("DEBUG: Rebase completed, attempting second push...");
1209
        await sh_run(undefined, "git", "push", "--set-upstream", "origin", target_branch);
1210
        console.log("DEBUG: Second push succeeded");
1211
        return { status: "changes pushed" };
1212
      } catch (retryError) {
1213
        const retryErrorString = retryError.toString();
1214

1215
        // Check if the retry failed due to empty repository (refs/heads/main doesn't exist)
1216
        if (retryErrorString.includes("no such ref was fetched") ||
1217
            retryErrorString.includes("couldn't find remote ref")) {
1218
          console.log("DEBUG: Retry failed due to empty repository - setting up initial branch and push");
1219
          try {
1220
            // Set the current branch to the target branch name
1221
            await sh_run(undefined, "git", "branch", "-M", target_branch);
1222
            console.log(`DEBUG: Set branch to ${target_branch}`);
1223

1224
            // Push with upstream to create the initial branch
1225
            await sh_run(undefined, "git", "push", "-u", "origin", target_branch);
1226
            console.log(`DEBUG: Initial push to ${target_branch} branch after retry succeeded`);
1227
            return { status: "changes pushed" };
1228
          } catch (finalPushError) {
1229
            console.log("DEBUG: Final push attempt failed:", finalPushError);
1230
            throw finalPushError;
1231
          }
1232
        }
1233

1234
        console.log("DEBUG: Second push also failed:", retryError);
1235
        throw retryError;
1236
      }
1237
    }
1238
  }
1239
}
1240

1241
async function set_gpg_signing_secret(gpg_key: GpgKey) {
1242
  const gpg_path = "/tmp/gpg";
1243
  await sh_run(undefined, "mkdir", "-p", gpg_path);
1244
  await sh_run(undefined, "chmod", "700", gpg_path);
1245
  process.env.GNUPGHOME = gpg_path;
1246

1247
  const formatted = gpg_key.private_key.replace(
1248
    /(-----BEGIN PGP PRIVATE KEY BLOCK-----)([\s\S]*?)(-----END PGP PRIVATE KEY BLOCK-----)/,
1249
    (_, header, body, footer) =>
1250
      header + "\n\n" + body.replace(/ ([^\s])/g, "\n$1").trim() + "\n" + footer
1251
  );
1252

1253
  try {
1254
    await sh_run(
1255
      1,
1256
      "bash",
1257
      "-c",
1258
      `cat <<EOF | gpg --batch --import \n${formatted}\nEOF`
1259
    );
1260
  } catch {
1261
    throw new Error("Failed to import GPG key!");
1262
  }
1263

1264
  const keyList = await sh_run(
1265
    undefined,
1266
    "gpg",
1267
    "--list-secret-keys",
1268
    "--with-colons",
1269
    "--keyid-format=long"
1270
  );
1271
  const match = keyList.match(
1272
    /sec:[^:]*:[^:]*:[^:]*:([A-F0-9]+):.*\nfpr:::::::::([A-F0-9]{40}):/
1273
  );
1274
  if (!match) throw new Error("Failed to extract GPG Key ID and Fingerprint");
1275

1276
  const keyId = match[1];
1277
  gpgFingerprint = match[2];
1278

1279
  if (gpg_key.passphrase) {
1280
    await sh_run(
1281
      1,
1282
      "bash",
1283
      "-c",
1284
      `echo dummy | gpg --batch --pinentry-mode loopback --passphrase '${gpg_key.passphrase}' --status-fd=2 -bsau ${keyId}`
1285
    );
1286
  }
1287

1288
  await sh_run(undefined, "git", "config", "user.signingkey", keyId);
1289
  await sh_run(undefined, "git", "config", "commit.gpgsign", "true");
1290
}
1291

1292
async function delete_pgp_keys() {
1293
  if (gpgFingerprint) {
1294
    await sh_run(
1295
      undefined,
1296
      "gpg",
1297
      "--batch",
1298
      "--yes",
1299
      "--pinentry-mode",
1300
      "loopback",
1301
      "--delete-secret-key",
1302
      gpgFingerprint
1303
    );
1304
    await sh_run(
1305
      undefined,
1306
      "gpg",
1307
      "--batch",
1308
      "--yes",
1309
      "--pinentry-mode",
1310
      "loopback",
1311
      "--delete-key",
1312
      gpgFingerprint
1313
    );
1314
  }
1315
}
1316

1317
async function get_gh_app_token() {
1318
  const workspace = process.env["WM_WORKSPACE"];
1319
  const jobToken = process.env["WM_TOKEN"];
1320
  const baseUrl =
1321
    process.env["BASE_INTERNAL_URL"] ??
1322
    process.env["BASE_URL"] ??
1323
    "http://localhost:8000";
1324
  const url = `${baseUrl}/api/w/${workspace}/github_app/token`;
1325

1326
  const response = await fetch(url, {
1327
    method: "POST",
1328
    headers: {
1329
      "Content-Type": "application/json",
1330
      Authorization: `Bearer ${jobToken}`,
1331
    },
1332
    body: JSON.stringify({ job_token: jobToken }),
1333
  });
1334

1335
  if (!response.ok) {
1336
    const errorBody = await response.text().catch(() => "");
1337
    throw new Error(`GitHub App token error (${response.status}): ${errorBody || response.statusText}`);
1338
  }
1339
  const data = await response.json();
1340
  return data.token;
1341
}
1342

1343
function prependTokenToGitHubUrl(gitHubUrl: string, installationToken: string) {
1344
  const url = new URL(gitHubUrl);
1345
  return `https://x-access-token:${installationToken}@${url.hostname}${url.pathname}`;
1346
}
1347