@camaradesuk/git-worktree-tools 1.0.4 → 1.1.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 (168) hide show
  1. package/README.md +4 -0
  2. package/dist/cli/cleanpr.d.ts +1 -6
  3. package/dist/cli/cleanpr.d.ts.map +1 -1
  4. package/dist/cli/cleanpr.js +110 -302
  5. package/dist/cli/cleanpr.js.map +1 -1
  6. package/dist/cli/cleanpr.test.d.ts +2 -0
  7. package/dist/cli/cleanpr.test.d.ts.map +1 -0
  8. package/dist/cli/cleanpr.test.js +225 -0
  9. package/dist/cli/cleanpr.test.js.map +1 -0
  10. package/dist/cli/lswt.d.ts +1 -4
  11. package/dist/cli/lswt.d.ts.map +1 -1
  12. package/dist/cli/lswt.js +32 -251
  13. package/dist/cli/lswt.js.map +1 -1
  14. package/dist/cli/lswt.test.d.ts +2 -0
  15. package/dist/cli/lswt.test.d.ts.map +1 -0
  16. package/dist/cli/lswt.test.js +165 -0
  17. package/dist/cli/lswt.test.js.map +1 -0
  18. package/dist/cli/newpr.d.ts +1 -4
  19. package/dist/cli/newpr.d.ts.map +1 -1
  20. package/dist/cli/newpr.js +98 -493
  21. package/dist/cli/newpr.js.map +1 -1
  22. package/dist/cli/newpr.test.d.ts +2 -0
  23. package/dist/cli/newpr.test.d.ts.map +1 -0
  24. package/dist/cli/newpr.test.js +329 -0
  25. package/dist/cli/newpr.test.js.map +1 -0
  26. package/dist/cli/wtlink.test.d.ts +2 -0
  27. package/dist/cli/wtlink.test.d.ts.map +1 -0
  28. package/dist/cli/wtlink.test.js +148 -0
  29. package/dist/cli/wtlink.test.js.map +1 -0
  30. package/dist/e2e/cli.e2e.test.js +44 -1
  31. package/dist/e2e/cli.e2e.test.js.map +1 -1
  32. package/dist/integration/git.integration.test.js.map +1 -1
  33. package/dist/lib/cleanpr/args.d.ts +14 -0
  34. package/dist/lib/cleanpr/args.d.ts.map +1 -0
  35. package/dist/lib/cleanpr/args.js +82 -0
  36. package/dist/lib/cleanpr/args.js.map +1 -0
  37. package/dist/lib/cleanpr/args.test.d.ts +2 -0
  38. package/dist/lib/cleanpr/args.test.d.ts.map +1 -0
  39. package/dist/lib/cleanpr/args.test.js +192 -0
  40. package/dist/lib/cleanpr/args.test.js.map +1 -0
  41. package/dist/lib/cleanpr/cleanup.d.ts +61 -0
  42. package/dist/lib/cleanpr/cleanup.d.ts.map +1 -0
  43. package/dist/lib/cleanpr/cleanup.js +97 -0
  44. package/dist/lib/cleanpr/cleanup.js.map +1 -0
  45. package/dist/lib/cleanpr/cleanup.test.d.ts +2 -0
  46. package/dist/lib/cleanpr/cleanup.test.d.ts.map +1 -0
  47. package/dist/lib/cleanpr/cleanup.test.js +264 -0
  48. package/dist/lib/cleanpr/cleanup.test.js.map +1 -0
  49. package/dist/lib/cleanpr/index.d.ts +10 -0
  50. package/dist/lib/cleanpr/index.d.ts.map +1 -0
  51. package/dist/lib/cleanpr/index.js +8 -0
  52. package/dist/lib/cleanpr/index.js.map +1 -0
  53. package/dist/lib/cleanpr/types.d.ts +49 -0
  54. package/dist/lib/cleanpr/types.d.ts.map +1 -0
  55. package/dist/lib/cleanpr/types.js +5 -0
  56. package/dist/lib/cleanpr/types.js.map +1 -0
  57. package/dist/lib/cleanpr/worktree-info.d.ts +27 -0
  58. package/dist/lib/cleanpr/worktree-info.d.ts.map +1 -0
  59. package/dist/lib/cleanpr/worktree-info.js +95 -0
  60. package/dist/lib/cleanpr/worktree-info.js.map +1 -0
  61. package/dist/lib/cleanpr/worktree-info.test.d.ts +2 -0
  62. package/dist/lib/cleanpr/worktree-info.test.d.ts.map +1 -0
  63. package/dist/lib/cleanpr/worktree-info.test.js +160 -0
  64. package/dist/lib/cleanpr/worktree-info.test.js.map +1 -0
  65. package/dist/lib/colors.test.js +73 -0
  66. package/dist/lib/colors.test.js.map +1 -1
  67. package/dist/lib/config.test.js +79 -2
  68. package/dist/lib/config.test.js.map +1 -1
  69. package/dist/lib/git.d.ts.map +1 -1
  70. package/dist/lib/git.js +4 -3
  71. package/dist/lib/git.js.map +1 -1
  72. package/dist/lib/git.test.js +7 -7
  73. package/dist/lib/git.test.js.map +1 -1
  74. package/dist/lib/github.d.ts.map +1 -1
  75. package/dist/lib/github.js +4 -3
  76. package/dist/lib/github.js.map +1 -1
  77. package/dist/lib/github.test.js +2 -2
  78. package/dist/lib/github.test.js.map +1 -1
  79. package/dist/lib/lswt/args.d.ts +14 -0
  80. package/dist/lib/lswt/args.d.ts.map +1 -0
  81. package/dist/lib/lswt/args.js +67 -0
  82. package/dist/lib/lswt/args.js.map +1 -0
  83. package/dist/lib/lswt/args.test.d.ts +2 -0
  84. package/dist/lib/lswt/args.test.d.ts.map +1 -0
  85. package/dist/lib/lswt/args.test.js +135 -0
  86. package/dist/lib/lswt/args.test.js.map +1 -0
  87. package/dist/lib/lswt/formatters.d.ts +29 -0
  88. package/dist/lib/lswt/formatters.d.ts.map +1 -0
  89. package/dist/lib/lswt/formatters.js +113 -0
  90. package/dist/lib/lswt/formatters.js.map +1 -0
  91. package/dist/lib/lswt/formatters.test.d.ts +2 -0
  92. package/dist/lib/lswt/formatters.test.d.ts.map +1 -0
  93. package/dist/lib/lswt/formatters.test.js +233 -0
  94. package/dist/lib/lswt/formatters.test.js.map +1 -0
  95. package/dist/lib/lswt/index.d.ts +9 -0
  96. package/dist/lib/lswt/index.d.ts.map +1 -0
  97. package/dist/lib/lswt/index.js +9 -0
  98. package/dist/lib/lswt/index.js.map +1 -0
  99. package/dist/lib/lswt/types.d.ts +44 -0
  100. package/dist/lib/lswt/types.d.ts.map +1 -0
  101. package/dist/lib/lswt/types.js +5 -0
  102. package/dist/lib/lswt/types.js.map +1 -0
  103. package/dist/lib/lswt/worktree-info.d.ts +23 -0
  104. package/dist/lib/lswt/worktree-info.d.ts.map +1 -0
  105. package/dist/lib/lswt/worktree-info.js +81 -0
  106. package/dist/lib/lswt/worktree-info.js.map +1 -0
  107. package/dist/lib/lswt/worktree-info.test.d.ts +2 -0
  108. package/dist/lib/lswt/worktree-info.test.d.ts.map +1 -0
  109. package/dist/lib/lswt/worktree-info.test.js +190 -0
  110. package/dist/lib/lswt/worktree-info.test.js.map +1 -0
  111. package/dist/lib/newpr/actions.d.ts +56 -0
  112. package/dist/lib/newpr/actions.d.ts.map +1 -0
  113. package/dist/lib/newpr/actions.js +130 -0
  114. package/dist/lib/newpr/actions.js.map +1 -0
  115. package/dist/lib/newpr/actions.test.d.ts +2 -0
  116. package/dist/lib/newpr/actions.test.d.ts.map +1 -0
  117. package/dist/lib/newpr/actions.test.js +254 -0
  118. package/dist/lib/newpr/actions.test.js.map +1 -0
  119. package/dist/lib/newpr/args.d.ts +17 -0
  120. package/dist/lib/newpr/args.d.ts.map +1 -0
  121. package/dist/lib/newpr/args.js +123 -0
  122. package/dist/lib/newpr/args.js.map +1 -0
  123. package/dist/lib/newpr/args.test.d.ts +2 -0
  124. package/dist/lib/newpr/args.test.d.ts.map +1 -0
  125. package/dist/lib/newpr/args.test.js +271 -0
  126. package/dist/lib/newpr/args.test.js.map +1 -0
  127. package/dist/lib/newpr/index.d.ts +10 -0
  128. package/dist/lib/newpr/index.d.ts.map +1 -0
  129. package/dist/lib/newpr/index.js +8 -0
  130. package/dist/lib/newpr/index.js.map +1 -0
  131. package/dist/lib/newpr/scenario-handler.d.ts +40 -0
  132. package/dist/lib/newpr/scenario-handler.d.ts.map +1 -0
  133. package/dist/lib/newpr/scenario-handler.js +261 -0
  134. package/dist/lib/newpr/scenario-handler.js.map +1 -0
  135. package/dist/lib/newpr/scenario-handler.test.d.ts +2 -0
  136. package/dist/lib/newpr/scenario-handler.test.d.ts.map +1 -0
  137. package/dist/lib/newpr/scenario-handler.test.js +250 -0
  138. package/dist/lib/newpr/scenario-handler.test.js.map +1 -0
  139. package/dist/lib/newpr/types.d.ts +76 -0
  140. package/dist/lib/newpr/types.d.ts.map +1 -0
  141. package/dist/lib/newpr/types.js +5 -0
  142. package/dist/lib/newpr/types.js.map +1 -0
  143. package/dist/lib/state-detection.d.ts.map +1 -1
  144. package/dist/lib/state-detection.js +12 -3
  145. package/dist/lib/state-detection.js.map +1 -1
  146. package/dist/lib/state-detection.test.js +253 -2
  147. package/dist/lib/state-detection.test.js.map +1 -1
  148. package/dist/lib/wtlink/link-configs.d.ts +40 -0
  149. package/dist/lib/wtlink/link-configs.d.ts.map +1 -1
  150. package/dist/lib/wtlink/link-configs.js +126 -11
  151. package/dist/lib/wtlink/link-configs.js.map +1 -1
  152. package/dist/lib/wtlink/link-configs.test.js +237 -84
  153. package/dist/lib/wtlink/link-configs.test.js.map +1 -1
  154. package/dist/lib/wtlink/manage-manifest.d.ts +86 -0
  155. package/dist/lib/wtlink/manage-manifest.d.ts.map +1 -1
  156. package/dist/lib/wtlink/manage-manifest.js +18 -15
  157. package/dist/lib/wtlink/manage-manifest.js.map +1 -1
  158. package/dist/lib/wtlink/manage-manifest.test.d.ts +2 -0
  159. package/dist/lib/wtlink/manage-manifest.test.d.ts.map +1 -0
  160. package/dist/lib/wtlink/manage-manifest.test.js +383 -0
  161. package/dist/lib/wtlink/manage-manifest.test.js.map +1 -0
  162. package/dist/lib/wtlink/validate-manifest.d.ts +27 -0
  163. package/dist/lib/wtlink/validate-manifest.d.ts.map +1 -1
  164. package/dist/lib/wtlink/validate-manifest.js +103 -28
  165. package/dist/lib/wtlink/validate-manifest.js.map +1 -1
  166. package/dist/lib/wtlink/validate-manifest.test.js +170 -65
  167. package/dist/lib/wtlink/validate-manifest.test.js.map +1 -1
  168. package/package.json +1 -1
