@hanseltime/template-repo-sync 2.2.0 → 2.3.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/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ # [2.3.0](https://github.com/HanseltimeIndustries/template-repo-sync/compare/v2.2.1...v2.3.0) (2026-03-04)
2
+
3
+
4
+ ### Features
5
+
6
+ * actually run plugin validate commands before mergin ([216ebbb](https://github.com/HanseltimeIndustries/template-repo-sync/commit/216ebbbf21f71f4480905fde336fbc0631b6aa7b))
7
+
8
+ ## [2.2.1](https://github.com/HanseltimeIndustries/template-repo-sync/compare/v2.2.0...v2.2.1) (2026-02-22)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * template sync afterRef updates ([21dd335](https://github.com/HanseltimeIndustries/template-repo-sync/commit/21dd335045034d132ad5f6ab7944a81c82b96981))
14
+
1
15
  # [2.2.0](https://github.com/HanseltimeIndustries/template-repo-sync/compare/v2.1.2...v2.2.0) (2026-02-21)
2
16
 
3
17
 
@@ -35,6 +35,7 @@ const formatting_1 = require("./formatting");
35
35
  const commentJSON = __importStar(require("comment-json"));
36
36
  const checkout_drivers_1 = require("./checkout-drivers");
37
37
  const micromatch_1 = require("micromatch");
38
+ const load_plugin_1 = require("./load-plugin");
38
39
  exports.TEMPLATE_SYNC_CONFIG = "templatesync";
39
40
  exports.TEMPLATE_SYNC_LOCAL_CONFIG = "templatesync.local";
40
41
  async function templateSync(options) {
@@ -61,12 +62,29 @@ async function templateSync(options) {
61
62
  const templateSyncConfig = (0, fs_1.existsSync)(cloneConfigPath)
62
63
  ? commentJSON.parse((0, fs_1.readFileSync)(cloneConfigPath).toString())
63
64
  : { ignore: [] };
64
- const localConfigPath = (0, path_1.join)(options.repoDir, `${exports.TEMPLATE_SYNC_LOCAL_CONFIG}.json`);
65
+ const localConfigFile = `${exports.TEMPLATE_SYNC_LOCAL_CONFIG}.json`;
66
+ const localConfigPath = (0, path_1.join)(options.repoDir, localConfigFile);
65
67
  const localTemplateSyncConfig = (0, fs_1.existsSync)(localConfigPath)
66
68
  ? commentJSON.parse((0, fs_1.readFileSync)(localConfigPath).toString())
67
69
  : { ignore: [] };
68
70
  let filesToSync;
71
+ const ref = await currentRefDriver({
72
+ rootDir: tempCloneDir,
73
+ });
69
74
  if (localTemplateSyncConfig.afterRef) {
75
+ if (ref === localTemplateSyncConfig.afterRef) {
76
+ // short circuit if the refs match
77
+ return {
78
+ localSkipFiles: [],
79
+ localFileChanges: {},
80
+ modifiedFiles: {
81
+ added: [],
82
+ modified: [],
83
+ deleted: [],
84
+ total: 0,
85
+ },
86
+ };
87
+ }
70
88
  filesToSync = await diffDriver(tempCloneDir, localTemplateSyncConfig.afterRef);
71
89
  }
72
90
  else {
@@ -79,6 +97,46 @@ async function templateSync(options) {
79
97
  modified: [],
80
98
  };
81
99
  }
100
+ // Pre-load plugins and make sure the sync file is respected
101
+ // Synchronous since grpc servers take a second
102
+ const localValidateErrors = {};
103
+ const validateErrors = {};
104
+ for (const config of localTemplateSyncConfig.merge ?? []) {
105
+ const plugin = await (0, load_plugin_1.loadPlugin)(config, options.repoDir);
106
+ const errors = plugin.validate(config.options ?? {});
107
+ if (errors && errors.length > 0) {
108
+ localValidateErrors[config.plugin] = errors;
109
+ }
110
+ }
111
+ for (const config of templateSyncConfig.merge ?? []) {
112
+ const plugin = await (0, load_plugin_1.loadPlugin)(config, tempCloneDir);
113
+ const errors = plugin.validate(config.options ?? {});
114
+ if (errors && errors.length > 0) {
115
+ validateErrors[config.plugin] = errors;
116
+ }
117
+ }
118
+ let errorStr = "";
119
+ if (Object.keys(validateErrors).length > 0) {
120
+ errorStr = `${errorStr}templatesync.json plugin option errors:\n`;
121
+ for (const plugin in validateErrors) {
122
+ errorStr = `${errorStr}\tPlugin (${plugin}):\n`;
123
+ validateErrors[plugin].forEach((err) => {
124
+ errorStr = `${errorStr}\t\t${err}\n`;
125
+ });
126
+ }
127
+ }
128
+ if (Object.keys(localValidateErrors).length > 0) {
129
+ errorStr = `${errorStr}templatesync.local.json plugin option errors:\n`;
130
+ for (const plugin in localValidateErrors) {
131
+ errorStr = `${errorStr}\tPlugin (${plugin}):\n`;
132
+ localValidateErrors[plugin].forEach((err) => {
133
+ errorStr = `${errorStr}\t\t${err}\n`;
134
+ });
135
+ }
136
+ }
137
+ if (errorStr !== "") {
138
+ throw Error(errorStr);
139
+ }
82
140
  // Apply ignore filters
83
141
  filesToSync.added = filesToSync.added.filter((f) => !(0, micromatch_1.some)(f, templateSyncConfig.ignore));
84
142
  filesToSync.modified = filesToSync.modified.filter((f) => !(0, micromatch_1.some)(f, templateSyncConfig.ignore));
@@ -114,7 +172,6 @@ async function templateSync(options) {
114
172
  added: actualAdded,
115
173
  modified: actualModified,
116
174
  deleted: actualDeleted,
117
- total: actualAdded.length + actualModified.length + actualDeleted.length,
118
175
  };
119
176
  // apply after ref
120
177
  if (options.updateAfterRef) {
@@ -126,15 +183,22 @@ async function templateSync(options) {
126
183
  const config = commentJSON.parse(configStr);
127
184
  config.afterRef = ref;
128
185
  (0, fs_1.writeFileSync)(localConfigPath, commentJSON.stringify(config, null, (0, formatting_1.inferJSONIndent)(configStr)));
186
+ modifiedFiles.modified.push(localConfigFile);
129
187
  }
130
188
  else {
131
189
  (0, fs_1.writeFileSync)(localConfigPath, commentJSON.stringify({ afterRef: ref }, null, 4));
190
+ modifiedFiles.added.push(localConfigFile);
132
191
  }
133
192
  }
134
193
  return {
135
194
  localSkipFiles: Array.from(localSkipFiles),
136
195
  localFileChanges,
137
- modifiedFiles: modifiedFiles,
196
+ modifiedFiles: {
197
+ ...modifiedFiles,
198
+ total: modifiedFiles.added.length +
199
+ modifiedFiles.deleted.length +
200
+ modifiedFiles.modified.length,
201
+ },
138
202
  };
139
203
  }
140
204
  exports.templateSync = templateSync;
@@ -35,6 +35,7 @@ const formatting_1 = require("./formatting");
35
35
  const commentJSON = __importStar(require("comment-json"));
36
36
  const checkout_drivers_1 = require("./checkout-drivers");
37
37
  const micromatch_1 = require("micromatch");
38
+ const load_plugin_1 = require("./load-plugin");
38
39
  exports.TEMPLATE_SYNC_CONFIG = "templatesync";
39
40
  exports.TEMPLATE_SYNC_LOCAL_CONFIG = "templatesync.local";
40
41
  async function templateSync(options) {
@@ -61,12 +62,29 @@ async function templateSync(options) {
61
62
  const templateSyncConfig = (0, fs_1.existsSync)(cloneConfigPath)
62
63
  ? commentJSON.parse((0, fs_1.readFileSync)(cloneConfigPath).toString())
63
64
  : { ignore: [] };
64
- const localConfigPath = (0, path_1.join)(options.repoDir, `${exports.TEMPLATE_SYNC_LOCAL_CONFIG}.json`);
65
+ const localConfigFile = `${exports.TEMPLATE_SYNC_LOCAL_CONFIG}.json`;
66
+ const localConfigPath = (0, path_1.join)(options.repoDir, localConfigFile);
65
67
  const localTemplateSyncConfig = (0, fs_1.existsSync)(localConfigPath)
66
68
  ? commentJSON.parse((0, fs_1.readFileSync)(localConfigPath).toString())
67
69
  : { ignore: [] };
68
70
  let filesToSync;
71
+ const ref = await currentRefDriver({
72
+ rootDir: tempCloneDir,
73
+ });
69
74
  if (localTemplateSyncConfig.afterRef) {
75
+ if (ref === localTemplateSyncConfig.afterRef) {
76
+ // short circuit if the refs match
77
+ return {
78
+ localSkipFiles: [],
79
+ localFileChanges: {},
80
+ modifiedFiles: {
81
+ added: [],
82
+ modified: [],
83
+ deleted: [],
84
+ total: 0,
85
+ },
86
+ };
87
+ }
70
88
  filesToSync = await diffDriver(tempCloneDir, localTemplateSyncConfig.afterRef);
71
89
  }
72
90
  else {
@@ -79,6 +97,46 @@ async function templateSync(options) {
79
97
  modified: [],
80
98
  };
81
99
  }
100
+ // Pre-load plugins and make sure the sync file is respected
101
+ // Synchronous since grpc servers take a second
102
+ const localValidateErrors = {};
103
+ const validateErrors = {};
104
+ for (const config of localTemplateSyncConfig.merge ?? []) {
105
+ const plugin = await (0, load_plugin_1.loadPlugin)(config, options.repoDir);
106
+ const errors = plugin.validate(config.options ?? {});
107
+ if (errors && errors.length > 0) {
108
+ localValidateErrors[config.plugin] = errors;
109
+ }
110
+ }
111
+ for (const config of templateSyncConfig.merge ?? []) {
112
+ const plugin = await (0, load_plugin_1.loadPlugin)(config, tempCloneDir);
113
+ const errors = plugin.validate(config.options ?? {});
114
+ if (errors && errors.length > 0) {
115
+ validateErrors[config.plugin] = errors;
116
+ }
117
+ }
118
+ let errorStr = "";
119
+ if (Object.keys(validateErrors).length > 0) {
120
+ errorStr = `${errorStr}templatesync.json plugin option errors:\n`;
121
+ for (const plugin in validateErrors) {
122
+ errorStr = `${errorStr}\tPlugin (${plugin}):\n`;
123
+ validateErrors[plugin].forEach((err) => {
124
+ errorStr = `${errorStr}\t\t${err}\n`;
125
+ });
126
+ }
127
+ }
128
+ if (Object.keys(localValidateErrors).length > 0) {
129
+ errorStr = `${errorStr}templatesync.local.json plugin option errors:\n`;
130
+ for (const plugin in localValidateErrors) {
131
+ errorStr = `${errorStr}\tPlugin (${plugin}):\n`;
132
+ localValidateErrors[plugin].forEach((err) => {
133
+ errorStr = `${errorStr}\t\t${err}\n`;
134
+ });
135
+ }
136
+ }
137
+ if (errorStr !== "") {
138
+ throw Error(errorStr);
139
+ }
82
140
  // Apply ignore filters
83
141
  filesToSync.added = filesToSync.added.filter((f) => !(0, micromatch_1.some)(f, templateSyncConfig.ignore));
84
142
  filesToSync.modified = filesToSync.modified.filter((f) => !(0, micromatch_1.some)(f, templateSyncConfig.ignore));
@@ -114,7 +172,6 @@ async function templateSync(options) {
114
172
  added: actualAdded,
115
173
  modified: actualModified,
116
174
  deleted: actualDeleted,
117
- total: actualAdded.length + actualModified.length + actualDeleted.length,
118
175
  };
119
176
  // apply after ref
120
177
  if (options.updateAfterRef) {
@@ -126,15 +183,22 @@ async function templateSync(options) {
126
183
  const config = commentJSON.parse(configStr);
127
184
  config.afterRef = ref;
128
185
  (0, fs_1.writeFileSync)(localConfigPath, commentJSON.stringify(config, null, (0, formatting_1.inferJSONIndent)(configStr)));
186
+ modifiedFiles.modified.push(localConfigFile);
129
187
  }
130
188
  else {
131
189
  (0, fs_1.writeFileSync)(localConfigPath, commentJSON.stringify({ afterRef: ref }, null, 4));
190
+ modifiedFiles.added.push(localConfigFile);
132
191
  }
133
192
  }
134
193
  return {
135
194
  localSkipFiles: Array.from(localSkipFiles),
136
195
  localFileChanges,
137
- modifiedFiles: modifiedFiles,
196
+ modifiedFiles: {
197
+ ...modifiedFiles,
198
+ total: modifiedFiles.added.length +
199
+ modifiedFiles.deleted.length +
200
+ modifiedFiles.modified.length,
201
+ },
138
202
  };
139
203
  }
140
204
  exports.templateSync = templateSync;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hanseltime/template-repo-sync",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "description": "An npm library that enables pluggable, customizable synchronization between template repos",
5
5
  "main": "lib/cjs/index.js",
6
6
  "types": "lib/cjs/index.d.ts",
@@ -5,24 +5,27 @@ import { tempDir, TEST_FIXTURES_DIR } from "./test-utils";
5
5
  import { join, resolve } from "path";
6
6
  import { existsSync, readFileSync, writeFileSync } from "fs";
7
7
 
8
- // Just return the test-fixture directory
9
- const dummyCloneDriver = async () => {
10
- return {
11
- dir: resolve(TEST_FIXTURES_DIR, "template"),
12
- remoteName: "ourRemote",
13
- };
14
- };
15
-
16
8
  const dummyCheckoutDriver = jest.fn();
9
+ const dummyCurrentRefDriver = jest.fn();
17
10
 
18
11
  const downstreamDir = resolve(TEST_FIXTURES_DIR, "downstream");
19
12
 
20
13
  describe("templateSync", () => {
21
14
  let tmpDir: string;
15
+ let templateDir: string;
16
+ let dummyCloneDriver: () => Promise<{ dir: string; remoteName: string }>;
22
17
  beforeEach(async () => {
23
18
  jest.resetAllMocks();
24
19
  tmpDir = await mkdtemp(tempDir());
20
+ templateDir = await mkdtemp(tempDir());
21
+ await copy(resolve(TEST_FIXTURES_DIR, "template"), templateDir);
25
22
  await copy(downstreamDir, tmpDir);
23
+ dummyCloneDriver = async () => {
24
+ return {
25
+ dir: templateDir,
26
+ remoteName: "ourRemote",
27
+ };
28
+ };
26
29
  });
27
30
  afterEach(async () => {
28
31
  await rm(tmpDir, {
@@ -30,6 +33,93 @@ describe("templateSync", () => {
30
33
  recursive: true,
31
34
  });
32
35
  });
36
+ // Note: for this test, we expect actions and users to handle misconfigured template syncs
37
+ it.each([
38
+ [
39
+ "local",
40
+ `templatesync.local.json plugin option errors:
41
+ \tPlugin (plugins/fail-validate-plugin.js):
42
+ \t\toh no!
43
+ \t\tnot this one too!
44
+ `,
45
+ ],
46
+ [
47
+ "template",
48
+ `templatesync.json plugin option errors:
49
+ \tPlugin (dummy-fail-plugin.js):
50
+ \t\tshucks
51
+ \t\tno good
52
+ `,
53
+ ],
54
+ [
55
+ "both",
56
+ `templatesync.json plugin option errors:
57
+ \tPlugin (dummy-fail-plugin.js):
58
+ \t\tshucks
59
+ \t\tno good
60
+ templatesync.local.json plugin option errors:
61
+ \tPlugin (plugins/fail-validate-plugin.js):
62
+ \t\toh no!
63
+ \t\tnot this one too!
64
+ `,
65
+ ],
66
+ ])("throws errors from plugin validation", async (mode, expected) => {
67
+ // Remove the local sync overrides
68
+ await rm(join(tmpDir, "templatesync.local.json"));
69
+
70
+ if (mode === "local" || mode == "both") {
71
+ writeFileSync(
72
+ join(tmpDir, "templatesync.local.json"),
73
+ JSON.stringify({
74
+ ignore: [
75
+ // Ignores the templated.ts
76
+ "**/*.ts",
77
+ // We don't have a need for this in here, but it's an example of keeping things cleaner for our custom plugins
78
+ "plugins/**",
79
+ ],
80
+ merge: [
81
+ {
82
+ glob: "package.json",
83
+ plugin: "plugins/fail-validate-plugin.js",
84
+ options: {},
85
+ },
86
+ ],
87
+ }),
88
+ );
89
+ }
90
+ if (mode === "template" || mode === "both") {
91
+ writeFileSync(
92
+ join(templateDir, "templatesync.json"),
93
+ JSON.stringify({
94
+ ignore: [
95
+ // Ignores the templated.ts
96
+ "**/*.ts",
97
+ // We don't have a need for this in here, but it's an example of keeping things cleaner for our custom plugins
98
+ "plugins/**",
99
+ ],
100
+ merge: [
101
+ {
102
+ glob: "package.json",
103
+ plugin: "dummy-fail-plugin.js",
104
+ options: {},
105
+ },
106
+ ],
107
+ }),
108
+ );
109
+ }
110
+
111
+ await expect(
112
+ async () =>
113
+ await templateSync({
114
+ tmpCloneDir: "stubbed-by-driver",
115
+ cloneDriver: dummyCloneDriver,
116
+ repoUrl: "not-important",
117
+ repoDir: tmpDir,
118
+ checkoutDriver: dummyCheckoutDriver,
119
+ currentRefDriver: dummyCurrentRefDriver,
120
+ }),
121
+ ).rejects.toThrow(expected);
122
+ });
33
123
  it("appropriately merges according to just the templatesync config file into an empty dir", async () => {
34
124
  const emptyTmpDir = await mkdtemp(tempDir());
35
125
  expect(
@@ -39,6 +129,7 @@ describe("templateSync", () => {
39
129
  repoUrl: "not-important",
40
130
  repoDir: emptyTmpDir,
41
131
  checkoutDriver: dummyCheckoutDriver,
132
+ currentRefDriver: dummyCurrentRefDriver,
42
133
  }),
43
134
  ).toEqual({
44
135
  // Expect no changes since there was no local sync file
@@ -79,6 +170,7 @@ describe("templateSync", () => {
79
170
  repoDir: emptyTmpDir,
80
171
  branch: "new-template-test",
81
172
  checkoutDriver: dummyCheckoutDriver,
173
+ currentRefDriver: dummyCurrentRefDriver,
82
174
  }),
83
175
  ).toEqual({
84
176
  // Expect no changes since there was no local sync file
@@ -123,6 +215,7 @@ describe("templateSync", () => {
123
215
  repoUrl: "not-important",
124
216
  repoDir: tmpDir,
125
217
  checkoutDriver: dummyCheckoutDriver,
218
+ currentRefDriver: dummyCurrentRefDriver,
126
219
  });
127
220
 
128
221
  expect(result.localSkipFiles).toEqual([]);
@@ -190,6 +283,7 @@ describe("templateSync", () => {
190
283
  repoUrl: "not-important",
191
284
  repoDir: tmpDir,
192
285
  checkoutDriver: dummyCheckoutDriver,
286
+ currentRefDriver: dummyCurrentRefDriver,
193
287
  });
194
288
 
195
289
  expect(result.localSkipFiles).toEqual(["src/templated.ts"]);
@@ -256,6 +350,7 @@ describe("templateSync", () => {
256
350
  repoUrl: "not-important",
257
351
  repoDir: tmpDir,
258
352
  diffDriver: mockDiffDriver,
353
+ currentRefDriver: dummyCurrentRefDriver,
259
354
  checkoutDriver: dummyCheckoutDriver,
260
355
  });
261
356
 
@@ -292,7 +387,7 @@ describe("templateSync", () => {
292
387
  // We will only update the templated.ts
293
388
  const mockDiffDriver = jest.fn().mockImplementation(async () => ({
294
389
  added: ["src/templated.ts"],
295
- modified: ["src/index.ts"], // Add index.ts so we make sure it is still ignored - due to a bug
390
+ modified: ["src/index.ts"], // Add index.ts so we make sure it is still ignored - see test-fixtures/template/templatesync.json ignores
296
391
  deleted: [],
297
392
  }));
298
393
  const mockCurrentRefDriver = jest
@@ -311,6 +406,12 @@ describe("templateSync", () => {
311
406
 
312
407
  // since there was no override for this file, no changes from the local file
313
408
  expect(result.localFileChanges).toEqual(expect.objectContaining({}));
409
+ expect(result.modifiedFiles).toEqual({
410
+ added: ["src/templated.ts"],
411
+ modified: ["templatesync.local.json"], // Add index.ts so we make sure it is still ignored - due to a bug
412
+ deleted: [],
413
+ total: 2,
414
+ });
314
415
 
315
416
  // Verify the files
316
417
  await fileMatchTemplate(tmpDir, "templatesync.json");
@@ -332,6 +433,73 @@ describe("templateSync", () => {
332
433
  });
333
434
  expect(dummyCheckoutDriver).not.toHaveBeenCalled();
334
435
  });
436
+ it("Does not update the local templatesync if updateAfterRef is true and the ref is the same", async () => {
437
+ // Remove the local sync overrides
438
+ await rm(join(tmpDir, "templatesync.local.json"));
439
+
440
+ const mockLocalConfig = {
441
+ afterRef: "dummySha",
442
+ ignore: [
443
+ // We don't have a need for this in here, but it's an example of keeping things cleaner for our custom plugins
444
+ "plugins/**",
445
+ ],
446
+ };
447
+
448
+ writeFileSync(
449
+ join(tmpDir, "templatesync.local.json"),
450
+ JSON.stringify(mockLocalConfig),
451
+ );
452
+
453
+ // We will only update the templated.ts
454
+ const mockDiffDriver = jest.fn().mockImplementation(async () => ({
455
+ added: ["src/templated.ts"],
456
+ modified: ["src/index.ts"], // Add index.ts so we make sure it is still ignored - see test-fixtures/template/templatesync.json ignores
457
+ deleted: [],
458
+ }));
459
+ const mockCurrentRefDriver = jest
460
+ .fn()
461
+ .mockImplementation(async () => "dummySha");
462
+ const result = await templateSync({
463
+ tmpCloneDir: "stubbed-by-driver",
464
+ cloneDriver: dummyCloneDriver,
465
+ repoUrl: "not-important",
466
+ repoDir: tmpDir,
467
+ updateAfterRef: true,
468
+ diffDriver: mockDiffDriver,
469
+ currentRefDriver: mockCurrentRefDriver,
470
+ checkoutDriver: dummyCheckoutDriver,
471
+ });
472
+
473
+ // Nothing shoudl be reported as changing
474
+ expect(result).toEqual({
475
+ localFileChanges: {},
476
+ localSkipFiles: [],
477
+ modifiedFiles: {
478
+ added: [],
479
+ modified: [],
480
+ deleted: [],
481
+ total: 0,
482
+ },
483
+ });
484
+ // Verify the files
485
+ await fileMatchDownstream(tmpDir, "templatesync.json");
486
+ await fileMatchDownstream(tmpDir, "src/templated.ts");
487
+
488
+ // Expect the none of the diff files to work
489
+ await fileMatchDownstream(tmpDir, "src/index.ts");
490
+ await fileMatchDownstream(tmpDir, "plugins/custom-plugin.js");
491
+ await fileMatchDownstream(tmpDir, "package.json");
492
+
493
+ // Ensure we have updated the local template field
494
+ expect(
495
+ JSON.parse(
496
+ (await readFile(join(tmpDir, "templatesync.local.json"))).toString(),
497
+ ),
498
+ ).toEqual({
499
+ ...mockLocalConfig,
500
+ });
501
+ expect(dummyCheckoutDriver).not.toHaveBeenCalled();
502
+ });
335
503
  it("creates the local templatesync with the current ref if updateAfterRef is true and no local template exists", async () => {
336
504
  // Remove the local sync overrides
337
505
  await rm(join(tmpDir, "templatesync.local.json"));
@@ -356,6 +524,19 @@ describe("templateSync", () => {
356
524
 
357
525
  // since there was no override for this file, no changes from the local file
358
526
  expect(result.localFileChanges).toEqual(expect.objectContaining({}));
527
+ expect(result.modifiedFiles).toEqual({
528
+ added: [
529
+ "package.json",
530
+ "src/index.js",
531
+ "src/templated.js",
532
+ "src/templated.ts",
533
+ "templatesync.json",
534
+ "templatesync.local.json",
535
+ ],
536
+ deleted: [],
537
+ modified: [],
538
+ total: 6,
539
+ });
359
540
 
360
541
  // Verify the files
361
542
  await fileMatchTemplate(tmpDir, "templatesync.json");
@@ -398,26 +579,24 @@ describe("templateSync", () => {
398
579
  afterRef: "newestSha",
399
580
  });
400
581
  });
401
- expect(dummyCheckoutDriver).not.toHaveBeenCalled();
582
+ // helper
583
+ async function fileMatchTemplate(tmpDir: string, relPath: string) {
584
+ return fileMatch(tmpDir, relPath, "template");
585
+ }
586
+
587
+ async function fileMatchDownstream(tmpDir: string, relPath: string) {
588
+ return fileMatch(tmpDir, relPath, "downstream");
589
+ }
590
+
591
+ async function fileMatch(
592
+ tmpDir: string,
593
+ relPath: string,
594
+ source: "downstream" | "template",
595
+ ) {
596
+ const dir =
597
+ source === "downstream" ? downstreamDir : (await dummyCloneDriver()).dir;
598
+ expect((await readFile(resolve(tmpDir, relPath))).toString()).toEqual(
599
+ (await readFile(resolve(dir, relPath))).toString(),
600
+ );
601
+ }
402
602
  });
403
-
404
- // helper
405
- async function fileMatchTemplate(tmpDir: string, relPath: string) {
406
- return fileMatch(tmpDir, relPath, "template");
407
- }
408
-
409
- async function fileMatchDownstream(tmpDir: string, relPath: string) {
410
- return fileMatch(tmpDir, relPath, "downstream");
411
- }
412
-
413
- async function fileMatch(
414
- tmpDir: string,
415
- relPath: string,
416
- source: "downstream" | "template",
417
- ) {
418
- const dir =
419
- source === "downstream" ? downstreamDir : (await dummyCloneDriver()).dir;
420
- expect((await readFile(resolve(tmpDir, relPath))).toString()).toEqual(
421
- (await readFile(resolve(dir, relPath))).toString(),
422
- );
423
- }
@@ -13,6 +13,7 @@ import { inferJSONIndent } from "./formatting";
13
13
  import * as commentJSON from "comment-json";
14
14
  import { TemplateCheckoutDriverFn, gitCheckout } from "./checkout-drivers";
15
15
  import { some } from "micromatch";
16
+ import { loadPlugin } from "./load-plugin";
16
17
 
17
18
  export interface TemplateSyncOptions {
18
19
  /**
@@ -125,10 +126,8 @@ export async function templateSync(
125
126
  ) as unknown as Config)
126
127
  : { ignore: [] };
127
128
 
128
- const localConfigPath = join(
129
- options.repoDir,
130
- `${TEMPLATE_SYNC_LOCAL_CONFIG}.json`,
131
- );
129
+ const localConfigFile = `${TEMPLATE_SYNC_LOCAL_CONFIG}.json`;
130
+ const localConfigPath = join(options.repoDir, localConfigFile);
132
131
  const localTemplateSyncConfig: LocalConfig = existsSync(localConfigPath)
133
132
  ? (commentJSON.parse(
134
133
  readFileSync(localConfigPath).toString(),
@@ -136,7 +135,23 @@ export async function templateSync(
136
135
  : { ignore: [] };
137
136
 
138
137
  let filesToSync: DiffResult;
138
+ const ref = await currentRefDriver({
139
+ rootDir: tempCloneDir,
140
+ });
139
141
  if (localTemplateSyncConfig.afterRef) {
142
+ if (ref === localTemplateSyncConfig.afterRef) {
143
+ // short circuit if the refs match
144
+ return {
145
+ localSkipFiles: [],
146
+ localFileChanges: {},
147
+ modifiedFiles: {
148
+ added: [],
149
+ modified: [],
150
+ deleted: [],
151
+ total: 0,
152
+ },
153
+ };
154
+ }
140
155
  filesToSync = await diffDriver(
141
156
  tempCloneDir,
142
157
  localTemplateSyncConfig.afterRef,
@@ -152,6 +167,53 @@ export async function templateSync(
152
167
  };
153
168
  }
154
169
 
170
+ // Pre-load plugins and make sure the sync file is respected
171
+ // Synchronous since grpc servers take a second
172
+ const localValidateErrors: {
173
+ [k: string]: string[];
174
+ } = {};
175
+ const validateErrors: {
176
+ [k: string]: string[];
177
+ } = {};
178
+ for (const config of localTemplateSyncConfig.merge ?? []) {
179
+ const plugin = await loadPlugin(config, options.repoDir);
180
+ const errors = plugin.validate(config.options ?? {});
181
+ if (errors && errors.length > 0) {
182
+ localValidateErrors[config.plugin] = errors;
183
+ }
184
+ }
185
+ for (const config of templateSyncConfig.merge ?? []) {
186
+ const plugin = await loadPlugin(config, tempCloneDir);
187
+ const errors = plugin.validate(config.options ?? {});
188
+ if (errors && errors.length > 0) {
189
+ validateErrors[config.plugin] = errors;
190
+ }
191
+ }
192
+
193
+ let errorStr = "";
194
+ if (Object.keys(validateErrors).length > 0) {
195
+ errorStr = `${errorStr}templatesync.json plugin option errors:\n`;
196
+ for (const plugin in validateErrors) {
197
+ errorStr = `${errorStr}\tPlugin (${plugin}):\n`;
198
+ validateErrors[plugin].forEach((err) => {
199
+ errorStr = `${errorStr}\t\t${err}\n`;
200
+ });
201
+ }
202
+ }
203
+ if (Object.keys(localValidateErrors).length > 0) {
204
+ errorStr = `${errorStr}templatesync.local.json plugin option errors:\n`;
205
+ for (const plugin in localValidateErrors) {
206
+ errorStr = `${errorStr}\tPlugin (${plugin}):\n`;
207
+ localValidateErrors[plugin].forEach((err) => {
208
+ errorStr = `${errorStr}\t\t${err}\n`;
209
+ });
210
+ }
211
+ }
212
+
213
+ if (errorStr !== "") {
214
+ throw Error(errorStr);
215
+ }
216
+
155
217
  // Apply ignore filters
156
218
  filesToSync.added = filesToSync.added.filter(
157
219
  (f) => !some(f, templateSyncConfig.ignore),
@@ -202,7 +264,6 @@ export async function templateSync(
202
264
  added: actualAdded,
203
265
  modified: actualModified,
204
266
  deleted: actualDeleted,
205
- total: actualAdded.length + actualModified.length + actualDeleted.length,
206
267
  };
207
268
 
208
269
  // apply after ref
@@ -219,17 +280,25 @@ export async function templateSync(
219
280
  localConfigPath,
220
281
  commentJSON.stringify(config, null, inferJSONIndent(configStr)),
221
282
  );
283
+ modifiedFiles.modified.push(localConfigFile);
222
284
  } else {
223
285
  writeFileSync(
224
286
  localConfigPath,
225
287
  commentJSON.stringify({ afterRef: ref }, null, 4),
226
288
  );
289
+ modifiedFiles.added.push(localConfigFile);
227
290
  }
228
291
  }
229
292
 
230
293
  return {
231
294
  localSkipFiles: Array.from(localSkipFiles),
232
295
  localFileChanges,
233
- modifiedFiles: modifiedFiles,
296
+ modifiedFiles: {
297
+ ...modifiedFiles,
298
+ total:
299
+ modifiedFiles.added.length +
300
+ modifiedFiles.deleted.length +
301
+ modifiedFiles.modified.length,
302
+ },
234
303
  };
235
304
  }
@@ -8,4 +8,7 @@ module.exports = {
8
8
  4,
9
9
  );
10
10
  },
11
+ validate: () => {
12
+ return [];
13
+ },
11
14
  };
@@ -0,0 +1,14 @@
1
+ module.exports = {
2
+ merge: () => {
3
+ return JSON.stringify(
4
+ {
5
+ downstream: true,
6
+ },
7
+ null,
8
+ 4,
9
+ );
10
+ },
11
+ validate: () => {
12
+ return ["oh no!", "not this one too!"];
13
+ },
14
+ };
@@ -1,5 +1,5 @@
1
1
  {
2
- "ignore": ["src/!(templated).ts", "custom-bin/**"],
2
+ "ignore": ["src/!(templated).ts", "custom-bin/**", "dummy-fail-plugin.js"],
3
3
  "merge": [
4
4
  {
5
5
  "glob": "package.json",
@@ -0,0 +1,8 @@
1
+ module.exports = {
2
+ merge: () => {
3
+ return JSON.stringify({ tested: true }, null, 4);
4
+ },
5
+ validate: () => {
6
+ return ["shucks", "no good"];
7
+ },
8
+ };
@@ -1,5 +1,5 @@
1
1
  {
2
- "ignore": ["src/!(templated).ts", "custom-bin/**"],
2
+ "ignore": ["src/!(templated).ts", "custom-bin/**", "dummy-fail-plugin.js"],
3
3
  "merge": [
4
4
  {
5
5
  "glob": "package.json",