@rmyndharis/aimhooman 0.1.1 → 0.1.3
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 +5 -3
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.clinerules/aimhooman.md +5 -3
- package/.codex-plugin/plugin.json +1 -1
- package/.cursor/rules/aimhooman.mdc +5 -3
- package/.github/copilot-instructions.md +5 -3
- package/.kiro/steering/aimhooman.md +5 -3
- package/.windsurf/rules/aimhooman.md +5 -3
- package/AGENTS.md +5 -3
- package/CHANGELOG.md +177 -1
- package/CONTRIBUTING.md +36 -3
- package/GEMINI.md +5 -3
- package/README.md +18 -8
- package/bin/aimhooman.mjs +163 -49
- package/docs/design/frictionless-enforcement.md +28 -0
- package/package.json +1 -1
- package/rules/attribution.json +8 -8
- package/rules/markers.json +31 -5
- package/rules/paths.json +60 -33
- package/rules/secrets.json +2 -1
- package/skills/aimhooman/SKILL.md +5 -3
- package/src/atomic-write.mjs +18 -1
- package/src/git-environment.mjs +10 -0
- package/src/githooks.mjs +113 -19
- package/src/gitx.mjs +65 -270
- package/src/history-scan.mjs +8 -1
- package/src/hook.mjs +424 -47
- package/src/scan-session.mjs +11 -4
- package/src/scan-target.mjs +29 -3
package/src/gitx.mjs
CHANGED
|
@@ -12,21 +12,8 @@ import {
|
|
|
12
12
|
rmSync,
|
|
13
13
|
symlinkSync,
|
|
14
14
|
} from 'node:fs';
|
|
15
|
-
import { createHash, randomUUID } from 'node:crypto';
|
|
16
15
|
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
|
-
}
|
|
16
|
+
import { gitEnvironment, GIT_TIMEOUT_MS } from './git-environment.mjs';
|
|
30
17
|
|
|
31
18
|
export class GitRevisionError extends Error {
|
|
32
19
|
constructor(revision, detail, cause) {
|
|
@@ -53,6 +40,7 @@ function gitBuf(args, cwd, input) {
|
|
|
53
40
|
env: gitEnvironment(),
|
|
54
41
|
encoding: 'buffer',
|
|
55
42
|
maxBuffer: 64 * 1024 * 1024,
|
|
43
|
+
timeout: GIT_TIMEOUT_MS,
|
|
56
44
|
// Capture stderr into the thrown error (pipe) instead of letting git's
|
|
57
45
|
// raw stderr leak to the user's terminal alongside aimhooman's own
|
|
58
46
|
// diagnostics; stdin is ignored unless input is supplied.
|
|
@@ -162,257 +150,17 @@ export function openRepo(cwd = process.cwd()) {
|
|
|
162
150
|
const gitDir = gitStr(['rev-parse', '--absolute-git-dir'], root);
|
|
163
151
|
let commonDir = gitStr(['rev-parse', '--git-common-dir'], root);
|
|
164
152
|
if (!isAbsolute(commonDir)) commonDir = join(root, commonDir);
|
|
165
|
-
const state = prepareSharedState(commonDir, gitDir);
|
|
166
153
|
return {
|
|
167
154
|
root,
|
|
168
155
|
gitDir,
|
|
169
156
|
commonDir,
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
157
|
+
// Shared by linked worktrees, which is why it hangs off commonDir rather
|
|
158
|
+
// than gitDir. Every released version has written here.
|
|
159
|
+
stateDir: join(commonDir, 'aimhooman'),
|
|
173
160
|
excludeFile: join(commonDir, 'info', 'exclude'),
|
|
174
161
|
};
|
|
175
162
|
}
|
|
176
163
|
|
|
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
164
|
// stagedEntries snapshots index changes, including deletion metadata. Rename
|
|
417
165
|
// and copy entries use the destination path; object IDs keep blob reads stable.
|
|
418
166
|
export function stagedEntries(repo) {
|
|
@@ -452,7 +200,7 @@ export function unmergedPaths(repo) {
|
|
|
452
200
|
export function stagedRenameSources(repo, destinations) {
|
|
453
201
|
const selected = new Set(destinations);
|
|
454
202
|
if (!selected.size) return [];
|
|
455
|
-
const entries = diffEntries(repo, ['--cached'], 1);
|
|
203
|
+
const entries = diffEntries(repo, ['--cached'], '1%');
|
|
456
204
|
const sources = new Set(entries
|
|
457
205
|
.filter((entry) => entry.status === 'R' && selected.has(entry.path) && entry.sourcePath)
|
|
458
206
|
.map((entry) => entry.sourcePath));
|
|
@@ -462,7 +210,9 @@ export function stagedRenameSources(repo, destinations) {
|
|
|
462
210
|
// there is no exact source to recover. When a blocked destination remains an
|
|
463
211
|
// add even at the lowest similarity threshold, conservatively restore every
|
|
464
212
|
// staged deletion. This may unstage an unrelated deletion, but it never lets
|
|
465
|
-
// automatic repair silently commit half of a possible rename.
|
|
213
|
+
// automatic repair silently commit half of a possible rename. That threshold
|
|
214
|
+
// carries a % sign because git reads a bare 1 as the fraction 0.1, so -M1
|
|
215
|
+
// asks for 10% and leaves a 3%-similar rename here as an add plus a delete.
|
|
466
216
|
if (entries.some((entry) => entry.status === 'A' && selected.has(entry.path))) {
|
|
467
217
|
for (const entry of entries) {
|
|
468
218
|
if (entry.status === 'D') sources.add(entry.path);
|
|
@@ -478,8 +228,13 @@ export function readStagedPath(repo, path) {
|
|
|
478
228
|
const target = 'staged';
|
|
479
229
|
let records;
|
|
480
230
|
try {
|
|
231
|
+
// The --literal-pathspecs flag rather than :(top,literal) magic: magic
|
|
232
|
+
// parsing is disabled by GIT_LITERAL_PATHSPECS in the environment, which
|
|
233
|
+
// would leave the pathspec a literal filename no repository contains, so
|
|
234
|
+
// git matched nothing and the read reported the policy absent. cwd is
|
|
235
|
+
// already repo.root, which is what the top magic was for.
|
|
481
236
|
records = nulStrings(gitBuf([
|
|
482
|
-
'ls-files', '--stage', '-z', '--',
|
|
237
|
+
'--literal-pathspecs', 'ls-files', '--stage', '-z', '--', path,
|
|
483
238
|
], repo.root));
|
|
484
239
|
} catch (error) {
|
|
485
240
|
throw new GitTargetReadError(target, path, gitErrorDetail(error), error);
|
|
@@ -526,7 +281,7 @@ export function readCommitPath(repo, revision, path) {
|
|
|
526
281
|
let records;
|
|
527
282
|
try {
|
|
528
283
|
records = nulStrings(gitBuf([
|
|
529
|
-
'ls-tree', '--full-tree', '-z', oid, '--',
|
|
284
|
+
'--literal-pathspecs', 'ls-tree', '--full-tree', '-z', oid, '--', path,
|
|
530
285
|
], repo.root));
|
|
531
286
|
} catch (error) {
|
|
532
287
|
throw new GitTargetReadError(target, path, gitErrorDetail(error), error);
|
|
@@ -636,21 +391,40 @@ export function withIndexFromTree(repo, treeOid, fn) {
|
|
|
636
391
|
}
|
|
637
392
|
}
|
|
638
393
|
|
|
639
|
-
//
|
|
640
|
-
//
|
|
641
|
-
//
|
|
642
|
-
//
|
|
643
|
-
|
|
394
|
+
// gatedTips lists the local branch tips this guard has already cleared. Only
|
|
395
|
+
// refs/heads/* qualifies, because it is the only namespace cmdRefcheck gates;
|
|
396
|
+
// the refs under review are excluded, since Git already publishes them at the
|
|
397
|
+
// `committed` phase and a ref must never serve as its own proof.
|
|
398
|
+
function gatedTips(repo, reviewing) {
|
|
399
|
+
return gitBuf(['for-each-ref', '--format=%(refname) %(objectname)', 'refs/heads/'], repo.root)
|
|
400
|
+
.toString('utf8')
|
|
401
|
+
.split('\n')
|
|
402
|
+
.filter(Boolean)
|
|
403
|
+
.map((line) => line.split(' '))
|
|
404
|
+
.filter(([name, oid]) => name && oid && !reviewing.has(name))
|
|
405
|
+
.map(([, oid]) => oid);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// introducedCommits returns each commit added by a proposed branch update.
|
|
409
|
+
// Reachability through refs/heads/* is trusted because those commits passed this
|
|
410
|
+
// same guard when their branch was written; every other namespace is not. Using
|
|
411
|
+
// `--not --all` would let an ignored tag or remote ref pre-poison reachability
|
|
412
|
+
// and suppress the scan, so tips come from refs/heads/* alone. A newly created
|
|
413
|
+
// branch is measured against those tips instead of its full ancestry, which
|
|
414
|
+
// would otherwise rescan the entire repository on every `git checkout -b`;
|
|
415
|
+
// ordinary updates scan exactly old..new.
|
|
644
416
|
export function introducedCommits(repo, updates) {
|
|
645
417
|
const commits = [];
|
|
646
418
|
const seen = new Set();
|
|
419
|
+
const reviewing = new Set(updates.map((update) => update?.ref).filter(Boolean));
|
|
420
|
+
const gated = gatedTips(repo, reviewing);
|
|
647
421
|
for (const update of updates) {
|
|
648
422
|
const oldObjectId = update?.oldObjectId;
|
|
649
423
|
const newObjectId = update?.newObjectId;
|
|
650
424
|
assertOid(oldObjectId);
|
|
651
425
|
assertOid(newObjectId);
|
|
652
426
|
const revisions = /^0+$/.test(oldObjectId)
|
|
653
|
-
? [newObjectId]
|
|
427
|
+
? [newObjectId, ...(gated.length ? ['--not', ...gated] : [])]
|
|
654
428
|
: [newObjectId, `^${oldObjectId}`];
|
|
655
429
|
const resolved = gitBuf(['rev-list', '--reverse', ...revisions], repo.root)
|
|
656
430
|
.toString('utf8')
|
|
@@ -686,12 +460,28 @@ export function unstagePaths(repo, paths) {
|
|
|
686
460
|
cwd: repo.root,
|
|
687
461
|
env: gitEnvironment(),
|
|
688
462
|
input: pathspecs,
|
|
463
|
+
timeout: GIT_TIMEOUT_MS,
|
|
689
464
|
stdio: ['pipe', 'ignore', 'pipe'],
|
|
690
465
|
};
|
|
691
466
|
let hasHead = true;
|
|
692
467
|
try {
|
|
693
|
-
|
|
694
|
-
|
|
468
|
+
// Deliberately not `opts`: rev-parse never reads stdin, so feeding it the
|
|
469
|
+
// pathspec makes git exit before draining the pipe, and a pathspec over the
|
|
470
|
+
// ~64 KiB pipe buffer fails the write with EPIPE. That read as "no HEAD" and
|
|
471
|
+
// sent a repository that has one down the `rm --cached` branch, staging the
|
|
472
|
+
// deletion of every tracked path this was asked to restore.
|
|
473
|
+
execFileSync('git', ['rev-parse', '--verify', 'HEAD'], {
|
|
474
|
+
cwd: repo.root,
|
|
475
|
+
env: gitEnvironment(),
|
|
476
|
+
timeout: GIT_TIMEOUT_MS,
|
|
477
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
478
|
+
});
|
|
479
|
+
} catch (error) {
|
|
480
|
+
// Only git's own verdict may select the branch. An unborn HEAD exits 128;
|
|
481
|
+
// a timeout or a missing git carries no exit status, and guessing "no HEAD"
|
|
482
|
+
// there would delete instead of restore. Fail closed and let the caller stop
|
|
483
|
+
// the commit rather than silently rewrite the index.
|
|
484
|
+
if (typeof error?.status !== 'number') throw error;
|
|
695
485
|
hasHead = false;
|
|
696
486
|
}
|
|
697
487
|
if (hasHead) {
|
|
@@ -700,8 +490,13 @@ export function unstagePaths(repo, paths) {
|
|
|
700
490
|
'--pathspec-from-file=-', '--pathspec-file-nul',
|
|
701
491
|
], opts);
|
|
702
492
|
} else {
|
|
493
|
+
// -f waives only the check that the staged blob still matches the file
|
|
494
|
+
// on disk. With no HEAD that check has nothing else to pass against, so
|
|
495
|
+
// an artifact appended to between `git add` and `git commit` could never
|
|
496
|
+
// be unstaged. --cached leaves the worktree alone either way, and the
|
|
497
|
+
// sibling `restore --staged` branch enforces no equivalent check.
|
|
703
498
|
execFileSync('git', [
|
|
704
|
-
'--literal-pathspecs', 'rm', '--cached', '--quiet', '--ignore-unmatch',
|
|
499
|
+
'--literal-pathspecs', 'rm', '--cached', '-f', '--quiet', '--ignore-unmatch',
|
|
705
500
|
'--pathspec-from-file=-', '--pathspec-file-nul',
|
|
706
501
|
], opts);
|
|
707
502
|
}
|
package/src/history-scan.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { execFileSync } from 'node:child_process';
|
|
|
2
2
|
import { readFileSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
import { GitRevisionError } from './gitx.mjs';
|
|
5
|
-
import { gitEnvironment } from './git-environment.mjs';
|
|
5
|
+
import { gitEnvironment, GIT_TIMEOUT_MS } from './git-environment.mjs';
|
|
6
6
|
|
|
7
7
|
export const EMPTY_HISTORY_OID = '0'.repeat(40);
|
|
8
8
|
|
|
@@ -12,6 +12,11 @@ function gitBuffer(repo, args, input) {
|
|
|
12
12
|
env: gitEnvironment(),
|
|
13
13
|
encoding: 'buffer',
|
|
14
14
|
maxBuffer: 128 * 1024 * 1024,
|
|
15
|
+
timeout: GIT_TIMEOUT_MS,
|
|
16
|
+
// Same reason as gitBuf in gitx.mjs: without an explicit stdio,
|
|
17
|
+
// execFileSync echoes the child's stderr before it checks the exit
|
|
18
|
+
// status, so git's raw output reaches the terminal even on success.
|
|
19
|
+
stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'],
|
|
15
20
|
...(input === undefined ? {} : { input }),
|
|
16
21
|
});
|
|
17
22
|
}
|
|
@@ -26,6 +31,7 @@ function gitStringQuiet(repo, args) {
|
|
|
26
31
|
env: gitEnvironment(),
|
|
27
32
|
encoding: 'utf8',
|
|
28
33
|
maxBuffer: 128 * 1024 * 1024,
|
|
34
|
+
timeout: GIT_TIMEOUT_MS,
|
|
29
35
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
30
36
|
}).trim();
|
|
31
37
|
}
|
|
@@ -223,6 +229,7 @@ function isAncestor(repo, ancestor, descendant) {
|
|
|
223
229
|
cwd: repo.root,
|
|
224
230
|
env: gitEnvironment(),
|
|
225
231
|
stdio: ['ignore', 'ignore', 'pipe'],
|
|
232
|
+
timeout: GIT_TIMEOUT_MS,
|
|
226
233
|
});
|
|
227
234
|
return true;
|
|
228
235
|
} catch (error) {
|