0

Git-sync: init repository

by
Published Jul 8, 2025
Script windmill Verified

The script

Submitted by hugo697 Bun
Verified 2 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 host's PR head ref (pull/<n>/head on GitHub, merge-requests/<n>/head on GitLab) for diff previews
37
  pr_head_sha?: string // With a PR 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
  } else if (!urlCarriesCredential(repo_resource.url)) {
91
    // A repository whose credential Windmill holds gets it from the same
92
    // endpoint the app path uses; one that carries a token in its URL, or needs
93
    // none at all, is left exactly as it is.
94
    const token = await get_stored_git_token();
95
    if (token) {
96
      console.log("DEBUG: Using the git credential stored for this repository");
97
      repo_resource.url = prependTokenToGitUrl(repo_resource.url, token);
98
    }
99
  }
100

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

124
  const subfolder = repo_resource.folder ?? "";
125
  const fullPath = join(cwd, repo_name, subfolder);
126
  console.log("DEBUG: Full path:", fullPath);
127

128
  process.chdir(fullPath);
129
  console.log("DEBUG: Changed directory to:", process.cwd());
130

131
  // Set up workspace context for CLI commands
132
  console.log("DEBUG: Setting up workspace...");
133
  await wmill_run(
134
    6,
135
    "workspace",
136
    "add",
137
    workspace_id,
138
    workspace_id,
139
    process.env["BASE_URL"] + "/",
140
    "--token",
141
    process.env["WM_TOKEN"] ?? ""
142
  );
143
  console.log("DEBUG: Workspace setup completed");
144

145
  let result;
146
  try {
147
    console.log("DEBUG: Entering main execution branch", {
148
      only_wmill_yaml,
149
      pull,
150
      dry_run,
151
    });
152

153
    if (only_wmill_yaml) {
154
      // Settings-only operations (wmill.yaml)
155
      result = await executeSettingsOperation(
156
        workspace_id,
157
        repository_path,
158
        settings_json,
159
        fullPath,
160
        pull,
161
        dry_run,
162
        clonedBranchName,
163
        repo_resource,
164
        promotion_branch
165
      );
166
    } else {
167
      // Full sync operations
168
      result = await executeSyncOperation(
169
        workspace_id,
170
        repository_path,
171
        settings_json,
172
        fullPath,
173
        repo_resource,
174
        pull,
175
        dry_run,
176
        clonedBranchName,
177
        promotion_branch
178
      );
179
    }
180

181
    console.log("DEBUG: Main execution completed successfully", result);
182
  } catch (error) {
183
    console.log("DEBUG: Error in main execution:", error);
184
    throw error;
185
  } finally {
186
    // Cleanup: remove safe.directory config
187
    if (safeDirectoryPath) {
188
      try {
189
        await sh_run(undefined, "git", "config", "--global", "--unset", "safe.directory", safeDirectoryPath);
190
      } catch (e) {
191
        console.log(`Warning: Could not unset safe.directory config: ${e}`);
192
      }
193
    }
194
    console.log("DEBUG: Changing back to original directory:", cwd);
195
    process.chdir(cwd);
196
  }
197

198
  // The CLI command's result, plus the commit and branch the clone was at: a
199
  // pull's completion hook records those as the head the workspace now reflects.
200
  if (pull && !dry_run && result && typeof result === "object" && clonedSha) {
201
    return { ...result, sha: clonedSha, branch: clonedBranchName };
202
  }
203
  return result;
204
}
205

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

263
async function executeSyncOperation(
264
  workspace_id: string,
265
  repository_path: string,
266
  settings_json: string | undefined,
267
  fullPath: string,
268
  repo_resource: any,
269
  pull: boolean,
270
  dry_run: boolean,
271
  clonedBranchName: string,
272
  promotion_branch?: string
273
) {
274
  if (pull) {
275
    console.log("DEBUG: Executing sync pull", { dry_run });
276
    // Frontend PULL = Git→Windmill = CLI sync push
277
    if (dry_run) {
278
      return await executeCliSyncPushDryRun(
279
        workspace_id,
280
        repository_path,
281
        settings_json,
282
        fullPath,
283
        promotion_branch
284
      );
285
    } else {
286
      return await executeCliSyncPush(
287
        workspace_id,
288
        repository_path,
289
        repo_resource,
290
        settings_json
291
      );
292
    }
293
  } else {
294
    console.log("DEBUG: Executing sync push", { dry_run });
295
    // Frontend PUSH = Windmill→Git = CLI sync pull
296
    if (dry_run) {
297
      return await executeCliSyncPullDryRun(
298
        workspace_id,
299
        repository_path,
300
        settings_json,
301
        fullPath
302
      );
303
    } else {
304
      return await executeCliSyncPull(
305
        workspace_id,
306
        repository_path,
307
        repo_resource,
308
        clonedBranchName,
309
        settings_json
310
      );
311
    }
312
  }
313
}
314

