@aspruyt/xfg 3.7.6 → 3.7.7

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 (156) hide show
  1. package/dist/cli/index.d.ts +6 -0
  2. package/dist/cli/index.js +9 -0
  3. package/dist/cli/program.d.ts +2 -0
  4. package/dist/cli/program.js +70 -0
  5. package/dist/cli/settings-command.d.ts +10 -0
  6. package/dist/cli/settings-command.js +228 -0
  7. package/dist/cli/sync-command.d.ts +25 -0
  8. package/dist/cli/sync-command.js +155 -0
  9. package/dist/cli/types.d.ts +45 -0
  10. package/dist/cli/types.js +15 -0
  11. package/dist/cli.js +2 -19
  12. package/dist/{file-reference-resolver.d.ts → config/file-reference-resolver.d.ts} +1 -1
  13. package/dist/config/index.d.ts +7 -0
  14. package/dist/config/index.js +12 -0
  15. package/dist/config/loader.d.ts +9 -0
  16. package/dist/{config.js → config/loader.js} +3 -24
  17. package/dist/{config-normalizer.d.ts → config/normalizer.d.ts} +1 -1
  18. package/dist/{config-normalizer.js → config/normalizer.js} +1 -1
  19. package/dist/{config.d.ts → config/types.d.ts} +5 -9
  20. package/dist/config/types.js +16 -0
  21. package/dist/{config-validator.d.ts → config/validator.d.ts} +5 -5
  22. package/dist/{config-validator.js → config/validator.js} +60 -372
  23. package/dist/config/validators/file-validator.d.ts +22 -0
  24. package/dist/config/validators/file-validator.js +46 -0
  25. package/dist/config/validators/index.d.ts +3 -0
  26. package/dist/config/validators/index.js +6 -0
  27. package/dist/config/validators/repo-settings-validator.d.ts +10 -0
  28. package/dist/config/validators/repo-settings-validator.js +71 -0
  29. package/dist/config/validators/ruleset-validator.d.ts +18 -0
  30. package/dist/config/validators/ruleset-validator.js +201 -0
  31. package/dist/index.d.ts +3 -66
  32. package/dist/index.js +3 -474
  33. package/dist/output/index.d.ts +4 -0
  34. package/dist/output/index.js +8 -0
  35. package/dist/{summary-utils.d.ts → output/summary-utils.d.ts} +3 -3
  36. package/dist/settings/index.d.ts +3 -0
  37. package/dist/settings/index.js +6 -0
  38. package/dist/{repo-settings-diff.d.ts → settings/repo-settings/diff.d.ts} +2 -2
  39. package/dist/{repo-settings-plan-formatter.d.ts → settings/repo-settings/formatter.d.ts} +1 -1
  40. package/dist/{strategies → settings/repo-settings}/github-repo-settings-strategy.d.ts +4 -4
  41. package/dist/{strategies → settings/repo-settings}/github-repo-settings-strategy.js +3 -3
  42. package/dist/settings/repo-settings/index.d.ts +5 -0
  43. package/dist/settings/repo-settings/index.js +10 -0
  44. package/dist/{repo-settings-processor.d.ts → settings/repo-settings/processor.d.ts} +4 -4
  45. package/dist/{repo-settings-processor.js → settings/repo-settings/processor.js} +6 -6
  46. package/dist/{strategies/repo-settings-strategy.d.ts → settings/repo-settings/types.d.ts} +2 -2
  47. package/dist/{resource-converters.d.ts → settings/resource-converters.d.ts} +4 -4
  48. package/dist/settings/rulesets/diff-algorithm.d.ts +18 -0
  49. package/dist/settings/rulesets/diff-algorithm.js +166 -0
  50. package/dist/{ruleset-diff.d.ts → settings/rulesets/diff.d.ts} +2 -2
  51. package/dist/{ruleset-diff.js → settings/rulesets/diff.js} +1 -1
  52. package/dist/{ruleset-plan-formatter.d.ts → settings/rulesets/formatter.d.ts} +4 -12
  53. package/dist/{ruleset-plan-formatter.js → settings/rulesets/formatter.js} +3 -164
  54. package/dist/{strategies → settings/rulesets}/github-ruleset-strategy.d.ts +4 -4
  55. package/dist/{strategies → settings/rulesets}/github-ruleset-strategy.js +3 -3
  56. package/dist/settings/rulesets/index.d.ts +6 -0
  57. package/dist/settings/rulesets/index.js +10 -0
  58. package/dist/{ruleset-processor.d.ts → settings/rulesets/processor.d.ts} +4 -4
  59. package/dist/{ruleset-processor.js → settings/rulesets/processor.js} +6 -6
  60. package/dist/{strategies/ruleset-strategy.d.ts → settings/rulesets/types.d.ts} +2 -2
  61. package/dist/{command-executor.d.ts → shared/command-executor.d.ts} +10 -2
  62. package/dist/{command-executor.js → shared/command-executor.js} +2 -1
  63. package/dist/shared/index.d.ts +8 -0
  64. package/dist/shared/index.js +16 -0
  65. package/dist/{logger.d.ts → shared/logger.d.ts} +1 -1
  66. package/dist/{logger.js → shared/logger.js} +1 -1
  67. package/dist/sync/auth-options-builder.d.ts +12 -0
  68. package/dist/sync/auth-options-builder.js +54 -0
  69. package/dist/sync/branch-manager.d.ts +7 -0
  70. package/dist/sync/branch-manager.js +36 -0
  71. package/dist/sync/commit-message.d.ts +11 -0
  72. package/dist/sync/commit-message.js +27 -0
  73. package/dist/sync/commit-push-manager.d.ts +8 -0
  74. package/dist/sync/commit-push-manager.js +71 -0
  75. package/dist/sync/file-sync-orchestrator.d.ts +11 -0
  76. package/dist/sync/file-sync-orchestrator.js +58 -0
  77. package/dist/sync/file-writer.d.ts +18 -0
  78. package/dist/sync/file-writer.js +101 -0
  79. package/dist/sync/index.d.ts +14 -0
  80. package/dist/sync/index.js +17 -0
  81. package/dist/sync/manifest-manager.d.ts +10 -0
  82. package/dist/sync/manifest-manager.js +64 -0
  83. package/dist/sync/pr-merge-handler.d.ts +11 -0
  84. package/dist/sync/pr-merge-handler.js +62 -0
  85. package/dist/sync/repository-processor.d.ts +30 -0
  86. package/dist/sync/repository-processor.js +278 -0
  87. package/dist/sync/repository-session.d.ts +9 -0
  88. package/dist/sync/repository-session.js +35 -0
  89. package/dist/sync/types.d.ts +296 -0
  90. package/dist/{xfg-template.d.ts → sync/xfg-template.d.ts} +2 -2
  91. package/dist/{authenticated-git-ops.js → vcs/authenticated-git-ops.js} +3 -3
  92. package/dist/{strategies → vcs}/azure-pr-strategy.d.ts +2 -2
  93. package/dist/{strategies → vcs}/azure-pr-strategy.js +5 -5
  94. package/dist/{strategies → vcs}/commit-strategy-selector.d.ts +3 -3
  95. package/dist/{strategies → vcs}/commit-strategy-selector.js +1 -1
  96. package/dist/{strategies → vcs}/git-commit-strategy.d.ts +2 -2
  97. package/dist/{strategies → vcs}/git-commit-strategy.js +3 -3
  98. package/dist/{git-ops.d.ts → vcs/git-ops.d.ts} +1 -1
  99. package/dist/{git-ops.js → vcs/git-ops.js} +4 -4
  100. package/dist/{github-app-token-manager.d.ts → vcs/github-app-token-manager.d.ts} +1 -1
  101. package/dist/{github-app-token-manager.js → vcs/github-app-token-manager.js} +1 -1
  102. package/dist/{strategies → vcs}/github-pr-strategy.d.ts +2 -2
  103. package/dist/{strategies → vcs}/github-pr-strategy.js +30 -33
  104. package/dist/{strategies → vcs}/gitlab-pr-strategy.d.ts +2 -2
  105. package/dist/{strategies → vcs}/gitlab-pr-strategy.js +5 -5
  106. package/dist/{strategies → vcs}/graphql-commit-strategy.d.ts +2 -2
  107. package/dist/{strategies → vcs}/graphql-commit-strategy.js +3 -3
  108. package/dist/vcs/index.d.ts +16 -0
  109. package/dist/{strategies → vcs}/index.js +15 -10
  110. package/dist/{pr-creator.d.ts → vcs/pr-creator.d.ts} +4 -4
  111. package/dist/{pr-creator.js → vcs/pr-creator.js} +3 -3
  112. package/dist/vcs/pr-strategy.d.ts +41 -0
  113. package/dist/{strategies → vcs}/pr-strategy.js +1 -1
  114. package/dist/{strategies/pr-strategy.d.ts → vcs/types.d.ts} +32 -35
  115. package/dist/vcs/types.js +1 -0
  116. package/package.json +2 -2
  117. package/dist/repository-processor.d.ts +0 -79
  118. package/dist/repository-processor.js +0 -659
  119. package/dist/strategies/commit-strategy.d.ts +0 -36
  120. package/dist/strategies/index.d.ts +0 -18
  121. /package/dist/{file-reference-resolver.js → config/file-reference-resolver.js} +0 -0
  122. /package/dist/{config-formatter.d.ts → config/formatter.d.ts} +0 -0
  123. /package/dist/{config-formatter.js → config/formatter.js} +0 -0
  124. /package/dist/{merge.d.ts → config/merge.d.ts} +0 -0
  125. /package/dist/{merge.js → config/merge.js} +0 -0
  126. /package/dist/{github-summary.d.ts → output/github-summary.d.ts} +0 -0
  127. /package/dist/{github-summary.js → output/github-summary.js} +0 -0
  128. /package/dist/{plan-formatter.d.ts → output/plan-formatter.d.ts} +0 -0
  129. /package/dist/{plan-formatter.js → output/plan-formatter.js} +0 -0
  130. /package/dist/{plan-summary.d.ts → output/plan-summary.d.ts} +0 -0
  131. /package/dist/{plan-summary.js → output/plan-summary.js} +0 -0
  132. /package/dist/{summary-utils.js → output/summary-utils.js} +0 -0
  133. /package/dist/{repo-settings-diff.js → settings/repo-settings/diff.js} +0 -0
  134. /package/dist/{repo-settings-plan-formatter.js → settings/repo-settings/formatter.js} +0 -0
  135. /package/dist/{strategies/repo-settings-strategy.js → settings/repo-settings/types.js} +0 -0
  136. /package/dist/{resource-converters.js → settings/resource-converters.js} +0 -0
  137. /package/dist/{strategies/commit-strategy.js → settings/rulesets/types.js} +0 -0
  138. /package/dist/{env.d.ts → shared/env.d.ts} +0 -0
  139. /package/dist/{env.js → shared/env.js} +0 -0
  140. /package/dist/{repo-detector.d.ts → shared/repo-detector.d.ts} +0 -0
  141. /package/dist/{repo-detector.js → shared/repo-detector.js} +0 -0
  142. /package/dist/{retry-utils.d.ts → shared/retry-utils.d.ts} +0 -0
  143. /package/dist/{retry-utils.js → shared/retry-utils.js} +0 -0
  144. /package/dist/{sanitize-utils.d.ts → shared/sanitize-utils.d.ts} +0 -0
  145. /package/dist/{sanitize-utils.js → shared/sanitize-utils.js} +0 -0
  146. /package/dist/{shell-utils.d.ts → shared/shell-utils.d.ts} +0 -0
  147. /package/dist/{shell-utils.js → shared/shell-utils.js} +0 -0
  148. /package/dist/{workspace-utils.d.ts → shared/workspace-utils.d.ts} +0 -0
  149. /package/dist/{workspace-utils.js → shared/workspace-utils.js} +0 -0
  150. /package/dist/{diff-utils.d.ts → sync/diff-utils.d.ts} +0 -0
  151. /package/dist/{diff-utils.js → sync/diff-utils.js} +0 -0
  152. /package/dist/{manifest.d.ts → sync/manifest.d.ts} +0 -0
  153. /package/dist/{manifest.js → sync/manifest.js} +0 -0
  154. /package/dist/{strategies/ruleset-strategy.js → sync/types.js} +0 -0
  155. /package/dist/{xfg-template.js → sync/xfg-template.js} +0 -0
  156. /package/dist/{authenticated-git-ops.d.ts → vcs/authenticated-git-ops.d.ts} +0 -0
