@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/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
|
+
}
|