@aspruyt/xfg 1.8.0 → 1.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,8 +1,11 @@
1
1
  # xfg
2
2
 
3
3
  [![CI](https://github.com/anthony-spruyt/xfg/actions/workflows/ci.yaml/badge.svg?branch=main)](https://github.com/anthony-spruyt/xfg/actions/workflows/ci.yaml)
4
+ [![codecov](https://codecov.io/gh/anthony-spruyt/xfg/graph/badge.svg)](https://codecov.io/gh/anthony-spruyt/xfg)
4
5
  [![npm version](https://img.shields.io/npm/v/@aspruyt/xfg.svg)](https://www.npmjs.com/package/@aspruyt/xfg)
5
6
  [![npm downloads](https://img.shields.io/npm/dw/@aspruyt/xfg.svg)](https://www.npmjs.com/package/@aspruyt/xfg)
7
+ [![GitHub Marketplace](https://img.shields.io/badge/Marketplace-xfg-blue?logo=github)](https://github.com/marketplace/actions/xfg-config-file-sync)
8
+ [![docs](https://img.shields.io/badge/docs-GitHub%20Pages-blue)](https://anthony-spruyt.github.io/xfg/)
6
9
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
7
10
 
8
11
  A CLI tool that syncs JSON, JSON5, YAML, or text configuration files across multiple GitHub, Azure DevOps, and GitLab repositories. By default, changes are made via pull requests, but you can also push directly to the default branch.
@@ -11,6 +14,29 @@ A CLI tool that syncs JSON, JSON5, YAML, or text configuration files across mult
11
14
 
12
15
  ## Quick Start
13
16
 
17
+ ### GitHub Action
18
+
19
+ ```yaml
20
+ # .github/workflows/sync-configs.yml
21
+ name: Sync Configs
22
+ on:
23
+ push:
24
+ branches: [main]
25
+ paths: [sync-config.yaml]
26
+
27
+ jobs:
28
+ sync:
29
+ runs-on: ubuntu-latest
30
+ steps:
31
+ - uses: actions/checkout@v4
32
+ - uses: anthony-spruyt/xfg@v1
33
+ with:
34
+ config: ./sync-config.yaml
35
+ github-token: ${{ secrets.GH_PAT }} # PAT with repo scope for cross-repo access
36
+ ```
37
+
38
+ ### CLI
39
+
14
40
  ```bash
15
41
  # Install
16
42
  npm install -g @aspruyt/xfg
@@ -18,8 +44,14 @@ npm install -g @aspruyt/xfg
18
44
  # Authenticate (GitHub)
19
45
  gh auth login
20
46
 
21
- # Create config.yaml
22
- cat > config.yaml << 'EOF'
47
+ # Run
48
+ xfg --config ./config.yaml
49
+ ```
50
+
51
+ ### Example Config
52
+
53
+ ```yaml
54
+ # sync-config.yaml
23
55
  files:
24
56
  .prettierrc.json:
25
57
  content:
@@ -33,10 +65,6 @@ repos:
33
65
  - git@github.com:your-org/frontend-app.git
34
66
  - git@github.com:your-org/backend-api.git
35
67
  - git@github.com:your-org/shared-lib.git
36
- EOF
37
-
38
- # Run
39
- xfg --config ./config.yaml
40
68
  ```
41
69
 
42
70
  **Result:** PRs are created in all three repos with identical `.prettierrc.json` files.
@@ -59,6 +87,7 @@ xfg --config ./config.yaml
59
87
  - **Multi-Platform** - Works with GitHub (including GitHub Enterprise Server), Azure DevOps, and GitLab (including self-hosted)
60
88
  - **Auto-Merge PRs** - Automatically merge PRs when checks pass, or force merge with admin privileges
61
89
  - **Direct Push Mode** - Push directly to default branch without creating PRs
90
+ - **Delete Orphaned Files** - Automatically remove files from repos when deleted from config (manifest-tracked)
62
91
  - **Dry-Run Mode** - Preview changes without creating PRs
63
92
  - **Error Resilience** - Continues processing if individual repos fail
64
93
  - **Automatic Retries** - Retries transient network errors with exponential backoff
@@ -121,6 +121,10 @@ export function normalizeConfig(raw) {
121
121
  const vars = fileConfig.vars || repoOverride?.vars
122
122
  ? { ...fileConfig.vars, ...repoOverride?.vars }
123
123
  : undefined;
124
+ // deleteOrphaned: per-repo overrides per-file overrides global
125
+ const deleteOrphaned = repoOverride?.deleteOrphaned ??
126
+ fileConfig.deleteOrphaned ??
127
+ raw.deleteOrphaned;
124
128
  files.push({
125
129
  fileName,
126
130
  content: mergedContent,
@@ -130,6 +134,7 @@ export function normalizeConfig(raw) {
130
134
  schemaUrl,
131
135
  template,
132
136
  vars,
137
+ deleteOrphaned,
133
138
  });
134
139
  }
135
140
  // Merge PR options: per-repo overrides global
@@ -145,5 +150,6 @@ export function normalizeConfig(raw) {
145
150
  repos: expandedRepos,
146
151
  prTemplate: raw.prTemplate,
147
152
  githubHosts: raw.githubHosts,
153
+ deleteOrphaned: raw.deleteOrphaned,
148
154
  };
149
155
  }
@@ -95,6 +95,15 @@ export function validateRawConfig(config) {
95
95
  }
96
96
  }
97
97
  }
98
+ if (fileConfig.deleteOrphaned !== undefined &&
99
+ typeof fileConfig.deleteOrphaned !== "boolean") {
100
+ throw new Error(`File '${fileName}' deleteOrphaned must be a boolean`);
101
+ }
102
+ }
103
+ // Validate global deleteOrphaned
104
+ if (config.deleteOrphaned !== undefined &&
105
+ typeof config.deleteOrphaned !== "boolean") {
106
+ throw new Error("Global deleteOrphaned must be a boolean");
98
107
  }
99
108
  if (!config.repos || !Array.isArray(config.repos)) {
100
109
  throw new Error("Config missing required field: repos (must be an array)");
@@ -196,6 +205,10 @@ export function validateRawConfig(config) {
196
205
  }
197
206
  }
198
207
  }
208
+ if (fileOverride.deleteOrphaned !== undefined &&
209
+ typeof fileOverride.deleteOrphaned !== "boolean") {
210
+ throw new Error(`Repo ${getGitDisplayName(repo.git)}: file '${fileName}' deleteOrphaned must be a boolean`);
211
+ }
199
212
  }
200
213
  }
201
214
  }
package/dist/config.d.ts CHANGED
@@ -18,6 +18,7 @@ export interface RawFileConfig {
18
18
  schemaUrl?: string;
19
19
  template?: boolean;
20
20
  vars?: Record<string, string>;
21
+ deleteOrphaned?: boolean;
21
22
  }
22
23
  export interface RawRepoFileOverride {
23
24
  content?: ContentValue;
@@ -28,6 +29,7 @@ export interface RawRepoFileOverride {
28
29
  schemaUrl?: string;
29
30
  template?: boolean;
30
31
  vars?: Record<string, string>;
32
+ deleteOrphaned?: boolean;
31
33
  }
32
34
  export interface RawRepoConfig {
33
35
  git: string | string[];
@@ -40,6 +42,7 @@ export interface RawConfig {
40
42
  prOptions?: PRMergeOptions;
41
43
  prTemplate?: string;
42
44
  githubHosts?: string[];
45
+ deleteOrphaned?: boolean;
43
46
  }
44
47
  export interface FileContent {
45
48
  fileName: string;
@@ -50,6 +53,7 @@ export interface FileContent {
50
53
  schemaUrl?: string;
51
54
  template?: boolean;
52
55
  vars?: Record<string, string>;
56
+ deleteOrphaned?: boolean;
53
57
  }
54
58
  export interface RepoConfig {
55
59
  git: string;
@@ -60,5 +64,6 @@ export interface Config {
60
64
  repos: RepoConfig[];
61
65
  prTemplate?: string;
62
66
  githubHosts?: string[];
67
+ deleteOrphaned?: boolean;
63
68
  }
64
69
  export declare function loadConfig(filePath: string): Config;
@@ -1,4 +1,4 @@
1
- export type FileStatus = "NEW" | "MODIFIED" | "UNCHANGED";
1
+ export type FileStatus = "NEW" | "MODIFIED" | "UNCHANGED" | "DELETED";
2
2
  /**
3
3
  * Determines file status based on existence and change detection.
4
4
  */
@@ -20,6 +20,7 @@ export interface DiffStats {
20
20
  newCount: number;
21
21
  modifiedCount: number;
22
22
  unchangedCount: number;
23
+ deletedCount: number;
23
24
  }
24
25
  /**
25
26
  * Create an empty diff stats object.
@@ -18,6 +18,8 @@ export function formatStatusBadge(status) {
18
18
  return chalk.yellow("[MODIFIED]");
19
19
  case "UNCHANGED":
20
20
  return chalk.gray("[UNCHANGED]");
21
+ case "DELETED":
22
+ return chalk.red("[DELETED]");
21
23
  }
22
24
  }
23
25
  /**
@@ -203,7 +205,7 @@ function groupIntoHunks(ops, oldLines, newLines, contextLines) {
203
205
  * Create an empty diff stats object.
204
206
  */
205
207
  export function createDiffStats() {
206
- return { newCount: 0, modifiedCount: 0, unchangedCount: 0 };
208
+ return { newCount: 0, modifiedCount: 0, unchangedCount: 0, deletedCount: 0 };
207
209
  }
208
210
  /**
209
211
  * Increment the appropriate counter in diff stats.
@@ -219,5 +221,8 @@ export function incrementDiffStats(stats, status) {
219
221
  case "UNCHANGED":
220
222
  stats.unchangedCount++;
221
223
  break;
224
+ case "DELETED":
225
+ stats.deletedCount++;
226
+ break;
222
227
  }
223
228
  }
package/dist/git-ops.d.ts CHANGED
@@ -67,6 +67,17 @@ export declare class GitOps {
67
67
  * Used for createOnly checks against the base branch (not the working directory).
68
68
  */
69
69
  fileExistsOnBranch(fileName: string, branch: string): Promise<boolean>;
70
+ /**
71
+ * Check if a file exists in the working directory.
72
+ */
73
+ fileExists(fileName: string): boolean;
74
+ /**
75
+ * Delete a file from the working directory.
76
+ * Does nothing in dry-run mode.
77
+ *
78
+ * @param fileName - The file path relative to the work directory
79
+ */
80
+ deleteFile(fileName: string): void;
70
81
  /**
71
82
  * Stage all changes and commit with the given message.
72
83
  * Uses --no-verify to skip pre-commit hooks (config sync should always succeed).
package/dist/git-ops.js CHANGED
@@ -174,6 +174,29 @@ export class GitOps {
174
174
  return false;
175
175
  }
176
176
  }
177
+ /**
178
+ * Check if a file exists in the working directory.
179
+ */
180
+ fileExists(fileName) {
181
+ const filePath = this.validatePath(fileName);
182
+ return existsSync(filePath);
183
+ }
184
+ /**
185
+ * Delete a file from the working directory.
186
+ * Does nothing in dry-run mode.
187
+ *
188
+ * @param fileName - The file path relative to the work directory
189
+ */
190
+ deleteFile(fileName) {
191
+ if (this.dryRun) {
192
+ return;
193
+ }
194
+ const filePath = this.validatePath(fileName);
195
+ if (!existsSync(filePath)) {
196
+ return; // File doesn't exist, nothing to delete
197
+ }
198
+ rmSync(filePath);
199
+ }
177
200
  /**
178
201
  * Stage all changes and commit with the given message.
179
202
  * Uses --no-verify to skip pre-commit hooks (config sync should always succeed).
package/dist/index.js CHANGED
@@ -41,6 +41,7 @@ program
41
41
  return value;
42
42
  })
43
43
  .option("--delete-branch", "Delete source branch after merge")
44
+ .option("--no-delete", "Skip deletion of orphaned files even if deleteOrphaned is configured")
44
45
  .parse();
45
46
  const options = program.opts();
46
47
  /**
@@ -134,6 +135,7 @@ async function main() {
134
135
  dryRun: options.dryRun,
135
136
  retries: options.retries,
136
137
  prTemplate: config.prTemplate,
138
+ noDelete: options.noDelete,
137
139
  });
138
140
  if (result.skipped) {
139
141
  logger.skip(current, repoName, result.message);
package/dist/logger.d.ts CHANGED
@@ -2,7 +2,7 @@ import { FileStatus } from "./diff-utils.js";
2
2
  export interface ILogger {
3
3
  info(message: string): void;
4
4
  fileDiff(fileName: string, status: FileStatus, diffLines: string[]): void;
5
- diffSummary(newCount: number, modifiedCount: number, unchangedCount: number): void;
5
+ diffSummary(newCount: number, modifiedCount: number, unchangedCount: number, deletedCount?: number): void;
6
6
  }
7
7
  export interface LoggerStats {
8
8
  total: number;
@@ -26,7 +26,7 @@ export declare class Logger {
26
26
  /**
27
27
  * Display summary statistics for dry-run diff.
28
28
  */
29
- diffSummary(newCount: number, modifiedCount: number, unchangedCount: number): void;
29
+ diffSummary(newCount: number, modifiedCount: number, unchangedCount: number, deletedCount?: number): void;
30
30
  summary(): void;
31
31
  hasFailures(): boolean;
32
32
  }
package/dist/logger.js CHANGED
@@ -49,12 +49,14 @@ export class Logger {
49
49
  /**
50
50
  * Display summary statistics for dry-run diff.
51
51
  */
52
- diffSummary(newCount, modifiedCount, unchangedCount) {
52
+ diffSummary(newCount, modifiedCount, unchangedCount, deletedCount) {
53
53
  const parts = [];
54
54
  if (newCount > 0)
55
55
  parts.push(chalk.green(`${newCount} new`));
56
56
  if (modifiedCount > 0)
57
57
  parts.push(chalk.yellow(`${modifiedCount} modified`));
58
+ if (deletedCount && deletedCount > 0)
59
+ parts.push(chalk.red(`${deletedCount} deleted`));
58
60
  if (unchangedCount > 0)
59
61
  parts.push(chalk.gray(`${unchangedCount} unchanged`));
60
62
  if (parts.length > 0) {
@@ -0,0 +1,46 @@
1
+ export declare const MANIFEST_FILENAME = ".xfg.json";
2
+ export interface XfgManifest {
3
+ version: 1;
4
+ managedFiles: string[];
5
+ }
6
+ /**
7
+ * Creates an empty manifest with the current version.
8
+ */
9
+ export declare function createEmptyManifest(): XfgManifest;
10
+ /**
11
+ * Loads the xfg manifest from a repository's working directory.
12
+ * Returns null if the manifest file doesn't exist.
13
+ *
14
+ * @param workDir - The repository working directory
15
+ * @returns The manifest or null if not found
16
+ */
17
+ export declare function loadManifest(workDir: string): XfgManifest | null;
18
+ /**
19
+ * Saves the xfg manifest to a repository's working directory.
20
+ *
21
+ * @param workDir - The repository working directory
22
+ * @param manifest - The manifest to save
23
+ */
24
+ export declare function saveManifest(workDir: string, manifest: XfgManifest): void;
25
+ /**
26
+ * Gets the list of managed files from a manifest.
27
+ * Returns an empty array if the manifest is null.
28
+ *
29
+ * @param manifest - The manifest or null
30
+ * @returns Array of managed file names
31
+ */
32
+ export declare function getManagedFiles(manifest: XfgManifest | null): string[];
33
+ /**
34
+ * Updates the manifest with the current set of files that have deleteOrphaned enabled.
35
+ * Files with deleteOrphaned: true are added to managedFiles.
36
+ * Files with deleteOrphaned: false (explicit) are removed from managedFiles.
37
+ * Files not in the config but in managedFiles are candidates for deletion.
38
+ *
39
+ * @param manifest - The existing manifest (or null for new repos)
40
+ * @param filesWithDeleteOrphaned - Map of fileName to deleteOrphaned value (true/false/undefined)
41
+ * @returns Updated manifest and list of files to delete
42
+ */
43
+ export declare function updateManifest(manifest: XfgManifest | null, filesWithDeleteOrphaned: Map<string, boolean | undefined>): {
44
+ manifest: XfgManifest;
45
+ filesToDelete: string[];
46
+ };
@@ -0,0 +1,105 @@
1
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ export const MANIFEST_FILENAME = ".xfg.json";
4
+ /**
5
+ * Creates an empty manifest with the current version.
6
+ */
7
+ export function createEmptyManifest() {
8
+ return {
9
+ version: 1,
10
+ managedFiles: [],
11
+ };
12
+ }
13
+ /**
14
+ * Loads the xfg manifest from a repository's working directory.
15
+ * Returns null if the manifest file doesn't exist.
16
+ *
17
+ * @param workDir - The repository working directory
18
+ * @returns The manifest or null if not found
19
+ */
20
+ export function loadManifest(workDir) {
21
+ const manifestPath = join(workDir, MANIFEST_FILENAME);
22
+ if (!existsSync(manifestPath)) {
23
+ return null;
24
+ }
25
+ try {
26
+ const content = readFileSync(manifestPath, "utf-8");
27
+ const parsed = JSON.parse(content);
28
+ // Validate the manifest structure
29
+ if (typeof parsed !== "object" || parsed === null) {
30
+ return null;
31
+ }
32
+ if (parsed.version !== 1) {
33
+ return null;
34
+ }
35
+ if (!Array.isArray(parsed.managedFiles)) {
36
+ return null;
37
+ }
38
+ return parsed;
39
+ }
40
+ catch {
41
+ return null;
42
+ }
43
+ }
44
+ /**
45
+ * Saves the xfg manifest to a repository's working directory.
46
+ *
47
+ * @param workDir - The repository working directory
48
+ * @param manifest - The manifest to save
49
+ */
50
+ export function saveManifest(workDir, manifest) {
51
+ const manifestPath = join(workDir, MANIFEST_FILENAME);
52
+ const content = JSON.stringify(manifest, null, 2) + "\n";
53
+ writeFileSync(manifestPath, content, "utf-8");
54
+ }
55
+ /**
56
+ * Gets the list of managed files from a manifest.
57
+ * Returns an empty array if the manifest is null.
58
+ *
59
+ * @param manifest - The manifest or null
60
+ * @returns Array of managed file names
61
+ */
62
+ export function getManagedFiles(manifest) {
63
+ if (!manifest) {
64
+ return [];
65
+ }
66
+ return [...manifest.managedFiles];
67
+ }
68
+ /**
69
+ * Updates the manifest with the current set of files that have deleteOrphaned enabled.
70
+ * Files with deleteOrphaned: true are added to managedFiles.
71
+ * Files with deleteOrphaned: false (explicit) are removed from managedFiles.
72
+ * Files not in the config but in managedFiles are candidates for deletion.
73
+ *
74
+ * @param manifest - The existing manifest (or null for new repos)
75
+ * @param filesWithDeleteOrphaned - Map of fileName to deleteOrphaned value (true/false/undefined)
76
+ * @returns Updated manifest and list of files to delete
77
+ */
78
+ export function updateManifest(manifest, filesWithDeleteOrphaned) {
79
+ const existingManaged = new Set(getManagedFiles(manifest));
80
+ const newManaged = new Set();
81
+ const filesToDelete = [];
82
+ // Process current config files
83
+ for (const [fileName, deleteOrphaned] of filesWithDeleteOrphaned) {
84
+ if (deleteOrphaned === true) {
85
+ // File has deleteOrphaned: true, add to managed set
86
+ newManaged.add(fileName);
87
+ }
88
+ // If deleteOrphaned is false or undefined, don't add to managed set
89
+ // (explicitly setting false removes from tracking)
90
+ }
91
+ // Find orphaned files: in old manifest but not in current config
92
+ for (const fileName of existingManaged) {
93
+ if (!filesWithDeleteOrphaned.has(fileName)) {
94
+ // File was managed before but is no longer in config - delete it
95
+ filesToDelete.push(fileName);
96
+ }
97
+ }
98
+ return {
99
+ manifest: {
100
+ version: 1,
101
+ managedFiles: Array.from(newManaged).sort(),
102
+ },
103
+ filesToDelete,
104
+ };
105
+ }
@@ -1,9 +1,10 @@
1
1
  import { RepoInfo } from "./repo-detector.js";
2
2
  import { MergeResult, PRMergeConfig } from "./strategies/index.js";
3
+ import { CommandExecutor } from "./command-executor.js";
3
4
  export { escapeShellArg } from "./shell-utils.js";
4
5
  export interface FileAction {
5
6
  fileName: string;
6
- action: "create" | "update" | "skip";
7
+ action: "create" | "update" | "skip" | "delete";
7
8
  }
8
9
  export interface PROptions {
9
10
  repoInfo: RepoInfo;
@@ -16,6 +17,8 @@ export interface PROptions {
16
17
  retries?: number;
17
18
  /** Custom PR body template */
18
19
  prTemplate?: string;
20
+ /** Optional command executor for shell commands (for testing) */
21
+ executor?: CommandExecutor;
19
22
  }
20
23
  export interface PRResult {
21
24
  url?: string;
@@ -45,5 +48,7 @@ export interface MergePROptions {
45
48
  workDir: string;
46
49
  dryRun?: boolean;
47
50
  retries?: number;
51
+ /** Optional command executor for shell commands (for testing) */
52
+ executor?: CommandExecutor;
48
53
  }
49
54
  export declare function mergePR(options: MergePROptions): Promise<MergeResult>;
@@ -37,7 +37,20 @@ function formatFileChanges(files) {
37
37
  const changedFiles = files.filter((f) => f.action !== "skip");
38
38
  return changedFiles
39
39
  .map((f) => {
40
- const actionText = f.action === "create" ? "Created" : "Updated";
40
+ let actionText;
41
+ switch (f.action) {
42
+ case "create":
43
+ actionText = "Created";
44
+ break;
45
+ case "update":
46
+ actionText = "Updated";
47
+ break;
48
+ case "delete":
49
+ actionText = "Deleted";
50
+ break;
51
+ default:
52
+ actionText = "Changed";
53
+ }
41
54
  return `- ${actionText} \`${f.fileName}\``;
42
55
  })
43
56
  .join("\n");
@@ -84,7 +97,7 @@ export function formatPRTitle(files) {
84
97
  return `chore: sync ${changedFiles.length} config files`;
85
98
  }
86
99
  export async function createPR(options) {
87
- const { repoInfo, branchName, baseBranch, files, workDir, dryRun, retries, prTemplate, } = options;
100
+ const { repoInfo, branchName, baseBranch, files, workDir, dryRun, retries, prTemplate, executor, } = options;
88
101
  const title = formatPRTitle(files);
89
102
  const body = formatPRBody(files, repoInfo, prTemplate);
90
103
  if (dryRun) {
@@ -94,7 +107,7 @@ export async function createPR(options) {
94
107
  };
95
108
  }
96
109
  // Get the appropriate strategy and execute
97
- const strategy = getPRStrategy(repoInfo);
110
+ const strategy = getPRStrategy(repoInfo, executor);
98
111
  return strategy.execute({
99
112
  repoInfo,
100
113
  title,
@@ -106,7 +119,7 @@ export async function createPR(options) {
106
119
  });
107
120
  }
108
121
  export async function mergePR(options) {
109
- const { repoInfo, prUrl, mergeConfig, workDir, dryRun, retries } = options;
122
+ const { repoInfo, prUrl, mergeConfig, workDir, dryRun, retries, executor } = options;
110
123
  if (dryRun) {
111
124
  const modeText = mergeConfig.mode === "force"
112
125
  ? "force merge"
@@ -120,7 +133,7 @@ export async function mergePR(options) {
120
133
  };
121
134
  }
122
135
  // Get the appropriate strategy and execute merge
123
- const strategy = getPRStrategy(repoInfo);
136
+ const strategy = getPRStrategy(repoInfo, executor);
124
137
  return strategy.merge({
125
138
  prUrl,
126
139
  config: mergeConfig,
@@ -13,6 +13,8 @@ export interface ProcessorOptions {
13
13
  executor?: CommandExecutor;
14
14
  /** Custom PR body template */
15
15
  prTemplate?: string;
16
+ /** Skip deleting orphaned files even if deleteOrphaned is configured */
17
+ noDelete?: boolean;
16
18
  }
17
19
  /**
18
20
  * Factory function type for creating GitOps instances.
@@ -9,6 +9,7 @@ import { logger } from "./logger.js";
9
9
  import { getPRStrategy } from "./strategies/index.js";
10
10
  import { defaultExecutor } from "./command-executor.js";
11
11
  import { getFileStatus, generateDiff, createDiffStats, incrementDiffStats, } from "./diff-utils.js";
12
+ import { loadManifest, saveManifest, updateManifest, MANIFEST_FILENAME, } from "./manifest.js";
12
13
  /**
13
14
  * Determines if a file should be marked as executable.
14
15
  * .sh files are auto-executable unless explicit executable: false is set.
@@ -160,9 +161,62 @@ export class RepositoryProcessor {
160
161
  await this.gitOps.setExecutable(file.fileName);
161
162
  }
162
163
  }
164
+ // Step 5c: Handle orphaned file deletion (manifest-based tracking)
165
+ const existingManifest = loadManifest(workDir);
166
+ // Build map of files with their deleteOrphaned setting
167
+ const filesWithDeleteOrphaned = new Map();
168
+ for (const file of repoConfig.files) {
169
+ // Skip files that were excluded (createOnly + exists)
170
+ if (skippedFileNames.has(file.fileName)) {
171
+ continue;
172
+ }
173
+ filesWithDeleteOrphaned.set(file.fileName, file.deleteOrphaned);
174
+ }
175
+ // Update manifest and get list of files to delete
176
+ const { manifest: newManifest, filesToDelete } = updateManifest(existingManifest, filesWithDeleteOrphaned);
177
+ // Delete orphaned files (unless --no-delete flag is set)
178
+ if (filesToDelete.length > 0 && !options.noDelete) {
179
+ for (const fileName of filesToDelete) {
180
+ // Only delete if file actually exists in the working directory
181
+ if (this.gitOps.fileExists(fileName)) {
182
+ if (dryRun) {
183
+ // In dry-run, show what would be deleted
184
+ this.log.fileDiff(fileName, "DELETED", []);
185
+ incrementDiffStats(diffStats, "DELETED");
186
+ }
187
+ else {
188
+ this.log.info(`Deleting orphaned file: ${fileName}`);
189
+ this.gitOps.deleteFile(fileName);
190
+ }
191
+ changedFiles.push({ fileName, action: "delete" });
192
+ }
193
+ }
194
+ }
195
+ else if (filesToDelete.length > 0 && options.noDelete) {
196
+ this.log.info(`Skipping deletion of ${filesToDelete.length} orphaned file(s) (--no-delete flag)`);
197
+ }
198
+ // Save updated manifest (tracks files with deleteOrphaned: true)
199
+ // Only save if there are managed files or if we had a previous manifest
200
+ if (newManifest.managedFiles.length > 0 || existingManifest !== null) {
201
+ if (!dryRun) {
202
+ saveManifest(workDir, newManifest);
203
+ }
204
+ // Track manifest file as changed if it would be different
205
+ const existingManifestFiles = existingManifest?.managedFiles ?? [];
206
+ const newManifestFiles = newManifest.managedFiles;
207
+ const manifestChanged = JSON.stringify(existingManifestFiles) !==
208
+ JSON.stringify(newManifestFiles);
209
+ if (manifestChanged) {
210
+ const manifestExisted = existingManifest !== null;
211
+ changedFiles.push({
212
+ fileName: MANIFEST_FILENAME,
213
+ action: manifestExisted ? "update" : "create",
214
+ });
215
+ }
216
+ }
163
217
  // Show diff summary in dry-run mode
164
218
  if (dryRun) {
165
- this.log.diffSummary(diffStats.newCount, diffStats.modifiedCount, diffStats.unchangedCount);
219
+ this.log.diffSummary(diffStats.newCount, diffStats.modifiedCount, diffStats.unchangedCount, diffStats.deletedCount);
166
220
  }
167
221
  // Step 6: Check for changes (exclude skipped files)
168
222
  let hasChanges;
@@ -261,6 +315,7 @@ export class RepositoryProcessor {
261
315
  dryRun,
262
316
  retries,
263
317
  prTemplate,
318
+ executor,
264
319
  });
265
320
  // Step 10: Handle merge options if configured
266
321
  let mergeResult;
@@ -279,6 +334,7 @@ export class RepositoryProcessor {
279
334
  workDir,
280
335
  dryRun,
281
336
  retries,
337
+ executor,
282
338
  });
283
339
  mergeResult = {
284
340
  merged: result.merged ?? false,
@@ -317,6 +373,16 @@ export class RepositoryProcessor {
317
373
  */
318
374
  formatCommitMessage(files) {
319
375
  const changedFiles = files.filter((f) => f.action !== "skip");
376
+ const deletedFiles = changedFiles.filter((f) => f.action === "delete");
377
+ const syncedFiles = changedFiles.filter((f) => f.action !== "delete");
378
+ // If only deletions, use "remove" prefix
379
+ if (syncedFiles.length === 0 && deletedFiles.length > 0) {
380
+ if (deletedFiles.length === 1) {
381
+ return `chore: remove ${deletedFiles[0].fileName}`;
382
+ }
383
+ return `chore: remove ${deletedFiles.length} orphaned config files`;
384
+ }
385
+ // Mixed or only syncs
320
386
  if (changedFiles.length === 1) {
321
387
  return `chore: sync ${changedFiles[0].fileName}`;
322
388
  }
@@ -23,6 +23,10 @@ export const DEFAULT_PERMANENT_ERROR_PATTERNS = [
23
23
  /not\s*a\s*git\s*repository/i,
24
24
  /non-fast-forward/i,
25
25
  /remote\s*rejected/i,
26
+ /set\s+the\s+GH_TOKEN\s+environment\s+variable/i,
27
+ /GITHUB_TOKEN\s+environment\s+variable/i,
28
+ /set\s+the\s+AZURE_DEVOPS_EXT_PAT\s+environment\s+variable/i,
29
+ /GITLAB_TOKEN\s+environment\s+variable/i,
26
30
  ];
27
31
  /**
28
32
  * Default patterns indicating transient errors that SHOULD be retried.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aspruyt/xfg",
3
- "version": "1.8.0",
3
+ "version": "1.10.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",
@@ -26,10 +26,11 @@
26
26
  "build": "tsc",
27
27
  "start": "node dist/index.js",
28
28
  "dev": "ts-node src/index.ts",
29
- "test": "node --import tsx --test src/config.test.ts src/merge.test.ts src/env.test.ts src/repo-detector.test.ts src/pr-creator.test.ts src/git-ops.test.ts src/logger.test.ts src/workspace-utils.test.ts src/strategies/pr-strategy.test.ts src/strategies/github-pr-strategy.test.ts src/strategies/azure-pr-strategy.test.ts src/strategies/gitlab-pr-strategy.test.ts src/repository-processor.test.ts src/retry-utils.test.ts src/command-executor.test.ts src/shell-utils.test.ts src/index.test.ts src/config-formatter.test.ts src/config-validator.test.ts src/config-normalizer.test.ts src/diff-utils.test.ts src/xfg-template.test.ts",
30
- "test:integration:github": "npm run build && node --import tsx --test src/integration-github.test.ts",
31
- "test:integration:ado": "npm run build && node --import tsx --test src/integration-ado.test.ts",
32
- "test:integration:gitlab": "npm run build && node --import tsx --test src/integration-gitlab.test.ts",
29
+ "test": "node --import tsx scripts/run-tests.js",
30
+ "test:coverage": "c8 --check-coverage --lines 95 --reporter=text --reporter=lcov --all --src=src --exclude='src/**/*.test.ts' --exclude='scripts/**' npm test",
31
+ "test:integration:github": "npm run build && node --import tsx --test test/integration/github.test.ts",
32
+ "test:integration:ado": "npm run build && node --import tsx --test test/integration/ado.test.ts",
33
+ "test:integration:gitlab": "npm run build && node --import tsx --test test/integration/gitlab.test.ts",
33
34
  "prepublishOnly": "npm run build"
34
35
  },
35
36
  "keywords": [
@@ -58,6 +59,7 @@
58
59
  },
59
60
  "devDependencies": {
60
61
  "@types/node": "^25.0.7",
62
+ "c8": "^10.1.3",
61
63
  "tsx": "^4.15.0",
62
64
  "typescript": "^5.4.5"
63
65
  }