package/dist/cli/newpr.js CHANGED
@@ -2,10 +2,7 @@
2
2
  /**
3
3
  * newpr - Create or setup a PR with a dedicated worktree
4
4
  *
5
- * Usage:
6
- * newpr "feature description" Create new branch + PR + worktree
7
- * newpr --pr <NUMBER> Setup worktree for existing PR
8
- * newpr --branch <NAME> Create PR for existing branch + worktree
5
+ * CLI thin wrapper - orchestration and side effects only
9
6
  */
10
7
  import path from 'path';
11
8
  import fs from 'fs';
@@ -14,137 +11,28 @@ import * as github from '../lib/github.js';
14
11
  import * as colors from '../lib/colors.js';
15
12
  import { promptChoiceIndex } from '../lib/prompts.js';
16
13
  import { loadConfig, generateBranchName, generateWorktreePath, } from '../lib/config.js';
17
- import { analyzeGitState, detectScenario, } from '../lib/state-detection.js';
14
+ import { analyzeGitState, detectScenario } from '../lib/state-detection.js';
15
+ import { parseArgs, getHelpText, getScenarioContext, isPrWorktreeScenario, isExistingBranchAction, executeStateAction, getBranchPoint, getScenarioMessageLevel, } from '../lib/newpr/index.js';
18
16
  /**
19
- * Parse command line arguments
17
+ * Create action dependencies using real git operations
20
18
  */
