0

Git-sync: init repository

by
Published Jul 8, 2025
Script windmill Verified

The script

Submitted by hugo697 Bun
Verified 3 days 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 to clone instead of the resource's configured branch: a branch name, or a synthetic pull/<n>/head ref (PR diff previews)
37
  pr_head_sha?: string // With a pull/<n>/head clone_ref: verify the fetched ref is at this sha and merge it into the cloned base branch, so the diff previews the PR's merge result
38
) {
39
  let safeDirectoryPath: string | undefined;
40
  console.log("DEBUG: Starting main function", {
41
    workspace_id,
42
    // repo_url_resource_path,
43
    dry_run,
44
    only_wmill_yaml,
45
    pull,
46
    settings_json: settings_json ? "PROVIDED" : "NOT_PROVIDED",
47
  });
48

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

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

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

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

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

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

92
  console.log("DEBUG: Starting git clone...");
93
  let cloneOutcome;
94
  try {
95
    cloneOutcome = await git_clone(cwd, repo_resource, pull, workspace_id, clone_ref, pr_head_sha);
96
  } catch (error) {
97
    const msg = error?.message ?? String(error);
98
    const sentinel = ["PR_MERGE_CONFLICTS", "PR_HEAD_REF_UNAVAILABLE"].find((s) => msg.includes(s));
99
    // PR diff sentinels go out as a structured RESULT, not a thrown error: an
100
    // uncaught exception reaches the job result as truncated log tails that
101
    // can drop the message, and the completion hook matches on the result.
102
    if (sentinel) {
103
      console.log(`DEBUG: PR diff aborted: ${msg}`);
104
      // git_clone chdirs into the clone; the result must be written from the
105
      // job's original working directory.
106
      process.chdir(cwd);
107
      return { pr_check_error: sentinel, message: msg };
108
    }
109
    throw error;
110
  }
111
  const { repo_name, safeDirectoryPath: cloneSafeDirectoryPath, clonedBranchName } = cloneOutcome;
112
  safeDirectoryPath = cloneSafeDirectoryPath;
113
  console.log("DEBUG: Git clone completed, repo name:", repo_name);
114

115
  const subfolder = repo_resource.folder ?? "";
116
  const fullPath = join(cwd, repo_name, subfolder);
117
  console.log("DEBUG: Full path:", fullPath);
118

119
  process.chdir(fullPath);
120
  console.log("DEBUG: Changed directory to:", process.cwd());
121

122
  // Set up workspace context for CLI commands
123
  console.log("DEBUG: Setting up workspace...");
124
  await wmill_run(
125
    6,
126
    "workspace",
127
    "add",
128
    workspace_id,
129
    workspace_id,
130
    process.env["BASE_URL"] + "/",
131
    "--token",
132
    process.env["WM_TOKEN"] ?? ""
133
  );
134
  console.log("DEBUG: Workspace setup completed");
135

136
  let result;
137
  try {
138
    console.log("DEBUG: Entering main execution branch", {
139
      only_wmill_yaml,
140
      pull,
141
      dry_run,
142
    });
143

144
    if (only_wmill_yaml) {
145
      // Settings-only operations (wmill.yaml)
146
      result = await executeSettingsOperation(
147
        workspace_id,
148
        repository_path,
149
        settings_json,
150
        fullPath,
151
        pull,
152
        dry_run,
153
        clonedBranchName,
154
        repo_resource,
155
        promotion_branch
156
      );
157
    } else {
158
      // Full sync operations
159
      result = await executeSyncOperation(
160
        workspace_id,
161
        repository_path,
162
        settings_json,
163
        fullPath,
164
        repo_resource,
165
        pull,
166
        dry_run,
167
        clonedBranchName,
168
        promotion_branch
169
      );
170
    }
171

172
    console.log("DEBUG: Main execution completed successfully", result);
173
  } catch (error) {
174
    console.log("DEBUG: Error in main execution:", error);
175
    throw error;
176
  } finally {
177
    // Cleanup: remove safe.directory config
178
    if (safeDirectoryPath) {
179
      try {
180
        await sh_run(undefined, "git", "config", "--global", "--unset", "safe.directory", safeDirectoryPath);
181
      } catch (e) {
182
        console.log(`Warning: Could not unset safe.directory config: ${e}`);
183
      }
184
    }
185
    console.log("DEBUG: Changing back to original directory:", cwd);
186
    process.chdir(cwd);
187
  }
188

189
  // Return the result directly from the CLI command
190
  return result;
191
}
192

193
async function executeSettingsOperation(
194
  workspace_id: string,
195
  repository_path: string,
196
  settings_json: string | undefined,
197
  fullPath: string,
198
  pull: boolean,
199
  dry_run: boolean,
200
  clonedBranchName: string,
201
  repo_resource?: any,
202
  promotion_branch?: string
203
) {
204
  if (pull) {
205
    console.log("DEBUG: Executing pull branch (wmill.yaml only)");
206
    // Frontend PULL = Git→Windmill = CLI settings push (push wmill.yaml from Git to Windmill)
207
    if (dry_run) {
208
      return await executeCliSettingsPushDryRun(
209
        workspace_id,
210
        repository_path,
211
        settings_json,
212
        fullPath,
213
        promotion_branch
214
      );
215
    } else {
216
      // For actual pull, we still just want to return the git repo settings_json
217
      return await executeCliSettingsPushDryRun(
218
        workspace_id,
219
        repository_path,
220
        settings_json,
221
        fullPath,
222
        promotion_branch
223
      );
224
    }
225
  } else {
226
    console.log("DEBUG: Executing push branch (wmill.yaml only)");
227
    // Frontend PUSH = Windmill→Git = CLI settings pull (pull from Windmill to generate wmill.yaml)
228
    if (dry_run) {
229
      return await executeCliSettingsPullDryRun(
230
        workspace_id,
231
        repository_path,
232
        settings_json,
233
        fullPath,
234
        promotion_branch
235
      );
236
    } else {
237
      if (!settings_json) throw Error("settings_json required in this mode");
238
      return await executeCliSettingsPull(
239
        workspace_id,
240
        repository_path,
241
        fullPath,
242
        settings_json,
243
        repo_resource,
244
        promotion_branch
245
      );
246
    }
247
  }
248
}
249

