cherrypick-interactive 1.7.1 → 1.8.0

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/package.json CHANGED
@@ -1,23 +1,32 @@
1
1
  {
2
2
  "name": "cherrypick-interactive",
3
- "version": "1.7.1",
3
+ "version": "1.8.0",
4
4
  "description": "Interactively cherry-pick commits that are in dev but not in main, using subject-based comparison.",
5
5
  "main": "cli.js",
6
6
  "bin": "cli.js",
7
7
  "type": "module",
8
+ "files": [
9
+ "cli.js",
10
+ "src/"
11
+ ],
8
12
  "scripts": {
9
13
  "lint": "biome lint .",
10
14
  "format": "biome format --write .",
11
15
  "check": "biome check .",
12
16
  "fix": "biome check --write .",
13
- "release": "npm publish --access public"
17
+ "release": "npm publish --access public",
18
+ "test": "node --test test/**/*.test.js"
14
19
  },
15
20
  "engines": {
16
21
  "node": ">=20"
17
22
  },
18
23
  "dependencies": {
19
24
  "chalk": "^5.6.2",
25
+ "htm": "^3.1.1",
26
+ "ink": "^6.8.0",
20
27
  "inquirer": "^13.0.2",
28
+ "react": "^19.2.4",
29
+ "safe-regex2": "^5.1.0",
21
30
  "semver": "^7.7.3",
22
31
  "simple-git": "^3.30.0",
23
32
  "update-notifier": "^7.3.1",
@@ -34,5 +43,5 @@
34
43
  "devops"
35
44
  ],
36
45
  "license": "MIT",
37
- "packageManager": "yarn@4.6.0"
38
- }
46
+ "packageManager": "yarn@4.13.0"
47
+ }
package/src/tui/App.js ADDED
@@ -0,0 +1,194 @@
1
+ import { useState, useEffect, useCallback } from 'react';
2
+ import { Text, Box, useInput, useApp } from 'ink';
3
+ import { html } from './html.js';
4
+ import { Header } from './Header.js';
5
+ import { CommitList } from './CommitList.js';
6
+ import { Preview } from './Preview.js';
7
+ import { KeyBar } from './KeyBar.js';
8
+
9
+ export function App({ commits, gitRawFn, devBranch, mainBranch, since, onDone }) {
10
+ const { exit } = useApp();
11
+ const [cursorIndex, setCursorIndex] = useState(0);
12
+ const [selected, setSelected] = useState(new Set());
13
+ const [previewText, setPreviewText] = useState('');
14
+ const [filterText, setFilterText] = useState('');
15
+ const [isSearching, setIsSearching] = useState(false);
16
+ const [searchInput, setSearchInput] = useState('');
17
+ const [showDiff, setShowDiff] = useState(false);
18
+ const [diffText, setDiffText] = useState('');
19
+ const [confirmQuit, setConfirmQuit] = useState(false);
20
+
21
+ const filtered = filterText
22
+ ? commits.filter((c) => c.subject.toLowerCase().includes(filterText.toLowerCase()))
23
+ : commits;
24
+
25
+ const currentCommit = filtered[cursorIndex];
26
+
27
+ // Load preview for current commit
28
+ useEffect(() => {
29
+ if (!currentCommit) return;
30
+ let cancelled = false;
31
+ gitRawFn(['show', '--stat', '--format=', currentCommit.hash]).then((text) => {
32
+ if (!cancelled) setPreviewText(text.trim());
33
+ }).catch(() => {
34
+ if (!cancelled) setPreviewText('(unable to load preview)');
35
+ });
36
+ return () => { cancelled = true; };
37
+ }, [currentCommit?.hash, gitRawFn]);
38
+
39
+ useInput((input, key) => {
40
+ // Confirm quit dialog
41
+ if (confirmQuit) {
42
+ if (input === 'y' || input === 'Y') {
43
+ onDone([]);
44
+ exit();
45
+ } else {
46
+ setConfirmQuit(false);
47
+ }
48
+ return;
49
+ }
50
+
51
+ // Diff overlay
52
+ if (showDiff) {
53
+ if (key.escape) setShowDiff(false);
54
+ return;
55
+ }
56
+
57
+ // Search mode
58
+ if (isSearching) {
59
+ if (key.escape) {
60
+ setIsSearching(false);
61
+ setSearchInput('');
62
+ } else if (key.return) {
63
+ setFilterText(searchInput);
64
+ setIsSearching(false);
65
+ setCursorIndex(0);
66
+ } else if (key.backspace || key.delete) {
67
+ setSearchInput((s) => s.slice(0, -1));
68
+ } else if (input && !key.ctrl && !key.meta) {
69
+ setSearchInput((s) => s + input);
70
+ }
71
+ return;
72
+ }
73
+
74
+ // Navigation
75
+ if (key.upArrow || input === 'k') {
76
+ setCursorIndex((i) => Math.max(0, i - 1));
77
+ } else if (key.downArrow || input === 'j') {
78
+ setCursorIndex((i) => Math.min(filtered.length - 1, i + 1));
79
+ }
80
+
81
+ // Toggle selection
82
+ else if (input === ' ') {
83
+ if (currentCommit) {
84
+ setSelected((prev) => {
85
+ const next = new Set(prev);
86
+ if (next.has(currentCommit.hash)) {
87
+ next.delete(currentCommit.hash);
88
+ } else {
89
+ next.add(currentCommit.hash);
90
+ }
91
+ return next;
92
+ });
93
+ }
94
+ }
95
+
96
+ // Select all
97
+ else if (input === 'a') {
98
+ setSelected(new Set(filtered.map((c) => c.hash)));
99
+ }
100
+
101
+ // Deselect all
102
+ else if (input === 'n') {
103
+ setSelected(new Set());
104
+ }
105
+
106
+ // Search
107
+ else if (input === '/') {
108
+ setIsSearching(true);
109
+ setSearchInput('');
110
+ }
111
+
112
+ // Diff
113
+ else if (input === 'd') {
114
+ if (currentCommit) {
115
+ setShowDiff(true);
116
+ setDiffText('Loading...');
117
+ gitRawFn(['show', '--stat', '-p', currentCommit.hash]).then((text) => {
118
+ setDiffText(text.trim());
119
+ }).catch(() => {
120
+ setDiffText('(unable to load diff)');
121
+ });
122
+ }
123
+ }
124
+
125
+ // Confirm
126
+ else if (key.return) {
127
+ const selectedHashes = [...selected];
128
+ onDone(selectedHashes);
129
+ exit();
130
+ }
131
+
132
+ // Quit
133
+ else if (input === 'q') {
134
+ if (selected.size > 0) {
135
+ setConfirmQuit(true);
136
+ } else {
137
+ onDone([]);
138
+ exit();
139
+ }
140
+ }
141
+ });
142
+
143
+ // Confirm quit dialog
144
+ if (confirmQuit) {
145
+ return html`
146
+ <${Box} flexDirection="column" padding=${1}>
147
+ <${Text} color="yellow">You have ${selected.size} commit(s) selected. Quit without cherry-picking?</${Text}>
148
+ <${Text} color="dim">Press y to quit, any other key to cancel.</${Text}>
149
+ </${Box}>
150
+ `;
151
+ }
152
+
153
+ // Diff overlay
154
+ if (showDiff) {
155
+ const maxLines = (process.stdout.rows || 24) - 4;
156
+ const lines = diffText.split('\n').slice(0, maxLines).join('\n');
157
+ return html`
158
+ <${Box} flexDirection="column" padding=${1}>
159
+ <${Text} color="cyan">── Full Diff (press Esc to return) ──</${Text}>
160
+ <${Text}>${lines}</${Text}>
161
+ </${Box}>
162
+ `;
163
+ }
164
+
165
+ return html`
166
+ <${Box} flexDirection="column">
167
+ <${Header}
168
+ devBranch=${devBranch}
169
+ mainBranch=${mainBranch}
170
+ commitCount=${filtered.length}
171
+ since=${since}
172
+ />
173
+ ${isSearching
174
+ ? html`<${Box} paddingX=${1}><${Text} color="yellow">Search: ${searchInput}_</${Text}></${Box}>`
175
+ : filterText
176
+ ? html`<${Box} paddingX=${1}><${Text} color="yellow">Filtered: "${filterText}" (${filtered.length} results)</${Text}></${Box}>`
177
+ : null
178
+ }
179
+ <${CommitList}
180
+ commits=${filtered}
181
+ selected=${selected}
182
+ cursorIndex=${cursorIndex}
183
+ />
184
+ <${KeyBar}
185
+ isSearching=${isSearching}
186
+ selectedCount=${selected.size}
187
+ />
188
+ <${Preview}
189
+ previewText=${previewText}
190
+ hash=${currentCommit?.hash}
191
+ />
192
+ </${Box}>
193
+ `;
194
+ }
@@ -0,0 +1,28 @@
1
+ import { Box } from 'ink';
2
+ import { html } from './html.js';
3
+ import { CommitRow } from './CommitRow.js';
4
+
5
+ export function CommitList({ commits, selected, cursorIndex }) {
6
+ const maxVisible = Math.max(5, (process.stdout.rows || 24) - 12);
7
+ const start = Math.max(0, cursorIndex - Math.floor(maxVisible / 2));
8
+ const visible = commits.slice(start, start + maxVisible);
9
+
10
+ return html`
11
+ <${Box} flexDirection="column">
12
+ ${visible.map(
13
+ (c, i) => html`
14
+ <${CommitRow}
15
+ key=${c.hash}
16
+ hash=${c.hash}
17
+ subject=${c.subject}
18
+ isSelected=${selected.has(c.hash)}
19
+ isCursor=${start + i === cursorIndex}
20
+ />
21
+ `,
22
+ )}
23
+ ${commits.length > maxVisible
24
+ ? html`<${Box}><${'Text'} color="dim"> ... ${commits.length - maxVisible} more (scroll with ↑↓)</${'Text'}></${Box}>`
25
+ : null}
26
+ </${Box}>
27
+ `;
28
+ }
@@ -0,0 +1,19 @@
1
+ import { Text, Box } from 'ink';
2
+ import { html } from './html.js';
3
+
4
+ export function CommitRow({ hash, subject, isSelected, isCursor }) {
5
+ const checkbox = isSelected ? '☑' : '☐';
6
+ const checkColor = isSelected ? 'green' : 'gray';
7
+ const cursor = isCursor ? '>' : ' ';
8
+ const cursorColor = isCursor ? 'cyan' : undefined;
9
+ const subjectColor = isCursor ? 'white' : 'gray';
10
+
11
+ return html`
12
+ <${Box}>
13
+ <${Text} color=${cursorColor}>${cursor} </${Text}>
14
+ <${Text} color=${checkColor}>${checkbox} </${Text}>
15
+ <${Text} color="dim">${hash.slice(0, 7)} </${Text}>
16
+ <${Text} color=${subjectColor}>${subject}</${Text}>
17
+ </${Box}>
18
+ `;
19
+ }
@@ -0,0 +1,14 @@
1
+ import { Text, Box } from 'ink';
2
+ import { html } from './html.js';
3
+
4
+ export function Header({ devBranch, mainBranch, commitCount, since }) {
5
+ return html`
6
+ <${Box} borderStyle="single" borderColor="cyan" paddingX=${1}>
7
+ <${Text} color="cyan">${devBranch} → ${mainBranch}</${Text}>
8
+ <${Text}> │ </${Text}>
9
+ <${Text} color="yellow">${commitCount} missing</${Text}>
10
+ <${Text}> │ </${Text}>
11
+ <${Text} color="gray">Since: ${since}</${Text}>
12
+ </${Box}>
13
+ `;
14
+ }
@@ -0,0 +1,20 @@
1
+ import { Text, Box } from 'ink';
2
+ import { html } from './html.js';
3
+
4
+ export function KeyBar({ isSearching, selectedCount }) {
5
+ if (isSearching) {
6
+ return html`
7
+ <${Box} paddingX=${1}>
8
+ <${Text} color="yellow">[Esc] cancel search [Enter] confirm filter</${Text}>
9
+ </${Box}>
10
+ `;
11
+ }
12
+
13
+ return html`
14
+ <${Box} paddingX=${1}>
15
+ <${Text} color="dim">
16
+ [space] toggle [a] all [n] none [/] search [d] diff [enter] confirm (${selectedCount}) [q] quit
17
+ </${Text}>
18
+ </${Box}>
19
+ `;
20
+ }
@@ -0,0 +1,15 @@
1
+ import { Text, Box } from 'ink';
2
+ import { html } from './html.js';
3
+
4
+ export function Preview({ previewText, hash }) {
5
+ if (!hash) {
6
+ return html`<${Box} paddingX=${1}><${Text} color="dim">No commit highlighted</${Text}></${Box}>`;
7
+ }
8
+
9
+ return html`
10
+ <${Box} flexDirection="column" paddingX=${1}>
11
+ <${Text} color="cyan">── Preview (${hash.slice(0, 7)}) ──</${Text}>
12
+ <${Text} color="gray">${previewText || 'Loading...'}</${Text}>
13
+ </${Box}>
14
+ `;
15
+ }
@@ -0,0 +1,4 @@
1
+ import { createElement } from 'ink';
2
+ import htm from 'htm';
3
+
4
+ export const html = htm.bind(createElement);
@@ -0,0 +1,34 @@
1
+ import { render } from 'ink';
2
+ import { createElement } from 'react';
3
+ import { App } from './App.js';
4
+
5
+ /**
6
+ * Render the TUI commit selector.
7
+ * @param {Array<{hash: string, subject: string}>} commits
8
+ * @param {Function} gitRawFn
9
+ * @param {{ devBranch: string, mainBranch: string, since: string }} options
10
+ * @returns {Promise<string[]>} selected commit hashes
11
+ */
12
+ export function renderCommitSelector(commits, gitRawFn, { devBranch, mainBranch, since }) {
13
+ return new Promise((resolve) => {
14
+ const { unmount, waitUntilExit } = render(
15
+ createElement(App, {
16
+ commits,
17
+ gitRawFn,
18
+ devBranch,
19
+ mainBranch,
20
+ since,
21
+ onDone: (selectedHashes) => {
22
+ resolve(selectedHashes);
23
+ },
24
+ }),
25
+ );
26
+
27
+ waitUntilExit().then(() => {
28
+ // Fallback: if exited without calling onDone, resolve with empty
29
+ resolve([]);
30
+ }).catch(() => {
31
+ resolve([]);
32
+ });
33
+ });
34
+ }
@@ -1,107 +0,0 @@
1
- name: Release & Publish
2
-
3
- on:
4
- push:
5
- branches: [main]
6
-
7
- permissions:
8
- contents: write
9
-
10
- jobs:
11
- release:
12
- runs-on: ubuntu-latest
13
- steps:
14
- - uses: actions/checkout@v4
15
- with:
16
- fetch-depth: 0
17
-
18
- - uses: actions/setup-node@v4
19
- with:
20
- node-version: 20
21
- registry-url: https://registry.npmjs.org
22
-
23
- - name: Get current version
24
- id: current
25
- run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
26
-
27
- - name: Determine version bump from commits
28
- id: bump
29
- run: |
30
- # Get commits since last tag (or all commits if no tags)
31
- LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
32
-
33
- if [ -z "$LAST_TAG" ]; then
34
- COMMITS=$(git log --pretty=format:"%s" HEAD)
35
- else
36
- COMMITS=$(git log --pretty=format:"%s" "${LAST_TAG}..HEAD")
37
- fi
38
-
39
- if [ -z "$COMMITS" ]; then
40
- echo "bump=none" >> $GITHUB_OUTPUT
41
- exit 0
42
- fi
43
-
44
- BUMP="none"
45
-
46
- # Check for breaking changes (major)
47
- if echo "$COMMITS" | grep -qE "^[a-z]+(\(.+\))?!:"; then
48
- BUMP="major"
49
- # Check for feat (minor)
50
- elif echo "$COMMITS" | grep -qE "^feat(\(.+\))?:"; then
51
- BUMP="minor"
52
- # Check for fix, refactor, perf, etc. (patch)
53
- elif echo "$COMMITS" | grep -qE "^(fix|refactor|perf|chore|docs|style|test|ci)(\(.+\))?:"; then
54
- BUMP="patch"
55
- fi
56
-
57
- echo "bump=$BUMP" >> $GITHUB_OUTPUT
58
-
59
- - name: Bump version
60
- if: steps.bump.outputs.bump != 'none'
61
- id: version
62
- run: |
63
- BUMP=${{ steps.bump.outputs.bump }}
64
- CURRENT=${{ steps.current.outputs.version }}
65
-
66
- IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT"
67
-
68
- case "$BUMP" in
69
- major) MAJOR=$((MAJOR + 1)); MINOR=0; PATCH=0 ;;
70
- minor) MINOR=$((MINOR + 1)); PATCH=0 ;;
71
- patch) PATCH=$((PATCH + 1)) ;;
72
- esac
73
-
74
- NEW_VERSION="${MAJOR}.${MINOR}.${PATCH}"
75
- echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
76
-
77
- # Update package.json
78
- node -e "
79
- const pkg = require('./package.json');
80
- pkg.version = '${NEW_VERSION}';
81
- require('fs').writeFileSync('package.json', JSON.stringify(pkg, null, 2) + '\n');
82
- "
83
-
84
- echo "Version: $CURRENT → $NEW_VERSION ($BUMP)"
85
-
86
- - name: Publish to npm
87
- if: steps.bump.outputs.bump != 'none'
88
- run: npm publish --access public
89
- env:
90
- NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
91
-
92
- - name: Commit version bump and tag
93
- if: steps.bump.outputs.bump != 'none'
94
- run: |
95
- git config user.name "github-actions[bot]"
96
- git config user.email "github-actions[bot]@users.noreply.github.com"
97
- git add package.json
98
- git commit -m "chore(release): v${{ steps.version.outputs.new_version }}"
99
- git tag "v${{ steps.version.outputs.new_version }}"
100
- git push
101
- git push --tags
102
-
103
- - name: Create GitHub Release
104
- if: steps.bump.outputs.bump != 'none'
105
- run: gh release create "v${{ steps.version.outputs.new_version }}" --generate-notes --title "v${{ steps.version.outputs.new_version }}"
106
- env:
107
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
package/.yarnrc.yml DELETED
@@ -1 +0,0 @@
1
- nodeLinker: node-modules
package/biome.json DELETED
@@ -1,23 +0,0 @@
1
- {
2
- "$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
3
- "formatter": {
4
- "enabled": true,
5
- "indentStyle": "space",
6
- "indentWidth": 2,
7
- "lineWidth": 100,
8
- "ignore": ["node_modules", "yarn.lock"]
9
- },
10
- "linter": {
11
- "enabled": true,
12
- "rules": {
13
- "recommended": true
14
- }
15
- },
16
- "organizeImports": {
17
- "enabled": true
18
- },
19
- "vcs": {
20
- "enabled": true,
21
- "clientKind": "git"
22
- }
23
- }