@aspruyt/xfg 2.1.2 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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");
@@ -105,6 +112,8 @@ export class RepositoryProcessor {
105
112
  // Track pre-write actions for non-dry-run mode (issue #252)
106
113
  // We need to know if a file was created vs updated BEFORE writing it
107
114
  const preWriteActions = new Map();
115
+ // Track file changes for commit strategy (path -> content, null for deletion)
116
+ const fileChangesForCommit = new Map();
108
117
  for (const file of repoConfig.files) {
109
118
  const filePath = join(workDir, file.fileName);
110
119
  const fileExistsLocal = existsSync(filePath);
@@ -154,6 +163,8 @@ export class RepositoryProcessor {
154
163
  // Write the file and store pre-write action for stats calculation
155
164
  preWriteActions.set(file.fileName, action);
156
165
  this.gitOps.writeFile(file.fileName, fileContent);
166
+ // Track content for commit strategy
167
+ fileChangesForCommit.set(file.fileName, fileContent);
157
168
  }
158
169
  }
159
170
  // Step 5b: Set executable permission for files that need it
@@ -192,6 +203,8 @@ export class RepositoryProcessor {
192
203
  else {
193
204
  this.log.info(`Deleting orphaned file: ${fileName}`);
194
205
  this.gitOps.deleteFile(fileName);
206
+ // Track deletion for commit strategy
207
+ fileChangesForCommit.set(fileName, null);
195
208
  }
196
209
  changedFiles.push({ fileName, action: "delete" });
197
210
  }
@@ -206,6 +219,9 @@ export class RepositoryProcessor {
206
219
  if (hasAnyManagedFiles || existingManifest !== null) {
207
220
  if (!dryRun) {
208
221
  saveManifest(workDir, newManifest);
222
+ // Track manifest content for commit strategy
223
+ const manifestContent = JSON.stringify(newManifest, null, 2) + "\n";
224
+ fileChangesForCommit.set(MANIFEST_FILENAME, manifestContent);
209
225
  }
210
226
  // Track manifest file as changed if it would be different
211
227
  const existingConfigs = existingManifest?.configs ?? {};
@@ -289,45 +305,68 @@ export class RepositoryProcessor {
289
305
  diffStats,
290
306
  };
291
307
  }
292
- // Step 7: Commit
293
- this.log.info("Staging changes...");
308
+ // Step 7: Commit and Push using commit strategy
294
309
  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
310
  const pushBranch = isDirectMode ? baseBranch : branchName;
312
- this.log.info(`Pushing to ${pushBranch}...`);
313
- try {
314
- await this.gitOps.push(pushBranch, { force: !isDirectMode });
311
+ if (dryRun) {
312
+ // In dry-run mode, just log what would happen
313
+ this.log.info("Staging changes...");
314
+ this.log.info(`Would commit: ${commitMessage}`);
315
+ this.log.info(`Would push to ${pushBranch}...`);
315
316
  }
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
- };
317
+ else {
318
+ // Build file changes for commit strategy
319
+ const fileChanges = [];
320
+ for (const [path, content] of fileChangesForCommit.entries()) {
321
+ fileChanges.push({ path, content });
322
+ }
323
+ // Check if there are actually staged changes (edge case handling)
324
+ // This handles scenarios where git status shows changes but git add doesn't stage anything
325
+ // (e.g., due to .gitattributes normalization)
326
+ this.log.info("Staging changes...");
327
+ await this.executor.exec("git add -A", workDir);
328
+ if (!(await this.gitOps.hasStagedChanges())) {
329
+ this.log.info("No staged changes after git add -A, skipping commit");
330
+ return {
331
+ success: true,
332
+ repoName,
333
+ message: "No changes detected after staging",
334
+ skipped: true,
335
+ diffStats,
336
+ };
337
+ }
338
+ // Use commit strategy (GitCommitStrategy or GraphQLCommitStrategy)
339
+ const commitStrategy = getCommitStrategy(repoInfo, this.executor);
340
+ this.log.info("Committing and pushing changes...");
341
+ try {
342
+ const commitResult = await commitStrategy.commit({
343
+ repoInfo,
344
+ branchName: pushBranch,
345
+ message: commitMessage,
346
+ fileChanges,
347
+ workDir,
348
+ retries: this.retries,
349
+ // Use force push (--force-with-lease) for PR branches, not for direct mode
350
+ force: !isDirectMode,
351
+ });
352
+ this.log.info(`Committed: ${commitResult.sha} (verified: ${commitResult.verified})`);
353
+ }
354
+ catch (error) {
355
+ // Handle branch protection errors in direct mode
356
+ if (isDirectMode) {
357
+ const errorMessage = error instanceof Error ? error.message : String(error);
358
+ if (errorMessage.includes("rejected") ||
359
+ errorMessage.includes("protected") ||
360
+ errorMessage.includes("denied")) {
361
+ return {
362
+ success: false,
363
+ repoName,
364
+ 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.`,
365
+ };
366
+ }
328
367
  }
368
+ throw error;
329
369
  }
330
- throw error;
331
370
  }
332
371
  // Direct mode: no PR creation, return success
333
372
  if (isDirectMode) {
@@ -348,9 +387,9 @@ export class RepositoryProcessor {
348
387
  files: changedFiles,
349
388
  workDir,
350
389
  dryRun,
351
- retries,
390
+ retries: this.retries,
352
391
  prTemplate,
353
- executor,
392
+ executor: this.executor,
354
393
  });
355
394
  // Step 10: Handle merge options if configured
356
395
  let mergeResult;
@@ -368,8 +407,8 @@ export class RepositoryProcessor {
368
407
  mergeConfig,
369
408
  workDir,
370
409
  dryRun,
371
- retries,
372
- executor,
410
+ retries: this.retries,
411
+ executor: this.executor,
373
412
  });
374
413
  mergeResult = {
375
414
  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,199 @@
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
+ const command = `echo ${escapeShellArg(requestBody)} | gh api graphql ${hostnameArg} --input -`;
153
+ const response = await this.executor.exec(command, workDir);
154
+ // Parse the response
155
+ const parsed = JSON.parse(response);
156
+ if (parsed.errors) {
157
+ throw new Error(`GraphQL error: ${parsed.errors.map((e) => e.message).join(", ")}`);
158
+ }
159
+ const oid = parsed.data?.createCommitOnBranch?.commit?.oid;
160
+ if (!oid) {
161
+ throw new Error("GraphQL response missing commit OID");
162
+ }
163
+ return {
164
+ sha: oid,
165
+ verified: true, // GraphQL commits via GitHub App are verified
166
+ pushed: true, // GraphQL commits are pushed directly
167
+ };
168
+ }
169
+ /**
170
+ * Ensure the branch exists on the remote.
171
+ * createCommitOnBranch requires the branch to already exist.
172
+ * If the branch doesn't exist, push it to create it.
173
+ */
174
+ async ensureBranchExistsOnRemote(branchName, workDir) {
175
+ // Branch name was validated in commit(), safe for shell use
176
+ try {
177
+ // Check if the branch exists on remote
178
+ await this.executor.exec(`git ls-remote --exit-code --heads origin ${branchName}`, workDir);
179
+ // Branch exists, nothing to do
180
+ }
181
+ catch {
182
+ // Branch doesn't exist on remote, push it
183
+ // This pushes the current local branch to create it on remote
184
+ await this.executor.exec(`git push -u origin HEAD:${branchName}`, workDir);
185
+ }
186
+ }
187
+ /**
188
+ * Check if an error is due to expectedHeadOid mismatch (optimistic locking failure).
189
+ * This happens when the branch was updated between getting HEAD and making the commit.
190
+ */
191
+ isHeadOidMismatchError(error) {
192
+ const message = error.message.toLowerCase();
193
+ return (message.includes("expected branch to point to") ||
194
+ message.includes("expectedheadoid") ||
195
+ message.includes("head oid") ||
196
+ // GitHub may return this generic error for OID mismatches
197
+ message.includes("was provided invalid value"));
198
+ }
199
+ }
@@ -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.0",
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": [