250
async function executeSyncOperation(
251
  workspace_id: string,
252
  repository_path: string,
253
  settings_json: string | undefined,
254
  fullPath: string,
255
  repo_resource: any,
256
  pull: boolean,
257
  dry_run: boolean,
258
  clonedBranchName: string,
259
  promotion_branch?: string
260
) {
261
  if (pull) {
262
    console.log("DEBUG: Executing sync pull", { dry_run });
263
    // Frontend PULL = Git→Windmill = CLI sync push
264
    if (dry_run) {
265
      return await executeCliSyncPushDryRun(
266
        workspace_id,
267
        repository_path,
268
        settings_json,
269
        fullPath,
270
        promotion_branch
271
      );
272
    } else {
273
      return await executeCliSyncPush(
274
        workspace_id,
275
        repository_path,
276
        repo_resource,
277
        settings_json
278
      );
279
    }
280
  } else {
281
    console.log("DEBUG: Executing sync push", { dry_run });
282
    // Frontend PUSH = Windmill→Git = CLI sync pull
283
    if (dry_run) {
284
      return await executeCliSyncPullDryRun(
285
        workspace_id,
286
        repository_path,
287
        settings_json,
288
        fullPath
289
      );
290
    } else {
291
      return await executeCliSyncPull(
292
        workspace_id,
293
        repository_path,
294
        repo_resource,
295
        clonedBranchName,
296
        settings_json
297
      );
298
    }
299
  }
300
}
301

302
// Use existing CLI settings pull --dry-run (from settings.ts)
303
async function executeCliSettingsPullDryRun(
304
  workspace_id: string,
305
  repository_path: string,
306
  settings_json?: string,
307
  repoPath?: string,
308
  promotion_branch?: string
309
) {
310
  try {
311
    // Check if wmill.yaml exists in the git repo
312
    let wmillYamlExists = existsSync("wmill.yaml");
313
    if (!wmillYamlExists) {
314
      console.log(
315
        "DEBUG: No wmill.yaml found, will create with repository settings"
316
      );
317

318
      // For new repositories, don't show a confusing diff between defaults and settings
319
      // Just return a simple success message indicating the file will be created
320
      return {
321
        success: true,
322
        hasChanges: true,
323
        message: "wmill.yaml will be created with repository settings",
324
        isInitialSetup: true,
325
        repository: repository_path
326
      };
327
    }
328

329
    // Use gitsync-settings diff for UI settings comparison
330
    // This shows what would change in Git if we pulled from Windmill
331
    const args = [
332
      undefined,
333
      "gitsync-settings",
334
      "pull",
335
      "--diff",
336
      "--repository",
337
      repository_path,
338
      "--workspace",
339
      workspace_id,
340
      "--override",
341
    ];
342

343
    if (settings_json) {
344
      args.push("--with-backend-settings", settings_json);
345
    }
346

347
    if (promotion_branch) {
348
      args.push("--promotion", promotion_branch);
349
    }
350

351
    args.push(
352
      "--token",
353
      process.env["WM_TOKEN"] ?? "",
354
      "--base-url",
355
      process.env["BASE_URL"] + "/",
356
      "--json-output"
357
    );
358

359
    return await wmill_run(...args);
360
  } catch (error) {
361
    const errorMessage = error.message || error.toString();
362
    // Check if this is an empty repository error (no commits/branches yet)
363
    if ((errorMessage.includes("src refspec") && errorMessage.includes("does not match any")) ||
364
        (errorMessage.includes("Remote branch") && errorMessage.includes("not found"))) {
365
      console.log("DEBUG: Empty repository detected - branch doesn't exist or no commits");
366
      return {
367
        success: true,
368
        hasChanges: true,
369
        message: "Empty repository detected - requires initialization",
370
        isInitialSetup: true,
371
        repository: repository_path
372
      };
373
    }
374
    throw new Error("Settings pull dry run failed: " + errorMessage);
375
  }
376
}
377

378
// Use existing CLI settings push --dry-run (from settings.ts)
379
async function executeCliSettingsPushDryRun(
380
  workspace_id: string,
381
  repository_path: string,
382
  settings_json?: string,
383
  repoPath?: string,
384
  promotion_branch?: string
385
) {
386
  try {
387
    console.log("DEBUG: Settings push dry run with JSON:", settings_json);
388

389
    // Check if wmill.yaml exists in the git repo
390
    if (!existsSync("wmill.yaml")) {
391
      console.log("DEBUG: No wmill.yaml found in git repository");
392
      throw new Error(
393
        "No wmill.yaml found in the git repository. Please initialize the repository first by pushing settings from Windmill to git."
394
      );
395
    }
396

397
    // Use gitsync-settings push for UI settings comparison
398
    const args = [
399
      undefined,
400
      "gitsync-settings",
401
      "push",
402
      "--diff",
403
      "--repository",
404
      repository_path,
405
      "--workspace",
406
      workspace_id,
407
    ];
408

409
    if (settings_json) {
410
      args.push("--with-backend-settings", settings_json);
411
    }
412

413
    if (promotion_branch) {
414
      args.push("--promotion", promotion_branch);
415
    }
416

417
    args.push(
418
      "--token",
419
      process.env["WM_TOKEN"] ?? "",
420
      "--base-url",
421
      process.env["BASE_URL"] + "/",
422
      "--json-output"
423
    );
424

425
    return await wmill_run(...args);
426
  } catch (error) {
427
    throw new Error("Settings push dry run failed: " + error.message);
428
  }
429
}
430

431
// Use existing CLI settings pull (from settings.ts)
432
async function executeCliSettingsPull(
433
  workspace_id: string,
434
  repository_path: string,
435
  repoPath: string,
436
  settings_json: string,
437
  clonedBranchName: string,
438
  repo_resource?: any,
439
  promotion_branch?: string
440
) {
441
  console.log("DEBUG: executeCliSettingsPull started", {
442
    workspace_id,
443
    repository_path,
444
    repoPath,
445
    settings_json: settings_json ? "PROVIDED" : "NOT_PROVIDED",
446
  });
447

448
  try {
449
    // Check if wmill.yaml exists in the git repo
450
    let wmillYamlExists = existsSync("wmill.yaml");
451
    if (!wmillYamlExists) {
452
      console.log(
453
        "DEBUG: No wmill.yaml found, initializing with default settings"
454
      );
455

456
      // Run wmill init with default settings
457
      await wmill_run(
458
        null,
459
        "init",
460
        "--use-default",
461
        "--token",
462
        process.env["WM_TOKEN"] ?? "",
463
        "--base-url",
464
        process.env["BASE_URL"] + "/",
465
        "--workspace",
466
        workspace_id
467
      );
468

469
      console.log("DEBUG: wmill.yaml initialized with defaults");
470
    }
471

472
    console.log("DEBUG: Running CLI gitsync-settings pull command...");
473
    const args = [
474
      null,
475
      "gitsync-settings",
476
      "pull",
477
      "--repository",
478
      repository_path,
479
      "--workspace",
480
      workspace_id,
481
      wmillYamlExists ? "--override" : "--replace",
482
    ];
483

484
    if (settings_json) {
485
      args.push("--with-backend-settings", settings_json);
486
    }
487

488
    if (promotion_branch) {
489
      args.push("--promotion", promotion_branch);
490
    }
491

492
    args.push(
493
      "--token",
494
      process.env["WM_TOKEN"] ?? "",
495
      "--base-url",
496
      process.env["BASE_URL"] + "/"
497
    );
498

499
    const res = await wmill_run(...args);
500
    console.log("DEBUG: CLI settings pull result:", res);
501

502
    console.log("DEBUG: Starting git push process...");
503
    const pushResult = await git_push(
504
      "Update wmill.yaml via settings",
505
      repo_resource || { gpg_key: null },
506
      clonedBranchName
507
    );
508
    console.log("DEBUG: Git push completed:", pushResult);
509

510
    return { success: true, message: "Settings pushed to git successfully" };
511
  } catch (error) {
512
    console.log("DEBUG: Error in executeCliSettingsPull:", error);
513
    const errorMessage = error.message || error.toString();
514
    throw new Error("Settings pull failed: " + errorMessage);
515
  }
516
}
517

