@aspruyt/xfg 6.0.3 → 6.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (143) hide show
  1. package/dist/cli/lifecycle-report-builder.d.ts +2 -2
  2. package/dist/cli/lifecycle-report-builder.js +3 -11
  3. package/dist/cli/program.d.ts +2 -1
  4. package/dist/cli/program.js +2 -3
  5. package/dist/cli/repo-sync-runner.d.ts +24 -0
  6. package/dist/cli/repo-sync-runner.js +156 -0
  7. package/dist/cli/results-collector.d.ts +1 -1
  8. package/dist/cli/results-collector.js +2 -2
  9. package/dist/cli/settings-factories.d.ts +7 -0
  10. package/dist/cli/settings-factories.js +27 -0
  11. package/dist/cli/settings-report-builder.d.ts +1 -1
  12. package/dist/cli/settings-report-builder.js +12 -23
  13. package/dist/cli/settings-runner.d.ts +2 -0
  14. package/dist/cli/settings-runner.js +87 -0
  15. package/dist/cli/sync-command.d.ts +1 -1
  16. package/dist/cli/sync-command.js +31 -372
  17. package/dist/cli/sync-report-builder.d.ts +1 -1
  18. package/dist/cli/sync-utils.d.ts +8 -0
  19. package/dist/cli/sync-utils.js +36 -0
  20. package/dist/cli/types.d.ts +5 -7
  21. package/dist/cli/unified-summary.d.ts +1 -3
  22. package/dist/cli/unified-summary.js +7 -5
  23. package/dist/cli.js +2 -1
  24. package/dist/{shared → config}/env.js +2 -2
  25. package/dist/config/extends-resolver.js +4 -3
  26. package/dist/config/file-reference-resolver.js +4 -2
  27. package/dist/config/formatter.js +18 -1
  28. package/dist/config/index.d.ts +1 -1
  29. package/dist/config/loader.js +30 -6
  30. package/dist/config/merge.d.ts +11 -1
  31. package/dist/config/merge.js +78 -6
  32. package/dist/config/normalizer.js +53 -38
  33. package/dist/config/validator.d.ts +1 -4
  34. package/dist/config/validator.js +13 -599
  35. package/dist/config/validators/file-validator.d.ts +2 -1
  36. package/dist/config/validators/file-validator.js +9 -1
  37. package/dist/config/validators/group-validator.d.ts +3 -0
  38. package/dist/config/validators/group-validator.js +167 -0
  39. package/dist/config/validators/repo-entry-validator.d.ts +2 -0
  40. package/dist/config/validators/repo-entry-validator.js +165 -0
  41. package/dist/config/validators/repo-settings-validator.js +18 -7
  42. package/dist/config/validators/ruleset-validator.js +2 -5
  43. package/dist/config/validators/shared.d.ts +11 -0
  44. package/dist/config/validators/shared.js +242 -0
  45. package/dist/lifecycle/ado-migration-source.js +2 -4
  46. package/dist/lifecycle/github-lifecycle-provider.d.ts +7 -11
  47. package/dist/lifecycle/github-lifecycle-provider.js +125 -136
  48. package/dist/lifecycle/{lifecycle-helpers.d.ts → helpers.d.ts} +5 -1
  49. package/dist/lifecycle/{lifecycle-helpers.js → helpers.js} +9 -8
  50. package/dist/lifecycle/index.d.ts +2 -2
  51. package/dist/lifecycle/index.js +1 -1
  52. package/dist/lifecycle/repo-lifecycle-factory.d.ts +2 -2
  53. package/dist/output/github-summary.js +2 -3
  54. package/dist/output/index.d.ts +4 -0
  55. package/dist/output/index.js +4 -0
  56. package/dist/output/lifecycle-report.d.ts +1 -1
  57. package/dist/output/lifecycle-report.js +5 -0
  58. package/dist/output/sync-report.d.ts +25 -3
  59. package/dist/output/sync-report.js +11 -11
  60. package/dist/settings/base-processor.d.ts +18 -7
  61. package/dist/settings/base-processor.js +26 -5
  62. package/dist/settings/code-scanning/diff.js +2 -2
  63. package/dist/settings/code-scanning/formatter.d.ts +2 -6
  64. package/dist/settings/code-scanning/formatter.js +2 -25
  65. package/dist/settings/code-scanning/github-code-scanning-strategy.d.ts +3 -7
  66. package/dist/settings/code-scanning/github-code-scanning-strategy.js +2 -2
  67. package/dist/settings/code-scanning/processor.js +6 -4
  68. package/dist/settings/code-scanning/types.d.ts +10 -8
  69. package/dist/settings/labels/github-labels-strategy.d.ts +3 -11
  70. package/dist/settings/labels/types.d.ts +12 -10
  71. package/dist/settings/repo-settings/diff.d.ts +1 -1
  72. package/dist/settings/repo-settings/diff.js +1 -1
  73. package/dist/settings/repo-settings/formatter.d.ts +2 -6
  74. package/dist/settings/repo-settings/formatter.js +4 -23
  75. package/dist/settings/repo-settings/github-repo-settings-strategy.d.ts +2 -2
  76. package/dist/settings/repo-settings/github-repo-settings-strategy.js +8 -7
  77. package/dist/settings/repo-settings/processor.js +11 -11
  78. package/dist/settings/repo-settings/types.d.ts +2 -2
  79. package/dist/settings/rulesets/diff-algorithm.js +4 -2
  80. package/dist/settings/rulesets/diff.js +2 -51
  81. package/dist/settings/rulesets/formatter.js +4 -0
  82. package/dist/settings/rulesets/github-ruleset-strategy.d.ts +3 -3
  83. package/dist/settings/rulesets/github-ruleset-strategy.js +4 -6
  84. package/dist/settings/rulesets/index.d.ts +1 -1
  85. package/dist/settings/rulesets/index.js +0 -2
  86. package/dist/settings/rulesets/processor.js +1 -1
  87. package/dist/settings/rulesets/types.d.ts +6 -2
  88. package/dist/shared/command-executor.d.ts +4 -4
  89. package/dist/shared/command-executor.js +9 -7
  90. package/dist/shared/diff-format.d.ts +1 -0
  91. package/dist/shared/diff-format.js +10 -0
  92. package/dist/shared/errors.d.ts +7 -4
  93. package/dist/shared/errors.js +8 -8
  94. package/dist/shared/gh-api-utils.d.ts +3 -34
  95. package/dist/shared/gh-api-utils.js +23 -53
  96. package/dist/shared/gh-token-utils.d.ts +26 -0
  97. package/dist/shared/gh-token-utils.js +32 -0
  98. package/dist/shared/json-utils.js +1 -1
  99. package/dist/shared/regex-utils.d.ts +1 -0
  100. package/dist/shared/regex-utils.js +3 -0
  101. package/dist/shared/retry-utils.d.ts +1 -0
  102. package/dist/shared/retry-utils.js +13 -7
  103. package/dist/sync/auth-options-builder.js +1 -1
  104. package/dist/sync/branch-manager.js +5 -3
  105. package/dist/sync/commit-push-manager.js +2 -3
  106. package/dist/sync/diff-utils.d.ts +0 -1
  107. package/dist/sync/diff-utils.js +5 -10
  108. package/dist/sync/file-sync-orchestrator.js +0 -2
  109. package/dist/sync/file-writer.d.ts +3 -0
  110. package/dist/sync/file-writer.js +84 -81
  111. package/dist/sync/index.d.ts +0 -1
  112. package/dist/sync/index.js +0 -1
  113. package/dist/sync/manifest.js +1 -1
  114. package/dist/sync/pr-merge-handler.js +6 -6
  115. package/dist/sync/sync-workflow.js +1 -1
  116. package/dist/sync/types.d.ts +2 -2
  117. package/dist/vcs/ado-pr-strategy.d.ts +3 -5
  118. package/dist/vcs/ado-pr-strategy.js +131 -33
  119. package/dist/vcs/authenticated-git-ops.js +45 -23
  120. package/dist/vcs/git-commit-strategy.js +10 -6
  121. package/dist/vcs/git-ops.js +30 -24
  122. package/dist/vcs/github-pr-strategy.d.ts +3 -2
  123. package/dist/vcs/github-pr-strategy.js +80 -30
  124. package/dist/vcs/gitlab-pr-strategy.d.ts +2 -5
  125. package/dist/vcs/gitlab-pr-strategy.js +88 -87
  126. package/dist/vcs/graphql-commit-strategy.d.ts +1 -5
  127. package/dist/vcs/graphql-commit-strategy.js +21 -37
  128. package/dist/vcs/pr-creator.js +9 -2
  129. package/dist/vcs/pr-strategy.d.ts +2 -3
  130. package/dist/vcs/pr-strategy.js +0 -1
  131. package/dist/vcs/types.d.ts +9 -5
  132. package/package.json +5 -5
  133. package/dist/config/validators/index.d.ts +0 -3
  134. package/dist/config/validators/index.js +0 -6
  135. package/dist/output/types.d.ts +0 -20
  136. package/dist/output/types.js +0 -1
  137. package/dist/shared/shell-utils.d.ts +0 -6
  138. package/dist/shared/shell-utils.js +0 -17
  139. /package/dist/{shared → config}/env.d.ts +0 -0
  140. /package/dist/lifecycle/{lifecycle-formatter.d.ts → formatter.d.ts} +0 -0
  141. /package/dist/lifecycle/{lifecycle-formatter.js → formatter.js} +0 -0
  142. /package/dist/{vcs → shared}/sanitize-utils.d.ts +0 -0
  143. /package/dist/{vcs → shared}/sanitize-utils.js +0 -0