315
// Use existing CLI settings pull --dry-run (from settings.ts)
316
async function executeCliSettingsPullDryRun(
317
  workspace_id: string,
318
  repository_path: string,
319
  settings_json?: string,
320
  repoPath?: string,
321
  promotion_branch?: string
322
) {
323
  try {
324
    // Check if wmill.yaml exists in the git repo
325
    let wmillYamlExists = existsSync("wmill.yaml");
326
    if (!wmillYamlExists) {
327
      console.log(
328
        "DEBUG: No wmill.yaml found, will create with repository settings"
329
      );
330

331
      // For new repositories, don't show a confusing diff between defaults and settings
332
      // Just return a simple success message indicating the file will be created
333
      return {
334
        success: true,
335
        hasChanges: true,
336
        message: "wmill.yaml will be created with repository settings",
337
        isInitialSetup: true,
338
        repository: repository_path
339
      };
340
    }
341

342
    // Use gitsync-settings diff for UI settings comparison
343
    // This shows what would change in Git if we pulled from Windmill
344
    const args = [
345
      undefined,
346
      "gitsync-settings",
347
      "pull",
348
      "--diff",
349
      "--repository",
350
      repository_path,
351
      "--workspace",
352
      workspace_id,
353
      "--override",
354
    ];
355

356
    if (settings_json) {
357
      args.push("--with-backend-settings", settings_json);
358
    }
359

360
    if (promotion_branch) {
361
      args.push("--promotion", promotion_branch);
362
    }
363

364
    args.push(
365
      "--token",
366
      process.env["WM_TOKEN"] ?? "",
367
      "--base-url",
368
      process.env["BASE_URL"] + "/",
369
      "--json-output"
370
    );
371

372
    return await wmill_run(...args);
373
  } catch (error) {
374
    const errorMessage = error.message || error.toString();
375
    // Check if this is an empty repository error (no commits/branches yet)
376
    if ((errorMessage.includes("src refspec") && errorMessage.includes("does not match any")) ||
377
        (errorMessage.includes("Remote branch") && errorMessage.includes("not found"))) {
378
      console.log("DEBUG: Empty repository detected - branch doesn't exist or no commits");
379
      return {
380
        success: true,
381
        hasChanges: true,
382
        message: "Empty repository detected - requires initialization",
383
        isInitialSetup: true,
384
        repository: repository_path
385
      };
386
    }
387
    throw new Error("Settings pull dry run failed: " + errorMessage);
388
  }
389
}
390

391
// Use existing CLI settings push --dry-run (from settings.ts)
392
async function executeCliSettingsPushDryRun(
393
  workspace_id: string,
394
  repository_path: string,
395
  settings_json?: string,
396
  repoPath?: string,
397
  promotion_branch?: string
398
) {
399
  try {
400
    console.log("DEBUG: Settings push dry run with JSON:", settings_json);
401

402
    // Check if wmill.yaml exists in the git repo
403
    if (!existsSync("wmill.yaml")) {
404
      console.log("DEBUG: No wmill.yaml found in git repository");
405
      throw new Error(
406
        "No wmill.yaml found in the git repository. Please initialize the repository first by pushing settings from Windmill to git."
407
      );
408
    }
409

410
    // Use gitsync-settings push for UI settings comparison
411
    const args = [
412
      undefined,
413
      "gitsync-settings",
414
      "push",
415
      "--diff",
416
      "--repository",
417
      repository_path,
418
      "--workspace",
419
      workspace_id,
420
    ];
421

422
    if (settings_json) {
423
      args.push("--with-backend-settings", settings_json);
424
    }
425

426
    if (promotion_branch) {
427
      args.push("--promotion", promotion_branch);
428
    }
429

430
    args.push(
431
      "--token",
432
      process.env["WM_TOKEN"] ?? "",
433
      "--base-url",
434
      process.env["BASE_URL"] + "/",
435
      "--json-output"
436
    );
437

438
    return await wmill_run(...args);
439
  } catch (error) {
440
    throw new Error("Settings push dry run failed: " + error.message);
441
  }
442
}
443

444
// Use existing CLI settings pull (from settings.ts)
445
async function executeCliSettingsPull(
446
  workspace_id: string,
447
  repository_path: string,
448
  repoPath: string,
449
  settings_json: string,
450
  clonedBranchName: string,
451
  repo_resource?: any,
452
  promotion_branch?: string
453
) {
454
  console.log("DEBUG: executeCliSettingsPull started", {
455
    workspace_id,
456
    repository_path,
457
    repoPath,
458
    settings_json: settings_json ? "PROVIDED" : "NOT_PROVIDED",
459
  });
460

461
  try {
462
    // Check if wmill.yaml exists in the git repo
463
    let wmillYamlExists = existsSync("wmill.yaml");
464
    if (!wmillYamlExists) {
465
      console.log(
466
        "DEBUG: No wmill.yaml found, initializing with default settings"
467
      );
468

469
      // Run wmill init with default settings
470
      await wmill_run(
471
        null,
472
        "init",
473
        "--use-default",
474
        "--token",
475
        process.env["WM_TOKEN"] ?? "",
476
        "--base-url",
477
        process.env["BASE_URL"] + "/",
478
        "--workspace",
479
        workspace_id
480
      );
481

482
      console.log("DEBUG: wmill.yaml initialized with defaults");
483
    }
484

485
    console.log("DEBUG: Running CLI gitsync-settings pull command...");
486
    const args = [
487
      null,
488
      "gitsync-settings",
489
      "pull",
490
      "--repository",
491
      repository_path,
492
      "--workspace",
493
      workspace_id,
494
      wmillYamlExists ? "--override" : "--replace",
495
    ];
496

497
    if (settings_json) {
498
      args.push("--with-backend-settings", settings_json);
499
    }
500

501
    if (promotion_branch) {
502
      args.push("--promotion", promotion_branch);
503
    }
504

505
    args.push(
506
      "--token",
507
      process.env["WM_TOKEN"] ?? "",
508
      "--base-url",
509
      process.env["BASE_URL"] + "/"
510
    );
511

512
    const res = await wmill_run(...args);
513
    console.log("DEBUG: CLI settings pull result:", res);
514

515
    console.log("DEBUG: Starting git push process...");
516
    const pushResult = await git_push(
517
      "Update wmill.yaml via settings",
518
      repo_resource || { gpg_key: null },
519
      clonedBranchName
520
    );
521
    console.log("DEBUG: Git push completed:", pushResult);
522

523
    return { success: true, message: "Settings pushed to git successfully" };
524
  } catch (error) {
525
    console.log("DEBUG: Error in executeCliSettingsPull:", error);
526
    const errorMessage = error.message || error.toString();
527
    throw new Error("Settings pull failed: " + errorMessage);
528
  }
529
}
530

