gitsquash 0.0.1 โ 0.1.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/bin/gitsquash.js +27 -1
- package/package.json +12 -2
- package/readme.md +67 -0
- package/src/index.js +270 -1
package/bin/gitsquash.js
CHANGED
@@ -1,3 +1,29 @@
|
|
1
1
|
#!/usr/bin/env node
|
2
2
|
|
3
|
-
|
3
|
+
import { program } from 'commander';
|
4
|
+
import { main } from '../src/index.js';
|
5
|
+
import chalk from 'chalk';
|
6
|
+
|
7
|
+
program
|
8
|
+
.name('gitsquash')
|
9
|
+
.description('Interactive CLI tool to squash git commits')
|
10
|
+
.version('0.0.1')
|
11
|
+
.option('-n, --number <count>', 'number of recent commits to show', '10')
|
12
|
+
.option('-m, --message <message>', 'preset commit message (skips the prompt)')
|
13
|
+
.option('--dry-run', 'show what commits would be squashed without actually squashing')
|
14
|
+
.addHelpText('after', `
|
15
|
+
Examples:
|
16
|
+
$ gitsquash # Interactive squash of last 10 commits
|
17
|
+
$ gitsquash -n 5 # Show only last 5 commits
|
18
|
+
$ gitsquash -m "feat: xyz" # Squash with preset commit message
|
19
|
+
$ gitsquash --dry-run # Preview squash operation
|
20
|
+
`);
|
21
|
+
|
22
|
+
program.parse();
|
23
|
+
|
24
|
+
const options = program.opts();
|
25
|
+
|
26
|
+
main(options).catch(error => {
|
27
|
+
console.error(chalk.red('An unexpected error occurred:'), error.message);
|
28
|
+
process.exit(1);
|
29
|
+
});
|
package/package.json
CHANGED
@@ -1,17 +1,27 @@
|
|
1
1
|
{
|
2
2
|
"name": "gitsquash",
|
3
|
-
"version": "0.0
|
4
|
-
"description": "
|
3
|
+
"version": "0.1.0",
|
4
|
+
"description": "Interactive CLI tool to squash git commits",
|
5
5
|
"main": "src/index.js",
|
6
6
|
"bin": {
|
7
7
|
"gitsquash": "bin/gitsquash.js"
|
8
8
|
},
|
9
|
+
"type": "module",
|
9
10
|
"files": [
|
10
11
|
"bin",
|
11
12
|
"src",
|
12
13
|
"license.md",
|
13
14
|
"package.json"
|
14
15
|
],
|
16
|
+
"scripts": {
|
17
|
+
"start": "node bin/gitsquash.js"
|
18
|
+
},
|
19
|
+
"dependencies": {
|
20
|
+
"chalk": "^5.3.0",
|
21
|
+
"inquirer": "^9.2.12",
|
22
|
+
"simple-git": "^3.22.0",
|
23
|
+
"commander": "^11.1.0"
|
24
|
+
},
|
15
25
|
"author": "Anoop M D",
|
16
26
|
"license": "MIT"
|
17
27
|
}
|
package/readme.md
ADDED
@@ -0,0 +1,67 @@
|
|
1
|
+
# gitsquash
|
2
|
+
|
3
|
+
An interactive CLI tool that makes git commit squashing simple and intuitive. Select multiple commits using an interactive interface, provide a new commit message, and squash them into a single commit.
|
4
|
+
|
5
|
+
## Features
|
6
|
+
|
7
|
+
- ๐ Interactive commit selection with checkboxes
|
8
|
+
- ๐ Preview commit details (hash, date, message)
|
9
|
+
- โก๏ธ Simple keyboard-based navigation
|
10
|
+
- ๐ Automatic stashing of uncommitted changes
|
11
|
+
- ๐ Dry-run mode to preview changes
|
12
|
+
- ๐ฌ Optional preset commit messages
|
13
|
+
|
14
|
+
## Installation
|
15
|
+
|
16
|
+
```bash
|
17
|
+
# Install globally
|
18
|
+
npm install -g gitsquash
|
19
|
+
|
20
|
+
# Or run directly with npx
|
21
|
+
npx gitsquash
|
22
|
+
```
|
23
|
+
|
24
|
+
## Usage
|
25
|
+
|
26
|
+
```bash
|
27
|
+
# Basic interactive mode - shows last 10 commits
|
28
|
+
gitsquash
|
29
|
+
|
30
|
+
# Show only last 5 commits
|
31
|
+
gitsquash -n 5
|
32
|
+
|
33
|
+
# Squash with a preset commit message (skips the message prompt)
|
34
|
+
gitsquash -m "feat: combine recent changes"
|
35
|
+
|
36
|
+
# Preview what would happen without making changes
|
37
|
+
gitsquash --dry-run
|
38
|
+
```
|
39
|
+
|
40
|
+
## Options
|
41
|
+
|
42
|
+
| Option | Description |
|
43
|
+
|--------|-------------|
|
44
|
+
| `-n, --number <count>` | Number of recent commits to show (default: 10) |
|
45
|
+
| `-m, --message <message>` | Preset commit message (skips the message prompt) |
|
46
|
+
| `--dry-run` | Preview squash operation without making changes |
|
47
|
+
| `--help` | Display help information |
|
48
|
+
| `--version` | Display version number |
|
49
|
+
|
50
|
+
## How It Works
|
51
|
+
|
52
|
+
1. Shows you a list of recent commits
|
53
|
+
2. Use space bar to select commits you want to squash
|
54
|
+
3. Press enter to confirm selection
|
55
|
+
4. Enter a new commit message (or use preset with `-m`)
|
56
|
+
5. The selected commits will be squashed into a single commit
|
57
|
+
|
58
|
+
## Notes
|
59
|
+
|
60
|
+
- Requires git to be installed and available in PATH
|
61
|
+
- Works on any git repository
|
62
|
+
- Automatically handles uncommitted changes by stashing them
|
63
|
+
- Minimum of 2 commits required for squashing
|
64
|
+
|
65
|
+
## License
|
66
|
+
|
67
|
+
MIT
|
package/src/index.js
CHANGED
@@ -1 +1,270 @@
|
|
1
|
-
|
1
|
+
import inquirer from 'inquirer';
|
2
|
+
import chalk from 'chalk';
|
3
|
+
import simpleGit from 'simple-git';
|
4
|
+
|
5
|
+
const git = simpleGit();
|
6
|
+
|
7
|
+
async function getRecentCommits(options) {
|
8
|
+
try {
|
9
|
+
const maxCount = parseInt(options.number) || 10;
|
10
|
+
const log = await git.log({ maxCount });
|
11
|
+
return log.all;
|
12
|
+
} catch (error) {
|
13
|
+
console.error(chalk.red('Error fetching git commits:'), error.message);
|
14
|
+
process.exit(1);
|
15
|
+
}
|
16
|
+
}
|
17
|
+
|
18
|
+
async function selectCommits(commits) {
|
19
|
+
const choices = commits.map(commit => ({
|
20
|
+
name: `${chalk.yellow(commit.hash.slice(0, 7))} - ${chalk.blue(commit.date)} - ${commit.message}`,
|
21
|
+
value: commit.hash,
|
22
|
+
short: commit.hash.slice(0, 7)
|
23
|
+
}));
|
24
|
+
|
25
|
+
const { selectedCommits } = await inquirer.prompt([
|
26
|
+
{
|
27
|
+
type: 'checkbox',
|
28
|
+
name: 'selectedCommits',
|
29
|
+
message: 'Select commits to squash (space to select, enter to confirm):',
|
30
|
+
choices,
|
31
|
+
pageSize: 10,
|
32
|
+
validate: input => {
|
33
|
+
if (input.length < 2) return 'Please select at least 2 commits to squash';
|
34
|
+
return true;
|
35
|
+
}
|
36
|
+
}
|
37
|
+
]);
|
38
|
+
|
39
|
+
return selectedCommits;
|
40
|
+
}
|
41
|
+
|
42
|
+
async function getNewCommitMessage(presetMessage) {
|
43
|
+
if (presetMessage) {
|
44
|
+
return presetMessage;
|
45
|
+
}
|
46
|
+
|
47
|
+
const { message } = await inquirer.prompt([
|
48
|
+
{
|
49
|
+
type: 'input',
|
50
|
+
name: 'message',
|
51
|
+
message: 'Enter the new commit message:',
|
52
|
+
validate: input => {
|
53
|
+
if (!input.trim()) return 'Commit message cannot be empty';
|
54
|
+
return true;
|
55
|
+
}
|
56
|
+
}
|
57
|
+
]);
|
58
|
+
|
59
|
+
return message;
|
60
|
+
}
|
61
|
+
|
62
|
+
async function squashLatestCommits(commits, message) {
|
63
|
+
const oldestCommit = commits[commits.length - 1];
|
64
|
+
|
65
|
+
// Reset to the commit before the oldest commit we want to squash
|
66
|
+
await git.reset(['--soft', `${oldestCommit}~1`]);
|
67
|
+
// Create a new commit with all the changes
|
68
|
+
await git.commit(message);
|
69
|
+
}
|
70
|
+
|
71
|
+
async function squashNonConsecutiveCommits(commits, message) {
|
72
|
+
// Get the current branch name
|
73
|
+
const branchData = await git.branch();
|
74
|
+
const currentBranch = branchData.current;
|
75
|
+
|
76
|
+
// Create a temporary branch
|
77
|
+
const tempBranch = `temp-squash-${Date.now()}`;
|
78
|
+
|
79
|
+
// Get all commits after our newest selected commit
|
80
|
+
console.log(chalk.yellow('\n๐ Phase 1: Analyzing commits...'));
|
81
|
+
const newestSelectedCommit = commits[0];
|
82
|
+
const log = await git.log();
|
83
|
+
const laterCommits = [];
|
84
|
+
for (const commit of log.all) {
|
85
|
+
if (commit.hash === newestSelectedCommit) {
|
86
|
+
break;
|
87
|
+
}
|
88
|
+
laterCommits.push({ hash: commit.hash, message: commit.message.split('\n')[0] });
|
89
|
+
}
|
90
|
+
console.log(chalk.dim(` Found ${laterCommits.length} newer commits to preserve`));
|
91
|
+
|
92
|
+
console.log(chalk.yellow('\n๐ฆ Phase 2: Preparing workspace...'));
|
93
|
+
console.log(chalk.dim(` Creating temporary branch: ${tempBranch}`));
|
94
|
+
await git.checkout(['-b', tempBranch]);
|
95
|
+
|
96
|
+
try {
|
97
|
+
console.log(chalk.yellow('\n๐ Phase 3: Reconstructing history...'));
|
98
|
+
const oldestCommit = commits[commits.length - 1];
|
99
|
+
console.log(chalk.dim(` Resetting to parent of ${oldestCommit.slice(0, 7)}`));
|
100
|
+
await git.reset(['--hard', `${oldestCommit}~1`]);
|
101
|
+
|
102
|
+
// Cherry pick all commits in order
|
103
|
+
console.log(chalk.magenta('\n ๐ Cherry picking selected commits:'));
|
104
|
+
for (const commit of commits.reverse()) {
|
105
|
+
const shortHash = commit.slice(0, 7);
|
106
|
+
process.stdout.write(chalk.dim(` Processing ${shortHash}... `));
|
107
|
+
await git.raw(['cherry-pick', commit]);
|
108
|
+
console.log(chalk.green('โ'));
|
109
|
+
}
|
110
|
+
|
111
|
+
// Now squash all the commits
|
112
|
+
console.log(chalk.magenta('\n ๐ผ Creating squashed commit:'));
|
113
|
+
console.log(chalk.dim(` Resetting to parent of ${oldestCommit.slice(0, 7)}`));
|
114
|
+
await git.reset(['--soft', `${oldestCommit}~1`]);
|
115
|
+
process.stdout.write(chalk.dim(' Creating new commit... '));
|
116
|
+
await git.commit(message);
|
117
|
+
console.log(chalk.green('โ'));
|
118
|
+
|
119
|
+
// Get the new commit hash
|
120
|
+
const newCommit = await git.revparse(['HEAD']);
|
121
|
+
const shortNewCommit = newCommit.slice(0, 7);
|
122
|
+
console.log(chalk.dim(` New commit hash: ${shortNewCommit}`));
|
123
|
+
|
124
|
+
console.log(chalk.yellow('\n๐ Phase 4: Applying changes to main branch...'));
|
125
|
+
console.log(chalk.dim(` Switching back to ${currentBranch}`));
|
126
|
+
await git.checkout([currentBranch]);
|
127
|
+
|
128
|
+
console.log(chalk.dim(` Resetting to parent of ${oldestCommit.slice(0, 7)}`));
|
129
|
+
await git.reset(['--hard', `${oldestCommit}~1`]);
|
130
|
+
|
131
|
+
process.stdout.write(chalk.dim(` Applying squashed commit ${shortNewCommit}... `));
|
132
|
+
await git.raw(['cherry-pick', newCommit]);
|
133
|
+
console.log(chalk.green('โ'));
|
134
|
+
|
135
|
+
// Now cherry pick all the later commits back on top
|
136
|
+
if (laterCommits.length > 0) {
|
137
|
+
console.log(chalk.magenta('\n ๐ Restoring newer commits:'));
|
138
|
+
for (const commit of laterCommits.reverse()) {
|
139
|
+
const shortHash = commit.hash.slice(0, 7);
|
140
|
+
process.stdout.write(chalk.dim(` ${shortHash} ${commit.message}... `));
|
141
|
+
await git.raw(['cherry-pick', commit.hash]);
|
142
|
+
console.log(chalk.green('โ'));
|
143
|
+
}
|
144
|
+
}
|
145
|
+
|
146
|
+
// Clean up: delete temporary branch
|
147
|
+
console.log(chalk.yellow('\n๐งน Phase 5: Cleanup'));
|
148
|
+
process.stdout.write(chalk.dim(` Removing temporary branch ${tempBranch}... `));
|
149
|
+
await git.branch(['-D', tempBranch]);
|
150
|
+
console.log(chalk.green('โ'));
|
151
|
+
|
152
|
+
} catch (error) {
|
153
|
+
// If something goes wrong, try to cleanup
|
154
|
+
console.log(chalk.red('\nโ Error: Squash failed!'));
|
155
|
+
console.log(chalk.yellow('๐งน Cleaning up...'));
|
156
|
+
console.log(chalk.dim(` Switching back to ${currentBranch}`));
|
157
|
+
await git.checkout([currentBranch]);
|
158
|
+
process.stdout.write(chalk.dim(` Removing temporary branch ${tempBranch}... `));
|
159
|
+
await git.branch(['-D', tempBranch]).catch(() => {});
|
160
|
+
console.log(chalk.green('โ'));
|
161
|
+
throw error;
|
162
|
+
}
|
163
|
+
}
|
164
|
+
|
165
|
+
async function areCommitsLatestAndConsecutive(commits) {
|
166
|
+
// Get recent commits - fetch at least the number of commits we're checking
|
167
|
+
const log = await git.log({ maxCount: commits.length });
|
168
|
+
const recentCommits = log.all.map(c => c.hash);
|
169
|
+
|
170
|
+
// Sort our selected commits by comparing their positions in the recent commits
|
171
|
+
const sortedSelectedCommits = [...commits].sort((a, b) => {
|
172
|
+
return recentCommits.indexOf(a) - recentCommits.indexOf(b);
|
173
|
+
});
|
174
|
+
|
175
|
+
// Check if they are the latest commits
|
176
|
+
for (let i = 0; i < commits.length; i++) {
|
177
|
+
if (sortedSelectedCommits[i] !== recentCommits[i]) {
|
178
|
+
return false;
|
179
|
+
}
|
180
|
+
}
|
181
|
+
|
182
|
+
return true;
|
183
|
+
}
|
184
|
+
|
185
|
+
async function squashCommits(commits, message, isDryRun) {
|
186
|
+
try {
|
187
|
+
const oldestCommit = commits[commits.length - 1];
|
188
|
+
const status = await git.status();
|
189
|
+
|
190
|
+
if (isDryRun) {
|
191
|
+
// Get all commits to show context - fetch more than selected for better context
|
192
|
+
const log = await git.log({ maxCount: Math.max(10, commits.length + 3) });
|
193
|
+
|
194
|
+
console.log(chalk.blue('\n๐ Dry Run - Squash Preview\n'));
|
195
|
+
|
196
|
+
// Show current state
|
197
|
+
console.log(chalk.yellow('Current Commits:'));
|
198
|
+
log.all.forEach(commit => {
|
199
|
+
const isSelected = commits.includes(commit.hash);
|
200
|
+
const prefix = isSelected ? '๐ท' : 'โช๏ธ';
|
201
|
+
const hash = commit.hash.slice(0, 7);
|
202
|
+
const message = commit.message.split('\n')[0];
|
203
|
+
console.log(`${prefix} ${chalk.dim(hash)} ${isSelected ? chalk.yellow(message) : message}`);
|
204
|
+
});
|
205
|
+
|
206
|
+
// Show future state
|
207
|
+
console.log(chalk.yellow('\nAfter Squash:'));
|
208
|
+
log.all.forEach(commit => {
|
209
|
+
const hash = commit.hash.slice(0, 7);
|
210
|
+
if (commits.includes(commit.hash)) {
|
211
|
+
if (commit.hash === oldestCommit) {
|
212
|
+
// Show the new squashed commit
|
213
|
+
console.log(`๐ถ ${chalk.dim('NEW')} ${chalk.green(message)}`);
|
214
|
+
}
|
215
|
+
// Skip other commits that will be squashed
|
216
|
+
return;
|
217
|
+
}
|
218
|
+
// Show unaffected commits
|
219
|
+
console.log(`โช๏ธ ${chalk.dim(hash)} ${commit.message.split('\n')[0]}`);
|
220
|
+
});
|
221
|
+
|
222
|
+
console.log(chalk.blue('\nDetails:'));
|
223
|
+
console.log(`โข ${commits.length} commits will be squashed into one`);
|
224
|
+
console.log(`โข New commit message: "${message}"`);
|
225
|
+
if (status.files.length > 0) {
|
226
|
+
console.log(`โข ${status.files.length} uncommitted changes will be preserved`);
|
227
|
+
}
|
228
|
+
return;
|
229
|
+
}
|
230
|
+
|
231
|
+
if (status.files.length > 0) {
|
232
|
+
console.log(chalk.yellow('Warning: You have uncommitted changes. Stashing them...'));
|
233
|
+
await git.stash(['save', 'temporary stash before squash']);
|
234
|
+
}
|
235
|
+
|
236
|
+
const useSimpleApproach = await areCommitsLatestAndConsecutive(commits);
|
237
|
+
|
238
|
+
if (useSimpleApproach) {
|
239
|
+
await squashLatestCommits(commits, message);
|
240
|
+
} else {
|
241
|
+
console.log(chalk.yellow('\n๐ Using advanced squash approach (non-consecutive or non-latest commits)...'));
|
242
|
+
await squashNonConsecutiveCommits(commits, message);
|
243
|
+
}
|
244
|
+
|
245
|
+
if (status.files.length > 0) {
|
246
|
+
await git.stash(['pop']);
|
247
|
+
}
|
248
|
+
|
249
|
+
console.log(chalk.green('\nโจ Successfully squashed commits!'));
|
250
|
+
} catch (error) {
|
251
|
+
console.error(chalk.red('Error during squash:'), error.message);
|
252
|
+
process.exit(1);
|
253
|
+
}
|
254
|
+
}
|
255
|
+
|
256
|
+
export async function main(options = {}) {
|
257
|
+
console.log(chalk.blue('๐ Fetching recent commits...'));
|
258
|
+
|
259
|
+
const commits = await getRecentCommits(options);
|
260
|
+
if (commits.length < 2) {
|
261
|
+
console.log(chalk.yellow('Not enough commits to squash. Need at least 2 commits.'));
|
262
|
+
process.exit(0);
|
263
|
+
}
|
264
|
+
|
265
|
+
const selectedCommits = await selectCommits(commits);
|
266
|
+
const newMessage = await getNewCommitMessage(options.message);
|
267
|
+
|
268
|
+
console.log(chalk.blue('\n๐ Squashing commits...'));
|
269
|
+
await squashCommits(selectedCommits, newMessage, options.dryRun);
|
270
|
+
}
|