@@ -1,4 +1,3 @@
1
- import { escapeShellArg } from "../shared/shell-utils.js";
2
1
  import { withRetry } from "../shared/retry-utils.js";
3
2
  import { toErrorMessage } from "../shared/type-guards.js";
4
3
  import { LifecycleError } from "../shared/errors.js";
@@ -24,9 +23,8 @@ export class AdoMigrationSource {
24
23
  }
25
24
  async cloneForMigration(repoInfo, workDir) {
26
25
  this.assertAdo(repoInfo);
27
- const command = `git clone --mirror ${escapeShellArg(repoInfo.gitUrl)} ${escapeShellArg(workDir)}`;
28
26
  try {
29
- await withRetry(() => this.executor.exec(command, this.cwd), {
27
+ await withRetry(() => this.executor.exec("git", ["clone", "--mirror", "--", repoInfo.gitUrl, workDir], this.cwd), {
30
28
  retries: this.retries,
31
29
  });
32
30
  }
@@ -34,7 +32,7 @@ export class AdoMigrationSource {
34
32
  const msg = toErrorMessage(error);
35
33
  throw new LifecycleError(`Failed to clone migration source ${repoInfo.gitUrl}: ${msg}. ` +
36
34
  `Ensure you have authentication configured for Azure DevOps ` +
37
- `(e.g., AZURE_DEVOPS_EXT_PAT or git credential helper).`);
35
+ `(e.g., AZURE_DEVOPS_EXT_PAT or git credential helper).`, { cause: error });
38
36
  }
39
37
  }
40
38
  }
@@ -1,5 +1,5 @@
1
1
  import type { ICommandExecutor } from "../shared/command-executor.js";
2
- import type { DebugWarnLog } from "../shared/logger.js";
2
+ import type { DebugInfoWarnLog } from "../shared/logger.js";
3
3
  import type { IRepoLifecycleProvider, LifecyclePlatform, LifecycleExistsParams, LifecycleCreateParams, LifecycleForkParams, LifecycleReceiveMigrationParams } from "./types.js";
4
4
  /**
5
5
  * GitHub implementation of IRepoLifecycleProvider.
@@ -13,7 +13,7 @@ interface GitHubLifecycleProviderOptions {
13
13
  forkReadyTimeoutMs?: number;
14
14
  /** Poll interval in ms for fork readiness checks (default: 2000) */