531
// Use existing CLI sync pull --dry-run
532
async function executeCliSyncPullDryRun(
533
  workspace_id: string,
534
  repository_path: string,
535
  settings_json?: string,
536
  repoPath?: string
537
) {
538
  try {
539
    console.log("DEBUG: executeCliSyncPullDryRun started", {
540
      workspace_id,
541
      repository_path,
542
      settings_json: settings_json ? "PROVIDED" : "NOT_PROVIDED",
543
    });
544

545
    // Check if wmill.yaml exists in the git repo
546
    let wmillYamlExists = existsSync("wmill.yaml");
547
    let settingsDiffResult = {}
548
    if (!wmillYamlExists) {
549
      console.log(
550
        "DEBUG: No wmill.yaml found, initializing with default settings"
551
      );
552

553
      // Run wmill init with default settings
554
      await wmill_run(
555
        null,
556
        "init",
557
        "--use-default",
558
        "--token",
559
        process.env["WM_TOKEN"] ?? "",
560
        "--base-url",
561
        process.env["BASE_URL"] + "/",
562
        "--workspace",
563
        workspace_id
564
      );
565

566
      console.log("DEBUG: wmill.yaml initialized with defaults");
567

568

569
      // Step 1: Check if wmill.yaml settings would change with gitsync-settings pull --diff
570
      console.log("DEBUG: Checking wmill.yaml changes with gitsync-settings pull --diff");
571
      const settingsDiffArgs = [
572
        null,
573
        "gitsync-settings",
574
        "pull",
575
        "--diff",
576
        "--repository",
577
        repository_path,
578
        "--workspace",
579
        workspace_id,
580
        "--replace",
581
        "--json-output"
582
      ];
583

584
      if (settings_json) {
585
        settingsDiffArgs.push("--with-backend-settings", settings_json);
586
      }
587

588

589
      settingsDiffArgs.push(
590
        "--token",
591
        process.env["WM_TOKEN"] ?? "",
592
        "--base-url",
593
        process.env["BASE_URL"] + "/"
594
      );
595

596
      settingsDiffResult = await wmill_run(...settingsDiffArgs);
597
      console.log("DEBUG: Settings diff result:", settingsDiffResult);
598

599
      // Step 2: Pull settings from backend (actual update)
600
      console.log("DEBUG: Pulling git-sync settings from backend");
601
      const settingsArgs = [
602
        null,
603
        "gitsync-settings",
604
        "pull",
605
        "--repository",
606
        repository_path,
607
        "--workspace",
608
        workspace_id,
609
        "--replace",
610
      ];
611

612
      if (settings_json) {
613
        settingsArgs.push("--with-backend-settings", settings_json);
614
      }
615

616

617
      settingsArgs.push(
618
        "--token",
619
        process.env["WM_TOKEN"] ?? "",
620
        "--base-url",
621
        process.env["BASE_URL"] + "/"
622
      );
623

624
      await wmill_run(...settingsArgs);
625
      console.log("DEBUG: Git-sync settings pulled successfully");
626
    }
627

628
    const args = [
629
      "sync",
630
      "pull",
631
      "--dry-run",
632
      "--json-output",
633
      "--workspace",
634
      workspace_id,
635
      "--token",
636
      process.env["WM_TOKEN"] ?? "",
637
      "--base-url",
638
      process.env["BASE_URL"] + "/",
639
      "--repository",
640
      repository_path,
641
    ];
642

643
    const result = await wmill_run(null, ...args);
644

645
    // Step 3: Check for wmill.yaml changes using CLI hasChanges flag
646
    if (!result.changes) {
647
      result.changes = [];
648
    }
649

650
    const hasWmillYaml = result.changes.some(change => change.path === 'wmill.yaml');
651
    if (!hasWmillYaml) {
652
      if (!wmillYamlExists) {
653
        // We created it during init
654
        result.total = result.total + 1
655
        result.changes.push({ type: 'added', path: 'wmill.yaml' });
656
      } else if (settingsDiffResult?.hasChanges) {
657
        // Settings would change - add as modified using CLI detection
658
        console.log("DEBUG: Adding wmill.yaml as modified due to settings changes");
659
        result.total = result.total + 1
660
        result.changes.push({ type: 'edited', path: 'wmill.yaml' });
661
      }
662
    }
663

664
    return result;
665
  } catch (error) {
666
    throw new Error("Sync pull dry run failed: " + error.message);
667
  }
668
}
669

670
// Use existing CLI sync push --dry-run
671
async function executeCliSyncPushDryRun(
672
  workspace_id: string,
673
  repository_path: string,
674
  settings_json?: string,
675
  repoPath?: string,
676
  promotion_branch?: string
677
) {
678
  try {
679
    // Step 1: Check if wmill.yaml settings would change
680
    console.log("DEBUG: Checking wmill.yaml changes with gitsync-settings push --diff");
681
    const settingsArgs = [
682
      undefined,
683
      "gitsync-settings",
684
      "push",
685
      "--diff",
686
      "--repository",
687
      repository_path,
688
      "--workspace",
689
      workspace_id,
690
      "--json-output"
691
    ];
692

693
    if (settings_json) {
694
      settingsArgs.push("--with-backend-settings", settings_json);
695
    }
696

697
    if (promotion_branch) {
698
      settingsArgs.push("--promotion", promotion_branch);
699
    }
700

701
    settingsArgs.push(
702
      "--token",
703
      process.env["WM_TOKEN"] ?? "",
704
      "--base-url",
705
      process.env["BASE_URL"] + "/"
706
    );
707

708
    const settingsDiffResult = await wmill_run(...settingsArgs);
709
    console.log("DEBUG: Settings diff result:", settingsDiffResult);
710

711
    // Step 2: Check resource changes with sync push --dry-run
712
    console.log("DEBUG: Checking resource changes with sync push --dry-run");
713
    const syncArgs = [
714
      "sync",
715
      "push",
716
      "--dry-run",
717
      "--json-output",
718
      "--workspace",
719
      workspace_id,
720
      "--token",
721
      process.env["WM_TOKEN"] ?? "",
722
      "--base-url",
723
      process.env["BASE_URL"] + "/",
724
      "--repository",
725
      repository_path,
726
    ];
727

728
    const syncResult = await wmill_run(null, ...syncArgs);
729
    console.log("DEBUG: Sync result:", syncResult);
730

731
    // Step 3: Combine results - add wmill.yaml as modified if settings would change
732
    if (!syncResult.changes) {
733
      syncResult.changes = [];
734
    }
735

736
    if (settingsDiffResult?.hasChanges) {
737
      console.log("DEBUG: Adding wmill.yaml as modified due to settings changes");
738
      syncResult.settingsDiffResult = settingsDiffResult
739
    }
740

741
    return syncResult;
742
  } catch (error) {
743
    throw new Error("Sync push dry run failed: " + error.message);
744
  }
745
}
746

