git-smart-flow 0.3.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/CHANGELOG.md +33 -0
- package/LICENSE +21 -0
- package/README.md +130 -0
- package/bin/git-smart-flow.js +2 -0
- package/bin/gsf.js +2 -0
- package/bin/gsfc.js +3 -0
- package/bin/gsfm.js +3 -0
- package/bin/gsfp.js +3 -0
- package/bin/gsfpr.js +3 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +214 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/aliases.d.ts +2 -0
- package/dist/commands/aliases.js +37 -0
- package/dist/commands/aliases.js.map +1 -0
- package/dist/commands/branch.d.ts +2 -0
- package/dist/commands/branch.js +414 -0
- package/dist/commands/branch.js.map +1 -0
- package/dist/commands/commit-message.d.ts +7 -0
- package/dist/commands/commit-message.js +95 -0
- package/dist/commands/commit-message.js.map +1 -0
- package/dist/commands/commit.d.ts +3 -0
- package/dist/commands/commit.js +597 -0
- package/dist/commands/commit.js.map +1 -0
- package/dist/commands/config.d.ts +2 -0
- package/dist/commands/config.js +88 -0
- package/dist/commands/config.js.map +1 -0
- package/dist/commands/doctor.d.ts +2 -0
- package/dist/commands/doctor.js +246 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/info.d.ts +2 -0
- package/dist/commands/info.js +155 -0
- package/dist/commands/info.js.map +1 -0
- package/dist/commands/install-hooks.d.ts +2 -0
- package/dist/commands/install-hooks.js +66 -0
- package/dist/commands/install-hooks.js.map +1 -0
- package/dist/commands/log.d.ts +2 -0
- package/dist/commands/log.js +101 -0
- package/dist/commands/log.js.map +1 -0
- package/dist/commands/menu.d.ts +2 -0
- package/dist/commands/menu.js +297 -0
- package/dist/commands/menu.js.map +1 -0
- package/dist/commands/merge.d.ts +6 -0
- package/dist/commands/merge.js +128 -0
- package/dist/commands/merge.js.map +1 -0
- package/dist/commands/pr.d.ts +2 -0
- package/dist/commands/pr.js +731 -0
- package/dist/commands/pr.js.map +1 -0
- package/dist/commands/push.d.ts +7 -0
- package/dist/commands/push.js +225 -0
- package/dist/commands/push.js.map +1 -0
- package/dist/commands/reflog.d.ts +2 -0
- package/dist/commands/reflog.js +162 -0
- package/dist/commands/reflog.js.map +1 -0
- package/dist/commands/repo-init.d.ts +2 -0
- package/dist/commands/repo-init.js +466 -0
- package/dist/commands/repo-init.js.map +1 -0
- package/dist/commands/revert.d.ts +7 -0
- package/dist/commands/revert.js +694 -0
- package/dist/commands/revert.js.map +1 -0
- package/dist/commands/setup.d.ts +2 -0
- package/dist/commands/setup.js +86 -0
- package/dist/commands/setup.js.map +1 -0
- package/dist/commands/stash.d.ts +2 -0
- package/dist/commands/stash.js +130 -0
- package/dist/commands/stash.js.map +1 -0
- package/dist/commands/sync.d.ts +2 -0
- package/dist/commands/sync.js +335 -0
- package/dist/commands/sync.js.map +1 -0
- package/dist/commands/tag.d.ts +2 -0
- package/dist/commands/tag.js +163 -0
- package/dist/commands/tag.js.map +1 -0
- package/dist/commands/validate.d.ts +2 -0
- package/dist/commands/validate.js +203 -0
- package/dist/commands/validate.js.map +1 -0
- package/dist/config/config.d.ts +10 -0
- package/dist/config/config.js +126 -0
- package/dist/config/config.js.map +1 -0
- package/dist/git/ai-context-builder.d.ts +11 -0
- package/dist/git/ai-context-builder.js +112 -0
- package/dist/git/ai-context-builder.js.map +1 -0
- package/dist/git/convention-detector.d.ts +3 -0
- package/dist/git/convention-detector.js +211 -0
- package/dist/git/convention-detector.js.map +1 -0
- package/dist/git/ensure-repo.d.ts +7 -0
- package/dist/git/ensure-repo.js +141 -0
- package/dist/git/ensure-repo.js.map +1 -0
- package/dist/git/gitignore.d.ts +8 -0
- package/dist/git/gitignore.js +261 -0
- package/dist/git/gitignore.js.map +1 -0
- package/dist/git/remote-setup.d.ts +2 -0
- package/dist/git/remote-setup.js +129 -0
- package/dist/git/remote-setup.js.map +1 -0
- package/dist/git/repo.d.ts +73 -0
- package/dist/git/repo.js +308 -0
- package/dist/git/repo.js.map +1 -0
- package/dist/git/validate.d.ts +36 -0
- package/dist/git/validate.js +113 -0
- package/dist/git/validate.js.map +1 -0
- package/dist/providers/base.provider.d.ts +10 -0
- package/dist/providers/base.provider.js +40 -0
- package/dist/providers/base.provider.js.map +1 -0
- package/dist/providers/claude.provider.d.ts +14 -0
- package/dist/providers/claude.provider.js +85 -0
- package/dist/providers/claude.provider.js.map +1 -0
- package/dist/providers/copilot.provider.d.ts +12 -0
- package/dist/providers/copilot.provider.js +88 -0
- package/dist/providers/copilot.provider.js.map +1 -0
- package/dist/providers/heuristic.provider.d.ts +9 -0
- package/dist/providers/heuristic.provider.js +163 -0
- package/dist/providers/heuristic.provider.js.map +1 -0
- package/dist/providers/ollama.provider.d.ts +14 -0
- package/dist/providers/ollama.provider.js +83 -0
- package/dist/providers/ollama.provider.js.map +1 -0
- package/dist/providers/openai.provider.d.ts +14 -0
- package/dist/providers/openai.provider.js +84 -0
- package/dist/providers/openai.provider.js.map +1 -0
- package/dist/providers/provider.factory.d.ts +5 -0
- package/dist/providers/provider.factory.js +51 -0
- package/dist/providers/provider.factory.js.map +1 -0
- package/dist/security/scanner.d.ts +13 -0
- package/dist/security/scanner.js +138 -0
- package/dist/security/scanner.js.map +1 -0
- package/dist/types/index.d.ts +146 -0
- package/dist/types/index.js +2 -0
- package/dist/types/index.js.map +1 -0
- package/dist/ux/components/BranchTree.d.ts +8 -0
- package/dist/ux/components/BranchTree.js +57 -0
- package/dist/ux/components/BranchTree.js.map +1 -0
- package/dist/ux/components/CommitProposal.d.ts +13 -0
- package/dist/ux/components/CommitProposal.js +127 -0
- package/dist/ux/components/CommitProposal.js.map +1 -0
- package/dist/ux/components/DiagnosticReport.d.ts +18 -0
- package/dist/ux/components/DiagnosticReport.js +19 -0
- package/dist/ux/components/DiagnosticReport.js.map +1 -0
- package/dist/ux/components/ErrorBox.d.ts +7 -0
- package/dist/ux/components/ErrorBox.js +9 -0
- package/dist/ux/components/ErrorBox.js.map +1 -0
- package/dist/ux/components/FileSelector.d.ts +14 -0
- package/dist/ux/components/FileSelector.js +87 -0
- package/dist/ux/components/FileSelector.js.map +1 -0
- package/dist/ux/components/Logo.d.ts +6 -0
- package/dist/ux/components/Logo.js +21 -0
- package/dist/ux/components/Logo.js.map +1 -0
- package/dist/ux/components/RepoContext.d.ts +8 -0
- package/dist/ux/components/RepoContext.js +17 -0
- package/dist/ux/components/RepoContext.js.map +1 -0
- package/dist/ux/components/SecurityAlert.d.ts +9 -0
- package/dist/ux/components/SecurityAlert.js +16 -0
- package/dist/ux/components/SecurityAlert.js.map +1 -0
- package/dist/ux/components/StatusDashboard.d.ts +14 -0
- package/dist/ux/components/StatusDashboard.js +36 -0
- package/dist/ux/components/StatusDashboard.js.map +1 -0
- package/dist/ux/components/SuccessBox.d.ts +7 -0
- package/dist/ux/components/SuccessBox.js +9 -0
- package/dist/ux/components/SuccessBox.js.map +1 -0
- package/dist/ux/components/ValidationReport.d.ts +16 -0
- package/dist/ux/components/ValidationReport.js +19 -0
- package/dist/ux/components/ValidationReport.js.map +1 -0
- package/dist/ux/components/WarningBox.d.ts +7 -0
- package/dist/ux/components/WarningBox.js +9 -0
- package/dist/ux/components/WarningBox.js.map +1 -0
- package/dist/ux/display.d.ts +21 -0
- package/dist/ux/display.js +96 -0
- package/dist/ux/display.js.map +1 -0
- package/dist/ux/hooks/useActivation.d.ts +8 -0
- package/dist/ux/hooks/useActivation.js +16 -0
- package/dist/ux/hooks/useActivation.js.map +1 -0
- package/dist/ux/hooks/useSpinner.d.ts +2 -0
- package/dist/ux/hooks/useSpinner.js +13 -0
- package/dist/ux/hooks/useSpinner.js.map +1 -0
- package/dist/ux/menu.d.ts +7 -0
- package/dist/ux/menu.js +56 -0
- package/dist/ux/menu.js.map +1 -0
- package/dist/ux/prompt.d.ts +7 -0
- package/dist/ux/prompt.js +361 -0
- package/dist/ux/prompt.js.map +1 -0
- package/dist/ux/renderer.d.ts +9 -0
- package/dist/ux/renderer.js +45 -0
- package/dist/ux/renderer.js.map +1 -0
- package/dist/ux/spinner.d.ts +6 -0
- package/dist/ux/spinner.js +42 -0
- package/dist/ux/spinner.js.map +1 -0
- package/dist/ux/statusbar.d.ts +2 -0
- package/dist/ux/statusbar.js +44 -0
- package/dist/ux/statusbar.js.map +1 -0
- package/dist/ux/theme.d.ts +37 -0
- package/dist/ux/theme.js +55 -0
- package/dist/ux/theme.js.map +1 -0
- package/package.json +125 -0
|
@@ -0,0 +1,694 @@
|
|
|
1
|
+
import { execSync, spawnSync } from 'child_process';
|
|
2
|
+
import { existsSync, appendFileSync, readFileSync } from 'fs';
|
|
3
|
+
import { join } from 'path';
|
|
4
|
+
import { ensureGitRepo } from '../git/ensure-repo.js';
|
|
5
|
+
import { hasCommits, unstageAll } from '../git/repo.js';
|
|
6
|
+
import { validateFilePath } from '../git/validate.js';
|
|
7
|
+
import { getConfig } from '../config/config.js';
|
|
8
|
+
import { guidedMessageBuilder } from './commit.js';
|
|
9
|
+
import { blank, divider, error, info, keyValue, section, success, warning } from '../ux/display.js';
|
|
10
|
+
import { confirmPrompt, inputPrompt, selectPrompt, smartFileSelectPrompt } from '../ux/prompt.js';
|
|
11
|
+
// ── Git helpers ────────────────────────────────────────────────────────────
|
|
12
|
+
function git(args, cwd) {
|
|
13
|
+
const r = spawnSync('git', args, { cwd, encoding: 'utf-8' });
|
|
14
|
+
return { ok: r.status === 0, out: (r.stdout ?? '').trim(), err: (r.stderr ?? '').trim() };
|
|
15
|
+
}
|
|
16
|
+
function getRecentCommits(n, cwd) {
|
|
17
|
+
const r = git(['log', `-${n}`, '--format=%H\x1f%s\x1f%ar\x1f%an'], cwd);
|
|
18
|
+
if (!r.ok || !r.out)
|
|
19
|
+
return [];
|
|
20
|
+
return r.out
|
|
21
|
+
.split('\n')
|
|
22
|
+
.filter(Boolean)
|
|
23
|
+
.map((line) => {
|
|
24
|
+
const parts = line.split('\x1f');
|
|
25
|
+
const sha = parts[0] ?? '';
|
|
26
|
+
const msg = parts[1] ?? '';
|
|
27
|
+
const date = parts[2] ?? '';
|
|
28
|
+
const author = parts[3] ?? '';
|
|
29
|
+
return { sha, shortSha: sha.slice(0, 8), msg, date, author };
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
function getCommitsAheadOfRemote(cwd) {
|
|
33
|
+
const r = git(['rev-list', '--count', '@{u}..HEAD'], cwd);
|
|
34
|
+
return r.ok ? parseInt(r.out, 10) || 0 : -1; // -1 = no upstream
|
|
35
|
+
}
|
|
36
|
+
function getFilesInCommit(sha, cwd) {
|
|
37
|
+
const r = git(['show', '--name-only', '--format=', sha], cwd);
|
|
38
|
+
return r.out.split('\n').filter(Boolean);
|
|
39
|
+
}
|
|
40
|
+
function getUpstreamBranch(cwd) {
|
|
41
|
+
const r = git(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{u}'], cwd);
|
|
42
|
+
return r.ok ? r.out : null;
|
|
43
|
+
}
|
|
44
|
+
function getCurrentBranch(cwd) {
|
|
45
|
+
const r = git(['symbolic-ref', '--short', 'HEAD'], cwd);
|
|
46
|
+
return r.ok ? r.out : 'HEAD';
|
|
47
|
+
}
|
|
48
|
+
function _isPushed(cwd) {
|
|
49
|
+
const ahead = getCommitsAheadOfRemote(cwd);
|
|
50
|
+
return ahead === 0; // 0 means all commits are on remote; -1 means no upstream
|
|
51
|
+
}
|
|
52
|
+
// ── Status banner ──────────────────────────────────────────────────────────
|
|
53
|
+
function printState(cwd) {
|
|
54
|
+
const commits = getRecentCommits(1, cwd);
|
|
55
|
+
const branch = getCurrentBranch(cwd);
|
|
56
|
+
const ahead = getCommitsAheadOfRemote(cwd);
|
|
57
|
+
const upstream = getUpstreamBranch(cwd);
|
|
58
|
+
section('Repository State');
|
|
59
|
+
keyValue('Branch', branch);
|
|
60
|
+
if (commits.length > 0) {
|
|
61
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
62
|
+
const c = commits[0];
|
|
63
|
+
keyValue('Last commit', `${c.shortSha} "${c.msg}" (${c.date})`);
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
info('No commits yet.');
|
|
67
|
+
}
|
|
68
|
+
if (upstream) {
|
|
69
|
+
if (ahead === 0)
|
|
70
|
+
keyValue('Remote', `in sync with ${upstream}`);
|
|
71
|
+
else if (ahead > 0)
|
|
72
|
+
keyValue('Remote', `${ahead} commit(s) ahead of ${upstream} — not yet pushed`);
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
keyValue('Remote', 'no upstream configured');
|
|
76
|
+
}
|
|
77
|
+
blank();
|
|
78
|
+
}
|
|
79
|
+
// ── Safety banner ──────────────────────────────────────────────────────────
|
|
80
|
+
function warnDestructive(detail) {
|
|
81
|
+
blank();
|
|
82
|
+
console.log(' ╔══════════════════════════════════════════════════════╗');
|
|
83
|
+
console.log(' ║ ⚠ DESTRUCTIVE OPERATION — cannot be undone ║');
|
|
84
|
+
console.log(' ╚══════════════════════════════════════════════════════╝');
|
|
85
|
+
console.log(' ' + detail);
|
|
86
|
+
blank();
|
|
87
|
+
}
|
|
88
|
+
function warnHistoryRewrite(pushed) {
|
|
89
|
+
if (!pushed)
|
|
90
|
+
return;
|
|
91
|
+
blank();
|
|
92
|
+
warning('This commit has already been pushed to the remote.');
|
|
93
|
+
warning('Rewriting history will require a force push, which can cause');
|
|
94
|
+
warning('problems for anyone who has already fetched or cloned this branch.');
|
|
95
|
+
blank();
|
|
96
|
+
}
|
|
97
|
+
export async function runRevert(opts = {}) {
|
|
98
|
+
const cwd = process.cwd();
|
|
99
|
+
if (!(await ensureGitRepo(cwd)))
|
|
100
|
+
return;
|
|
101
|
+
if (opts.dryRun)
|
|
102
|
+
info('[DRY RUN] No changes will be made.\n');
|
|
103
|
+
if (!hasCommits(cwd)) {
|
|
104
|
+
info('No commits yet — nothing to revert.');
|
|
105
|
+
const unstage = opts.yes || opts.dryRun ? true : await confirmPrompt('Unstage all files instead?', true);
|
|
106
|
+
if (unstage) {
|
|
107
|
+
if (opts.dryRun) {
|
|
108
|
+
info('[DRY RUN] Would unstage all files.');
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
unstageAll(cwd);
|
|
112
|
+
success('All files unstaged. Working tree untouched.');
|
|
113
|
+
}
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
let running = true;
|
|
117
|
+
while (running) {
|
|
118
|
+
printState(cwd);
|
|
119
|
+
const choice = await selectPrompt('What do you want to undo or fix?', [
|
|
120
|
+
'Remove files from the last commit (forgot to gitignore, wrong files…)',
|
|
121
|
+
'Undo the last commit — keep changes staged (soft reset)',
|
|
122
|
+
'Undo the last commit — keep changes unstaged (mixed reset)',
|
|
123
|
+
'Undo the last commit — DISCARD all changes (hard reset)',
|
|
124
|
+
'Go back N commits (choose soft / mixed / hard)',
|
|
125
|
+
'Reset to a specific commit (pick from history)',
|
|
126
|
+
'Reset to the remote branch state (discard all local commits)',
|
|
127
|
+
'Safely undo a pushed commit (creates a new revert commit)',
|
|
128
|
+
'Discard uncommitted changes in working directory',
|
|
129
|
+
'Unstage staged files (keep changes in working directory)',
|
|
130
|
+
'Cherry-pick a commit from another branch',
|
|
131
|
+
'Done / Cancel',
|
|
132
|
+
]);
|
|
133
|
+
blank();
|
|
134
|
+
switch (choice) {
|
|
135
|
+
case 'Remove files from the last commit (forgot to gitignore, wrong files…)':
|
|
136
|
+
await flowRemoveFilesFromCommit(cwd, opts);
|
|
137
|
+
break;
|
|
138
|
+
case 'Undo the last commit — keep changes staged (soft reset)':
|
|
139
|
+
await flowResetLast(cwd, 'soft', opts);
|
|
140
|
+
break;
|
|
141
|
+
case 'Undo the last commit — keep changes unstaged (mixed reset)':
|
|
142
|
+
await flowResetLast(cwd, 'mixed', opts);
|
|
143
|
+
break;
|
|
144
|
+
case 'Undo the last commit — DISCARD all changes (hard reset)':
|
|
145
|
+
await flowResetLast(cwd, 'hard', opts);
|
|
146
|
+
break;
|
|
147
|
+
case 'Go back N commits (choose soft / mixed / hard)':
|
|
148
|
+
await flowResetN(cwd, opts);
|
|
149
|
+
break;
|
|
150
|
+
case 'Reset to a specific commit (pick from history)':
|
|
151
|
+
await flowResetToCommit(cwd, opts);
|
|
152
|
+
break;
|
|
153
|
+
case 'Reset to the remote branch state (discard all local commits)':
|
|
154
|
+
await flowResetToRemote(cwd, opts);
|
|
155
|
+
break;
|
|
156
|
+
case 'Safely undo a pushed commit (creates a new revert commit)':
|
|
157
|
+
await flowSafeRevert(cwd, opts);
|
|
158
|
+
break;
|
|
159
|
+
case 'Discard uncommitted changes in working directory':
|
|
160
|
+
await flowDiscardWorkingChanges(cwd, opts);
|
|
161
|
+
break;
|
|
162
|
+
case 'Unstage staged files (keep changes in working directory)':
|
|
163
|
+
await flowUnstage(cwd, opts);
|
|
164
|
+
break;
|
|
165
|
+
case 'Cherry-pick a commit from another branch':
|
|
166
|
+
await flowCherryPick(cwd, opts);
|
|
167
|
+
break;
|
|
168
|
+
default:
|
|
169
|
+
running = false;
|
|
170
|
+
}
|
|
171
|
+
if (running && choice !== 'Done / Cancel') {
|
|
172
|
+
blank();
|
|
173
|
+
const again = opts.yes ? false : await confirmPrompt('Do another undo operation?', false);
|
|
174
|
+
if (!again)
|
|
175
|
+
running = false;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
divider();
|
|
179
|
+
}
|
|
180
|
+
// ── Flow: remove files from last commit ───────────────────────────────────
|
|
181
|
+
async function flowRemoveFilesFromCommit(cwd, opts = {}) {
|
|
182
|
+
section('Remove Files from Last Commit');
|
|
183
|
+
const commits = getRecentCommits(1, cwd);
|
|
184
|
+
if (commits.length === 0) {
|
|
185
|
+
info('No commits found.');
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
189
|
+
const last = commits[0];
|
|
190
|
+
info(`Last commit: ${last.shortSha} "${last.msg}"`);
|
|
191
|
+
blank();
|
|
192
|
+
const filesInCommit = getFilesInCommit('HEAD', cwd);
|
|
193
|
+
if (filesInCommit.length === 0) {
|
|
194
|
+
info('No files found in last commit.');
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
info(`${filesInCommit.length} file(s) in this commit.`);
|
|
198
|
+
const aheadCount = getCommitsAheadOfRemote(cwd);
|
|
199
|
+
// aheadCount === -1 means no upstream; === 0 means all commits are on remote (pushed)
|
|
200
|
+
const alreadyPushed = aheadCount >= 0 && aheadCount === 0;
|
|
201
|
+
warnHistoryRewrite(alreadyPushed);
|
|
202
|
+
const toRemove = await smartFileSelectPrompt('Select files to remove from the commit', filesInCommit);
|
|
203
|
+
if (toRemove.length === 0) {
|
|
204
|
+
info('Nothing selected. Cancelled.');
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
blank();
|
|
208
|
+
warning(`Will remove ${toRemove.length} file(s) from commit "${last.shortSha}" and amend it.`);
|
|
209
|
+
if (alreadyPushed)
|
|
210
|
+
warning('A force push will be needed afterwards.');
|
|
211
|
+
const confirmed = opts.yes || opts.dryRun ? true : await confirmPrompt('Proceed?', false);
|
|
212
|
+
if (!confirmed) {
|
|
213
|
+
info('Cancelled.');
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
for (const f of toRemove) {
|
|
217
|
+
const vf = validateFilePath(f);
|
|
218
|
+
if (!vf.valid) {
|
|
219
|
+
error(`Invalid file path "${f}": ${vf.reason}`);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (opts.dryRun) {
|
|
224
|
+
info(`[DRY RUN] Would remove ${toRemove.length} file(s) from commit "${last.shortSha}" and amend it.`);
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
// Remove from index (working tree untouched)
|
|
228
|
+
const rm = spawnSync('git', ['rm', '--cached', '-r', '--', ...toRemove], {
|
|
229
|
+
cwd,
|
|
230
|
+
encoding: 'utf-8',
|
|
231
|
+
});
|
|
232
|
+
if (rm.status !== 0) {
|
|
233
|
+
error(`git rm failed: ${rm.stderr?.trim()}`);
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
// Offer to also add to .gitignore
|
|
237
|
+
await offerGitignoreAppend(toRemove, cwd);
|
|
238
|
+
// Amend the commit
|
|
239
|
+
const editMsg = await confirmPrompt('Edit the commit message?', false);
|
|
240
|
+
if (editMsg) {
|
|
241
|
+
const built = await guidedMessageBuilder(last.msg);
|
|
242
|
+
if (built) {
|
|
243
|
+
try {
|
|
244
|
+
execSync(`git commit --amend -m ${JSON.stringify(built)}`, { cwd, stdio: 'inherit' });
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
error('git commit --amend failed.');
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
else {
|
|
252
|
+
try {
|
|
253
|
+
execSync('git commit --amend --no-edit', { cwd, stdio: 'inherit' });
|
|
254
|
+
}
|
|
255
|
+
catch {
|
|
256
|
+
error('git commit --amend failed.');
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
else {
|
|
262
|
+
try {
|
|
263
|
+
execSync('git commit --amend --no-edit', { cwd, stdio: 'inherit' });
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
error('git commit --amend failed.');
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
success(`${toRemove.length} file(s) removed from commit.`);
|
|
271
|
+
blank();
|
|
272
|
+
if (alreadyPushed) {
|
|
273
|
+
warning('Run "gsf push" — it will detect the force push needed.');
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
async function offerGitignoreAppend(files, cwd) {
|
|
277
|
+
// Detect patterns that should go to .gitignore
|
|
278
|
+
const patterns = new Set();
|
|
279
|
+
for (const f of files) {
|
|
280
|
+
const topDir = f.includes('/') ? f.split('/')[0] + '/' : f;
|
|
281
|
+
// Suggest directory-level entries for dirs, exact name for root files
|
|
282
|
+
patterns.add(topDir);
|
|
283
|
+
}
|
|
284
|
+
const gitignorePath = join(cwd, '.gitignore');
|
|
285
|
+
const existing = existsSync(gitignorePath) ? readFileSync(gitignorePath, 'utf-8') : '';
|
|
286
|
+
const toAdd = [...patterns].filter((p) => !existing.includes(p));
|
|
287
|
+
if (toAdd.length === 0)
|
|
288
|
+
return;
|
|
289
|
+
blank();
|
|
290
|
+
info('These patterns are not yet in .gitignore:');
|
|
291
|
+
toAdd.forEach((p) => console.log(` ${p}`));
|
|
292
|
+
const add = await confirmPrompt('Add them to .gitignore to prevent re-staging?', true);
|
|
293
|
+
if (!add)
|
|
294
|
+
return;
|
|
295
|
+
const lines = '\n# Added by gsf revert\n' + toAdd.join('\n') + '\n';
|
|
296
|
+
appendFileSync(gitignorePath, lines);
|
|
297
|
+
success('.gitignore updated.');
|
|
298
|
+
// Stage the .gitignore change so it's part of the amended commit
|
|
299
|
+
spawnSync('git', ['add', '.gitignore'], { cwd });
|
|
300
|
+
}
|
|
301
|
+
// ── Flow: undo last commit ─────────────────────────────────────────────────
|
|
302
|
+
async function flowResetLast(cwd, mode, opts = {}) {
|
|
303
|
+
const commits = getRecentCommits(2, cwd);
|
|
304
|
+
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
305
|
+
const last = commits[0];
|
|
306
|
+
const aheadCount = getCommitsAheadOfRemote(cwd);
|
|
307
|
+
const alreadyPushed = aheadCount >= 0 && aheadCount === 0;
|
|
308
|
+
const modeDesc = {
|
|
309
|
+
soft: 'Commit undone. Changes stay STAGED — ready to re-commit.',
|
|
310
|
+
mixed: 'Commit undone. Changes go back to UNSTAGED — you choose what to re-stage.',
|
|
311
|
+
hard: 'Commit undone. ALL changes DISCARDED — working directory reset to previous commit.',
|
|
312
|
+
};
|
|
313
|
+
section(`Undo Last Commit (${mode})`);
|
|
314
|
+
if (last)
|
|
315
|
+
info(`Will undo: ${last.shortSha} "${last.msg}" (${last.date})`);
|
|
316
|
+
if (commits[1])
|
|
317
|
+
info(`Landing on: ${commits[1].shortSha} "${commits[1].msg}"`);
|
|
318
|
+
blank();
|
|
319
|
+
info(`Effect: ${modeDesc[mode]}`);
|
|
320
|
+
warnHistoryRewrite(alreadyPushed);
|
|
321
|
+
if (mode === 'hard')
|
|
322
|
+
warnDestructive('All changes from the undone commit will be permanently lost.');
|
|
323
|
+
const confirmed = opts.yes || opts.dryRun ? true : await confirmPrompt('Proceed?', false);
|
|
324
|
+
if (!confirmed) {
|
|
325
|
+
info('Cancelled.');
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
if (opts.dryRun) {
|
|
329
|
+
info(`[DRY RUN] Would run: git reset --${mode} HEAD~1`);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
const r = git(['reset', `--${mode}`, 'HEAD~1'], cwd);
|
|
333
|
+
if (r.ok) {
|
|
334
|
+
success(`Last commit undone (${mode}).`);
|
|
335
|
+
if (mode === 'soft')
|
|
336
|
+
info('Changes are staged. Run "gsf commit" to recommit.');
|
|
337
|
+
if (mode === 'mixed')
|
|
338
|
+
info('Changes are unstaged. Run "gsf commit" to restage and recommit.');
|
|
339
|
+
if (alreadyPushed)
|
|
340
|
+
warning('Run "gsf push" — force push will be needed.');
|
|
341
|
+
}
|
|
342
|
+
else {
|
|
343
|
+
error(`Reset failed: ${r.err}`);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
// ── Flow: go back N commits ────────────────────────────────────────────────
|
|
347
|
+
async function flowResetN(cwd, opts = {}) {
|
|
348
|
+
section('Go Back N Commits');
|
|
349
|
+
const commits = getRecentCommits(15, cwd);
|
|
350
|
+
if (commits.length === 0) {
|
|
351
|
+
info('No commits found.');
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
info('Recent commits:');
|
|
355
|
+
commits.forEach((c, i) => {
|
|
356
|
+
console.log(` ${String(i + 1).padStart(2)}. ${c.shortSha} ${c.date.padEnd(14)} ${c.msg}`);
|
|
357
|
+
});
|
|
358
|
+
blank();
|
|
359
|
+
const nStr = await inputPrompt('How many commits to go back?', '1');
|
|
360
|
+
const n = parseInt(nStr, 10);
|
|
361
|
+
if (!n || n < 1 || n > commits.length) {
|
|
362
|
+
error('Invalid number.');
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
const modeChoice = await selectPrompt('Reset mode:', [
|
|
366
|
+
'mixed — keep changes unstaged (recommended)',
|
|
367
|
+
'soft — keep changes staged',
|
|
368
|
+
'hard — DISCARD all changes',
|
|
369
|
+
]);
|
|
370
|
+
const mode = modeChoice.split(' ')[0];
|
|
371
|
+
const aheadCount = getCommitsAheadOfRemote(cwd);
|
|
372
|
+
const alreadyPushed = aheadCount !== -1 && n > aheadCount;
|
|
373
|
+
blank();
|
|
374
|
+
const targetCommit = commits[n - 1];
|
|
375
|
+
if (!targetCommit) {
|
|
376
|
+
error('Invalid commit selection');
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
info(`Will reset ${n} commit(s) — landing on: ${targetCommit.shortSha} "${targetCommit.msg}"`);
|
|
380
|
+
warnHistoryRewrite(alreadyPushed);
|
|
381
|
+
if (mode === 'hard')
|
|
382
|
+
warnDestructive(`Changes from the last ${n} commit(s) will be permanently lost.`);
|
|
383
|
+
const confirmed = opts.yes || opts.dryRun ? true : await confirmPrompt('Proceed?', false);
|
|
384
|
+
if (!confirmed) {
|
|
385
|
+
info('Cancelled.');
|
|
386
|
+
return;
|
|
387
|
+
}
|
|
388
|
+
if (opts.dryRun) {
|
|
389
|
+
info(`[DRY RUN] Would run: git reset --${mode} HEAD~${n}`);
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
const r = git(['reset', `--${mode}`, `HEAD~${n}`], cwd);
|
|
393
|
+
if (r.ok) {
|
|
394
|
+
success(`Went back ${n} commit(s) (${mode}).`);
|
|
395
|
+
if (alreadyPushed)
|
|
396
|
+
warning('Force push needed. Run "gsf push".');
|
|
397
|
+
}
|
|
398
|
+
else {
|
|
399
|
+
error(`Reset failed: ${r.err}`);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
// ── Flow: reset to specific commit ────────────────────────────────────────
|
|
403
|
+
async function flowResetToCommit(cwd, opts = {}) {
|
|
404
|
+
section('Reset to a Specific Commit');
|
|
405
|
+
const historyLimit = getConfig().ui?.historyLimit ?? 20;
|
|
406
|
+
const commits = getRecentCommits(historyLimit, cwd);
|
|
407
|
+
if (commits.length === 0) {
|
|
408
|
+
info('No commits found.');
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
const labels = commits.map((c) => `${c.shortSha} ${c.date.padEnd(14)} ${c.msg}`);
|
|
412
|
+
const chosen = await selectPrompt('Choose the commit to land on (this and all later commits will be undone):', labels);
|
|
413
|
+
const idx = labels.indexOf(chosen);
|
|
414
|
+
const target = commits[idx];
|
|
415
|
+
if (!target) {
|
|
416
|
+
info('Cancelled.');
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
const modeChoice = await selectPrompt('Reset mode:', [
|
|
420
|
+
'mixed — keep changes unstaged (recommended)',
|
|
421
|
+
'soft — keep changes staged',
|
|
422
|
+
'hard — DISCARD all changes',
|
|
423
|
+
]);
|
|
424
|
+
const mode = modeChoice.split(' ')[0];
|
|
425
|
+
const aheadCount = getCommitsAheadOfRemote(cwd);
|
|
426
|
+
const alreadyPushed = aheadCount !== -1 && idx >= aheadCount;
|
|
427
|
+
blank();
|
|
428
|
+
info(`Will reset to: ${target.shortSha} "${target.msg}"`);
|
|
429
|
+
warnHistoryRewrite(alreadyPushed);
|
|
430
|
+
if (mode === 'hard')
|
|
431
|
+
warnDestructive('All changes between now and the chosen commit will be permanently lost.');
|
|
432
|
+
const confirmed = opts.yes || opts.dryRun ? true : await confirmPrompt('Proceed?', false);
|
|
433
|
+
if (!confirmed) {
|
|
434
|
+
info('Cancelled.');
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
if (opts.dryRun) {
|
|
438
|
+
info(`[DRY RUN] Would run: git reset --${mode} ${target.sha} (${target.shortSha})`);
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
const r = git(['reset', `--${mode}`, target.sha], cwd);
|
|
442
|
+
if (r.ok) {
|
|
443
|
+
success(`Reset to ${target.shortSha} (${mode}).`);
|
|
444
|
+
if (alreadyPushed)
|
|
445
|
+
warning('Force push needed. Run "gsf push".');
|
|
446
|
+
}
|
|
447
|
+
else {
|
|
448
|
+
error(`Reset failed: ${r.err}`);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
// ── Flow: reset to remote ─────────────────────────────────────────────────
|
|
452
|
+
async function flowResetToRemote(cwd, opts = {}) {
|
|
453
|
+
section('Reset to Remote Branch State');
|
|
454
|
+
const upstream = getUpstreamBranch(cwd);
|
|
455
|
+
if (!upstream) {
|
|
456
|
+
warning('No upstream branch configured.');
|
|
457
|
+
info('Set one with: git branch --set-upstream-to=origin/<branch>');
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
const ahead = getCommitsAheadOfRemote(cwd);
|
|
461
|
+
blank();
|
|
462
|
+
keyValue('Remote', upstream);
|
|
463
|
+
if (ahead > 0)
|
|
464
|
+
warning(`You are ${ahead} commit(s) ahead — these will all be DISCARDED.`);
|
|
465
|
+
warnDestructive(`All local commits not on ${upstream} and all uncommitted changes will be lost.`);
|
|
466
|
+
const confirmed = opts.yes || opts.dryRun
|
|
467
|
+
? true
|
|
468
|
+
: await confirmPrompt(`Reset to ${upstream}? This cannot be undone.`, false);
|
|
469
|
+
if (!confirmed) {
|
|
470
|
+
info('Cancelled.');
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
if (opts.dryRun) {
|
|
474
|
+
info(`[DRY RUN] Would run: git fetch && git reset --hard ${upstream}`);
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
// Fetch latest from remote first
|
|
478
|
+
info('Fetching from remote...');
|
|
479
|
+
const fetch = git(['fetch'], cwd);
|
|
480
|
+
if (!fetch.ok)
|
|
481
|
+
warning('Fetch failed — using cached remote state.');
|
|
482
|
+
const r = git(['reset', '--hard', upstream], cwd);
|
|
483
|
+
if (r.ok) {
|
|
484
|
+
success(`Branch reset to ${upstream}.`);
|
|
485
|
+
blank();
|
|
486
|
+
info('Working directory matches remote exactly.');
|
|
487
|
+
}
|
|
488
|
+
else {
|
|
489
|
+
error(`Reset failed: ${r.err}`);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
// ── Flow: safe revert (new commit) ────────────────────────────────────────
|
|
493
|
+
async function flowSafeRevert(cwd, opts = {}) {
|
|
494
|
+
section('Safely Undo a Commit (git revert)');
|
|
495
|
+
blank();
|
|
496
|
+
info('git revert creates a NEW commit that undoes a previous one.');
|
|
497
|
+
info('This is safe for shared/pushed history — no force push needed.');
|
|
498
|
+
blank();
|
|
499
|
+
const historyLimit = getConfig().ui?.historyLimit ?? 20;
|
|
500
|
+
const commits = getRecentCommits(historyLimit, cwd);
|
|
501
|
+
if (commits.length === 0) {
|
|
502
|
+
info('No commits found.');
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
const labels = commits.map((c) => `${c.shortSha} ${c.date.padEnd(14)} ${c.msg}`);
|
|
506
|
+
const chosen = await selectPrompt('Choose the commit to revert:', labels);
|
|
507
|
+
const target = commits[labels.indexOf(chosen)];
|
|
508
|
+
if (!target) {
|
|
509
|
+
info('Cancelled.');
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
blank();
|
|
513
|
+
info(`Will create a new commit that undoes: ${target.shortSha} "${target.msg}"`);
|
|
514
|
+
const confirmed = opts.yes || opts.dryRun ? true : await confirmPrompt('Proceed?', false);
|
|
515
|
+
if (!confirmed) {
|
|
516
|
+
info('Cancelled.');
|
|
517
|
+
return;
|
|
518
|
+
}
|
|
519
|
+
if (opts.dryRun) {
|
|
520
|
+
info(`[DRY RUN] Would create a revert commit undoing: ${target.shortSha} "${target.msg}"`);
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
const r = git(['revert', '--no-commit', target.sha], cwd);
|
|
524
|
+
if (!r.ok) {
|
|
525
|
+
if (r.err.includes('conflict')) {
|
|
526
|
+
warning('Revert produced merge conflicts. Resolve them, then run:');
|
|
527
|
+
console.log(' git revert --continue');
|
|
528
|
+
}
|
|
529
|
+
else {
|
|
530
|
+
error(`Revert failed: ${r.err}`);
|
|
531
|
+
}
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
const editMsg = await confirmPrompt('Edit the revert commit message?', false);
|
|
535
|
+
let msg = `revert: undo "${target.msg}" (${target.shortSha})`;
|
|
536
|
+
if (editMsg) {
|
|
537
|
+
const built = await guidedMessageBuilder(msg);
|
|
538
|
+
if (built)
|
|
539
|
+
msg = built;
|
|
540
|
+
}
|
|
541
|
+
try {
|
|
542
|
+
execSync(`git commit -m ${JSON.stringify(msg)}`, { cwd, stdio: 'inherit' });
|
|
543
|
+
success(`Revert commit created. No force push needed.`);
|
|
544
|
+
}
|
|
545
|
+
catch {
|
|
546
|
+
error('git commit failed.');
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
// ── Flow: discard working directory changes ────────────────────────────────
|
|
550
|
+
async function flowDiscardWorkingChanges(cwd, opts = {}) {
|
|
551
|
+
section('Discard Uncommitted Changes');
|
|
552
|
+
const r = git(['diff', '--name-only'], cwd);
|
|
553
|
+
const modified = r.out.split('\n').filter(Boolean);
|
|
554
|
+
if (modified.length === 0) {
|
|
555
|
+
info('No uncommitted changes in tracked files.');
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
info(`${modified.length} file(s) with uncommitted changes:`);
|
|
559
|
+
modified.slice(0, 10).forEach((f) => console.log(` ${f}`));
|
|
560
|
+
if (modified.length > 10)
|
|
561
|
+
info(` ... and ${modified.length - 10} more`);
|
|
562
|
+
blank();
|
|
563
|
+
const scope = await selectPrompt('What do you want to discard?', [
|
|
564
|
+
'All changes in all files',
|
|
565
|
+
'Choose specific files',
|
|
566
|
+
'Cancel',
|
|
567
|
+
]);
|
|
568
|
+
if (scope === 'Cancel')
|
|
569
|
+
return;
|
|
570
|
+
const targets = scope === 'All changes in all files'
|
|
571
|
+
? modified
|
|
572
|
+
: await smartFileSelectPrompt('Select files to discard', modified);
|
|
573
|
+
if (targets.length === 0) {
|
|
574
|
+
info('Nothing selected.');
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
for (const f of targets) {
|
|
578
|
+
const vf = validateFilePath(f);
|
|
579
|
+
if (!vf.valid) {
|
|
580
|
+
error(`Invalid file path "${f}": ${vf.reason}`);
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
warnDestructive(`Changes in ${targets.length} file(s) will be lost and cannot be recovered.`);
|
|
585
|
+
const confirmed = opts.yes || opts.dryRun ? true : await confirmPrompt('Discard these changes?', false);
|
|
586
|
+
if (!confirmed) {
|
|
587
|
+
info('Cancelled.');
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
if (opts.dryRun) {
|
|
591
|
+
info(`[DRY RUN] Would discard changes in ${targets.length} file(s).`);
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
const restore = git(['restore', '--', ...targets], cwd);
|
|
595
|
+
if (restore.ok) {
|
|
596
|
+
success(`Discarded changes in ${targets.length} file(s).`);
|
|
597
|
+
}
|
|
598
|
+
else {
|
|
599
|
+
// Fallback for older git versions
|
|
600
|
+
const checkout = git(['checkout', '--', ...targets], cwd);
|
|
601
|
+
if (checkout.ok)
|
|
602
|
+
success(`Discarded changes in ${targets.length} file(s).`);
|
|
603
|
+
else
|
|
604
|
+
error(`Failed: ${checkout.err}`);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
// ── Flow: unstage ──────────────────────────────────────────────────────────
|
|
608
|
+
async function flowUnstage(cwd, opts = {}) {
|
|
609
|
+
section('Unstage Files');
|
|
610
|
+
const r = git(['diff', '--cached', '--name-only'], cwd);
|
|
611
|
+
const staged = r.out.split('\n').filter(Boolean);
|
|
612
|
+
if (staged.length === 0) {
|
|
613
|
+
info('No staged files.');
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
const scope = await selectPrompt(`${staged.length} staged file(s). What do you want to unstage?`, ['All staged files', 'Choose specific files', 'Cancel']);
|
|
617
|
+
if (scope === 'Cancel')
|
|
618
|
+
return;
|
|
619
|
+
const targets = scope === 'All staged files'
|
|
620
|
+
? staged
|
|
621
|
+
: await smartFileSelectPrompt('Select files to unstage', staged);
|
|
622
|
+
if (targets.length === 0) {
|
|
623
|
+
info('Nothing selected.');
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
if (opts.dryRun) {
|
|
627
|
+
info(`[DRY RUN] Would unstage ${targets.length} file(s).`);
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
630
|
+
const hasC = hasCommits(cwd);
|
|
631
|
+
const restoreArgs = hasC
|
|
632
|
+
? ['restore', '--staged', '--', ...targets]
|
|
633
|
+
: ['rm', '--cached', '-r', '--', ...targets];
|
|
634
|
+
const result = git(restoreArgs, cwd);
|
|
635
|
+
if (result.ok) {
|
|
636
|
+
success(`${targets.length} file(s) unstaged. Changes kept in working directory.`);
|
|
637
|
+
}
|
|
638
|
+
else {
|
|
639
|
+
error(`Failed: ${result.err}`);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
// ── Flow: cherry-pick ──────────────────────────────────────────────────────
|
|
643
|
+
async function flowCherryPick(cwd, opts = {}) {
|
|
644
|
+
section('Cherry-pick a Commit from Another Branch');
|
|
645
|
+
blank();
|
|
646
|
+
info('Fetching recent commits from all branches...');
|
|
647
|
+
const historyLimit = getConfig().ui?.historyLimit ?? 20;
|
|
648
|
+
const r = git(['log', '--oneline', '--all', '--not', 'HEAD', `-${historyLimit}`], cwd);
|
|
649
|
+
const lines = r.out.split('\n').filter(Boolean);
|
|
650
|
+
if (lines.length === 0) {
|
|
651
|
+
info('No commits found on other branches that are not already in your current branch.');
|
|
652
|
+
return;
|
|
653
|
+
}
|
|
654
|
+
blank();
|
|
655
|
+
const chosen = await selectPrompt('Select a commit to cherry-pick:', [...lines, '← Cancel']);
|
|
656
|
+
if (chosen.includes('← Cancel'))
|
|
657
|
+
return;
|
|
658
|
+
const sha = chosen.split(' ')[0] ?? '';
|
|
659
|
+
if (!sha)
|
|
660
|
+
return;
|
|
661
|
+
blank();
|
|
662
|
+
keyValue('Commit', chosen);
|
|
663
|
+
blank();
|
|
664
|
+
const mode = await selectPrompt('How do you want to apply it?', [
|
|
665
|
+
'Cherry-pick directly (creates a new commit)',
|
|
666
|
+
'Stage only, no commit (--no-commit, review before committing)',
|
|
667
|
+
'← Cancel',
|
|
668
|
+
]);
|
|
669
|
+
if (mode.includes('← Cancel'))
|
|
670
|
+
return;
|
|
671
|
+
const noCommit = mode.includes('no commit');
|
|
672
|
+
if (opts.dryRun) {
|
|
673
|
+
info(`[DRY RUN] Would cherry-pick ${sha}${noCommit ? ' --no-commit' : ''}`);
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
const args = ['cherry-pick', ...(noCommit ? ['--no-commit'] : []), sha];
|
|
677
|
+
const result = spawnSync('git', args, { cwd, stdio: 'inherit' });
|
|
678
|
+
if (result.status === 0) {
|
|
679
|
+
if (noCommit) {
|
|
680
|
+
success('Changes staged. Review with "git diff --cached", then run "gsf commit".');
|
|
681
|
+
}
|
|
682
|
+
else {
|
|
683
|
+
success('Cherry-pick applied successfully.');
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
else {
|
|
687
|
+
warning('Cherry-pick produced conflicts. Resolve them, then:');
|
|
688
|
+
console.log(' git add <resolved-files>');
|
|
689
|
+
console.log(' git cherry-pick --continue');
|
|
690
|
+
blank();
|
|
691
|
+
info('To abort: git cherry-pick --abort');
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
//# sourceMappingURL=revert.js.map
|