518
// Use existing CLI sync pull --dry-run
519
async function executeCliSyncPullDryRun(
520
  workspace_id: string,
521
  repository_path: string,
522
  settings_json?: string,
523
  repoPath?: string
524
) {
525
  try {
526
    console.log("DEBUG: executeCliSyncPullDryRun started", {
527
      workspace_id,
528
      repository_path,
529
      settings_json: settings_json ? "PROVIDED" : "NOT_PROVIDED",
530
    });
531

532
    // Check if wmill.yaml exists in the git repo
533
    let wmillYamlExists = existsSync("wmill.yaml");
534
    let settingsDiffResult = {}
535
    if (!wmillYamlExists) {
536
      console.log(
537
        "DEBUG: No wmill.yaml found, initializing with default settings"
538
      );
539

540
      // Run wmill init with default settings
541
      await wmill_run(
542
        null,
543
        "init",
544
        "--use-default",
545
        "--token",
546
        process.env["WM_TOKEN"] ?? "",
547
        "--base-url",
548
        process.env["BASE_URL"] + "/",
549
        "--workspace",
550
        workspace_id
551
      );
552

553
      console.log("DEBUG: wmill.yaml initialized with defaults");
554

555

556
      // Step 1: Check if wmill.yaml settings would change with gitsync-settings pull --diff
557
      console.log("DEBUG: Checking wmill.yaml changes with gitsync-settings pull --diff");
558
      const settingsDiffArgs = [
559
        null,
560
        "gitsync-settings",
561
        "pull",
562
        "--diff",
563
        "--repository",
564
        repository_path,
565
        "--workspace",
566
        workspace_id,
567
        "--replace",
568
        "--json-output"
569
      ];
570

571
      if (settings_json) {
572
        settingsDiffArgs.push("--with-backend-settings", settings_json);
573
      }
574

575

576
      settingsDiffArgs.push(
577
        "--token",
578
        process.env["WM_TOKEN"] ?? "",
579
        "--base-url",
580
        process.env["BASE_URL"] + "/"
581
      );
582

583
      settingsDiffResult = await wmill_run(...settingsDiffArgs);
584
      console.log("DEBUG: Settings diff result:", settingsDiffResult);
585

586
      // Step 2: Pull settings from backend (actual update)
587
      console.log("DEBUG: Pulling git-sync settings from backend");
588
      const settingsArgs = [
589
        null,
590
        "gitsync-settings",
591
        "pull",
592
        "--repository",
593
        repository_path,
594
        "--workspace",
595
        workspace_id,
596
        "--replace",
597
      ];
598

599
      if (settings_json) {
600
        settingsArgs.push("--with-backend-settings", settings_json);
601
      }
602

603

604
      settingsArgs.push(
605
        "--token",
606
        process.env["WM_TOKEN"] ?? "",
607
        "--base-url",
608
        process.env["BASE_URL"] + "/"
609
      );
610

611
      await wmill_run(...settingsArgs);
612
      console.log("DEBUG: Git-sync settings pulled successfully");
613
    }
614

615
    const args = [
616
      "sync",
617
      "pull",
618
      "--dry-run",
619
      "--json-output",
620
      "--workspace",
621
      workspace_id,
622
      "--token",
623
      process.env["WM_TOKEN"] ?? "",
624
      "--base-url",
625
      process.env["BASE_URL"] + "/",
626
      "--repository",
627
      repository_path,
628
    ];
629

630
    const result = await wmill_run(null, ...args);
631

632
    // Step 3: Check for wmill.yaml changes using CLI hasChanges flag
633
    if (!result.changes) {
634
      result.changes = [];
635
    }
636

637
    const hasWmillYaml = result.changes.some(change => change.path === 'wmill.yaml');
638
    if (!hasWmillYaml) {
639
      if (!wmillYamlExists) {
640
        // We created it during init
641
        result.total = result.total + 1
642
        result.changes.push({ type: 'added', path: 'wmill.yaml' });
643
      } else if (settingsDiffResult?.hasChanges) {
644
        // Settings would change - add as modified using CLI detection
645
        console.log("DEBUG: Adding wmill.yaml as modified due to settings changes");
646
        result.total = result.total + 1
647
        result.changes.push({ type: 'edited', path: 'wmill.yaml' });
648
      }
649
    }
650

651
    return result;
652
  } catch (error) {
653
    throw new Error("Sync pull dry run failed: " + error.message);
654
  }
655
}
656

657
// Use existing CLI sync push --dry-run
658
async function executeCliSyncPushDryRun(
659
  workspace_id: string,
660
  repository_path: string,
661
  settings_json?: string,
662
  repoPath?: string,
663
  promotion_branch?: string
664
) {
665
  try {
666
    // Step 1: Check if wmill.yaml settings would change
667
    console.log("DEBUG: Checking wmill.yaml changes with gitsync-settings push --diff");
668
    const settingsArgs = [
669
      undefined,
670
      "gitsync-settings",
671
      "push",
672
      "--diff",
673
      "--repository",
674
      repository_path,
675
      "--workspace",
676
      workspace_id,
677
      "--json-output"
678
    ];
679

680
    if (settings_json) {
681
      settingsArgs.push("--with-backend-settings", settings_json);
682
    }
683

684
    if (promotion_branch) {
685
      settingsArgs.push("--promotion", promotion_branch);
686
    }
687

688
    settingsArgs.push(
689
      "--token",
690
      process.env["WM_TOKEN"] ?? "",
691
      "--base-url",
692
      process.env["BASE_URL"] + "/"
693
    );
694

695
    const settingsDiffResult = await wmill_run(...settingsArgs);
696
    console.log("DEBUG: Settings diff result:", settingsDiffResult);
697

698
    // Step 2: Check resource changes with sync push --dry-run
699
    console.log("DEBUG: Checking resource changes with sync push --dry-run");
700
    const syncArgs = [
701
      "sync",
702
      "push",
703
      "--dry-run",
704
      "--json-output",
705
      "--workspace",
706
      workspace_id,
707
      "--token",
708
      process.env["WM_TOKEN"] ?? "",
709
      "--base-url",
710
      process.env["BASE_URL"] + "/",
711
      "--repository",
712
      repository_path,
713
    ];
714

715
    const syncResult = await wmill_run(null, ...syncArgs);
716
    console.log("DEBUG: Sync result:", syncResult);
717

718
    // Step 3: Combine results - add wmill.yaml as modified if settings would change
719
    if (!syncResult.changes) {
720
      syncResult.changes = [];
721
    }
722

723
    if (settingsDiffResult?.hasChanges) {
724
      console.log("DEBUG: Adding wmill.yaml as modified due to settings changes");
725
      syncResult.settingsDiffResult = settingsDiffResult
726
    }
727

728
    return syncResult;
729
  } catch (error) {
730
    throw new Error("Sync push dry run failed: " + error.message);
731
  }
732
}
733

