reslop 0.1.8 → 0.1.9

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/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  ## [Unreleased][unreleased]
4
4
 
5
+ - Remove `r` reload hotkey and hint; auto-reload covers disk changes
6
+ - Improve branch list TUI/TUX
7
+ - Right-align the time column in the branch list
8
+ - Refresh the branch list after push, pull, and rebase
9
+ - Keep diff viewer order when staging or unstaging a block
10
+ - Add a commits list screen with commit operation
11
+ - Show staged/total rather than staged/unstaged in files list
12
+ - Show file ages in the file list right-aligned
13
+ - Sort the file list by path from the repo root
14
+ - Update file-list hotkeys and hints
15
+ - Fix dependency updates, locks and audit
16
+
5
17
  ## [0.1.8][] - 2026-09-17
6
18
 
7
19
  - Simplify session controllers, input dispatch, and loading orchestration
@@ -83,7 +95,8 @@
83
95
  - Feedback and todos that produce a repair plan for an agent
84
96
  - Intra-line highlighting in unified, mixed, and side-by-side layouts
85
97
 
86
- [unreleased]: https://github.com/tshemsedinov/reslop/compare/v0.1.8...HEAD
98
+ [unreleased]: https://github.com/tshemsedinov/reslop/compare/v0.1.9...HEAD
99
+ [0.1.9]: https://github.com/tshemsedinov/reslop/compare/v0.1.8...v0.1.9
87
100
  [0.1.8]: https://github.com/tshemsedinov/reslop/compare/v0.1.7...v0.1.8
88
101
  [0.1.7]: https://github.com/tshemsedinov/reslop/compare/v0.1.6...v0.1.7
89
102
  [0.1.6]: https://github.com/tshemsedinov/reslop/compare/v0.1.5...v0.1.6
package/README.md CHANGED
@@ -1,4 +1,6 @@
1
- # 👁️ reslop — Review AI generated code and plan changes
1
+ # 👁️ reslop
2
+
3
+ ## Review AI generated code and plan changes
2
4
 