21
- function parseArgs(args) {
22
- const options = {
23
- mode: 'new',
24
- baseBranch: 'main',
25
- draft: true,
26
- installDeps: false,
27
- openEditor: false,
28
- runWtlink: true,
19
+ function createActionDeps(cwd) {
20
+ return {
21
+ gitAdd: (addPath, cwdPath) => git.add(addPath, cwdPath ?? cwd),
22
+ gitStash: (options, cwdPath) => git.stash({ message: options.message, keepIndex: options.keepIndex }, cwdPath ?? cwd),
23
+ gitPush: (options, cwdPath) => git.push({ remote: options.remote, branch: options.branch, setUpstream: options.setUpstream }, cwdPath ?? cwd),
24
+ gitCommit: (options, cwdPath) => git.commit({ message: options.message, allowEmpty: options.allowEmpty }, cwdPath ?? cwd),
29
25
  };
30
- let i = 0;
31
- while (i < args.length) {
32
- const arg = args[i];
33
- switch (arg) {
34
- case '-h':
35
- case '--help':
36
- printHelp();
37
- process.exit(0);
38
- break;
39
- case '--pr':
40
- case '-p':
41
- options.mode = 'pr';
42
- i++;
43
- if (!args[i] || args[i].startsWith('-')) {
44
- console.error(colors.error('--pr requires a PR number'));
45
- process.exit(1);
46
- }
47
- options.prNumber = parseInt(args[i], 10);
48
- if (isNaN(options.prNumber)) {
49
- console.error(colors.error('PR number must be numeric'));
50
- process.exit(1);
51
- }
52
- break;
53
- case '--branch':
54
- case '-B':
55
- options.mode = 'branch';
56
- i++;
57
- if (!args[i] || args[i].startsWith('-')) {
58
- console.error(colors.error('--branch requires a branch name'));
59
- process.exit(1);
60
- }
61
- options.branchName = args[i];
62
- break;
63
- case '-b':
64
- case '--base':
65
- i++;
66
- if (!args[i] || args[i].startsWith('-')) {
67
- console.error(colors.error('--base requires a branch name'));
68
- process.exit(1);
69
- }
70
- options.baseBranch = args[i];
71
- break;
72
- case '-i':
73
- case '--install':
74
- options.installDeps = true;
75
- break;
76
- case '-c':
77
- case '--code':
78
- options.openEditor = true;
79
- break;
80
- case '-r':
81
- case '--ready':
82
- options.draft = false;
83
- break;
84
- case '--no-wtlink':
85
- options.runWtlink = false;
86
- break;
87
- default:
88
- if (arg.startsWith('-')) {
89
- console.error(colors.error(`Unknown option: ${arg}`));
90
- process.exit(1);
91
- }
92
- // Positional argument = description
93
- if (!options.description && options.mode === 'new') {
94
- options.description = arg;
95
- }
96
- else {
97
- console.error(colors.error(`Unexpected argument: ${arg}`));
98
- process.exit(1);
99
- }
100
- }
101
- i++;
102
- }
103
- // Validate
104
- if (options.mode === 'new' && !options.description) {
105
- console.error(colors.error('Description required. Usage: newpr "feature description"'));
106
- process.exit(1);
107
- }
108
- return options;
109
- }
110
- /**
111
- * Print help message
112
- */
113
- function printHelp() {
114
- console.log(`
115
- ${colors.bold('newpr')} - Create or setup a PR with a dedicated worktree
116
-
117
- ${colors.bold('Usage:')}
118
- newpr "description" Create new branch + PR + worktree
119
- newpr --pr <NUMBER> Setup worktree for existing PR
120
- newpr --branch <NAME> Create PR for existing branch + worktree
121
-
122
- ${colors.bold('Options:')}
123
- -b, --base BRANCH Base branch for PR (default: main)
124
- -i, --install Install dependencies after setup
125
- -c, --code Open editor to the new worktree
126
- -r, --ready Create PR as ready for review (default: draft)
127
- --no-wtlink Skip wtlink config sync
128
- -h, --help Show this help message
129
-
130
- ${colors.bold('Examples:')}
131
- newpr "Add user authentication"
132
- newpr "Fix login bug" --install --code
133
- newpr --pr 1234
134
- newpr --branch feat/my-feature
135
- `);
136
26
  }
137
27
  /**
138
28
  * Check prerequisites
139
29
  */
140
30
  function checkPrerequisites() {
141
31
  console.log(colors.info('Checking prerequisites...'));
142
- // Check gh CLI
143
32
  if (!github.isGhInstalled()) {
144
33
  console.error(colors.error('GitHub CLI (gh) is required. See: https://cli.github.com'));
145
34
  process.exit(1);
146
35
  }
147
- // Check gh auth
148
36
  if (!github.isAuthenticated()) {
149
37
  console.error(colors.error('GitHub CLI not authenticated. Run: gh auth login'));
150
38
  process.exit(1);
@@ -205,330 +93,82 @@ function showUnstagedChanges(cwd) {
205
93
  /**
206
94
  * Handle scenario and return action to take
207
95
  */
208
- async function handleScenario(scenario, state, baseBranch) {
209
- const defaultAction = {
210
- action: 'empty_commit',
211
- branchFrom: 'origin_main',
212
- stashUnstaged: false,
213
- };
214
- switch (scenario) {
215
- case 'main_clean_same': {
216
- // Scenario 1: On main, same as origin/main, clean
217
- console.log(colors.warning('No changes detected from main branch.'));
218
- console.log();
219
- console.log("You are on 'main' with no local commits or uncommitted changes.");
220
- console.log('A PR requires at least one commit difference from the base branch.');
221
- const choice = await promptChoiceIndex('How would you like to proceed?', [
222
- 'Continue with empty initial commit',
223
- "Cancel - I'll make some changes first",
224
- ]);
225
- if (choice === 1) {
226
- return { action: 'empty_commit', branchFrom: 'origin_main', stashUnstaged: false };
227
- }
228
- return null;
229
- }
230
- case 'main_staged_same': {
231
- // Scenario 2a: On main, same as origin/main, staged changes only
232
- console.log(colors.info('You have staged changes ready to commit:'));
233
- showStagedChanges();
234
- const choice = await promptChoiceIndex('How would you like to proceed?', [
235
- 'Commit staged changes to the new PR branch',
236
- 'Leave changes here and continue with empty initial commit',
237
- 'Cancel',
238
- ]);
239
- switch (choice) {
240
- case 1:
241
- return { action: 'commit_staged', branchFrom: 'origin_main', stashUnstaged: false };
242
- case 2:
243
- return { action: 'empty_commit', branchFrom: 'origin_main', stashUnstaged: false };
244
- default:
245
- return null;
246
- }
247
- }
248
- case 'main_unstaged_same': {
249
- // Scenario 2b: On main, same as origin/main, unstaged changes only
250
- console.log(colors.info('You have unstaged changes:'));
251
- showUncommittedChanges();
252
- const choice = await promptChoiceIndex('How would you like to proceed?', [
253
- 'Stage all and commit to the new PR branch',
254
- 'Leave changes here and continue with empty initial commit',
255
- 'Stash changes (will restore after)',
256
- 'Cancel',
257
- ]);
258
- switch (choice) {
259
- case 1:
260
- return { action: 'commit_all', branchFrom: 'origin_main', stashUnstaged: false };
261
- case 2:
262
- return { action: 'empty_commit', branchFrom: 'origin_main', stashUnstaged: false };
263
- case 3:
264
- return { action: 'stash_and_empty', branchFrom: 'origin_main', stashUnstaged: false };
265
- default:
266
- return null;
267
- }
268
- }
269
- case 'main_both_same': {
270
- // Scenario 2c: On main, same as origin/main, both staged and unstaged
271
- console.log(colors.info('You have both staged and unstaged changes:'));
272
- showStagedChanges();
273
- showUnstagedChanges();
274
- const choice = await promptChoiceIndex('How would you like to proceed?', [
275
- 'Commit staged to PR branch, move unstaged to new worktree',
276
- 'Stage all and commit everything to the new PR branch',
277
- 'Leave all changes here and continue with empty initial commit',
278
- 'Stash all changes (will restore after)',
279
- 'Cancel',
280
- ]);
281
- switch (choice) {
282
- case 1:
283
- return { action: 'commit_staged', branchFrom: 'origin_main', stashUnstaged: true };
284
- case 2:
285
- return { action: 'commit_all', branchFrom: 'origin_main', stashUnstaged: false };
286
- case 3:
287
- return { action: 'empty_commit', branchFrom: 'origin_main', stashUnstaged: false };
288
- case 4:
289
- return { action: 'stash_and_empty', branchFrom: 'origin_main', stashUnstaged: false };
290
- default:
291
- return null;
292
- }
293
- }
294
- case 'main_clean_ahead': {
295
- // Scenario 3: On main, ahead of origin/main, clean
296
- console.log(colors.info("You have local commits on 'main' not yet pushed:"));
297
- showLocalCommits(baseBranch);
298
- console.log();
299
- console.log('These commits will NOT be included in the new PR branch by default.');
300
- const choice = await promptChoiceIndex('How would you like to proceed?', [
301
- 'Use these commits for the PR (create branch from HEAD)',
302
- 'Push commits to origin/main first, then create PR branch',
303
- 'Start fresh from origin/main (ignore local commits)',
304
- 'Cancel',
305
- ]);
306
- switch (choice) {
307
- case 1:
308
- return { action: 'use_commits', branchFrom: 'head', stashUnstaged: false };
309
- case 2:
310
- return { action: 'push_then_branch', branchFrom: 'origin_main', stashUnstaged: false };
311
- case 3:
312
- return { action: 'empty_commit', branchFrom: 'origin_main', stashUnstaged: false };
313
- default:
314
- return null;
315
- }
316
- }
317
- case 'main_changes_ahead': {
318
- // Scenario 4: On main, ahead of origin/main, has changes
319
- console.log(colors.info('You have local commits AND uncommitted changes:'));
320
- console.log();
321
- console.log('Local commits (not pushed):');
322
- showLocalCommits(baseBranch);
323
- console.log();
324
- console.log('Uncommitted changes:');
325
- showUncommittedChanges();
326
- const choice = await promptChoiceIndex('How would you like to proceed?', [
327
- 'Include commits + commit uncommitted changes to PR branch',
328
- 'Include commits only, stash uncommitted changes',
329
- 'Start fresh from origin/main (ignore all local work)',
330
- 'Cancel',
331
- ]);
332
- switch (choice) {
333
- case 1:
334
- return { action: 'use_commits_and_commit_all', branchFrom: 'head', stashUnstaged: false };
335
- case 2:
336
- return { action: 'use_commits_and_stash', branchFrom: 'head', stashUnstaged: false };
337
- case 3:
338
- return { action: 'empty_commit', branchFrom: 'origin_main', stashUnstaged: false };
339
- default:
340
- return null;
341
- }
96
+ async function handleScenario(state, baseBranch) {
97
+ let scenario = detectScenario(state);
98
+ // Handle pr_worktree scenario - re-analyze after warning
99
+ if (isPrWorktreeScenario(scenario)) {
100
+ console.log(colors.warning('You are in a PR worktree, not the main worktree.'));
101
+ console.log();
102
+ console.log('Creating a new PR is best done from the main worktree.');
103
+ const choice = await promptChoiceIndex('How would you like to proceed?', [
104
+ "Continue anyway (create PR from this worktree's state)",
105
+ "Cancel - I'll switch to the main worktree",
106
+ ]);
107
+ if (choice === 1) {
108
+ // Re-analyze and get new scenario
109
+ const newState = analyzeGitState(baseBranch);
110
+ scenario = detectScenario(newState);
342
111
  }
343
- case 'branch_same_as_main': {
344
- // Scenario 5: On different branch, same commit as main
345
- const branch = state.currentBranch || 'unknown';
346
- console.log(colors.warning(`Branch '${branch}' is at the same commit as main.`));
347
- console.log();
348
- console.log('No divergent commits detected. A PR requires at least one commit difference.');
349
- const choice = await promptChoiceIndex('How would you like to proceed?', [
350
- 'Continue with empty initial commit (new branch from main)',
351
- 'Cancel',
352
- ]);
353
- if (choice === 1) {
354
- return { action: 'empty_commit', branchFrom: 'origin_main', stashUnstaged: false };
355
- }
112
+ else {
356
113
  return null;
357
114
  }
358
- case 'branch_ancestor': {
359
- // Scenario 6: On different branch, already merged
360
- const branch = state.currentBranch || 'unknown';
361
- const shortSha = git.getShortCommit();
362
- console.log(colors.warning(`Branch '${branch}' appears to be already merged into main.`));
115
+ }
116
+ const context = getScenarioContext(scenario, state, baseBranch);
117
+ if (!context) {
118
+ // Shouldn't happen if pr_worktree is handled above
119
+ return null;
120
+ }
121
+ // Display scenario message
122
+ const level = getScenarioMessageLevel(scenario);
123
+ if (level === 'warning') {
124
+ console.log(colors.warning(context.message));
125
+ }
126
+ else {
127
+ console.log(colors.info(context.message));
128
+ }
129
+ if (context.subMessage) {
130
+ console.log();
131
+ console.log(context.subMessage);
132
+ }
133
+ // Show relevant changes based on scenario
134
+ if (scenario === 'main_staged_same') {
135
+ showStagedChanges();
136
+ }
137
+ else if (scenario === 'main_unstaged_same') {
138
+ showUncommittedChanges();
139
+ }
140
+ else if (scenario === 'main_both_same') {
141
+ showStagedChanges();
142
+ showUnstagedChanges();
143
+ }
144
+ else if (scenario === 'main_clean_ahead' || scenario === 'branch_divergent') {
145
+ showLocalCommits(baseBranch);
146
+ }
147
+ else if (scenario === 'main_changes_ahead') {
148
+ console.log();
149
+ console.log('Local commits (not pushed):');
150
+ showLocalCommits(baseBranch);
151
+ console.log();
152
+ console.log('Uncommitted changes:');
153
+ showUncommittedChanges();
154
+ }
155
+ else if (scenario === 'branch_with_changes') {
156
+ showUncommittedChanges();
157
+ if (state.localCommits.length > 0) {
363
158
  console.log();
364
- console.log(`Current commit (${shortSha}) is an ancestor of origin/main.`);
365
- console.log('Creating a PR would result in no changes.');
366
- const choice = await promptChoiceIndex('How would you like to proceed?', [
367
- 'Continue with empty initial commit (new branch from main)',
368
- "Cancel - I'll check the branch status first",
369
- ]);
370
- if (choice === 1) {
371
- return { action: 'empty_commit', branchFrom: 'origin_main', stashUnstaged: false };
372
- }
373
- return null;
374
- }
375
- case 'branch_divergent': {
376
- // Scenario 7: On different branch, divergent commits
377
- const branch = state.currentBranch || 'unknown';
378
- console.log(colors.info(`You are on branch '${branch}' with commits not in main:`));
159
+ console.log('Branch also has commits not in main:');
379
160
  showLocalCommits(baseBranch);
380
- const choice = await promptChoiceIndex('How would you like to proceed?', [
381
- `Create PR for THIS branch (${branch} → main)`,
382
- "Create NEW branch from main (ignore current branch's commits)",
383
- 'Cancel',
384
- ]);
385
- switch (choice) {
386
- case 1:
387
- return { action: 'create_pr_for_branch', branchFrom: 'head', stashUnstaged: false };
388
- case 2:
389
- return { action: 'empty_commit', branchFrom: 'origin_main', stashUnstaged: false };
390
- default:
391
- return null;
392
- }
393
- }
394
- case 'branch_with_changes': {
395
- // Scenario 8: On different branch with uncommitted changes
396
- const branch = state.currentBranch || 'unknown';
397
- console.log(colors.info(`You are on branch '${branch}' with uncommitted changes:`));
398
- showUncommittedChanges();
399
- // Check if branch has divergent commits
400
- const hasDivergent = state.localCommits.length > 0;
401
- if (hasDivergent) {
402
- console.log();
403
- console.log('Branch also has commits not in main:');
404
- showLocalCommits(baseBranch);
405
- const choice = await promptChoiceIndex('How would you like to proceed?', [
406
- 'Create PR for THIS branch, commit changes first',
407
- 'Create PR for THIS branch, stash uncommitted changes',
408
- 'Create NEW branch from main (ignore current branch)',
409
- 'Cancel',
410
- ]);
411
- switch (choice) {
412
- case 1:
413
- return { action: 'pr_for_branch_commit_all', branchFrom: 'head', stashUnstaged: false };
414
- case 2:
415
- return { action: 'pr_for_branch_stash', branchFrom: 'head', stashUnstaged: false };
416
- case 3:
417
- return { action: 'empty_commit', branchFrom: 'origin_main', stashUnstaged: false };
418
- default:
419
- return null;
420
- }
421
- }
422
- else {
423
- const choice = await promptChoiceIndex('How would you like to proceed?', [
424
- 'Stage all and commit to a new PR branch',
425
- 'Leave changes and continue with empty initial commit',
426
- 'Stash changes (will restore after)',
427
- 'Cancel',
428
- ]);
429
- switch (choice) {
430
- case 1:
431
- return { action: 'commit_all', branchFrom: 'origin_main', stashUnstaged: false };
432
- case 2:
433
- return { action: 'empty_commit', branchFrom: 'origin_main', stashUnstaged: false };
434
- case 3:
435
- return { action: 'stash_and_empty', branchFrom: 'origin_main', stashUnstaged: false };
436
- default:
437
- return null;
438
- }
439
- }
440
- }
441
- case 'detached_head': {
442
- // Scenario 9: Detached HEAD
443
- const shortSha = git.getShortCommit();
444
- console.log(colors.warning(`You are in detached HEAD state at commit ${shortSha}.`));
445
- const choice = await promptChoiceIndex('How would you like to proceed?', [
446
- 'Create branch from this commit',
447
- 'Create branch from origin/main',
448
- 'Cancel',
449
- ]);
450
- switch (choice) {
451
- case 1:
452
- return { action: 'branch_from_detached', branchFrom: 'head', stashUnstaged: false };
453
- case 2:
454
- return { action: 'empty_commit', branchFrom: 'origin_main', stashUnstaged: false };
455
- default:
456
- return null;
457
- }
458
- }
459
- case 'pr_worktree': {
460
- // Scenario 10: Running from PR worktree
461
- console.log(colors.warning('You are in a PR worktree, not the main worktree.'));
462
- console.log();
463
- console.log('Creating a new PR is best done from the main worktree.');
464
- const choice = await promptChoiceIndex('How would you like to proceed?', [
465
- "Continue anyway (create PR from this worktree's state)",
466
- "Cancel - I'll switch to the main worktree",
467
- ]);
468
- if (choice === 1) {
469
- // Analyze actual state and recurse
470
- const newState = analyzeGitState(baseBranch);
471
- const newScenario = detectScenario(newState);
472
- return handleScenario(newScenario, newState, baseBranch);
473
- }
474
- return null;
475
161
  }
476
- default:
477
- return defaultAction;
478
- }
479
- }
480
- /**
481
- * Execute state action
482
- */
483
- function executeStateAction(action, description, branchName, cwd) {
484
- let stashRef = null;
485
- switch (action.action) {
486
- case 'empty_commit':
487
- // No action needed before branch creation
488
- break;
489
- case 'commit_staged':
490
- // Will commit staged changes after creating branch
491
- break;
492
- case 'commit_all':
493
- console.log(colors.info('Staging all changes...'));
494
- git.add('.', cwd);
495
- break;
496
- case 'stash_and_empty':
497
- console.log(colors.info('Stashing all changes...'));
498
- stashRef = git.stash({ message: `newpr: auto-stash before creating ${branchName}` }, cwd);
499
- break;
500
- case 'use_commits':
501
- case 'branch_from_detached':
502
- // Branch from HEAD instead of origin/main
503
- break;
504
- case 'use_commits_and_commit_all':
505
- console.log(colors.info('Staging all uncommitted changes...'));
506
- git.add('.', cwd);
507
- break;
508
- case 'use_commits_and_stash':
509
- console.log(colors.info('Stashing uncommitted changes...'));
510
- stashRef = git.stash({ message: `newpr: auto-stash before creating ${branchName}` }, cwd);
511
- break;
512
- case 'push_then_branch':
513
- console.log(colors.info('Pushing local commits to origin/main...'));
514
- git.push({ remote: 'origin', branch: 'main' }, cwd);
515
- break;
516
- case 'pr_for_branch_commit_all':
517
- console.log(colors.info('Staging and committing all changes to current branch...'));
518
- git.add('.', cwd);
519
- git.commit({ message: 'chore: work in progress\n\nšŸ¤– Committed with newpr' }, cwd);
520
- break;
521
- case 'pr_for_branch_stash':
522
- console.log(colors.info('Stashing uncommitted changes...'));
523
- stashRef = git.stash({ message: 'newpr: auto-stash before creating PR' }, cwd);
524
- break;
525
162
  }
526
- return stashRef;
163
+ // Prompt user for choice
164
+ const choiceLabels = context.choices.map((c) => c.label);
165
+ const choiceIndex = await promptChoiceIndex('How would you like to proceed?', choiceLabels);
166
+ return context.choices[choiceIndex].action;
527
167
  }
528
168
  /**
529
169
  * Setup worktree (symlinks, wtlink, deps)
530
170
  */
531
- async function setupWorktree(worktreePath, config, options) {
171
+ async function setupWorktree(worktreePath, config, _options) {
532
172
  const repoRoot = git.getRepoRoot();
533
173
  const parentDir = path.dirname(repoRoot);
534
174
  // Create symlinks for shared repos
@@ -556,12 +196,6 @@ async function setupWorktree(worktreePath, config, options) {
556
196
  }
557
197
  }
558
198
  }
559
- // TODO: Run wtlink if available
560
- // if (options.runWtlink) { ... }
561
- // TODO: Install dependencies if requested
562
- // if (options.installDeps) { ... }
563
- // TODO: Open editor if requested
564
- // if (options.openEditor) { ... }
565
199
  }
566
200
  /**
567
201
  * Print summary
@@ -588,7 +222,6 @@ async function modeExistingPr(prNumber, options) {
588
222
  const repoRoot = git.getRepoRoot();
589
223
  const repoName = git.getRepoName(repoRoot);
590
224
  const config = loadConfig(repoRoot);
591
- // Get PR info
592
225
  const pr = github.getPr(prNumber);
593
226
  if (!pr) {
594
227
  console.error(colors.error(`Could not find PR #${prNumber}`));
@@ -598,16 +231,13 @@ async function modeExistingPr(prNumber, options) {
598
231
  console.log(colors.warning(`PR #${prNumber} is ${pr.state}`));
599
232
  }
600
233
  console.log(colors.info(`PR branch: ${pr.headBranch}`));
601
- // Generate worktree path
602
234
  const worktreePath = generateWorktreePath(config, repoRoot, repoName, prNumber);
603
235
  if (fs.existsSync(worktreePath)) {
604
236
  console.error(colors.error(`Worktree already exists: ${worktreePath}`));
605
237
  process.exit(1);
606
238
  }
607
- // Fetch the branch
608
239
  console.log(colors.info('Fetching branch from origin...'));
609
240
  git.fetch('origin');
610
- // Create worktree
611
241
  console.log(colors.info(`Creating worktree at ${worktreePath}...`));
612
242
  try {
613
243
  git.addWorktree(worktreePath, pr.headBranch, {
@@ -616,13 +246,10 @@ async function modeExistingPr(prNumber, options) {
616
246
  });
617
247
  }
618
248
  catch {
619
- // Branch might already exist locally
620
249
  git.addWorktree(worktreePath, pr.headBranch);
621
250
  }
622
251
  console.log(colors.success(`Created worktree: ${worktreePath}`));
623
- // Setup worktree
624
252
  await setupWorktree(worktreePath, config, options);
625
- // Print summary
626
253
  printSummary(prNumber, pr.headBranch, worktreePath, pr.url);
627
254
  }
628
255
  /**
@@ -633,12 +260,9 @@ async function modeExistingBranch(branchName, options) {
633
260
  const repoRoot = git.getRepoRoot();
634
261
  const repoName = git.getRepoName(repoRoot);
635
262
  const config = loadConfig(repoRoot);
636
- // Fetch latest
637
263
  console.log(colors.info('Fetching latest from origin...'));
638
264
  git.fetch('origin');
639
- // Check if branch exists on remote
640
265
  if (!git.remoteBranchExists(branchName)) {
641
- // Check if it exists locally
642
266
  if (git.branchExists(branchName)) {
643
267
  console.log(colors.info('Branch exists locally, pushing to origin...'));
644
268
  git.push({ setUpstream: true, remote: 'origin', branch: branchName });
@@ -648,16 +272,13 @@ async function modeExistingBranch(branchName, options) {
648
272
  process.exit(1);
649
273
  }
650
274
  }
651
- // Check if PR already exists
652
275
  const existingPr = github.getPrByBranch(branchName);
653
276
  if (existingPr) {
654
277
  console.log(colors.info(`PR #${existingPr.number} already exists for branch ${branchName}`));
655
278
  await modeExistingPr(existingPr.number, options);
656
279
  return;
657
280
  }
658
- // Create PR
659
281
  console.log(colors.info('Creating pull request...'));
660
- // Generate title from branch name
661
282
  const title = branchName
662
283
  .replace(/^(feat|fix|chore)\//, '')
663
284
  .replace(/-/g, ' ')
@@ -683,9 +304,7 @@ PR created from existing branch: \`${branchName}\`
683
304
  draft: options.draft,
684
305
  });
685
306
  console.log(colors.success(`Created PR #${pr.number}: ${pr.url}`));
686
- // Generate worktree path
687
307
  const worktreePath = generateWorktreePath(config, repoRoot, repoName, pr.number);
688
- // Create worktree
689
308
  console.log(colors.info(`Creating worktree at ${worktreePath}...`));
690
309
  try {
691
310
  git.addWorktree(worktreePath, branchName, {
@@ -697,9 +316,7 @@ PR created from existing branch: \`${branchName}\`
697
316
  git.addWorktree(worktreePath, branchName);
698
317
  }
699
318
  console.log(colors.success(`Created worktree: ${worktreePath}`));
700
- // Setup worktree
701
319
  await setupWorktree(worktreePath, config, options);
702
- // Print summary
703
320
  printSummary(pr.number, branchName, worktreePath, pr.url);
704
321
  }
705
322
  /**
@@ -709,9 +326,7 @@ async function modeNewFeature(description, options) {
709
326
  const repoRoot = git.getRepoRoot();
710
327
  const repoName = git.getRepoName(repoRoot);
711
328
  const config = loadConfig(repoRoot);
712
- // Generate branch name
713
329
  const branchName = generateBranchName(config, description);
714
- // Fetch latest
715
330
  console.log(colors.info('Fetching latest from origin...'));
716
331
  try {
717
332
  git.fetch('origin');
@@ -719,37 +334,29 @@ async function modeNewFeature(description, options) {
719
334
  catch {
720
335
  console.log(colors.warning('Could not fetch from origin (network unavailable?)'));
721
336
  }
722
- // Analyze git state
723
337
  const state = analyzeGitState(options.baseBranch);
724
- const scenario = detectScenario(state);
725
- // Handle scenario and get action
726
- const action = await handleScenario(scenario, state, options.baseBranch);
338
+ const action = await handleScenario(state, options.baseBranch);
727
339
  if (!action) {
728
340
  console.log(colors.error('Aborted by user.'));
729
341
  process.exit(1);
730
342
  }
731
343
  // Handle special case: create PR for existing branch
732
- if (action.action === 'create_pr_for_branch' ||
733
- action.action === 'pr_for_branch_commit_all' ||
734
- action.action === 'pr_for_branch_stash') {
344
+ if (isExistingBranchAction(action)) {
735
345
  const currentBranch = state.currentBranch;
736
346
  if (!currentBranch) {
737
347
  console.error(colors.error('Cannot determine current branch'));
738
348
  process.exit(1);
739
349
  }
740
- // Execute action for current branch
741
- executeStateAction(action, description, currentBranch);
742
- // Push if needed
350
+ const deps = createActionDeps();
351
+ executeStateAction(action, description, currentBranch, deps);
743
352
  if (!git.remoteBranchExists(currentBranch)) {
744
353
  console.log(colors.info('Pushing branch to origin...'));
745
354
  git.push({ setUpstream: true, remote: 'origin', branch: currentBranch });
746
355
  }
747
- // Delegate to existing branch mode
748
356
  await modeExistingBranch(currentBranch, options);
749
357
  return;
750
358
  }
751
359
  console.log(colors.info(`Creating feature branch: ${branchName}`));
752
- // Check if branch already exists on remote
753
360
  if (git.remoteBranchExists(branchName)) {
754
361
  console.log(colors.warning(`Branch ${branchName} already exists on remote`));
755
362
  const existingPr = github.getPrByBranch(branchName);
@@ -763,10 +370,13 @@ async function modeNewFeature(description, options) {
763
370
  }
764
371
  return;
765
372
  }
766
- // Save original branch
767
373
  const originalBranch = git.getCurrentBranch() || 'main';
768
- // Execute pre-branch actions
769
- const stashRef = executeStateAction(action, description, branchName);
374
+ const deps = createActionDeps();
375
+ const actionResult = executeStateAction(action, description, branchName, deps);
376
+ if (!actionResult.success) {
377
+ console.error(colors.error(`Action failed: ${actionResult.message}`));
378
+ process.exit(1);
379
+ }
770
380
  // Stash unstaged changes if needed
771
381
  let unstagedStashRef = null;
772
382
  if (action.stashUnstaged) {
@@ -777,31 +387,24 @@ async function modeNewFeature(description, options) {
777
387
  });
778
388
  }
779
389
  try {
780
- // Determine branch point
781
- const branchFrom = action.branchFrom === 'head' ? 'HEAD' : `origin/${options.baseBranch}`;
390
+ const branchFrom = getBranchPoint(action, options.baseBranch);
782
391
  console.log(colors.info(`Creating branch from ${branchFrom}...`));
783
- // Create and checkout new branch
784
392
  git.exec(['checkout', '-b', branchName, branchFrom]);
785
- // Create initial commit
786
393
  const stagedFiles = git.getStagedFiles();
787
394
  if (stagedFiles.length > 0) {
788
395
  console.log(colors.info('Committing staged changes...'));
789
396
  git.commit({ message: `feat: ${description}\n\nšŸ¤– Created with newpr` });
790
397
  }
791
398
  else if (action.branchFrom === 'origin_main') {
792
- // No commits ahead and no staged changes - create empty commit
793
399
  console.log(colors.info('Creating initial commit (required for PR creation)...'));
794
400
  git.commit({
795
401
  message: `chore: initialize ${branchName}\n\nBranch created for: ${description}\n\nšŸ¤– Created with newpr`,
796
402
  allowEmpty: true,
797
403
  });
798
404
  }
799
- // Push branch
800
405
  console.log(colors.info('Pushing branch to origin...'));
801
406
  git.push({ setUpstream: true, remote: 'origin', branch: branchName });
802
- // Switch back to original branch
803
407
  git.checkout(originalBranch);
804
- // Create PR
805
408
  console.log(colors.info('Creating pull request...'));
806
409
  const pr = github.createPr({
807
410
  title: description,
@@ -824,13 +427,10 @@ ${description}
824
427
  draft: options.draft,
825
428
  });
826
429
  console.log(colors.success(`Created PR #${pr.number}: ${pr.url}`));
827
- // Generate worktree path
828
430
  const worktreePath = generateWorktreePath(config, repoRoot, repoName, pr.number);
829
- // Create worktree
830
431
  console.log(colors.info(`Creating worktree at ${worktreePath}...`));
831
432
  git.addWorktree(worktreePath, branchName);
832
433
  console.log(colors.success(`Created worktree: ${worktreePath}`));
833
- // Apply unstaged changes to worktree if we stashed them
834
434
  if (unstagedStashRef) {
835
435
  console.log(colors.info('Moving unstaged changes to worktree...'));
836
436
  try {
@@ -843,17 +443,14 @@ ${description}
843
443
  console.log(colors.warning("Run 'git stash pop' in main worktree to recover them."));
844
444
  }
845
445
  }
846
- // Setup worktree
847
446
  await setupWorktree(worktreePath, config, options);
848
- // Print summary
849
447
  printSummary(pr.number, branchName, worktreePath, pr.url);
850
448
  }
851
449
  catch (error) {
852
- // Restore stashed changes on failure
853
- if (stashRef) {
450
+ if (actionResult.stashRef) {
854
451
  console.log(colors.info('Restoring stashed changes...'));
855
452
  try {
856
- git.stashPop(stashRef);
453
+ git.stashPop(actionResult.stashRef);
857
454
  }
858
455
  catch {
859
456
  console.log(colors.warning("Failed to restore stash. Run 'git stash pop' manually."));
@@ -866,8 +463,16 @@ ${description}
866
463
  * Main entry point
867
464
  */
868
465
  async function main() {
869
- const args = process.argv.slice(2);
870
- const options = parseArgs(args);
466
+ const result = parseArgs(process.argv.slice(2));
467
+ if (result.kind === 'help') {
468
+ console.log(getHelpText());
469
+ process.exit(0);
470
+ }
471
+ if (result.kind === 'error') {
472
+ console.error(colors.error(result.message));
473
+ process.exit(1);
474
+ }
475
+ const { options } = result;
871
476
  checkPrerequisites();
872
477
  switch (options.mode) {
873
478
  case 'pr':