geeto 0.4.4 → 0.6.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.
Files changed (115) hide show
  1. package/README.md +1 -1
  2. package/lib/api/copilot.d.ts +3 -3
  3. package/lib/api/copilot.js +3 -3
  4. package/lib/cli/input.d.ts +21 -18
  5. package/lib/cli/input.d.ts.map +1 -1
  6. package/lib/cli/input.js +174 -372
  7. package/lib/cli/input.js.map +1 -1
  8. package/lib/cli/menu.d.ts.map +1 -1
  9. package/lib/cli/menu.js +83 -4
  10. package/lib/cli/menu.js.map +1 -1
  11. package/lib/core/copilot-setup.d.ts +2 -2
  12. package/lib/core/copilot-setup.d.ts.map +1 -1
  13. package/lib/core/copilot-setup.js +16 -19
  14. package/lib/core/copilot-setup.js.map +1 -1
  15. package/lib/core/setup.d.ts +1 -1
  16. package/lib/core/setup.js +1 -1
  17. package/lib/index.js +203 -3
  18. package/lib/index.js.map +1 -1
  19. package/lib/utils/branch-naming.d.ts.map +1 -1
  20. package/lib/utils/branch-naming.js +12 -6
  21. package/lib/utils/branch-naming.js.map +1 -1
  22. package/lib/utils/dry-run.d.ts +21 -0
  23. package/lib/utils/dry-run.d.ts.map +1 -0
  24. package/lib/utils/dry-run.js +102 -0
  25. package/lib/utils/dry-run.js.map +1 -0
  26. package/lib/utils/exec.d.ts.map +1 -1
  27. package/lib/utils/exec.js +13 -0
  28. package/lib/utils/exec.js.map +1 -1
  29. package/lib/utils/git-ai.d.ts.map +1 -1
  30. package/lib/utils/git-ai.js +40 -21
  31. package/lib/utils/git-ai.js.map +1 -1
  32. package/lib/utils/git-errors.d.ts.map +1 -1
  33. package/lib/utils/git-errors.js +15 -2
  34. package/lib/utils/git-errors.js.map +1 -1
  35. package/lib/utils/menu-builders.js +1 -1
  36. package/lib/utils/menu-builders.js.map +1 -1
  37. package/lib/utils/scramble.d.ts +117 -0
  38. package/lib/utils/scramble.d.ts.map +1 -0
  39. package/lib/utils/scramble.js +317 -0
  40. package/lib/utils/scramble.js.map +1 -0
  41. package/lib/version.d.ts +1 -1
  42. package/lib/version.js +1 -1
  43. package/lib/workflows/abort.d.ts +9 -0
  44. package/lib/workflows/abort.d.ts.map +1 -0
  45. package/lib/workflows/abort.js +158 -0
  46. package/lib/workflows/abort.js.map +1 -0
  47. package/lib/workflows/ai-provider.js +1 -1
  48. package/lib/workflows/ai-provider.js.map +1 -1
  49. package/lib/workflows/alias.d.ts +6 -0
  50. package/lib/workflows/alias.d.ts.map +1 -0
  51. package/lib/workflows/alias.js +420 -0
  52. package/lib/workflows/alias.js.map +1 -0
  53. package/lib/workflows/amend.d.ts.map +1 -1
  54. package/lib/workflows/amend.js +9 -4
  55. package/lib/workflows/amend.js.map +1 -1
  56. package/lib/workflows/branch-helpers.js +2 -2
  57. package/lib/workflows/branch-helpers.js.map +1 -1
  58. package/lib/workflows/branch-utils.d.ts.map +1 -1
  59. package/lib/workflows/branch-utils.js +4 -3
  60. package/lib/workflows/branch-utils.js.map +1 -1
  61. package/lib/workflows/branch.js +2 -2
  62. package/lib/workflows/branch.js.map +1 -1
  63. package/lib/workflows/cleanup.js +3 -3
  64. package/lib/workflows/cleanup.js.map +1 -1
  65. package/lib/workflows/commit.d.ts.map +1 -1
  66. package/lib/workflows/commit.js +58 -6
  67. package/lib/workflows/commit.js.map +1 -1
  68. package/lib/workflows/dry-run.d.ts +5 -0
  69. package/lib/workflows/dry-run.d.ts.map +1 -0
  70. package/lib/workflows/dry-run.js +127 -0
  71. package/lib/workflows/dry-run.js.map +1 -0
  72. package/lib/workflows/fetch.d.ts +9 -0
  73. package/lib/workflows/fetch.d.ts.map +1 -0
  74. package/lib/workflows/fetch.js +118 -0
  75. package/lib/workflows/fetch.js.map +1 -0
  76. package/lib/workflows/issue.d.ts.map +1 -1
  77. package/lib/workflows/issue.js +317 -72
  78. package/lib/workflows/issue.js.map +1 -1
  79. package/lib/workflows/main-steps.d.ts.map +1 -1
  80. package/lib/workflows/main-steps.js +144 -99
  81. package/lib/workflows/main-steps.js.map +1 -1
  82. package/lib/workflows/main.d.ts.map +1 -1
  83. package/lib/workflows/main.js +14 -6
  84. package/lib/workflows/main.js.map +1 -1
  85. package/lib/workflows/pr.d.ts.map +1 -1
  86. package/lib/workflows/pr.js +307 -39
  87. package/lib/workflows/pr.js.map +1 -1
  88. package/lib/workflows/prune.d.ts +9 -0
  89. package/lib/workflows/prune.d.ts.map +1 -0
  90. package/lib/workflows/prune.js +116 -0
  91. package/lib/workflows/prune.js.map +1 -0
  92. package/lib/workflows/pull.d.ts +9 -0
  93. package/lib/workflows/pull.d.ts.map +1 -0
  94. package/lib/workflows/pull.js +281 -0
  95. package/lib/workflows/pull.js.map +1 -0
  96. package/lib/workflows/release.d.ts.map +1 -1
  97. package/lib/workflows/release.js +50 -38
  98. package/lib/workflows/release.js.map +1 -1
  99. package/lib/workflows/repo-settings.js +2 -2
  100. package/lib/workflows/repo-settings.js.map +1 -1
  101. package/lib/workflows/revert.d.ts +9 -0
  102. package/lib/workflows/revert.d.ts.map +1 -0
  103. package/lib/workflows/revert.js +77 -0
  104. package/lib/workflows/revert.js.map +1 -0
  105. package/lib/workflows/reword.d.ts +9 -0
  106. package/lib/workflows/reword.d.ts.map +1 -0
  107. package/lib/workflows/reword.js +722 -0
  108. package/lib/workflows/reword.js.map +1 -0
  109. package/lib/workflows/settings.js +1 -1
  110. package/lib/workflows/settings.js.map +1 -1
  111. package/lib/workflows/status.d.ts +9 -0
  112. package/lib/workflows/status.d.ts.map +1 -0
  113. package/lib/workflows/status.js +164 -0
  114. package/lib/workflows/status.js.map +1 -0
  115. package/package.json +1 -1