734
// Use existing CLI sync pull
735
async function executeCliSyncPull(
736
  workspace_id: string,
737
  repository_path: string,
738
  repo_resource: any,
739
  clonedBranchName: string,
740
  settings_json?: string
741
) {
742
  try {
743
    // Let the CLI handle cleanup - it knows best how to manage the local folder
744
    // Initialize wmill.yaml if needed
745
    console.log("DEBUG: Initializing with default settings");
746

747
    // Check if wmill.yaml exists in the git repo
748
    let wmillYamlExists = existsSync("wmill.yaml");
749
    let settingsDiffResult = {}
750
    if (!wmillYamlExists) {
751
      console.log(
752
        "DEBUG: No wmill.yaml found, initializing with default settings"
753
      );
754

755
      // Run wmill init with default settings
756
      await wmill_run(
757
        null,
758
        "init",
759
        "--use-default",
760
        "--token",
761
        process.env["WM_TOKEN"] ?? "",
762
        "--base-url",
763
        process.env["BASE_URL"] + "/",
764
        "--workspace",
765
        workspace_id
766
      );
767

768
      console.log("DEBUG: wmill.yaml initialized with defaults");
769

770

771
      // Step 1: Check if wmill.yaml settings would change with gitsync-settings pull --diff
772
      console.log("DEBUG: Checking wmill.yaml changes with gitsync-settings pull --diff");
773
      const settingsDiffArgs = [
774
        null,
775
        "gitsync-settings",
776
        "pull",
777
        "--diff",
778
        "--repository",
779
        repository_path,
780
        "--workspace",
781
        workspace_id,
782
        "--replace",
783
        "--json-output"
784
      ];
785

786
      if (settings_json) {
787
        settingsDiffArgs.push("--with-backend-settings", settings_json);
788
      }
789

790
      settingsDiffArgs.push(
791
        "--token",
792
        process.env["WM_TOKEN"] ?? "",
793
        "--base-url",
794
        process.env["BASE_URL"] + "/"
795
      );
796

797
      settingsDiffResult = await wmill_run(...settingsDiffArgs);
798
      console.log("DEBUG: Settings diff result:", settingsDiffResult);
799

800
      // Step 2: Pull settings from backend (actual update)
801
      console.log("DEBUG: Pulling git-sync settings from backend");
802
      const settingsArgs = [
803
        null,
804
        "gitsync-settings",
805
        "pull",
806
        "--repository",
807
        repository_path,
808
        "--workspace",
809
        workspace_id,
810
        "--replace",
811
      ];
812

813
      if (settings_json) {
814
        settingsArgs.push("--with-backend-settings", settings_json);
815
      }
816

817
      settingsArgs.push(
818
        "--token",
819
        process.env["WM_TOKEN"] ?? "",
820
        "--base-url",
821
        process.env["BASE_URL"] + "/"
822
      );
823

824
      await wmill_run(...settingsArgs);
825
      console.log("DEBUG: Git-sync settings pulled successfully");
826
    }
827

828
    const args = [
829
      "sync",
830
      "pull",
831
      "--yes",
832
      "--workspace",
833
      workspace_id,
834
      "--token",
835
      process.env["WM_TOKEN"] ?? "",
836
      "--base-url",
837
      process.env["BASE_URL"] + "/",
838
      "--repository",
839
      repository_path,
840
    ];
841

842
    await wmill_run(null, ...args);
843

844
    // Commit and push
845
    await git_push(
846
      "Initialize windmill sync repo",
847
      repo_resource,
848
      clonedBranchName
849
    );
850
    await delete_pgp_keys();
851

852
    return { success: true, message: "CLI sync pull completed" };
853
  } catch (error) {
854
    const errorMessage = error.message || error.toString();
855
    throw new Error("Sync pull failed: " + errorMessage);
856
  }
857
}
858

859
// Use existing CLI sync push
860
async function executeCliSyncPush(
861
  workspace_id: string,
862
  repository_path: string,
863
  repo_resource: any,
864
  settings_json?: string
865
) {
866
  try {
867
    // Step 1: Get git repo settings using gitsync-settings push --diff
868
    console.log("DEBUG: Getting git repo settings with gitsync-settings push --diff");
869
    const settingsArgs = [
870
      undefined,
871
      "gitsync-settings",
872
      "push",
873
      "--diff",
874
      "--repository",
875
      repository_path,
876
      "--workspace",
877
      workspace_id,
878
      "--json-output"
879
    ];
880

881
    settingsArgs.push(
882
      "--token",
883
      process.env["WM_TOKEN"] ?? "",
884
      "--base-url",
885
      process.env["BASE_URL"] + "/"
886
    );
887

888
    const settingsResult = await wmill_run(...settingsArgs);
889
    console.log("DEBUG: Settings result:", settingsResult);
890

891
    // Step 2: Run normal sync push
892
    console.log("DEBUG: Running sync push");
893
    const syncArgs = [
894
      "sync",
895
      "push",
896
      "--yes",
897
      "--json-output",
898
      "--workspace",
899
      workspace_id,
900
      "--token",
901
      process.env["WM_TOKEN"] ?? "",
902
      "--base-url",
903
      process.env["BASE_URL"] + "/",
904
      "--repository",
905
      repository_path,
906
    ];
907

908
    const syncResult = await wmill_run(null, ...syncArgs);
909
    console.log("DEBUG: Sync result:", syncResult);
910

911
    // Step 3: Return combined result with settings_json for UI application
912
    const result = {
913
      ...syncResult,
914
      success: true,
915
      message: "CLI sync push completed",
916
      settings_json: settingsResult?.local
917
    };
918

919
    console.log("DEBUG: Combined result with settings_json:", result);
920
    return result;
921
  } catch (error) {
922
    throw new Error("Sync push failed: " + error.message);
923
  }
924
}
925

