@aspruyt/xfg 3.13.0 → 4.0.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 (44) hide show
  1. package/README.md +1 -4
  2. package/dist/cli/index.d.ts +0 -2
  3. package/dist/cli/index.js +0 -1
  4. package/dist/cli/program.js +0 -12
  5. package/dist/cli/sync-command.d.ts +2 -3
  6. package/dist/cli/sync-command.js +143 -4
  7. package/dist/cli/types.d.ts +11 -4
  8. package/dist/config/index.d.ts +1 -1
  9. package/dist/config/index.js +1 -1
  10. package/dist/config/validator.d.ts +0 -5
  11. package/dist/config/validator.js +13 -30
  12. package/dist/index.d.ts +2 -2
  13. package/dist/index.js +1 -1
  14. package/dist/lifecycle/github-lifecycle-provider.d.ts +9 -0
  15. package/dist/lifecycle/github-lifecycle-provider.js +31 -0
  16. package/dist/settings/labels/diff.d.ts +2 -2
  17. package/dist/settings/labels/diff.js +15 -19
  18. package/dist/settings/labels/processor.d.ts +0 -10
  19. package/dist/settings/labels/processor.js +3 -16
  20. package/dist/settings/rulesets/diff.d.ts +2 -2
  21. package/dist/settings/rulesets/diff.js +6 -8
  22. package/dist/settings/rulesets/processor.d.ts +0 -10
  23. package/dist/settings/rulesets/processor.js +3 -18
  24. package/dist/sync/index.d.ts +1 -2
  25. package/dist/sync/index.js +1 -2
  26. package/dist/sync/manifest.d.ts +3 -45
  27. package/dist/sync/manifest.js +46 -166
  28. package/dist/sync/repository-processor.d.ts +1 -6
  29. package/dist/sync/repository-processor.js +2 -52
  30. package/dist/sync/types.d.ts +0 -4
  31. package/dist/vcs/graphql-commit-strategy.js +15 -1
  32. package/package.json +1 -1
  33. package/dist/cli/settings/lifecycle-checks.d.ts +0 -11
  34. package/dist/cli/settings/lifecycle-checks.js +0 -64
  35. package/dist/cli/settings/process-labels.d.ts +0 -9
  36. package/dist/cli/settings/process-labels.js +0 -125
  37. package/dist/cli/settings/process-repo-settings.d.ts +0 -9
  38. package/dist/cli/settings/process-repo-settings.js +0 -80
  39. package/dist/cli/settings/process-rulesets.d.ts +0 -9
  40. package/dist/cli/settings/process-rulesets.js +0 -118
  41. package/dist/cli/settings-command.d.ts +0 -11
  42. package/dist/cli/settings-command.js +0 -90
  43. package/dist/sync/manifest-strategy.d.ts +0 -21
  44. package/dist/sync/manifest-strategy.js +0 -67
package/README.md CHANGED
@@ -47,11 +47,8 @@ npm install -g @aspruyt/xfg
47
47
  # Authenticate (GitHub)
48
48
  gh auth login
49
49
 
50
- # Sync files across repos
50
+ # Sync files, settings, rulesets, and labels across repos
51
51
  xfg sync --config ./config.yaml
