@rmyndharis/aimhooman 0.1.7 → 0.2.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/CHANGELOG.md +156 -0
- package/README.md +2 -1
- package/bin/aimhooman.mjs +473 -241
- package/package.json +1 -1
- package/rules/paths.json +65 -16
- package/rules/secrets.json +11 -7
- package/src/exclude.mjs +14 -0
- package/src/githooks.mjs +89 -9
- package/src/gitx.mjs +78 -6
- package/src/hook.mjs +79 -4
- package/src/report.mjs +71 -3
- package/src/scan-session.mjs +77 -1
- package/src/scan.mjs +40 -7
package/bin/aimhooman.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { execFileSync } from 'node:child_process';
|
|
3
|
-
import {
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { chmodSync, lstatSync, readdirSync, readFileSync, rmSync, rmdirSync, writeFileSync } from 'node:fs';
|
|
4
5
|
import { join } from 'node:path';
|
|
5
6
|
import { fileURLToPath } from 'node:url';
|
|
6
7
|
import { GIT_TIMEOUT_MS } from '../src/git-environment.mjs';
|
|
@@ -9,20 +10,23 @@ import { exitCode, human, jsonReport, visible } from '../src/report.mjs';
|
|
|
9
10
|
import {
|
|
10
11
|
GitRevisionError,
|
|
11
12
|
gitConfig,
|
|
13
|
+
ignoredByPatterns,
|
|
12
14
|
introducedCommits,
|
|
13
15
|
openRepo,
|
|
14
16
|
readCommitPath,
|
|
15
17
|
readStagedPath,
|
|
16
18
|
stagedPaths,
|
|
17
19
|
stagedRenameSources,
|
|
20
|
+
stagedTreeSha,
|
|
18
21
|
unstagePaths,
|
|
19
22
|
withIndexFromTree,
|
|
20
23
|
} from '../src/gitx.mjs';
|
|
21
24
|
import { loadConfig, loadOverrides, loadProjectPolicy, normalizeOverrideTarget, saveConfig, saveOverrides } from '../src/state.mjs';
|
|
22
|
-
import { applyExclude, inspectExclude, patternsForRules, removeExclude } from '../src/exclude.mjs';
|
|
25
|
+
import { applyExclude, inspectExclude, managedPatterns, patternsForRules, removeExclude } from '../src/exclude.mjs';
|
|
23
26
|
import { effectiveHooksDir, hookDiagnostics, installHooks, installGlobalHooks, uninstallGlobalHooks, globalHooksDir, installedHooks, remainingDispatchers, uninstallHooks, unrestoredChainedBackups } from '../src/githooks.mjs';
|
|
24
27
|
import { ArgumentError, parseArguments } from '../src/args.mjs';
|
|
25
28
|
import { engineForPolicy, scanGitTarget, scanMessage } from '../src/scan-target.mjs';
|
|
29
|
+
import { DEFAULT_SCAN_LIMITS } from '../src/scan-session.mjs';
|
|
26
30
|
import { resolvePolicy } from '../src/policy-resolver.mjs';
|
|
27
31
|
import { atomicWrite, withLock } from '../src/atomic-write.mjs';
|
|
28
32
|
import { commitParents, resolveCommit } from '../src/history-scan.mjs';
|
|
@@ -118,6 +122,22 @@ function configuredEngine(profile, repo) {
|
|
|
118
122
|
|
|
119
123
|
function main(argv) {
|
|
120
124
|
const [cmd, ...rest] = argv;
|
|
125
|
+
// Subcommand-level --help: `aimhooman override --help` (or `init -h`, etc.)
|
|
126
|
+
// previously fell into the subcommand's strict argument parser, which rejects
|
|
127
|
+
// --help as an unknown option and exits 20. Recognise a leading help flag on
|
|
128
|
+
// any real subcommand and route to usage() instead, so help works everywhere.
|
|
129
|
+
// The top-level help forms (cmd itself is help/--help/-h) are handled in the
|
|
130
|
+
// switch below.
|
|
131
|
+
const SUBCOMMAND_HELP_FLAGS = new Set(['--help', '-h', 'help']);
|
|
132
|
+
const knownSubcommands = new Set([
|
|
133
|
+
'check', 'audit', 'scan', 'precommit', 'commitmsg', 'refcheck', 'init',
|
|
134
|
+
'status', 'explain', 'allow', 'deny', 'override', 'review',
|
|
135
|
+
'policy-review', 'fix', 'doctor', 'uninstall',
|
|
136
|
+
]);
|
|
137
|
+
if (knownSubcommands.has(cmd) && rest.length && SUBCOMMAND_HELP_FLAGS.has(rest[0])) {
|
|
138
|
+
usage();
|
|
139
|
+
return 0;
|
|
140
|
+
}
|
|
121
141
|
switch (cmd) {
|
|
122
142
|
case 'check':
|
|
123
143
|
return cmdCheck(rest);
|
|
@@ -197,8 +217,11 @@ function emitDiagnostics(diagnostics = []) {
|
|
|
197
217
|
|
|
198
218
|
function incompleteMessage(scan) {
|
|
199
219
|
const reasons = scan.stats?.skipped || {};
|
|
220
|
+
// Each count is how many items were skipped, not the limit that fired:
|
|
221
|
+
// "(size-limit=1)" read as a one-byte budget. Name the noun so the number
|
|
222
|
+
// cannot be mistaken for the budget it tripped.
|
|
200
223
|
const skipped = Object.entries(reasons)
|
|
201
|
-
.map(([reason, count]) => `${reason}=${count}`)
|
|
224
|
+
.map(([reason, count]) => `${reason}=${count} ${skipCountNoun(reason, count)}`)
|
|
202
225
|
.join(', ');
|
|
203
226
|
// Every other reason is a size or budget the caller can shrink. A pack that
|
|
204
227
|
// will not compile is not, and the warning above already names the file and
|
|
@@ -212,7 +235,7 @@ function incompleteMessage(scan) {
|
|
|
212
235
|
: budgeted
|
|
213
236
|
? 'reduce the target, or raise AIMHOOMAN_MAX_FILE_BYTES / AIMHOOMAN_MAX_TOTAL_BYTES, and retry'
|
|
214
237
|
: 'reduce the target or limits and retry';
|
|
215
|
-
let message = `aimhooman: scan incomplete${skipped ? ` (${skipped})` : ''}; ${hint}\n`;
|
|
238
|
+
let message = `aimhooman: scan incomplete${skipped ? ` (skipped: ${skipped})` : ''}; ${hint}\n`;
|
|
216
239
|
const skippedPaths = scan.stats?.skippedPaths || {};
|
|
217
240
|
const pathLines = [];
|
|
218
241
|
for (const [reason, entries] of Object.entries(skippedPaths)) {
|
|
@@ -225,6 +248,17 @@ function incompleteMessage(scan) {
|
|
|
225
248
|
return message + pathLines.join('');
|
|
226
249
|
}
|
|
227
250
|
|
|
251
|
+
// Most skip reasons tally files; the three below tally something else, so the
|
|
252
|
+
// noun travels with the reason instead of reading "local-pack-error=1 file".
|
|
253
|
+
function skipCountNoun(reason, count) {
|
|
254
|
+
const noun = {
|
|
255
|
+
'finding-limit': 'finding',
|
|
256
|
+
'local-input-limit': 'input',
|
|
257
|
+
'local-pack-error': 'pack',
|
|
258
|
+
}[reason] || 'file';
|
|
259
|
+
return `${noun}${count === 1 ? '' : 's'}`;
|
|
260
|
+
}
|
|
261
|
+
|
|
228
262
|
function formatBytes(bytes) {
|
|
229
263
|
if (bytes == null || bytes < 0) return '?';
|
|
230
264
|
if (bytes < 1024) return `${bytes} B`;
|
|
@@ -311,6 +345,81 @@ function dispatchHooksChanged(repo, profile) {
|
|
|
311
345
|
return true;
|
|
312
346
|
}
|
|
313
347
|
|
|
348
|
+
// W5 pre-commit/commit-msg marker dedup. pre-commit writes a marker after a
|
|
349
|
+
// clean, complete scan of the staged tree; commit-msg reads it and skips its
|
|
350
|
+
// duplicate ~170ms tree scan when the staged tree sha, profile, and
|
|
351
|
+
// completeness all match. The marker lives in stateDir (gitignored plumbing).
|
|
352
|
+
// It is self-invalidating: any index mutation between the two hooks changes the
|
|
353
|
+
// tree sha, so a stale marker never matches. A missing/corrupt/mismatched
|
|
354
|
+
// marker makes commit-msg fall back to the full scan, so this is purely an
|
|
355
|
+
// optimization and never weakens the guard.
|
|
356
|
+
const PRECOMMIT_CLEAN_VERSION = 1;
|
|
357
|
+
function precommitCleanPath(repo) {
|
|
358
|
+
return join(repo.stateDir, 'precommit-clean.json');
|
|
359
|
+
}
|
|
360
|
+
function recordPrecommitClean(repo, profile) {
|
|
361
|
+
let treeSha;
|
|
362
|
+
try {
|
|
363
|
+
treeSha = stagedTreeSha(repo);
|
|
364
|
+
} catch {
|
|
365
|
+
return; // cannot compute the sha → do not record; commit-msg will scan
|
|
366
|
+
}
|
|
367
|
+
try {
|
|
368
|
+
writeFileSync(precommitCleanPath(repo), JSON.stringify({
|
|
369
|
+
version: PRECOMMIT_CLEAN_VERSION,
|
|
370
|
+
tree: treeSha,
|
|
371
|
+
profile,
|
|
372
|
+
complete: true,
|
|
373
|
+
}));
|
|
374
|
+
} catch {
|
|
375
|
+
// best effort; a missing marker just means commit-msg scans normally
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
function precommitCleanMatches(repo, treeSha, profile) {
|
|
379
|
+
let marker;
|
|
380
|
+
try {
|
|
381
|
+
marker = JSON.parse(readFileSync(precommitCleanPath(repo), 'utf8'));
|
|
382
|
+
} catch {
|
|
383
|
+
return false; // missing/corrupt → fall back to full scan
|
|
384
|
+
}
|
|
385
|
+
return marker?.version === PRECOMMIT_CLEAN_VERSION
|
|
386
|
+
&& marker.complete === true
|
|
387
|
+
&& marker.tree === treeSha
|
|
388
|
+
&& marker.profile === profile;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// F-E1: the prevention layer keeps AI artifacts out of `git status`, which
|
|
392
|
+
// also keeps their exclusion silent — a `git add .` never tells the developer
|
|
393
|
+
// the chat log did not make the commit. Name the artifacts once per set
|
|
394
|
+
// change. The worktree walk is pathspec-pruned to the anchored managed
|
|
395
|
+
// patterns (~15ms; a bare ignored-listing costs ~150ms on a large tree), so
|
|
396
|
+
// `**/`-prefixed duplicates are skipped: nested artifacts still get excluded,
|
|
397
|
+
// they just do not get the notice. Informational only — it must never change
|
|
398
|
+
// an exit code, so every failure inside degrades to silence.
|
|
399
|
+
const IGNORED_NOTICE_VERSION = 1;
|
|
400
|
+
function noticeIgnoredArtifacts(repo) {
|
|
401
|
+
try {
|
|
402
|
+
const patterns = managedPatterns(repo.excludeFile)
|
|
403
|
+
.filter((pattern) => !pattern.startsWith('**/'));
|
|
404
|
+
if (!patterns.length) return;
|
|
405
|
+
const paths = ignoredByPatterns(repo, patterns).sort();
|
|
406
|
+
if (!paths.length) return;
|
|
407
|
+
const hash = createHash('sha256').update(paths.join('\0')).digest('hex');
|
|
408
|
+
const statePath = join(repo.stateDir, 'ignored-notice.json');
|
|
409
|
+
let previous = null;
|
|
410
|
+
try { previous = JSON.parse(readFileSync(statePath, 'utf8')); } catch { /* first run or corrupt state */ }
|
|
411
|
+
if (previous?.version === IGNORED_NOTICE_VERSION && previous.hash === hash) return;
|
|
412
|
+
writeFileSync(statePath, JSON.stringify({ version: IGNORED_NOTICE_VERSION, hash }));
|
|
413
|
+
const shown = paths.slice(0, 5);
|
|
414
|
+
const more = paths.length - shown.length;
|
|
415
|
+
process.stderr.write(
|
|
416
|
+
`aimhooman: ${paths.length} AI artifact(s) present locally are kept out of commits: `
|
|
417
|
+
+ `${shown.map(visible).join(', ')}${more ? `, and ${more} more` : ''} `
|
|
418
|
+
+ "(they stay on disk; 'git status --ignored' lists them; shown once per set change)\n"
|
|
419
|
+
);
|
|
420
|
+
} catch { /* informational only */ }
|
|
421
|
+
}
|
|
422
|
+
|
|
314
423
|
function cmdPrecommit(args) {
|
|
315
424
|
parseNoArguments(args);
|
|
316
425
|
const repo = tryRepo();
|
|
@@ -336,6 +445,14 @@ function cmdPrecommit(args) {
|
|
|
336
445
|
process.stderr.write(incompleteMessage(scan));
|
|
337
446
|
return 31;
|
|
338
447
|
}
|
|
448
|
+
// W5 marker dedup: record that this staged tree scanned clean so the
|
|
449
|
+
// upcoming commit-msg hook can skip its duplicate tree scan. The tree
|
|
450
|
+
// sha is the same value the commit-msg dispatcher computes via
|
|
451
|
+
// `git write-tree`; the index is unchanged on this no-block path, so the
|
|
452
|
+
// sha is stable. A missing/stale/mismatched marker makes commit-msg
|
|
453
|
+
// fall back to the full scan, so this is purely an optimization.
|
|
454
|
+
recordPrecommitClean(repo, profile);
|
|
455
|
+
noticeIgnoredArtifacts(repo);
|
|
339
456
|
return profile === 'strict' && reviews.length ? 11 : 0;
|
|
340
457
|
}
|
|
341
458
|
if (profile === 'strict') {
|
|
@@ -412,6 +529,7 @@ function cmdPrecommit(args) {
|
|
|
412
529
|
// not. The request was to commit the artifact, not to stamp the history with
|
|
413
530
|
// its message and no content.
|
|
414
531
|
if (emptied) return 10;
|
|
532
|
+
if (scan.complete) noticeIgnoredArtifacts(repo);
|
|
415
533
|
return scan.complete ? 0 : 31;
|
|
416
534
|
}
|
|
417
535
|
|
|
@@ -463,7 +581,14 @@ function cmdCommitmsg(args) {
|
|
|
463
581
|
try {
|
|
464
582
|
const checked = againstWouldBeTree(() => ({
|
|
465
583
|
message: scanMessage(repo, text, { target: 'staged' }),
|
|
466
|
-
|
|
584
|
+
// W5 marker dedup: if pre-commit already scanned this exact tree
|
|
585
|
+
// clean (matching sha + profile + complete), skip the duplicate
|
|
586
|
+
// ~170ms tree scan. The marker is self-invalidating (any index
|
|
587
|
+
// mutation changes the tree sha), and a missing/stale/mismatched
|
|
588
|
+
// marker falls back to the full scan, so this is safe.
|
|
589
|
+
tree: options.tree && precommitCleanMatches(repo, options.tree, hookProfile)
|
|
590
|
+
? null
|
|
591
|
+
: (options.tree ? scanGitTarget(repo, { kind: 'staged', limits }) : null),
|
|
467
592
|
}));
|
|
468
593
|
scan = checked.message;
|
|
469
594
|
treeScan = checked.tree;
|
|
@@ -672,6 +797,15 @@ function cmdRefcheck(args) {
|
|
|
672
797
|
process.stderr.write(human(scan.findings, tone()));
|
|
673
798
|
if (!scan.complete) process.stderr.write(incompleteMessage(scan));
|
|
674
799
|
process.stderr.write(`aimhooman: proposed commit ${revision} was rejected before refs changed\n`);
|
|
800
|
+
// The vetoed commit stays in the object store: the ref never
|
|
801
|
+
// moved, but the bytes — including whatever triggered the block —
|
|
802
|
+
// are still on disk. Whether gc collects it depends on other refs,
|
|
803
|
+
// so phrase the cleanup as conditional; rotating an exposed secret
|
|
804
|
+
// stays the operator's call either way.
|
|
805
|
+
process.stderr.write(
|
|
806
|
+
`aimhooman: note: the rejected commit object remains in the local object store; `
|
|
807
|
+
+ "if nothing else references it, 'git gc --prune=now' removes it when you are done inspecting\n"
|
|
808
|
+
);
|
|
675
809
|
return code;
|
|
676
810
|
}
|
|
677
811
|
}
|
|
@@ -766,8 +900,9 @@ function cmdInit(args) {
|
|
|
766
900
|
profile: { names: ['--profile'], type: 'string', choices: [...PROFILES] },
|
|
767
901
|
global: { names: ['--global'], type: 'boolean' },
|
|
768
902
|
yes: { names: ['--yes'], type: 'boolean' },
|
|
903
|
+
grandfatherSecrets: { names: ['--grandfather-secrets'], type: 'boolean' },
|
|
769
904
|
},
|
|
770
|
-
conflicts: [['profile', 'global']],
|
|
905
|
+
conflicts: [['profile', 'global'], ['grandfatherSecrets', 'global']],
|
|
771
906
|
maxPositionals: 0,
|
|
772
907
|
});
|
|
773
908
|
if (rejectUnsupportedGit()) return 20;
|
|
@@ -777,166 +912,240 @@ function cmdInit(args) {
|
|
|
777
912
|
return 20;
|
|
778
913
|
}
|
|
779
914
|
return withLock(join(globalHooksDir(), 'lifecycle.lock'), () => {
|
|
780
|
-
|
|
781
|
-
|
|
915
|
+
const aimDir = globalHooksDir();
|
|
916
|
+
let existing = '';
|
|
917
|
+
try {
|
|
918
|
+
existing = execFileSync('git', ['config', '--global', '--get', 'core.hooksPath'], { encoding: 'utf8', timeout: GIT_TIMEOUT_MS }).trim();
|
|
919
|
+
} catch { /* unset */ }
|
|
920
|
+
if (existing && existing !== aimDir) {
|
|
921
|
+
console.error(
|
|
922
|
+
`aimhooman: core.hooksPath is already set to '${existing}'; refusing to overwrite. ` +
|
|
923
|
+
'Unset it (git config --global --unset core.hooksPath) or choose a different setup.'
|
|
924
|
+
);
|
|
925
|
+
return 20;
|
|
926
|
+
}
|
|
927
|
+
// A --system hooksPath would be shadowed by the --global write below and
|
|
928
|
+
// could silently disable a system-wide hook manager, so refuse here too.
|
|
929
|
+
let systemHooksPath = '';
|
|
930
|
+
try {
|
|
931
|
+
systemHooksPath = execFileSync('git', ['config', '--system', '--get', 'core.hooksPath'], { encoding: 'utf8', timeout: GIT_TIMEOUT_MS }).trim();
|
|
932
|
+
} catch { /* unset or no system config */ }
|
|
933
|
+
if (systemHooksPath) {
|
|
934
|
+
console.error(
|
|
935
|
+
`aimhooman: core.hooksPath is set at --system scope to '${systemHooksPath}'; ` +
|
|
936
|
+
'a --global install would shadow it. Unset it or choose a different setup.'
|
|
937
|
+
);
|
|
938
|
+
return 20;
|
|
939
|
+
}
|
|
940
|
+
const localRepo = tryRepo();
|
|
941
|
+
const localHooksPath = localRepo ? gitConfigAtScope(localRepo.root, '--local', 'core.hooksPath') : '';
|
|
942
|
+
if (localHooksPath) {
|
|
943
|
+
console.error(`aimhooman: warning: this repository has local core.hooksPath="${localHooksPath}", which overrides the global guard here`);
|
|
944
|
+
}
|
|
945
|
+
const hookSnapshots = REQUIRED_GIT_HOOKS
|
|
946
|
+
.map((name) => snapshotFile(join(aimDir, name)));
|
|
947
|
+
let rep;
|
|
948
|
+
try {
|
|
949
|
+
rep = installGlobalHooks(CLI_PATH);
|
|
950
|
+
if (rep.skipped?.length) {
|
|
951
|
+
for (const warning of rep.warnings || []) console.error(`aimhooman: ${warning}`);
|
|
952
|
+
console.error('aimhooman: global hook installation aborted; core.hooksPath was not changed');
|
|
953
|
+
return 20;
|
|
954
|
+
}
|
|
955
|
+
execFileSync('git', ['config', '--global', 'core.hooksPath', rep.dir], { timeout: GIT_TIMEOUT_MS });
|
|
956
|
+
} catch (error) {
|
|
957
|
+
const rollbackFailures = [];
|
|
958
|
+
for (const snapshot of hookSnapshots.reverse()) {
|
|
959
|
+
try { restoreSnapshot(snapshot); }
|
|
960
|
+
catch (rollbackError) {
|
|
961
|
+
rollbackFailures.push(`${snapshot.path}: ${rollbackError.message}`);
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
try {
|
|
965
|
+
if (existing) execFileSync('git', ['config', '--global', 'core.hooksPath', existing], { timeout: GIT_TIMEOUT_MS });
|
|
966
|
+
else execFileSync('git', ['config', '--global', '--unset', 'core.hooksPath'], { stdio: 'ignore', timeout: GIT_TIMEOUT_MS });
|
|
967
|
+
} catch (rollbackError) {
|
|
968
|
+
rollbackFailures.push(`global core.hooksPath: ${rollbackError.message}`);
|
|
969
|
+
}
|
|
970
|
+
console.error(`aimhooman: global hook installation failed: ${error.message}`);
|
|
971
|
+
if (rollbackFailures.length) {
|
|
972
|
+
console.error(`aimhooman: rollback incomplete: ${rollbackFailures.join('; ')}`);
|
|
973
|
+
}
|
|
974
|
+
return 30;
|
|
975
|
+
}
|
|
976
|
+
console.log(`aimhooman: global hooks at ${rep.dir} (core.hooksPath set)`);
|
|
977
|
+
for (const w of rep.warnings || []) console.log(` warning: ${w}`);
|
|
978
|
+
console.log(' note: this replaces the default .git/hooks directory in non-bare repositories that inherit the global setting; local/worktree core.hooksPath overrides it.');
|
|
979
|
+
return 0;
|
|
980
|
+
}, LIFECYCLE_LOCK_OPTIONS);
|
|
981
|
+
}
|
|
982
|
+
if (options.yes) throw new ArgumentError('--yes is only valid with --global');
|
|
983
|
+
let profile = options.profile || 'clean';
|
|
984
|
+
const profileExplicit = Boolean(options.profile);
|
|
985
|
+
const repo = tryRepo();
|
|
986
|
+
if (!repo) {
|
|
987
|
+
console.error('aimhooman: not a git repository');
|
|
988
|
+
return 30;
|
|
989
|
+
}
|
|
990
|
+
const code = withLock(join(repo.commonDir, 'aimhooman-lifecycle.lock'), () => {
|
|
991
|
+
let projectPolicy;
|
|
782
992
|
try {
|
|
783
|
-
|
|
784
|
-
} catch {
|
|
785
|
-
|
|
786
|
-
console.error(
|
|
787
|
-
`aimhooman: core.hooksPath is already set to '${existing}'; refusing to overwrite. ` +
|
|
788
|
-
'Unset it (git config --global --unset core.hooksPath) or choose a different setup.'
|
|
789
|
-
);
|
|
993
|
+
projectPolicy = loadProjectPolicy(repo.root);
|
|
994
|
+
} catch (e) {
|
|
995
|
+
console.error(`aimhooman: cannot load project policy: ${e.message}`);
|
|
790
996
|
return 20;
|
|
791
997
|
}
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
998
|
+
if (projectPolicy) {
|
|
999
|
+
if (profileExplicit && profile !== projectPolicy.profile) {
|
|
1000
|
+
console.error(
|
|
1001
|
+
`aimhooman: project policy requires profile "${projectPolicy.profile}"; ` +
|
|
1002
|
+
'edit .aimhooman.json to change the team baseline'
|
|
1003
|
+
);
|
|
1004
|
+
return 20;
|
|
1005
|
+
}
|
|
1006
|
+
profile = projectPolicy.profile;
|
|
1007
|
+
}
|
|
1008
|
+
let eng;
|
|
795
1009
|
try {
|
|
796
|
-
|
|
797
|
-
} catch {
|
|
798
|
-
|
|
799
|
-
console.error(
|
|
800
|
-
`aimhooman: core.hooksPath is set at --system scope to '${systemHooksPath}'; ` +
|
|
801
|
-
'a --global install would shadow it. Unset it or choose a different setup.'
|
|
802
|
-
);
|
|
1010
|
+
eng = configuredEngine(profile, repo);
|
|
1011
|
+
} catch (e) {
|
|
1012
|
+
console.error(`aimhooman: cannot initialise policy: ${e.message}`);
|
|
803
1013
|
return 20;
|
|
804
1014
|
}
|
|
805
|
-
const
|
|
806
|
-
const
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
const hookSnapshots = REQUIRED_GIT_HOOKS
|
|
811
|
-
.map((name) => snapshotFile(join(aimDir, name)));
|
|
1015
|
+
const hookState = hookDiagnostics(repo);
|
|
1016
|
+
const hookFiles = hookState.some((hook) => hook.shared)
|
|
1017
|
+
? []
|
|
1018
|
+
: [...new Set(hookState.flatMap((hook) => [hook.path, hook.chainedPath]))];
|
|
1019
|
+
let snapshots = [];
|
|
812
1020
|
let rep;
|
|
813
1021
|
try {
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
1022
|
+
snapshots = [join(repo.stateDir, 'config.json'), repo.excludeFile, ...hookFiles]
|
|
1023
|
+
.map(snapshotFile);
|
|
1024
|
+
rep = installHooks(repo, CLI_PATH);
|
|
1025
|
+
const activeHooks = installedHooks(repo);
|
|
1026
|
+
if (!REQUIRED_GIT_HOOKS.every((name) => activeHooks.includes(name))) {
|
|
1027
|
+
// installHooks declines rather than throws when the hooks directory is
|
|
1028
|
+
// not ours, and its warnings are the only record of why. They are
|
|
1029
|
+
// printed on the success path only, so carry them into the failure or
|
|
1030
|
+
// the user is told nothing but "incomplete". The prefix is load-bearing:
|
|
1031
|
+
// the exit-code branch below matches on it.
|
|
1032
|
+
const cause = rep.shared && rep.warnings.length ? `${rep.warnings.join('; ')}; ` : '';
|
|
1033
|
+
// When the decline is because the hooks path is shared/tracked (or,
|
|
1034
|
+
// after B2, worktree content the next add would commit), name the two
|
|
1035
|
+
// ways out so the user is not stuck: let aimhooman use the default
|
|
1036
|
+
// .git/hooks, or — for a worktree hooks path kept local — exclude it
|
|
1037
|
+
// first. The uninstall hint lets them undo this init attempt.
|
|
1038
|
+
const remedy = rep.shared
|
|
1039
|
+
? ' To proceed, either unset core.hooksPath so aimhooman uses the default .git/hooks, or (for a worktree hooks path you keep local) add it to .gitignore or .git/info/exclude and retry. Run "aimhooman uninstall" to undo this init attempt.'
|
|
1040
|
+
: '';
|
|
1041
|
+
throw new Error(`hook installation incomplete; ${cause}repository guard is not active.${remedy}`);
|
|
1042
|
+
}
|
|
1043
|
+
saveConfig(repo.stateDir, { profile });
|
|
1044
|
+
applyExclude(repo.excludeFile, patternsForRules(eng.rules));
|
|
1045
|
+
const saved = loadConfig(repo.stateDir);
|
|
1046
|
+
const excludes = inspectExclude(repo.excludeFile, patternsForRules(eng.rules));
|
|
1047
|
+
if (saved.profile !== profile || !excludes.current) {
|
|
1048
|
+
throw new Error('post-install state verification failed');
|
|
819
1049
|
}
|
|
820
|
-
execFileSync('git', ['config', '--global', 'core.hooksPath', rep.dir], { timeout: GIT_TIMEOUT_MS });
|
|
821
1050
|
} catch (error) {
|
|
822
1051
|
const rollbackFailures = [];
|
|
823
|
-
|
|
1052
|
+
try {
|
|
1053
|
+
const uninstalled = uninstallHooks(repo);
|
|
1054
|
+
rollbackFailures.push(...(uninstalled.failures || []));
|
|
1055
|
+
} catch (rollbackError) {
|
|
1056
|
+
rollbackFailures.push(`hook uninstall: ${rollbackError.message}`);
|
|
1057
|
+
}
|
|
1058
|
+
const hookSet = new Set(hookFiles);
|
|
1059
|
+
const restoreHooks = Boolean(rep?.installed?.length || rep?.chained?.length);
|
|
1060
|
+
for (const snapshot of snapshots.reverse()) {
|
|
1061
|
+
if (!restoreHooks && hookSet.has(snapshot.path)) continue;
|
|
824
1062
|
try { restoreSnapshot(snapshot); }
|
|
825
1063
|
catch (rollbackError) {
|
|
826
1064
|
rollbackFailures.push(`${snapshot.path}: ${rollbackError.message}`);
|
|
827
1065
|
}
|
|
828
1066
|
}
|
|
829
|
-
try {
|
|
830
|
-
if (existing) execFileSync('git', ['config', '--global', 'core.hooksPath', existing], { timeout: GIT_TIMEOUT_MS });
|
|
831
|
-
else execFileSync('git', ['config', '--global', '--unset', 'core.hooksPath'], { stdio: 'ignore', timeout: GIT_TIMEOUT_MS });
|
|
832
|
-
} catch (rollbackError) {
|
|
833
|
-
rollbackFailures.push(`global core.hooksPath: ${rollbackError.message}`);
|
|
834
|
-
}
|
|
835
|
-
console.error(`aimhooman: global hook installation failed: ${error.message}`);
|
|
836
1067
|
if (rollbackFailures.length) {
|
|
1068
|
+
console.error(`aimhooman: initialisation failed: ${error.message}`);
|
|
837
1069
|
console.error(`aimhooman: rollback incomplete: ${rollbackFailures.join('; ')}`);
|
|
1070
|
+
return 30;
|
|
838
1071
|
}
|
|
839
|
-
|
|
1072
|
+
console.error(`aimhooman: initialisation failed and prior files were restored: ${error.message}`);
|
|
1073
|
+
return /hook installation incomplete/.test(error.message) ? 20 : expectedErrorCode(error);
|
|
840
1074
|
}
|
|
841
|
-
console.log(`aimhooman:
|
|
842
|
-
|
|
843
|
-
console.log(
|
|
1075
|
+
console.log(`aimhooman: initialised (profile: ${profile})`);
|
|
1076
|
+
console.log(` state: ${repo.stateDir}`);
|
|
1077
|
+
console.log(` excludes: ${repo.excludeFile}`);
|
|
1078
|
+
console.log(` note: known AI artifacts are now ignored locally; see them with 'git status --ignored'`);
|
|
1079
|
+
if (rep.installed.length) console.log(` hooks: ${rep.installed.join(', ')}`);
|
|
1080
|
+
if (rep.chained.length) console.log(` chained: ${rep.chained.join(', ')} (existing hooks preserved)`);
|
|
1081
|
+
for (const warning of rep.warnings) console.log(` warning: ${warning}`);
|
|
1082
|
+
console.log(' undo: aimhooman uninstall');
|
|
844
1083
|
return 0;
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
1084
|
+
}, LIFECYCLE_LOCK_OPTIONS);
|
|
1085
|
+
if (code === 0 && options.grandfatherSecrets) grandfatherTrackedSecrets(repo);
|
|
1086
|
+
return code;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
// --grandfather-secrets: a repository that already tracks secret-looking
|
|
1090
|
+
// fixtures (test certs, sample keys) would see every commit touching one
|
|
1091
|
+
// blocked, and allowing each path by hand does not scale. After a successful
|
|
1092
|
+
// init, scan the tracked tree once and write a --scope secret-path allow for
|
|
1093
|
+
// every path with a secret finding. The posture for NEW files is unchanged —
|
|
1094
|
+
// only paths found in this scan get an allow, so a secret added after init is
|
|
1095
|
+
// still blocked. A failed or incomplete scan never fails the init itself: the
|
|
1096
|
+
// guard is already active by then, so the gap is reported as a warning.
|
|
1097
|
+
function grandfatherTrackedSecrets(repo) {
|
|
1098
|
+
let scan;
|
|
857
1099
|
try {
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
1100
|
+
// Commit-time budgets exist for latency, but a grandfather scan that
|
|
1101
|
+
// stops at the default 64 MiB total or 1,000 findings silently misses
|
|
1102
|
+
// fixtures in exactly the large, fixture-heavy repositories it exists
|
|
1103
|
+
// for (OpenSSL tracks ~200 MiB and its 279 key files produce more
|
|
1104
|
+
// than a thousand PEM findings). This is a one-shot, operator-invoked
|
|
1105
|
+
// scan of the operator's own repository, so the defaults here are the
|
|
1106
|
+
// same cap an env raise could reach plus a generous finding budget;
|
|
1107
|
+
// the per-file budget and an explicit env override still apply.
|
|
1108
|
+
scan = scanGitTarget(repo, {
|
|
1109
|
+
kind: 'tracked',
|
|
1110
|
+
limits: { maxTotalBytes: MAX_SCAN_LIMIT_BYTES, maxFindings: 100_000, ...scanLimits() },
|
|
1111
|
+
});
|
|
1112
|
+
} catch (error) {
|
|
1113
|
+
console.error(`aimhooman: warning: grandfather scan failed (${error.message}); no pre-existing secret paths were allowed`);
|
|
1114
|
+
return;
|
|
862
1115
|
}
|
|
863
|
-
if (
|
|
864
|
-
|
|
865
|
-
console.error(
|
|
866
|
-
`aimhooman: project policy requires profile "${projectPolicy.profile}"; ` +
|
|
867
|
-
'edit .aimhooman.json to change the team baseline'
|
|
868
|
-
);
|
|
869
|
-
return 20;
|
|
870
|
-
}
|
|
871
|
-
profile = projectPolicy.profile;
|
|
1116
|
+
if (!scan.complete) {
|
|
1117
|
+
console.error('aimhooman: warning: tracked scan was incomplete, so the grandfathered set may be partial; allow the remaining paths with --scope secret-path');
|
|
872
1118
|
}
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
console.
|
|
878
|
-
return
|
|
1119
|
+
const paths = [...new Set(scan.findings
|
|
1120
|
+
.filter((finding) => finding.category === 'secret' && finding.path)
|
|
1121
|
+
.map((finding) => finding.path))].sort();
|
|
1122
|
+
if (!paths.length) {
|
|
1123
|
+
console.log('aimhooman: no tracked secret findings to grandfather');
|
|
1124
|
+
return;
|
|
879
1125
|
}
|
|
880
|
-
const
|
|
881
|
-
const
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
throw new Error(`hook installation incomplete; ${cause}repository guard is not active`);
|
|
899
|
-
}
|
|
900
|
-
saveConfig(repo.stateDir, { profile });
|
|
901
|
-
applyExclude(repo.excludeFile, patternsForRules(eng.rules));
|
|
902
|
-
const saved = loadConfig(repo.stateDir);
|
|
903
|
-
const excludes = inspectExclude(repo.excludeFile, patternsForRules(eng.rules));
|
|
904
|
-
if (saved.profile !== profile || !excludes.current) {
|
|
905
|
-
throw new Error('post-install state verification failed');
|
|
906
|
-
}
|
|
907
|
-
} catch (error) {
|
|
908
|
-
const rollbackFailures = [];
|
|
909
|
-
try {
|
|
910
|
-
const uninstalled = uninstallHooks(repo);
|
|
911
|
-
rollbackFailures.push(...(uninstalled.failures || []));
|
|
912
|
-
} catch (rollbackError) {
|
|
913
|
-
rollbackFailures.push(`hook uninstall: ${rollbackError.message}`);
|
|
914
|
-
}
|
|
915
|
-
const hookSet = new Set(hookFiles);
|
|
916
|
-
const restoreHooks = Boolean(rep?.installed?.length || rep?.chained?.length);
|
|
917
|
-
for (const snapshot of snapshots.reverse()) {
|
|
918
|
-
if (!restoreHooks && hookSet.has(snapshot.path)) continue;
|
|
919
|
-
try { restoreSnapshot(snapshot); }
|
|
920
|
-
catch (rollbackError) {
|
|
921
|
-
rollbackFailures.push(`${snapshot.path}: ${rollbackError.message}`);
|
|
922
|
-
}
|
|
923
|
-
}
|
|
924
|
-
if (rollbackFailures.length) {
|
|
925
|
-
console.error(`aimhooman: initialisation failed: ${error.message}`);
|
|
926
|
-
console.error(`aimhooman: rollback incomplete: ${rollbackFailures.join('; ')}`);
|
|
927
|
-
return 30;
|
|
1126
|
+
const { engine } = newEngineWithDiagnostics('clean', repo.stateDir);
|
|
1127
|
+
const added = withLock(join(repo.stateDir, 'overrides.json.lock'), () => {
|
|
1128
|
+
const ov = loadOverrides(repo.stateDir);
|
|
1129
|
+
let count = 0;
|
|
1130
|
+
for (const path of paths) {
|
|
1131
|
+
const entry = {
|
|
1132
|
+
target: path,
|
|
1133
|
+
scope: 'secret-path',
|
|
1134
|
+
reason: 'pre-existing tracked fixture (grandfathered at init)',
|
|
1135
|
+
actor: gitConfig(repo.root, 'user.email'),
|
|
1136
|
+
at: new Date().toISOString(),
|
|
1137
|
+
};
|
|
1138
|
+
const sameAllow = (candidate) => (
|
|
1139
|
+
candidate.target === path && effectiveOverrideScope(candidate, engine) === 'secret-path'
|
|
1140
|
+
);
|
|
1141
|
+
if (ov.allow.some(sameAllow)) continue;
|
|
1142
|
+
ov.allow = upsert(ov.allow, entry, sameAllow);
|
|
1143
|
+
count += 1;
|
|
928
1144
|
}
|
|
929
|
-
|
|
930
|
-
return
|
|
931
|
-
}
|
|
932
|
-
console.log(`aimhooman:
|
|
933
|
-
console.log(` state: ${repo.stateDir}`);
|
|
934
|
-
console.log(` excludes: ${repo.excludeFile}`);
|
|
935
|
-
if (rep.installed.length) console.log(` hooks: ${rep.installed.join(', ')}`);
|
|
936
|
-
if (rep.chained.length) console.log(` chained: ${rep.chained.join(', ')} (existing hooks preserved)`);
|
|
937
|
-
for (const warning of rep.warnings) console.log(` warning: ${warning}`);
|
|
938
|
-
return 0;
|
|
939
|
-
}, LIFECYCLE_LOCK_OPTIONS);
|
|
1145
|
+
saveOverrides(repo.stateDir, ov);
|
|
1146
|
+
return count;
|
|
1147
|
+
});
|
|
1148
|
+
console.log(`aimhooman: grandfathered ${added} tracked secret path(s) with --scope secret-path allows; new files with secrets are still blocked`);
|
|
940
1149
|
}
|
|
941
1150
|
|
|
942
1151
|
function cmdStatus(args) {
|
|
@@ -1049,6 +1258,25 @@ function cmdExplain(args) {
|
|
|
1049
1258
|
return 0;
|
|
1050
1259
|
}
|
|
1051
1260
|
|
|
1261
|
+
// The content half of the secret-allow guard in cmdOverride: run the engine's
|
|
1262
|
+
// own secret-category content rules over the target's bytes. Files over the
|
|
1263
|
+
// scanner's per-file budget, unreadable paths, and non-regular files skip it —
|
|
1264
|
+
// this guard is a UX safety net against an allow that would report success yet
|
|
1265
|
+
// leave the block in place, and the commit-time scanner still fails closed on
|
|
1266
|
+
// whatever was not checked here.
|
|
1267
|
+
function fileContentMatchesSecret(engine, repo, target) {
|
|
1268
|
+
let content;
|
|
1269
|
+
try {
|
|
1270
|
+
const file = join(repo.root, target);
|
|
1271
|
+
const stat = lstatSync(file);
|
|
1272
|
+
if (!stat.isFile() || stat.size > DEFAULT_SCAN_LIMITS.maxFileBytes) return false;
|
|
1273
|
+
content = readFileSync(file, 'utf8');
|
|
1274
|
+
} catch {
|
|
1275
|
+
return false;
|
|
1276
|
+
}
|
|
1277
|
+
return engine.checkContent(target, content, { categories: ['secret'] }).length > 0;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1052
1280
|
function cmdOverride(args, allow) {
|
|
1053
1281
|
const verb = allow ? 'allow' : 'deny';
|
|
1054
1282
|
const { options, positionals } = parseArguments(args, {
|
|
@@ -1080,9 +1308,13 @@ function cmdOverride(args, allow) {
|
|
|
1080
1308
|
// allow: only --scope secret-path suppresses secret findings, deliberately,
|
|
1081
1309
|
// so a local override cannot hide a possible leaked key. Without this check
|
|
1082
1310
|
// a bare `allow .env.minimal` would report success but leave the block in
|
|
1083
|
-
// place, which reads as a broken allow.
|
|
1311
|
+
// place, which reads as a broken allow. checkPaths only evaluates path
|
|
1312
|
+
// rules, so a content-shaped secret (a private key inside an
|
|
1313
|
+
// ordinary-looking filename) would slip past it; the content half of the
|
|
1314
|
+
// guard closes that hole.
|
|
1084
1315
|
if (allow && scope !== 'secret-path'
|
|
1085
|
-
&& engine.checkPaths([target]).some((finding) => finding.category === 'secret')
|
|
1316
|
+
&& (engine.checkPaths([target]).some((finding) => finding.category === 'secret')
|
|
1317
|
+
|| fileContentMatchesSecret(engine, repo, target))) {
|
|
1086
1318
|
throw new ArgumentError(
|
|
1087
1319
|
`"${target}" matches a secret rule, so a ${scope} allow cannot silence it `
|
|
1088
1320
|
+ '(a local override must not hide a possible leaked key); '
|
|
@@ -1638,41 +1870,41 @@ function cmdUninstall(args) {
|
|
|
1638
1870
|
});
|
|
1639
1871
|
if (options.global) {
|
|
1640
1872
|
return withLock(join(globalHooksDir(), 'lifecycle.lock'), () => {
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1873
|
+
const aimDir = globalHooksDir();
|
|
1874
|
+
const readGlobalHooksPath = () => {
|
|
1875
|
+
try {
|
|
1876
|
+
return execFileSync('git', ['config', '--global', '--get', 'core.hooksPath'], { encoding: 'utf8', timeout: GIT_TIMEOUT_MS }).trim();
|
|
1877
|
+
} catch { return ''; }
|
|
1878
|
+
};
|
|
1879
|
+
const current = readGlobalHooksPath();
|
|
1880
|
+
let unsetAttempted = false;
|
|
1881
|
+
if (current === aimDir) {
|
|
1882
|
+
unsetAttempted = true;
|
|
1883
|
+
try {
|
|
1884
|
+
execFileSync('git', ['config', '--global', '--unset', 'core.hooksPath'], { stdio: ['ignore', 'ignore', 'ignore'], timeout: GIT_TIMEOUT_MS });
|
|
1885
|
+
} catch { /* may have failed; re-verify below */ }
|
|
1886
|
+
// A read-only or locked ~/.gitconfig can make --unset fail silently.
|
|
1887
|
+
// Do not remove the dispatchers while core.hooksPath still resolves to
|
|
1888
|
+
// them, or every repository inheriting this setting would run zero
|
|
1889
|
+
// hooks with no warning. Preserve the working dispatchers until the
|
|
1890
|
+
// config can be cleaned.
|
|
1891
|
+
if (readGlobalHooksPath() === aimDir) {
|
|
1892
|
+
console.error('aimhooman: could not unset global core.hooksPath (read-only or locked ~/.gitconfig); leaving dispatchers in place.');
|
|
1893
|
+
console.error(' Fix the config permissions and run `aimhooman uninstall --global` again.');
|
|
1894
|
+
return 30;
|
|
1895
|
+
}
|
|
1663
1896
|
}
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
return 0;
|
|
1897
|
+
const rep = uninstallGlobalHooks();
|
|
1898
|
+
console.log('aimhooman: global hooks uninstalled');
|
|
1899
|
+
if (current && current !== aimDir) {
|
|
1900
|
+
console.log(` core.hooksPath kept at "${current}" (not owned by aimhooman)`);
|
|
1901
|
+
} else {
|
|
1902
|
+
console.log(` core.hooksPath ${unsetAttempted ? 'unset' : 'was already unset'}`);
|
|
1903
|
+
}
|
|
1904
|
+
if (rep.removed.length) console.log(` dispatchers removed: ${rep.removed.join(', ')}`);
|
|
1905
|
+
for (const w of rep.warnings || []) console.log(` warning: ${w}`);
|
|
1906
|
+
console.log(` dir kept at ${rep.dir}`);
|
|
1907
|
+
return 0;
|
|
1676
1908
|
}, LIFECYCLE_LOCK_OPTIONS);
|
|
1677
1909
|
}
|
|
1678
1910
|
const purge = Boolean(options.purgeState);
|
|
@@ -1683,72 +1915,72 @@ function cmdUninstall(args) {
|
|
|
1683
1915
|
}
|
|
1684
1916
|
const lifecycleLock = join(repo.commonDir, 'aimhooman-lifecycle.lock');
|
|
1685
1917
|
const exitStatus = withLock(lifecycleLock, () => {
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
} else {
|
|
1707
|
-
console.log('aimhooman: uninstalled');
|
|
1708
|
-
}
|
|
1709
|
-
if (rep.removed.length) console.log(` hooks removed: ${rep.removed.join(', ')}`);
|
|
1710
|
-
if (rep.restored.length) console.log(` hooks restored: ${rep.restored.join(', ')}`);
|
|
1711
|
-
for (const w of rep.warnings || []) console.log(` warning: ${w}`);
|
|
1712
|
-
for (const f of rep.failures || []) console.error(` failure: ${f}`);
|
|
1713
|
-
if (excludeFailure) console.error(` failure: ${excludeFailure}`);
|
|
1714
|
-
const unrestored = purge ? unrestoredChainedBackups(repo) : [];
|
|
1715
|
-
if (purge) {
|
|
1716
|
-
// Never wipe stateDir while a predecessor hook backup is still on disk:
|
|
1717
|
-
// a per-hook restore failure leaves the user's original hook existing
|
|
1718
|
-
// only in <stateDir>/chained, so purging would destroy it irrecoverably.
|
|
1719
|
-
if (unrestored.length || rep.failures?.length) {
|
|
1720
|
-
console.error(
|
|
1721
|
-
'aimhooman: state NOT purged — '
|
|
1722
|
-
+ `${unrestored.length} chained hook backup(s) remain unrestored`
|
|
1723
|
-
+ `${rep.failures?.length ? ` (failures: ${rep.failures.join('; ')})` : ''}; `
|
|
1724
|
-
+ "re-run 'aimhooman uninstall' to retry restore before purging."
|
|
1725
|
-
);
|
|
1918
|
+
const rep = uninstallHooks(repo);
|
|
1919
|
+
// The irreversible work is already done above, and the report is still
|
|
1920
|
+
// below. A throw from here unwound past all of it, so a damaged marker was
|
|
1921
|
+
// the only thing the user heard while four dispatchers kept guarding every
|
|
1922
|
+
// commit. Report the failure beside the removal report instead of in place
|
|
1923
|
+
// of it. Nothing is swallowed: this is also where the symlink and permission
|
|
1924
|
+
// guards surface, and their messages still reach the user and still exit 30.
|
|
1925
|
+
let excludeFailure = '';
|
|
1926
|
+
try {
|
|
1927
|
+
removeExclude(repo.excludeFile);
|
|
1928
|
+
} catch (error) {
|
|
1929
|
+
excludeFailure = `exclude block left in ${repo.excludeFile}: ${error.message}`;
|
|
1930
|
+
}
|
|
1931
|
+
// Trust the directory, not the report. Every refusal below leaves a working
|
|
1932
|
+
// dispatcher behind, and one printed under "uninstalled" reads as done.
|
|
1933
|
+
const remaining = remainingDispatchers(repo);
|
|
1934
|
+
if (remaining.length) {
|
|
1935
|
+
console.error('aimhooman: NOT uninstalled; leaving dispatchers in place:');
|
|
1936
|
+
for (const path of remaining) console.error(` ${path}`);
|
|
1937
|
+
console.error(' These still guard every commit. Remove them by hand to finish uninstalling.');
|
|
1726
1938
|
} else {
|
|
1727
|
-
|
|
1728
|
-
console.log(' state purged');
|
|
1939
|
+
console.log('aimhooman: uninstalled');
|
|
1729
1940
|
}
|
|
1730
|
-
|
|
1731
|
-
console.log(`
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1941
|
+
if (rep.removed.length) console.log(` hooks removed: ${rep.removed.join(', ')}`);
|
|
1942
|
+
if (rep.restored.length) console.log(` hooks restored: ${rep.restored.join(', ')}`);
|
|
1943
|
+
for (const w of rep.warnings || []) console.log(` warning: ${w}`);
|
|
1944
|
+
for (const f of rep.failures || []) console.error(` failure: ${f}`);
|
|
1945
|
+
if (excludeFailure) console.error(` failure: ${excludeFailure}`);
|
|
1946
|
+
const unrestored = purge ? unrestoredChainedBackups(repo) : [];
|
|
1947
|
+
if (purge) {
|
|
1948
|
+
// Never wipe stateDir while a predecessor hook backup is still on disk:
|
|
1949
|
+
// a per-hook restore failure leaves the user's original hook existing
|
|
1950
|
+
// only in <stateDir>/chained, so purging would destroy it irrecoverably.
|
|
1951
|
+
if (unrestored.length || rep.failures?.length) {
|
|
1952
|
+
console.error(
|
|
1953
|
+
'aimhooman: state NOT purged — '
|
|
1954
|
+
+ `${unrestored.length} chained hook backup(s) remain unrestored`
|
|
1955
|
+
+ `${rep.failures?.length ? ` (failures: ${rep.failures.join('; ')})` : ''}; `
|
|
1956
|
+
+ "re-run 'aimhooman uninstall' to retry restore before purging."
|
|
1957
|
+
);
|
|
1958
|
+
} else {
|
|
1959
|
+
rmSync(repo.stateDir, { recursive: true, force: true });
|
|
1960
|
+
console.log(' state purged');
|
|
1961
|
+
}
|
|
1962
|
+
} else {
|
|
1963
|
+
console.log(` state kept at ${repo.stateDir} (use --purge-state to remove)`);
|
|
1964
|
+
}
|
|
1965
|
+
// Surface when a global core.hooksPath still enforces aimhooman, so a local
|
|
1966
|
+
// uninstall cannot be mistaken for full removal. Foreign hooksPath values
|
|
1967
|
+
// are left to the generic "not modified" warning emitted above.
|
|
1968
|
+
const aimDir = globalHooksDir();
|
|
1969
|
+
let globalHooksPath = '';
|
|
1970
|
+
try {
|
|
1971
|
+
globalHooksPath = execFileSync('git', ['config', '--global', '--get', 'core.hooksPath'], {
|
|
1972
|
+
encoding: 'utf8',
|
|
1973
|
+
timeout: GIT_TIMEOUT_MS,
|
|
1974
|
+
}).trim();
|
|
1975
|
+
} catch { /* unset */ }
|
|
1976
|
+
if (globalHooksPath === aimDir) {
|
|
1977
|
+
console.log('aimhooman: global Git guard is still active');
|
|
1978
|
+
console.log(' eligible non-bare repositories that inherit core.hooksPath are still guarded.');
|
|
1979
|
+
console.log(' run `aimhooman uninstall --global` to remove it.');
|
|
1980
|
+
}
|
|
1981
|
+
// The managed block is still in the exclude file and still ignoring paths, so
|
|
1982
|
+
// the uninstall is genuinely incomplete and 30 is the honest answer.
|
|
1983
|
+
return remaining.length || rep.failures?.length || unrestored.length || excludeFailure ? 30 : 0;
|
|
1752
1984
|
}, LIFECYCLE_LOCK_OPTIONS);
|
|
1753
1985
|
// Sweep the operational residue the guard authored in .git, so uninstall leaves
|
|
1754
1986
|
// no aimhooman fingerprints behind — the same tooling residue this tool exists
|
|
@@ -1792,7 +2024,7 @@ function usage() {
|
|
|
1792
2024
|
process.stdout.write(`aimhooman ${VERSION} - AI works. Hoomans ship.
|
|
1793
2025
|
|
|
1794
2026
|
Usage:
|
|
1795
|
-
aimhooman init [--profile clean|strict|compliance]
|
|
2027
|
+
aimhooman init [--profile clean|strict|compliance] [--grandfather-secrets]
|
|
1796
2028
|
aimhooman init --global --yes
|
|
1797
2029
|
aimhooman check [--staged] [-m <file>|--message <file>] [--profile ...] [--json]
|
|
1798
2030
|
aimhooman check --commit <rev> | --range <base>...<head> | --tracked
|