926
function get_fork_branch_name(w_id: string, originalBranch: string): string {
927
  if (w_id.startsWith(FORKED_WORKSPACE_PREFIX)) {
928
    return w_id.replace(FORKED_WORKSPACE_PREFIX, `${FORKED_BRANCH_PREFIX}/${originalBranch}/`);
929
  }
930
  return w_id;
931
}
932

933
// Clone repo and optionally enter subfolder
934
async function git_clone(
935
  cwd: string,
936
  repo_resource: any,
937
  isPull: boolean,
938
  workspace_id: string,
939
  cloneRefOverride?: string,
940
  prHeadSha?: string
941
): Promise<{ repo_name: string; safeDirectoryPath: string; clonedBranchName: string }> {
942
  let repo_url = repo_resource.url;
943
  const subfolder = repo_resource.folder ?? "";
944
  // The synthetic pull/<n>/head ref (PR diff previews) only exists in the base
945
  // repo and can't be cloned with --branch, so it is fetched explicitly below.
946
  const prRefMatch = (cloneRefOverride ?? "").match(/^(?:refs\/)?(pull\/\d+\/head)$/);
947
  // A clone-ref override (e.g. a PR head branch for diff previews) takes
948
  // precedence over the resource's configured branch.
949
  let branch = (cloneRefOverride && cloneRefOverride !== "" && !prRefMatch) ? cloneRefOverride : (repo_resource.branch ?? "");
950
  const repo_name = basename(repo_url, ".git");
951

952
  const azureMatch = repo_url.match(/AZURE_DEVOPS_TOKEN\((?<url>.+)\)/);
953
  if (azureMatch) {
954
    console.log("Fetching Azure DevOps access token...");
955
    const azureResource = await wmillclient.getResource(azureMatch.groups.url);
956
    const response = await fetch(
957
      `https://login.microsoftonline.com/${azureResource.azureTenantId}/oauth2/token`,
958
      {
959
        method: "POST",
960
        body: new URLSearchParams({
961
          client_id: azureResource.azureClientId,
962
          client_secret: azureResource.azureClientSecret,
963
          grant_type: "client_credentials",
964
          resource: "499b84ac-1321-427f-aa17-267ca6975798/.default",
965
        }),
966
      }
967
    );
968
    const { access_token } = await response.json();
969
    repo_url = repo_url.replace(azureMatch[0], access_token);
970
  }
971

972
  const args = ["clone", "--quiet", "--depth", "1"];
973
  if (workspace_id.startsWith(FORKED_WORKSPACE_PREFIX)) args.push("--no-single-branch");
974
  if (subfolder !== "") args.push("--sparse");
975
  if (branch !== "") args.push("--branch", branch);
976
  args.push(repo_url, repo_name);
977

978
  try {
979
    await sh_run(-1, "git", ...args);
980
  } catch (error) {
981
    const errorString = error.toString();
982
    // If cloning failed because the branch doesn't exist (empty repo case)
983
    if (branch !== "" && errorString.includes("Remote branch") && errorString.includes("not found")) {
984
      console.log(`DEBUG: Branch ${branch} not found, cloning without branch specification for empty repo`);
985
      // Retry clone without branch specification
986
      const fallbackArgs = ["clone", "--quiet", "--depth", "1"];
987
      if (subfolder !== "") fallbackArgs.push("--sparse");
988
      fallbackArgs.push(repo_url, repo_name);
989
      await sh_run(-1, "git", ...fallbackArgs);
990
    } else {
991
      throw error;
992
    }
993
  }
994

995
  const fullPath = join(cwd, repo_name);
996
  process.chdir(fullPath);
997

998
  const safeDirectoryPath = fullPath;
999
  // Add safe.directory to handle dubious ownership in cloned repo
1000
  try {
1001
    await sh_run(undefined, "git", "config", "--global", "--add", "safe.directory", process.cwd());
1002
  } catch (e) {
1003
    console.log(`Warning: Could not add safe.directory config: ${e}`);
1004
  }
1005

1006
  if (prRefMatch) {
1007
    const prRef = prRefMatch[1];
1008
    // With prHeadSha the fetched ref must be at that exact commit: the ref is
1009
    // advertised near-synchronously with the push that fired the webhook, so
1010
    // retry briefly rather than silently diffing an older head.
1011
    const maxAttempts = prHeadSha ? 5 : 1;
1012
    let fetchError: any = undefined;
1013
    let fetched = false;
1014
    for (let attempt = 1; attempt <= maxAttempts; attempt++) {
1015
      if (attempt > 1) await new Promise((resolve) => setTimeout(resolve, 3000));
1016
      try {
1017
        await sh_run(undefined, "git", "fetch", "--depth", "1", "origin", `+${prRef}:__wm_pr_ref`);
1018
        if (prHeadSha) {
1019
          const sha = (await sh_run(undefined, "git", "rev-parse", "__wm_pr_ref")).trim();
1020
          if (sha !== prHeadSha) {
1021
            console.log(`DEBUG: ${prRef} is at ${sha}, expected ${prHeadSha} (attempt ${attempt}/${maxAttempts})`);
1022
            continue;
1023
          }
1024
        }
1025
        fetched = true;
1026
        break;
1027
      } catch (error) {
1028
        fetchError = error;
1029
        console.log(`DEBUG: Fetching ${prRef} failed (attempt ${attempt}/${maxAttempts})`);
1030
      }
1031
    }
1032
    if (!fetched) {
1033
      if (prHeadSha) {
1034
        // PR_HEAD_REF_UNAVAILABLE is a sentinel the job-completion hook
1035
        // matches to word the failure on the PR's check run.
1036
        throw new Error(
1037
          `PR_HEAD_REF_UNAVAILABLE: could not fetch ${prRef} at ${prHeadSha}`
1038
        );
1039
      }
1040
      throw fetchError;
1041
    }
1042
    if (prHeadSha) {
1043
      // The diff must preview the PR's merge with its base, so merge the head
1044
      // into the cloned base branch locally. GitHub's own pull/<n>/merge ref is
1045
      // not used: it is computed lazily and its advertised value lags pushes by
1046
      // minutes, which would silently diff an outdated merge.
1047
      const baseBranch = branch !== ""
1048
        ? branch
1049
        : (await sh_run(undefined, "git", "rev-parse", "--abbrev-ref", "HEAD")).trim();
1050
      // The depth-1 clone has no common ancestor with the fetched head;
1051
      // deepen both sides until a merge base appears.
1052
      for (const step of ["--deepen=64", "--deepen=1024", "--unshallow"]) {
1053
        const found = await sh_run(undefined, "git", "merge-base", "HEAD", "__wm_pr_ref")
1054
          .then(() => true)
1055
          .catch(() => false);
1056
        if (found) break;
1057
        try {
1058
          await sh_run(undefined, "git", "fetch", step, "origin", baseBranch, `+${prRef}:__wm_pr_ref`);
1059
        } catch (error) {
1060
          console.log(`DEBUG: fetch ${step} failed: ${error}`);
1061
        }
1062
      }
1063
      const hasMergeBase = await sh_run(undefined, "git", "merge-base", "HEAD", "__wm_pr_ref")
1064
        .then(() => true)
1065
        .catch(() => false);
1066
      if (!hasMergeBase) {
1067
        // Only claim the branches are unmergeable when the full history is
1068
        // present; without it a failed deepen fetch (transient network) would
1069
        // masquerade as merge conflicts on a clean PR.
1070
        const shallow = (
1071
          await sh_run(undefined, "git", "rev-parse", "--is-shallow-repository").catch(() => "true")
1072
        ).toString().trim();
1073
        if (shallow === "false") {
1074
          throw new Error(
1075
            `PR_MERGE_CONFLICTS: ${prRef} shares no history with ${baseBranch}`
1076
          );
1077
        }
1078
        throw new Error(
1079
          `PR_HEAD_REF_UNAVAILABLE: could not fetch enough history to find a merge base between ${baseBranch} and ${prRef}`
1080
        );
1081
      }
1082
      // The deepen re-fetches force-update __wm_pr_ref, so a push landing
1083
      // mid-job can move it off the verified sha; the diff must describe the
1084
      // commit the check run was created for (the newer push triggers its own
1085
      // run), so bail rather than merge a different head.
1086
      const refSha = (await sh_run(undefined, "git", "rev-parse", "__wm_pr_ref")).trim();
1087
      if (refSha !== prHeadSha) {
1088
        throw new Error(
1089
          `PR_HEAD_REF_UNAVAILABLE: ${prRef} moved to ${refSha} while computing the diff for ${prHeadSha}`
1090
        );
1091
      }
1092
      try {
1093
        await sh_run(
1094
          undefined,
1095
          "git",
1096
          "-c", "user.name=windmill",
1097
          "-c", "[email protected]",
1098
          "merge", "--no-ff", "--no-edit", "__wm_pr_ref"
1099
        );
1100
      } catch (error) {
1101
        // git reports conflicts on stdout, which exec errors don't carry, so
1102
        // detect them from the unmerged index entries instead.
1103
        const unmerged = await sh_run(undefined, "git", "ls-files", "-u")
1104
          .then((out) => out.trim())
1105
          .catch(() => "");
1106
        if (unmerged !== "") {
1107
          // PR_MERGE_CONFLICTS is a sentinel the job-completion hook matches
1108
          // to report merge conflicts on the PR's check run.
1109
          throw new Error(
1110
            `PR_MERGE_CONFLICTS: merging ${prRef} into ${baseBranch} produced conflicts`
1111
          );
1112
        }
1113
        throw error;
1114
      }
1115
    } else {
1116
      await sh_run(undefined, "git", "checkout", "__wm_pr_ref");
1117
    }
1118
  }
1119

1120
  if (subfolder !== "") {
1121
    await sh_run(undefined, "git", "sparse-checkout", "add", subfolder);
1122
    const subfolderPath = join(fullPath, subfolder);
1123

1124
    if (!existsSync(subfolderPath)) {
1125
      if (isPull) {
1126
        // When pulling FROM git, subfolder must exist
1127
        throw new Error(`Subfolder ${subfolder} does not exist.`);
1128
      } else {
1129
        // When pushing TO git, create subfolder if it doesn't exist
1130
        console.log(
1131
          `DEBUG: Creating subfolder ${subfolder} for push operation`
1132
        );
1133
        await sh_run(undefined, "mkdir", "-p", subfolderPath);
1134
      }
1135
    }
1136

1137
    process.chdir(subfolderPath);
1138
  }
1139

1140
  let clonedBranchName: string;
1141
  try {
1142
    clonedBranchName = (await sh_run(undefined, "git", "rev-parse", "--abbrev-ref", "HEAD")).trim();
1143
  } catch (error) {
1144
    // Empty repository - no HEAD yet, use the branch we tried to clone or default
1145
    console.log("DEBUG: No HEAD found (empty repository), using target branch:", branch || "main");
1146
    clonedBranchName = branch || "main";
1147
  }
1148
  // Skip when the clone-ref override already put HEAD on the fork branch.
1149
  if (
1150
    workspace_id.startsWith(FORKED_WORKSPACE_PREFIX) &&
1151
    !clonedBranchName.startsWith(`${FORKED_BRANCH_PREFIX}/`)
1152
  ) {
1153
    clonedBranchName = get_fork_branch_name(workspace_id, clonedBranchName);
1154
    try {
1155
      // Root on the existing remote fork branch when there is one (fork clones
1156
      // fetch all branch refs); otherwise branch off the cloned HEAD.
1157
      await sh_run(undefined, "git", "checkout", "-b", clonedBranchName, `origin/${clonedBranchName}`);
1158
    } catch {
1159
      try {
1160
        await sh_run(undefined, "git", "checkout", "-b", clonedBranchName);
1161
      } catch {
1162
        console.info("Could not create branch, trying to switch to existing branch");
1163
        await sh_run(undefined, "git", "checkout", clonedBranchName);
1164
      }
1165
    }
1166
  }
1167

1168
  return { repo_name, safeDirectoryPath, clonedBranchName };
1169
}
1170