52
-
53
- # Apply repository settings and rulesets
54
- xfg settings --config ./config.yaml
55
52
  ```
56
53
 
57
54
  ### Example Config
@@ -1,6 +1,4 @@
1
1
  export { runSync } from "./sync-command.js";
2
- export { runSettings } from "./settings-command.js";
3
2
  export { program } from "./program.js";
4
3
  export { type IRepositoryProcessor, type ProcessorFactory, type IRulesetProcessor, type RulesetProcessorFactory, type RepoSettingsProcessorFactory, type IRepoSettingsProcessor, type ILabelsProcessor, type LabelsProcessorFactory, defaultProcessorFactory, defaultRulesetProcessorFactory, defaultRepoSettingsProcessorFactory, defaultLabelsProcessorFactory, } from "./types.js";
5
4
  export type { SyncOptions, SharedOptions } from "./sync-command.js";
6
- export type { SettingsOptions } from "./settings-command.js";
package/dist/cli/index.js CHANGED
@@ -1,6 +1,5 @@
1
1
  // CLI command implementations
2
2
  export { runSync } from "./sync-command.js";
3
- export { runSettings } from "./settings-command.js";
4
3
  export { program } from "./program.js";
5
4
  // Export types - using 'export type' for type aliases, but interfaces need special handling
6
5
  // For ESM compatibility, re-export everything from types.js
@@ -3,7 +3,6 @@ import { dirname, join } from "node:path";
3
3
  import { readFileSync } from "node:fs";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { runSync } from "./sync-command.js";
6
- import { runSettings } from "./settings-command.js";
7
6
  // Get version from package.json
8
7
  const __filename = fileURLToPath(import.meta.url);
9
8
  const __dirname = dirname(__filename);
@@ -56,15 +55,4 @@ const syncCommand = new Command("sync")
56
55
  });
57
56
  addSharedOptions(syncCommand);
58
57
  program.addCommand(syncCommand);
59
- // Settings command (repository settings and rulesets)
60
- const settingsCommand = new Command("settings")
61
- .description("Manage GitHub repository settings and rulesets")
62
- .action((opts) => {
63
- runSettings(opts).catch((error) => {
64
- console.error("Fatal error:", error);
65
- process.exit(1);
66
- });
67
- });
68
- addSharedOptions(settingsCommand);
69
- program.addCommand(settingsCommand);
70
58
  export { program };
@@ -1,6 +1,5 @@
1
1
  import { MergeMode, MergeStrategy } from "../config/index.js";
2
- import { ProcessorFactory } from "./types.js";
3
- import { type IRepoLifecycleManager } from "../lifecycle/index.js";
2
+ import { type SyncDependencies } from "./types.js";
4
3
  /**
5
4
  * Shared options common to all commands.
6
5
  */
@@ -23,4 +22,4 @@ export interface SyncOptions extends SharedOptions {
23
22
  /**
24
23
  * Run the sync command - synchronizes files across repositories.
25
24
  */
26
- export declare function runSync(options: SyncOptions, processorFactory?: ProcessorFactory, lifecycleManager?: IRepoLifecycleManager): Promise<void>;
25
+ export declare function runSync(options: SyncOptions, deps?: SyncDependencies): Promise<void>;
@@ -7,7 +7,10 @@ import { sanitizeBranchName, validateBranchName } from "../vcs/git-ops.js";
7
7
  import { hasGitHubAppCredentials, GitHubAppTokenManager, } from "../vcs/index.js";
8
8
  import { logger } from "../shared/logger.js";
9
9
  import { generateWorkspaceName } from "../shared/workspace-utils.js";
10
- import { defaultProcessorFactory } from "./types.js";
10
+ import { defaultProcessorFactory, defaultRulesetProcessorFactory, defaultRepoSettingsProcessorFactory, defaultLabelsProcessorFactory, } from "./types.js";
11
+ import { ResultsCollector } from "./settings/results-collector.js";
12
+ import { buildSettingsReport } from "./settings-report-builder.js";
13
+ import { formatSettingsReportCLI } from "../output/settings-report.js";
11
14
  import { buildSyncReport } from "./sync-report-builder.js";
12
15
  import { formatSyncReportCLI } from "../output/sync-report.js";
13
16
  import { buildLifecycleReport, formatLifecycleReportCLI, hasLifecycleChanges, } from "../output/lifecycle-report.js";
@@ -63,7 +66,8 @@ function determineMergeOutcome(result) {
63
66
  /**
64
67
  * Run the sync command - synchronizes files across repositories.
65
68
  */
66
- export async function runSync(options, processorFactory = defaultProcessorFactory, lifecycleManager) {
69
+ export async function runSync(options, deps = {}) {
70
+ const { processorFactory = defaultProcessorFactory, lifecycleManager, rulesetProcessorFactory = defaultRulesetProcessorFactory, repoSettingsProcessorFactory = defaultRepoSettingsProcessorFactory, labelsProcessorFactory = defaultLabelsProcessorFactory, } = deps;
67
71
  const configPath = resolve(options.config);
68
72
  if (!existsSync(configPath)) {
69
73
  console.error(`Config file not found: ${configPath}`);
@@ -102,6 +106,7 @@ export async function runSync(options, processorFactory = defaultProcessorFactor
102
106
  : null;
103
107
  const reportResults = [];
104
108
  const lifecycleReportInputs = [];
109
+ const settingsCollector = new ResultsCollector();
105
110
  for (let i = 0; i < config.repos.length; i++) {
106
111
  const repoConfig = config.repos[i];
107
112
  if (options.merge || options.mergeStrategy || options.deleteBranch) {
@@ -230,6 +235,125 @@ export async function runSync(options, processorFactory = defaultProcessorFactor
230
235
  error: error instanceof Error ? error.message : String(error),
231
236
  });
232
237
  }
238
+ // After file sync, apply settings via API (GitHub-only — ADO and GitLab repos are skipped)
239
+ if (repoConfig.settings && isGitHubRepo(repoInfo)) {
240
+ const githubRepo = repoInfo;
241
+ let settingsToken;
242
+ try {
243
+ settingsToken =
244
+ (await tokenManager?.getTokenForRepo(githubRepo)) ??
245
+ process.env.GH_TOKEN;
246
+ }
247
+ catch {
248
+ settingsToken = process.env.GH_TOKEN;
249
+ }
250
+ // Apply rulesets
251
+ if (repoConfig.settings.rulesets &&
252
+ Object.keys(repoConfig.settings.rulesets).length > 0) {
253
+ try {
254
+ const rulesetProcessor = rulesetProcessorFactory();
255
+ const rulesetResult = await rulesetProcessor.process(repoConfig, repoInfo, {
256
+ dryRun: options.dryRun,
257
+ noDelete: options.noDelete,
258
+ token: settingsToken,
259
+ });
260
+ if (rulesetResult.planOutput?.lines?.length) {
261
+ logger.info("");
262
+ logger.info(`${repoName} - Rulesets:`);
263
+ for (const line of rulesetResult.planOutput.lines) {
264
+ logger.info(line);
265
+ }
266
+ }
267
+ else if (!rulesetResult.skipped && rulesetResult.success) {
268
+ logger.success(current, repoName, `Rulesets: ${rulesetResult.message}`);
269
+ }
270
+ if (!rulesetResult.skipped) {
271
+ settingsCollector.getOrCreate(repoName).rulesetResult =
272
+ rulesetResult;
273
+ }
274
+ if (!rulesetResult.success && !rulesetResult.skipped) {
275
+ logger.error(current, repoName, `Rulesets: ${rulesetResult.message}`);
276
+ settingsCollector.appendError(repoName, rulesetResult.message);
277
+ }
278
+ }
279
+ catch (error) {
280
+ logger.error(current, repoName, `Rulesets: ${String(error)}`);
281
+ settingsCollector.appendError(repoName, error);
282
+ }
283
+ }
284
+ // Apply labels
285
+ if (repoConfig.settings.labels &&
286
+ Object.keys(repoConfig.settings.labels).length > 0) {
287
+ try {
288
+ const labelsProcessor = labelsProcessorFactory();
289
+ const labelsResult = await labelsProcessor.process(repoConfig, repoInfo, {
290
+ dryRun: options.dryRun,
291
+ noDelete: options.noDelete,
292
+ token: settingsToken,
293
+ });
294
+ if (labelsResult.planOutput?.lines?.length) {
295
+ logger.info("");
296
+ logger.info(`${repoName} - Labels:`);
297
+ for (const line of labelsResult.planOutput.lines) {
298
+ logger.info(line);
299
+ }
300
+ }
301
+ else if (!labelsResult.skipped && labelsResult.success) {
302
+ logger.success(current, repoName, `Labels: ${labelsResult.message}`);
303
+ }
304
+ if (!labelsResult.skipped) {
305
+ settingsCollector.getOrCreate(repoName).labelsResult = labelsResult;
306
+ }
307
+ if (!labelsResult.success && !labelsResult.skipped) {
308
+ logger.error(current, repoName, `Labels: ${labelsResult.message}`);
309
+ settingsCollector.appendError(repoName, labelsResult.message);
310
+ }
311
+ }
312
+ catch (error) {
313
+ logger.error(current, repoName, `Labels: ${String(error)}`);
314
+ settingsCollector.appendError(repoName, error);
315
+ }
316
+ }
317
+ // Apply repo settings
318
+ if (repoConfig.settings.repo &&
319
+ Object.keys(repoConfig.settings.repo).length > 0) {
320
+ try {
321
+ const repoSettingsProcessor = repoSettingsProcessorFactory();
322
+ const repoSettingsResult = await repoSettingsProcessor.process(repoConfig, repoInfo, {
323
+ dryRun: options.dryRun,
324
+ token: settingsToken,
325
+ });
326
+ if (repoSettingsResult.planOutput?.lines?.length) {
327
+ logger.info("");
328
+ logger.info(`${repoName} - Repo Settings:`);
329
+ for (const line of repoSettingsResult.planOutput.lines) {
330
+ logger.info(line);
331
+ }
332
+ if (repoSettingsResult.warnings?.length) {
333
+ for (const warning of repoSettingsResult.warnings) {
334
+ logger.info(`Warning: ${warning}`);
335
+ }
336
+ }
337
+ }
338
+ else if (!repoSettingsResult.skipped &&
339
+ repoSettingsResult.success) {
340
+ logger.success(current, repoName, `Repo settings: ${repoSettingsResult.message}`);
341
+ }
342
+ if (!repoSettingsResult.skipped) {
343
+ settingsCollector.getOrCreate(repoName).settingsResult =
344
+ repoSettingsResult;
345
+ }
346
+ if (!repoSettingsResult.success && !repoSettingsResult.skipped) {
347
+ logger.error(current, repoName, `Repo settings: ${repoSettingsResult.message}`);
348
+ settingsCollector.appendError(repoName, repoSettingsResult.message);
349
+ }
350
+ }
351
+ catch (error) {
352
+ logger.error(current, repoName, `Repo settings: ${String(error)}`);
353
+ settingsCollector.appendError(repoName, error);
354
+ }
355
+ }
356
+ }
233
357
  }
234
358
  // Build and display lifecycle report (before sync report)
235
359
  const lifecycleReport = buildLifecycleReport(lifecycleReportInputs);
@@ -245,15 +369,30 @@ export async function runSync(options, processorFactory = defaultProcessorFactor
245
369
  for (const line of formatSyncReportCLI(report)) {
246
370
  console.log(line);
247
371
  }
372
+ // Build and display settings report (if any settings were processed)
373
+ const settingsResults = settingsCollector.getAll();
374
+ let settingsReport;
375
+ if (settingsResults.length > 0) {
376
+ settingsReport = buildSettingsReport(settingsResults);
377
+ const settingsLines = formatSettingsReportCLI(settingsReport);
378
+ if (settingsLines.length > 0) {
379
+ console.log("");
380
+ for (const line of settingsLines) {
381
+ console.log(line);
382
+ }
383
+ }
384
+ }
248
385
  // Write unified summary to GITHUB_STEP_SUMMARY
249
386
  writeUnifiedSummary({
250
387
  lifecycle: lifecycleReport,
251
388
  sync: report,
389
+ settings: settingsReport,
252
390
  dryRun: options.dryRun ?? false,
253
391
  });
254
- // Exit with error if any failures
392
+ // Exit with error if any failures (file sync or settings)
255
393
  const hasErrors = reportResults.some((r) => r.error);
256
- if (hasErrors) {
394
+ const hasSettingsErrors = settingsResults.some((r) => r.error);
395
+ if (hasErrors || hasSettingsErrors) {
257
396
  process.exit(1);
258
397
  }
259
398
  }
@@ -1,5 +1,6 @@
1
1
  import { RepoConfig } from "../config/index.js";
2
2
  import { RepoInfo } from "../shared/repo-detector.js";
3
+ import type { IRepoLifecycleManager } from "../lifecycle/index.js";
3
4
  import { type ProcessorResult, type ProcessorOptions } from "../sync/index.js";
4
5
  import { RulesetProcessorOptions, RulesetProcessorResult } from "../settings/rulesets/processor.js";
5
6
  import { type IRepoSettingsProcessor } from "../settings/repo-settings/processor.js";
@@ -9,10 +10,6 @@ import { type ILabelsProcessor } from "../settings/labels/processor.js";
9
10
  */
10
11
  export interface IRepositoryProcessor {
11
12
  process(repoConfig: RepoConfig, repoInfo: RepoInfo, options: ProcessorOptions): Promise<ProcessorResult>;
12
- updateManifestOnly(repoInfo: RepoInfo, repoConfig: RepoConfig, options: ProcessorOptions, manifestUpdate: {
13
- rulesets?: string[];
14
- labels?: string[];
15
- }): Promise<ProcessorResult>;
16
13
  }
17
14
  /**
18
15
  * Factory function type for creating processors.
@@ -52,4 +49,14 @@ export type LabelsProcessorFactory = () => ILabelsProcessor;
52
49
  * Default factory that creates a real LabelsProcessor.
53
50
  */
54
51
  export declare const defaultLabelsProcessorFactory: LabelsProcessorFactory;
52
+ /**
53
+ * Dependencies for the sync command (dependency injection).
54
+ */
55
+ export interface SyncDependencies {
56
+ processorFactory?: ProcessorFactory;
57
+ lifecycleManager?: IRepoLifecycleManager;
58
+ rulesetProcessorFactory?: RulesetProcessorFactory;
59
+ repoSettingsProcessorFactory?: RepoSettingsProcessorFactory;
60
+ labelsProcessorFactory?: LabelsProcessorFactory;
61
+ }
55
62
  export type { IRepoSettingsProcessor, ILabelsProcessor };
@@ -4,4 +4,4 @@ export { loadRawConfig, loadConfig, normalizeConfig } from "./loader.js";
4
4
  export { convertContentToString, detectOutputFormat, type OutputFormat, type ConvertOptions, } from "./formatter.js";
5
5
  export { isFileReference, resolveFileReference, type FileReferenceOptions, } from "./file-reference-resolver.js";
6
6
  export { arrayMergeStrategies, deepMerge, stripMergeDirectives, createMergeContext, isTextContent, mergeTextContent, type ArrayMergeStrategy, type ArrayMergeHandler, type MergeContext, } from "./merge.js";
7
- export { validateRawConfig, validateSettings, validateForSync, validateForSettings, hasActionableSettings, } from "./validator.js";
7
+ export { validateRawConfig, validateSettings, validateForSync, hasActionableSettings, } from "./validator.js";
@@ -9,4 +9,4 @@ export { isFileReference, resolveFileReference, } from "./file-reference-resolve
9
9
  // Deep merge utilities
10
10
  export { arrayMergeStrategies, deepMerge, stripMergeDirectives, createMergeContext, isTextContent, mergeTextContent, } from "./merge.js";
11
11
  // Validation
12
- export { validateRawConfig, validateSettings, validateForSync, validateForSettings, hasActionableSettings, } from "./validator.js";
12
+ export { validateRawConfig, validateSettings, validateForSync, hasActionableSettings, } from "./validator.js";
@@ -17,8 +17,3 @@ export declare function validateForSync(config: RawConfig): void;
17
17
  * Checks if settings contain actionable configuration.
18
18
  */
19
19
  export declare function hasActionableSettings(settings: RawRootSettings | RawRepoSettings | undefined): boolean;
20
- /**
21
- * Validates that config is suitable for the settings command.
22
- * @throws Error if no settings are defined or no actionable settings exist
23
- */
24
- export declare function validateForSettings(config: RawConfig): void;
@@ -537,9 +537,19 @@ export function validateForSync(config) {
537
537
  const hasGroupFiles = config.groups &&
538
538
  Object.values(config.groups).some((g) => g.files &&
539
539
  Object.keys(g.files).filter((k) => k !== "inherit" && g.files[k] !== false).length > 0);
540
- if (!hasRootFiles && !hasGroupFiles) {
541
- throw new Error("The 'sync' command requires files defined in root 'files' or in at least one group. " +
542
- "To manage repository settings instead, use 'xfg settings'.");
540
+ const hasSettings = hasActionableSettings(config.settings);
541
+ const hasRepoSettings = config.repos.some((repo) => hasActionableSettings(repo.settings));
542
+ const hasGroupSettings = config.groups &&
543
+ typeof config.groups === "object" &&
544
+ !Array.isArray(config.groups) &&
545
+ Object.values(config.groups).some((g) => g.settings && hasActionableSettings(g.settings));
546
+ if (!hasRootFiles &&
547
+ !hasGroupFiles &&
548
+ !hasSettings &&
549
+ !hasRepoSettings &&
550
+ !hasGroupSettings) {
551
+ throw new Error("Config requires at least one of: 'files' or 'settings'. " +
552
+ "Use 'files' to sync configuration files, or 'settings' to manage repository settings.");
543
553
  }
544
554
  }
545
555
  /**
@@ -564,30 +574,3 @@ export function hasActionableSettings(settings) {
564
574
  }
565
575
  return false;
566
576
  }
567
- /**
568
- * Validates that config is suitable for the settings command.
569
- * @throws Error if no settings are defined or no actionable settings exist
570
- */
571
- export function validateForSettings(config) {
572
- // Check if settings exist at root, in any repo, or in any group
573
- const hasRootSettings = config.settings !== undefined;
574
- const hasRepoSettings = config.repos.some((repo) => repo.settings !== undefined);
575
- const hasGroupSettings = config.groups &&
576
- typeof config.groups === "object" &&
577
- !Array.isArray(config.groups) &&
578
- Object.values(config.groups).some((g) => g.settings && typeof g.settings === "object");
579
- if (!hasRootSettings && !hasRepoSettings && !hasGroupSettings) {
580
- throw new Error("The 'settings' command requires a 'settings' section at root level, " +
581
- "in at least one repo, or in at least one group. To sync files instead, use 'xfg sync'.");
582
- }
583
- // Check if there's at least one actionable setting
584
- const rootActionable = hasActionableSettings(config.settings);
585
- const repoActionable = config.repos.some((repo) => hasActionableSettings(repo.settings));
586
- const groupActionable = config.groups &&
587
- Object.values(config.groups).some((g) => hasActionableSettings(g.settings));
588
- if (!rootActionable && !repoActionable && !groupActionable) {
589
- throw new Error("No actionable settings configured. Currently supported: rulesets, repo, labels. " +
590
- "To sync files instead, use 'xfg sync'. " +
591
- "See docs: https://anthony-spruyt.github.io/xfg/settings");
592
- }
593
- }
package/dist/index.d.ts CHANGED
@@ -1,3 +1,3 @@
1
- export { runSync, runSettings } from "./cli/index.js";
2
- export type { SyncOptions, SettingsOptions, SharedOptions, } from "./cli/index.js";
1
+ export { runSync } from "./cli/index.js";
2
+ export type { SyncOptions, SharedOptions } from "./cli/index.js";
3
3
  export { type IRepositoryProcessor, type ProcessorFactory, defaultProcessorFactory, type IRulesetProcessor, type RulesetProcessorFactory, defaultRulesetProcessorFactory, type RepoSettingsProcessorFactory, defaultRepoSettingsProcessorFactory, type ILabelsProcessor, type LabelsProcessorFactory, defaultLabelsProcessorFactory, } from "./cli/index.js";
package/dist/index.js CHANGED
@@ -1,3 +1,3 @@
1
1
  // Public API for library consumers
2
- export { runSync, runSettings } from "./cli/index.js";
2
+ export { runSync } from "./cli/index.js";
3
3
  export { defaultProcessorFactory, defaultRulesetProcessorFactory, defaultRepoSettingsProcessorFactory, defaultLabelsProcessorFactory, } from "./cli/index.js";
@@ -52,6 +52,15 @@ export declare class GitHubLifecycleProvider implements IRepoLifecycleProvider {
52
52
  * GitHub automatically updates the default branch pointer.
53
53
  */
54
54
  private renameBranch;
55
+ /**
56
+ * Poll until the GitHub API reports the expected default branch.
57
+ * After a branch rename, the API may lag for a few seconds.
58
+ *
59
+ * Note: Uses the same executor.exec pattern as the rest of this class.
60
+ * The command arguments are constructed from trusted RepoInfo values
61
+ * (validated during config parsing), not user input.
62
+ */
63
+ private waitForDefaultBranch;
55
64
  /**
56
65
  * Delete the README.md that --add-readme creates.
57
66
  * This leaves the repo with a default branch established (from the initial
@@ -165,6 +165,9 @@ export class GitHubLifecycleProvider {
165
165
  })).trim();
166
166
  if (actualBranch !== settings.defaultBranch) {
167
167
  await this.renameBranch(repoInfo, actualBranch, settings.defaultBranch, token);
168
+ // Wait for the rename to propagate — GitHub's API may still report
169
+ // the old default branch for a few seconds after the rename call.
170
+ await this.waitForDefaultBranch(repoInfo, settings.defaultBranch, token);
168
171
  }
169
172
  }
170
173
  // Delete the README so xfg sync starts from a clean state.
@@ -340,6 +343,34 @@ export class GitHubLifecycleProvider {
340
343
  retries: this.retries,
341
344
  });
342
345
  }
346
+ /**
347
+ * Poll until the GitHub API reports the expected default branch.
348
+ * After a branch rename, the API may lag for a few seconds.
349
+ *
350
+ * Note: Uses the same executor.exec pattern as the rest of this class.
351
+ * The command arguments are constructed from trusted RepoInfo values
352
+ * (validated during config parsing), not user input.
353
+ */
354
+ async waitForDefaultBranch(repoInfo, expectedBranch, token, timeoutMs = 15000, pollMs = 1000) {
355
+ const tokenPrefix = this.buildTokenPrefix(token);
356
+ const hostnameFlag = getHostnameFlag(repoInfo);
357
+ const hostnamePart = hostnameFlag ? `${hostnameFlag} ` : "";
358
+ const apiPath = `repos/${escapeShellArg(repoInfo.owner)}/${escapeShellArg(repoInfo.repo)}`;
359
+ const startTime = Date.now();
360
+ while (Date.now() - startTime < timeoutMs) {
361
+ try {
362
+ const branch = (await this.executor.exec(`${tokenPrefix}gh api ${hostnamePart}${apiPath} --jq '.default_branch'`, this.cwd)).trim();
363
+ if (branch === expectedBranch) {
364
+ return;
365
+ }
366
+ }
367
+ catch {
368
+ // API call failed, continue polling
369
+ }
370
+ await new Promise((resolve) => setTimeout(resolve, pollMs));
371
+ }
372
+ // Don't throw — rename succeeded, this is just a best-effort wait
373
+ }
343
374
  /**
344
375
  * Delete the README.md that --add-readme creates.
345
376
  * This leaves the repo with a default branch established (from the initial
@@ -25,9 +25,9 @@ export interface LabelChange {
25
25
  *
26
26
  * @param current - Current labels from GitHub API
27
27
  * @param desired - Desired labels from config (name -> label)
28
- * @param managedLabels - Names of labels managed by xfg (from manifest)
28
+ * @param deleteOrphaned - If true, delete current labels not in desired config
29
29
  * @param noDelete - If true, skip delete operations
30
30
  * @returns Array of changes to apply
31
31
  * @throws Error if rename collisions are detected
32
32
  */
33
- export declare function diffLabels(current: GitHubLabel[], desired: Record<string, Label>, managedLabels: string[], noDelete: boolean): LabelChange[];
33
+ export declare function diffLabels(current: GitHubLabel[], desired: Record<string, Label>, deleteOrphaned: boolean, noDelete: boolean): LabelChange[];
@@ -11,19 +11,18 @@ import { normalizeColor } from "./converter.js";
11
11
  *
12
12
  * @param current - Current labels from GitHub API
13
13
  * @param desired - Desired labels from config (name -> label)
14
- * @param managedLabels - Names of labels managed by xfg (from manifest)
14
+ * @param deleteOrphaned - If true, delete current labels not in desired config
15
15
  * @param noDelete - If true, skip delete operations
16
16
  * @returns Array of changes to apply
17
17
  * @throws Error if rename collisions are detected
18
18
  */
19
- export function diffLabels(current, desired, managedLabels, noDelete) {
19
+ export function diffLabels(current, desired, deleteOrphaned, noDelete) {
20
20
  const changes = [];
21
21
  // Build case-insensitive lookup of current labels
22
22
  const currentByName = new Map();
23
23
  for (const label of current) {
24
24
  currentByName.set(label.name.toLowerCase(), label);
25
25
  }
26
- const managedSet = new Set(managedLabels.map((n) => n.toLowerCase()));
27
26
  // Collect rename targets for collision detection
28
27
  const renameTargets = new Map(); // lowercase target -> source name
29
28
  for (const [name, label] of Object.entries(desired)) {
@@ -38,10 +37,10 @@ export function diffLabels(current, desired, managedLabels, noDelete) {
38
37
  // Determine which labels will be deleted (for collision checking)
39
38
  const desiredLower = new Set(Object.keys(desired).map((n) => n.toLowerCase()));
40
39
  const deletedNames = new Set();
41
- if (!noDelete) {
42
- for (const name of managedSet) {
43
- if (!desiredLower.has(name) && currentByName.has(name)) {
44
- deletedNames.add(name);
40
+ if (deleteOrphaned && !noDelete) {
41
+ for (const nameLower of currentByName.keys()) {
42
+ if (!desiredLower.has(nameLower)) {
43
+ deletedNames.add(nameLower);
45
44
  }
46
45
  }
47
46
  }
@@ -130,18 +129,15 @@ export function diffLabels(current, desired, managedLabels, noDelete) {
130
129
  }
131
130
  }
132
131
  }
133
- // Check for orphaned labels (in manifest but not in desired config)
134
- if (!noDelete) {
135
- for (const name of managedSet) {
136
- if (!desiredLower.has(name)) {
137
- const currentLabel = currentByName.get(name);
138
- if (currentLabel) {
139
- changes.push({
140
- action: "delete",
141
- name: currentLabel.name,
142
- current: currentLabel,
143
- });
144
- }
132
+ // Desired-state orphan detection: delete ALL current not in desired
133
+ if (deleteOrphaned && !noDelete) {
134
+ for (const [nameLower, currentLabel] of currentByName) {
135
+ if (!desiredLower.has(nameLower)) {
136
+ changes.push({
137
+ action: "delete",
138
+ name: currentLabel.name,
139
+ current: currentLabel,
140
+ });
145
141
  }
146
142
  }
147
143
  }
@@ -6,9 +6,7 @@ export interface ILabelsProcessor {
6
6
  process(repoConfig: RepoConfig, repoInfo: RepoInfo, options: LabelsProcessorOptions): Promise<LabelsProcessorResult>;
7
7
  }
8
8
  export interface LabelsProcessorOptions {
9
- configId: string;
10
9
  dryRun?: boolean;
11
- managedLabels: string[];
12
10
  noDelete?: boolean;
13
11
  token?: string;
14
12
  }
@@ -24,9 +22,6 @@ export interface LabelsProcessorResult {
24
22
  delete: number;
25
23
  unchanged: number;
26
24
  };
27
- manifestUpdate?: {
28
- labels: string[];
29
- };
30
25
  planOutput?: LabelsPlanResult;
31
26
  }
32
27
  /**
@@ -45,11 +40,6 @@ export declare class LabelsProcessor implements ILabelsProcessor {
45
40
  * Format change counts into a summary string.
46
41
  */
47
42
  private formatChangeSummary;
48
- /**
49
- * Compute manifest update based on current config.
50
- * Only labels with deleteOrphaned enabled should be tracked.
51
- */
52
- private computeManifestUpdate;
53
43
  /**
54
44
  * Resolves a GitHub App installation token for the given repo.
55
45
  */
@@ -29,7 +29,7 @@ export class LabelsProcessor {
29
29
  */
30
30
  async process(repoConfig, repoInfo, options) {
31
31
  const repoName = getRepoDisplayName(repoInfo);
32
- const { dryRun, managedLabels, noDelete, token } = options;
32
+ const { dryRun, noDelete, token } = options;
33
33
  // Check if this is a GitHub repo
34
34
  if (!isGitHubRepo(repoInfo)) {
35
35
  return {
@@ -44,7 +44,7 @@ export class LabelsProcessor {
44
44
  const desiredLabels = settings?.labels ?? {};
45
45
  const deleteOrphaned = settings?.deleteOrphaned ?? false;
46
46
  // If no labels configured, skip
47
- if (Object.keys(desiredLabels).length === 0 && managedLabels.length === 0) {
47
+ if (Object.keys(desiredLabels).length === 0) {
48
48
  return {
49
49
  success: true,
50
50
  repoName,
@@ -58,7 +58,7 @@ export class LabelsProcessor {
58
58
  const strategyOptions = { token: effectiveToken, host: githubRepo.host };
59
59
  const currentLabels = await this.strategy.list(githubRepo, strategyOptions);
60
60
  // Compute diff
61
- const changes = diffLabels(currentLabels, desiredLabels, managedLabels, noDelete ?? false);
61
+ const changes = diffLabels(currentLabels, desiredLabels, deleteOrphaned, noDelete ?? false);
62
62
  // Count changes by type
63
63
  const changeCounts = {
64
64
  create: changes.filter((c) => c.action === "create").length,
@@ -77,7 +77,6 @@ export class LabelsProcessor {
77
77
  dryRun: true,
78
78
  changes: changeCounts,
79
79
  planOutput,
80
- manifestUpdate: this.computeManifestUpdate(desiredLabels, deleteOrphaned),
81
80
  };
82
81
  }
83
82
  // Apply changes (diff is already sorted: delete, update, create, unchanged)
@@ -133,7 +132,6 @@ export class LabelsProcessor {
133
132
  message: appliedCount > 0 ? `Applied: ${summary}` : "No changes needed",
134
133
  changes: changeCounts,
135
134
  planOutput,
136
- manifestUpdate: this.computeManifestUpdate(desiredLabels, deleteOrphaned),
137
135
  };
138
136
  }
139
137
  catch (error) {
@@ -160,17 +158,6 @@ export class LabelsProcessor {
160
158
  parts.push(`${counts.unchanged} unchanged`);
161
159
  return parts.length > 0 ? parts.join(", ") : "no changes";
162
160
  }
163
- /**
164
- * Compute manifest update based on current config.
165
- * Only labels with deleteOrphaned enabled should be tracked.
166
- */
167
- computeManifestUpdate(labels, deleteOrphaned) {
168
- if (!deleteOrphaned) {
169
- return undefined;
170
- }
171
- const labelNames = Object.keys(labels).sort();
172
- return { labels: labelNames };
173
- }
174
161
  /**
175
162
  * Resolves a GitHub App installation token for the given repo.
176
163
  */
@@ -25,10 +25,10 @@ export declare function projectToDesiredShape(current: unknown, desired: unknown
25
25
  *
26
26
  * @param current - Current rulesets from GitHub API
27
27
  * @param desired - Desired rulesets from config (name → ruleset)
28
- * @param managedNames - Names of rulesets managed by xfg (from manifest)
28
+ * @param deleteOrphaned - When true, delete ALL current rulesets not in desired (desired-state model)
29
29
  * @returns Array of changes to apply
30
30
  */
31
- export declare function diffRulesets(current: GitHubRuleset[], desired: Map<string, Ruleset>, managedNames: string[]): RulesetChange[];
31
+ export declare function diffRulesets(current: GitHubRuleset[], desired: Map<string, Ruleset>, deleteOrphaned: boolean): RulesetChange[];
32
32
  /**
33
33
  * Formats diff output for display (dry-run mode).
34
34
  *