@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.
Files changed (54) hide show
  1. package/.agents/rules/aimhooman.md +53 -0
  2. package/.claude-plugin/marketplace.json +23 -0
  3. package/.claude-plugin/plugin.json +9 -0
  4. package/.clinerules/aimhooman.md +53 -0
  5. package/.codex-plugin/plugin.json +37 -0
  6. package/.cursor/rules/aimhooman.mdc +59 -0
  7. package/.gemini/settings.json +7 -0
  8. package/.github/copilot-instructions.md +53 -0
  9. package/.github/hooks/aimhooman.json +22 -0
  10. package/.kiro/steering/aimhooman.md +53 -0
  11. package/.windsurf/rules/aimhooman.md +57 -0
  12. package/AGENTS.md +53 -0
  13. package/CHANGELOG.md +153 -0
  14. package/CODE_OF_CONDUCT.md +128 -0
  15. package/CONTRIBUTING.md +144 -0
  16. package/GEMINI.md +53 -0
  17. package/LICENSE +21 -0
  18. package/README.md +472 -0
  19. package/SECURITY.md +74 -0
  20. package/bin/aimhooman.mjs +1589 -0
  21. package/docs/design/agent-portability.md +73 -0
  22. package/docs/design/frictionless-enforcement.md +135 -0
  23. package/docs/hosts.json +164 -0
  24. package/docs/logo/aimhooman-logo.png +0 -0
  25. package/docs/logo/aimhooman.png +0 -0
  26. package/hooks/hooks.json +29 -0
  27. package/package.json +77 -0
  28. package/rules/attribution.json +142 -0
  29. package/rules/markers.json +88 -0
  30. package/rules/paths.json +407 -0
  31. package/rules/secrets.json +90 -0
  32. package/schemas/overrides.schema.json +134 -0
  33. package/schemas/project-policy.schema.json +12 -0
  34. package/schemas/rule-pack.schema.json +126 -0
  35. package/schemas/scan-report.schema.json +165 -0
  36. package/skills/aimhooman/SKILL.md +65 -0
  37. package/src/args.mjs +72 -0
  38. package/src/atomic-write.mjs +285 -0
  39. package/src/codex-manifest.mjs +65 -0
  40. package/src/exclude.mjs +125 -0
  41. package/src/git-environment.mjs +5 -0
  42. package/src/git-path.mjs +5 -0
  43. package/src/githooks.mjs +813 -0
  44. package/src/gitx.mjs +721 -0
  45. package/src/history-scan.mjs +295 -0
  46. package/src/hook.mjs +2602 -0
  47. package/src/policy-resolver.mjs +200 -0
  48. package/src/report.mjs +63 -0
  49. package/src/rules.mjs +509 -0
  50. package/src/ruleset-text.mjs +29 -0
  51. package/src/scan-session.mjs +152 -0
  52. package/src/scan-target.mjs +697 -0
  53. package/src/scan.mjs +325 -0
  54. package/src/state.mjs +360 -0
