@mmnto/cli 1.120.0 → 1.121.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/dist/commands/config-drift.test.js +10 -2
- package/dist/commands/config-drift.test.js.map +1 -1
- package/dist/commands/doctor-parity.d.ts.map +1 -1
- package/dist/commands/doctor-parity.js +21 -6
- package/dist/commands/doctor-parity.js.map +1 -1
- package/dist/commands/doctor-parity.test.js +14 -7
- package/dist/commands/doctor-parity.test.js.map +1 -1
- package/dist/commands/doctor.d.ts +21 -1
- package/dist/commands/doctor.d.ts.map +1 -1
- package/dist/commands/doctor.js +167 -9
- package/dist/commands/doctor.js.map +1 -1
- package/dist/commands/doctor.test.js +234 -6
- package/dist/commands/doctor.test.js.map +1 -1
- package/dist/commands/ecl-gc.d.ts.map +1 -1
- package/dist/commands/ecl-gc.js +38 -5
- package/dist/commands/ecl-gc.js.map +1 -1
- package/dist/commands/ecl-gc.test.js +71 -0
- package/dist/commands/ecl-gc.test.js.map +1 -1
- package/dist/commands/eject-totemdir.test.d.ts +8 -0
- package/dist/commands/eject-totemdir.test.d.ts.map +1 -0
- package/dist/commands/eject-totemdir.test.js +101 -0
- package/dist/commands/eject-totemdir.test.js.map +1 -0
- package/dist/commands/eject.d.ts +30 -5
- package/dist/commands/eject.d.ts.map +1 -1
- package/dist/commands/eject.js +145 -31
- package/dist/commands/eject.js.map +1 -1
- package/dist/commands/eject.test.js +3 -3
- package/dist/commands/eject.test.js.map +1 -1
- package/dist/commands/hook-totemdir-render.test.d.ts +23 -0
- package/dist/commands/hook-totemdir-render.test.d.ts.map +1 -0
- package/dist/commands/hook-totemdir-render.test.js +288 -0
- package/dist/commands/hook-totemdir-render.test.js.map +1 -0
- package/dist/commands/init-templates.d.ts +4 -4
- package/dist/commands/init-templates.d.ts.map +1 -1
- package/dist/commands/init-templates.js +3 -3
- package/dist/commands/install-hooks-exit-contract.test.js +6 -6
- package/dist/commands/install-hooks-exit-contract.test.js.map +1 -1
- package/dist/commands/install-hooks.d.ts +162 -12
- package/dist/commands/install-hooks.d.ts.map +1 -1
- package/dist/commands/install-hooks.js +355 -88
- package/dist/commands/install-hooks.js.map +1 -1
- package/dist/commands/install-hooks.test.js +381 -147
- package/dist/commands/install-hooks.test.js.map +1 -1
- package/dist/commands/link.d.ts.map +1 -1
- package/dist/commands/link.js +71 -16
- package/dist/commands/link.js.map +1 -1
- package/dist/commands/link.test.d.ts +9 -0
- package/dist/commands/link.test.d.ts.map +1 -0
- package/dist/commands/link.test.js +92 -0
- package/dist/commands/link.test.js.map +1 -0
- package/dist/commands/mail.d.ts +48 -2
- package/dist/commands/mail.d.ts.map +1 -1
- package/dist/commands/mail.js +76 -2
- package/dist/commands/mail.js.map +1 -1
- package/dist/commands/mail.test.js +177 -0
- package/dist/commands/mail.test.js.map +1 -1
- package/dist/commands/pre-push-gate-matrix.test.js +9 -2
- package/dist/commands/pre-push-gate-matrix.test.js.map +1 -1
- package/dist/commands/shield.js +1 -1
- package/dist/commands/shield.js.map +1 -1
- package/dist/commands/tools-hook-parity.test.js +7 -3
- package/dist/commands/tools-hook-parity.test.js.map +1 -1
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
|
@@ -5,6 +5,15 @@ import * as path from 'node:path';
|
|
|
5
5
|
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
6
6
|
import { cleanTmpDir } from '../test-utils.js';
|
|
7
7
|
import { buildHookContent, buildPostCheckoutHookContent, buildPreCommitHook, buildPrePushHook, buildResolveBlock, checkHooksInstalled, detectTotemPrefix, generateHookHelpers, getFallbackCommand, installGitHook, installHooksNonInteractive, TOTEM_HOOK_END, TOTEM_HOOK_MARKER, TOTEM_PRECOMMIT_END, TOTEM_PRECOMMIT_MARKER, TOTEM_PREPUSH_END, TOTEM_PREPUSH_MARKER, upgradePrePushHookIfNeeded, } from './install-hooks.js';
|
|
8
|
+
// The default render options every pre-#2692 positional call implied
|
|
9
|
+
// (mmnto-ai/totem#2692 C2 — the builders take a REQUIRED options object now, so
|
|
10
|
+
// each site states the tier/totemDir/fallbackCmd it always assumed). Spread and
|
|
11
|
+
// override the one field a case actually varies.
|
|
12
|
+
const RENDER = {
|
|
13
|
+
tier: 'standard',
|
|
14
|
+
totemDir: '.totem',
|
|
15
|
+
fallbackCmd: 'pnpm dlx @mmnto/cli',
|
|
16
|
+
};
|
|
8
17
|
describe('detectTotemPrefix', () => {
|
|
9
18
|
let tmpDir;
|
|
10
19
|
beforeEach(() => {
|
|
@@ -152,76 +161,75 @@ describe('buildResolveBlock', () => {
|
|
|
152
161
|
});
|
|
153
162
|
describe('buildPreCommitHook', () => {
|
|
154
163
|
it('contains the marker for idempotency', () => {
|
|
155
|
-
const hook = buildPreCommitHook();
|
|
164
|
+
const hook = buildPreCommitHook(RENDER);
|
|
156
165
|
expect(hook).toContain(TOTEM_PRECOMMIT_MARKER);
|
|
157
166
|
});
|
|
158
167
|
it('blocks main and master branches', () => {
|
|
159
|
-
const hook = buildPreCommitHook();
|
|
168
|
+
const hook = buildPreCommitHook(RENDER);
|
|
160
169
|
expect(hook).toContain('"main"');
|
|
161
170
|
expect(hook).toContain('"master"');
|
|
162
171
|
});
|
|
163
172
|
it('prints a helpful error message with override instructions', () => {
|
|
164
|
-
const hook = buildPreCommitHook();
|
|
173
|
+
const hook = buildPreCommitHook(RENDER);
|
|
165
174
|
expect(hook).toContain('git checkout -b feat/my-feature');
|
|
166
175
|
expect(hook).toContain('git commit --no-verify');
|
|
167
176
|
});
|
|
168
177
|
it('starts with a shebang', () => {
|
|
169
|
-
const hook = buildPreCommitHook();
|
|
178
|
+
const hook = buildPreCommitHook(RENDER);
|
|
170
179
|
expect(hook).toMatch(/^#!\/bin\/sh\n/);
|
|
171
180
|
});
|
|
172
181
|
it('exits with code 1 when on protected branch', () => {
|
|
173
|
-
const hook = buildPreCommitHook();
|
|
182
|
+
const hook = buildPreCommitHook(RENDER);
|
|
174
183
|
expect(hook).toContain('exit 1');
|
|
175
184
|
});
|
|
176
185
|
});
|
|
177
186
|
describe('buildPrePushHook', () => {
|
|
178
|
-
const FALLBACK = 'pnpm dlx @mmnto/cli';
|
|
179
187
|
it('contains the marker for idempotency', () => {
|
|
180
|
-
const hook = buildPrePushHook(
|
|
188
|
+
const hook = buildPrePushHook(RENDER);
|
|
181
189
|
expect(hook).toContain(TOTEM_PREPUSH_MARKER);
|
|
182
190
|
});
|
|
183
191
|
it('starts with a shebang', () => {
|
|
184
|
-
const hook = buildPrePushHook(
|
|
192
|
+
const hook = buildPrePushHook(RENDER);
|
|
185
193
|
expect(hook).toMatch(/^#!\/bin\/sh\n/);
|
|
186
194
|
});
|
|
187
195
|
it('does not advertise --no-verify escape hatch', () => {
|
|
188
|
-
const hook = buildPrePushHook(
|
|
196
|
+
const hook = buildPrePushHook(RENDER);
|
|
189
197
|
expect(hook).not.toContain('--no-verify');
|
|
190
198
|
});
|
|
191
199
|
it('contains verify-manifest check', () => {
|
|
192
|
-
const hook = buildPrePushHook(
|
|
200
|
+
const hook = buildPrePushHook(RENDER);
|
|
193
201
|
expect(hook).toContain('verify-manifest');
|
|
194
202
|
expect(hook).toContain('compile manifest is stale');
|
|
195
203
|
});
|
|
196
204
|
it('contains $TOTEM_CMD lint', () => {
|
|
197
|
-
const hook = buildPrePushHook(
|
|
205
|
+
const hook = buildPrePushHook(RENDER);
|
|
198
206
|
expect(hook).toContain('$TOTEM_CMD lint');
|
|
199
207
|
});
|
|
200
208
|
// totem-context: hook-content assertion (not an orchestrator test, no LLM calls — default vitest timeout is sufficient)
|
|
201
209
|
it('contains $TOTEM_CMD verify-badges (mmnto-ai/totem#1926)', () => {
|
|
202
|
-
const hook = buildPrePushHook(
|
|
210
|
+
const hook = buildPrePushHook(RENDER);
|
|
203
211
|
// totem-context: substring match on hook script content (not secret masking) — toContain is correct here
|
|
204
212
|
expect(hook).toContain('$TOTEM_CMD verify-badges');
|
|
205
213
|
});
|
|
206
214
|
// totem-context: hook-content assertion (not an orchestrator test, no LLM calls — default vitest timeout is sufficient)
|
|
207
215
|
it('gates verify-badges on README.md existing', () => {
|
|
208
|
-
const hook = buildPrePushHook(
|
|
216
|
+
const hook = buildPrePushHook(RENDER);
|
|
209
217
|
expect(hook).toMatch(/-f "README\.md".*verify-badges/s);
|
|
210
218
|
});
|
|
211
219
|
// totem-context: hook-content assertion (not an orchestrator test, no LLM calls — default vitest timeout is sufficient)
|
|
212
220
|
it('contains $TOTEM_CMD verify-lockfile-sync (mmnto-ai/totem#1961)', () => {
|
|
213
|
-
const hook = buildPrePushHook(
|
|
221
|
+
const hook = buildPrePushHook(RENDER);
|
|
214
222
|
// totem-context: substring match on hook script content (not secret masking) — toContain is correct here
|
|
215
223
|
expect(hook).toContain('$TOTEM_CMD verify-lockfile-sync');
|
|
216
224
|
});
|
|
217
225
|
// totem-context: hook-content assertion (not an orchestrator test, no LLM calls — default vitest timeout is sufficient)
|
|
218
226
|
it('gates verify-lockfile-sync on pnpm-lock.yaml existing', () => {
|
|
219
|
-
const hook = buildPrePushHook(
|
|
227
|
+
const hook = buildPrePushHook(RENDER);
|
|
220
228
|
expect(hook).toMatch(/-f "pnpm-lock\.yaml".*verify-lockfile-sync/s);
|
|
221
229
|
});
|
|
222
230
|
// totem-context: hook-content assertion (not an orchestrator test, no LLM calls — default vitest timeout is sufficient)
|
|
223
231
|
it('slots verify-lockfile-sync before claim-discipline in the pre-push sequence', () => {
|
|
224
|
-
const hook = buildPrePushHook(
|
|
232
|
+
const hook = buildPrePushHook(RENDER);
|
|
225
233
|
// Assert presence with toContain (precise matcher); index comparison is
|
|
226
234
|
// safe only because both substrings are guaranteed present by the
|
|
227
235
|
// toContain assertions above.
|
|
@@ -235,12 +243,12 @@ describe('buildPrePushHook', () => {
|
|
|
235
243
|
});
|
|
236
244
|
// totem-context: hook-content assertion (not an orchestrator test, no LLM calls — default vitest timeout is sufficient)
|
|
237
245
|
it('contains $TOTEM_CMD doctor --claim-discipline --strict (Proposal 279 Q3)', () => {
|
|
238
|
-
const hook = buildPrePushHook(
|
|
246
|
+
const hook = buildPrePushHook(RENDER);
|
|
239
247
|
expect(hook).toContain('$TOTEM_CMD doctor --claim-discipline --strict');
|
|
240
248
|
});
|
|
241
249
|
// totem-context: hook-content assertion (mmnto-ai/totem#2002 — diff-scope narrowing)
|
|
242
250
|
it('contains --scope-to-diff on the claim-discipline invocation (mmnto-ai/totem#2002)', () => {
|
|
243
|
-
const hook = buildPrePushHook(
|
|
251
|
+
const hook = buildPrePushHook(RENDER);
|
|
244
252
|
// The flag must appear on the same `doctor --claim-discipline` line so the
|
|
245
253
|
// hook narrows the WWND scan to diff-touched files. Standing-gate full scan
|
|
246
254
|
// produced N=8 false-positive bypasses in <24hr on a pre-existing surface
|
|
@@ -249,7 +257,7 @@ describe('buildPrePushHook', () => {
|
|
|
249
257
|
});
|
|
250
258
|
// totem-context: hook-content assertion (mmnto-ai/totem#2002 — bootstrap defensive degrade)
|
|
251
259
|
it('defensively degrades --scope-to-diff when CLI predates 1.47.0 (cohort bootstrap safety)', () => {
|
|
252
|
-
const hook = buildPrePushHook(
|
|
260
|
+
const hook = buildPrePushHook(RENDER);
|
|
253
261
|
// The hook MUST detect --scope-to-diff support via --help and fall back to
|
|
254
262
|
// standing-scan when the resolved CLI doesn't carry the flag. Required so a
|
|
255
263
|
// cohort agent whose global @mmnto/cli predates 1.47.0 doesn't fail commander
|
|
@@ -270,13 +278,13 @@ describe('buildPrePushHook', () => {
|
|
|
270
278
|
});
|
|
271
279
|
// totem-context: hook-content assertion (not an orchestrator test, no LLM calls — default vitest timeout is sufficient)
|
|
272
280
|
it('gates claim-discipline on at-least-one in-scope public surface existing', () => {
|
|
273
|
-
const hook = buildPrePushHook(
|
|
281
|
+
const hook = buildPrePushHook(RENDER);
|
|
274
282
|
// The gate fires when any of README.md, AGENTS.md, design-tenets.md, or docs/wiki/ exists
|
|
275
283
|
expect(hook).toMatch(/-f "README\.md".*-f "AGENTS\.md".*-f "design-tenets\.md".*-d "docs\/wiki"/s);
|
|
276
284
|
});
|
|
277
285
|
// totem-context: hook-content assertion (not an orchestrator test, no LLM calls — default vitest timeout is sufficient)
|
|
278
286
|
it('slots claim-discipline after verify-badges in the pre-push sequence', () => {
|
|
279
|
-
const hook = buildPrePushHook(
|
|
287
|
+
const hook = buildPrePushHook(RENDER);
|
|
280
288
|
const verifyBadgesIdx = hook.indexOf('$TOTEM_CMD verify-badges');
|
|
281
289
|
const claimDisciplineIdx = hook.indexOf('$TOTEM_CMD doctor --claim-discipline');
|
|
282
290
|
expect(verifyBadgesIdx).toBeGreaterThan(-1);
|
|
@@ -285,36 +293,36 @@ describe('buildPrePushHook', () => {
|
|
|
285
293
|
});
|
|
286
294
|
// totem-context: hook-content assertion (not an orchestrator test, no LLM calls — default vitest timeout is sufficient)
|
|
287
295
|
it('mentions TOTEM_GATE_BYPASS_JUSTIFICATION as the bypass mechanism (Proposal 279 Q3 standardized convention)', () => {
|
|
288
|
-
const hook = buildPrePushHook(
|
|
296
|
+
const hook = buildPrePushHook(RENDER);
|
|
289
297
|
expect(hook).toContain('TOTEM_GATE_BYPASS_JUSTIFICATION');
|
|
290
298
|
});
|
|
291
299
|
it('does NOT contain old flag-file references', () => {
|
|
292
|
-
const hook = buildPrePushHook(
|
|
300
|
+
const hook = buildPrePushHook(RENDER);
|
|
293
301
|
expect(hook).not.toContain('.lint-passed');
|
|
294
302
|
expect(hook).not.toContain('.shield-passed');
|
|
295
303
|
expect(hook).not.toContain('.target-globs');
|
|
296
304
|
});
|
|
297
305
|
it('does NOT contain merge-base (no ancestry checks)', () => {
|
|
298
|
-
const hook = buildPrePushHook(
|
|
306
|
+
const hook = buildPrePushHook(RENDER);
|
|
299
307
|
expect(hook).not.toContain('merge-base');
|
|
300
308
|
});
|
|
301
309
|
it('uses the resolve block', () => {
|
|
302
|
-
const hook = buildPrePushHook(
|
|
310
|
+
const hook = buildPrePushHook(RENDER);
|
|
303
311
|
expect(hook).toContain('TOTEM_CMD=');
|
|
304
312
|
expect(hook).toContain('command -v totem');
|
|
305
313
|
});
|
|
306
314
|
it('uses POSIX-compatible syntax only', () => {
|
|
307
|
-
const hook = buildPrePushHook(
|
|
315
|
+
const hook = buildPrePushHook(RENDER);
|
|
308
316
|
// Must use [ ] not [[ ]]
|
|
309
317
|
expect(hook).not.toContain('[[');
|
|
310
318
|
expect(hook).not.toContain(']]');
|
|
311
319
|
});
|
|
312
320
|
it('embeds the provided fallback command', () => {
|
|
313
|
-
const hook = buildPrePushHook('yarn dlx @mmnto/cli');
|
|
321
|
+
const hook = buildPrePushHook({ ...RENDER, fallbackCmd: 'yarn dlx @mmnto/cli' });
|
|
314
322
|
expect(hook).toContain('yarn dlx @mmnto/cli');
|
|
315
323
|
});
|
|
316
324
|
it('includes format:check before push', () => {
|
|
317
|
-
const hook = buildPrePushHook(
|
|
325
|
+
const hook = buildPrePushHook(RENDER);
|
|
318
326
|
expect(hook).toContain('format:check');
|
|
319
327
|
expect(hook).toContain('Formatting check failed');
|
|
320
328
|
});
|
|
@@ -330,7 +338,7 @@ describe('installGitHook', () => {
|
|
|
330
338
|
cleanTmpDir(tmpDir);
|
|
331
339
|
});
|
|
332
340
|
it('creates a new hook file when none exists', () => {
|
|
333
|
-
const content = buildPreCommitHook();
|
|
341
|
+
const content = buildPreCommitHook(RENDER);
|
|
334
342
|
const result = installGitHook(hooksDir, 'pre-commit', content, TOTEM_PRECOMMIT_MARKER);
|
|
335
343
|
expect(result).toBe('installed');
|
|
336
344
|
expect(fs.existsSync(path.join(hooksDir, 'pre-commit'))).toBe(true);
|
|
@@ -339,11 +347,11 @@ describe('installGitHook', () => {
|
|
|
339
347
|
});
|
|
340
348
|
it('creates parent directories as needed', () => {
|
|
341
349
|
expect(fs.existsSync(hooksDir)).toBe(false);
|
|
342
|
-
installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(), TOTEM_PRECOMMIT_MARKER);
|
|
350
|
+
installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(RENDER), TOTEM_PRECOMMIT_MARKER);
|
|
343
351
|
expect(fs.existsSync(hooksDir)).toBe(true);
|
|
344
352
|
});
|
|
345
353
|
it('returns exists when marker is already present (idempotent)', () => {
|
|
346
|
-
const content = buildPreCommitHook();
|
|
354
|
+
const content = buildPreCommitHook(RENDER);
|
|
347
355
|
installGitHook(hooksDir, 'pre-commit', content, TOTEM_PRECOMMIT_MARKER);
|
|
348
356
|
const result = installGitHook(hooksDir, 'pre-commit', content, TOTEM_PRECOMMIT_MARKER);
|
|
349
357
|
expect(result).toBe('exists');
|
|
@@ -353,7 +361,7 @@ describe('installGitHook', () => {
|
|
|
353
361
|
const hookPath = path.join(hooksDir, 'pre-commit');
|
|
354
362
|
const userHook = '#!/bin/sh\necho "user hook"\n';
|
|
355
363
|
fs.writeFileSync(hookPath, userHook);
|
|
356
|
-
const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(), TOTEM_PRECOMMIT_MARKER);
|
|
364
|
+
const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(RENDER), TOTEM_PRECOMMIT_MARKER);
|
|
357
365
|
expect(result).toBe('appended');
|
|
358
366
|
const written = fs.readFileSync(hookPath, 'utf-8');
|
|
359
367
|
expect(written).toContain('echo "user hook"');
|
|
@@ -366,13 +374,13 @@ describe('installGitHook', () => {
|
|
|
366
374
|
const hookPath = path.join(hooksDir, 'pre-push');
|
|
367
375
|
const userHook = '#!/bin/sh\nrun_my_tests\n';
|
|
368
376
|
fs.writeFileSync(hookPath, userHook);
|
|
369
|
-
installGitHook(hooksDir, 'pre-push', buildPrePushHook(
|
|
377
|
+
installGitHook(hooksDir, 'pre-push', buildPrePushHook(RENDER), TOTEM_PREPUSH_MARKER);
|
|
370
378
|
const written = fs.readFileSync(hookPath, 'utf-8');
|
|
371
379
|
expect(written).toContain('run_my_tests');
|
|
372
380
|
expect(written).toContain(TOTEM_PREPUSH_MARKER);
|
|
373
381
|
});
|
|
374
382
|
it('is idempotent — double install does not duplicate', () => {
|
|
375
|
-
const content = buildPrePushHook(
|
|
383
|
+
const content = buildPrePushHook(RENDER);
|
|
376
384
|
installGitHook(hooksDir, 'pre-push', content, TOTEM_PREPUSH_MARKER);
|
|
377
385
|
installGitHook(hooksDir, 'pre-push', content, TOTEM_PREPUSH_MARKER);
|
|
378
386
|
const written = fs.readFileSync(path.join(hooksDir, 'pre-push'), 'utf-8');
|
|
@@ -384,7 +392,7 @@ describe('installGitHook', () => {
|
|
|
384
392
|
const hookPath = path.join(hooksDir, 'pre-commit');
|
|
385
393
|
const nodeHook = '#!/usr/bin/env node\nconsole.log("lint");\n'; // totem-ignore — test fixture, not real logging
|
|
386
394
|
fs.writeFileSync(hookPath, nodeHook);
|
|
387
|
-
const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(), TOTEM_PRECOMMIT_MARKER);
|
|
395
|
+
const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(RENDER), TOTEM_PRECOMMIT_MARKER);
|
|
388
396
|
expect(result).toBe('skipped-non-shell');
|
|
389
397
|
const written = fs.readFileSync(hookPath, 'utf-8');
|
|
390
398
|
expect(written).toBe(nodeHook); // File untouched
|
|
@@ -394,7 +402,7 @@ describe('installGitHook', () => {
|
|
|
394
402
|
const hookPath = path.join(hooksDir, 'pre-push');
|
|
395
403
|
const pythonHook = '#!/usr/bin/env python3\nimport subprocess\n';
|
|
396
404
|
fs.writeFileSync(hookPath, pythonHook);
|
|
397
|
-
const result = installGitHook(hooksDir, 'pre-push', buildPrePushHook(
|
|
405
|
+
const result = installGitHook(hooksDir, 'pre-push', buildPrePushHook(RENDER), TOTEM_PREPUSH_MARKER);
|
|
398
406
|
expect(result).toBe('skipped-non-shell');
|
|
399
407
|
const written = fs.readFileSync(hookPath, 'utf-8');
|
|
400
408
|
expect(written).toBe(pythonHook); // File untouched
|
|
@@ -403,33 +411,33 @@ describe('installGitHook', () => {
|
|
|
403
411
|
fs.mkdirSync(hooksDir, { recursive: true });
|
|
404
412
|
const hookPath = path.join(hooksDir, 'pre-commit');
|
|
405
413
|
fs.writeFileSync(hookPath, '#!/bin/sh\necho "existing"\n');
|
|
406
|
-
const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(), TOTEM_PRECOMMIT_MARKER);
|
|
414
|
+
const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(RENDER), TOTEM_PRECOMMIT_MARKER);
|
|
407
415
|
expect(result).toBe('appended');
|
|
408
416
|
});
|
|
409
417
|
it('appends normally to bash hooks', () => {
|
|
410
418
|
fs.mkdirSync(hooksDir, { recursive: true });
|
|
411
419
|
const hookPath = path.join(hooksDir, 'pre-commit');
|
|
412
420
|
fs.writeFileSync(hookPath, '#!/bin/bash\necho "existing"\n');
|
|
413
|
-
const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(), TOTEM_PRECOMMIT_MARKER);
|
|
421
|
+
const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(RENDER), TOTEM_PRECOMMIT_MARKER);
|
|
414
422
|
expect(result).toBe('appended');
|
|
415
423
|
});
|
|
416
424
|
it('appends normally to env bash hooks', () => {
|
|
417
425
|
fs.mkdirSync(hooksDir, { recursive: true });
|
|
418
426
|
const hookPath = path.join(hooksDir, 'pre-commit');
|
|
419
427
|
fs.writeFileSync(hookPath, '#!/usr/bin/env bash\necho "existing"\n');
|
|
420
|
-
const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(), TOTEM_PRECOMMIT_MARKER);
|
|
428
|
+
const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(RENDER), TOTEM_PRECOMMIT_MARKER);
|
|
421
429
|
expect(result).toBe('appended');
|
|
422
430
|
});
|
|
423
431
|
it('appends to hooks without a shebang (plain text)', () => {
|
|
424
432
|
fs.mkdirSync(hooksDir, { recursive: true });
|
|
425
433
|
const hookPath = path.join(hooksDir, 'pre-commit');
|
|
426
434
|
fs.writeFileSync(hookPath, 'echo "no shebang"\n');
|
|
427
|
-
const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(), TOTEM_PRECOMMIT_MARKER);
|
|
435
|
+
const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(RENDER), TOTEM_PRECOMMIT_MARKER);
|
|
428
436
|
expect(result).toBe('appended');
|
|
429
437
|
});
|
|
430
438
|
it('handles pre-commit and pre-push independently', () => {
|
|
431
|
-
installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(), TOTEM_PRECOMMIT_MARKER);
|
|
432
|
-
installGitHook(hooksDir, 'pre-push', buildPrePushHook(
|
|
439
|
+
installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(RENDER), TOTEM_PRECOMMIT_MARKER);
|
|
440
|
+
installGitHook(hooksDir, 'pre-push', buildPrePushHook(RENDER), TOTEM_PREPUSH_MARKER);
|
|
433
441
|
expect(fs.existsSync(path.join(hooksDir, 'pre-commit'))).toBe(true);
|
|
434
442
|
expect(fs.existsSync(path.join(hooksDir, 'pre-push'))).toBe(true);
|
|
435
443
|
const preCommit = fs.readFileSync(path.join(hooksDir, 'pre-commit'), 'utf-8');
|
|
@@ -444,7 +452,7 @@ describe('installGitHook', () => {
|
|
|
444
452
|
const hookPath = path.join(hooksDir, 'pre-push');
|
|
445
453
|
// Write an old-format hook with the marker
|
|
446
454
|
fs.writeFileSync(hookPath, `#!/bin/sh\n# ${TOTEM_PREPUSH_MARKER}\n$TOTEM_CMD lint\n`);
|
|
447
|
-
const result = installGitHook(hooksDir, 'pre-push', buildPrePushHook(
|
|
455
|
+
const result = installGitHook(hooksDir, 'pre-push', buildPrePushHook(RENDER), TOTEM_PREPUSH_MARKER, true);
|
|
448
456
|
expect(result).toBe('overwritten');
|
|
449
457
|
const content = fs.readFileSync(hookPath, 'utf-8');
|
|
450
458
|
expect(content).toContain('verify-manifest'); // new format
|
|
@@ -453,7 +461,7 @@ describe('installGitHook', () => {
|
|
|
453
461
|
it('returns exists when on-disk content already matches canonical (idempotent)', () => {
|
|
454
462
|
fs.mkdirSync(hooksDir, { recursive: true });
|
|
455
463
|
const hookPath = path.join(hooksDir, 'pre-push');
|
|
456
|
-
const canonical = buildPrePushHook(
|
|
464
|
+
const canonical = buildPrePushHook(RENDER);
|
|
457
465
|
fs.writeFileSync(hookPath, canonical);
|
|
458
466
|
const result = installGitHook(hooksDir, 'pre-push', canonical, TOTEM_PREPUSH_MARKER);
|
|
459
467
|
expect(result).toBe('exists');
|
|
@@ -464,7 +472,7 @@ describe('installGitHook', () => {
|
|
|
464
472
|
// A totem-owned whole file frozen at an older generator's output — but it carries
|
|
465
473
|
// the bounded end marker, so drift-repair may upgrade it in place without --force.
|
|
466
474
|
fs.writeFileSync(hookPath, `#!/bin/sh\n# ${TOTEM_PREPUSH_MARKER}\n$TOTEM_CMD lint\n# ${TOTEM_PREPUSH_END}\n`);
|
|
467
|
-
const canonical = buildPrePushHook(
|
|
475
|
+
const canonical = buildPrePushHook(RENDER);
|
|
468
476
|
const result = installGitHook(hooksDir, 'pre-push', canonical, TOTEM_PREPUSH_MARKER, false, TOTEM_PREPUSH_END);
|
|
469
477
|
expect(result).toBe('overwritten');
|
|
470
478
|
expect(fs.readFileSync(hookPath, 'utf-8')).toBe(canonical);
|
|
@@ -473,7 +481,7 @@ describe('installGitHook', () => {
|
|
|
473
481
|
fs.mkdirSync(hooksDir, { recursive: true });
|
|
474
482
|
const hookPath = path.join(hooksDir, 'pre-commit');
|
|
475
483
|
fs.writeFileSync(hookPath, `#!/bin/sh\n# ${TOTEM_PRECOMMIT_MARKER}\nstale body\n# ${TOTEM_PRECOMMIT_END}\n`);
|
|
476
|
-
const canonical = buildPreCommitHook();
|
|
484
|
+
const canonical = buildPreCommitHook(RENDER);
|
|
477
485
|
const result = installGitHook(hooksDir, 'pre-commit', canonical, TOTEM_PRECOMMIT_MARKER, false, TOTEM_PRECOMMIT_END);
|
|
478
486
|
expect(result).toBe('overwritten');
|
|
479
487
|
expect(fs.readFileSync(hookPath, 'utf-8')).toBe(canonical);
|
|
@@ -486,7 +494,7 @@ describe('installGitHook', () => {
|
|
|
486
494
|
// marker → the region cannot be bounded → drift-repair declines (legacy path).
|
|
487
495
|
const legacy = `#!/bin/sh\n# ${TOTEM_PRECOMMIT_MARKER}\nstale legacy body\n`;
|
|
488
496
|
fs.writeFileSync(hookPath, legacy);
|
|
489
|
-
const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(), TOTEM_PRECOMMIT_MARKER, false, TOTEM_PRECOMMIT_END);
|
|
497
|
+
const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(RENDER), TOTEM_PRECOMMIT_MARKER, false, TOTEM_PRECOMMIT_END);
|
|
490
498
|
expect(result).toBe('exists');
|
|
491
499
|
// Left untouched — the legacy hook takes one `totem hook install --force`.
|
|
492
500
|
expect(fs.readFileSync(hookPath, 'utf-8')).toBe(legacy);
|
|
@@ -498,7 +506,7 @@ describe('installGitHook', () => {
|
|
|
498
506
|
// block is bounded by an end marker.
|
|
499
507
|
const userThenTotem = `#!/bin/sh\nrun_my_tests\n# ${TOTEM_PREPUSH_MARKER}\nold content\n# ${TOTEM_PREPUSH_END}\n`;
|
|
500
508
|
fs.writeFileSync(hookPath, userThenTotem);
|
|
501
|
-
const result = installGitHook(hooksDir, 'pre-push', buildPrePushHook(
|
|
509
|
+
const result = installGitHook(hooksDir, 'pre-push', buildPrePushHook(RENDER), TOTEM_PREPUSH_MARKER, false, TOTEM_PREPUSH_END);
|
|
502
510
|
expect(result).toBe('exists');
|
|
503
511
|
// Left untouched — the user's hook is preserved verbatim.
|
|
504
512
|
expect(fs.readFileSync(hookPath, 'utf-8')).toBe(userThenTotem);
|
|
@@ -510,7 +518,7 @@ describe('installGitHook', () => {
|
|
|
510
518
|
// whole-file overwrite would clobber it, so the end-marker guard must decline.
|
|
511
519
|
const staleWithTrailingUser = `#!/bin/sh\n# ${TOTEM_PRECOMMIT_MARKER}\nstale body\n# ${TOTEM_PRECOMMIT_END}\necho "my pre-commit notice"\n`;
|
|
512
520
|
fs.writeFileSync(hookPath, staleWithTrailingUser);
|
|
513
|
-
const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(), TOTEM_PRECOMMIT_MARKER, false, TOTEM_PRECOMMIT_END);
|
|
521
|
+
const result = installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(RENDER), TOTEM_PRECOMMIT_MARKER, false, TOTEM_PRECOMMIT_END);
|
|
514
522
|
expect(result).toBe('exists');
|
|
515
523
|
expect(fs.readFileSync(hookPath, 'utf-8')).toBe(staleWithTrailingUser);
|
|
516
524
|
});
|
|
@@ -519,7 +527,7 @@ describe('installGitHook', () => {
|
|
|
519
527
|
const hookPath = path.join(hooksDir, 'pre-push');
|
|
520
528
|
const staleWithTrailingUser = `#!/bin/sh\n# ${TOTEM_PREPUSH_MARKER}\nstale body\n# ${TOTEM_PREPUSH_END}\necho "my pre-push notice"\n`;
|
|
521
529
|
fs.writeFileSync(hookPath, staleWithTrailingUser);
|
|
522
|
-
const result = installGitHook(hooksDir, 'pre-push', buildPrePushHook(
|
|
530
|
+
const result = installGitHook(hooksDir, 'pre-push', buildPrePushHook(RENDER), TOTEM_PREPUSH_MARKER, false, TOTEM_PREPUSH_END);
|
|
523
531
|
expect(result).toBe('exists');
|
|
524
532
|
expect(fs.readFileSync(hookPath, 'utf-8')).toBe(staleWithTrailingUser);
|
|
525
533
|
});
|
|
@@ -530,7 +538,7 @@ describe('installGitHook', () => {
|
|
|
530
538
|
// whole-file overwrite would clobber it, so the end-marker guard must decline.
|
|
531
539
|
const staleWithTrailingUser = `#!/bin/sh\n# ${TOTEM_HOOK_MARKER}\nstale body\n# ${TOTEM_HOOK_END}\necho "my deploy notice"\n`;
|
|
532
540
|
fs.writeFileSync(hookPath, staleWithTrailingUser);
|
|
533
|
-
const result = installGitHook(hooksDir, 'post-merge', buildHookContent(
|
|
541
|
+
const result = installGitHook(hooksDir, 'post-merge', buildHookContent(RENDER), TOTEM_HOOK_MARKER, false, TOTEM_HOOK_END);
|
|
534
542
|
expect(result).toBe('exists');
|
|
535
543
|
expect(fs.readFileSync(hookPath, 'utf-8')).toBe(staleWithTrailingUser);
|
|
536
544
|
});
|
|
@@ -545,7 +553,7 @@ describe('generateHookHelpers', () => {
|
|
|
545
553
|
cleanTmpDir(tmpDir);
|
|
546
554
|
});
|
|
547
555
|
it('creates .totem/hooks/ directory and writes all 4 .sh files', () => {
|
|
548
|
-
generateHookHelpers(tmpDir,
|
|
556
|
+
generateHookHelpers(tmpDir, RENDER);
|
|
549
557
|
const hooksDir = path.join(tmpDir, '.totem', 'hooks');
|
|
550
558
|
expect(fs.existsSync(path.join(hooksDir, 'post-merge.sh'))).toBe(true);
|
|
551
559
|
expect(fs.existsSync(path.join(hooksDir, 'post-checkout.sh'))).toBe(true);
|
|
@@ -553,7 +561,7 @@ describe('generateHookHelpers', () => {
|
|
|
553
561
|
expect(fs.existsSync(path.join(hooksDir, 'pre-push.sh'))).toBe(true);
|
|
554
562
|
});
|
|
555
563
|
it('generated scripts contain expected content', () => {
|
|
556
|
-
generateHookHelpers(tmpDir,
|
|
564
|
+
generateHookHelpers(tmpDir, RENDER);
|
|
557
565
|
const hooksDir = path.join(tmpDir, '.totem', 'hooks');
|
|
558
566
|
const postMerge = fs.readFileSync(path.join(hooksDir, 'post-merge.sh'), 'utf-8');
|
|
559
567
|
expect(postMerge).toContain('command -v totem');
|
|
@@ -563,8 +571,8 @@ describe('generateHookHelpers', () => {
|
|
|
563
571
|
expect(prePush).toContain('verify-manifest');
|
|
564
572
|
});
|
|
565
573
|
it('is idempotent — calling twice does not error', () => {
|
|
566
|
-
generateHookHelpers(tmpDir,
|
|
567
|
-
generateHookHelpers(tmpDir,
|
|
574
|
+
generateHookHelpers(tmpDir, RENDER);
|
|
575
|
+
generateHookHelpers(tmpDir, RENDER);
|
|
568
576
|
const hooksDir = path.join(tmpDir, '.totem', 'hooks');
|
|
569
577
|
expect(fs.existsSync(path.join(hooksDir, 'post-merge.sh'))).toBe(true);
|
|
570
578
|
});
|
|
@@ -578,14 +586,14 @@ describe('installHooksNonInteractive', () => {
|
|
|
578
586
|
afterEach(() => {
|
|
579
587
|
cleanTmpDir(tmpDir);
|
|
580
588
|
});
|
|
581
|
-
it('returns null when not a git repo', () => {
|
|
582
|
-
const result = installHooksNonInteractive(tmpDir);
|
|
589
|
+
it('returns null when not a git repo', async () => {
|
|
590
|
+
const result = await installHooksNonInteractive(tmpDir);
|
|
583
591
|
expect(result).toBeNull();
|
|
584
592
|
});
|
|
585
|
-
it('installs all four hooks in a git repo', () => {
|
|
593
|
+
it('installs all four hooks in a git repo', async () => {
|
|
586
594
|
execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
|
|
587
595
|
fs.writeFileSync(path.join(tmpDir, 'pnpm-lock.yaml'), '');
|
|
588
|
-
const result = installHooksNonInteractive(tmpDir);
|
|
596
|
+
const result = await installHooksNonInteractive(tmpDir);
|
|
589
597
|
expect(result).not.toBeNull();
|
|
590
598
|
expect(result.preCommit).toBe('installed');
|
|
591
599
|
expect(result.prePush).toBe('installed');
|
|
@@ -598,33 +606,33 @@ describe('installHooksNonInteractive', () => {
|
|
|
598
606
|
expect(fs.existsSync(path.join(hooksDir, 'post-merge'))).toBe(true);
|
|
599
607
|
expect(fs.existsSync(path.join(hooksDir, 'post-checkout'))).toBe(true);
|
|
600
608
|
});
|
|
601
|
-
it('is idempotent — second call returns exists for all hooks', () => {
|
|
609
|
+
it('is idempotent — second call returns exists for all hooks', async () => {
|
|
602
610
|
execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
|
|
603
611
|
fs.writeFileSync(path.join(tmpDir, 'pnpm-lock.yaml'), '');
|
|
604
|
-
installHooksNonInteractive(tmpDir);
|
|
605
|
-
const result = installHooksNonInteractive(tmpDir);
|
|
612
|
+
await installHooksNonInteractive(tmpDir);
|
|
613
|
+
const result = await installHooksNonInteractive(tmpDir);
|
|
606
614
|
expect(result).not.toBeNull();
|
|
607
615
|
expect(result.preCommit).toBe('exists');
|
|
608
616
|
expect(result.prePush).toBe('exists');
|
|
609
617
|
expect(result.postMerge).toBe('exists');
|
|
610
618
|
expect(result.postCheckout).toBe('exists');
|
|
611
619
|
});
|
|
612
|
-
it('returns null and generates helper scripts when hook manager is detected', () => {
|
|
620
|
+
it('returns null and generates helper scripts when hook manager is detected', async () => {
|
|
613
621
|
execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
|
|
614
622
|
fs.mkdirSync(path.join(tmpDir, '.husky'), { recursive: true });
|
|
615
|
-
const result = installHooksNonInteractive(tmpDir);
|
|
623
|
+
const result = await installHooksNonInteractive(tmpDir);
|
|
616
624
|
expect(result).toBeNull();
|
|
617
625
|
// Verify helper scripts were generated
|
|
618
626
|
const hooksDir = path.join(tmpDir, '.totem', 'hooks');
|
|
619
627
|
expect(fs.existsSync(path.join(hooksDir, 'post-merge.sh'))).toBe(true);
|
|
620
628
|
expect(fs.existsSync(path.join(hooksDir, 'pre-push.sh'))).toBe(true);
|
|
621
629
|
});
|
|
622
|
-
it('installs hooks at git root when run from a subdirectory', () => {
|
|
630
|
+
it('installs hooks at git root when run from a subdirectory', async () => {
|
|
623
631
|
execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
|
|
624
632
|
fs.writeFileSync(path.join(tmpDir, 'pnpm-lock.yaml'), '');
|
|
625
633
|
const subDir = path.join(tmpDir, 'packages', 'cli');
|
|
626
634
|
fs.mkdirSync(subDir, { recursive: true });
|
|
627
|
-
const result = installHooksNonInteractive(subDir);
|
|
635
|
+
const result = await installHooksNonInteractive(subDir);
|
|
628
636
|
expect(result).not.toBeNull();
|
|
629
637
|
expect(result.preCommit).toBe('installed');
|
|
630
638
|
expect(result.prePush).toBe('installed');
|
|
@@ -638,31 +646,31 @@ describe('installHooksNonInteractive', () => {
|
|
|
638
646
|
expect(fs.existsSync(path.join(hooksDir, 'post-checkout'))).toBe(true);
|
|
639
647
|
expect(fs.existsSync(path.join(subDir, '.git'))).toBe(false);
|
|
640
648
|
});
|
|
641
|
-
it('check passes from subdirectory after install', () => {
|
|
649
|
+
it('check passes from subdirectory after install', async () => {
|
|
642
650
|
execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
|
|
643
651
|
fs.writeFileSync(path.join(tmpDir, 'pnpm-lock.yaml'), '');
|
|
644
652
|
const subDir = path.join(tmpDir, 'packages', 'cli');
|
|
645
653
|
fs.mkdirSync(subDir, { recursive: true });
|
|
646
|
-
installHooksNonInteractive(subDir);
|
|
654
|
+
await installHooksNonInteractive(subDir);
|
|
647
655
|
expect(checkHooksInstalled(subDir)).toBe(true);
|
|
648
656
|
});
|
|
649
|
-
it('appends to existing hooks without clobbering', () => {
|
|
657
|
+
it('appends to existing hooks without clobbering', async () => {
|
|
650
658
|
execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
|
|
651
659
|
fs.writeFileSync(path.join(tmpDir, 'pnpm-lock.yaml'), '');
|
|
652
660
|
fs.writeFileSync(path.join(tmpDir, '.git', 'hooks', 'pre-push'), '#!/bin/sh\nrun_my_tests\n');
|
|
653
|
-
const result = installHooksNonInteractive(tmpDir);
|
|
661
|
+
const result = await installHooksNonInteractive(tmpDir);
|
|
654
662
|
expect(result.prePush).toBe('appended');
|
|
655
663
|
const content = fs.readFileSync(path.join(tmpDir, '.git', 'hooks', 'pre-push'), 'utf-8');
|
|
656
664
|
expect(content).toContain('run_my_tests');
|
|
657
665
|
expect(content).toContain(TOTEM_PREPUSH_MARKER);
|
|
658
666
|
});
|
|
659
|
-
it('force-overwrites all hooks when force is true', () => {
|
|
667
|
+
it('force-overwrites all hooks when force is true', async () => {
|
|
660
668
|
execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
|
|
661
669
|
fs.writeFileSync(path.join(tmpDir, 'pnpm-lock.yaml'), '');
|
|
662
670
|
// First install — creates hooks
|
|
663
|
-
installHooksNonInteractive(tmpDir);
|
|
671
|
+
await installHooksNonInteractive(tmpDir);
|
|
664
672
|
// Second install with force — overwrites all hooks
|
|
665
|
-
const result = installHooksNonInteractive(tmpDir, true);
|
|
673
|
+
const result = await installHooksNonInteractive(tmpDir, true);
|
|
666
674
|
expect(result).not.toBeNull();
|
|
667
675
|
expect(result.preCommit).toBe('overwritten');
|
|
668
676
|
expect(result.prePush).toBe('overwritten');
|
|
@@ -684,14 +692,14 @@ describe('checkHooksInstalled', () => {
|
|
|
684
692
|
it('returns false when no hooks are installed', () => {
|
|
685
693
|
expect(checkHooksInstalled(tmpDir)).toBe(false);
|
|
686
694
|
});
|
|
687
|
-
it('returns true when all hooks are installed', () => {
|
|
688
|
-
installHooksNonInteractive(tmpDir);
|
|
695
|
+
it('returns true when all hooks are installed', async () => {
|
|
696
|
+
await installHooksNonInteractive(tmpDir);
|
|
689
697
|
expect(checkHooksInstalled(tmpDir)).toBe(true);
|
|
690
698
|
});
|
|
691
699
|
it('returns false when only some hooks are installed', () => {
|
|
692
700
|
const hooksDir = path.join(tmpDir, '.git', 'hooks');
|
|
693
701
|
fs.mkdirSync(hooksDir, { recursive: true });
|
|
694
|
-
installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(), TOTEM_PRECOMMIT_MARKER);
|
|
702
|
+
installGitHook(hooksDir, 'pre-commit', buildPreCommitHook(RENDER), TOTEM_PRECOMMIT_MARKER);
|
|
695
703
|
// Missing pre-push and post-merge
|
|
696
704
|
expect(checkHooksInstalled(tmpDir)).toBe(false);
|
|
697
705
|
});
|
|
@@ -716,8 +724,8 @@ describe('post-merge hook content', () => {
|
|
|
716
724
|
afterEach(() => {
|
|
717
725
|
cleanTmpDir(tmpDir);
|
|
718
726
|
});
|
|
719
|
-
it('generates post-merge hook with git diff-tree lesson check', () => {
|
|
720
|
-
installHooksNonInteractive(tmpDir);
|
|
727
|
+
it('generates post-merge hook with git diff-tree lesson check', async () => {
|
|
728
|
+
await installHooksNonInteractive(tmpDir);
|
|
721
729
|
const hookPath = path.join(tmpDir, '.git', 'hooks', 'post-merge');
|
|
722
730
|
const content = fs.readFileSync(hookPath, 'utf-8');
|
|
723
731
|
expect(content).toContain('ORIG_HEAD');
|
|
@@ -728,17 +736,17 @@ describe('post-merge hook content', () => {
|
|
|
728
736
|
expect(content).toContain('[totem] post-merge hook');
|
|
729
737
|
expect(content).toContain('[totem] end post-merge');
|
|
730
738
|
});
|
|
731
|
-
it('passes quiet flag to sync command in post-merge hook', () => {
|
|
732
|
-
installHooksNonInteractive(tmpDir);
|
|
739
|
+
it('passes quiet flag to sync command in post-merge hook', async () => {
|
|
740
|
+
await installHooksNonInteractive(tmpDir);
|
|
733
741
|
const hookPath = path.join(tmpDir, '.git', 'hooks', 'post-merge');
|
|
734
742
|
const content = fs.readFileSync(hookPath, 'utf-8');
|
|
735
743
|
expect(content).toContain('--quiet');
|
|
736
744
|
});
|
|
737
|
-
it('preserves existing hooks when appending post-merge block', () => {
|
|
745
|
+
it('preserves existing hooks when appending post-merge block', async () => {
|
|
738
746
|
const hooksDir = path.join(tmpDir, '.git', 'hooks');
|
|
739
747
|
fs.mkdirSync(hooksDir, { recursive: true });
|
|
740
748
|
fs.writeFileSync(path.join(hooksDir, 'post-merge'), '#!/bin/sh\necho "my custom hook"\n');
|
|
741
|
-
installHooksNonInteractive(tmpDir);
|
|
749
|
+
await installHooksNonInteractive(tmpDir);
|
|
742
750
|
const content = fs.readFileSync(path.join(hooksDir, 'post-merge'), 'utf-8');
|
|
743
751
|
expect(content).toContain('echo "my custom hook"');
|
|
744
752
|
expect(content).toContain('[totem] post-merge hook');
|
|
@@ -757,8 +765,8 @@ describe('post-checkout hook content', () => {
|
|
|
757
765
|
afterEach(() => {
|
|
758
766
|
cleanTmpDir(tmpDir);
|
|
759
767
|
});
|
|
760
|
-
it('generates post-checkout hook with branch switch guard', () => {
|
|
761
|
-
installHooksNonInteractive(tmpDir);
|
|
768
|
+
it('generates post-checkout hook with branch switch guard', async () => {
|
|
769
|
+
await installHooksNonInteractive(tmpDir);
|
|
762
770
|
const hookPath = path.join(tmpDir, '.git', 'hooks', 'post-checkout');
|
|
763
771
|
const content = fs.readFileSync(hookPath, 'utf-8');
|
|
764
772
|
expect(content).toContain('$3');
|
|
@@ -767,18 +775,18 @@ describe('post-checkout hook content', () => {
|
|
|
767
775
|
expect(content).toContain('[totem] end post-checkout');
|
|
768
776
|
});
|
|
769
777
|
it('handles null SHA for initial checkout', () => {
|
|
770
|
-
const hook = buildPostCheckoutHookContent(
|
|
778
|
+
const hook = buildPostCheckoutHookContent(RENDER);
|
|
771
779
|
expect(hook).toContain('0000000000000000000000000000000000000000');
|
|
772
780
|
expect(hook).toContain('.totem');
|
|
773
781
|
});
|
|
774
|
-
it('uses quiet sync command', () => {
|
|
775
|
-
installHooksNonInteractive(tmpDir);
|
|
782
|
+
it('uses quiet sync command', async () => {
|
|
783
|
+
await installHooksNonInteractive(tmpDir);
|
|
776
784
|
const hookPath = path.join(tmpDir, '.git', 'hooks', 'post-checkout');
|
|
777
785
|
const content = fs.readFileSync(hookPath, 'utf-8');
|
|
778
786
|
expect(content).toContain('--quiet');
|
|
779
787
|
});
|
|
780
|
-
it('includes post-checkout in non-interactive install', () => {
|
|
781
|
-
const result = installHooksNonInteractive(tmpDir);
|
|
788
|
+
it('includes post-checkout in non-interactive install', async () => {
|
|
789
|
+
const result = await installHooksNonInteractive(tmpDir);
|
|
782
790
|
expect(result).not.toBeNull();
|
|
783
791
|
expect(result.postCheckout).toBe('installed');
|
|
784
792
|
});
|
|
@@ -787,7 +795,7 @@ describe('post-checkout hook content', () => {
|
|
|
787
795
|
// ─── totem-status refresh-gh wiring (mmnto-ai/totem#2556) ─
|
|
788
796
|
describe('post-merge hook fires totem-status refresh-gh', () => {
|
|
789
797
|
it('invokes refresh-gh presence-gated and backgrounded (mmnto-ai/totem-status#127 C3)', () => {
|
|
790
|
-
const hook = buildHookContent(
|
|
798
|
+
const hook = buildHookContent(RENDER);
|
|
791
799
|
// Presence gate (absent binary = zero noise) AND primary-checkout gate: in a
|
|
792
800
|
// linked worktree .git is a pointer FILE and a backgrounded child inheriting
|
|
793
801
|
// the worktree cwd holds a Windows directory lock that breaks worktree removal.
|
|
@@ -799,7 +807,7 @@ describe('post-merge hook fires totem-status refresh-gh', () => {
|
|
|
799
807
|
expect(hook.trimEnd().endsWith(`# ${TOTEM_HOOK_END}`)).toBe(true);
|
|
800
808
|
});
|
|
801
809
|
it('#2570: stamps each firing and hands the child the same log (observability leg)', () => {
|
|
802
|
-
const hook = buildHookContent(
|
|
810
|
+
const hook = buildHookContent(RENDER);
|
|
803
811
|
// Repo-local log inside .git; the stamp carries the firing site, cwd, and
|
|
804
812
|
// WHICH binary resolved (shell search order can be shadowed by a
|
|
805
813
|
// checkout-local exe on Windows).
|
|
@@ -822,7 +830,7 @@ describe('post-merge hook fires totem-status refresh-gh', () => {
|
|
|
822
830
|
// (mmnto-ai/totem-status#127; sibling of mmnto-ai/totem#2556.) The durable
|
|
823
831
|
// obligation store gets the same post-merge moment as the GH snapshot.
|
|
824
832
|
it('also fires refresh-obligation-store under the SAME gate and log (mmnto-ai/totem-status#127)', () => {
|
|
825
|
-
const hook = buildHookContent(
|
|
833
|
+
const hook = buildHookContent(RENDER);
|
|
826
834
|
// One gate, not two: both verbs live inside the single presence +
|
|
827
835
|
// primary-checkout `if`, so a worktree/non-adopter skips BOTH.
|
|
828
836
|
const gateIdx = hook.indexOf('if [ -d .git ] && command -v totem-status >/dev/null 2>&1; then');
|
|
@@ -905,7 +913,7 @@ describe.skipIf(process.platform === 'win32')('post-merge refresh-gh behavior (P
|
|
|
905
913
|
const repo = path.join(tmpDir, 'repo');
|
|
906
914
|
fs.mkdirSync(path.join(repo, '.git'), { recursive: true });
|
|
907
915
|
const hookPath = path.join(repo, 'post-merge');
|
|
908
|
-
fs.writeFileSync(hookPath, buildHookContent(
|
|
916
|
+
fs.writeFileSync(hookPath, buildHookContent(RENDER), { mode: 0o755 });
|
|
909
917
|
execSync('sh ./post-merge', {
|
|
910
918
|
cwd: repo,
|
|
911
919
|
env: { ...process.env, PATH: `${binDir}:${process.env.PATH ?? ''}` },
|
|
@@ -935,7 +943,7 @@ describe.skipIf(process.platform === 'win32')('post-merge refresh-gh behavior (P
|
|
|
935
943
|
// append; falsification round, MAJOR 1).
|
|
936
944
|
fs.mkdirSync(path.join(repo, '.git', 'totem-status-refresh-hook.log'), { recursive: true });
|
|
937
945
|
const hookPath = path.join(repo, 'post-merge');
|
|
938
|
-
fs.writeFileSync(hookPath, buildHookContent(
|
|
946
|
+
fs.writeFileSync(hookPath, buildHookContent(RENDER), { mode: 0o755 });
|
|
939
947
|
const result = spawnSync('sh', ['./post-merge'], {
|
|
940
948
|
cwd: repo,
|
|
941
949
|
env: { ...process.env, PATH: `${binDir}:${process.env.PATH ?? ''}` },
|
|
@@ -953,7 +961,7 @@ describe.skipIf(process.platform === 'win32')('post-merge refresh-gh behavior (P
|
|
|
953
961
|
// Linked-worktree shape: .git is a pointer FILE, not a directory.
|
|
954
962
|
fs.writeFileSync(path.join(repo, '.git'), 'gitdir: /elsewhere/.git/worktrees/x\n');
|
|
955
963
|
const hookPath = path.join(repo, 'post-merge');
|
|
956
|
-
fs.writeFileSync(hookPath, buildHookContent(
|
|
964
|
+
fs.writeFileSync(hookPath, buildHookContent(RENDER), { mode: 0o755 });
|
|
957
965
|
execSync('sh ./post-merge', {
|
|
958
966
|
cwd: repo,
|
|
959
967
|
env: { ...process.env, PATH: `${binDir}:${process.env.PATH ?? ''}` },
|
|
@@ -967,14 +975,14 @@ describe.skipIf(process.platform === 'win32')('post-merge refresh-gh behavior (P
|
|
|
967
975
|
});
|
|
968
976
|
describe('sync-log redirect resolves the git dir (worktree-safe)', () => {
|
|
969
977
|
it('post-merge hook derives the log path from git rev-parse --git-dir', () => {
|
|
970
|
-
const hook = buildHookContent(
|
|
978
|
+
const hook = buildHookContent(RENDER);
|
|
971
979
|
expect(hook).toContain('GIT_DIR_RESOLVED=$(git rev-parse --git-dir 2>/dev/null || echo .git)');
|
|
972
980
|
expect(hook).toContain('> "$GIT_DIR_RESOLVED/totem-sync.log"');
|
|
973
981
|
// The hardcoded `.git/`-as-directory redirect must be gone (ENOTDIR in a worktree).
|
|
974
982
|
expect(hook).not.toContain('> .git/totem-sync.log');
|
|
975
983
|
});
|
|
976
984
|
it('post-checkout hook derives the log path from git rev-parse --git-dir on both branches', () => {
|
|
977
|
-
const hook = buildPostCheckoutHookContent(
|
|
985
|
+
const hook = buildPostCheckoutHookContent(RENDER);
|
|
978
986
|
expect(hook).toContain('GIT_DIR_RESOLVED=$(git rev-parse --git-dir 2>/dev/null || echo .git)');
|
|
979
987
|
// Both the null-SHA and the branch-diff redirect use the resolved dir.
|
|
980
988
|
expect(hook.match(/> "\$GIT_DIR_RESOLVED\/totem-sync\.log"/g)).toHaveLength(2);
|
|
@@ -988,13 +996,19 @@ describe('upgradePrePushHookIfNeeded', () => {
|
|
|
988
996
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-hooks-upgrade-'));
|
|
989
997
|
execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
|
|
990
998
|
fs.writeFileSync(path.join(tmpDir, 'pnpm-lock.yaml'), '');
|
|
999
|
+
// Pin the repo-local render options: since mmnto-ai/totem#2692 the upgrade
|
|
1000
|
+
// path resolves `hooks.tier` from config, and a config-less temp repo would
|
|
1001
|
+
// fall through to the developer's global `~/.totem/` profile — a global
|
|
1002
|
+
// `hooks.tier: strict` would then break the standard-tier expectation below
|
|
1003
|
+
// (CodeRabbit on mmnto-ai/totem#2701).
|
|
1004
|
+
fs.writeFileSync(path.join(tmpDir, 'totem.yaml'), 'targets:\n - glob: "docs/*.md"\n type: lesson\n strategy: markdown-heading\nhooks:\n tier: standard\n', 'utf-8');
|
|
991
1005
|
});
|
|
992
1006
|
afterEach(() => {
|
|
993
1007
|
cleanTmpDir(tmpDir);
|
|
994
1008
|
});
|
|
995
1009
|
/**
|
|
996
1010
|
* Helper: extract the totem block from a hook file and compare it against
|
|
997
|
-
* the canonical output of buildPrePushHook(
|
|
1011
|
+
* the canonical output of buildPrePushHook(RENDER) (shebang stripped, trimmed).
|
|
998
1012
|
* Catches stale shell fragments or splice boundary bugs that toContain would miss.
|
|
999
1013
|
* The stateless format is now bounded by the pre-push end marker, so the block is the
|
|
1000
1014
|
* span from the start-marker comment through the end-marker comment line inclusive —
|
|
@@ -1012,11 +1026,11 @@ describe('upgradePrePushHookIfNeeded', () => {
|
|
|
1012
1026
|
}
|
|
1013
1027
|
/** Canonical totem block: shebang stripped, trimmed — the expected upgrade output. */
|
|
1014
1028
|
function expectedTotemBlock() {
|
|
1015
|
-
return buildPrePushHook(getFallbackCommand(tmpDir))
|
|
1029
|
+
return buildPrePushHook({ ...RENDER, fallbackCmd: getFallbackCommand(tmpDir) })
|
|
1016
1030
|
.replace(/^#!\/bin\/sh\n/, '')
|
|
1017
1031
|
.trim();
|
|
1018
1032
|
}
|
|
1019
|
-
it('upgrades old command-executing hook to stateless format', () => {
|
|
1033
|
+
it('upgrades old command-executing hook to stateless format', async () => {
|
|
1020
1034
|
// Install an old-style hook that executes $TOTEM_CMD lint
|
|
1021
1035
|
const hooksDir = path.join(tmpDir, '.git', 'hooks');
|
|
1022
1036
|
fs.mkdirSync(hooksDir, { recursive: true });
|
|
@@ -1032,7 +1046,7 @@ if [ -f ".totem/compiled-rules.json" ]; then
|
|
|
1032
1046
|
fi
|
|
1033
1047
|
`;
|
|
1034
1048
|
fs.writeFileSync(path.join(hooksDir, 'pre-push'), oldHook);
|
|
1035
|
-
const upgraded = upgradePrePushHookIfNeeded(tmpDir);
|
|
1049
|
+
const upgraded = await upgradePrePushHookIfNeeded(tmpDir);
|
|
1036
1050
|
expect(upgraded).toBe(true);
|
|
1037
1051
|
const content = fs.readFileSync(path.join(hooksDir, 'pre-push'), 'utf-8');
|
|
1038
1052
|
expect(content).toContain('verify-manifest');
|
|
@@ -1044,41 +1058,41 @@ fi
|
|
|
1044
1058
|
const actual = extractTotemBlock(content);
|
|
1045
1059
|
expect(actual).toBe(expectedTotemBlock());
|
|
1046
1060
|
});
|
|
1047
|
-
it('skips hook without totem marker', () => {
|
|
1061
|
+
it('skips hook without totem marker', async () => {
|
|
1048
1062
|
const hooksDir = path.join(tmpDir, '.git', 'hooks');
|
|
1049
1063
|
fs.mkdirSync(hooksDir, { recursive: true });
|
|
1050
1064
|
const userHook = '#!/bin/sh\necho "user hook"\n';
|
|
1051
1065
|
fs.writeFileSync(path.join(hooksDir, 'pre-push'), userHook);
|
|
1052
|
-
const upgraded = upgradePrePushHookIfNeeded(tmpDir);
|
|
1066
|
+
const upgraded = await upgradePrePushHookIfNeeded(tmpDir);
|
|
1053
1067
|
expect(upgraded).toBe(false);
|
|
1054
1068
|
const content = fs.readFileSync(path.join(hooksDir, 'pre-push'), 'utf-8');
|
|
1055
1069
|
expect(content).toBe(userHook); // File untouched
|
|
1056
1070
|
});
|
|
1057
|
-
it('skips hook that already uses stateless format', () => {
|
|
1071
|
+
it('skips hook that already uses stateless format', async () => {
|
|
1058
1072
|
// Install the current-version hook via non-interactive installer
|
|
1059
|
-
installHooksNonInteractive(tmpDir);
|
|
1073
|
+
await installHooksNonInteractive(tmpDir);
|
|
1060
1074
|
const hooksDir = path.join(tmpDir, '.git', 'hooks');
|
|
1061
1075
|
const beforeContent = fs.readFileSync(path.join(hooksDir, 'pre-push'), 'utf-8');
|
|
1062
|
-
const upgraded = upgradePrePushHookIfNeeded(tmpDir);
|
|
1076
|
+
const upgraded = await upgradePrePushHookIfNeeded(tmpDir);
|
|
1063
1077
|
expect(upgraded).toBe(false);
|
|
1064
1078
|
const afterContent = fs.readFileSync(path.join(hooksDir, 'pre-push'), 'utf-8');
|
|
1065
1079
|
expect(afterContent).toBe(beforeContent); // File untouched
|
|
1066
1080
|
});
|
|
1067
|
-
it('returns false when no pre-push hook exists', () => {
|
|
1068
|
-
const upgraded = upgradePrePushHookIfNeeded(tmpDir);
|
|
1081
|
+
it('returns false when no pre-push hook exists', async () => {
|
|
1082
|
+
const upgraded = await upgradePrePushHookIfNeeded(tmpDir);
|
|
1069
1083
|
expect(upgraded).toBe(false);
|
|
1070
1084
|
});
|
|
1071
|
-
it('returns false when not a git repo', () => {
|
|
1085
|
+
it('returns false when not a git repo', async () => {
|
|
1072
1086
|
const nonGitDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-no-git-'));
|
|
1073
1087
|
try {
|
|
1074
|
-
const upgraded = upgradePrePushHookIfNeeded(nonGitDir);
|
|
1088
|
+
const upgraded = await upgradePrePushHookIfNeeded(nonGitDir);
|
|
1075
1089
|
expect(upgraded).toBe(false);
|
|
1076
1090
|
}
|
|
1077
1091
|
finally {
|
|
1078
1092
|
cleanTmpDir(nonGitDir);
|
|
1079
1093
|
}
|
|
1080
1094
|
});
|
|
1081
|
-
it('preserves user-appended content when upgrading', () => {
|
|
1095
|
+
it('preserves user-appended content when upgrading', async () => {
|
|
1082
1096
|
const hooksDir = path.join(tmpDir, '.git', 'hooks');
|
|
1083
1097
|
fs.mkdirSync(hooksDir, { recursive: true });
|
|
1084
1098
|
// Simulate an old totem hook with user content appended after it
|
|
@@ -1093,7 +1107,7 @@ fi
|
|
|
1093
1107
|
`;
|
|
1094
1108
|
const userAppended = '\n# My custom deploy notification\ncurl -X POST https://hooks.example.com/deploy\n';
|
|
1095
1109
|
fs.writeFileSync(path.join(hooksDir, 'pre-push'), oldTotemBlock + userAppended);
|
|
1096
|
-
const upgraded = upgradePrePushHookIfNeeded(tmpDir);
|
|
1110
|
+
const upgraded = await upgradePrePushHookIfNeeded(tmpDir);
|
|
1097
1111
|
expect(upgraded).toBe(true);
|
|
1098
1112
|
const content = fs.readFileSync(path.join(hooksDir, 'pre-push'), 'utf-8');
|
|
1099
1113
|
// New totem block should use stateless format
|
|
@@ -1107,7 +1121,7 @@ fi
|
|
|
1107
1121
|
const actual = extractTotemBlock(content);
|
|
1108
1122
|
expect(actual).toBe(expectedTotemBlock());
|
|
1109
1123
|
});
|
|
1110
|
-
it('preserves user-appended if/fi blocks without corrupting them', () => {
|
|
1124
|
+
it('preserves user-appended if/fi blocks without corrupting them', async () => {
|
|
1111
1125
|
const hooksDir = path.join(tmpDir, '.git', 'hooks');
|
|
1112
1126
|
fs.mkdirSync(hooksDir, { recursive: true });
|
|
1113
1127
|
// Old totem block (needs upgrade) PLUS user content that contains its own if/fi structures
|
|
@@ -1138,7 +1152,7 @@ if [ -n "$SLACK_WEBHOOK" ]; then
|
|
|
1138
1152
|
fi
|
|
1139
1153
|
`;
|
|
1140
1154
|
fs.writeFileSync(path.join(hooksDir, 'pre-push'), oldTotemBlock + userIfFiBlock);
|
|
1141
|
-
const upgraded = upgradePrePushHookIfNeeded(tmpDir);
|
|
1155
|
+
const upgraded = await upgradePrePushHookIfNeeded(tmpDir);
|
|
1142
1156
|
expect(upgraded).toBe(true);
|
|
1143
1157
|
const content = fs.readFileSync(path.join(hooksDir, 'pre-push'), 'utf-8');
|
|
1144
1158
|
// Totem block must match canonical output exactly
|
|
@@ -1156,7 +1170,7 @@ fi
|
|
|
1156
1170
|
const userBlockStart = content.indexOf('# Custom deploy guard');
|
|
1157
1171
|
expect(userBlockStart).toBeGreaterThan(totemBlockEnd);
|
|
1158
1172
|
});
|
|
1159
|
-
it('leaves no stale fi or orphaned shell fragments after upgrade', () => {
|
|
1173
|
+
it('leaves no stale fi or orphaned shell fragments after upgrade', async () => {
|
|
1160
1174
|
const hooksDir = path.join(tmpDir, '.git', 'hooks');
|
|
1161
1175
|
fs.mkdirSync(hooksDir, { recursive: true });
|
|
1162
1176
|
const oldHook = `#!/bin/sh
|
|
@@ -1171,7 +1185,7 @@ if [ -f ".totem/compiled-rules.json" ]; then
|
|
|
1171
1185
|
fi
|
|
1172
1186
|
`;
|
|
1173
1187
|
fs.writeFileSync(path.join(hooksDir, 'pre-push'), oldHook);
|
|
1174
|
-
upgradePrePushHookIfNeeded(tmpDir);
|
|
1188
|
+
await upgradePrePushHookIfNeeded(tmpDir);
|
|
1175
1189
|
const content = fs.readFileSync(path.join(hooksDir, 'pre-push'), 'utf-8');
|
|
1176
1190
|
// Count if/fi balance: every `if` must have a matching `fi`.
|
|
1177
1191
|
// Inline `if ... fi` on a single line are self-balanced and excluded from both counts.
|
|
@@ -1187,7 +1201,7 @@ fi
|
|
|
1187
1201
|
const actual = extractTotemBlock(content);
|
|
1188
1202
|
expect(actual).toBe(expectedTotemBlock());
|
|
1189
1203
|
});
|
|
1190
|
-
it('upgrades old flag-checking hook to stateless format', () => {
|
|
1204
|
+
it('upgrades old flag-checking hook to stateless format', async () => {
|
|
1191
1205
|
const hooksDir = path.join(tmpDir, '.git', 'hooks');
|
|
1192
1206
|
fs.mkdirSync(hooksDir, { recursive: true });
|
|
1193
1207
|
// Simulate the previous flag-checking format that used .lint-passed
|
|
@@ -1208,7 +1222,7 @@ if [ -f ".totem/compiled-rules.json" ]; then
|
|
|
1208
1222
|
fi
|
|
1209
1223
|
`;
|
|
1210
1224
|
fs.writeFileSync(path.join(hooksDir, 'pre-push'), oldHook);
|
|
1211
|
-
const upgraded = upgradePrePushHookIfNeeded(tmpDir);
|
|
1225
|
+
const upgraded = await upgradePrePushHookIfNeeded(tmpDir);
|
|
1212
1226
|
expect(upgraded).toBe(true);
|
|
1213
1227
|
const content = fs.readFileSync(path.join(hooksDir, 'pre-push'), 'utf-8');
|
|
1214
1228
|
expect(content).toContain('verify-manifest');
|
|
@@ -1218,7 +1232,7 @@ fi
|
|
|
1218
1232
|
const actual = extractTotemBlock(content);
|
|
1219
1233
|
expect(actual).toBe(expectedTotemBlock());
|
|
1220
1234
|
});
|
|
1221
|
-
it('upgrades hook with auto-refresh and $TOTEM_CMD to stateless format', () => {
|
|
1235
|
+
it('upgrades hook with auto-refresh and $TOTEM_CMD to stateless format', async () => {
|
|
1222
1236
|
const hooksDir = path.join(tmpDir, '.git', 'hooks');
|
|
1223
1237
|
fs.mkdirSync(hooksDir, { recursive: true });
|
|
1224
1238
|
// Simulate a hook with the auto-refresh logic still using $TOTEM_CMD
|
|
@@ -1248,7 +1262,7 @@ if [ -f ".totem/compiled-rules.json" ]; then
|
|
|
1248
1262
|
fi
|
|
1249
1263
|
`;
|
|
1250
1264
|
fs.writeFileSync(path.join(hooksDir, 'pre-push'), oldHook);
|
|
1251
|
-
const upgraded = upgradePrePushHookIfNeeded(tmpDir);
|
|
1265
|
+
const upgraded = await upgradePrePushHookIfNeeded(tmpDir);
|
|
1252
1266
|
expect(upgraded).toBe(true);
|
|
1253
1267
|
const content = fs.readFileSync(path.join(hooksDir, 'pre-push'), 'utf-8');
|
|
1254
1268
|
expect(content).toContain('verify-manifest');
|
|
@@ -1263,7 +1277,7 @@ fi
|
|
|
1263
1277
|
// ─── Enforcement tier: agent detection & strict mode ──
|
|
1264
1278
|
describe('buildPreCommitHook agent detection', () => {
|
|
1265
1279
|
it('includes agent detection snippet', () => {
|
|
1266
|
-
const hook = buildPreCommitHook();
|
|
1280
|
+
const hook = buildPreCommitHook(RENDER);
|
|
1267
1281
|
expect(hook).toContain('is_agent=0');
|
|
1268
1282
|
expect(hook).toContain('is_agent=1');
|
|
1269
1283
|
expect(hook).toContain('CLAUDE_CODE_AGENT');
|
|
@@ -1272,52 +1286,270 @@ describe('buildPreCommitHook agent detection', () => {
|
|
|
1272
1286
|
// GEMINI_API_KEY intentionally excluded — human devs export it for Totem's embedding provider
|
|
1273
1287
|
});
|
|
1274
1288
|
it('includes TOTEM_HOOK_TIER variable', () => {
|
|
1275
|
-
const hook = buildPreCommitHook();
|
|
1289
|
+
const hook = buildPreCommitHook(RENDER);
|
|
1276
1290
|
expect(hook).toContain('TOTEM_HOOK_TIER="standard"');
|
|
1277
1291
|
});
|
|
1278
1292
|
it('sets TOTEM_HOOK_TIER to strict when tier is strict', () => {
|
|
1279
|
-
const hook = buildPreCommitHook('strict');
|
|
1293
|
+
const hook = buildPreCommitHook({ ...RENDER, tier: 'strict' });
|
|
1280
1294
|
expect(hook).toContain('TOTEM_HOOK_TIER="strict"');
|
|
1281
1295
|
});
|
|
1282
1296
|
});
|
|
1283
1297
|
describe('buildPreCommitHook with strict tier', () => {
|
|
1284
|
-
it('
|
|
1285
|
-
const hook = buildPreCommitHook('strict');
|
|
1286
|
-
|
|
1298
|
+
it('gates on spec evidence and never on the retired hand-set marker', () => {
|
|
1299
|
+
const hook = buildPreCommitHook({ ...RENDER, tier: 'strict' });
|
|
1300
|
+
// The former `.totem/cache/.spec-completed` sentinel (mmnto-ai/totem#2690)
|
|
1301
|
+
// may be NAMED in a comment that says it is not honored, but no test-able
|
|
1302
|
+
// path may read it.
|
|
1303
|
+
expect(hook).not.toMatch(/-f\s+"?\.totem\/cache\/\.spec-completed/);
|
|
1287
1304
|
expect(hook).toContain("Run 'totem spec <issue>' before committing (strict mode)");
|
|
1288
1305
|
});
|
|
1289
1306
|
it('gates spec check on agent detection or strict tier', () => {
|
|
1290
|
-
const hook = buildPreCommitHook('strict');
|
|
1307
|
+
const hook = buildPreCommitHook({ ...RENDER, tier: 'strict' });
|
|
1291
1308
|
expect(hook).toContain('$is_agent');
|
|
1292
1309
|
expect(hook).toContain('$TOTEM_HOOK_TIER');
|
|
1293
1310
|
});
|
|
1311
|
+
it('gates on the totem spec run artifact, JSON-aware — the only evidence', () => {
|
|
1312
|
+
const hook = buildPreCommitHook({ ...RENDER, tier: 'strict' });
|
|
1313
|
+
expect(hook).toContain('.totem/artifacts/runs');
|
|
1314
|
+
expect(hook).toContain('admission.runMetadata.caller');
|
|
1315
|
+
expect(hook).toContain('spec evidence:');
|
|
1316
|
+
expect(hook).not.toContain('(legacy marker)');
|
|
1317
|
+
});
|
|
1318
|
+
});
|
|
1319
|
+
// The strict block EXECUTED under sh (mmnto-ai/totem#2690): the string checks
|
|
1320
|
+
// above pin the text; these pin the behavior the gate exists for. `git init` +
|
|
1321
|
+
// a feature branch so the protected-branch block does not fire; the agent
|
|
1322
|
+
// env var arms the strict tier the way an agent commit does.
|
|
1323
|
+
describe('buildPreCommitHook strict evidence — executed under sh (mmnto-ai/totem#2690)', () => {
|
|
1324
|
+
const shellOk = spawnSync('sh', ['-c', 'command -v node >/dev/null 2>&1'], { encoding: 'utf-8' }).status === 0;
|
|
1325
|
+
let tmpDir;
|
|
1326
|
+
beforeEach(() => {
|
|
1327
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-hook-evidence-'));
|
|
1328
|
+
execSync('git init -q', { cwd: tmpDir, stdio: 'ignore' });
|
|
1329
|
+
execSync('git checkout -q -b feat/evidence', { cwd: tmpDir, stdio: 'ignore' });
|
|
1330
|
+
fs.writeFileSync(path.join(tmpDir, 'pre-commit'), buildPreCommitHook(RENDER));
|
|
1331
|
+
});
|
|
1332
|
+
afterEach(() => {
|
|
1333
|
+
cleanTmpDir(tmpDir);
|
|
1334
|
+
});
|
|
1335
|
+
function runHook() {
|
|
1336
|
+
const r = spawnSync('sh', ['./pre-commit'], {
|
|
1337
|
+
cwd: tmpDir,
|
|
1338
|
+
encoding: 'utf-8',
|
|
1339
|
+
env: { ...process.env, CLAUDE_CODE_AGENT: '1' },
|
|
1340
|
+
});
|
|
1341
|
+
return { status: r.status, stdout: r.stdout };
|
|
1342
|
+
}
|
|
1343
|
+
function writeRun(name, artifact) {
|
|
1344
|
+
const dir = path.join(tmpDir, '.totem', 'artifacts', 'runs');
|
|
1345
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1346
|
+
fs.writeFileSync(path.join(dir, name), typeof artifact === 'string' ? artifact : JSON.stringify(artifact, null, 2));
|
|
1347
|
+
}
|
|
1348
|
+
// A `node` shim that fails: first on PATH, so the hook's reader cannot run.
|
|
1349
|
+
// Exercises the reader-failure arm without touching the real runtime.
|
|
1350
|
+
function shimNodeExiting(status) {
|
|
1351
|
+
const shimDir = path.join(tmpDir, 'node-shim');
|
|
1352
|
+
fs.mkdirSync(shimDir, { recursive: true });
|
|
1353
|
+
fs.writeFileSync(path.join(shimDir, 'node'), `#!/bin/sh\nexit ${status}\n`, { mode: 0o755 });
|
|
1354
|
+
return {
|
|
1355
|
+
...process.env,
|
|
1356
|
+
CLAUDE_CODE_AGENT: '1',
|
|
1357
|
+
PATH: `${shimDir}${path.delimiter}${process.env.PATH ?? ''}`,
|
|
1358
|
+
};
|
|
1359
|
+
}
|
|
1360
|
+
function runHookWith(env) {
|
|
1361
|
+
const r = spawnSync('sh', ['./pre-commit'], { cwd: tmpDir, encoding: 'utf-8', env });
|
|
1362
|
+
return { status: r.status, stdout: r.stdout };
|
|
1363
|
+
}
|
|
1364
|
+
it.skipIf(!shellOk)('reports a reader that cannot run DISTINCTLY from missing evidence, and still fails closed', () => {
|
|
1365
|
+
const r = runHookWith(shimNodeExiting(5));
|
|
1366
|
+
expect(r.status).toBe(1);
|
|
1367
|
+
expect(r.stdout).toContain('[Totem] BLOCKED: the spec-evidence reader could not run (node exit status 5');
|
|
1368
|
+
expect(r.stdout).not.toContain('no totem spec run artifact');
|
|
1369
|
+
});
|
|
1370
|
+
it.skipIf(!shellOk)('a hand-set marker does not rescue a reader that cannot run — still the distinct failure, still closed', () => {
|
|
1371
|
+
fs.mkdirSync(path.join(tmpDir, '.totem', 'cache'), { recursive: true });
|
|
1372
|
+
fs.writeFileSync(path.join(tmpDir, '.totem', 'cache', '.spec-completed'), '');
|
|
1373
|
+
const r = runHookWith(shimNodeExiting(127));
|
|
1374
|
+
expect(r.status).toBe(1);
|
|
1375
|
+
expect(r.stdout).toContain('the spec-evidence reader could not run (node exit status 127');
|
|
1376
|
+
expect(r.stdout).not.toContain('legacy');
|
|
1377
|
+
});
|
|
1378
|
+
it.skipIf(!shellOk)('blocks when this checkout carries no evidence at all', () => {
|
|
1379
|
+
const r = runHook();
|
|
1380
|
+
expect(r.status).toBe(1);
|
|
1381
|
+
expect(r.stdout).toContain("[Totem] BLOCKED: Run 'totem spec <issue>' before committing (strict mode)");
|
|
1382
|
+
expect(r.stdout).toContain('no totem spec run artifact under .totem/artifacts/runs/');
|
|
1383
|
+
});
|
|
1384
|
+
it.skipIf(!shellOk)('passes on a spec run artifact and prints the evidence line with its age', () => {
|
|
1385
|
+
writeRun('a.json', {
|
|
1386
|
+
admission: { runMetadata: { caller: 'spec' } },
|
|
1387
|
+
createdAt: new Date().toISOString(),
|
|
1388
|
+
});
|
|
1389
|
+
const r = runHook();
|
|
1390
|
+
expect(r.status).toBe(0);
|
|
1391
|
+
expect(r.stdout).toContain('[Totem] spec evidence: .totem/artifacts/runs/a.json (');
|
|
1392
|
+
expect(r.stdout).toContain('0 days old');
|
|
1393
|
+
});
|
|
1394
|
+
it.skipIf(!shellOk)('does NOT pass on a review artifact that merely CARRIES a caller:spec object below the top level (the substring hazard)', () => {
|
|
1395
|
+
// A substring read of the run store would match this file: the key
|
|
1396
|
+
// spelling appears verbatim, as a carried OBJECT under inputBundle (the
|
|
1397
|
+
// shape a review over run metadata produces). Only the TOP-LEVEL
|
|
1398
|
+
// admission.runMetadata.caller is evidence. (Carried TEXT — a prompt that
|
|
1399
|
+
// quotes the key — is JSON-escaped on disk and never matches either read;
|
|
1400
|
+
// the carried-object form is the real hazard, so it is the control.)
|
|
1401
|
+
writeRun('r.json', {
|
|
1402
|
+
admission: { runMetadata: { caller: 'review' } },
|
|
1403
|
+
inputBundle: { runMetadata: { caller: 'spec' } },
|
|
1404
|
+
createdAt: new Date().toISOString(),
|
|
1405
|
+
});
|
|
1406
|
+
const onDisk = fs.readFileSync(path.join(tmpDir, '.totem/artifacts/runs/r.json'), 'utf-8');
|
|
1407
|
+
// Mutation check on the control itself: the naive pattern WOULD match.
|
|
1408
|
+
expect(/"caller": *"spec"/.test(onDisk)).toBe(true);
|
|
1409
|
+
const r = runHook();
|
|
1410
|
+
expect(r.status).toBe(1);
|
|
1411
|
+
expect(r.stdout).toContain('BLOCKED');
|
|
1412
|
+
});
|
|
1413
|
+
it.skipIf(!shellOk)('ignores the retired hand-set marker — a marker alone is BLOCKED', () => {
|
|
1414
|
+
// No CLI path ever wrote `.totem/cache/.spec-completed`; honoring it would be
|
|
1415
|
+
// compatibility with a hand hack, not with a user (operator ruling
|
|
1416
|
+
// 2026-08-29, mmnto-ai/totem#2690).
|
|
1417
|
+
fs.mkdirSync(path.join(tmpDir, '.totem', 'cache'), { recursive: true });
|
|
1418
|
+
fs.writeFileSync(path.join(tmpDir, '.totem', 'cache', '.spec-completed'), '');
|
|
1419
|
+
const r = runHook();
|
|
1420
|
+
expect(r.status).toBe(1);
|
|
1421
|
+
expect(r.stdout).toContain('no totem spec run artifact under .totem/artifacts/runs/');
|
|
1422
|
+
expect(r.stdout).not.toContain('spec evidence:');
|
|
1423
|
+
});
|
|
1424
|
+
it.skipIf(!shellOk)('skips a torn (unparseable) artifact and still passes on a valid one beside it', () => {
|
|
1425
|
+
writeRun('torn.json', '{"admission": {"runMetadata": {"caller": "spec"');
|
|
1426
|
+
writeRun('ok.json', {
|
|
1427
|
+
admission: { runMetadata: { caller: 'spec' } },
|
|
1428
|
+
createdAt: new Date().toISOString(),
|
|
1429
|
+
});
|
|
1430
|
+
const r = runHook();
|
|
1431
|
+
expect(r.status).toBe(0);
|
|
1432
|
+
expect(r.stdout).toContain('.totem/artifacts/runs/ok.json');
|
|
1433
|
+
});
|
|
1434
|
+
it.skipIf(!shellOk)('blocks when the only artifact is torn', () => {
|
|
1435
|
+
writeRun('torn.json', '{"admission": {"runMetadata": {"caller": "spec"');
|
|
1436
|
+
const r = runHook();
|
|
1437
|
+
expect(r.status).toBe(1);
|
|
1438
|
+
});
|
|
1439
|
+
it.skipIf(!shellOk)('names the NEWEST spec artifact by its own createdAt', () => {
|
|
1440
|
+
writeRun('old.json', {
|
|
1441
|
+
admission: { runMetadata: { caller: 'spec' } },
|
|
1442
|
+
createdAt: '2026-01-01T00:00:00.000Z',
|
|
1443
|
+
});
|
|
1444
|
+
writeRun('new.json', {
|
|
1445
|
+
admission: { runMetadata: { caller: 'spec' } },
|
|
1446
|
+
createdAt: new Date().toISOString(),
|
|
1447
|
+
});
|
|
1448
|
+
const r = runHook();
|
|
1449
|
+
expect(r.status).toBe(0);
|
|
1450
|
+
expect(r.stdout).toContain('.totem/artifacts/runs/new.json');
|
|
1451
|
+
expect(r.stdout).not.toContain('old.json');
|
|
1452
|
+
});
|
|
1453
|
+
});
|
|
1454
|
+
// The mmnto-ai/totem#2692 keystone, EXECUTED: under a custom `totemDir` the
|
|
1455
|
+
// hook's reader and `totem spec`'s writer must name the SAME tree. The fixture
|
|
1456
|
+
// writes where the WRITER writes (`<totemDir>/artifacts/runs`, the
|
|
1457
|
+
// `path.join(configRoot, config.totemDir)/artifacts/runs` the run store uses) and
|
|
1458
|
+
// the hook is rendered at that same totemDir — so a pass here is the two halves
|
|
1459
|
+
// agreeing, and the control below is the pre-fix behavior (evidence under
|
|
1460
|
+
// `.totem/` while the hook reads `knowledge/`) still failing closed.
|
|
1461
|
+
describe('buildPreCommitHook strict evidence under a CUSTOM totemDir — executed under sh (mmnto-ai/totem#2692)', () => {
|
|
1462
|
+
const shellOk = spawnSync('sh', ['-c', 'command -v node >/dev/null 2>&1'], { encoding: 'utf-8' }).status === 0;
|
|
1463
|
+
const CUSTOM_TOTEM_DIR = 'knowledge';
|
|
1464
|
+
let tmpDir;
|
|
1465
|
+
beforeEach(() => {
|
|
1466
|
+
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-hook-customdir-'));
|
|
1467
|
+
execSync('git init -q', { cwd: tmpDir, stdio: 'ignore' });
|
|
1468
|
+
execSync('git checkout -q -b feat/evidence', { cwd: tmpDir, stdio: 'ignore' });
|
|
1469
|
+
fs.writeFileSync(path.join(tmpDir, 'pre-commit'), buildPreCommitHook({ ...RENDER, totemDir: CUSTOM_TOTEM_DIR }));
|
|
1470
|
+
});
|
|
1471
|
+
afterEach(() => {
|
|
1472
|
+
cleanTmpDir(tmpDir);
|
|
1473
|
+
});
|
|
1474
|
+
function runHook() {
|
|
1475
|
+
const r = spawnSync('sh', ['./pre-commit'], {
|
|
1476
|
+
cwd: tmpDir,
|
|
1477
|
+
encoding: 'utf-8',
|
|
1478
|
+
env: { ...process.env, CLAUDE_CODE_AGENT: '1' },
|
|
1479
|
+
});
|
|
1480
|
+
return { status: r.status, stdout: r.stdout };
|
|
1481
|
+
}
|
|
1482
|
+
/** Write a run artifact where the WRITER writes it, for an arbitrary totemDir. */
|
|
1483
|
+
function writeRunUnder(totemDir, name, artifact) {
|
|
1484
|
+
const dir = path.join(tmpDir, ...totemDir.split('/'), 'artifacts', 'runs');
|
|
1485
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1486
|
+
fs.writeFileSync(path.join(dir, name), JSON.stringify(artifact, null, 2));
|
|
1487
|
+
}
|
|
1488
|
+
const specArtifact = () => ({
|
|
1489
|
+
admission: { runMetadata: { caller: 'spec' } },
|
|
1490
|
+
createdAt: new Date().toISOString(),
|
|
1491
|
+
});
|
|
1492
|
+
it.skipIf(!shellOk)('PASSES on evidence written under the configured totemDir', () => {
|
|
1493
|
+
writeRunUnder(CUSTOM_TOTEM_DIR, 'a.json', specArtifact());
|
|
1494
|
+
const r = runHook();
|
|
1495
|
+
expect(r.status).toBe(0);
|
|
1496
|
+
expect(r.stdout).toContain(`[Totem] spec evidence: ${CUSTOM_TOTEM_DIR}/artifacts/runs/a.json (`);
|
|
1497
|
+
});
|
|
1498
|
+
it.skipIf(!shellOk)('the pre-fix control: evidence under .totem/ does NOT satisfy a hook rendered for knowledge/', () => {
|
|
1499
|
+
// This is the mmnto-ai/totem#2692 defect, inverted: before the fix the hook
|
|
1500
|
+
// ALWAYS read `.totem/artifacts/runs` while the writer used the configured
|
|
1501
|
+
// dir, so the gate failed closed forever. With the hook rendered at the
|
|
1502
|
+
// configured dir, evidence in the WRONG tree is correctly not evidence.
|
|
1503
|
+
writeRunUnder('.totem', 'a.json', specArtifact());
|
|
1504
|
+
const r = runHook();
|
|
1505
|
+
expect(r.status).toBe(1);
|
|
1506
|
+
expect(r.stdout).toContain(`no totem spec run artifact under ${CUSTOM_TOTEM_DIR}/artifacts/runs/`);
|
|
1507
|
+
});
|
|
1508
|
+
it.skipIf(!shellOk)('blocks with a message naming the configured directory', () => {
|
|
1509
|
+
const r = runHook();
|
|
1510
|
+
expect(r.status).toBe(1);
|
|
1511
|
+
expect(r.stdout).toContain("[Totem] BLOCKED: Run 'totem spec <issue>' before committing (strict mode)");
|
|
1512
|
+
expect(r.stdout).toContain(`under ${CUSTOM_TOTEM_DIR}/artifacts/runs/`);
|
|
1513
|
+
expect(r.stdout).not.toContain('.totem');
|
|
1514
|
+
});
|
|
1515
|
+
it.skipIf(!shellOk)('a nested totemDir survives the same round trip', () => {
|
|
1516
|
+
const nested = 'var/totem-state';
|
|
1517
|
+
fs.writeFileSync(path.join(tmpDir, 'pre-commit'), buildPreCommitHook({ ...RENDER, totemDir: nested }));
|
|
1518
|
+
writeRunUnder(nested, 'a.json', specArtifact());
|
|
1519
|
+
const r = runHook();
|
|
1520
|
+
expect(r.status).toBe(0);
|
|
1521
|
+
expect(r.stdout).toContain(`[Totem] spec evidence: ${nested}/artifacts/runs/a.json (`);
|
|
1522
|
+
});
|
|
1294
1523
|
});
|
|
1295
1524
|
describe('buildPreCommitHook with standard tier', () => {
|
|
1296
|
-
it('includes spec-
|
|
1297
|
-
const hook = buildPreCommitHook(
|
|
1298
|
-
expect(hook).toContain('.
|
|
1525
|
+
it('includes the spec-evidence check guarded by agent/tier condition', () => {
|
|
1526
|
+
const hook = buildPreCommitHook(RENDER);
|
|
1527
|
+
expect(hook).toContain('.totem/artifacts/runs');
|
|
1299
1528
|
expect(hook).toContain('is_agent');
|
|
1300
1529
|
expect(hook).toContain('TOTEM_HOOK_TIER="standard"');
|
|
1301
1530
|
});
|
|
1302
|
-
|
|
1303
|
-
|
|
1531
|
+
// Pre-mmnto-ai/totem#2692 this read "defaults to standard tier when no tier
|
|
1532
|
+
// specified". There is no default any more — the options object is required and
|
|
1533
|
+
// the RESOLVER supplies 'standard' — so the surviving intent is that the
|
|
1534
|
+
// standard-tier render still names the run store the resolver hands it.
|
|
1535
|
+
it('renders the run store at the options it was given', () => {
|
|
1536
|
+
const hook = buildPreCommitHook(RENDER);
|
|
1304
1537
|
expect(hook).toContain('TOTEM_HOOK_TIER="standard"');
|
|
1305
|
-
expect(hook).toContain('.
|
|
1538
|
+
expect(hook).toContain('.totem/artifacts/runs');
|
|
1306
1539
|
});
|
|
1307
1540
|
});
|
|
1308
1541
|
describe('buildPrePushHook with strict tier', () => {
|
|
1309
|
-
const FALLBACK = 'pnpm dlx @mmnto/cli';
|
|
1310
1542
|
it('includes shield gate', () => {
|
|
1311
|
-
const hook = buildPrePushHook(
|
|
1543
|
+
const hook = buildPrePushHook({ ...RENDER, tier: 'strict' });
|
|
1312
1544
|
expect(hook).toContain('review');
|
|
1313
1545
|
expect(hook).toContain('shield gate (strict mode)');
|
|
1314
1546
|
});
|
|
1315
1547
|
it('includes TOTEM_HOOK_TIER set to strict', () => {
|
|
1316
|
-
const hook = buildPrePushHook(
|
|
1548
|
+
const hook = buildPrePushHook({ ...RENDER, tier: 'strict' });
|
|
1317
1549
|
expect(hook).toContain('TOTEM_HOOK_TIER="strict"');
|
|
1318
1550
|
});
|
|
1319
1551
|
it('includes agent detection snippet', () => {
|
|
1320
|
-
const hook = buildPrePushHook(
|
|
1552
|
+
const hook = buildPrePushHook({ ...RENDER, tier: 'strict' });
|
|
1321
1553
|
expect(hook).toContain('is_agent=0');
|
|
1322
1554
|
expect(hook).toContain('CLAUDE_CODE_AGENT');
|
|
1323
1555
|
});
|
|
@@ -1325,12 +1557,12 @@ describe('buildPrePushHook with strict tier', () => {
|
|
|
1325
1557
|
// shieldBlock alongside the existing `totem review` gate. Repo-state
|
|
1326
1558
|
// checks (like checkAgentsMdCanonical) now block push when they fail.
|
|
1327
1559
|
it('includes $TOTEM_CMD doctor --strict in the shield block', () => {
|
|
1328
|
-
const hook = buildPrePushHook(
|
|
1560
|
+
const hook = buildPrePushHook({ ...RENDER, tier: 'strict' });
|
|
1329
1561
|
expect(hook).toContain('$TOTEM_CMD doctor --strict');
|
|
1330
1562
|
expect(hook).toContain('doctor --strict (repo-state gate)');
|
|
1331
1563
|
});
|
|
1332
1564
|
it('gates doctor --strict on the agent/strict guard (does NOT fire unconditionally)', () => {
|
|
1333
|
-
const hook = buildPrePushHook(
|
|
1565
|
+
const hook = buildPrePushHook({ ...RENDER, tier: 'strict' });
|
|
1334
1566
|
// Find the position of the doctor --strict invocation and the surrounding
|
|
1335
1567
|
// guard; assert the invocation lives INSIDE the agent-or-tier block.
|
|
1336
1568
|
const guardIdx = hook.indexOf('if [ "$is_agent" = "1" ] || [ "$TOTEM_HOOK_TIER" = "strict" ]');
|
|
@@ -1342,20 +1574,22 @@ describe('buildPrePushHook with strict tier', () => {
|
|
|
1342
1574
|
});
|
|
1343
1575
|
});
|
|
1344
1576
|
describe('buildPrePushHook with standard tier', () => {
|
|
1345
|
-
const FALLBACK = 'pnpm dlx @mmnto/cli';
|
|
1346
1577
|
it('includes shield gate guarded by agent/tier condition', () => {
|
|
1347
|
-
const hook = buildPrePushHook(
|
|
1578
|
+
const hook = buildPrePushHook(RENDER);
|
|
1348
1579
|
expect(hook).toContain('shield gate');
|
|
1349
1580
|
expect(hook).toContain('is_agent');
|
|
1350
1581
|
expect(hook).toContain('TOTEM_HOOK_TIER="standard"');
|
|
1351
1582
|
});
|
|
1352
|
-
|
|
1353
|
-
|
|
1583
|
+
// Pre-mmnto-ai/totem#2692 this read "defaults to standard tier when no tier
|
|
1584
|
+
// specified" — the builders take a REQUIRED options object now, so the
|
|
1585
|
+
// surviving intent is that a standard-tier render stamps the standard tier.
|
|
1586
|
+
it('stamps the standard tier it was given', () => {
|
|
1587
|
+
const hook = buildPrePushHook(RENDER);
|
|
1354
1588
|
expect(hook).toContain('TOTEM_HOOK_TIER="standard"');
|
|
1355
1589
|
expect(hook).toContain('shield gate');
|
|
1356
1590
|
});
|
|
1357
1591
|
it('still includes agent detection', () => {
|
|
1358
|
-
const hook = buildPrePushHook(
|
|
1592
|
+
const hook = buildPrePushHook(RENDER);
|
|
1359
1593
|
expect(hook).toContain('is_agent=0');
|
|
1360
1594
|
});
|
|
1361
1595
|
// mmnto-ai/totem#1908 — even in standard tier, the doctor --strict line
|
|
@@ -1364,7 +1598,7 @@ describe('buildPrePushHook with standard tier', () => {
|
|
|
1364
1598
|
// operators bypass it because their `is_agent=0` and `TOTEM_HOOK_TIER` is
|
|
1365
1599
|
// standard, so the guard branch never enters.
|
|
1366
1600
|
it('emits doctor --strict gated by agent/strict guard (no unconditional fire in standard tier)', () => {
|
|
1367
|
-
const hook = buildPrePushHook(
|
|
1601
|
+
const hook = buildPrePushHook(RENDER);
|
|
1368
1602
|
const guardIdx = hook.indexOf('if [ "$is_agent" = "1" ] || [ "$TOTEM_HOOK_TIER" = "strict" ]');
|
|
1369
1603
|
const doctorIdx = hook.indexOf('$TOTEM_CMD doctor --strict');
|
|
1370
1604
|
const fiCloseIdx = hook.indexOf('fi', guardIdx);
|
|
@@ -1375,7 +1609,7 @@ describe('buildPrePushHook with standard tier', () => {
|
|
|
1375
1609
|
});
|
|
1376
1610
|
describe('agent detection uses POSIX syntax', () => {
|
|
1377
1611
|
it('pre-commit hook has no bashisms', () => {
|
|
1378
|
-
const hook = buildPreCommitHook('strict');
|
|
1612
|
+
const hook = buildPreCommitHook({ ...RENDER, tier: 'strict' });
|
|
1379
1613
|
// Must use [ ] not [[ ]]
|
|
1380
1614
|
expect(hook).not.toContain('[[');
|
|
1381
1615
|
expect(hook).not.toContain(']]');
|
|
@@ -1385,7 +1619,7 @@ describe('agent detection uses POSIX syntax', () => {
|
|
|
1385
1619
|
expect(hook).toMatch(/^#!\/bin\/sh\n/);
|
|
1386
1620
|
});
|
|
1387
1621
|
it('pre-push hook has no bashisms', () => {
|
|
1388
|
-
const hook = buildPrePushHook(
|
|
1622
|
+
const hook = buildPrePushHook({ ...RENDER, tier: 'strict' });
|
|
1389
1623
|
// Must use [ ] not [[ ]]
|
|
1390
1624
|
expect(hook).not.toContain('[[');
|
|
1391
1625
|
expect(hook).not.toContain(']]');
|