@aspruyt/json-config-sync 2.1.1 → 3.0.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 +191 -118
- package/dist/config-normalizer.js +35 -21
- package/dist/config-validator.js +65 -25
- package/dist/config.d.ts +15 -7
- package/dist/index.js +37 -4
- package/dist/pr-creator.d.ts +13 -3
- package/dist/pr-creator.js +63 -12
- package/dist/repository-processor.d.ts +4 -1
- package/dist/repository-processor.js +60 -25
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# json-config-sync
|
|
2
2
|
|
|
3
|
-
[](https://github.com/anthony-spruyt/json-config-sync/actions/workflows/ci.yml)
|
|
4
|
-
[](https://github.com/anthony-spruyt/json-config-sync/actions/workflows/integration-test.yml)
|
|
3
|
+
[](https://github.com/anthony-spruyt/json-config-sync/actions/workflows/ci.yml)
|
|
5
4
|
[](https://www.npmjs.com/package/@aspruyt/json-config-sync)
|
|
6
5
|
[](https://www.npmjs.com/package/@aspruyt/json-config-sync)
|
|
7
6
|
|
|
@@ -36,14 +35,13 @@ gh auth login
|
|
|
36
35
|
|
|
37
36
|
# Create config.yaml
|
|
38
37
|
cat > config.yaml << 'EOF'
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
trailingComma: es5
|
|
38
|
+
files:
|
|
39
|
+
.prettierrc.json:
|
|
40
|
+
content:
|
|
41
|
+
semi: false
|
|
42
|
+
singleQuote: true
|
|
43
|
+
tabWidth: 2
|
|
44
|
+
trailingComma: es5
|
|
47
45
|
|
|
48
46
|
repos:
|
|
49
47
|
# Multiple repos can share the same config
|
|
@@ -61,6 +59,7 @@ json-config-sync --config ./config.yaml
|
|
|
61
59
|
|
|
62
60
|
## Features
|
|
63
61
|
|
|
62
|
+
- **Multi-File Sync** - Sync multiple config files in a single run
|
|
64
63
|
- **JSON/YAML Output** - Automatically outputs JSON or YAML based on filename extension
|
|
65
64
|
- **Content Inheritance** - Define base config once, override per-repo as needed
|
|
66
65
|
- **Multi-Repo Targeting** - Apply same config to multiple repos with array syntax
|
|
@@ -130,61 +129,84 @@ json-config-sync --config ./config.yaml --branch feature/update-eslint
|
|
|
130
129
|
|
|
131
130
|
### Options
|
|
132
131
|
|
|
133
|
-
| Option | Alias | Description
|
|
134
|
-
| ------------ | ----- |
|
|
135
|
-
| `--config` | `-c` | Path to YAML config file
|
|
136
|
-
| `--dry-run` | `-d` | Show what would be done without making changes
|
|
137
|
-
| `--work-dir` | `-w` | Temporary directory for cloning (default: `./tmp`)
|
|
138
|
-
| `--retries` | `-r` | Number of retries for network operations (default: 3)
|
|
139
|
-
| `--branch` | `-b` | Override branch name (default: `chore/sync-
|
|
132
|
+
| Option | Alias | Description | Required |
|
|
133
|
+
| ------------ | ----- | ----------------------------------------------------- | -------- |
|
|
134
|
+
| `--config` | `-c` | Path to YAML config file | Yes |
|
|
135
|
+
| `--dry-run` | `-d` | Show what would be done without making changes | No |
|
|
136
|
+
| `--work-dir` | `-w` | Temporary directory for cloning (default: `./tmp`) | No |
|
|
137
|
+
| `--retries` | `-r` | Number of retries for network operations (default: 3) | No |
|
|
138
|
+
| `--branch` | `-b` | Override branch name (default: `chore/sync-config`) | No |
|
|
140
139
|
|
|
141
140
|
## Configuration Format
|
|
142
141
|
|
|
143
142
|
### Basic Structure
|
|
144
143
|
|
|
145
144
|
```yaml
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
content: # Base config content
|
|
150
|
-
|
|
145
|
+
files:
|
|
146
|
+
my.config.json: # Target file (.json outputs JSON, .yaml/.yml outputs YAML)
|
|
147
|
+
mergeStrategy: replace # Array merge strategy for this file (optional)
|
|
148
|
+
content: # Base config content
|
|
149
|
+
key: value
|
|
151
150
|
|
|
152
151
|
repos: # List of repositories
|
|
153
152
|
- git: git@github.com:org/repo.git
|
|
154
|
-
|
|
155
|
-
|
|
153
|
+
files: # Per-repo file overrides (optional)
|
|
154
|
+
my.config.json:
|
|
155
|
+
content:
|
|
156
|
+
key: override
|
|
156
157
|
```
|
|
157
158
|
|
|
158
159
|
### Root-Level Fields
|
|
159
160
|
|
|
160
|
-
| Field
|
|
161
|
-
|
|
|
162
|
-
| `
|
|
163
|
-
| `
|
|
164
|
-
|
|
165
|
-
|
|
161
|
+
| Field | Description | Required |
|
|
162
|
+
| ------- | ---------------------------------- | -------- |
|
|
163
|
+
| `files` | Map of target filenames to configs | Yes |
|
|
164
|
+
| `repos` | Array of repository configurations | Yes |
|
|
165
|
+
|
|
166
|
+
### Per-File Fields
|
|
166
167
|
|
|
167
|
-
|
|
168
|
+
| Field | Description | Required |
|
|
169
|
+
| --------------- | ---------------------------------------------------- | -------- |
|
|
170
|
+
| `content` | Base config inherited by all repos | Yes |
|
|
171
|
+
| `mergeStrategy` | Array merge strategy: `replace`, `append`, `prepend` | No |
|
|
168
172
|
|
|
169
173
|
### Per-Repo Fields
|
|
170
174
|
|
|
171
|
-
| Field
|
|
172
|
-
|
|
|
173
|
-
| `git`
|
|
174
|
-
| `
|
|
175
|
-
|
|
175
|
+
| Field | Description | Required |
|
|
176
|
+
| ------- | ---------------------------------- | -------- |
|
|
177
|
+
| `git` | Git URL (string) or array of URLs | Yes |
|
|
178
|
+
| `files` | Per-repo file overrides (optional) | No |
|
|
179
|
+
|
|
180
|
+
### Per-Repo File Override Fields
|
|
181
|
+
|
|
182
|
+
| Field | Description | Required |
|
|
183
|
+
| ---------- | ------------------------------------------------------- | -------- |
|
|
184
|
+
| `content` | Content overlay merged onto file's base content | No |
|
|
185
|
+
| `override` | If `true`, ignore base content and use only this repo's | No |
|
|
186
|
+
|
|
187
|
+
**File Exclusion:** Set a file to `false` to exclude it from a specific repo:
|
|
176
188
|
|
|
177
|
-
|
|
189
|
+
```yaml
|
|
190
|
+
repos:
|
|
191
|
+
- git: git@github.com:org/repo.git
|
|
192
|
+
files:
|
|
193
|
+
eslint.json: false # This repo won't receive eslint.json
|
|
194
|
+
```
|
|
178
195
|
|
|
179
196
|
### Environment Variables
|
|
180
197
|
|
|
181
198
|
Use `${VAR}` syntax in string values:
|
|
182
199
|
|
|
183
200
|
```yaml
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
201
|
+
files:
|
|
202
|
+
app.config.json:
|
|
203
|
+
content:
|
|
204
|
+
apiUrl: ${API_URL} # Required - errors if not set
|
|
205
|
+
environment: ${ENV:-development} # With default value
|
|
206
|
+
secretKey: ${SECRET:?Secret required} # Required with custom error message
|
|
207
|
+
|
|
208
|
+
repos:
|
|
209
|
+
- git: git@github.com:org/backend.git
|
|
188
210
|
```
|
|
189
211
|
|
|
190
212
|
### Merge Directives
|
|
@@ -192,66 +214,106 @@ content:
|
|
|
192
214
|
Control array merging with the `$arrayMerge` directive:
|
|
193
215
|
|
|
194
216
|
```yaml
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
217
|
+
files:
|
|
218
|
+
config.json:
|
|
219
|
+
content:
|
|
220
|
+
features:
|
|
221
|
+
- core
|
|
222
|
+
- monitoring
|
|
199
223
|
|
|
200
224
|
repos:
|
|
201
225
|
- git: git@github.com:org/repo.git
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
226
|
+
files:
|
|
227
|
+
config.json:
|
|
228
|
+
content:
|
|
229
|
+
features:
|
|
230
|
+
$arrayMerge: append # append | prepend | replace
|
|
231
|
+
values:
|
|
232
|
+
- custom-feature # Results in: [core, monitoring, custom-feature]
|
|
207
233
|
```
|
|
208
234
|
|
|
209
235
|
## Examples
|
|
210
236
|
|
|
237
|
+
### Multi-File Sync
|
|
238
|
+
|
|
239
|
+
Sync multiple configuration files to all repos:
|
|
240
|
+
|
|
241
|
+
```yaml
|
|
242
|
+
files:
|
|
243
|
+
.eslintrc.json:
|
|
244
|
+
content:
|
|
245
|
+
extends: ["@org/eslint-config"]
|
|
246
|
+
rules:
|
|
247
|
+
no-console: warn
|
|
248
|
+
|
|
249
|
+
.prettierrc.yaml:
|
|
250
|
+
content:
|
|
251
|
+
semi: false
|
|
252
|
+
singleQuote: true
|
|
253
|
+
|
|
254
|
+
tsconfig.json:
|
|
255
|
+
content:
|
|
256
|
+
compilerOptions:
|
|
257
|
+
strict: true
|
|
258
|
+
target: ES2022
|
|
259
|
+
|
|
260
|
+
repos:
|
|
261
|
+
- git:
|
|
262
|
+
- git@github.com:org/frontend.git
|
|
263
|
+
- git@github.com:org/backend.git
|
|
264
|
+
- git@github.com:org/shared-lib.git
|
|
265
|
+
```
|
|
266
|
+
|
|
211
267
|
### Shared Config Across Teams
|
|
212
268
|
|
|
213
269
|
Define common settings once, customize per team:
|
|
214
270
|
|
|
215
271
|
```yaml
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
content:
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
272
|
+
files:
|
|
273
|
+
service.config.json:
|
|
274
|
+
content:
|
|
275
|
+
version: "2.0"
|
|
276
|
+
logging:
|
|
277
|
+
level: info
|
|
278
|
+
format: json
|
|
279
|
+
features:
|
|
280
|
+
- health-check
|
|
281
|
+
- metrics
|
|
226
282
|
|
|
227
283
|
repos:
|
|
228
284
|
# Platform team repos - add extra features
|
|
229
285
|
- git:
|
|
230
286
|
- git@github.com:org/api-gateway.git
|
|
231
287
|
- git@github.com:org/auth-service.git
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
288
|
+
files:
|
|
289
|
+
service.config.json:
|
|
290
|
+
content:
|
|
291
|
+
team: platform
|
|
292
|
+
features:
|
|
293
|
+
$arrayMerge: append
|
|
294
|
+
values:
|
|
295
|
+
- tracing
|
|
296
|
+
- rate-limiting
|
|
239
297
|
|
|
240
298
|
# Data team repos - different logging
|
|
241
299
|
- git:
|
|
242
300
|
- git@github.com:org/data-pipeline.git
|
|
243
301
|
- git@github.com:org/analytics.git
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
302
|
+
files:
|
|
303
|
+
service.config.json:
|
|
304
|
+
content:
|
|
305
|
+
team: data
|
|
306
|
+
logging:
|
|
307
|
+
level: debug
|
|
248
308
|
|
|
249
309
|
# Legacy service - completely different config
|
|
250
310
|
- git: git@github.com:org/legacy-api.git
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
311
|
+
files:
|
|
312
|
+
service.config.json:
|
|
313
|
+
override: true
|
|
314
|
+
content:
|
|
315
|
+
version: "1.0"
|
|
316
|
+
legacy: true
|
|
255
317
|
```
|
|
256
318
|
|
|
257
319
|
### Environment-Specific Values
|
|
@@ -259,40 +321,46 @@ repos:
|
|
|
259
321
|
Use environment variables for secrets and environment-specific values:
|
|
260
322
|
|
|
261
323
|
```yaml
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
content:
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
324
|
+
files:
|
|
325
|
+
app.config.json:
|
|
326
|
+
content:
|
|
327
|
+
database:
|
|
328
|
+
host: ${DB_HOST:-localhost}
|
|
329
|
+
port: ${DB_PORT:-5432}
|
|
330
|
+
password: ${DB_PASSWORD:?Database password required}
|
|
269
331
|
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
332
|
+
api:
|
|
333
|
+
baseUrl: ${API_BASE_URL}
|
|
334
|
+
timeout: 30000
|
|
273
335
|
|
|
274
336
|
repos:
|
|
275
337
|
- git: git@github.com:org/backend.git
|
|
276
338
|
```
|
|
277
339
|
|
|
278
|
-
###
|
|
340
|
+
### Per-File Merge Strategies
|
|
279
341
|
|
|
280
|
-
|
|
342
|
+
Different files can use different array merge strategies:
|
|
281
343
|
|
|
282
344
|
```yaml
|
|
283
|
-
|
|
345
|
+
files:
|
|
346
|
+
eslint.config.json:
|
|
347
|
+
mergeStrategy: append # Extends will append
|
|
348
|
+
content:
|
|
349
|
+
extends: ["@company/base"]
|
|
284
350
|
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
351
|
+
tsconfig.json:
|
|
352
|
+
mergeStrategy: replace # Lib will replace entirely
|
|
353
|
+
content:
|
|
354
|
+
compilerOptions:
|
|
355
|
+
lib: ["ES2022"]
|
|
289
356
|
|
|
290
357
|
repos:
|
|
291
|
-
- git:
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
358
|
+
- git: git@github.com:org/frontend.git
|
|
359
|
+
files:
|
|
360
|
+
eslint.config.json:
|
|
361
|
+
content:
|
|
362
|
+
extends: ["plugin:react/recommended"]
|
|
363
|
+
# Results in extends: ["@company/base", "plugin:react/recommended"]
|
|
296
364
|
```
|
|
297
365
|
|
|
298
366
|
## Supported Git URL Formats
|
|
@@ -312,17 +380,17 @@ repos:
|
|
|
312
380
|
```mermaid
|
|
313
381
|
flowchart TB
|
|
314
382
|
subgraph Input
|
|
315
|
-
YAML[/"YAML Config File<br/>
|
|
383
|
+
YAML[/"YAML Config File<br/>files{} + repos[]"/]
|
|
316
384
|
end
|
|
317
385
|
|
|
318
386
|
subgraph Normalization
|
|
319
|
-
EXPAND[Expand git arrays] --> MERGE[Merge base + overlay content]
|
|
387
|
+
EXPAND[Expand git arrays] --> MERGE[Merge base + overlay content<br/>for each file]
|
|
320
388
|
MERGE --> ENV[Interpolate env vars]
|
|
321
389
|
end
|
|
322
390
|
|
|
323
391
|
subgraph Processing["For Each Repository"]
|
|
324
|
-
CLONE[Clone Repo] --> BRANCH[Create/Checkout Branch<br/>--branch or chore/sync-
|
|
325
|
-
BRANCH --> WRITE[Write Config
|
|
392
|
+
CLONE[Clone Repo] --> BRANCH[Create/Checkout Branch<br/>--branch or chore/sync-config]
|
|
393
|
+
BRANCH --> WRITE[Write All Config Files<br/>JSON or YAML]
|
|
326
394
|
WRITE --> CHECK{Changes?}
|
|
327
395
|
CHECK -->|No| SKIP[Skip - No Changes]
|
|
328
396
|
CHECK -->|Yes| COMMIT[Commit Changes]
|
|
@@ -347,12 +415,12 @@ flowchart TB
|
|
|
347
415
|
For each repository in the config, the tool:
|
|
348
416
|
|
|
349
417
|
1. Expands git URL arrays into individual entries
|
|
350
|
-
2.
|
|
418
|
+
2. For each file, merges base content with per-repo overlay
|
|
351
419
|
3. Interpolates environment variables
|
|
352
420
|
4. Cleans the temporary workspace
|
|
353
421
|
5. Clones the repository
|
|
354
|
-
6. Creates/checks out branch (custom `--branch` or default `chore/sync-
|
|
355
|
-
7.
|
|
422
|
+
6. Creates/checks out branch (custom `--branch` or default `chore/sync-config`)
|
|
423
|
+
7. Writes all config files (JSON or YAML based on filename extension)
|
|
356
424
|
8. Checks for changes (skips if no changes)
|
|
357
425
|
9. Commits and pushes changes
|
|
358
426
|
10. Creates a pull request
|
|
@@ -417,22 +485,25 @@ steps:
|
|
|
417
485
|
```
|
|
418
486
|
[1/3] Processing example-org/repo1...
|
|
419
487
|
✓ Cloned repository
|
|
420
|
-
✓ Created branch chore/sync-
|
|
421
|
-
✓ Wrote
|
|
488
|
+
✓ Created branch chore/sync-config
|
|
489
|
+
✓ Wrote .eslintrc.json
|
|
490
|
+
✓ Wrote .prettierrc.yaml
|
|
422
491
|
✓ Committed changes
|
|
423
492
|
✓ Pushed to remote
|
|
424
493
|
✓ Created PR: https://github.com/example-org/repo1/pull/42
|
|
425
494
|
|
|
426
495
|
[2/3] Processing example-org/repo2...
|
|
427
496
|
✓ Cloned repository
|
|
428
|
-
✓ Checked out existing branch chore/sync-
|
|
429
|
-
✓ Wrote
|
|
497
|
+
✓ Checked out existing branch chore/sync-config
|
|
498
|
+
✓ Wrote .eslintrc.json
|
|
499
|
+
✓ Wrote .prettierrc.yaml
|
|
430
500
|
⊘ No changes detected, skipping
|
|
431
501
|
|
|
432
502
|
[3/3] Processing example-org/repo3...
|
|
433
503
|
✓ Cloned repository
|
|
434
|
-
✓ Created branch chore/sync-
|
|
435
|
-
✓ Wrote
|
|
504
|
+
✓ Created branch chore/sync-config
|
|
505
|
+
✓ Wrote .eslintrc.json
|
|
506
|
+
✓ Wrote .prettierrc.yaml
|
|
436
507
|
✓ Committed changes
|
|
437
508
|
✓ Pushed to remote
|
|
438
509
|
✓ PR already exists: https://github.com/example-org/repo3/pull/15
|
|
@@ -444,9 +515,9 @@ Summary: 2 succeeded, 1 skipped, 0 failed
|
|
|
444
515
|
|
|
445
516
|
The tool creates PRs with:
|
|
446
517
|
|
|
447
|
-
- **Title:** `chore: sync
|
|
448
|
-
- **Branch:** `chore/sync-
|
|
449
|
-
- **Body:** Describes the sync action and
|
|
518
|
+
- **Title:** `chore: sync config files` (or lists files if ≤3)
|
|
519
|
+
- **Branch:** `chore/sync-config` (or custom `--branch`)
|
|
520
|
+
- **Body:** Describes the sync action and lists changed files
|
|
450
521
|
|
|
451
522
|
## Troubleshooting
|
|
452
523
|
|
|
@@ -485,7 +556,7 @@ The tool automatically reuses existing branches. If you see unexpected behavior:
|
|
|
485
556
|
|
|
486
557
|
```bash
|
|
487
558
|
# Delete the remote branch to start fresh
|
|
488
|
-
git push origin --delete chore/sync-
|
|
559
|
+
git push origin --delete chore/sync-config
|
|
489
560
|
```
|
|
490
561
|
|
|
491
562
|
### Missing Environment Variables
|
|
@@ -535,9 +606,11 @@ For autocomplete and validation in VS Code, install the [YAML extension](https:/
|
|
|
535
606
|
|
|
536
607
|
```yaml
|
|
537
608
|
# yaml-language-server: $schema=https://raw.githubusercontent.com/anthony-spruyt/json-config-sync/main/config-schema.json
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
609
|
+
files:
|
|
610
|
+
my.config.json:
|
|
611
|
+
content:
|
|
612
|
+
key: value
|
|
613
|
+
|
|
541
614
|
repos:
|
|
542
615
|
- git: git@github.com:org/repo.git
|
|
543
616
|
```
|
|
@@ -557,7 +630,7 @@ repos:
|
|
|
557
630
|
|
|
558
631
|
This enables:
|
|
559
632
|
|
|
560
|
-
- Autocomplete for `
|
|
633
|
+
- Autocomplete for `files`, `repos`, `content`, `mergeStrategy`, `git`, `override`
|
|
561
634
|
- Enum suggestions for `mergeStrategy` values (`replace`, `append`, `prepend`)
|
|
562
635
|
- Validation of required fields
|
|
563
636
|
- Hover documentation for each field
|
|
@@ -5,39 +5,53 @@ import { interpolateEnvVars } from "./env.js";
|
|
|
5
5
|
* Pipeline: expand git arrays -> merge content -> interpolate env vars
|
|
6
6
|
*/
|
|
7
7
|
export function normalizeConfig(raw) {
|
|
8
|
-
const baseContent = raw.content ?? {};
|
|
9
|
-
const defaultStrategy = raw.mergeStrategy ?? "replace";
|
|
10
8
|
const expandedRepos = [];
|
|
9
|
+
const fileNames = Object.keys(raw.files);
|
|
11
10
|
for (const rawRepo of raw.repos) {
|
|
12
11
|
// Step 1: Expand git arrays
|
|
13
12
|
const gitUrls = Array.isArray(rawRepo.git) ? rawRepo.git : [rawRepo.git];
|
|
14
13
|
for (const gitUrl of gitUrls) {
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
14
|
+
const files = [];
|
|
15
|
+
// Step 2: Process each file definition
|
|
16
|
+
for (const fileName of fileNames) {
|
|
17
|
+
const repoOverride = rawRepo.files?.[fileName];
|
|
18
|
+
// Skip excluded files (set to false)
|
|
19
|
+
if (repoOverride === false) {
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
const fileConfig = raw.files[fileName];
|
|
23
|
+
const baseContent = fileConfig.content ?? {};
|
|
24
|
+
const fileStrategy = fileConfig.mergeStrategy ?? "replace";
|
|
25
|
+
// Step 3: Compute merged content for this file
|
|
26
|
+
let mergedContent;
|
|
27
|
+
if (repoOverride?.override) {
|
|
28
|
+
// Override mode: use only repo file content
|
|
29
|
+
mergedContent = stripMergeDirectives(structuredClone(repoOverride.content));
|
|
30
|
+
}
|
|
31
|
+
else if (!repoOverride?.content) {
|
|
32
|
+
// No repo override: use file base content as-is
|
|
33
|
+
mergedContent = structuredClone(baseContent);
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
// Merge mode: deep merge file base + repo overlay
|
|
37
|
+
const ctx = createMergeContext(fileStrategy);
|
|
38
|
+
mergedContent = deepMerge(structuredClone(baseContent), repoOverride.content, ctx);
|
|
39
|
+
mergedContent = stripMergeDirectives(mergedContent);
|
|
40
|
+
}
|
|
41
|
+
// Step 4: Interpolate env vars
|
|
42
|
+
mergedContent = interpolateEnvVars(mergedContent, { strict: true });
|
|
43
|
+
files.push({
|
|
44
|
+
fileName,
|
|
45
|
+
content: mergedContent,
|
|
46
|
+
});
|
|
20
47
|
}
|
|
21
|
-
else if (!rawRepo.content) {
|
|
22
|
-
// No repo content: use root content as-is
|
|
23
|
-
mergedContent = structuredClone(baseContent);
|
|
24
|
-
}
|
|
25
|
-
else {
|
|
26
|
-
// Merge mode: deep merge base + overlay
|
|
27
|
-
const ctx = createMergeContext(defaultStrategy);
|
|
28
|
-
mergedContent = deepMerge(structuredClone(baseContent), rawRepo.content, ctx);
|
|
29
|
-
mergedContent = stripMergeDirectives(mergedContent);
|
|
30
|
-
}
|
|
31
|
-
// Step 3: Interpolate env vars
|
|
32
|
-
mergedContent = interpolateEnvVars(mergedContent, { strict: true });
|
|
33
48
|
expandedRepos.push({
|
|
34
49
|
git: gitUrl,
|
|
35
|
-
|
|
50
|
+
files,
|
|
36
51
|
});
|
|
37
52
|
}
|
|
38
53
|
}
|
|
39
54
|
return {
|
|
40
|
-
fileName: raw.fileName,
|
|
41
55
|
repos: expandedRepos,
|
|
42
56
|
};
|
|
43
57
|
}
|
package/dist/config-validator.js
CHANGED
|
@@ -1,35 +1,39 @@
|
|
|
1
1
|
import { isAbsolute } from "node:path";
|
|
2
|
+
const VALID_STRATEGIES = ["replace", "append", "prepend"];
|
|
2
3
|
/**
|
|
3
4
|
* Validates raw config structure before normalization.
|
|
4
5
|
* @throws Error if validation fails
|
|
5
6
|
*/
|
|
6
7
|
export function validateRawConfig(config) {
|
|
7
|
-
if (!config.
|
|
8
|
-
throw new Error("Config missing required field:
|
|
8
|
+
if (!config.files || typeof config.files !== "object") {
|
|
9
|
+
throw new Error("Config missing required field: files (must be an object)");
|
|
9
10
|
}
|
|
10
|
-
|
|
11
|
-
if (
|
|
12
|
-
throw new Error(
|
|
11
|
+
const fileNames = Object.keys(config.files);
|
|
12
|
+
if (fileNames.length === 0) {
|
|
13
|
+
throw new Error("Config files object cannot be empty");
|
|
13
14
|
}
|
|
14
|
-
// Validate
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
// Validate each file definition
|
|
16
|
+
for (const fileName of fileNames) {
|
|
17
|
+
validateFileName(fileName);
|
|
18
|
+
const fileConfig = config.files[fileName];
|
|
19
|
+
if (!fileConfig || typeof fileConfig !== "object") {
|
|
20
|
+
throw new Error(`File '${fileName}' must have a configuration object`);
|
|
21
|
+
}
|
|
22
|
+
if (fileConfig.content !== undefined &&
|
|
23
|
+
(typeof fileConfig.content !== "object" ||
|
|
24
|
+
fileConfig.content === null ||
|
|
25
|
+
Array.isArray(fileConfig.content))) {
|
|
26
|
+
throw new Error(`File '${fileName}' content must be an object`);
|
|
27
|
+
}
|
|
28
|
+
if (fileConfig.mergeStrategy !== undefined &&
|
|
29
|
+
!VALID_STRATEGIES.includes(fileConfig.mergeStrategy)) {
|
|
30
|
+
throw new Error(`File '${fileName}' has invalid mergeStrategy: ${fileConfig.mergeStrategy}. Must be one of: ${VALID_STRATEGIES.join(", ")}`);
|
|
31
|
+
}
|
|
17
32
|
}
|
|
18
33
|
if (!config.repos || !Array.isArray(config.repos)) {
|
|
19
34
|
throw new Error("Config missing required field: repos (must be an array)");
|
|
20
35
|
}
|
|
21
|
-
|
|
22
|
-
if (config.mergeStrategy !== undefined &&
|
|
23
|
-
!validStrategies.includes(config.mergeStrategy)) {
|
|
24
|
-
throw new Error(`Invalid mergeStrategy: ${config.mergeStrategy}. Must be one of: ${validStrategies.join(", ")}`);
|
|
25
|
-
}
|
|
26
|
-
if (config.content !== undefined &&
|
|
27
|
-
(typeof config.content !== "object" ||
|
|
28
|
-
config.content === null ||
|
|
29
|
-
Array.isArray(config.content))) {
|
|
30
|
-
throw new Error("Root content must be an object");
|
|
31
|
-
}
|
|
32
|
-
const hasRootContent = config.content !== undefined;
|
|
36
|
+
// Validate each repo
|
|
33
37
|
for (let i = 0; i < config.repos.length; i++) {
|
|
34
38
|
const repo = config.repos[i];
|
|
35
39
|
if (!repo.git) {
|
|
@@ -38,14 +42,50 @@ export function validateRawConfig(config) {
|
|
|
38
42
|
if (Array.isArray(repo.git) && repo.git.length === 0) {
|
|
39
43
|
throw new Error(`Repo at index ${i} has empty git array`);
|
|
40
44
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
45
|
+
// Validate per-repo file overrides
|
|
46
|
+
if (repo.files) {
|
|
47
|
+
if (typeof repo.files !== "object" || Array.isArray(repo.files)) {
|
|
48
|
+
throw new Error(`Repo at index ${i}: files must be an object`);
|
|
49
|
+
}
|
|
50
|
+
for (const fileName of Object.keys(repo.files)) {
|
|
51
|
+
// Ensure the file is defined at root level
|
|
52
|
+
if (!config.files[fileName]) {
|
|
53
|
+
throw new Error(`Repo at index ${i} references undefined file '${fileName}'. File must be defined in root 'files' object.`);
|
|
54
|
+
}
|
|
55
|
+
const fileOverride = repo.files[fileName];
|
|
56
|
+
// false means exclude this file for this repo - no further validation needed
|
|
57
|
+
if (fileOverride === false) {
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (fileOverride.override && !fileOverride.content) {
|
|
61
|
+
throw new Error(`Repo ${getGitDisplayName(repo.git)} has override: true for file '${fileName}' but no content defined`);
|
|
62
|
+
}
|
|
63
|
+
if (fileOverride.content !== undefined &&
|
|
64
|
+
(typeof fileOverride.content !== "object" ||
|
|
65
|
+
fileOverride.content === null ||
|
|
66
|
+
Array.isArray(fileOverride.content))) {
|
|
67
|
+
throw new Error(`Repo at index ${i}: file '${fileName}' content must be an object`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
46
70
|
}
|
|
47
71
|
}
|
|
48
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* Validates a file name for security issues
|
|
75
|
+
*/
|
|
76
|
+
function validateFileName(fileName) {
|
|
77
|
+
if (!fileName || typeof fileName !== "string") {
|
|
78
|
+
throw new Error("File name must be a non-empty string");
|
|
79
|
+
}
|
|
80
|
+
// Validate fileName doesn't allow path traversal
|
|
81
|
+
if (fileName.includes("..") || isAbsolute(fileName)) {
|
|
82
|
+
throw new Error(`Invalid fileName '${fileName}': must be a relative path without '..' components`);
|
|
83
|
+
}
|
|
84
|
+
// Validate fileName doesn't contain control characters that could bypass shell escaping
|
|
85
|
+
if (/[\n\r\0]/.test(fileName)) {
|
|
86
|
+
throw new Error(`Invalid fileName '${fileName}': cannot contain newlines or null bytes`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
49
89
|
function getGitDisplayName(git) {
|
|
50
90
|
if (Array.isArray(git)) {
|
|
51
91
|
return git[0] || "unknown";
|
package/dist/config.d.ts
CHANGED
|
@@ -1,22 +1,30 @@
|
|
|
1
1
|
import type { ArrayMergeStrategy } from "./merge.js";
|
|
2
2
|
export { convertContentToString } from "./config-formatter.js";
|
|
3
|
-
export interface
|
|
4
|
-
|
|
3
|
+
export interface RawFileConfig {
|
|
4
|
+
content: Record<string, unknown>;
|
|
5
|
+
mergeStrategy?: ArrayMergeStrategy;
|
|
6
|
+
}
|
|
7
|
+
export interface RawRepoFileOverride {
|
|
5
8
|
content?: Record<string, unknown>;
|
|
6
9
|
override?: boolean;
|
|
7
10
|
}
|
|
11
|
+
export interface RawRepoConfig {
|
|
12
|
+
git: string | string[];
|
|
13
|
+
files?: Record<string, RawRepoFileOverride | false>;
|
|
14
|
+
}
|
|
8
15
|
export interface RawConfig {
|
|
9
|
-
|
|
10
|
-
content?: Record<string, unknown>;
|
|
11
|
-
mergeStrategy?: ArrayMergeStrategy;
|
|
16
|
+
files: Record<string, RawFileConfig>;
|
|
12
17
|
repos: RawRepoConfig[];
|
|
13
18
|
}
|
|
19
|
+
export interface FileContent {
|
|
20
|
+
fileName: string;
|
|
21
|
+
content: Record<string, unknown>;
|
|
22
|
+
}
|
|
14
23
|
export interface RepoConfig {
|
|
15
24
|
git: string;
|
|
16
|
-
|
|
25
|
+
files: FileContent[];
|
|
17
26
|
}
|
|
18
27
|
export interface Config {
|
|
19
|
-
fileName: string;
|
|
20
28
|
repos: RepoConfig[];
|
|
21
29
|
}
|
|
22
30
|
export declare function loadConfig(filePath: string): Config;
|
package/dist/index.js
CHANGED
|
@@ -25,9 +25,42 @@ program
|
|
|
25
25
|
.option("-d, --dry-run", "Show what would be done without making changes")
|
|
26
26
|
.option("-w, --work-dir <path>", "Temporary directory for cloning", "./tmp")
|
|
27
27
|
.option("-r, --retries <number>", "Number of retries for network operations (0 to disable)", (v) => parseInt(v, 10), 3)
|
|
28
|
-
.option("-b, --branch <name>", "Override the branch name (default: chore/sync-{filename})")
|
|
28
|
+
.option("-b, --branch <name>", "Override the branch name (default: chore/sync-{filename} or chore/sync-config)")
|
|
29
29
|
.parse();
|
|
30
30
|
const options = program.opts();
|
|
31
|
+
/**
|
|
32
|
+
* Get unique file names from all repos in the config
|
|
33
|
+
*/
|
|
34
|
+
function getUniqueFileNames(config) {
|
|
35
|
+
const fileNames = new Set();
|
|
36
|
+
for (const repo of config.repos) {
|
|
37
|
+
for (const file of repo.files) {
|
|
38
|
+
fileNames.add(file.fileName);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return Array.from(fileNames);
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Generate default branch name based on files being synced
|
|
45
|
+
*/
|
|
46
|
+
function generateBranchName(fileNames) {
|
|
47
|
+
if (fileNames.length === 1) {
|
|
48
|
+
return `chore/sync-${sanitizeBranchName(fileNames[0])}`;
|
|
49
|
+
}
|
|
50
|
+
return "chore/sync-config";
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Format file names for display
|
|
54
|
+
*/
|
|
55
|
+
function formatFileNames(fileNames) {
|
|
56
|
+
if (fileNames.length === 1) {
|
|
57
|
+
return fileNames[0];
|
|
58
|
+
}
|
|
59
|
+
if (fileNames.length <= 3) {
|
|
60
|
+
return fileNames.join(", ");
|
|
61
|
+
}
|
|
62
|
+
return `${fileNames.length} files`;
|
|
63
|
+
}
|
|
31
64
|
async function main() {
|
|
32
65
|
const configPath = resolve(options.config);
|
|
33
66
|
if (!existsSync(configPath)) {
|
|
@@ -39,17 +72,18 @@ async function main() {
|
|
|
39
72
|
console.log("Running in DRY RUN mode - no changes will be made\n");
|
|
40
73
|
}
|
|
41
74
|
const config = loadConfig(configPath);
|
|
75
|
+
const fileNames = getUniqueFileNames(config);
|
|
42
76
|
let branchName;
|
|
43
77
|
if (options.branch) {
|
|
44
78
|
validateBranchName(options.branch);
|
|
45
79
|
branchName = options.branch;
|
|
46
80
|
}
|
|
47
81
|
else {
|
|
48
|
-
branchName =
|
|
82
|
+
branchName = generateBranchName(fileNames);
|
|
49
83
|
}
|
|
50
84
|
logger.setTotal(config.repos.length);
|
|
51
85
|
console.log(`Found ${config.repos.length} repositories to process`);
|
|
52
|
-
console.log(`Target
|
|
86
|
+
console.log(`Target files: ${formatFileNames(fileNames)}`);
|
|
53
87
|
console.log(`Branch: ${branchName}\n`);
|
|
54
88
|
const processor = defaultProcessorFactory();
|
|
55
89
|
for (let i = 0; i < config.repos.length; i++) {
|
|
@@ -69,7 +103,6 @@ async function main() {
|
|
|
69
103
|
try {
|
|
70
104
|
logger.progress(current, repoName, "Processing...");
|
|
71
105
|
const result = await processor.process(repoConfig, repoInfo, {
|
|
72
|
-
fileName: config.fileName,
|
|
73
106
|
branchName,
|
|
74
107
|
workDir,
|
|
75
108
|
dryRun: options.dryRun,
|
package/dist/pr-creator.d.ts
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
import { RepoInfo } from "./repo-detector.js";
|
|
2
2
|
export { escapeShellArg } from "./shell-utils.js";
|
|
3
|
+
export interface FileAction {
|
|
4
|
+
fileName: string;
|
|
5
|
+
action: "create" | "update";
|
|
6
|
+
}
|
|
3
7
|
export interface PROptions {
|
|
4
8
|
repoInfo: RepoInfo;
|
|
5
9
|
branchName: string;
|
|
6
10
|
baseBranch: string;
|
|
7
|
-
|
|
8
|
-
action: "create" | "update";
|
|
11
|
+
files: FileAction[];
|
|
9
12
|
workDir: string;
|
|
10
13
|
dryRun?: boolean;
|
|
11
14
|
/** Number of retries for API operations (default: 3) */
|
|
@@ -16,5 +19,12 @@ export interface PRResult {
|
|
|
16
19
|
success: boolean;
|
|
17
20
|
message: string;
|
|
18
21
|
}
|
|
19
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Format PR body for multiple files
|
|
24
|
+
*/
|
|
25
|
+
export declare function formatPRBody(files: FileAction[]): string;
|
|
26
|
+
/**
|
|
27
|
+
* Generate PR title based on files changed
|
|
28
|
+
*/
|
|
29
|
+
export declare function formatPRTitle(files: FileAction[]): string;
|
|
20
30
|
export declare function createPR(options: PROptions): Promise<PRResult>;
|
package/dist/pr-creator.js
CHANGED
|
@@ -12,27 +12,78 @@ function loadPRTemplate() {
|
|
|
12
12
|
if (existsSync(templatePath)) {
|
|
13
13
|
return readFileSync(templatePath, "utf-8");
|
|
14
14
|
}
|
|
15
|
-
// Fallback template
|
|
15
|
+
// Fallback template for multi-file support
|
|
16
16
|
return `## Summary
|
|
17
|
-
Automated sync of
|
|
17
|
+
Automated sync of configuration files.
|
|
18
18
|
|
|
19
19
|
## Changes
|
|
20
|
-
|
|
20
|
+
{{FILE_CHANGES}}
|
|
21
|
+
|
|
22
|
+
## Source
|
|
23
|
+
Configuration synced using [json-config-sync](https://github.com/anthony-spruyt/json-config-sync).
|
|
21
24
|
|
|
22
25
|
---
|
|
23
|
-
|
|
26
|
+
_This PR was automatically generated by [json-config-sync](https://github.com/anthony-spruyt/json-config-sync)_`;
|
|
24
27
|
}
|
|
25
|
-
|
|
28
|
+
/**
|
|
29
|
+
* Format PR body for multiple files
|
|
30
|
+
*/
|
|
31
|
+
export function formatPRBody(files) {
|
|
26
32
|
const template = loadPRTemplate();
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
33
|
+
// Check if template supports multi-file format
|
|
34
|
+
if (template.includes("{{FILE_CHANGES}}")) {
|
|
35
|
+
// Multi-file template
|
|
36
|
+
const fileChanges = files
|
|
37
|
+
.map((f) => {
|
|
38
|
+
const actionText = f.action === "create" ? "Created" : "Updated";
|
|
39
|
+
return `- ${actionText} \`${f.fileName}\``;
|
|
40
|
+
})
|
|
41
|
+
.join("\n");
|
|
42
|
+
return template.replace(/\{\{FILE_CHANGES\}\}/g, fileChanges);
|
|
43
|
+
}
|
|
44
|
+
// Legacy single-file template - adapt it for multiple files
|
|
45
|
+
if (files.length === 1) {
|
|
46
|
+
const actionText = files[0].action === "create" ? "Created" : "Updated";
|
|
47
|
+
return template
|
|
48
|
+
.replace(/\{\{FILE_NAME\}\}/g, files[0].fileName)
|
|
49
|
+
.replace(/\{\{ACTION\}\}/g, actionText);
|
|
50
|
+
}
|
|
51
|
+
// Multiple files with legacy template - generate custom body
|
|
52
|
+
const fileChanges = files
|
|
53
|
+
.map((f) => {
|
|
54
|
+
const actionText = f.action === "create" ? "Created" : "Updated";
|
|
55
|
+
return `- ${actionText} \`${f.fileName}\``;
|
|
56
|
+
})
|
|
57
|
+
.join("\n");
|
|
58
|
+
return `## Summary
|
|
59
|
+
Automated sync of configuration files.
|
|
60
|
+
|
|
61
|
+
## Changes
|
|
62
|
+
${fileChanges}
|
|
63
|
+
|
|
64
|
+
## Source
|
|
65
|
+
Configuration synced using [json-config-sync](https://github.com/anthony-spruyt/json-config-sync).
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
_This PR was automatically generated by [json-config-sync](https://github.com/anthony-spruyt/json-config-sync)_`;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Generate PR title based on files changed
|
|
72
|
+
*/
|
|
73
|
+
export function formatPRTitle(files) {
|
|
74
|
+
if (files.length === 1) {
|
|
75
|
+
return `chore: sync ${files[0].fileName}`;
|
|
76
|
+
}
|
|
77
|
+
if (files.length <= 3) {
|
|
78
|
+
const fileNames = files.map((f) => f.fileName).join(", ");
|
|
79
|
+
return `chore: sync ${fileNames}`;
|
|
80
|
+
}
|
|
81
|
+
return `chore: sync ${files.length} config files`;
|
|
31
82
|
}
|
|
32
83
|
export async function createPR(options) {
|
|
33
|
-
const { repoInfo, branchName, baseBranch,
|
|
34
|
-
const title =
|
|
35
|
-
const body = formatPRBody(
|
|
84
|
+
const { repoInfo, branchName, baseBranch, files, workDir, dryRun, retries } = options;
|
|
85
|
+
const title = formatPRTitle(files);
|
|
86
|
+
const body = formatPRBody(files);
|
|
36
87
|
if (dryRun) {
|
|
37
88
|
return {
|
|
38
89
|
success: true,
|
|
@@ -3,7 +3,6 @@ import { RepoInfo } from "./repo-detector.js";
|
|
|
3
3
|
import { GitOps, GitOpsOptions } from "./git-ops.js";
|
|
4
4
|
import { ILogger } from "./logger.js";
|
|
5
5
|
export interface ProcessorOptions {
|
|
6
|
-
fileName: string;
|
|
7
6
|
branchName: string;
|
|
8
7
|
workDir: string;
|
|
9
8
|
dryRun?: boolean;
|
|
@@ -33,4 +32,8 @@ export declare class RepositoryProcessor {
|
|
|
33
32
|
*/
|
|
34
33
|
constructor(gitOpsFactory?: GitOpsFactory, log?: ILogger);
|
|
35
34
|
process(repoConfig: RepoConfig, repoInfo: RepoInfo, options: ProcessorOptions): Promise<ProcessorResult>;
|
|
35
|
+
/**
|
|
36
|
+
* Format commit message based on files changed
|
|
37
|
+
*/
|
|
38
|
+
private formatCommitMessage;
|
|
36
39
|
}
|
|
@@ -20,7 +20,7 @@ export class RepositoryProcessor {
|
|
|
20
20
|
}
|
|
21
21
|
async process(repoConfig, repoInfo, options) {
|
|
22
22
|
const repoName = getRepoDisplayName(repoInfo);
|
|
23
|
-
const {
|
|
23
|
+
const { branchName, workDir, dryRun, retries } = options;
|
|
24
24
|
this.gitOps = this.gitOpsFactory({ workDir, dryRun, retries });
|
|
25
25
|
try {
|
|
26
26
|
// Step 1: Clean workspace
|
|
@@ -35,30 +35,52 @@ export class RepositoryProcessor {
|
|
|
35
35
|
// Step 4: Create/checkout branch
|
|
36
36
|
this.log.info(`Switching to branch: ${branchName}`);
|
|
37
37
|
await this.gitOps.createBranch(branchName);
|
|
38
|
-
// Step 5: Write config
|
|
39
|
-
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
38
|
+
// Step 5: Write all config files and track changes
|
|
39
|
+
const changedFiles = [];
|
|
40
|
+
for (const file of repoConfig.files) {
|
|
41
|
+
this.log.info(`Writing ${file.fileName}...`);
|
|
42
|
+
const fileContent = convertContentToString(file.content, file.fileName);
|
|
43
|
+
const filePath = join(workDir, file.fileName);
|
|
44
|
+
// Determine action type (create vs update)
|
|
45
|
+
const action = existsSync(filePath)
|
|
46
|
+
? "update"
|
|
47
|
+
: "create";
|
|
48
|
+
if (dryRun) {
|
|
49
|
+
// In dry-run, check if file would change without writing
|
|
50
|
+
if (this.gitOps.wouldChange(file.fileName, fileContent)) {
|
|
51
|
+
changedFiles.push({ fileName: file.fileName, action });
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
// Write the file
|
|
56
|
+
this.gitOps.writeFile(file.fileName, fileContent);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// Step 6: Check for changes
|
|
60
|
+
let hasChanges;
|
|
51
61
|
if (dryRun) {
|
|
52
|
-
|
|
53
|
-
wouldHaveChanges = this.gitOps.wouldChange(fileName, fileContent);
|
|
62
|
+
hasChanges = changedFiles.length > 0;
|
|
54
63
|
}
|
|
55
64
|
else {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
65
|
+
hasChanges = await this.gitOps.hasChanges();
|
|
66
|
+
// If there are changes, determine which files changed
|
|
67
|
+
if (hasChanges) {
|
|
68
|
+
// Rebuild the changed files list by checking git status
|
|
69
|
+
// For simplicity, we include all files with their detected actions
|
|
70
|
+
for (const file of repoConfig.files) {
|
|
71
|
+
const filePath = join(workDir, file.fileName);
|
|
72
|
+
// We check if file existed before writing (action was determined above)
|
|
73
|
+
// Since we don't have pre-write state, we'll mark all files that are in the commit
|
|
74
|
+
// A more accurate approach would track this before writing, but for now
|
|
75
|
+
// we'll assume all files are being synced and include them all
|
|
76
|
+
const action = existsSync(filePath)
|
|
77
|
+
? "update"
|
|
78
|
+
: "create";
|
|
79
|
+
changedFiles.push({ fileName: file.fileName, action });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
60
82
|
}
|
|
61
|
-
if (!
|
|
83
|
+
if (!hasChanges) {
|
|
62
84
|
return {
|
|
63
85
|
success: true,
|
|
64
86
|
repoName,
|
|
@@ -68,7 +90,8 @@ export class RepositoryProcessor {
|
|
|
68
90
|
}
|
|
69
91
|
// Step 7: Commit
|
|
70
92
|
this.log.info("Committing changes...");
|
|
71
|
-
|
|
93
|
+
const commitMessage = this.formatCommitMessage(changedFiles);
|
|
94
|
+
await this.gitOps.commit(commitMessage);
|
|
72
95
|
// Step 8: Push
|
|
73
96
|
this.log.info("Pushing to remote...");
|
|
74
97
|
await this.gitOps.push(branchName);
|
|
@@ -78,8 +101,7 @@ export class RepositoryProcessor {
|
|
|
78
101
|
repoInfo,
|
|
79
102
|
branchName,
|
|
80
103
|
baseBranch,
|
|
81
|
-
|
|
82
|
-
action,
|
|
104
|
+
files: changedFiles,
|
|
83
105
|
workDir,
|
|
84
106
|
dryRun,
|
|
85
107
|
retries,
|
|
@@ -92,7 +114,7 @@ export class RepositoryProcessor {
|
|
|
92
114
|
};
|
|
93
115
|
}
|
|
94
116
|
finally {
|
|
95
|
-
// Always cleanup workspace on completion or failure
|
|
117
|
+
// Always cleanup workspace on completion or failure
|
|
96
118
|
if (this.gitOps) {
|
|
97
119
|
try {
|
|
98
120
|
this.gitOps.cleanWorkspace();
|
|
@@ -103,4 +125,17 @@ export class RepositoryProcessor {
|
|
|
103
125
|
}
|
|
104
126
|
}
|
|
105
127
|
}
|
|
128
|
+
/**
|
|
129
|
+
* Format commit message based on files changed
|
|
130
|
+
*/
|
|
131
|
+
formatCommitMessage(files) {
|
|
132
|
+
if (files.length === 1) {
|
|
133
|
+
return `chore: sync ${files[0].fileName}`;
|
|
134
|
+
}
|
|
135
|
+
if (files.length <= 3) {
|
|
136
|
+
const fileNames = files.map((f) => f.fileName).join(", ");
|
|
137
|
+
return `chore: sync ${fileNames}`;
|
|
138
|
+
}
|
|
139
|
+
return `chore: sync ${files.length} config files`;
|
|
140
|
+
}
|
|
106
141
|
}
|
package/package.json
CHANGED