@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 +35 -6
- package/dist/config-normalizer.js +6 -0
- package/dist/config-validator.js +13 -0
- package/dist/config.d.ts +5 -0
- package/dist/diff-utils.d.ts +2 -1
- package/dist/diff-utils.js +6 -1
- package/dist/git-ops.d.ts +11 -0
- package/dist/git-ops.js +23 -0
- package/dist/index.js +2 -0
- package/dist/logger.d.ts +2 -2
- package/dist/logger.js +3 -1
- package/dist/manifest.d.ts +46 -0
- package/dist/manifest.js +105 -0
- package/dist/pr-creator.d.ts +6 -1
- package/dist/pr-creator.js +18 -5
- package/dist/repository-processor.d.ts +2 -0
- package/dist/repository-processor.js +67 -1
- package/dist/retry-utils.js +4 -0
- package/package.json +7 -5
package/README.md
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
# xfg
|
|
2
2
|
|
|
3
3
|
[](https://github.com/anthony-spruyt/xfg/actions/workflows/ci.yaml)
|
|
4
|
+
[](https://codecov.io/gh/anthony-spruyt/xfg)
|
|
4
5
|
[](https://www.npmjs.com/package/@aspruyt/xfg)
|
|
5
6
|
[](https://www.npmjs.com/package/@aspruyt/xfg)
|
|
7
|
+
[](https://github.com/marketplace/actions/xfg-config-file-sync)
|
|
8
|
+
[](https://anthony-spruyt.github.io/xfg/)
|
|
6
9
|
[](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
|
-
#
|
|
22
|
-
|
|
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
|
}
|
package/dist/config-validator.js
CHANGED
|
@@ -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;
|
package/dist/diff-utils.d.ts
CHANGED
|
@@ -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.
|
package/dist/diff-utils.js
CHANGED
|
@@ -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
|
+
};
|
package/dist/manifest.js
ADDED
|
@@ -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
|
+
}
|
package/dist/pr-creator.d.ts
CHANGED
|
@@ -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>;
|
package/dist/pr-creator.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/retry-utils.js
CHANGED
|
@@ -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.
|
|
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
|
|
30
|
-
"test:
|
|
31
|
-
"test:integration:
|
|
32
|
-
"test:integration:
|
|
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
|
}
|