@rmyndharis/aimhooman 0.1.6 → 0.1.8
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 +108 -0
- package/README.md +2 -2
- package/bin/aimhooman.mjs +368 -242
- package/docs/design/frictionless-enforcement.md +7 -4
- package/package.json +1 -1
- package/rules/paths.json +65 -16
- package/rules/secrets.json +3 -3
- package/src/githooks.mjs +51 -6
- package/src/gitx.mjs +10 -0
- package/src/hook.mjs +137 -5
- package/src/report.mjs +27 -2
- package/src/scan-session.mjs +77 -1
- package/src/scan-target.mjs +23 -5
- package/src/scan.mjs +40 -7
package/bin/aimhooman.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { execFileSync } from 'node:child_process';
|
|
3
|
-
import { chmodSync, lstatSync, readdirSync, readFileSync, rmdirSync,
|
|
3
|
+
import { chmodSync, lstatSync, readdirSync, readFileSync, rmSync, rmdirSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import { GIT_TIMEOUT_MS } from '../src/git-environment.mjs';
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
readStagedPath,
|
|
16
16
|
stagedPaths,
|
|
17
17
|
stagedRenameSources,
|
|
18
|
+
stagedTreeSha,
|
|
18
19
|
unstagePaths,
|
|
19
20
|
withIndexFromTree,
|
|
20
21
|
} from '../src/gitx.mjs';
|
|
@@ -118,6 +119,22 @@ function configuredEngine(profile, repo) {
|
|
|
118
119
|
|
|
119
120
|
function main(argv) {
|
|
120
121
|
const [cmd, ...rest] = argv;
|
|
122
|
+
// Subcommand-level --help: `aimhooman override --help` (or `init -h`, etc.)
|
|
123
|
+
// previously fell into the subcommand's strict argument parser, which rejects
|
|
124
|
+
// --help as an unknown option and exits 20. Recognise a leading help flag on
|
|
125
|
+
// any real subcommand and route to usage() instead, so help works everywhere.
|
|
126
|
+
// The top-level help forms (cmd itself is help/--help/-h) are handled in the
|
|
127
|
+
// switch below.
|
|
128
|
+
const SUBCOMMAND_HELP_FLAGS = new Set(['--help', '-h', 'help']);
|
|
129
|
+
const knownSubcommands = new Set([
|
|
130
|
+
'check', 'audit', 'scan', 'precommit', 'commitmsg', 'refcheck', 'init',
|
|
131
|
+
'status', 'explain', 'allow', 'deny', 'override', 'review',
|
|
132
|
+
'policy-review', 'fix', 'doctor', 'uninstall',
|
|
133
|
+
]);
|
|
134
|
+
if (knownSubcommands.has(cmd) && rest.length && SUBCOMMAND_HELP_FLAGS.has(rest[0])) {
|
|
135
|
+
usage();
|
|
136
|
+
return 0;
|
|
137
|
+
}
|
|
121
138
|
switch (cmd) {
|
|
122
139
|
case 'check':
|
|
123
140
|
return cmdCheck(rest);
|
|
@@ -311,6 +328,49 @@ function dispatchHooksChanged(repo, profile) {
|
|
|
311
328
|
return true;
|
|
312
329
|
}
|
|
313
330
|
|
|
331
|
+
// W5 pre-commit/commit-msg marker dedup. pre-commit writes a marker after a
|
|
332
|
+
// clean, complete scan of the staged tree; commit-msg reads it and skips its
|
|
333
|
+
// duplicate ~170ms tree scan when the staged tree sha, profile, and
|
|
334
|
+
// completeness all match. The marker lives in stateDir (gitignored plumbing).
|
|
335
|
+
// It is self-invalidating: any index mutation between the two hooks changes the
|
|
336
|
+
// tree sha, so a stale marker never matches. A missing/corrupt/mismatched
|
|
337
|
+
// marker makes commit-msg fall back to the full scan, so this is purely an
|
|
338
|
+
// optimization and never weakens the guard.
|
|
339
|
+
const PRECOMMIT_CLEAN_VERSION = 1;
|
|
340
|
+
function precommitCleanPath(repo) {
|
|
341
|
+
return join(repo.stateDir, 'precommit-clean.json');
|
|
342
|
+
}
|
|
343
|
+
function recordPrecommitClean(repo, profile) {
|
|
344
|
+
let treeSha;
|
|
345
|
+
try {
|
|
346
|
+
treeSha = stagedTreeSha(repo);
|
|
347
|
+
} catch {
|
|
348
|
+
return; // cannot compute the sha → do not record; commit-msg will scan
|
|
349
|
+
}
|
|
350
|
+
try {
|
|
351
|
+
writeFileSync(precommitCleanPath(repo), JSON.stringify({
|
|
352
|
+
version: PRECOMMIT_CLEAN_VERSION,
|
|
353
|
+
tree: treeSha,
|
|
354
|
+
profile,
|
|
355
|
+
complete: true,
|
|
356
|
+
}));
|
|
357
|
+
} catch {
|
|
358
|
+
// best effort; a missing marker just means commit-msg scans normally
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
function precommitCleanMatches(repo, treeSha, profile) {
|
|
362
|
+
let marker;
|
|
363
|
+
try {
|
|
364
|
+
marker = JSON.parse(readFileSync(precommitCleanPath(repo), 'utf8'));
|
|
365
|
+
} catch {
|
|
366
|
+
return false; // missing/corrupt → fall back to full scan
|
|
367
|
+
}
|
|
368
|
+
return marker?.version === PRECOMMIT_CLEAN_VERSION
|
|
369
|
+
&& marker.complete === true
|
|
370
|
+
&& marker.tree === treeSha
|
|
371
|
+
&& marker.profile === profile;
|
|
372
|
+
}
|
|
373
|
+
|
|
314
374
|
function cmdPrecommit(args) {
|
|
315
375
|
parseNoArguments(args);
|
|
316
376
|
const repo = tryRepo();
|
|
@@ -336,6 +396,13 @@ function cmdPrecommit(args) {
|
|
|
336
396
|
process.stderr.write(incompleteMessage(scan));
|
|
337
397
|
return 31;
|
|
338
398
|
}
|
|
399
|
+
// W5 marker dedup: record that this staged tree scanned clean so the
|
|
400
|
+
// upcoming commit-msg hook can skip its duplicate tree scan. The tree
|
|
401
|
+
// sha is the same value the commit-msg dispatcher computes via
|
|
402
|
+
// `git write-tree`; the index is unchanged on this no-block path, so the
|
|
403
|
+
// sha is stable. A missing/stale/mismatched marker makes commit-msg
|
|
404
|
+
// fall back to the full scan, so this is purely an optimization.
|
|
405
|
+
recordPrecommitClean(repo, profile);
|
|
339
406
|
return profile === 'strict' && reviews.length ? 11 : 0;
|
|
340
407
|
}
|
|
341
408
|
if (profile === 'strict') {
|
|
@@ -463,7 +530,14 @@ function cmdCommitmsg(args) {
|
|
|
463
530
|
try {
|
|
464
531
|
const checked = againstWouldBeTree(() => ({
|
|
465
532
|
message: scanMessage(repo, text, { target: 'staged' }),
|
|
466
|
-
|
|
533
|
+
// W5 marker dedup: if pre-commit already scanned this exact tree
|
|
534
|
+
// clean (matching sha + profile + complete), skip the duplicate
|
|
535
|
+
// ~170ms tree scan. The marker is self-invalidating (any index
|
|
536
|
+
// mutation changes the tree sha), and a missing/stale/mismatched
|
|
537
|
+
// marker falls back to the full scan, so this is safe.
|
|
538
|
+
tree: options.tree && precommitCleanMatches(repo, options.tree, hookProfile)
|
|
539
|
+
? null
|
|
540
|
+
: (options.tree ? scanGitTarget(repo, { kind: 'staged', limits }) : null),
|
|
467
541
|
}));
|
|
468
542
|
scan = checked.message;
|
|
469
543
|
treeScan = checked.tree;
|
|
@@ -599,8 +673,23 @@ function cmdRefcheck(args) {
|
|
|
599
673
|
let commits;
|
|
600
674
|
try {
|
|
601
675
|
const contextsByCommit = new Map();
|
|
676
|
+
// A commit's message belongs to whoever wrote it. Attribution and marker
|
|
677
|
+
// rules police the text a local developer can edit, so they are scoped
|
|
678
|
+
// to commits authored here: an update that introduces exactly one new
|
|
679
|
+
// commit on top of a non-zero old tip (a plain commit, an --amend, or a
|
|
680
|
+
// local --no-ff merge of an already-gated branch). Anything else — a new
|
|
681
|
+
// branch pulled in by `gh pr checkout` or `git fetch`, a merge of fetched
|
|
682
|
+
// history — imports other people's commit text the developer cannot
|
|
683
|
+
// change, and scanning it only blocks the review.
|
|
684
|
+
const localAuthorTips = new Set();
|
|
602
685
|
for (const update of updates) {
|
|
603
|
-
|
|
686
|
+
const introduced = introducedCommits(repo, [update]);
|
|
687
|
+
if (!/^0+$/.test(update.oldObjectId)
|
|
688
|
+
&& introduced.length === 1
|
|
689
|
+
&& introduced[0] === update.newObjectId) {
|
|
690
|
+
localAuthorTips.add(update.newObjectId);
|
|
691
|
+
}
|
|
692
|
+
for (const revision of introduced) {
|
|
604
693
|
const contexts = contextsByCommit.get(revision) || [];
|
|
605
694
|
contexts.push({
|
|
606
695
|
head: update.newObjectId,
|
|
@@ -624,14 +713,18 @@ function cmdRefcheck(args) {
|
|
|
624
713
|
contextsByCommit.set(revision, contexts);
|
|
625
714
|
}
|
|
626
715
|
}
|
|
627
|
-
commits = [...contextsByCommit]
|
|
716
|
+
commits = [...contextsByCommit].map(([revision, reviewContexts]) => [
|
|
717
|
+
revision,
|
|
718
|
+
reviewContexts,
|
|
719
|
+
localAuthorTips.has(revision),
|
|
720
|
+
]);
|
|
628
721
|
}
|
|
629
722
|
catch (error) {
|
|
630
723
|
console.error(`aimhooman: cannot resolve proposed commits: ${error.message}`);
|
|
631
724
|
return 30;
|
|
632
725
|
}
|
|
633
726
|
const limits = scanLimits();
|
|
634
|
-
for (const [revision, reviewContexts] of commits) {
|
|
727
|
+
for (const [revision, reviewContexts, authoredLocally] of commits) {
|
|
635
728
|
let scan;
|
|
636
729
|
try {
|
|
637
730
|
scan = scanGitTarget(repo, {
|
|
@@ -640,6 +733,7 @@ function cmdRefcheck(args) {
|
|
|
640
733
|
reviewContexts,
|
|
641
734
|
policyMigrationContexts: reviewContexts,
|
|
642
735
|
limits,
|
|
736
|
+
messageScope: authoredLocally ? 'commit' : 'changes-only',
|
|
643
737
|
});
|
|
644
738
|
}
|
|
645
739
|
catch (error) {
|
|
@@ -757,71 +851,71 @@ function cmdInit(args) {
|
|
|
757
851
|
return 20;
|
|
758
852
|
}
|
|
759
853
|
return withLock(join(globalHooksDir(), 'lifecycle.lock'), () => {
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
return 20;
|
|
771
|
-
}
|
|
772
|
-
// A --system hooksPath would be shadowed by the --global write below and
|
|
773
|
-
// could silently disable a system-wide hook manager, so refuse here too.
|
|
774
|
-
let systemHooksPath = '';
|
|
775
|
-
try {
|
|
776
|
-
systemHooksPath = execFileSync('git', ['config', '--system', '--get', 'core.hooksPath'], { encoding: 'utf8', timeout: GIT_TIMEOUT_MS }).trim();
|
|
777
|
-
} catch { /* unset or no system config */ }
|
|
778
|
-
if (systemHooksPath) {
|
|
779
|
-
console.error(
|
|
780
|
-
`aimhooman: core.hooksPath is set at --system scope to '${systemHooksPath}'; ` +
|
|
781
|
-
'a --global install would shadow it. Unset it or choose a different setup.'
|
|
782
|
-
);
|
|
783
|
-
return 20;
|
|
784
|
-
}
|
|
785
|
-
const localRepo = tryRepo();
|
|
786
|
-
const localHooksPath = localRepo ? gitConfigAtScope(localRepo.root, '--local', 'core.hooksPath') : '';
|
|
787
|
-
if (localHooksPath) {
|
|
788
|
-
console.error(`aimhooman: warning: this repository has local core.hooksPath="${localHooksPath}", which overrides the global guard here`);
|
|
789
|
-
}
|
|
790
|
-
const hookSnapshots = REQUIRED_GIT_HOOKS
|
|
791
|
-
.map((name) => snapshotFile(join(aimDir, name)));
|
|
792
|
-
let rep;
|
|
793
|
-
try {
|
|
794
|
-
rep = installGlobalHooks(CLI_PATH);
|
|
795
|
-
if (rep.skipped?.length) {
|
|
796
|
-
for (const warning of rep.warnings || []) console.error(`aimhooman: ${warning}`);
|
|
797
|
-
console.error('aimhooman: global hook installation aborted; core.hooksPath was not changed');
|
|
854
|
+
const aimDir = globalHooksDir();
|
|
855
|
+
let existing = '';
|
|
856
|
+
try {
|
|
857
|
+
existing = execFileSync('git', ['config', '--global', '--get', 'core.hooksPath'], { encoding: 'utf8', timeout: GIT_TIMEOUT_MS }).trim();
|
|
858
|
+
} catch { /* unset */ }
|
|
859
|
+
if (existing && existing !== aimDir) {
|
|
860
|
+
console.error(
|
|
861
|
+
`aimhooman: core.hooksPath is already set to '${existing}'; refusing to overwrite. ` +
|
|
862
|
+
'Unset it (git config --global --unset core.hooksPath) or choose a different setup.'
|
|
863
|
+
);
|
|
798
864
|
return 20;
|
|
799
865
|
}
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
for (const snapshot of hookSnapshots.reverse()) {
|
|
804
|
-
try { restoreSnapshot(snapshot); }
|
|
805
|
-
catch (rollbackError) {
|
|
806
|
-
rollbackFailures.push(`${snapshot.path}: ${rollbackError.message}`);
|
|
807
|
-
}
|
|
808
|
-
}
|
|
866
|
+
// A --system hooksPath would be shadowed by the --global write below and
|
|
867
|
+
// could silently disable a system-wide hook manager, so refuse here too.
|
|
868
|
+
let systemHooksPath = '';
|
|
809
869
|
try {
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
870
|
+
systemHooksPath = execFileSync('git', ['config', '--system', '--get', 'core.hooksPath'], { encoding: 'utf8', timeout: GIT_TIMEOUT_MS }).trim();
|
|
871
|
+
} catch { /* unset or no system config */ }
|
|
872
|
+
if (systemHooksPath) {
|
|
873
|
+
console.error(
|
|
874
|
+
`aimhooman: core.hooksPath is set at --system scope to '${systemHooksPath}'; ` +
|
|
875
|
+
'a --global install would shadow it. Unset it or choose a different setup.'
|
|
876
|
+
);
|
|
877
|
+
return 20;
|
|
814
878
|
}
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
879
|
+
const localRepo = tryRepo();
|
|
880
|
+
const localHooksPath = localRepo ? gitConfigAtScope(localRepo.root, '--local', 'core.hooksPath') : '';
|
|
881
|
+
if (localHooksPath) {
|
|
882
|
+
console.error(`aimhooman: warning: this repository has local core.hooksPath="${localHooksPath}", which overrides the global guard here`);
|
|
818
883
|
}
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
884
|
+
const hookSnapshots = REQUIRED_GIT_HOOKS
|
|
885
|
+
.map((name) => snapshotFile(join(aimDir, name)));
|
|
886
|
+
let rep;
|
|
887
|
+
try {
|
|
888
|
+
rep = installGlobalHooks(CLI_PATH);
|
|
889
|
+
if (rep.skipped?.length) {
|
|
890
|
+
for (const warning of rep.warnings || []) console.error(`aimhooman: ${warning}`);
|
|
891
|
+
console.error('aimhooman: global hook installation aborted; core.hooksPath was not changed');
|
|
892
|
+
return 20;
|
|
893
|
+
}
|
|
894
|
+
execFileSync('git', ['config', '--global', 'core.hooksPath', rep.dir], { timeout: GIT_TIMEOUT_MS });
|
|
895
|
+
} catch (error) {
|
|
896
|
+
const rollbackFailures = [];
|
|
897
|
+
for (const snapshot of hookSnapshots.reverse()) {
|
|
898
|
+
try { restoreSnapshot(snapshot); }
|
|
899
|
+
catch (rollbackError) {
|
|
900
|
+
rollbackFailures.push(`${snapshot.path}: ${rollbackError.message}`);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
try {
|
|
904
|
+
if (existing) execFileSync('git', ['config', '--global', 'core.hooksPath', existing], { timeout: GIT_TIMEOUT_MS });
|
|
905
|
+
else execFileSync('git', ['config', '--global', '--unset', 'core.hooksPath'], { stdio: 'ignore', timeout: GIT_TIMEOUT_MS });
|
|
906
|
+
} catch (rollbackError) {
|
|
907
|
+
rollbackFailures.push(`global core.hooksPath: ${rollbackError.message}`);
|
|
908
|
+
}
|
|
909
|
+
console.error(`aimhooman: global hook installation failed: ${error.message}`);
|
|
910
|
+
if (rollbackFailures.length) {
|
|
911
|
+
console.error(`aimhooman: rollback incomplete: ${rollbackFailures.join('; ')}`);
|
|
912
|
+
}
|
|
913
|
+
return 30;
|
|
914
|
+
}
|
|
915
|
+
console.log(`aimhooman: global hooks at ${rep.dir} (core.hooksPath set)`);
|
|
916
|
+
for (const w of rep.warnings || []) console.log(` warning: ${w}`);
|
|
917
|
+
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.');
|
|
918
|
+
return 0;
|
|
825
919
|
}, LIFECYCLE_LOCK_OPTIONS);
|
|
826
920
|
}
|
|
827
921
|
if (options.yes) throw new ArgumentError('--yes is only valid with --global');
|
|
@@ -833,89 +927,97 @@ function cmdInit(args) {
|
|
|
833
927
|
return 30;
|
|
834
928
|
}
|
|
835
929
|
return withLock(join(repo.commonDir, 'aimhooman-lifecycle.lock'), () => {
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
return 20;
|
|
842
|
-
}
|
|
843
|
-
if (projectPolicy) {
|
|
844
|
-
if (profileExplicit && profile !== projectPolicy.profile) {
|
|
845
|
-
console.error(
|
|
846
|
-
`aimhooman: project policy requires profile "${projectPolicy.profile}"; ` +
|
|
847
|
-
'edit .aimhooman.json to change the team baseline'
|
|
848
|
-
);
|
|
930
|
+
let projectPolicy;
|
|
931
|
+
try {
|
|
932
|
+
projectPolicy = loadProjectPolicy(repo.root);
|
|
933
|
+
} catch (e) {
|
|
934
|
+
console.error(`aimhooman: cannot load project policy: ${e.message}`);
|
|
849
935
|
return 20;
|
|
850
936
|
}
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
const hookState = hookDiagnostics(repo);
|
|
861
|
-
const hookFiles = hookState.some((hook) => hook.shared)
|
|
862
|
-
? []
|
|
863
|
-
: [...new Set(hookState.flatMap((hook) => [hook.path, hook.chainedPath]))];
|
|
864
|
-
let snapshots = [];
|
|
865
|
-
let rep;
|
|
866
|
-
try {
|
|
867
|
-
snapshots = [join(repo.stateDir, 'config.json'), repo.excludeFile, ...hookFiles]
|
|
868
|
-
.map(snapshotFile);
|
|
869
|
-
rep = installHooks(repo, CLI_PATH);
|
|
870
|
-
const activeHooks = installedHooks(repo);
|
|
871
|
-
if (!REQUIRED_GIT_HOOKS.every((name) => activeHooks.includes(name))) {
|
|
872
|
-
// installHooks declines rather than throws when the hooks directory is
|
|
873
|
-
// not ours, and its warnings are the only record of why. They are
|
|
874
|
-
// printed on the success path only, so carry them into the failure or
|
|
875
|
-
// the user is told nothing but "incomplete". The prefix is load-bearing:
|
|
876
|
-
// the exit-code branch below matches on it.
|
|
877
|
-
const cause = rep.shared && rep.warnings.length ? `${rep.warnings.join('; ')}; ` : '';
|
|
878
|
-
throw new Error(`hook installation incomplete; ${cause}repository guard is not active`);
|
|
879
|
-
}
|
|
880
|
-
saveConfig(repo.stateDir, { profile });
|
|
881
|
-
applyExclude(repo.excludeFile, patternsForRules(eng.rules));
|
|
882
|
-
const saved = loadConfig(repo.stateDir);
|
|
883
|
-
const excludes = inspectExclude(repo.excludeFile, patternsForRules(eng.rules));
|
|
884
|
-
if (saved.profile !== profile || !excludes.current) {
|
|
885
|
-
throw new Error('post-install state verification failed');
|
|
937
|
+
if (projectPolicy) {
|
|
938
|
+
if (profileExplicit && profile !== projectPolicy.profile) {
|
|
939
|
+
console.error(
|
|
940
|
+
`aimhooman: project policy requires profile "${projectPolicy.profile}"; ` +
|
|
941
|
+
'edit .aimhooman.json to change the team baseline'
|
|
942
|
+
);
|
|
943
|
+
return 20;
|
|
944
|
+
}
|
|
945
|
+
profile = projectPolicy.profile;
|
|
886
946
|
}
|
|
887
|
-
|
|
888
|
-
const rollbackFailures = [];
|
|
947
|
+
let eng;
|
|
889
948
|
try {
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
949
|
+
eng = configuredEngine(profile, repo);
|
|
950
|
+
} catch (e) {
|
|
951
|
+
console.error(`aimhooman: cannot initialise policy: ${e.message}`);
|
|
952
|
+
return 20;
|
|
894
953
|
}
|
|
895
|
-
const
|
|
896
|
-
const
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
954
|
+
const hookState = hookDiagnostics(repo);
|
|
955
|
+
const hookFiles = hookState.some((hook) => hook.shared)
|
|
956
|
+
? []
|
|
957
|
+
: [...new Set(hookState.flatMap((hook) => [hook.path, hook.chainedPath]))];
|
|
958
|
+
let snapshots = [];
|
|
959
|
+
let rep;
|
|
960
|
+
try {
|
|
961
|
+
snapshots = [join(repo.stateDir, 'config.json'), repo.excludeFile, ...hookFiles]
|
|
962
|
+
.map(snapshotFile);
|
|
963
|
+
rep = installHooks(repo, CLI_PATH);
|
|
964
|
+
const activeHooks = installedHooks(repo);
|
|
965
|
+
if (!REQUIRED_GIT_HOOKS.every((name) => activeHooks.includes(name))) {
|
|
966
|
+
// installHooks declines rather than throws when the hooks directory is
|
|
967
|
+
// not ours, and its warnings are the only record of why. They are
|
|
968
|
+
// printed on the success path only, so carry them into the failure or
|
|
969
|
+
// the user is told nothing but "incomplete". The prefix is load-bearing:
|
|
970
|
+
// the exit-code branch below matches on it.
|
|
971
|
+
const cause = rep.shared && rep.warnings.length ? `${rep.warnings.join('; ')}; ` : '';
|
|
972
|
+
// When the decline is because the hooks path is shared/tracked (or,
|
|
973
|
+
// after B2, worktree content the next add would commit), name the two
|
|
974
|
+
// ways out so the user is not stuck: let aimhooman use the default
|
|
975
|
+
// .git/hooks, or — for a worktree hooks path kept local — exclude it
|
|
976
|
+
// first. The uninstall hint lets them undo this init attempt.
|
|
977
|
+
const remedy = rep.shared
|
|
978
|
+
? ' 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.'
|
|
979
|
+
: '';
|
|
980
|
+
throw new Error(`hook installation incomplete; ${cause}repository guard is not active.${remedy}`);
|
|
902
981
|
}
|
|
982
|
+
saveConfig(repo.stateDir, { profile });
|
|
983
|
+
applyExclude(repo.excludeFile, patternsForRules(eng.rules));
|
|
984
|
+
const saved = loadConfig(repo.stateDir);
|
|
985
|
+
const excludes = inspectExclude(repo.excludeFile, patternsForRules(eng.rules));
|
|
986
|
+
if (saved.profile !== profile || !excludes.current) {
|
|
987
|
+
throw new Error('post-install state verification failed');
|
|
988
|
+
}
|
|
989
|
+
} catch (error) {
|
|
990
|
+
const rollbackFailures = [];
|
|
991
|
+
try {
|
|
992
|
+
const uninstalled = uninstallHooks(repo);
|
|
993
|
+
rollbackFailures.push(...(uninstalled.failures || []));
|
|
994
|
+
} catch (rollbackError) {
|
|
995
|
+
rollbackFailures.push(`hook uninstall: ${rollbackError.message}`);
|
|
996
|
+
}
|
|
997
|
+
const hookSet = new Set(hookFiles);
|
|
998
|
+
const restoreHooks = Boolean(rep?.installed?.length || rep?.chained?.length);
|
|
999
|
+
for (const snapshot of snapshots.reverse()) {
|
|
1000
|
+
if (!restoreHooks && hookSet.has(snapshot.path)) continue;
|
|
1001
|
+
try { restoreSnapshot(snapshot); }
|
|
1002
|
+
catch (rollbackError) {
|
|
1003
|
+
rollbackFailures.push(`${snapshot.path}: ${rollbackError.message}`);
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
if (rollbackFailures.length) {
|
|
1007
|
+
console.error(`aimhooman: initialisation failed: ${error.message}`);
|
|
1008
|
+
console.error(`aimhooman: rollback incomplete: ${rollbackFailures.join('; ')}`);
|
|
1009
|
+
return 30;
|
|
1010
|
+
}
|
|
1011
|
+
console.error(`aimhooman: initialisation failed and prior files were restored: ${error.message}`);
|
|
1012
|
+
return /hook installation incomplete/.test(error.message) ? 20 : expectedErrorCode(error);
|
|
903
1013
|
}
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
}
|
|
909
|
-
|
|
910
|
-
return
|
|
911
|
-
}
|
|
912
|
-
console.log(`aimhooman: initialised (profile: ${profile})`);
|
|
913
|
-
console.log(` state: ${repo.stateDir}`);
|
|
914
|
-
console.log(` excludes: ${repo.excludeFile}`);
|
|
915
|
-
if (rep.installed.length) console.log(` hooks: ${rep.installed.join(', ')}`);
|
|
916
|
-
if (rep.chained.length) console.log(` chained: ${rep.chained.join(', ')} (existing hooks preserved)`);
|
|
917
|
-
for (const warning of rep.warnings) console.log(` warning: ${warning}`);
|
|
918
|
-
return 0;
|
|
1014
|
+
console.log(`aimhooman: initialised (profile: ${profile})`);
|
|
1015
|
+
console.log(` state: ${repo.stateDir}`);
|
|
1016
|
+
console.log(` excludes: ${repo.excludeFile}`);
|
|
1017
|
+
if (rep.installed.length) console.log(` hooks: ${rep.installed.join(', ')}`);
|
|
1018
|
+
if (rep.chained.length) console.log(` chained: ${rep.chained.join(', ')} (existing hooks preserved)`);
|
|
1019
|
+
for (const warning of rep.warnings) console.log(` warning: ${warning}`);
|
|
1020
|
+
return 0;
|
|
919
1021
|
}, LIFECYCLE_LOCK_OPTIONS);
|
|
920
1022
|
}
|
|
921
1023
|
|
|
@@ -927,9 +1029,17 @@ function cmdStatus(args) {
|
|
|
927
1029
|
return 30;
|
|
928
1030
|
}
|
|
929
1031
|
let policy;
|
|
1032
|
+
let stagedPolicy;
|
|
930
1033
|
let overrides;
|
|
931
1034
|
try {
|
|
1035
|
+
// `policy` describes the worktree file as written; `stagedPolicy` is the
|
|
1036
|
+
// one the enforcing hooks actually read (they resolve from the index, so
|
|
1037
|
+
// a worktree profile that has not been staged is invisible to them).
|
|
1038
|
+
// Reporting the worktree value alone used to advertise a profile the
|
|
1039
|
+
// guard was not applying, so the two are compared below and the
|
|
1040
|
+
// mismatch is named when they disagree.
|
|
932
1041
|
policy = resolvePolicy(repo, { target: 'worktree' });
|
|
1042
|
+
stagedPolicy = resolvePolicy(repo, { target: 'staged' });
|
|
933
1043
|
overrides = loadOverrides(repo.stateDir);
|
|
934
1044
|
} catch (e) {
|
|
935
1045
|
console.error(`aimhooman: cannot load enforcement state: ${e.message}`);
|
|
@@ -952,9 +1062,25 @@ function cmdStatus(args) {
|
|
|
952
1062
|
excludeError = e.message;
|
|
953
1063
|
excludes = { current: false, installed: false };
|
|
954
1064
|
}
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
1065
|
+
// The pre-commit and reference-transaction guards resolve the policy from
|
|
1066
|
+
// the index, so the staged profile is what is actually enforced. Report it
|
|
1067
|
+
// first; the worktree file is shown alongside so an edit that has not been
|
|
1068
|
+
// staged (or a file that was never `git add`ed) cannot masquerade as active.
|
|
1069
|
+
const policyLabelOf = (resolved) => (
|
|
1070
|
+
resolved.source === 'worktree-policy'
|
|
1071
|
+
|| resolved.source === 'staged-policy'
|
|
1072
|
+
|| resolved.source === 'commit-policy'
|
|
1073
|
+
) ? 'project' : resolved.source;
|
|
1074
|
+
const enforced = stagedPolicy.profile;
|
|
1075
|
+
const enforcedLabel = policyLabelOf(stagedPolicy);
|
|
1076
|
+
const worktreeLabel = policyLabelOf(policy);
|
|
1077
|
+
const policyDrift = policy.profile !== stagedPolicy.profile
|
|
1078
|
+
|| policy.policy_object_id !== stagedPolicy.policy_object_id;
|
|
1079
|
+
console.log(`profile: ${enforced}${policyDrift ? ` (worktree: ${policy.profile})` : ''}`);
|
|
1080
|
+
console.log(`policy: ${enforcedLabel} (${stagedPolicy.source}, object=${stagedPolicy.policy_object_id || 'none'})${policyDrift ? `; worktree ${worktreeLabel} (${policy.source}, object=${policy.policy_object_id || 'none'})` : ''}`);
|
|
1081
|
+
if (policyDrift) {
|
|
1082
|
+
console.log(`warning: worktree .aimhooman.json is not staged, so the hooks enforce the staged profile (${enforced}); run \`git add .aimhooman.json\` to apply ${policy.profile}`);
|
|
1083
|
+
}
|
|
958
1084
|
console.log(`hooks: ${hooksComplete ? installed.join(', ') : installed.length ? `${installed.join(', ')} (incomplete; run: aimhooman init)` : 'not installed (run: aimhooman init)'}`);
|
|
959
1085
|
for (const hook of hooks) {
|
|
960
1086
|
console.log(`hook ${hook.name}: ${hook.managed ? 'managed' : 'not managed'}, ${hook.executable ? 'executable' : 'not executable'}, ${hook.reachable ? 'reachable' : hook.reason}`);
|
|
@@ -1594,41 +1720,41 @@ function cmdUninstall(args) {
|
|
|
1594
1720
|
});
|
|
1595
1721
|
if (options.global) {
|
|
1596
1722
|
return withLock(join(globalHooksDir(), 'lifecycle.lock'), () => {
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1723
|
+
const aimDir = globalHooksDir();
|
|
1724
|
+
const readGlobalHooksPath = () => {
|
|
1725
|
+
try {
|
|
1726
|
+
return execFileSync('git', ['config', '--global', '--get', 'core.hooksPath'], { encoding: 'utf8', timeout: GIT_TIMEOUT_MS }).trim();
|
|
1727
|
+
} catch { return ''; }
|
|
1728
|
+
};
|
|
1729
|
+
const current = readGlobalHooksPath();
|
|
1730
|
+
let unsetAttempted = false;
|
|
1731
|
+
if (current === aimDir) {
|
|
1732
|
+
unsetAttempted = true;
|
|
1733
|
+
try {
|
|
1734
|
+
execFileSync('git', ['config', '--global', '--unset', 'core.hooksPath'], { stdio: ['ignore', 'ignore', 'ignore'], timeout: GIT_TIMEOUT_MS });
|
|
1735
|
+
} catch { /* may have failed; re-verify below */ }
|
|
1736
|
+
// A read-only or locked ~/.gitconfig can make --unset fail silently.
|
|
1737
|
+
// Do not remove the dispatchers while core.hooksPath still resolves to
|
|
1738
|
+
// them, or every repository inheriting this setting would run zero
|
|
1739
|
+
// hooks with no warning. Preserve the working dispatchers until the
|
|
1740
|
+
// config can be cleaned.
|
|
1741
|
+
if (readGlobalHooksPath() === aimDir) {
|
|
1742
|
+
console.error('aimhooman: could not unset global core.hooksPath (read-only or locked ~/.gitconfig); leaving dispatchers in place.');
|
|
1743
|
+
console.error(' Fix the config permissions and run `aimhooman uninstall --global` again.');
|
|
1744
|
+
return 30;
|
|
1745
|
+
}
|
|
1619
1746
|
}
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
return 0;
|
|
1747
|
+
const rep = uninstallGlobalHooks();
|
|
1748
|
+
console.log('aimhooman: global hooks uninstalled');
|
|
1749
|
+
if (current && current !== aimDir) {
|
|
1750
|
+
console.log(` core.hooksPath kept at "${current}" (not owned by aimhooman)`);
|
|
1751
|
+
} else {
|
|
1752
|
+
console.log(` core.hooksPath ${unsetAttempted ? 'unset' : 'was already unset'}`);
|
|
1753
|
+
}
|
|
1754
|
+
if (rep.removed.length) console.log(` dispatchers removed: ${rep.removed.join(', ')}`);
|
|
1755
|
+
for (const w of rep.warnings || []) console.log(` warning: ${w}`);
|
|
1756
|
+
console.log(` dir kept at ${rep.dir}`);
|
|
1757
|
+
return 0;
|
|
1632
1758
|
}, LIFECYCLE_LOCK_OPTIONS);
|
|
1633
1759
|
}
|
|
1634
1760
|
const purge = Boolean(options.purgeState);
|
|
@@ -1639,72 +1765,72 @@ function cmdUninstall(args) {
|
|
|
1639
1765
|
}
|
|
1640
1766
|
const lifecycleLock = join(repo.commonDir, 'aimhooman-lifecycle.lock');
|
|
1641
1767
|
const exitStatus = withLock(lifecycleLock, () => {
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
} else {
|
|
1663
|
-
console.log('aimhooman: uninstalled');
|
|
1664
|
-
}
|
|
1665
|
-
if (rep.removed.length) console.log(` hooks removed: ${rep.removed.join(', ')}`);
|
|
1666
|
-
if (rep.restored.length) console.log(` hooks restored: ${rep.restored.join(', ')}`);
|
|
1667
|
-
for (const w of rep.warnings || []) console.log(` warning: ${w}`);
|
|
1668
|
-
for (const f of rep.failures || []) console.error(` failure: ${f}`);
|
|
1669
|
-
if (excludeFailure) console.error(` failure: ${excludeFailure}`);
|
|
1670
|
-
const unrestored = purge ? unrestoredChainedBackups(repo) : [];
|
|
1671
|
-
if (purge) {
|
|
1672
|
-
// Never wipe stateDir while a predecessor hook backup is still on disk:
|
|
1673
|
-
// a per-hook restore failure leaves the user's original hook existing
|
|
1674
|
-
// only in <stateDir>/chained, so purging would destroy it irrecoverably.
|
|
1675
|
-
if (unrestored.length || rep.failures?.length) {
|
|
1676
|
-
console.error(
|
|
1677
|
-
'aimhooman: state NOT purged — '
|
|
1678
|
-
+ `${unrestored.length} chained hook backup(s) remain unrestored`
|
|
1679
|
-
+ `${rep.failures?.length ? ` (failures: ${rep.failures.join('; ')})` : ''}; `
|
|
1680
|
-
+ "re-run 'aimhooman uninstall' to retry restore before purging."
|
|
1681
|
-
);
|
|
1768
|
+
const rep = uninstallHooks(repo);
|
|
1769
|
+
// The irreversible work is already done above, and the report is still
|
|
1770
|
+
// below. A throw from here unwound past all of it, so a damaged marker was
|
|
1771
|
+
// the only thing the user heard while four dispatchers kept guarding every
|
|
1772
|
+
// commit. Report the failure beside the removal report instead of in place
|
|
1773
|
+
// of it. Nothing is swallowed: this is also where the symlink and permission
|
|
1774
|
+
// guards surface, and their messages still reach the user and still exit 30.
|
|
1775
|
+
let excludeFailure = '';
|
|
1776
|
+
try {
|
|
1777
|
+
removeExclude(repo.excludeFile);
|
|
1778
|
+
} catch (error) {
|
|
1779
|
+
excludeFailure = `exclude block left in ${repo.excludeFile}: ${error.message}`;
|
|
1780
|
+
}
|
|
1781
|
+
// Trust the directory, not the report. Every refusal below leaves a working
|
|
1782
|
+
// dispatcher behind, and one printed under "uninstalled" reads as done.
|
|
1783
|
+
const remaining = remainingDispatchers(repo);
|
|
1784
|
+
if (remaining.length) {
|
|
1785
|
+
console.error('aimhooman: NOT uninstalled; leaving dispatchers in place:');
|
|
1786
|
+
for (const path of remaining) console.error(` ${path}`);
|
|
1787
|
+
console.error(' These still guard every commit. Remove them by hand to finish uninstalling.');
|
|
1682
1788
|
} else {
|
|
1683
|
-
|
|
1684
|
-
console.log(' state purged');
|
|
1789
|
+
console.log('aimhooman: uninstalled');
|
|
1685
1790
|
}
|
|
1686
|
-
|
|
1687
|
-
console.log(`
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1791
|
+
if (rep.removed.length) console.log(` hooks removed: ${rep.removed.join(', ')}`);
|
|
1792
|
+
if (rep.restored.length) console.log(` hooks restored: ${rep.restored.join(', ')}`);
|
|
1793
|
+
for (const w of rep.warnings || []) console.log(` warning: ${w}`);
|
|
1794
|
+
for (const f of rep.failures || []) console.error(` failure: ${f}`);
|
|
1795
|
+
if (excludeFailure) console.error(` failure: ${excludeFailure}`);
|
|
1796
|
+
const unrestored = purge ? unrestoredChainedBackups(repo) : [];
|
|
1797
|
+
if (purge) {
|
|
1798
|
+
// Never wipe stateDir while a predecessor hook backup is still on disk:
|
|
1799
|
+
// a per-hook restore failure leaves the user's original hook existing
|
|
1800
|
+
// only in <stateDir>/chained, so purging would destroy it irrecoverably.
|
|
1801
|
+
if (unrestored.length || rep.failures?.length) {
|
|
1802
|
+
console.error(
|
|
1803
|
+
'aimhooman: state NOT purged — '
|
|
1804
|
+
+ `${unrestored.length} chained hook backup(s) remain unrestored`
|
|
1805
|
+
+ `${rep.failures?.length ? ` (failures: ${rep.failures.join('; ')})` : ''}; `
|
|
1806
|
+
+ "re-run 'aimhooman uninstall' to retry restore before purging."
|
|
1807
|
+
);
|
|
1808
|
+
} else {
|
|
1809
|
+
rmSync(repo.stateDir, { recursive: true, force: true });
|
|
1810
|
+
console.log(' state purged');
|
|
1811
|
+
}
|
|
1812
|
+
} else {
|
|
1813
|
+
console.log(` state kept at ${repo.stateDir} (use --purge-state to remove)`);
|
|
1814
|
+
}
|
|
1815
|
+
// Surface when a global core.hooksPath still enforces aimhooman, so a local
|
|
1816
|
+
// uninstall cannot be mistaken for full removal. Foreign hooksPath values
|
|
1817
|
+
// are left to the generic "not modified" warning emitted above.
|
|
1818
|
+
const aimDir = globalHooksDir();
|
|
1819
|
+
let globalHooksPath = '';
|
|
1820
|
+
try {
|
|
1821
|
+
globalHooksPath = execFileSync('git', ['config', '--global', '--get', 'core.hooksPath'], {
|
|
1822
|
+
encoding: 'utf8',
|
|
1823
|
+
timeout: GIT_TIMEOUT_MS,
|
|
1824
|
+
}).trim();
|
|
1825
|
+
} catch { /* unset */ }
|
|
1826
|
+
if (globalHooksPath === aimDir) {
|
|
1827
|
+
console.log('aimhooman: global Git guard is still active');
|
|
1828
|
+
console.log(' eligible non-bare repositories that inherit core.hooksPath are still guarded.');
|
|
1829
|
+
console.log(' run `aimhooman uninstall --global` to remove it.');
|
|
1830
|
+
}
|
|
1831
|
+
// The managed block is still in the exclude file and still ignoring paths, so
|
|
1832
|
+
// the uninstall is genuinely incomplete and 30 is the honest answer.
|
|
1833
|
+
return remaining.length || rep.failures?.length || unrestored.length || excludeFailure ? 30 : 0;
|
|
1708
1834
|
}, LIFECYCLE_LOCK_OPTIONS);
|
|
1709
1835
|
// Sweep the operational residue the guard authored in .git, so uninstall leaves
|
|
1710
1836
|
// no aimhooman fingerprints behind — the same tooling residue this tool exists
|