@boyingliu01/xp-gate 0.12.2 → 0.12.3
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/bin/xp-gate.js +22 -0
- package/lib/__tests__/update-hooks.test.js +573 -0
- package/lib/update-hooks.js +288 -0
- package/mock-policy/AGENTS.md +3 -3
- package/mutation/AGENTS.md +3 -3
- package/package.json +1 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +6 -6
- package/plugins/claude-code/skills/delphi-review/SKILL.md +34 -12
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +6 -6
- package/plugins/opencode/skills/delphi-review/SKILL.md +34 -12
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +6 -6
- package/plugins/qoder/skills/delphi-review/SKILL.md +198 -264
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
- package/principles/AGENTS.md +3 -3
- package/skills/delphi-review/AGENTS.md +6 -6
- package/skills/delphi-review/SKILL.md +34 -12
- package/skills/sprint-flow/AGENTS.md +3 -3
- package/skills/test-specification-alignment/AGENTS.md +3 -3
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');
|
|
@@ -143,6 +144,27 @@ const COMMANDS = {
|
|
|
143
144
|
handleNextSprint(subargs).then(code => process.exit(code));
|
|
144
145
|
},
|
|
145
146
|
usage: 'xp-gate next-sprint [--json] [--plan] [--dir <path>]'
|
|
147
|
+
},
|
|
148
|
+
'update-hooks': {
|
|
149
|
+
description: 'Sync latest hooks from xp-gate package to project',
|
|
150
|
+
run: subargs => {
|
|
151
|
+
const scopeArg = subargs.find(a => a.startsWith('--scope='));
|
|
152
|
+
const scopeVal = scopeArg ? scopeArg.split('=')[1] : 'all';
|
|
153
|
+
const options = {
|
|
154
|
+
global: subargs.includes('--global'),
|
|
155
|
+
force: subargs.includes('--force'),
|
|
156
|
+
dryRun: subargs.includes('--dry-run'),
|
|
157
|
+
noBackup: subargs.includes('--no-backup'),
|
|
158
|
+
scope: scopeVal,
|
|
159
|
+
};
|
|
160
|
+
try {
|
|
161
|
+
process.exit(updateHooks(options));
|
|
162
|
+
} catch (err) {
|
|
163
|
+
console.error(`Error: ${err.message}`);
|
|
164
|
+
process.exit(1);
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
usage: 'xp-gate update-hooks [--global] [--force] [--dry-run] [--no-backup] [--scope hooks|adapters|all]'
|
|
146
168
|
}
|
|
147
169
|
};
|
|
148
170
|
|
|
@@ -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
|
+
});
|