@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/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
- stateDir: state.stateDir,
171
- legacyStateDirs: state.legacyStateDirs,
172
- stateDiagnostics: state.diagnostics,
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', '--', `:(top,literal)${path}`,
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, '--', `:(top,literal)${path}`,
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
- // introducedCommits returns each commit added by a proposed branch update,
640
- // independent of reachability through other refs. Using `--not --all` here
641
- // would let an ignored tag/remote ref pre-poison reachability and suppress the
642
- // final scan. A newly created branch has no trusted old tip, so its full ancestry
643
- // is checked; ordinary updates scan exactly old..new.
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
- execFileSync('git', ['rev-parse', '--verify', 'HEAD'], opts);
694
- } catch {
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
  }
@@ -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) {