747
// Use existing CLI sync pull
748
async function executeCliSyncPull(
749
  workspace_id: string,
750
  repository_path: string,
751
  repo_resource: any,
752
  clonedBranchName: string,
753
  settings_json?: string
754
) {
755
  try {
756
    // Let the CLI handle cleanup - it knows best how to manage the local folder
757
    // Initialize wmill.yaml if needed
758
    console.log("DEBUG: Initializing with default settings");
759

760
    // Check if wmill.yaml exists in the git repo
761
    let wmillYamlExists = existsSync("wmill.yaml");
762
    let settingsDiffResult = {}
763
    if (!wmillYamlExists) {
764
      console.log(
765
        "DEBUG: No wmill.yaml found, initializing with default settings"
766
      );
767

768
      // Run wmill init with default settings
769
      await wmill_run(
770
        null,
771
        "init",
772
        "--use-default",
773
        "--token",
774
        process.env["WM_TOKEN"] ?? "",
775
        "--base-url",
776
        process.env["BASE_URL"] + "/",
777
        "--workspace",
778
        workspace_id
779
      );
780

781
      console.log("DEBUG: wmill.yaml initialized with defaults");
782

783

784
      // Step 1: Check if wmill.yaml settings would change with gitsync-settings pull --diff
785
      console.log("DEBUG: Checking wmill.yaml changes with gitsync-settings pull --diff");
786
      const settingsDiffArgs = [
787
        null,
788
        "gitsync-settings",
789
        "pull",
790
        "--diff",
791
        "--repository",
792
        repository_path,
793
        "--workspace",
794
        workspace_id,
795
        "--replace",
796
        "--json-output"
797
      ];
798

799
      if (settings_json) {
800
        settingsDiffArgs.push("--with-backend-settings", settings_json);
801
      }
802

803
      settingsDiffArgs.push(
804
        "--token",
805
        process.env["WM_TOKEN"] ?? "",
806
        "--base-url",
807
        process.env["BASE_URL"] + "/"
808
      );
809

810
      settingsDiffResult = await wmill_run(...settingsDiffArgs);
811
      console.log("DEBUG: Settings diff result:", settingsDiffResult);
812

813
      // Step 2: Pull settings from backend (actual update)
814
      console.log("DEBUG: Pulling git-sync settings from backend");
815
      const settingsArgs = [
816
        null,
817
        "gitsync-settings",
818
        "pull",
819
        "--repository",
820
        repository_path,
821
        "--workspace",
822
        workspace_id,
823
        "--replace",
824
      ];
825

826
      if (settings_json) {
827
        settingsArgs.push("--with-backend-settings", settings_json);
828
      }
829

830
      settingsArgs.push(
831
        "--token",
832
        process.env["WM_TOKEN"] ?? "",
833
        "--base-url",
834
        process.env["BASE_URL"] + "/"
835
      );
836

837
      await wmill_run(...settingsArgs);
838
      console.log("DEBUG: Git-sync settings pulled successfully");
839
    }
840

841
    const args = [
842
      "sync",
843
      "pull",
844
      "--yes",
845
      "--workspace",
846
      workspace_id,
847
      "--token",
848
      process.env["WM_TOKEN"] ?? "",
849
      "--base-url",
850
      process.env["BASE_URL"] + "/",
851
      "--repository",
852
      repository_path,
853
    ];
854

855
    await wmill_run(null, ...args);
856

857
    // Commit and push
858
    await git_push(
859
      "Initialize windmill sync repo",
860
      repo_resource,
861
      clonedBranchName
862
    );
863
    await delete_pgp_keys();
864

865
    return { success: true, message: "CLI sync pull completed" };
866
  } catch (error) {
867
    const errorMessage = error.message || error.toString();
868
    throw new Error("Sync pull failed: " + errorMessage);
869
  }
870
}
871

872
// Use existing CLI sync push
873
async function executeCliSyncPush(
874
  workspace_id: string,
875
  repository_path: string,
876
  repo_resource: any,
877
  settings_json?: string
878
) {
879
  try {
880
    // Step 1: Get git repo settings using gitsync-settings push --diff
881
    console.log("DEBUG: Getting git repo settings with gitsync-settings push --diff");
882
    const settingsArgs = [
883
      undefined,
884
      "gitsync-settings",
885
      "push",
886
      "--diff",
887
      "--repository",
888
      repository_path,
889
      "--workspace",
890
      workspace_id,
891
      "--json-output"
892
    ];
893

894
    settingsArgs.push(
895
      "--token",
896
      process.env["WM_TOKEN"] ?? "",
897
      "--base-url",
898
      process.env["BASE_URL"] + "/"
899
    );
900

901
    const settingsResult = await wmill_run(...settingsArgs);
902
    console.log("DEBUG: Settings result:", settingsResult);
903

904
    // Step 2: Run normal sync push
905
    console.log("DEBUG: Running sync push");
906
    const syncArgs = [
907
      "sync",
908
      "push",
909
      "--yes",
910
      "--json-output",
911
      "--workspace",
912
      workspace_id,
913
      "--token",
914
      process.env["WM_TOKEN"] ?? "",
915
      "--base-url",
916
      process.env["BASE_URL"] + "/",
917
      "--repository",
918
      repository_path,
919
    ];
920

921
    const syncResult = await wmill_run(null, ...syncArgs);
922
    console.log("DEBUG: Sync result:", syncResult);
923

924
    // Step 3: Return combined result with settings_json for UI application
925
    const result = {
926
      ...syncResult,
927
      success: true,
928
      message: "CLI sync push completed",
929
      settings_json: settingsResult?.local
930
    };
931

932
    console.log("DEBUG: Combined result with settings_json:", result);
933
    return result;
934
  } catch (error) {
935
    throw new Error("Sync push failed: " + error.message);
936
  }
937
}
938

