@mmnto/cli 1.119.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/gemini-sessionstart-contract.test.js +50 -0
- package/dist/commands/gemini-sessionstart-contract.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 +6 -6
- package/dist/commands/init-templates.d.ts.map +1 -1
- package/dist/commands/init-templates.js +105 -19
- package/dist/commands/init-templates.js.map +1 -1
- package/dist/commands/init.test.js +136 -12
- package/dist/commands/init.test.js.map +1 -1
- 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 +381 -92
- package/dist/commands/install-hooks.js.map +1 -1
- package/dist/commands/install-hooks.test.js +446 -158
- 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/rule.test.js +30 -0
- package/dist/commands/rule.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).
|
|
@@ -812,10 +820,40 @@ describe('post-merge hook fires totem-status refresh-gh', () => {
|
|
|
812
820
|
// so the open failure of an unwritable log is itself silent (falsification
|
|
813
821
|
// round, MAJOR 1).
|
|
814
822
|
expect(hook).toContain('2>/dev/null >> "$TS_REFRESH_LOG"');
|
|
815
|
-
// The
|
|
816
|
-
//
|
|
823
|
+
// The children append to the SAME log so their output lands after the
|
|
824
|
+
// stamps. Narrowed once a second verb joined the block: child output is
|
|
825
|
+
// unlabelled and interleaves, so a silent tail attributes only to the LAST
|
|
826
|
+
// verb stamped — not per child.
|
|
817
827
|
expect(hook).toContain('(totem-status refresh-gh >> "$TS_REFRESH_LOG" 2>&1 &)');
|
|
818
828
|
});
|
|
829
|
+
// ── slice-two residual: refresh-obligation-store beside refresh-gh ──
|
|
830
|
+
// (mmnto-ai/totem-status#127; sibling of mmnto-ai/totem#2556.) The durable
|
|
831
|
+
// obligation store gets the same post-merge moment as the GH snapshot.
|
|
832
|
+
it('also fires refresh-obligation-store under the SAME gate and log (mmnto-ai/totem-status#127)', () => {
|
|
833
|
+
const hook = buildHookContent(RENDER);
|
|
834
|
+
// One gate, not two: both verbs live inside the single presence +
|
|
835
|
+
// primary-checkout `if`, so a worktree/non-adopter skips BOTH.
|
|
836
|
+
const gateIdx = hook.indexOf('if [ -d .git ] && command -v totem-status >/dev/null 2>&1; then');
|
|
837
|
+
expect(gateIdx).toBeGreaterThanOrEqual(0);
|
|
838
|
+
// Anchor on the EXECUTABLE form, not the banner prose above the gate: a
|
|
839
|
+
// mutant that relocates the invocation outside the gate but leaves the
|
|
840
|
+
// comment in place must fail here.
|
|
841
|
+
expect(hook.indexOf('(totem-status refresh-obligation-store')).toBeGreaterThan(gateIdx);
|
|
842
|
+
// …and it stays inside the block, ahead of the gate's closing `fi`.
|
|
843
|
+
expect(hook.indexOf('(totem-status refresh-obligation-store')).toBeLessThan(hook.indexOf('# Only sync when lessons changed'));
|
|
844
|
+
// Both arms of the log-writability branch mirror the refresh-gh pair: the
|
|
845
|
+
// logged form, and the blind fallback when the log cannot be opened.
|
|
846
|
+
expect(hook).toContain('(totem-status refresh-obligation-store >> "$TS_REFRESH_LOG" 2>&1 &)');
|
|
847
|
+
expect(hook).toContain('(totem-status refresh-obligation-store >/dev/null 2>&1 &)');
|
|
848
|
+
// The stamp NAMES the verb, so the log records which verbs fired and in
|
|
849
|
+
// what order (it does not restore per-child reap attribution — child
|
|
850
|
+
// output is unlabelled).
|
|
851
|
+
expect(hook).toContain('post-merge spawn cwd=%s bin=%s verb=%s');
|
|
852
|
+
expect(hook).toContain('"$(command -v totem-status | tr -d \'[:cntrl:]\')" refresh-gh');
|
|
853
|
+
expect(hook).toContain('"$(command -v totem-status | tr -d \'[:cntrl:]\')" refresh-obligation-store');
|
|
854
|
+
// The bounded owned region stays intact after the addition.
|
|
855
|
+
expect(hook.trimEnd().endsWith(`# ${TOTEM_HOOK_END}`)).toBe(true);
|
|
856
|
+
});
|
|
819
857
|
});
|
|
820
858
|
// Behavioral coverage of BOTH gate branches (falsification-leg round 1: the string
|
|
821
859
|
// assertions above are satisfiable without the behavior). POSIX-only: the stub
|
|
@@ -830,23 +868,38 @@ describe.skipIf(process.platform === 'win32')('post-merge refresh-gh behavior (P
|
|
|
830
868
|
binDir = path.join(tmpDir, 'stub-bin');
|
|
831
869
|
fs.mkdirSync(binDir);
|
|
832
870
|
markerPath = path.join(tmpDir, 'refresh-fired.marker');
|
|
833
|
-
|
|
871
|
+
// APPEND, not truncate: the gate now fires TWO verbs from one block and the
|
|
872
|
+
// backgrounded children land in whatever order they finish — a `>` stub
|
|
873
|
+
// would race them into a last-writer-wins single line.
|
|
874
|
+
fs.writeFileSync(path.join(binDir, 'totem-status'), `#!/bin/sh\necho "$1" >> "${markerPath}"\n`, { mode: 0o755 });
|
|
834
875
|
});
|
|
835
876
|
afterEach(() => {
|
|
836
877
|
cleanTmpDir(tmpDir);
|
|
837
878
|
});
|
|
838
|
-
/**
|
|
839
|
-
* the stub's open-
|
|
840
|
-
* `expected '' to be 'refresh-gh'`). */
|
|
841
|
-
function
|
|
879
|
+
/** The verbs the stub sidecar has been invoked with so far, sorted. Reading
|
|
880
|
+
* CONTENT (not existence) still guards the stub's open-then-write window
|
|
881
|
+
* (observed as a CI flake: `expected '' to be 'refresh-gh'`). */
|
|
882
|
+
function firedVerbs() {
|
|
842
883
|
try {
|
|
843
|
-
|
|
844
|
-
|
|
884
|
+
if (!fs.existsSync(markerPath))
|
|
885
|
+
return [];
|
|
886
|
+
return fs
|
|
887
|
+
.readFileSync(markerPath, 'utf-8')
|
|
888
|
+
.split('\n')
|
|
889
|
+
.map((line) => line.trim())
|
|
890
|
+
.filter((line) => line.length > 0)
|
|
891
|
+
.sort();
|
|
892
|
+
// totem-context: intentional [] on a read race (marker mid-write) — the poll loop retries
|
|
845
893
|
}
|
|
846
894
|
catch {
|
|
847
|
-
return
|
|
895
|
+
return [];
|
|
848
896
|
}
|
|
849
897
|
}
|
|
898
|
+
/** Both verbs must land: refresh-gh AND the slice-two refresh-obligation-store. */
|
|
899
|
+
const BOTH_VERBS = ['refresh-gh', 'refresh-obligation-store'];
|
|
900
|
+
function markerReady() {
|
|
901
|
+
return firedVerbs().length === BOTH_VERBS.length;
|
|
902
|
+
}
|
|
850
903
|
async function markerAppears(timeoutMs) {
|
|
851
904
|
const deadline = Date.now() + timeoutMs;
|
|
852
905
|
while (Date.now() < deadline) {
|
|
@@ -860,14 +913,15 @@ describe.skipIf(process.platform === 'win32')('post-merge refresh-gh behavior (P
|
|
|
860
913
|
const repo = path.join(tmpDir, 'repo');
|
|
861
914
|
fs.mkdirSync(path.join(repo, '.git'), { recursive: true });
|
|
862
915
|
const hookPath = path.join(repo, 'post-merge');
|
|
863
|
-
fs.writeFileSync(hookPath, buildHookContent(
|
|
916
|
+
fs.writeFileSync(hookPath, buildHookContent(RENDER), { mode: 0o755 });
|
|
864
917
|
execSync('sh ./post-merge', {
|
|
865
918
|
cwd: repo,
|
|
866
919
|
env: { ...process.env, PATH: `${binDir}:${process.env.PATH ?? ''}` },
|
|
867
920
|
stdio: 'ignore',
|
|
868
921
|
});
|
|
869
922
|
expect(await markerAppears(5000)).toBe(true);
|
|
870
|
-
|
|
923
|
+
// BOTH verbs fired from the one gate, and nothing else did.
|
|
924
|
+
expect(firedVerbs()).toEqual(BOTH_VERBS);
|
|
871
925
|
// #2570 observability leg: the firing left a stamp in the repo-local
|
|
872
926
|
// .git log before the child ran, and bin= carries the RESOLVED path.
|
|
873
927
|
const logPath = path.join(repo, '.git', 'totem-status-refresh-hook.log');
|
|
@@ -875,6 +929,10 @@ describe.skipIf(process.platform === 'win32')('post-merge refresh-gh behavior (P
|
|
|
875
929
|
const logText = fs.readFileSync(logPath, 'utf-8');
|
|
876
930
|
expect(logText).toContain('post-merge spawn cwd=');
|
|
877
931
|
expect(logText).toMatch(/bin=\S*totem-status/);
|
|
932
|
+
// Each stamp NAMES its verb, so the log records which verbs fired and in
|
|
933
|
+
// what order — the attribution the unlabelled child output cannot give.
|
|
934
|
+
expect(logText).toContain('verb=refresh-gh');
|
|
935
|
+
expect(logText).toContain('verb=refresh-obligation-store');
|
|
878
936
|
});
|
|
879
937
|
it('#2570: an unwritable log falls back to blind firing with zero stderr noise', { timeout: 15000 }, async () => {
|
|
880
938
|
const repo = path.join(tmpDir, 'repo');
|
|
@@ -885,7 +943,7 @@ describe.skipIf(process.platform === 'win32')('post-merge refresh-gh behavior (P
|
|
|
885
943
|
// append; falsification round, MAJOR 1).
|
|
886
944
|
fs.mkdirSync(path.join(repo, '.git', 'totem-status-refresh-hook.log'), { recursive: true });
|
|
887
945
|
const hookPath = path.join(repo, 'post-merge');
|
|
888
|
-
fs.writeFileSync(hookPath, buildHookContent(
|
|
946
|
+
fs.writeFileSync(hookPath, buildHookContent(RENDER), { mode: 0o755 });
|
|
889
947
|
const result = spawnSync('sh', ['./post-merge'], {
|
|
890
948
|
cwd: repo,
|
|
891
949
|
env: { ...process.env, PATH: `${binDir}:${process.env.PATH ?? ''}` },
|
|
@@ -894,7 +952,8 @@ describe.skipIf(process.platform === 'win32')('post-merge refresh-gh behavior (P
|
|
|
894
952
|
expect(result.status).toBe(0);
|
|
895
953
|
expect(result.stderr ?? '').not.toContain('totem-status-refresh-hook.log');
|
|
896
954
|
expect(await markerAppears(5000)).toBe(true);
|
|
897
|
-
|
|
955
|
+
// The blind fallback carries BOTH verbs, not just the first.
|
|
956
|
+
expect(firedVerbs()).toEqual(BOTH_VERBS);
|
|
898
957
|
});
|
|
899
958
|
it('skips the sidecar when .git is not a directory (worktree/non-git guard)', { timeout: 15000 }, async () => {
|
|
900
959
|
const repo = path.join(tmpDir, 'repo');
|
|
@@ -902,25 +961,28 @@ describe.skipIf(process.platform === 'win32')('post-merge refresh-gh behavior (P
|
|
|
902
961
|
// Linked-worktree shape: .git is a pointer FILE, not a directory.
|
|
903
962
|
fs.writeFileSync(path.join(repo, '.git'), 'gitdir: /elsewhere/.git/worktrees/x\n');
|
|
904
963
|
const hookPath = path.join(repo, 'post-merge');
|
|
905
|
-
fs.writeFileSync(hookPath, buildHookContent(
|
|
964
|
+
fs.writeFileSync(hookPath, buildHookContent(RENDER), { mode: 0o755 });
|
|
906
965
|
execSync('sh ./post-merge', {
|
|
907
966
|
cwd: repo,
|
|
908
967
|
env: { ...process.env, PATH: `${binDir}:${process.env.PATH ?? ''}` },
|
|
909
968
|
stdio: 'ignore',
|
|
910
969
|
});
|
|
911
970
|
expect(await markerAppears(500)).toBe(false);
|
|
971
|
+
// The gate covers BOTH verbs — a partial leak (either one firing) is a
|
|
972
|
+
// failure here, not a pass by way of "fewer than two".
|
|
973
|
+
expect(firedVerbs()).toEqual([]);
|
|
912
974
|
});
|
|
913
975
|
});
|
|
914
976
|
describe('sync-log redirect resolves the git dir (worktree-safe)', () => {
|
|
915
977
|
it('post-merge hook derives the log path from git rev-parse --git-dir', () => {
|
|
916
|
-
const hook = buildHookContent(
|
|
978
|
+
const hook = buildHookContent(RENDER);
|
|
917
979
|
expect(hook).toContain('GIT_DIR_RESOLVED=$(git rev-parse --git-dir 2>/dev/null || echo .git)');
|
|
918
980
|
expect(hook).toContain('> "$GIT_DIR_RESOLVED/totem-sync.log"');
|
|
919
981
|
// The hardcoded `.git/`-as-directory redirect must be gone (ENOTDIR in a worktree).
|
|
920
982
|
expect(hook).not.toContain('> .git/totem-sync.log');
|
|
921
983
|
});
|
|
922
984
|
it('post-checkout hook derives the log path from git rev-parse --git-dir on both branches', () => {
|
|
923
|
-
const hook = buildPostCheckoutHookContent(
|
|
985
|
+
const hook = buildPostCheckoutHookContent(RENDER);
|
|
924
986
|
expect(hook).toContain('GIT_DIR_RESOLVED=$(git rev-parse --git-dir 2>/dev/null || echo .git)');
|
|
925
987
|
// Both the null-SHA and the branch-diff redirect use the resolved dir.
|
|
926
988
|
expect(hook.match(/> "\$GIT_DIR_RESOLVED\/totem-sync\.log"/g)).toHaveLength(2);
|
|
@@ -934,13 +996,19 @@ describe('upgradePrePushHookIfNeeded', () => {
|
|
|
934
996
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-hooks-upgrade-'));
|
|
935
997
|
execSync('git init', { cwd: tmpDir, stdio: 'ignore' });
|
|
936
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');
|
|
937
1005
|
});
|
|
938
1006
|
afterEach(() => {
|
|
939
1007
|
cleanTmpDir(tmpDir);
|
|
940
1008
|
});
|
|
941
1009
|
/**
|
|
942
1010
|
* Helper: extract the totem block from a hook file and compare it against
|
|
943
|
-
* the canonical output of buildPrePushHook(
|
|
1011
|
+
* the canonical output of buildPrePushHook(RENDER) (shebang stripped, trimmed).
|
|
944
1012
|
* Catches stale shell fragments or splice boundary bugs that toContain would miss.
|
|
945
1013
|
* The stateless format is now bounded by the pre-push end marker, so the block is the
|
|
946
1014
|
* span from the start-marker comment through the end-marker comment line inclusive —
|
|
@@ -958,11 +1026,11 @@ describe('upgradePrePushHookIfNeeded', () => {
|
|
|
958
1026
|
}
|
|
959
1027
|
/** Canonical totem block: shebang stripped, trimmed — the expected upgrade output. */
|
|
960
1028
|
function expectedTotemBlock() {
|
|
961
|
-
return buildPrePushHook(getFallbackCommand(tmpDir))
|
|
1029
|
+
return buildPrePushHook({ ...RENDER, fallbackCmd: getFallbackCommand(tmpDir) })
|
|
962
1030
|
.replace(/^#!\/bin\/sh\n/, '')
|
|
963
1031
|
.trim();
|
|
964
1032
|
}
|
|
965
|
-
it('upgrades old command-executing hook to stateless format', () => {
|
|
1033
|
+
it('upgrades old command-executing hook to stateless format', async () => {
|
|
966
1034
|
// Install an old-style hook that executes $TOTEM_CMD lint
|
|
967
1035
|
const hooksDir = path.join(tmpDir, '.git', 'hooks');
|
|
968
1036
|
fs.mkdirSync(hooksDir, { recursive: true });
|
|
@@ -978,7 +1046,7 @@ if [ -f ".totem/compiled-rules.json" ]; then
|
|
|
978
1046
|
fi
|
|
979
1047
|
`;
|
|
980
1048
|
fs.writeFileSync(path.join(hooksDir, 'pre-push'), oldHook);
|
|
981
|
-
const upgraded = upgradePrePushHookIfNeeded(tmpDir);
|
|
1049
|
+
const upgraded = await upgradePrePushHookIfNeeded(tmpDir);
|
|
982
1050
|
expect(upgraded).toBe(true);
|
|
983
1051
|
const content = fs.readFileSync(path.join(hooksDir, 'pre-push'), 'utf-8');
|
|
984
1052
|
expect(content).toContain('verify-manifest');
|
|
@@ -990,41 +1058,41 @@ fi
|
|
|
990
1058
|
const actual = extractTotemBlock(content);
|
|
991
1059
|
expect(actual).toBe(expectedTotemBlock());
|
|
992
1060
|
});
|
|
993
|
-
it('skips hook without totem marker', () => {
|
|
1061
|
+
it('skips hook without totem marker', async () => {
|
|
994
1062
|
const hooksDir = path.join(tmpDir, '.git', 'hooks');
|
|
995
1063
|
fs.mkdirSync(hooksDir, { recursive: true });
|
|
996
1064
|
const userHook = '#!/bin/sh\necho "user hook"\n';
|
|
997
1065
|
fs.writeFileSync(path.join(hooksDir, 'pre-push'), userHook);
|
|
998
|
-
const upgraded = upgradePrePushHookIfNeeded(tmpDir);
|
|
1066
|
+
const upgraded = await upgradePrePushHookIfNeeded(tmpDir);
|
|
999
1067
|
expect(upgraded).toBe(false);
|
|
1000
1068
|
const content = fs.readFileSync(path.join(hooksDir, 'pre-push'), 'utf-8');
|
|
1001
1069
|
expect(content).toBe(userHook); // File untouched
|
|
1002
1070
|
});
|
|
1003
|
-
it('skips hook that already uses stateless format', () => {
|
|
1071
|
+
it('skips hook that already uses stateless format', async () => {
|
|
1004
1072
|
// Install the current-version hook via non-interactive installer
|
|
1005
|
-
installHooksNonInteractive(tmpDir);
|
|
1073
|
+
await installHooksNonInteractive(tmpDir);
|
|
1006
1074
|
const hooksDir = path.join(tmpDir, '.git', 'hooks');
|
|
1007
1075
|
const beforeContent = fs.readFileSync(path.join(hooksDir, 'pre-push'), 'utf-8');
|
|
1008
|
-
const upgraded = upgradePrePushHookIfNeeded(tmpDir);
|
|
1076
|
+
const upgraded = await upgradePrePushHookIfNeeded(tmpDir);
|
|
1009
1077
|
expect(upgraded).toBe(false);
|
|
1010
1078
|
const afterContent = fs.readFileSync(path.join(hooksDir, 'pre-push'), 'utf-8');
|
|
1011
1079
|
expect(afterContent).toBe(beforeContent); // File untouched
|
|
1012
1080
|
});
|
|
1013
|
-
it('returns false when no pre-push hook exists', () => {
|
|
1014
|
-
const upgraded = upgradePrePushHookIfNeeded(tmpDir);
|
|
1081
|
+
it('returns false when no pre-push hook exists', async () => {
|
|
1082
|
+
const upgraded = await upgradePrePushHookIfNeeded(tmpDir);
|
|
1015
1083
|
expect(upgraded).toBe(false);
|
|
1016
1084
|
});
|
|
1017
|
-
it('returns false when not a git repo', () => {
|
|
1085
|
+
it('returns false when not a git repo', async () => {
|
|
1018
1086
|
const nonGitDir = fs.mkdtempSync(path.join(os.tmpdir(), 'totem-no-git-'));
|
|
1019
1087
|
try {
|
|
1020
|
-
const upgraded = upgradePrePushHookIfNeeded(nonGitDir);
|
|
1088
|
+
const upgraded = await upgradePrePushHookIfNeeded(nonGitDir);
|
|
1021
1089
|
expect(upgraded).toBe(false);
|
|
1022
1090
|
}
|
|
1023
1091
|
finally {
|
|
1024
1092
|
cleanTmpDir(nonGitDir);
|
|
1025
1093
|
}
|
|
1026
1094
|
});
|
|
1027
|
-
it('preserves user-appended content when upgrading', () => {
|
|
1095
|
+
it('preserves user-appended content when upgrading', async () => {
|
|
1028
1096
|
const hooksDir = path.join(tmpDir, '.git', 'hooks');
|
|
1029
1097
|
fs.mkdirSync(hooksDir, { recursive: true });
|
|
1030
1098
|
// Simulate an old totem hook with user content appended after it
|
|
@@ -1039,7 +1107,7 @@ fi
|
|
|
1039
1107
|
`;
|
|
1040
1108
|
const userAppended = '\n# My custom deploy notification\ncurl -X POST https://hooks.example.com/deploy\n';
|
|
1041
1109
|
fs.writeFileSync(path.join(hooksDir, 'pre-push'), oldTotemBlock + userAppended);
|
|
1042
|
-
const upgraded = upgradePrePushHookIfNeeded(tmpDir);
|
|
1110
|
+
const upgraded = await upgradePrePushHookIfNeeded(tmpDir);
|
|
1043
1111
|
expect(upgraded).toBe(true);
|
|
1044
1112
|
const content = fs.readFileSync(path.join(hooksDir, 'pre-push'), 'utf-8');
|
|
1045
1113
|
// New totem block should use stateless format
|
|
@@ -1053,7 +1121,7 @@ fi
|
|
|
1053
1121
|
const actual = extractTotemBlock(content);
|
|
1054
1122
|
expect(actual).toBe(expectedTotemBlock());
|
|
1055
1123
|
});
|
|
1056
|
-
it('preserves user-appended if/fi blocks without corrupting them', () => {
|
|
1124
|
+
it('preserves user-appended if/fi blocks without corrupting them', async () => {
|
|
1057
1125
|
const hooksDir = path.join(tmpDir, '.git', 'hooks');
|
|
1058
1126
|
fs.mkdirSync(hooksDir, { recursive: true });
|
|
1059
1127
|
// Old totem block (needs upgrade) PLUS user content that contains its own if/fi structures
|
|
@@ -1084,7 +1152,7 @@ if [ -n "$SLACK_WEBHOOK" ]; then
|
|
|
1084
1152
|
fi
|
|
1085
1153
|
`;
|
|
1086
1154
|
fs.writeFileSync(path.join(hooksDir, 'pre-push'), oldTotemBlock + userIfFiBlock);
|
|
1087
|
-
const upgraded = upgradePrePushHookIfNeeded(tmpDir);
|
|
1155
|
+
const upgraded = await upgradePrePushHookIfNeeded(tmpDir);
|
|
1088
1156
|
expect(upgraded).toBe(true);
|
|
1089
1157
|
const content = fs.readFileSync(path.join(hooksDir, 'pre-push'), 'utf-8');
|
|
1090
1158
|
// Totem block must match canonical output exactly
|
|
@@ -1102,7 +1170,7 @@ fi
|
|
|
1102
1170
|
const userBlockStart = content.indexOf('# Custom deploy guard');
|
|
1103
1171
|
expect(userBlockStart).toBeGreaterThan(totemBlockEnd);
|
|
1104
1172
|
});
|
|
1105
|
-
it('leaves no stale fi or orphaned shell fragments after upgrade', () => {
|
|
1173
|
+
it('leaves no stale fi or orphaned shell fragments after upgrade', async () => {
|
|
1106
1174
|
const hooksDir = path.join(tmpDir, '.git', 'hooks');
|
|
1107
1175
|
fs.mkdirSync(hooksDir, { recursive: true });
|
|
1108
1176
|
const oldHook = `#!/bin/sh
|
|
@@ -1117,7 +1185,7 @@ if [ -f ".totem/compiled-rules.json" ]; then
|
|
|
1117
1185
|
fi
|
|
1118
1186
|
`;
|
|
1119
1187
|
fs.writeFileSync(path.join(hooksDir, 'pre-push'), oldHook);
|
|
1120
|
-
upgradePrePushHookIfNeeded(tmpDir);
|
|
1188
|
+
await upgradePrePushHookIfNeeded(tmpDir);
|
|
1121
1189
|
const content = fs.readFileSync(path.join(hooksDir, 'pre-push'), 'utf-8');
|
|
1122
1190
|
// Count if/fi balance: every `if` must have a matching `fi`.
|
|
1123
1191
|
// Inline `if ... fi` on a single line are self-balanced and excluded from both counts.
|
|
@@ -1133,7 +1201,7 @@ fi
|
|
|
1133
1201
|
const actual = extractTotemBlock(content);
|
|
1134
1202
|
expect(actual).toBe(expectedTotemBlock());
|
|
1135
1203
|
});
|
|
1136
|
-
it('upgrades old flag-checking hook to stateless format', () => {
|
|
1204
|
+
it('upgrades old flag-checking hook to stateless format', async () => {
|
|
1137
1205
|
const hooksDir = path.join(tmpDir, '.git', 'hooks');
|
|
1138
1206
|
fs.mkdirSync(hooksDir, { recursive: true });
|
|
1139
1207
|
// Simulate the previous flag-checking format that used .lint-passed
|
|
@@ -1154,7 +1222,7 @@ if [ -f ".totem/compiled-rules.json" ]; then
|
|
|
1154
1222
|
fi
|
|
1155
1223
|
`;
|
|
1156
1224
|
fs.writeFileSync(path.join(hooksDir, 'pre-push'), oldHook);
|
|
1157
|
-
const upgraded = upgradePrePushHookIfNeeded(tmpDir);
|
|
1225
|
+
const upgraded = await upgradePrePushHookIfNeeded(tmpDir);
|
|
1158
1226
|
expect(upgraded).toBe(true);
|
|
1159
1227
|
const content = fs.readFileSync(path.join(hooksDir, 'pre-push'), 'utf-8');
|
|
1160
1228
|
expect(content).toContain('verify-manifest');
|
|
@@ -1164,7 +1232,7 @@ fi
|
|
|
1164
1232
|
const actual = extractTotemBlock(content);
|
|
1165
1233
|
expect(actual).toBe(expectedTotemBlock());
|
|
1166
1234
|
});
|
|
1167
|
-
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 () => {
|
|
1168
1236
|
const hooksDir = path.join(tmpDir, '.git', 'hooks');
|
|
1169
1237
|
fs.mkdirSync(hooksDir, { recursive: true });
|
|
1170
1238
|
// Simulate a hook with the auto-refresh logic still using $TOTEM_CMD
|
|
@@ -1194,7 +1262,7 @@ if [ -f ".totem/compiled-rules.json" ]; then
|
|
|
1194
1262
|
fi
|
|
1195
1263
|
`;
|
|
1196
1264
|
fs.writeFileSync(path.join(hooksDir, 'pre-push'), oldHook);
|
|
1197
|
-
const upgraded = upgradePrePushHookIfNeeded(tmpDir);
|
|
1265
|
+
const upgraded = await upgradePrePushHookIfNeeded(tmpDir);
|
|
1198
1266
|
expect(upgraded).toBe(true);
|
|
1199
1267
|
const content = fs.readFileSync(path.join(hooksDir, 'pre-push'), 'utf-8');
|
|
1200
1268
|
expect(content).toContain('verify-manifest');
|
|
@@ -1209,7 +1277,7 @@ fi
|
|
|
1209
1277
|
// ─── Enforcement tier: agent detection & strict mode ──
|
|
1210
1278
|
describe('buildPreCommitHook agent detection', () => {
|
|
1211
1279
|
it('includes agent detection snippet', () => {
|
|
1212
|
-
const hook = buildPreCommitHook();
|
|
1280
|
+
const hook = buildPreCommitHook(RENDER);
|
|
1213
1281
|
expect(hook).toContain('is_agent=0');
|
|
1214
1282
|
expect(hook).toContain('is_agent=1');
|
|
1215
1283
|
expect(hook).toContain('CLAUDE_CODE_AGENT');
|
|
@@ -1218,52 +1286,270 @@ describe('buildPreCommitHook agent detection', () => {
|
|
|
1218
1286
|
// GEMINI_API_KEY intentionally excluded — human devs export it for Totem's embedding provider
|
|
1219
1287
|
});
|
|
1220
1288
|
it('includes TOTEM_HOOK_TIER variable', () => {
|
|
1221
|
-
const hook = buildPreCommitHook();
|
|
1289
|
+
const hook = buildPreCommitHook(RENDER);
|
|
1222
1290
|
expect(hook).toContain('TOTEM_HOOK_TIER="standard"');
|
|
1223
1291
|
});
|
|
1224
1292
|
it('sets TOTEM_HOOK_TIER to strict when tier is strict', () => {
|
|
1225
|
-
const hook = buildPreCommitHook('strict');
|
|
1293
|
+
const hook = buildPreCommitHook({ ...RENDER, tier: 'strict' });
|
|
1226
1294
|
expect(hook).toContain('TOTEM_HOOK_TIER="strict"');
|
|
1227
1295
|
});
|
|
1228
1296
|
});
|
|
1229
1297
|
describe('buildPreCommitHook with strict tier', () => {
|
|
1230
|
-
it('
|
|
1231
|
-
const hook = buildPreCommitHook('strict');
|
|
1232
|
-
|
|
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/);
|
|
1233
1304
|
expect(hook).toContain("Run 'totem spec <issue>' before committing (strict mode)");
|
|
1234
1305
|
});
|
|
1235
1306
|
it('gates spec check on agent detection or strict tier', () => {
|
|
1236
|
-
const hook = buildPreCommitHook('strict');
|
|
1307
|
+
const hook = buildPreCommitHook({ ...RENDER, tier: 'strict' });
|
|
1237
1308
|
expect(hook).toContain('$is_agent');
|
|
1238
1309
|
expect(hook).toContain('$TOTEM_HOOK_TIER');
|
|
1239
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
|
+
});
|
|
1240
1523
|
});
|
|
1241
1524
|
describe('buildPreCommitHook with standard tier', () => {
|
|
1242
|
-
it('includes spec-
|
|
1243
|
-
const hook = buildPreCommitHook(
|
|
1244
|
-
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');
|
|
1245
1528
|
expect(hook).toContain('is_agent');
|
|
1246
1529
|
expect(hook).toContain('TOTEM_HOOK_TIER="standard"');
|
|
1247
1530
|
});
|
|
1248
|
-
|
|
1249
|
-
|
|
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);
|
|
1250
1537
|
expect(hook).toContain('TOTEM_HOOK_TIER="standard"');
|
|
1251
|
-
expect(hook).toContain('.
|
|
1538
|
+
expect(hook).toContain('.totem/artifacts/runs');
|
|
1252
1539
|
});
|
|
1253
1540
|
});
|
|
1254
1541
|
describe('buildPrePushHook with strict tier', () => {
|
|
1255
|
-
const FALLBACK = 'pnpm dlx @mmnto/cli';
|
|
1256
1542
|
it('includes shield gate', () => {
|
|
1257
|
-
const hook = buildPrePushHook(
|
|
1543
|
+
const hook = buildPrePushHook({ ...RENDER, tier: 'strict' });
|
|
1258
1544
|
expect(hook).toContain('review');
|
|
1259
1545
|
expect(hook).toContain('shield gate (strict mode)');
|
|
1260
1546
|
});
|
|
1261
1547
|
it('includes TOTEM_HOOK_TIER set to strict', () => {
|
|
1262
|
-
const hook = buildPrePushHook(
|
|
1548
|
+
const hook = buildPrePushHook({ ...RENDER, tier: 'strict' });
|
|
1263
1549
|
expect(hook).toContain('TOTEM_HOOK_TIER="strict"');
|
|
1264
1550
|
});
|
|
1265
1551
|
it('includes agent detection snippet', () => {
|
|
1266
|
-
const hook = buildPrePushHook(
|
|
1552
|
+
const hook = buildPrePushHook({ ...RENDER, tier: 'strict' });
|
|
1267
1553
|
expect(hook).toContain('is_agent=0');
|
|
1268
1554
|
expect(hook).toContain('CLAUDE_CODE_AGENT');
|
|
1269
1555
|
});
|
|
@@ -1271,12 +1557,12 @@ describe('buildPrePushHook with strict tier', () => {
|
|
|
1271
1557
|
// shieldBlock alongside the existing `totem review` gate. Repo-state
|
|
1272
1558
|
// checks (like checkAgentsMdCanonical) now block push when they fail.
|
|
1273
1559
|
it('includes $TOTEM_CMD doctor --strict in the shield block', () => {
|
|
1274
|
-
const hook = buildPrePushHook(
|
|
1560
|
+
const hook = buildPrePushHook({ ...RENDER, tier: 'strict' });
|
|
1275
1561
|
expect(hook).toContain('$TOTEM_CMD doctor --strict');
|
|
1276
1562
|
expect(hook).toContain('doctor --strict (repo-state gate)');
|
|
1277
1563
|
});
|
|
1278
1564
|
it('gates doctor --strict on the agent/strict guard (does NOT fire unconditionally)', () => {
|
|
1279
|
-
const hook = buildPrePushHook(
|
|
1565
|
+
const hook = buildPrePushHook({ ...RENDER, tier: 'strict' });
|
|
1280
1566
|
// Find the position of the doctor --strict invocation and the surrounding
|
|
1281
1567
|
// guard; assert the invocation lives INSIDE the agent-or-tier block.
|
|
1282
1568
|
const guardIdx = hook.indexOf('if [ "$is_agent" = "1" ] || [ "$TOTEM_HOOK_TIER" = "strict" ]');
|
|
@@ -1288,20 +1574,22 @@ describe('buildPrePushHook with strict tier', () => {
|
|
|
1288
1574
|
});
|
|
1289
1575
|
});
|
|
1290
1576
|
describe('buildPrePushHook with standard tier', () => {
|
|
1291
|
-
const FALLBACK = 'pnpm dlx @mmnto/cli';
|
|
1292
1577
|
it('includes shield gate guarded by agent/tier condition', () => {
|
|
1293
|
-
const hook = buildPrePushHook(
|
|
1578
|
+
const hook = buildPrePushHook(RENDER);
|
|
1294
1579
|
expect(hook).toContain('shield gate');
|
|
1295
1580
|
expect(hook).toContain('is_agent');
|
|
1296
1581
|
expect(hook).toContain('TOTEM_HOOK_TIER="standard"');
|
|
1297
1582
|
});
|
|
1298
|
-
|
|
1299
|
-
|
|
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);
|
|
1300
1588
|
expect(hook).toContain('TOTEM_HOOK_TIER="standard"');
|
|
1301
1589
|
expect(hook).toContain('shield gate');
|
|
1302
1590
|
});
|
|
1303
1591
|
it('still includes agent detection', () => {
|
|
1304
|
-
const hook = buildPrePushHook(
|
|
1592
|
+
const hook = buildPrePushHook(RENDER);
|
|
1305
1593
|
expect(hook).toContain('is_agent=0');
|
|
1306
1594
|
});
|
|
1307
1595
|
// mmnto-ai/totem#1908 — even in standard tier, the doctor --strict line
|
|
@@ -1310,7 +1598,7 @@ describe('buildPrePushHook with standard tier', () => {
|
|
|
1310
1598
|
// operators bypass it because their `is_agent=0` and `TOTEM_HOOK_TIER` is
|
|
1311
1599
|
// standard, so the guard branch never enters.
|
|
1312
1600
|
it('emits doctor --strict gated by agent/strict guard (no unconditional fire in standard tier)', () => {
|
|
1313
|
-
const hook = buildPrePushHook(
|
|
1601
|
+
const hook = buildPrePushHook(RENDER);
|
|
1314
1602
|
const guardIdx = hook.indexOf('if [ "$is_agent" = "1" ] || [ "$TOTEM_HOOK_TIER" = "strict" ]');
|
|
1315
1603
|
const doctorIdx = hook.indexOf('$TOTEM_CMD doctor --strict');
|
|
1316
1604
|
const fiCloseIdx = hook.indexOf('fi', guardIdx);
|
|
@@ -1321,7 +1609,7 @@ describe('buildPrePushHook with standard tier', () => {
|
|
|
1321
1609
|
});
|
|
1322
1610
|
describe('agent detection uses POSIX syntax', () => {
|
|
1323
1611
|
it('pre-commit hook has no bashisms', () => {
|
|
1324
|
-
const hook = buildPreCommitHook('strict');
|
|
1612
|
+
const hook = buildPreCommitHook({ ...RENDER, tier: 'strict' });
|
|
1325
1613
|
// Must use [ ] not [[ ]]
|
|
1326
1614
|
expect(hook).not.toContain('[[');
|
|
1327
1615
|
expect(hook).not.toContain(']]');
|
|
@@ -1331,7 +1619,7 @@ describe('agent detection uses POSIX syntax', () => {
|
|
|
1331
1619
|
expect(hook).toMatch(/^#!\/bin\/sh\n/);
|
|
1332
1620
|
});
|
|
1333
1621
|
it('pre-push hook has no bashisms', () => {
|
|
1334
|
-
const hook = buildPrePushHook(
|
|
1622
|
+
const hook = buildPrePushHook({ ...RENDER, tier: 'strict' });
|
|
1335
1623
|
// Must use [ ] not [[ ]]
|
|
1336
1624
|
expect(hook).not.toContain('[[');
|
|
1337
1625
|
expect(hook).not.toContain(']]');
|