@rmyndharis/aimhooman 0.1.7 → 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/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, rmSync } from 'node:fs';
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
- tree: options.tree ? scanGitTarget(repo, { kind: 'staged', limits }) : null,
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;
@@ -777,71 +851,71 @@ function cmdInit(args) {
777
851
  return 20;
778
852
  }
779
853
  return withLock(join(globalHooksDir(), 'lifecycle.lock'), () => {
780
- const aimDir = globalHooksDir();
781
- let existing = '';
782
- try {
783
- existing = execFileSync('git', ['config', '--global', '--get', 'core.hooksPath'], { encoding: 'utf8', timeout: GIT_TIMEOUT_MS }).trim();
784
- } catch { /* unset */ }
785
- if (existing && existing !== aimDir) {
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
- );
790
- return 20;
791
- }
792
- // A --system hooksPath would be shadowed by the --global write below and
793
- // could silently disable a system-wide hook manager, so refuse here too.
794
- let systemHooksPath = '';
795
- try {
796
- systemHooksPath = execFileSync('git', ['config', '--system', '--get', 'core.hooksPath'], { encoding: 'utf8', timeout: GIT_TIMEOUT_MS }).trim();
797
- } catch { /* unset or no system config */ }
798
- if (systemHooksPath) {
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
- );
803
- return 20;
804
- }
805
- const localRepo = tryRepo();
806
- const localHooksPath = localRepo ? gitConfigAtScope(localRepo.root, '--local', 'core.hooksPath') : '';
807
- if (localHooksPath) {
808
- console.error(`aimhooman: warning: this repository has local core.hooksPath="${localHooksPath}", which overrides the global guard here`);
809
- }
810
- const hookSnapshots = REQUIRED_GIT_HOOKS
811
- .map((name) => snapshotFile(join(aimDir, name)));
812
- let rep;
813
- try {
814
- rep = installGlobalHooks(CLI_PATH);
815
- if (rep.skipped?.length) {
816
- for (const warning of rep.warnings || []) console.error(`aimhooman: ${warning}`);
817
- 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
+ );
818
864
  return 20;
819
865
  }
820
- execFileSync('git', ['config', '--global', 'core.hooksPath', rep.dir], { timeout: GIT_TIMEOUT_MS });
821
- } catch (error) {
822
- const rollbackFailures = [];
823
- for (const snapshot of hookSnapshots.reverse()) {
824
- try { restoreSnapshot(snapshot); }
825
- catch (rollbackError) {
826
- rollbackFailures.push(`${snapshot.path}: ${rollbackError.message}`);
827
- }
828
- }
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 = '';
829
869
  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}`);
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;
834
878
  }
835
- console.error(`aimhooman: global hook installation failed: ${error.message}`);
836
- if (rollbackFailures.length) {
837
- console.error(`aimhooman: rollback incomplete: ${rollbackFailures.join('; ')}`);
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`);
838
883
  }
839
- return 30;
840
- }
841
- console.log(`aimhooman: global hooks at ${rep.dir} (core.hooksPath set)`);
842
- for (const w of rep.warnings || []) console.log(` warning: ${w}`);
843
- 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.');
844
- return 0;
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;
845
919
  }, LIFECYCLE_LOCK_OPTIONS);
846
920
  }
847
921
  if (options.yes) throw new ArgumentError('--yes is only valid with --global');
@@ -853,89 +927,97 @@ function cmdInit(args) {
853
927
  return 30;
854
928
  }