@@ -0,0 +1,722 @@
1
+ /**
2
+ * Reword workflow — edit past commit messages without changing position.
3
+ *
4
+ * Uses git rebase -i with GIT_SEQUENCE_EDITOR / GIT_EDITOR overrides
5
+ * so the user can edit commit title + body via nano/notepad and all
6
+ * selected commits are reworded in a single rebase operation.
7
+ */
8
+ import { execSync, spawnSync } from 'node:child_process';
9
+ import fs from 'node:fs';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+ import { askQuestion, confirm, editInline } from '../cli/input.js';
13
+ import { multiSelect, select } from '../cli/menu.js';
14
+ import { colors } from '../utils/colors.js';
15
+ import { extractCommitBody, extractCommitTitle, formatCommitBody, normalizeAIOutput, } from '../utils/commit-helpers.js';
16
+ import { DEFAULT_GEMINI_MODEL } from '../utils/config.js';
17
+ import { isDryRun, logDryRun } from '../utils/dry-run.js';
18
+ import { execSilent } from '../utils/exec.js';
19
+ import { chooseModelForProvider, getAIProviderShortName, getModelValue, interactiveAIFallback, isContextLimitFailure, isTransientAIFailure, } from '../utils/git-ai.js';
20
+ import { getCurrentBranch } from '../utils/git.js';
21
+ import { log } from '../utils/logging.js';
22
+ import { ScrambleProgress } from '../utils/scramble.js';
23
+ import { loadState, saveState } from '../utils/state.js';
24
+ // ── Helpers ────────────────────────────────────────────────────────
25
+ const SEP = '<<GTO>>';
26
+ const REC = '<<END>>';
27
+ const getRecentCommits = (limit) => {
28
+ try {
29
+ const format = ['%H', '%h', '%s', '%b', '%an', '%cr', '%D'].join(SEP);
30
+ const raw = execSilent(`git log --format="${format}${REC}" -${limit}`).trim();
31
+ if (!raw)
32
+ return [];
33
+ return raw
34
+ .split(REC)
35
+ .map((r) => r.trim())
36
+ .filter(Boolean)
37
+ .map((r) => {
38
+ const p = r.split(SEP);
39
+ return {
40
+ hash: p[0] ?? '',
41
+ shortHash: p[1] ?? '',
42
+ subject: p[2] ?? '',
43
+ body: (p[3] ?? '').trim(),
44
+ author: p[4] ?? '',
45
+ relativeDate: p[5] ?? '',
46
+ refs: p[6] ?? '',
47
+ };
48
+ })
49
+ .filter((c) => c.hash !== '');
50
+ }
51
+ catch {
52
+ return [];
53
+ }
54
+ };
55
+ /** Get the diff (patch) for a specific commit. */
56
+ const getCommitDiff = (hash) => {
57
+ try {
58
+ return execSilent(`git show --format= --patch ${hash}`).trim();
59
+ }
60
+ catch {
61
+ return '';
62
+ }
63
+ };
64
+ /** Get full commit message (title + body). */
65
+ const getCommitMessage = (hash) => {
66
+ try {
67
+ return execSilent(`git log -1 --format=%B ${hash}`).trim();
68
+ }
69
+ catch {
70
+ return '';
71
+ }
72
+ };
73
+ /** Check if the working tree is clean (no uncommitted changes). */
74
+ const isWorkingTreeClean = () => {
75
+ try {
76
+ execSync('git diff --quiet && git diff --cached --quiet', {
77
+ stdio: 'pipe',
78
+ });
79
+ return true;
80
+ }
81
+ catch {
82
+ return false;
83
+ }
84
+ };
85
+ /** Format ref tags (HEAD, branches, etc.) */
86
+ const formatRefs = (refs) => {
87
+ if (!refs.trim())
88
+ return '';
89
+ const parts = refs.split(',').map((r) => r.trim());
90
+ const formatted = parts.map((ref) => {
91
+ if (ref.startsWith('HEAD'))
92
+ return `${colors.red}${colors.bright}HEAD${colors.reset}`;
93
+ if (ref.startsWith('tag:')) {
94
+ return `${colors.yellow}${ref.replace('tag: ', '')}${colors.reset}`;
95
+ }
96
+ if (ref.includes('origin/'))
97
+ return `${colors.cyan}${ref}${colors.reset}`;
98
+ return `${colors.green}${ref}${colors.reset}`;
99
+ });
100
+ return ` (${formatted.join(', ')})`;
101
+ };
102
+ // ── Rebase helper scripts ──────────────────────────────────────────
103
+ /**
104
+ * Create a Node.js script (GIT_SEQUENCE_EDITOR) that replaces
105
+ * `pick <hash>` with `reword <hash>` for the selected commits.
106
+ */
107
+ const writeSequenceEditor = (dir, hashes) => {
108
+ const scriptPath = path.join(dir, 'sequence-editor.js');
109
+ // Build sed-like replacement logic in JS
110
+ const hashSet = JSON.stringify(hashes);
111
+ const script = String.raw `#!/usr/bin/env node
112
+ const fs = require('fs');
113
+ const file = process.argv[2];
114
+ const hashes = new Set(${hashSet});
115
+ let content = fs.readFileSync(file, 'utf8');
116
+ const lines = content.split('\n').map(line => {
117
+ const m = line.match(/^pick\s+(\w+)/);
118
+ if (m) {
119
+ const short = m[1];
120
+ for (const h of hashes) {
121
+ if (h.startsWith(short) || short.startsWith(h.slice(0, short.length))) {
122
+ return line.replace(/^pick/, 'reword');
123
+ }
124
+ }
125
+ }
126
+ return line;
127
+ });
128
+ fs.writeFileSync(file, lines.join('\n'), 'utf8');
129
+ `;
130
+ fs.writeFileSync(scriptPath, script, { mode: 0o755 });
131
+ return scriptPath;
132
+ };
133
+ /**
134
+ * Create a Node.js script (GIT_EDITOR) that injects the new commit
135
+ * message by looking up the current HEAD hash in our temp directory.
136
+ */
137
+ const writeMessageEditor = (dir) => {
138
+ const scriptPath = path.join(dir, 'message-editor.js');
139
+ const messagesDir = path.join(dir, 'messages');
140
+ const script = String.raw `#!/usr/bin/env node
141
+ const fs = require('fs');
142
+ const { execSync } = require('child_process');
143
+ const path = require('path');
144
+
145
+ const file = process.argv[2];
146
+ const messagesDir = ${JSON.stringify(messagesDir)};
147
+
148
+ // Get current HEAD hash (during rebase, HEAD = commit being reworded)
149
+ let hash;
150
+ try {
151
+ hash = execSync('git rev-parse HEAD', { encoding: 'utf8' }).trim();
152
+ } catch { process.exit(0); }
153
+
154
+ // Find matching message file
155
+ const files = fs.readdirSync(messagesDir);
156
+ let msgFile = null;
157
+ for (const f of files) {
158
+ if (hash.startsWith(f) || f.startsWith(hash.slice(0, f.length))) {
159
+ msgFile = path.join(messagesDir, f);
160
+ break;
161
+ }
162
+ }
163
+ if (!msgFile) process.exit(0);
164
+
165
+ // Read new message and comment lines from original
166
+ const original = fs.readFileSync(file, 'utf8');
167
+ const comments = original.split('\n').filter(l => l.startsWith('#')).join('\n');
168
+ const newMsg = fs.readFileSync(msgFile, 'utf8').trim();
169
+
170
+ fs.writeFileSync(file, newMsg + '\n\n' + comments + '\n', 'utf8');
171
+ `;
172
+ fs.writeFileSync(scriptPath, script, { mode: 0o755 });
173
+ return scriptPath;
174
+ };
175
+ // ── Main handler ───────────────────────────────────────────────────
176
+ export const handleReword = async () => {
177
+ log.banner();
178
+ log.step(`${colors.cyan}Edit Commit Messages${colors.reset}\n`);
179
+ const branch = getCurrentBranch();
180
+ console.log(` ${colors.gray}Branch: ${branch}${colors.reset}`);
181
+ // Check working tree
182
+ if (!isWorkingTreeClean()) {
183
+ log.error('Working tree has uncommitted changes.');
184
+ log.info('Please commit or stash changes before rewording.');
185
+ return;
186
+ }
187
+ // Load commits
188
+ const commits = getRecentCommits(30);
189
+ if (commits.length === 0) {
190
+ log.warn('No commits found.');
191
+ return;
192
+ }
193
+ // Compute column widths
194
+ const maxHash = Math.max(...commits.map((c) => c.shortHash.length));
195
+ const maxDate = Math.max(...commits.map((c) => c.relativeDate.length));
196
+ // Build multi-select options
197
+ const options = commits.map((c) => {
198
+ const hashCol = c.shortHash.padEnd(maxHash);
199
+ const dateCol = c.relativeDate.padEnd(maxDate);
200
+ const refs = formatRefs(c.refs);
201
+ const subj = c.subject.length > 60 ? c.subject.slice(0, 57) + '...' : c.subject;
202
+ const label = `${colors.yellow}${hashCol}${colors.reset}` +
203
+ ` ${colors.gray}${dateCol}${colors.reset}` +
204
+ `${refs} ${colors.bright}${subj}${colors.reset}`;
205
+ return { label, value: c.hash };
206
+ });
207
+ console.log('');
208
+ const selected = await multiSelect('Select commits to edit:', options);
209
+ if (selected.length === 0) {
210
+ log.info('No commits selected.');
211
+ return;
212
+ }
213
+ // Sort selected: oldest first (for rebase ordering)
214
+ const filtered = commits.filter((c) => selected.includes(c.hash));
215
+ const selectedCommits = [];
216
+ for (let i = filtered.length - 1; i >= 0; i--) {
217
+ const item = filtered[i];
218
+ if (item)
219
+ selectedCommits.push(item);
220
+ }
221
+ // Collect new messages for each commit
222
+ console.log('');
223
+ log.info(`Editing ${selectedCommits.length} commit message(s)...`);
224
+ console.log('');
225
+ const newMessages = new Map();
226
+ // Load provider/model state for AI generation
227
+ const state = loadState() ?? {
228
+ step: 0,
229
+ workingBranch: '',
230
+ targetBranch: '',
231
+ currentBranch: branch,
232
+ timestamp: new Date().toISOString(),
233
+ };
234
+ for (const commit of selectedCommits) {
235
+ const currentMsg = getCommitMessage(commit.hash);
236
+ console.log(` ${colors.yellow}${commit.shortHash}${colors.reset}` +
237
+ ` ${colors.gray}${commit.relativeDate}${colors.reset}` +
238
+ ` ${colors.bright}${commit.subject}${colors.reset}`);
239
+ // Per-commit method menu
240
+ const method = await select('How to edit this commit message?', [
241
+ { label: 'Generate from AI', value: 'ai' },
242
+ { label: 'Edit manually', value: 'manual' },
243
+ { label: 'Skip this commit', value: 'skip' },
244
+ ]);
245
+ if (method === 'skip') {
246
+ log.info(`Skipped ${commit.shortHash}`);
247
+ console.log('');
248
+ continue;
249
+ }
250
+ if (method === 'manual') {
251
+ const edited = await editInline(currentMsg, `Edit: ${commit.shortHash} ${commit.subject}`);
252
+ if (edited === null) {
253
+ log.info(`Skipped ${commit.shortHash}`);
254
+ }
255
+ else if (edited.trim() === currentMsg.trim()) {
256
+ log.info(`No changes for ${commit.shortHash}`);
257
+ }
258
+ else {
259
+ newMessages.set(commit.hash, edited.trim());
260
+ }
261
+ console.log('');
262
+ continue;
263
+ }
264
+ // ── AI generation flow ───────────────────────────────────
265
+ const diff = getCommitDiff(commit.hash);
266
+ if (!diff) {
267
+ log.warn(`No diff found for ${commit.shortHash}, falling back to manual edit`);
268
+ const edited = await editInline(currentMsg, `Edit: ${commit.shortHash} ${commit.subject}`);
269
+ if (edited && edited.trim() !== currentMsg.trim()) {
270
+ newMessages.set(commit.hash, edited.trim());
271
+ }
272
+ console.log('');
273
+ continue;
274
+ }
275
+ // Determine current provider/model
276
+ let currentProvider = (state.aiProvider === 'manual' ? undefined : state.aiProvider) ?? 'gemini';
277
+ let currentModel;
278
+ if (currentProvider === 'copilot') {
279
+ currentModel = state.copilotModel;
280
+ }
281
+ else if (currentProvider === 'openrouter') {
282
+ currentModel = state.openrouterModel;
283
+ }
284
+ else {
285
+ currentModel = state.geminiModel ?? DEFAULT_GEMINI_MODEL;
286
+ }
287
+ // Initial AI generation
288
+ let correction = '';
289
+ let initialAiResult = null;
290
+ const spinner = new ScrambleProgress();
291
+ try {
292
+ spinner.start([
293
+ 'analyzing commit diff...',
294
+ `generating commit message with ${getAIProviderShortName(currentProvider)}${currentModel ? ` (${currentModel})` : ''}...`,
295
+ 'formatting conventional commit...',
296
+ ]);
297
+ if (currentProvider === 'copilot') {
298
+ const { generateCommitMessage } = await import('../api/copilot.js');
299
+ initialAiResult = await generateCommitMessage(diff, correction, state.copilotModel);
300
+ }
301
+ else if (currentProvider === 'openrouter') {
302
+ const { generateCommitMessage } = await import('../api/openrouter.js');
303
+ initialAiResult = await generateCommitMessage(diff, correction, state.openrouterModel);
304
+ }
305
+ else {
306
+ const { generateCommitMessage } = await import('../api/gemini.js');
307
+ initialAiResult = await generateCommitMessage(diff, correction, state.geminiModel);
308
+ }
309
+ spinner.stop();
310
+ }
311
+ catch {
312
+ spinner.stop();
313
+ log.warn('AI generation failed, falling back to manual edit');
314
+ const edited = await editInline(currentMsg, `Edit: ${commit.shortHash} ${commit.subject}`);
315
+ if (edited && edited.trim() !== currentMsg.trim()) {
316
+ newMessages.set(commit.hash, edited.trim());
317
+ }
318
+ console.log('');
319
+ continue;
320
+ }
321
+ // AI accept loop (mirrors commit.ts pattern)
322
+ let firstAttempt = true;
323
+ let forceDirect = false;
324
+ let skipRegenerate = false;
325
+ let previousAiResult = initialAiResult;
326
+ let commitDone = false;
327
+ while (!commitDone) {
328
+ let aiResult = null;
329
+ if (skipRegenerate) {
330
+ aiResult = previousAiResult;
331
+ skipRegenerate = false;
332
+ }
333
+ else if (firstAttempt &&
334
+ initialAiResult &&
335
+ !isTransientAIFailure(initialAiResult) &&
336
+ !isContextLimitFailure(initialAiResult)) {
337
+ aiResult = initialAiResult;
338
+ }
339
+ else if (forceDirect) {
340
+ let directAttempt = 0;
341
+ const maxDirectAttempts = 2;
342
+ while (directAttempt < maxDirectAttempts && !aiResult) {
343
+ let directModelName = '';
344
+ if (state.aiProvider === 'copilot' && state.copilotModel) {
345
+ directModelName = state.copilotModel;
346
+ }
347
+ else if (state.aiProvider === 'openrouter' && state.openrouterModel) {
348
+ directModelName = state.openrouterModel;
349
+ }
350
+ else if (state.aiProvider === 'gemini') {
351
+ directModelName = state.geminiModel ?? DEFAULT_GEMINI_MODEL;
352
+ }
353
+ if (correction)
354
+ console.log('');
355
+ const sp = new ScrambleProgress();
356
+ sp.start([
357
+ 'reviewing feedback...',
358
+ `regenerating with ${getAIProviderShortName(state.aiProvider ?? 'gemini')}${directModelName ? ` (${directModelName})` : ''}...`,
359
+ 'formatting conventional commit...',
360
+ ]);
361
+ try {
362
+ switch (state.aiProvider) {
363
+ case 'copilot': {
364
+ const { generateCommitMessage } = await import('../api/copilot.js');
365
+ aiResult = await generateCommitMessage(diff, correction, state.copilotModel);
366
+ break;
367
+ }
368
+ case 'openrouter': {
369
+ const { generateCommitMessage } = await import('../api/openrouter.js');
370
+ aiResult = await generateCommitMessage(diff, correction, state.openrouterModel);
371
+ break;
372
+ }
373
+ case 'gemini': {
374
+ const { generateCommitMessage } = await import('../api/gemini.js');
375
+ aiResult = await generateCommitMessage(diff, correction, state.geminiModel);
376
+ break;
377
+ }
378
+ default: {
379
+ aiResult = null;
380
+ break;
381
+ }
382
+ }
383
+ sp.stop();
384
+ }
385
+ catch {
386
+ sp.stop();
387
+ aiResult = null;
388
+ }
389
+ directAttempt += 1;
390
+ if (!aiResult && directAttempt < maxDirectAttempts) {
391
+ log.ai('Regenerate returned no suggestion; retrying...');
392
+ }
393
+ }
394
+ }
395
+ else {
396
+ const provForFallback = (state.aiProvider ?? 'gemini');
397
+ let modelChoice;
398
+ if (provForFallback === 'copilot') {
399
+ modelChoice = state.copilotModel;
400
+ }
401
+ else if (provForFallback === 'openrouter') {
402
+ modelChoice = state.openrouterModel;
403
+ }
404
+ else {
405
+ modelChoice = state.geminiModel ?? DEFAULT_GEMINI_MODEL;
406
+ }
407
+ aiResult = await interactiveAIFallback(firstAttempt ? initialAiResult : null, provForFallback, modelChoice, diff, correction, branch, (provider, model) => {
408
+ state.aiProvider = provider;
409
+ switch (provider) {
410
+ case 'copilot': {
411
+ state.copilotModel = model;
412
+ break;
413
+ }
414
+ case 'openrouter': {
415
+ state.openrouterModel = model;
416
+ break;
417
+ }
418
+ case 'gemini': {
419
+ if (model && typeof model === 'string') {
420
+ state.geminiModel = model;
421
+ }
422
+ break;
423
+ }
424
+ default: {
425
+ break;
426
+ }
427
+ }
428
+ saveState(state);
429
+ }, true);
430
+ }
431
+ previousAiResult = aiResult;
432
+ firstAttempt = false;
433
+ forceDirect = false;
434
+ const commitMessage = aiResult ?? '';
435
+ if (!commitMessage) {
436
+ log.warn('Could not generate message; falling back to manual edit');
437
+ const edited = await editInline(currentMsg, `Edit: ${commit.shortHash} ${commit.subject}`);
438
+ if (edited && edited.trim() !== currentMsg.trim()) {
439
+ newMessages.set(commit.hash, edited.trim());
440
+ }
441
+ break;
442
+ }
443
+ const contextLimitDetected = isContextLimitFailure(commitMessage);
444
+ // Display AI suggestion
445
+ const lines = commitMessage.split('\n');
446
+ const subject = lines.find((l) => l.trim()) ?? commitMessage;
447
+ const body = lines
448
+ .slice(lines.indexOf(subject) + 1)
449
+ .join('\n')
450
+ .trim();
451
+ log.ai(`Suggested Message:\n\n${colors.cyan}${colors.bright}${subject}`);
452
+ if (body) {
453
+ console.log('\n' + body + `${colors.reset}\n`);
454
+ }
455
+ // Accept menu
456
+ let acceptChoice;
457
+ if (contextLimitDetected && !subject.trim()) {
458
+ acceptChoice = await select('Token/context limits. Choose:', [
459
+ {
460
+ label: `Try again with ${getAIProviderShortName(currentProvider)}${getModelValue(currentModel) ? ` (${getModelValue(currentModel)})` : ''}`,
461
+ value: 'try-same',
462
+ },
463
+ { label: 'Change model', value: 'change-model' },
464
+ { label: 'Change AI provider', value: 'change-provider' },
465
+ { label: 'Edit manually', value: 'edit' },
466
+ ]);
467
+ }
468
+ else {
469
+ acceptChoice = await select('Accept this commit message?', [
470
+ { label: 'Yes, use it', value: 'accept' },
471
+ { label: 'Regenerate', value: 'regenerate' },
472
+ { label: 'Edit inline', value: 'edit' },
473
+ { label: 'Correct AI (give feedback)', value: 'correct' },
474
+ { label: 'Change model', value: 'change-model' },
475
+ { label: 'Change AI provider', value: 'change-provider' },
476
+ ]);
477
+ }
478
+ switch (acceptChoice) {
479
+ case 'accept': {
480
+ const normalized = normalizeAIOutput(commitMessage);
481
+ const extracted = extractCommitTitle(normalized);
482
+ let title;
483
+ let bodyText = null;
484
+ if (extracted) {
485
+ title = extracted;
486
+ bodyText = extractCommitBody(normalized, title);
487
+ if (bodyText)
488
+ bodyText = formatCommitBody(bodyText);
489
+ }
490
+ else {
491
+ const first = normalized.split('\n').find((l) => l.trim());
492
+ title = first?.trim() ?? normalized;
493
+ }
494
+ const finalMsg = bodyText ? `${title}\n\n${bodyText}` : title;
495
+ newMessages.set(commit.hash, finalMsg.trim());
496
+ log.success(`Message set for ${commit.shortHash}`);
497
+ commitDone = true;
498
+ break;
499
+ }
500
+ case 'regenerate': {
501
+ correction = '';
502
+ forceDirect = true;
503
+ continue;
504
+ }
505
+ case 'try-same': {
506
+ forceDirect = true;
507
+ continue;
508
+ }
509
+ case 'change-provider': {
510
+ const prov = await select('Choose AI provider:', [
511
+ { label: 'Gemini', value: 'gemini' },
512
+ {
513
+ label: 'GitHub (Recommended)',
514
+ value: 'copilot',
515
+ },
516
+ { label: 'OpenRouter', value: 'openrouter' },
517
+ { label: 'Back', value: 'back' },
518
+ ]);
519
+ if (prov === 'back') {
520
+ skipRegenerate = true;
521
+ continue;
522
+ }
523
+ const chosenModel = await chooseModelForProvider(prov, 'Choose model:', 'Back');
524
+ if (!chosenModel || chosenModel === 'back') {
525
+ skipRegenerate = true;
526
+ continue;
527
+ }
528
+ state.aiProvider = prov;
529
+ currentProvider = prov;
530
+ switch (prov) {
531
+ case 'copilot': {
532
+ state.copilotModel = chosenModel;
533
+ state.openrouterModel = undefined;
534
+ state.geminiModel = undefined;
535
+ currentModel = chosenModel;
536
+ break;
537
+ }
538
+ case 'openrouter': {
539
+ state.openrouterModel = chosenModel;
540
+ state.copilotModel = undefined;
541
+ state.geminiModel = undefined;
542
+ currentModel = chosenModel;
543
+ break;
544
+ }
545
+ default: {
546
+ state.geminiModel = chosenModel;
547
+ state.copilotModel = undefined;
548
+ state.openrouterModel = undefined;
549
+ currentModel = chosenModel;
550
+ break;
551
+ }
552
+ }
553
+ saveState(state);
554
+ forceDirect = true;
555
+ correction = '';
556
+ continue;
557
+ }
558
+ case 'change-model': {
559
+ const provKey = (currentProvider === 'gemini' ||
560
+ currentProvider === 'copilot' ||
561
+ currentProvider === 'openrouter'
562
+ ? currentProvider
563
+ : 'gemini');
564
+ const chosen = await chooseModelForProvider(provKey, 'Choose model:', 'Back');
565
+ if (!chosen || chosen === 'back') {
566
+ skipRegenerate = true;
567
+ continue;
568
+ }
569
+ switch (provKey) {
570
+ case 'copilot': {
571
+ state.copilotModel = chosen;
572
+ currentModel = chosen;
573
+ break;
574
+ }
575
+ case 'openrouter': {
576
+ state.openrouterModel = chosen;
577
+ currentModel = chosen;
578
+ break;
579
+ }
580
+ default: {
581
+ state.geminiModel = chosen;
582
+ state.copilotModel = undefined;
583
+ state.openrouterModel = undefined;
584
+ currentModel = chosen;
585
+ break;
586
+ }
587
+ }
588
+ saveState(state);
589
+ forceDirect = true;
590
+ correction = '';
591
+ continue;
592
+ }
593
+ case 'correct': {
594
+ correction = askQuestion('Provide corrections for the AI: ');
595
+ forceDirect = true;
596
+ continue;
597
+ }
598
+ case 'edit': {
599
+ const edited = await editInline(commitMessage, `Edit: ${commit.shortHash}`);
600
+ if (edited?.trim()) {
601
+ const editedNorm = normalizeAIOutput(edited.trim());
602
+ const editedTitle = extractCommitTitle(editedNorm);
603
+ let title;
604
+ let bodyText = null;
605
+ if (editedTitle) {
606
+ title = editedTitle;
607
+ bodyText = extractCommitBody(editedNorm, title);
608
+ if (bodyText)
609
+ bodyText = formatCommitBody(bodyText);
610
+ }
611
+ else {
612
+ const first = editedNorm.split('\n').find((l) => l.trim());
613
+ title = first?.trim() ?? editedNorm;
614
+ }
615
+ const finalMsg = bodyText ? `${title}\n\n${bodyText}` : title;
616
+ newMessages.set(commit.hash, finalMsg.trim());
617
+ log.success(`Message set for ${commit.shortHash}`);
618
+ commitDone = true;
619
+ }
620
+ break;
621
+ }
622
+ default: {
623
+ break;
624
+ }
625
+ }
626
+ }
627
+ console.log('');
628
+ }
629
+ if (newMessages.size === 0) {
630
+ log.info('No messages changed.');
631
+ return;
632
+ }
633
+ // Preview changes
634
+ console.log('');
635
+ console.log(` ${colors.bright}Changes to apply:${colors.reset}`);
636
+ for (const [hash, msg] of newMessages) {
637
+ const commit = commits.find((c) => c.hash === hash);
638
+ const short = commit?.shortHash ?? hash.slice(0, 7);
639
+ const oldSubj = commit?.subject ?? '';
640
+ const newSubj = msg.split('\n')[0] ?? msg;
641
+ console.log(` ${colors.yellow}${short}${colors.reset}` +
642
+ ` ${colors.red}${oldSubj}${colors.reset}` +
643
+ ` → ${colors.green}${newSubj}${colors.reset}`);
644
+ }
645
+ console.log('');
646
+ if (isDryRun()) {
647
+ logDryRun(`git rebase -i (reword ${newMessages.size} commits)`);
648
+ return;
649
+ }
650
+ const proceed = confirm(`Reword ${newMessages.size} commit(s)?`);
651
+ if (!proceed) {
652
+ log.info('Cancelled.');
653
+ return;
654
+ }
655
+ // Prepare temp directory with scripts and messages
656
+ const tmpDir = path.join(os.tmpdir(), `geeto-reword-${Date.now()}`);
657
+ const messagesDir = path.join(tmpDir, 'messages');
658
+ fs.mkdirSync(messagesDir, { recursive: true });
659
+ // Write message files (keyed by full hash)
660
+ for (const [hash, msg] of newMessages) {
661
+ fs.writeFileSync(path.join(messagesDir, hash), msg, 'utf8');
662
+ }
663
+ // Create editor scripts
664
+ const allHashes = [...newMessages.keys()];
665
+ const seqEditorPath = writeSequenceEditor(tmpDir, allHashes);
666
+ const msgEditorPath = writeMessageEditor(tmpDir);
667
+ // Find the parent of the oldest selected commit
668
+ const oldestHash = selectedCommits.find((c) => newMessages.has(c.hash))?.hash;
669
+ if (!oldestHash) {
670
+ log.error('Internal error: could not find oldest commit.');
671
+ return;
672
+ }
673
+ let parentRef;
674
+ try {
675
+ parentRef = execSilent(`git rev-parse ${oldestHash}^`).trim();
676
+ }
677
+ catch {
678
+ // Oldest commit might be the root commit
679
+ parentRef = '--root';
680
+ }
681
+ // Execute rebase
682
+ console.log('');
683
+ const rebaseTarget = parentRef === '--root' ? '--root' : parentRef;
684
+ const env = {
685
+ ...process.env,
686
+ GIT_SEQUENCE_EDITOR: `node ${seqEditorPath}`,
687
+ GIT_EDITOR: `node ${msgEditorPath}`,
688
+ };
689
+ try {
690
+ const result = spawnSync('git', ['rebase', '-i', rebaseTarget], { stdio: 'inherit', env });
691
+ if (result.status === 0) {
692
+ log.success(`Reworded ${newMessages.size} commit(s)!`);
693
+ // Show updated commits
694
+ for (const [hash] of newMessages) {
695
+ const short = hash.slice(0, 7);
696
+ const newMsg = getCommitMessage(hash)?.split('\n')[0];
697
+ if (newMsg) {
698
+ console.log(` ${colors.green}✓${colors.reset} ${colors.yellow}${short}${colors.reset} ${newMsg}`);
699
+ }
700
+ }
701
+ }
702
+ else {
703
+ log.error('Rebase failed. You may need to resolve conflicts.');
704
+ log.info('Run: git rebase --abort to undo');
705
+ }
706
+ }
707
+ catch (error) {
708
+ const msg = error instanceof Error ? error.message : String(error);
709
+ log.error(`Rebase error: ${msg}`);
710
+ log.info('Run: git rebase --abort to undo');
711
+ }
712
+ finally {
713
+ // Cleanup temp files
714
+ try {
715
+ fs.rmSync(tmpDir, { recursive: true, force: true });
716
+ }
717
+ catch {
718
+ // ignore
719
+ }
720
+ }
721
+ };
722
+ //# sourceMappingURL=reword.js.map