@boyingliu01/xp-gate 0.12.2 → 0.12.4

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.
Files changed (54) hide show
  1. package/adapters/cpp.sh +0 -0
  2. package/adapters/dart.sh +0 -0
  3. package/adapters/flutter.sh +0 -0
  4. package/adapters/go.sh +0 -0
  5. package/adapters/java.sh +0 -0
  6. package/adapters/kotlin.sh +0 -0
  7. package/adapters/objectivec.sh +0 -0
  8. package/adapters/powershell.sh +0 -0
  9. package/adapters/python.sh +0 -0
  10. package/adapters/shell.sh +0 -0
  11. package/adapters/swift.sh +0 -0
  12. package/adapters/typescript.sh +0 -0
  13. package/bin/xp-gate.js +19 -5
  14. package/gate-3.sh +0 -0
  15. package/gate-4.sh +0 -0
  16. package/gate-7.sh +0 -0
  17. package/gate-8.sh +0 -0
  18. package/gate-9.sh +0 -0
  19. package/lib/__tests__/update-hooks.test.js +573 -0
  20. package/lib/init.js +9 -6
  21. package/lib/sprint-status.js +35 -1
  22. package/lib/update-hooks.js +288 -0
  23. package/mock-policy/AGENTS.md +3 -3
  24. package/mutation/AGENTS.md +3 -3
  25. package/package.json +1 -1
  26. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  27. package/plugins/claude-code/skills/delphi-review/AGENTS.md +6 -6
  28. package/plugins/claude-code/skills/delphi-review/SKILL.md +34 -12
  29. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
  30. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  31. package/plugins/opencode/package.json +1 -1
  32. package/plugins/opencode/skills/delphi-review/AGENTS.md +6 -6
  33. package/plugins/opencode/skills/delphi-review/SKILL.md +34 -12
  34. package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
  35. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  36. package/plugins/qoder/plugin.json +1 -1
  37. package/plugins/qoder/skills/delphi-review/AGENTS.md +6 -6
  38. package/plugins/qoder/skills/delphi-review/SKILL.md +198 -264
  39. package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
  40. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  41. package/principles/AGENTS.md +3 -3
  42. package/skills/delphi-review/AGENTS.md +6 -6
  43. package/skills/delphi-review/SKILL.md +34 -12
  44. package/skills/sprint-flow/AGENTS.md +3 -3
  45. package/skills/test-specification-alignment/AGENTS.md +3 -3
  46. package/lib/__tests__/next-sprint.test.js +0 -231
  47. package/lib/next-sprint.js +0 -129
  48. package/lib/shared-phase-constants.d.ts +0 -17
  49. package/lib/shared-phase-constants.js +0 -77
  50. package/plugins/opencode/__tests__/tui-plugin.test.ts +0 -543
  51. package/plugins/opencode/__tests__/xp-gate-e2e-upgrade.test.ts +0 -562
  52. package/plugins/opencode/__tests__/xp-gate-update.test.ts +0 -518
  53. package/plugins/opencode/tui-plugin.ts +0 -414
  54. package/plugins/opencode/tui-plugin.tsx +0 -306
package/adapters/cpp.sh CHANGED
File without changes
package/adapters/dart.sh CHANGED
File without changes
File without changes
package/adapters/go.sh CHANGED
File without changes
package/adapters/java.sh CHANGED
File without changes
File without changes
File without changes
File without changes
File without changes
package/adapters/shell.sh CHANGED
File without changes
package/adapters/swift.sh CHANGED
File without changes
File without changes
package/bin/xp-gate.js CHANGED
@@ -13,6 +13,7 @@ const { principles } = require('../lib/principles.js');
13
13
  const { arch } = require('../lib/arch.js');
14
14
  const { upgrade } = require('../lib/upgrade.js');
15
15
  const { bootstrap } = require('../lib/bootstrap.js');
16
+ const { updateHooks } = require('../lib/update-hooks.js');
16
17
 
17
18
  function handleUIReview() {
18
19
  const { execSync } = require('child_process');
@@ -136,13 +137,26 @@ const COMMANDS = {
136
137
  },
137
138
  usage: 'xp-gate sprint-status [--json] [--watch] [--dir <path>]'
138
139
  },
