reslop 0.2.0 → 0.2.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.
Files changed (43) hide show
  1. package/CHANGELOG.md +27 -1
  2. package/README.md +13 -2
  3. package/lib/ansi.js +263 -6
  4. package/lib/api.js +2 -0
  5. package/lib/capabilities.js +6 -0
  6. package/lib/cli.js +13 -2
  7. package/lib/deps.js +43 -3
  8. package/lib/git-branches.js +228 -17
  9. package/lib/git-reword-todo.js +20 -0
  10. package/lib/git.js +20 -3
  11. package/lib/keys.js +10 -4
  12. package/lib/npm-commands.js +513 -0
  13. package/lib/render/branches.js +23 -58
  14. package/lib/render/chrome.js +156 -99
  15. package/lib/render/commits.js +643 -31
  16. package/lib/render/diff.js +4 -4
  17. package/lib/render/frame.js +51 -5
  18. package/lib/render/notes.js +22 -5
  19. package/lib/render/npm.js +228 -0
  20. package/lib/render/primitives.js +48 -34
  21. package/lib/render/render.js +3 -3
  22. package/lib/report-model.js +127 -0
  23. package/lib/report-parse.js +94 -0
  24. package/lib/report-render.js +87 -0
  25. package/lib/report-run.js +164 -0
  26. package/lib/report-test.js +506 -0
  27. package/lib/report-tools.js +311 -0
  28. package/lib/review-merge.js +270 -0
  29. package/lib/review.js +38 -6
  30. package/lib/session/actions.js +81 -5
  31. package/lib/session/commit.js +187 -19
  32. package/lib/session/compose.js +96 -32
  33. package/lib/session/dispatch.js +36 -2
  34. package/lib/session/files-pane.js +36 -6
  35. package/lib/session/lifecycle.js +116 -16
  36. package/lib/session/navigation.js +1 -0
  37. package/lib/session/npm.js +335 -0
  38. package/lib/session/pointer.js +12 -1
  39. package/lib/session/todos.js +29 -1
  40. package/lib/session/view.js +14 -3
  41. package/lib/session/watch.js +120 -8
  42. package/lib/session.js +11 -2
  43. package/package.json +7 -7
@@ -1,5 +1,9 @@
1
1
  'use strict';
2
2
 
3
+ const fs = require('node:fs');
4
+ const os = require('node:os');
5
+ const path = require('node:path');
6
+
3
7
  const { oneLine, requireOk, runGit, runGitAsync } = require('./git.js');
4
8
 
