@camaradesuk/git-worktree-tools 1.3.0 → 1.4.1

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 (284) hide show
  1. package/README.md +290 -3
  2. package/dist/api/clean.d.ts +65 -0
  3. package/dist/api/clean.d.ts.map +1 -0
  4. package/dist/api/clean.js +209 -0
  5. package/dist/api/clean.js.map +1 -0
  6. package/dist/api/create.d.ts +88 -0
  7. package/dist/api/create.d.ts.map +1 -0
  8. package/dist/api/create.js +373 -0
  9. package/dist/api/create.js.map +1 -0
  10. package/dist/api/index.d.ts +15 -0
  11. package/dist/api/index.d.ts.map +1 -0
  12. package/dist/api/index.js +19 -0
  13. package/dist/api/index.js.map +1 -0
  14. package/dist/api/list.d.ts +74 -0
  15. package/dist/api/list.d.ts.map +1 -0
  16. package/dist/api/list.js +80 -0
  17. package/dist/api/list.js.map +1 -0
  18. package/dist/api/state.d.ts +43 -0
  19. package/dist/api/state.d.ts.map +1 -0
  20. package/dist/api/state.js +70 -0
  21. package/dist/api/state.js.map +1 -0
  22. package/dist/cli/cleanpr.js +171 -28
  23. package/dist/cli/cleanpr.js.map +1 -1
  24. package/dist/cli/cleanpr.test.js +459 -7
  25. package/dist/cli/cleanpr.test.js.map +1 -1
  26. package/dist/cli/newpr.js +189 -28
  27. package/dist/cli/newpr.js.map +1 -1
  28. package/dist/cli/newpr.test.js +349 -0
  29. package/dist/cli/newpr.test.js.map +1 -1
  30. package/dist/cli/wtconfig.d.ts +14 -0
  31. package/dist/cli/wtconfig.d.ts.map +1 -0
  32. package/dist/cli/wtconfig.js +948 -0
  33. package/dist/cli/wtconfig.js.map +1 -0
  34. package/dist/cli/wtconfig.test.d.ts +5 -0
  35. package/dist/cli/wtconfig.test.d.ts.map +1 -0
  36. package/dist/cli/wtconfig.test.js +1281 -0
  37. package/dist/cli/wtconfig.test.js.map +1 -0
  38. package/dist/cli/wtlink.js +5 -0
  39. package/dist/cli/wtlink.js.map +1 -1
  40. package/dist/cli/wtstate.d.ts +8 -0
  41. package/dist/cli/wtstate.d.ts.map +1 -0
  42. package/dist/cli/wtstate.js +83 -0
  43. package/dist/cli/wtstate.js.map +1 -0
  44. package/dist/cli/wtstate.test.d.ts +5 -0
  45. package/dist/cli/wtstate.test.d.ts.map +1 -0
  46. package/dist/cli/wtstate.test.js +193 -0
  47. package/dist/cli/wtstate.test.js.map +1 -0
  48. package/dist/e2e/cleanpr/cleanpr.e2e.test.d.ts +2 -0
  49. package/dist/e2e/cleanpr/cleanpr.e2e.test.d.ts.map +1 -0
  50. package/dist/e2e/cleanpr/cleanpr.e2e.test.js +326 -0
  51. package/dist/e2e/cleanpr/cleanpr.e2e.test.js.map +1 -0
  52. package/dist/e2e/helpers/cli-runner.d.ts +103 -0
  53. package/dist/e2e/helpers/cli-runner.d.ts.map +1 -0
  54. package/dist/e2e/helpers/cli-runner.js +200 -0
  55. package/dist/e2e/helpers/cli-runner.js.map +1 -0
  56. package/dist/e2e/helpers/gh-mock.d.ts +87 -0
  57. package/dist/e2e/helpers/gh-mock.d.ts.map +1 -0
  58. package/dist/e2e/helpers/gh-mock.js +384 -0
  59. package/dist/e2e/helpers/gh-mock.js.map +1 -0
  60. package/dist/e2e/helpers/index.d.ts +12 -0
  61. package/dist/e2e/helpers/index.d.ts.map +1 -0
  62. package/dist/e2e/helpers/index.js +16 -0
  63. package/dist/e2e/helpers/index.js.map +1 -0
  64. package/dist/e2e/helpers/pty-wrapper.d.ts +118 -0
  65. package/dist/e2e/helpers/pty-wrapper.d.ts.map +1 -0
  66. package/dist/e2e/helpers/pty-wrapper.js +276 -0
  67. package/dist/e2e/helpers/pty-wrapper.js.map +1 -0
  68. package/dist/e2e/helpers/scenario-harness.d.ts +55 -0
  69. package/dist/e2e/helpers/scenario-harness.d.ts.map +1 -0
  70. package/dist/e2e/helpers/scenario-harness.js +360 -0
  71. package/dist/e2e/helpers/scenario-harness.js.map +1 -0
  72. package/dist/e2e/helpers/test-context.d.ts +120 -0
  73. package/dist/e2e/helpers/test-context.d.ts.map +1 -0
  74. package/dist/e2e/helpers/test-context.js +263 -0
  75. package/dist/e2e/helpers/test-context.js.map +1 -0
  76. package/dist/e2e/lswt/lswt.e2e.test.d.ts +2 -0
  77. package/dist/e2e/lswt/lswt.e2e.test.d.ts.map +1 -0
  78. package/dist/e2e/lswt/lswt.e2e.test.js +328 -0
  79. package/dist/e2e/lswt/lswt.e2e.test.js.map +1 -0
  80. package/dist/e2e/newpr/newpr.e2e.test.d.ts +2 -0
  81. package/dist/e2e/newpr/newpr.e2e.test.d.ts.map +1 -0
  82. package/dist/e2e/newpr/newpr.e2e.test.js +286 -0
  83. package/dist/e2e/newpr/newpr.e2e.test.js.map +1 -0
  84. package/dist/e2e/newpr/scenarios.e2e.test.d.ts +2 -0
  85. package/dist/e2e/newpr/scenarios.e2e.test.d.ts.map +1 -0
  86. package/dist/e2e/newpr/scenarios.e2e.test.js +426 -0
  87. package/dist/e2e/newpr/scenarios.e2e.test.js.map +1 -0
  88. package/dist/e2e/workflows/pr-lifecycle.e2e.test.d.ts +2 -0
  89. package/dist/e2e/workflows/pr-lifecycle.e2e.test.d.ts.map +1 -0
  90. package/dist/e2e/workflows/pr-lifecycle.e2e.test.js +298 -0
  91. package/dist/e2e/workflows/pr-lifecycle.e2e.test.js.map +1 -0
  92. package/dist/e2e/wtlink/wtlink.e2e.test.d.ts +2 -0
  93. package/dist/e2e/wtlink/wtlink.e2e.test.d.ts.map +1 -0
  94. package/dist/e2e/wtlink/wtlink.e2e.test.js +364 -0
  95. package/dist/e2e/wtlink/wtlink.e2e.test.js.map +1 -0
  96. package/dist/index.d.ts +15 -1
  97. package/dist/index.d.ts.map +1 -1
  98. package/dist/index.js +14 -0
  99. package/dist/index.js.map +1 -1
  100. package/dist/lib/ai/base-provider.d.ts +58 -0
  101. package/dist/lib/ai/base-provider.d.ts.map +1 -0
  102. package/dist/lib/ai/base-provider.js +246 -0
  103. package/dist/lib/ai/base-provider.js.map +1 -0
  104. package/dist/lib/ai/base-provider.test.d.ts +7 -0
  105. package/dist/lib/ai/base-provider.test.d.ts.map +1 -0
  106. package/dist/lib/ai/base-provider.test.js +320 -0
  107. package/dist/lib/ai/base-provider.test.js.map +1 -0
  108. package/dist/lib/ai/cli-provider.d.ts +87 -0
  109. package/dist/lib/ai/cli-provider.d.ts.map +1 -0
  110. package/dist/lib/ai/cli-provider.js +280 -0
  111. package/dist/lib/ai/cli-provider.js.map +1 -0
  112. package/dist/lib/ai/cli-provider.test.d.ts +5 -0
  113. package/dist/lib/ai/cli-provider.test.d.ts.map +1 -0
  114. package/dist/lib/ai/cli-provider.test.js +462 -0
  115. package/dist/lib/ai/cli-provider.test.js.map +1 -0
  116. package/dist/lib/ai/fallback-provider.d.ts +20 -0
  117. package/dist/lib/ai/fallback-provider.d.ts.map +1 -0
  118. package/dist/lib/ai/fallback-provider.js +125 -0
  119. package/dist/lib/ai/fallback-provider.js.map +1 -0
  120. package/dist/lib/ai/fallback-provider.test.d.ts +7 -0
  121. package/dist/lib/ai/fallback-provider.test.d.ts.map +1 -0
  122. package/dist/lib/ai/fallback-provider.test.js +165 -0
  123. package/dist/lib/ai/fallback-provider.test.js.map +1 -0
  124. package/dist/lib/ai/generation-service.d.ts +44 -0
  125. package/dist/lib/ai/generation-service.d.ts.map +1 -0
  126. package/dist/lib/ai/generation-service.js +107 -0
  127. package/dist/lib/ai/generation-service.js.map +1 -0
  128. package/dist/lib/ai/generation-service.test.d.ts +7 -0
  129. package/dist/lib/ai/generation-service.test.d.ts.map +1 -0
  130. package/dist/lib/ai/generation-service.test.js +213 -0
  131. package/dist/lib/ai/generation-service.test.js.map +1 -0
  132. package/dist/lib/ai/index.d.ts +19 -0
  133. package/dist/lib/ai/index.d.ts.map +1 -0
  134. package/dist/lib/ai/index.js +22 -0
  135. package/dist/lib/ai/index.js.map +1 -0
  136. package/dist/lib/ai/provider-manager.d.ts +109 -0
  137. package/dist/lib/ai/provider-manager.d.ts.map +1 -0
  138. package/dist/lib/ai/provider-manager.js +270 -0
  139. package/dist/lib/ai/provider-manager.js.map +1 -0
  140. package/dist/lib/ai/provider-manager.test.d.ts +5 -0
  141. package/dist/lib/ai/provider-manager.test.d.ts.map +1 -0
  142. package/dist/lib/ai/provider-manager.test.js +312 -0
  143. package/dist/lib/ai/provider-manager.test.js.map +1 -0
  144. package/dist/lib/ai/types.d.ts +166 -0
  145. package/dist/lib/ai/types.d.ts.map +1 -0
  146. package/dist/lib/ai/types.js +19 -0
  147. package/dist/lib/ai/types.js.map +1 -0
  148. package/dist/lib/cleanpr/args.d.ts.map +1 -1
  149. package/dist/lib/cleanpr/args.js +18 -0
  150. package/dist/lib/cleanpr/args.js.map +1 -1
  151. package/dist/lib/cleanpr/args.test.js +88 -11
  152. package/dist/lib/cleanpr/args.test.js.map +1 -1
  153. package/dist/lib/cleanpr/cleanup.d.ts +2 -0
  154. package/dist/lib/cleanpr/cleanup.d.ts.map +1 -1
  155. package/dist/lib/cleanpr/cleanup.js +30 -2
  156. package/dist/lib/cleanpr/cleanup.js.map +1 -1
  157. package/dist/lib/cleanpr/cleanup.test.js +37 -5
  158. package/dist/lib/cleanpr/cleanup.test.js.map +1 -1
  159. package/dist/lib/cleanpr/types.d.ts +10 -0
  160. package/dist/lib/cleanpr/types.d.ts.map +1 -1
  161. package/dist/lib/cleanpr/worktree-info.test.js +72 -1
  162. package/dist/lib/cleanpr/worktree-info.test.js.map +1 -1
  163. package/dist/lib/config.d.ts +170 -1
  164. package/dist/lib/config.d.ts.map +1 -1
  165. package/dist/lib/config.js +129 -2
  166. package/dist/lib/config.js.map +1 -1
  167. package/dist/lib/config.test.js +406 -2
  168. package/dist/lib/config.test.js.map +1 -1
  169. package/dist/lib/hooks/executor.d.ts +35 -0
  170. package/dist/lib/hooks/executor.d.ts.map +1 -0
  171. package/dist/lib/hooks/executor.js +401 -0
  172. package/dist/lib/hooks/executor.js.map +1 -0
  173. package/dist/lib/hooks/executor.test.d.ts +5 -0
  174. package/dist/lib/hooks/executor.test.d.ts.map +1 -0
  175. package/dist/lib/hooks/executor.test.js +648 -0
  176. package/dist/lib/hooks/executor.test.js.map +1 -0
  177. package/dist/lib/hooks/index.d.ts +25 -0
  178. package/dist/lib/hooks/index.d.ts.map +1 -0
  179. package/dist/lib/hooks/index.js +26 -0
  180. package/dist/lib/hooks/index.js.map +1 -0
  181. package/dist/lib/hooks/templates.d.ts +74 -0
  182. package/dist/lib/hooks/templates.d.ts.map +1 -0
  183. package/dist/lib/hooks/templates.js +270 -0
  184. package/dist/lib/hooks/templates.js.map +1 -0
  185. package/dist/lib/hooks/templates.test.d.ts +5 -0
  186. package/dist/lib/hooks/templates.test.d.ts.map +1 -0
  187. package/dist/lib/hooks/templates.test.js +163 -0
  188. package/dist/lib/hooks/templates.test.js.map +1 -0
  189. package/dist/lib/hooks/types.d.ts +161 -0
  190. package/dist/lib/hooks/types.d.ts.map +1 -0
  191. package/dist/lib/hooks/types.js +73 -0
  192. package/dist/lib/hooks/types.js.map +1 -0
  193. package/dist/lib/hooks/types.test.d.ts +5 -0
  194. package/dist/lib/hooks/types.test.d.ts.map +1 -0
  195. package/dist/lib/hooks/types.test.js +132 -0
  196. package/dist/lib/hooks/types.test.js.map +1 -0
  197. package/dist/lib/json-output.d.ts +172 -0
  198. package/dist/lib/json-output.d.ts.map +1 -0
  199. package/dist/lib/json-output.js +134 -0
  200. package/dist/lib/json-output.js.map +1 -0
  201. package/dist/lib/json-output.test.d.ts +5 -0
  202. package/dist/lib/json-output.test.d.ts.map +1 -0
  203. package/dist/lib/json-output.test.js +259 -0
  204. package/dist/lib/json-output.test.js.map +1 -0
  205. package/dist/lib/lswt/action-executors.test.js +6 -0
  206. package/dist/lib/lswt/action-executors.test.js.map +1 -1
  207. package/dist/lib/newpr/action-deps.d.ts +15 -0
  208. package/dist/lib/newpr/action-deps.d.ts.map +1 -0
  209. package/dist/lib/newpr/action-deps.js +22 -0
  210. package/dist/lib/newpr/action-deps.js.map +1 -0
  211. package/dist/lib/newpr/args.d.ts.map +1 -1
  212. package/dist/lib/newpr/args.js +56 -0
  213. package/dist/lib/newpr/args.js.map +1 -1
  214. package/dist/lib/newpr/hook-runner.d.ts +80 -0
  215. package/dist/lib/newpr/hook-runner.d.ts.map +1 -0
  216. package/dist/lib/newpr/hook-runner.js +182 -0
  217. package/dist/lib/newpr/hook-runner.js.map +1 -0
  218. package/dist/lib/newpr/hook-runner.test.d.ts +7 -0
  219. package/dist/lib/newpr/hook-runner.test.d.ts.map +1 -0
  220. package/dist/lib/newpr/hook-runner.test.js +301 -0
  221. package/dist/lib/newpr/hook-runner.test.js.map +1 -0
  222. package/dist/lib/newpr/index.d.ts +3 -0
  223. package/dist/lib/newpr/index.d.ts.map +1 -1
  224. package/dist/lib/newpr/index.js +3 -0
  225. package/dist/lib/newpr/index.js.map +1 -1
  226. package/dist/lib/newpr/types.d.ts +9 -0
  227. package/dist/lib/newpr/types.d.ts.map +1 -1
  228. package/dist/lib/wtconfig/config-manager.d.ts +72 -0
  229. package/dist/lib/wtconfig/config-manager.d.ts.map +1 -0
  230. package/dist/lib/wtconfig/config-manager.js +408 -0
  231. package/dist/lib/wtconfig/config-manager.js.map +1 -0
  232. package/dist/lib/wtconfig/config-manager.test.d.ts +5 -0
  233. package/dist/lib/wtconfig/config-manager.test.d.ts.map +1 -0
  234. package/dist/lib/wtconfig/config-manager.test.js +501 -0
  235. package/dist/lib/wtconfig/config-manager.test.js.map +1 -0
  236. package/dist/lib/wtconfig/environment.d.ts +23 -0
  237. package/dist/lib/wtconfig/environment.d.ts.map +1 -0
  238. package/dist/lib/wtconfig/environment.js +242 -0
  239. package/dist/lib/wtconfig/environment.js.map +1 -0
  240. package/dist/lib/wtconfig/environment.test.d.ts +5 -0
  241. package/dist/lib/wtconfig/environment.test.d.ts.map +1 -0
  242. package/dist/lib/wtconfig/environment.test.js +246 -0
  243. package/dist/lib/wtconfig/environment.test.js.map +1 -0
  244. package/dist/lib/wtconfig/index.d.ts +7 -0
  245. package/dist/lib/wtconfig/index.d.ts.map +1 -0
  246. package/dist/lib/wtconfig/index.js +8 -0
  247. package/dist/lib/wtconfig/index.js.map +1 -0
  248. package/dist/lib/wtconfig/types.d.ts +97 -0
  249. package/dist/lib/wtconfig/types.d.ts.map +1 -0
  250. package/dist/lib/wtconfig/types.js +5 -0
  251. package/dist/lib/wtconfig/types.js.map +1 -0
  252. package/dist/lib/wtstate/analyze.d.ts +13 -0
  253. package/dist/lib/wtstate/analyze.d.ts.map +1 -0
  254. package/dist/lib/wtstate/analyze.js +165 -0
  255. package/dist/lib/wtstate/analyze.js.map +1 -0
  256. package/dist/lib/wtstate/analyze.test.d.ts +5 -0
  257. package/dist/lib/wtstate/analyze.test.d.ts.map +1 -0
  258. package/dist/lib/wtstate/analyze.test.js +282 -0
  259. package/dist/lib/wtstate/analyze.test.js.map +1 -0
  260. package/dist/lib/wtstate/args.d.ts +17 -0
  261. package/dist/lib/wtstate/args.d.ts.map +1 -0
  262. package/dist/lib/wtstate/args.js +91 -0
  263. package/dist/lib/wtstate/args.js.map +1 -0
  264. package/dist/lib/wtstate/args.test.d.ts +5 -0
  265. package/dist/lib/wtstate/args.test.d.ts.map +1 -0
  266. package/dist/lib/wtstate/args.test.js +120 -0
  267. package/dist/lib/wtstate/args.test.js.map +1 -0
  268. package/dist/lib/wtstate/index.d.ts +7 -0
  269. package/dist/lib/wtstate/index.d.ts.map +1 -0
  270. package/dist/lib/wtstate/index.js +8 -0
  271. package/dist/lib/wtstate/index.js.map +1 -0
  272. package/dist/lib/wtstate/types.d.ts +64 -0
  273. package/dist/lib/wtstate/types.d.ts.map +1 -0
  274. package/dist/lib/wtstate/types.js +5 -0
  275. package/dist/lib/wtstate/types.js.map +1 -0
  276. package/dist/mcp/server.d.ts +14 -0
  277. package/dist/mcp/server.d.ts.map +1 -0
  278. package/dist/mcp/server.js +339 -0
  279. package/dist/mcp/server.js.map +1 -0
  280. package/dist/mcp/server.test.d.ts +9 -0
  281. package/dist/mcp/server.test.d.ts.map +1 -0
  282. package/dist/mcp/server.test.js +390 -0
  283. package/dist/mcp/server.test.js.map +1 -0
  284. package/package.json +8 -2
