@camaradesuk/git-worktree-tools 1.9.0 → 1.10.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 (230) hide show
  1. package/README.md +48 -27
  2. package/dist/cli/cleanpr.js +74 -53
  3. package/dist/cli/cleanpr.js.map +1 -1
  4. package/dist/cli/lswt.js +32 -56
  5. package/dist/cli/lswt.js.map +1 -1
  6. package/dist/cli/lswt.test.js +17 -27
  7. package/dist/cli/lswt.test.js.map +1 -1
  8. package/dist/cli/newpr.d.ts +13 -1
  9. package/dist/cli/newpr.d.ts.map +1 -1
  10. package/dist/cli/newpr.js +159 -153
  11. package/dist/cli/newpr.js.map +1 -1
  12. package/dist/cli/newpr.test.js +1 -1
  13. package/dist/cli/newpr.test.js.map +1 -1
  14. package/dist/cli/prs.d.ts +3 -10
  15. package/dist/cli/prs.d.ts.map +1 -1
  16. package/dist/cli/prs.js +6 -168
  17. package/dist/cli/prs.js.map +1 -1
  18. package/dist/cli/prs.test.js +53 -0
  19. package/dist/cli/prs.test.js.map +1 -1
  20. package/dist/cli/wt/clean.d.ts +6 -2
  21. package/dist/cli/wt/clean.d.ts.map +1 -1
  22. package/dist/cli/wt/clean.js +401 -20
  23. package/dist/cli/wt/clean.js.map +1 -1
  24. package/dist/cli/wt/clean.test.d.ts +8 -0
  25. package/dist/cli/wt/clean.test.d.ts.map +1 -0
  26. package/dist/cli/wt/clean.test.js +624 -0
  27. package/dist/cli/wt/clean.test.js.map +1 -0
  28. package/dist/cli/wt/completion.d.ts +3 -0
  29. package/dist/cli/wt/completion.d.ts.map +1 -1
  30. package/dist/cli/wt/completion.js +80 -9
  31. package/dist/cli/wt/completion.js.map +1 -1
  32. package/dist/cli/wt/completion.test.js +102 -0
  33. package/dist/cli/wt/completion.test.js.map +1 -1
  34. package/dist/cli/wt/config.d.ts +3 -1
  35. package/dist/cli/wt/config.d.ts.map +1 -1
  36. package/dist/cli/wt/config.js +323 -32
  37. package/dist/cli/wt/config.js.map +1 -1
  38. package/dist/cli/wt/config.test.d.ts +2 -0
  39. package/dist/cli/wt/config.test.d.ts.map +1 -1
  40. package/dist/cli/wt/config.test.js +206 -26
  41. package/dist/cli/wt/config.test.js.map +1 -1
  42. package/dist/cli/wt/interactive-menu.d.ts +2 -0
  43. package/dist/cli/wt/interactive-menu.d.ts.map +1 -1
  44. package/dist/cli/wt/interactive-menu.js +346 -73
  45. package/dist/cli/wt/interactive-menu.js.map +1 -1
  46. package/dist/cli/wt/interactive-menu.test.d.ts +4 -2
  47. package/dist/cli/wt/interactive-menu.test.d.ts.map +1 -1
  48. package/dist/cli/wt/interactive-menu.test.js +380 -323
  49. package/dist/cli/wt/interactive-menu.test.js.map +1 -1
  50. package/dist/cli/wt/link.d.ts +3 -1
  51. package/dist/cli/wt/link.d.ts.map +1 -1
  52. package/dist/cli/wt/link.js +125 -38
  53. package/dist/cli/wt/link.js.map +1 -1
  54. package/dist/cli/wt/list.d.ts +4 -1
  55. package/dist/cli/wt/list.d.ts.map +1 -1
  56. package/dist/cli/wt/list.js +85 -16
  57. package/dist/cli/wt/list.js.map +1 -1
  58. package/dist/cli/wt/list.test.d.ts +10 -0
  59. package/dist/cli/wt/list.test.d.ts.map +1 -0
  60. package/dist/cli/wt/list.test.js +157 -0
  61. package/dist/cli/wt/list.test.js.map +1 -0
  62. package/dist/cli/wt/new.d.ts +8 -2
  63. package/dist/cli/wt/new.d.ts.map +1 -1
  64. package/dist/cli/wt/new.js +91 -46
  65. package/dist/cli/wt/new.js.map +1 -1
  66. package/dist/cli/wt/prs.d.ts +2 -1
  67. package/dist/cli/wt/prs.d.ts.map +1 -1
  68. package/dist/cli/wt/prs.js +3 -164
  69. package/dist/cli/wt/prs.js.map +1 -1
  70. package/dist/cli/wt/run-command.d.ts +4 -2
  71. package/dist/cli/wt/run-command.d.ts.map +1 -1
  72. package/dist/cli/wt/run-command.js +6 -4
  73. package/dist/cli/wt/run-command.js.map +1 -1
  74. package/dist/cli/wt/state.d.ts +3 -1
  75. package/dist/cli/wt/state.d.ts.map +1 -1
  76. package/dist/cli/wt/state.js +74 -10
  77. package/dist/cli/wt/state.js.map +1 -1
  78. package/dist/cli/wt/state.test.d.ts +9 -0
  79. package/dist/cli/wt/state.test.d.ts.map +1 -0
  80. package/dist/cli/wt/state.test.js +127 -0
  81. package/dist/cli/wt/state.test.js.map +1 -0
  82. package/dist/cli/wt/wt.test.d.ts +2 -2
  83. package/dist/cli/wt/wt.test.js +430 -212
  84. package/dist/cli/wt/wt.test.js.map +1 -1
  85. package/dist/cli/wt.d.ts.map +1 -1
  86. package/dist/cli/wt.js +50 -36
  87. package/dist/cli/wt.js.map +1 -1
  88. package/dist/cli/wt.unit.test.js +16 -38
  89. package/dist/cli/wt.unit.test.js.map +1 -1
  90. package/dist/cli/wtconfig.js +99 -22
  91. package/dist/cli/wtconfig.js.map +1 -1
  92. package/dist/cli/wtlink.js +85 -61
  93. package/dist/cli/wtlink.js.map +1 -1
  94. package/dist/cli/wtstate.js +21 -2
  95. package/dist/cli/wtstate.js.map +1 -1
  96. package/dist/e2e/wt/interactive-menu.e2e.test.js +17 -17
  97. package/dist/e2e/wt/interactive-menu.e2e.test.js.map +1 -1
  98. package/dist/lib/cleanpr/args.d.ts.map +1 -1
  99. package/dist/lib/cleanpr/args.js +20 -0
  100. package/dist/lib/cleanpr/args.js.map +1 -1
  101. package/dist/lib/cleanpr/types.d.ts +6 -0
  102. package/dist/lib/cleanpr/types.d.ts.map +1 -1
  103. package/dist/lib/colors.d.ts +5 -0
  104. package/dist/lib/colors.d.ts.map +1 -1
  105. package/dist/lib/colors.js +13 -6
  106. package/dist/lib/colors.js.map +1 -1
  107. package/dist/lib/config.test.js +3 -15
  108. package/dist/lib/config.test.js.map +1 -1
  109. package/dist/lib/constants.d.ts +12 -4
  110. package/dist/lib/constants.d.ts.map +1 -1
  111. package/dist/lib/constants.js +24 -5
  112. package/dist/lib/constants.js.map +1 -1
  113. package/dist/lib/constants.test.js +88 -29
  114. package/dist/lib/constants.test.js.map +1 -1
  115. package/dist/lib/deprecation.d.ts +18 -0
  116. package/dist/lib/deprecation.d.ts.map +1 -0
  117. package/dist/lib/deprecation.js +28 -0
  118. package/dist/lib/deprecation.js.map +1 -0
  119. package/dist/lib/deprecation.test.d.ts +2 -0
  120. package/dist/lib/deprecation.test.d.ts.map +1 -0
  121. package/dist/lib/deprecation.test.js +71 -0
  122. package/dist/lib/deprecation.test.js.map +1 -0
  123. package/dist/lib/logger.d.ts +40 -155
  124. package/dist/lib/logger.d.ts.map +1 -1
  125. package/dist/lib/logger.js +349 -420
  126. package/dist/lib/logger.js.map +1 -1
  127. package/dist/lib/logger.test.d.ts +10 -1
  128. package/dist/lib/logger.test.d.ts.map +1 -1
  129. package/dist/lib/logger.test.js +658 -258
  130. package/dist/lib/logger.test.js.map +1 -1
  131. package/dist/lib/lswt/args.d.ts.map +1 -1
  132. package/dist/lib/lswt/args.js +15 -1
  133. package/dist/lib/lswt/args.js.map +1 -1
  134. package/dist/lib/lswt/index.d.ts +1 -0
  135. package/dist/lib/lswt/index.d.ts.map +1 -1
  136. package/dist/lib/lswt/index.js +2 -0
  137. package/dist/lib/lswt/index.js.map +1 -1
  138. package/dist/lib/lswt/table.d.ts +15 -0
  139. package/dist/lib/lswt/table.d.ts.map +1 -0
  140. package/dist/lib/lswt/table.js +61 -0
  141. package/dist/lib/lswt/table.js.map +1 -0
  142. package/dist/lib/lswt/table.test.d.ts +5 -0
  143. package/dist/lib/lswt/table.test.d.ts.map +1 -0
  144. package/dist/lib/lswt/table.test.js +262 -0
  145. package/dist/lib/lswt/table.test.js.map +1 -0
  146. package/dist/lib/lswt/types.d.ts +4 -0
  147. package/dist/lib/lswt/types.d.ts.map +1 -1
  148. package/dist/lib/newpr/args.d.ts.map +1 -1
  149. package/dist/lib/newpr/args.js +21 -0
  150. package/dist/lib/newpr/args.js.map +1 -1
  151. package/dist/lib/newpr/types.d.ts +6 -0
  152. package/dist/lib/newpr/types.d.ts.map +1 -1
  153. package/dist/lib/prs/command.d.ts +21 -0
  154. package/dist/lib/prs/command.d.ts.map +1 -0
  155. package/dist/lib/prs/command.js +175 -0
  156. package/dist/lib/prs/command.js.map +1 -0
  157. package/dist/lib/prs/command.test.d.ts +11 -0
  158. package/dist/lib/prs/command.test.d.ts.map +1 -0
  159. package/dist/lib/prs/command.test.js +409 -0
  160. package/dist/lib/prs/command.test.js.map +1 -0
  161. package/dist/lib/prs/interactive.d.ts.map +1 -1
  162. package/dist/lib/prs/interactive.js +15 -2
  163. package/dist/lib/prs/interactive.js.map +1 -1
  164. package/dist/lib/prs/interactive.test.js +153 -0
  165. package/dist/lib/prs/interactive.test.js.map +1 -1
  166. package/dist/lib/prs/types.d.ts +15 -0
  167. package/dist/lib/prs/types.d.ts.map +1 -1
  168. package/dist/lib/ui/error.d.ts +31 -0
  169. package/dist/lib/ui/error.d.ts.map +1 -0
  170. package/dist/lib/ui/error.js +47 -0
  171. package/dist/lib/ui/error.js.map +1 -0
  172. package/dist/lib/ui/error.test.d.ts +2 -0
  173. package/dist/lib/ui/error.test.d.ts.map +1 -0
  174. package/dist/lib/ui/error.test.js +143 -0
  175. package/dist/lib/ui/error.test.js.map +1 -0
  176. package/dist/lib/ui/index.d.ts +15 -0
  177. package/dist/lib/ui/index.d.ts.map +1 -0
  178. package/dist/lib/ui/index.js +19 -0
  179. package/dist/lib/ui/index.js.map +1 -0
  180. package/dist/lib/ui/output.d.ts +18 -0
  181. package/dist/lib/ui/output.d.ts.map +1 -0
  182. package/dist/lib/ui/output.js +31 -0
  183. package/dist/lib/ui/output.js.map +1 -0
  184. package/dist/lib/ui/output.test.d.ts +2 -0
  185. package/dist/lib/ui/output.test.d.ts.map +1 -0
  186. package/dist/lib/ui/output.test.js +59 -0
  187. package/dist/lib/ui/output.test.js.map +1 -0
  188. package/dist/lib/ui/spinner.d.ts +10 -0
  189. package/dist/lib/ui/spinner.d.ts.map +1 -0
  190. package/dist/lib/ui/spinner.js +10 -0
  191. package/dist/lib/ui/spinner.js.map +1 -0
  192. package/dist/lib/ui/status.d.ts +65 -0
  193. package/dist/lib/ui/status.d.ts.map +1 -0
  194. package/dist/lib/ui/status.js +100 -0
  195. package/dist/lib/ui/status.js.map +1 -0
  196. package/dist/lib/ui/status.test.d.ts +2 -0
  197. package/dist/lib/ui/status.test.d.ts.map +1 -0
  198. package/dist/lib/ui/status.test.js +158 -0
  199. package/dist/lib/ui/status.test.js.map +1 -0
  200. package/dist/lib/ui/table.d.ts +39 -0
  201. package/dist/lib/ui/table.d.ts.map +1 -0
  202. package/dist/lib/ui/table.js +45 -0
  203. package/dist/lib/ui/table.js.map +1 -0
  204. package/dist/lib/ui/table.test.d.ts +2 -0
  205. package/dist/lib/ui/table.test.d.ts.map +1 -0
  206. package/dist/lib/ui/table.test.js +115 -0
  207. package/dist/lib/ui/table.test.js.map +1 -0
  208. package/dist/lib/ui/theme.d.ts +34 -0
  209. package/dist/lib/ui/theme.d.ts.map +1 -0
  210. package/dist/lib/ui/theme.js +37 -0
  211. package/dist/lib/ui/theme.js.map +1 -0
  212. package/dist/lib/ui/theme.test.d.ts +2 -0
  213. package/dist/lib/ui/theme.test.d.ts.map +1 -0
  214. package/dist/lib/ui/theme.test.js +76 -0
  215. package/dist/lib/ui/theme.test.js.map +1 -0
  216. package/dist/lib/wtlink/link-configs.js +7 -7
  217. package/dist/lib/wtlink/link-configs.js.map +1 -1
  218. package/dist/lib/wtlink/validate-manifest.d.ts.map +1 -1
  219. package/dist/lib/wtlink/validate-manifest.js +5 -5
  220. package/dist/lib/wtlink/validate-manifest.js.map +1 -1
  221. package/dist/lib/wtstate/args.d.ts.map +1 -1
  222. package/dist/lib/wtstate/args.js +2 -0
  223. package/dist/lib/wtstate/args.js.map +1 -1
  224. package/dist/mcp/server.d.ts +2 -1
  225. package/dist/mcp/server.d.ts.map +1 -1
  226. package/dist/mcp/server.js +264 -44
  227. package/dist/mcp/server.js.map +1 -1
  228. package/dist/mcp/server.test.js +111 -0
  229. package/dist/mcp/server.test.js.map +1 -1
  230. package/package.json +2 -1