5
9
  const BRANCH_FORMAT = [
@@ -20,27 +24,59 @@ const PUSH_REJECTED = [
20
24
  'updates were rejected',
21
25
  ];
22
26
 
23
- const lastMessage = (top) => {
24
- const result = runGit(['log', '-1', '--format=%B'], top);
27
+ const commitMessage = (top, sha) => {
28
+ const spec = `${sha ?? ''}`.trim() || 'HEAD';
29
+ const result = runGit(['log', '-1', '--format=%B', spec], top);
25
30
  if (result.status !== 0) return '';
26
31
  return (result.stdout ?? '').replace(/\s+$/, '');
27
32
  };
28
33
 
29
- const COMMIT_FORMAT = ['%H', '%h', '%an', '%ar', '%s'].join('%x1f');
34
+ const lastMessage = (top) => commitMessage(top, 'HEAD');
35
+
36
+ const COMMIT_FIELDS = ['%H', '%h', '%an', '%ae', '%ar', '%ci', '%D', '%s'];
37
+ const COMMIT_FORMAT = `${COMMIT_FIELDS.join('%x1f')}%x1e%B`;
30
38
  const COMMIT_LIMIT = 256;
31
39
  const UNIT = '\x1f';
40
+ const RECORD = '\x1e';
41
+ const TAG_REF = /^tag: /;
42
+ const REMOTE_HEAD = /\/HEAD(?: -> |$)/;
43
+
44
+ const formatCommitRefs = (raw) => {
45
+ const text = `${raw ?? ''}`.trim();
46
+ if (!text) return '';
47
+ const names = [];
48
+ for (const part of text.split(', ')) {
49
+ const name = part.trim();
50
+ if (!name || TAG_REF.test(name)) continue;
51
+ if (name !== 'HEAD' && REMOTE_HEAD.test(name)) continue;
52
+ names.push(name);
53
+ }
54
+ return names.join(', ');
55
+ };
32
56
 
33
- const parseCommitLine = (line, index) => {
34
- const parts = line.split(UNIT);
35
- if (parts.length < 5) return null;
57
+ const parseCommitRecord = (record, index) => {
58
+ const cut = record.indexOf(RECORD);
59
+ if (cut < 0) return null;
60
+ const parts = record.slice(0, cut).split(UNIT);
61
+ if (parts.length < COMMIT_FIELDS.length) return null;
36
62
  const sha = (parts[0] ?? '').trim();
37
63
  if (!sha) return null;
64
+ const body = record.slice(cut + 1).replace(/\s+$/, '');
65
+ const read = (spec) => {
66
+ const at = COMMIT_FIELDS.indexOf(spec);
67
+ return (parts[at] ?? '').trim();
68
+ };
69
+ const subject = read('%s') || body.split('\n')[0].trim();
38
70
  return {
39
71
  sha,
40
- shortSha: (parts[1] ?? '').trim() || sha.slice(0, 7),
41
- author: (parts[2] ?? '').trim(),
42
- date: (parts[3] ?? '').trim(),
43
- subject: parts.slice(4).join(UNIT).trim(),
72
+ shortSha: read('%h') || sha.slice(0, 7),
73
+ author: read('%an'),
74
+ email: read('%ae'),
75
+ date: read('%ar'),
76
+ when: read('%ci'),
77
+ refs: formatCommitRefs(read('%D')),
78
+ subject,
79
+ body,
44
80
  head: index === 0,
45
81
  };
46
82
  };
@@ -48,14 +84,15 @@ const parseCommitLine = (line, index) => {
48
84
  const listCommits = (top) => {
49
85
  const args = [
50
86
  'log',
87
+ '-z',
51
88
  `--format=${COMMIT_FORMAT}`,
52
89
  `--max-count=${COMMIT_LIMIT}`,
53
90
  ];
54
91
  const result = requireOk(runGit(args, top), 'log failed');
55
92
  const commits = [];
56
- for (const line of result.stdout.split('\n')) {
57
- if (!line) continue;
58
- const entry = parseCommitLine(line, commits.length);
93
+ for (const record of result.stdout.split('\0')) {
94
+ if (!record) continue;
95
+ const entry = parseCommitRecord(record, commits.length);
59
96
  if (entry) commits.push(entry);
60
97
  }
61
98
  return commits;
@@ -120,6 +157,179 @@ const dropCommitAsync = async (top, sha) => {
120
157
  await dropOlderCommitAsync(top, target);
121
158
  };
122
159
 
160
+ const FIXUP_MARK = 'fixup!';
161
+ const AUTOSQUASH_ARGS = [
162
+ '-c',
163
+ 'sequence.editor=:',
164
+ 'rebase',
165
+ '--interactive',
166
+ '--autosquash',
167
+ ];
168
+
169
+ const isFixupSubject = (subject) =>
170
+ `${subject ?? ''}`.trim().startsWith(FIXUP_MARK);
171
+
172
+ const fixupRest = (subject) => {
173
+ let text = `${subject ?? ''}`.trim();
174
+ while (text.startsWith(FIXUP_MARK)) {
175
+ text = text.slice(FIXUP_MARK.length).trim();
176
+ }
177
+ return text;
178
+ };
179
+
180
+ const commitSubject = (top, sha) => {
181
+ const args = ['log', '-1', '--format=%s', sha];
182
+ const result = requireOk(runGit(args, top), 'log failed');
183
+ return (result.stdout ?? '').trim();
184
+ };
185
+
186
+ const ancestorWithSubject = (top, sha, want) => {
187
+ const args = ['log', '--format=%H%x1f%s', `${sha}^`];
188
+ const result = runGit(args, top);
189
+ if (result.status !== 0) return '';
190
+ for (const line of result.stdout.split('\n')) {
191
+ const cut = line.indexOf(UNIT);
192
+ if (cut < 0) continue;
193
+ const subject = line.slice(cut + 1).trim();
194
+ if (subject !== want) continue;
195
+ return line.slice(0, cut).trim();
196
+ }
197
+ return '';
198
+ };
199
+
200
+ const applyAutosquash = (top, ontoArgs) => {
201
+ const result = runGit([...AUTOSQUASH_ARGS, ...ontoArgs], top);
202
+ const failed = rebaseFailed(result);
203
+ if (!failed) return;
204
+ runGit(['rebase', '--abort'], top);
205
+ throw new Error(oneLine(failed));
206
+ };
207
+
208
+ const applyAutosquashAsync = async (top, ontoArgs) => {
209
+ const result = await runGitAsync([...AUTOSQUASH_ARGS, ...ontoArgs], top);
210
+ const failed = rebaseFailed(result);
211
+ if (!failed) return;
212
+ await runGitAsync(['rebase', '--abort'], top);
213
+ throw new Error(oneLine(failed));
214
+ };
215
+
216
+ const applyFixupOnto = (top, original, applyRange) => {
217
+ const parent = runGit(['rev-parse', '--verify', `${original}^`], top);
218
+ if (parent.status !== 0) return applyRange(top, ['--root']);
219
+ return applyRange(top, [(parent.stdout ?? '').trim()]);
220
+ };
221
+
222
+ const applyFixup = (top, sha) => {
223
+ const spec = namedCommit(sha);
224
+ const commit = resolveCommit(top, spec);
225
+ const subject = commitSubject(top, commit);
226
+ if (!isFixupSubject(subject)) throw new Error('not a fixup commit');
227
+ const want = fixupRest(subject);
228
+ if (!want) throw new Error('no fixup target');
229
+ const original = ancestorWithSubject(top, commit, want);
230
+ if (!original) throw new Error('no fixup target');
231
+ applyFixupOnto(top, original, applyAutosquash);
232
+ };
233
+
234
+ const applyFixupAsync = async (top, sha) => {
235
+ const spec = namedCommit(sha);
236
+ const commit = resolveCommit(top, spec);
237
+ const subject = commitSubject(top, commit);
238
+ if (!isFixupSubject(subject)) throw new Error('not a fixup commit');
239
+ const want = fixupRest(subject);
240
+ if (!want) throw new Error('no fixup target');
241
+ const original = ancestorWithSubject(top, commit, want);
242
+ if (!original) throw new Error('no fixup target');
243
+ await applyFixupOnto(top, original, applyAutosquashAsync);
244
+ };
245
+
246
+ const messageBody = (message) => {
247
+ const text = `${message ?? ''}`;
248
+ return text.endsWith('\n') ? text : `${text}\n`;
249
+ };
250
+
251
+ const rewordHead = (top, message) => {
252
+ const args = ['commit', '--amend', '--only', '-F', '-'];
253
+ requireOk(runGit(args, top, messageBody(message)));
254
+ };
255
+
256
+ const REWORD_TODO = path.join(__dirname, 'git-reword-todo.js');
257
+
258
+ const prepareRewordEditor = (message) => {
259
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'reslop-reword-'));
260
+ const msgFile = path.join(dir, 'MSG');
261
+ fs.writeFileSync(msgFile, messageBody(message));
262
+ const node = JSON.stringify(process.execPath);
263
+ const script = JSON.stringify(REWORD_TODO);
264
+ return {
265
+ dir,
266
+ env: {
267
+ GIT_SEQUENCE_EDITOR: `${node} ${script}`,
268
+ RESLOP_REWORD_FILE: msgFile,
269
+ },
270
+ };
271
+ };
272
+
273
+ const rewordRange = (top, sha, env) => {
274
+ const parent = runGit(['rev-parse', '--verify', `${sha}^`], top);
275
+ const onto =
276
+ parent.status !== 0 ? ['--root'] : [(parent.stdout ?? '').trim()];
277
+ return runGit(['rebase', '--interactive', ...onto], top, undefined, { env });
278
+ };
279
+
280
+ const rewordRangeAsync = async (top, sha, env) => {
281
+ const parent = runGit(['rev-parse', '--verify', `${sha}^`], top);
282
+ const onto =
283
+ parent.status !== 0 ? ['--root'] : [(parent.stdout ?? '').trim()];
284
+ return runGitAsync(['rebase', '--interactive', ...onto], top, { env });
285
+ };
286
+
287
+ const finishRewordRebase = (top, result) => {
288
+ const failed = rebaseFailed(result);
289
+ if (!failed) return;
290
+ runGit(['rebase', '--abort'], top);
291
+ throw new Error(oneLine(failed));
292
+ };
293
+
294
+ const rewordOlder = (top, sha, message) => {
295
+ const prepared = prepareRewordEditor(message);
296
+ try {
297
+ const result = rewordRange(top, sha, prepared.env);
298
+ finishRewordRebase(top, result);
299
+ } finally {
300
+ fs.rmSync(prepared.dir, { recursive: true, force: true });
301
+ }
302
+ };
303
+
304
+ const rewordOlderAsync = async (top, sha, message) => {
305
+ const prepared = prepareRewordEditor(message);
306
+ try {
307
+ const result = await rewordRangeAsync(top, sha, prepared.env);
308
+ const failed = rebaseFailed(result);
309
+ if (!failed) return;
310
+ await runGitAsync(['rebase', '--abort'], top);
311
+ throw new Error(oneLine(failed));
312
+ } finally {
313
+ fs.rmSync(prepared.dir, { recursive: true, force: true });
314
+ }
315
+ };
316
+
317
+ const rewordCommit = (top, sha, message) => {
318
+ const spec = namedCommit(sha);
319
+ const commit = resolveCommit(top, spec);
320
+ const head = resolveCommit(top, 'HEAD');
321
+ if (commit === head) return void rewordHead(top, message);
322
+ rewordOlder(top, commit, message);
323
+ };
324
+
325
+ const rewordCommitAsync = async (top, sha, message) => {
326
+ const spec = namedCommit(sha);
327
+ const commit = resolveCommit(top, spec);
328
+ const head = resolveCommit(top, 'HEAD');
329
+ if (commit === head) return void rewordHead(top, message);
330
+ await rewordOlderAsync(top, commit, message);
331
+ };
332
+
123
333
  const parseTrack = (text) => {
124
334
  const raw = `${text ?? ''}`.trim().replace(/^\[|\]$/g, '');
125
335
  if (!raw) return { ahead: 0, behind: 0, gone: false };
@@ -338,10 +548,6 @@ const hasStaged = (top) => {
338
548
  };
339
549
 
340
550
  const commitPlan = (kind, message) => {
341
- if (kind === 'fixup') {
342
- const target = (message ?? '').trim() || 'HEAD';
343
- return { args: ['commit', `--fixup=${target}`], body: undefined };
344
- }
345
551
  const args = ['commit'];
346
552
  if (kind === 'amend') args.push('--amend');
347
553
  args.push('-F', '-');
@@ -362,6 +568,7 @@ const commitChangesAsync = async (top, kind, message) => {
362
568
 
363
569
  module.exports = {
364
570
  lastMessage,
571
+ commitMessage,
365
572
  listCommits,
366
573
  listBranches,
367
574
  checkoutBranch,
@@ -372,6 +579,10 @@ module.exports = {
372
579
  rebaseBranchAsync,
373
580
  dropCommit,
374
581
  dropCommitAsync,
582
+ applyFixup,
583
+ applyFixupAsync,
584
+ rewordCommit,
585
+ rewordCommitAsync,
375
586
  dropBranch,
376
587
  dropBranchAsync,
377
588
  pullChanges,
@@ -0,0 +1,20 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+
5
+ const file = process.argv[process.argv.length - 1];
6
+ const msg = process.env.RESLOP_REWORD_FILE;
7
+ if (!file || !msg) process.exit(1);
8
+ const text = fs.readFileSync(file, 'utf8');
9
+ const rows = text.split('\n');
10
+ const out = [];
11
+ let seen = false;
12
+ for (const row of rows) {
13
+ out.push(row);
14
+ if (seen) continue;
15
+ if (!/^(pick|p)\s+/.test(row)) continue;
16
+ const spec = JSON.stringify(msg);
17
+ out.push(`exec git commit --amend --only -F ${spec}`);
18
+ seen = true;
19
+ }
20
+ fs.writeFileSync(file, out.join('\n'));
package/lib/git.js CHANGED
@@ -25,7 +25,7 @@ const GIT_MAX_BUFFER = proc.DEFAULT_MAX_BUFFER;
25
25
 
26
26
  const gitProcOptions = (cwd, extra = {}) => ({
27
27
  cwd,
28
- env: { ...process.env, GIT_OPTIONAL_LOCKS: '0' },
28
+ env: { ...process.env, GIT_OPTIONAL_LOCKS: '0', ...extra.env },
29
29
  encoding: GIT_ENCODING,
30
30
  maxBuffer: GIT_MAX_BUFFER,
31
31
  input: extra.input,
@@ -45,8 +45,8 @@ const requireOk = (result, fallback = 'git failed') => {
45
45
  throw new Error(oneLine(msg, fallback));
46
46
  };
47
47
 
48
- const runGit = (args, cwd, input) => {
49
- const options = gitProcOptions(cwd, { input });
48
+ const runGit = (args, cwd, input, extra = {}) => {
49
+ const options = gitProcOptions(cwd, { input, env: extra.env });
50
50
  return gitResult(runProcSync('git', args, options));
51
51
  };
52
52
 
@@ -503,6 +503,11 @@ const writeFile = (top, rel, text) => {
503
503
  writeWorktree(abs, text);
504
504
  };
505
505
 
506
+ const stagePath = (top, rel) => {
507
+ if (!rel) return;
508
+ requireOk(runGit(['add', '--', rel], top));
509
+ };
510
+
506
511
  module.exports = {
507
512
  oneLine,
508
513
  requireOk,
@@ -521,6 +526,7 @@ module.exports = {
521
526
  readSnapshotAsync,
522
527
  editItem,
523
528
  writeFile,
529
+ stagePath,
524
530
  };
525
531
 
526
532
  const branches = require('./git-branches.js');
@@ -590,10 +596,12 @@ const createGitRepo = () =>
590
596
  revertFile: revertGitFile,
591
597
  edit: editItem,
592
598
  writeFile,
599
+ stagePath,
593
600
  commit: branches.commitChanges,
594
601
  commitAsync: branches.commitChangesAsync,
595
602
  hasStaged: branches.hasStaged,
596
603
  lastMessage: branches.lastMessage,
604
+ commitMessage: branches.commitMessage,
597
605
  listBranches: branches.listBranches,
598
606
  listCommits: branches.listCommits,
599
607
  currentBranch,
@@ -607,6 +615,10 @@ const createGitRepo = () =>
607
615
  dropAsync: branches.dropBranchAsync,
608
616
  dropCommit: branches.dropCommit,
609
617
  dropCommitAsync: branches.dropCommitAsync,
618
+ applyFixup: branches.applyFixup,
619
+ applyFixupAsync: branches.applyFixupAsync,
620
+ reword: branches.rewordCommit,
621
+ rewordAsync: branches.rewordCommitAsync,
610
622
  pull: branches.pullChanges,
611
623
  pullAsync: branches.pullChangesAsync,
612
624
  push: branches.pushChanges,
@@ -628,6 +640,7 @@ module.exports.commitChangesAsync = branches.commitChangesAsync;
628
640
  module.exports.hasStaged = branches.hasStaged;
629
641
  module.exports.currentBranch = currentBranch;
630
642
  module.exports.lastMessage = branches.lastMessage;
643
+ module.exports.commitMessage = branches.commitMessage;
631
644
  module.exports.listBranches = branches.listBranches;
632
645
  module.exports.listCommits = branches.listCommits;
633
646
  module.exports.checkoutBranch = branches.checkoutBranch;
@@ -640,6 +653,10 @@ module.exports.dropBranch = branches.dropBranch;
640
653
  module.exports.dropBranchAsync = branches.dropBranchAsync;
641
654
  module.exports.dropCommit = branches.dropCommit;
642
655
  module.exports.dropCommitAsync = branches.dropCommitAsync;
656
+ module.exports.applyFixup = branches.applyFixup;
657
+ module.exports.applyFixupAsync = branches.applyFixupAsync;
658
+ module.exports.rewordCommit = branches.rewordCommit;
659
+ module.exports.rewordCommitAsync = branches.rewordCommitAsync;
643
660
  module.exports.pullChanges = branches.pullChanges;
644
661
  module.exports.pullChangesAsync = branches.pullChangesAsync;
645
662
  module.exports.pushChanges = branches.pushChanges;
package/lib/keys.js CHANGED
@@ -6,7 +6,7 @@ const { ACTIONS, ACTION_IDS, BRANCH_ACTIONS, COMMIT_ACTIONS } = actions;
6
6
  const { DIFF_DISABLED, FILES_DISABLED, FILES_GIT_DISABLED } = actions;
7
7
  const { FILES_HIDDEN, FILES_TODO_DISABLED, TODO_DISABLED, BRANCHES_DISABLED } =
8
8
  actions;
9
- const { COMMITS_DISABLED, UNIT_DISABLED } = actions;
9
+ const { COMMITS_DISABLED, UNIT_DISABLED, NPM_DISABLED } = actions;
10
10
  const { disabledActions, diffGitDisabled, branchGitDisabled } = actions;
11
11
  const { commitGitDisabled } = actions;
12
12
  const { actionFromKey, actionHot, actionLetter, buttonWord } = actions;
@@ -65,15 +65,20 @@ const parseCsiSeq = (rest) => {
65
65
  return { params: body.slice(0, i), final: body[i], eat: 2 + i + 1 };
66
66
  };
67
67
 
68
+ const ctrlHeld = (modText) => {
69
+ const mod = parseInt(modText, 10);
70
+ return Number.isFinite(mod) && ((mod - 1) & 4) !== 0;
71
+ };
72
+
68
73
  const keyFromCsi = (paramsText, final) => {
69
74
  const params = paramsText ? paramsText.split(';') : [];
70
75
  if (final === '~') return CSI_TILDE[params[0]] ?? null;
71
76
  const base = CSI_KEYS[final];
72
77
  if (!base) return null;
73
78
  if (params.length < 2) return base;
74
- const mod = parseInt(params[1], 10);
75
- const ctrl = Number.isFinite(mod) && ((mod - 1) & 4) !== 0;
76
- if (ctrl && (base === 'left' || base === 'right')) return `ctrl-${base}`;
79
+ const arrow =
80
+ base === 'left' || base === 'right' || base === 'up' || base === 'down';
81
+ if (ctrlHeld(params[1]) && arrow) return `ctrl-${base}`;
77
82
  return base;
78
83
  };
79
84
 
@@ -193,6 +198,7 @@ module.exports = {
193
198
  TODO_DISABLED,
194
199
  BRANCHES_DISABLED,
195
200
  COMMITS_DISABLED,
201
+ NPM_DISABLED,
196
202
  UNIT_DISABLED,
197
203
  disabledActions,
198
204
  diffGitDisabled,