@aspruyt/xfg 2.2.0 → 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.
|
@@ -101,19 +101,10 @@ export class RepositoryProcessor {
|
|
|
101
101
|
// - Dry-run: Uses wouldChange() for read-only content comparison (no side effects)
|
|
102
102
|
// - Normal: Uses git status after writing (source of truth for what git will commit)
|
|
103
103
|
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
// it requires actually writing files, which defeats dry-run's purpose.
|
|
107
|
-
//
|
|
108
|
-
// For config files (JSON/YAML), these approaches produce identical results in practice.
|
|
109
|
-
// Edge cases (repos with unusual git attributes on config files) are essentially nonexistent.
|
|
110
|
-
const changedFiles = [];
|
|
111
|
-
const diffStats = createDiffStats();
|
|
112
|
-
// Track pre-write actions for non-dry-run mode (issue #252)
|
|
113
|
-
// We need to know if a file was created vs updated BEFORE writing it
|
|
114
|
-
const preWriteActions = new Map();
|
|
115
|
-
// Track file changes for commit strategy (path -> content, null for deletion)
|
|
104
|
+
// Track all file changes with content and action - single source of truth
|
|
105
|
+
// Used for both commit message generation and actual commit
|
|
116
106
|
const fileChangesForCommit = new Map();
|
|
107
|
+
const diffStats = createDiffStats();
|
|
117
108
|
for (const file of repoConfig.files) {
|
|
118
109
|
const filePath = join(workDir, file.fileName);
|
|
119
110
|
const fileExistsLocal = existsSync(filePath);
|
|
@@ -123,7 +114,10 @@ export class RepositoryProcessor {
|
|
|
123
114
|
const existsOnBase = await this.gitOps.fileExistsOnBranch(file.fileName, baseBranch);
|
|
124
115
|
if (existsOnBase) {
|
|
125
116
|
this.log.info(`Skipping ${file.fileName} (createOnly: exists on ${baseBranch})`);
|
|
126
|
-
|
|
117
|
+
fileChangesForCommit.set(file.fileName, {
|
|
118
|
+
content: null,
|
|
119
|
+
action: "skip",
|
|
120
|
+
});
|
|
127
121
|
continue;
|
|
128
122
|
}
|
|
129
123
|
}
|
|
@@ -141,37 +135,37 @@ export class RepositoryProcessor {
|
|
|
141
135
|
header: file.header,
|
|
142
136
|
schemaUrl: file.schemaUrl,
|
|
143
137
|
});
|
|
144
|
-
// Determine action type (create vs update)
|
|
138
|
+
// Determine action type (create vs update) BEFORE writing
|
|
145
139
|
const action = fileExistsLocal
|
|
146
140
|
? "update"
|
|
147
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
|
+
}
|
|
148
152
|
if (dryRun) {
|
|
149
|
-
// In dry-run,
|
|
150
|
-
const existingContent = this.gitOps.getFileContent(file.fileName);
|
|
151
|
-
const changed = this.gitOps.wouldChange(file.fileName, fileContent);
|
|
153
|
+
// In dry-run, show diff but don't write
|
|
152
154
|
const status = getFileStatus(existingContent !== null, changed);
|
|
153
|
-
// Track stats
|
|
154
155
|
incrementDiffStats(diffStats, status);
|
|
155
|
-
if (changed) {
|
|
156
|
-
changedFiles.push({ fileName: file.fileName, action });
|
|
157
|
-
}
|
|
158
|
-
// Generate and display diff
|
|
159
156
|
const diffLines = generateDiff(existingContent, fileContent, file.fileName);
|
|
160
157
|
this.log.fileDiff(file.fileName, status, diffLines);
|
|
161
158
|
}
|
|
162
159
|
else {
|
|
163
|
-
// Write the file
|
|
164
|
-
preWriteActions.set(file.fileName, action);
|
|
160
|
+
// Write the file
|
|
165
161
|
this.gitOps.writeFile(file.fileName, fileContent);
|
|
166
|
-
// Track content for commit strategy
|
|
167
|
-
fileChangesForCommit.set(file.fileName, fileContent);
|
|
168
162
|
}
|
|
169
163
|
}
|
|
170
164
|
// Step 5b: Set executable permission for files that need it
|
|
171
|
-
const skippedFileNames = new Set(changedFiles.filter((f) => f.action === "skip").map((f) => f.fileName));
|
|
172
165
|
for (const file of repoConfig.files) {
|
|
173
166
|
// Skip files that were excluded (createOnly + exists)
|
|
174
|
-
|
|
167
|
+
const tracked = fileChangesForCommit.get(file.fileName);
|
|
168
|
+
if (tracked?.action === "skip") {
|
|
175
169
|
continue;
|
|
176
170
|
}
|
|
177
171
|
if (shouldBeExecutable(file)) {
|
|
@@ -195,6 +189,11 @@ export class RepositoryProcessor {
|
|
|
195
189
|
for (const fileName of filesToDelete) {
|
|
196
190
|
// Only delete if file actually exists in the working directory
|
|
197
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
|
+
});
|
|
198
197
|
if (dryRun) {
|
|
199
198
|
// In dry-run, show what would be deleted
|
|
200
199
|
this.log.fileDiff(fileName, "DELETED", []);
|
|
@@ -203,10 +202,7 @@ export class RepositoryProcessor {
|
|
|
203
202
|
else {
|
|
204
203
|
this.log.info(`Deleting orphaned file: ${fileName}`);
|
|
205
204
|
this.gitOps.deleteFile(fileName);
|
|
206
|
-
// Track deletion for commit strategy
|
|
207
|
-
fileChangesForCommit.set(fileName, null);
|
|
208
205
|
}
|
|
209
|
-
changedFiles.push({ fileName, action: "delete" });
|
|
210
206
|
}
|
|
211
207
|
}
|
|
212
208
|
}
|
|
@@ -217,85 +213,41 @@ export class RepositoryProcessor {
|
|
|
217
213
|
// Only save if there are managed files for any config, or if we had a previous manifest
|
|
218
214
|
const hasAnyManagedFiles = Object.keys(newManifest.configs).length > 0;
|
|
219
215
|
if (hasAnyManagedFiles || existingManifest !== null) {
|
|
220
|
-
if (!dryRun) {
|
|
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);
|
|
225
|
-
}
|
|
226
216
|
// Track manifest file as changed if it would be different
|
|
227
217
|
const existingConfigs = existingManifest?.configs ?? {};
|
|
228
218
|
const manifestChanged = JSON.stringify(existingConfigs) !==
|
|
229
219
|
JSON.stringify(newManifest.configs);
|
|
230
220
|
if (manifestChanged) {
|
|
231
221
|
const manifestExisted = existsSync(join(workDir, MANIFEST_FILENAME));
|
|
232
|
-
|
|
233
|
-
|
|
222
|
+
const manifestContent = JSON.stringify(newManifest, null, 2) + "\n";
|
|
223
|
+
fileChangesForCommit.set(MANIFEST_FILENAME, {
|
|
224
|
+
content: manifestContent,
|
|
234
225
|
action: manifestExisted ? "update" : "create",
|
|
235
226
|
});
|
|
236
227
|
}
|
|
228
|
+
if (!dryRun) {
|
|
229
|
+
saveManifest(workDir, newManifest);
|
|
230
|
+
}
|
|
237
231
|
}
|
|
238
232
|
// Show diff summary in dry-run mode
|
|
239
233
|
if (dryRun) {
|
|
240
234
|
this.log.diffSummary(diffStats.newCount, diffStats.modifiedCount, diffStats.unchangedCount, diffStats.deletedCount);
|
|
241
235
|
}
|
|
242
|
-
// Step 6:
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
const alreadyTracked = new Set(changedFiles.map((f) => f.fileName));
|
|
255
|
-
// Add config files that actually changed according to git
|
|
256
|
-
for (const file of repoConfig.files) {
|
|
257
|
-
if (alreadyTracked.has(file.fileName)) {
|
|
258
|
-
continue; // Already tracked (skipped, deleted, or manifest)
|
|
259
|
-
}
|
|
260
|
-
// Only include files that git reports as changed
|
|
261
|
-
if (!gitChangedFiles.has(file.fileName)) {
|
|
262
|
-
continue; // File didn't actually change
|
|
263
|
-
}
|
|
264
|
-
// Use pre-write action (issue #252) - we stored whether file existed
|
|
265
|
-
// BEFORE writing, which is the correct basis for create vs update
|
|
266
|
-
const action = preWriteActions.get(file.fileName) ?? "update";
|
|
267
|
-
changedFiles.push({ fileName: file.fileName, action });
|
|
268
|
-
}
|
|
269
|
-
// Add any other files from git status that aren't already tracked
|
|
270
|
-
// This catches files like .xfg.json when manifestChanged was false
|
|
271
|
-
// but git still reports a change (e.g., due to formatting differences)
|
|
272
|
-
for (const gitFile of gitChangedFiles) {
|
|
273
|
-
if (changedFiles.some((f) => f.fileName === gitFile)) {
|
|
274
|
-
continue; // Already tracked
|
|
275
|
-
}
|
|
276
|
-
const filePath = join(workDir, gitFile);
|
|
277
|
-
const action = existsSync(filePath)
|
|
278
|
-
? "update"
|
|
279
|
-
: "create";
|
|
280
|
-
changedFiles.push({ fileName: gitFile, action });
|
|
281
|
-
}
|
|
282
|
-
// Calculate diff stats from changedFiles (issue #252)
|
|
283
|
-
for (const file of changedFiles) {
|
|
284
|
-
switch (file.action) {
|
|
285
|
-
case "create":
|
|
286
|
-
incrementDiffStats(diffStats, "NEW");
|
|
287
|
-
break;
|
|
288
|
-
case "update":
|
|
289
|
-
incrementDiffStats(diffStats, "MODIFIED");
|
|
290
|
-
break;
|
|
291
|
-
case "delete":
|
|
292
|
-
incrementDiffStats(diffStats, "DELETED");
|
|
293
|
-
break;
|
|
294
|
-
// "skip" files are not counted in stats
|
|
295
|
-
}
|
|
296
|
-
}
|
|
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");
|
|
297
248
|
}
|
|
298
249
|
}
|
|
250
|
+
const hasChanges = changedFiles.filter((f) => f.action !== "skip").length > 0;
|
|
299
251
|
if (!hasChanges) {
|
|
300
252
|
return {
|
|
301
253
|
success: true,
|
|
@@ -315,11 +267,10 @@ export class RepositoryProcessor {
|
|
|
315
267
|
this.log.info(`Would push to ${pushBranch}...`);
|
|
316
268
|
}
|
|
317
269
|
else {
|
|
318
|
-
// Build file changes for commit strategy
|
|
319
|
-
const fileChanges =
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
}
|
|
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 }));
|
|
323
274
|
// Check if there are actually staged changes (edge case handling)
|
|
324
275
|
// This handles scenarios where git status shows changes but git add doesn't stage anything
|
|
325
276
|
// (e.g., due to .gitattributes normalization)
|
|
@@ -149,7 +149,14 @@ export class GraphQLCommitStrategy {
|
|
|
149
149
|
const hostnameArg = repoInfo.host !== "github.com"
|
|
150
150
|
? `--hostname ${escapeShellArg(repoInfo.host)}`
|
|
151
151
|
: "";
|
|
152
|
-
|
|
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 -`;
|
|
153
160
|
const response = await this.executor.exec(command, workDir);
|
|
154
161
|
// Parse the response
|
|
155
162
|
const parsed = JSON.parse(response);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aspruyt/xfg",
|
|
3
|
-
"version": "2.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",
|