855
929
  return withLock(join(repo.commonDir, 'aimhooman-lifecycle.lock'), () => {
856
- let projectPolicy;
857
- try {
858
- projectPolicy = loadProjectPolicy(repo.root);
859
- } catch (e) {
860
- console.error(`aimhooman: cannot load project policy: ${e.message}`);
861
- return 20;
862
- }
863
- if (projectPolicy) {
864
- if (profileExplicit && profile !== projectPolicy.profile) {
865
- console.error(
866
- `aimhooman: project policy requires profile "${projectPolicy.profile}"; ` +
867
- 'edit .aimhooman.json to change the team baseline'
868
- );
930
+ let projectPolicy;
931
+ try {
932
+ projectPolicy = loadProjectPolicy(repo.root);
933
+ } catch (e) {
934
+ console.error(`aimhooman: cannot load project policy: ${e.message}`);
869
935
  return 20;
870
936
  }
871
- profile = projectPolicy.profile;
872
- }
873
- let eng;
874
- try {
875
- eng = configuredEngine(profile, repo);
876
- } catch (e) {
877
- console.error(`aimhooman: cannot initialise policy: ${e.message}`);
878
- return 20;
879
- }
880
- const hookState = hookDiagnostics(repo);
881
- const hookFiles = hookState.some((hook) => hook.shared)
882
- ? []
883
- : [...new Set(hookState.flatMap((hook) => [hook.path, hook.chainedPath]))];
884
- let snapshots = [];
885
- let rep;
886
- try {
887
- snapshots = [join(repo.stateDir, 'config.json'), repo.excludeFile, ...hookFiles]
888
- .map(snapshotFile);
889
- rep = installHooks(repo, CLI_PATH);
890
- const activeHooks = installedHooks(repo);
891
- if (!REQUIRED_GIT_HOOKS.every((name) => activeHooks.includes(name))) {
892
- // installHooks declines rather than throws when the hooks directory is
893
- // not ours, and its warnings are the only record of why. They are
894
- // printed on the success path only, so carry them into the failure or
895
- // the user is told nothing but "incomplete". The prefix is load-bearing:
896
- // the exit-code branch below matches on it.
897
- const cause = rep.shared && rep.warnings.length ? `${rep.warnings.join('; ')}; ` : '';
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');
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;
906
946
  }
907
- } catch (error) {
908
- const rollbackFailures = [];
947
+ let eng;
909
948
  try {
910
- const uninstalled = uninstallHooks(repo);
911
- rollbackFailures.push(...(uninstalled.failures || []));
912
- } catch (rollbackError) {
913
- rollbackFailures.push(`hook uninstall: ${rollbackError.message}`);
949
+ eng = configuredEngine(profile, repo);
950
+ } catch (e) {
951
+ console.error(`aimhooman: cannot initialise policy: ${e.message}`);
952
+ return 20;
914
953
  }
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}`);
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}`);
922
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);
923
1013
  }
924
- if (rollbackFailures.length) {
925
- console.error(`aimhooman: initialisation failed: ${error.message}`);
926
- console.error(`aimhooman: rollback incomplete: ${rollbackFailures.join('; ')}`);
927
- return 30;
928
- }
929
- console.error(`aimhooman: initialisation failed and prior files were restored: ${error.message}`);
930
- return /hook installation incomplete/.test(error.message) ? 20 : expectedErrorCode(error);
931
- }
932
- console.log(`aimhooman: initialised (profile: ${profile})`);
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;
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;
939
1021
  }, LIFECYCLE_LOCK_OPTIONS);
940
1022
  }
941
1023
 
@@ -1638,41 +1720,41 @@ function cmdUninstall(args) {
1638
1720
  });
1639
1721
  if (options.global) {
1640
1722
  return withLock(join(globalHooksDir(), 'lifecycle.lock'), () => {
1641
- const aimDir = globalHooksDir();
1642
- const readGlobalHooksPath = () => {
1643
- try {
1644
- return execFileSync('git', ['config', '--global', '--get', 'core.hooksPath'], { encoding: 'utf8', timeout: GIT_TIMEOUT_MS }).trim();
1645
- } catch { return ''; }
1646
- };
1647
- const current = readGlobalHooksPath();
1648
- let unsetAttempted = false;
1649
- if (current === aimDir) {
1650
- unsetAttempted = true;
1651
- try {
1652
- execFileSync('git', ['config', '--global', '--unset', 'core.hooksPath'], { stdio: ['ignore', 'ignore', 'ignore'], timeout: GIT_TIMEOUT_MS });
1653
- } catch { /* may have failed; re-verify below */ }
1654
- // A read-only or locked ~/.gitconfig can make --unset fail silently.
1655
- // Do not remove the dispatchers while core.hooksPath still resolves to
1656
- // them, or every repository inheriting this setting would run zero
1657
- // hooks with no warning. Preserve the working dispatchers until the
1658
- // config can be cleaned.
1659
- if (readGlobalHooksPath() === aimDir) {
1660
- console.error('aimhooman: could not unset global core.hooksPath (read-only or locked ~/.gitconfig); leaving dispatchers in place.');
1661
- console.error(' Fix the config permissions and run `aimhooman uninstall --global` again.');
1662
- return 30;
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
+ }
1663
1746
  }
1664
- }
1665
- const rep = uninstallGlobalHooks();
1666
- console.log('aimhooman: global hooks uninstalled');
1667
- if (current && current !== aimDir) {
1668
- console.log(` core.hooksPath kept at "${current}" (not owned by aimhooman)`);
1669
- } else {
1670
- console.log(` core.hooksPath ${unsetAttempted ? 'unset' : 'was already unset'}`);
1671
- }
1672
- if (rep.removed.length) console.log(` dispatchers removed: ${rep.removed.join(', ')}`);
1673
- for (const w of rep.warnings || []) console.log(` warning: ${w}`);
1674
- console.log(` dir kept at ${rep.dir}`);
1675
- 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;
1676
1758
  }, LIFECYCLE_LOCK_OPTIONS);