139
- 'next-sprint': {
140
- description: 'Analyze remaining open issues and plan next iteration',
140
+ 'update-hooks': {
141
+ description: 'Sync latest hooks from xp-gate package to project',
141
142
  run: subargs => {
142
- const { handleNextSprint } = require('../lib/next-sprint.js');
143
- handleNextSprint(subargs).then(code => process.exit(code));
143
+ const scopeArg = subargs.find(a => a.startsWith('--scope='));
144
+ const scopeVal = scopeArg ? scopeArg.split('=')[1] : 'all';
145
+ const options = {
146
+ global: subargs.includes('--global'),
147
+ force: subargs.includes('--force'),
148
+ dryRun: subargs.includes('--dry-run'),
149
+ noBackup: subargs.includes('--no-backup'),
150
+ scope: scopeVal,
151
+ };
152
+ try {
153
+ process.exit(updateHooks(options));
154
+ } catch (err) {
155
+ console.error(`Error: ${err.message}`);
156
+ process.exit(1);
157
+ }
144
158
  },
145
- usage: 'xp-gate next-sprint [--json] [--plan] [--dir <path>]'
159
+ usage: 'xp-gate update-hooks [--global] [--force] [--dry-run] [--no-backup] [--scope hooks|adapters|all]'
146
160
  }
147
161
  };
148
162
 