939
function get_fork_branch_name(w_id: string, originalBranch: string): string {
940
  if (w_id.startsWith(FORKED_WORKSPACE_PREFIX)) {
941
    return w_id.replace(FORKED_WORKSPACE_PREFIX, `${FORKED_BRANCH_PREFIX}/${originalBranch}/`);
942
  }
943
  return w_id;
944
}
945

946
// Clone repo and optionally enter subfolder
947
async function git_clone(
948
  cwd: string,
949
  repo_resource: any,
950
  isPull: boolean,
951
  workspace_id: string,
952
  cloneRefOverride?: string,
953
  prHeadSha?: string
954
): Promise<{
955
  repo_name: string;
956
  safeDirectoryPath: string;
957
  clonedBranchName: string;
958
  /** Commit the clone's HEAD is at, or undefined for an empty repository. */
959
  clonedSha?: string;
960
}> {
961
  let repo_url = repo_resource.url;
962
  const subfolder = repo_resource.folder ?? "";
963
  // A host's PR head ref (PR diff previews) is only published by the target
964
  // repo and can't be cloned with --branch, so it is fetched explicitly below.
965
  const prRefMatch = (cloneRefOverride ?? "").match(/^(?:refs\/)?((?:pull|merge-requests)\/\d+\/head)$/);
966
  // A clone-ref override (e.g. a PR head branch for diff previews) takes
967
  // precedence over the resource's configured branch.
968
  let branch = (cloneRefOverride && cloneRefOverride !== "" && !prRefMatch) ? cloneRefOverride : (repo_resource.branch ?? "");
969
  const repo_name = basename(repo_url, ".git");
970

971
  const azureMatch = repo_url.match(/AZURE_DEVOPS_TOKEN\((?<url>.+)\)/);
972
  if (azureMatch) {
973
    console.log("Fetching Azure DevOps access token...");
974
    const azureResource = await wmillclient.getResource(azureMatch.groups.url);
975
    const response = await fetch(
976
      `https://login.microsoftonline.com/${azureResource.azureTenantId}/oauth2/token`,
977
      {
978
        method: "POST",
979
        body: new URLSearchParams({
980
          client_id: azureResource.azureClientId,
981
          client_secret: azureResource.azureClientSecret,
982
          grant_type: "client_credentials",
983
          resource: "499b84ac-1321-427f-aa17-267ca6975798/.default",
984
        }),
985
      }
986
    );
987
    const { access_token } = await response.json();
988
    repo_url = repo_url.replace(azureMatch[0], access_token);
989
  }
990

991
  const args = ["clone", "--quiet", "--depth", "1"];
992
  if (workspace_id.startsWith(FORKED_WORKSPACE_PREFIX)) args.push("--no-single-branch");
993
  if (subfolder !== "") args.push("--sparse");
994
  if (branch !== "") args.push("--branch", branch);
995
  args.push(repo_url, repo_name);
996

997
  try {
998
    await sh_run(-1, "git", ...args);
999
  } catch (error) {
1000
    const errorString = error.toString();
1001
    // If cloning failed because the branch doesn't exist (empty repo case)
1002
    if (branch !== "" && errorString.includes("Remote branch") && errorString.includes("not found")) {
1003
      console.log(`DEBUG: Branch ${branch} not found, cloning without branch specification for empty repo`);
1004
      // Retry clone without branch specification
1005
      const fallbackArgs = ["clone", "--quiet", "--depth", "1"];
1006
      if (subfolder !== "") fallbackArgs.push("--sparse");
1007
      fallbackArgs.push(repo_url, repo_name);
1008
      await sh_run(-1, "git", ...fallbackArgs);
1009
    } else {
1010
      throw error;
1011
    }
1012
  }
1013

1014
  const fullPath = join(cwd, repo_name);
1015
  process.chdir(fullPath);
1016

1017
  const safeDirectoryPath = fullPath;
1018
  // Add safe.directory to handle dubious ownership in cloned repo
1019
  try {
1020
    await sh_run(undefined, "git", "config", "--global", "--add", "safe.directory", process.cwd());
1021
  } catch (e) {
1022
    console.log(`Warning: Could not add safe.directory config: ${e}`);
1023
  }
1024

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

1139
  if (subfolder !== "") {
1140
    await sh_run(undefined, "git", "sparse-checkout", "add", subfolder);
1141
    const subfolderPath = join(fullPath, subfolder);
1142

1143
    if (!existsSync(subfolderPath)) {
1144
      if (isPull) {
1145
        // When pulling FROM git, subfolder must exist
1146
        throw new Error(`Subfolder ${subfolder} does not exist.`);
1147
      } else {
1148
        // When pushing TO git, create subfolder if it doesn't exist
1149
        console.log(
1150
          `DEBUG: Creating subfolder ${subfolder} for push operation`
1151
        );
1152
        await sh_run(undefined, "mkdir", "-p", subfolderPath);
1153
      }
1154
    }
1155

1156
    process.chdir(subfolderPath);
1157
  }
1158

1159
  let clonedBranchName: string;
1160
  try {
1161
    clonedBranchName = (await sh_run(undefined, "git", "rev-parse", "--abbrev-ref", "HEAD")).trim();
1162
  } catch (error) {
1163
    // Empty repository - no HEAD yet, use the branch we tried to clone or default
1164
    console.log("DEBUG: No HEAD found (empty repository), using target branch:", branch || "main");
1165
    clonedBranchName = branch || "main";
1166
  }
1167
  // Skip when the clone-ref override already put HEAD on the fork branch.
1168
  if (
1169
    workspace_id.startsWith(FORKED_WORKSPACE_PREFIX) &&
1170
    !clonedBranchName.startsWith(`${FORKED_BRANCH_PREFIX}/`)
1171
  ) {
1172
    clonedBranchName = get_fork_branch_name(workspace_id, clonedBranchName);
1173
    try {
1174
      // Root on the existing remote fork branch when there is one (fork clones
1175
      // fetch all branch refs); otherwise branch off the cloned HEAD.
1176
      await sh_run(undefined, "git", "checkout", "-b", clonedBranchName, `origin/${clonedBranchName}`);
1177
    } catch {
1178
      try {
1179
        await sh_run(undefined, "git", "checkout", "-b", clonedBranchName);
1180
      } catch {
1181
        console.info("Could not create branch, trying to switch to existing branch");
1182
        await sh_run(undefined, "git", "checkout", clonedBranchName);
1183
      }
1184
    }
1185
  }
1186

1187
  // The commit this run applies. A pull is enqueued for a head observed earlier
1188
  // and clones the branch's current tip, which may have moved on; the result
1189
  // reports what was actually checked out.
1190
  let clonedSha: string | undefined;
1191
  try {
1192
    clonedSha = (await sh_run(undefined, "git", "rev-parse", "HEAD")).trim();
1193
  } catch {
1194
    clonedSha = undefined;
1195
  }
1196

1197
  return { repo_name, safeDirectoryPath, clonedBranchName, clonedSha };
1198
}
1199

