cluttry 1.0.3 → 1.5.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 (91) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/README.md +203 -300
  3. package/dist/commands/completions.d.ts +16 -0
  4. package/dist/commands/completions.d.ts.map +1 -0
  5. package/dist/commands/completions.js +46 -0
  6. package/dist/commands/completions.js.map +1 -0
  7. package/dist/commands/explain-copy.d.ts +11 -0
  8. package/dist/commands/explain-copy.d.ts.map +1 -0
  9. package/dist/commands/explain-copy.js +34 -0
  10. package/dist/commands/explain-copy.js.map +1 -0
  11. package/dist/commands/finish.d.ts +20 -0
  12. package/dist/commands/finish.d.ts.map +1 -0
  13. package/dist/commands/finish.js +817 -0
  14. package/dist/commands/finish.js.map +1 -0
  15. package/dist/commands/gc.d.ts +22 -0
  16. package/dist/commands/gc.d.ts.map +1 -0
  17. package/dist/commands/gc.js +163 -0
  18. package/dist/commands/gc.js.map +1 -0
  19. package/dist/commands/init.d.ts.map +1 -1
  20. package/dist/commands/init.js +1 -0
  21. package/dist/commands/init.js.map +1 -1
  22. package/dist/commands/open.d.ts +15 -1
  23. package/dist/commands/open.d.ts.map +1 -1
  24. package/dist/commands/open.js +96 -17
  25. package/dist/commands/open.js.map +1 -1
  26. package/dist/commands/resume.d.ts +21 -0
  27. package/dist/commands/resume.d.ts.map +1 -0
  28. package/dist/commands/resume.js +106 -0
  29. package/dist/commands/resume.js.map +1 -0
  30. package/dist/commands/rm.d.ts.map +1 -1
  31. package/dist/commands/rm.js +6 -14
  32. package/dist/commands/rm.js.map +1 -1
  33. package/dist/commands/spawn.d.ts +3 -0
  34. package/dist/commands/spawn.d.ts.map +1 -1
  35. package/dist/commands/spawn.js +182 -19
  36. package/dist/commands/spawn.js.map +1 -1
  37. package/dist/index.d.ts +4 -0
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +198 -9
  40. package/dist/index.js.map +1 -1
  41. package/dist/lib/completions.d.ts +35 -0
  42. package/dist/lib/completions.d.ts.map +1 -0
  43. package/dist/lib/completions.js +368 -0
  44. package/dist/lib/completions.js.map +1 -0
  45. package/dist/lib/config.d.ts.map +1 -1
  46. package/dist/lib/config.js +2 -0
  47. package/dist/lib/config.js.map +1 -1
  48. package/dist/lib/errors.d.ts +43 -0
  49. package/dist/lib/errors.d.ts.map +1 -0
  50. package/dist/lib/errors.js +251 -0
  51. package/dist/lib/errors.js.map +1 -0
  52. package/dist/lib/git.d.ts +17 -0
  53. package/dist/lib/git.d.ts.map +1 -1
  54. package/dist/lib/git.js +78 -10
  55. package/dist/lib/git.js.map +1 -1
  56. package/dist/lib/safety.d.ts +79 -0
  57. package/dist/lib/safety.d.ts.map +1 -0
  58. package/dist/lib/safety.js +133 -0
  59. package/dist/lib/safety.js.map +1 -0
  60. package/dist/lib/secrets.d.ts +29 -0
  61. package/dist/lib/secrets.d.ts.map +1 -1
  62. package/dist/lib/secrets.js +115 -0
  63. package/dist/lib/secrets.js.map +1 -1
  64. package/dist/lib/session.d.ts +93 -0
  65. package/dist/lib/session.d.ts.map +1 -0
  66. package/dist/lib/session.js +254 -0
  67. package/dist/lib/session.js.map +1 -0
  68. package/dist/lib/types.d.ts +6 -1
  69. package/dist/lib/types.d.ts.map +1 -1
  70. package/package.json +1 -1
  71. package/.claude/settings.local.json +0 -7
  72. package/src/commands/doctor.ts +0 -222
  73. package/src/commands/init.ts +0 -120
  74. package/src/commands/list.ts +0 -133
  75. package/src/commands/open.ts +0 -78
  76. package/src/commands/prune.ts +0 -36
  77. package/src/commands/rm.ts +0 -125
  78. package/src/commands/shell.ts +0 -99
  79. package/src/commands/spawn.ts +0 -169
  80. package/src/index.ts +0 -123
  81. package/src/lib/config.ts +0 -120
  82. package/src/lib/git.ts +0 -243
  83. package/src/lib/output.ts +0 -102
  84. package/src/lib/paths.ts +0 -108
  85. package/src/lib/secrets.ts +0 -193
  86. package/src/lib/types.ts +0 -69
  87. package/tests/config.test.ts +0 -102
  88. package/tests/paths.test.ts +0 -155
  89. package/tests/secrets.test.ts +0 -150
  90. package/tsconfig.json +0 -20
  91. package/vitest.config.ts +0 -15
