reslop 0.2.5 → 0.2.7

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 (114) hide show
  1. package/CHANGELOG.md +22 -1
  2. package/LICENSE +1 -1
  3. package/README.md +11 -11
  4. package/bin/reslop.js +3 -8
  5. package/lib/ansi.js +72 -19
  6. package/lib/api.js +1 -1
  7. package/lib/capabilities.js +8 -8
  8. package/lib/cli.js +61 -75
  9. package/lib/clipboard.js +39 -47
  10. package/lib/dashboard/git.js +128 -0
  11. package/lib/dashboard/model.js +357 -0
  12. package/lib/dashboard/npm.js +290 -0
  13. package/lib/dashboard/tree.js +278 -0
  14. package/lib/deps-diff.js +197 -402
  15. package/lib/deps.js +113 -240
  16. package/lib/detect.js +8 -18
  17. package/lib/diff/align.js +41 -113
  18. package/lib/diff/diff.js +12 -43
  19. package/lib/diff/display.js +25 -36
  20. package/lib/diff/edit.js +6 -20
  21. package/lib/diff/inline.js +76 -144
  22. package/lib/diff/items.js +33 -47
  23. package/lib/diff/parse.js +28 -56
  24. package/lib/diff/patch.js +12 -33
  25. package/lib/diff/unit.js +79 -178
  26. package/lib/editor.js +52 -69
  27. package/lib/files.js +79 -91
  28. package/lib/find.js +61 -0
  29. package/lib/git-branches.js +254 -416
  30. package/lib/git-deps.js +148 -272
  31. package/lib/git-reword-todo.js +5 -13
  32. package/lib/git-worktree.js +161 -214
  33. package/lib/git.js +21 -49
  34. package/lib/github.js +67 -181
  35. package/lib/gitlab.js +40 -90
  36. package/lib/highlight/bash.js +55 -177
  37. package/lib/highlight/core.js +155 -141
  38. package/lib/highlight/css.js +25 -58
  39. package/lib/highlight/csv.js +19 -37
  40. package/lib/highlight/dart.js +72 -213
  41. package/lib/highlight/dot.js +14 -14
  42. package/lib/highlight/html.js +42 -63
  43. package/lib/highlight/js.js +177 -506
  44. package/lib/highlight/json.js +17 -37
  45. package/lib/highlight/log.js +19 -25
  46. package/lib/highlight/txt.js +7 -10
  47. package/lib/highlight.js +17 -46
  48. package/lib/keys.js +75 -83
  49. package/lib/manifest.js +67 -126
  50. package/lib/npm-commands.js +120 -169
  51. package/lib/remote.js +140 -190
  52. package/lib/render/branches.js +63 -61
  53. package/lib/render/chrome.js +412 -347
  54. package/lib/render/commits.js +160 -361
  55. package/lib/render/dash-activity.js +212 -0
  56. package/lib/render/dash-blocks.js +496 -0
  57. package/lib/render/dash-table.js +295 -0
  58. package/lib/render/dashboard.js +62 -0
  59. package/lib/render/diff.js +123 -258
  60. package/lib/render/files.js +60 -86
  61. package/lib/render/frame.js +82 -117
  62. package/lib/render/notes.js +150 -210
  63. package/lib/render/npm.js +213 -177
  64. package/lib/render/packages.js +93 -0
  65. package/lib/render/present.js +4 -11
  66. package/lib/render/primitives.js +77 -172
  67. package/lib/render/render.js +5 -14
  68. package/lib/render/report.js +43 -0
  69. package/lib/render/repos.js +226 -0
  70. package/lib/render/search.js +25 -0
  71. package/lib/render/tiles.js +355 -0
  72. package/lib/render/unit.js +15 -31
  73. package/lib/report-model.js +110 -33
  74. package/lib/report-parse.js +56 -37
  75. package/lib/report-render.js +102 -59
  76. package/lib/report-run.js +87 -77
  77. package/lib/report-test.js +221 -99
  78. package/lib/report-tools.js +96 -154
  79. package/lib/review-merge.js +33 -41
  80. package/lib/review.js +69 -129
  81. package/lib/runs.js +225 -0
  82. package/lib/select.js +53 -91
  83. package/lib/session/accessors.js +14 -21
  84. package/lib/session/actions.js +189 -65
  85. package/lib/session/branches.js +164 -222
  86. package/lib/session/change-actions.js +49 -119
  87. package/lib/session/commit.js +298 -352
  88. package/lib/session/compose.js +572 -715
  89. package/lib/session/dashboard.js +530 -0
  90. package/lib/session/dispatch.js +197 -171
  91. package/lib/session/files-pane.js +92 -45
  92. package/lib/session/find.js +94 -0
  93. package/lib/session/items.js +6 -17
  94. package/lib/session/lifecycle.js +420 -466
  95. package/lib/session/load.js +1 -14
  96. package/lib/session/navigation.js +4 -2
  97. package/lib/session/npm.js +403 -420
  98. package/lib/session/ops.js +5 -13
  99. package/lib/session/packages.js +143 -0
  100. package/lib/session/pointer.js +30 -14
  101. package/lib/session/progress.js +39 -30
  102. package/lib/session/review.js +1 -3
  103. package/lib/session/tasks.js +277 -236
  104. package/lib/session/templates.js +51 -59
  105. package/lib/session/terminal.js +85 -132
  106. package/lib/session/view.js +59 -80
  107. package/lib/session/watch.js +201 -212
  108. package/lib/session/workspace.js +455 -0
  109. package/lib/session.js +95 -161
  110. package/lib/update.js +44 -111
  111. package/lib/utilities.js +46 -88
  112. package/lib/workspace.js +166 -0
  113. package/lib/wrap.js +29 -55
  114. package/package.json +1 -1