1677
1759
  }
1678
1760
  const purge = Boolean(options.purgeState);
@@ -1683,72 +1765,72 @@ function cmdUninstall(args) {
1683
1765
  }
1684
1766
  const lifecycleLock = join(repo.commonDir, 'aimhooman-lifecycle.lock');
1685
1767
  const exitStatus = withLock(lifecycleLock, () => {
1686
- const rep = uninstallHooks(repo);
1687
- // The irreversible work is already done above, and the report is still
1688
- // below. A throw from here unwound past all of it, so a damaged marker was
1689
- // the only thing the user heard while four dispatchers kept guarding every
1690
- // commit. Report the failure beside the removal report instead of in place
1691
- // of it. Nothing is swallowed: this is also where the symlink and permission
1692
- // guards surface, and their messages still reach the user and still exit 30.
1693
- let excludeFailure = '';
1694
- try {
1695
- removeExclude(repo.excludeFile);
1696
- } catch (error) {
1697
- excludeFailure = `exclude block left in ${repo.excludeFile}: ${error.message}`;
1698
- }
1699
- // Trust the directory, not the report. Every refusal below leaves a working
1700
- // dispatcher behind, and one printed under "uninstalled" reads as done.
1701
- const remaining = remainingDispatchers(repo);
1702
- if (remaining.length) {
1703
- console.error('aimhooman: NOT uninstalled; leaving dispatchers in place:');
1704
- for (const path of remaining) console.error(` ${path}`);
1705
- console.error(' These still guard every commit. Remove them by hand to finish uninstalling.');
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
- );
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.');
1726
1788
  } else {
1727
- rmSync(repo.stateDir, { recursive: true, force: true });
1728
- console.log(' state purged');
1789
+ console.log('aimhooman: uninstalled');
1729
1790
  }
1730
- } else {
1731
- console.log(` state kept at ${repo.stateDir} (use --purge-state to remove)`);
1732
- }
1733
- // Surface when a global core.hooksPath still enforces aimhooman, so a local
1734
- // uninstall cannot be mistaken for full removal. Foreign hooksPath values
1735
- // are left to the generic "not modified" warning emitted above.
1736
- const aimDir = globalHooksDir();
1737
- let globalHooksPath = '';
1738
- try {
1739
- globalHooksPath = execFileSync('git', ['config', '--global', '--get', 'core.hooksPath'], {
1740
- encoding: 'utf8',
1741
- timeout: GIT_TIMEOUT_MS,
1742
- }).trim();
1743
- } catch { /* unset */ }
1744
- if (globalHooksPath === aimDir) {
1745
- console.log('aimhooman: global Git guard is still active');
1746
- console.log(' eligible non-bare repositories that inherit core.hooksPath are still guarded.');
1747
- console.log(' run `aimhooman uninstall --global` to remove it.');
1748
- }
1749
- // The managed block is still in the exclude file and still ignoring paths, so
1750
- // the uninstall is genuinely incomplete and 30 is the honest answer.
1751
- return remaining.length || rep.failures?.length || unrestored.length || excludeFailure ? 30 : 0;
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;
1752
1834
  }, LIFECYCLE_LOCK_OPTIONS);
1753
1835
  // Sweep the operational residue the guard authored in .git, so uninstall leaves
1754
1836
  // no aimhooman fingerprints behind — the same tooling residue this tool exists