Git-sync: init repository
One script reply has been approved by the moderators Verified
Created by hugo697 261 days ago Picked 32 times
Submitted by hugo697 Bun
Verified 2 days ago
1
import * as wmillclient from "windmill-client";
2
import wmill from "windmill-cli";
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
) {
37
  let safeDirectoryPath: string | undefined;
38
  console.log("DEBUG: Starting main function", {
39
    workspace_id,
40
    // repo_url_resource_path,
41
    dry_run,
42
    only_wmill_yaml,
43
    pull,
44
    settings_json: settings_json ? "PROVIDED" : "NOT_PROVIDED",
45
  });
46

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

535

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

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

555

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

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

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

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

583

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

750

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

913
// Clone repo and optionally enter subfolder
914
async function git_clone(
915
  cwd: string,
916
  repo_resource: any,
917
  isPull: boolean,
918
  workspace_id: string
919
): Promise<{ repo_name: string; safeDirectoryPath: string; clonedBranchName: string }> {
920
  let repo_url = repo_resource.url;
921
  const subfolder = repo_resource.folder ?? "";
922
  let branch = repo_resource.branch ?? "";
923
  const repo_name = basename(repo_url, ".git");
924

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

945
  const args = ["clone", "--quiet", "--depth", "1"];
946
  if (workspace_id.startsWith(FORKED_WORKSPACE_PREFIX)) args.push("--no-single-branch");
947
  if (subfolder !== "") args.push("--sparse");
948
  if (branch !== "") args.push("--branch", branch);
949
  args.push(repo_url, repo_name);
950

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

968
  const fullPath = join(cwd, repo_name);
969
  process.chdir(fullPath);
970

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

979
  if (subfolder !== "") {
980
    await sh_run(undefined, "git", "sparse-checkout", "add", subfolder);
981
    const subfolderPath = join(fullPath, subfolder);
982

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

996
    process.chdir(subfolderPath);
997
  }
998

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

1017
  return { repo_name, safeDirectoryPath, clonedBranchName };
1018
}
1019

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

1030
  let secret: string | undefined = undefined;
1031
  if (secret_position != undefined) {
1032
    nargs[secret_position] = "***";
1033
    secret = args[secret_position];
1034
  }
1035

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

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

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

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

1087
  console.log("DEBUG: Captured CLI output length:", cliOutput.length);
1088
  console.log("DEBUG: Raw CLI output:", cliOutput);
1089

1090
  try {
1091
    console.log("DEBUG: Attempting to parse CLI output as JSON...");
1092

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

1100
    // Extract everything from the first '{' to the end
1101
    const jsonString = cliOutput.substring(jsonStartIndex).trim();
1102
    console.log("DEBUG: Extracted JSON string:", jsonString);
1103

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

1114
async function git_push(
1115
  commit_msg: string,
1116
  repo_resource: any,
1117
  target_branch: string
1118
) {
1119
  console.log("DEBUG: git_push started", {
1120
    commit_msg,
1121
    target_branch,
1122
    has_gpg_key: !!repo_resource.gpg_key,
1123
  });
1124

1125
  const user_email = process.env["WM_EMAIL"] ?? "";
1126
  const user_name = process.env["WM_USERNAME"] ?? "";
1127

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

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

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

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

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

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

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

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

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

1230
        console.log("DEBUG: Second push also failed:", retryError);
1231
        throw retryError;
1232
      }
1233
    }
1234
  }
1235
}
1236

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

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

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

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

1272
  const keyId = match[1];
1273
  gpgFingerprint = match[2];
1274

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

1284
  await sh_run(undefined, "git", "config", "user.signingkey", keyId);
1285
  await sh_run(undefined, "git", "config", "commit.gpgsign", "true");
1286
}
1287

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

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

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

1331
  if (!response.ok)
1332
    throw new Error(`GitHub App token error: ${response.statusText}`);
1333
  const data = await response.json();
1334
  return data.token;
1335
}
1336

1337
function prependTokenToGitHubUrl(gitHubUrl: string, installationToken: string) {
1338
  const url = new URL(gitHubUrl);
1339
  return `https://x-access-token:${installationToken}@${url.hostname}${url.pathname}`;
1340
}
1341