@@ -4,10 +4,12 @@ const fs = require('node:fs');
4
4
  const os = require('node:os');
5
5
  const path = require('node:path');
6
6
 
7
+ const utilities = require('./utilities.js');
8
+ const { oneLine } = utilities;
7
9
  const files = require('./files.js');
8
- const gitWorktree = require('./git-worktree.js');
9
10
  const { shortAge } = files;
10
- const { oneLine, requireOk, runGit, runGitAsync } = gitWorktree;
11
+ const worktree = require('./git-worktree.js');
12
+ const { requireOk, runGit, command, gitOp } = worktree;
11
13
 
12
14
  const BRANCH_FORMAT = [
13
15
  '%(HEAD)',
@@ -17,25 +19,14 @@ const BRANCH_FORMAT = [
17
19
  '%(upstream:track)',
18
20
  '%(subject)',
19
21
  ].join('%09');
20
- const TRACK_AHEAD = /ahead (\d+)/;
21
- const TRACK_BEHIND = /behind (\d+)/;
22
22
  const ORIGIN_HEAD = 'refs/remotes/origin/HEAD';
23
+ const ORIGIN_PREFIX = 'origin/';
23
24
  const DEFAULT_BRANCHES = ['main', 'master'];
24
25
  const PUSH_REJECTED = [
25
26
  'non-fast-forward',
26
27
  'failed to push some refs',
27
28
  'updates were rejected',
28
29
  ];
29
-
30
- const commitMessage = (top, sha) => {
31
- const spec = `${sha ?? ''}`.trim() || 'HEAD';
32
- const result = runGit(['log', '-1', '--format=%B', spec], top);
33
- if (result.status !== 0) return '';
34
- return (result.stdout ?? '').replace(/\s+$/, '');
35
- };
36
-
37
- const lastMessage = (top) => commitMessage(top, 'HEAD');
38
-
39
30
  const COMMIT_FIELDS = ['%H', '%h', '%an', '%ae', '%ar', '%ci', '%D', '%s'];
40
31
  const COMMIT_FORMAT = `${COMMIT_FIELDS.join('%x1f')}%x1e%B`;
41
32
  const COMMIT_LIMIT = 256;
@@ -43,42 +34,72 @@ const UNIT = '\x1f';
43
34
  const RECORD = '\x1e';
44
35
  const TAG_REF = /^tag: /;
45
36
  const REMOTE_HEAD = /\/HEAD(?: -> |$)/;
37
+ const STAT_LEAD = /^\n* *\d+ files? changed[^\n]*\n?/;
38
+ const FIXUP_MARK = 'fixup!';
39
+ const AUTOSQUASH_ARGS = [
40
+ '-c',
41
+ 'sequence.editor=:',
42
+ 'rebase',
43
+ '--interactive',
44
+ '--autosquash',
45
+ ];
46
+ const REWORD_TODO = path.join(__dirname, 'git-reword-todo.js');
47
+
48
+ const trimText = (value) => `${value ?? ''}`.trim();
49
+
50
+ const required = (value, message) => {
51
+ const text = trimText(value);
52
+ if (!text) throw new Error(message);
53
+ return text;
54
+ };
55
+
56
+ const branchName = (name) => required(name, 'empty branch name');
57
+
58
+ const messageBody = (message) => {
59
+ const text = `${message ?? ''}`;
60
+ return text.endsWith('\n') ? text : `${text}\n`;
61
+ };
62
+
63
+ const commitMessage = (top, sha) => {
64
+ const spec = trimText(sha) || 'HEAD';
65
+ const result = runGit(['log', '-1', '--format=%B', spec], top);
66
+ return result.status === 0 ? result.stdout.trimEnd() : '';
67
+ };
68
+
69
+ const lastMessage = (top) => commitMessage(top, 'HEAD');
70
+
71
+ const isShownRef = (name) => {
72
+ if (!name || TAG_REF.test(name)) return false;
73
+ return name === 'HEAD' || !REMOTE_HEAD.test(name);
74
+ };
46
75
 
47
76
  const formatCommitRefs = (raw) => {
48
- const text = `${raw ?? ''}`.trim();
49
- if (!text) return '';
50
- const names = [];
51
- for (const part of text.split(', ')) {
52
- const name = part.trim();
53
- if (!name || TAG_REF.test(name)) continue;
54
- if (name !== 'HEAD' && REMOTE_HEAD.test(name)) continue;
55
- names.push(name);
56
- }
57
- return names.join(', ');
77
+ const names = trimText(raw).split(', ');
78
+ return names
79
+ .map((name) => name.trim())
80
+ .filter(isShownRef)
81
+ .join(', ');
58
82
  };
59
83
 
60
84
  const parseCommitRecord = (record, index) => {
61
85
  const cut = record.indexOf(RECORD);
62
86
  if (cut < 0) return null;
63
- const parts = record.slice(0, cut).split(UNIT);
64
- if (parts.length < COMMIT_FIELDS.length) return null;
65
- const sha = (parts[0] ?? '').trim();
66
- if (!sha) return null;
67
- const body = record.slice(cut + 1).replace(/\s+$/, '');
68
- const read = (spec) => {
69
- const at = COMMIT_FIELDS.indexOf(spec);
70
- return (parts[at] ?? '').trim();
71
- };
72
- const subject = read('%s') || body.split('\n')[0].trim();
87
+ const parts = record
88
+ .slice(0, cut)
89
+ .split(UNIT)
90
+ .map((part) => part.trim());
91
+ if (parts.length < COMMIT_FIELDS.length || !parts[0]) return null;
92
+ const sha = parts[0];
93
+ const body = record.slice(cut + 1).trimEnd();
73
94
  return {
74
95
  sha,
75
- shortSha: read('%h') || sha.slice(0, 7),
76
- author: read('%an'),
77
- email: read('%ae'),
78
- date: shortAge(read('%ar')),
79
- when: read('%ci'),
80
- refs: formatCommitRefs(read('%D')),
81
- subject,
96
+ shortSha: parts[1] || sha.slice(0, 7),
97
+ author: parts[2],
98
+ email: parts[3],
99
+ date: shortAge(parts[4]),
100
+ when: parts[5],
101
+ refs: formatCommitRefs(parts[6]),
102
+ subject: parts[7] || body.split('\n')[0].trim(),
82
103
  body,
83
104
  added: 0,
84
105
  removed: 0,
@@ -86,25 +107,11 @@ const parseCommitRecord = (record, index) => {
86
107
  };
87
108
  };
88
109
 
89
- const STAT_LEAD = /^\n* *\d+ files? changed[^\n]*\n?/;
90
-
91
- const takeStat = (chunk) => {
92
- const match = STAT_LEAD.exec(chunk);
93
- if (!match) return { stat: '', rest: chunk };
94
- return { stat: match[0], rest: chunk.slice(match[0].length) };
95
- };
96
-
97
- const countIn = (line, word) => {
98
- const match = new RegExp(`(\\d+) ${word}`).exec(line);
99
- if (!match) return 0;
100
- return parseInt(match[1], 10);
110
+ const statCount = (stat, word) => {
111
+ const match = new RegExp(`(\\d+) ${word}`).exec(stat);
112
+ return match ? parseInt(match[1], 10) : 0;
101
113
  };
102
114
 
103
- const parseShortstat = (line) => ({
104
- added: countIn(line, 'insertion'),
105
- removed: countIn(line, 'deletion'),
106
- });
107
-
108
115
  const listCommits = (top) => {
109
116
  const args = [
110
117
  'log',
@@ -116,257 +123,62 @@ const listCommits = (top) => {
116
123
  const result = requireOk(runGit(args, top), 'log failed');
117
124
  const commits = [];
118
125
  for (const chunk of result.stdout.split('\0')) {
119
- const taken = takeStat(chunk);
120
- if (taken.stat && commits.length) {
121
- const stat = parseShortstat(taken.stat);
122
- const last = commits[commits.length - 1];
123
- last.added = stat.added;
124
- last.removed = stat.removed;
126
+ const stat = STAT_LEAD.exec(chunk);
127
+ const last = commits.at(-1);
128
+ if (stat && last) {
129
+ last.added = statCount(stat[0], 'insertion');
130
+ last.removed = statCount(stat[0], 'deletion');
125
131
  }
126
- if (!taken.rest) continue;
127
- const entry = parseCommitRecord(taken.rest, commits.length);
132
+ const rest = stat ? chunk.slice(stat[0].length) : chunk;
133
+ if (!rest) continue;
134
+ const entry = parseCommitRecord(rest, commits.length);
128
135
  if (entry) commits.push(entry);
129
136
  }
130
137
  return commits;
131
138
  };
132
139
 
133
- const namedCommit = (sha) => {
134
- const commit = `${sha ?? ''}`.trim();
135
- if (!commit) throw new Error('empty commit');
136
- return commit;
137
- };
138
-
139
- const resolveCommit = (top, sha) => {
140
- const result = requireOk(runGit(['rev-parse', sha], top), 'rev-parse failed');
141
- return (result.stdout ?? '').trim();
142
- };
143
-
144
- const dropHeadCommit = (top) => {
145
- requireOk(runGit(['reset', '--soft', 'HEAD~1'], top), 'drop failed');
146
- };
147
-
148
- const runRebase = (top, args, extra = {}) => {
149
- const result = runGit(args, top, undefined, extra);
150
- if (result.status === 0) return;
151
- runGit(['rebase', '--abort'], top);
152
- requireOk(result, 'rebase failed');
153
- };
154
-
155
- const runRebaseAsync = async (top, args, extra = {}) => {
156
- const result = await runGitAsync(args, top, extra);
157
- if (result.status === 0) return;
158
- await runGitAsync(['rebase', '--abort'], top);
159
- requireOk(result, 'rebase failed');
160
- };
161
-
162
- const dropTarget = (top, sha) => {
163
- const target = resolveCommit(top, namedCommit(sha));
164
- const parent = runGit(['rev-parse', '--verify', `${target}^`], top);
165
- if (parent.status !== 0) throw new Error('cannot drop root commit');
166
- return target;
167
- };
168
-
169
- const dropCommit = (top, sha) => {
170
- const target = dropTarget(top, sha);
171
- if (target === resolveCommit(top, 'HEAD')) return void dropHeadCommit(top);
172
- runRebase(top, ['rebase', '--onto', `${target}^`, target]);
173
- };
174
-
175
- const dropCommitAsync = async (top, sha) => {
176
- const target = dropTarget(top, sha);
177
- if (target === resolveCommit(top, 'HEAD')) {
178
- requireOk(await runGitAsync(['reset', '--soft', 'HEAD~1'], top));
179
- return;
180
- }
181
- await runRebaseAsync(top, ['rebase', '--onto', `${target}^`, target]);
182
- };
183
-
184
- const FIXUP_MARK = 'fixup!';
185
- const AUTOSQUASH_ARGS = [
186
- '-c',
187
- 'sequence.editor=:',
188
- 'rebase',
189
- '--interactive',
190
- '--autosquash',
191
- ];
192
-
193
- const isFixupSubject = (subject) =>
194
- `${subject ?? ''}`.trim().startsWith(FIXUP_MARK);
195
-
196
- const fixupRest = (subject) => {
197
- let text = `${subject ?? ''}`.trim();
198
- while (text.startsWith(FIXUP_MARK)) {
199
- text = text.slice(FIXUP_MARK.length).trim();
200
- }
201
- return text;
202
- };
203
-
204
- const commitSubject = (top, sha) => {
205
- const args = ['log', '-1', '--format=%s', sha];
206
- const result = requireOk(runGit(args, top), 'log failed');
207
- return (result.stdout ?? '').trim();
208
- };
209
-
210
- const ancestorWithSubject = (top, sha, want) => {
211
- const args = ['log', '--format=%H%x1f%s', `${sha}^`];
212
- const result = runGit(args, top);
213
- if (result.status !== 0) return '';
214
- for (const line of result.stdout.split('\n')) {
215
- const cut = line.indexOf(UNIT);
216
- if (cut < 0) continue;
217
- const subject = line.slice(cut + 1).trim();
218
- if (subject !== want) continue;
219
- return line.slice(0, cut).trim();
220
- }
221
- return '';
222
- };
223
-
224
- const rebaseRange = (top, sha) => {
225
- const parent = runGit(['rev-parse', '--verify', `${sha}^`], top);
226
- return parent.status === 0 ? [parent.stdout.trim()] : ['--root'];
227
- };
228
-
229
- const fixupArgs = (top, sha) => {
230
- const commit = resolveCommit(top, namedCommit(sha));
231
- const subject = commitSubject(top, commit);
232
- if (!isFixupSubject(subject)) throw new Error('not a fixup commit');
233
- const want = fixupRest(subject);
234
- if (!want) throw new Error('no fixup target');
235
- const original = ancestorWithSubject(top, commit, want);
236
- if (!original) throw new Error('no fixup target');
237
- return [...AUTOSQUASH_ARGS, ...rebaseRange(top, original)];
238
- };
239
-
240
- const applyFixup = (top, sha) => runRebase(top, fixupArgs(top, sha));
241
-
242
- const applyFixupAsync = async (top, sha) =>
243
- runRebaseAsync(top, fixupArgs(top, sha));
244
-
245
- const messageBody = (message) => {
246
- const text = `${message ?? ''}`;
247
- return text.endsWith('\n') ? text : `${text}\n`;
248
- };
249
-
250
- const REWORD_HEAD_ARGS = ['commit', '--amend', '--only', '-F', '-'];
251
-
252
- const REWORD_TODO = path.join(__dirname, 'git-reword-todo.js');
253
-
254
- const prepareRewordEditor = (message) => {
255
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'reslop-reword-'));
256
- const msgFile = path.join(dir, 'MSG');
257
- fs.writeFileSync(msgFile, messageBody(message));
258
- const node = JSON.stringify(process.execPath);
259
- const script = JSON.stringify(REWORD_TODO);
140
+ const parseTrack = (text) => {
141
+ const raw = trimText(text).replace(/^\[|\]$/g, '');
142
+ const ahead = /ahead (\d+)/.exec(raw);
143
+ const behind = /behind (\d+)/.exec(raw);
260
144
  return {
261
- dir,
262
- env: {
263
- GIT_SEQUENCE_EDITOR: `${node} ${script}`,
264
- RESLOP_REWORD_FILE: msgFile,
265
- },
145
+ ahead: ahead ? Number(ahead[1]) : 0,
146
+ behind: behind ? Number(behind[1]) : 0,
147
+ gone: raw === 'gone',
266
148
  };
267
149
  };
268
150
 
269
- const rewordOlder = (top, sha, message) => {
270
- const prepared = prepareRewordEditor(message);
271
- try {
272
- const args = ['rebase', '--interactive', ...rebaseRange(top, sha)];
273
- runRebase(top, args, { env: prepared.env });
274
- } finally {
275
- fs.rmSync(prepared.dir, { recursive: true, force: true });
276
- }
277
- };
278
-
279
- const rewordOlderAsync = async (top, sha, message) => {
280
- const prepared = prepareRewordEditor(message);
281
- try {
282
- const args = ['rebase', '--interactive', ...rebaseRange(top, sha)];
283
- await runRebaseAsync(top, args, { env: prepared.env });
284
- } finally {
285
- fs.rmSync(prepared.dir, { recursive: true, force: true });
286
- }
287
- };
288
-
289
- const rewordCommit = (top, sha, message) => {
290
- const commit = resolveCommit(top, namedCommit(sha));
291
- if (commit === resolveCommit(top, 'HEAD')) {
292
- requireOk(runGit(REWORD_HEAD_ARGS, top, messageBody(message)));
293
- return;
294
- }
295
- rewordOlder(top, commit, message);
296
- };
297
-
298
- const rewordCommitAsync = async (top, sha, message) => {
299
- const commit = resolveCommit(top, namedCommit(sha));
300
- if (commit === resolveCommit(top, 'HEAD')) {
301
- const input = messageBody(message);
302
- requireOk(await runGitAsync(REWORD_HEAD_ARGS, top, { input }));
303
- return;
304
- }
305
- await rewordOlderAsync(top, commit, message);
306
- };
307
-
308
- const parseTrack = (text) => {
309
- const raw = `${text ?? ''}`.trim().replace(/^\[|\]$/g, '');
310
- if (!raw) return { ahead: 0, behind: 0, gone: false };
311
- if (raw === 'gone') return { ahead: 0, behind: 0, gone: true };
312
- const aheadMatch = TRACK_AHEAD.exec(raw);
313
- const behindMatch = TRACK_BEHIND.exec(raw);
314
- const ahead = aheadMatch ? Number(aheadMatch[1]) : 0;
315
- const behind = behindMatch ? Number(behindMatch[1]) : 0;
316
- return { ahead, behind, gone: false };
317
- };
318
-
319
151
  const parseBranchLine = (line) => {
320
152
  const parts = line.split('\t');
321
- if (parts.length < 2) return null;
322
- const name = (parts[1] ?? '').trim();
153
+ const name = trimText(parts[1]);
323
154
  if (!name) return null;
324
- const mark = parts[0] ?? '';
325
- const sha = (parts[2] ?? '').trim();
326
- const date = shortAge(parts[3] ?? '');
327
- const track = parseTrack(parts[4]);
328
- const subject = parts.slice(5).join('\t').trim();
329
155
  return {
330
156
  name,
331
- current: mark.includes('*'),
157
+ current: parts[0].includes('*'),
332
158
  isDefault: false,
333
- sha,
334
- date,
335
- subject,
336
- ...track,
159
+ sha: trimText(parts[2]),
160
+ date: shortAge(parts[3]),
161
+ subject: parts.slice(5).join('\t').trim(),
162
+ ...parseTrack(parts[4]),
337
163
  };
338
164
  };
339
165
 
340
- const originHead = (top) => {
341
- const args = ['symbolic-ref', '--short', ORIGIN_HEAD];
342
- const result = runGit(args, top);
343
- if (result.status !== 0) return '';
344
- const ref = result.stdout.trim();
345
- const prefix = 'origin/';
346
- if (ref.startsWith(prefix)) return ref.slice(prefix.length);
347
- return ref;
348
- };
349
-
350
166
  const defaultBranch = (top, names) => {
351
- const remote = originHead(top);
167
+ const result = runGit(['symbolic-ref', '--short', ORIGIN_HEAD], top);
168
+ const ref = result.status === 0 ? result.stdout.trim() : '';
169
+ const remote = ref.startsWith(ORIGIN_PREFIX)
170
+ ? ref.slice(ORIGIN_PREFIX.length)
171
+ : ref;
352
172
  if (names.has(remote)) return remote;
353
- for (const name of DEFAULT_BRANCHES) {
354
- if (names.has(name)) return name;
355
- }
356
- return '';
173
+ return DEFAULT_BRANCHES.find((name) => names.has(name)) ?? '';
357
174
  };
358
175
 
359
176
  const listBranches = (top) => {
360
177
  const args = ['for-each-ref', `--format=${BRANCH_FORMAT}`, 'refs/heads'];
361
178
  const result = requireOk(runGit(args, top), 'branch failed');
362
- const branches = [];
363
- for (const line of result.stdout.split('\n')) {
364
- if (!line) continue;
365
- const entry = parseBranchLine(line);
366
- if (entry) branches.push(entry);
367
- }
368
- const names = new Set();
369
- for (const entry of branches) names.add(entry.name);
179
+ const lines = result.stdout.split('\n').filter(Boolean);
180
+ const branches = lines.map((line) => parseBranchLine(line)).filter(Boolean);
181
+ const names = new Set(branches.map((entry) => entry.name));
370
182
  const primary = defaultBranch(top, names);
371
183
  return branches.map((entry) => ({
372
184
  ...entry,
@@ -374,175 +186,201 @@ const listBranches = (top) => {
374
186
  }));
375
187
  };
376
188
 
377
- const namedBranch = (name) => {
378
- const branch = `${name ?? ''}`.trim();
379
- if (!branch) throw new Error('empty branch name');
380
- return branch;
189
+ const hasStaged = (top) => {
190
+ const result = runGit(['diff', '--cached', '--quiet'], top);
191
+ return result.status === 1;
381
192
  };
382
193
 
383
- const checkoutBranch = (top, name) => {
384
- const branch = namedBranch(name);
385
- requireOk(runGit(['checkout', branch], top), 'checkout failed');
386
- };
194
+ function* resolveCommit(sha) {
195
+ const result = yield command(['rev-parse', sha]);
196
+ return requireOk(result, 'rev-parse failed').stdout.trim();
197
+ }
387
198
 
388
- const checkoutBranchAsync = async (top, name) => {
389
- const branch = namedBranch(name);
390
- requireOk(await runGitAsync(['checkout', branch], top), 'checkout failed');
391
- };
199
+ function* rebaseRange(sha) {
200
+ const parent = yield command(['rev-parse', '--verify', `${sha}^`]);
201
+ return parent.status === 0 ? [parent.stdout.trim()] : ['--root'];
202
+ }
392
203
 
393
- const createBranch = (top, name) => {
394
- const branch = namedBranch(name);
395
- const args = ['checkout', '-b', branch];
396
- requireOk(runGit(args, top), 'checkout failed');
397
- };
204
+ function* rebase(args, extra) {
205
+ const result = yield command(args, extra);
206
+ if (result.status === 0) return;
207
+ yield command(['rebase', '--abort']);
208
+ requireOk(result, 'rebase failed');
209
+ }
398
210
 
399
- const createBranchAsync = async (top, name) => {
400
- const branch = namedBranch(name);
401
- const args = ['checkout', '-b', branch];
402
- requireOk(await runGitAsync(args, top), 'checkout failed');
403
- };
211
+ const simpleOp = (toArgs, fallback) =>
212
+ gitOp(function* (...args) {
213
+ const result = yield command(toArgs(...args));
214
+ requireOk(result, fallback);
215
+ });
404
216
 
405
- const rebaseBranch = (top, onto) =>
406
- runRebase(top, ['rebase', namedBranch(onto)]);
217
+ const checkoutOp = simpleOp(
218
+ (name) => ['checkout', branchName(name)],
219
+ 'checkout failed',
220
+ );
407
221
 
408
- const rebaseBranchAsync = async (top, onto) =>
409
- runRebaseAsync(top, ['rebase', namedBranch(onto)]);
222
+ const createOp = simpleOp(
223
+ (name) => ['checkout', '-b', branchName(name)],
224
+ 'checkout failed',
225
+ );
410
226
 
411
- const dropBranch = (top, name) => {
412
- const branch = namedBranch(name);
413
- requireOk(runGit(['branch', '-D', branch], top), 'drop failed');
414
- };
227
+ const dropBranchOp = simpleOp(
228
+ (name) => ['branch', '-D', branchName(name)],
229
+ 'drop failed',
230
+ );
415
231
 
416
- const dropBranchAsync = async (top, name) => {
417
- const branch = namedBranch(name);
418
- requireOk(await runGitAsync(['branch', '-D', branch], top), 'drop failed');
419
- };
232
+ const pullOp = simpleOp(() => ['pull'], 'pull failed');
420
233
 
421
- const pullChanges = (top) => {
422
- requireOk(runGit(['pull'], top), 'pull failed');
423
- };
234
+ const rebaseOp = gitOp(function* (onto) {
235
+ yield* rebase(['rebase', branchName(onto)]);
236
+ });
424
237
 
425
- const pullChangesAsync = async (top) => {
426
- requireOk(await runGitAsync(['pull'], top), 'pull failed');
427
- };
238
+ const commitOp = gitOp(function* (kind, message) {
239
+ const args = ['commit'];
240
+ if (kind === 'amend') args.push('--amend');
241
+ args.push('-F', '-');
242
+ const result = yield command(args, { input: messageBody(message) });
243
+ requireOk(result);
244
+ });
428
245
 
429
- const isRejectedPush = (msg) => {
430
- const text = `${msg ?? ''}`.toLowerCase();
431
- for (const token of PUSH_REJECTED) {
432
- if (text.includes(token)) return true;
246
+ const dropCommitOp = gitOp(function* (sha) {
247
+ const target = yield* resolveCommit(required(sha, 'empty commit'));
248
+ const parent = yield command(['rev-parse', '--verify', `${target}^`]);
249
+ if (parent.status !== 0) throw new Error('cannot drop root commit');
250
+ if (target === (yield* resolveCommit('HEAD'))) {
251
+ const result = yield command(['reset', '--soft', 'HEAD~1']);
252
+ return void requireOk(result, 'drop failed');
433
253
  }
434
- return false;
435
- };
254
+ yield* rebase(['rebase', '--onto', `${target}^`, target]);
255
+ });
436
256
 
437
- const pushError = (msg) => {
438
- const text = `${msg ?? ''}`;
439
- const rejected = isRejectedPush(text);
440
- const error = new Error(rejected ? 'push rejected' : oneLine(text));
441
- error.rejected = rejected;
442
- return error;
257
+ const fixupRest = (subject) => {
258
+ let text = subject;
259
+ while (text.startsWith(FIXUP_MARK)) {
260
+ text = text.slice(FIXUP_MARK.length).trim();
261
+ }
262
+ return text;
443
263
  };
444
264
 
445
- const pushBranch = (top) => {
446
- const result = runGit(['rev-parse', '--abbrev-ref', 'HEAD'], top);
447
- const name = (result.stdout ?? '').trim();
448
- if (!name || name === 'HEAD') return 'HEAD';
449
- return name;
265
+ const shaWithSubject = (log, subject) => {
266
+ for (const line of log.split('\n')) {
267
+ const cut = line.indexOf(UNIT);
268
+ if (cut < 0 || line.slice(cut + 1).trim() !== subject) continue;
269
+ return line.slice(0, cut).trim();
270
+ }
271
+ return '';
450
272
  };
451
273
 
452
- const hasUpstream = (top) => {
453
- const args = [
454
- 'rev-parse',
455
- '--abbrev-ref',
456
- '--symbolic-full-name',
457
- '@{upstream}',
458
- ];
459
- const result = runGit(args, top);
460
- return result.status === 0;
274
+ function* fixupTarget(commit) {
275
+ const shown = yield command(['log', '-1', '--format=%s', commit]);
276
+ const subject = requireOk(shown, 'log failed').stdout.trim();
277
+ if (!subject.startsWith(FIXUP_MARK)) throw new Error('not a fixup commit');
278
+ const want = fixupRest(subject);
279
+ if (!want) throw new Error('no fixup target');
280
+ const log = yield command(['log', '--format=%H%x1f%s', `${commit}^`]);
281
+ const original = log.status === 0 ? shaWithSubject(log.stdout, want) : '';
282
+ if (!original) throw new Error('no fixup target');
283
+ return original;
284
+ }
285
+
286
+ const fixupOp = gitOp(function* (sha) {
287
+ const commit = yield* resolveCommit(required(sha, 'empty commit'));
288
+ const original = yield* fixupTarget(commit);
289
+ const range = yield* rebaseRange(original);
290
+ yield* rebase([...AUTOSQUASH_ARGS, ...range]);
291
+ });
292
+
293
+ const rewordEnv = (msgFile) => {
294
+ const node = JSON.stringify(process.execPath);
295
+ const script = JSON.stringify(REWORD_TODO);
296
+ return {
297
+ GIT_SEQUENCE_EDITOR: `${node} ${script}`,
298
+ RESLOP_REWORD_FILE: msgFile,
299
+ };
461
300
  };
462
301
 
463
- const pushCmd = (force, branch) => {
302
+ const rewordOp = gitOp(function* (sha, message) {
303
+ const commit = yield* resolveCommit(required(sha, 'empty commit'));
304
+ const input = messageBody(message);
305
+ if (commit === (yield* resolveCommit('HEAD'))) {
306
+ const args = ['commit', '--amend', '--only', '-F', '-'];
307
+ const result = yield command(args, { input });
308
+ return void requireOk(result);
309
+ }
310
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'reslop-reword-'));
311
+ try {
312
+ const msgFile = path.join(dir, 'MSG');
313
+ fs.writeFileSync(msgFile, input);
314
+ const range = yield* rebaseRange(commit);
315
+ const env = rewordEnv(msgFile);
316
+ yield* rebase(['rebase', '--interactive', ...range], { env });
317
+ } finally {
318
+ fs.rmSync(dir, { recursive: true, force: true });
319
+ }
320
+ });
321
+
322
+ const pushArgs = (force, branch) => {
464
323
  const args = ['push'];
465
324
  if (force) args.push('--force-with-lease');
466
325
  if (branch) args.push('--set-upstream', 'origin', branch);
467
326
  return args;
468
327
  };
469
328
 
470
- const noUpstream = (msg) =>
471
- `${msg ?? ''}`.toLowerCase().includes('no upstream');
472
-
473
- const requirePushOk = (result) => {
474
- if (result.status === 0) return;
475
- throw pushError(result.stderr || result.stdout || 'push failed');
329
+ const pushError = (text) => {
330
+ const lower = text.toLowerCase();
331
+ const rejected = PUSH_REJECTED.some((token) => lower.includes(token));
332
+ const message = rejected ? 'push rejected' : oneLine(text, 'git failed');
333
+ const error = new Error(message);
334
+ error.rejected = rejected;
335
+ return error;
476
336
  };
477
337
 
478
- const pushChanges = (top, force) => {
479
- const useForce = force === true;
480
- const branch = pushBranch(top);
481
- const tracked = hasUpstream(top) ? '' : branch;
482
- let result = runGit(pushCmd(useForce, tracked), top);
483
- if (result.status !== 0 && noUpstream(result.stderr || result.stdout)) {
484
- result = runGit(pushCmd(useForce, branch), top);
485
- }
486
- requirePushOk(result);
487
- };
338
+ const UPSTREAM_ARGS = [
339
+ 'rev-parse',
340
+ '--abbrev-ref',
341
+ '--symbolic-full-name',
342
+ '@{upstream}',
343
+ ];
488
344
 
489
- const pushChangesAsync = async (top, force) => {
345
+ const pushOp = gitOp(function* (force) {
490
346
  const useForce = force === true;
491
- const branch = pushBranch(top);
492
- const tracked = hasUpstream(top) ? '' : branch;
493
- let result = await runGitAsync(pushCmd(useForce, tracked), top);
494
- if (result.status !== 0 && noUpstream(result.stderr || result.stdout)) {
495
- result = await runGitAsync(pushCmd(useForce, branch), top);
347
+ const head = yield command(['rev-parse', '--abbrev-ref', 'HEAD']);
348
+ const branch = head.stdout.trim() || 'HEAD';
349
+ const upstream = yield command(UPSTREAM_ARGS);
350
+ const tracked = upstream.status === 0 ? '' : branch;
351
+ let result = yield command(pushArgs(useForce, tracked));
352
+ const output = (result.stderr || result.stdout).toLowerCase();
353
+ if (result.status !== 0 && output.includes('no upstream')) {
354
+ result = yield command(pushArgs(useForce, branch));
496
355
  }
497
- requirePushOk(result);
498
- };
499
-
500
- const hasStaged = (top) => {
501
- const result = runGit(['diff', '--cached', '--quiet'], top);
502
- return result.status === 1;
503
- };
504
-
505
- const commitPlan = (kind, message) => {
506
- const args = ['commit'];
507
- if (kind === 'amend') args.push('--amend');
508
- args.push('-F', '-');
509
- return { args, body: messageBody(message) };
510
- };
511
-
512
- const commitChanges = (top, kind, message) => {
513
- const plan = commitPlan(kind, message);
514
- requireOk(runGit(plan.args, top, plan.body));
515
- };
516
-
517
- const commitChangesAsync = async (top, kind, message) => {
518
- const plan = commitPlan(kind, message);
519
- requireOk(await runGitAsync(plan.args, top, { input: plan.body }));
520
- };
356
+ if (result.status === 0) return;
357
+ throw pushError(result.stderr || result.stdout || 'push failed');
358
+ });
521
359
 
522
360
  module.exports = {
523
361
  lastMessage,
524
362
  commitMessage,
525
363
  listCommits,
526
364
  listBranches,
527
- checkoutBranch,
528
- checkoutBranchAsync,
529
- createBranch,
530
- createBranchAsync,
531
- rebaseBranch,
532
- rebaseBranchAsync,
533
- dropCommit,
534
- dropCommitAsync,
535
- applyFixup,
536
- applyFixupAsync,
537
- rewordCommit,
538
- rewordCommitAsync,
539
- dropBranch,
540
- dropBranchAsync,
541
- pullChanges,
542
- pullChangesAsync,
543
- pushChanges,
544
- pushChangesAsync,
545
365
  hasStaged,
546
- commitChanges,
547
- commitChangesAsync,
366
+ checkoutBranch: checkoutOp.sync,
367
+ checkoutBranchAsync: checkoutOp.async,
368
+ createBranch: createOp.sync,
369
+ createBranchAsync: createOp.async,
370
+ rebaseBranch: rebaseOp.sync,
371
+ rebaseBranchAsync: rebaseOp.async,
372
+ dropBranch: dropBranchOp.sync,
373
+ dropBranchAsync: dropBranchOp.async,
374
+ dropCommit: dropCommitOp.sync,
375
+ dropCommitAsync: dropCommitOp.async,
376
+ applyFixup: fixupOp.sync,
377
+ applyFixupAsync: fixupOp.async,
378
+ rewordCommit: rewordOp.sync,
379
+ rewordCommitAsync: rewordOp.async,
380
+ pullChanges: pullOp.sync,
381
+ pullChangesAsync: pullOp.async,
382
+ pushChanges: pushOp.sync,
383
+ pushChangesAsync: pushOp.async,
384
+ commitChanges: commitOp.sync,
385
+ commitChangesAsync: commitOp.async,
548
386
  };