smart-git-undo 1.0.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/LICENSE +21 -0
- package/README.md +120 -0
- package/package.json +40 -0
- package/src/actions.js +132 -0
- package/src/detector.js +189 -0
- package/src/git.js +48 -0
- package/src/index.js +42 -0
- package/src/redo.js +26 -0
- package/src/stack.js +70 -0
- package/src/ui.js +135 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Larsen Cundric
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# smart-git-undo
|
|
2
|
+
|
|
3
|
+
A smart CLI tool that figures out what you probably want to undo. Just type `git undo`.
|
|
4
|
+
|
|
5
|
+
No flags to remember, no man pages to read. It looks at your recent git state and does the right thing.
|
|
6
|
+
|
|
7
|
+
## What it does
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
$ git commit -m "whoops"
|
|
11
|
+
$ git undo
|
|
12
|
+
|
|
13
|
+
đ Detected: Revert commit a1b2c3d ("whoops")
|
|
14
|
+
Type: commit
|
|
15
|
+
|
|
16
|
+
â
Reverted commit a1b2c3d ("whoops"). Changes are preserved in your working tree.
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
It detects and undoes these situations:
|
|
20
|
+
|
|
21
|
+
| You just did... | `git undo` will... |
|
|
22
|
+
|---|---|
|
|
23
|
+
| `git add` files | Unstage them |
|
|
24
|
+
| Started a merge with conflicts | `git merge --abort` |
|
|
25
|
+
| Completed a rebase | Restore pre-rebase state from reflog |
|
|
26
|
+
| Merged a branch | Undo the merge commit |
|
|
27
|
+
| Deleted a branch | Restore it from reflog |
|
|
28
|
+
| Made a commit | Soft reset (keeps your changes) |
|
|
29
|
+
|
|
30
|
+
Detection is prioritized in that order â if you staged files and also have a recent commit, it unstages first.
|
|
31
|
+
|
|
32
|
+
## Install
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
npm install -g smart-git-undo
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Or run without installing:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
npx smart-git-undo
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Once installed, git finds it automatically as a subcommand:
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
git undo # undo the last thing
|
|
48
|
+
git redo # went too far? redo it
|
|
49
|
+
git undo --dry-run # see what it would do without doing it
|
|
50
|
+
git undo --history # see your undo/redo history
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Usage
|
|
54
|
+
|
|
55
|
+
### Undo
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
git undo
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Analyzes your recent git state and performs the most likely undo. Commits are soft-reset so your changes stay in the working tree.
|
|
62
|
+
|
|
63
|
+
### Dry run
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
git undo --dry-run
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Shows what would be undone and the exact git command, without changing anything.
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
đī¸ Dry run â nothing will be changed
|
|
73
|
+
|
|
74
|
+
đ Detected: Revert commit a1b2c3d ("bad commit")
|
|
75
|
+
Type: commit
|
|
76
|
+
|
|
77
|
+
Would execute: git reset --soft HEAD~1
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Redo
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
git redo
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Reverses the last undo. Useful when you undo too far.
|
|
87
|
+
|
|
88
|
+
### History
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
git undo --history
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
Shows your full undo/redo timeline:
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
đ Undo/Redo History
|
|
98
|
+
|
|
99
|
+
Actions (newest first):
|
|
100
|
+
âĒī¸ Redo: Reverted commit a1b2c3d: "bad commit" â 4/2/2026, 3:21:00 PM
|
|
101
|
+
âŠī¸ Reverted commit a1b2c3d: "bad commit" â 4/2/2026, 3:20:58 PM
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## How it works
|
|
105
|
+
|
|
106
|
+
1. **Detector** (`src/detector.js`) checks git state in priority order: staged files â in-progress merge â recent rebase â merge commit â deleted branch â recent commit
|
|
107
|
+
2. **Actions** (`src/actions.js`) performs the undo safely â commits use soft reset to preserve changes, merges and rebases use hard reset
|
|
108
|
+
3. **Stack** (`src/stack.js`) records every undo in `.git/undo-stack/stack.json` so redo and history work across sessions
|
|
109
|
+
|
|
110
|
+
The undo stack lives inside `.git/` so it's per-repo and never gets committed.
|
|
111
|
+
|
|
112
|
+
## Platform support
|
|
113
|
+
|
|
114
|
+
- **macOS** â supported
|
|
115
|
+
- **Linux** â supported
|
|
116
|
+
- **Windows** â should work in Git Bash / WSL (not tested)
|
|
117
|
+
|
|
118
|
+
## License
|
|
119
|
+
|
|
120
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "smart-git-undo",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Smart CLI tool â type 'git undo' and it figures out what you probably want to undo",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"git",
|
|
8
|
+
"undo",
|
|
9
|
+
"redo",
|
|
10
|
+
"cli",
|
|
11
|
+
"revert",
|
|
12
|
+
"reset",
|
|
13
|
+
"developer-tools",
|
|
14
|
+
"terminal",
|
|
15
|
+
"reflog"
|
|
16
|
+
],
|
|
17
|
+
"author": "Larsen Cundric",
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"repository": {
|
|
20
|
+
"type": "git",
|
|
21
|
+
"url": "https://github.com/LarsenCundric/smart-git-undo.git"
|
|
22
|
+
},
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=18"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"src/"
|
|
28
|
+
],
|
|
29
|
+
"bin": {
|
|
30
|
+
"git-undo": "./src/index.js",
|
|
31
|
+
"git-redo": "./src/redo.js"
|
|
32
|
+
},
|
|
33
|
+
"scripts": {
|
|
34
|
+
"start": "node src/index.js",
|
|
35
|
+
"demo": "bash demo.sh"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"chalk": "^5.3.0"
|
|
39
|
+
}
|
|
40
|
+
}
|
package/src/actions.js
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { git, getHeadSha, getShortSha, getCurrentBranch } from './git.js';
|
|
2
|
+
import { recordUndo, recordRedo } from './stack.js';
|
|
3
|
+
|
|
4
|
+
export function undoStaged(details) {
|
|
5
|
+
const { files } = details;
|
|
6
|
+
git('reset HEAD -- ' + files.map(f => `"${f}"`).join(' '));
|
|
7
|
+
|
|
8
|
+
recordUndo({
|
|
9
|
+
type: 'staged',
|
|
10
|
+
description: `Unstaged ${files.length} file(s)`,
|
|
11
|
+
restore: { files },
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
return `Unstaged ${files.length} file(s).`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function undoInProgressMerge(details) {
|
|
18
|
+
const { mergeHead, branch } = details;
|
|
19
|
+
git('merge --abort');
|
|
20
|
+
|
|
21
|
+
recordUndo({
|
|
22
|
+
type: 'in-progress-merge',
|
|
23
|
+
description: `Aborted merge from ${branch}`,
|
|
24
|
+
restore: { mergeHead, branch },
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
return `Aborted in-progress merge from ${branch}.`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function undoRebase(details) {
|
|
31
|
+
const { preRebaseSha, currentSha } = details;
|
|
32
|
+
const branch = getCurrentBranch();
|
|
33
|
+
git(`reset --hard ${preRebaseSha}`);
|
|
34
|
+
|
|
35
|
+
recordUndo({
|
|
36
|
+
type: 'rebase',
|
|
37
|
+
description: `Undid rebase on ${branch}`,
|
|
38
|
+
restore: { postRebaseSha: currentSha, branch },
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
return `Restored ${branch} to pre-rebase state (${getShortSha(preRebaseSha)}).`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function undoMerge(details) {
|
|
45
|
+
const { sha, message } = details;
|
|
46
|
+
git('reset --hard HEAD~1');
|
|
47
|
+
|
|
48
|
+
recordUndo({
|
|
49
|
+
type: 'merge',
|
|
50
|
+
description: `Undid merge: "${message}"`,
|
|
51
|
+
restore: { sha },
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
return `Undid merge commit (${getShortSha(sha)}): "${message}".`;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function undoDeletedBranch(details) {
|
|
58
|
+
const { sha, branch } = details;
|
|
59
|
+
git(`branch ${branch} ${sha}`);
|
|
60
|
+
|
|
61
|
+
recordUndo({
|
|
62
|
+
type: 'deleted-branch',
|
|
63
|
+
description: `Restored branch "${branch}"`,
|
|
64
|
+
restore: { sha, branch },
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
return `Restored branch "${branch}" at ${getShortSha(sha)}.`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function undoCommit(details) {
|
|
71
|
+
const { sha, parentSha, message, shortSha } = details;
|
|
72
|
+
git('reset --soft HEAD~1');
|
|
73
|
+
|
|
74
|
+
recordUndo({
|
|
75
|
+
type: 'commit',
|
|
76
|
+
description: `Reverted commit ${shortSha}: "${message}"`,
|
|
77
|
+
restore: { sha, message },
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
return `Reverted commit ${shortSha} ("${message}"). Changes are preserved in your working tree.`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function redo(entry) {
|
|
84
|
+
const { type, restore } = entry;
|
|
85
|
+
let result;
|
|
86
|
+
|
|
87
|
+
switch (type) {
|
|
88
|
+
case 'staged':
|
|
89
|
+
git('add -- ' + restore.files.map(f => `"${f}"`).join(' '));
|
|
90
|
+
result = `Re-staged ${restore.files.length} file(s).`;
|
|
91
|
+
break;
|
|
92
|
+
case 'in-progress-merge':
|
|
93
|
+
git(`merge ${restore.mergeHead}`);
|
|
94
|
+
result = `Re-started merge from ${restore.branch}.`;
|
|
95
|
+
break;
|
|
96
|
+
case 'rebase':
|
|
97
|
+
git(`reset --hard ${restore.postRebaseSha}`);
|
|
98
|
+
result = `Restored post-rebase state on ${restore.branch}.`;
|
|
99
|
+
break;
|
|
100
|
+
case 'merge':
|
|
101
|
+
git(`reset --hard ${restore.sha}`);
|
|
102
|
+
result = `Restored merge commit ${getShortSha(restore.sha)}.`;
|
|
103
|
+
break;
|
|
104
|
+
case 'deleted-branch':
|
|
105
|
+
git(`branch -D ${restore.branch}`);
|
|
106
|
+
result = `Re-deleted branch "${restore.branch}".`;
|
|
107
|
+
break;
|
|
108
|
+
case 'commit':
|
|
109
|
+
git(`commit -m "${restore.message.replace(/"/g, '\\"')}"`);
|
|
110
|
+
result = `Re-committed: "${restore.message}".`;
|
|
111
|
+
break;
|
|
112
|
+
default:
|
|
113
|
+
return `Unknown undo type "${type}" â cannot redo.`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
recordRedo({ type, description: `Redo: ${entry.description}`, restore });
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function performUndo(detection) {
|
|
121
|
+
const { type, details } = detection;
|
|
122
|
+
|
|
123
|
+
switch (type) {
|
|
124
|
+
case 'staged': return undoStaged(details);
|
|
125
|
+
case 'in-progress-merge': return undoInProgressMerge(details);
|
|
126
|
+
case 'rebase': return undoRebase(details);
|
|
127
|
+
case 'merge': return undoMerge(details);
|
|
128
|
+
case 'deleted-branch': return undoDeletedBranch(details);
|
|
129
|
+
case 'commit': return undoCommit(details);
|
|
130
|
+
default: return `Unknown action type: ${type}`;
|
|
131
|
+
}
|
|
132
|
+
}
|
package/src/detector.js
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { git, gitLines, getCurrentBranch, getHeadSha, getShortSha, getCommitMessage } from './git.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Analyzes recent git state and returns the most likely "undo" action.
|
|
5
|
+
* Priority order:
|
|
6
|
+
* 1. Staged files (user just ran git add)
|
|
7
|
+
* 2. In-progress merge (MERGE_HEAD exists)
|
|
8
|
+
* 3. Recent rebase (check reflog for rebase entries)
|
|
9
|
+
* 4. Recent merge commit (HEAD is a merge)
|
|
10
|
+
* 5. Recent branch deletion (check reflog)
|
|
11
|
+
* 6. Recent commit (most common case)
|
|
12
|
+
*
|
|
13
|
+
* Returns: { type, description, details } or null
|
|
14
|
+
*/
|
|
15
|
+
export function detect() {
|
|
16
|
+
// 1. Staged files?
|
|
17
|
+
const staged = detectStaged();
|
|
18
|
+
if (staged) return staged;
|
|
19
|
+
|
|
20
|
+
// 2. In-progress merge? (MERGE_HEAD present)
|
|
21
|
+
const mergingNow = detectInProgressMerge();
|
|
22
|
+
if (mergingNow) return mergingNow;
|
|
23
|
+
|
|
24
|
+
// 3. Recent rebase?
|
|
25
|
+
const rebase = detectRebase();
|
|
26
|
+
if (rebase) return rebase;
|
|
27
|
+
|
|
28
|
+
// 4. HEAD is a merge commit?
|
|
29
|
+
const merge = detectMergeCommit();
|
|
30
|
+
if (merge) return merge;
|
|
31
|
+
|
|
32
|
+
// 5. Deleted branch recently?
|
|
33
|
+
const branch = detectDeletedBranch();
|
|
34
|
+
if (branch) return branch;
|
|
35
|
+
|
|
36
|
+
// 6. Recent commit
|
|
37
|
+
const commit = detectCommit();
|
|
38
|
+
if (commit) return commit;
|
|
39
|
+
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function detectStaged() {
|
|
44
|
+
const staged = gitLines('diff --cached --name-only', { allowFailure: true });
|
|
45
|
+
if (staged.length === 0) return null;
|
|
46
|
+
|
|
47
|
+
return {
|
|
48
|
+
type: 'staged',
|
|
49
|
+
description: `Unstage ${staged.length} file${staged.length > 1 ? 's' : ''}`,
|
|
50
|
+
details: { files: staged },
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function detectInProgressMerge() {
|
|
55
|
+
const mergeHead = git('rev-parse MERGE_HEAD', { silent: true, allowFailure: true });
|
|
56
|
+
if (!mergeHead) return null;
|
|
57
|
+
|
|
58
|
+
const branch = git('name-rev --name-only --no-undefined MERGE_HEAD', { silent: true, allowFailure: true }) || getShortSha(mergeHead);
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
type: 'in-progress-merge',
|
|
62
|
+
description: `Abort in-progress merge from ${branch}`,
|
|
63
|
+
details: { mergeHead, branch },
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function detectRebase() {
|
|
68
|
+
// Check reflog for recent rebase finish (within last 10 entries)
|
|
69
|
+
const reflog = gitLines('reflog --format=%H:%gs -n 10', { allowFailure: true });
|
|
70
|
+
|
|
71
|
+
for (let i = 0; i < reflog.length; i++) {
|
|
72
|
+
const [sha, ...msgParts] = reflog[i].split(':');
|
|
73
|
+
const msg = msgParts.join(':');
|
|
74
|
+
|
|
75
|
+
if (/rebase.*finished|rebase.*onto/.test(msg)) {
|
|
76
|
+
// Find the pre-rebase state: the entry right before the rebase started
|
|
77
|
+
// Look for the reflog entry before the rebase sequence
|
|
78
|
+
const preRebase = findPreRebaseState(reflog, i);
|
|
79
|
+
if (preRebase) {
|
|
80
|
+
return {
|
|
81
|
+
type: 'rebase',
|
|
82
|
+
description: 'Undo recent rebase',
|
|
83
|
+
details: { preRebaseSha: preRebase, currentSha: getHeadSha() },
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function findPreRebaseState(reflog, rebaseIdx) {
|
|
93
|
+
// Walk backwards from the rebase to find where HEAD was before
|
|
94
|
+
for (let i = rebaseIdx + 1; i < reflog.length; i++) {
|
|
95
|
+
const [sha, ...msgParts] = reflog[i].split(':');
|
|
96
|
+
const msg = msgParts.join(':');
|
|
97
|
+
// The entry just before the rebase operations is the pre-rebase state
|
|
98
|
+
if (!/rebase/.test(msg)) {
|
|
99
|
+
return sha;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// If all 10 entries are rebase, look further back
|
|
103
|
+
const deeper = gitLines('reflog --format=%H:%gs -n 50', { allowFailure: true });
|
|
104
|
+
for (const line of deeper) {
|
|
105
|
+
const [sha, ...msgParts] = line.split(':');
|
|
106
|
+
const msg = msgParts.join(':');
|
|
107
|
+
if (!/rebase/.test(msg)) {
|
|
108
|
+
return sha;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function detectMergeCommit() {
|
|
115
|
+
const parents = git('cat-file -p HEAD', { silent: true, allowFailure: true });
|
|
116
|
+
if (!parents) return null;
|
|
117
|
+
|
|
118
|
+
const parentLines = parents.split('\n').filter(l => l.startsWith('parent '));
|
|
119
|
+
if (parentLines.length < 2) return null; // not a merge
|
|
120
|
+
|
|
121
|
+
const headSha = getHeadSha();
|
|
122
|
+
const msg = getCommitMessage(headSha);
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
type: 'merge',
|
|
126
|
+
description: `Undo merge commit: "${msg}"`,
|
|
127
|
+
details: { sha: headSha, message: msg },
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function detectDeletedBranch() {
|
|
132
|
+
// Look in reflog for recent branch deletions
|
|
133
|
+
// When a branch is deleted, there's usually a reflog entry referencing it
|
|
134
|
+
const reflog = gitLines('reflog --format=%H:%gs -n 20', { allowFailure: true });
|
|
135
|
+
|
|
136
|
+
for (const line of reflog) {
|
|
137
|
+
const [sha, ...msgParts] = line.split(':');
|
|
138
|
+
const msg = msgParts.join(':');
|
|
139
|
+
|
|
140
|
+
// Branch delete shows up via the branch's last known reflog
|
|
141
|
+
// But we can also check stale branch references
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Better approach: check for recently deleted branches from all reflogs
|
|
145
|
+
const allRefs = git('for-each-ref --sort=-committerdate --format=%(refname:short) refs/heads/', { silent: true, allowFailure: true });
|
|
146
|
+
|
|
147
|
+
// Check if there are orphaned reflog entries that don't have matching branches
|
|
148
|
+
// This is complex â we'll look for the pattern in `git reflog` that mentions branch deletion
|
|
149
|
+
const fullReflog = gitLines('reflog --all --format=%gd:%H:%gs -n 30', { allowFailure: true });
|
|
150
|
+
|
|
151
|
+
for (const line of fullReflog) {
|
|
152
|
+
const parts = line.split(':');
|
|
153
|
+
if (parts.length >= 3) {
|
|
154
|
+
const msg = parts.slice(2).join(':');
|
|
155
|
+
if (/Branch: deleted/.test(msg) || /branch: Reset.*delete/.test(msg)) {
|
|
156
|
+
const sha = parts[1];
|
|
157
|
+
const branchMatch = msg.match(/refs\/heads\/(\S+)/);
|
|
158
|
+
const branchName = branchMatch ? branchMatch[1] : null;
|
|
159
|
+
if (branchName && sha) {
|
|
160
|
+
return {
|
|
161
|
+
type: 'deleted-branch',
|
|
162
|
+
description: `Restore deleted branch "${branchName}"`,
|
|
163
|
+
details: { sha, branch: branchName },
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function detectCommit() {
|
|
174
|
+
const headSha = getHeadSha();
|
|
175
|
+
if (!headSha) return null;
|
|
176
|
+
|
|
177
|
+
// Make sure there's a parent (not the initial commit)
|
|
178
|
+
const parentSha = git('rev-parse HEAD~1', { silent: true, allowFailure: true });
|
|
179
|
+
if (!parentSha) return null;
|
|
180
|
+
|
|
181
|
+
const msg = getCommitMessage(headSha);
|
|
182
|
+
const short = getShortSha(headSha);
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
type: 'commit',
|
|
186
|
+
description: `Revert commit ${short} ("${msg}")`,
|
|
187
|
+
details: { sha: headSha, parentSha, message: msg, shortSha: short },
|
|
188
|
+
};
|
|
189
|
+
}
|
package/src/git.js
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { execSync } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
export function git(args, { silent = false, allowFailure = false } = {}) {
|
|
4
|
+
try {
|
|
5
|
+
return execSync(`git ${args}`, {
|
|
6
|
+
encoding: 'utf-8',
|
|
7
|
+
stdio: silent ? 'pipe' : ['pipe', 'pipe', 'pipe'],
|
|
8
|
+
}).trim();
|
|
9
|
+
} catch (err) {
|
|
10
|
+
if (allowFailure) return null;
|
|
11
|
+
throw err;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function gitLines(args, opts) {
|
|
16
|
+
const out = git(args, { silent: true, ...opts });
|
|
17
|
+
if (!out) return [];
|
|
18
|
+
return out.split('\n').filter(Boolean);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function getGitDir() {
|
|
22
|
+
return git('rev-parse --git-dir', { silent: true });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function isInsideWorkTree() {
|
|
26
|
+
try {
|
|
27
|
+
git('rev-parse --is-inside-work-tree', { silent: true });
|
|
28
|
+
return true;
|
|
29
|
+
} catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function getCurrentBranch() {
|
|
35
|
+
return git('rev-parse --abbrev-ref HEAD', { silent: true, allowFailure: true });
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function getHeadSha() {
|
|
39
|
+
return git('rev-parse HEAD', { silent: true, allowFailure: true });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function getShortSha(sha) {
|
|
43
|
+
return git(`rev-parse --short ${sha}`, { silent: true, allowFailure: true });
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function getCommitMessage(sha) {
|
|
47
|
+
return git(`log -1 --format=%s ${sha}`, { silent: true, allowFailure: true });
|
|
48
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { isInsideWorkTree } from './git.js';
|
|
4
|
+
import { detect } from './detector.js';
|
|
5
|
+
import { performUndo } from './actions.js';
|
|
6
|
+
import { getHistory } from './stack.js';
|
|
7
|
+
import { printDetection, printDryRun, printResult, printError, printNothing, printHistory } from './ui.js';
|
|
8
|
+
|
|
9
|
+
const args = process.argv.slice(2);
|
|
10
|
+
const dryRun = args.includes('--dry-run');
|
|
11
|
+
const showHistory = args.includes('--history');
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
if (!isInsideWorkTree()) {
|
|
15
|
+
printError('Not inside a git repository.');
|
|
16
|
+
process.exit(1);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
if (showHistory) {
|
|
20
|
+
printHistory(getHistory());
|
|
21
|
+
process.exit(0);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const detection = detect();
|
|
25
|
+
|
|
26
|
+
if (!detection) {
|
|
27
|
+
printNothing();
|
|
28
|
+
process.exit(0);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (dryRun) {
|
|
32
|
+
printDryRun(detection);
|
|
33
|
+
process.exit(0);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
printDetection(detection);
|
|
37
|
+
const result = performUndo(detection);
|
|
38
|
+
printResult(result);
|
|
39
|
+
} catch (err) {
|
|
40
|
+
printError(err.message || String(err));
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
package/src/redo.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { isInsideWorkTree } from './git.js';
|
|
4
|
+
import { popRedoable } from './stack.js';
|
|
5
|
+
import { redo } from './actions.js';
|
|
6
|
+
import { printRedoResult, printError } from './ui.js';
|
|
7
|
+
|
|
8
|
+
try {
|
|
9
|
+
if (!isInsideWorkTree()) {
|
|
10
|
+
printError('Not inside a git repository.');
|
|
11
|
+
process.exit(1);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const entry = popRedoable();
|
|
15
|
+
|
|
16
|
+
if (!entry) {
|
|
17
|
+
printError('Nothing to redo.');
|
|
18
|
+
process.exit(0);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const result = redo(entry);
|
|
22
|
+
printRedoResult(result);
|
|
23
|
+
} catch (err) {
|
|
24
|
+
printError(err.message || String(err));
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
package/src/stack.js
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { getGitDir } from './git.js';
|
|
4
|
+
|
|
5
|
+
function getStackDir() {
|
|
6
|
+
const dir = join(getGitDir(), 'undo-stack');
|
|
7
|
+
mkdirSync(dir, { recursive: true });
|
|
8
|
+
return dir;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function readJson(path, fallback) {
|
|
12
|
+
try {
|
|
13
|
+
return JSON.parse(readFileSync(path, 'utf-8'));
|
|
14
|
+
} catch {
|
|
15
|
+
return fallback;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function getStackPath() {
|
|
20
|
+
return join(getStackDir(), 'stack.json');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function loadStack() {
|
|
24
|
+
return readJson(getStackPath(), { done: [], redoable: [] });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function saveStack(stack) {
|
|
28
|
+
writeFileSync(getStackPath(), JSON.stringify(stack, null, 2));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Record that an undo was performed. The entry contains info to redo it.
|
|
33
|
+
* This pushes to the redoable stack (user can redo) and records in done (history).
|
|
34
|
+
*/
|
|
35
|
+
export function recordUndo(entry) {
|
|
36
|
+
const stack = loadStack();
|
|
37
|
+
const timestamped = { ...entry, timestamp: new Date().toISOString() };
|
|
38
|
+
stack.done.push(timestamped);
|
|
39
|
+
stack.redoable.push(timestamped);
|
|
40
|
+
saveStack(stack);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Pop the most recent redoable entry. Returns null if nothing to redo.
|
|
45
|
+
*/
|
|
46
|
+
export function popRedoable() {
|
|
47
|
+
const stack = loadStack();
|
|
48
|
+
const entry = stack.redoable.pop();
|
|
49
|
+
if (entry) {
|
|
50
|
+
saveStack(stack);
|
|
51
|
+
}
|
|
52
|
+
return entry || null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Record that a redo was performed (goes into done history).
|
|
57
|
+
*/
|
|
58
|
+
export function recordRedo(entry) {
|
|
59
|
+
const stack = loadStack();
|
|
60
|
+
stack.done.push({ ...entry, timestamp: new Date().toISOString(), action: 'redo' });
|
|
61
|
+
saveStack(stack);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function getHistory() {
|
|
65
|
+
const stack = loadStack();
|
|
66
|
+
return {
|
|
67
|
+
done: [...stack.done],
|
|
68
|
+
redoable: [...stack.redoable],
|
|
69
|
+
};
|
|
70
|
+
}
|
package/src/ui.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
|
|
3
|
+
const ICONS = {
|
|
4
|
+
detect: '\u{1F50D}',
|
|
5
|
+
undo: '\u21A9\uFE0F ',
|
|
6
|
+
redo: '\u21AA\uFE0F ',
|
|
7
|
+
info: '\u2139\uFE0F ',
|
|
8
|
+
warn: '\u26A0\uFE0F ',
|
|
9
|
+
ok: '\u2705',
|
|
10
|
+
staged: '\u{1F4E6}',
|
|
11
|
+
commit: '\u{1F4DD}',
|
|
12
|
+
merge: '\u{1F500}',
|
|
13
|
+
rebase: '\u{1F504}',
|
|
14
|
+
branch: '\u{1F33F}',
|
|
15
|
+
history: '\u{1F4DC}',
|
|
16
|
+
dry: '\u{1F441}\uFE0F ',
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function printDetection(detection) {
|
|
20
|
+
const icon = getIcon(detection.type);
|
|
21
|
+
console.log();
|
|
22
|
+
console.log(chalk.cyan.bold(`${ICONS.detect} Detected: `) + chalk.white(detection.description));
|
|
23
|
+
console.log(chalk.dim(` Type: ${detection.type}`));
|
|
24
|
+
|
|
25
|
+
if (detection.type === 'staged' && detection.details.files) {
|
|
26
|
+
const files = detection.details.files;
|
|
27
|
+
const shown = files.slice(0, 5);
|
|
28
|
+
for (const f of shown) {
|
|
29
|
+
console.log(chalk.dim(` - ${f}`));
|
|
30
|
+
}
|
|
31
|
+
if (files.length > 5) {
|
|
32
|
+
console.log(chalk.dim(` ... and ${files.length - 5} more`));
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function printDryRun(detection) {
|
|
38
|
+
console.log();
|
|
39
|
+
console.log(chalk.yellow.bold(`${ICONS.dry} Dry run â nothing will be changed`));
|
|
40
|
+
printDetection(detection);
|
|
41
|
+
console.log();
|
|
42
|
+
console.log(chalk.yellow(` Would execute: `) + chalk.white(describeAction(detection)));
|
|
43
|
+
console.log();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function printResult(message) {
|
|
47
|
+
console.log();
|
|
48
|
+
console.log(chalk.green.bold(`${ICONS.ok} `) + chalk.white(message));
|
|
49
|
+
console.log();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function printRedoResult(message) {
|
|
53
|
+
console.log();
|
|
54
|
+
console.log(chalk.blue.bold(`${ICONS.redo} Redo: `) + chalk.white(message));
|
|
55
|
+
console.log();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function printError(message) {
|
|
59
|
+
console.log();
|
|
60
|
+
console.log(chalk.red.bold(`${ICONS.warn} `) + chalk.red(message));
|
|
61
|
+
console.log();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function printNothing() {
|
|
65
|
+
console.log();
|
|
66
|
+
console.log(chalk.yellow(`${ICONS.info} Nothing to undo.`));
|
|
67
|
+
console.log(chalk.dim(' No recent git action detected that can be undone.'));
|
|
68
|
+
console.log();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function printHistory(history) {
|
|
72
|
+
const { done, redoable } = history;
|
|
73
|
+
|
|
74
|
+
console.log();
|
|
75
|
+
console.log(chalk.cyan.bold(`${ICONS.history} Undo/Redo History`));
|
|
76
|
+
console.log();
|
|
77
|
+
|
|
78
|
+
if (done.length === 0 && redoable.length === 0) {
|
|
79
|
+
console.log(chalk.dim(' No history yet.'));
|
|
80
|
+
console.log();
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (done.length > 0) {
|
|
85
|
+
console.log(chalk.green.bold(' Actions') + chalk.dim(' (newest first):'));
|
|
86
|
+
for (let i = done.length - 1; i >= 0; i--) {
|
|
87
|
+
const e = done[i];
|
|
88
|
+
const time = new Date(e.timestamp).toLocaleString();
|
|
89
|
+
const icon = e.action === 'redo' ? ICONS.redo : ICONS.undo;
|
|
90
|
+
const color = e.action === 'redo' ? chalk.blue : chalk.green;
|
|
91
|
+
console.log(color(` ${icon} ${e.description}`) + chalk.dim(` â ${time}`));
|
|
92
|
+
}
|
|
93
|
+
console.log();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (redoable.length > 0) {
|
|
97
|
+
console.log(chalk.blue.bold(' Available to redo') + chalk.dim(` (${redoable.length}):`));
|
|
98
|
+
for (let i = redoable.length - 1; i >= 0; i--) {
|
|
99
|
+
const e = redoable[i];
|
|
100
|
+
console.log(chalk.blue(` ${ICONS.redo} ${e.description}`));
|
|
101
|
+
}
|
|
102
|
+
console.log();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function getIcon(type) {
|
|
107
|
+
const map = {
|
|
108
|
+
staged: ICONS.staged,
|
|
109
|
+
'in-progress-merge': ICONS.merge,
|
|
110
|
+
rebase: ICONS.rebase,
|
|
111
|
+
merge: ICONS.merge,
|
|
112
|
+
'deleted-branch': ICONS.branch,
|
|
113
|
+
commit: ICONS.commit,
|
|
114
|
+
};
|
|
115
|
+
return map[type] || ICONS.info;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function describeAction(detection) {
|
|
119
|
+
switch (detection.type) {
|
|
120
|
+
case 'staged':
|
|
121
|
+
return `git reset HEAD -- <${detection.details.files.length} files>`;
|
|
122
|
+
case 'in-progress-merge':
|
|
123
|
+
return 'git merge --abort';
|
|
124
|
+
case 'rebase':
|
|
125
|
+
return `git reset --hard ${detection.details.preRebaseSha.slice(0, 7)}`;
|
|
126
|
+
case 'merge':
|
|
127
|
+
return 'git reset --hard HEAD~1';
|
|
128
|
+
case 'deleted-branch':
|
|
129
|
+
return `git branch ${detection.details.branch} ${detection.details.sha.slice(0, 7)}`;
|
|
130
|
+
case 'commit':
|
|
131
|
+
return 'git reset --soft HEAD~1';
|
|
132
|
+
default:
|
|
133
|
+
return '(unknown)';
|
|
134
|
+
}
|
|
135
|
+
}
|