1171
// Shell runner with secret redaction
1172
async function sh_run(
1173
  secret_position: number | undefined,
1174
  cmd: string,
1175
  ...args: string[]
1176
) {
1177
  const nargs = secret_position != undefined ? args.slice() : args;
1178
  if (secret_position && secret_position < 0)
1179
    secret_position = nargs.length - 1 + secret_position;
1180

1181
  let secret: string | undefined = undefined;
1182
  if (secret_position != undefined) {
1183
    nargs[secret_position] = "***";
1184
    secret = args[secret_position];
1185
  }
1186

1187
  console.log(`DEBUG: Running shell command: '${cmd} ${nargs.join(" ")} ...'`);
1188
  try {
1189
    const { stdout, stderr } = await exec(`${cmd} ${args.join(" ")}`);
1190
    if (stdout.length > 0) {
1191
      console.log("DEBUG: Shell stdout:", stdout);
1192
    }
1193
    if (stderr.length > 0) {
1194
      console.log("DEBUG: Shell stderr:", stderr);
1195
    }
1196
    console.log(`DEBUG: Shell command completed successfully: ${cmd}`);
1197
    return stdout;
1198
  } catch (error: any) {
1199
    let errorString = error.toString();
1200
    if (secret) errorString = errorString.replace(secret, "***");
1201
    console.log(`DEBUG: Shell command FAILED: ${cmd}`, errorString);
1202
    throw new Error(
1203
      `SH command '${cmd} ${nargs.join(" ")}' failed: ${errorString}`
1204
    );
1205
  }
1206
}
1207

