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 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
+ }
@@ -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
+ }