1200
// Shell runner with secret redaction
1201
async function sh_run(
1202
  secret_position: number | undefined,
1203
  cmd: string,
1204
  ...args: string[]
1205
) {
1206
  const nargs = secret_position != undefined ? args.slice() : args;
1207
  if (secret_position && secret_position < 0)
1208
    secret_position = nargs.length - 1 + secret_position;
1209

1210
  let secret: string | undefined = undefined;
1211
  if (secret_position != undefined) {
1212
    nargs[secret_position] = "***";
1213
    secret = args[secret_position];
1214
  }
1215

1216
  console.log(`DEBUG: Running shell command: '${cmd} ${nargs.join(" ")} ...'`);
1217
  try {
1218
    const { stdout, stderr } = await exec(`${cmd} ${args.join(" ")}`);
1219
    if (stdout.length > 0) {
1220
      console.log("DEBUG: Shell stdout:", stdout);
1221
    }
1222
    if (stderr.length > 0) {
1223
      console.log("DEBUG: Shell stderr:", stderr);
1224
    }
1225
    console.log(`DEBUG: Shell command completed successfully: ${cmd}`);
1226
    return stdout;
1227
  } catch (error: any) {
1228
    let errorString = error.toString();
1229
    if (secret) errorString = errorString.replace(secret, "***");
1230
    console.log(`DEBUG: Shell command FAILED: ${cmd}`, errorString);
1231
    throw new Error(
1232
      `SH command '${cmd} ${nargs.join(" ")}' failed: ${errorString}`
1233
    );
1234
  }
1235
}
1236

1237
async function wmill_run(
1238
  secret_position: number | undefined | null,
1239
  ...cmd: string[]
1240
) {
1241
  cmd = cmd.filter((elt) => elt !== "");
1242
  const cmd2 = cmd.slice();
1243
  if (secret_position) {
1244
    cmd2[secret_position] = "***";
1245
  }
1246
  console.log(`DEBUG: Running CLI command: 'wmill ${cmd2.join(" ")} ...'`);
1247

1248
  // Capture CLI output to parse JSON response
1249
  const originalLog = console.log;
1250
  let cliOutput = "";
1251
  console.log = (msg: string) => {
1252
    cliOutput += msg + "\n";
1253
    originalLog(msg);
1254
  };
1255

1256
  try {
1257
    await wmill.parse(cmd);
1258
    console.log = originalLog;
1259
    console.log("DEBUG: CLI command executed successfully");
1260
  } catch (error) {
1261
    console.log = originalLog;
1262
    console.log("DEBUG: CLI command execution failed:", error);
1263
    throw error;
1264
  }
1265
  // END capture log
1266

1267
  console.log("DEBUG: Captured CLI output length:", cliOutput.length);
1268
  console.log("DEBUG: Raw CLI output:", cliOutput);
1269

1270
  try {
1271
    console.log("DEBUG: Attempting to parse CLI output as JSON...");
1272

1273
    // Find the first occurrence of '{' which indicates the start of JSON
1274
    const jsonStartIndex = cliOutput.indexOf('{');
1275
    if (jsonStartIndex === -1) {
1276
      console.log("DEBUG: No JSON found in CLI output");
1277
      return {};
1278
    }
1279

1280
    // Extract everything from the first '{' to the end
1281
    const jsonString = cliOutput.substring(jsonStartIndex).trim();
1282
    console.log("DEBUG: Extracted JSON string:", jsonString);
1283

1284
    const res = JSON.parse(jsonString);
1285
    console.log("DEBUG: Successfully parsed JSON result:", res);
1286
    return res;
1287
  } catch (e) {
1288
    console.log("DEBUG: Failed to parse CLI output as JSON:", e);
1289
    console.log("DEBUG: Returning empty object");
1290
    return {};
1291
  }
1292
}
1293

