@aspruyt/json-config-sync 2.1.0 → 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 -117
- 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,6 +1,6 @@
|
|
|
1
1
|
# json-config-sync
|
|
2
2
|
|
|
3
|
-
[](https://github.com/anthony-spruyt/json-config-sync/actions/workflows/ci.yml)
|
|
3
|
+
[](https://github.com/anthony-spruyt/json-config-sync/actions/workflows/ci.yml)
|
|
4
4
|
[](https://www.npmjs.com/package/@aspruyt/json-config-sync)
|
|
5
5
|
[](https://www.npmjs.com/package/@aspruyt/json-config-sync)
|
|
6
6
|
|
|
@@ -35,14 +35,13 @@ gh auth login
|
|
|
35
35
|
|
|
36
36
|
# Create config.yaml
|
|
37
37
|
cat > config.yaml << 'EOF'
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
trailingComma: es5
|
|
38
|
+
files:
|
|
39
|
+
.prettierrc.json:
|
|
40
|
+
content:
|
|
41
|
+
semi: false
|
|
42
|
+
singleQuote: true
|
|
43
|
+
tabWidth: 2
|
|
44
|
+
trailingComma: es5
|
|
46
45
|
|
|
47
46
|
repos:
|
|
48
47
|
# Multiple repos can share the same config
|
|
@@ -60,6 +59,7 @@ json-config-sync --config ./config.yaml
|
|
|
60
59
|
|
|
61
60
|
## Features
|
|
62
61
|
|
|
62
|
+
- **Multi-File Sync** - Sync multiple config files in a single run
|
|
63
63
|
- **JSON/YAML Output** - Automatically outputs JSON or YAML based on filename extension
|
|
64
64
|
- **Content Inheritance** - Define base config once, override per-repo as needed
|
|
65
65
|
- **Multi-Repo Targeting** - Apply same config to multiple repos with array syntax
|
|
@@ -129,61 +129,84 @@ json-config-sync --config ./config.yaml --branch feature/update-eslint
|
|
|
129
129
|
|
|
130
130
|
### Options
|
|
131
131
|
|
|
132
|
-
| Option | Alias | Description
|
|
133
|
-
| ------------ | ----- |
|
|
134
|
-
| `--config` | `-c` | Path to YAML config file
|
|
135
|
-
| `--dry-run` | `-d` | Show what would be done without making changes
|
|
136
|
-
| `--work-dir` | `-w` | Temporary directory for cloning (default: `./tmp`)
|
|
137
|
-
| `--retries` | `-r` | Number of retries for network operations (default: 3)
|
|
138
|
-
| `--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 |
|
|
139
139
|
|
|
140
140
|
## Configuration Format
|
|
141
141
|
|
|
142
142
|
### Basic Structure
|
|
143
143
|
|
|
144
144
|
```yaml
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
content: # Base config content
|
|
149
|
-
|
|
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
|
|
150
150
|
|
|
151
151
|
repos: # List of repositories
|
|
152
152
|
- git: git@github.com:org/repo.git
|
|
153
|
-
|
|
154
|
-
|
|
153
|
+
files: # Per-repo file overrides (optional)
|
|
154
|
+
my.config.json:
|
|
155
|
+
content:
|
|
156
|
+
key: override
|
|
155
157
|
```
|
|
156
158
|
|
|
157
159
|
### Root-Level Fields
|
|
158
160
|
|
|
159
|
-
| Field
|
|
160
|
-
|
|
|
161
|
-
| `
|
|
162
|
-
| `
|
|
163
|
-
|
|
164
|
-
|
|
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
|
|
165
167
|
|
|
166
|
-
|
|
168
|
+
| Field | Description | Required |
|
|
169
|
+
| --------------- | ---------------------------------------------------- | -------- |
|
|
170
|
+
| `content` | Base config inherited by all repos | Yes |
|
|
171
|
+
| `mergeStrategy` | Array merge strategy: `replace`, `append`, `prepend` | No |
|
|
167
172
|
|
|
168
173
|
### Per-Repo Fields
|
|
169
174
|
|
|
170
|
-
| Field
|
|
171
|
-
|
|
|
172
|
-
| `git`
|
|
173
|
-
| `
|
|
174
|
-
|
|
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:
|
|
175
188
|
|
|
176
|
-
|
|
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
|
+
```
|
|
177
195
|
|
|
178
196
|
### Environment Variables
|
|
179
197
|
|
|
180
198
|
Use `${VAR}` syntax in string values:
|
|
181
199
|
|
|
182
200
|
```yaml
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
|
187
210
|
```
|
|
188
211
|
|
|
189
212
|
### Merge Directives
|
|
@@ -191,66 +214,106 @@ content:
|
|
|
191
214
|
Control array merging with the `$arrayMerge` directive:
|
|
192
215
|
|
|
193
216
|
```yaml
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
217
|
+
files:
|
|
218
|
+
config.json:
|
|
219
|
+
content:
|
|
220
|
+
features:
|
|
221
|
+
- core
|
|
222
|
+
- monitoring
|
|
198
223
|
|
|
199
224
|
repos:
|
|
200
225
|
- git: git@github.com:org/repo.git
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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]
|
|
206
233
|
```
|
|
207
234
|
|
|
208
235
|
## Examples
|
|
209
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
|
+
|
|
210
267
|
### Shared Config Across Teams
|
|
211
268
|
|
|
212
269
|
Define common settings once, customize per team:
|
|
213
270
|
|
|
214
271
|
```yaml
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
content:
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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
|
|
225
282
|
|
|
226
283
|
repos:
|
|
227
284
|
# Platform team repos - add extra features
|
|
228
285
|
- git:
|
|
229
286
|
- git@github.com:org/api-gateway.git
|
|
230
287
|
- git@github.com:org/auth-service.git
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
288
|
+
files:
|
|
289
|
+
service.config.json:
|
|
290
|
+
content:
|
|
291
|
+
team: platform
|
|
292
|
+
features:
|
|
293
|
+
$arrayMerge: append
|
|
294
|
+
values:
|
|
295
|
+
- tracing
|
|
296
|
+
- rate-limiting
|
|
238
297
|
|
|
239
298
|
# Data team repos - different logging
|
|
240
299
|
- git:
|
|
241
300
|
- git@github.com:org/data-pipeline.git
|
|
242
301
|
- git@github.com:org/analytics.git
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
302
|
+
files:
|
|
303
|
+
service.config.json:
|
|
304
|
+
content:
|
|
305
|
+
team: data
|
|
306
|
+
logging:
|
|
307
|
+
level: debug
|
|
247
308
|
|
|
248
309
|
# Legacy service - completely different config
|
|
249
310
|
- git: git@github.com:org/legacy-api.git
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
311
|
+
files:
|
|
312
|
+
service.config.json:
|
|
313
|
+
override: true
|
|
314
|
+
content:
|
|
315
|
+
version: "1.0"
|
|
316
|
+
legacy: true
|
|
254
317
|
```
|
|
255
318
|
|
|
256
319
|
### Environment-Specific Values
|
|
@@ -258,40 +321,46 @@ repos:
|
|
|
258
321
|
Use environment variables for secrets and environment-specific values:
|
|
259
322
|
|
|
260
323
|
```yaml
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
content:
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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}
|
|
268
331
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
332
|
+
api:
|
|
333
|
+
baseUrl: ${API_BASE_URL}
|
|
334
|
+
timeout: 30000
|
|
272
335
|
|
|
273
336
|
repos:
|
|
274
337
|
- git: git@github.com:org/backend.git
|
|
275
338
|
```
|
|
276
339
|
|
|
277
|
-
###
|
|
340
|
+
### Per-File Merge Strategies
|
|
278
341
|
|
|
279
|
-
|
|
342
|
+
Different files can use different array merge strategies:
|
|
280
343
|
|
|
281
344
|
```yaml
|
|
282
|
-
|
|
345
|
+
files:
|
|
346
|
+
eslint.config.json:
|
|
347
|
+
mergeStrategy: append # Extends will append
|
|
348
|
+
content:
|
|
349
|
+
extends: ["@company/base"]
|
|
283
350
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
351
|
+
tsconfig.json:
|
|
352
|
+
mergeStrategy: replace # Lib will replace entirely
|
|
353
|
+
content:
|
|
354
|
+
compilerOptions:
|
|
355
|
+
lib: ["ES2022"]
|
|
288
356
|
|
|
289
357
|
repos:
|
|
290
|
-
- git:
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
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"]
|
|
295
364
|
```
|
|
296
365
|
|
|
297
366
|
## Supported Git URL Formats
|
|
@@ -311,17 +380,17 @@ repos:
|
|
|
311
380
|
```mermaid
|
|
312
381
|
flowchart TB
|
|
313
382
|
subgraph Input
|
|
314
|
-
YAML[/"YAML Config File<br/>
|
|
383
|
+
YAML[/"YAML Config File<br/>files{} + repos[]"/]
|
|
315
384
|
end
|
|
316
385
|
|
|
317
386
|
subgraph Normalization
|
|
318
|
-
EXPAND[Expand git arrays] --> MERGE[Merge base + overlay content]
|
|
387
|
+
EXPAND[Expand git arrays] --> MERGE[Merge base + overlay content<br/>for each file]
|
|
319
388
|
MERGE --> ENV[Interpolate env vars]
|
|
320
389
|
end
|
|
321
390
|
|
|
322
391
|
subgraph Processing["For Each Repository"]
|
|
323
|
-
CLONE[Clone Repo] --> BRANCH[Create/Checkout Branch<br/>--branch or chore/sync-
|
|
324
|
-
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]
|
|
325
394
|
WRITE --> CHECK{Changes?}
|
|
326
395
|
CHECK -->|No| SKIP[Skip - No Changes]
|
|
327
396
|
CHECK -->|Yes| COMMIT[Commit Changes]
|
|
@@ -346,12 +415,12 @@ flowchart TB
|
|
|
346
415
|
For each repository in the config, the tool:
|
|
347
416
|
|
|
348
417
|
1. Expands git URL arrays into individual entries
|
|
349
|
-
2.
|
|
418
|
+
2. For each file, merges base content with per-repo overlay
|
|
350
419
|
3. Interpolates environment variables
|
|
351
420
|
4. Cleans the temporary workspace
|
|
352
421
|
5. Clones the repository
|
|
353
|
-
6. Creates/checks out branch (custom `--branch` or default `chore/sync-
|
|
354
|
-
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)
|
|
355
424
|
8. Checks for changes (skips if no changes)
|
|
356
425
|
9. Commits and pushes changes
|
|
357
426
|
10. Creates a pull request
|
|
@@ -416,22 +485,25 @@ steps:
|
|
|
416
485
|
```
|
|
417
486
|
[1/3] Processing example-org/repo1...
|
|
418
487
|
✓ Cloned repository
|
|
419
|
-
✓ Created branch chore/sync-
|
|
420
|
-
✓ Wrote
|
|
488
|
+
✓ Created branch chore/sync-config
|
|
489
|
+
✓ Wrote .eslintrc.json
|
|
490
|
+
✓ Wrote .prettierrc.yaml
|
|
421
491
|
✓ Committed changes
|
|
422
492
|
✓ Pushed to remote
|
|
423
493
|
✓ Created PR: https://github.com/example-org/repo1/pull/42
|
|
424
494
|
|
|
425
495
|
[2/3] Processing example-org/repo2...
|
|
426
496
|
✓ Cloned repository
|
|
427
|
-
✓ Checked out existing branch chore/sync-
|
|
428
|
-
✓ Wrote
|
|
497
|
+
✓ Checked out existing branch chore/sync-config
|
|
498
|
+
✓ Wrote .eslintrc.json
|
|
499
|
+
✓ Wrote .prettierrc.yaml
|
|
429
500
|
⊘ No changes detected, skipping
|
|
430
501
|
|
|
431
502
|
[3/3] Processing example-org/repo3...
|
|
432
503
|
✓ Cloned repository
|
|
433
|
-
✓ Created branch chore/sync-
|
|
434
|
-
✓ Wrote
|
|
504
|
+
✓ Created branch chore/sync-config
|
|
505
|
+
✓ Wrote .eslintrc.json
|
|
506
|
+
✓ Wrote .prettierrc.yaml
|
|
435
507
|
✓ Committed changes
|
|
436
508
|
✓ Pushed to remote
|
|
437
509
|
✓ PR already exists: https://github.com/example-org/repo3/pull/15
|
|
@@ -443,9 +515,9 @@ Summary: 2 succeeded, 1 skipped, 0 failed
|
|
|
443
515
|
|
|
444
516
|
The tool creates PRs with:
|
|
445
517
|
|
|
446
|
-
- **Title:** `chore: sync
|
|
447
|
-
- **Branch:** `chore/sync-
|
|
448
|
-
- **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
|
|
449
521
|
|
|
450
522
|
## Troubleshooting
|
|
451
523
|
|
|
@@ -484,7 +556,7 @@ The tool automatically reuses existing branches. If you see unexpected behavior:
|
|
|
484
556
|
|
|
485
557
|
```bash
|
|
486
558
|
# Delete the remote branch to start fresh
|
|
487
|
-
git push origin --delete chore/sync-
|
|
559
|
+
git push origin --delete chore/sync-config
|
|
488
560
|
```
|
|
489
561
|
|
|
490
562
|
### Missing Environment Variables
|
|
@@ -534,9 +606,11 @@ For autocomplete and validation in VS Code, install the [YAML extension](https:/
|
|
|
534
606
|
|
|
535
607
|
```yaml
|
|
536
608
|
# yaml-language-server: $schema=https://raw.githubusercontent.com/anthony-spruyt/json-config-sync/main/config-schema.json
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
609
|
+
files:
|
|
610
|
+
my.config.json:
|
|
611
|
+
content:
|
|
612
|
+
key: value
|
|
613
|
+
|
|
540
614
|
repos:
|
|
541
615
|
- git: git@github.com:org/repo.git
|
|
542
616
|
```
|
|
@@ -556,7 +630,7 @@ repos:
|
|
|
556
630
|
|
|
557
631
|
This enables:
|
|
558
632
|
|
|
559
|
-
- Autocomplete for `
|
|
633
|
+
- Autocomplete for `files`, `repos`, `content`, `mergeStrategy`, `git`, `override`
|
|
560
634
|
- Enum suggestions for `mergeStrategy` values (`replace`, `append`, `prepend`)
|
|
561
635
|
- Validation of required fields
|
|
562
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