15
15
  forkPollIntervalMs?: number;
16
- log?: DebugWarnLog;
16
+ log?: DebugInfoWarnLog;
17
17
  }
18
18
  export declare class GitHubLifecycleProvider implements IRepoLifecycleProvider {
19
19
  readonly platform: LifecyclePlatform;
@@ -30,15 +30,11 @@ export declare class GitHubLifecycleProvider implements IRepoLifecycleProvider {
30
30
  */
31
31
  private isOrganization;
32
32
  private assertGitHub;
33
- /**
34
- * Builds the common gh API command prefix parts for a given repo.
35
- * Returns tokenEnv for exec options, and the command prefix string
36
- * (e.g., "gh api --hostname host repos/owner/repo").
37
- */
38
33
  private buildGhApiPrefix;
39
34
  exists(params: LifecycleExistsParams): Promise<boolean>;
40
35
  create(params: LifecycleCreateParams): Promise<void>;
41
36
  fork(params: LifecycleForkParams): Promise<void>;
37
+ private pollWithDeadline;
42
38
  /**
43
39
  * Wait for a forked repo to become available via the GitHub API.
44
40
  * GitHub forks are created asynchronously; polls exists() with a timeout.
@@ -49,6 +45,10 @@ export declare class GitHubLifecycleProvider implements IRepoLifecycleProvider {
49
45
  */
50
46
  private applyRepoSettings;
51
47
  receiveMigration(params: LifecycleReceiveMigrationParams): Promise<void>;
48
+ private removeOriginRemote;
49
+ private cleanNonStandardRefs;
50
+ private renameMirrorDefaultBranch;
51
+ private createRepoAndPushMirror;
52
52
  /**
53
53
  * Rename a branch via the GitHub branch rename API.
54
54
  * GitHub automatically updates the default branch pointer.
@@ -57,10 +57,6 @@ export declare class GitHubLifecycleProvider implements IRepoLifecycleProvider {
57
57
  /**
58
58
  * Poll until the GitHub API reports the expected default branch.
59
59
  * After a branch rename, the API may lag for a few seconds.
60
- *
61
- * Note: Uses the same executor.exec pattern as the rest of this class.
62
- * The command arguments are constructed from trusted RepoInfo values
63
- * (validated during config parsing), not user input.
64
60
  */
65
61
  private waitForDefaultBranch;
66
62
  /**
@@ -1,9 +1,8 @@
1
- import { escapeShellArg } from "../shared/shell-utils.js";
2
1
  import { withRetry, isPermanentError, DEFAULT_PERMANENT_ERROR_PATTERNS, } from "../shared/retry-utils.js";
3
2
  import { assertGitHubRepo, } from "../repo/index.js";
4
3
  import { toErrorMessage } from "../shared/type-guards.js";
5
4
  import { LifecycleError } from "../shared/errors.js";
6
- import { buildTokenEnv, getHostnameFlag } from "../shared/gh-api-utils.js";
5
+ import { buildTokenEnv, buildHostnameArgs } from "../shared/gh-api-utils.js";
7
6
  /**
8
7
  * Error messages that indicate "repo not found" vs actual errors.
9
8
  */
@@ -42,24 +41,24 @@ const POST_CREATE_PERMANENT_PATTERNS = [
42
41
  * Interval between fork readiness checks (2 seconds).
43
42
  */
44
43
  const FORK_POLL_INTERVAL_MS = 2_000;
45
- function buildRepoCreateFlags(parts, settings) {
44
+ function buildRepoCreateArgs(args, settings) {
46
45
  if (settings?.visibility === "public") {
47
- parts.push("--public");
46
+ args.push("--public");
48
47
  }
49
48
  else if (settings?.visibility === "internal") {
50
- parts.push("--internal");
49
+ args.push("--internal");
51
50
  }
52
51
  else {
53
- parts.push("--private");
52
+ args.push("--private");
54
53
  }
55
54
  if (settings?.description) {
56
- parts.push("--description", escapeShellArg(settings.description));
55
+ args.push("--description", settings.description);
57
56
  }
58
57
  if (settings?.hasIssues === false) {
59
- parts.push("--disable-issues");
58
+ args.push("--disable-issues");
60
59
  }
61
60
  if (settings?.hasWiki === false) {
62
- parts.push("--disable-wiki");
61
+ args.push("--disable-wiki");
63
62
  }
64
63
  }
65
64
  export class GitHubLifecycleProvider {
@@ -85,10 +84,11 @@ export class GitHubLifecycleProvider {
85
84
  * Uses gh api to query the user/org endpoint.
86
85
  */
87
86
  async isOrganization(owner, repoInfo, token) {
88
- const { tokenEnv, prefix } = this.buildGhApiPrefix(repoInfo, token);
89
- const command = `${prefix}users/${escapeShellArg(owner)}`;
87
+ const { tokenEnv, baseArgs } = this.buildGhApiPrefix(repoInfo, token);
90
88
  try {
91
- const stdout = await withRetry(() => this.executor.exec(command, this.cwd, { env: tokenEnv }), { retries: this.retries });
89
+ const stdout = await withRetry(() => this.executor.exec("gh", [...baseArgs, `users/${owner}`], this.cwd, {
90
+ env: tokenEnv,
91
+ }), { retries: this.retries });
92
92
  const data = JSON.parse(stdout);
93
93
  return data.type === "Organization";
94
94
  }
@@ -114,27 +114,22 @@ export class GitHubLifecycleProvider {
114
114
  assertGitHub(repoInfo) {
115
115
  assertGitHubRepo(repoInfo, "GitHubLifecycleProvider");
116
116
  }
117
- /**
118
- * Builds the common gh API command prefix parts for a given repo.
119
- * Returns tokenEnv for exec options, and the command prefix string
120
- * (e.g., "gh api --hostname host repos/owner/repo").
121
- */
122
117
  buildGhApiPrefix(repoInfo, token) {
123
118
  const tokenEnv = buildTokenEnv(token);
124
- const hostnameFlag = getHostnameFlag(repoInfo);
125
- const hostnamePart = hostnameFlag ? `${hostnameFlag} ` : "";
126
- const apiPath = `repos/${escapeShellArg(repoInfo.owner)}/${escapeShellArg(repoInfo.repo)}`;
127
- return { tokenEnv, prefix: `gh api ${hostnamePart}`, apiPath };
119
+ const hostnameArgs = buildHostnameArgs(repoInfo);
120
+ const apiPath = `repos/${repoInfo.owner}/${repoInfo.repo}`;
121
+ return { tokenEnv, baseArgs: ["api", ...hostnameArgs], apiPath };
128
122
  }
129
123
  async exists(params) {
130
124
  const { repo: repoInfo, token } = params;
131
125
  this.assertGitHub(repoInfo);
132
- const { tokenEnv, prefix, apiPath } = this.buildGhApiPrefix(repoInfo, token);
133
- const command = `${prefix}${apiPath}`;
126
+ const { tokenEnv, baseArgs, apiPath } = this.buildGhApiPrefix(repoInfo, token);
134
127
  try {
135
128
  // Note: withRetry already classifies 404/not-found as permanent errors,
136
129
  // so retries are aborted immediately for non-existent repos.
137
- await withRetry(() => this.executor.exec(command, this.cwd, { env: tokenEnv }), {
130
+ await withRetry(() => this.executor.exec("gh", [...baseArgs, apiPath], this.cwd, {
131
+ env: tokenEnv,
132
+ }), {
138
133
  retries: this.retries,
139
134
  });
140
135
  return true;
@@ -152,23 +147,23 @@ export class GitHubLifecycleProvider {
152
147
  const { repo: repoInfo, settings, token } = params;
153
148
  this.assertGitHub(repoInfo);
154
149
  const tokenEnv = buildTokenEnv(token);
155
- const parts = [
156
- "gh repo create",
157
- escapeShellArg(`${repoInfo.owner}/${repoInfo.repo}`),
150
+ const args = [
151
+ "repo",
152
+ "create",
153
+ `${repoInfo.owner}/${repoInfo.repo}`,
158
154
  ];
159
- buildRepoCreateFlags(parts, settings);
155
+ buildRepoCreateArgs(args, settings);
160
156
  // Add --add-readme to establish the default branch via an initial commit.
161
157
  // This avoids empty repos where HEAD doesn't resolve.
162
- parts.push("--add-readme");
163
- const command = parts.join(" ");
164
- await withRetry(() => this.executor.exec(command, this.cwd, { env: tokenEnv }), {
158
+ args.push("--add-readme");
159
+ await withRetry(() => this.executor.exec("gh", args, this.cwd, { env: tokenEnv }), {
165
160
  retries: this.retries,
166
161
  });
167
162
  // Rename default branch if requested and it differs from what GitHub created.
168
163
  if (settings?.defaultBranch) {
169
- const { tokenEnv: branchTokenEnv, prefix, apiPath, } = this.buildGhApiPrefix(repoInfo, token);
164
+ const { tokenEnv: branchTokenEnv, baseArgs, apiPath, } = this.buildGhApiPrefix(repoInfo, token);
170
165
  // Detect the actual default branch name
171
- const actualBranch = (await withRetry(() => this.executor.exec(`${prefix}${apiPath} --jq '.default_branch'`, this.cwd, { env: branchTokenEnv }), {
166
+ const actualBranch = (await withRetry(() => this.executor.exec("gh", [...baseArgs, apiPath, "--jq", ".default_branch"], this.cwd, { env: branchTokenEnv }), {
172
167
  retries: this.retries,
173
168
  permanentErrorPatterns: POST_CREATE_PERMANENT_PATTERNS,
174
169
  })).trim();
@@ -199,16 +194,12 @@ export class GitHubLifecycleProvider {
199
194
  // Build fork command
200
195
  // For orgs: gh repo fork <upstream> --org <target-org> --fork-name <name> --clone=false
201
196
  // For users: gh repo fork <upstream> --fork-name <name> --clone=false
202
- const parts = [
203
- "gh repo fork",
204
- escapeShellArg(`${upstream.owner}/${upstream.repo}`),
205
- ];
197
+ const forkArgs = ["repo", "fork", `${upstream.owner}/${upstream.repo}`];
206
198
  if (isOrg) {
207
- parts.push("--org", escapeShellArg(target.owner));
199
+ forkArgs.push("--org", target.owner);
208
200
  }
209
- parts.push("--fork-name", escapeShellArg(target.repo), "--clone=false");
210
- const forkCommand = parts.join(" ");
211
- await withRetry(() => this.executor.exec(forkCommand, this.cwd, { env: tokenEnv }), {
201
+ forkArgs.push("--fork-name", target.repo, "--clone=false");
202
+ await withRetry(() => this.executor.exec("gh", forkArgs, this.cwd, { env: tokenEnv }), {
212
203
  retries: this.retries,
213
204
  });
214
205
  // GitHub forks are async - wait for the fork to be ready for git operations
@@ -222,131 +213,131 @@ export class GitHubLifecycleProvider {
222
213
  await this.applyRepoSettings(target, settings, token);
223
214
  }
224
215
  }
225
- /**
226
- * Wait for a forked repo to become available via the GitHub API.
227
- * GitHub forks are created asynchronously; polls exists() with a timeout.
228
- */
229
- async waitForForkReady(repoInfo, options) {
230
- const timeoutMs = options?.timeoutMs ?? FORK_READY_TIMEOUT_MS;
231
- const intervalMs = options?.pollMs ?? FORK_POLL_INTERVAL_MS;
232
- const token = options?.token;
233
- const deadline = Date.now() + timeoutMs;
216
+ async pollWithDeadline(check, opts) {
217
+ const deadline = Date.now() + opts.timeoutMs;
234
218
  while (Date.now() < deadline) {
235
219
  try {
236
- const ready = await this.exists({ repo: repoInfo, token });
237
- if (ready) {
238
- return;
239
- }
220
+ if (await check())
221
+ return true;
240
222
  }
241
223
  catch (error) {
242
- this.log?.debug(`Polling fork readiness: ${toErrorMessage(error)}`);
224
+ this.log?.debug(`Polling ${opts.debugLabel}: ${toErrorMessage(error)}`);
243
225
  }
244
226
  const remaining = deadline - Date.now();
245
227
  if (remaining <= 0)
246
228
  break;
247
- await new Promise((resolve) => setTimeout(resolve, Math.min(intervalMs, remaining)));
229
+ await new Promise((resolve) => setTimeout(resolve, Math.min(opts.pollMs, remaining)));
230
+ }
231
+ return false;
232
+ }
233
+ /**
234
+ * Wait for a forked repo to become available via the GitHub API.
235
+ * GitHub forks are created asynchronously; polls exists() with a timeout.
236
+ */
237
+ async waitForForkReady(repoInfo, options) {
238
+ const timeoutMs = options?.timeoutMs ?? FORK_READY_TIMEOUT_MS;
239
+ const pollMs = options?.pollMs ?? FORK_POLL_INTERVAL_MS;
240
+ const token = options?.token;
241
+ const ready = await this.pollWithDeadline(() => this.exists({ repo: repoInfo, token }), { timeoutMs, pollMs, debugLabel: "fork readiness" });
242
+ if (!ready) {
243
+ throw new LifecycleError(`Timed out waiting for fork ${repoInfo.owner}/${repoInfo.repo} to become available ` +
244
+ `after ${timeoutMs / 1000}s. The fork may still be processing on GitHub.`);
248
245
  }
249
- throw new LifecycleError(`Timed out waiting for fork ${repoInfo.owner}/${repoInfo.repo} to become available ` +
250
- `after ${timeoutMs / 1000}s. The fork may still be processing on GitHub.`);
251
246
  }
252
247
  /**
253
248
  * Apply settings to an existing repo using gh repo edit.
254
249
  */
255
250
  async applyRepoSettings(repoInfo, settings, token) {
256
251
  const tokenEnv = buildTokenEnv(token);
257
- const parts = [
258
- "gh repo edit",
259
- escapeShellArg(`${repoInfo.owner}/${repoInfo.repo}`),
260
- ];
252
+ const args = ["repo", "edit", `${repoInfo.owner}/${repoInfo.repo}`];
261
253
  if (settings.visibility) {
262
- parts.push("--visibility", settings.visibility, "--accept-visibility-change-consequences");
254
+ args.push("--visibility", settings.visibility, "--accept-visibility-change-consequences");
263
255
  }
264
256
  if (settings.description) {
265
- parts.push("--description", escapeShellArg(settings.description));
257
+ args.push("--description", settings.description);
266
258
  }
267
- const command = parts.join(" ");
268
- await withRetry(() => this.executor.exec(command, this.cwd, { env: tokenEnv }), {
259
+ await withRetry(() => this.executor.exec("gh", args, this.cwd, { env: tokenEnv }), {
269
260
  retries: this.retries,
270
261
  });
271
262
  }
272
263
  async receiveMigration(params) {
273
264
  const { repo: repoInfo, sourceDir, settings, token } = params;
274
265
  this.assertGitHub(repoInfo);
275
- const tokenEnv = buildTokenEnv(token);
276
- // Remove existing "origin" remote if present (e.g., from git clone --mirror).
277
- // gh repo create --source --push needs to set its own origin remote.
266
+ await this.removeOriginRemote(sourceDir);
267
+ await this.cleanNonStandardRefs(sourceDir);
268
+ await this.renameMirrorDefaultBranch(sourceDir, settings?.defaultBranch);
269
+ await this.createRepoAndPushMirror(repoInfo, sourceDir, settings, token);
270
+ }
271
+ async removeOriginRemote(sourceDir) {
278
272
  try {
279
- await this.executor.exec(`git -C ${escapeShellArg(sourceDir)} remote remove origin`, this.cwd);
273
+ await this.executor.exec("git", ["-C", sourceDir, "remote", "remove", "origin"], this.cwd);
280
274
  }
281
275
  catch (error) {
282
276
  this.log?.debug(`Cleanup: remote remove origin skipped - ${toErrorMessage(error)}`);
283
277
  }
284
- // Remove all non-standard refs that GitHub rejects on push.
285
- // Mirror clones include ALL refs from the source, but GitHub only
286
- // accepts branches (refs/heads/*) and tags (refs/tags/*).
287
- // Other refs like refs/pull/* (GitHub), refs/merge-requests/* (GitLab),
288
- // refs/keep-around/* etc. must be removed.
278
+ }
279
+ // Mirror clones include ALL refs from the source, but GitHub only
280
+ // accepts branches (refs/heads/*) and tags (refs/tags/*).
281
+ async cleanNonStandardRefs(sourceDir) {
289
282
  try {
290
- const allRefs = await this.executor.exec(`git -C ${escapeShellArg(sourceDir)} for-each-ref --format='%(refname)'`, this.cwd);
283
+ const allRefs = await this.executor.exec("git", ["-C", sourceDir, "for-each-ref", "--format=%(refname)"], this.cwd);
291
284
  for (const ref of allRefs.split("\n").filter((r) => r.trim())) {
292
285
  const trimmed = ref.trim();
293
286
  if (!trimmed.startsWith("refs/heads/") &&
294
287
  !trimmed.startsWith("refs/tags/")) {
295
- await this.executor.exec(`git -C ${escapeShellArg(sourceDir)} update-ref -d ${escapeShellArg(trimmed)}`, this.cwd);
288
+ await this.executor.exec("git", ["-C", sourceDir, "update-ref", "-d", trimmed], this.cwd);
296
289
  }
297
290
  }
298
291
  }
299
292
  catch (error) {
300
293
  this.log?.debug(`Cleanup: ref cleanup skipped - ${toErrorMessage(error)}`);
301
294
  }
302
- // Rename default branch in mirror clone if requested.
303
- if (settings?.defaultBranch) {
304
- const headRef = (await this.executor.exec(`git -C ${escapeShellArg(sourceDir)} symbolic-ref HEAD`, this.cwd)).trim();
305
- const prefix = "refs/heads/";
306
- if (!headRef.startsWith(prefix)) {
307
- throw new LifecycleError(`Mirror clone HEAD symbolic-ref is '${headRef}', expected to start with '${prefix}'. ` +
308
- `Cannot rename default branch.`);
309
- }
310
- const sourceBranch = headRef.slice(prefix.length);
311
- if (sourceBranch !== settings.defaultBranch) {
312
- await this.executor.exec(`git -C ${escapeShellArg(sourceDir)} branch -m ${escapeShellArg(sourceBranch)} ${escapeShellArg(settings.defaultBranch)}`, this.cwd);
313
- await this.executor.exec(`git -C ${escapeShellArg(sourceDir)} symbolic-ref HEAD refs/heads/${escapeShellArg(settings.defaultBranch)}`, this.cwd);
314
- }
295
+ }
296
+ async renameMirrorDefaultBranch(sourceDir, targetBranch) {
297
+ if (!targetBranch)
298
+ return;
299
+ const headRef = (await this.executor.exec("git", ["-C", sourceDir, "symbolic-ref", "HEAD"], this.cwd)).trim();
300
+ const prefix = "refs/heads/";
301
+ if (!headRef.startsWith(prefix)) {
302
+ throw new LifecycleError(`Mirror clone HEAD symbolic-ref is '${headRef}', expected to start with '${prefix}'. ` +
303
+ `Cannot rename default branch.`);
304
+ }
305
+ const sourceBranch = headRef.slice(prefix.length);
306
+ if (sourceBranch !== targetBranch) {
307
+ await this.executor.exec("git", ["-C", sourceDir, "branch", "-m", sourceBranch, targetBranch], this.cwd);
308
+ await this.executor.exec("git", ["-C", sourceDir, "symbolic-ref", "HEAD", `refs/heads/${targetBranch}`], this.cwd);
315
309
  }
316
- // Split create and push into two steps. gh repo create --source --push
317
- // does both atomically, but if the git backend hasn't propagated after
318
- // the GraphQL create, the push fails with "Repository not found". A
319
- // retry then hits "Name already exists" on the create step.
310
+ }
311
+ async createRepoAndPushMirror(repoInfo, sourceDir, settings, token) {
312
+ // Split create and push: gh repo create --source --push does both
313
+ // atomically, but if the git backend hasn't propagated after the
314
+ // GraphQL create, the push fails with "Repository not found".
320
315
  const repoSlug = `${repoInfo.owner}/${repoInfo.repo}`;
321
- const createParts = ["gh repo create", escapeShellArg(repoSlug)];
322
- buildRepoCreateFlags(createParts, settings);
316
+ const tokenEnv = buildTokenEnv(token);
317
+ const createArgs = ["repo", "create", repoSlug];
318
+ buildRepoCreateArgs(createArgs, settings);
323
319
  try {
324
- await withRetry(() => this.executor.exec(createParts.join(" "), this.cwd, {
325
- env: tokenEnv,
326
- }), {
320
+ await withRetry(() => this.executor.exec("gh", createArgs, this.cwd, { env: tokenEnv }), {
327
321
  retries: this.retries,
328
322
  permanentErrorPatterns: POST_CREATE_PERMANENT_PATTERNS,
329
323
  log: this.log
330
- ? { info: (m) => this.log.warn(m) }
324
+ ? { info: (m) => this.log.info(m) }
331
325
  : undefined,
332
326
  });
333
327
  }
334
328
  catch (error) {
335
- if (!/already\s*exists/i.test(toErrorMessage(error))) {
329
+ if (!isPermanentError(error, [/already\s*exists/i])) {
336
330
  throw error;
337
331
  }
338
332
  }
339
- // Push mirror content via authenticated URL. Retries handle the git
340
- // backend propagation delay (POST_CREATE_PERMANENT_PATTERNS allows
341
- // retry on 404/not-found).
342
333
  const remoteUrl = token
343
334
  ? `https://x-access-token:${token}@${repoInfo.host}/${repoSlug}.git`
344
335
  : `https://${repoInfo.host}/${repoSlug}.git`;
345
- await this.executor.exec(`git -C ${escapeShellArg(sourceDir)} remote add origin ${escapeShellArg(remoteUrl)}`, this.cwd);
346
- await withRetry(() => this.executor.exec(`git -C ${escapeShellArg(sourceDir)} push --mirror origin`, this.cwd, { env: tokenEnv }), {
336
+ await this.executor.exec("git", ["-C", sourceDir, "remote", "add", "origin", remoteUrl], this.cwd);
337
+ await withRetry(() => this.executor.exec("git", ["-C", sourceDir, "push", "--mirror", "origin"], this.cwd, { env: tokenEnv }), {
347
338
  retries: this.retries,
348
339
  permanentErrorPatterns: POST_CREATE_PERMANENT_PATTERNS,
349
- log: this.log ? { info: (m) => this.log.warn(m) } : undefined,
340
+ log: this.log ? { info: (m) => this.log.info(m) } : undefined,
350
341
  });
351
342
  }
352
343
  /**
@@ -354,41 +345,31 @@ export class GitHubLifecycleProvider {
354
345
  * GitHub automatically updates the default branch pointer.
355
346
  */
356
347
  async renameBranch(repoInfo, current, desired, token) {
357
- const { tokenEnv, prefix, apiPath } = this.buildGhApiPrefix(repoInfo, token);
358
- await withRetry(() => this.executor.exec(`${prefix}${apiPath}/branches/${escapeShellArg(current)}/rename ` +
359
- `--method POST -f new_name=${escapeShellArg(desired)}`, this.cwd, { env: tokenEnv }), {
348
+ const { tokenEnv, baseArgs, apiPath } = this.buildGhApiPrefix(repoInfo, token);
349
+ await withRetry(() => this.executor.exec("gh", [
350
+ ...baseArgs,
351
+ `${apiPath}/branches/${current}/rename`,
352
+ "--method",
353
+ "POST",
354
+ "-f",
355
+ `new_name=${desired}`,
356
+ ], this.cwd, { env: tokenEnv }), {
360
357
  retries: this.retries,
361
358
  });
362
359
  }
363
360
  /**
364
361
  * Poll until the GitHub API reports the expected default branch.
365
362
  * After a branch rename, the API may lag for a few seconds.
366
- *
367
- * Note: Uses the same executor.exec pattern as the rest of this class.
368
- * The command arguments are constructed from trusted RepoInfo values
369
- * (validated during config parsing), not user input.
370
363
  */
371
364
  async waitForDefaultBranch(repoInfo, expectedBranch, options) {
372
365
  const timeoutMs = options?.timeoutMs ?? 15000;
373
366
  const pollMs = options?.pollMs ?? 1000;
374
- const { tokenEnv, prefix, apiPath } = this.buildGhApiPrefix(repoInfo, options?.token);
375
- const deadline = Date.now() + timeoutMs;
376
- while (Date.now() < deadline) {
377
- try {
378
- const branch = (await this.executor.exec(`${prefix}${apiPath} --jq '.default_branch'`, this.cwd, { env: tokenEnv })).trim();
379
- if (branch === expectedBranch) {
380
- return;
381
- }
382
- }
383
- catch (error) {
384
- this.log?.debug(`Polling default branch: ${toErrorMessage(error)}`);
385
- }
386
- const remaining = deadline - Date.now();
387
- if (remaining <= 0)
388
- break;
389
- await new Promise((resolve) => setTimeout(resolve, Math.min(pollMs, remaining)));
390
- }
391
- // Don't throw — rename succeeded, this is just a best-effort wait
367
+ const { tokenEnv, baseArgs, apiPath } = this.buildGhApiPrefix(repoInfo, options?.token);
368
+ // Best-effort wait don't throw on timeout since rename already succeeded
369
+ await this.pollWithDeadline(async () => {
370
+ const branch = (await this.executor.exec("gh", [...baseArgs, apiPath, "--jq", ".default_branch"], this.cwd, { env: tokenEnv })).trim();
371
+ return branch === expectedBranch;
372
+ }, { timeoutMs, pollMs, debugLabel: "default branch" });
392
373
  }
393
374
  /**
394
375
  * Delete the README.md that --add-readme creates.
@@ -396,16 +377,24 @@ export class GitHubLifecycleProvider {
396
377
  * commit) but no files, so xfg sync starts from a clean state.
397
378
  */
398
379
  async deleteReadme(repoInfo, token) {
399
- const { tokenEnv, prefix, apiPath } = this.buildGhApiPrefix(repoInfo, token);
380
+ const { tokenEnv, baseArgs, apiPath } = this.buildGhApiPrefix(repoInfo, token);
400
381
  // Get the SHA of the README.md created by --add-readme
401
- const fileInfo = await withRetry(() => this.executor.exec(`${prefix}${apiPath}/contents/README.md --jq '.sha'`, this.cwd, { env: tokenEnv }), {
382
+ const fileInfo = await withRetry(() => this.executor.exec("gh", [...baseArgs, `${apiPath}/contents/README.md`, "--jq", ".sha"], this.cwd, { env: tokenEnv }), {
402
383
  retries: this.retries,
403
384
  permanentErrorPatterns: POST_CREATE_PERMANENT_PATTERNS,
404
385
  });
405
386
  const sha = fileInfo.trim();
406
387
  // Delete the README.md to leave the repo clean
407
- await withRetry(() => this.executor.exec(`${prefix}${apiPath}/contents/README.md ` +
408
- `--method DELETE -f message='Remove initialization file' -f sha=${escapeShellArg(sha)}`, this.cwd, { env: tokenEnv }), {
388
+ await withRetry(() => this.executor.exec("gh", [
389
+ ...baseArgs,
390
+ `${apiPath}/contents/README.md`,
391
+ "--method",
392
+ "DELETE",
393
+ "-f",
394
+ "message=Remove initialization file",
395
+ "-f",
396
+ `sha=${sha}`,
397
+ ], this.cwd, { env: tokenEnv }), {
409
398
  retries: this.retries,
410
399
  permanentErrorPatterns: POST_CREATE_PERMANENT_PATTERNS,
411
400
  });
@@ -20,10 +20,14 @@ interface LifecycleCheckOptions {
20
20
  * Extracts only the fields relevant for repo creation.
21
21
  */
22
22
  export declare function toCreateRepoSettings(repo: GitHubRepoSettings | undefined): CreateRepoSettings | undefined;
23
+ export interface LifecycleReportSettings {
24
+ visibility?: CreateRepoSettings["visibility"];
25
+ description?: CreateRepoSettings["description"];
26
+ }
23
27
  export interface LifecycleCheckResult {
24
28
  lifecycleResult: LifecycleResult;
25
29
  outputLines: string[];
26
- createSettings: CreateRepoSettings | undefined;
30
+ reportSettings: LifecycleReportSettings | undefined;
27
31
  }
28
32
  /**
29
33
  * Run lifecycle check for a single repo.
@@ -1,6 +1,6 @@
1
1
  import { resolve, join } from "node:path";
2
2
  import { generateWorkspaceName } from "../shared/workspace-utils.js";
3
- import { formatLifecycleAction } from "./lifecycle-formatter.js";
3
+ import { formatLifecycleAction } from "./formatter.js";
4
4
  /**
5
5
  * Build CreateRepoSettings from GitHubRepoSettings.
6
6
  * Extracts only the fields relevant for repo creation.
@@ -35,15 +35,16 @@ export async function runLifecycleCheck(repoConfig, repoInfo, options) {
35
35
  githubHosts: options.githubHosts,
36
36
  token: options.token,
37
37
  }, createSettings);
38
+ const reportSettings = createSettings
39
+ ? {
40
+ visibility: createSettings.visibility,
41
+ description: createSettings.description,
42
+ }
43
+ : undefined;
38
44
  const outputLines = formatLifecycleAction(lifecycleResult, {
39
45
  upstream: repoConfig.upstream,
40
46
  source: repoConfig.source,
41
- settings: createSettings
42
- ? {
43
- visibility: createSettings.visibility,
44
- description: createSettings.description,
45
- }
46
- : undefined,
47
+ settings: reportSettings,
47
48
  });
48
- return { lifecycleResult, outputLines, createSettings };
49
+ return { lifecycleResult, outputLines, reportSettings };
49
50
  }
@@ -1,3 +1,3 @@
1
- export type { IRepoLifecycleManager } from "./types.js";
1
+ export type { IRepoLifecycleManager, LifecycleActionKind } from "./types.js";
2
2
  export { RepoLifecycleManager } from "./repo-lifecycle-manager.js";
3
- export { runLifecycleCheck, toCreateRepoSettings, type LifecycleCheckResult, } from "./lifecycle-helpers.js";
3
+ export { runLifecycleCheck, toCreateRepoSettings, type LifecycleCheckResult, } from "./helpers.js";
@@ -1,2 +1,2 @@
1
1
  export { RepoLifecycleManager } from "./repo-lifecycle-manager.js";
2
- export { runLifecycleCheck, toCreateRepoSettings, } from "./lifecycle-helpers.js";
2
+ export { runLifecycleCheck, toCreateRepoSettings, } from "./helpers.js";
@@ -1,5 +1,5 @@
1
1
  import type { ICommandExecutor } from "../shared/command-executor.js";
2
- import type { DebugWarnLog } from "../shared/logger.js";
2
+ import type { DebugInfoWarnLog } from "../shared/logger.js";
3
3
  import type { IRepoLifecycleFactory, IRepoLifecycleProvider, IMigrationSource, LifecyclePlatform } from "./types.js";
4
4
  export declare class RepoLifecycleFactory implements IRepoLifecycleFactory {
5
5
  private readonly providers;
@@ -8,7 +8,7 @@ export declare class RepoLifecycleFactory implements IRepoLifecycleFactory {
8
8
  private readonly retries;
9
9
  private readonly cwd;
10
10
  private readonly log?;
11
- constructor(executor: ICommandExecutor, retries: number | undefined, cwd: string, log?: DebugWarnLog);
11
+ constructor(executor: ICommandExecutor, retries: number | undefined, cwd: string, log?: DebugInfoWarnLog);
12
12
  getProvider(platform: LifecyclePlatform): IRepoLifecycleProvider;
13
13
  getMigrationSource(platform: LifecyclePlatform): IMigrationSource;
14
14
  }
@@ -5,11 +5,10 @@ import { toErrorMessage } from "../shared/type-guards.js";
5
5
  * No-op if summaryPath is not provided.
6
6
  */
7
7
  export function writeGitHubStepSummary(markdown, summaryPath, log) {
8
- const path = summaryPath;
9
- if (!path)
8
+ if (!summaryPath)
10
9
  return;
11
10
  try {
12
- appendFileSync(path, "\n" + markdown + "\n");
11
+ appendFileSync(summaryPath, "\n" + markdown + "\n");
13
12
  }
14
13
  catch (error) {
15
14
  log?.debug(`Failed to write GitHub step summary: ${toErrorMessage(error)}`);
@@ -0,0 +1,4 @@
1
+ export { writeGitHubStepSummary } from "./github-summary.js";
2
+ export { hasLifecycleChanges, formatLifecycleReportCLI, formatLifecycleReportMarkdown, writeLifecycleReportSummary, type LifecycleReport, type LifecycleAction, } from "./lifecycle-report.js";
3
+ export { formatCountEntry, formatSettingsReportCLI, renderRepoSettingsDiffLines, formatSettingsReportMarkdown, writeSettingsReportSummary, type SettingsReport, type RepoChanges, type SettingChange, type RulesetChange, type LabelChange, } from "./settings-report.js";
4
+ export { formatSyncReportCLI, formatSyncReportMarkdown, renderSyncLines, writeSyncReportSummary, type SyncReport, type RepoFileChanges, type ReportFileChange, } from "./sync-report.js";
@@ -0,0 +1,4 @@
1
+ export { writeGitHubStepSummary } from "./github-summary.js";
2
+ export { hasLifecycleChanges, formatLifecycleReportCLI, formatLifecycleReportMarkdown, writeLifecycleReportSummary, } from "./lifecycle-report.js";
3
+ export { formatCountEntry, formatSettingsReportCLI, renderRepoSettingsDiffLines, formatSettingsReportMarkdown, writeSettingsReportSummary, } from "./settings-report.js";
4
+ export { formatSyncReportCLI, formatSyncReportMarkdown, renderSyncLines, writeSyncReportSummary, } from "./sync-report.js";
@@ -1,4 +1,4 @@
1
- import type { LifecycleActionKind } from "../lifecycle/types.js";
1
+ import type { LifecycleActionKind } from "../lifecycle/index.js";
2
2
  import type { RepoVisibility } from "../config/index.js";
3
3
  export interface LifecycleReport {
4
4
  actions: LifecycleAction[];
@@ -33,6 +33,11 @@ function renderActionDiffLines(actions) {
33
33
  case "migrated":
34
34
  lines.push(`+ MIGRATE ${action.source ?? "source"} -> ${action.repoName}`);
35
35
  break;
36
+ /* c8 ignore next 4 */
37
+ default: {
38
+ const _exhaustive = action.action;
39
+ throw new Error(`Unexpected lifecycle action: ${_exhaustive}`);
40
+ }
36
41
  }
37
42
  if (action.settings) {
38
43
  if (action.settings.visibility) {