reslop 0.1.0 → 0.1.2

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/lib/git.js CHANGED
@@ -6,16 +6,24 @@ const path = require('node:path');
6
6
 
7
7
  const diff = require('./diff.js');
8
8
  const { parseDiff, synthesizeNewFile, itemsFromFiles } = diff;
9
- const { itemPath } = require('./files.js');
10
- const { REVIEW_DIR } = require('./review.js');
9
+ const { itemPath, isPathInScope, REVIEW_DIR } = require('./files.js');
10
+ const deps = require('./deps.js');
11
+ const { foldDepItems, mergeDepFile, depFileMeta } = deps;
12
+ const { collectUsedNames, parseAuditReport, parseOutdatedReport } = deps;
13
+ const { mergeProposedItems, MANIFEST, LOCKFILE } = deps;
11
14
 
12
15
  const GIT_CONFIG = ['-c', 'core.quotepath=false'];
13
16
  const DIFF_OPTS = ['--no-color', '--no-ext-diff', '--no-renames', '-U3'];
14
17
  const GIT_ENCODING = 'utf8';
15
18
  const GIT_MAX_BUFFER = 32 * 1024 * 1024;
16
19
  const BINARY_SIZE = 1000000;
20
+ const AUDIT_MS = 15000;
21
+ const INSTALL_MS = 120000;
17
22
 
18
- const oneLine = (text) => text.trim().split('\n')[0] || 'git failed';
23
+ const oneLine = (text) => {
24
+ const line = text.trim().split('\n')[0];
25
+ return line || 'git failed';
26
+ };
19
27
 