@@ -3,8 +3,10 @@
3
3
  *
4
4
  * These tests verify that each menu flow:
5
5
  * 1. Gathers the correct user inputs
6
- * 2. Passes the correct arguments to subcommands
7
- * 3. Handles cancellation and back navigation correctly
6
+ * 2. Calls the correct library functions with proper arguments
7
+ * 3. Returns to menu after operation execution (not exit)
8
+ * 4. Handles cancellation and back navigation correctly
9
+ * 5. Uses direct library calls (no subprocess spawning)
8
10
  */
9
11
  import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
10
12
  // Mock modules before importing the module under test
@@ -23,12 +25,6 @@ vi.mock('../../lib/prompts.js', () => {
23
25
  UserNavigatedBack: MockUserNavigatedBack,
24
26
  };
25
27
  });
26
- vi.mock('./run-command.js', () => ({
27
- runSubcommand: vi.fn(() => {
28
- // Mock never returns - simulate process.exit
29
- throw new Error('process.exit called');
30
- }),
31
- }));
32
28
  vi.mock('../../lib/config.js', () => ({
33
29
  loadConfig: vi.fn(() => ({
34
30
  configVersion: 1,
@@ -63,12 +59,90 @@ vi.mock('../../lib/config.js', () => ({
63
59
  vi.mock('../../lib/git.js', () => ({
64
60
  getRepoRoot: vi.fn(() => '/mock/repo'),
65
61
  listLocalBranches: vi.fn(() => ['feat/existing-branch', 'fix/bug-fix', 'main', 'develop']),
62
+ removeWorktree: vi.fn(),
63
+ pruneWorktrees: vi.fn(),
64
+ }));
65
+ vi.mock('../../lib/wtlink/config-manifest.js', () => ({
66
+ loadManifestData: vi.fn(() => ({
67
+ enabled: ['.env', '.env.local'],
68
+ disabled: ['config.json'],
69
+ source: 'config',
70
+ })),
71
+ saveManifestData: vi.fn(),
72
+ }));
73
+ // Mock direct library imports
74
+ vi.mock('../../lib/lswt/index.js', () => ({
75
+ gatherWorktreeInfo: vi.fn(async () => []),
76
+ createDefaultDeps: vi.fn(() => ({})),
77
+ runInteractiveMode: vi.fn(async () => { }),
78
+ }));
79
+ vi.mock('../../lib/prs/command.js', () => ({
80
+ runPrsCommand: vi.fn(async () => { }),
81
+ }));
82
+ vi.mock('../newpr.js', () => ({
83
+ runNewprHandler: vi.fn(async () => { }),
84
+ }));
85
+ vi.mock('../../lib/cleanpr/index.js', () => ({
86
+ gatherPrWorktreeInfo: vi.fn(async () => []),
87
+ createDefaultDeps: vi.fn(() => ({})),
88
+ getCleanableWorktrees: vi.fn(() => []),
89
+ cleanWorktree: vi.fn(() => ({ success: true, message: 'Cleaned', prNumber: 42 })),
90
+ findWorktreeByPrNumber: vi.fn(() => null),
91
+ summarizeResults: vi.fn(() => ({ cleaned: 0, total: 0 })),
92
+ }));
93
+ vi.mock('../../lib/wtstate/index.js', () => ({
94
+ analyzeState: vi.fn(() => ({
95
+ scenario: 'main_clean_same',
96
+ scenarioDescription: 'On main, clean, same as origin',
97
+ currentBranch: 'main',
98
+ baseBranch: 'main',
99
+ worktreeType: 'main_worktree',
100
+ hasChanges: false,
101
+ hasStagedChanges: false,
102
+ hasUnstagedChanges: false,
103
+ localCommits: 0,
104
+ stagedFiles: [],
105
+ unstagedFiles: [],
106
+ availableActions: [],
107
+ recommendedAction: null,
108
+ })),
109
+ formatText: vi.fn(() => 'State: main_clean_same'),
110
+ }));
111
+ vi.mock('../../lib/wtlink/link-configs.js', () => ({
112
+ run: vi.fn(async () => { }),
113
+ }));
114
+ vi.mock('../../lib/wtlink/validate-manifest.js', () => ({
115
+ run: vi.fn(() => { }),
116
+ }));
117
+ vi.mock('../../lib/wtconfig/index.js', () => ({
118
+ formatConfigDisplay: vi.fn(() => '{ baseBranch: "main" }'),
119
+ setConfigValue: vi.fn((config, _key, _value) => config),
120
+ loadRepoConfig: vi.fn(() => ({})),
121
+ saveRepoConfig: vi.fn(),
122
+ validateConfig: vi.fn(() => ({ valid: true, errors: [], warnings: [] })),
123
+ }));
124
+ vi.mock('../../lib/constants.js', () => ({
125
+ DEFAULT_MANIFEST_FILE: '.wtlinkrc',
126
+ }));
127
+ vi.mock('../../lib/ui/index.js', () => ({
128
+ printStatus: vi.fn(),
129
+ }));
130
+ vi.mock('child_process', () => ({
131
+ execSync: vi.fn(),
66
132
  }));
67
133
  // Import mocked modules
68
134
  import { promptChoice, promptInput, promptConfirm } from '../../lib/prompts.js';
69
- import { runSubcommand } from './run-command.js';
70
135
  import { loadConfig } from '../../lib/config.js';
71
136
  import * as git from '../../lib/git.js';
137
+ import { loadManifestData, saveManifestData } from '../../lib/wtlink/config-manifest.js';
138
+ import { gatherWorktreeInfo, runInteractiveMode } from '../../lib/lswt/index.js';
139
+ import { runPrsCommand } from '../../lib/prs/command.js';
140
+ import { runNewprHandler } from '../newpr.js';
141
+ import { gatherPrWorktreeInfo, getCleanableWorktrees, } from '../../lib/cleanpr/index.js';
142
+ import { analyzeState, formatText } from '../../lib/wtstate/index.js';
143
+ import { run as runWtlinkLink } from '../../lib/wtlink/link-configs.js';
144
+ import { run as runWtlinkValidate } from '../../lib/wtlink/validate-manifest.js';
145
+ import { formatConfigDisplay, setConfigValue, loadRepoConfig, saveRepoConfig, } from '../../lib/wtconfig/index.js';
72
146
  // Import flows after mocks are set up
73
147
  import { flows, showMainMenu } from './interactive-menu.js';
74
148
  // Mock console.log to keep test output clean
@@ -81,36 +155,41 @@ describe('Interactive Menu Flows', () => {
81
155
  consoleSpy.mockClear();
82
156
  });
83
157
  describe('handleListWorktrees', () => {
84
- it('calls lswt subcommand with no args', async () => {
85
- try {
86
- await flows.handleListWorktrees();
87
- }
88
- catch {
89
- // Expected - runSubcommand throws
90
- }
91
- expect(runSubcommand).toHaveBeenCalledWith('lswt', []);
158
+ it('calls gatherWorktreeInfo and runInteractiveMode and returns to menu', async () => {
159
+ const result = await flows.handleListWorktrees();
160
+ expect(gatherWorktreeInfo).toHaveBeenCalledWith('/mock/repo', { verbose: false, json: false, showStatus: false }, expect.anything());
161
+ expect(runInteractiveMode).toHaveBeenCalled();
162
+ expect(result).toEqual({ completed: true, returnToMenu: true });
163
+ });
164
+ it('returns to menu with error message when library call fails', async () => {
165
+ vi.mocked(gatherWorktreeInfo).mockRejectedValueOnce(new Error('git error'));
166
+ const result = await flows.handleListWorktrees();
167
+ expect(result).toEqual({ completed: true, returnToMenu: true });
168
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('git error'));
92
169
  });
93
170
  });
94
171
  describe('handleBrowsePRs', () => {
95
- it('calls prs subcommand with no args', async () => {
96
- try {
97
- await flows.handleBrowsePRs();
98
- }
99
- catch {
100
- // Expected - runSubcommand throws
101
- }
102
- expect(runSubcommand).toHaveBeenCalledWith('prs', []);
172
+ it('calls runPrsCommand and returns to menu', async () => {
173
+ const result = await flows.handleBrowsePRs();
174
+ expect(runPrsCommand).toHaveBeenCalledWith({
175
+ state: 'open',
176
+ limit: 50,
177
+ json: false,
178
+ noInteractive: false,
179
+ });
180
+ expect(result).toEqual({ completed: true, returnToMenu: true });
103
181
  });
104
182
  });
105
183
  describe('handleShowState', () => {
106
- it('calls wtstate subcommand with no args', async () => {
107
- try {
108
- await flows.handleShowState();
109
- }
110
- catch {
111
- // Expected - runSubcommand throws
112
- }
113
- expect(runSubcommand).toHaveBeenCalledWith('wtstate', []);
184
+ it('calls analyzeState and formatText and returns to menu', async () => {
185
+ const result = await flows.handleShowState();
186
+ expect(analyzeState).toHaveBeenCalledWith({
187
+ verbose: false,
188
+ json: false,
189
+ baseBranch: 'main',
190
+ });
191
+ expect(formatText).toHaveBeenCalled();
192
+ expect(result).toEqual({ completed: true, returnToMenu: true });
114
193
  });
115
194
  });
116
195
  describe('handleNewPR', () => {
@@ -118,7 +197,7 @@ describe('Interactive Menu Flows', () => {
118
197
  vi.mocked(promptChoice).mockResolvedValueOnce('back');
119
198
  const result = await flows.handleNewPR();
120
199
  expect(result).toEqual({ completed: false, returnToMenu: true });
121
- expect(runSubcommand).not.toHaveBeenCalled();
200
+ expect(runNewprHandler).not.toHaveBeenCalled();
122
201
  });
123
202
  it('handles user cancellation (Ctrl+C)', async () => {
124
203
  vi.mocked(promptChoice).mockRejectedValueOnce(new Error('User cancelled'));
@@ -126,7 +205,7 @@ describe('Interactive Menu Flows', () => {
126
205
  expect(result).toEqual({ completed: false, returnToMenu: true });
127
206
  });
128
207
  describe('from-description flow', () => {
129
- it('gathers all inputs and calls newpr with correct args', async () => {
208
+ it('gathers all inputs and calls runNewprHandler with correct Options', async () => {
130
209
  vi.mocked(promptChoice)
131
210
  .mockResolvedValueOnce('from-description') // New PR sub-menu
132
211
  .mockResolvedValueOnce(true); // Draft PR selection
@@ -136,15 +215,18 @@ describe('Interactive Menu Flows', () => {
136
215
  vi.mocked(promptConfirm)
137
216
  .mockResolvedValueOnce(false) // Install deps
138
217
  .mockResolvedValueOnce(false); // Open VS Code
139
- try {
140
- await flows.handleNewPR();
141
- }
142
- catch {
143
- // Expected
144
- }
145
- expect(runSubcommand).toHaveBeenCalledWith('newpr', ['Add dark mode support']);
146
- });
147
- it('passes --ready flag when not draft', async () => {
218
+ const result = await flows.handleNewPR();
219
+ expect(runNewprHandler).toHaveBeenCalledWith(expect.objectContaining({
220
+ mode: 'new',
221
+ description: 'Add dark mode support',
222
+ baseBranch: 'main',
223
+ draft: true,
224
+ installDeps: false,
225
+ openEditor: false,
226
+ }));
227
+ expect(result).toEqual({ completed: true, returnToMenu: true });
228
+ });
229
+ it('passes ready flag when not draft', async () => {
148
230
  vi.mocked(promptChoice)
149
231
  .mockResolvedValueOnce('from-description')
150
232
  .mockResolvedValueOnce(false); // Ready for review (not draft)
@@ -152,15 +234,15 @@ describe('Interactive Menu Flows', () => {
152
234
  .mockResolvedValueOnce('Fix critical bug')
153
235
  .mockResolvedValueOnce('main');
154
236
  vi.mocked(promptConfirm).mockResolvedValueOnce(false).mockResolvedValueOnce(false);
155
- try {
156
- await flows.handleNewPR();
157
- }
158
- catch {
159
- // Expected
160
- }
161
- expect(runSubcommand).toHaveBeenCalledWith('newpr', ['Fix critical bug', '--ready']);
162
- });
163
- it('passes --base flag when not main', async () => {
237
+ const result = await flows.handleNewPR();
238
+ expect(runNewprHandler).toHaveBeenCalledWith(expect.objectContaining({
239
+ mode: 'new',
240
+ description: 'Fix critical bug',
241
+ draft: false,
242
+ }));
243
+ expect(result).toEqual({ completed: true, returnToMenu: true });
244
+ });
245
+ it('passes non-main base branch', async () => {
164
246
  vi.mocked(promptChoice)
165
247
  .mockResolvedValueOnce('from-description')
166
248
  .mockResolvedValueOnce(true);
@@ -168,15 +250,13 @@ describe('Interactive Menu Flows', () => {
168
250
  .mockResolvedValueOnce('Feature work')
169
251
  .mockResolvedValueOnce('develop'); // Non-main base branch
170
252
  vi.mocked(promptConfirm).mockResolvedValueOnce(false).mockResolvedValueOnce(false);
171
- try {
172
- await flows.handleNewPR();
173
- }
174
- catch {
175
- // Expected
176
- }
177
- expect(runSubcommand).toHaveBeenCalledWith('newpr', ['Feature work', '--base', 'develop']);
178
- });
179
- it('passes --install flag when requested', async () => {
253
+ const result = await flows.handleNewPR();
254
+ expect(runNewprHandler).toHaveBeenCalledWith(expect.objectContaining({
255
+ baseBranch: 'develop',
256
+ }));
257
+ expect(result).toEqual({ completed: true, returnToMenu: true });
258
+ });
259
+ it('passes install flag when requested', async () => {
180
260
  vi.mocked(promptChoice)
181
261
  .mockResolvedValueOnce('from-description')
182
262
  .mockResolvedValueOnce(true);
@@ -184,27 +264,23 @@ describe('Interactive Menu Flows', () => {
184
264
  vi.mocked(promptConfirm)
185
265
  .mockResolvedValueOnce(true) // Install deps
186
266
  .mockResolvedValueOnce(false);
187
- try {
188
- await flows.handleNewPR();
189
- }
190
- catch {
191
- // Expected
192
- }
193
- expect(runSubcommand).toHaveBeenCalledWith('newpr', ['Add feature', '--install']);
194
- });
195
- it('passes --code flag when requested', async () => {
267
+ const result = await flows.handleNewPR();
268
+ expect(runNewprHandler).toHaveBeenCalledWith(expect.objectContaining({
269
+ installDeps: true,
270
+ }));
271
+ expect(result).toEqual({ completed: true, returnToMenu: true });
272
+ });
273
+ it('passes code flag when requested', async () => {
196
274
  vi.mocked(promptChoice)
197
275
  .mockResolvedValueOnce('from-description')
198
276
  .mockResolvedValueOnce(true);
199
277
  vi.mocked(promptInput).mockResolvedValueOnce('Add feature').mockResolvedValueOnce('main');
200
278
  vi.mocked(promptConfirm).mockResolvedValueOnce(false).mockResolvedValueOnce(true); // Open VS Code
201
- try {
202
- await flows.handleNewPR();
203
- }
204
- catch {
205
- // Expected
206
- }
207
- expect(runSubcommand).toHaveBeenCalledWith('newpr', ['Add feature', '--code']);
279
+ const result = await flows.handleNewPR();
280
+ expect(runNewprHandler).toHaveBeenCalledWith(expect.objectContaining({
281
+ openEditor: true,
282
+ }));
283
+ expect(result).toEqual({ completed: true, returnToMenu: true });
208
284
  });
209
285
  it('passes all optional flags together', async () => {
210
286
  vi.mocked(promptChoice)
@@ -216,27 +292,23 @@ describe('Interactive Menu Flows', () => {
216
292
  vi.mocked(promptConfirm)
217
293
  .mockResolvedValueOnce(true) // Install
218
294
  .mockResolvedValueOnce(true); // VS Code
219
- try {
220
- await flows.handleNewPR();
221
- }
222
- catch {
223
- // Expected
224
- }
225
- expect(runSubcommand).toHaveBeenCalledWith('newpr', [
226
- 'Full feature',
227
- '--base',
228
- 'develop',
229
- '--ready',
230
- '--install',
231
- '--code',
232
- ]);
295
+ const result = await flows.handleNewPR();
296
+ expect(runNewprHandler).toHaveBeenCalledWith(expect.objectContaining({
297
+ mode: 'new',
298
+ description: 'Full feature',
299
+ baseBranch: 'develop',
300
+ draft: false,
301
+ installDeps: true,
302
+ openEditor: true,
303
+ }));
304
+ expect(result).toEqual({ completed: true, returnToMenu: true });
233
305
  });
234
306
  it('returns CANCELLED when description is empty', async () => {
235
307
  vi.mocked(promptChoice).mockResolvedValueOnce('from-description');
236
308
  vi.mocked(promptInput).mockResolvedValueOnce(''); // Empty description
237
309
  const result = await flows.handleNewPR();
238
310
  expect(result).toEqual({ completed: false, returnToMenu: true });
239
- expect(runSubcommand).not.toHaveBeenCalled();
311
+ expect(runNewprHandler).not.toHaveBeenCalled();
240
312
  });
241
313
  it('handles user cancellation during input', async () => {
242
314
  vi.mocked(promptChoice).mockResolvedValueOnce('from-description');
@@ -246,59 +318,59 @@ describe('Interactive Menu Flows', () => {
246
318
  });
247
319
  });
248
320
  describe('from-pr flow', () => {
249
- it('gathers PR number and calls newpr with --pr flag', async () => {
321
+ it('gathers PR number and calls runNewprHandler with mode pr', async () => {
250
322
  vi.mocked(promptChoice).mockResolvedValueOnce('from-pr');
251
323
  vi.mocked(promptInput).mockResolvedValueOnce('42');
252
324
  vi.mocked(promptConfirm).mockResolvedValueOnce(false).mockResolvedValueOnce(false);
253
- try {
254
- await flows.handleNewPR();
255
- }
256
- catch {
257
- // Expected
258
- }
259
- expect(runSubcommand).toHaveBeenCalledWith('newpr', ['--pr', '42']);
260
- });
261
- it('passes --install and --code flags', async () => {
325
+ const result = await flows.handleNewPR();
326
+ expect(runNewprHandler).toHaveBeenCalledWith(expect.objectContaining({
327
+ mode: 'pr',
328
+ prNumber: 42,
329
+ }));
330
+ expect(result).toEqual({ completed: true, returnToMenu: true });
331
+ });
332
+ it('passes install and code flags', async () => {
262
333
  vi.mocked(promptChoice).mockResolvedValueOnce('from-pr');
263
334
  vi.mocked(promptInput).mockResolvedValueOnce('123');
264
335
  vi.mocked(promptConfirm)
265
336
  .mockResolvedValueOnce(true) // Install
266
337
  .mockResolvedValueOnce(true); // VS Code
267
- try {
268
- await flows.handleNewPR();
269
- }
270
- catch {
271
- // Expected
272
- }
273
- expect(runSubcommand).toHaveBeenCalledWith('newpr', ['--pr', '123', '--install', '--code']);
338
+ const result = await flows.handleNewPR();
339
+ expect(runNewprHandler).toHaveBeenCalledWith(expect.objectContaining({
340
+ mode: 'pr',
341
+ prNumber: 123,
342
+ installDeps: true,
343
+ openEditor: true,
344
+ }));
345
+ expect(result).toEqual({ completed: true, returnToMenu: true });
274
346
  });
275
347
  it('returns CANCELLED when PR number is empty', async () => {
276
348
  vi.mocked(promptChoice).mockResolvedValueOnce('from-pr');
277
349
  vi.mocked(promptInput).mockResolvedValueOnce('');
278
350
  const result = await flows.handleNewPR();
279
351
  expect(result).toEqual({ completed: false, returnToMenu: true });
280
- expect(runSubcommand).not.toHaveBeenCalled();
352
+ expect(runNewprHandler).not.toHaveBeenCalled();
281
353
  });
282
354
  it('returns CANCELLED when PR number is invalid', async () => {
283
355
  vi.mocked(promptChoice).mockResolvedValueOnce('from-pr');
284
356
  vi.mocked(promptInput).mockResolvedValueOnce('not-a-number');
285
357
  const result = await flows.handleNewPR();
286
358
  expect(result).toEqual({ completed: false, returnToMenu: true });
287
- expect(runSubcommand).not.toHaveBeenCalled();
359
+ expect(runNewprHandler).not.toHaveBeenCalled();
288
360
  });
289
361
  it('returns CANCELLED when PR number is zero', async () => {
290
362
  vi.mocked(promptChoice).mockResolvedValueOnce('from-pr');
291
363
  vi.mocked(promptInput).mockResolvedValueOnce('0');
292
364
  const result = await flows.handleNewPR();
293
365
  expect(result).toEqual({ completed: false, returnToMenu: true });
294
- expect(runSubcommand).not.toHaveBeenCalled();
366
+ expect(runNewprHandler).not.toHaveBeenCalled();
295
367
  });
296
368
  it('returns CANCELLED when PR number is negative', async () => {
297
369
  vi.mocked(promptChoice).mockResolvedValueOnce('from-pr');
298
370
  vi.mocked(promptInput).mockResolvedValueOnce('-5');
299
371
  const result = await flows.handleNewPR();
300
372
  expect(result).toEqual({ completed: false, returnToMenu: true });
301
- expect(runSubcommand).not.toHaveBeenCalled();
373
+ expect(runNewprHandler).not.toHaveBeenCalled();
302
374
  });
303
375
  });
304
376
  describe('from-branch flow', () => {
@@ -308,13 +380,12 @@ describe('Interactive Menu Flows', () => {
308
380
  .mockResolvedValueOnce('feat/existing-branch') // Select branch
309
381
  .mockResolvedValueOnce(true); // Draft PR
310
382
  vi.mocked(promptInput).mockResolvedValueOnce('main');
311
- try {
312
- await flows.handleNewPR();
313
- }
314
- catch {
315
- // Expected
316
- }
317
- expect(runSubcommand).toHaveBeenCalledWith('newpr', ['--branch', 'feat/existing-branch']);
383
+ const result = await flows.handleNewPR();
384
+ expect(runNewprHandler).toHaveBeenCalledWith(expect.objectContaining({
385
+ mode: 'branch',
386
+ branchName: 'feat/existing-branch',
387
+ }));
388
+ expect(result).toEqual({ completed: true, returnToMenu: true });
318
389
  });
319
390
  it('allows typing custom branch name', async () => {
320
391
  vi.mocked(promptChoice)
@@ -324,33 +395,27 @@ describe('Interactive Menu Flows', () => {
324
395
  vi.mocked(promptInput)
325
396
  .mockResolvedValueOnce('feat/my-new-branch') // Custom branch name
326
397
  .mockResolvedValueOnce('main');
327
- try {
328
- await flows.handleNewPR();
329
- }
330
- catch {
331
- // Expected
332
- }
333
- expect(runSubcommand).toHaveBeenCalledWith('newpr', ['--branch', 'feat/my-new-branch']);
334
- });
335
- it('passes --base and --ready flags', async () => {
398
+ const result = await flows.handleNewPR();
399
+ expect(runNewprHandler).toHaveBeenCalledWith(expect.objectContaining({
400
+ mode: 'branch',
401
+ branchName: 'feat/my-new-branch',
402
+ }));
403
+ expect(result).toEqual({ completed: true, returnToMenu: true });
404
+ });
405
+ it('passes non-main base branch and ready flag', async () => {
336
406
  vi.mocked(promptChoice)
337
407
  .mockResolvedValueOnce('from-branch')
338
408
  .mockResolvedValueOnce('fix/bug-fix')
339
409
  .mockResolvedValueOnce(false); // Ready for review
340
410
  vi.mocked(promptInput).mockResolvedValueOnce('develop');
341
- try {
342
- await flows.handleNewPR();
343
- }
344
- catch {
345
- // Expected
346
- }
347
- expect(runSubcommand).toHaveBeenCalledWith('newpr', [
348
- '--branch',
349
- 'fix/bug-fix',
350
- '--base',
351
- 'develop',
352
- '--ready',
353
- ]);
411
+ const result = await flows.handleNewPR();
412
+ expect(runNewprHandler).toHaveBeenCalledWith(expect.objectContaining({
413
+ mode: 'branch',
414
+ branchName: 'fix/bug-fix',
415
+ baseBranch: 'develop',
416
+ draft: false,
417
+ }));
418
+ expect(result).toEqual({ completed: true, returnToMenu: true });
354
419
  });
355
420
  it('returns CANCELLED when branch name is empty', async () => {
356
421
  vi.mocked(promptChoice)
@@ -359,7 +424,7 @@ describe('Interactive Menu Flows', () => {
359
424
  vi.mocked(promptInput).mockResolvedValueOnce(''); // Empty branch name
360
425
  const result = await flows.handleNewPR();
361
426
  expect(result).toEqual({ completed: false, returnToMenu: true });
362
- expect(runSubcommand).not.toHaveBeenCalled();
427
+ expect(runNewprHandler).not.toHaveBeenCalled();
363
428
  });
364
429
  it('handles empty branch list gracefully', async () => {
365
430
  // Mock empty branch list
@@ -369,14 +434,10 @@ describe('Interactive Menu Flows', () => {
369
434
  .mockResolvedValueOnce('feat/new-branch') // Manual branch input
370
435
  .mockResolvedValueOnce('main');
371
436
  vi.mocked(promptChoice).mockResolvedValueOnce(true); // Draft
372
- try {
373
- await flows.handleNewPR();
374
- }
375
- catch {
376
- // Expected
377
- }
437
+ const result = await flows.handleNewPR();
378
438
  // Should have prompted for branch name directly
379
439
  expect(promptInput).toHaveBeenCalledWith('Branch name');
440
+ expect(result).toEqual({ completed: true, returnToMenu: true });
380
441
  });
381
442
  });
382
443
  });
@@ -385,39 +446,31 @@ describe('Interactive Menu Flows', () => {
385
446
  vi.mocked(promptChoice).mockResolvedValueOnce('back');
386
447
  const result = await flows.handleCleanPRs();
387
448
  expect(result).toEqual({ completed: false, returnToMenu: true });
388
- expect(runSubcommand).not.toHaveBeenCalled();
449
+ expect(gatherPrWorktreeInfo).not.toHaveBeenCalled();
389
450
  });
390
451
  describe('clean-all', () => {
391
- it('calls cleanpr with --all after confirmation', async () => {
452
+ it('calls cleanpr library after confirmation and returns to menu', async () => {
392
453
  vi.mocked(promptChoice).mockResolvedValueOnce('clean-all');
393
454
  vi.mocked(promptConfirm).mockResolvedValueOnce(true);
394
- try {
395
- await flows.handleCleanPRs();
396
- }
397
- catch {
398
- // Expected
399
- }
400
- expect(runSubcommand).toHaveBeenCalledWith('cleanpr', ['--all']);
455
+ const result = await flows.handleCleanPRs();
456
+ expect(gatherPrWorktreeInfo).toHaveBeenCalled();
457
+ expect(result).toEqual({ completed: true, returnToMenu: true });
401
458
  });
402
459
  it('returns CANCELLED when not confirmed', async () => {
403
460
  vi.mocked(promptChoice).mockResolvedValueOnce('clean-all');
404
461
  vi.mocked(promptConfirm).mockResolvedValueOnce(false);
405
462
  const result = await flows.handleCleanPRs();
406
463
  expect(result).toEqual({ completed: false, returnToMenu: true });
407
- expect(runSubcommand).not.toHaveBeenCalled();
464
+ expect(gatherPrWorktreeInfo).not.toHaveBeenCalled();
408
465
  });
409
466
  });
410
467
  describe('clean-specific', () => {
411
- it('calls cleanpr with PR number', async () => {
468
+ it('calls cleanpr with PR number and returns to menu', async () => {
412
469
  vi.mocked(promptChoice).mockResolvedValueOnce('clean-specific');
413
470
  vi.mocked(promptInput).mockResolvedValueOnce('42');
414
- try {
415
- await flows.handleCleanPRs();
416
- }
417
- catch {
418
- // Expected
419
- }
420
- expect(runSubcommand).toHaveBeenCalledWith('cleanpr', ['42']);
471
+ const result = await flows.handleCleanPRs();
472
+ expect(gatherPrWorktreeInfo).toHaveBeenCalled();
473
+ expect(result).toEqual({ completed: true, returnToMenu: true });
421
474
  });
422
475
  it('returns CANCELLED when PR number is empty', async () => {
423
476
  vi.mocked(promptChoice).mockResolvedValueOnce('clean-specific');
@@ -433,15 +486,12 @@ describe('Interactive Menu Flows', () => {
433
486
  });
434
487
  });
435
488
  describe('dry-run', () => {
436
- it('calls cleanpr with --dry-run', async () => {
489
+ it('calls cleanpr with dry-run and returns to menu', async () => {
437
490
  vi.mocked(promptChoice).mockResolvedValueOnce('dry-run');
438
- try {
439
- await flows.handleCleanPRs();
440
- }
441
- catch {
442
- // Expected
443
- }
444
- expect(runSubcommand).toHaveBeenCalledWith('cleanpr', ['--dry-run']);
491
+ const result = await flows.handleCleanPRs();
492
+ expect(gatherPrWorktreeInfo).toHaveBeenCalled();
493
+ expect(getCleanableWorktrees).toHaveBeenCalled();
494
+ expect(result).toEqual({ completed: true, returnToMenu: true });
445
495
  });
446
496
  });
447
497
  it('handles user cancellation', async () => {
@@ -455,72 +505,110 @@ describe('Interactive Menu Flows', () => {
455
505
  vi.mocked(promptChoice).mockResolvedValueOnce('back');
456
506
  const result = await flows.handleLinkConfig();
457
507
  expect(result).toEqual({ completed: false, returnToMenu: true });
458
- expect(runSubcommand).not.toHaveBeenCalled();
459
508
  });
460
- it('view calls wtlink list', async () => {
461
- vi.mocked(promptChoice).mockResolvedValueOnce('view');
462
- try {
463
- await flows.handleLinkConfig();
464
- }
465
- catch {
466
- // Expected
467
- }
468
- expect(runSubcommand).toHaveBeenCalledWith('wtlink', ['list']);
469
- });
470
- it('sync calls wtlink sync', async () => {
471
- vi.mocked(promptChoice).mockResolvedValueOnce('sync');
472
- try {
473
- await flows.handleLinkConfig();
474
- }
475
- catch {
476
- // Expected
477
- }
478
- expect(runSubcommand).toHaveBeenCalledWith('wtlink', ['sync']);
479
- });
480
- it('add calls wtlink add with file path', async () => {
481
- vi.mocked(promptChoice).mockResolvedValueOnce('add');
482
- vi.mocked(promptInput).mockResolvedValueOnce('.env');
483
- try {
484
- await flows.handleLinkConfig();
485
- }
486
- catch {
487
- // Expected
488
- }
489
- expect(runSubcommand).toHaveBeenCalledWith('wtlink', ['add', '.env']);
490
- });
491
- it('add returns CANCELLED when file path is empty', async () => {
492
- vi.mocked(promptChoice).mockResolvedValueOnce('add');
493
- vi.mocked(promptInput).mockResolvedValueOnce('');
494
- const result = await flows.handleLinkConfig();
495
- expect(result).toEqual({ completed: false, returnToMenu: true });
496
- expect(runSubcommand).not.toHaveBeenCalled();
497
- });
498
- it('remove calls wtlink remove with file path', async () => {
499
- vi.mocked(promptChoice).mockResolvedValueOnce('remove');
500
- vi.mocked(promptInput).mockResolvedValueOnce('.env.local');
501
- try {
502
- await flows.handleLinkConfig();
503
- }
504
- catch {
505
- // Expected
506
- }
507
- expect(runSubcommand).toHaveBeenCalledWith('wtlink', ['remove', '.env.local']);
508
- });
509
- it('remove returns CANCELLED when file path is empty', async () => {
510
- vi.mocked(promptChoice).mockResolvedValueOnce('remove');
511
- vi.mocked(promptInput).mockResolvedValueOnce('');
512
- const result = await flows.handleLinkConfig();
513
- expect(result).toEqual({ completed: false, returnToMenu: true });
509
+ describe('view via library', () => {
510
+ it('displays manifest contents from loadManifestData', async () => {
511
+ vi.mocked(promptChoice).mockResolvedValueOnce('view');
512
+ const result = await flows.handleLinkConfig();
513
+ expect(loadManifestData).toHaveBeenCalledWith('/mock/repo');
514
+ expect(result).toEqual({ completed: true, returnToMenu: true });
515
+ });
516
+ it('shows empty message when manifest has no files', async () => {
517
+ vi.mocked(loadManifestData).mockReturnValueOnce({
518
+ enabled: [],
519
+ disabled: [],
520
+ source: 'empty',
521
+ });
522
+ vi.mocked(promptChoice).mockResolvedValueOnce('view');
523
+ const result = await flows.handleLinkConfig();
524
+ expect(result).toEqual({ completed: true, returnToMenu: true });
525
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('No files'));
526
+ });
527
+ it('displays enabled and disabled files', async () => {
528
+ vi.mocked(promptChoice).mockResolvedValueOnce('view');
529
+ const result = await flows.handleLinkConfig();
530
+ expect(result).toEqual({ completed: true, returnToMenu: true });
531
+ // Check enabled files are shown
532
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Enabled'));
533
+ expect(consoleSpy).toHaveBeenCalledWith(' .env');
534
+ expect(consoleSpy).toHaveBeenCalledWith(' .env.local');
535
+ // Check disabled files are shown
536
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Disabled'));
537
+ });
514
538
  });
515
- it('validate calls wtlink validate', async () => {
516
- vi.mocked(promptChoice).mockResolvedValueOnce('validate');
517
- try {
518
- await flows.handleLinkConfig();
519
- }
520
- catch {
521
- // Expected
522
- }
523
- expect(runSubcommand).toHaveBeenCalledWith('wtlink', ['validate']);
539
+ describe('sync via wtlink link', () => {
540
+ it('calls wtlink link library function', async () => {
541
+ vi.mocked(promptChoice).mockResolvedValueOnce('sync');
542
+ const result = await flows.handleLinkConfig();
543
+ expect(runWtlinkLink).toHaveBeenCalledWith(expect.objectContaining({
544
+ manifestFile: '.wtlinkrc',
545
+ dryRun: false,
546
+ type: 'hard',
547
+ }));
548
+ expect(result).toEqual({ completed: true, returnToMenu: true });
549
+ });
550
+ it('shows error when sync fails', async () => {
551
+ vi.mocked(runWtlinkLink).mockRejectedValueOnce(new Error('Link failed'));
552
+ vi.mocked(promptChoice).mockResolvedValueOnce('sync');
553
+ const result = await flows.handleLinkConfig();
554
+ expect(result).toEqual({ completed: true, returnToMenu: true });
555
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('Link failed'));
556
+ });
557
+ });
558
+ describe('add via library', () => {
559
+ it('adds file to manifest via saveManifestData', async () => {
560
+ vi.mocked(promptChoice).mockResolvedValueOnce('add');
561
+ vi.mocked(promptInput).mockResolvedValueOnce('.npmrc');
562
+ const result = await flows.handleLinkConfig();
563
+ expect(saveManifestData).toHaveBeenCalledWith('/mock/repo', ['.env', '.env.local', '.npmrc'], ['config.json']);
564
+ expect(result).toEqual({ completed: true, returnToMenu: true });
565
+ });
566
+ it('skips duplicate files', async () => {
567
+ vi.mocked(promptChoice).mockResolvedValueOnce('add');
568
+ vi.mocked(promptInput).mockResolvedValueOnce('.env');
569
+ const result = await flows.handleLinkConfig();
570
+ expect(saveManifestData).not.toHaveBeenCalled();
571
+ expect(result).toEqual({ completed: true, returnToMenu: true });
572
+ });
573
+ it('returns CANCELLED when file path is empty', async () => {
574
+ vi.mocked(promptChoice).mockResolvedValueOnce('add');
575
+ vi.mocked(promptInput).mockResolvedValueOnce('');
576
+ const result = await flows.handleLinkConfig();
577
+ expect(result).toEqual({ completed: false, returnToMenu: true });
578
+ expect(saveManifestData).not.toHaveBeenCalled();
579
+ });
580
+ });
581
+ describe('remove via library', () => {
582
+ it('removes file from manifest via saveManifestData', async () => {
583
+ vi.mocked(promptChoice).mockResolvedValueOnce('remove');
584
+ vi.mocked(promptInput).mockResolvedValueOnce('.env');
585
+ const result = await flows.handleLinkConfig();
586
+ expect(saveManifestData).toHaveBeenCalledWith('/mock/repo', ['.env.local'], ['config.json']);
587
+ expect(result).toEqual({ completed: true, returnToMenu: true });
588
+ });
589
+ it('handles file not in manifest', async () => {
590
+ vi.mocked(promptChoice).mockResolvedValueOnce('remove');
591
+ vi.mocked(promptInput).mockResolvedValueOnce('nonexistent.txt');
592
+ const result = await flows.handleLinkConfig();
593
+ expect(saveManifestData).not.toHaveBeenCalled();
594
+ expect(result).toEqual({ completed: true, returnToMenu: true });
595
+ });
596
+ it('returns CANCELLED when file path is empty', async () => {
597
+ vi.mocked(promptChoice).mockResolvedValueOnce('remove');
598
+ vi.mocked(promptInput).mockResolvedValueOnce('');
599
+ const result = await flows.handleLinkConfig();
600
+ expect(result).toEqual({ completed: false, returnToMenu: true });
601
+ });
602
+ });
603
+ describe('validate', () => {
604
+ it('calls wtlink validate and returns to menu', async () => {
605
+ vi.mocked(promptChoice).mockResolvedValueOnce('validate');
606
+ const result = await flows.handleLinkConfig();
607
+ expect(runWtlinkValidate).toHaveBeenCalledWith(expect.objectContaining({
608
+ manifestFile: '.wtlinkrc',
609
+ }));
610
+ expect(result).toEqual({ completed: true, returnToMenu: true });
611
+ });
524
612
  });
525
613
  it('handles user cancellation', async () => {
526
614
  vi.mocked(promptChoice).mockRejectedValueOnce(new Error('User cancelled'));
@@ -533,53 +621,41 @@ describe('Interactive Menu Flows', () => {
533
621
  vi.mocked(promptChoice).mockResolvedValueOnce('back');
534
622
  const result = await flows.handleConfigure();
535
623
  expect(result).toEqual({ completed: false, returnToMenu: true });
536
- expect(runSubcommand).not.toHaveBeenCalled();
537
624
  });
538
- it('view calls wtconfig show', async () => {
625
+ it('view calls formatConfigDisplay and returns to menu', async () => {
539
626
  vi.mocked(promptChoice).mockResolvedValueOnce('view');
540
- try {
541
- await flows.handleConfigure();
542
- }
543
- catch {
544
- // Expected
545
- }
546
- expect(runSubcommand).toHaveBeenCalledWith('wtconfig', ['show']);
547
- });
548
- it('init calls wtconfig init after confirmation', async () => {
627
+ const result = await flows.handleConfigure();
628
+ expect(loadRepoConfig).toHaveBeenCalledWith('/mock/repo');
629
+ expect(formatConfigDisplay).toHaveBeenCalled();
630
+ expect(result).toEqual({ completed: true, returnToMenu: true });
631
+ });
632
+ it('init shows redirect message after confirmation and returns to menu', async () => {
549
633
  vi.mocked(promptChoice).mockResolvedValueOnce('init');
550
634
  vi.mocked(promptConfirm).mockResolvedValueOnce(true);
551
- try {
552
- await flows.handleConfigure();
553
- }
554
- catch {
555
- // Expected
556
- }
557
- expect(runSubcommand).toHaveBeenCalledWith('wtconfig', ['init']);
635
+ const result = await flows.handleConfigure();
636
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('wt init'));
637
+ expect(result).toEqual({ completed: true, returnToMenu: true });
558
638
  });
559
639
  it('init returns CANCELLED when not confirmed', async () => {
560
640
  vi.mocked(promptChoice).mockResolvedValueOnce('init');
561
641
  vi.mocked(promptConfirm).mockResolvedValueOnce(false);
562
642
  const result = await flows.handleConfigure();
563
643
  expect(result).toEqual({ completed: false, returnToMenu: true });
564
- expect(runSubcommand).not.toHaveBeenCalled();
565
644
  });
566
- it('edit calls wtconfig set with setting and value', async () => {
645
+ it('edit calls setConfigValue and saveRepoConfig with setting and value', async () => {
567
646
  vi.mocked(promptChoice).mockResolvedValueOnce('edit').mockResolvedValueOnce('baseBranch');
568
647
  vi.mocked(promptInput).mockResolvedValueOnce('develop');
569
- try {
570
- await flows.handleConfigure();
571
- }
572
- catch {
573
- // Expected
574
- }
575
- expect(runSubcommand).toHaveBeenCalledWith('wtconfig', ['set', 'baseBranch', 'develop']);
648
+ const result = await flows.handleConfigure();
649
+ expect(setConfigValue).toHaveBeenCalledWith({}, 'baseBranch', 'develop');
650
+ expect(saveRepoConfig).toHaveBeenCalled();
651
+ expect(result).toEqual({ completed: true, returnToMenu: true });
576
652
  });
577
653
  it('edit returns CANCELLED when value is empty', async () => {
578
654
  vi.mocked(promptChoice).mockResolvedValueOnce('edit').mockResolvedValueOnce('branchPrefix');
579
655
  vi.mocked(promptInput).mockResolvedValueOnce('');
580
656
  const result = await flows.handleConfigure();
581
657
  expect(result).toEqual({ completed: false, returnToMenu: true });
582
- expect(runSubcommand).not.toHaveBeenCalled();
658
+ expect(saveRepoConfig).not.toHaveBeenCalled();
583
659
  });
584
660
  it('handles user cancellation', async () => {
585
661
  vi.mocked(promptChoice).mockRejectedValueOnce(new Error('User cancelled'));
@@ -591,12 +667,12 @@ describe('Interactive Menu Flows', () => {
591
667
  it('exits on exit selection', async () => {
592
668
  vi.mocked(promptChoice).mockResolvedValueOnce('exit');
593
669
  await showMainMenu();
594
- expect(runSubcommand).not.toHaveBeenCalled();
670
+ expect(runNewprHandler).not.toHaveBeenCalled();
595
671
  });
596
672
  it('exits on user cancellation', async () => {
597
673
  vi.mocked(promptChoice).mockRejectedValueOnce(new Error('User cancelled'));
598
674
  await showMainMenu();
599
- expect(runSubcommand).not.toHaveBeenCalled();
675
+ expect(runNewprHandler).not.toHaveBeenCalled();
600
676
  });
601
677
  it('re-throws non-cancellation errors', async () => {
602
678
  vi.mocked(promptChoice).mockRejectedValueOnce(new Error('Some other error'));
@@ -611,35 +687,29 @@ describe('Interactive Menu Flows', () => {
611
687
  // Should have called promptChoice 3 times (menu -> sub-menu -> back to menu -> exit)
612
688
  expect(promptChoice).toHaveBeenCalledTimes(3);
613
689
  });
614
- it('handles list worktrees selection', async () => {
615
- vi.mocked(promptChoice).mockResolvedValueOnce('list');
616
- try {
617
- await showMainMenu();
618
- }
619
- catch {
620
- // Expected - runSubcommand throws
621
- }
622
- expect(runSubcommand).toHaveBeenCalledWith('lswt', []);
623
- });
624
- it('handles browse PRs selection', async () => {
625
- vi.mocked(promptChoice).mockResolvedValueOnce('browse-prs');
626
- try {
627
- await showMainMenu();
628
- }
629
- catch {
630
- // Expected - runSubcommand throws
631
- }
632
- expect(runSubcommand).toHaveBeenCalledWith('prs', []);
633
- });
634
- it('handles show state selection', async () => {
635
- vi.mocked(promptChoice).mockResolvedValueOnce('state');
636
- try {
637
- await showMainMenu();
638
- }
639
- catch {
640
- // Expected
641
- }
642
- expect(runSubcommand).toHaveBeenCalledWith('wtstate', []);
690
+ it('handles list worktrees and returns to menu', async () => {
691
+ vi.mocked(promptChoice)
692
+ .mockResolvedValueOnce('list') // Select list
693
+ .mockResolvedValueOnce('exit'); // Then exit
694
+ await showMainMenu();
695
+ expect(gatherWorktreeInfo).toHaveBeenCalled();
696
+ expect(promptChoice).toHaveBeenCalledTimes(2);
697
+ });
698
+ it('handles browse PRs and returns to menu', async () => {
699
+ vi.mocked(promptChoice)
700
+ .mockResolvedValueOnce('browse-prs') // Select browse-prs
701
+ .mockResolvedValueOnce('exit'); // Then exit
702
+ await showMainMenu();
703
+ expect(runPrsCommand).toHaveBeenCalled();
704
+ expect(promptChoice).toHaveBeenCalledTimes(2);
705
+ });
706
+ it('handles show state and returns to menu', async () => {
707
+ vi.mocked(promptChoice)
708
+ .mockResolvedValueOnce('state') // Select state
709
+ .mockResolvedValueOnce('exit'); // Then exit
710
+ await showMainMenu();
711
+ expect(analyzeState).toHaveBeenCalled();
712
+ expect(promptChoice).toHaveBeenCalledTimes(2);
643
713
  });
644
714
  });
645
715
  describe('FlowResult types', () => {
@@ -649,17 +719,10 @@ describe('Interactive Menu Flows', () => {
649
719
  expect(result.completed).toBe(false);
650
720
  expect(result.returnToMenu).toBe(true);
651
721
  });
652
- it('flows that run subcommands return COMPLETED_EXIT', async () => {
722
+ it('flows that run operations return completed with returnToMenu=true', async () => {
653
723
  vi.mocked(promptChoice).mockResolvedValueOnce('dry-run');
654
- // We can't test the actual return value since runSubcommand throws,
655
- // but we can verify the flow attempted to call the subcommand
656
- try {
657
- await flows.handleCleanPRs();
658
- }
659
- catch {
660
- // Expected
661
- }
662
- expect(runSubcommand).toHaveBeenCalled();
724
+ const result = await flows.handleCleanPRs();
725
+ expect(result).toEqual({ completed: true, returnToMenu: true });
663
726
  });
664
727
  });
665
728
  });
@@ -702,16 +765,14 @@ describe('Config loading in flows', () => {
702
765
  vi.mocked(promptChoice).mockResolvedValueOnce('from-description').mockResolvedValueOnce(true);
703
766
  vi.mocked(promptInput).mockResolvedValueOnce('Test feature').mockResolvedValueOnce('develop'); // User accepts default
704
767
  vi.mocked(promptConfirm).mockResolvedValueOnce(false).mockResolvedValueOnce(false);
705
- try {
706
- await flows.handleNewPR();
707
- }
708
- catch {
709
- // Expected
710
- }
768
+ const result = await flows.handleNewPR();
711
769
  // Verify loadConfig was called
712
770
  expect(loadConfig).toHaveBeenCalled();
713
- // Since user entered 'develop' (matching config default), no --base flag
714
- expect(runSubcommand).toHaveBeenCalledWith('newpr', ['Test feature', '--base', 'develop']);
771
+ // Verify runNewprHandler was called with develop base branch
772
+ expect(runNewprHandler).toHaveBeenCalledWith(expect.objectContaining({
773
+ baseBranch: 'develop',
774
+ }));
775
+ expect(result).toEqual({ completed: true, returnToMenu: true });
715
776
  });
716
777
  });
717
778
  describe('Git branch listing in flows', () => {
@@ -726,14 +787,10 @@ describe('Git branch listing in flows', () => {
726
787
  .mockResolvedValueOnce('feat/existing-branch')
727
788
  .mockResolvedValueOnce(true);
728
789
  vi.mocked(promptInput).mockResolvedValueOnce('main');
729
- try {
730
- await flows.handleNewPR();
731
- }
732
- catch {
733
- // Expected
734
- }
790
+ const result = await flows.handleNewPR();
735
791
  // Check that listLocalBranches was called
736
792
  expect(git.listLocalBranches).toHaveBeenCalled();
793
+ expect(result).toEqual({ completed: true, returnToMenu: true });
737
794
  });
738
795
  });
739
796
  //# sourceMappingURL=interactive-menu.test.js.map