@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
@@ -201,13 +201,12 @@ function matchByIndex(current, desired) {
201
201
  *
202
202
  * @param current - Current rulesets from GitHub API
203
203
  * @param desired - Desired rulesets from config (name → ruleset)
204
- * @param managedNames - Names of rulesets managed by xfg (from manifest)
204
+ * @param deleteOrphaned - When true, delete ALL current rulesets not in desired (desired-state model)
205
205
  * @returns Array of changes to apply
206
206
  */
207
- export function diffRulesets(current, desired, managedNames) {
207
+ export function diffRulesets(current, desired, deleteOrphaned) {
208
208
  const changes = [];
209
209
  const currentByName = new Map(current.map((r) => [r.name, r]));
210
- const managedSet = new Set(managedNames);
211
210
  // Check each desired ruleset
212
211
  for (const [name, desiredRuleset] of desired) {
213
212
  const currentRuleset = currentByName.get(name);
@@ -244,11 +243,10 @@ export function diffRulesets(current, desired, managedNames) {
244
243
  }
245
244
  }
246
245
  }
247
- // Check for orphaned rulesets (in manifest but not in desired config)
248
- for (const name of managedSet) {
249
- if (!desired.has(name)) {
250
- const currentRuleset = currentByName.get(name);
251
- if (currentRuleset) {
246
+ // Desired-state: delete ALL current rulesets not in desired when deleteOrphaned is true
247
+ if (deleteOrphaned) {
248
+ for (const [name, currentRuleset] of currentByName) {
249
+ if (!desired.has(name)) {
252
250
  changes.push({
253
251
  action: "delete",
254
252
  name,
@@ -6,9 +6,7 @@ export interface IRulesetProcessor {
6
6
  process(repoConfig: RepoConfig, repoInfo: RepoInfo, options: RulesetProcessorOptions): Promise<RulesetProcessorResult>;
7
7
  }
8
8
  export interface RulesetProcessorOptions {
9
- configId: string;
10
9
  dryRun?: boolean;
11
- managedRulesets: string[];
12
10
  noDelete?: boolean;
13
11
  token?: string;
14
12
  }
@@ -24,9 +22,6 @@ export interface RulesetProcessorResult {
24
22
  delete: number;
25
23
  unchanged: number;
26
24
  };
27
- manifestUpdate?: {
28
- rulesets: string[];
29
- };
30
25
  planOutput?: RulesetPlanResult;
31
26
  }
32
27
  /**
@@ -45,11 +40,6 @@ export declare class RulesetProcessor implements IRulesetProcessor {
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 rulesets 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
  * Returns undefined if no token manager or token resolution fails.
@@ -28,7 +28,7 @@ export class RulesetProcessor {
28
28
  */
29
29
  async process(repoConfig, repoInfo, options) {
30
30
  const repoName = getRepoDisplayName(repoInfo);
31
- const { dryRun, managedRulesets, noDelete, token } = options;
31
+ const { dryRun, noDelete, token } = options;
32
32
  // Check if this is a GitHub repo
33
33
  if (!isGitHubRepo(repoInfo)) {
34
34
  return {
@@ -43,8 +43,7 @@ export class RulesetProcessor {
43
43
  const desiredRulesets = settings?.rulesets ?? {};
44
44
  const deleteOrphaned = settings?.deleteOrphaned ?? false;
45
45
  // If no rulesets configured, skip
46
- if (Object.keys(desiredRulesets).length === 0 &&
47
- managedRulesets.length === 0) {
46
+ if (Object.keys(desiredRulesets).length === 0) {
48
47
  return {
49
48
  success: true,
50
49
  repoName,
@@ -73,7 +72,7 @@ export class RulesetProcessor {
73
72
  }
74
73
  }
75
74
  // Compute diff
76
- const changes = diffRulesets(fullRulesets, desiredMap, managedRulesets);
75
+ const changes = diffRulesets(fullRulesets, desiredMap, deleteOrphaned);
77
76
  // Count changes by type
78
77
  const changeCounts = {
79
78
  create: changes.filter((c) => c.action === "create").length,
@@ -92,7 +91,6 @@ export class RulesetProcessor {
92
91
  dryRun: true,
93
92
  changes: changeCounts,
94
93
  planOutput,
95
- manifestUpdate: this.computeManifestUpdate(desiredRulesets, deleteOrphaned),
96
94
  };
97
95
  }
98
96
  // Apply changes
@@ -130,7 +128,6 @@ export class RulesetProcessor {
130
128
  message: appliedCount > 0 ? `Applied: ${summary}` : "No changes needed",
131
129
  changes: changeCounts,
132
130
  planOutput,
133
- manifestUpdate: this.computeManifestUpdate(desiredRulesets, deleteOrphaned),
134
131
  };
135
132
  }
136
133
  catch (error) {
@@ -157,18 +154,6 @@ export class RulesetProcessor {
157
154
  parts.push(`${counts.unchanged} unchanged`);
158
155
  return parts.length > 0 ? parts.join(", ") : "no changes";
159
156
  }
160
- /**
161
- * Compute manifest update based on current config.
162
- * Only rulesets with deleteOrphaned enabled should be tracked.
163
- */
164
- computeManifestUpdate(rulesets, deleteOrphaned) {
165
- if (!deleteOrphaned) {
166
- return undefined;
167
- }
168
- // Track all ruleset names when deleteOrphaned is enabled
169
- const rulesetNames = Object.keys(rulesets).sort();
170
- return { rulesets: rulesetNames };
171
- }
172
157
  /**
173
158
  * Resolves a GitHub App installation token for the given repo.
174
159
  * Returns undefined if no token manager or token resolution fails.
@@ -8,10 +8,9 @@ export { formatCommitMessage } from "./commit-message.js";
8
8
  export { FileSyncOrchestrator } from "./file-sync-orchestrator.js";
9
9
  export { PRMergeHandler } from "./pr-merge-handler.js";
10
10
  export { FileSyncStrategy } from "./file-sync-strategy.js";
11
- export { ManifestStrategy, type ManifestUpdateParams, } from "./manifest-strategy.js";
12
11
  export { SyncWorkflow } from "./sync-workflow.js";
13
12
  export type { IFileWriter, FileWriteContext, FileWriterDeps, FileWriteAllResult, FileWriteResult, IManifestManager, OrphanProcessResult, OrphanDeleteOptions, OrphanDeleteDeps, IBranchManager, BranchSetupOptions, IAuthOptionsBuilder, AuthResult, IRepositorySession, SessionOptions, SessionContext, ICommitPushManager, CommitPushOptions, CommitPushResult, GitOpsFactory, IRepositoryProcessor, ProcessorOptions, ProcessorResult, FileChangeDetail, IFileSyncOrchestrator, FileSyncResult, IPRMergeHandler, PRHandlerOptions, WorkResult, IWorkStrategy, ISyncWorkflow, } from "./types.js";
14
13
  export { RepositoryProcessor } from "./repository-processor.js";
15
- export { createEmptyManifest, loadManifest, saveManifest, getManagedFiles, getManagedRulesets, getManagedLabels, updateManifest, updateManifestRulesets, updateManifestLabels, MANIFEST_FILENAME, type XfgManifest, type XfgManifestConfigEntry, } from "./manifest.js";
14
+ export { createEmptyManifest, loadManifest, saveManifest, getManagedFiles, updateManifest, MANIFEST_FILENAME, type XfgManifest, type XfgManifestConfigEntry, } from "./manifest.js";
16
15
  export { getFileStatus, formatStatusBadge, formatDiffLine, generateDiff, createDiffStats, incrementDiffStats, type FileStatus, type DiffStats, } from "./diff-utils.js";
17
16
  export { interpolateXfgContent, type XfgTemplateContext, type XfgInterpolationOptions, } from "./xfg-template.js";
@@ -9,12 +9,11 @@ export { FileSyncOrchestrator } from "./file-sync-orchestrator.js";
9
9
  export { PRMergeHandler } from "./pr-merge-handler.js";
10
10
  // Strategy pattern components
11
11
  export { FileSyncStrategy } from "./file-sync-strategy.js";
12
- export { ManifestStrategy, } from "./manifest-strategy.js";
13
12
  export { SyncWorkflow } from "./sync-workflow.js";
14
13
  // Repository processor
15
14
  export { RepositoryProcessor } from "./repository-processor.js";
16
15
  // Manifest handling
17
- export { createEmptyManifest, loadManifest, saveManifest, getManagedFiles, getManagedRulesets, getManagedLabels, updateManifest, updateManifestRulesets, updateManifestLabels, MANIFEST_FILENAME, } from "./manifest.js";
16
+ export { createEmptyManifest, loadManifest, saveManifest, getManagedFiles, updateManifest, MANIFEST_FILENAME, } from "./manifest.js";
18
17
  // Diff utilities
19
18
  export { getFileStatus, formatStatusBadge, formatDiffLine, generateDiff, createDiffStats, incrementDiffStats, } from "./diff-utils.js";
20
19
  // XFG templating
@@ -1,11 +1,9 @@
1
1
  export declare const MANIFEST_FILENAME = ".xfg.json";
2
2
  export interface XfgManifestConfigEntry {
3
3
  files?: string[];
4
- rulesets?: string[];
5
- labels?: string[];
6
4
  }
7
5
  export interface XfgManifest {
8
- version: 3;
6
+ version: 4;
9
7
  configs: Record<string, XfgManifestConfigEntry>;
10
8
  }
11
9
  /**
@@ -18,9 +16,9 @@ export declare function createEmptyManifest(): XfgManifest;
18
16
  *
19
17
  * V1 manifests are treated as non-existent because they lack the config ID
20
18
  * namespace required for multi-config support. The next run will create
21
- * a fresh v3 manifest.
19
+ * a fresh v4 manifest.
22
20
  *
23
- * V2 manifests are automatically migrated to V3 format.
21
+ * V2/V3 manifests are automatically migrated to V4 format.
24
22
  *
25
23
  * @param workDir - The repository working directory
26
24
  * @returns The manifest or null if not found or incompatible
@@ -47,20 +45,6 @@ export declare function saveManifest(workDir: string, manifest: XfgManifest): vo
47
45
  * @returns Array of managed file names for the given config
48
46
  */
49
47
  export declare function getManagedFiles(manifest: XfgManifest | null, configId: string): string[];
50
- /**
51
- * Gets the list of managed rulesets for a specific config from a manifest.
52
- * Returns an empty array if the manifest is null or the config isn't found.
53
- *
54
- * @param manifest - The manifest or null
55
- * @param configId - The config ID to get rulesets for
56
- * @returns Array of managed ruleset names for the given config
57
- */
58
- export declare function getManagedRulesets(manifest: XfgManifest | null, configId: string): string[];
59
- /**
60
- * Gets the list of managed labels for a specific config from a manifest.
61
- * Returns an empty array if the manifest is null or the config isn't found.
62
- */
63
- export declare function getManagedLabels(manifest: XfgManifest | null, configId: string): string[];
64
48
  /**
65
49
  * Updates the manifest with the current set of files that have deleteOrphaned enabled
66
50
  * for a specific config. Only modifies that config's files namespace - other configs are untouched.
@@ -78,29 +62,3 @@ export declare function updateManifest(manifest: XfgManifest | null, configId: s
78
62
  manifest: XfgManifest;
79
63
  filesToDelete: string[];
80
64
  };
81
- /**
82
- * Updates the manifest with the current set of rulesets that have deleteOrphaned enabled
83
- * for a specific config. Only modifies that config's rulesets namespace - other configs are untouched.
84
- *
85
- * @param manifest - The existing manifest (or null for new repos)
86
- * @param configId - The config ID to update
87
- * @param rulesetsWithDeleteOrphaned - Map of ruleset name to deleteOrphaned value (true/false/undefined)
88
- * @returns Updated manifest and list of rulesets to delete
89
- */
90
- export declare function updateManifestRulesets(manifest: XfgManifest | null, configId: string, rulesetsWithDeleteOrphaned: Map<string, boolean | undefined>): {
91
- manifest: XfgManifest;
92
- rulesetsToDelete: string[];
93
- };
94
- /**
95
- * Updates the manifest with the current set of labels that have deleteOrphaned enabled
96
- * for a specific config. Only modifies that config's labels namespace - other configs are untouched.
97
- *
98
- * @param manifest - The existing manifest (or null for new repos)
99
- * @param configId - The config ID to update
100
- * @param labelsWithDeleteOrphaned - Map of label name to deleteOrphaned value (true/false/undefined)
101
- * @returns Updated manifest and list of labels to delete
102
- */
103
- export declare function updateManifestLabels(manifest: XfgManifest | null, configId: string, labelsWithDeleteOrphaned: Map<string, boolean | undefined>): {
104
- manifest: XfgManifest;
105
- labelsToDelete: string[];
106
- };
@@ -30,10 +30,20 @@ function isV3Manifest(manifest) {
30
30
  typeof manifest.configs === "object" &&
31
31
  manifest.configs !== null);
32
32
  }
33
+ /**
34
+ * Type guard to check if a manifest is v4 format.
35
+ */
36
+ function isV4Manifest(manifest) {
37
+ return (typeof manifest === "object" &&
38
+ manifest !== null &&
39
+ manifest.version === 4 &&
40
+ typeof manifest.configs === "object" &&
41
+ manifest.configs !== null);
42
+ }
33
43
  /**
34
44
  * Migrates a V2 manifest to V3 format.
35
45
  * V2: configs is Record<string, string[]>
36
- * V3: configs is Record<string, { files?: string[], rulesets?: string[] }>
46
+ * V3: configs is Record<string, { files?: string[], rulesets?: string[], labels?: string[] }>
37
47
  */
38
48
  function migrateV2ToV3(v2) {
39
49
  const v3Configs = {};
@@ -47,12 +57,27 @@ function migrateV2ToV3(v2) {
47
57
  configs: v3Configs,
48
58
  };
49
59
  }
60
+ /**
61
+ * Migrates a V3 manifest to V4 format.
62
+ * V3: configs have files, rulesets, labels
63
+ * V4: configs have files only — rulesets and labels are dropped
64
+ */
65
+ function migrateV3ToV4(v3) {
66
+ const v4Configs = {};
67
+ for (const [configId, entry] of Object.entries(v3.configs)) {
68
+ // Only preserve files — rulesets and labels are dropped
69
+ if (entry.files && entry.files.length > 0) {
70
+ v4Configs[configId] = { files: entry.files };
71
+ }
72
+ }
73
+ return { version: 4, configs: v4Configs };
74
+ }
50
75
  /**
51
76
  * Creates an empty manifest with the current version.
52
77
  */
53
78
  export function createEmptyManifest() {
54
79
  return {
55
- version: 3,
80
+ version: 4,
56
81
  configs: {},
57
82
  };
58
83
  }
@@ -62,9 +87,9 @@ export function createEmptyManifest() {
62
87
  *
63
88
  * V1 manifests are treated as non-existent because they lack the config ID
64
89
  * namespace required for multi-config support. The next run will create
65
- * a fresh v3 manifest.
90
+ * a fresh v4 manifest.
66
91
  *
67
- * V2 manifests are automatically migrated to V3 format.
92
+ * V2/V3 manifests are automatically migrated to V4 format.
68
93
  *
69
94
  * @param workDir - The repository working directory
70
95
  * @returns The manifest or null if not found or incompatible
@@ -77,15 +102,19 @@ export function loadManifest(workDir) {
77
102
  try {
78
103
  const content = readFileSync(manifestPath, "utf-8");
79
104
  const parsed = JSON.parse(content);
80
- // V3 manifest - return as-is
81
- if (isV3Manifest(parsed)) {
105
+ // V4 manifest - return as-is
106
+ if (isV4Manifest(parsed)) {
82
107
  return parsed;
83
108
  }
84
- // V2 manifest - migrate to V3
109
+ // V3 manifest - migrate to V4
110
+ if (isV3Manifest(parsed)) {
111
+ return migrateV3ToV4(parsed);
112
+ }
113
+ // V2 manifest - migrate to V3, then to V4
85
114
  if (isV2Manifest(parsed)) {
86
- return migrateV2ToV3(parsed);
115
+ return migrateV3ToV4(migrateV2ToV3(parsed));
87
116
  }
88
- // V1 manifest - treat as no manifest (will be overwritten with v3)
117
+ // V1 manifest - treat as no manifest (will be overwritten with v4)
89
118
  if (isV1Manifest(parsed)) {
90
119
  return null;
91
120
  }
@@ -103,11 +132,14 @@ export function loadManifest(workDir) {
103
132
  export function parseManifestContent(content) {
104
133
  try {
105
134
  const parsed = JSON.parse(content);
106
- if (isV3Manifest(parsed)) {
135
+ if (isV4Manifest(parsed)) {
107
136
  return parsed;
108
137
  }
138
+ if (isV3Manifest(parsed)) {
139
+ return migrateV3ToV4(parsed);
140
+ }
109
141
  if (isV2Manifest(parsed)) {
110
- return migrateV2ToV3(parsed);
142
+ return migrateV3ToV4(migrateV2ToV3(parsed));
111
143
  }
112
144
  return null;
113
145
  }
@@ -140,30 +172,6 @@ export function getManagedFiles(manifest, configId) {
140
172
  }
141
173
  return [...(manifest.configs[configId]?.files ?? [])];
142
174
  }
143
- /**
144
- * Gets the list of managed rulesets for a specific config from a manifest.
145
- * Returns an empty array if the manifest is null or the config isn't found.
146
- *
147
- * @param manifest - The manifest or null
148
- * @param configId - The config ID to get rulesets for
149
- * @returns Array of managed ruleset names for the given config
150
- */
151
- export function getManagedRulesets(manifest, configId) {
152
- if (!manifest) {
153
- return [];
154
- }
155
- return [...(manifest.configs[configId]?.rulesets ?? [])];
156
- }
157
- /**
158
- * Gets the list of managed labels for a specific config from a manifest.
159
- * Returns an empty array if the manifest is null or the config isn't found.
160
- */
161
- export function getManagedLabels(manifest, configId) {
162
- if (!manifest) {
163
- return [];
164
- }
165
- return [...(manifest.configs[configId]?.labels ?? [])];
166
- }
167
175
  /**
168
176
  * Updates the manifest with the current set of files that have deleteOrphaned enabled
169
177
  * for a specific config. Only modifies that config's files namespace - other configs are untouched.
@@ -202,147 +210,19 @@ export function updateManifest(manifest, configId, filesWithDeleteOrphaned) {
202
210
  const updatedConfigs = {
203
211
  ...(manifest?.configs ?? {}),
204
212
  };
205
- // Preserve existing rulesets and labels for this config
206
- const existingEntry = manifest?.configs[configId];
207
- const existingRulesets = existingEntry?.rulesets;
208
- const existingLabels = existingEntry?.labels;
209
213
  // Update this config's managed files
210
214
  const sortedManaged = Array.from(newManaged).sort();
211
- if (sortedManaged.length > 0 ||
212
- (existingRulesets && existingRulesets.length > 0) ||
213
- (existingLabels && existingLabels.length > 0)) {
214
- updatedConfigs[configId] = {
215
- ...(sortedManaged.length > 0 ? { files: sortedManaged } : {}),
216
- ...(existingRulesets && existingRulesets.length > 0
217
- ? { rulesets: existingRulesets }
218
- : {}),
219
- ...(existingLabels && existingLabels.length > 0
220
- ? { labels: existingLabels }
221
- : {}),
222
- };
215
+ if (sortedManaged.length > 0) {
216
+ updatedConfigs[configId] = { files: sortedManaged };
223
217
  }
224
218
  else {
225
- // Remove config entry if no managed files, rulesets, or labels
226
219
  delete updatedConfigs[configId];
227
220
  }
228
221
  return {
229
222
  manifest: {
230
- version: 3,
223
+ version: 4,
231
224
  configs: updatedConfigs,
232
225
  },
233
226
  filesToDelete,
234
227
  };
235
228
  }
236
- /**
237
- * Updates the manifest with the current set of rulesets that have deleteOrphaned enabled
238
- * for a specific config. Only modifies that config's rulesets namespace - other configs are untouched.
239
- *
240
- * @param manifest - The existing manifest (or null for new repos)
241
- * @param configId - The config ID to update
242
- * @param rulesetsWithDeleteOrphaned - Map of ruleset name to deleteOrphaned value (true/false/undefined)
243
- * @returns Updated manifest and list of rulesets to delete
244
- */
245
- export function updateManifestRulesets(manifest, configId, rulesetsWithDeleteOrphaned) {
246
- // Get existing managed rulesets for this config only
247
- const existingManaged = new Set(getManagedRulesets(manifest, configId));
248
- const newManaged = new Set();
249
- const rulesetsToDelete = [];
250
- // Process current config rulesets
251
- for (const [rulesetName, deleteOrphaned] of rulesetsWithDeleteOrphaned) {
252
- if (deleteOrphaned === true) {
253
- newManaged.add(rulesetName);
254
- }
255
- }
256
- // Find orphaned rulesets: in old manifest for this config but not in current config
257
- for (const rulesetName of existingManaged) {
258
- if (!rulesetsWithDeleteOrphaned.has(rulesetName)) {
259
- rulesetsToDelete.push(rulesetName);
260
- }
261
- }
262
- // Build updated manifest, preserving other configs
263
- const updatedConfigs = {
264
- ...(manifest?.configs ?? {}),
265
- };
266
- // Preserve existing files and labels for this config
267
- const existingEntry = manifest?.configs[configId];
268
- const existingFiles = existingEntry?.files;
269
- const existingLabels = existingEntry?.labels;
270
- // Update this config's managed rulesets
271
- const sortedManaged = Array.from(newManaged).sort();
272
- if (sortedManaged.length > 0 ||
273
- (existingFiles && existingFiles.length > 0) ||
274
- (existingLabels && existingLabels.length > 0)) {
275
- updatedConfigs[configId] = {
276
- ...(existingFiles && existingFiles.length > 0
277
- ? { files: existingFiles }
278
- : {}),
279
- ...(sortedManaged.length > 0 ? { rulesets: sortedManaged } : {}),
280
- ...(existingLabels && existingLabels.length > 0
281
- ? { labels: existingLabels }
282
- : {}),
283
- };
284
- }
285
- else {
286
- // Remove config entry if no managed files, rulesets, or labels
287
- delete updatedConfigs[configId];
288
- }
289
- return {
290
- manifest: {
291
- version: 3,
292
- configs: updatedConfigs,
293
- },
294
- rulesetsToDelete,
295
- };
296
- }
297
- /**
298
- * Updates the manifest with the current set of labels that have deleteOrphaned enabled
299
- * for a specific config. Only modifies that config's labels namespace - other configs are untouched.
300
- *
301
- * @param manifest - The existing manifest (or null for new repos)
302
- * @param configId - The config ID to update
303
- * @param labelsWithDeleteOrphaned - Map of label name to deleteOrphaned value (true/false/undefined)
304
- * @returns Updated manifest and list of labels to delete
305
- */
306
- export function updateManifestLabels(manifest, configId, labelsWithDeleteOrphaned) {
307
- const existingManaged = new Set(getManagedLabels(manifest, configId));
308
- const newManaged = new Set();
309
- const labelsToDelete = [];
310
- for (const [labelName, deleteOrphaned] of labelsWithDeleteOrphaned) {
311
- if (deleteOrphaned === true) {
312
- newManaged.add(labelName);
313
- }
314
- }
315
- for (const labelName of existingManaged) {
316
- if (!labelsWithDeleteOrphaned.has(labelName)) {
317
- labelsToDelete.push(labelName);
318
- }
319
- }
320
- const updatedConfigs = {
321
- ...(manifest?.configs ?? {}),
322
- };
323
- // Preserve existing files and rulesets for this config
324
- const existingEntry = manifest?.configs[configId];
325
- const existingFiles = existingEntry?.files;
326
- const existingRulesets = existingEntry?.rulesets;
327
- const sortedManaged = Array.from(newManaged).sort();
328
- if (sortedManaged.length > 0 ||
329
- (existingFiles && existingFiles.length > 0) ||
330
- (existingRulesets && existingRulesets.length > 0)) {
331
- updatedConfigs[configId] = {
332
- ...(existingFiles && existingFiles.length > 0
333
- ? { files: existingFiles }
334
- : {}),
335
- ...(existingRulesets && existingRulesets.length > 0
336
- ? { rulesets: existingRulesets }
337
- : {}),
338
- ...(sortedManaged.length > 0 ? { labels: sortedManaged } : {}),
339
- };
340
- }
341
- else {
342
- delete updatedConfigs[configId];
343
- }
344
- return {
345
- manifest: { version: 3, configs: updatedConfigs },
346
- labelsToDelete,
347
- };
348
- }
@@ -3,8 +3,7 @@ import type { RepoInfo } from "../shared/repo-detector.js";
3
3
  import { ILogger } from "../shared/logger.js";
4
4
  import { type IFileWriter, type IManifestManager, type IBranchManager, type IAuthOptionsBuilder, type IRepositorySession, type ICommitPushManager, type IFileSyncOrchestrator, type IPRMergeHandler, type ISyncWorkflow, type IRepositoryProcessor, type GitOpsFactory, type ProcessorOptions, type ProcessorResult } from "./index.js";
5
5
  /**
6
- * Thin facade that delegates to SyncWorkflow with appropriate strategy.
7
- * process() uses FileSyncStrategy, updateManifestOnly() uses ManifestStrategy.
6
+ * Thin facade that delegates to SyncWorkflow with FileSyncStrategy.
8
7
  */
9
8
  export declare class RepositoryProcessor implements IRepositoryProcessor {
10
9
  private readonly syncWorkflow;
@@ -22,8 +21,4 @@ export declare class RepositoryProcessor implements IRepositoryProcessor {
22
21
  syncWorkflow?: ISyncWorkflow;
23
22
  });
24
23
  process(repoConfig: RepoConfig, repoInfo: RepoInfo, options: ProcessorOptions): Promise<ProcessorResult>;
25
- updateManifestOnly(repoInfo: RepoInfo, repoConfig: RepoConfig, options: ProcessorOptions, manifestUpdate: {
26
- rulesets?: string[];
27
- labels?: string[];
28
- }): Promise<ProcessorResult>;
29
24
  }
@@ -3,11 +3,9 @@ import { AuthenticatedGitOps } from "../vcs/authenticated-git-ops.js";
3
3
  import { logger } from "../shared/logger.js";
4
4
  import { hasGitHubAppCredentials } from "../vcs/index.js";
5
5
  import { GitHubAppTokenManager } from "../vcs/github-app-token-manager.js";
6
- import { FileWriter, ManifestManager, BranchManager, AuthOptionsBuilder, RepositorySession, CommitPushManager, FileSyncOrchestrator, PRMergeHandler, FileSyncStrategy, ManifestStrategy, SyncWorkflow, loadManifest, updateManifestRulesets, updateManifestLabels, MANIFEST_FILENAME, } from "./index.js";
7
- import { getRepoDisplayName } from "../shared/repo-detector.js";
6
+ import { FileWriter, ManifestManager, BranchManager, AuthOptionsBuilder, RepositorySession, CommitPushManager, FileSyncOrchestrator, PRMergeHandler, FileSyncStrategy, SyncWorkflow, } from "./index.js";
8
7
  /**
9
- * Thin facade that delegates to SyncWorkflow with appropriate strategy.
10
- * process() uses FileSyncStrategy, updateManifestOnly() uses ManifestStrategy.
8
+ * Thin facade that delegates to SyncWorkflow with FileSyncStrategy.
11
9
  */
12
10
  export class RepositoryProcessor {
13
11
  syncWorkflow;
@@ -42,52 +40,4 @@ export class RepositoryProcessor {
42
40
  const strategy = new FileSyncStrategy(this.fileSyncOrchestrator);
43
41
  return this.syncWorkflow.execute(repoConfig, repoInfo, options, strategy);
44
42
  }
45
- async updateManifestOnly(repoInfo, repoConfig, options, manifestUpdate) {
46
- const repoName = getRepoDisplayName(repoInfo);
47
- const { workDir, dryRun } = options;
48
- // Pre-check manifest changes (preserves original early-return behavior)
49
- const existingManifest = loadManifest(workDir);
50
- let simulatedManifest = existingManifest;
51
- if (manifestUpdate.rulesets) {
52
- const rulesetsWithDeleteOrphaned = new Map(manifestUpdate.rulesets.map((name) => [name, true]));
53
- const result = updateManifestRulesets(simulatedManifest, options.configId, rulesetsWithDeleteOrphaned);
54
- simulatedManifest = result.manifest;
55
- }
56
- if (manifestUpdate.labels) {
57
- const labelsWithDeleteOrphaned = new Map(manifestUpdate.labels.map((name) => [name, true]));
58
- const result = updateManifestLabels(simulatedManifest, options.configId, labelsWithDeleteOrphaned);
59
- simulatedManifest = result.manifest;
60
- }
61
- const existingConfigs = existingManifest?.configs ?? {};
62
- if (JSON.stringify(existingConfigs) ===
63
- JSON.stringify(simulatedManifest.configs)) {
64
- return {
65
- success: true,
66
- repoName,
67
- message: "No manifest changes detected",
68
- skipped: true,
69
- };
70
- }
71
- const manifestFileChange = [
72
- { path: MANIFEST_FILENAME, action: "update" },
73
- ];
74
- if (dryRun) {
75
- const parts = [];
76
- if (manifestUpdate.rulesets)
77
- parts.push("ruleset");
78
- if (manifestUpdate.labels)
79
- parts.push("labels");
80
- const trackingType = parts.join("/") || "settings";
81
- this.log.info(`Would update ${MANIFEST_FILENAME} with ${trackingType} tracking`);
82
- return {
83
- success: true,
84
- repoName,
85
- message: "Would update manifest (dry-run)",
86
- fileChanges: manifestFileChange,
87
- };
88
- }
89
- // Delegate to workflow for actual commit/push/PR
90
- const strategy = new ManifestStrategy(manifestUpdate, this.log);
91
- return this.syncWorkflow.execute(repoConfig, repoInfo, options, strategy);
92
- }
93
43
  }
@@ -257,10 +257,6 @@ export interface ProcessorResult {
257
257
  */
258
258
  export interface IRepositoryProcessor {
259
259
  process(repoConfig: RepoConfig, repoInfo: RepoInfo, options: ProcessorOptions): Promise<ProcessorResult>;
260
- updateManifestOnly(repoInfo: RepoInfo, repoConfig: RepoConfig, options: ProcessorOptions, manifestUpdate: {
261
- rulesets?: string[];
262
- labels?: string[];
263
- }): Promise<ProcessorResult>;
264
260
  }
265
261
  /**
266
262
  * Result of file synchronization
@@ -65,6 +65,7 @@ export class GraphQLCommitStrategy {
65
65
  /403\b/,
66
66
  /does\s*not\s*exist/i,
67
67
  /could\s*not\s*resolve/i,
68
+ /already\s*exists/i,
68
69
  ];
69
70
  executor;
70
71
  constructor(executor) {
@@ -243,8 +244,21 @@ export class GraphQLCommitStrategy {
243
244
  }
244
245
  else if (!refId) {
245
246
  // Branch doesn't exist: create from local HEAD
247
+ // Race condition: on newly created forks, queryRemoteRef may return null
248
+ // due to eventual consistency, but the branch may exist by the time we
249
+ // try to create it. Treat "already exists" as success.
246
250
  const sha = (await this.executor.exec("git rev-parse HEAD", workDir)).trim();
247
- await this.createRemoteRef(repositoryId, branchName, sha, workDir, repoInfo, token);
251
+ try {
252
+ await this.createRemoteRef(repositoryId, branchName, sha, workDir, repoInfo, token);
253
+ }
254
+ catch (error) {
255
+ const msg = error instanceof Error ? error.message : String(error);
256
+ if (/already exists/i.test(msg)) {
257
+ // Branch was created between our query and create — that's fine
258
+ return;
259
+ }
260
+ throw error;
261
+ }
248
262
  }
249
263
  // refId exists + !force: no-op (branch already exists)
250
264
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aspruyt/xfg",
3
- "version": "3.13.0",
3
+ "version": "4.0.0",
4
4
  "description": "Manage files, settings, and repositories across GitHub, Azure DevOps, and GitLab — declaratively, from a single YAML config",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -1,11 +0,0 @@
1
- import type { GitHubAppTokenManager } from "../../vcs/index.js";
2
- import type { RepoResult } from "../../output/github-summary.js";
3
- import type { IRepoLifecycleManager } from "../../lifecycle/index.js";
4
- import type { Config, RepoConfig } from "../../config/types.js";
5
- import type { ResultsCollector } from "./results-collector.js";
6
- import type { SettingsOptions } from "../settings-command.js";
7
- /**
8
- * Run lifecycle checks for all unique repos before processing.
9
- * Returns a Set of git URLs to skip (lifecycle errors or repos that would be created in dry-run).
10
- */
11
- export declare function runLifecycleChecks(allRepos: RepoConfig[], config: Config, options: SettingsOptions, lifecycleManager: IRepoLifecycleManager, results: RepoResult[], collector: ResultsCollector, tokenManager: GitHubAppTokenManager | null): Promise<Set<string>>;