@@ -0,0 +1,1281 @@
1
+ /**
2
+ * wtconfig CLI Tests
3
+ */
4
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
5
+ // Mock modules before importing
6
+ vi.mock('inquirer');
7
+ vi.mock('child_process');
8
+ vi.mock('../lib/git.js');
9
+ vi.mock('../lib/config.js');
10
+ vi.mock('../lib/wtconfig/index.js');
11
+ import inquirer from 'inquirer';
12
+ import { execSync } from 'child_process';
13
+ import * as git from '../lib/git.js';
14
+ import { getDefaultConfig } from '../lib/config.js';
15
+ import * as wtconfig from '../lib/wtconfig/index.js';
16
+ describe('cli/wtconfig', () => {
17
+ let mockConsoleLog;
18
+ let mockConsoleError;
19
+ let mockConsoleWarn;
20
+ let mockProcessExit;
21
+ let originalArgv;
22
+ const mockConfig = {
23
+ baseBranch: 'main',
24
+ draftPr: false,
25
+ branchPrefix: 'feat',
26
+ worktreePattern: '{repo}.pr{number}',
27
+ worktreeParent: '..',
28
+ };
29
+ const mockDefaultConfig = {
30
+ baseBranch: 'main',
31
+ draftPr: false,
32
+ branchPrefix: 'feat',
33
+ worktreePattern: '{repo}.pr{number}',
34
+ worktreeParent: '..',
35
+ sharedRepos: [],
36
+ syncPatterns: [],
37
+ preferredEditor: 'auto',
38
+ plugins: [],
39
+ generators: {},
40
+ integrations: {},
41
+ ai: {
42
+ provider: 'none',
43
+ branchName: false,
44
+ prTitle: false,
45
+ prDescription: false,
46
+ },
47
+ hooks: {},
48
+ hookDefaults: { timeout: 30000, maxTimeout: 60000 },
49
+ };
50
+ beforeEach(() => {
51
+ vi.resetAllMocks();
52
+ vi.resetModules();
53
+ mockConsoleLog = vi.spyOn(console, 'log').mockImplementation(() => { });
54
+ mockConsoleError = vi.spyOn(console, 'error').mockImplementation(() => { });
55
+ mockConsoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => { });
56
+ // @ts-expect-error - process.exit mock type is complex
57
+ mockProcessExit = vi.spyOn(process, 'exit').mockImplementation((() => { }));
58
+ originalArgv = process.argv;
59
+ // Setup default mocks
60
+ vi.mocked(git.getRepoRoot).mockReturnValue('/repo');
61
+ vi.mocked(getDefaultConfig).mockReturnValue(mockDefaultConfig);
62
+ vi.mocked(wtconfig.loadMergedConfig).mockReturnValue(mockConfig);
63
+ vi.mocked(wtconfig.getConfigSource).mockReturnValue({
64
+ type: 'repository',
65
+ path: '/repo/.worktreerc',
66
+ });
67
+ vi.mocked(wtconfig.formatConfigDisplay).mockReturnValue('formatted config');
68
+ vi.mocked(wtconfig.validateConfig).mockReturnValue({ valid: true, errors: [], warnings: [] });
69
+ });
70
+ afterEach(() => {
71
+ mockConsoleLog.mockRestore();
72
+ mockConsoleError.mockRestore();
73
+ mockConsoleWarn.mockRestore();
74
+ mockProcessExit.mockRestore();
75
+ process.argv = originalArgv;
76
+ });
77
+ async function runCli(args = []) {
78
+ process.argv = ['node', 'wtconfig', ...args];
79
+ // Re-import to trigger CLI execution
80
+ await import('./wtconfig.js');
81
+ // Wait for async operations
82
+ await new Promise((resolve) => setTimeout(resolve, 50));
83
+ }
84
+ describe('show command', () => {
85
+ it('shows current configuration with source', async () => {
86
+ await runCli(['show']);
87
+ expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('Current Configuration'));
88
+ });
89
+ it('shows default message when no config exists', async () => {
90
+ vi.mocked(wtconfig.getConfigSource).mockReturnValue({ type: 'none', path: null });
91
+ await runCli(['show']);
92
+ expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('No configuration file found'));
93
+ });
94
+ it('shows config source path when config exists', async () => {
95
+ await runCli(['show']);
96
+ expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('Source:'));
97
+ });
98
+ });
99
+ describe('get command', () => {
100
+ it('displays error when no key provided', async () => {
101
+ await runCli(['get']);
102
+ expect(mockConsoleError).toHaveBeenCalledWith(expect.stringContaining('Usage: wtconfig get <key>'));
103
+ expect(mockProcessExit).toHaveBeenCalledWith(1);
104
+ });
105
+ it('returns value for valid key', async () => {
106
+ vi.mocked(wtconfig.getConfigValue).mockReturnValueOnce('develop');
107
+ await runCli(['get', 'baseBranch']);
108
+ expect(mockConsoleLog).toHaveBeenCalledWith('develop');
109
+ });
110
+ it('returns default value when key not in user config', async () => {
111
+ vi.mocked(wtconfig.getConfigValue).mockReturnValueOnce(undefined).mockReturnValueOnce('feat');
112
+ await runCli(['get', 'branchPrefix']);
113
+ expect(mockConsoleLog).toHaveBeenCalledWith('feat');
114
+ });
115
+ it('displays error for unknown key', async () => {
116
+ vi.mocked(wtconfig.getConfigValue).mockReturnValue(undefined);
117
+ await runCli(['get', 'unknownKey']);
118
+ expect(mockConsoleError).toHaveBeenCalledWith(expect.stringContaining('Unknown configuration key'));
119
+ expect(mockProcessExit).toHaveBeenCalledWith(1);
120
+ });
121
+ it('outputs JSON for object values', async () => {
122
+ vi.mocked(wtconfig.getConfigValue).mockReturnValueOnce({
123
+ provider: 'claude',
124
+ branchName: true,
125
+ });
126
+ await runCli(['get', 'ai']);
127
+ expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('"provider"'));
128
+ });
129
+ });
130
+ describe('set command', () => {
131
+ beforeEach(() => {
132
+ vi.mocked(inquirer.prompt).mockResolvedValue({ saveLocation: 'repo' });
133
+ vi.mocked(wtconfig.loadRepoConfig).mockReturnValue({});
134
+ vi.mocked(wtconfig.setConfigValue).mockReturnValue({ baseBranch: 'develop' });
135
+ });
136
+ it('displays error when no key provided', async () => {
137
+ await runCli(['set']);
138
+ expect(mockConsoleError).toHaveBeenCalledWith(expect.stringContaining('Usage: wtconfig set <key> <value>'));
139
+ expect(mockProcessExit).toHaveBeenCalledWith(1);
140
+ });
141
+ it('displays error when no value provided', async () => {
142
+ await runCli(['set', 'baseBranch']);
143
+ expect(mockConsoleError).toHaveBeenCalledWith(expect.stringContaining('Missing value for key'));
144
+ expect(mockProcessExit).toHaveBeenCalledWith(1);
145
+ });
146
+ it('prompts for save location and saves to repo', async () => {
147
+ await runCli(['set', 'baseBranch', 'develop']);
148
+ expect(inquirer.prompt).toHaveBeenCalled();
149
+ expect(wtconfig.saveRepoConfig).toHaveBeenCalledWith('/repo', expect.any(Object));
150
+ expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('Set baseBranch = develop'));
151
+ });
152
+ it('saves to global config when selected', async () => {
153
+ vi.mocked(inquirer.prompt).mockResolvedValue({ saveLocation: 'global' });
154
+ vi.mocked(wtconfig.loadGlobalConfig).mockReturnValue({});
155
+ await runCli(['set', 'baseBranch', 'develop']);
156
+ expect(wtconfig.saveGlobalConfig).toHaveBeenCalled();
157
+ expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('~/.worktreerc'));
158
+ });
159
+ it('shows validation errors and exits', async () => {
160
+ vi.mocked(wtconfig.validateConfig).mockReturnValue({
161
+ valid: false,
162
+ errors: [{ path: 'baseBranch', message: 'Invalid value' }],
163
+ warnings: [],
164
+ });
165
+ await runCli(['set', 'baseBranch', 'invalid']);
166
+ expect(mockConsoleError).toHaveBeenCalledWith(expect.stringContaining('validation failed'));
167
+ expect(mockProcessExit).toHaveBeenCalledWith(1);
168
+ });
169
+ it('shows validation warnings', async () => {
170
+ vi.mocked(wtconfig.validateConfig).mockReturnValue({
171
+ valid: true,
172
+ errors: [],
173
+ warnings: [{ path: 'ai.provider', message: 'Experimental feature' }],
174
+ });
175
+ await runCli(['set', 'ai.provider', 'claude']);
176
+ expect(mockConsoleWarn).toHaveBeenCalledWith(expect.stringContaining('Warning'));
177
+ });
178
+ });
179
+ describe('validate command', () => {
180
+ it('shows success when no config exists', async () => {
181
+ vi.mocked(wtconfig.getConfigSource).mockReturnValue({ type: 'none', path: null });
182
+ await runCli(['validate']);
183
+ expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('No configuration file found'));
184
+ });
185
+ it('shows success for valid config', async () => {
186
+ await runCli(['validate']);
187
+ expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('Configuration is valid'));
188
+ });
189
+ it('shows validation source path', async () => {
190
+ await runCli(['validate']);
191
+ expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('Validating:'));
192
+ });
193
+ it('shows errors and exits for invalid config', async () => {
194
+ vi.mocked(wtconfig.validateConfig).mockReturnValue({
195
+ valid: false,
196
+ errors: [
197
+ { path: 'baseBranch', message: 'Invalid branch name' },
198
+ { path: 'ai.provider', message: 'Unknown provider' },
199
+ ],
200
+ warnings: [],
201
+ });
202
+ await runCli(['validate']);
203
+ expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('Errors:'));
204
+ expect(mockProcessExit).toHaveBeenCalledWith(1);
205
+ });
206
+ it('shows warnings for valid config with warnings', async () => {
207
+ vi.mocked(wtconfig.validateConfig).mockReturnValue({
208
+ valid: true,
209
+ errors: [],
210
+ warnings: [{ path: 'sharedRepos', message: 'Repo not found' }],
211
+ });
212
+ await runCli(['validate']);
213
+ expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('Warnings:'));
214
+ });
215
+ it('shows both errors and warnings', async () => {
216
+ vi.mocked(wtconfig.validateConfig).mockReturnValue({
217
+ valid: false,
218
+ errors: [{ path: 'baseBranch', message: 'Invalid' }],
219
+ warnings: [{ path: 'ai.provider', message: 'Deprecated' }],
220
+ });
221
+ await runCli(['validate']);
222
+ expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('Errors:'));
223
+ expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('Warnings:'));
224
+ expect(mockProcessExit).toHaveBeenCalledWith(1);
225
+ });
226
+ });
227
+ describe('help command', () => {
228
+ it('shows help text for help command', async () => {
229
+ await runCli(['help']);
230
+ expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('wtconfig'));
231
+ expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('Usage:'));
232
+ });
233
+ it('shows help text for --help flag', async () => {
234
+ await runCli(['--help']);
235
+ expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('Usage:'));
236
+ });
237
+ it('shows help text for -h flag', async () => {
238
+ await runCli(['-h']);
239
+ expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('Usage:'));
240
+ });
241
+ });
242
+ describe('unknown command', () => {
243
+ it('shows error and help for unknown command', async () => {
244
+ await runCli(['unknown']);
245
+ expect(mockConsoleError).toHaveBeenCalledWith(expect.stringContaining('Unknown command'));
246
+ expect(mockProcessExit).toHaveBeenCalledWith(1);
247
+ });
248
+ });
249
+ describe('error handling', () => {
250
+ it('handles git repo not found gracefully', async () => {
251
+ vi.mocked(git.getRepoRoot).mockImplementation(() => {
252
+ throw new Error('Not a git repository');
253
+ });
254
+ vi.mocked(wtconfig.getConfigSource).mockReturnValue({
255
+ type: 'global',
256
+ path: '/home/user/.worktreerc',
257
+ });
258
+ await runCli(['show']);
259
+ // Should still show config (from global)
260
+ expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('Current Configuration'));
261
+ });
262
+ });
263
+ describe('edit command', () => {
264
+ beforeEach(() => {
265
+ vi.mocked(inquirer.prompt).mockResolvedValue({ editLocation: 'repo' });
266
+ vi.mocked(wtconfig.getDefaultRepoConfigPath).mockReturnValue('/repo/.worktreerc');
267
+ vi.mocked(wtconfig.getGlobalConfigPath).mockReturnValue('/home/user/.worktreerc');
268
+ });
269
+ it('prompts for location when editing', async () => {
270
+ // Mock fs.existsSync for the dynamic import
271
+ vi.doMock('fs', () => ({
272
+ existsSync: vi.fn().mockReturnValue(true),
273
+ }));
274
+ vi.mocked(execSync).mockImplementation(() => Buffer.from(''));
275
+ await runCli(['edit']);
276
+ expect(inquirer.prompt).toHaveBeenCalled();
277
+ });
278
+ });
279
+ describe('init/wizard command', () => {
280
+ const mockEnv = {
281
+ os: 'linux',
282
+ git: { version: '2.30.0', configured: true, user: 'testuser', email: 'test@example.com' },
283
+ github: { installed: true, authenticated: true, user: 'testuser' },
284
+ ai: { claudeCode: false, geminiCLI: false, ollama: false, openaiKey: false },
285
+ packageManager: 'npm',
286
+ ide: { vscode: true, cursor: false },
287
+ };
288
+ beforeEach(() => {
289
+ vi.mocked(wtconfig.detectEnvironment).mockReturnValue(mockEnv);
290
+ vi.mocked(wtconfig.detectDefaultBranch).mockReturnValue('main');
291
+ vi.mocked(wtconfig.getDefaultRepoConfigPath).mockReturnValue('/repo/.worktreerc');
292
+ vi.mocked(wtconfig.getGlobalConfigPath).mockReturnValue('/home/user/.worktreerc');
293
+ vi.mocked(wtconfig.getInstallCommand).mockReturnValue('npm install');
294
+ vi.mocked(wtconfig.getEditorCommand).mockReturnValue('code');
295
+ });
296
+ it('displays wizard header and detects environment', async () => {
297
+ // Mock all wizard prompts in sequence (Step 1 has 3 questions in one call)
298
+ vi.mocked(inquirer.prompt)
299
+ .mockResolvedValueOnce({ baseBranch: 'main', draftPr: false, branchPrefix: 'feat' })
300
+ .mockResolvedValueOnce({
301
+ worktreeLocation: 'sibling',
302
+ worktreePattern: '{repo}.pr{number}',
303
+ })
304
+ .mockResolvedValueOnce({ hooks: ['autoDeps'] })
305
+ .mockResolvedValueOnce({ saveChoice: 'repo' });
306
+ await runCli(['init']);
307
+ expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('Setup Wizard'));
308
+ expect(wtconfig.detectEnvironment).toHaveBeenCalled();
309
+ });
310
+ it('runs wizard command alias', async () => {
311
+ vi.mocked(inquirer.prompt)
312
+ .mockResolvedValueOnce({ baseBranch: 'main', draftPr: false, branchPrefix: 'feat' })
313
+ .mockResolvedValueOnce({
314
+ worktreeLocation: 'sibling',
315
+ worktreePattern: '{repo}.pr{number}',
316
+ })
317
+ .mockResolvedValueOnce({ hooks: [] })
318
+ .mockResolvedValueOnce({ saveChoice: 'cancel' });
319
+ await runCli(['wizard']);
320
+ expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('Setup Wizard'));
321
+ });
322
+ it('displays environment detection message', async () => {
323
+ vi.mocked(inquirer.prompt)
324
+ .mockResolvedValueOnce({ baseBranch: 'main', draftPr: false, branchPrefix: 'feat' })
325
+ .mockResolvedValueOnce({
326
+ worktreeLocation: 'sibling',
327
+ worktreePattern: '{repo}.pr{number}',
328
+ })
329
+ .mockResolvedValueOnce({ hooks: [] })
330
+ .mockResolvedValueOnce({ saveChoice: 'cancel' });
331
+ await runCli(['init']);
332
+ expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('Detecting'));
333
+ });
334
+ });
335
+ describe('show command with config values', () => {
336
+ it('shows AI config when present', async () => {
337
+ vi.mocked(wtconfig.loadMergedConfig).mockReturnValue({
338
+ ...mockConfig,
339
+ ai: { provider: 'claude', branchName: true, prTitle: false, prDescription: false },
340
+ });
341
+ await runCli(['show']);
342
+ expect(mockConsoleLog).toHaveBeenCalled();
343
+ });
344
+ it('shows hooks config when present', async () => {
345
+ vi.mocked(wtconfig.loadMergedConfig).mockReturnValue({
346
+ ...mockConfig,
347
+ hooks: { 'post-worktree': 'npm install' },
348
+ });
349
+ await runCli(['show']);
350
+ expect(mockConsoleLog).toHaveBeenCalled();
351
+ });
352
+ it('shows plugins when present', async () => {
353
+ vi.mocked(wtconfig.loadMergedConfig).mockReturnValue({
354
+ ...mockConfig,
355
+ plugins: ['@worktree/plugin-linear'],
356
+ });
357
+ await runCli(['show']);
358
+ expect(mockConsoleLog).toHaveBeenCalled();
359
+ });
360
+ it('shows generators when present', async () => {
361
+ vi.mocked(wtconfig.loadMergedConfig).mockReturnValue({
362
+ ...mockConfig,
363
+ generators: { branchName: './scripts/gen.js' },
364
+ });
365
+ await runCli(['show']);
366
+ expect(mockConsoleLog).toHaveBeenCalled();
367
+ });
368
+ it('shows integrations when present', async () => {
369
+ vi.mocked(wtconfig.loadMergedConfig).mockReturnValue({
370
+ ...mockConfig,
371
+ integrations: { linear: { teamId: 'ENG' } },
372
+ });
373
+ await runCli(['show']);
374
+ expect(mockConsoleLog).toHaveBeenCalled();
375
+ });
376
+ it('shows sharedRepos when present', async () => {
377
+ vi.mocked(wtconfig.loadMergedConfig).mockReturnValue({
378
+ ...mockConfig,
379
+ sharedRepos: ['other-repo'],
380
+ });
381
+ await runCli(['show']);
382
+ expect(mockConsoleLog).toHaveBeenCalled();
383
+ });
384
+ it('shows syncPatterns when present', async () => {
385
+ vi.mocked(wtconfig.loadMergedConfig).mockReturnValue({
386
+ ...mockConfig,
387
+ syncPatterns: ['*.env'],
388
+ });
389
+ await runCli(['show']);
390
+ expect(mockConsoleLog).toHaveBeenCalled();
391
+ });
392
+ });
393
+ describe('displayEnvironment scenarios', () => {
394
+ const mockEnvBase = {
395
+ os: 'linux',
396
+ git: { version: '2.30.0', configured: true, user: 'testuser', email: 'test@example.com' },
397
+ github: { installed: true, authenticated: true, user: 'testuser' },
398
+ ai: { claudeCode: false, geminiCLI: false, ollama: false, openaiKey: false },
399
+ packageManager: 'npm',
400
+ ide: { vscode: true, cursor: false },
401
+ };
402
+ beforeEach(() => {
403
+ vi.mocked(wtconfig.detectDefaultBranch).mockReturnValue('main');
404
+ vi.mocked(wtconfig.getDefaultRepoConfigPath).mockReturnValue('/repo/.worktreerc');
405
+ vi.mocked(wtconfig.getGlobalConfigPath).mockReturnValue('/home/user/.worktreerc');
406
+ vi.mocked(wtconfig.getInstallCommand).mockReturnValue('npm install');
407
+ vi.mocked(wtconfig.getEditorCommand).mockReturnValue('code');
408
+ });
409
+ it('displays git not found message', async () => {
410
+ vi.mocked(wtconfig.detectEnvironment).mockReturnValue({
411
+ ...mockEnvBase,
412
+ git: { version: null, configured: false, user: null, email: null },
413
+ });
414
+ vi.mocked(inquirer.prompt)
415
+ .mockResolvedValueOnce({ baseBranch: 'main', draftPr: false, branchPrefix: 'feat' })
416
+ .mockResolvedValueOnce({
417
+ worktreeLocation: 'sibling',
418
+ worktreePattern: '{repo}.pr{number}',
419
+ })
420
+ .mockResolvedValueOnce({ hooks: [] })
421
+ .mockResolvedValueOnce({ configureAdvanced: false })
422
+ .mockResolvedValueOnce({ saveChoice: 'cancel' });
423
+ await runCli(['init']);
424
+ expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('Git not found'));
425
+ });
426
+ it('displays git not configured warning', async () => {
427
+ vi.mocked(wtconfig.detectEnvironment).mockReturnValue({
428
+ ...mockEnvBase,
429
+ git: { version: '2.30.0', configured: false, user: null, email: null },
430
+ });
431
+ vi.mocked(inquirer.prompt)
432
+ .mockResolvedValueOnce({ baseBranch: 'main', draftPr: false, branchPrefix: 'feat' })
433
+ .mockResolvedValueOnce({
434
+ worktreeLocation: 'sibling',
435
+ worktreePattern: '{repo}.pr{number}',
436
+ })
437
+ .mockResolvedValueOnce({ hooks: [] })
438
+ .mockResolvedValueOnce({ configureAdvanced: false })
439
+ .mockResolvedValueOnce({ saveChoice: 'cancel' });
440
+ await runCli(['init']);
441
+ expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('not configured'));
442
+ });
443
+ it('displays GitHub CLI not installed', async () => {
444
+ vi.mocked(wtconfig.detectEnvironment).mockReturnValue({
445
+ ...mockEnvBase,
446
+ github: { installed: false, authenticated: false, user: null },
447
+ });
448
+ vi.mocked(inquirer.prompt)
449
+ .mockResolvedValueOnce({ baseBranch: 'main', draftPr: false, branchPrefix: 'feat' })
450
+ .mockResolvedValueOnce({
451
+ worktreeLocation: 'sibling',
452
+ worktreePattern: '{repo}.pr{number}',
453
+ })
454
+ .mockResolvedValueOnce({ hooks: [] })
455
+ .mockResolvedValueOnce({ configureAdvanced: false })
456
+ .mockResolvedValueOnce({ saveChoice: 'cancel' });
457
+ await runCli(['init']);
458
+ expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('GitHub CLI not installed'));
459
+ });
460
+ it('displays GitHub CLI not authenticated', async () => {
461
+ vi.mocked(wtconfig.detectEnvironment).mockReturnValue({
462
+ ...mockEnvBase,
463
+ github: { installed: true, authenticated: false, user: null },
464
+ });
465
+ vi.mocked(inquirer.prompt)
466
+ .mockResolvedValueOnce({ baseBranch: 'main', draftPr: false, branchPrefix: 'feat' })
467
+ .mockResolvedValueOnce({
468
+ worktreeLocation: 'sibling',
469
+ worktreePattern: '{repo}.pr{number}',
470
+ })
471
+ .mockResolvedValueOnce({ hooks: [] })
472
+ .mockResolvedValueOnce({ configureAdvanced: false })
473
+ .mockResolvedValueOnce({ saveChoice: 'cancel' });
474
+ await runCli(['init']);
475
+ expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('not authenticated'));
476
+ });
477
+ it('displays detected AI tools', async () => {
478
+ vi.mocked(wtconfig.detectEnvironment).mockReturnValue({
479
+ ...mockEnvBase,
480
+ ai: { claudeCode: true, geminiCLI: true, ollama: false, openaiKey: false },
481
+ });
482
+ vi.mocked(inquirer.prompt)
483
+ .mockResolvedValueOnce({ baseBranch: 'main', draftPr: false, branchPrefix: 'feat' })
484
+ .mockResolvedValueOnce({
485
+ worktreeLocation: 'sibling',
486
+ worktreePattern: '{repo}.pr{number}',
487
+ })
488
+ .mockResolvedValueOnce({ aiChoice: 'no' })
489
+ .mockResolvedValueOnce({ hooks: [] })
490
+ .mockResolvedValueOnce({ configureAdvanced: false })
491
+ .mockResolvedValueOnce({ saveChoice: 'cancel' });
492
+ await runCli(['init']);
493
+ expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('AI tools'));
494
+ });
495
+ it('displays no AI tools message', async () => {
496
+ vi.mocked(wtconfig.detectEnvironment).mockReturnValue({
497
+ ...mockEnvBase,
498
+ ai: { claudeCode: false, geminiCLI: false, ollama: false, openaiKey: false },
499
+ });
500
+ vi.mocked(inquirer.prompt)
501
+ .mockResolvedValueOnce({ baseBranch: 'main', draftPr: false, branchPrefix: 'feat' })
502
+ .mockResolvedValueOnce({
503
+ worktreeLocation: 'sibling',
504
+ worktreePattern: '{repo}.pr{number}',
505
+ })
506
+ .mockResolvedValueOnce({ hooks: [] })
507
+ .mockResolvedValueOnce({ configureAdvanced: false })
508
+ .mockResolvedValueOnce({ saveChoice: 'cancel' });
509
+ await runCli(['init']);
510
+ expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('No AI tools detected'));
511
+ });
512
+ it('displays no package manager', async () => {
513
+ vi.mocked(wtconfig.detectEnvironment).mockReturnValue({
514
+ ...mockEnvBase,
515
+ packageManager: null,
516
+ });
517
+ vi.mocked(wtconfig.getEditorCommand).mockReturnValue(null);
518
+ vi.mocked(inquirer.prompt)
519
+ .mockResolvedValueOnce({ baseBranch: 'main', draftPr: false, branchPrefix: 'feat' })
520
+ .mockResolvedValueOnce({
521
+ worktreeLocation: 'sibling',
522
+ worktreePattern: '{repo}.pr{number}',
523
+ })
524
+ .mockResolvedValueOnce({ configureAdvanced: false })
525
+ .mockResolvedValueOnce({ saveChoice: 'cancel' });
526
+ await runCli(['init']);
527
+ // No package manager means no autoDeps hook available
528
+ expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('Detecting'));
529
+ });
530
+ it('displays both IDEs when present', async () => {
531
+ vi.mocked(wtconfig.detectEnvironment).mockReturnValue({
532
+ ...mockEnvBase,
533
+ ide: { vscode: true, cursor: true },
534
+ });
535
+ vi.mocked(inquirer.prompt)
536
+ .mockResolvedValueOnce({ baseBranch: 'main', draftPr: false, branchPrefix: 'feat' })
537
+ .mockResolvedValueOnce({
538
+ worktreeLocation: 'sibling',
539
+ worktreePattern: '{repo}.pr{number}',
540
+ })
541
+ .mockResolvedValueOnce({ hooks: [] })
542
+ .mockResolvedValueOnce({ configureAdvanced: false })
543
+ .mockResolvedValueOnce({ saveChoice: 'cancel' });
544
+ await runCli(['init']);
545
+ expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('IDE'));
546
+ });
547
+ it('displays no IDEs message when none present', async () => {
548
+ vi.mocked(wtconfig.detectEnvironment).mockReturnValue({
549
+ ...mockEnvBase,
550
+ ide: { vscode: false, cursor: false },
551
+ });
552
+ vi.mocked(wtconfig.getEditorCommand).mockReturnValue(null);
553
+ vi.mocked(inquirer.prompt)
554
+ .mockResolvedValueOnce({ baseBranch: 'main', draftPr: false, branchPrefix: 'feat' })
555
+ .mockResolvedValueOnce({
556
+ worktreeLocation: 'sibling',
557
+ worktreePattern: '{repo}.pr{number}',
558
+ })
559
+ .mockResolvedValueOnce({ hooks: [] })
560
+ .mockResolvedValueOnce({ configureAdvanced: false })
561
+ .mockResolvedValueOnce({ saveChoice: 'cancel' });
562
+ await runCli(['init']);
563
+ expect(mockConsoleLog).toHaveBeenCalled();
564
+ });
565
+ });
566
+ describe('wizard step paths', () => {
567
+ const mockEnvBase = {
568
+ os: 'linux',
569
+ git: { version: '2.30.0', configured: true, user: 'testuser', email: 'test@example.com' },
570
+ github: { installed: true, authenticated: true, user: 'testuser' },
571
+ ai: { claudeCode: false, geminiCLI: false, ollama: false, openaiKey: false },
572
+ packageManager: 'npm',
573
+ ide: { vscode: true, cursor: false },
574
+ };
575
+ beforeEach(() => {
576
+ vi.mocked(wtconfig.detectEnvironment).mockReturnValue(mockEnvBase);
577
+ vi.mocked(wtconfig.detectDefaultBranch).mockReturnValue('main');
578
+ vi.mocked(wtconfig.getDefaultRepoConfigPath).mockReturnValue('/repo/.worktreerc');
579
+ vi.mocked(wtconfig.getGlobalConfigPath).mockReturnValue('/home/user/.worktreerc');
580
+ vi.mocked(wtconfig.getInstallCommand).mockReturnValue('npm install');
581
+ vi.mocked(wtconfig.getEditorCommand).mockReturnValue('code');
582
+ });
583
+ it('handles custom base branch input', async () => {
584
+ vi.mocked(inquirer.prompt)
585
+ .mockResolvedValueOnce({ baseBranch: '__other__', draftPr: false, branchPrefix: 'feat' })
586
+ .mockResolvedValueOnce({ customBranch: 'my-branch' })
587
+ .mockResolvedValueOnce({
588
+ worktreeLocation: 'sibling',
589
+ worktreePattern: '{repo}.pr{number}',
590
+ })
591
+ .mockResolvedValueOnce({ hooks: [] })
592
+ .mockResolvedValueOnce({ configureAdvanced: false })
593
+ .mockResolvedValueOnce({ saveChoice: 'cancel' });
594
+ await runCli(['init']);
595
+ expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('cancelled'));
596
+ });
597
+ it('handles inside worktree location', async () => {
598
+ vi.mocked(inquirer.prompt)
599
+ .mockResolvedValueOnce({ baseBranch: 'main', draftPr: false, branchPrefix: 'feat' })
600
+ .mockResolvedValueOnce({ worktreeLocation: 'inside', worktreePattern: '{repo}.pr{number}' })
601
+ .mockResolvedValueOnce({ hooks: [] })
602
+ .mockResolvedValueOnce({ configureAdvanced: false })
603
+ .mockResolvedValueOnce({ saveChoice: 'cancel' });
604
+ await runCli(['init']);
605
+ expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('cancelled'));
606
+ });
607
+ it('handles custom worktree location', async () => {
608
+ vi.mocked(inquirer.prompt)
609
+ .mockResolvedValueOnce({ baseBranch: 'main', draftPr: false, branchPrefix: 'feat' })
610
+ .mockResolvedValueOnce({ worktreeLocation: 'custom', worktreePattern: '{repo}.pr{number}' })
611
+ .mockResolvedValueOnce({ customParent: '/custom/path' })
612
+ .mockResolvedValueOnce({ hooks: [] })
613
+ .mockResolvedValueOnce({ configureAdvanced: false })
614
+ .mockResolvedValueOnce({ saveChoice: 'cancel' });
615
+ await runCli(['init']);
616
+ expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('cancelled'));
617
+ });
618
+ it('handles AI configuration with detected Claude', async () => {
619
+ vi.mocked(wtconfig.detectEnvironment).mockReturnValue({
620
+ ...mockEnvBase,
621
+ ai: { claudeCode: true, geminiCLI: false, ollama: false, openaiKey: false },
622
+ });
623
+ vi.mocked(inquirer.prompt)
624
+ .mockResolvedValueOnce({ baseBranch: 'main', draftPr: false, branchPrefix: 'feat' })
625
+ .mockResolvedValueOnce({
626
+ worktreeLocation: 'sibling',
627
+ worktreePattern: '{repo}.pr{number}',
628
+ })
629
+ .mockResolvedValueOnce({ aiChoice: 'yes' })
630
+ .mockResolvedValueOnce({ aiFeatures: ['branchName', 'prDescription'] })
631
+ .mockResolvedValueOnce({ hooks: [] })
632
+ .mockResolvedValueOnce({ configureAdvanced: false })
633
+ .mockResolvedValueOnce({ saveChoice: 'cancel' });
634
+ await runCli(['init']);
635
+ // Claude Code is shown in environment display as "AI tools: Claude Code"
636
+ expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('Claude Code'));
637
+ });
638
+ it('handles AI configuration with detected Gemini', async () => {
639
+ vi.mocked(wtconfig.detectEnvironment).mockReturnValue({
640
+ ...mockEnvBase,
641
+ ai: { claudeCode: false, geminiCLI: true, ollama: false, openaiKey: false },
642
+ });
643
+ vi.mocked(inquirer.prompt)
644
+ .mockResolvedValueOnce({ baseBranch: 'main', draftPr: false, branchPrefix: 'feat' })
645
+ .mockResolvedValueOnce({
646
+ worktreeLocation: 'sibling',
647
+ worktreePattern: '{repo}.pr{number}',
648
+ })
649
+ .mockResolvedValueOnce({ aiChoice: 'yes' })
650
+ .mockResolvedValueOnce({ aiFeatures: [] })
651
+ .mockResolvedValueOnce({ hooks: [] })
652
+ .mockResolvedValueOnce({ configureAdvanced: false })
653
+ .mockResolvedValueOnce({ saveChoice: 'cancel' });
654
+ await runCli(['init']);
655
+ // Gemini CLI is shown in environment display as "AI tools: Gemini CLI"
656
+ expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('Gemini CLI'));
657
+ });
658
+ it('handles AI configuration with detected Ollama', async () => {
659
+ vi.mocked(wtconfig.detectEnvironment).mockReturnValue({
660
+ ...mockEnvBase,
661
+ ai: { claudeCode: false, geminiCLI: false, ollama: true, openaiKey: false },
662
+ });
663
+ vi.mocked(inquirer.prompt)
664
+ .mockResolvedValueOnce({ baseBranch: 'main', draftPr: false, branchPrefix: 'feat' })
665
+ .mockResolvedValueOnce({
666
+ worktreeLocation: 'sibling',
667
+ worktreePattern: '{repo}.pr{number}',
668
+ })
669
+ .mockResolvedValueOnce({ aiChoice: 'yes' })
670
+ .mockResolvedValueOnce({ aiFeatures: [] })
671
+ .mockResolvedValueOnce({ hooks: [] })
672
+ .mockResolvedValueOnce({ configureAdvanced: false })
673
+ .mockResolvedValueOnce({ saveChoice: 'cancel' });
674
+ await runCli(['init']);
675
+ // Ollama is shown in environment display as "AI tools: Ollama"
676
+ expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('Ollama'));
677
+ });
678
+ it('handles AI configuration with detected OpenAI', async () => {
679
+ vi.mocked(wtconfig.detectEnvironment).mockReturnValue({
680
+ ...mockEnvBase,
681
+ ai: { claudeCode: false, geminiCLI: false, ollama: false, openaiKey: true },
682
+ });
683
+ vi.mocked(inquirer.prompt)
684
+ .mockResolvedValueOnce({ baseBranch: 'main', draftPr: false, branchPrefix: 'feat' })
685
+ .mockResolvedValueOnce({
686
+ worktreeLocation: 'sibling',
687
+ worktreePattern: '{repo}.pr{number}',
688
+ })
689
+ .mockResolvedValueOnce({ aiChoice: 'yes' })
690
+ .mockResolvedValueOnce({ aiFeatures: [] })
691
+ .mockResolvedValueOnce({ hooks: [] })
692
+ .mockResolvedValueOnce({ configureAdvanced: false })
693
+ .mockResolvedValueOnce({ saveChoice: 'cancel' });
694
+ await runCli(['init']);
695
+ // OpenAI is shown in environment display as "AI tools: OpenAI API"
696
+ expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('OpenAI API'));
697
+ });
698
+ it('handles manual AI provider configuration', async () => {
699
+ vi.mocked(wtconfig.detectEnvironment).mockReturnValue({
700
+ ...mockEnvBase,
701
+ ai: { claudeCode: true, geminiCLI: false, ollama: false, openaiKey: false },
702
+ });
703
+ vi.mocked(inquirer.prompt)
704
+ .mockResolvedValueOnce({ baseBranch: 'main', draftPr: false, branchPrefix: 'feat' })
705
+ .mockResolvedValueOnce({
706
+ worktreeLocation: 'sibling',
707
+ worktreePattern: '{repo}.pr{number}',
708
+ })
709
+ .mockResolvedValueOnce({ aiChoice: 'configure' })
710
+ .mockResolvedValueOnce({ manualProvider: 'gemini' })
711
+ .mockResolvedValueOnce({ aiFeatures: ['branchName'] })
712
+ .mockResolvedValueOnce({ hooks: [] })
713
+ .mockResolvedValueOnce({ configureAdvanced: false })
714
+ .mockResolvedValueOnce({ saveChoice: 'cancel' });
715
+ await runCli(['init']);
716
+ expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('cancelled'));
717
+ });
718
+ it('handles hooks with both IDEs detected and editor preference', async () => {
719
+ vi.mocked(wtconfig.detectEnvironment).mockReturnValue({
720
+ ...mockEnvBase,
721
+ ide: { vscode: true, cursor: true },
722
+ });
723
+ vi.mocked(inquirer.prompt)
724
+ .mockResolvedValueOnce({ baseBranch: 'main', draftPr: false, branchPrefix: 'feat' })
725
+ .mockResolvedValueOnce({
726
+ worktreeLocation: 'sibling',
727
+ worktreePattern: '{repo}.pr{number}',
728
+ })
729
+ .mockResolvedValueOnce({ hooks: ['autoDeps', 'openEditor'] })
730
+ .mockResolvedValueOnce({ editorChoice: 'cursor' })
731
+ .mockResolvedValueOnce({ configureAdvanced: false })
732
+ .mockResolvedValueOnce({ saveChoice: 'cancel' });
733
+ await runCli(['init']);
734
+ expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('cancelled'));
735
+ });
736
+ it('handles hooks with cursor only', async () => {
737
+ vi.mocked(wtconfig.detectEnvironment).mockReturnValue({
738
+ ...mockEnvBase,
739
+ ide: { vscode: false, cursor: true },
740
+ });
741
+ vi.mocked(wtconfig.getEditorCommand).mockReturnValue('cursor');
742
+ vi.mocked(inquirer.prompt)
743
+ .mockResolvedValueOnce({ baseBranch: 'main', draftPr: false, branchPrefix: 'feat' })
744
+ .mockResolvedValueOnce({
745
+ worktreeLocation: 'sibling',
746
+ worktreePattern: '{repo}.pr{number}',
747
+ })
748
+ .mockResolvedValueOnce({ hooks: ['openEditor'] })
749
+ .mockResolvedValueOnce({ configureAdvanced: false })
750
+ .mockResolvedValueOnce({ saveChoice: 'cancel' });
751
+ await runCli(['init']);
752
+ expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('cancelled'));
753
+ });
754
+ it('saves configuration to repository', async () => {
755
+ vi.mocked(inquirer.prompt)
756
+ .mockResolvedValueOnce({ baseBranch: 'main', draftPr: true, branchPrefix: 'fix' })
757
+ .mockResolvedValueOnce({
758
+ worktreeLocation: 'sibling',
759
+ worktreePattern: '{repo}.pr{number}',
760
+ })
761
+ .mockResolvedValueOnce({ hooks: [] })
762
+ .mockResolvedValueOnce({ configureAdvanced: false })
763
+ .mockResolvedValueOnce({ saveChoice: 'repo' });
764
+ await runCli(['init']);
765
+ expect(wtconfig.saveRepoConfig).toHaveBeenCalled();
766
+ expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('Configuration saved'));
767
+ });
768
+ it('saves configuration globally', async () => {
769
+ vi.mocked(inquirer.prompt)
770
+ .mockResolvedValueOnce({ baseBranch: 'main', draftPr: false, branchPrefix: 'feat' })
771
+ .mockResolvedValueOnce({
772
+ worktreeLocation: 'sibling',
773
+ worktreePattern: '{repo}.pr{number}',
774
+ })
775
+ .mockResolvedValueOnce({ hooks: [] })
776
+ .mockResolvedValueOnce({ configureAdvanced: false })
777
+ .mockResolvedValueOnce({ saveChoice: 'global' });
778
+ await runCli(['init']);
779
+ expect(wtconfig.saveGlobalConfig).toHaveBeenCalled();
780
+ expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('Configuration saved'));
781
+ });
782
+ it('displays quick start after saving', async () => {
783
+ vi.mocked(inquirer.prompt)
784
+ .mockResolvedValueOnce({ baseBranch: 'main', draftPr: false, branchPrefix: 'feat' })
785
+ .mockResolvedValueOnce({
786
+ worktreeLocation: 'sibling',
787
+ worktreePattern: '{repo}.pr{number}',
788
+ })
789
+ .mockResolvedValueOnce({ hooks: [] })
790
+ .mockResolvedValueOnce({ configureAdvanced: false })
791
+ .mockResolvedValueOnce({ saveChoice: 'repo' });
792
+ await runCli(['init']);
793
+ expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('Quick Start'));
794
+ });
795
+ });
796
+ describe('advanced configuration', () => {
797
+ const mockEnvBase = {
798
+ os: 'linux',
799
+ git: { version: '2.30.0', configured: true, user: 'testuser', email: 'test@example.com' },
800
+ github: { installed: true, authenticated: true, user: 'testuser' },
801
+ ai: { claudeCode: false, geminiCLI: false, ollama: false, openaiKey: false },
802
+ packageManager: 'npm',
803
+ ide: { vscode: true, cursor: false },
804
+ };
805
+ beforeEach(() => {
806
+ vi.mocked(wtconfig.detectEnvironment).mockReturnValue(mockEnvBase);
807
+ vi.mocked(wtconfig.detectDefaultBranch).mockReturnValue('main');
808
+ vi.mocked(wtconfig.getDefaultRepoConfigPath).mockReturnValue('/repo/.worktreerc');
809
+ vi.mocked(wtconfig.getGlobalConfigPath).mockReturnValue('/home/user/.worktreerc');
810
+ vi.mocked(wtconfig.getInstallCommand).mockReturnValue('npm install');
811
+ vi.mocked(wtconfig.getEditorCommand).mockReturnValue('code');
812
+ });
813
+ it('configures plugins', async () => {
814
+ vi.mocked(inquirer.prompt)
815
+ .mockResolvedValueOnce({ baseBranch: 'main', draftPr: false, branchPrefix: 'feat' })
816
+ .mockResolvedValueOnce({
817
+ worktreeLocation: 'sibling',
818
+ worktreePattern: '{repo}.pr{number}',
819
+ })
820
+ .mockResolvedValueOnce({ hooks: [] })
821
+ .mockResolvedValueOnce({ configureAdvanced: true })
822
+ .mockResolvedValueOnce({ addPlugins: true })
823
+ .mockResolvedValueOnce({ pluginList: 'plugin1, plugin2' })
824
+ .mockResolvedValueOnce({ useGenerators: false })
825
+ .mockResolvedValueOnce({ integrationsToAdd: [] })
826
+ .mockResolvedValueOnce({ saveChoice: 'cancel' });
827
+ await runCli(['init']);
828
+ expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('Advanced Configuration'));
829
+ });
830
+ it('configures custom generators', async () => {
831
+ vi.mocked(inquirer.prompt)
832
+ .mockResolvedValueOnce({ baseBranch: 'main', draftPr: false, branchPrefix: 'feat' })
833
+ .mockResolvedValueOnce({
834
+ worktreeLocation: 'sibling',
835
+ worktreePattern: '{repo}.pr{number}',
836
+ })
837
+ .mockResolvedValueOnce({ hooks: [] })
838
+ .mockResolvedValueOnce({ configureAdvanced: true })
839
+ .mockResolvedValueOnce({ addPlugins: false })
840
+ .mockResolvedValueOnce({ useGenerators: true })
841
+ .mockResolvedValueOnce({
842
+ branchNameGen: './scripts/branch.js',
843
+ prTitleGen: './scripts/title.js',
844
+ prDescGen: '',
845
+ commitMsgGen: './scripts/commit.js',
846
+ })
847
+ .mockResolvedValueOnce({ integrationsToAdd: [] })
848
+ .mockResolvedValueOnce({ saveChoice: 'cancel' });
849
+ await runCli(['init']);
850
+ expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('Advanced Configuration'));
851
+ });
852
+ it('configures Linear integration', async () => {
853
+ vi.mocked(inquirer.prompt)
854
+ .mockResolvedValueOnce({ baseBranch: 'main', draftPr: false, branchPrefix: 'feat' })
855
+ .mockResolvedValueOnce({
856
+ worktreeLocation: 'sibling',
857
+ worktreePattern: '{repo}.pr{number}',
858
+ })
859
+ .mockResolvedValueOnce({ hooks: [] })
860
+ .mockResolvedValueOnce({ configureAdvanced: true })
861
+ .mockResolvedValueOnce({ addPlugins: false })
862
+ .mockResolvedValueOnce({ useGenerators: false })
863
+ .mockResolvedValueOnce({ integrationsToAdd: ['linear'] })
864
+ .mockResolvedValueOnce({ teamId: 'ENG', apiKeyEnv: 'LINEAR_API_KEY' })
865
+ .mockResolvedValueOnce({ saveChoice: 'cancel' });
866
+ await runCli(['init']);
867
+ expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('Advanced Configuration'));
868
+ });
869
+ it('configures Jira integration', async () => {
870
+ vi.mocked(inquirer.prompt)
871
+ .mockResolvedValueOnce({ baseBranch: 'main', draftPr: false, branchPrefix: 'feat' })
872
+ .mockResolvedValueOnce({
873
+ worktreeLocation: 'sibling',
874
+ worktreePattern: '{repo}.pr{number}',
875
+ })
876
+ .mockResolvedValueOnce({ hooks: [] })
877
+ .mockResolvedValueOnce({ configureAdvanced: true })
878
+ .mockResolvedValueOnce({ addPlugins: false })
879
+ .mockResolvedValueOnce({ useGenerators: false })
880
+ .mockResolvedValueOnce({ integrationsToAdd: ['jira'] })
881
+ .mockResolvedValueOnce({
882
+ projectKey: 'PROJ',
883
+ baseUrl: 'https://jira.example.com',
884
+ apiTokenEnv: 'JIRA_TOKEN',
885
+ })
886
+ .mockResolvedValueOnce({ saveChoice: 'cancel' });
887
+ await runCli(['init']);
888
+ expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('Advanced Configuration'));
889
+ });
890
+ it('configures Slack integration', async () => {
891
+ vi.mocked(inquirer.prompt)
892
+ .mockResolvedValueOnce({ baseBranch: 'main', draftPr: false, branchPrefix: 'feat' })
893
+ .mockResolvedValueOnce({
894
+ worktreeLocation: 'sibling',
895
+ worktreePattern: '{repo}.pr{number}',
896
+ })
897
+ .mockResolvedValueOnce({ hooks: [] })
898
+ .mockResolvedValueOnce({ configureAdvanced: true })
899
+ .mockResolvedValueOnce({ addPlugins: false })
900
+ .mockResolvedValueOnce({ useGenerators: false })
901
+ .mockResolvedValueOnce({ integrationsToAdd: ['slack'] })
902
+ .mockResolvedValueOnce({ webhookUrl: 'SLACK_WEBHOOK_URL', channel: '#releases' })
903
+ .mockResolvedValueOnce({ saveChoice: 'cancel' });
904
+ await runCli(['init']);
905
+ expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('Advanced Configuration'));
906
+ });
907
+ it('configures all integrations', async () => {
908
+ vi.mocked(inquirer.prompt)
909
+ .mockResolvedValueOnce({ baseBranch: 'main', draftPr: false, branchPrefix: 'feat' })
910
+ .mockResolvedValueOnce({
911
+ worktreeLocation: 'sibling',
912
+ worktreePattern: '{repo}.pr{number}',
913
+ })
914
+ .mockResolvedValueOnce({ hooks: [] })
915
+ .mockResolvedValueOnce({ configureAdvanced: true })
916
+ .mockResolvedValueOnce({ addPlugins: false })
917
+ .mockResolvedValueOnce({ useGenerators: false })
918
+ .mockResolvedValueOnce({ integrationsToAdd: ['linear', 'jira', 'slack'] })
919
+ .mockResolvedValueOnce({ teamId: '', apiKeyEnv: '' })
920
+ .mockResolvedValueOnce({ projectKey: '', baseUrl: '', apiTokenEnv: '' })
921
+ .mockResolvedValueOnce({ webhookUrl: '', channel: '' })
922
+ .mockResolvedValueOnce({ saveChoice: 'cancel' });
923
+ await runCli(['init']);
924
+ expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('Advanced Configuration'));
925
+ });
926
+ });
927
+ describe('edit command edge cases', () => {
928
+ beforeEach(() => {
929
+ vi.mocked(wtconfig.getDefaultRepoConfigPath).mockReturnValue('/repo/.worktreerc');
930
+ vi.mocked(wtconfig.getGlobalConfigPath).mockReturnValue('/home/user/.worktreerc');
931
+ });
932
+ it('edits global config when not in repo', async () => {
933
+ vi.mocked(git.getRepoRoot).mockImplementation(() => {
934
+ throw new Error('Not a git repository');
935
+ });
936
+ vi.mocked(inquirer.prompt).mockResolvedValueOnce({ editLocation: 'global' });
937
+ // Mock fs for the dynamic import
938
+ vi.doMock('fs', () => ({
939
+ existsSync: vi.fn().mockReturnValue(true),
940
+ writeFileSync: vi.fn(),
941
+ }));
942
+ vi.mocked(execSync).mockImplementation(() => Buffer.from(''));
943
+ await runCli(['edit']);
944
+ expect(inquirer.prompt).toHaveBeenCalled();
945
+ });
946
+ it('cancels file creation when user declines', async () => {
947
+ vi.mocked(inquirer.prompt)
948
+ .mockResolvedValueOnce({ editLocation: 'repo' })
949
+ .mockResolvedValueOnce({ create: false });
950
+ vi.doMock('fs', () => ({
951
+ existsSync: vi.fn().mockReturnValue(false),
952
+ writeFileSync: vi.fn(),
953
+ }));
954
+ await runCli(['edit']);
955
+ expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('Cancelled'));
956
+ });
957
+ it('creates new config file when user confirms', async () => {
958
+ vi.mocked(inquirer.prompt)
959
+ .mockResolvedValueOnce({ editLocation: 'repo' })
960
+ .mockResolvedValueOnce({ create: true });
961
+ const mockWriteFileSync = vi.fn();
962
+ vi.doMock('fs', () => ({
963
+ existsSync: vi.fn().mockReturnValue(false),
964
+ writeFileSync: mockWriteFileSync,
965
+ }));
966
+ vi.mocked(execSync).mockImplementation(() => Buffer.from(''));
967
+ await runCli(['edit']);
968
+ // Config file should be created
969
+ expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('Opening'));
970
+ });
971
+ it('handles editor failure gracefully', async () => {
972
+ vi.mocked(inquirer.prompt).mockResolvedValueOnce({ editLocation: 'repo' });
973
+ vi.doMock('fs', () => ({
974
+ existsSync: vi.fn().mockReturnValue(true),
975
+ writeFileSync: vi.fn(),
976
+ }));
977
+ vi.mocked(execSync).mockImplementation(() => {
978
+ throw new Error('Editor failed');
979
+ });
980
+ await runCli(['edit']);
981
+ expect(mockConsoleError).toHaveBeenCalledWith(expect.stringContaining('Failed to open editor'));
982
+ expect(mockProcessExit).toHaveBeenCalledWith(1);
983
+ });
984
+ });
985
+ describe('set command edge cases', () => {
986
+ beforeEach(() => {
987
+ vi.mocked(inquirer.prompt).mockResolvedValue({ saveLocation: 'repo' });
988
+ vi.mocked(wtconfig.loadRepoConfig).mockReturnValue({});
989
+ });
990
+ it('handles setConfigValue error', async () => {
991
+ vi.mocked(wtconfig.setConfigValue).mockImplementation(() => {
992
+ throw new Error('Invalid key format');
993
+ });
994
+ await runCli(['set', 'invalid..key', 'value']);
995
+ expect(mockConsoleError).toHaveBeenCalledWith(expect.stringContaining('Failed to set value'));
996
+ expect(mockProcessExit).toHaveBeenCalledWith(1);
997
+ });
998
+ it('uses global config when not in repo', async () => {
999
+ vi.mocked(git.getRepoRoot).mockImplementation(() => {
1000
+ throw new Error('Not a git repository');
1001
+ });
1002
+ vi.mocked(inquirer.prompt).mockResolvedValue({ saveLocation: 'global' });
1003
+ vi.mocked(wtconfig.loadGlobalConfig).mockReturnValue({});
1004
+ vi.mocked(wtconfig.setConfigValue).mockReturnValue({ baseBranch: 'develop' });
1005
+ await runCli(['set', 'baseBranch', 'develop']);
1006
+ expect(wtconfig.saveGlobalConfig).toHaveBeenCalled();
1007
+ });
1008
+ });
1009
+ describe('wizard outside repository', () => {
1010
+ beforeEach(() => {
1011
+ vi.mocked(git.getRepoRoot).mockImplementation(() => {
1012
+ throw new Error('Not a git repository');
1013
+ });
1014
+ vi.mocked(wtconfig.detectEnvironment).mockReturnValue({
1015
+ os: 'linux',
1016
+ git: { version: '2.30.0', configured: true, user: 'testuser', email: 'test@example.com' },
1017
+ github: { installed: true, authenticated: true, user: 'testuser' },
1018
+ ai: { claudeCode: false, geminiCLI: false, ollama: false, openaiKey: false },
1019
+ packageManager: 'npm',
1020
+ ide: { vscode: true, cursor: false },
1021
+ });
1022
+ vi.mocked(wtconfig.getDefaultRepoConfigPath).mockReturnValue('/repo/.worktreerc');
1023
+ vi.mocked(wtconfig.getGlobalConfigPath).mockReturnValue('/home/user/.worktreerc');
1024
+ vi.mocked(wtconfig.getInstallCommand).mockReturnValue('npm install');
1025
+ vi.mocked(wtconfig.getEditorCommand).mockReturnValue('code');
1026
+ });
1027
+ it('runs wizard without repo context and uses default branch', async () => {
1028
+ vi.mocked(inquirer.prompt)
1029
+ .mockResolvedValueOnce({ baseBranch: 'main', draftPr: false, branchPrefix: 'feat' })
1030
+ .mockResolvedValueOnce({
1031
+ worktreeLocation: 'sibling',
1032
+ worktreePattern: '{repo}.pr{number}',
1033
+ })
1034
+ .mockResolvedValueOnce({ hooks: [] })
1035
+ .mockResolvedValueOnce({ configureAdvanced: false })
1036
+ .mockResolvedValueOnce({ saveChoice: 'global' });
1037
+ await runCli(['init']);
1038
+ // Should not call detectDefaultBranch with null
1039
+ expect(wtconfig.detectDefaultBranch).not.toHaveBeenCalled();
1040
+ expect(wtconfig.saveGlobalConfig).toHaveBeenCalled();
1041
+ });
1042
+ });
1043
+ describe('buildConfigFromState paths', () => {
1044
+ const mockEnvBase = {
1045
+ os: 'linux',
1046
+ git: { version: '2.30.0', configured: true, user: 'testuser', email: 'test@example.com' },
1047
+ github: { installed: true, authenticated: true, user: 'testuser' },
1048
+ ai: { claudeCode: false, geminiCLI: false, ollama: false, openaiKey: false },
1049
+ packageManager: 'npm',
1050
+ ide: { vscode: true, cursor: false },
1051
+ };
1052
+ beforeEach(() => {
1053
+ vi.mocked(wtconfig.detectEnvironment).mockReturnValue(mockEnvBase);
1054
+ vi.mocked(wtconfig.detectDefaultBranch).mockReturnValue('main');
1055
+ vi.mocked(wtconfig.getDefaultRepoConfigPath).mockReturnValue('/repo/.worktreerc');
1056
+ vi.mocked(wtconfig.getGlobalConfigPath).mockReturnValue('/home/user/.worktreerc');
1057
+ vi.mocked(wtconfig.getInstallCommand).mockReturnValue('npm install');
1058
+ vi.mocked(wtconfig.getEditorCommand).mockReturnValue('code');
1059
+ });
1060
+ it('builds config with non-default baseBranch', async () => {
1061
+ vi.mocked(inquirer.prompt)
1062
+ .mockResolvedValueOnce({ baseBranch: 'develop', draftPr: false, branchPrefix: 'feat' })
1063
+ .mockResolvedValueOnce({
1064
+ worktreeLocation: 'sibling',
1065
+ worktreePattern: '{repo}.pr{number}',
1066
+ })
1067
+ .mockResolvedValueOnce({ hooks: [] })
1068
+ .mockResolvedValueOnce({ configureAdvanced: false })
1069
+ .mockResolvedValueOnce({ saveChoice: 'repo' });
1070
+ await runCli(['init']);
1071
+ expect(wtconfig.saveRepoConfig).toHaveBeenCalledWith('/repo', expect.objectContaining({ baseBranch: 'develop' }));
1072
+ });
1073
+ it('builds config with draftPr enabled', async () => {
1074
+ vi.mocked(inquirer.prompt)
1075
+ .mockResolvedValueOnce({ baseBranch: 'main', draftPr: true, branchPrefix: 'feat' })
1076
+ .mockResolvedValueOnce({
1077
+ worktreeLocation: 'sibling',
1078
+ worktreePattern: '{repo}.pr{number}',
1079
+ })
1080
+ .mockResolvedValueOnce({ hooks: [] })
1081
+ .mockResolvedValueOnce({ configureAdvanced: false })
1082
+ .mockResolvedValueOnce({ saveChoice: 'repo' });
1083
+ await runCli(['init']);
1084
+ expect(wtconfig.saveRepoConfig).toHaveBeenCalledWith('/repo', expect.objectContaining({ draftPr: true }));
1085
+ });
1086
+ it('builds config with non-default branchPrefix', async () => {
1087
+ vi.mocked(inquirer.prompt)
1088
+ .mockResolvedValueOnce({ baseBranch: 'main', draftPr: false, branchPrefix: 'fix' })
1089
+ .mockResolvedValueOnce({
1090
+ worktreeLocation: 'sibling',
1091
+ worktreePattern: '{repo}.pr{number}',
1092
+ })
1093
+ .mockResolvedValueOnce({ hooks: [] })
1094
+ .mockResolvedValueOnce({ configureAdvanced: false })
1095
+ .mockResolvedValueOnce({ saveChoice: 'repo' });
1096
+ await runCli(['init']);
1097
+ expect(wtconfig.saveRepoConfig).toHaveBeenCalledWith('/repo', expect.objectContaining({ branchPrefix: 'fix' }));
1098
+ });
1099
+ it('builds config with non-default worktreePattern', async () => {
1100
+ vi.mocked(inquirer.prompt)
1101
+ .mockResolvedValueOnce({ baseBranch: 'main', draftPr: false, branchPrefix: 'feat' })
1102
+ .mockResolvedValueOnce({ worktreeLocation: 'sibling', worktreePattern: 'pr-{number}' })
1103
+ .mockResolvedValueOnce({ hooks: [] })
1104
+ .mockResolvedValueOnce({ configureAdvanced: false })
1105
+ .mockResolvedValueOnce({ saveChoice: 'repo' });
1106
+ await runCli(['init']);
1107
+ expect(wtconfig.saveRepoConfig).toHaveBeenCalledWith('/repo', expect.objectContaining({ worktreePattern: 'pr-{number}' }));
1108
+ });
1109
+ it('builds config with non-default worktreeParent', async () => {
1110
+ vi.mocked(inquirer.prompt)
1111
+ .mockResolvedValueOnce({ baseBranch: 'main', draftPr: false, branchPrefix: 'feat' })
1112
+ .mockResolvedValueOnce({ worktreeLocation: 'inside', worktreePattern: '{repo}.pr{number}' })
1113
+ .mockResolvedValueOnce({ hooks: [] })
1114
+ .mockResolvedValueOnce({ configureAdvanced: false })
1115
+ .mockResolvedValueOnce({ saveChoice: 'repo' });
1116
+ await runCli(['init']);
1117
+ expect(wtconfig.saveRepoConfig).toHaveBeenCalledWith('/repo', expect.objectContaining({ worktreeParent: '.worktrees' }));
1118
+ });
1119
+ it('builds config with AI enabled', async () => {
1120
+ vi.mocked(wtconfig.detectEnvironment).mockReturnValue({
1121
+ ...mockEnvBase,
1122
+ ai: { claudeCode: true, geminiCLI: false, ollama: false, openaiKey: false },
1123
+ });
1124
+ vi.mocked(inquirer.prompt)
1125
+ .mockResolvedValueOnce({ baseBranch: 'main', draftPr: false, branchPrefix: 'feat' })
1126
+ .mockResolvedValueOnce({
1127
+ worktreeLocation: 'sibling',
1128
+ worktreePattern: '{repo}.pr{number}',
1129
+ })
1130
+ .mockResolvedValueOnce({ aiChoice: 'yes' })
1131
+ .mockResolvedValueOnce({ aiFeatures: ['branchName', 'prDescription'] })
1132
+ .mockResolvedValueOnce({ hooks: [] })
1133
+ .mockResolvedValueOnce({ configureAdvanced: false })
1134
+ .mockResolvedValueOnce({ saveChoice: 'repo' });
1135
+ await runCli(['init']);
1136
+ expect(wtconfig.saveRepoConfig).toHaveBeenCalledWith('/repo', expect.objectContaining({
1137
+ ai: expect.objectContaining({
1138
+ provider: 'auto',
1139
+ branchName: true,
1140
+ prDescription: true,
1141
+ }),
1142
+ }));
1143
+ });
1144
+ it('builds config with hooks (autoDeps)', async () => {
1145
+ vi.mocked(inquirer.prompt)
1146
+ .mockResolvedValueOnce({ baseBranch: 'main', draftPr: false, branchPrefix: 'feat' })
1147
+ .mockResolvedValueOnce({
1148
+ worktreeLocation: 'sibling',
1149
+ worktreePattern: '{repo}.pr{number}',
1150
+ })
1151
+ .mockResolvedValueOnce({ hooks: ['autoDeps'] })
1152
+ .mockResolvedValueOnce({ configureAdvanced: false })
1153
+ .mockResolvedValueOnce({ saveChoice: 'repo' });
1154
+ await runCli(['init']);
1155
+ expect(wtconfig.saveRepoConfig).toHaveBeenCalledWith('/repo', expect.objectContaining({
1156
+ hooks: expect.objectContaining({
1157
+ 'post-worktree': 'npm install',
1158
+ }),
1159
+ }));
1160
+ });
1161
+ it('builds config with hooks (openEditor)', async () => {
1162
+ vi.mocked(inquirer.prompt)
1163
+ .mockResolvedValueOnce({ baseBranch: 'main', draftPr: false, branchPrefix: 'feat' })
1164
+ .mockResolvedValueOnce({
1165
+ worktreeLocation: 'sibling',
1166
+ worktreePattern: '{repo}.pr{number}',
1167
+ })
1168
+ .mockResolvedValueOnce({ hooks: ['openEditor'] })
1169
+ .mockResolvedValueOnce({ configureAdvanced: false })
1170
+ .mockResolvedValueOnce({ saveChoice: 'repo' });
1171
+ await runCli(['init']);
1172
+ expect(wtconfig.saveRepoConfig).toHaveBeenCalledWith('/repo', expect.objectContaining({
1173
+ hooks: expect.objectContaining({
1174
+ 'post-worktree': 'code',
1175
+ }),
1176
+ }));
1177
+ });
1178
+ it('builds config with multiple hooks', async () => {
1179
+ vi.mocked(inquirer.prompt)
1180
+ .mockResolvedValueOnce({ baseBranch: 'main', draftPr: false, branchPrefix: 'feat' })
1181
+ .mockResolvedValueOnce({
1182
+ worktreeLocation: 'sibling',
1183
+ worktreePattern: '{repo}.pr{number}',
1184
+ })
1185
+ .mockResolvedValueOnce({ hooks: ['autoDeps', 'openEditor'] })
1186
+ .mockResolvedValueOnce({ configureAdvanced: false })
1187
+ .mockResolvedValueOnce({ saveChoice: 'repo' });
1188
+ await runCli(['init']);
1189
+ expect(wtconfig.saveRepoConfig).toHaveBeenCalledWith('/repo', expect.objectContaining({
1190
+ hooks: expect.objectContaining({
1191
+ 'post-worktree': expect.arrayContaining(['npm install', 'code']),
1192
+ }),
1193
+ }));
1194
+ });
1195
+ it('builds config with preferredEditor set', async () => {
1196
+ vi.mocked(wtconfig.detectEnvironment).mockReturnValue({
1197
+ ...mockEnvBase,
1198
+ ide: { vscode: true, cursor: true },
1199
+ });
1200
+ vi.mocked(inquirer.prompt)
1201
+ .mockResolvedValueOnce({ baseBranch: 'main', draftPr: false, branchPrefix: 'feat' })
1202
+ .mockResolvedValueOnce({
1203
+ worktreeLocation: 'sibling',
1204
+ worktreePattern: '{repo}.pr{number}',
1205
+ })
1206
+ .mockResolvedValueOnce({ hooks: ['openEditor'] })
1207
+ .mockResolvedValueOnce({ editorChoice: 'cursor' })
1208
+ .mockResolvedValueOnce({ configureAdvanced: false })
1209
+ .mockResolvedValueOnce({ saveChoice: 'repo' });
1210
+ await runCli(['init']);
1211
+ expect(wtconfig.saveRepoConfig).toHaveBeenCalledWith('/repo', expect.objectContaining({ preferredEditor: 'cursor' }));
1212
+ });
1213
+ it('builds config with plugins', async () => {
1214
+ vi.mocked(inquirer.prompt)
1215
+ .mockResolvedValueOnce({ baseBranch: 'main', draftPr: false, branchPrefix: 'feat' })
1216
+ .mockResolvedValueOnce({
1217
+ worktreeLocation: 'sibling',
1218
+ worktreePattern: '{repo}.pr{number}',
1219
+ })
1220
+ .mockResolvedValueOnce({ hooks: [] })
1221
+ .mockResolvedValueOnce({ configureAdvanced: true })
1222
+ .mockResolvedValueOnce({ addPlugins: true })
1223
+ .mockResolvedValueOnce({ pluginList: 'plugin-a, plugin-b' })
1224
+ .mockResolvedValueOnce({ useGenerators: false })
1225
+ .mockResolvedValueOnce({ integrationsToAdd: [] })
1226
+ .mockResolvedValueOnce({ saveChoice: 'repo' });
1227
+ await runCli(['init']);
1228
+ expect(wtconfig.saveRepoConfig).toHaveBeenCalledWith('/repo', expect.objectContaining({ plugins: ['plugin-a', 'plugin-b'] }));
1229
+ });
1230
+ it('builds config with generators', async () => {
1231
+ vi.mocked(inquirer.prompt)
1232
+ .mockResolvedValueOnce({ baseBranch: 'main', draftPr: false, branchPrefix: 'feat' })
1233
+ .mockResolvedValueOnce({
1234
+ worktreeLocation: 'sibling',
1235
+ worktreePattern: '{repo}.pr{number}',
1236
+ })
1237
+ .mockResolvedValueOnce({ hooks: [] })
1238
+ .mockResolvedValueOnce({ configureAdvanced: true })
1239
+ .mockResolvedValueOnce({ addPlugins: false })
1240
+ .mockResolvedValueOnce({ useGenerators: true })
1241
+ .mockResolvedValueOnce({
1242
+ branchNameGen: './branch.sh',
1243
+ prTitleGen: '',
1244
+ prDescGen: './pr-desc.sh',
1245
+ commitMsgGen: '',
1246
+ })
1247
+ .mockResolvedValueOnce({ integrationsToAdd: [] })
1248
+ .mockResolvedValueOnce({ saveChoice: 'repo' });
1249
+ await runCli(['init']);
1250
+ expect(wtconfig.saveRepoConfig).toHaveBeenCalledWith('/repo', expect.objectContaining({
1251
+ generators: { branchName: './branch.sh', prDescription: './pr-desc.sh' },
1252
+ }));
1253
+ });
1254
+ it('builds config with integrations', async () => {
1255
+ vi.mocked(inquirer.prompt)
1256
+ .mockResolvedValueOnce({ baseBranch: 'main', draftPr: false, branchPrefix: 'feat' })
1257
+ .mockResolvedValueOnce({
1258
+ worktreeLocation: 'sibling',
1259
+ worktreePattern: '{repo}.pr{number}',
1260
+ })
1261
+ .mockResolvedValueOnce({ hooks: [] })
1262
+ .mockResolvedValueOnce({ configureAdvanced: true })
1263
+ .mockResolvedValueOnce({ addPlugins: false })
1264
+ .mockResolvedValueOnce({ useGenerators: false })
1265
+ .mockResolvedValueOnce({ integrationsToAdd: ['linear'] })
1266
+ .mockResolvedValueOnce({ teamId: 'TEAM', apiKeyEnv: 'MY_KEY' })
1267
+ .mockResolvedValueOnce({ saveChoice: 'repo' });
1268
+ await runCli(['init']);
1269
+ expect(wtconfig.saveRepoConfig).toHaveBeenCalledWith('/repo', expect.objectContaining({
1270
+ integrations: { linear: { teamId: 'TEAM', apiKeyEnv: 'MY_KEY' } },
1271
+ }));
1272
+ });
1273
+ });
1274
+ describe('default command behavior', () => {
1275
+ it('defaults to show command when no command provided', async () => {
1276
+ await runCli([]);
1277
+ expect(mockConsoleLog).toHaveBeenCalledWith(expect.stringContaining('Current Configuration'));
1278
+ });
1279
+ });
1280
+ });
1281
+ //# sourceMappingURL=wtconfig.test.js.map