20
28
  const requireOk = (result, fallback = 'git failed') => {
21
29
  if (result.status === 0) return result;
@@ -40,13 +48,9 @@ const dropReviewItems = (items) => {
40
48
 
41
49
  const runGit = (args, cwd, input) => {
42
50
  const env = { ...process.env, GIT_OPTIONAL_LOCKS: '0' };
43
- const options = {
44
- cwd,
45
- encoding: GIT_ENCODING,
46
- input,
47
- maxBuffer: GIT_MAX_BUFFER,
48
- env,
49
- };
51
+ const encoding = GIT_ENCODING;
52
+ const maxBuffer = GIT_MAX_BUFFER;
53
+ const options = { cwd, encoding, input, maxBuffer, env };
50
54
  const result = spawnSync('git', args, options);
51
55
  if (result.error) {
52
56
  if (result.error.code === 'ENOENT') throw new Error('git not found');
@@ -131,6 +135,264 @@ const mergeItems = (groups) => {
131
135
  return items;
132
136
  };
133
137
 
138
+ const gitText = (top, spec) => {
139
+ const result = runGit(['show', spec], top);
140
+ if (result.status !== 0) return '';
141
+ return result.stdout ?? '';
142
+ };
143
+
144
+ const worktreeText = (top, rel) => {
145
+ try {
146
+ return fs.readFileSync(path.join(top, rel), 'utf8');
147
+ } catch {
148
+ return '';
149
+ }
150
+ };
151
+
152
+ const readDepSides = (top, rev, origin, rel) => {
153
+ if (origin === 'untracked') {
154
+ const newText = worktreeText(top, rel);
155
+ return { oldText: '', newText };
156
+ }
157
+ if (origin === 'staged') {
158
+ const oldText = gitText(top, `HEAD:${rel}`);
159
+ const newText = gitText(top, `:${rel}`);
160
+ return { oldText, newText };
161
+ }
162
+ if (origin === 'unstaged') {
163
+ const indexed = gitText(top, `:${rel}`);
164
+ const oldText = indexed || gitText(top, `HEAD:${rel}`);
165
+ const newText = worktreeText(top, rel);
166
+ return { oldText, newText };
167
+ }
168
+ if (origin === 'commit' && rev) {
169
+ const oldText = gitText(top, `${rev}^:${rel}`);
170
+ const newText = gitText(top, `${rev}:${rel}`);
171
+ return { oldText, newText };
172
+ }
173
+ return { oldText: '', newText: '' };
174
+ };
175
+
176
+ const runNpmJson = (top, args, timeout) => {
177
+ const result = spawnSync('npm', args, {
178
+ cwd: top,
179
+ encoding: GIT_ENCODING,
180
+ maxBuffer: GIT_MAX_BUFFER,
181
+ timeout,
182
+ });
183
+ if (result.error) return '';
184
+ return result.stdout || '';
185
+ };
186
+
187
+ const collectAudit = (top) => {
188
+ const lock = path.join(top, LOCKFILE);
189
+ if (!fs.existsSync(lock)) return null;
190
+ const args = ['audit', '--json', '--package-lock-only'];
191
+ const text = runNpmJson(top, args, AUDIT_MS);
192
+ return parseAuditReport(text);
193
+ };
194
+
195
+ const collectOutdated = (top) => {
196
+ const manifest = path.join(top, MANIFEST);
197
+ if (!fs.existsSync(manifest)) return null;
198
+ const args = ['outdated', '--json', '--long'];
199
+ const text = runNpmJson(top, args, AUDIT_MS);
200
+ return parseOutdatedReport(text);
201
+ };
202
+
203
+ const pickNpmMap = (override, collect, top) => {
204
+ if (override !== undefined) return override;
205
+ return collect(top);
206
+ };
207
+
208
+ const foldLoaded = (items, top, rev, extra = {}) => {
209
+ const readSides = (origin, rel) => readDepSides(top, rev, origin, rel);
210
+ let usedNames = null;
211
+ let audit = null;
212
+ let outdated = null;
213
+ const live = extra.audit === true && !rev;
214
+ let hasDepDiff = false;
215
+ for (const item of items) {
216
+ if (!depFileMeta(itemPath(item))) continue;
217
+ hasDepDiff = true;
218
+ break;
219
+ }
220
+ if (hasDepDiff || live) usedNames = collectUsedNames(top);
221
+ if (live) {
222
+ audit = pickNpmMap(extra.auditMap, collectAudit, top);
223
+ outdated = pickNpmMap(extra.outdatedMap, collectOutdated, top);
224
+ } else if (extra.audit && hasDepDiff) {
225
+ audit = pickNpmMap(extra.auditMap, collectAudit, top);
226
+ }
227
+ const folded = foldDepItems(items, readSides, usedNames, audit, outdated, {
228
+ root: top,
229
+ });
230
+ if (!live) return folded;
231
+ if (!isPathInScope(MANIFEST, extra.paths ?? [])) return folded;
232
+ const pkgText = worktreeText(top, MANIFEST);
233
+ const lockText = worktreeText(top, LOCKFILE);
234
+ return mergeProposedItems(folded, pkgText, outdated, audit, {
235
+ lockText,
236
+ usedNames,
237
+ root: top,
238
+ });
239
+ };
240
+
241
+ const depPaths = (item) => item.dep?.files ?? [];
242
+
243
+ const origDepItems = (item) => item.dep?.items ?? [];
244
+
245
+ const depChange = (item) => item.dep?.change ?? null;
246
+
247
+ const writeWorktreeFile = (top, rel, text) => {
248
+ const abs = path.join(top, rel);
249
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
250
+ fs.writeFileSync(abs, text);
251
+ };
252
+
253
+ const restoreWorktreeFile = (top, rel, saved, existed) => {
254
+ const abs = path.join(top, rel);
255
+ if (existed) {
256
+ fs.writeFileSync(abs, saved);
257
+ return;
258
+ }
259
+ try {
260
+ fs.unlinkSync(abs);
261
+ } catch {
262
+ // missing
263
+ }
264
+ };
265
+
266
+ const writeIndexFile = (top, rel, text) => {
267
+ const abs = path.join(top, rel);
268
+ const existed = fs.existsSync(abs);
269
+ const saved = existed ? fs.readFileSync(abs, 'utf8') : '';
270
+ writeWorktreeFile(top, rel, text);
271
+ try {
272
+ requireOk(runGit(['add', '--', rel], top));
273
+ } finally {
274
+ restoreWorktreeFile(top, rel, saved, existed);
275
+ }
276
+ };
277
+
278
+ const mergeDepLive = (top, rel, change, origin, base, side, kind) => {
279
+ const sides = readDepSides(top, null, origin, rel);
280
+ return mergeDepFile(base, sides.oldText, sides.newText, change, side, kind);
281
+ };
282
+
283
+ const applyDepIndex = (top, rel, change, origin, side) => {
284
+ const meta = depFileMeta(rel);
285
+ if (!meta) return;
286
+ const indexed = gitText(top, `:${rel}`) || gitText(top, `HEAD:${rel}`);
287
+ const text = mergeDepLive(top, rel, change, origin, indexed, side, meta.kind);
288
+ writeIndexFile(top, rel, text);
289
+ };
290
+
291
+ const applyDepWorktree = (top, rel, change, origin) => {
292
+ const meta = depFileMeta(rel);
293
+ if (!meta) return;
294
+ const worktree = worktreeText(top, rel);
295
+ const text = mergeDepLive(
296
+ top,
297
+ rel,
298
+ change,
299
+ origin,
300
+ worktree,
301
+ 'old',
302
+ meta.kind,
303
+ );
304
+ writeWorktreeFile(top, rel, text);
305
+ };
306
+
307
+ const runNpm = (cwd, args) =>
308
+ spawnSync('npm', args, {
309
+ cwd,
310
+ encoding: GIT_ENCODING,
311
+ maxBuffer: GIT_MAX_BUFFER,
312
+ timeout: INSTALL_MS,
313
+ });
314
+
315
+ const firstDepRel = (files, kind) => {
316
+ for (const rel of files) {
317
+ const meta = depFileMeta(rel);
318
+ if (meta && meta.kind === kind) return rel;
319
+ }
320
+ return '';
321
+ };
322
+
323
+ const applyProposedUpdate = (top, item) => {
324
+ const change = depChange(item);
325
+ const origin = item.origin;
326
+ const files = depPaths(item);
327
+ const lockOnly = change.section === 'resolved';
328
+ const removing = !!change.unused;
329
+ const manifest = firstDepRel(files, 'manifest');
330
+ const lockRel = firstDepRel(files, 'lockfile');
331
+ const picked = lockOnly ? lockRel : manifest;
332
+ const target = picked || files[0] || '';
333
+ const meta = depFileMeta(target);
334
+ if (!meta) return;
335
+ let saved = '';
336
+ if (!lockOnly && !removing && manifest) {
337
+ const worktree = worktreeText(top, manifest);
338
+ saved = worktree;
339
+ const text = mergeDepLive(
340
+ top,
341
+ manifest,
342
+ change,
343
+ origin,
344
+ worktree,
345
+ 'new',
346
+ 'manifest',
347
+ );
348
+ writeWorktreeFile(top, manifest, text);
349
+ }
350
+ const cwd = meta.dir === '.' ? top : path.join(top, meta.dir);
351
+ let npmArgs = ['i'];
352
+ if (removing) npmArgs = ['uninstall', change.name];
353
+ else if (lockOnly) npmArgs = ['audit', 'fix'];
354
+ const install = item.dep.install ?? ((dir) => runNpm(dir, npmArgs));
355
+ const result = install(cwd);
356
+ const fail = removing ? 'npm uninstall failed' : 'npm i failed';
357
+ if (result && (result.error || result.status !== 0)) {
358
+ if (saved && manifest) writeWorktreeFile(top, manifest, saved);
359
+ if (result.error) {
360
+ const msg = result.error.message || fail;
361
+ throw new Error(oneLine(msg));
362
+ }
363
+ const msg = result.stderr || result.stdout || fail;
364
+ throw new Error(oneLine(msg));
365
+ }
366
+ const toAdd = [];
367
+ if (!lockOnly && manifest) toAdd.push(manifest);
368
+ const dirLock = meta.dir === '.' ? LOCKFILE : `${meta.dir}/${LOCKFILE}`;
369
+ const lockPath = lockRel || dirLock;
370
+ if (fs.existsSync(path.join(top, lockPath))) toAdd.push(lockPath);
371
+ if (toAdd.length) requireOk(runGit(['add', '--', ...toAdd], top));
372
+ };
373
+
374
+ const applyDepChange = (top, item, mode) => {
375
+ const change = depChange(item);
376
+ if (change && change.propose) {
377
+ if (mode === 'add') return void applyProposedUpdate(top, item);
378
+ if (!change.unused) return;
379
+ }
380
+ const origin = item.origin;
381
+ const files = depPaths(item);
382
+ for (const rel of files) {
383
+ if (mode === 'add') {
384
+ applyDepIndex(top, rel, change, origin, 'new');
385
+ continue;
386
+ }
387
+ if (mode === 'unstage') {
388
+ applyDepIndex(top, rel, change, origin, 'old');
389
+ continue;
390
+ }
391
+ if (origin === 'staged') applyDepIndex(top, rel, change, origin, 'old');
392
+ applyDepWorktree(top, rel, change, origin);
393
+ }
394
+ };
395
+
134
396
  const resolveRev = (cwd, spec) => {
135
397
  if (!spec) return null;
136
398
  const top = toplevel(cwd);
@@ -174,7 +436,9 @@ const load = (cwd, paths = [], options = {}) => {
174
436
  const rev = options.commit;
175
437
  if (rev) {
176
438
  const files = loadCommit(top, rev, extra);
177
- const items = dropReviewItems(itemsFromFiles(files, 'commit'));
439
+ const commitItems = itemsFromFiles(files, 'commit');
440
+ const parsed = dropReviewItems(commitItems);
441
+ const items = foldLoaded(parsed, top, rev, { ...options, paths });
178
442
  const revShort = shortRev(top, rev);
179
443
  return { top, items, rev, revShort };
180
444
  }
@@ -188,15 +452,28 @@ const load = (cwd, paths = [], options = {}) => {
188
452
  const unstagedArgs = [...GIT_CONFIG, 'diff', ...DIFF_OPTS, ...extra];
189
453
  const stagedRes = requireOk(runGit(stagedArgs, top));
190
454
  const unstagedRes = requireOk(runGit(unstagedArgs, top));
191
- const staged = itemsFromFiles(parseDiff(stagedRes.stdout), 'staged');
192
- const unstaged = itemsFromFiles(parseDiff(unstagedRes.stdout), 'unstaged');
455
+ const stagedDiff = parseDiff(stagedRes.stdout);
456
+ const unstagedDiff = parseDiff(unstagedRes.stdout);
457
+ const staged = itemsFromFiles(stagedDiff, 'staged');
458
+ const unstaged = itemsFromFiles(unstagedDiff, 'unstaged');
193
459
  const untracked = untrackedItems(top, paths);
194
- const items = dropReviewItems(mergeItems([staged, unstaged, untracked]));
460
+ const combined = mergeItems([staged, unstaged, untracked]);
461
+ const merged = dropReviewItems(combined);
462
+ const items = foldLoaded(merged, top, undefined, { ...options, paths });
195
463
  return { top, items };
196
464
  };
197
465
 
198
466
  const addItem = (top, item) => {
199
467
  if (item.origin === 'staged' || item.origin === 'commit') return;
468
+ if (depChange(item)) {
469
+ applyDepChange(top, item, 'add');
470
+ return;
471
+ }
472
+ const files = depPaths(item);
473
+ if (files.length) {
474
+ requireOk(runGit(['add', '--', ...files], top));
475
+ return;
476
+ }
200
477
  if (item.origin === 'untracked' || item.file.isBinary) {
201
478
  requireOk(runGit(['add', '--', item.file.newPath], top));
202
479
  return;
@@ -206,6 +483,15 @@ const addItem = (top, item) => {
206
483
 
207
484
  const unstageItem = (top, item) => {
208
485
  if (item.origin !== 'staged') return;
486
+ if (depChange(item)) {
487
+ applyDepChange(top, item, 'unstage');
488
+ return;
489
+ }
490
+ const files = depPaths(item);
491
+ if (files.length) {
492
+ requireOk(runGit(['restore', '--staged', '--', ...files], top));
493
+ return;
494
+ }
209
495
  const rel = itemPath(item);
210
496
  if (item.file.isBinary) {
211
497
  requireOk(runGit(['restore', '--staged', '--', rel], top));
@@ -214,8 +500,62 @@ const unstageItem = (top, item) => {
214
500
  applyPatch(top, item.patchAdd, ['--reverse', '--cached']);
215
501
  };
216
502
 
503
+ const revertOnePath = (top, rel, items) => {
504
+ const origins = new Set();
505
+ let isNew = false;
506
+ for (const item of items) {
507
+ origins.add(item.origin);
508
+ if (item.file && item.file.isNew) isNew = true;
509
+ }
510
+ if (isNew) {
511
+ if (origins.has('staged')) {
512
+ requireOk(runGit(['rm', '-f', '--', rel], top));
513
+ return;
514
+ }
515
+ fs.unlinkSync(path.join(top, rel));
516
+ return;
517
+ }
518
+ if (origins.has('untracked') && origins.size === 1) {
519
+ fs.unlinkSync(path.join(top, rel));
520
+ return;
521
+ }
522
+ const args = ['restore', '-s', 'HEAD', '--worktree', '--staged', '--', rel];
523
+ requireOk(runGit(args, top));
524
+ };
525
+
526
+ const itemsForPath = (rel, items) => {
527
+ const group = [];
528
+ for (const item of items) {
529
+ if (item.dep) {
530
+ for (const orig of origDepItems(item)) {
531
+ if (itemPath(orig) === rel) group.push(orig);
532
+ }
533
+ continue;
534
+ }
535
+ if (itemPath(item) === rel) group.push(item);
536
+ }
537
+ return group;
538
+ };
539
+
217
540
  const revertItem = (top, item) => {
218
541
  if (item.origin === 'commit') return;
542
+ if (depChange(item)) {
543
+ applyDepChange(top, item, 'revert');
544
+ return;
545
+ }
546
+ if (item.dep) {
547
+ const byPath = new Map();
548
+ for (const orig of origDepItems(item)) {
549
+ const rel = itemPath(orig);
550
+ const list = byPath.get(rel);
551
+ if (list) list.push(orig);
552
+ else byPath.set(rel, [orig]);
553
+ }
554
+ for (const rel of byPath.keys()) {
555
+ revertOnePath(top, rel, byPath.get(rel));
556
+ }
557
+ return;
558
+ }
219
559
  const rel = itemPath(item);
220
560
  if (item.origin === 'untracked') {
221
561
  fs.unlinkSync(path.join(top, rel));
@@ -240,22 +580,31 @@ const revertItem = (top, item) => {
240
580
  }
241
581
  };
242
582
 
583
+ const revertFile = (top, rel, items) => {
584
+ const paths = new Set([rel]);
585
+ for (const item of items) {
586
+ for (const file of depPaths(item)) paths.add(file);
587
+ }
588
+ for (const file of paths) {
589
+ const group = itemsForPath(file, items);
590
+ const targets = group.length ? group : items;
591
+ revertOnePath(top, file, targets);
592
+ }
593
+ };
594
+
243
595
  const createGitRepo = () => ({
244
596
  load,
245
597
  resolveRev,
246
598
  add: addItem,
247
599
  unstage: unstageItem,
248
600
  revert: revertItem,
601
+ revertFile,
249
602
  });
250
603
 
251
604
  module.exports = {
252
- runGit,
253
- toplevel,
254
605
  load,
255
- resolveRev,
256
606
  addItem,
257
607
  unstageItem,
258
608
  revertItem,
259
609
  createGitRepo,
260
- oneLine,
261
610
  };