@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 +1 -59
- package/dist/repository-processor.d.ts +2 -0
- package/dist/repository-processor.js +83 -44
- package/dist/strategies/commit-strategy-selector.d.ts +16 -0
- package/dist/strategies/commit-strategy-selector.js +21 -0
- package/dist/strategies/commit-strategy.d.ts +31 -0
- package/dist/strategies/commit-strategy.js +1 -0
- package/dist/strategies/git-commit-strategy.d.ts +18 -0
- package/dist/strategies/git-commit-strategy.js +41 -0
- package/dist/strategies/graphql-commit-strategy.d.ts +58 -0
- package/dist/strategies/graphql-commit-strategy.js +199 -0
- package/dist/strategies/index.d.ts +4 -0
- package/dist/strategies/index.js +3 -0
- package/package.json +2 -1
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
|
-
|
|
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,
|
|
43
|
-
|
|
44
|
-
this.
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
317
|
-
//
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
package/dist/strategies/index.js
CHANGED
|
@@ -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.
|
|
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": [
|