1208
async function wmill_run(
1209
  secret_position: number | undefined | null,
1210
  ...cmd: string[]
1211
) {
1212
  cmd = cmd.filter((elt) => elt !== "");
1213
  const cmd2 = cmd.slice();
1214
  if (secret_position) {
1215
    cmd2[secret_position] = "***";
1216
  }
1217
  console.log(`DEBUG: Running CLI command: 'wmill ${cmd2.join(" ")} ...'`);
1218

1219
  // Capture CLI output to parse JSON response
1220
  const originalLog = console.log;
1221
  let cliOutput = "";
1222
  console.log = (msg: string) => {
1223
    cliOutput += msg + "\n";
1224
    originalLog(msg);
1225
  };
1226

1227
  try {
1228
    await wmill.parse(cmd);
1229
    console.log = originalLog;
1230
    console.log("DEBUG: CLI command executed successfully");
1231
  } catch (error) {
1232
    console.log = originalLog;
1233
    console.log("DEBUG: CLI command execution failed:", error);
1234
    throw error;
1235
  }
1236
  // END capture log
1237

1238
  console.log("DEBUG: Captured CLI output length:", cliOutput.length);
1239
  console.log("DEBUG: Raw CLI output:", cliOutput);
1240

1241
  try {
1242
    console.log("DEBUG: Attempting to parse CLI output as JSON...");
1243

1244
    // Find the first occurrence of '{' which indicates the start of JSON
1245
    const jsonStartIndex = cliOutput.indexOf('{');
1246
    if (jsonStartIndex === -1) {
1247
      console.log("DEBUG: No JSON found in CLI output");
1248
      return {};
1249
    }
1250

1251
    // Extract everything from the first '{' to the end
1252
    const jsonString = cliOutput.substring(jsonStartIndex).trim();
1253
    console.log("DEBUG: Extracted JSON string:", jsonString);
1254

1255
    const res = JSON.parse(jsonString);
1256
    console.log("DEBUG: Successfully parsed JSON result:", res);
1257
    return res;
1258
  } catch (e) {
1259
    console.log("DEBUG: Failed to parse CLI output as JSON:", e);
1260
    console.log("DEBUG: Returning empty object");
1261
    return {};
1262
  }
1263
}
1264

1265
async function git_push(
1266
  commit_msg: string,
1267
  repo_resource: any,
1268
  target_branch: string
1269
) {
1270
  console.log("DEBUG: git_push started", {
1271
    commit_msg,
1272
    target_branch,
1273
    has_gpg_key: !!repo_resource.gpg_key,
1274
  });
1275

1276
  const user_email = process.env["WM_EMAIL"] ?? "";
1277
  const user_name = process.env["WM_USERNAME"] ?? "";
1278

1279
  if (repo_resource.gpg_key) {
1280
    console.log("DEBUG: Setting up GPG signing...");
1281
    await set_gpg_signing_secret(repo_resource.gpg_key);
1282
    // Configure git with GPG key email for signing
1283
    console.log("DEBUG: Setting git user config with GPG key email...");
1284
    await sh_run(
1285
      undefined,
1286
      "git",
1287
      "config",
1288
      "user.email",
1289
      repo_resource.gpg_key.email
1290
    );
1291
    await sh_run(undefined, "git", "config", "user.name", user_name);
1292
  } else {
1293
    console.log("DEBUG: Setting git user config...");
1294
    await sh_run(undefined, "git", "config", "user.email", user_email);
1295
    await sh_run(undefined, "git", "config", "user.name", user_name);
1296
  }
1297

1298
  try {
1299
    console.log("DEBUG: Adding files to git...");
1300
    await sh_run(undefined, "git", "add", "-A", ":!./.config");
1301
    console.log("DEBUG: Files added successfully");
1302
  } catch (error) {
1303
    console.log("DEBUG: Unable to stage files:", error);
1304
  }
1305

1306
  try {
1307
    console.log("DEBUG: Checking for changes to commit...");
1308
    await sh_run(undefined, "git", "diff", "--cached", "--quiet");
1309
    console.log("DEBUG: No changes detected, returning no changes status");
1310
    return { status: "no changes pushed" };
1311
  } catch {
1312
    console.log("DEBUG: Changes detected, proceeding with commit...");
1313
    // Always use --author to set consistent authorship (matching sync script behavior)
1314
    await sh_run(
1315
      undefined,
1316
      "git",
1317
      "commit",
1318
      "--author",
1319
      `"${user_name} <${user_email}>"`,
1320
      "-m",
1321
      `"${commit_msg}"`
1322
    );
1323
    console.log("DEBUG: Commit completed successfully");
1324

1325
    try {
1326
      console.log("DEBUG: Attempting first push...");
1327
      await sh_run(undefined, "git", "push", "--set-upstream", "origin", target_branch);
1328
      console.log("DEBUG: First push succeeded");
1329
      return { status: "changes pushed" };
1330
    } catch (e) {
1331
      const errorString = e.toString();
1332

1333
      // Check if this is an empty repository error (no commits/branches yet)
1334
      if (errorString.includes("src refspec") && errorString.includes("does not match any")) {
1335
        console.log("DEBUG: Empty repository detected - setting up initial branch and push");
1336
        try {
1337
          // For empty repositories, we need to set up the branch properly
1338
          // Set the current branch to the target branch name
1339
          await sh_run(undefined, "git", "branch", "-M", target_branch);
1340
          console.log(`DEBUG: Set branch to ${target_branch}`);
1341

1342
          // Push with upstream to create the initial branch
1343
          await sh_run(undefined, "git", "push", "-u", "origin", target_branch);
1344
          console.log(`DEBUG: Initial push to ${target_branch} branch succeeded`);
1345
          return { status: "changes pushed" };
1346
        } catch (initialPushError) {
1347
          console.log("DEBUG: Initial push setup failed:", initialPushError);
1348
          throw initialPushError;
1349
        }
1350
      }
1351

1352
      console.log("DEBUG: First push failed, attempting rebase and retry:", e);
1353
      try {
1354
        await sh_run(undefined, "git", "pull", "--rebase");
1355
        console.log("DEBUG: Rebase completed, attempting second push...");
1356
        await sh_run(undefined, "git", "push", "--set-upstream", "origin", target_branch);
1357
        console.log("DEBUG: Second push succeeded");
1358
        return { status: "changes pushed" };
1359
      } catch (retryError) {
1360
        const retryErrorString = retryError.toString();
1361

1362
        // Check if the retry failed due to empty repository (refs/heads/main doesn't exist)
1363
        if (retryErrorString.includes("no such ref was fetched") ||
1364
            retryErrorString.includes("couldn't find remote ref")) {
1365
          console.log("DEBUG: Retry failed due to empty repository - setting up initial branch and push");
1366
          try {
1367
            // Set the current branch to the target branch name
1368
            await sh_run(undefined, "git", "branch", "-M", target_branch);
1369
            console.log(`DEBUG: Set branch to ${target_branch}`);
1370

1371
            // Push with upstream to create the initial branch
1372
            await sh_run(undefined, "git", "push", "-u", "origin", target_branch);
1373
            console.log(`DEBUG: Initial push to ${target_branch} branch after retry succeeded`);
1374
            return { status: "changes pushed" };
1375
          } catch (finalPushError) {
1376
            console.log("DEBUG: Final push attempt failed:", finalPushError);
1377
            throw finalPushError;
1378
          }
1379
        }
1380

1381
        console.log("DEBUG: Second push also failed:", retryError);
1382
        throw retryError;
1383
      }
1384
    }
1385
  }
1386
}
1387