3
5
  [![ci status](https://github.com/tshemsedinov/reslop/workflows/Testing%20CI/badge.svg)](https://github.com/tshemsedinov/reslop/actions?query=workflow%3A%22Testing+CI%22+branch%3Amain)
4
6
  [![snyk](https://snyk.io/test/github/tshemsedinov/reslop/badge.svg)](https://snyk.io/test/github/tshemsedinov/reslop)
@@ -16,16 +18,18 @@ for the agent.
16
18
  Review → Plan → Repair → Verify
17
19
  ```
18
20
 
19
- - Leave feedback, issues/todos, and in-place code proposals as a repair plan.
20
- - Review uncommitted git diffs, a given commit, a GitHub PR, or a GitLab MR.
21
- - Stage, unstage, or revert each contiguous block of diff lines.
22
- - Auto-reload local diffs when files change, keeping the current screen.
23
- - Import GitHub PR and GitLab MR review comments into the local plan for AI.
24
- - Commit, amend, or fixup staged changes from the file list.
25
- - Branches: checkout, create, rebase, drop, pull and push.
26
- - Review each npm dependency once across `package.json` and the lockfile.
27
- - Propose unused removals, npm audit fixes, and outdated updates as diffs.
28
- - Code highlighting, diff compare: unified, mixed, and side-by-side layouts.
21
+ - Leave feedback, issues/todos, and in-place code proposals as a repair plan
22
+ - Review uncommitted git diffs, a given commit, a GitHub PR, or a GitLab MR
23
+ - Stage, unstage, or revert each contiguous block of diff lines
24
+ - Auto-reload local diffs when files change, keeping the current screen
25
+ - Import GitHub PR and GitLab MR review comments into the local plan for AI
26
+ - Commits list: commit, amend, fixup, and drop
27
+ - Branches: checkout, create, rebase, drop, pull, and push
28
+ - Review each npm dependency once across `package.json` and the lockfile
29
+ - Propose unused removals, npm audit fixes, and outdated updates as diffs
30
+ - Code highlighting and intra-line diffs: unified, mixed, and side-by-side
31
+ - Auto-update patch and minor releases; confirm a new major
32
+ - Work with git, npm and fetch in background
29
33
 
30
34
  ## Install
31
35
 
@@ -51,35 +55,19 @@ Reviews go in `.review/YYYY-MM-DD-NN.md` with frontmatter `status`:
51
55
  - `partial`: AI started; some items remain
52
56
  - `done`: all items marked `[x]`
53
57
 
54
- ## Hotkeys
55
-
56
- | Key | Action |
57
- | ------------------- | --------------------------------------------- |
58
- | `a` / `u` / `d` | Stage / unstage / drop this diff |
59
- | `c` | File list: `c` commit / `a` amend / `f` fixup |
60
- | `b` | List branches; `n` new; `r` rebase; `d` drop |
61
- | `p` / `s` | git pull/push |
62
- | `f` / `t` / `e` | Feedback / repo todo / edit added lines |
63
- | `m` | Mode: unified / mixed / side-by-side |
64
- | `⏎` | Open the selected file |
65
- | `→` / `j` | Next remaining diff / file |
66
- | `←` / `k` | Previous remaining diff / file |
67
- | `Ctrl-e` / `Ctrl-y` | Scroll one line down / up |
68
- | `Ctrl-f` / `Ctrl-b` | Scroll page down / up |
69
- | `Ctrl-d` / `Ctrl-u` | Scroll half-page down / up |
70
- | `⌫` / `Del` | Remove the selected todo |
71
- | `Esc` / `q` | Quit |
72
-
73
58
  ## Future
74
59
 
75
- - Import issues from GitHub and GitLab.
76
- - Security and code-quality audit; propose a repair plan.
77
- - Send anonymized code blocks for expert review.
78
- - Send questions and the repair plan to experts for approval.
79
- - Ask experts.
80
- - Verify the codebase.
81
- - Apply refactoring skills.
82
- - Call agents, harnesses, and IDEs to execute prepared plans.
60
+ - Import issues from GitHub and GitLab
61
+ - Search, blame, and file history in the review
62
+ - Security and code-quality audit; propose a repair plan
63
+ - Send anonymized code blocks for expert review
64
+ - Send questions and the repair plan to experts for approval
65
+ - Ask experts
66
+ - Verify the codebase
67
+ - Apply refactoring skills
68
+ - Call agents, harnesses, and IDEs to execute prepared plans
69
+ - Squash, cherry-pick, and interactive rebase
70
+ - Merge, rename, and conflict resolution
83
71
 
84
72
  ## License
85
73
 
package/lib/ansi.js CHANGED
@@ -67,6 +67,7 @@ const THEME = {
67
67
  buttonFg: [0x6a, 0x6a, 0x7e],
68
68
  buttonBg: [0x2a, 0x2a, 0x40],
69
69
  buttonHotFg: [0xff, 0xff, 0xff],
70
+ currentBg: [0xa8, 0xcc, 0xb4],
70
71
  checkFg: [0xf4, 0xf4, 0xfc],
71
72
  checkBg: [0x52, 0x52, 0x74],
72
73
  checkDoneFg: [0xf0, 0xff, 0xf4],
@@ -75,6 +76,7 @@ const THEME = {
75
76
  warnFg: [0xe8, 0xc8, 0x4a],
76
77
  mutedFg: [0x7a, 0x7a, 0x8a],
77
78
  shaFg: [0x56, 0x9c, 0xd6],
79
+ shaDarkFg: [0x15, 0x4a, 0x8c],
78
80
  stagedDelLineFg: [0xa0, 0xa0, 0xa8],
79
81
  stagedDelLineBg: [0x20, 0x20, 0x26],
80
82
  stagedDelCharFg: [0xf0, 0xf0, 0xf4],
@@ -25,6 +25,7 @@ const BRANCH_METHODS = [
25
25
  'hasStaged',
26
26
  'lastMessage',
27
27
  'listBranches',
28
+ 'listCommits',
28
29
  'currentBranch',
29
30
  'checkout',
30
31
  'checkoutAsync',
@@ -34,6 +35,8 @@ const BRANCH_METHODS = [
34
35
  'rebaseAsync',
35
36
  'drop',
36
37
  'dropAsync',
38
+ 'dropCommit',
39
+ 'dropCommitAsync',
37
40
  'pull',
38
41
  'pullAsync',
39
42
  'push',
@@ -1,6 +1,11 @@
1
1
  'use strict';
2
2
 
3
3
  const { itemPath } = require('../files.js');
4
+ const {
5
+ countSides,
6
+ formatHunkHeader,
7
+ formatPatch,
8
+ } = require('../diff/patch.js');
4
9
 
5
10
  const manifest = require('./manifest.js');
6
11
  const { MANIFEST, LOCKFILE, SECTION_KEYS, depFileMeta, relOf } = manifest;
@@ -73,6 +78,87 @@ const diffOtherFields = (oldPkg, newPkg) => {
73
78
  return changes;
74
79
  };
75
80
 
81
+ const foldedDepNames = (changes) => {
82
+ const names = new Set();
83
+ for (const change of changes) {
84
+ if (change.section === 'field') continue;
85
+ names.add(change.name);
86
+ }
87
+ return names;
88
+ };
89
+
90
+ const lineEntryName = (text) => {
91
+ const entry = DEP_ENTRY.exec(text);
92
+ return entry ? entry[1] : '';
93
+ };
94
+
95
+ const lineInBlock = (item, line) => {
96
+ const blockId = item.blockId;
97
+ if (typeof blockId !== 'number') return true;
98
+ return line.blockId === blockId;
99
+ };
100
+
101
+ const isSectionFence = (text) =>
102
+ SECTION_OPEN.test(text) || SECTION_END.test(text);
103
+
104
+ const hunkOnlyFoldedDeps = (item, names) => {
105
+ if (!item.hunk || !names.size) return false;
106
+ let folded = 0;
107
+ for (const line of item.hunk.lines) {
108
+ if (line.type === 'ctx') continue;
109
+ if (!lineInBlock(item, line)) continue;
110
+ const name = lineEntryName(line.text);
111
+ if (name && names.has(name)) {
112
+ folded += 1;
113
+ continue;
114
+ }
115
+ if (isSectionFence(line.text)) continue;
116
+ return false;
117
+ }
118
+ return folded > 0;
119
+ };
120
+
121
+ const stripFoldedDepItem = (item, names) => {
122
+ if (!item.hunk || !names.size) return item;
123
+ const lines = [];
124
+ let removed = false;
125
+ for (const line of item.hunk.lines) {
126
+ if (line.type !== 'ctx') {
127
+ const name = lineEntryName(line.text);
128
+ if (name && names.has(name)) {
129
+ removed = true;
130
+ continue;
131
+ }
132
+ }
133
+ lines.push(line);
134
+ }
135
+ if (!removed) return item;
136
+ let hasEdit = false;
137
+ for (const line of lines) {
138
+ if (line.type === 'ctx') continue;
139
+ if (!lineInBlock(item, line)) continue;
140
+ hasEdit = true;
141
+ break;
142
+ }
143
+ if (!hasEdit) return null;
144
+ const counts = countSides(lines);
145
+ const hunk = {
146
+ ...item.hunk,
147
+ oldCount: counts.oldCount,
148
+ newCount: counts.newCount,
149
+ header: formatHunkHeader(
150
+ item.hunk.oldStart,
151
+ counts.oldCount,
152
+ item.hunk.newStart,
153
+ counts.newCount,
154
+ ),
155
+ lines,
156
+ };
157
+ const patchAdd = formatPatch(item.file, hunk, item.blockId, 'old');
158
+ const patchRevert = formatPatch(item.file, hunk, item.blockId, 'new');
159
+ return { ...item, hunk, patchAdd, patchRevert };
160
+ };
161
+
76
162
  const onlyDepEdits = (items) => {
77
163
  if (!items.length) return false;
78
164
  for (const item of items) {
@@ -314,9 +400,12 @@ const collectGroups = (items) => {
314
400
  const takeManifestItems = (summary) => {
315
401
  if (!summary.foldManifest) return [];
316
402
  if (summary.takeAllManifest) return [...summary.manifestItems];
403
+ const names = foldedDepNames(summary.changes);
317
404
  const taken = [];
318
405
  for (const item of summary.manifestItems) {
319
- if (onlyDepEdits([item])) taken.push(item);
406
+ if (onlyDepEdits([item]) || hunkOnlyFoldedDeps(item, names)) {
407
+ taken.push(item);
408
+ }
320
409
  }
321
410
  return taken;
322
411
  };
@@ -328,4 +417,6 @@ module.exports = {
328
417
  summarizeGroup,
329
418
  collectGroups,
330
419
  takeManifestItems,
420
+ foldedDepNames,
421
+ stripFoldedDepItem,
331
422
  };
package/lib/deps/items.js CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  const { itemPath } = require('../files.js');
4
4
 
5
- const { depFileMeta } = require('./manifest.js');
5
+ const { depFileMeta, listFilePath } = require('./manifest.js');
6
6
  const { orderedChanges } = require('./changes.js');
7
7
  const { buildRows, makeHunk } = require('./rows.js');
8
8
 
@@ -57,7 +57,7 @@ const makeDepItem = (origin, fileItems, hidden, change, extra, blockId) => {
57
57
  const allFiles = uniquePaths(fileItems);
58
58
  const files = change ? filesForChange(change, allFiles) : allFiles;
59
59
  const primary = pickPrimary(fileItems);
60
- const oldPath = itemPath(primary);
60
+ const oldPath = listFilePath(itemPath(primary));
61
61
  const newPath = oldPath;
62
62
  const isNew = fileItems.some((item) => item.file && item.file.isNew);
63
63
  const isDeleted = fileItems.every((item) => item.file && item.file.isDeleted);
@@ -92,6 +92,9 @@ const makeDepItem = (origin, fileItems, hidden, change, extra, blockId) => {
92
92
  const makeDepItems = (origin, fileItems, hidden, summary) => {
93
93
  const ordered = orderedChanges(summary.changes);
94
94
  if (!ordered.length) {
95
+ const oldCount = summary.oldCount ?? 0;
96
+ const newCount = summary.newCount ?? 0;
97
+ if (oldCount === newCount) return [];
95
98
  return [makeDepItem(origin, fileItems, hidden, null, summary, 0)];
96
99
  }
97
100
  const items = [];
@@ -29,6 +29,12 @@ const depFileMeta = (rel) => {
29
29
 
30
30
  const relOf = (dir, name) => (dir === '.' ? name : `${dir}/${name}`);
31
31
 
32
+ const listFilePath = (rel) => {
33
+ const meta = depFileMeta(rel);
34
+ if (meta && meta.kind === 'lockfile') return relOf(meta.dir, MANIFEST);
35
+ return posixRel(rel);
36
+ };
37
+
32
38
  const parseJson = (text) => {
33
39
  if (!text || !`${text}`.trim()) return null;
34
40
  try {
@@ -134,6 +140,7 @@ module.exports = {
134
140
  posixRel,
135
141
  depFileMeta,
136
142
  relOf,
143
+ listFilePath,
137
144
  parseJson,
138
145
  emptySectionMaps,
139
146
  readSections,
package/lib/deps/rows.js CHANGED
@@ -153,7 +153,6 @@ const buildRows = (change, oldCount, newCount) => {
153
153
  if (rows.at(-1).text !== '') rows.push(ctxLine(''));
154
154
  rows.push(ctxLine(countLine));
155
155
  }
156
- if (!change && !countLine) rows.push(ctxLine('lockfile updated'));
157
156
  return rows;
158
157
  };
159
158
 
package/lib/deps.js CHANGED
@@ -4,6 +4,7 @@ const { itemPath } = require('./files.js');
4
4
 
5
5
  const manifest = require('./deps/manifest.js');
6
6
  const { MANIFEST, LOCKFILE, depFileMeta, parseJson } = manifest;
7
+ const { relOf, listFilePath } = manifest;
7
8
  const { readSections, lockEntries, lockPackageCount } = manifest;
8
9
 
9
10
  const { mergeDepFile } = require('./deps/merge.js');
@@ -11,6 +12,7 @@ const { mergeDepFile } = require('./deps/merge.js');
11
12
  const changes = require('./deps/changes.js');
12
13
  const { diffSections, mergeResolved, summarizeGroup } = changes;
13
14
  const { collectGroups, takeManifestItems } = changes;
15
+ const { foldedDepNames, stripFoldedDepItem } = changes;
14
16
 
15
17
  const { makeDepItems, stubFileItem } = require('./deps/items.js');
16
18
 
@@ -46,6 +48,47 @@ const loadExportEntries = (names, extra = {}, pkgDir) => {
46
48
  return merged;
47
49
  };
48
50
 
51
+ const groupDir = (key) => key.slice(key.indexOf('\0') + 1);
52
+
53
+ const groupedSynthetics = (synthetic) => {
54
+ const byPath = new Map();
55
+ for (const key of synthetic.keys()) {
56
+ const rel = relOf(groupDir(key), MANIFEST);
57
+ const list = byPath.get(rel) ?? [];
58
+ for (const item of synthetic.get(key)) list.push(item);
59
+ byPath.set(rel, list);
60
+ }
61
+ return byPath;
62
+ };
63
+
64
+ const spliceAtSlots = (out, synthetic, slot) => {
65
+ const byPath = groupedSynthetics(synthetic);
66
+ const inserts = [];
67
+ for (const rel of byPath.keys()) {
68
+ const at = slot.has(rel) ? slot.get(rel) : out.length;
69
+ inserts.push({ at, items: byPath.get(rel) });
70
+ }
71
+ inserts.sort((left, right) => right.at - left.at);
72
+ for (const insert of inserts) {
73
+ out.splice(insert.at, 0, ...insert.items);
74
+ }
75
+ return out;
76
+ };
77
+
78
+ const lastPathIndex = (items, rel) => {
79
+ let at = -1;
80
+ for (let i = 0; i < items.length; i++) {
81
+ if (listFilePath(itemPath(items[i])) === rel) at = i;
82
+ }
83
+ return at;
84
+ };
85
+
86
+ const insertAfterPath = (items, added, rel) => {
87
+ const at = lastPathIndex(items, rel);
88
+ if (at < 0) return [...items, ...added];
89
+ return [...items.slice(0, at + 1), ...added, ...items.slice(at + 1)];
90
+ };
91
+
49
92
  const foldDepItems = (
50
93
  items,
51
94
  readSides = noSides,
@@ -58,6 +101,7 @@ const foldDepItems = (
58
101
  if (!groups.size) return items;
59
102
  const folded = new Set();
60
103
  const synthetic = new Map();
104
+ const leftoverNames = new Map();
61
105
  for (const key of groups.keys()) {
62
106
  const group = groups.get(key);
63
107
  const summary = summarizeGroup(group, readSides);
@@ -72,32 +116,42 @@ const foldDepItems = (
72
116
  const audited = markAuditChanges(unused, audit);
73
117
  const changes = markOutdatedChanges(audited, outdated);
74
118
  const next = { ...summary, changes };
119
+ leftoverNames.set(key, foldedDepNames(next.changes));
75
120
  const taken = takeManifestItems(next);
76
121
  if (next.foldLock) {
77
122
  for (const item of next.lockItems) taken.push(item);
78
123
  }
79
124
  if (!taken.length && !next.changes.length) continue;
80
125
  const made = makeDepItems(group.origin, group.items, taken, next);
81
- synthetic.set(key, made);
126
+ if (made.length) synthetic.set(key, made);
82
127
  for (const item of taken) folded.add(item);
83
128
  }
84
- if (!synthetic.size) return items;
129
+ if (!synthetic.size && !folded.size) return items;
85
130
  const out = [];
86
- const placed = new Set();
131
+ const slot = new Map();
87
132
  for (const item of items) {
88
133
  const rel = itemPath(item);
89
134
  const meta = depFileMeta(rel);
90
135
  if (meta) {
136
+ const pkgRel = relOf(meta.dir, MANIFEST);
91
137
  const key = `${item.origin}\0${meta.dir}`;
92
- if (synthetic.has(key) && !placed.has(key)) {
93
- placed.add(key);
94
- const made = synthetic.get(key);
95
- for (const depItem of made) out.push(depItem);
138
+ if (synthetic.has(key) && !slot.has(pkgRel)) {
139
+ slot.set(pkgRel, out.length);
140
+ }
141
+ }
142
+ if (folded.has(item)) continue;
143
+ if (meta && meta.kind === 'manifest') {
144
+ const names = leftoverNames.get(`${item.origin}\0${meta.dir}`);
145
+ const kept = names ? stripFoldedDepItem(item, names) : item;
146
+ if (kept) {
147
+ out.push(kept);
148
+ slot.set(relOf(meta.dir, MANIFEST), out.length);
96
149
  }
150
+ continue;
97
151
  }
98
- if (!folded.has(item)) out.push(item);
152
+ out.push(item);
99
153
  }
100
- return out;
154
+ return spliceAtSlots(out, synthetic, slot);
101
155
  };
102
156
 
103
157
  const proposeDepItems = (pkgText, outdated, audit, extra = {}) => {
@@ -133,7 +187,8 @@ const mergeProposedItems = (items, pkgText, outdated, audit, extra = {}) => {
133
187
  added.push(item);
134
188
  }
135
189
  if (!added.length) return items;
136
- return [...items, ...added];
190
+ const rel = relOf(extra.dir ?? '.', MANIFEST);
191
+ return insertAfterPath(items, added, rel);
137
192
  };
138
193
 
139
194
  module.exports = {
package/lib/files.js CHANGED
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ const { listFilePath } = require('./deps/manifest.js');
4
+
3
5
  const REVIEW_DIR = '.review';
4
6
  const ORIGIN_ORDER = ['staged', 'unstaged', 'untracked', 'commit', 'pr'];
5
7
  const TODO_FILE = 'TODOs';
@@ -24,6 +26,8 @@ const itemPath = (item) => {
24
26
  return item.file.newPath || item.file.oldPath || '';
25
27
  };
26
28
 
29
+ const listPath = (item) => listFilePath(itemPath(item));
30
+
27
31
  const isTodoItem = (item) => !!(item && item.origin === 'todo');
28
32
 
29
33
  const isTodosEntry = (entry) => !!(entry && entry.kind === 'todos');
@@ -72,6 +76,25 @@ const lineDelta = ({ hunk, blockId }) => {
72
76
  return delta;
73
77
  };
74
78
 
79
+ const countAgo = (n, unit) => {
80
+ const label = n === 1 ? unit : `${unit}s`;
81
+ return `${n} ${label} ago`;
82
+ };
83
+
84
+ const relativeAge = (thenMs, nowMs = Date.now()) => {
85
+ const diff = Math.max(0, Math.round((nowMs - thenMs) / 1000));
86
+ if (diff < 90) return countAgo(diff, 'second');
87
+ const minutes = Math.round(diff / 60);
88
+ if (minutes < 90) return countAgo(minutes, 'minute');
89
+ const hours = Math.round(minutes / 60);
90
+ if (hours < 36) return countAgo(hours, 'hour');
91
+ const days = Math.round(hours / 24);
92
+ if (days < 14) return countAgo(days, 'day');
93
+ if (days < 70) return countAgo(Math.round(days / 7), 'week');
94
+ if (days < 365) return countAgo(Math.round(days / 30), 'month');
95
+ return countAgo(Math.round(days / 365), 'year');
96
+ };
97
+
75
98
  const addLineDelta = (group, origin, delta) => {
76
99
  group.added += delta.added;
77
100
  group.removed += delta.removed;
@@ -116,7 +139,8 @@ const fileEntries = (items) => {
116
139
  for (let i = 0; i < items.length; i++) {
117
140
  const item = items[i];
118
141
  if (isTodoItem(item)) continue;
119
- const rel = itemPath(item);
142
+ const rel = listPath(item);
143
+ if (!rel) continue;
120
144
  if (!groups.has(rel)) {
121
145
  groups.set(rel, {
122
146
  path: rel,
@@ -132,10 +156,12 @@ const fileEntries = (items) => {
132
156
  unstagedRemoved: 0,
133
157
  staged: 0,
134
158
  unstaged: 0,
159
+ date: '',
135
160
  });
136
161
  }
137
162
  const group = groups.get(rel);
138
163
  const { origin } = item;
164
+ if (item.date && !group.date) group.date = item.date;
139
165
  group.origins.push(origin);
140
166
  group.remaining += 1;
141
167
  addLineDelta(group, origin, lineDelta(item));
@@ -149,6 +175,7 @@ const fileEntries = (items) => {
149
175
  for (const group of entries) {
150
176
  group.status = fileStatus(group.origins);
151
177
  }
178
+ entries.sort((left, right) => left.path.localeCompare(right.path, 'en'));
152
179
  return entries;
153
180
  };
154
181
 
@@ -197,6 +224,7 @@ module.exports = {
197
224
  TOTAL_LABEL,
198
225
  isPathInScope,
199
226
  itemPath,
227
+ listPath,
200
228
  isTodoItem,
201
229
  isTodosEntry,
202
230
  isTotalEntry,
@@ -205,4 +233,5 @@ module.exports = {
205
233
  fileStatus,
206
234
  fileEntries,
207
235
  fileTotals,
236
+ relativeAge,
208
237
  };
@@ -26,6 +26,100 @@ const lastMessage = (top) => {
26
26
  return (result.stdout ?? '').replace(/\s+$/, '');
27
27
  };
28
28
 
29
+ const COMMIT_FORMAT = ['%H', '%h', '%an', '%ar', '%s'].join('%x1f');
30
+ const COMMIT_LIMIT = 256;
31
+ const UNIT = '\x1f';
32
+
33
+ const parseCommitLine = (line, index) => {
34
+ const parts = line.split(UNIT);
35
+ if (parts.length < 5) return null;
36
+ const sha = (parts[0] ?? '').trim();
37
+ if (!sha) return null;
38
+ return {
39
+ 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(),
44
+ head: index === 0,
45
+ };
46
+ };
47
+
48
+ const listCommits = (top) => {
49
+ const args = [
50
+ 'log',
51
+ `--format=${COMMIT_FORMAT}`,
52
+ `--max-count=${COMMIT_LIMIT}`,
53
+ ];
54
+ const result = requireOk(runGit(args, top), 'log failed');
55
+ const commits = [];
56
+ for (const line of result.stdout.split('\n')) {
57
+ if (!line) continue;
58
+ const entry = parseCommitLine(line, commits.length);
59
+ if (entry) commits.push(entry);
60
+ }
61
+ return commits;
62
+ };
63
+
64
+ const namedCommit = (sha) => {
65
+ const commit = `${sha ?? ''}`.trim();
66
+ if (!commit) throw new Error('empty commit');
67
+ return commit;
68
+ };
69
+
70
+ const resolveCommit = (top, sha) => {
71
+ const result = requireOk(runGit(['rev-parse', sha], top), 'rev-parse failed');
72
+ return (result.stdout ?? '').trim();
73
+ };
74
+
75
+ const dropHeadCommit = (top) => {
76
+ requireOk(runGit(['reset', '--soft', 'HEAD~1'], top), 'drop failed');
77
+ };
78
+
79
+ const rebaseFailed = (result) => {
80
+ if (result.status === 0) return '';
81
+ return result.stderr || result.stdout || 'rebase failed';
82
+ };
83
+
84
+ const dropOlderCommit = (top, sha) => {
85
+ const onto = `${sha}^`;
86
+ const result = runGit(['rebase', '--onto', onto, sha], top);
87
+ const failed = rebaseFailed(result);
88
+ if (!failed) return;
89
+ runGit(['rebase', '--abort'], top);
90
+ throw new Error(oneLine(failed));
91
+ };
92
+
93
+ const dropOlderCommitAsync = async (top, sha) => {
94
+ const onto = `${sha}^`;
95
+ const result = await runGitAsync(['rebase', '--onto', onto, sha], top);
96
+ const failed = rebaseFailed(result);
97
+ if (!failed) return;
98
+ await runGitAsync(['rebase', '--abort'], top);
99
+ throw new Error(oneLine(failed));
100
+ };
101
+
102
+ const dropCommit = (top, sha) => {
103
+ const spec = namedCommit(sha);
104
+ const target = resolveCommit(top, spec);
105
+ const parent = runGit(['rev-parse', '--verify', `${target}^`], top);
106
+ if (parent.status !== 0) throw new Error('cannot drop root commit');
107
+ const head = resolveCommit(top, 'HEAD');
108
+ if (target === head) return void dropHeadCommit(top);
109
+ dropOlderCommit(top, target);
110
+ };
111
+
112
+ const dropCommitAsync = async (top, sha) => {
113
+ const spec = namedCommit(sha);
114
+ const target = resolveCommit(top, spec);
115
+ const parentArgs = ['rev-parse', '--verify', `${target}^`];
116
+ const parent = await runGitAsync(parentArgs, top);
117
+ if (parent.status !== 0) throw new Error('cannot drop root commit');
118
+ const head = resolveCommit(top, 'HEAD');
119
+ if (target === head) return void dropHeadCommit(top);
120
+ await dropOlderCommitAsync(top, target);
121
+ };
122
+
29
123
  const parseTrack = (text) => {
30
124
  const raw = `${text ?? ''}`.trim().replace(/^\[|\]$/g, '');
31
125
  if (!raw) return { ahead: 0, behind: 0, gone: false };
@@ -125,11 +219,6 @@ const createBranchAsync = async (top, name) => {
125
219
  requireOk(await runGitAsync(args, top), 'checkout failed');
126
220
  };
127
221
 
128
- const rebaseFailed = (result) => {
129
- if (result.status === 0) return '';
130
- return result.stderr || result.stdout || 'rebase failed';
131
- };
132
-
133
222
  const rebaseBranch = (top, onto) => {
134
223
  const branch = namedBranch(onto);
135
224
  const result = runGit(['rebase', branch], top);
@@ -272,6 +361,7 @@ const commitChangesAsync = async (top, kind, message) => {
272
361
 
273
362
  module.exports = {
274
363
  lastMessage,
364
+ listCommits,
275
365
  listBranches,
276
366
  checkoutBranch,
277
367
  checkoutBranchAsync,
@@ -279,6 +369,8 @@ module.exports = {
279
369
  createBranchAsync,
280
370
  rebaseBranch,
281
371
  rebaseBranchAsync,
372
+ dropCommit,
373
+ dropCommitAsync,
282
374
  dropBranch,
283
375
  dropBranchAsync,
284
376
  pullChanges,