1294
async function git_push(
1295
  commit_msg: string,
1296
  repo_resource: any,
1297
  target_branch: string
1298
) {
1299
  console.log("DEBUG: git_push started", {
1300
    commit_msg,
1301
    target_branch,
1302
    has_gpg_key: !!repo_resource.gpg_key,
1303
  });
1304

1305
  const user_email = process.env["WM_EMAIL"] ?? "";
1306
  const user_name = process.env["WM_USERNAME"] ?? "";
1307

1308
  if (repo_resource.gpg_key) {
1309
    console.log("DEBUG: Setting up GPG signing...");
1310
    await set_gpg_signing_secret(repo_resource.gpg_key);
1311
    // Configure git with GPG key email for signing
1312
    console.log("DEBUG: Setting git user config with GPG key email...");
1313
    await sh_run(
1314
      undefined,
1315
      "git",
1316
      "config",
1317
      "user.email",
1318
      repo_resource.gpg_key.email
1319
    );
1320
    await sh_run(undefined, "git", "config", "user.name", user_name);
1321
  } else {
1322
    console.log("DEBUG: Setting git user config...");
1323
    await sh_run(undefined, "git", "config", "user.email", user_email);
1324
    await sh_run(undefined, "git", "config", "user.name", user_name);
1325
  }
1326

1327
  try {
1328
    console.log("DEBUG: Adding files to git...");
1329
    await sh_run(undefined, "git", "add", "-A", ":!./.config");
1330
    console.log("DEBUG: Files added successfully");
1331
  } catch (error) {
1332
    console.log("DEBUG: Unable to stage files:", error);
1333
  }
1334

1335
  try {
1336
    console.log("DEBUG: Checking for changes to commit...");
1337
    await sh_run(undefined, "git", "diff", "--cached", "--quiet");
1338
    console.log("DEBUG: No changes detected, returning no changes status");
1339
    return { status: "no changes pushed" };
1340
  } catch {
1341
    console.log("DEBUG: Changes detected, proceeding with commit...");
1342
    // Always use --author to set consistent authorship (matching sync script behavior)
1343
    await sh_run(
1344
      undefined,
1345
      "git",
1346
      "commit",
1347
      "--author",
1348
      `"${user_name} <${user_email}>"`,
1349
      "-m",
1350
      `"${commit_msg}"`
1351
    );
1352
    console.log("DEBUG: Commit completed successfully");
1353

1354
    try {
1355
      console.log("DEBUG: Attempting first push...");
1356
      await sh_run(undefined, "git", "push", "--set-upstream", "origin", target_branch);
1357
      console.log("DEBUG: First push succeeded");
1358
      return { status: "changes pushed" };
1359
    } catch (e) {
1360
      const errorString = e.toString();
1361

1362
      // Check if this is an empty repository error (no commits/branches yet)
1363
      if (errorString.includes("src refspec") && errorString.includes("does not match any")) {
1364
        console.log("DEBUG: Empty repository detected - setting up initial branch and push");
1365
        try {
1366
          // For empty repositories, we need to set up the branch properly
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 succeeded`);
1374
          return { status: "changes pushed" };
1375
        } catch (initialPushError) {
1376
          console.log("DEBUG: Initial push setup failed:", initialPushError);
1377
          throw initialPushError;
1378
        }
1379
      }
1380

1381
      console.log("DEBUG: First push failed, attempting rebase and retry:", e);
1382
      try {
1383
        await sh_run(undefined, "git", "pull", "--rebase");
1384
        console.log("DEBUG: Rebase completed, attempting second push...");
1385
        await sh_run(undefined, "git", "push", "--set-upstream", "origin", target_branch);
1386
        console.log("DEBUG: Second push succeeded");
1387
        return { status: "changes pushed" };
1388
      } catch (retryError) {
1389
        const retryErrorString = retryError.toString();
1390

1391
        // Check if the retry failed due to empty repository (refs/heads/main doesn't exist)
1392
        if (retryErrorString.includes("no such ref was fetched") ||
1393
            retryErrorString.includes("couldn't find remote ref")) {
1394
          console.log("DEBUG: Retry failed due to empty repository - setting up initial branch and push");
1395
          try {
1396
            // Set the current branch to the target branch name
1397
            await sh_run(undefined, "git", "branch", "-M", target_branch);
1398
            console.log(`DEBUG: Set branch to ${target_branch}`);
1399

1400
            // Push with upstream to create the initial branch
1401
            await sh_run(undefined, "git", "push", "-u", "origin", target_branch);
1402
            console.log(`DEBUG: Initial push to ${target_branch} branch after retry succeeded`);
1403
            return { status: "changes pushed" };
1404
          } catch (finalPushError) {
1405
            console.log("DEBUG: Final push attempt failed:", finalPushError);
1406
            throw finalPushError;
1407
          }
1408
        }
1409

1410
        console.log("DEBUG: Second push also failed:", retryError);
1411
        throw retryError;
1412
      }
1413
    }
1414
  }
1415
}
1416

1417
async function set_gpg_signing_secret(gpg_key: GpgKey) {
1418
  const gpg_path = "/tmp/gpg";
1419
  await sh_run(undefined, "mkdir", "-p", gpg_path);
1420
  await sh_run(undefined, "chmod", "700", gpg_path);
1421
  process.env.GNUPGHOME = gpg_path;
1422

1423
  const formatted = gpg_key.private_key.replace(
1424
    /(-----BEGIN PGP PRIVATE KEY BLOCK-----)([\s\S]*?)(-----END PGP PRIVATE KEY BLOCK-----)/,
1425
    (_, header, body, footer) =>
1426
      header + "\n\n" + body.replace(/ ([^\s])/g, "\n$1").trim() + "\n" + footer
1427
  );
1428

1429
  try {
1430
    await sh_run(
1431
      1,
1432
      "bash",
1433
      "-c",
1434
      `cat <<EOF | gpg --batch --import \n${formatted}\nEOF`
1435
    );
1436
  } catch {
1437
    throw new Error("Failed to import GPG key!");
1438
  }
1439

1440
  const keyList = await sh_run(
1441
    undefined,
1442
    "gpg",
1443
    "--list-secret-keys",
1444
    "--with-colons",
1445
    "--keyid-format=long"
1446
  );
1447
  const match = keyList.match(
1448
    /sec:[^:]*:[^:]*:[^:]*:([A-F0-9]+):.*\nfpr:::::::::([A-F0-9]{40}):/
1449
  );
1450
  if (!match) throw new Error("Failed to extract GPG Key ID and Fingerprint");
1451

1452
  const keyId = match[1];
1453
  gpgFingerprint = match[2];
1454

1455
  if (gpg_key.passphrase) {
1456
    await sh_run(
1457
      1,
1458
      "bash",
1459
      "-c",
1460
      `echo dummy | gpg --batch --pinentry-mode loopback --passphrase '${gpg_key.passphrase}' --status-fd=2 -bsau ${keyId}`
1461
    );
1462
  }
1463

1464
  await sh_run(undefined, "git", "config", "user.signingkey", keyId);
1465
  await sh_run(undefined, "git", "config", "commit.gpgsign", "true");
1466
}
1467

1468
async function delete_pgp_keys() {
1469
  if (gpgFingerprint) {
1470
    await sh_run(
1471
      undefined,
1472
      "gpg",
1473
      "--batch",
1474
      "--yes",
1475
      "--pinentry-mode",
1476
      "loopback",
1477
      "--delete-secret-key",
1478
      gpgFingerprint
1479
    );
1480
    await sh_run(
1481
      undefined,
1482
      "gpg",
1483
      "--batch",
1484
      "--yes",
1485
      "--pinentry-mode",
1486
      "loopback",
1487
      "--delete-key",
1488
      gpgFingerprint
1489
    );
1490
  }
1491
}
1492

1493
// True when the remote already authenticates itself, i.e. it has a `user@` or
1494
// `user:password@` userinfo component.
1495
function urlCarriesCredential(url: string | undefined): boolean {
1496
  return /:\/\/[^/@]+@/.test(url ?? "");
1497
}
1498

1499
// The credential Windmill stores for this repository, or undefined when it holds
1500
// none. Absence is normal (a public remote), so it must not fail the sync; the
1501
// reason is logged so a real outage is still diagnosable from the job log.
1502
async function get_stored_git_token() {
1503
  try {
1504
    return await get_gh_app_token();
1505
  } catch (error) {
1506
    console.log(
1507
      "DEBUG: no stored git credential for this repository:",
1508
      error?.message ?? String(error)
1509
    );
1510
    return undefined;
1511
  }
1512
}
1513

1514
async function get_gh_app_token() {
1515
  const workspace = process.env["WM_WORKSPACE"];
1516
  const jobToken = process.env["WM_TOKEN"];
1517
  const baseUrl =
1518
    process.env["BASE_INTERNAL_URL"] ??
1519
    process.env["BASE_URL"] ??
1520
    "http://localhost:8000";
1521
  const url = `${baseUrl}/api/w/${workspace}/github_app/token`;
1522

1523
  const response = await fetch(url, {
1524
    method: "POST",
1525
    headers: {
1526
      "Content-Type": "application/json",
1527
      Authorization: `Bearer ${jobToken}`,
1528
    },
1529
    body: JSON.stringify({ job_token: jobToken }),
1530
  });
1531

1532
  if (!response.ok) {
1533
    const errorBody = await response.text().catch(() => "");
1534
    throw new Error(`GitHub App token error (${response.status}): ${errorBody || response.statusText}`);
1535
  }
1536
  const data = await response.json();
1537
  return data.token;
1538
}
1539

1540
// Rewrite the git remote formats `new URL` can't parse into an https URL:
1541
// scp-like ssh ([user@]host:owner/repo) and scheme-less (host/owner/repo).
1542
// A host:8080/... port form is kept as authority rather than read as scp.
1543
function normalizeGitHubUrl(raw: string): string {
1544
  const cleaned = raw.trim();
1545
  if (cleaned.includes("://")) {
1546
    return cleaned;
1547
  }
1548
  const colonIdx = cleaned.indexOf(":");
1549
  if (colonIdx !== -1) {
1550
    const before = cleaned.slice(0, colonIdx);
1551
    const after = cleaned.slice(colonIdx + 1);
1552
    const firstSeg = after.split("/")[0];
1553
    // `user@host:...` is always scp form, so digits there are an owner
1554
    // (GitHub allows all-numeric ones), not a port.
1555
    const isPort = !before.includes("@") && firstSeg !== "" && /^\d+$/.test(firstSeg);
1556
    // A `@` right after the `:` means `user:token@host/...` — an authority
1557
    // (userinfo) form, not scp.
1558
    const isUserinfo = firstSeg.includes("@");
1559
    if (before !== "" && !before.includes("/") && after !== "" && !isPort && !isUserinfo) {
1560
      const host = before.split("@").pop();
1561
      return `https://${host}/${after.replace(/^\/+/, "")}`;
1562
    }
1563
  }
1564
  return `https://${cleaned}`;
1565
}
1566

1567
function prependTokenToGitHubUrl(gitHubUrl: string, installationToken: string) {
1568
  // `host` (not `hostname`) so a custom port on a self-hosted git server survives.
1569
  const url = new URL(normalizeGitHubUrl(gitHubUrl));
1570
  return `https://x-access-token:${installationToken}@${url.host}${url.pathname}`;
1571
}
1572

1573
// Authenticate a remote with a stored token. Unlike the app-token form above,
1574
// this keeps the scheme the resource chose, so a self-managed git server reached
1575
// over http is not dialled as https; `oauth2` is the username GitLab documents
1576
// for token auth, and the setters percent-encode the token.
1577
function prependTokenToGitUrl(gitUrl: string, token: string) {
1578
  const url = new URL(normalizeGitHubUrl(gitUrl));
1579
  url.username = "oauth2";
1580
  url.password = token;
1581
  return url.toString();
1582
}
1583