1388
async function set_gpg_signing_secret(gpg_key: GpgKey) {
1389
  const gpg_path = "/tmp/gpg";
1390
  await sh_run(undefined, "mkdir", "-p", gpg_path);
1391
  await sh_run(undefined, "chmod", "700", gpg_path);
1392
  process.env.GNUPGHOME = gpg_path;
1393

1394
  const formatted = gpg_key.private_key.replace(
1395
    /(-----BEGIN PGP PRIVATE KEY BLOCK-----)([\s\S]*?)(-----END PGP PRIVATE KEY BLOCK-----)/,
1396
    (_, header, body, footer) =>
1397
      header + "\n\n" + body.replace(/ ([^\s])/g, "\n$1").trim() + "\n" + footer
1398
  );
1399

1400
  try {
1401
    await sh_run(
1402
      1,
1403
      "bash",
1404
      "-c",
1405
      `cat <<EOF | gpg --batch --import \n${formatted}\nEOF`
1406
    );
1407
  } catch {
1408
    throw new Error("Failed to import GPG key!");
1409
  }
1410

1411
  const keyList = await sh_run(
1412
    undefined,
1413
    "gpg",
1414
    "--list-secret-keys",
1415
    "--with-colons",
1416
    "--keyid-format=long"
1417
  );
1418
  const match = keyList.match(
1419
    /sec:[^:]*:[^:]*:[^:]*:([A-F0-9]+):.*\nfpr:::::::::([A-F0-9]{40}):/
1420
  );
1421
  if (!match) throw new Error("Failed to extract GPG Key ID and Fingerprint");
1422

1423
  const keyId = match[1];
1424
  gpgFingerprint = match[2];
1425

1426
  if (gpg_key.passphrase) {
1427
    await sh_run(
1428
      1,
1429
      "bash",
1430
      "-c",
1431
      `echo dummy | gpg --batch --pinentry-mode loopback --passphrase '${gpg_key.passphrase}' --status-fd=2 -bsau ${keyId}`
1432
    );
1433
  }
1434

1435
  await sh_run(undefined, "git", "config", "user.signingkey", keyId);
1436
  await sh_run(undefined, "git", "config", "commit.gpgsign", "true");
1437
}
1438

1439
async function delete_pgp_keys() {
1440
  if (gpgFingerprint) {
1441
    await sh_run(
1442
      undefined,
1443
      "gpg",
1444
      "--batch",
1445
      "--yes",
1446
      "--pinentry-mode",
1447
      "loopback",
1448
      "--delete-secret-key",
1449
      gpgFingerprint
1450
    );
1451
    await sh_run(
1452
      undefined,
1453
      "gpg",
1454
      "--batch",
1455
      "--yes",
1456
      "--pinentry-mode",
1457
      "loopback",
1458
      "--delete-key",
1459
      gpgFingerprint
1460
    );
1461
  }
1462
}
1463

1464
async function get_gh_app_token() {
1465
  const workspace = process.env["WM_WORKSPACE"];
1466
  const jobToken = process.env["WM_TOKEN"];
1467
  const baseUrl =
1468
    process.env["BASE_INTERNAL_URL"] ??
1469
    process.env["BASE_URL"] ??
1470
    "http://localhost:8000";
1471
  const url = `${baseUrl}/api/w/${workspace}/github_app/token`;
1472

1473
  const response = await fetch(url, {
1474
    method: "POST",
1475
    headers: {
1476
      "Content-Type": "application/json",
1477
      Authorization: `Bearer ${jobToken}`,
1478
    },
1479
    body: JSON.stringify({ job_token: jobToken }),
1480
  });
1481

1482
  if (!response.ok) {
1483
    const errorBody = await response.text().catch(() => "");
1484
    throw new Error(`GitHub App token error (${response.status}): ${errorBody || response.statusText}`);
1485
  }
1486
  const data = await response.json();
1487
  return data.token;
1488
}
1489

1490
// Rewrite the git remote formats `new URL` can't parse into an https URL:
1491
// scp-like ssh ([user@]host:owner/repo) and scheme-less (host/owner/repo).
1492
// A host:8080/... port form is kept as authority rather than read as scp.
1493
function normalizeGitHubUrl(raw: string): string {
1494
  const cleaned = raw.trim();
1495
  if (cleaned.includes("://")) {
1496
    return cleaned;
1497
  }
1498
  const colonIdx = cleaned.indexOf(":");
1499
  if (colonIdx !== -1) {
1500
    const before = cleaned.slice(0, colonIdx);
1501
    const after = cleaned.slice(colonIdx + 1);
1502
    const firstSeg = after.split("/")[0];
1503
    // `user@host:...` is always scp form, so digits there are an owner
1504
    // (GitHub allows all-numeric ones), not a port.
1505
    const isPort = !before.includes("@") && firstSeg !== "" && /^\d+$/.test(firstSeg);
1506
    // A `@` right after the `:` means `user:token@host/...` — an authority
1507
    // (userinfo) form, not scp.
1508
    const isUserinfo = firstSeg.includes("@");
1509
    if (before !== "" && !before.includes("/") && after !== "" && !isPort && !isUserinfo) {
1510
      const host = before.split("@").pop();
1511
      return `https://${host}/${after.replace(/^\/+/, "")}`;
1512
    }
1513
  }
1514
  return `https://${cleaned}`;
1515
}
1516

1517
function prependTokenToGitHubUrl(gitHubUrl: string, installationToken: string) {
1518
  // `host` (not `hostname`) so a custom port on a self-hosted git server survives.
1519
  const url = new URL(normalizeGitHubUrl(gitHubUrl));
1520
  return `https://x-access-token:${installationToken}@${url.host}${url.pathname}`;
1521
}
1522