@rmyndharis/aimhooman 0.1.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/.agents/rules/aimhooman.md +53 -0
- package/.claude-plugin/marketplace.json +23 -0
- package/.claude-plugin/plugin.json +9 -0
- package/.clinerules/aimhooman.md +53 -0
- package/.codex-plugin/plugin.json +37 -0
- package/.cursor/rules/aimhooman.mdc +59 -0
- package/.gemini/settings.json +7 -0
- package/.github/copilot-instructions.md +53 -0
- package/.github/hooks/aimhooman.json +22 -0
- package/.kiro/steering/aimhooman.md +53 -0
- package/.windsurf/rules/aimhooman.md +57 -0
- package/AGENTS.md +53 -0
- package/CHANGELOG.md +153 -0
- package/CODE_OF_CONDUCT.md +128 -0
- package/CONTRIBUTING.md +144 -0
- package/GEMINI.md +53 -0
- package/LICENSE +21 -0
- package/README.md +472 -0
- package/SECURITY.md +74 -0
- package/bin/aimhooman.mjs +1589 -0
- package/docs/design/agent-portability.md +73 -0
- package/docs/design/frictionless-enforcement.md +135 -0
- package/docs/hosts.json +164 -0
- package/docs/logo/aimhooman-logo.png +0 -0
- package/docs/logo/aimhooman.png +0 -0
- package/hooks/hooks.json +29 -0
- package/package.json +77 -0
- package/rules/attribution.json +142 -0
- package/rules/markers.json +88 -0
- package/rules/paths.json +407 -0
- package/rules/secrets.json +90 -0
- package/schemas/overrides.schema.json +134 -0
- package/schemas/project-policy.schema.json +12 -0
- package/schemas/rule-pack.schema.json +126 -0
- package/schemas/scan-report.schema.json +165 -0
- package/skills/aimhooman/SKILL.md +65 -0
- package/src/args.mjs +72 -0
- package/src/atomic-write.mjs +285 -0
- package/src/codex-manifest.mjs +65 -0
- package/src/exclude.mjs +125 -0
- package/src/git-environment.mjs +5 -0
- package/src/git-path.mjs +5 -0
- package/src/githooks.mjs +813 -0
- package/src/gitx.mjs +721 -0
- package/src/history-scan.mjs +295 -0
- package/src/hook.mjs +2602 -0
- package/src/policy-resolver.mjs +200 -0
- package/src/report.mjs +63 -0
- package/src/rules.mjs +509 -0
- package/src/ruleset-text.mjs +29 -0
- package/src/scan-session.mjs +152 -0
- package/src/scan-target.mjs +697 -0
- package/src/scan.mjs +325 -0
- package/src/state.mjs +360 -0
package/src/githooks.mjs
ADDED
|
@@ -0,0 +1,813 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import {
|
|
4
|
+
accessSync,
|
|
5
|
+
chmodSync,
|
|
6
|
+
constants,
|
|
7
|
+
lstatSync,
|
|
8
|
+
mkdirSync,
|
|
9
|
+
readdirSync,
|
|
10
|
+
readFileSync,
|
|
11
|
+
realpathSync,
|
|
12
|
+
statSync,
|
|
13
|
+
unlinkSync,
|
|
14
|
+
} from 'node:fs';
|
|
15
|
+
import { homedir } from 'node:os';
|
|
16
|
+
import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
17
|
+
import { gitConfig } from './gitx.mjs';
|
|
18
|
+
import { atomicWrite, withLock } from './atomic-write.mjs';
|
|
19
|
+
|
|
20
|
+
const MARKER = '# aimhooman-managed hook';
|
|
21
|
+
const HOOK_FORMAT_VERSION = 2;
|
|
22
|
+
const FINGERPRINT_PLACEHOLDER = '0'.repeat(64);
|
|
23
|
+
const MANAGED = {
|
|
24
|
+
'pre-commit': 'precommit',
|
|
25
|
+
'pre-merge-commit': 'precommit',
|
|
26
|
+
'commit-msg': 'commitmsg "$1"',
|
|
27
|
+
'reference-transaction': 'refcheck "$1"',
|
|
28
|
+
};
|
|
29
|
+
const LEGACY_MANAGED = {
|
|
30
|
+
'pre-commit': 'precommit',
|
|
31
|
+
'commit-msg': 'commitmsg "$1"',
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function hooksDir(repo) {
|
|
35
|
+
const hp = gitConfig(repo.root, 'core.hooksPath');
|
|
36
|
+
const dir = effectiveHooksDir(repo);
|
|
37
|
+
if (!hp) return { dir, shared: false, warnings: [] };
|
|
38
|
+
|
|
39
|
+
const scope = configScope(repo, 'core.hooksPath');
|
|
40
|
+
const localScope = ['local', 'worktree'].includes(scope);
|
|
41
|
+
const repositoryOwned = localScope && repositoryOwnsPath(repo, dir);
|
|
42
|
+
const shared = !repositoryOwned || resolve(dir) === resolve(globalHooksDir());
|
|
43
|
+
if (shared) {
|
|
44
|
+
const where = scope ? `${scope} scope` : 'a non-local scope';
|
|
45
|
+
const reason = localScope && !repositoryOwned
|
|
46
|
+
? `${where}, outside this repository`
|
|
47
|
+
: where;
|
|
48
|
+
return {
|
|
49
|
+
dir,
|
|
50
|
+
shared: true,
|
|
51
|
+
warnings: [
|
|
52
|
+
`core.hooksPath is set to "${hp}" at ${reason}; local hooks were not modified`,
|
|
53
|
+
],
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
dir,
|
|
58
|
+
shared: false,
|
|
59
|
+
warnings: [`core.hooksPath is set to "${hp}"; hooks installed there`],
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function repositoryOwnsPath(repo, path) {
|
|
64
|
+
const candidate = canonicalPath(path);
|
|
65
|
+
return [repo.root, repo.commonDir, repo.gitDir]
|
|
66
|
+
.filter(Boolean)
|
|
67
|
+
.map(canonicalPath)
|
|
68
|
+
.some((root) => {
|
|
69
|
+
const rel = relative(root, candidate);
|
|
70
|
+
return rel === '' || (!rel.startsWith(`..${sep}`)
|
|
71
|
+
&& rel !== '..' && !isAbsolute(rel));
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function canonicalPath(path) {
|
|
76
|
+
let current = resolve(path);
|
|
77
|
+
const tail = [];
|
|
78
|
+
for (;;) {
|
|
79
|
+
try {
|
|
80
|
+
return resolve(realpathSync(current), ...tail);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
83
|
+
const parent = dirname(current);
|
|
84
|
+
if (parent === current) return resolve(path);
|
|
85
|
+
tail.unshift(basename(current));
|
|
86
|
+
current = parent;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Ask Git for the path it actually uses. In a linked worktree this resolves to
|
|
92
|
+
// the common repository hooks directory, not <gitDir>/hooks.
|
|
93
|
+
function effectiveHooksDir(repo) {
|
|
94
|
+
try {
|
|
95
|
+
return execFileSync(
|
|
96
|
+
'git',
|
|
97
|
+
['rev-parse', '--path-format=absolute', '--git-path', 'hooks'],
|
|
98
|
+
{ cwd: repo.root, encoding: 'utf8' }
|
|
99
|
+
).trim();
|
|
100
|
+
} catch {
|
|
101
|
+
try {
|
|
102
|
+
const path = execFileSync('git', ['rev-parse', '--git-path', 'hooks'], {
|
|
103
|
+
cwd: repo.root,
|
|
104
|
+
encoding: 'utf8',
|
|
105
|
+
}).trim();
|
|
106
|
+
return isAbsolute(path) ? path : resolve(repo.root, path);
|
|
107
|
+
} catch {
|
|
108
|
+
return join(repo.commonDir || repo.gitDir, 'hooks');
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function configScope(repo, key) {
|
|
114
|
+
try {
|
|
115
|
+
const line = execFileSync('git', ['config', '--show-scope', '--get', key], {
|
|
116
|
+
cwd: repo.root,
|
|
117
|
+
encoding: 'utf8',
|
|
118
|
+
}).trim();
|
|
119
|
+
return line.split(/\s/, 1)[0];
|
|
120
|
+
} catch {
|
|
121
|
+
// Older Git versions do not support --show-scope. Only classify a
|
|
122
|
+
// value as local when we can prove it; the conservative fallback keeps
|
|
123
|
+
// a global dispatcher from being overwritten.
|
|
124
|
+
try {
|
|
125
|
+
execFileSync('git', ['config', '--local', '--get', key], {
|
|
126
|
+
cwd: repo.root,
|
|
127
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
128
|
+
});
|
|
129
|
+
return 'local';
|
|
130
|
+
} catch {
|
|
131
|
+
return '';
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function entry(path) {
|
|
137
|
+
try {
|
|
138
|
+
return lstatSync(path);
|
|
139
|
+
} catch (error) {
|
|
140
|
+
if (error?.code === 'ENOENT') return null;
|
|
141
|
+
throw error;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function snapshotFile(path) {
|
|
146
|
+
const stat = entry(path);
|
|
147
|
+
if (!stat) return { path, existed: false };
|
|
148
|
+
if (!stat.isFile()) throw new Error(`cannot snapshot non-file hook path "${path}"`);
|
|
149
|
+
return {
|
|
150
|
+
path,
|
|
151
|
+
existed: true,
|
|
152
|
+
content: readFileSync(path),
|
|
153
|
+
mode: stat.mode & 0o777,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function rollbackFiles(snapshots, originalError) {
|
|
158
|
+
const failures = [];
|
|
159
|
+
for (const snapshot of [...snapshots].reverse()) {
|
|
160
|
+
try {
|
|
161
|
+
const current = entry(snapshot.path);
|
|
162
|
+
if (current?.isSymbolicLink()) {
|
|
163
|
+
throw new Error('path became a symlink during installation');
|
|
164
|
+
}
|
|
165
|
+
if (snapshot.existed) {
|
|
166
|
+
atomicWrite(snapshot.path, snapshot.content, { mode: snapshot.mode });
|
|
167
|
+
chmodSync(snapshot.path, snapshot.mode);
|
|
168
|
+
} else if (current) {
|
|
169
|
+
if (!current.isFile()) throw new Error('path became a non-file during installation');
|
|
170
|
+
unlinkSync(snapshot.path);
|
|
171
|
+
}
|
|
172
|
+
} catch (error) {
|
|
173
|
+
failures.push(`${snapshot.path}: ${error.message}`);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
if (failures.length) {
|
|
177
|
+
throw new Error(
|
|
178
|
+
`${originalError.message}; hook rollback also failed: ${failures.join('; ')}`,
|
|
179
|
+
{ cause: originalError },
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
throw originalError;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function symlinkWarning(name) {
|
|
186
|
+
return `${name} is a symlink; refusing to follow or overwrite it`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function chainDir(repo) {
|
|
190
|
+
// Chained predecessors live in common repository state (shared by linked
|
|
191
|
+
// worktrees). repo.stateDir is always join(commonDir, 'aimhooman'), so this
|
|
192
|
+
// resolves to <commonDir>/aimhooman/chained regardless of the hooks dir.
|
|
193
|
+
// Global hooks (installGlobalHooks) keep their chained backups in the hooks
|
|
194
|
+
// dir itself; hookDiagnostics special-cases that path.
|
|
195
|
+
return join(repo.stateDir, 'chained');
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// unrestoredChainedBackups lists predecessor hook backups still on disk after
|
|
199
|
+
// an uninstall attempt. A non-empty result means restore did not finish for
|
|
200
|
+
// those hooks (a per-hook failure left the user's original hook existing only
|
|
201
|
+
// in the backup), so the caller must NOT purge stateDir or the originals would
|
|
202
|
+
// be destroyed. Only managed hook names count; other files are ignored.
|
|
203
|
+
export function unrestoredChainedBackups(repo) {
|
|
204
|
+
let entries;
|
|
205
|
+
try {
|
|
206
|
+
entries = readdirSync(chainDir(repo));
|
|
207
|
+
} catch (error) {
|
|
208
|
+
if (error?.code === 'ENOENT') return [];
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
return entries.filter((name) => Object.prototype.hasOwnProperty.call(MANAGED, name)).sort();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// globalHooksDir is the aimhooman global hooks path (~/.aimhooman/hooks).
|
|
215
|
+
export function globalHooksDir() {
|
|
216
|
+
return join(homedir(), '.aimhooman', 'hooks');
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// installGlobalHooks writes aimhooman dispatchers to ~/.aimhooman/hooks for global use.
|
|
220
|
+
export function installGlobalHooks(cliPath, options = {}) {
|
|
221
|
+
const dir = options.dir || globalHooksDir();
|
|
222
|
+
mkdirSync(dir, { recursive: true });
|
|
223
|
+
const installed = [];
|
|
224
|
+
const skipped = [];
|
|
225
|
+
const warnings = [];
|
|
226
|
+
for (const name of Object.keys(MANAGED)) {
|
|
227
|
+
const dest = join(dir, name);
|
|
228
|
+
const current = entry(dest);
|
|
229
|
+
if (current?.isSymbolicLink()) {
|
|
230
|
+
skipped.push(name);
|
|
231
|
+
warnings.push(symlinkWarning(name));
|
|
232
|
+
} else if (current && !ownedHook(readFileSync(dest, 'utf8'), name)) {
|
|
233
|
+
skipped.push(name);
|
|
234
|
+
warnings.push(`${name} already exists and is not managed by aimhooman; refusing to overwrite it`);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
// Preflight every destination so installation is all-or-nothing.
|
|
238
|
+
if (skipped.length) return { dir, installed, skipped: skipped.sort(), warnings };
|
|
239
|
+
const destinations = Object.keys(MANAGED).map((name) => join(dir, name));
|
|
240
|
+
const snapshots = destinations.map(snapshotFile);
|
|
241
|
+
const writeHook = options.writeHook || atomicWrite;
|
|
242
|
+
try {
|
|
243
|
+
for (const [name, cmd] of Object.entries(MANAGED)) {
|
|
244
|
+
const dest = join(dir, name);
|
|
245
|
+
writeHook(dest, hookScript(name, cmd, cliPath, join(dir, 'chained', name)), { mode: 0o755 });
|
|
246
|
+
chmodSync(dest, 0o755);
|
|
247
|
+
installed.push(name);
|
|
248
|
+
}
|
|
249
|
+
} catch (error) {
|
|
250
|
+
rollbackFiles(snapshots, error);
|
|
251
|
+
}
|
|
252
|
+
return { dir, installed: installed.sort(), skipped: skipped.sort(), warnings };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// uninstallGlobalHooks removes aimhooman-managed dispatchers from the global
|
|
256
|
+
// hooks dir. Filesystem errors propagate so callers cannot report a partial
|
|
257
|
+
// removal as success; the directory itself is left in place.
|
|
258
|
+
export function uninstallGlobalHooks(options = {}) {
|
|
259
|
+
const dir = options.dir || globalHooksDir();
|
|
260
|
+
const readHook = options.readHook || readFileSync;
|
|
261
|
+
const unlinkHook = options.unlinkHook || unlinkSync;
|
|
262
|
+
const removed = [];
|
|
263
|
+
const warnings = [];
|
|
264
|
+
for (const name of Object.keys(MANAGED)) {
|
|
265
|
+
const dest = join(dir, name);
|
|
266
|
+
if (entry(dest)?.isSymbolicLink()) {
|
|
267
|
+
warnings.push(symlinkWarning(name));
|
|
268
|
+
continue;
|
|
269
|
+
}
|
|
270
|
+
const current = entry(dest);
|
|
271
|
+
if (!current) continue;
|
|
272
|
+
if (ownedHook(readHook(dest, 'utf8'), name)) {
|
|
273
|
+
unlinkHook(dest);
|
|
274
|
+
removed.push(name);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
return { dir, removed: removed.sort(), warnings };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// installHooks writes aimhooman dispatchers, chaining any existing foreign hooks.
|
|
281
|
+
export function installHooks(repo, cliPath, options = {}) {
|
|
282
|
+
const { dir, shared, warnings } = hooksDir(repo);
|
|
283
|
+
if (shared) return { installed: [], chained: [], warnings, shared: true };
|
|
284
|
+
return withLock(join(dir, '.aimhooman-hooks.lock'), () => (
|
|
285
|
+
installHooksLocked(repo, cliPath, options, dir, warnings)
|
|
286
|
+
));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function installHooksLocked(repo, cliPath, options, dir, warnings) {
|
|
290
|
+
mkdirSync(dir, { recursive: true });
|
|
291
|
+
const chainedDir = chainDir(repo);
|
|
292
|
+
const installed = [];
|
|
293
|
+
const chained = [];
|
|
294
|
+
let unsafe = false;
|
|
295
|
+
for (const name of Object.keys(MANAGED)) {
|
|
296
|
+
const dest = join(dir, name);
|
|
297
|
+
const current = entry(dest);
|
|
298
|
+
const chainedPath = join(chainedDir, name);
|
|
299
|
+
if (current?.isSymbolicLink()) {
|
|
300
|
+
warnings.push(symlinkWarning(name));
|
|
301
|
+
unsafe = true;
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
if (entry(chainedPath)?.isSymbolicLink()) {
|
|
305
|
+
warnings.push(`${name} chained backup is a symlink; refusing to use it`);
|
|
306
|
+
unsafe = true;
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
if (current && ownedHook(readFileSync(dest, 'utf8'), name)
|
|
310
|
+
&& !ownedForChain(readFileSync(dest, 'utf8'), name, chainedPath)) {
|
|
311
|
+
warnings.push(`${name} is managed for another repository; refusing to overwrite it`);
|
|
312
|
+
unsafe = true;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
// Do not leave a repository with only part of the policy hook set.
|
|
316
|
+
if (unsafe) return { installed, chained, warnings, shared: false };
|
|
317
|
+
|
|
318
|
+
const paths = Object.keys(MANAGED).flatMap((name) => [
|
|
319
|
+
join(dir, name),
|
|
320
|
+
join(chainedDir, name),
|
|
321
|
+
]);
|
|
322
|
+
const snapshots = paths.map(snapshotFile);
|
|
323
|
+
const writeHook = options.writeHook || atomicWrite;
|
|
324
|
+
try {
|
|
325
|
+
for (const [name, cmd] of Object.entries(MANAGED)) {
|
|
326
|
+
const dest = join(dir, name);
|
|
327
|
+
const current = entry(dest);
|
|
328
|
+
if (current) {
|
|
329
|
+
const cur = readFileSync(dest);
|
|
330
|
+
if (!ownedHook(cur, name)) {
|
|
331
|
+
mkdirSync(chainedDir, { recursive: true });
|
|
332
|
+
const chainedPath = join(chainedDir, name);
|
|
333
|
+
const predecessorMode = current.mode & 0o777;
|
|
334
|
+
writeHook(chainedPath, cur, { mode: predecessorMode });
|
|
335
|
+
chmodSync(chainedPath, predecessorMode);
|
|
336
|
+
chained.push(name);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
writeHook(dest, hookScript(name, cmd, cliPath, join(chainedDir, name)), { mode: 0o755 });
|
|
340
|
+
chmodSync(dest, 0o755);
|
|
341
|
+
installed.push(name);
|
|
342
|
+
}
|
|
343
|
+
} catch (error) {
|
|
344
|
+
rollbackFiles(snapshots, error);
|
|
345
|
+
}
|
|
346
|
+
return { installed: installed.sort(), chained: chained.sort(), warnings, shared: false };
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// uninstallHooks removes aimhooman hooks and restores any chained originals.
|
|
350
|
+
// Best-effort and atomic across the hook set: a filesystem error on one hook
|
|
351
|
+
// is recorded as a failure and the remaining hooks are still processed, so a
|
|
352
|
+
// re-run self-heals (already-processed hooks are skipped via ownedHook).
|
|
353
|
+
export function uninstallHooks(repo) {
|
|
354
|
+
const { dir, shared, warnings } = hooksDir(repo);
|
|
355
|
+
if (shared) return { removed: [], restored: [], warnings, failures: [] };
|
|
356
|
+
return withLock(join(dir, '.aimhooman-hooks.lock'), () => (
|
|
357
|
+
uninstallHooksLocked(repo, dir, warnings)
|
|
358
|
+
));
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function uninstallHooksLocked(repo, dir, warnings) {
|
|
362
|
+
const chainedDir = chainDir(repo);
|
|
363
|
+
const removed = [];
|
|
364
|
+
const restored = [];
|
|
365
|
+
const failures = [];
|
|
366
|
+
for (const name of Object.keys(MANAGED)) {
|
|
367
|
+
const dest = join(dir, name);
|
|
368
|
+
let current;
|
|
369
|
+
try {
|
|
370
|
+
current = entry(dest);
|
|
371
|
+
} catch (error) {
|
|
372
|
+
failures.push(`${name}: cannot inspect hook: ${error.message}`);
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
if (!current) continue;
|
|
376
|
+
if (current.isSymbolicLink()) {
|
|
377
|
+
warnings.push(symlinkWarning(name));
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
try {
|
|
381
|
+
const chained = join(chainedDir, name);
|
|
382
|
+
const content = readFileSync(dest, 'utf8');
|
|
383
|
+
if (!ownedHook(content, name)) continue;
|
|
384
|
+
if (!ownedForChain(content, name, chained)) {
|
|
385
|
+
warnings.push(`${name} is managed for another repository; refusing to remove it`);
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
const predecessor = entry(chained);
|
|
389
|
+
if (predecessor?.isSymbolicLink()) {
|
|
390
|
+
warnings.push(`${name} chained backup is a symlink; refusing to restore it`);
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
if (predecessor) {
|
|
394
|
+
const predecessorMode = predecessor.mode & 0o777;
|
|
395
|
+
atomicWrite(dest, readFileSync(chained), { mode: predecessorMode });
|
|
396
|
+
chmodSync(dest, predecessorMode);
|
|
397
|
+
unlinkSync(chained);
|
|
398
|
+
restored.push(name);
|
|
399
|
+
} else {
|
|
400
|
+
unlinkSync(dest);
|
|
401
|
+
}
|
|
402
|
+
removed.push(name);
|
|
403
|
+
} catch (error) {
|
|
404
|
+
failures.push(`${name}: ${error.message}`);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
return { removed: removed.sort(), restored: restored.sort(), warnings, failures };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// hookDiagnostics reports both dispatcher integrity and whether its configured
|
|
411
|
+
// command can be reached from the current environment.
|
|
412
|
+
export function hookDiagnostics(repo) {
|
|
413
|
+
const { dir, shared } = hooksDir(repo);
|
|
414
|
+
// Global hooks (installGlobalHooks) keep chained backups in the hooks dir
|
|
415
|
+
// itself; local hooks keep them in repo state via chainDir. Diagnose the
|
|
416
|
+
// path the dispatcher actually embeds, not the repo-relative default.
|
|
417
|
+
const globalDir = globalHooksDir();
|
|
418
|
+
const chained = resolve(dir) === resolve(globalDir)
|
|
419
|
+
? join(globalDir, 'chained')
|
|
420
|
+
: chainDir(repo);
|
|
421
|
+
return Object.keys(MANAGED).sort().map((name) => {
|
|
422
|
+
const path = join(dir, name);
|
|
423
|
+
const base = { name, path, chainedPath: join(chained, name), shared };
|
|
424
|
+
let stat;
|
|
425
|
+
try {
|
|
426
|
+
stat = entry(path);
|
|
427
|
+
} catch (error) {
|
|
428
|
+
return {
|
|
429
|
+
...base,
|
|
430
|
+
managed: false,
|
|
431
|
+
reachable: false,
|
|
432
|
+
reason: `cannot inspect hook: ${error.message}`,
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
if (!stat) return { ...base, managed: false, reachable: false, reason: 'missing' };
|
|
436
|
+
if (stat.isSymbolicLink()) {
|
|
437
|
+
return { ...base, managed: false, reachable: false, reason: 'symlink' };
|
|
438
|
+
}
|
|
439
|
+
let content;
|
|
440
|
+
try {
|
|
441
|
+
content = readFileSync(path, 'utf8');
|
|
442
|
+
} catch (error) {
|
|
443
|
+
return {
|
|
444
|
+
...base,
|
|
445
|
+
managed: false,
|
|
446
|
+
reachable: false,
|
|
447
|
+
reason: `unreadable: ${error.message}`,
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
const inspected = inspectHook(content, name);
|
|
451
|
+
const executable = hookFileExecutable(stat);
|
|
452
|
+
if (!inspected.valid) {
|
|
453
|
+
return {
|
|
454
|
+
...base,
|
|
455
|
+
managed: false,
|
|
456
|
+
reachable: false,
|
|
457
|
+
executable,
|
|
458
|
+
reason: inspected.reason,
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
if (!ownedForChain(content, name, base.chainedPath)) {
|
|
462
|
+
return {
|
|
463
|
+
...base,
|
|
464
|
+
managed: false,
|
|
465
|
+
reachable: false,
|
|
466
|
+
executable,
|
|
467
|
+
reason: 'managed for another repository',
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
const nodeReachable = regularExecutableFile(inspected.nodePath);
|
|
471
|
+
const embeddedReachable = regularReadableFile(inspected.cliPath);
|
|
472
|
+
const gitReachable = pathCommandReachable('git', inspected.pathValue);
|
|
473
|
+
let chainedSafe = true;
|
|
474
|
+
try {
|
|
475
|
+
chainedSafe = !entry(base.chainedPath)?.isSymbolicLink();
|
|
476
|
+
} catch {
|
|
477
|
+
chainedSafe = false;
|
|
478
|
+
}
|
|
479
|
+
const reachable = nodeReachable
|
|
480
|
+
&& embeddedReachable
|
|
481
|
+
&& gitReachable
|
|
482
|
+
&& chainedSafe
|
|
483
|
+
&& inspected.shellPathCompatible;
|
|
484
|
+
return {
|
|
485
|
+
...base,
|
|
486
|
+
managed: true,
|
|
487
|
+
reachable,
|
|
488
|
+
executable,
|
|
489
|
+
version: inspected.version,
|
|
490
|
+
fingerprint: inspected.fingerprint,
|
|
491
|
+
cliPath: inspected.cliPath,
|
|
492
|
+
nodePath: inspected.nodePath,
|
|
493
|
+
embeddedReachable,
|
|
494
|
+
nodeReachable,
|
|
495
|
+
gitReachable,
|
|
496
|
+
chainedSafe,
|
|
497
|
+
shellPathCompatible: inspected.shellPathCompatible,
|
|
498
|
+
reason: !executable
|
|
499
|
+
? 'not executable'
|
|
500
|
+
: reachable
|
|
501
|
+
? ''
|
|
502
|
+
: !chainedSafe
|
|
503
|
+
? 'chained predecessor is a symlink'
|
|
504
|
+
: !inspected.shellPathCompatible
|
|
505
|
+
? 'dispatcher PATH is incompatible with the Git shell; run aimhooman init again'
|
|
506
|
+
: 'aimhooman command is not reachable',
|
|
507
|
+
};
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
// installedHooks lists current, executable dispatchers whose fingerprint and
|
|
512
|
+
// command reachability have both been verified.
|
|
513
|
+
export function installedHooks(repo) {
|
|
514
|
+
return hookDiagnostics(repo)
|
|
515
|
+
.filter((hook) => hook.managed && hook.executable && hook.reachable)
|
|
516
|
+
.map((hook) => hook.name)
|
|
517
|
+
.sort();
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// activeGitHook reports whether Git can execute an unmanaged hook that runs
|
|
521
|
+
// outside aimhooman's dispatcher chain.
|
|
522
|
+
export function activeGitHook(repo, name) {
|
|
523
|
+
if (!/^[a-z][a-z0-9-]*$/.test(String(name))) throw new Error('invalid Git hook name');
|
|
524
|
+
const path = join(effectiveHooksDir(repo), name);
|
|
525
|
+
try {
|
|
526
|
+
const stat = statSync(path);
|
|
527
|
+
return { name, path, active: hookFileExecutable(stat) };
|
|
528
|
+
} catch (error) {
|
|
529
|
+
if (error?.code === 'ENOENT') return { name, path, active: false };
|
|
530
|
+
throw error;
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function hookScript(name, cmd, cliPath, chainedPath) {
|
|
535
|
+
const resolvedCliPath = resolve(String(cliPath));
|
|
536
|
+
const cliMetadata = Buffer.from(resolvedCliPath, 'utf8').toString('base64url');
|
|
537
|
+
const nodePath = process.execPath;
|
|
538
|
+
const pathValue = String(process.env.PATH || '')
|
|
539
|
+
.split(delimiter)
|
|
540
|
+
.filter((directory) => directory && isAbsolute(directory))
|
|
541
|
+
.join(delimiter);
|
|
542
|
+
const shellPathValue = hookPathForShell(pathValue);
|
|
543
|
+
const nodeMetadata = Buffer.from(nodePath, 'utf8').toString('base64url');
|
|
544
|
+
const pathMetadata = Buffer.from(pathValue, 'utf8').toString('base64url');
|
|
545
|
+
const captureTree = name === 'commit-msg'
|
|
546
|
+
? `AIMHOOMAN_COMMIT_TREE=$(PATH="$AIMHOOMAN_PATH" git write-tree) || {
|
|
547
|
+
echo "aimhooman: cannot snapshot the would-be commit tree" >&2
|
|
548
|
+
exit 30
|
|
549
|
+
}
|
|
550
|
+
`
|
|
551
|
+
: '';
|
|
552
|
+
const captureTransaction = name === 'reference-transaction'
|
|
553
|
+
? `AIMHOOMAN_REF_UPDATES=$(
|
|
554
|
+
while IFS= read -r AIMHOOMAN_REF_UPDATE || [ -n "$AIMHOOMAN_REF_UPDATE" ]; do
|
|
555
|
+
printf '%s\\n' "$AIMHOOMAN_REF_UPDATE" || exit $?
|
|
556
|
+
done
|
|
557
|
+
) || exit $?
|
|
558
|
+
`
|
|
559
|
+
: '';
|
|
560
|
+
const chainedInvocation = name === 'reference-transaction'
|
|
561
|
+
? ` printf '%s\\n' "$AIMHOOMAN_REF_UPDATES" | "$CHAINED" "$@" || exit $?`
|
|
562
|
+
: ` "$CHAINED" "$@" || exit $?`;
|
|
563
|
+
const aimCommand = name === 'commit-msg'
|
|
564
|
+
? 'commitmsg "$1" --tree "$AIMHOOMAN_COMMIT_TREE"'
|
|
565
|
+
: name === 'reference-transaction'
|
|
566
|
+
? 'refcheck "$1"'
|
|
567
|
+
: cmd;
|
|
568
|
+
const aimInvocation = name === 'reference-transaction'
|
|
569
|
+
? `printf '%s\\n' "$AIMHOOMAN_REF_UPDATES" | run_aimhooman ${aimCommand} || exit $?`
|
|
570
|
+
: `run_aimhooman ${aimCommand} || exit $?`;
|
|
571
|
+
const template = `#!/bin/sh -p
|
|
572
|
+
${MARKER} (${name})
|
|
573
|
+
# aimhooman-hook-version: ${HOOK_FORMAT_VERSION}
|
|
574
|
+
# aimhooman-hook-fingerprint: ${FINGERPRINT_PLACEHOLDER}
|
|
575
|
+
# aimhooman-cli-base64url: ${cliMetadata}
|
|
576
|
+
# aimhooman-node-base64url: ${nodeMetadata}
|
|
577
|
+
# aimhooman-path-base64url: ${pathMetadata}
|
|
578
|
+
# Managed by aimhooman. Remove with: aimhooman uninstall
|
|
579
|
+
AIMHOOMAN_CLI=${shq(resolvedCliPath)}
|
|
580
|
+
AIMHOOMAN_NODE=${shq(nodePath)}
|
|
581
|
+
AIMHOOMAN_PATH=${shq(shellPathValue)}
|
|
582
|
+
${captureTree}${captureTransaction}run_aimhooman() {
|
|
583
|
+
if [ ! -f "$AIMHOOMAN_CLI" ] || [ ! -f "$AIMHOOMAN_NODE" ]; then
|
|
584
|
+
echo "aimhooman: guard unavailable; install it or run 'aimhooman init' again" >&2
|
|
585
|
+
return 127
|
|
586
|
+
fi
|
|
587
|
+
(
|
|
588
|
+
unset NODE_OPTIONS NODE_PATH NODE_REPL_EXTERNAL_MODULE NODE_EXTRA_CA_CERTS
|
|
589
|
+
unset NODE_CHANNEL_FD NODE_CHANNEL_SERIALIZATION_MODE NODE_V8_COVERAGE NODE_DEBUG
|
|
590
|
+
unset LD_PRELOAD LD_LIBRARY_PATH DYLD_INSERT_LIBRARIES DYLD_LIBRARY_PATH
|
|
591
|
+
unset DYLD_FRAMEWORK_PATH DYLD_FALLBACK_LIBRARY_PATH DYLD_FALLBACK_FRAMEWORK_PATH
|
|
592
|
+
unset BASH_ENV ENV CDPATH
|
|
593
|
+
PATH=$AIMHOOMAN_PATH
|
|
594
|
+
AIMHOOMAN_ACTIVE_HOOK=${shq(name)}
|
|
595
|
+
export PATH
|
|
596
|
+
export AIMHOOMAN_ACTIVE_HOOK
|
|
597
|
+
"$AIMHOOMAN_NODE" "$AIMHOOMAN_CLI" "$@"
|
|
598
|
+
)
|
|
599
|
+
}
|
|
600
|
+
CHAINED=${shq(chainedPath)}
|
|
601
|
+
if [ -L "$CHAINED" ]; then
|
|
602
|
+
echo "aimhooman: chained hook is a symlink; run 'aimhooman init' after restoring the original hook" >&2
|
|
603
|
+
exit 126
|
|
604
|
+
fi
|
|
605
|
+
if [ -x "$CHAINED" ]; then
|
|
606
|
+
${chainedInvocation}
|
|
607
|
+
fi
|
|
608
|
+
${aimInvocation}
|
|
609
|
+
`;
|
|
610
|
+
const fingerprint = hookFingerprint(template);
|
|
611
|
+
return template.replace(FINGERPRINT_PLACEHOLDER, fingerprint);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function inspectHook(content, expectedName) {
|
|
615
|
+
const text = String(content);
|
|
616
|
+
const header = new RegExp(
|
|
617
|
+
`^#!\\/bin\\/sh(?: -p)?\\n${escapeRegExp(MARKER)} \\(${escapeRegExp(expectedName)}\\)\\n` +
|
|
618
|
+
`# aimhooman-hook-version: (\\d+)\\n` +
|
|
619
|
+
'# aimhooman-hook-fingerprint: ([a-f0-9]{64})\\n' +
|
|
620
|
+
'# aimhooman-cli-base64url: ([A-Za-z0-9_-]*)\\n' +
|
|
621
|
+
'(?:# aimhooman-node-base64url: ([A-Za-z0-9_-]*)\\n)?' +
|
|
622
|
+
'(?:# aimhooman-path-base64url: ([A-Za-z0-9_-]*)\\n)?'
|
|
623
|
+
);
|
|
624
|
+
const match = text.match(header);
|
|
625
|
+
if (!match) return { valid: false, reason: 'managed header is missing or malformed' };
|
|
626
|
+
const version = Number(match[1]);
|
|
627
|
+
const fingerprint = match[2];
|
|
628
|
+
const normalized = text.replace(
|
|
629
|
+
`# aimhooman-hook-fingerprint: ${fingerprint}`,
|
|
630
|
+
`# aimhooman-hook-fingerprint: ${FINGERPRINT_PLACEHOLDER}`
|
|
631
|
+
);
|
|
632
|
+
if (hookFingerprint(normalized) !== fingerprint) {
|
|
633
|
+
return { valid: false, reason: 'managed hook fingerprint does not match' };
|
|
634
|
+
}
|
|
635
|
+
if (version !== HOOK_FORMAT_VERSION) {
|
|
636
|
+
return {
|
|
637
|
+
valid: false,
|
|
638
|
+
version,
|
|
639
|
+
fingerprint,
|
|
640
|
+
reason: `unsupported managed hook version ${match[1]}`,
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
const cliPath = decodeMetadata(match[3]);
|
|
644
|
+
const nodePath = decodeMetadata(match[4]);
|
|
645
|
+
const pathValue = decodeMetadata(match[5]);
|
|
646
|
+
if (cliPath === null || nodePath === null || pathValue === null) {
|
|
647
|
+
return { valid: false, reason: 'managed CLI metadata is invalid' };
|
|
648
|
+
}
|
|
649
|
+
const nativePathAssignment = `AIMHOOMAN_PATH=${shq(pathValue)}\n`;
|
|
650
|
+
const shellPathAssignment = `AIMHOOMAN_PATH=${shq(hookPathForShell(pathValue))}\n`;
|
|
651
|
+
const shellPathCompatible = text.includes(shellPathAssignment);
|
|
652
|
+
if (
|
|
653
|
+
!isAbsolute(cliPath)
|
|
654
|
+
|| !isAbsolute(nodePath)
|
|
655
|
+
|| !text.includes(`AIMHOOMAN_CLI=${shq(cliPath)}\n`)
|
|
656
|
+
|| !text.includes(`AIMHOOMAN_NODE=${shq(nodePath)}\n`)
|
|
657
|
+
|| (!shellPathCompatible && !text.includes(nativePathAssignment))
|
|
658
|
+
) {
|
|
659
|
+
return { valid: false, reason: 'managed CLI metadata does not match the dispatcher' };
|
|
660
|
+
}
|
|
661
|
+
return {
|
|
662
|
+
valid: true,
|
|
663
|
+
owned: true,
|
|
664
|
+
version,
|
|
665
|
+
fingerprint,
|
|
666
|
+
cliPath,
|
|
667
|
+
nodePath,
|
|
668
|
+
pathValue,
|
|
669
|
+
shellPathCompatible,
|
|
670
|
+
};
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
// Git executes hooks with a POSIX shell even on Windows. Keep the native PATH
|
|
674
|
+
// in authenticated metadata for diagnostics, but render drive and UNC entries
|
|
675
|
+
// in the form Git for Windows' shell accepts. MSYS converts the exported value
|
|
676
|
+
// back to a native PATH when the dispatcher starts the embedded Node binary.
|
|
677
|
+
export function hookPathForShell(pathValue, platform = process.platform) {
|
|
678
|
+
const value = String(pathValue || '');
|
|
679
|
+
if (platform !== 'win32') return value;
|
|
680
|
+
return value.split(';').filter(Boolean).map((directory) => {
|
|
681
|
+
const normalized = directory.replace(/\\/g, '/');
|
|
682
|
+
const extendedUnc = normalized.match(/^\/\/\?\/UNC\/(.+)$/i);
|
|
683
|
+
if (extendedUnc) return `//${extendedUnc[1]}`;
|
|
684
|
+
const drive = normalized.match(/^(?:\/\/\?\/)?([A-Za-z]):(?:\/(.*))?$/);
|
|
685
|
+
if (drive) {
|
|
686
|
+
return `/${drive[1].toLowerCase()}${drive[2] === undefined ? '' : `/${drive[2]}`}`;
|
|
687
|
+
}
|
|
688
|
+
return normalized.startsWith('/') ? normalized : '';
|
|
689
|
+
}).filter(Boolean).join(':');
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function hookFingerprint(content) {
|
|
693
|
+
return createHash('sha256').update(content, 'utf8').digest('hex');
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function ownedHook(content, name) {
|
|
697
|
+
const inspected = inspectHook(content, name);
|
|
698
|
+
return inspected.valid || legacyHook(content, name);
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function ownedForChain(content, name, chainedPath) {
|
|
702
|
+
if (!ownedHook(content, name)) return false;
|
|
703
|
+
return assignmentValue(content, 'CHAINED') === String(chainedPath);
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function decodeMetadata(value) {
|
|
707
|
+
if (typeof value !== 'string') return null;
|
|
708
|
+
const decoded = Buffer.from(value, 'base64url').toString('utf8');
|
|
709
|
+
return Buffer.from(decoded, 'utf8').toString('base64url') === value ? decoded : null;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// Recognize only the exact pre-fingerprint dispatcher so an existing install
|
|
713
|
+
// can be upgraded without treating an arbitrary copied marker as ownership.
|
|
714
|
+
function legacyHook(content, name) {
|
|
715
|
+
if (!Object.hasOwn(LEGACY_MANAGED, name)) return false;
|
|
716
|
+
const cli = assignmentValue(content, 'AIMHOOMAN_CLI');
|
|
717
|
+
const chained = assignmentValue(content, 'CHAINED');
|
|
718
|
+
if (cli === null || chained === null) return false;
|
|
719
|
+
return content === legacyHookScript(name, LEGACY_MANAGED[name], cli, chained);
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function assignmentValue(content, key) {
|
|
723
|
+
const match = String(content).match(new RegExp(`^${key}=(.*)$`, 'm'));
|
|
724
|
+
if (!match || !match[1].startsWith("'") || !match[1].endsWith("'")) return null;
|
|
725
|
+
return match[1].slice(1, -1).split("'\\''").join("'");
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function legacyHookScript(name, cmd, cliPath, chainedPath) {
|
|
729
|
+
return `#!/bin/sh
|
|
730
|
+
${MARKER} (${name})
|
|
731
|
+
# Managed by aimhooman. Remove with: aimhooman uninstall
|
|
732
|
+
AIMHOOMAN_CLI=${shq(cliPath)}
|
|
733
|
+
run_aimhooman() {
|
|
734
|
+
if [ -f "$AIMHOOMAN_CLI" ] && command -v node >/dev/null 2>&1; then
|
|
735
|
+
node "$AIMHOOMAN_CLI" "$@"
|
|
736
|
+
elif command -v aimhooman >/dev/null 2>&1; then
|
|
737
|
+
aimhooman "$@"
|
|
738
|
+
else
|
|
739
|
+
echo "aimhooman: not found; skipping guard (npm i -g @rmyndharis/aimhooman)" >&2
|
|
740
|
+
return 0
|
|
741
|
+
fi
|
|
742
|
+
}
|
|
743
|
+
run_aimhooman ${cmd} || exit $?
|
|
744
|
+
CHAINED=${shq(chainedPath)}
|
|
745
|
+
if [ -x "$CHAINED" ]; then
|
|
746
|
+
"$CHAINED" "$@" || exit $?
|
|
747
|
+
fi
|
|
748
|
+
`;
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
function regularReadableFile(path) {
|
|
752
|
+
try {
|
|
753
|
+
if (!statSync(path).isFile()) return false;
|
|
754
|
+
accessSync(path, constants.R_OK);
|
|
755
|
+
return true;
|
|
756
|
+
} catch {
|
|
757
|
+
return false;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function regularExecutableFile(path) {
|
|
762
|
+
try {
|
|
763
|
+
if (!statSync(path).isFile()) return false;
|
|
764
|
+
accessSync(path, process.platform === 'win32' ? constants.F_OK : constants.X_OK);
|
|
765
|
+
return true;
|
|
766
|
+
} catch {
|
|
767
|
+
return false;
|
|
768
|
+
}
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
export function pathCommandReachable(
|
|
772
|
+
command,
|
|
773
|
+
pathValue = process.env.PATH,
|
|
774
|
+
platform = process.platform,
|
|
775
|
+
) {
|
|
776
|
+
// Node launches Git without a command shell. Windows batch files listed in
|
|
777
|
+
// PATHEXT are therefore not valid substitutes for git.exe, even though a
|
|
778
|
+
// shell lookup would find them.
|
|
779
|
+
const extensions = platform === 'win32' ? ['.exe'] : [''];
|
|
780
|
+
const pathDelimiter = platform === 'win32' ? ';' : delimiter;
|
|
781
|
+
for (const directory of String(pathValue || '').split(pathDelimiter).filter(Boolean)) {
|
|
782
|
+
for (const extension of extensions) {
|
|
783
|
+
const candidate = join(directory, command + extension);
|
|
784
|
+
try {
|
|
785
|
+
if (!statSync(candidate).isFile()) continue;
|
|
786
|
+
accessSync(candidate, platform === 'win32' ? constants.F_OK : constants.X_OK);
|
|
787
|
+
return true;
|
|
788
|
+
} catch {
|
|
789
|
+
/* keep looking */
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
return false;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
export function hookFileExecutable(stat, platform = process.platform) {
|
|
797
|
+
if (!stat?.isFile?.()) return false;
|
|
798
|
+
// Bit-mask approximation (any exec bit set). This diverges from git's
|
|
799
|
+
// euid-aware access(X_OK) only for group/world-only exec modes (e.g. 0o770
|
|
800
|
+
// set by a sharing tool) in shared-ownership repos — out of scope for this
|
|
801
|
+
// tool, which writes 0o755 hooks for individual developers. git enforces the
|
|
802
|
+
// real exec check at hook time; this is a diagnostic only. regularExecutableFile
|
|
803
|
+
// uses accessSync(X_OK) where a live path is available.
|
|
804
|
+
return platform === 'win32' || Boolean(stat.mode & 0o111);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
function escapeRegExp(value) {
|
|
808
|
+
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function shq(s) {
|
|
812
|
+
return "'" + String(s).replace(/'/g, `'\\''`) + "'";
|
|
813
|
+
}
|