@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
- // This is intentional. git status is more accurate because it respects .gitattributes
105
- // (line ending normalization, filters) and detects executable bit changes. However,
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
- changedFiles.push({ fileName: file.fileName, action: "skip" });
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, check if file would change and show diff
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 and store pre-write action for stats calculation
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
- if (skippedFileNames.has(file.fileName)) {
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
- changedFiles.push({
233
- fileName: MANIFEST_FILENAME,
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: Check for changes (exclude skipped files)
243
- let hasChanges;
244
- if (dryRun) {
245
- hasChanges = changedFiles.filter((f) => f.action !== "skip").length > 0;
246
- }
247
- else {
248
- hasChanges = await this.gitOps.hasChanges();
249
- // If there are changes, determine which files changed
250
- if (hasChanges) {
251
- // Get the actual list of changed files from git status
252
- const gitChangedFiles = new Set(await this.gitOps.getChangedFiles());
253
- // Build set of files already tracked (skip, delete, manifest updates added earlier)
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
- for (const [path, content] of fileChangesForCommit.entries()) {
321
- fileChanges.push({ path, content });
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
- const command = `echo ${escapeShellArg(requestBody)} | gh api graphql ${hostnameArg} --input -`;
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.0",
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",