@@ -1,659 +0,0 @@
1
- import { existsSync } from "node:fs";
2
- import { join } from "node:path";
3
- import { convertContentToString, } from "./config.js";
4
- import { getRepoDisplayName, isGitHubRepo, } from "./repo-detector.js";
5
- import { interpolateXfgContent } from "./xfg-template.js";
6
- import { GitOps } from "./git-ops.js";
7
- import { AuthenticatedGitOps, } from "./authenticated-git-ops.js";
8
- import { createPR, mergePR } from "./pr-creator.js";
9
- import { logger } from "./logger.js";
10
- import { getPRStrategy, getCommitStrategy, hasGitHubAppCredentials, } from "./strategies/index.js";
11
- import { defaultExecutor } from "./command-executor.js";
12
- import { getFileStatus, generateDiff, createDiffStats, incrementDiffStats, } from "./diff-utils.js";
13
- import { loadManifest, saveManifest, updateManifest, updateManifestRulesets, MANIFEST_FILENAME, } from "./manifest.js";
14
- import { GitHubAppTokenManager } from "./github-app-token-manager.js";
15
- /**
16
- * Determines if a file should be marked as executable.
17
- * .sh files are auto-executable unless explicit executable: false is set.
18
- * Non-.sh files are executable only if executable: true is explicitly set.
19
- */
20
- function shouldBeExecutable(file) {
21
- const isShellScript = file.fileName.endsWith(".sh");
22
- if (file.executable !== undefined) {
23
- // Explicit setting takes precedence
24
- return file.executable;
25
- }
26
- // Default: .sh files are executable, others are not
27
- return isShellScript;
28
- }
29
- export class RepositoryProcessor {
30
- gitOps = null;
31
- gitOpsFactory;
32
- log;
33
- retries = 3;
34
- executor = defaultExecutor;
35
- tokenManager;
36
- /**
37
- * Creates a new RepositoryProcessor.
38
- * @param gitOpsFactory - Optional factory for creating AuthenticatedGitOps instances (for testing)
39
- * @param log - Optional logger instance (for testing)
40
- */
41
- constructor(gitOpsFactory, log) {
42
- this.gitOpsFactory =
43
- gitOpsFactory ??
44
- ((opts, auth) => new AuthenticatedGitOps(new GitOps(opts), auth));
45
- this.log = log ?? logger;
46
- // Initialize GitHub App token manager if credentials are configured
47
- if (hasGitHubAppCredentials()) {
48
- this.tokenManager = new GitHubAppTokenManager(process.env.XFG_GITHUB_APP_ID, process.env.XFG_GITHUB_APP_PRIVATE_KEY);
49
- }
50
- else {
51
- this.tokenManager = null;
52
- }
53
- }
54
- async process(repoConfig, repoInfo, options) {
55
- const repoName = getRepoDisplayName(repoInfo);
56
- const { branchName, workDir, dryRun, prTemplate } = options;
57
- this.retries = options.retries ?? 3;
58
- this.executor = options.executor ?? defaultExecutor;
59
- // Get installation token if needed
60
- const token = await this.getInstallationToken(repoInfo);
61
- if (token === null) {
62
- return {
63
- success: true,
64
- repoName,
65
- message: `No GitHub App installation found for ${repoInfo.owner}`,
66
- skipped: true,
67
- };
68
- }
69
- // Build auth options - use installation token OR fall back to GH_TOKEN for PAT flow
70
- const effectiveToken = token ?? (isGitHubRepo(repoInfo) ? process.env.GH_TOKEN : undefined);
71
- const authOptions = effectiveToken
72
- ? {
73
- token: effectiveToken,
74
- host: isGitHubRepo(repoInfo)
75
- ? repoInfo.host
76
- : "github.com",
77
- owner: repoInfo.owner,
78
- repo: repoInfo.repo,
79
- }
80
- : undefined;
81
- this.gitOps = this.gitOpsFactory({
82
- workDir,
83
- dryRun,
84
- retries: this.retries,
85
- }, authOptions);
86
- // Determine merge mode early - affects workflow steps
87
- const mergeMode = repoConfig.prOptions?.merge ?? "auto";
88
- const isDirectMode = mergeMode === "direct";
89
- // Warn if mergeStrategy is set with direct mode (irrelevant)
90
- if (isDirectMode && repoConfig.prOptions?.mergeStrategy) {
91
- this.log.info(`Warning: mergeStrategy '${repoConfig.prOptions.mergeStrategy}' is ignored in direct mode (no PR created)`);
92
- }
93
- try {
94
- // Step 1: Clean workspace
95
- this.log.info("Cleaning workspace...");
96
- this.gitOps.cleanWorkspace();
97
- // Step 2: Clone repo
98
- this.log.info("Cloning repository...");
99
- await this.gitOps.clone(repoInfo.gitUrl);
100
- // Step 3: Get default branch for PR base
101
- const { branch: baseBranch, method: detectionMethod } = await this.gitOps.getDefaultBranch();
102
- this.log.info(`Default branch: ${baseBranch} (detected via ${detectionMethod})`);
103
- // Step 3.5: Close existing PR if exists (fresh start approach)
104
- // This ensures isolated sync attempts - each run starts from clean state
105
- // Skip for direct mode - no PR involved
106
- if (!dryRun && !isDirectMode) {
107
- this.log.info("Checking for existing PR...");
108
- const strategy = getPRStrategy(repoInfo, this.executor);
109
- const closed = await strategy.closeExistingPR({
110
- repoInfo,
111
- branchName,
112
- baseBranch,
113
- workDir,
114
- retries: this.retries,
115
- token,
116
- });
117
- if (closed) {
118
- this.log.info("Closed existing PR and deleted branch for fresh sync");
119
- // Prune stale remote tracking refs so --force-with-lease works correctly
120
- // The remote branch was deleted but local git still has tracking info
121
- await this.gitOps.fetch({ prune: true });
122
- }
123
- }
124
- // Step 4: Create branch (always fresh from base branch)
125
- // Skip for direct mode - stay on default branch
126
- if (!isDirectMode) {
127
- this.log.info(`Creating branch: ${branchName}`);
128
- await this.gitOps.createBranch(branchName);
129
- }
130
- else {
131
- this.log.info(`Direct mode: staying on ${baseBranch}`);
132
- }
133
- // Step 5: Write all config files and track changes
134
- //
135
- // DESIGN NOTE: Change detection differs between dry-run and normal mode:
136
- // - Dry-run: Uses wouldChange() for read-only content comparison (no side effects)
137
- // - Normal: Uses git status after writing (source of truth for what git will commit)
138
- //
139
- // Track all file changes with content and action - single source of truth
140
- // Used for both commit message generation and actual commit
141
- const fileChangesForCommit = new Map();
142
- const diffStats = createDiffStats();
143
- for (const file of repoConfig.files) {
144
- const filePath = join(workDir, file.fileName);
145
- const fileExistsLocal = existsSync(filePath);
146
- // Handle createOnly - check against BASE branch, not current working directory
147
- // This ensures consistent behavior: createOnly means "only create if doesn't exist on main"
148
- if (file.createOnly) {
149
- const existsOnBase = await this.gitOps.fileExistsOnBranch(file.fileName, baseBranch);
150
- if (existsOnBase) {
151
- this.log.info(`Skipping ${file.fileName} (createOnly: exists on ${baseBranch})`);
152
- fileChangesForCommit.set(file.fileName, {
153
- content: null,
154
- action: "skip",
155
- });
156
- continue;
157
- }
158
- }
159
- this.log.info(`Writing ${file.fileName}...`);
160
- // Apply xfg templating if enabled
161
- let contentToWrite = file.content;
162
- if (file.template && contentToWrite !== null) {
163
- contentToWrite = interpolateXfgContent(contentToWrite, {
164
- repoInfo,
165
- fileName: file.fileName,
166
- vars: file.vars,
167
- }, { strict: true });
168
- }
169
- const fileContent = convertContentToString(contentToWrite, file.fileName, {
170
- header: file.header,
171
- schemaUrl: file.schemaUrl,
172
- });
173
- // Determine action type (create vs update) BEFORE writing
174
- const action = fileExistsLocal
175
- ? "update"
176
- : "create";
177
- // Check if file would change (needed for both modes)
178
- const existingContent = this.gitOps.getFileContent(file.fileName);
179
- const changed = this.gitOps.wouldChange(file.fileName, fileContent);
180
- if (changed) {
181
- // Track in single source of truth
182
- fileChangesForCommit.set(file.fileName, {
183
- content: fileContent,
184
- action,
185
- });
186
- }
187
- if (dryRun) {
188
- // In dry-run, show diff but don't write
189
- const status = getFileStatus(existingContent !== null, changed);
190
- incrementDiffStats(diffStats, status);
191
- const diffLines = generateDiff(existingContent, fileContent, file.fileName);
192
- this.log.fileDiff(file.fileName, status, diffLines);
193
- }
194
- else {
195
- // Write the file
196
- this.gitOps.writeFile(file.fileName, fileContent);
197
- }
198
- }
199
- // Step 5b: Set executable permission for files that need it
200
- for (const file of repoConfig.files) {
201
- // Skip files that were excluded (createOnly + exists)
202
- const tracked = fileChangesForCommit.get(file.fileName);
203
- if (tracked?.action === "skip") {
204
- continue;
205
- }
206
- if (shouldBeExecutable(file)) {
207
- this.log.info(`Setting executable: ${file.fileName}`);
208
- await this.gitOps.setExecutable(file.fileName);
209
- }
210
- }
211
- // Step 5c: Handle orphaned file deletion (manifest-based tracking)
212
- const existingManifest = loadManifest(workDir);
213
- // Build map of files with their deleteOrphaned setting
214
- // Include ALL files from config, even skipped ones (createOnly + exists),
215
- // so they aren't incorrectly treated as orphaned (issue #199)
216
- const filesWithDeleteOrphaned = new Map();
217
- for (const file of repoConfig.files) {
218
- filesWithDeleteOrphaned.set(file.fileName, file.deleteOrphaned);
219
- }
220
- // Update manifest and get list of files to delete
221
- const { manifest: newManifest, filesToDelete } = updateManifest(existingManifest, options.configId, filesWithDeleteOrphaned);
222
- // Delete orphaned files (unless --no-delete flag is set)
223
- if (filesToDelete.length > 0 && !options.noDelete) {
224
- for (const fileName of filesToDelete) {
225
- // Only delete if file actually exists in the working directory
226
- if (this.gitOps.fileExists(fileName)) {
227
- // Track deletion in single source of truth
228
- fileChangesForCommit.set(fileName, {
229
- content: null,
230
- action: "delete",
231
- });
232
- if (dryRun) {
233
- // In dry-run, show what would be deleted
234
- this.log.fileDiff(fileName, "DELETED", []);
235
- incrementDiffStats(diffStats, "DELETED");
236
- }
237
- else {
238
- this.log.info(`Deleting orphaned file: ${fileName}`);
239
- this.gitOps.deleteFile(fileName);
240
- }
241
- }
242
- }
243
- }
244
- else if (filesToDelete.length > 0 && options.noDelete) {
245
- this.log.info(`Skipping deletion of ${filesToDelete.length} orphaned file(s) (--no-delete flag)`);
246
- }
247
- // Save updated manifest (tracks files with deleteOrphaned: true)
248
- // Only save if there are managed files for any config, or if we had a previous manifest
249
- const hasAnyManagedFiles = Object.keys(newManifest.configs).length > 0;
250
- if (hasAnyManagedFiles || existingManifest !== null) {
251
- // Track manifest file as changed if it would be different
252
- const existingConfigs = existingManifest?.configs ?? {};
253
- const manifestChanged = JSON.stringify(existingConfigs) !==
254
- JSON.stringify(newManifest.configs);
255
- if (manifestChanged) {
256
- const manifestExisted = existsSync(join(workDir, MANIFEST_FILENAME));
257
- const manifestContent = JSON.stringify(newManifest, null, 2) + "\n";
258
- fileChangesForCommit.set(MANIFEST_FILENAME, {
259
- content: manifestContent,
260
- action: manifestExisted ? "update" : "create",
261
- });
262
- }
263
- if (!dryRun) {
264
- saveManifest(workDir, newManifest);
265
- }
266
- }
267
- // Show diff summary in dry-run mode
268
- if (dryRun) {
269
- this.log.diffSummary(diffStats.newCount, diffStats.modifiedCount, diffStats.unchangedCount, diffStats.deletedCount);
270
- }
271
- // Step 6: Derive changedFiles from single source of truth
272
- // This ensures dry-run and non-dry-run modes use identical logic
273
- const changedFiles = Array.from(fileChangesForCommit.entries()).map(([fileName, info]) => ({ fileName, action: info.action }));
274
- // Calculate diff stats for non-dry-run mode (dry-run already calculated above)
275
- if (!dryRun) {
276
- for (const [, info] of fileChangesForCommit) {
277
- if (info.action === "create")
278
- incrementDiffStats(diffStats, "NEW");
279
- else if (info.action === "update")
280
- incrementDiffStats(diffStats, "MODIFIED");
281
- else if (info.action === "delete")
282
- incrementDiffStats(diffStats, "DELETED");
283
- }
284
- }
285
- const hasChanges = changedFiles.filter((f) => f.action !== "skip").length > 0;
286
- if (!hasChanges) {
287
- return {
288
- success: true,
289
- repoName,
290
- message: "No changes detected",
291
- skipped: true,
292
- diffStats,
293
- };
294
- }
295
- // Step 7: Commit and Push using commit strategy
296
- const commitMessage = this.formatCommitMessage(changedFiles);
297
- const pushBranch = isDirectMode ? baseBranch : branchName;
298
- if (dryRun) {
299
- // In dry-run mode, just log what would happen
300
- this.log.info("Staging changes...");
301
- this.log.info(`Would commit: ${commitMessage}`);
302
- this.log.info(`Would push to ${pushBranch}...`);
303
- }
304
- else {
305
- // Build file changes for commit strategy (filter out skipped files)
306
- const fileChanges = Array.from(fileChangesForCommit.entries())
307
- .filter(([, info]) => info.action !== "skip")
308
- .map(([path, info]) => ({ path, content: info.content }));
309
- // Check if there are actually staged changes (edge case handling)
310
- // This handles scenarios where git status shows changes but git add doesn't stage anything
311
- // (e.g., due to .gitattributes normalization)
312
- this.log.info("Staging changes...");
313
- await this.executor.exec("git add -A", workDir);
314
- if (!(await this.gitOps.hasStagedChanges())) {
315
- this.log.info("No staged changes after git add -A, skipping commit");
316
- return {
317
- success: true,
318
- repoName,
319
- message: "No changes detected after staging",
320
- skipped: true,
321
- diffStats,
322
- };
323
- }
324
- // Use commit strategy (GitCommitStrategy or GraphQLCommitStrategy)
325
- const commitStrategy = getCommitStrategy(repoInfo, this.executor);
326
- this.log.info("Committing and pushing changes...");
327
- try {
328
- const commitResult = await commitStrategy.commit({
329
- repoInfo,
330
- branchName: pushBranch,
331
- message: commitMessage,
332
- fileChanges,
333
- workDir,
334
- retries: this.retries,
335
- // Use force push (--force-with-lease) for PR branches, not for direct mode
336
- force: !isDirectMode,
337
- token,
338
- gitOps: this.gitOps,
339
- });
340
- this.log.info(`Committed: ${commitResult.sha} (verified: ${commitResult.verified})`);
341
- }
342
- catch (error) {
343
- // Handle branch protection errors in direct mode
344
- if (isDirectMode) {
345
- const errorMessage = error instanceof Error ? error.message : String(error);
346
- if (errorMessage.includes("rejected") ||
347
- errorMessage.includes("protected") ||
348
- errorMessage.includes("denied")) {
349
- return {
350
- success: false,
351
- repoName,
352
- message: `Push to '${baseBranch}' was rejected (likely branch protection). To use 'direct' mode, the target branch must allow direct pushes. Use 'merge: force' to create a PR and merge with admin privileges.`,
353
- };
354
- }
355
- }
356
- throw error;
357
- }
358
- }
359
- // Direct mode: no PR creation, return success
360
- if (isDirectMode) {
361
- this.log.info(`Changes pushed directly to ${baseBranch}`);
362
- return {
363
- success: true,
364
- repoName,
365
- message: `Pushed directly to ${baseBranch}`,
366
- diffStats,
367
- };
368
- }
369
- // Step 9: Create PR (non-direct modes only)
370
- this.log.info("Creating pull request...");
371
- const prResult = await createPR({
372
- repoInfo,
373
- branchName,
374
- baseBranch,
375
- files: changedFiles,
376
- workDir,
377
- dryRun,
378
- retries: this.retries,
379
- prTemplate,
380
- executor: this.executor,
381
- token,
382
- });
383
- // Step 10: Handle merge options if configured
384
- let mergeResult;
385
- if (prResult.success && prResult.url && mergeMode !== "manual") {
386
- this.log.info(`Handling merge (mode: ${mergeMode})...`);
387
- const mergeConfig = {
388
- mode: mergeMode,
389
- strategy: repoConfig.prOptions?.mergeStrategy ?? "squash",
390
- deleteBranch: repoConfig.prOptions?.deleteBranch ?? true,
391
- bypassReason: repoConfig.prOptions?.bypassReason,
392
- };
393
- const result = await mergePR({
394
- repoInfo,
395
- prUrl: prResult.url,
396
- mergeConfig,
397
- workDir,
398
- dryRun,
399
- retries: this.retries,
400
- executor: this.executor,
401
- token,
402
- });
403
- mergeResult = {
404
- merged: result.merged ?? false,
405
- autoMergeEnabled: result.autoMergeEnabled,
406
- message: result.message,
407
- };
408
- if (!result.success) {
409
- this.log.info(`Warning: Merge operation failed - ${result.message}`);
410
- }
411
- else {
412
- this.log.info(result.message);
413
- }
414
- }
415
- return {
416
- success: prResult.success,
417
- repoName,
418
- message: prResult.message,
419
- prUrl: prResult.url,
420
- mergeResult,
421
- diffStats,
422
- };
423
- }
424
- finally {
425
- // Always cleanup workspace on completion or failure
426
- if (this.gitOps) {
427
- try {
428
- this.gitOps.cleanWorkspace();
429
- }
430
- catch {
431
- // Ignore cleanup errors - best effort
432
- }
433
- }
434
- }
435
- }
436
- /**
437
- * Gets installation token for GitHub repos when GitHub App is configured.
438
- * Returns undefined if no token needed or token retrieval fails.
439
- * Returns null if no installation found (caller should skip repo).
440
- */
441
- async getInstallationToken(repoInfo) {
442
- if (!this.tokenManager || !isGitHubRepo(repoInfo)) {
443
- return undefined;
444
- }
445
- try {
446
- return await this.tokenManager.getTokenForRepo(repoInfo);
447
- }
448
- catch (error) {
449
- this.log.info(`Warning: Failed to get GitHub App token: ${error instanceof Error ? error.message : String(error)}`);
450
- return undefined;
451
- }
452
- }
453
- /**
454
- * Updates only the manifest file with ruleset tracking.
455
- * Used by settings command to persist state for deleteOrphaned.
456
- * Reuses existing clone/commit/PR workflow.
457
- */
458
- async updateManifestOnly(repoInfo, repoConfig, options, manifestUpdate) {
459
- const repoName = getRepoDisplayName(repoInfo);
460
- const { branchName, workDir, dryRun } = options;
461
- this.retries = options.retries ?? 3;
462
- this.executor = options.executor ?? defaultExecutor;
463
- // Get installation token if needed
464
- const token = await this.getInstallationToken(repoInfo);
465
- if (token === null) {
466
- return {
467
- success: true,
468
- repoName,
469
- message: `No GitHub App installation found for ${repoInfo.owner}`,
470
- skipped: true,
471
- };
472
- }
473
- // Build auth options - use installation token OR fall back to GH_TOKEN for PAT flow
474
- const effectiveToken = token ?? (isGitHubRepo(repoInfo) ? process.env.GH_TOKEN : undefined);
475
- const authOptions = effectiveToken
476
- ? {
477
- token: effectiveToken,
478
- host: isGitHubRepo(repoInfo)
479
- ? repoInfo.host
480
- : "github.com",
481
- owner: repoInfo.owner,
482
- repo: repoInfo.repo,
483
- }
484
- : undefined;
485
- this.gitOps = this.gitOpsFactory({
486
- workDir,
487
- dryRun,
488
- retries: this.retries,
489
- }, authOptions);
490
- const mergeMode = repoConfig.prOptions?.merge ?? "auto";
491
- const isDirectMode = mergeMode === "direct";
492
- try {
493
- // Clone repo and get base branch
494
- this.log.info("Cleaning workspace...");
495
- this.gitOps.cleanWorkspace();
496
- this.log.info("Cloning repository...");
497
- await this.gitOps.clone(repoInfo.gitUrl);
498
- const { branch: baseBranch } = await this.gitOps.getDefaultBranch();
499
- // Load and update manifest
500
- const existingManifest = loadManifest(workDir);
501
- const rulesetsWithDeleteOrphaned = new Map(manifestUpdate.rulesets.map((name) => [name, true]));
502
- const { manifest: newManifest } = updateManifestRulesets(existingManifest, options.configId, rulesetsWithDeleteOrphaned);
503
- // Check if manifest changed
504
- const existingConfigs = existingManifest?.configs ?? {};
505
- if (JSON.stringify(existingConfigs) === JSON.stringify(newManifest.configs)) {
506
- return {
507
- success: true,
508
- repoName,
509
- message: "No manifest changes detected",
510
- skipped: true,
511
- };
512
- }
513
- // Dry-run mode: report what would happen
514
- if (dryRun) {
515
- this.log.info(`Would update ${MANIFEST_FILENAME} with rulesets`);
516
- return {
517
- success: true,
518
- repoName,
519
- message: "Would update manifest (dry-run)",
520
- };
521
- }
522
- // Prepare branch for commit
523
- if (!isDirectMode) {
524
- const strategy = getPRStrategy(repoInfo, this.executor);
525
- if (await strategy.closeExistingPR({
526
- repoInfo,
527
- branchName,
528
- baseBranch,
529
- workDir,
530
- retries: this.retries,
531
- token,
532
- })) {
533
- await this.gitOps.fetch({ prune: true });
534
- }
535
- await this.gitOps.createBranch(branchName);
536
- }
537
- // Save manifest and commit
538
- saveManifest(workDir, newManifest);
539
- await this.executor.exec("git add -A", workDir);
540
- if (!(await this.gitOps.hasStagedChanges())) {
541
- return {
542
- success: true,
543
- repoName,
544
- message: "No changes detected after staging",
545
- skipped: true,
546
- };
547
- }
548
- const pushBranch = isDirectMode ? baseBranch : branchName;
549
- const commitStrategy = getCommitStrategy(repoInfo, this.executor);
550
- try {
551
- await commitStrategy.commit({
552
- repoInfo,
553
- branchName: pushBranch,
554
- message: "chore: update manifest with ruleset tracking",
555
- fileChanges: [
556
- {
557
- path: MANIFEST_FILENAME,
558
- content: JSON.stringify(newManifest, null, 2) + "\n",
559
- },
560
- ],
561
- workDir,
562
- retries: this.retries,
563
- force: !isDirectMode,
564
- token,
565
- gitOps: this.gitOps,
566
- });
567
- }
568
- catch (error) {
569
- const msg = error instanceof Error ? error.message : String(error);
570
- if (isDirectMode &&
571
- (msg.includes("rejected") ||
572
- msg.includes("protected") ||
573
- msg.includes("denied"))) {
574
- return {
575
- success: false,
576
- repoName,
577
- message: `Push to '${baseBranch}' was rejected (likely branch protection).`,
578
- };
579
- }
580
- throw error;
581
- }
582
- if (isDirectMode) {
583
- return {
584
- success: true,
585
- repoName,
586
- message: `Manifest updated directly on ${baseBranch}`,
587
- };
588
- }
589
- // Create PR and handle merge
590
- const prResult = await createPR({
591
- repoInfo,
592
- branchName,
593
- baseBranch,
594
- files: [{ fileName: MANIFEST_FILENAME, action: "update" }],
595
- workDir,
596
- dryRun: false,
597
- retries: this.retries,
598
- executor: this.executor,
599
- token,
600
- });
601
- if (prResult.success && prResult.url && mergeMode !== "manual") {
602
- await mergePR({
603
- repoInfo,
604
- prUrl: prResult.url,
605
- mergeConfig: {
606
- mode: mergeMode,
607
- strategy: repoConfig.prOptions?.mergeStrategy ?? "squash",
608
- deleteBranch: repoConfig.prOptions?.deleteBranch ?? true,
609
- },
610
- workDir,
611
- dryRun: false,
612
- retries: this.retries,
613
- executor: this.executor,
614
- token,
615
- });
616
- }
617
- return {
618
- success: prResult.success,
619
- repoName,
620
- message: prResult.message,
621
- prUrl: prResult.url,
622
- };
623
- }
624
- finally {
625
- if (this.gitOps) {
626
- try {
627
- this.gitOps.cleanWorkspace();
628
- }
629
- catch {
630
- // Ignore cleanup errors
631
- }
632
- }
633
- }
634
- }
635
- /**
636
- * Format commit message based on files changed (excludes skipped files)
637
- */
638
- formatCommitMessage(files) {
639
- const changedFiles = files.filter((f) => f.action !== "skip");
640
- const deletedFiles = changedFiles.filter((f) => f.action === "delete");
641
- const syncedFiles = changedFiles.filter((f) => f.action !== "delete");
642
- // If only deletions, use "remove" prefix
643
- if (syncedFiles.length === 0 && deletedFiles.length > 0) {
644
- if (deletedFiles.length === 1) {
645
- return `chore: remove ${deletedFiles[0].fileName}`;
646
- }
647
- return `chore: remove ${deletedFiles.length} orphaned config files`;
648
- }
649
- // Mixed or only syncs
650
- if (changedFiles.length === 1) {
651
- return `chore: sync ${changedFiles[0].fileName}`;
652
- }
653
- if (changedFiles.length <= 3) {
654
- const fileNames = changedFiles.map((f) => f.fileName).join(", ");
655
- return `chore: sync ${fileNames}`;
656
- }
657
- return `chore: sync ${changedFiles.length} config files`;
658
- }
659
- }