package/src/gitx.mjs ADDED
@@ -0,0 +1,721 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import {
3
+ cpSync,
4
+ lstatSync,
5
+ mkdirSync,
6
+ mkdtempSync,
7
+ readFileSync,
8
+ realpathSync,
9
+ readdirSync,
10
+ readlinkSync,
11
+ renameSync,
12
+ rmSync,
13
+ symlinkSync,
14
+ } from 'node:fs';
15
+ import { createHash, randomUUID } from 'node:crypto';
16
+ import { isAbsolute, join } from 'node:path';
17
+ import { gitEnvironment } from './git-environment.mjs';
18
+
19
+ export class StateMigrationError extends Error {
20
+ constructor(detail, { stateDir, legacyStateDirs = [], diagnostics = [], cause } = {}) {
21
+ super(`repository state: ${detail}`);
22
+ this.name = 'StateMigrationError';
23
+ this.stateDir = stateDir;
24
+ this.legacyStateDirs = legacyStateDirs;
25
+ this.stateDiagnostics = diagnostics;
26
+ this.diagnostics = diagnostics;
27
+ if (cause) this.cause = cause;
28
+ }
29
+ }
30
+
31
+ export class GitRevisionError extends Error {
32
+ constructor(revision, detail, cause) {
33
+ super(`Git revision "${revision}": ${detail}`);
34
+ this.name = 'GitRevisionError';
35
+ this.revision = revision;
36
+ if (cause) this.cause = cause;
37
+ }
38
+ }
39
+
40
+ export class GitTargetReadError extends Error {
41
+ constructor(target, path, detail, cause) {
42
+ super(`Git target "${target}" path "${path}": ${detail}`);
43
+ this.name = 'GitTargetReadError';
44
+ this.target = target;
45
+ this.path = path;
46
+ if (cause) this.cause = cause;
47
+ }
48
+ }
49
+
50
+ function gitBuf(args, cwd, input) {
51
+ return execFileSync('git', args, {
52
+ cwd,
53
+ env: gitEnvironment(),
54
+ encoding: 'buffer',
55
+ maxBuffer: 64 * 1024 * 1024,
56
+ // Capture stderr into the thrown error (pipe) instead of letting git's
57
+ // raw stderr leak to the user's terminal alongside aimhooman's own
58
+ // diagnostics; stdin is ignored unless input is supplied.
59
+ stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'],
60
+ ...(input === undefined ? {} : { input }),
61
+ });
62
+ }
63
+
64
+ function gitStr(args, cwd) {
65
+ return gitBuf(args, cwd).toString('utf8').trim();
66
+ }
67
+
68
+ function nulStrings(buf) {
69
+ return buf.toString('utf8').split('\0').filter(Boolean);
70
+ }
71
+
72
+ function assertRevision(revision, label) {
73
+ if (typeof revision !== 'string' || !revision || revision.startsWith('-')) {
74
+ throw new TypeError(`${label} must be a non-empty Git revision`);
75
+ }
76
+ }
77
+
78
+ function assertOid(oid) {
79
+ if (typeof oid !== 'string' || !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(oid)) {
80
+ throw new TypeError('oid must be a full Git object ID');
81
+ }
82
+ }
83
+
84
+ function typeForMode(mode) {
85
+ if (mode === '160000') return 'commit';
86
+ if (mode === '040000') return 'tree';
87
+ return 'blob';
88
+ }
89
+
90
+ function objectMetadata(repo, oids) {
91
+ const unique = [...new Set(oids.filter(Boolean))];
92
+ if (!unique.length) return new Map();
93
+ const output = gitBuf(
94
+ ['cat-file', '--batch-check=%(objectname) %(objecttype) %(objectsize)'],
95
+ repo.root,
96
+ Buffer.from(unique.join('\n') + '\n'),
97
+ ).toString('utf8').trimEnd().split('\n');
98
+ const metadata = new Map();
99
+
100
+ for (let i = 0; i < unique.length; i++) {
101
+ const fields = (output[i] || '').split(' ');
102
+ if (fields[1] === 'missing') {
103
+ metadata.set(unique[i], { type: null, size: null });
104
+ } else if (fields.length === 3 && /^\d+$/.test(fields[2])) {
105
+ metadata.set(unique[i], { type: fields[1], size: Number(fields[2]) });
106
+ } else {
107
+ throw new Error('unexpected output from git cat-file --batch-check');
108
+ }
109
+ }
110
+ return metadata;
111
+ }
112
+
113
+ function enrichEntries(repo, entries) {
114
+ const metadata = objectMetadata(repo, entries.map((entry) => entry.oid));
115
+ return entries.map((entry) => {
116
+ const object = metadata.get(entry.oid) || { type: null, size: null };
117
+ return {
118
+ ...entry,
119
+ type: object.type || entry.type || typeForMode(entry.mode),
120
+ size: object.size,
121
+ };
122
+ });
123
+ }
124
+
125
+ function diffEntries(repo, args, renameThreshold = null) {
126
+ const fields = nulStrings(gitBuf([
127
+ 'diff', '--raw', '--no-abbrev',
128
+ renameThreshold === null ? '--find-renames' : `--find-renames=${renameThreshold}`,
129
+ '-z', '--diff-filter=ACMRTD', ...args, '--',
130
+ ], repo.root));
131
+ const entries = [];
132
+
133
+ for (let i = 0; i < fields.length;) {
134
+ const header = fields[i++];
135
+ const match = /^:(\d+) (\d+) ([0-9a-f]+) ([0-9a-f]+) ([ACMRTD])(\d*)$/.exec(header);
136
+ if (!match || i >= fields.length) {
137
+ throw new Error('unexpected output from git diff --raw');
138
+ }
139
+ const sourcePath = fields[i++];
140
+ let path = sourcePath;
141
+ if (match[5] === 'R' || match[5] === 'C') {
142
+ if (i >= fields.length) throw new Error('unexpected rename output from git diff --raw');
143
+ path = fields[i++];
144
+ }
145
+ const deleted = match[5] === 'D';
146
+ entries.push({
147
+ path,
148
+ ...(path === sourcePath ? {} : { sourcePath }),
149
+ oid: deleted ? null : match[4],
150
+ mode: deleted ? null : match[2],
151
+ type: deleted ? 'deleted' : typeForMode(match[2]),
152
+ status: match[5],
153
+ });
154
+ }
155
+
156
+ return enrichEntries(repo, entries);
157
+ }
158
+
159
+ // openRepo resolves the repository containing cwd. Throws if not a repo.
160
+ export function openRepo(cwd = process.cwd()) {
161
+ const root = gitStr(['rev-parse', '--show-toplevel'], cwd);
162
+ const gitDir = gitStr(['rev-parse', '--absolute-git-dir'], root);
163
+ let commonDir = gitStr(['rev-parse', '--git-common-dir'], root);
164
+ if (!isAbsolute(commonDir)) commonDir = join(root, commonDir);
165
+ const state = prepareSharedState(commonDir, gitDir);
166
+ return {
167
+ root,
168
+ gitDir,
169
+ commonDir,
170
+ stateDir: state.stateDir,
171
+ legacyStateDirs: state.legacyStateDirs,
172
+ stateDiagnostics: state.diagnostics,
173
+ excludeFile: join(commonDir, 'info', 'exclude'),
174
+ };
175
+ }
176
+
177
+ function prepareSharedState(commonDir, gitDir) {
178
+ const stateDir = join(commonDir, 'aimhooman');
179
+ const legacyStateDirs = findLegacyStateDirs(commonDir, gitDir, stateDir);
180
+ const diagnostics = [];
181
+ if (!legacyStateDirs.length) return { stateDir, legacyStateDirs, diagnostics };
182
+
183
+ const currentExists = pathExists(stateDir);
184
+ let fingerprints;
185
+ try {
186
+ fingerprints = legacyStateDirs.map((path) => ({ path, fingerprint: stateFingerprint(path) }));
187
+ if (currentExists) {
188
+ const currentFingerprint = stateFingerprint(stateDir);
189
+ const conflicts = fingerprints.filter((entry) => entry.fingerprint !== currentFingerprint);
190
+ if (conflicts.length) {
191
+ throw stateConflict(stateDir, legacyStateDirs, conflicts.map((entry) => entry.path));
192
+ }
193
+ archiveLegacyStates(fingerprints, stateDir, diagnostics);
194
+ return { stateDir, legacyStateDirs, diagnostics };
195
+ }
196
+
197
+ const first = fingerprints[0];
198
+ const conflicts = fingerprints.filter((entry) => entry.fingerprint !== first.fingerprint);
199
+ if (conflicts.length) {
200
+ throw stateConflict(stateDir, legacyStateDirs, conflicts.map((entry) => entry.path));
201
+ }
202
+ try {
203
+ copyStateAtomically(first.path, stateDir, first.fingerprint);
204
+ } catch (error) {
205
+ if (!pathExists(stateDir)) throw error;
206
+ const currentFingerprint = stateFingerprint(stateDir);
207
+ const conflicts = fingerprints.filter((entry) => entry.fingerprint !== currentFingerprint);
208
+ if (conflicts.length) {
209
+ throw stateConflict(stateDir, legacyStateDirs, conflicts.map((entry) => entry.path));
210
+ }
211
+ diagnostics.push({
212
+ level: 'info',
213
+ code: 'legacy-state-migrated-concurrently',
214
+ path: first.path,
215
+ stateDir,
216
+ message: `shared state at "${stateDir}" was created by another process`,
217
+ });
218
+ archiveLegacyStates(fingerprints, stateDir, diagnostics);
219
+ return { stateDir, legacyStateDirs, diagnostics };
220
+ }
221
+ diagnostics.push({
222
+ level: 'info',
223
+ code: 'legacy-state-migrated',
224
+ path: first.path,
225
+ stateDir,
226
+ message: `copied legacy state from "${first.path}" to shared state "${stateDir}"`,
227
+ });
228
+ archiveLegacyStates(fingerprints, stateDir, diagnostics);
229
+ return { stateDir, legacyStateDirs, diagnostics };
230
+ } catch (error) {
231
+ if (error instanceof StateMigrationError) throw error;
232
+ throw new StateMigrationError(`cannot inspect or migrate legacy state: ${error.message}`, {
233
+ stateDir,
234
+ legacyStateDirs,
235
+ diagnostics,
236
+ cause: error,
237
+ });
238
+ }
239
+ }
240
+
241
+ function findLegacyStateDirs(commonDir, gitDir, stateDir) {
242
+ const found = new Set();
243
+ const adminDir = join(commonDir, 'worktrees');
244
+ let entries = [];
245
+ try {
246
+ entries = readdirSync(adminDir, { withFileTypes: true });
247
+ } catch (error) {
248
+ if (error?.code !== 'ENOENT') {
249
+ throw new StateMigrationError(`cannot list linked worktrees: ${error.message}`, {
250
+ stateDir,
251
+ cause: error,
252
+ });
253
+ }
254
+ }
255
+ for (const entry of entries) {
256
+ if (!entry.isDirectory()) continue;
257
+ const candidate = join(adminDir, entry.name, 'aimhooman');
258
+ if (pathExists(candidate) && !isCompatibilityLink(candidate, stateDir)) found.add(candidate);
259
+ }
260
+ const currentLegacy = join(gitDir, 'aimhooman');
261
+ if (currentLegacy !== stateDir
262
+ && pathExists(currentLegacy)
263
+ && !isCompatibilityLink(currentLegacy, stateDir)) {
264
+ found.add(currentLegacy);
265
+ }
266
+ return [...found].sort();
267
+ }
268
+
269
+ function isCompatibilityLink(path, stateDir) {
270
+ try {
271
+ if (!lstatSync(path).isSymbolicLink()) return false;
272
+ return realpathSync(path) === realpathSync(stateDir);
273
+ } catch (error) {
274
+ if (error?.code === 'ENOENT') return false;
275
+ throw error;
276
+ }
277
+ }
278
+
279
+ function pathExists(path) {
280
+ try {
281
+ lstatSync(path);
282
+ return true;
283
+ } catch (error) {
284
+ if (error?.code === 'ENOENT') return false;
285
+ throw error;
286
+ }
287
+ }
288
+
289
+ function stateFingerprint(root) {
290
+ const hash = createHash('sha256');
291
+ function visit(path, relative) {
292
+ const stat = lstatSync(path);
293
+ hash.update(relative);
294
+ hash.update('\0');
295
+ hash.update(String(stat.mode & 0o777));
296
+ hash.update('\0');
297
+ if (stat.isDirectory()) {
298
+ hash.update('directory\0');
299
+ for (const name of readdirSync(path).sort()) {
300
+ visit(join(path, name), relative ? join(relative, name) : name);
301
+ }
302
+ return;
303
+ }
304
+ if (stat.isFile()) {
305
+ hash.update('file\0');
306
+ hash.update(readFileSync(path));
307
+ hash.update('\0');
308
+ return;
309
+ }
310
+ if (stat.isSymbolicLink()) {
311
+ hash.update('symlink\0');
312
+ hash.update(readlinkSync(path));
313
+ hash.update('\0');
314
+ return;
315
+ }
316
+ throw new Error(`unsupported state entry "${path}"`);
317
+ }
318
+ visit(root, '');
319
+ return hash.digest('hex');
320
+ }
321
+
322
+ function copyStateAtomically(source, destination, expectedFingerprint) {
323
+ const temporary = `${destination}.migrate-${process.pid}-${randomUUID()}`;
324
+ try {
325
+ cpSync(source, temporary, {
326
+ recursive: true,
327
+ dereference: false,
328
+ errorOnExist: true,
329
+ force: false,
330
+ preserveTimestamps: true,
331
+ });
332
+ if (stateFingerprint(temporary) !== expectedFingerprint) {
333
+ throw new Error(`legacy state changed while it was copied from "${source}"`);
334
+ }
335
+ renameSync(temporary, destination);
336
+ } catch (error) {
337
+ try {
338
+ rmSync(temporary, { recursive: true, force: true });
339
+ } catch {
340
+ // Keep the migration error when cleanup is unavailable.
341
+ }
342
+ throw error;
343
+ }
344
+ }
345
+
346
+ export function archiveLegacyStates(entries, stateDir, diagnostics) {
347
+ for (const entry of entries) {
348
+ // A concurrent process may have already archived this legacy state and
349
+ // replaced entry.path with a compatibility symlink to stateDir. Renaming
350
+ // that symlink would then fingerprint stateDir (mismatching the legacy
351
+ // fingerprint taken before the race) and spuriously fail migration. Treat
352
+ // an already-symlinked entry as archived-concurrently and move on.
353
+ if (isCompatibilityLink(entry.path, stateDir)) {
354
+ diagnostics.push({
355
+ level: 'info',
356
+ code: 'legacy-state-archived-concurrently',
357
+ path: entry.path,
358
+ stateDir,
359
+ message: `legacy state at "${entry.path}" was archived by another process`,
360
+ });
361
+ continue;
362
+ }
363
+ const archivedPath = `${entry.path}.migrated-${randomUUID()}`;
364
+ let renamed = false;
365
+ try {
366
+ renameSync(entry.path, archivedPath);
367
+ renamed = true;
368
+ if (stateFingerprint(archivedPath) !== entry.fingerprint) {
369
+ throw new Error(`legacy state changed while it was archived from "${entry.path}"`);
370
+ }
371
+ symlinkSync(stateDir, entry.path, process.platform === 'win32' ? 'junction' : 'dir');
372
+ } catch (error) {
373
+ if (!renamed && error?.code === 'ENOENT' && !pathExists(entry.path)) {
374
+ diagnostics.push({
375
+ level: 'info',
376
+ code: 'legacy-state-archived-concurrently',
377
+ path: entry.path,
378
+ stateDir,
379
+ message: `legacy state at "${entry.path}" was archived by another process`,
380
+ });
381
+ continue;
382
+ }
383
+ try {
384
+ if (isCompatibilityLink(entry.path, stateDir)) rmSync(entry.path, { force: true });
385
+ if (!pathExists(entry.path) && pathExists(archivedPath)) renameSync(archivedPath, entry.path);
386
+ } catch {
387
+ // Keep the migration error; both locations are named in it.
388
+ }
389
+ throw error;
390
+ }
391
+ diagnostics.push({
392
+ level: 'info',
393
+ code: 'legacy-state-archived',
394
+ path: entry.path,
395
+ archivedPath,
396
+ stateDir,
397
+ message: `archived legacy state at "${archivedPath}" and linked its old path to "${stateDir}"`,
398
+ });
399
+ }
400
+ }
401
+
402
+ function stateConflict(stateDir, legacyStateDirs, conflictingPaths) {
403
+ const diagnostics = conflictingPaths.map((path) => ({
404
+ level: 'error',
405
+ code: 'legacy-state-conflict',
406
+ path,
407
+ stateDir,
408
+ message: `legacy state at "${path}" conflicts with another per-clone state`,
409
+ }));
410
+ return new StateMigrationError(
411
+ `conflicting legacy state found; select which state to keep at "${stateDir}"`,
412
+ { stateDir, legacyStateDirs, diagnostics },
413
+ );
414
+ }
415
+
416
+ // stagedEntries snapshots index changes, including deletion metadata. Rename
417
+ // and copy entries use the destination path; object IDs keep blob reads stable.
418
+ export function stagedEntries(repo) {
419
+ return diffEntries(repo, ['--cached']);
420
+ }
421
+
422
+ // stagedPaths is the path-only view of staged index changes. It reads names
423
+ // directly (git diff --name-only) so it neither spends a cat-file --batch-check
424
+ // round-trip nor throws on an object git cannot resolve — path rules only need
425
+ // the path. Rename/copy entries report their destination, matching stagedEntries.
426
+ export function stagedPaths(repo) {
427
+ return nulStrings(gitBuf([
428
+ 'diff', '--cached', '--name-only', '--find-renames', '-z', '--diff-filter=ACMRTD', '--',
429
+ ], repo.root));
430
+ }
431
+
432
+ // Unmerged index entries have only stages 1-3 and are omitted by the ordinary
433
+ // cached diff. Expose their paths so callers cannot mistake a conflicted index
434
+ // for a complete, clean staged snapshot.
435
+ export function unmergedPaths(repo) {
436
+ const paths = new Set();
437
+ for (const record of nulStrings(gitBuf(['ls-files', '--unmerged', '-z'], repo.root))) {
438
+ const tab = record.indexOf('\t');
439
+ const metadata = tab < 0 ? '' : record.slice(0, tab);
440
+ const path = tab < 0 ? '' : record.slice(tab + 1);
441
+ if (!/^\d+ [0-9a-f]+ [1-3]$/.test(metadata) || !path) {
442
+ throw new Error('unexpected unmerged index entry');
443
+ }
444
+ paths.add(path);
445
+ }
446
+ return [...paths];
447
+ }
448
+
449
+ // stagedRenameSources identifies low-similarity rename sources for selected
450
+ // destinations. Git does not record a rename in the index, so this conservative
451
+ // second pass prevents clean-mode repair from leaving only the deletion staged.
452
+ export function stagedRenameSources(repo, destinations) {
453
+ const selected = new Set(destinations);
454
+ if (!selected.size) return [];
455
+ const entries = diffEntries(repo, ['--cached'], 1);
456
+ const sources = new Set(entries
457
+ .filter((entry) => entry.status === 'R' && selected.has(entry.path) && entry.sourcePath)
458
+ .map((entry) => entry.sourcePath));
459
+
460
+ // Git stores no rename relation in the index. A zero-similarity rename is
461
+ // indistinguishable from one staged add plus one or more staged deletes, so
462
+ // there is no exact source to recover. When a blocked destination remains an
463
+ // add even at the lowest similarity threshold, conservatively restore every
464
+ // staged deletion. This may unstage an unrelated deletion, but it never lets
465
+ // automatic repair silently commit half of a possible rename.
466
+ if (entries.some((entry) => entry.status === 'A' && selected.has(entry.path))) {
467
+ for (const entry of entries) {
468
+ if (entry.status === 'D') sources.add(entry.path);
469
+ }
470
+ }
471
+ return [...sources];
472
+ }
473
+
474
+ // readStagedPath distinguishes an absent index path from an unreadable index or
475
+ // object. The returned object ID pins the bytes used by policy resolution.
476
+ export function readStagedPath(repo, path) {
477
+ assertGitPath(path);
478
+ const target = 'staged';
479
+ let records;
480
+ try {
481
+ records = nulStrings(gitBuf([
482
+ 'ls-files', '--stage', '-z', '--', `:(top,literal)${path}`,
483
+ ], repo.root));
484
+ } catch (error) {
485
+ throw new GitTargetReadError(target, path, gitErrorDetail(error), error);
486
+ }
487
+ if (!records.length) return missingPath(target, path);
488
+
489
+ let selected = null;
490
+ let unmerged = false;
491
+ for (const record of records) {
492
+ const tab = record.indexOf('\t');
493
+ const match = /^(\d+) ([0-9a-f]+) ([0-3])$/.exec(record.slice(0, tab));
494
+ if (tab < 0 || !match || record.slice(tab + 1) !== path) {
495
+ throw new GitTargetReadError(target, path, 'unexpected index entry');
496
+ }
497
+ if (match[3] === '0') selected = { mode: match[1], oid: match[2] };
498
+ else unmerged = true;
499
+ }
500
+ if (unmerged || !selected) {
501
+ throw new GitTargetReadError(target, path, 'path has unmerged index stages');
502
+ }
503
+ return presentPath(repo, target, path, selected);
504
+ }
505
+
506
+ // readCommitPath resolves revision to a commit first. Only a missing path is a
507
+ // normal fallback; an invalid revision or an object read error is reported.
508
+ export function readCommitPath(repo, revision, path) {
509
+ assertGitPath(path);
510
+ let oid;
511
+ try {
512
+ assertRevision(revision, 'revision');
513
+ oid = gitBuf([
514
+ 'rev-parse', '--verify', '--quiet', '--end-of-options', `${revision}^{commit}`,
515
+ ], repo.root).toString('utf8').trim();
516
+ } catch (error) {
517
+ if (error instanceof TypeError || error?.status === 1) {
518
+ throw new GitRevisionError(revision, 'does not resolve to a commit', error);
519
+ }
520
+ throw new GitTargetReadError(`commit:${revision}`, path, gitErrorDetail(error), error);
521
+ }
522
+ if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/i.test(oid)) {
523
+ throw new GitTargetReadError(`commit:${revision}`, path, 'Git returned an invalid commit object ID');
524
+ }
525
+ const target = `commit:${oid}`;
526
+ let records;
527
+ try {
528
+ records = nulStrings(gitBuf([
529
+ 'ls-tree', '--full-tree', '-z', oid, '--', `:(top,literal)${path}`,
530
+ ], repo.root));
531
+ } catch (error) {
532
+ throw new GitTargetReadError(target, path, gitErrorDetail(error), error);
533
+ }
534
+ if (!records.length) return missingPath(target, path);
535
+ if (records.length !== 1) {
536
+ throw new GitTargetReadError(target, path, 'Git returned more than one tree entry');
537
+ }
538
+ const tab = records[0].indexOf('\t');
539
+ const match = /^(\d+) (\w+) ([0-9a-f]+)$/.exec(records[0].slice(0, tab));
540
+ if (tab < 0 || !match || records[0].slice(tab + 1) !== path) {
541
+ throw new GitTargetReadError(target, path, 'unexpected tree entry');
542
+ }
543
+ if (match[2] !== 'blob') {
544
+ throw new GitTargetReadError(target, path, `expected a blob, found ${match[2]}`);
545
+ }
546
+ return presentPath(repo, target, path, { mode: match[1], oid: match[3] });
547
+ }
548
+
549
+ export function hashBlob(repo, content) {
550
+ let oid;
551
+ try {
552
+ oid = gitBuf(['hash-object', '--stdin'], repo.root, content).toString('utf8').trim();
553
+ assertOid(oid);
554
+ } catch (error) {
555
+ throw new GitTargetReadError('worktree', '.aimhooman.json', gitErrorDetail(error), error);
556
+ }
557
+ return oid;
558
+ }
559
+
560
+ function assertGitPath(path) {
561
+ if (typeof path !== 'string' || !path || path.includes('\0')) {
562
+ throw new TypeError('path must be a non-empty Git path');
563
+ }
564
+ }
565
+
566
+ function missingPath(target, path) {
567
+ return { status: 'missing', target, path, oid: null, content: null };
568
+ }
569
+
570
+ function presentPath(repo, target, path, entry) {
571
+ let content;
572
+ try {
573
+ content = gitBuf(['cat-file', 'blob', entry.oid], repo.root);
574
+ } catch (error) {
575
+ throw new GitTargetReadError(target, path, gitErrorDetail(error), error);
576
+ }
577
+ return {
578
+ status: 'present',
579
+ target,
580
+ path,
581
+ oid: entry.oid,
582
+ mode: entry.mode,
583
+ content,
584
+ };
585
+ }
586
+
587
+ function gitErrorDetail(error) {
588
+ const stderr = Buffer.isBuffer(error?.stderr)
589
+ ? error.stderr.toString('utf8').trim()
590
+ : String(error?.stderr || '').trim();
591
+ return stderr || error?.message || 'Git read failed';
592
+ }
593
+
594
+ // trackedEntries snapshots every index entry. A resolved path has one stage-zero
595
+ // entry; an unresolved path keeps all stage 1/2/3 candidates so content scanning
596
+ // cannot miss a blob that exists only on one side of a conflict.
597
+ export function trackedEntries(repo) {
598
+ const records = nulStrings(gitBuf(['ls-files', '--stage', '-z'], repo.root));
599
+ const entries = [];
600
+ for (const record of records) {
601
+ const tab = record.indexOf('\t');
602
+ const match = /^(\d+) ([0-9a-f]+) ([0-3])$/.exec(record.slice(0, tab));
603
+ if (tab < 0 || !match) throw new Error('unexpected output from git ls-files --stage');
604
+ entries.push({
605
+ path: record.slice(tab + 1),
606
+ oid: match[2],
607
+ mode: match[1],
608
+ type: typeForMode(match[1]),
609
+ stage: Number(match[3]),
610
+ });
611
+ }
612
+ return enrichEntries(repo, entries);
613
+ }
614
+
615
+ // hasStagedChanges checks the complete index delta, including deletions that
616
+ // are intentionally excluded from content scanning.
617
+ export function hasStagedChanges(repo) {
618
+ try {
619
+ gitBuf(['diff', '--cached', '--quiet', '--'], repo.root);
620
+ return false;
621
+ } catch (error) {
622
+ if (error?.status === 1) return true;
623
+ throw error;
624
+ }
625
+ }
626
+
627
+ // withIndexFromTree exposes an immutable tree through Git's staged-index APIs.
628
+ // It is used by commit-msg after the dispatcher snapshots the would-be commit
629
+ // tree, so a chained hook cannot switch the policy by mutating the live index.
630
+ export function withIndexFromTree(repo, treeOid, fn) {
631
+ assertOid(treeOid);
632
+ if (typeof fn !== 'function') throw new TypeError('fn must be a function');
633
+ const type = gitStr(['cat-file', '-t', treeOid], repo.root);
634
+ if (type !== 'tree') throw new TypeError('treeOid must identify a Git tree');
635
+
636
+ mkdirSync(repo.stateDir, { recursive: true });
637
+ const temporary = mkdtempSync(join(repo.stateDir, '.commit-tree-index-'));
638
+ const index = join(temporary, 'index');
639
+ const previous = process.env.GIT_INDEX_FILE;
640
+ process.env.GIT_INDEX_FILE = index;
641
+ try {
642
+ gitBuf(['read-tree', treeOid], repo.root);
643
+ return fn();
644
+ } finally {
645
+ if (previous === undefined) delete process.env.GIT_INDEX_FILE;
646
+ else process.env.GIT_INDEX_FILE = previous;
647
+ rmSync(temporary, { recursive: true, force: true });
648
+ }
649
+ }
650
+
651
+ // introducedCommits returns each commit added by a proposed branch update,
652
+ // independent of reachability through other refs. Using `--not --all` here
653
+ // would let an ignored tag/remote ref pre-poison reachability and suppress the
654
+ // final scan. A newly created branch has no trusted old tip, so its full ancestry
655
+ // is checked; ordinary updates scan exactly old..new.
656
+ export function introducedCommits(repo, updates) {
657
+ const commits = [];
658
+ const seen = new Set();
659
+ for (const update of updates) {
660
+ const oldObjectId = update?.oldObjectId;
661
+ const newObjectId = update?.newObjectId;
662
+ assertOid(oldObjectId);
663
+ assertOid(newObjectId);
664
+ const revisions = /^0+$/.test(oldObjectId)
665
+ ? [newObjectId]
666
+ : [newObjectId, `^${oldObjectId}`];
667
+ const resolved = gitBuf(['rev-list', '--reverse', ...revisions], repo.root)
668
+ .toString('utf8')
669
+ .trim()
670
+ .split('\n')
671
+ .filter(Boolean);
672
+ for (const commit of resolved) {
673
+ if (!seen.has(commit)) {
674
+ seen.add(commit);
675
+ commits.push(commit);
676
+ }
677
+ }
678
+ }
679
+ return commits;
680
+ }
681
+
682
+ // gitConfig returns a git config value, or '' if unset.
683
+ export function gitConfig(root, key) {
684
+ try {
685
+ return gitStr(['config', '--get', key], root);
686
+ } catch {
687
+ return '';
688
+ }
689
+ }
690
+
691
+ // unstagePaths removes the given paths from the index (non-blocking).
692
+ // HEAD-safe: `git restore --staged` needs HEAD, so on the initial commit
693
+ // (no HEAD yet) it falls back to `git rm --cached`.
694
+ export function unstagePaths(repo, paths) {
695
+ if (!paths.length) return [];
696
+ const pathspecs = Buffer.from(paths.join('\0') + '\0');
697
+ const opts = {
698
+ cwd: repo.root,
699
+ env: gitEnvironment(),
700
+ input: pathspecs,
701
+ stdio: ['pipe', 'ignore', 'pipe'],
702
+ };
703
+ let hasHead = true;
704
+ try {
705
+ execFileSync('git', ['rev-parse', '--verify', 'HEAD'], opts);
706
+ } catch {
707
+ hasHead = false;
708
+ }
709
+ if (hasHead) {
710
+ execFileSync('git', [
711
+ '--literal-pathspecs', 'restore', '--staged',
712
+ '--pathspec-from-file=-', '--pathspec-file-nul',
713
+ ], opts);
714
+ } else {
715
+ execFileSync('git', [
716
+ '--literal-pathspecs', 'rm', '--cached', '--quiet', '--ignore-unmatch',
717
+ '--pathspec-from-file=-', '--pathspec-file-nul',
718
+ ], opts);
719
+ }
720
+ return paths;
721
+ }