@aspruyt/xfg 2.1.2 → 2.2.1

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.
package/README.md CHANGED
@@ -70,64 +70,6 @@ repos:
70
70
 
71
71
  **Result:** PRs are created in all three repos with identical `.prettierrc.json` files.
72
72
 
73
- ## Features
74
-
75
- - **Multi-File Sync** - Sync multiple config files in a single run
76
- - **Multi-Format Output** - JSON, JSON5, YAML, or plain text based on filename extension
77
- - **Subdirectory Support** - Sync files to any path (e.g., `.github/workflows/ci.yaml`)
78
- - **Text Files** - Sync `.gitignore`, `.markdownlintignore`, etc. with string or lines array
79
- - **File References** - Use `@path/to/file` to load content from external template files
80
- - **Content Inheritance** - Define base config once, override per-repo as needed
81
- - **Multi-Repo Targeting** - Apply same config to multiple repos with array syntax
82
- - **Environment Variables** - Use `${VAR}` syntax for dynamic values
83
- - **Templating** - Use `${xfg:repo.name}` syntax for dynamic repo-specific content
84
- - **Merge Strategies** - Control how arrays merge (replace, append, prepend)
85
- - **Override Mode** - Skip merging entirely for specific repos
86
- - **Empty Files** - Create files with no content (e.g., `.prettierignore`)
87
- - **YAML Comments** - Add header comments and schema directives to YAML files
88
- - **Multi-Platform** - Works with GitHub (including GitHub Enterprise Server), Azure DevOps, and GitLab (including self-hosted)
89
- - **Auto-Merge PRs** - Automatically merge PRs when checks pass, or force merge with admin privileges
90
- - **Direct Push Mode** - Push directly to default branch without creating PRs
91
- - **Delete Orphaned Files** - Automatically remove files from repos when deleted from config (manifest-tracked)
92
- - **Dry-Run Mode** - Preview changes without creating PRs
93
- - **Error Resilience** - Continues processing if individual repos fail
94
- - **Automatic Retries** - Retries transient network errors with exponential backoff
95
-
96
- ## Use Cases
97
-
98
- ### Platform Engineering Teams
99
-
100
- Enforce organization-wide standards without requiring a monorepo. Push consistent tooling configs (linters, formatters, TypeScript settings) to hundreds of microservice repos from a single source of truth.
101
-
102
- ### CI/CD Workflow Standardization
103
-
104
- Keep GitHub Actions workflows, Azure Pipelines, or GitLab CI configs in sync across all repos. Update a workflow once, create PRs everywhere.
105
-
106
- ### Security & Compliance Governance
107
-
108
- Roll out security scanning configs (Dependabot, CodeQL, SAST tools) or compliance policies across your entire organization. Audit and update security settings from one place.
109
-
110
- ### Developer Experience Consistency
111
-
112
- Sync `.editorconfig`, `.prettierrc`, `tsconfig.json`, and other DX configs so every repo feels the same. Onboard new team members faster with consistent tooling.
113
-
114
- ### Open Source Maintainers
115
-
116
- Manage configuration across multiple related projects. Keep issue templates, contributing guidelines, and CI workflows consistent across your ecosystem.
117
-
118
- ### Configuration Drift Prevention
119
-
120
- Detect and fix configuration drift automatically. Run xfg on a schedule to ensure repos stay in compliance with your standards.
121
-
122
- **[See detailed use cases with examples →](https://anthony-spruyt.github.io/xfg/use-cases/)**
123
-
124
73
  ## Documentation
125
74
 
126
- Visit **[anthony-spruyt.github.io/xfg](https://anthony-spruyt.github.io/xfg/)** for:
127
-
128
- - [Getting Started](https://anthony-spruyt.github.io/xfg/getting-started/) - Installation and prerequisites
129
- - [Configuration](https://anthony-spruyt.github.io/xfg/configuration/) - Full configuration reference
130
- - [Examples](https://anthony-spruyt.github.io/xfg/examples/) - Real-world usage examples
131
- - [Platforms](https://anthony-spruyt.github.io/xfg/platforms/) - GitHub, Azure DevOps, GitLab setup
132
- - [CI/CD Integration](https://anthony-spruyt.github.io/xfg/ci-cd/) - GitHub Actions, Azure Pipelines
133
- - [Troubleshooting](https://anthony-spruyt.github.io/xfg/troubleshooting/)
75
+ See **[anthony-spruyt.github.io/xfg](https://anthony-spruyt.github.io/xfg/)** for configuration reference, examples, platform setup, and troubleshooting.
@@ -41,6 +41,8 @@ export declare class RepositoryProcessor {
41
41
  private gitOps;
42
42
  private readonly gitOpsFactory;
43
43
  private readonly log;
44
+ private retries;
45
+ private executor;
44
46
  /**
45
47
  * Creates a new RepositoryProcessor.
46
48
  * @param gitOpsFactory - Optional factory for creating GitOps instances (for testing)
@@ -6,7 +6,7 @@ import { interpolateXfgContent } from "./xfg-template.js";
6
6
  import { GitOps } from "./git-ops.js";
7
7
  import { createPR, mergePR } from "./pr-creator.js";
8
8
  import { logger } from "./logger.js";
9
- import { getPRStrategy } from "./strategies/index.js";
9
+ import { getPRStrategy, getCommitStrategy } from "./strategies/index.js";
10
10
  import { defaultExecutor } from "./command-executor.js";
11
11
  import { getFileStatus, generateDiff, createDiffStats, incrementDiffStats, } from "./diff-utils.js";
12
12
  import { loadManifest, saveManifest, updateManifest, MANIFEST_FILENAME, } from "./manifest.js";
@@ -28,6 +28,8 @@ export class RepositoryProcessor {
28
28
  gitOps = null;
29
29
  gitOpsFactory;
30
30
  log;
31
+ retries = 3;
32
+ executor = defaultExecutor;
31
33
  /**
32
34
  * Creates a new RepositoryProcessor.
33
35
  * @param gitOpsFactory - Optional factory for creating GitOps instances (for testing)
@@ -39,9 +41,14 @@ export class RepositoryProcessor {
39
41
  }
40
42
  async process(repoConfig, repoInfo, options) {
41
43
  const repoName = getRepoDisplayName(repoInfo);
42
- const { branchName, workDir, dryRun, retries, prTemplate } = options;
43
- const executor = options.executor ?? defaultExecutor;
44
- this.gitOps = this.gitOpsFactory({ workDir, dryRun, retries });
44
+ const { branchName, workDir, dryRun, prTemplate } = options;
45
+ this.retries = options.retries ?? 3;
46
+ this.executor = options.executor ?? defaultExecutor;
47
+ this.gitOps = this.gitOpsFactory({
48
+ workDir,
49
+ dryRun,
50
+ retries: this.retries,
51
+ });
45
52
  // Determine merge mode early - affects workflow steps
46
53
  const mergeMode = repoConfig.prOptions?.merge ?? "auto";
47
54
  const isDirectMode = mergeMode === "direct";
@@ -64,13 +71,13 @@ export class RepositoryProcessor {
64
71
  // Skip for direct mode - no PR involved
65
72
  if (!dryRun && !isDirectMode) {
66
73
  this.log.info("Checking for existing PR...");
67
- const strategy = getPRStrategy(repoInfo, executor);
74
+ const strategy = getPRStrategy(repoInfo, this.executor);
68
75
  const closed = await strategy.closeExistingPR({
69
76
  repoInfo,
70
77
  branchName,
71
78
  baseBranch,
72
79
  workDir,
73
- retries,
80
+ retries: this.retries,
74
81
  });
75
82
  if (closed) {
76
83
  this.log.info("Closed existing PR and deleted branch for fresh sync");
@@ -94,17 +101,10 @@ export class RepositoryProcessor {
94
101
  // - Dry-run: Uses wouldChange() for read-only content comparison (no side effects)
95
102
  // - Normal: Uses git status after writing (source of truth for what git will commit)
96
103
  //
97
- // This is intentional. git status is more accurate because it respects .gitattributes
98
- // (line ending normalization, filters) and detects executable bit changes. However,
99
- // it requires actually writing files, which defeats dry-run's purpose.
100
- //
101
- // For config files (JSON/YAML), these approaches produce identical results in practice.
102
- // Edge cases (repos with unusual git attributes on config files) are essentially nonexistent.
103
- const changedFiles = [];
104
+ // Track all file changes with content and action - single source of truth
105
+ // Used for both commit message generation and actual commit
106
+ const fileChangesForCommit = new Map();
104
107
  const diffStats = createDiffStats();
105
- // Track pre-write actions for non-dry-run mode (issue #252)
106
- // We need to know if a file was created vs updated BEFORE writing it
107
- const preWriteActions = new Map();
108
108
  for (const file of repoConfig.files) {
109
109
  const filePath = join(workDir, file.fileName);
110
110
  const fileExistsLocal = existsSync(filePath);
@@ -114,7 +114,10 @@ export class RepositoryProcessor {
114
114
  const existsOnBase = await this.gitOps.fileExistsOnBranch(file.fileName, baseBranch);
115
115
  if (existsOnBase) {
116
116
  this.log.info(`Skipping ${file.fileName} (createOnly: exists on ${baseBranch})`);
117
- changedFiles.push({ fileName: file.fileName, action: "skip" });
117
+ fileChangesForCommit.set(file.fileName, {
118
+ content: null,
119
+ action: "skip",
120
+ });
118
121
  continue;
119
122
  }
120
123
  }
@@ -132,35 +135,37 @@ export class RepositoryProcessor {
132
135
  header: file.header,
133
136
  schemaUrl: file.schemaUrl,
134
137
  });
135
- // Determine action type (create vs update)
138
+ // Determine action type (create vs update) BEFORE writing
136
139
  const action = fileExistsLocal
137
140
  ? "update"
138
141
  : "create";
142
+ // Check if file would change (needed for both modes)
143
+ const existingContent = this.gitOps.getFileContent(file.fileName);
144
+ const changed = this.gitOps.wouldChange(file.fileName, fileContent);
145
+ if (changed) {
146
+ // Track in single source of truth
147
+ fileChangesForCommit.set(file.fileName, {
148
+ content: fileContent,
149
+ action,
150
+ });
151
+ }
139
152
  if (dryRun) {
140
- // In dry-run, check if file would change and show diff
141
- const existingContent = this.gitOps.getFileContent(file.fileName);
142
- const changed = this.gitOps.wouldChange(file.fileName, fileContent);
153
+ // In dry-run, show diff but don't write
143
154
  const status = getFileStatus(existingContent !== null, changed);
144
- // Track stats
145
155
  incrementDiffStats(diffStats, status);
146
- if (changed) {
147
- changedFiles.push({ fileName: file.fileName, action });
148
- }
149
- // Generate and display diff
150
156
  const diffLines = generateDiff(existingContent, fileContent, file.fileName);
151
157
  this.log.fileDiff(file.fileName, status, diffLines);
152
158
  }
153
159
  else {
154
- // Write the file and store pre-write action for stats calculation
155
- preWriteActions.set(file.fileName, action);
160
+ // Write the file
156
161
  this.gitOps.writeFile(file.fileName, fileContent);
157
162
  }
158
163
  }
159
164
  // Step 5b: Set executable permission for files that need it
160
- const skippedFileNames = new Set(changedFiles.filter((f) => f.action === "skip").map((f) => f.fileName));
161
165
  for (const file of repoConfig.files) {
162
166
  // Skip files that were excluded (createOnly + exists)
163
- if (skippedFileNames.has(file.fileName)) {
167
+ const tracked = fileChangesForCommit.get(file.fileName);
168
+ if (tracked?.action === "skip") {
164
169
  continue;
165
170
  }
166
171
  if (shouldBeExecutable(file)) {
@@ -184,6 +189,11 @@ export class RepositoryProcessor {
184
189
  for (const fileName of filesToDelete) {
185
190
  // Only delete if file actually exists in the working directory
186
191
  if (this.gitOps.fileExists(fileName)) {
192
+ // Track deletion in single source of truth
193
+ fileChangesForCommit.set(fileName, {
194
+ content: null,
195
+ action: "delete",
196
+ });
187
197
  if (dryRun) {
188
198
  // In dry-run, show what would be deleted
189
199
  this.log.fileDiff(fileName, "DELETED", []);
@@ -193,7 +203,6 @@ export class RepositoryProcessor {
193
203
  this.log.info(`Deleting orphaned file: ${fileName}`);
194
204
  this.gitOps.deleteFile(fileName);
195
205
  }
196
- changedFiles.push({ fileName, action: "delete" });
197
206
  }
198
207
  }
199
208
  }
@@ -204,82 +213,41 @@ export class RepositoryProcessor {
204
213
  // Only save if there are managed files for any config, or if we had a previous manifest
205
214
  const hasAnyManagedFiles = Object.keys(newManifest.configs).length > 0;
206
215
  if (hasAnyManagedFiles || existingManifest !== null) {
207
- if (!dryRun) {
208
- saveManifest(workDir, newManifest);
209
- }
210
216
  // Track manifest file as changed if it would be different
211
217
  const existingConfigs = existingManifest?.configs ?? {};
212
218
  const manifestChanged = JSON.stringify(existingConfigs) !==
213
219
  JSON.stringify(newManifest.configs);
214
220
  if (manifestChanged) {
215
221
  const manifestExisted = existsSync(join(workDir, MANIFEST_FILENAME));
216
- changedFiles.push({
217
- fileName: MANIFEST_FILENAME,
222
+ const manifestContent = JSON.stringify(newManifest, null, 2) + "\n";
223
+ fileChangesForCommit.set(MANIFEST_FILENAME, {
224
+ content: manifestContent,
218
225
  action: manifestExisted ? "update" : "create",
219
226
  });
220
227
  }
228
+ if (!dryRun) {
229
+ saveManifest(workDir, newManifest);
230
+ }
221
231
  }
222
232
  // Show diff summary in dry-run mode
223
233
  if (dryRun) {
224
234
  this.log.diffSummary(diffStats.newCount, diffStats.modifiedCount, diffStats.unchangedCount, diffStats.deletedCount);
225
235
  }
226
- // Step 6: Check for changes (exclude skipped files)
227
- let hasChanges;
228
- if (dryRun) {
229
- hasChanges = changedFiles.filter((f) => f.action !== "skip").length > 0;
230
- }
231
- else {
232
- hasChanges = await this.gitOps.hasChanges();
233
- // If there are changes, determine which files changed
234
- if (hasChanges) {
235
- // Get the actual list of changed files from git status
236
- const gitChangedFiles = new Set(await this.gitOps.getChangedFiles());
237
- // Build set of files already tracked (skip, delete, manifest updates added earlier)
238
- const alreadyTracked = new Set(changedFiles.map((f) => f.fileName));
239
- // Add config files that actually changed according to git
240
- for (const file of repoConfig.files) {
241
- if (alreadyTracked.has(file.fileName)) {
242
- continue; // Already tracked (skipped, deleted, or manifest)
243
- }
244
- // Only include files that git reports as changed
245
- if (!gitChangedFiles.has(file.fileName)) {
246
- continue; // File didn't actually change
247
- }
248
- // Use pre-write action (issue #252) - we stored whether file existed
249
- // BEFORE writing, which is the correct basis for create vs update
250
- const action = preWriteActions.get(file.fileName) ?? "update";
251
- changedFiles.push({ fileName: file.fileName, action });
252
- }
253
- // Add any other files from git status that aren't already tracked
254
- // This catches files like .xfg.json when manifestChanged was false
255
- // but git still reports a change (e.g., due to formatting differences)
256
- for (const gitFile of gitChangedFiles) {
257
- if (changedFiles.some((f) => f.fileName === gitFile)) {
258
- continue; // Already tracked
259
- }
260
- const filePath = join(workDir, gitFile);
261
- const action = existsSync(filePath)
262
- ? "update"
263
- : "create";
264
- changedFiles.push({ fileName: gitFile, action });
265
- }
266
- // Calculate diff stats from changedFiles (issue #252)
267
- for (const file of changedFiles) {
268
- switch (file.action) {
269
- case "create":
270
- incrementDiffStats(diffStats, "NEW");
271
- break;
272
- case "update":
273
- incrementDiffStats(diffStats, "MODIFIED");
274
- break;
275
- case "delete":
276
- incrementDiffStats(diffStats, "DELETED");
277
- break;
278
- // "skip" files are not counted in stats
279
- }
280
- }
236
+ // Step 6: Derive changedFiles from single source of truth
237
+ // This ensures dry-run and non-dry-run modes use identical logic
238
+ const changedFiles = Array.from(fileChangesForCommit.entries()).map(([fileName, info]) => ({ fileName, action: info.action }));
239
+ // Calculate diff stats for non-dry-run mode (dry-run already calculated above)
240
+ if (!dryRun) {
241
+ for (const [, info] of fileChangesForCommit) {
242
+ if (info.action === "create")
243
+ incrementDiffStats(diffStats, "NEW");
244
+ else if (info.action === "update")
245
+ incrementDiffStats(diffStats, "MODIFIED");
246
+ else if (info.action === "delete")
247
+ incrementDiffStats(diffStats, "DELETED");
281
248
  }
282
249
  }
250
+ const hasChanges = changedFiles.filter((f) => f.action !== "skip").length > 0;
283
251
  if (!hasChanges) {
284
252
  return {
285
253
  success: true,
@@ -289,45 +257,67 @@ export class RepositoryProcessor {
289
257
  diffStats,
290
258
  };
291
259
  }
292
- // Step 7: Commit
293
- this.log.info("Staging changes...");
260
+ // Step 7: Commit and Push using commit strategy
294
261
  const commitMessage = this.formatCommitMessage(changedFiles);
295
- const committed = await this.gitOps.commit(commitMessage);
296
- if (!committed) {
297
- this.log.info("No staged changes after git add -A, skipping commit");
298
- return {
299
- success: true,
300
- repoName,
301
- message: "No changes detected after staging",
302
- skipped: true,
303
- diffStats,
304
- };
305
- }
306
- this.log.info(`Committed: ${commitMessage}`);
307
- // Step 8: Push
308
- // In direct mode, push to default branch; otherwise push to sync branch
309
- // Use force-with-lease for sync branch (PR modes) to handle divergent history
310
- // Never force push to default branch (direct mode) - could overwrite others' work
311
262
  const pushBranch = isDirectMode ? baseBranch : branchName;
312
- this.log.info(`Pushing to ${pushBranch}...`);
313
- try {
314
- await this.gitOps.push(pushBranch, { force: !isDirectMode });
263
+ if (dryRun) {
264
+ // In dry-run mode, just log what would happen
265
+ this.log.info("Staging changes...");
266
+ this.log.info(`Would commit: ${commitMessage}`);
267
+ this.log.info(`Would push to ${pushBranch}...`);
315
268
  }
316
- catch (error) {
317
- // Handle branch protection errors in direct mode
318
- if (isDirectMode) {
319
- const errorMessage = error instanceof Error ? error.message : String(error);
320
- if (errorMessage.includes("rejected") ||
321
- errorMessage.includes("protected") ||
322
- errorMessage.includes("denied")) {
323
- return {
324
- success: false,
325
- repoName,
326
- 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.`,
327
- };
269
+ else {
270
+ // Build file changes for commit strategy (filter out skipped files)
271
+ const fileChanges = Array.from(fileChangesForCommit.entries())
272
+ .filter(([, info]) => info.action !== "skip")
273
+ .map(([path, info]) => ({ path, content: info.content }));
274
+ // Check if there are actually staged changes (edge case handling)
275
+ // This handles scenarios where git status shows changes but git add doesn't stage anything
276
+ // (e.g., due to .gitattributes normalization)
277
+ this.log.info("Staging changes...");
278
+ await this.executor.exec("git add -A", workDir);
279
+ if (!(await this.gitOps.hasStagedChanges())) {
280
+ this.log.info("No staged changes after git add -A, skipping commit");
281
+ return {
282
+ success: true,
283
+ repoName,
284
+ message: "No changes detected after staging",
285
+ skipped: true,
286
+ diffStats,
287
+ };
288
+ }
289
+ // Use commit strategy (GitCommitStrategy or GraphQLCommitStrategy)
290
+ const commitStrategy = getCommitStrategy(repoInfo, this.executor);
291
+ this.log.info("Committing and pushing changes...");
292
+ try {
293
+ const commitResult = await commitStrategy.commit({
294
+ repoInfo,
295
+ branchName: pushBranch,
296
+ message: commitMessage,
297
+ fileChanges,
298
+ workDir,
299
+ retries: this.retries,
300
+ // Use force push (--force-with-lease) for PR branches, not for direct mode
301
+ force: !isDirectMode,
302
+ });
303
+ this.log.info(`Committed: ${commitResult.sha} (verified: ${commitResult.verified})`);
304
+ }
305
+ catch (error) {
306
+ // Handle branch protection errors in direct mode
307
+ if (isDirectMode) {
308
+ const errorMessage = error instanceof Error ? error.message : String(error);
309
+ if (errorMessage.includes("rejected") ||
310
+ errorMessage.includes("protected") ||
311
+ errorMessage.includes("denied")) {
312
+ return {
313
+ success: false,
314
+ repoName,
315
+ 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.`,
316
+ };
317
+ }
328
318
  }
319
+ throw error;
329
320
  }
330
- throw error;
331
321
  }
332
322
  // Direct mode: no PR creation, return success
333
323
  if (isDirectMode) {
@@ -348,9 +338,9 @@ export class RepositoryProcessor {
348
338
  files: changedFiles,
349
339
  workDir,
350
340
  dryRun,
351
- retries,
341
+ retries: this.retries,
352
342
  prTemplate,
353
- executor,
343
+ executor: this.executor,
354
344
  });
355
345
  // Step 10: Handle merge options if configured
356
346
  let mergeResult;
@@ -368,8 +358,8 @@ export class RepositoryProcessor {
368
358
  mergeConfig,
369
359
  workDir,
370
360
  dryRun,
371
- retries,
372
- executor,
361
+ retries: this.retries,
362
+ executor: this.executor,
373
363
  });
374
364
  mergeResult = {
375
365
  merged: result.merged ?? false,
@@ -0,0 +1,16 @@
1
+ import { RepoInfo } from "../repo-detector.js";
2
+ import { CommitStrategy } from "./commit-strategy.js";
3
+ import { CommandExecutor } from "../command-executor.js";
4
+ /**
5
+ * Factory function to get the appropriate commit strategy for a repository.
6
+ *
7
+ * For GitHub repositories with GH_INSTALLATION_TOKEN set, returns GraphQLCommitStrategy
8
+ * which creates verified commits via the GitHub GraphQL API.
9
+ *
10
+ * For all other cases (GitHub with PAT, Azure DevOps, GitLab), returns GitCommitStrategy
11
+ * which uses standard git commands.
12
+ *
13
+ * @param repoInfo - Repository information
14
+ * @param executor - Optional command executor for shell commands
15
+ */
16
+ export declare function getCommitStrategy(repoInfo: RepoInfo, executor?: CommandExecutor): CommitStrategy;
@@ -0,0 +1,21 @@
1
+ import { isGitHubRepo } from "../repo-detector.js";
2
+ import { GitCommitStrategy } from "./git-commit-strategy.js";
3
+ import { GraphQLCommitStrategy } from "./graphql-commit-strategy.js";
4
+ /**
5
+ * Factory function to get the appropriate commit strategy for a repository.
6
+ *
7
+ * For GitHub repositories with GH_INSTALLATION_TOKEN set, returns GraphQLCommitStrategy
8
+ * which creates verified commits via the GitHub GraphQL API.
9
+ *
10
+ * For all other cases (GitHub with PAT, Azure DevOps, GitLab), returns GitCommitStrategy
11
+ * which uses standard git commands.
12
+ *
13
+ * @param repoInfo - Repository information
14
+ * @param executor - Optional command executor for shell commands
15
+ */
16
+ export function getCommitStrategy(repoInfo, executor) {
17
+ if (isGitHubRepo(repoInfo) && process.env.GH_INSTALLATION_TOKEN) {
18
+ return new GraphQLCommitStrategy(executor);
19
+ }
20
+ return new GitCommitStrategy(executor);
21
+ }
@@ -0,0 +1,31 @@
1
+ import { RepoInfo } from "../repo-detector.js";
2
+ export interface FileChange {
3
+ path: string;
4
+ content: string | null;
5
+ }
6
+ export interface CommitOptions {
7
+ repoInfo: RepoInfo;
8
+ branchName: string;
9
+ message: string;
10
+ fileChanges: FileChange[];
11
+ workDir: string;
12
+ retries?: number;
13
+ /** Use force push (--force-with-lease). Default: true for PR branches, false for direct push to main. */
14
+ force?: boolean;
15
+ }
16
+ export interface CommitResult {
17
+ sha: string;
18
+ verified: boolean;
19
+ pushed: boolean;
20
+ }
21
+ /**
22
+ * Strategy interface for creating commits.
23
+ * Implementations handle platform-specific commit mechanisms.
24
+ */
25
+ export interface CommitStrategy {
26
+ /**
27
+ * Create a commit with the given file changes and push to remote.
28
+ * @returns Commit result with SHA and verification status
29
+ */
30
+ commit(options: CommitOptions): Promise<CommitResult>;
31
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,18 @@
1
+ import { CommitStrategy, CommitOptions, CommitResult } from "./commit-strategy.js";
2
+ import { CommandExecutor } from "../command-executor.js";
3
+ /**
4
+ * Git-based commit strategy using standard git commands (add, commit, push).
5
+ * Used with PAT authentication. Commits via this strategy are NOT verified
6
+ * by GitHub (no signature).
7
+ */
8
+ export declare class GitCommitStrategy implements CommitStrategy {
9
+ private executor;
10
+ constructor(executor?: CommandExecutor);
11
+ /**
12
+ * Create a commit with the given file changes and push to remote.
13
+ * Runs: git add -A, git commit, git push (with optional --force-with-lease)
14
+ *
15
+ * @returns Commit result with SHA and verified: false (no signature)
16
+ */
17
+ commit(options: CommitOptions): Promise<CommitResult>;
18
+ }
@@ -0,0 +1,41 @@
1
+ import { defaultExecutor } from "../command-executor.js";
2
+ import { withRetry } from "../retry-utils.js";
3
+ import { escapeShellArg } from "../shell-utils.js";
4
+ /**
5
+ * Git-based commit strategy using standard git commands (add, commit, push).
6
+ * Used with PAT authentication. Commits via this strategy are NOT verified
7
+ * by GitHub (no signature).
8
+ */
9
+ export class GitCommitStrategy {
10
+ executor;
11
+ constructor(executor) {
12
+ this.executor = executor ?? defaultExecutor;
13
+ }
14
+ /**
15
+ * Create a commit with the given file changes and push to remote.
16
+ * Runs: git add -A, git commit, git push (with optional --force-with-lease)
17
+ *
18
+ * @returns Commit result with SHA and verified: false (no signature)
19
+ */
20
+ async commit(options) {
21
+ const { branchName, message, workDir, retries = 3, force = true } = options;
22
+ // Stage all changes
23
+ await this.executor.exec("git add -A", workDir);
24
+ // Commit with the message (--no-verify to skip pre-commit hooks)
25
+ await this.executor.exec(`git commit --no-verify -m ${escapeShellArg(message)}`, workDir);
26
+ // Build push command - use --force-with-lease for PR branches, regular push for direct mode
27
+ const forceFlag = force ? "--force-with-lease " : "";
28
+ const pushCommand = `git push ${forceFlag}-u origin ${escapeShellArg(branchName)}`;
29
+ // Push with retry for transient network failures
30
+ await withRetry(() => this.executor.exec(pushCommand, workDir), {
31
+ retries,
32
+ });
33
+ // Get the commit SHA
34
+ const sha = await this.executor.exec("git rev-parse HEAD", workDir);
35
+ return {
36
+ sha: sha.trim(),
37
+ verified: false, // Git-based commits are not verified
38
+ pushed: true,
39
+ };
40
+ }
41
+ }
@@ -0,0 +1,58 @@
1
+ import { CommitStrategy, CommitOptions, CommitResult } from "./commit-strategy.js";
2
+ import { CommandExecutor } from "../command-executor.js";
3
+ /**
4
+ * Maximum payload size for GitHub GraphQL API (50MB).
5
+ * Base64 encoding adds ~33% overhead, so raw content should be checked.
6
+ */
7
+ export declare const MAX_PAYLOAD_SIZE: number;
8
+ /**
9
+ * Pattern for valid git branch names that are also safe for shell commands.
10
+ * Git branch names have strict rules:
11
+ * - Cannot contain: space, ~, ^, :, ?, *, [, \, .., @{
12
+ * - Cannot start with: - or .
13
+ * - Cannot end with: / or .lock
14
+ * - Cannot contain consecutive slashes
15
+ *
16
+ * This pattern allows only alphanumeric chars, hyphens, underscores, dots, and slashes
17
+ * which covers all practical branch names and is shell-safe.
18
+ */
19
+ export declare const SAFE_BRANCH_NAME_PATTERN: RegExp;
20
+ /**
21
+ * Validates that a branch name is safe for use in shell commands.
22
+ * Throws an error if the branch name contains potentially dangerous characters.
23
+ */
24
+ export declare function validateBranchName(branchName: string): void;
25
+ /**
26
+ * GraphQL-based commit strategy using GitHub's createCommitOnBranch mutation.
27
+ * Used with GitHub App authentication. Commits via this strategy ARE verified
28
+ * by GitHub (signed by the GitHub App).
29
+ *
30
+ * This strategy is GitHub-only and requires the `gh` CLI to be authenticated.
31
+ */
32
+ export declare class GraphQLCommitStrategy implements CommitStrategy {
33
+ private executor;
34
+ constructor(executor?: CommandExecutor);
35
+ /**
36
+ * Create a commit with the given file changes using GitHub's GraphQL API.
37
+ * Uses the createCommitOnBranch mutation for verified commits.
38
+ *
39
+ * @returns Commit result with SHA and verified: true
40
+ * @throws Error if repo is not GitHub, payload exceeds 50MB, or API fails
41
+ */
42
+ commit(options: CommitOptions): Promise<CommitResult>;
43
+ /**
44
+ * Execute the createCommitOnBranch GraphQL mutation.
45
+ */
46
+ private executeGraphQLMutation;
47
+ /**
48
+ * Ensure the branch exists on the remote.
49
+ * createCommitOnBranch requires the branch to already exist.
50
+ * If the branch doesn't exist, push it to create it.
51
+ */
52
+ private ensureBranchExistsOnRemote;
53
+ /**
54
+ * Check if an error is due to expectedHeadOid mismatch (optimistic locking failure).
55
+ * This happens when the branch was updated between getting HEAD and making the commit.
56
+ */
57
+ private isHeadOidMismatchError;
58
+ }
@@ -0,0 +1,206 @@
1
+ import { defaultExecutor } from "../command-executor.js";
2
+ import { isGitHubRepo } from "../repo-detector.js";
3
+ import { escapeShellArg } from "../shell-utils.js";
4
+ /**
5
+ * Maximum payload size for GitHub GraphQL API (50MB).
6
+ * Base64 encoding adds ~33% overhead, so raw content should be checked.
7
+ */
8
+ export const MAX_PAYLOAD_SIZE = 50 * 1024 * 1024;
9
+ /**
10
+ * Pattern for valid git branch names that are also safe for shell commands.
11
+ * Git branch names have strict rules:
12
+ * - Cannot contain: space, ~, ^, :, ?, *, [, \, .., @{
13
+ * - Cannot start with: - or .
14
+ * - Cannot end with: / or .lock
15
+ * - Cannot contain consecutive slashes
16
+ *
17
+ * This pattern allows only alphanumeric chars, hyphens, underscores, dots, and slashes
18
+ * which covers all practical branch names and is shell-safe.
19
+ */
20
+ export const SAFE_BRANCH_NAME_PATTERN = /^[a-zA-Z0-9][-a-zA-Z0-9_./]*$/;
21
+ /**
22
+ * Validates that a branch name is safe for use in shell commands.
23
+ * Throws an error if the branch name contains potentially dangerous characters.
24
+ */
25
+ export function validateBranchName(branchName) {
26
+ if (!SAFE_BRANCH_NAME_PATTERN.test(branchName)) {
27
+ throw new Error(`Invalid branch name for GraphQL commit strategy: "${branchName}". ` +
28
+ `Branch names must start with alphanumeric and contain only ` +
29
+ `alphanumeric characters, hyphens, underscores, dots, and forward slashes.`);
30
+ }
31
+ }
32
+ /**
33
+ * GraphQL-based commit strategy using GitHub's createCommitOnBranch mutation.
34
+ * Used with GitHub App authentication. Commits via this strategy ARE verified
35
+ * by GitHub (signed by the GitHub App).
36
+ *
37
+ * This strategy is GitHub-only and requires the `gh` CLI to be authenticated.
38
+ */
39
+ export class GraphQLCommitStrategy {
40
+ executor;
41
+ constructor(executor) {
42
+ this.executor = executor ?? defaultExecutor;
43
+ }
44
+ /**
45
+ * Create a commit with the given file changes using GitHub's GraphQL API.
46
+ * Uses the createCommitOnBranch mutation for verified commits.
47
+ *
48
+ * @returns Commit result with SHA and verified: true
49
+ * @throws Error if repo is not GitHub, payload exceeds 50MB, or API fails
50
+ */
51
+ async commit(options) {
52
+ const { repoInfo, branchName, message, fileChanges, workDir, retries = 3, } = options;
53
+ // Validate this is a GitHub repo
54
+ if (!isGitHubRepo(repoInfo)) {
55
+ throw new Error(`GraphQL commit strategy requires GitHub repositories. Got: ${repoInfo.type}`);
56
+ }
57
+ // Validate branch name is safe for shell commands
58
+ validateBranchName(branchName);
59
+ const githubInfo = repoInfo;
60
+ // Separate additions from deletions
61
+ const additions = fileChanges.filter((fc) => fc.content !== null);
62
+ const deletions = fileChanges.filter((fc) => fc.content === null);
63
+ // Calculate payload size (base64 adds ~33% overhead)
64
+ const totalSize = additions.reduce((sum, fc) => {
65
+ const base64Size = Math.ceil((fc.content.length * 4) / 3);
66
+ return sum + base64Size;
67
+ }, 0);
68
+ if (totalSize > MAX_PAYLOAD_SIZE) {
69
+ throw new Error(`GraphQL payload exceeds 50 MB limit (${Math.round(totalSize / (1024 * 1024))} MB). ` +
70
+ `Consider using smaller files or the git commit strategy.`);
71
+ }
72
+ // Ensure the branch exists on remote before making GraphQL commit
73
+ // createCommitOnBranch requires the branch to already exist
74
+ await this.ensureBranchExistsOnRemote(branchName, workDir);
75
+ // Retry loop for expectedHeadOid mismatch
76
+ let lastError = null;
77
+ for (let attempt = 0; attempt <= retries; attempt++) {
78
+ try {
79
+ // Fetch from remote to ensure we have the latest HEAD
80
+ // This is critical for expectedHeadOid to match
81
+ // Branch name was validated above, safe for shell use
82
+ await this.executor.exec(`git fetch origin ${branchName}:refs/remotes/origin/${branchName}`, workDir);
83
+ // Get the remote HEAD SHA for this branch (not local HEAD)
84
+ const headSha = await this.executor.exec(`git rev-parse origin/${branchName}`, workDir);
85
+ // Build and execute the GraphQL mutation
86
+ const result = await this.executeGraphQLMutation(githubInfo, branchName, message, headSha.trim(), additions, deletions, workDir);
87
+ return result;
88
+ }
89
+ catch (error) {
90
+ lastError = error instanceof Error ? error : new Error(String(error));
91
+ // Check if this is an expectedHeadOid mismatch error (retryable)
92
+ if (this.isHeadOidMismatchError(lastError) && attempt < retries) {
93
+ // Retry - the next iteration will fetch and get fresh HEAD SHA
94
+ continue;
95
+ }
96
+ // For other errors, throw immediately
97
+ throw lastError;
98
+ }
99
+ }
100
+ // Should not reach here, but just in case
101
+ throw lastError ?? new Error("Unexpected error in GraphQL commit");
102
+ }
103
+ /**
104
+ * Execute the createCommitOnBranch GraphQL mutation.
105
+ */
106
+ async executeGraphQLMutation(repoInfo, branchName, message, expectedHeadOid, additions, deletions, workDir) {
107
+ const repositoryNameWithOwner = `${repoInfo.owner}/${repoInfo.repo}`;
108
+ // Build file additions with base64 encoding
109
+ const fileAdditions = additions.map((fc) => ({
110
+ path: fc.path,
111
+ contents: Buffer.from(fc.content).toString("base64"),
112
+ }));
113
+ // Build file deletions (path only)
114
+ const fileDeletions = deletions.map((fc) => ({
115
+ path: fc.path,
116
+ }));
117
+ // Build the mutation (minified to avoid shell escaping issues with newlines)
118
+ const mutation = "mutation CreateCommit($input: CreateCommitOnBranchInput!) { createCommitOnBranch(input: $input) { commit { oid } } }";
119
+ // Build the input variables
120
+ // Note: GitHub API doesn't accept empty arrays, so only include fields when non-empty
121
+ const fileChanges = {};
122
+ if (fileAdditions.length > 0) {
123
+ fileChanges.additions = fileAdditions;
124
+ }
125
+ if (fileDeletions.length > 0) {
126
+ fileChanges.deletions = fileDeletions;
127
+ }
128
+ const variables = {
129
+ input: {
130
+ branch: {
131
+ repositoryNameWithOwner,
132
+ branchName,
133
+ },
134
+ expectedHeadOid,
135
+ message: {
136
+ headline: message,
137
+ },
138
+ fileChanges,
139
+ },
140
+ };
141
+ // Build the GraphQL request body
142
+ const requestBody = JSON.stringify({
143
+ query: mutation,
144
+ variables,
145
+ });
146
+ // Build the gh api graphql command
147
+ // Use --input - to pass the JSON body via stdin (more reliable for complex nested JSON)
148
+ // Use --hostname for GitHub Enterprise
149
+ const hostnameArg = repoInfo.host !== "github.com"
150
+ ? `--hostname ${escapeShellArg(repoInfo.host)}`
151
+ : "";
152
+ // Use GH_INSTALLATION_TOKEN explicitly for authentication (issue #268)
153
+ // This ensures the GitHub App is used as the commit author, not github-actions[bot]
154
+ // The token is passed via Authorization header rather than relying on GH_TOKEN env var
155
+ const installationToken = process.env.GH_INSTALLATION_TOKEN;
156
+ const authArg = installationToken
157
+ ? `-H "Authorization: token ${installationToken}"`
158
+ : "";
159
+ const command = `echo ${escapeShellArg(requestBody)} | gh api graphql ${authArg} ${hostnameArg} --input -`;
160
+ const response = await this.executor.exec(command, workDir);
161
+ // Parse the response
162
+ const parsed = JSON.parse(response);
163
+ if (parsed.errors) {
164
+ throw new Error(`GraphQL error: ${parsed.errors.map((e) => e.message).join(", ")}`);
165
+ }
166
+ const oid = parsed.data?.createCommitOnBranch?.commit?.oid;
167
+ if (!oid) {
168
+ throw new Error("GraphQL response missing commit OID");
169
+ }
170
+ return {
171
+ sha: oid,
172
+ verified: true, // GraphQL commits via GitHub App are verified
173
+ pushed: true, // GraphQL commits are pushed directly
174
+ };
175
+ }
176
+ /**
177
+ * Ensure the branch exists on the remote.
178
+ * createCommitOnBranch requires the branch to already exist.
179
+ * If the branch doesn't exist, push it to create it.
180
+ */
181
+ async ensureBranchExistsOnRemote(branchName, workDir) {
182
+ // Branch name was validated in commit(), safe for shell use
183
+ try {
184
+ // Check if the branch exists on remote
185
+ await this.executor.exec(`git ls-remote --exit-code --heads origin ${branchName}`, workDir);
186
+ // Branch exists, nothing to do
187
+ }
188
+ catch {
189
+ // Branch doesn't exist on remote, push it
190
+ // This pushes the current local branch to create it on remote
191
+ await this.executor.exec(`git push -u origin HEAD:${branchName}`, workDir);
192
+ }
193
+ }
194
+ /**
195
+ * Check if an error is due to expectedHeadOid mismatch (optimistic locking failure).
196
+ * This happens when the branch was updated between getting HEAD and making the commit.
197
+ */
198
+ isHeadOidMismatchError(error) {
199
+ const message = error.message.toLowerCase();
200
+ return (message.includes("expected branch to point to") ||
201
+ message.includes("expectedheadoid") ||
202
+ message.includes("head oid") ||
203
+ // GitHub may return this generic error for OID mismatches
204
+ message.includes("was provided invalid value"));
205
+ }
206
+ }
@@ -6,6 +6,10 @@ export { BasePRStrategy, PRWorkflowExecutor } from "./pr-strategy.js";
6
6
  export { GitHubPRStrategy } from "./github-pr-strategy.js";
7
7
  export { AzurePRStrategy } from "./azure-pr-strategy.js";
8
8
  export { GitLabPRStrategy } from "./gitlab-pr-strategy.js";
9
+ export type { CommitStrategy, CommitOptions, CommitResult, FileChange, } from "./commit-strategy.js";
10
+ export { GitCommitStrategy } from "./git-commit-strategy.js";
11
+ export { GraphQLCommitStrategy, MAX_PAYLOAD_SIZE, } from "./graphql-commit-strategy.js";
12
+ export { getCommitStrategy } from "./commit-strategy-selector.js";
9
13
  /**
10
14
  * Factory function to get the appropriate PR strategy for a repository.
11
15
  * @param repoInfo - Repository information
@@ -6,6 +6,9 @@ export { BasePRStrategy, PRWorkflowExecutor } from "./pr-strategy.js";
6
6
  export { GitHubPRStrategy } from "./github-pr-strategy.js";
7
7
  export { AzurePRStrategy } from "./azure-pr-strategy.js";
8
8
  export { GitLabPRStrategy } from "./gitlab-pr-strategy.js";
9
+ export { GitCommitStrategy } from "./git-commit-strategy.js";
10
+ export { GraphQLCommitStrategy, MAX_PAYLOAD_SIZE, } from "./graphql-commit-strategy.js";
11
+ export { getCommitStrategy } from "./commit-strategy-selector.js";
9
12
  /**
10
13
  * Factory function to get the appropriate PR strategy for a repository.
11
14
  * @param repoInfo - Repository information
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aspruyt/xfg",
3
- "version": "2.1.2",
3
+ "version": "2.2.1",
4
4
  "description": "CLI tool to sync JSON, JSON5, YAML, or text configuration files across multiple Git repositories via pull requests or direct push",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -31,6 +31,7 @@
31
31
  "test:integration:github": "npm run build && node --import tsx --test test/integration/github.test.ts",
32
32
  "test:integration:ado": "npm run build && node --import tsx --test test/integration/ado.test.ts",
33
33
  "test:integration:gitlab": "npm run build && node --import tsx --test test/integration/gitlab.test.ts",
34
+ "test:integration:github-app": "npm run build && node --import tsx --test test/integration/github-app.test.ts",
34
35
  "prepublishOnly": "npm run build"
35
36
  },
36
37
  "keywords": [