@@ -0,0 +1,817 @@
1
+ /**
2
+ * cry finish command
3
+ *
4
+ * Show session summary and optionally create PR, cleanup worktree.
5
+ * Safe by default - never auto-merges, never deletes without confirmation.
6
+ */
7
+ import { createInterface } from 'node:readline';
8
+ import { execSync, spawnSync } from 'node:child_process';
9
+ import { isGitRepo, getRepoRoot, getCurrentBranch, git, removeWorktree, deleteBranch, getDefaultBranch, getUpstreamBranch, } from '../lib/git.js';
10
+ import { findSessionForCwd, findMainRepoRoot, deleteSession, } from '../lib/session.js';
11
+ import * as out from '../lib/output.js';
12
+ import { fail, errors, printError } from '../lib/errors.js';
13
+ /**
14
+ * Parse git status --porcelain output
15
+ */
16
+ function parseGitStatus(output) {
17
+ const lines = output.split('\n').filter(line => line.trim());
18
+ const staged = [];
19
+ const unstaged = [];
20
+ const untracked = [];
21
+ for (const line of lines) {
22
+ const index = line[0];
23
+ const worktree = line[1];
24
+ const file = line.slice(3);
25
+ if (index === '?' && worktree === '?') {
26
+ untracked.push(file);
27
+ }
28
+ else {
29
+ if (index !== ' ' && index !== '?') {
30
+ staged.push(file);
31
+ }
32
+ if (worktree !== ' ' && worktree !== '?') {
33
+ unstaged.push(file);
34
+ }
35
+ }
36
+ }
37
+ return {
38
+ staged,
39
+ unstaged,
40
+ untracked,
41
+ clean: staged.length === 0 && unstaged.length === 0 && untracked.length === 0,
42
+ };
43
+ }
44
+ /**
45
+ * Parse git diff --stat output
46
+ */
47
+ function parseDiffStat(output) {
48
+ const lines = output.trim().split('\n');
49
+ if (lines.length === 0 || output.trim() === '') {
50
+ return { filesChanged: 0, insertions: 0, deletions: 0, summary: 'No changes' };
51
+ }
52
+ // Last line contains summary like: "5 files changed, 100 insertions(+), 20 deletions(-)"
53
+ const lastLine = lines[lines.length - 1];
54
+ const filesMatch = lastLine.match(/(\d+) files? changed/);
55
+ const insertMatch = lastLine.match(/(\d+) insertions?\(\+\)/);
56
+ const deleteMatch = lastLine.match(/(\d+) deletions?\(-\)/);
57
+ return {
58
+ filesChanged: filesMatch ? parseInt(filesMatch[1], 10) : 0,
59
+ insertions: insertMatch ? parseInt(insertMatch[1], 10) : 0,
60
+ deletions: deleteMatch ? parseInt(deleteMatch[1], 10) : 0,
61
+ summary: lastLine.trim() || 'No changes',
62
+ };
63
+ }
64
+ /**
65
+ * Get commits ahead/behind base branch
66
+ */
67
+ function getCommitInfo(baseBranch, cwd) {
68
+ const commits = {
69
+ ahead: 0,
70
+ behind: 0,
71
+ list: [],
72
+ };
73
+ try {
74
+ // Get commits ahead (on this branch but not on base)
75
+ const aheadOutput = git(['rev-list', '--count', `${baseBranch}..HEAD`], cwd);
76
+ commits.ahead = parseInt(aheadOutput, 10) || 0;
77
+ // Get commits behind (on base but not on this branch)
78
+ const behindOutput = git(['rev-list', '--count', `HEAD..${baseBranch}`], cwd);
79
+ commits.behind = parseInt(behindOutput, 10) || 0;
80
+ // Get list of commits ahead
81
+ if (commits.ahead > 0) {
82
+ const logOutput = git(['log', '--oneline', '--no-decorate', `${baseBranch}..HEAD`], cwd);
83
+ commits.list = logOutput.split('\n').filter(line => line.trim()).map(line => {
84
+ const [sha, ...rest] = line.split(' ');
85
+ return { sha, message: rest.join(' ') };
86
+ });
87
+ }
88
+ }
89
+ catch {
90
+ // Base branch might not exist or be reachable
91
+ }
92
+ return commits;
93
+ }
94
+ /**
95
+ * Detect session info from git when no manifest is available
96
+ * Uses improved base branch detection via merge-base and upstream tracking
97
+ */
98
+ function detectSessionFromGit(cwd) {
99
+ try {
100
+ const branch = getCurrentBranch(cwd);
101
+ if (!branch)
102
+ return null;
103
+ const repoRoot = getRepoRoot(cwd);
104
+ const mainRepoRoot = findMainRepoRoot(cwd);
105
+ // Determine base branch with fallback chain:
106
+ // 1. Upstream tracking branch (e.g., origin/main)
107
+ // 2. Default branch from origin/HEAD
108
+ // 3. 'main' or 'master' if they exist
109
+ // 4. Current branch as last resort
110
+ let baseBranch = null;
111
+ // Try upstream tracking branch
112
+ const upstream = getUpstreamBranch(cwd);
113
+ if (upstream) {
114
+ // Extract branch name from origin/branch format
115
+ const upstreamBranch = upstream.replace(/^origin\//, '');
116
+ // Verify it's a different branch
117
+ if (upstreamBranch !== branch) {
118
+ baseBranch = upstreamBranch;
119
+ }
120
+ }
121
+ // Try default branch
122
+ if (!baseBranch) {
123
+ const defaultBranch = getDefaultBranch(cwd);
124
+ if (defaultBranch && defaultBranch !== branch) {
125
+ baseBranch = defaultBranch;
126
+ }
127
+ }
128
+ // Try common branch names
129
+ if (!baseBranch) {
130
+ for (const candidate of ['main', 'master', 'develop']) {
131
+ try {
132
+ git(['rev-parse', '--verify', `refs/heads/${candidate}`], cwd);
133
+ if (candidate !== branch) {
134
+ baseBranch = candidate;
135
+ break;
136
+ }
137
+ }
138
+ catch {
139
+ // Branch doesn't exist, try next
140
+ }
141
+ }
142
+ }
143
+ // Last resort: use current branch (no comparison possible)
144
+ if (!baseBranch) {
145
+ baseBranch = branch;
146
+ }
147
+ return {
148
+ branch,
149
+ baseBranch,
150
+ worktreePath: repoRoot,
151
+ repoRoot: mainRepoRoot ?? repoRoot,
152
+ };
153
+ }
154
+ catch {
155
+ return null;
156
+ }
157
+ }
158
+ /**
159
+ * Build session summary
160
+ */
161
+ function buildSummary(session, sessionId) {
162
+ const cwd = session.worktreePath;
163
+ const baseBranch = session.baseBranch ?? 'main';
164
+ // Get git status
165
+ let statusOutput = '';
166
+ try {
167
+ statusOutput = git(['status', '--porcelain'], cwd);
168
+ }
169
+ catch {
170
+ // Ignore errors
171
+ }
172
+ // Get diff stat against base branch
173
+ let diffOutput = '';
174
+ try {
175
+ diffOutput = git(['diff', '--stat', baseBranch], cwd);
176
+ }
177
+ catch {
178
+ // Base branch might not exist
179
+ }
180
+ return {
181
+ branch: session.branch,
182
+ baseBranch,
183
+ worktreePath: session.worktreePath,
184
+ repoRoot: session.repoRoot,
185
+ sessionId,
186
+ status: parseGitStatus(statusOutput),
187
+ diff: parseDiffStat(diffOutput),
188
+ commits: getCommitInfo(baseBranch, cwd),
189
+ };
190
+ }
191
+ /**
192
+ * Print summary in human-readable format
193
+ */
194
+ function printSummary(summary) {
195
+ out.header('Session Summary');
196
+ out.newline();
197
+ // Basic info
198
+ out.log(` Branch: ${out.fmt.branch(summary.branch)}`);
199
+ out.log(` Base: ${out.fmt.branch(summary.baseBranch)}`);
200
+ out.log(` Worktree: ${out.fmt.path(summary.worktreePath)}`);
201
+ if (summary.sessionId) {
202
+ out.log(` Session ID: ${out.fmt.dim(summary.sessionId)}`);
203
+ }
204
+ out.newline();
205
+ // Status
206
+ out.log(out.fmt.bold('Working Tree Status:'));
207
+ if (summary.status.clean) {
208
+ out.log(` ${out.fmt.green('✓')} Clean`);
209
+ }
210
+ else {
211
+ if (summary.status.staged.length > 0) {
212
+ out.log(` ${out.fmt.green('Staged:')} ${summary.status.staged.length} file(s)`);
213
+ for (const file of summary.status.staged.slice(0, 5)) {
214
+ out.log(` ${out.fmt.green('+')} ${file}`);
215
+ }
216
+ if (summary.status.staged.length > 5) {
217
+ out.log(` ${out.fmt.dim(`... and ${summary.status.staged.length - 5} more`)}`);
218
+ }
219
+ }
220
+ if (summary.status.unstaged.length > 0) {
221
+ out.log(` ${out.fmt.yellow('Modified:')} ${summary.status.unstaged.length} file(s)`);
222
+ for (const file of summary.status.unstaged.slice(0, 5)) {
223
+ out.log(` ${out.fmt.yellow('~')} ${file}`);
224
+ }
225
+ if (summary.status.unstaged.length > 5) {
226
+ out.log(` ${out.fmt.dim(`... and ${summary.status.unstaged.length - 5} more`)}`);
227
+ }
228
+ }
229
+ if (summary.status.untracked.length > 0) {
230
+ out.log(` ${out.fmt.gray('Untracked:')} ${summary.status.untracked.length} file(s)`);
231
+ for (const file of summary.status.untracked.slice(0, 3)) {
232
+ out.log(` ${out.fmt.gray('?')} ${file}`);
233
+ }
234
+ if (summary.status.untracked.length > 3) {
235
+ out.log(` ${out.fmt.dim(`... and ${summary.status.untracked.length - 3} more`)}`);
236
+ }
237
+ }
238
+ }
239
+ out.newline();
240
+ // Diff stats
241
+ out.log(out.fmt.bold(`Changes vs ${summary.baseBranch}:`));
242
+ if (summary.diff.filesChanged === 0 && summary.commits.ahead === 0) {
243
+ out.log(` ${out.fmt.dim('No changes')}`);
244
+ }
245
+ else {
246
+ out.log(` ${summary.diff.summary}`);
247
+ }
248
+ out.newline();
249
+ // Commits
250
+ out.log(out.fmt.bold('Commits:'));
251
+ if (summary.commits.ahead === 0 && summary.commits.behind === 0) {
252
+ out.log(` ${out.fmt.dim('Up to date with')} ${summary.baseBranch}`);
253
+ }
254
+ else {
255
+ if (summary.commits.ahead > 0) {
256
+ out.log(` ${out.fmt.green(`↑ ${summary.commits.ahead}`)} ahead of ${summary.baseBranch}`);
257
+ for (const commit of summary.commits.list.slice(0, 5)) {
258
+ out.log(` ${out.fmt.dim(commit.sha)} ${commit.message}`);
259
+ }
260
+ if (summary.commits.list.length > 5) {
261
+ out.log(` ${out.fmt.dim(`... and ${summary.commits.list.length - 5} more`)}`);
262
+ }
263
+ }
264
+ if (summary.commits.behind > 0) {
265
+ out.log(` ${out.fmt.yellow(`↓ ${summary.commits.behind}`)} behind ${summary.baseBranch}`);
266
+ }
267
+ }
268
+ out.newline();
269
+ }
270
+ /**
271
+ * Interactive prompt with choices
272
+ */
273
+ async function promptChoice(message, choices) {
274
+ const rl = createInterface({
275
+ input: process.stdin,
276
+ output: process.stdout,
277
+ });
278
+ out.log(message);
279
+ for (const choice of choices) {
280
+ out.log(` ${out.fmt.bold(choice.key)}) ${choice.label}`);
281
+ }
282
+ return new Promise((resolve) => {
283
+ rl.question('Choice: ', (answer) => {
284
+ rl.close();
285
+ const match = choices.find(c => c.key.toLowerCase() === answer.toLowerCase());
286
+ if (match) {
287
+ resolve(match.value);
288
+ }
289
+ else {
290
+ // Default to first choice or abort
291
+ resolve(choices.find(c => c.value === 'abort')?.value ?? choices[0].value);
292
+ }
293
+ });
294
+ });
295
+ }
296
+ /**
297
+ * Prompt for text input
298
+ */
299
+ async function promptText(message, defaultValue) {
300
+ const rl = createInterface({
301
+ input: process.stdin,
302
+ output: process.stdout,
303
+ });
304
+ const prompt = defaultValue
305
+ ? `${message} [${defaultValue}]: `
306
+ : `${message}: `;
307
+ return new Promise((resolve) => {
308
+ rl.question(prompt, (answer) => {
309
+ rl.close();
310
+ resolve(answer.trim() || defaultValue || '');
311
+ });
312
+ });
313
+ }
314
+ /**
315
+ * Simple yes/no confirmation
316
+ */
317
+ async function confirm(message) {
318
+ const rl = createInterface({
319
+ input: process.stdin,
320
+ output: process.stdout,
321
+ });
322
+ return new Promise((resolve) => {
323
+ rl.question(`${message} [y/N] `, (answer) => {
324
+ rl.close();
325
+ resolve(answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes');
326
+ });
327
+ });
328
+ }
329
+ /**
330
+ * Check if gh CLI is installed and authenticated
331
+ */
332
+ function isGhAvailable() {
333
+ try {
334
+ const result = spawnSync('gh', ['auth', 'status'], {
335
+ encoding: 'utf-8',
336
+ stdio: 'pipe',
337
+ });
338
+ return result.status === 0;
339
+ }
340
+ catch {
341
+ return false;
342
+ }
343
+ }
344
+ /**
345
+ * Check if repo has an origin remote
346
+ */
347
+ function hasOriginRemote(cwd) {
348
+ try {
349
+ const remotes = git(['remote'], cwd);
350
+ return remotes.split('\n').some(r => r.trim() === 'origin');
351
+ }
352
+ catch {
353
+ return false;
354
+ }
355
+ }
356
+ /**
357
+ * Push branch to origin
358
+ */
359
+ function pushToOrigin(branch, cwd) {
360
+ try {
361
+ git(['push', '-u', 'origin', branch], cwd);
362
+ return true;
363
+ }
364
+ catch {
365
+ return false;
366
+ }
367
+ }
368
+ /**
369
+ * Check if branch is pushed to origin
370
+ */
371
+ function isBranchPushed(branch, cwd) {
372
+ try {
373
+ git(['rev-parse', `origin/${branch}`], cwd);
374
+ return true;
375
+ }
376
+ catch {
377
+ return false;
378
+ }
379
+ }
380
+ /**
381
+ * Create PR using gh CLI
382
+ */
383
+ function createPullRequest(branch, baseBranch, cwd) {
384
+ try {
385
+ const result = spawnSync('gh', ['pr', 'create', '--base', baseBranch, '--head', branch, '--fill'], {
386
+ cwd,
387
+ encoding: 'utf-8',
388
+ stdio: 'pipe',
389
+ });
390
+ if (result.status === 0) {
391
+ const url = result.stdout.trim().split('\n').pop() || '';
392
+ return { success: true, url };
393
+ }
394
+ else {
395
+ return { success: false, error: result.stderr || 'Unknown error' };
396
+ }
397
+ }
398
+ catch (error) {
399
+ return { success: false, error: error.message };
400
+ }
401
+ }
402
+ /**
403
+ * Show git diff in pager
404
+ */
405
+ function showDiff(cwd) {
406
+ try {
407
+ execSync('git diff', {
408
+ cwd,
409
+ stdio: 'inherit',
410
+ });
411
+ }
412
+ catch {
413
+ // User may have quit pager
414
+ }
415
+ }
416
+ /**
417
+ * Generate a default commit message from branch name
418
+ * feature/add-login -> Add login
419
+ * fix-auth-bug -> Fix auth bug
420
+ * feat-oauth -> Feat oauth
421
+ */
422
+ function generateDefaultMessage(branch) {
423
+ // Remove common prefixes
424
+ let message = branch
425
+ .replace(/^(feature|feat|fix|bugfix|hotfix|chore|refactor|docs|test|ci)[\/\-]/i, '')
426
+ .replace(/[-_]/g, ' ')
427
+ .trim();
428
+ // Capitalize first letter
429
+ if (message.length > 0) {
430
+ message = message.charAt(0).toUpperCase() + message.slice(1);
431
+ }
432
+ return message || branch;
433
+ }
434
+ /**
435
+ * Stage all changes
436
+ */
437
+ function stageAll(cwd) {
438
+ try {
439
+ git(['add', '-A'], cwd);
440
+ return true;
441
+ }
442
+ catch {
443
+ return false;
444
+ }
445
+ }
446
+ /**
447
+ * Create a commit with the given message
448
+ */
449
+ function createCommit(message, cwd) {
450
+ try {
451
+ git(['commit', '-m', message], cwd);
452
+ return true;
453
+ }
454
+ catch {
455
+ return false;
456
+ }
457
+ }
458
+ /**
459
+ * Check if there are staged changes
460
+ */
461
+ function hasStagedChanges(cwd) {
462
+ try {
463
+ const output = git(['diff', '--cached', '--name-only'], cwd);
464
+ return output.trim().length > 0;
465
+ }
466
+ catch {
467
+ return false;
468
+ }
469
+ }
470
+ /**
471
+ * Full commit wizard with staging options
472
+ *
473
+ * Returns true if commit was successful, false if aborted/failed
474
+ */
475
+ async function runCommitWizard(cwd, branch, status, providedMessage) {
476
+ const hasStaged = status.staged.length > 0;
477
+ const hasUnstaged = status.unstaged.length > 0 || status.untracked.length > 0;
478
+ // Non-interactive mode with provided message
479
+ if (providedMessage) {
480
+ out.log('Staging all changes...');
481
+ if (!stageAll(cwd)) {
482
+ out.error('Failed to stage changes.');
483
+ return false;
484
+ }
485
+ out.log(`Committing with message: "${providedMessage}"`);
486
+ if (!createCommit(providedMessage, cwd)) {
487
+ out.error('Failed to create commit.');
488
+ return false;
489
+ }
490
+ out.success('Commit created');
491
+ return true;
492
+ }
493
+ // Interactive mode
494
+ out.newline();
495
+ out.header('Commit Wizard');
496
+ out.newline();
497
+ // Show current state
498
+ if (hasStaged) {
499
+ out.log(` ${out.fmt.green('Staged:')} ${status.staged.length} file(s)`);
500
+ }
501
+ if (hasUnstaged) {
502
+ out.log(` ${out.fmt.yellow('Unstaged:')} ${status.unstaged.length + status.untracked.length} file(s)`);
503
+ }
504
+ out.newline();
505
+ // Build choices based on current state
506
+ const choices = [];
507
+ if (hasUnstaged) {
508
+ choices.push({ key: 'a', label: 'Stage all changes and commit', value: 'stage-all' });
509
+ }
510
+ if (hasStaged) {
511
+ choices.push({ key: 's', label: 'Commit only staged changes', value: 'staged-only' });
512
+ }
513
+ choices.push({ key: 'x', label: 'Abort', value: 'abort' });
514
+ // If nothing staged and nothing unstaged, nothing to do
515
+ if (!hasStaged && !hasUnstaged) {
516
+ out.log(out.fmt.dim('Nothing to commit.'));
517
+ return true;
518
+ }
519
+ const action = await promptChoice('How would you like to commit?', choices);
520
+ if (action === 'abort') {
521
+ out.log('Commit aborted.');
522
+ return false;
523
+ }
524
+ // Stage if needed
525
+ if (action === 'stage-all') {
526
+ out.log('Staging all changes...');
527
+ if (!stageAll(cwd)) {
528
+ out.error('Failed to stage changes.');
529
+ return false;
530
+ }
531
+ }
532
+ // Verify we have something to commit
533
+ if (!hasStagedChanges(cwd)) {
534
+ out.warn('No changes staged for commit.');
535
+ return false;
536
+ }
537
+ // Get commit message
538
+ const defaultMessage = generateDefaultMessage(branch);
539
+ const message = await promptText('Commit message', defaultMessage);
540
+ if (!message) {
541
+ out.warn('Empty commit message. Aborting.');
542
+ return false;
543
+ }
544
+ // Create commit
545
+ out.log('Creating commit...');
546
+ if (!createCommit(message, cwd)) {
547
+ out.error('Failed to create commit.');
548
+ return false;
549
+ }
550
+ out.success('Commit created');
551
+ return true;
552
+ }
553
+ /**
554
+ * Print manual PR instructions when gh is not available
555
+ */
556
+ function printManualInstructions(summary) {
557
+ out.newline();
558
+ out.header('Manual PR Instructions');
559
+ out.newline();
560
+ const { branch, baseBranch, worktreePath } = summary;
561
+ out.log('GitHub CLI (gh) is not available. To create a PR manually:');
562
+ out.newline();
563
+ if (!isBranchPushed(branch, worktreePath)) {
564
+ out.log(` 1. Push your branch:`);
565
+ out.log(` ${out.fmt.dim(`git push -u origin ${branch}`)}`);
566
+ out.newline();
567
+ }
568
+ out.log(` 2. Create a PR on GitHub:`);
569
+ out.log(` ${out.fmt.dim(`https://github.com/<owner>/<repo>/compare/${baseBranch}...${branch}`)}`);
570
+ out.newline();
571
+ out.log(` Or install gh CLI:`);
572
+ out.log(` ${out.fmt.dim('brew install gh && gh auth login')}`);
573
+ out.newline();
574
+ }
575
+ /**
576
+ * Perform cleanup: remove worktree, optionally delete branch and session
577
+ */
578
+ async function performCleanup(summary, options, dryRun) {
579
+ const { branch, worktreePath, repoRoot, sessionId } = summary;
580
+ if (dryRun) {
581
+ out.log(out.fmt.dim('[dry-run] Would remove worktree:') + ` ${worktreePath}`);
582
+ if (options.deleteBranch) {
583
+ out.log(out.fmt.dim('[dry-run] Would delete branch:') + ` ${branch}`);
584
+ }
585
+ if (sessionId) {
586
+ out.log(out.fmt.dim('[dry-run] Would delete session:') + ` ${sessionId}`);
587
+ }
588
+ return true;
589
+ }
590
+ // Remove worktree
591
+ out.log(`Removing worktree: ${out.fmt.path(worktreePath)}`);
592
+ try {
593
+ removeWorktree(worktreePath, false, repoRoot);
594
+ out.success('Worktree removed');
595
+ }
596
+ catch (error) {
597
+ out.error(`Failed to remove worktree: ${error.message}`);
598
+ return false;
599
+ }
600
+ // Delete branch if requested
601
+ if (options.deleteBranch) {
602
+ const currentBranch = getCurrentBranch(repoRoot);
603
+ if (branch === currentBranch) {
604
+ out.warn(`Cannot delete branch '${branch}' - it's currently checked out.`);
605
+ }
606
+ else {
607
+ out.log(`Deleting branch: ${out.fmt.branch(branch)}`);
608
+ try {
609
+ deleteBranch(branch, false, repoRoot);
610
+ out.success('Branch deleted');
611
+ }
612
+ catch (error) {
613
+ out.warn(`Failed to delete branch: ${error.message}`);
614
+ }
615
+ }
616
+ }
617
+ // Delete session manifest
618
+ if (sessionId) {
619
+ if (deleteSession(repoRoot, sessionId)) {
620
+ out.success('Session manifest removed');
621
+ }
622
+ }
623
+ return true;
624
+ }
625
+ export async function finish(options) {
626
+ const cwd = process.cwd();
627
+ // Check if we're in a git repo
628
+ if (!isGitRepo(cwd)) {
629
+ if (options.json) {
630
+ console.log(JSON.stringify({ error: 'Not a git repository' }));
631
+ process.exit(1);
632
+ }
633
+ fail(errors.notGitRepo());
634
+ }
635
+ // Try to find session manifest first
636
+ let session = findSessionForCwd(cwd);
637
+ let sessionId = null;
638
+ if (session) {
639
+ sessionId = session.id;
640
+ }
641
+ else {
642
+ // Fallback to git introspection
643
+ const detected = detectSessionFromGit(cwd);
644
+ if (!detected) {
645
+ if (options.json) {
646
+ console.log(JSON.stringify({ error: 'Could not detect session info' }));
647
+ process.exit(1);
648
+ }
649
+ fail(errors.sessionNotFound('current directory'));
650
+ }
651
+ session = detected;
652
+ }
653
+ // Build summary
654
+ let summary = buildSummary(session, sessionId);
655
+ // JSON mode - just output and exit
656
+ if (options.json) {
657
+ console.log(JSON.stringify(summary, null, 2));
658
+ return;
659
+ }
660
+ // Print summary
661
+ printSummary(summary);
662
+ const isDirty = !summary.status.clean;
663
+ const hasCommits = summary.commits.ahead > 0;
664
+ const dryRun = options.dryRun ?? false;
665
+ // Handle dirty state
666
+ if (isDirty) {
667
+ // Skip commit entirely if --skip-commit is set
668
+ if (options.skipCommit) {
669
+ out.log(out.fmt.dim('Skipping commit (--skip-commit).'));
670
+ }
671
+ else if (options.message) {
672
+ // Non-interactive commit with provided message
673
+ if (dryRun) {
674
+ out.log(out.fmt.dim(`[dry-run] Would stage all and commit with message: "${options.message}"`));
675
+ }
676
+ else {
677
+ const success = await runCommitWizard(cwd, summary.branch, summary.status, options.message);
678
+ if (!success) {
679
+ out.error('Commit failed.');
680
+ process.exit(1);
681
+ }
682
+ // Refresh summary
683
+ summary = buildSummary(session, sessionId);
684
+ }
685
+ }
686
+ else if (options.nonInteractive) {
687
+ if (options.allowDirty) {
688
+ out.warn('Working tree is dirty. Proceeding with --allow-dirty.');
689
+ }
690
+ else {
691
+ out.error('Working tree has uncommitted changes.');
692
+ out.info('Use --allow-dirty to proceed anyway, --message to commit, or --skip-commit to bypass.');
693
+ process.exit(1);
694
+ }
695
+ }
696
+ else {
697
+ // Interactive dirty handling
698
+ const action = await promptChoice('Working tree has uncommitted changes. What would you like to do?', [
699
+ { key: 'd', label: 'View diff', value: 'diff' },
700
+ { key: 'c', label: 'Commit changes', value: 'commit' },
701
+ { key: 'a', label: 'Abort', value: 'abort' },
702
+ ]);
703
+ if (action === 'diff') {
704
+ showDiff(cwd);
705
+ out.newline();
706
+ // After viewing diff, ask again
707
+ const nextAction = await promptChoice('What next?', [
708
+ { key: 'c', label: 'Commit changes', value: 'commit' },
709
+ { key: 'a', label: 'Abort', value: 'abort' },
710
+ ]);
711
+ if (nextAction === 'abort') {
712
+ out.log('Aborted.');
713
+ process.exit(0);
714
+ }
715
+ // Run commit wizard
716
+ const success = await runCommitWizard(cwd, summary.branch, summary.status);
717
+ if (!success) {
718
+ out.error('Commit failed or was cancelled.');
719
+ process.exit(1);
720
+ }
721
+ // Refresh summary
722
+ summary = buildSummary(session, sessionId);
723
+ }
724
+ else if (action === 'commit') {
725
+ const success = await runCommitWizard(cwd, summary.branch, summary.status);
726
+ if (!success) {
727
+ out.error('Commit failed or was cancelled.');
728
+ process.exit(1);
729
+ }
730
+ // Refresh summary
731
+ summary = buildSummary(session, sessionId);
732
+ }
733
+ else {
734
+ out.log('Aborted.');
735
+ process.exit(0);
736
+ }
737
+ }
738
+ }
739
+ // Check for commits to create PR
740
+ const updatedHasCommits = summary.commits.ahead > 0 || hasCommits;
741
+ const ghAvailable = isGhAvailable();
742
+ const hasOrigin = hasOriginRemote(cwd);
743
+ const canCreatePr = ghAvailable && hasOrigin;
744
+ if (updatedHasCommits || options.pr) {
745
+ out.newline();
746
+ if (dryRun) {
747
+ out.log(out.fmt.dim('[dry-run] Would push branch and create PR'));
748
+ }
749
+ else if (canCreatePr) {
750
+ // Push if needed
751
+ if (!isBranchPushed(summary.branch, cwd)) {
752
+ out.log(`Pushing branch: ${out.fmt.branch(summary.branch)}`);
753
+ if (!pushToOrigin(summary.branch, cwd)) {
754
+ fail(errors.pushFailed(summary.branch));
755
+ }
756
+ out.success('Branch pushed');
757
+ }
758
+ // Create PR
759
+ out.log('Creating pull request...');
760
+ const prResult = createPullRequest(summary.branch, summary.baseBranch, cwd);
761
+ if (prResult.success) {
762
+ out.success(`PR created: ${prResult.url}`);
763
+ }
764
+ else {
765
+ // PR might already exist
766
+ if (prResult.error?.includes('already exists')) {
767
+ out.info('A pull request already exists for this branch.');
768
+ }
769
+ else {
770
+ out.warn(`Could not create PR: ${prResult.error}`);
771
+ }
772
+ }
773
+ }
774
+ else if (!ghAvailable) {
775
+ printManualInstructions(summary);
776
+ // Exit 0 as requested - not an error
777
+ process.exit(0);
778
+ }
779
+ else if (!hasOrigin) {
780
+ printError(errors.noRemoteConfigured());
781
+ }
782
+ }
783
+ else {
784
+ out.log(out.fmt.dim('No commits to push.'));
785
+ }
786
+ // Cleanup prompt
787
+ if (options.noCleanup) {
788
+ // Skip cleanup entirely
789
+ out.newline();
790
+ out.success('Done (cleanup skipped)');
791
+ return;
792
+ }
793
+ if (options.cleanup) {
794
+ // Auto cleanup
795
+ out.newline();
796
+ await performCleanup(summary, options, dryRun);
797
+ out.newline();
798
+ out.success('Done');
799
+ return;
800
+ }
801
+ // Interactive cleanup prompt
802
+ if (!options.nonInteractive) {
803
+ out.newline();
804
+ const shouldCleanup = await confirm('Remove worktree and clean up?');
805
+ if (shouldCleanup) {
806
+ let deleteBranchChoice = options.deleteBranch ?? false;
807
+ if (!deleteBranchChoice) {
808
+ deleteBranchChoice = await confirm('Also delete the branch?');
809
+ }
810
+ const cleanupOpts = { ...options, deleteBranch: deleteBranchChoice };
811
+ await performCleanup(summary, cleanupOpts, dryRun);
812
+ }
813
+ }
814
+ out.newline();
815
+ out.success('Done');
816
+ }
817
+ //# sourceMappingURL=finish.js.map