package/gate-3.sh CHANGED
File without changes
package/gate-4.sh CHANGED
File without changes
package/gate-7.sh CHANGED
File without changes
package/gate-8.sh CHANGED
File without changes
package/gate-9.sh CHANGED
File without changes
@@ -0,0 +1,573 @@
1
+ /**
2
+ * @test REQ-265 update-hooks command
3
+ * @intent Verify updateHooks correctly syncs hook files with proper backup, dry-run, scope, and detection behavior
4
+ * @covers AC-265-01, AC-265-02, AC-265-03, AC-265-04, AC-265-05
5
+ */
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const os = require('os');
9
+
10
+ describe('updateHooks', () => {
11
+ let tmpHome;
12
+ let tmpProject;
13
+ let tmpPackage;
14
+ let originalHome;
15
+ let logSpy;
16
+ let warnSpy;
17
+ let errorSpy;
18
+
19
+ beforeEach(() => {
20
+ originalHome = process.env.HOME;
21
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'xpgate-uh-home-'));
22
+ tmpProject = fs.mkdtempSync(path.join(os.tmpdir(), 'xpgate-uh-proj-'));
23
+ tmpPackage = fs.mkdtempSync(path.join(os.tmpdir(), 'xpgate-uh-pkg-'));
24
+ process.env.HOME = tmpHome;
25
+ vi.resetModules();
26
+ logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
27
+ warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
28
+ errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
29
+ });
30
+
31
+ afterEach(() => {
32
+ process.env.HOME = originalHome;
33
+ fs.rmSync(tmpHome, { recursive: true, force: true });
34
+ fs.rmSync(tmpProject, { recursive: true, force: true });
35
+ fs.rmSync(tmpPackage, { recursive: true, force: true });
36
+ vi.restoreAllMocks();
37
+ });
38
+
39
+ /** Helper: create a fake package structure under tmpPackage */
40
+ function createPackageSource(overrides = {}) {
41
+ const hooksDir = path.join(tmpPackage, 'hooks');
42
+ const adaptersDir = path.join(tmpPackage, 'adapters');
43
+ fs.mkdirSync(hooksDir, { recursive: true });
44
+ fs.mkdirSync(adaptersDir, { recursive: true });
45
+
46
+ fs.writeFileSync(path.join(hooksDir, 'pre-commit'), overrides['hooks/pre-commit'] || '#!/bin/bash\necho "hook-v2"');
47
+ fs.writeFileSync(path.join(hooksDir, 'pre-push'), overrides['hooks/pre-push'] || '#!/bin/bash\necho "push-v2"');
48
+ fs.writeFileSync(path.join(tmpPackage, 'adapter-common.sh'), overrides['adapter-common.sh'] || '#!/bin/bash\necho "adapter-common-v2"');
49
+ fs.writeFileSync(path.join(adaptersDir, 'typescript.sh'), overrides['adapters/typescript.sh'] || '#!/bin/bash\necho "ts-v2"');
50
+ fs.writeFileSync(path.join(adaptersDir, 'python.sh'), overrides['adapters/python.sh'] || '#!/bin/bash\necho "py-v2"');
51
+ fs.writeFileSync(path.join(tmpPackage, 'gate-3.sh'), overrides['gate-3.sh'] || '#!/bin/bash\necho "gate3-v2"');
52
+ fs.writeFileSync(path.join(tmpPackage, 'gate-4.sh'), overrides['gate-4.sh'] || '#!/bin/bash\necho "gate4-v2"');
53
+ }
54
+
55
+ /** Helper: get fresh updateHooks module */
56
+ function getModule() {
57
+ delete require.cache[require.resolve('../update-hooks')];
58
+ delete require.cache[require.resolve('../shared-paths')];
59
+ return require('../update-hooks');
60
+ }
61
+
62
+ // ===== copyHooks tests =====
63
+
64
+ describe('copyHooks', () => {
65
+ it('copies pre-commit and pre-push to destination', () => {
66
+ createPackageSource();
67
+ const mod = getModule();
68
+ const dest = path.join(tmpProject, 'hooks-dest');
69
+ fs.mkdirSync(dest, { recursive: true });
70
+
71
+ mod.copyHooks(tmpPackage, dest, false, true);
72
+
73
+ expect(fs.readFileSync(path.join(dest, 'pre-commit'), 'utf8')).toContain('hook-v2');
74
+ expect(fs.readFileSync(path.join(dest, 'pre-push'), 'utf8')).toContain('push-v2');
75
+ });
76
+
77
+ it('creates destination directory if it does not exist', () => {
78
+ createPackageSource();
79
+ const mod = getModule();
80
+ const dest = path.join(tmpProject, 'deep', 'nested', 'hooks');
81
+
82
+ mod.copyHooks(tmpPackage, dest, false, true);
83
+
84
+ expect(fs.existsSync(dest)).toBe(true);
85
+ expect(fs.existsSync(path.join(dest, 'pre-commit'))).toBe(true);
86
+ });
87
+
88
+ it('sets executable permissions (0o755) on copied hooks', () => {
89
+ createPackageSource();
90
+ const mod = getModule();
91
+ const dest = path.join(tmpProject, 'hooks-perm');
92
+ fs.mkdirSync(dest, { recursive: true });
93
+
94
+ mod.copyHooks(tmpPackage, dest, false, true);
95
+
96
+ const stat = fs.statSync(path.join(dest, 'pre-commit'));
97
+ expect(stat.mode & 0o100).toBeTruthy();
98
+ });
99
+ });
100
+
101
+ // ===== copyAdapters tests =====
102
+
103
+ describe('copyAdapters', () => {
104
+ it('copies adapter-common.sh and adapters/*.sh', () => {
105
+ createPackageSource();
106
+ const mod = getModule();
107
+ const dest = path.join(tmpProject, 'adapters-dest');
108
+ fs.mkdirSync(path.join(dest, 'adapters'), { recursive: true });
109
+
110
+ mod.copyAdapters(tmpPackage, dest, false, true);
111
+
112
+ expect(fs.readFileSync(path.join(dest, 'adapter-common.sh'), 'utf8')).toContain('adapter-common-v2');
113
+ expect(fs.readFileSync(path.join(dest, 'adapters', 'typescript.sh'), 'utf8')).toContain('ts-v2');
114
+ expect(fs.readFileSync(path.join(dest, 'adapters', 'python.sh'), 'utf8')).toContain('py-v2');
115
+ });
116
+
117
+ it('creates adapters subdirectory if it does not exist', () => {
118
+ createPackageSource();
119
+ const mod = getModule();
120
+ const dest = path.join(tmpProject, 'adapters-new');
121
+
122
+ mod.copyAdapters(tmpPackage, dest, false, true);
123
+
124
+ expect(fs.existsSync(path.join(dest, 'adapters', 'typescript.sh'))).toBe(true);
125
+ });
126
+ });
127
+
128
+ // ===== copyGateScripts tests =====
129
+
130
+ describe('copyGateScripts', () => {
131
+ it('copies gate-*.sh scripts from package root', () => {
132
+ createPackageSource();
133
+ const mod = getModule();
134
+ const dest = path.join(tmpProject, 'gates-dest');
135
+ fs.mkdirSync(dest, { recursive: true });
136
+
137
+ mod.copyGateScripts(tmpPackage, dest, false, true);
138
+
139
+ expect(fs.readFileSync(path.join(dest, 'gate-3.sh'), 'utf8')).toContain('gate3-v2');
140
+ expect(fs.readFileSync(path.join(dest, 'gate-4.sh'), 'utf8')).toContain('gate4-v2');
141
+ });
142
+
143
+ it('does not copy non-gate files', () => {
144
+ createPackageSource();
145
+ fs.writeFileSync(path.join(tmpPackage, 'some-other.sh'), 'echo other');
146
+ const mod = getModule();
147
+ const dest = path.join(tmpProject, 'gates-other');
148
+ fs.mkdirSync(dest, { recursive: true });
149
+
150
+ mod.copyGateScripts(tmpPackage, dest, false, true);
151
+
152
+ expect(fs.existsSync(path.join(dest, 'some-other.sh'))).toBe(false);
153
+ });
154
+
155
+ it('handles missing source directory gracefully', () => {
156
+ const mod = getModule();
157
+ const dest = path.join(tmpProject, 'gates-empty');
158
+ fs.mkdirSync(dest, { recursive: true });
159
+
160
+ mod.copyGateScripts('/nonexistent/path', dest, false, true);
161
+
162
+ const gateFiles = fs.readdirSync(dest).filter(f => f.startsWith('gate-'));
163
+ expect(gateFiles).toEqual([]);
164
+ });
165
+ });
166
+
167
+ // ===== atomicCopyFile tests =====
168
+
169
+ describe('atomicCopyFile', () => {
170
+ it('returns false when source does not exist', () => {
171
+ const mod = getModule();
172
+ const result = mod.atomicCopyFile('/nonexistent/file', '/tmp/dest', false, true, 'test-file');
173
+ expect(result).toBe(false);
174
+ });
175
+
176
+ it('warns when source does not exist', () => {
177
+ const mod = getModule();
178
+ mod.atomicCopyFile('/nonexistent/file', '/tmp/dest', false, true, 'test-file');
179
+ expect(warnSpy).toHaveBeenCalledWith(' ⚠ test-file not found, skipping');
180
+ });
181
+
182
+ it('returns true and logs in dry-run mode for existing source', () => {
183
+ createPackageSource();
184
+ const mod = getModule();
185
+ const src = path.join(tmpPackage, 'hooks', 'pre-commit');
186
+ const dest = path.join(tmpProject, 'dry-dest', 'pre-commit');
187
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
188
+
189
+ const result = mod.atomicCopyFile(src, dest, true, true, 'pre-commit');
190
+ expect(result).toBe(true);
191
+ expect(logSpy).toHaveBeenCalledWith(' would update: pre-commit');
192
+ expect(fs.existsSync(dest)).toBe(false);
193
+ });
194
+
195
+ it('returns false in dry-run mode when source does not exist', () => {
196
+ const mod = getModule();
197
+ const result = mod.atomicCopyFile('/nonexistent/file', '/tmp/dest', true, true, 'test-file');
198
+ expect(result).toBe(false);
199
+ expect(warnSpy).toHaveBeenCalledWith(' ⚠ test-file not found, skipping');
200
+ });
201
+
202
+ it('uses atomic write (temp file + rename)', () => {
203
+ createPackageSource();
204
+ const mod = getModule();
205
+ const src = path.join(tmpPackage, 'hooks', 'pre-commit');
206
+ const dest = path.join(tmpProject, 'atomic-dest', 'pre-commit');
207
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
208
+
209
+ mod.atomicCopyFile(src, dest, false, true, 'pre-commit');
210
+
211
+ expect(fs.readFileSync(dest, 'utf8')).toContain('hook-v2');
212
+ expect(fs.existsSync(`${dest}.tmp`)).toBe(false);
213
+ });
214
+
215
+ it('creates .bak backup when backup enabled and dest exists', () => {
216
+ createPackageSource();
217
+ const mod = getModule();
218
+ const src = path.join(tmpPackage, 'hooks', 'pre-commit');
219
+ const destDir = path.join(tmpProject, 'bak-dest');
220
+ fs.mkdirSync(destDir, { recursive: true });
221
+ fs.writeFileSync(path.join(destDir, 'pre-commit'), '#!/bin/bash\necho "old"');
222
+
223
+ mod.atomicCopyFile(src, path.join(destDir, 'pre-commit'), false, false, 'pre-commit');
224
+
225
+ expect(fs.existsSync(path.join(destDir, 'pre-commit.bak'))).toBe(true);
226
+ expect(fs.readFileSync(path.join(destDir, 'pre-commit.bak'), 'utf8')).toContain('old');
227
+ expect(fs.readFileSync(path.join(destDir, 'pre-commit'), 'utf8')).toContain('hook-v2');
228
+ });
229
+
230
+ it('skips backup when noBackup is true', () => {
231
+ createPackageSource();
232
+ const mod = getModule();
233
+ const src = path.join(tmpPackage, 'hooks', 'pre-commit');
234
+ const destDir = path.join(tmpProject, 'nobak-dest');
235
+ fs.mkdirSync(destDir, { recursive: true });
236
+ fs.writeFileSync(path.join(destDir, 'pre-commit'), '#!/bin/bash\necho "old"');
237
+
238
+ mod.atomicCopyFile(src, path.join(destDir, 'pre-commit'), false, true, 'pre-commit');
239
+
240
+ expect(fs.existsSync(path.join(destDir, 'pre-commit.bak'))).toBe(false);
241
+ expect(fs.readFileSync(path.join(destDir, 'pre-commit'), 'utf8')).toContain('hook-v2');
242
+ });
243
+ });
244
+
245
+ // ===== detectLocalModifications tests =====
246
+
247
+ describe('detectLocalModifications', () => {
248
+ it('detects modified hook files', () => {
249
+ createPackageSource();
250
+ const mod = getModule();
251
+ const hooksDest = path.join(tmpProject, 'hooks-det');
252
+ const adaptersDest = path.join(tmpProject, 'adapters-det');
253
+ fs.mkdirSync(hooksDest, { recursive: true });
254
+ fs.mkdirSync(path.join(adaptersDest, 'adapters'), { recursive: true });
255
+
256
+ fs.writeFileSync(path.join(hooksDest, 'pre-commit'), '#!/bin/bash\necho "custom"');
257
+ fs.writeFileSync(path.join(hooksDest, 'pre-push'), '#!/bin/bash\necho "push-v2"');
258
+
259
+ const modified = mod.detectLocalModifications(tmpPackage, hooksDest, adaptersDest);
260
+ expect(modified).toContain('pre-commit');
261
+ expect(modified).not.toContain('pre-push');
262
+ });
263
+
264
+ it('detects modified adapter files', () => {
265
+ createPackageSource();
266
+ const mod = getModule();
267
+ const hooksDest = path.join(tmpProject, 'hooks-det2');
268
+ const adaptersDest = path.join(tmpProject, 'adapters-det2');
269
+ fs.mkdirSync(hooksDest, { recursive: true });
270
+ fs.mkdirSync(path.join(adaptersDest, 'adapters'), { recursive: true });
271
+
272
+ fs.writeFileSync(path.join(adaptersDest, 'adapter-common.sh'), '#!/bin/bash\necho "custom-adapter"');
273
+
274
+ const modified = mod.detectLocalModifications(tmpPackage, hooksDest, adaptersDest);
275
+ expect(modified).toContain('adapter-common.sh');
276
+ });
277
+
278
+ it('detects modified adapter sub-files', () => {
279
+ createPackageSource();
280
+ const mod = getModule();
281
+ const hooksDest = path.join(tmpProject, 'hooks-det3');
282
+ const adaptersDest = path.join(tmpProject, 'adapters-det3');
283
+ fs.mkdirSync(hooksDest, { recursive: true });
284
+ fs.mkdirSync(path.join(adaptersDest, 'adapters'), { recursive: true });
285
+
286
+ fs.writeFileSync(path.join(adaptersDest, 'adapters', 'typescript.sh'), '#!/bin/bash\necho "custom-ts"');
287
+
288
+ const modified = mod.detectLocalModifications(tmpPackage, hooksDest, adaptersDest);
289
+ expect(modified).toContain('adapters/typescript.sh');
290
+ });
291
+
292
+ it('detects modified gate scripts', () => {
293
+ createPackageSource();
294
+ const mod = getModule();
295
+ const hooksDest = path.join(tmpProject, 'hooks-det4');
296
+ const adaptersDest = path.join(tmpProject, 'adapters-det4');
297
+ fs.mkdirSync(hooksDest, { recursive: true });
298
+ fs.mkdirSync(path.join(adaptersDest, 'adapters'), { recursive: true });
299
+
300
+ fs.writeFileSync(path.join(adaptersDest, 'gate-3.sh'), '#!/bin/bash\necho "custom-gate3"');
301
+
302
+ const modified = mod.detectLocalModifications(tmpPackage, hooksDest, adaptersDest);
303
+ expect(modified).toContain('gate-3.sh');
304
+ });
305
+
306
+ it('returns empty array when files match source', () => {
307
+ createPackageSource();
308
+ const mod = getModule();
309
+ const hooksDest = path.join(tmpProject, 'hooks-match');
310
+ const adaptersDest = path.join(tmpProject, 'adapters-match');
311
+ fs.mkdirSync(hooksDest, { recursive: true });
312
+ fs.mkdirSync(path.join(adaptersDest, 'adapters'), { recursive: true });
313
+
314
+ fs.writeFileSync(path.join(hooksDest, 'pre-commit'), '#!/bin/bash\necho "hook-v2"');
315
+ fs.writeFileSync(path.join(hooksDest, 'pre-push'), '#!/bin/bash\necho "push-v2"');
316
+ fs.writeFileSync(path.join(adaptersDest, 'adapter-common.sh'), '#!/bin/bash\necho "adapter-common-v2"');
317
+
318
+ const modified = mod.detectLocalModifications(tmpPackage, hooksDest, adaptersDest);
319
+ expect(modified).toEqual([]);
320
+ });
321
+
322
+ it('returns empty array when no dest files exist', () => {
323
+ createPackageSource();
324
+ const mod = getModule();
325
+ const hooksDest = path.join(tmpProject, 'hooks-empty');
326
+ const adaptersDest = path.join(tmpProject, 'adapters-empty');
327
+ fs.mkdirSync(hooksDest, { recursive: true });
328
+ fs.mkdirSync(path.join(adaptersDest, 'adapters'), { recursive: true });
329
+
330
+ const modified = mod.detectLocalModifications(tmpPackage, hooksDest, adaptersDest);
331
+ expect(modified).toEqual([]);
332
+ });
333
+ });
334
+
335
+ // ===== getPackageRoot tests =====
336
+
337
+ describe('getPackageRoot', () => {
338
+ it('returns the parent directory of lib/', () => {
339
+ const mod = getModule();
340
+ const root = mod.getPackageRoot();
341
+ const expected = path.resolve(path.dirname(require.resolve('../update-hooks')), '..');
342
+ expect(root).toBe(expected);
343
+ });
344
+ });
345
+
346
+ // ===== getProjectHooksDir tests =====
347
+
348
+ describe('getProjectHooksDir', () => {
349
+ it('returns .git/hooks path under cwd', () => {
350
+ const mod = getModule();
351
+ const cwd = process.cwd();
352
+ const result = mod.getProjectHooksDir();
353
+ expect(result).toBe(path.join(cwd, '.git', 'hooks'));
354
+ });
355
+
356
+ it('throws when .git/ does not exist', () => {
357
+ vi.spyOn(process, 'cwd').mockReturnValue(tmpProject);
358
+ const mod = getModule();
359
+ expect(() => mod.getProjectHooksDir()).toThrow('Not a Git repository');
360
+ });
361
+ });
362
+
363
+ // ===== updateHooks integration tests =====
364
+
365
+ describe('updateHooks', () => {
366
+ it('returns 0 on success with force flag', () => {
367
+ createPackageSource();
368
+ const gitDir = path.join(tmpProject, '.git');
369
+ fs.mkdirSync(path.join(gitDir, 'hooks'), { recursive: true });
370
+ vi.spyOn(process, 'cwd').mockReturnValue(tmpProject);
371
+ const mod = getModule();
372
+ const origGetPackageRoot = mod.getPackageRoot;
373
+ mod.getPackageRoot = () => tmpPackage;
374
+
375
+ const result = mod.updateHooks({
376
+ global: false,
377
+ force: true,
378
+ dryRun: false,
379
+ noBackup: true,
380
+ scope: 'all',
381
+ });
382
+ expect(result).toBe(0);
383
+
384
+ mod.getPackageRoot = origGetPackageRoot;
385
+ });
386
+
387
+ it('returns 1 when local modifications detected and no --force', () => {
388
+ createPackageSource();
389
+ const hooksDest = path.join(tmpProject, '.git', 'hooks');
390
+ fs.mkdirSync(hooksDest, { recursive: true });
391
+ fs.writeFileSync(path.join(hooksDest, 'pre-commit'), '#!/bin/bash\necho "custom"');
392
+
393
+ vi.spyOn(process, 'cwd').mockReturnValue(tmpProject);
394
+ const mod = getModule();
395
+ const origGetPackageRoot = mod.getPackageRoot;
396
+ mod.getPackageRoot = () => tmpPackage;
397
+
398
+ const result = mod.updateHooks({
399
+ global: false,
400
+ force: false,
401
+ dryRun: false,
402
+ noBackup: true,
403
+ scope: 'all',
404
+ });
405
+ expect(result).toBe(1);
406
+ expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('locally modified'));
407
+
408
+ mod.getPackageRoot = origGetPackageRoot;
409
+ });
410
+
411
+ it('--force bypasses modification detection', () => {
412
+ createPackageSource();
413
+ const hooksDest = path.join(tmpProject, '.git', 'hooks');
414
+ fs.mkdirSync(hooksDest, { recursive: true });
415
+ fs.writeFileSync(path.join(hooksDest, 'pre-commit'), '#!/bin/bash\necho "custom"');
416
+
417
+ vi.spyOn(process, 'cwd').mockReturnValue(tmpProject);
418
+ const mod = getModule();
419
+ const origGetPackageRoot = mod.getPackageRoot;
420
+ mod.getPackageRoot = () => tmpPackage;
421
+
422
+ const result = mod.updateHooks({
423
+ global: false,
424
+ force: true,
425
+ dryRun: false,
426
+ noBackup: true,
427
+ scope: 'all',
428
+ });
429
+ expect(result).toBe(0);
430
+ expect(fs.readFileSync(path.join(hooksDest, 'pre-commit'), 'utf8')).toContain('hook-v2');
431
+
432
+ mod.getPackageRoot = origGetPackageRoot;
433
+ });
434
+
435
+ it('--dry-run shows plan without writing files', () => {
436
+ createPackageSource();
437
+ const gitDir = path.join(tmpProject, '.git');
438
+ fs.mkdirSync(path.join(gitDir, 'hooks'), { recursive: true });
439
+ vi.spyOn(process, 'cwd').mockReturnValue(tmpProject);
440
+ const mod = getModule();
441
+ const origGetPackageRoot = mod.getPackageRoot;
442
+ mod.getPackageRoot = () => tmpPackage;
443
+
444
+ const result = mod.updateHooks({
445
+ global: false,
446
+ force: false,
447
+ dryRun: true,
448
+ noBackup: true,
449
+ scope: 'all',
450
+ });
451
+ expect(result).toBe(0);
452
+ expect(logSpy).toHaveBeenCalledWith('Dry run: yes (no files will be modified)');
453
+ expect(logSpy).toHaveBeenCalledWith(' would update: pre-commit');
454
+ expect(logSpy).toHaveBeenCalledWith(' would update: pre-push');
455
+
456
+ mod.getPackageRoot = origGetPackageRoot;
457
+ });
458
+
459
+ it('--scope hooks only copies hooks', () => {
460
+ createPackageSource();
461
+ const gitDir = path.join(tmpProject, '.git');
462
+ fs.mkdirSync(path.join(gitDir, 'hooks'), { recursive: true });
463
+ vi.spyOn(process, 'cwd').mockReturnValue(tmpProject);
464
+ const mod = getModule();
465
+ const origGetPackageRoot = mod.getPackageRoot;
466
+ mod.getPackageRoot = () => tmpPackage;
467
+
468
+ mod.updateHooks({
469
+ global: false,
470
+ force: true,
471
+ dryRun: false,
472
+ noBackup: true,
473
+ scope: 'hooks',
474
+ });
475
+
476
+ expect(logSpy).toHaveBeenCalledWith('Updating hooks...');
477
+ expect(logSpy).not.toHaveBeenCalledWith('Updating adapters...');
478
+
479
+ mod.getPackageRoot = origGetPackageRoot;
480
+ });
481
+
482
+ it('--scope adapters only copies adapters', () => {
483
+ createPackageSource();
484
+ const gitDir = path.join(tmpProject, '.git');
485
+ fs.mkdirSync(path.join(gitDir, 'hooks'), { recursive: true });
486
+ vi.spyOn(process, 'cwd').mockReturnValue(tmpProject);
487
+ const mod = getModule();
488
+ const origGetPackageRoot = mod.getPackageRoot;
489
+ mod.getPackageRoot = () => tmpPackage;
490
+
491
+ mod.updateHooks({
492
+ global: false,
493
+ force: true,
494
+ dryRun: false,
495
+ noBackup: true,
496
+ scope: 'adapters',
497
+ });
498
+
499
+ expect(logSpy).toHaveBeenCalledWith('Updating adapters...');
500
+ expect(logSpy).not.toHaveBeenCalledWith('Updating hooks...');
501
+
502
+ mod.getPackageRoot = origGetPackageRoot;
503
+ });
504
+
505
+ it('logs mode and destination info', () => {
506
+ createPackageSource();
507
+ const gitDir = path.join(tmpProject, '.git');
508
+ fs.mkdirSync(path.join(gitDir, 'hooks'), { recursive: true });
509
+ vi.spyOn(process, 'cwd').mockReturnValue(tmpProject);
510
+ const mod = getModule();
511
+ const origGetPackageRoot = mod.getPackageRoot;
512
+ mod.getPackageRoot = () => tmpPackage;
513
+
514
+ mod.updateHooks({
515
+ global: false,
516
+ force: true,
517
+ dryRun: false,
518
+ noBackup: true,
519
+ scope: 'hooks',
520
+ });
521
+
522
+ expect(logSpy).toHaveBeenCalledWith('XP-Gate Update Hooks');
523
+ expect(logSpy).toHaveBeenCalledWith('====================');
524
+ expect(logSpy).toHaveBeenCalledWith('Mode: Local');
525
+
526
+ mod.getPackageRoot = origGetPackageRoot;
527
+ });
528
+
529
+ it('logs global mode correctly', () => {
530
+ createPackageSource();
531
+ const mod = getModule();
532
+ const origGetPackageRoot = mod.getPackageRoot;
533
+ mod.getPackageRoot = () => tmpPackage;
534
+
535
+ mod.updateHooks({
536
+ global: true,
537
+ force: true,
538
+ dryRun: false,
539
+ noBackup: true,
540
+ scope: 'hooks',
541
+ });
542
+
543
+ expect(logSpy).toHaveBeenCalledWith('Mode: Global');
544
+
545
+ mod.getPackageRoot = origGetPackageRoot;
546
+ });
547
+
548
+ it('creates backup files in default mode', () => {
549
+ createPackageSource();
550
+ const hooksDest = path.join(tmpProject, '.git', 'hooks');
551
+ fs.mkdirSync(hooksDest, { recursive: true });
552
+ fs.writeFileSync(path.join(hooksDest, 'pre-commit'), '#!/bin/bash\necho "old"');
553
+
554
+ vi.spyOn(process, 'cwd').mockReturnValue(tmpProject);
555
+ const mod = getModule();
556
+ const origGetPackageRoot = mod.getPackageRoot;
557
+ mod.getPackageRoot = () => tmpPackage;
558
+
559
+ mod.updateHooks({
560
+ global: false,
561
+ force: true,
562
+ dryRun: false,
563
+ noBackup: false,
564
+ scope: 'hooks',
565
+ });
566
+
567
+ expect(fs.existsSync(path.join(hooksDest, 'pre-commit.bak'))).toBe(true);
568
+ expect(fs.readFileSync(path.join(hooksDest, 'pre-commit.bak'), 'utf8')).toContain('old');
569
+
570
+ mod.getPackageRoot = origGetPackageRoot;
571
+ });
572
+ });
573
+ });
package/lib/init.js CHANGED
@@ -35,13 +35,16 @@ function copyAdapters(srcDir, destDir) {
35
35
  }
36
36
  });
37
37
  }
38
- // Copy gate scripts (gate-3.sh through gate-9.sh) from package root to destDir.
38
+ // Copy gate scripts (gate-*.sh) from githooks/ source-of-truth to destDir.
39
39
  // These are sourced by pre-commit via GATE_DIR which resolves to the adapter dir.
40
- fs.readdirSync(srcDir).forEach(f => {
41
- if (f.startsWith('gate-') && f.endsWith('.sh')) {
42
- fs.copyFileSync(path.join(srcDir, f), path.join(destDir, f));
43
- }
44
- });
40
+ const githooksDir = path.resolve(srcDir, '..', '..', '..', 'githooks');
41
+ if (fs.existsSync(githooksDir)) {
42
+ fs.readdirSync(githooksDir).forEach(f => {
43
+ if (f.startsWith('gate-') && f.endsWith('.sh')) {
44
+ fs.copyFileSync(path.join(githooksDir, f), path.join(destDir, f));
45
+ }
46
+ });
47
+ }
45
48
  }
46
49
 
47
50
  function copyRecursive(src, dest) {