ccmanager 2.8.0 → 2.9.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 (82) hide show
  1. package/dist/cli.test.js +13 -2
  2. package/dist/components/App.js +125 -50
  3. package/dist/components/App.test.js +270 -0
  4. package/dist/components/ConfigureShortcuts.js +82 -8
  5. package/dist/components/DeleteWorktree.js +39 -5
  6. package/dist/components/DeleteWorktree.test.d.ts +1 -0
  7. package/dist/components/DeleteWorktree.test.js +128 -0
  8. package/dist/components/LoadingSpinner.d.ts +8 -0
  9. package/dist/components/LoadingSpinner.js +37 -0
  10. package/dist/components/LoadingSpinner.test.d.ts +1 -0
  11. package/dist/components/LoadingSpinner.test.js +187 -0
  12. package/dist/components/Menu.js +64 -16
  13. package/dist/components/Menu.recent-projects.test.js +32 -11
  14. package/dist/components/Menu.test.js +136 -4
  15. package/dist/components/MergeWorktree.js +79 -18
  16. package/dist/components/MergeWorktree.test.d.ts +1 -0
  17. package/dist/components/MergeWorktree.test.js +227 -0
  18. package/dist/components/NewWorktree.js +88 -9
  19. package/dist/components/NewWorktree.test.d.ts +1 -0
  20. package/dist/components/NewWorktree.test.js +244 -0
  21. package/dist/components/ProjectList.js +44 -13
  22. package/dist/components/ProjectList.recent-projects.test.js +8 -3
  23. package/dist/components/ProjectList.test.js +105 -8
  24. package/dist/components/RemoteBranchSelector.test.js +3 -1
  25. package/dist/components/Session.js +11 -6
  26. package/dist/hooks/useGitStatus.d.ts +11 -0
  27. package/dist/hooks/useGitStatus.js +70 -12
  28. package/dist/hooks/useGitStatus.test.js +30 -23
  29. package/dist/services/configurationManager.d.ts +75 -0
  30. package/dist/services/configurationManager.effect.test.d.ts +1 -0
  31. package/dist/services/configurationManager.effect.test.js +407 -0
  32. package/dist/services/configurationManager.js +246 -0
  33. package/dist/services/globalSessionOrchestrator.test.js +0 -8
  34. package/dist/services/projectManager.d.ts +98 -2
  35. package/dist/services/projectManager.js +228 -59
  36. package/dist/services/projectManager.test.js +242 -2
  37. package/dist/services/sessionManager.d.ts +44 -2
  38. package/dist/services/sessionManager.effect.test.d.ts +1 -0
  39. package/dist/services/sessionManager.effect.test.js +321 -0
  40. package/dist/services/sessionManager.js +216 -65
  41. package/dist/services/sessionManager.statePersistence.test.js +18 -9
  42. package/dist/services/sessionManager.test.js +40 -36
  43. package/dist/services/shortcutManager.d.ts +2 -0
  44. package/dist/services/shortcutManager.js +53 -0
  45. package/dist/services/shortcutManager.test.d.ts +1 -0
  46. package/dist/services/shortcutManager.test.js +30 -0
  47. package/dist/services/worktreeService.d.ts +356 -26
  48. package/dist/services/worktreeService.js +793 -353
  49. package/dist/services/worktreeService.test.js +294 -313
  50. package/dist/types/errors.d.ts +74 -0
  51. package/dist/types/errors.js +31 -0
  52. package/dist/types/errors.test.d.ts +1 -0
  53. package/dist/types/errors.test.js +201 -0
  54. package/dist/types/index.d.ts +5 -17
  55. package/dist/utils/claudeDir.d.ts +58 -6
  56. package/dist/utils/claudeDir.js +103 -8
  57. package/dist/utils/claudeDir.test.d.ts +1 -0
  58. package/dist/utils/claudeDir.test.js +108 -0
  59. package/dist/utils/concurrencyLimit.d.ts +5 -0
  60. package/dist/utils/concurrencyLimit.js +11 -0
  61. package/dist/utils/concurrencyLimit.test.js +40 -1
  62. package/dist/utils/gitStatus.d.ts +36 -8
  63. package/dist/utils/gitStatus.js +170 -88
  64. package/dist/utils/gitStatus.test.js +12 -9
  65. package/dist/utils/hookExecutor.d.ts +41 -6
  66. package/dist/utils/hookExecutor.js +75 -32
  67. package/dist/utils/hookExecutor.test.js +73 -20
  68. package/dist/utils/terminalCapabilities.d.ts +18 -0
  69. package/dist/utils/terminalCapabilities.js +81 -0
  70. package/dist/utils/terminalCapabilities.test.d.ts +1 -0
  71. package/dist/utils/terminalCapabilities.test.js +104 -0
  72. package/dist/utils/testHelpers.d.ts +106 -0
  73. package/dist/utils/testHelpers.js +153 -0
  74. package/dist/utils/testHelpers.test.d.ts +1 -0
  75. package/dist/utils/testHelpers.test.js +114 -0
  76. package/dist/utils/worktreeConfig.d.ts +77 -2
  77. package/dist/utils/worktreeConfig.js +156 -16
  78. package/dist/utils/worktreeConfig.test.d.ts +1 -0
  79. package/dist/utils/worktreeConfig.test.js +39 -0
  80. package/package.json +4 -4
  81. package/dist/integration-tests/devcontainer.integration.test.js +0 -101
  82. /package/dist/{integration-tests/devcontainer.integration.test.d.ts → components/App.test.d.ts} +0 -0
@@ -1,9 +1,10 @@
1
- import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
2
  import { WorktreeService } from './worktreeService.js';
3
3
  import { execSync } from 'child_process';
4
4
  import { existsSync, statSync } from 'fs';
5
5
  import { configurationManager } from './configurationManager.js';
6
- import { executeWorktreePostCreationHook } from '../utils/hookExecutor.js';
6
+ import { Effect } from 'effect';
7
+ import { GitError } from '../types/errors.js';
7
8
  // Mock child_process module
8
9
  vi.mock('child_process');
9
10
  // Mock fs module
@@ -31,7 +32,6 @@ const mockedExecSync = vi.mocked(execSync);
31
32
  const mockedExistsSync = vi.mocked(existsSync);
32
33
  const mockedStatSync = vi.mocked(statSync);
33
34
  const mockedGetWorktreeHooks = vi.mocked(configurationManager.getWorktreeHooks);
34
- const mockedExecuteHook = vi.mocked(executeWorktreePostCreationHook);
35
35
  describe('WorktreeService', () => {
36
36
  let service;
37
37
  beforeEach(() => {
@@ -119,8 +119,8 @@ describe('WorktreeService', () => {
119
119
  expect(result.startsWith('/')).toBe(true);
120
120
  });
121
121
  });
122
- describe('getDefaultBranch', () => {
123
- it('should return default branch from origin', () => {
122
+ describe('getDefaultBranchEffect', () => {
123
+ it('should return Effect with default branch from origin', async () => {
124
124
  mockedExecSync.mockImplementation((cmd, _options) => {
125
125
  if (typeof cmd === 'string') {
126
126
  if (cmd === 'git rev-parse --git-common-dir') {
@@ -132,7 +132,8 @@ describe('WorktreeService', () => {
132
132
  }
133
133
  throw new Error('Command not mocked: ' + cmd);
134
134
  });
135
- const result = service.getDefaultBranch();
135
+ const effect = service.getDefaultBranchEffect();
136
+ const result = await Effect.runPromise(effect);
136
137
  expect(result).toBe('main');
137
138
  expect(execSync).toHaveBeenCalledWith("git symbolic-ref refs/remotes/origin/HEAD | sed 's@^refs/remotes/origin/@@'", expect.objectContaining({
138
139
  cwd: '/fake/path',
@@ -140,7 +141,7 @@ describe('WorktreeService', () => {
140
141
  shell: '/bin/bash',
141
142
  }));
142
143
  });
143
- it('should fallback to main if origin HEAD fails', () => {
144
+ it('should fallback to main if origin HEAD fails', async () => {
144
145
  mockedExecSync.mockImplementation((cmd, _options) => {
145
146
  if (typeof cmd === 'string') {
146
147
  if (cmd === 'git rev-parse --git-common-dir') {
@@ -155,12 +156,13 @@ describe('WorktreeService', () => {
155
156
  }
156
157
  throw new Error('Not found');
157
158
  });
158
- const result = service.getDefaultBranch();
159
+ const effect = service.getDefaultBranchEffect();
160
+ const result = await Effect.runPromise(effect);
159
161
  expect(result).toBe('main');
160
162
  });
161
163
  });
162
- describe('getAllBranches', () => {
163
- it('should return all branches without duplicates', () => {
164
+ describe('getAllBranchesEffect', () => {
165
+ it('should return Effect with all branches without duplicates', async () => {
164
166
  mockedExecSync.mockImplementation((cmd, _options) => {
165
167
  if (typeof cmd === 'string') {
166
168
  if (cmd === 'git rev-parse --git-common-dir') {
@@ -177,10 +179,11 @@ origin/feature/test
177
179
  }
178
180
  throw new Error('Command not mocked: ' + cmd);
179
181
  });
180
- const result = service.getAllBranches();
182
+ const effect = service.getAllBranchesEffect();
183
+ const result = await Effect.runPromise(effect);
181
184
  expect(result).toEqual(['main', 'feature/test', 'feature/remote']);
182
185
  });
183
- it('should return empty array on error', () => {
186
+ it('should return empty array on error', async () => {
184
187
  mockedExecSync.mockImplementation((cmd, _options) => {
185
188
  if (typeof cmd === 'string' &&
186
189
  cmd === 'git rev-parse --git-common-dir') {
@@ -188,10 +191,67 @@ origin/feature/test
188
191
  }
189
192
  throw new Error('Git error');
190
193
  });
191
- const result = service.getAllBranches();
194
+ const effect = service.getAllBranchesEffect();
195
+ const result = await Effect.runPromise(effect);
192
196
  expect(result).toEqual([]);
193
197
  });
194
198
  });
199
+ describe('getCurrentBranchEffect', () => {
200
+ it('should return Effect with current branch name on success', async () => {
201
+ mockedExecSync.mockImplementation((cmd, _options) => {
202
+ if (typeof cmd === 'string') {
203
+ if (cmd === 'git rev-parse --git-common-dir') {
204
+ return '/fake/path/.git\n';
205
+ }
206
+ if (cmd === 'git rev-parse --abbrev-ref HEAD') {
207
+ return 'feature-branch\n';
208
+ }
209
+ }
210
+ throw new Error('Command not mocked: ' + cmd);
211
+ });
212
+ const effect = service.getCurrentBranchEffect();
213
+ const result = await Effect.runPromise(effect);
214
+ expect(result).toBe('feature-branch');
215
+ expect(execSync).toHaveBeenCalledWith('git rev-parse --abbrev-ref HEAD', expect.objectContaining({
216
+ cwd: '/fake/path',
217
+ encoding: 'utf8',
218
+ }));
219
+ });
220
+ it('should return Effect with "unknown" when git command fails', async () => {
221
+ mockedExecSync.mockImplementation((cmd, _options) => {
222
+ if (typeof cmd === 'string') {
223
+ if (cmd === 'git rev-parse --git-common-dir') {
224
+ return '/fake/path/.git\n';
225
+ }
226
+ if (cmd === 'git rev-parse --abbrev-ref HEAD') {
227
+ throw new Error('fatal: not a git repository');
228
+ }
229
+ }
230
+ throw new Error('Command not mocked: ' + cmd);
231
+ });
232
+ const effect = service.getCurrentBranchEffect();
233
+ const result = await Effect.runPromise(effect);
234
+ // Should fallback to 'unknown' instead of failing
235
+ expect(result).toBe('unknown');
236
+ });
237
+ it('should return Effect with "unknown" when branch name is empty', async () => {
238
+ mockedExecSync.mockImplementation((cmd, _options) => {
239
+ if (typeof cmd === 'string') {
240
+ if (cmd === 'git rev-parse --git-common-dir') {
241
+ return '/fake/path/.git\n';
242
+ }
243
+ if (cmd === 'git rev-parse --abbrev-ref HEAD') {
244
+ return '\n'; // Empty branch name
245
+ }
246
+ }
247
+ throw new Error('Command not mocked: ' + cmd);
248
+ });
249
+ const effect = service.getCurrentBranchEffect();
250
+ const result = await Effect.runPromise(effect);
251
+ // Should fallback to 'unknown' when no branch returned
252
+ expect(result).toBe('unknown');
253
+ });
254
+ });
195
255
  describe('resolveBranchReference', () => {
196
256
  it('should return local branch when it exists', () => {
197
257
  mockedExecSync.mockImplementation((cmd, _options) => {
@@ -308,88 +368,8 @@ origin/feature/test
308
368
  expect(result).toBe('foo/bar-xyz');
309
369
  });
310
370
  });
311
- describe('createWorktree', () => {
312
- it('should create worktree with base branch when branch does not exist', async () => {
313
- mockedExecSync.mockImplementation((cmd, _options) => {
314
- if (typeof cmd === 'string') {
315
- if (cmd === 'git rev-parse --git-common-dir') {
316
- return '/fake/path/.git\n';
317
- }
318
- if (cmd.includes('rev-parse --verify')) {
319
- throw new Error('Branch not found');
320
- }
321
- return '';
322
- }
323
- throw new Error('Unexpected command');
324
- });
325
- const result = await service.createWorktree('/path/to/worktree', 'new-feature', 'develop');
326
- expect(result).toEqual({ success: true });
327
- expect(execSync).toHaveBeenCalledWith('git worktree add -b "new-feature" "/path/to/worktree" "develop"', expect.any(Object));
328
- });
329
- it('should create worktree without base branch when branch exists', async () => {
330
- mockedExecSync.mockImplementation((cmd, _options) => {
331
- if (typeof cmd === 'string') {
332
- if (cmd === 'git rev-parse --git-common-dir') {
333
- return '/fake/path/.git\n';
334
- }
335
- if (cmd.includes('rev-parse --verify')) {
336
- return 'hash';
337
- }
338
- return '';
339
- }
340
- throw new Error('Unexpected command');
341
- });
342
- const result = await service.createWorktree('/path/to/worktree', 'existing-feature', 'main');
343
- expect(result).toEqual({ success: true });
344
- expect(execSync).toHaveBeenCalledWith('git worktree add "/path/to/worktree" "existing-feature"', expect.any(Object));
345
- });
346
- it('should handle ambiguous branch error gracefully', async () => {
347
- mockedExecSync.mockImplementation((cmd, _options) => {
348
- if (typeof cmd === 'string') {
349
- if (cmd === 'git rev-parse --git-common-dir') {
350
- return '/fake/path/.git\n';
351
- }
352
- if (cmd.includes('rev-parse --verify new-feature')) {
353
- throw new Error('Branch not found');
354
- }
355
- if (cmd.includes('show-ref --verify --quiet refs/heads/foo/bar-xyz')) {
356
- throw new Error('Local branch not found');
357
- }
358
- if (cmd === 'git remote') {
359
- return 'origin\nupstream\n';
360
- }
361
- if (cmd.includes('show-ref --verify --quiet refs/remotes/origin/foo/bar-xyz') ||
362
- cmd.includes('show-ref --verify --quiet refs/remotes/upstream/foo/bar-xyz')) {
363
- return ''; // Both remotes have the branch
364
- }
365
- }
366
- throw new Error('Command not mocked: ' + cmd);
367
- });
368
- const result = await service.createWorktree('/path/to/worktree', 'new-feature', 'foo/bar-xyz');
369
- expect(result.success).toBe(false);
370
- expect(result.error).toContain("Ambiguous branch 'foo/bar-xyz' found in multiple remotes");
371
- expect(result.error).toContain('origin/foo/bar-xyz, upstream/foo/bar-xyz');
372
- });
373
- it('should create worktree from specified base branch when branch does not exist', async () => {
374
- mockedExecSync.mockImplementation((cmd, _options) => {
375
- if (typeof cmd === 'string') {
376
- if (cmd === 'git rev-parse --git-common-dir') {
377
- return '/fake/path/.git\n';
378
- }
379
- if (cmd.includes('rev-parse --verify')) {
380
- throw new Error('Branch not found');
381
- }
382
- return '';
383
- }
384
- throw new Error('Unexpected command');
385
- });
386
- const result = await service.createWorktree('/path/to/worktree', 'new-feature', 'main');
387
- expect(result).toEqual({ success: true });
388
- expect(execSync).toHaveBeenCalledWith('git worktree add -b "new-feature" "/path/to/worktree" "main"', expect.any(Object));
389
- });
390
- });
391
- describe('hasClaudeDirectoryInBranch', () => {
392
- it('should return true when .claude directory exists in branch worktree', () => {
371
+ describe('hasClaudeDirectoryInBranchEffect', () => {
372
+ it('should return Effect with true when .claude directory exists in branch worktree', async () => {
393
373
  mockedExecSync.mockImplementation((cmd, _options) => {
394
374
  if (typeof cmd === 'string') {
395
375
  if (cmd === 'git rev-parse --git-common-dir') {
@@ -414,12 +394,13 @@ branch refs/heads/feature-branch
414
394
  mockedStatSync.mockImplementation(() => ({
415
395
  isDirectory: () => true,
416
396
  }));
417
- const result = service.hasClaudeDirectoryInBranch('feature-branch');
397
+ const effect = service.hasClaudeDirectoryInBranchEffect('feature-branch');
398
+ const result = await Effect.runPromise(effect);
418
399
  expect(result).toBe(true);
419
400
  expect(existsSync).toHaveBeenCalledWith('/fake/path/feature-branch/.claude');
420
401
  expect(statSync).toHaveBeenCalledWith('/fake/path/feature-branch/.claude');
421
402
  });
422
- it('should return false when .claude directory does not exist', () => {
403
+ it('should return Effect with false when .claude directory does not exist', async () => {
423
404
  mockedExecSync.mockImplementation((cmd, _options) => {
424
405
  if (typeof cmd === 'string') {
425
406
  if (cmd === 'git rev-parse --git-common-dir') {
@@ -439,11 +420,12 @@ branch refs/heads/feature-branch
439
420
  throw new Error('Command not mocked: ' + cmd);
440
421
  });
441
422
  mockedExistsSync.mockReturnValue(false);
442
- const result = service.hasClaudeDirectoryInBranch('feature-branch');
423
+ const effect = service.hasClaudeDirectoryInBranchEffect('feature-branch');
424
+ const result = await Effect.runPromise(effect);
443
425
  expect(result).toBe(false);
444
426
  expect(existsSync).toHaveBeenCalledWith('/fake/path/feature-branch/.claude');
445
427
  });
446
- it('should return false when .claude exists but is not a directory', () => {
428
+ it('should return Effect with false when .claude exists but is not a directory', async () => {
447
429
  mockedExecSync.mockImplementation((cmd, _options) => {
448
430
  if (typeof cmd === 'string') {
449
431
  if (cmd === 'git rev-parse --git-common-dir') {
@@ -466,10 +448,11 @@ branch refs/heads/feature-branch
466
448
  mockedStatSync.mockImplementation(() => ({
467
449
  isDirectory: () => false,
468
450
  }));
469
- const result = service.hasClaudeDirectoryInBranch('feature-branch');
451
+ const effect = service.hasClaudeDirectoryInBranchEffect('feature-branch');
452
+ const result = await Effect.runPromise(effect);
470
453
  expect(result).toBe(false);
471
454
  });
472
- it('should fallback to default branch when branch worktree not found', () => {
455
+ it('should fallback to default branch when branch worktree not found', async () => {
473
456
  mockedExecSync.mockImplementation((cmd, _options) => {
474
457
  if (typeof cmd === 'string') {
475
458
  if (cmd === 'git rev-parse --git-common-dir') {
@@ -492,11 +475,12 @@ branch refs/heads/main
492
475
  isDirectory: () => true,
493
476
  }));
494
477
  // When asking for main branch that doesn't have a separate worktree
495
- const result = service.hasClaudeDirectoryInBranch('main');
478
+ const effect = service.hasClaudeDirectoryInBranchEffect('main');
479
+ const result = await Effect.runPromise(effect);
496
480
  expect(result).toBe(true);
497
481
  expect(existsSync).toHaveBeenCalledWith('/fake/path/.claude');
498
482
  });
499
- it('should return false when branch not found in any worktree', () => {
483
+ it('should return Effect with false when branch not found in any worktree', async () => {
500
484
  mockedExecSync.mockImplementation((cmd, _options) => {
501
485
  if (typeof cmd === 'string') {
502
486
  if (cmd === 'git rev-parse --git-common-dir') {
@@ -514,10 +498,11 @@ branch refs/heads/main
514
498
  }
515
499
  throw new Error('Command not mocked: ' + cmd);
516
500
  });
517
- const result = service.hasClaudeDirectoryInBranch('non-existent-branch');
501
+ const effect = service.hasClaudeDirectoryInBranchEffect('non-existent-branch');
502
+ const result = await Effect.runPromise(effect);
518
503
  expect(result).toBe(false);
519
504
  });
520
- it('should check main worktree when branch is default branch', () => {
505
+ it('should check main worktree when branch is default branch', async () => {
521
506
  mockedExecSync.mockImplementation((cmd, _options) => {
522
507
  if (typeof cmd === 'string') {
523
508
  if (cmd === 'git rev-parse --git-common-dir') {
@@ -543,327 +528,323 @@ branch refs/heads/other-branch
543
528
  mockedStatSync.mockImplementation(() => ({
544
529
  isDirectory: () => true,
545
530
  }));
546
- const result = service.hasClaudeDirectoryInBranch('main');
531
+ const effect = service.hasClaudeDirectoryInBranchEffect('main');
532
+ const result = await Effect.runPromise(effect);
547
533
  expect(result).toBe(true);
548
534
  expect(existsSync).toHaveBeenCalledWith('/fake/path/.claude');
549
535
  });
550
536
  });
551
- describe('Worktree Hook Execution', () => {
552
- afterEach(() => {
553
- vi.clearAllMocks();
554
- });
555
- it('should execute post-creation hook when worktree is created', async () => {
556
- // Arrange
557
- const hookCommand = 'echo "Worktree created: $CCMANAGER_WORKTREE_PATH"';
558
- mockedGetWorktreeHooks.mockReturnValue({
559
- post_creation: {
560
- command: hookCommand,
561
- enabled: true,
562
- },
563
- });
564
- mockedExecuteHook.mockResolvedValue(undefined);
537
+ describe('Effect-based getWorktrees', () => {
538
+ it('should return Effect with worktree array on success', async () => {
565
539
  mockedExecSync.mockImplementation((cmd, _options) => {
566
540
  if (typeof cmd === 'string') {
567
541
  if (cmd === 'git rev-parse --git-common-dir') {
568
542
  return '/fake/path/.git\n';
569
543
  }
570
- if (cmd.includes('git worktree list')) {
571
- return 'worktree /fake/path\nHEAD abc123\nbranch refs/heads/main\n';
572
- }
573
- if (cmd.includes('git worktree add')) {
574
- return '';
575
- }
576
- if (cmd.includes('git rev-parse --verify')) {
577
- throw new Error('Branch not found');
544
+ if (cmd === 'git worktree list --porcelain') {
545
+ return `worktree /fake/path
546
+ HEAD abcd1234
547
+ branch refs/heads/main
548
+
549
+ worktree /fake/path/feature
550
+ HEAD efgh5678
551
+ branch refs/heads/feature
552
+ `;
578
553
  }
579
554
  }
580
- return '';
555
+ throw new Error('Command not mocked: ' + cmd);
581
556
  });
582
- // Act
583
- const result = await service.createWorktree('feature-branch-dir', 'feature-branch', 'main', false, false);
584
- // Assert
585
- expect(result.success).toBe(true);
586
- expect(mockedGetWorktreeHooks).toHaveBeenCalled();
587
- expect(mockedExecuteHook).toHaveBeenCalledWith(hookCommand, expect.objectContaining({
588
- path: '/fake/path/feature-branch-dir',
589
- branch: 'feature-branch',
557
+ const effect = service.getWorktreesEffect();
558
+ const result = await Effect.runPromise(effect);
559
+ expect(result).toHaveLength(2);
560
+ expect(result[0]).toMatchObject({
561
+ path: '/fake/path',
562
+ branch: 'main',
563
+ isMainWorktree: true,
564
+ });
565
+ expect(result[1]).toMatchObject({
566
+ path: '/fake/path/feature',
567
+ branch: 'feature',
590
568
  isMainWorktree: false,
591
- hasSession: false,
592
- }), '/fake/path', 'main');
593
- });
594
- it('should not execute hook when disabled', async () => {
595
- // Arrange
596
- mockedGetWorktreeHooks.mockReturnValue({
597
- post_creation: {
598
- command: 'echo "Should not run"',
599
- enabled: false,
600
- },
601
569
  });
570
+ });
571
+ it('should return Effect that fails with GitError when git command fails', async () => {
602
572
  mockedExecSync.mockImplementation((cmd, _options) => {
603
573
  if (typeof cmd === 'string') {
604
574
  if (cmd === 'git rev-parse --git-common-dir') {
605
575
  return '/fake/path/.git\n';
606
576
  }
607
- if (cmd.includes('git worktree list')) {
608
- return 'worktree /fake/path\nHEAD abc123\nbranch refs/heads/main\n';
609
- }
610
- if (cmd.includes('git worktree add')) {
611
- return '';
612
- }
613
- if (cmd.includes('git rev-parse --verify')) {
614
- throw new Error('Branch not found');
577
+ if (cmd === 'git worktree list --porcelain') {
578
+ const error = new Error('fatal: not a git repository');
579
+ error.status = 128;
580
+ error.stderr = 'fatal: not a git repository';
581
+ throw error;
615
582
  }
616
583
  }
617
- return '';
584
+ throw new Error('Command not mocked: ' + cmd);
618
585
  });
619
- // Act
620
- const result = await service.createWorktree('feature-branch-dir', 'feature-branch', 'main', false, false);
621
- // Assert
622
- expect(result.success).toBe(true);
623
- expect(mockedGetWorktreeHooks).toHaveBeenCalled();
624
- expect(mockedExecuteHook).not.toHaveBeenCalled();
586
+ const effect = service.getWorktreesEffect();
587
+ const result = await Effect.runPromise(Effect.either(effect));
588
+ if (result._tag === 'Left') {
589
+ expect(result.left).toBeInstanceOf(GitError);
590
+ expect(result.left.command).toBe('git worktree list --porcelain');
591
+ expect(result.left.exitCode).toBe(128);
592
+ expect(result.left.stderr).toContain('not a git repository');
593
+ }
594
+ else {
595
+ expect.fail('Should have returned Left with GitError');
596
+ }
625
597
  });
626
- it('should not execute hook when not configured', async () => {
627
- // Arrange
628
- mockedGetWorktreeHooks.mockReturnValue({});
598
+ it('should fallback to single worktree when git worktree command not supported', async () => {
629
599
  mockedExecSync.mockImplementation((cmd, _options) => {
630
600
  if (typeof cmd === 'string') {
631
601
  if (cmd === 'git rev-parse --git-common-dir') {
632
602
  return '/fake/path/.git\n';
633
603
  }
634
- if (cmd.includes('git worktree list')) {
635
- return 'worktree /fake/path\nHEAD abc123\nbranch refs/heads/main\n';
636
- }
637
- if (cmd.includes('git worktree add')) {
638
- return '';
604
+ if (cmd === 'git worktree list --porcelain') {
605
+ const error = new Error('unknown command: worktree');
606
+ error.status = 1;
607
+ error.stderr = 'unknown command: worktree';
608
+ throw error;
639
609
  }
640
- if (cmd.includes('git rev-parse --verify')) {
641
- throw new Error('Branch not found');
610
+ if (cmd === 'git rev-parse --abbrev-ref HEAD') {
611
+ return 'main\n';
642
612
  }
643
613
  }
644
- return '';
614
+ throw new Error('Command not mocked: ' + cmd);
615
+ });
616
+ const effect = service.getWorktreesEffect();
617
+ const result = await Effect.runPromise(effect);
618
+ expect(result).toHaveLength(1);
619
+ expect(result[0]).toMatchObject({
620
+ path: '/fake/path',
621
+ branch: 'main',
622
+ isMainWorktree: true,
645
623
  });
646
- // Act
647
- const result = await service.createWorktree('feature-branch-dir', 'feature-branch', 'main', false, false);
648
- // Assert
649
- expect(result.success).toBe(true);
650
- expect(mockedGetWorktreeHooks).toHaveBeenCalled();
651
- expect(mockedExecuteHook).not.toHaveBeenCalled();
652
- });
653
- it('should not fail worktree creation if hook execution fails', async () => {
654
- // Arrange
655
- mockedGetWorktreeHooks.mockReturnValue({
656
- post_creation: {
657
- command: 'failing-command',
658
- enabled: true,
659
- },
660
- });
661
- // The real executeWorktreePostCreationHook doesn't throw, it catches errors internally
662
- // So the mock should resolve, not reject
663
- mockedExecuteHook.mockResolvedValue(undefined);
624
+ });
625
+ });
626
+ describe('Effect-based createWorktree', () => {
627
+ it('should return Effect with Worktree on success', async () => {
664
628
  mockedExecSync.mockImplementation((cmd, _options) => {
665
629
  if (typeof cmd === 'string') {
666
630
  if (cmd === 'git rev-parse --git-common-dir') {
667
631
  return '/fake/path/.git\n';
668
632
  }
669
- if (cmd.includes('git worktree list')) {
670
- return 'worktree /fake/path\nHEAD abc123\nbranch refs/heads/main\n';
633
+ if (cmd.includes('rev-parse --verify')) {
634
+ throw new Error('Branch not found');
671
635
  }
672
636
  if (cmd.includes('git worktree add')) {
673
637
  return '';
674
638
  }
675
- if (cmd.includes('git rev-parse --verify')) {
676
- throw new Error('Branch not found');
677
- }
678
639
  }
679
640
  return '';
680
641
  });
681
- // Act
682
- const result = await service.createWorktree('feature-branch-dir', 'feature-branch', 'main', false, false);
683
- // Allow async operations to complete
684
- await new Promise(resolve => setTimeout(resolve, 10));
685
- // Assert
686
- expect(result.success).toBe(true);
687
- expect(mockedExecuteHook).toHaveBeenCalled();
642
+ const effect = service.createWorktreeEffect('/path/to/worktree', 'new-feature', 'main');
643
+ const result = await Effect.runPromise(effect);
644
+ expect(result).toMatchObject({
645
+ path: '/path/to/worktree',
646
+ branch: 'new-feature',
647
+ isMainWorktree: false,
648
+ });
688
649
  });
689
- });
690
- describe('AmbiguousBranchError Integration', () => {
691
- it('should return error message when createWorktree encounters ambiguous branch', async () => {
650
+ it('should return Effect that fails with GitError on git command failure', async () => {
692
651
  mockedExecSync.mockImplementation((cmd, _options) => {
693
652
  if (typeof cmd === 'string') {
694
653
  if (cmd === 'git rev-parse --git-common-dir') {
695
654
  return '/fake/path/.git\n';
696
655
  }
697
- if (cmd.includes('rev-parse --verify new-feature')) {
656
+ if (cmd.includes('rev-parse --verify')) {
698
657
  throw new Error('Branch not found');
699
658
  }
700
- if (cmd.includes('show-ref --verify --quiet refs/heads/ambiguous-branch')) {
701
- throw new Error('Local branch not found');
702
- }
703
- if (cmd === 'git remote') {
704
- return 'origin\nupstream\n';
705
- }
706
- if (cmd.includes('show-ref --verify --quiet refs/remotes/origin/ambiguous-branch') ||
707
- cmd.includes('show-ref --verify --quiet refs/remotes/upstream/ambiguous-branch')) {
708
- return ''; // Both remotes have the branch
659
+ if (cmd.includes('git worktree add')) {
660
+ const error = new Error('fatal: invalid reference: main');
661
+ error.status = 128;
662
+ error.stderr = 'fatal: invalid reference: main';
663
+ throw error;
709
664
  }
710
665
  }
711
666
  throw new Error('Command not mocked: ' + cmd);
712
667
  });
713
- const result = await service.createWorktree('/path/to/worktree', 'new-feature', 'ambiguous-branch');
714
- expect(result.success).toBe(false);
715
- expect(result.error).toContain("Ambiguous branch 'ambiguous-branch' found in multiple remotes");
716
- expect(result.error).toContain('origin/ambiguous-branch, upstream/ambiguous-branch');
717
- expect(result.error).toContain('Please specify which remote to use');
668
+ const effect = service.createWorktreeEffect('/path/to/worktree', 'new-feature', 'main');
669
+ const result = await Effect.runPromise(Effect.either(effect));
670
+ if (result._tag === 'Left') {
671
+ expect(result.left).toBeInstanceOf(GitError);
672
+ expect(result.left.exitCode).toBe(128);
673
+ expect(result.left.stderr).toContain('invalid reference');
674
+ }
675
+ else {
676
+ expect.fail('Should have returned Left with GitError');
677
+ }
718
678
  });
719
- it('should successfully create worktree with resolved remote reference', async () => {
679
+ });
680
+ describe('Effect-based deleteWorktree', () => {
681
+ it('should return Effect with void on success', async () => {
720
682
  mockedExecSync.mockImplementation((cmd, _options) => {
721
683
  if (typeof cmd === 'string') {
722
684
  if (cmd === 'git rev-parse --git-common-dir') {
723
685
  return '/fake/path/.git\n';
724
686
  }
725
- if (cmd.includes('rev-parse --verify new-feature')) {
726
- throw new Error('Branch not found');
727
- }
728
- // Simulate resolved reference (origin/ambiguous-branch) exists
729
- if (cmd.includes('show-ref --verify --quiet refs/heads/origin/ambiguous-branch')) {
730
- throw new Error('Local branch not found');
731
- }
732
- if (cmd === 'git remote') {
733
- return 'origin\n';
687
+ if (cmd === 'git worktree list --porcelain') {
688
+ return `worktree /fake/path
689
+ HEAD abcd1234
690
+ branch refs/heads/main
691
+
692
+ worktree /fake/path/feature
693
+ HEAD efgh5678
694
+ branch refs/heads/feature
695
+ `;
734
696
  }
735
- if (cmd.includes('show-ref --verify --quiet refs/remotes/origin/origin/ambiguous-branch')) {
736
- throw new Error('Remote branch not found'); // This is expected for resolved reference
697
+ if (cmd.includes('git worktree remove')) {
698
+ return '';
737
699
  }
738
- // Mock successful worktree creation with resolved reference
739
- if (cmd.includes('git worktree add -b "new-feature" "/path/to/worktree" "origin/ambiguous-branch"')) {
700
+ if (cmd.includes('git branch -D')) {
740
701
  return '';
741
702
  }
742
703
  }
743
704
  throw new Error('Command not mocked: ' + cmd);
744
705
  });
745
- mockedExistsSync.mockReturnValue(false);
746
- const result = await service.createWorktree('/path/to/worktree', 'new-feature', 'origin/ambiguous-branch');
747
- expect(result.success).toBe(true);
748
- expect(mockedExecSync).toHaveBeenCalledWith('git worktree add -b "new-feature" "/path/to/worktree" "origin/ambiguous-branch"', { cwd: '/fake/path', encoding: 'utf8' });
706
+ const effect = service.deleteWorktreeEffect('/fake/path/feature');
707
+ await Effect.runPromise(effect);
708
+ expect(execSync).toHaveBeenCalledWith(expect.stringContaining('git worktree remove'), expect.any(Object));
749
709
  });
750
- it('should handle three-way ambiguous branch scenario', async () => {
710
+ it('should return Effect that fails with GitError when worktree not found', async () => {
751
711
  mockedExecSync.mockImplementation((cmd, _options) => {
752
712
  if (typeof cmd === 'string') {
753
713
  if (cmd === 'git rev-parse --git-common-dir') {
754
714
  return '/fake/path/.git\n';
755
715
  }
756
- if (cmd.includes('rev-parse --verify test-branch')) {
757
- throw new Error('Branch not found');
758
- }
759
- if (cmd.includes('show-ref --verify --quiet refs/heads/three-way-branch')) {
760
- throw new Error('Local branch not found');
761
- }
762
- if (cmd === 'git remote') {
763
- return 'origin\nupstream\nfork\n';
764
- }
765
- // All three remotes have the branch
766
- if (cmd.includes('show-ref --verify --quiet refs/remotes/origin/three-way-branch') ||
767
- cmd.includes('show-ref --verify --quiet refs/remotes/upstream/three-way-branch') ||
768
- cmd.includes('show-ref --verify --quiet refs/remotes/fork/three-way-branch')) {
769
- return '';
716
+ if (cmd === 'git worktree list --porcelain') {
717
+ return `worktree /fake/path
718
+ HEAD abcd1234
719
+ branch refs/heads/main
720
+ `;
770
721
  }
771
722
  }
772
723
  throw new Error('Command not mocked: ' + cmd);
773
724
  });
774
- const result = await service.createWorktree('/path/to/worktree', 'test-branch', 'three-way-branch');
775
- expect(result.success).toBe(false);
776
- expect(result.error).toContain("Ambiguous branch 'three-way-branch' found in multiple remotes");
777
- expect(result.error).toContain('origin/three-way-branch, upstream/three-way-branch, fork/three-way-branch');
725
+ const effect = service.deleteWorktreeEffect('/fake/path/nonexistent');
726
+ const result = await Effect.runPromise(Effect.either(effect));
727
+ if (result._tag === 'Left') {
728
+ expect(result.left).toBeInstanceOf(GitError);
729
+ expect(result.left.stderr).toContain('Worktree not found');
730
+ }
731
+ else {
732
+ expect.fail('Should have returned Left with GitError');
733
+ }
778
734
  });
779
- it('should handle complex branch names with slashes in ambiguous scenario', async () => {
735
+ it('should return Effect that fails with GitError when trying to delete main worktree', async () => {
780
736
  mockedExecSync.mockImplementation((cmd, _options) => {
781
737
  if (typeof cmd === 'string') {
782
738
  if (cmd === 'git rev-parse --git-common-dir') {
783
739
  return '/fake/path/.git\n';
784
740
  }
785
- if (cmd.includes('rev-parse --verify new-feature')) {
786
- throw new Error('Branch not found');
787
- }
788
- if (cmd.includes('show-ref --verify --quiet refs/heads/feature/sub/complex-name')) {
789
- throw new Error('Local branch not found');
790
- }
791
- if (cmd === 'git remote') {
792
- return 'origin\nfork\n';
793
- }
794
- if (cmd.includes('show-ref --verify --quiet refs/remotes/origin/feature/sub/complex-name') ||
795
- cmd.includes('show-ref --verify --quiet refs/remotes/fork/feature/sub/complex-name')) {
796
- return '';
741
+ if (cmd === 'git worktree list --porcelain') {
742
+ return `worktree /fake/path
743
+ HEAD abcd1234
744
+ branch refs/heads/main
745
+ `;
797
746
  }
798
747
  }
799
748
  throw new Error('Command not mocked: ' + cmd);
800
749
  });
801
- const result = await service.createWorktree('/path/to/worktree', 'new-feature', 'feature/sub/complex-name');
802
- expect(result.success).toBe(false);
803
- expect(result.error).toContain("Ambiguous branch 'feature/sub/complex-name' found in multiple remotes");
804
- expect(result.error).toContain('origin/feature/sub/complex-name, fork/feature/sub/complex-name');
750
+ const effect = service.deleteWorktreeEffect('/fake/path');
751
+ const result = await Effect.runPromise(Effect.either(effect));
752
+ if (result._tag === 'Left') {
753
+ expect(result.left).toBeInstanceOf(GitError);
754
+ expect(result.left.stderr).toContain('Cannot delete the main worktree');
755
+ }
756
+ else {
757
+ expect.fail('Should have returned Left with GitError');
758
+ }
805
759
  });
806
- it('should successfully resolve single remote branch with slashes', async () => {
760
+ });
761
+ describe('Effect-based mergeWorktree', () => {
762
+ it('should return Effect with void on successful merge', async () => {
807
763
  mockedExecSync.mockImplementation((cmd, _options) => {
808
764
  if (typeof cmd === 'string') {
809
765
  if (cmd === 'git rev-parse --git-common-dir') {
810
766
  return '/fake/path/.git\n';
811
767
  }
812
- if (cmd.includes('rev-parse --verify new-feature')) {
813
- throw new Error('Branch not found');
814
- }
815
- if (cmd.includes('show-ref --verify --quiet refs/heads/feature/auto-resolve')) {
816
- throw new Error('Local branch not found');
817
- }
818
- if (cmd === 'git remote') {
819
- return 'origin\nupstream\n';
768
+ if (cmd === 'git worktree list --porcelain') {
769
+ return `worktree /fake/path
770
+ HEAD abcd1234
771
+ branch refs/heads/main
772
+
773
+ worktree /fake/path/feature
774
+ HEAD efgh5678
775
+ branch refs/heads/feature
776
+ `;
820
777
  }
821
- // Only origin has this branch
822
- if (cmd.includes('show-ref --verify --quiet refs/remotes/origin/feature/auto-resolve')) {
823
- return '';
778
+ if (cmd.includes('git merge')) {
779
+ return 'Merge successful';
824
780
  }
825
- if (cmd.includes('show-ref --verify --quiet refs/remotes/upstream/feature/auto-resolve')) {
826
- throw new Error('Remote branch not found');
781
+ }
782
+ throw new Error('Command not mocked: ' + cmd);
783
+ });
784
+ const effect = service.mergeWorktreeEffect('feature', 'main', false);
785
+ await Effect.runPromise(effect);
786
+ expect(execSync).toHaveBeenCalledWith('git merge --no-ff "feature"', expect.any(Object));
787
+ });
788
+ it('should return Effect that fails with GitError when target branch not found', async () => {
789
+ mockedExecSync.mockImplementation((cmd, _options) => {
790
+ if (typeof cmd === 'string') {
791
+ if (cmd === 'git rev-parse --git-common-dir') {
792
+ return '/fake/path/.git\n';
827
793
  }
828
- // Mock successful worktree creation with auto-resolved reference
829
- if (cmd.includes('git worktree add -b "new-feature" "/path/to/worktree" "origin/feature/auto-resolve"')) {
830
- return '';
794
+ if (cmd === 'git worktree list --porcelain') {
795
+ return `worktree /fake/path
796
+ HEAD abcd1234
797
+ branch refs/heads/main
798
+ `;
831
799
  }
832
800
  }
833
801
  throw new Error('Command not mocked: ' + cmd);
834
802
  });
835
- mockedExistsSync.mockReturnValue(false);
836
- const result = await service.createWorktree('/path/to/worktree', 'new-feature', 'feature/auto-resolve');
837
- expect(result.success).toBe(true);
838
- expect(mockedExecSync).toHaveBeenCalledWith('git worktree add -b "new-feature" "/path/to/worktree" "origin/feature/auto-resolve"', { cwd: '/fake/path', encoding: 'utf8' });
803
+ const effect = service.mergeWorktreeEffect('feature', 'nonexistent', false);
804
+ const result = await Effect.runPromise(Effect.either(effect));
805
+ if (result._tag === 'Left') {
806
+ expect(result.left).toBeInstanceOf(GitError);
807
+ expect(result.left.stderr).toContain('Target branch worktree not found');
808
+ }
809
+ else {
810
+ expect.fail('Should have returned Left with GitError');
811
+ }
839
812
  });
840
- it('should prioritize local branch over remote branches', async () => {
813
+ it('should return Effect that fails with GitError on merge conflict', async () => {
841
814
  mockedExecSync.mockImplementation((cmd, _options) => {
842
815
  if (typeof cmd === 'string') {
843
816
  if (cmd === 'git rev-parse --git-common-dir') {
844
817
  return '/fake/path/.git\n';
845
818
  }
846
- if (cmd.includes('rev-parse --verify new-feature')) {
847
- throw new Error('Branch not found');
848
- }
849
- // Local branch exists (highest priority)
850
- if (cmd.includes('show-ref --verify --quiet refs/heads/local-priority')) {
851
- return '';
819
+ if (cmd === 'git worktree list --porcelain') {
820
+ return `worktree /fake/path
821
+ HEAD abcd1234
822
+ branch refs/heads/main
823
+
824
+ worktree /fake/path/feature
825
+ HEAD efgh5678
826
+ branch refs/heads/feature
827
+ `;
852
828
  }
853
- // Remote checks should not be executed when local exists
854
- // Mock successful worktree creation with local branch
855
- if (cmd.includes('git worktree add -b "new-feature" "/path/to/worktree" "local-priority"')) {
856
- return '';
829
+ if (cmd.includes('git merge')) {
830
+ const error = new Error('CONFLICT: Merge conflict');
831
+ error.status = 1;
832
+ error.stderr = 'CONFLICT: Merge conflict in file.txt';
833
+ throw error;
857
834
  }
858
835
  }
859
836
  throw new Error('Command not mocked: ' + cmd);
860
837
  });
861
- mockedExistsSync.mockReturnValue(false);
862
- const result = await service.createWorktree('/path/to/worktree', 'new-feature', 'local-priority');
863
- expect(result.success).toBe(true);
864
- expect(mockedExecSync).toHaveBeenCalledWith('git worktree add -b "new-feature" "/path/to/worktree" "local-priority"', { cwd: '/fake/path', encoding: 'utf8' });
865
- // Verify remote command was never called since local branch exists
866
- expect(mockedExecSync).not.toHaveBeenCalledWith('git remote', expect.any(Object));
838
+ const effect = service.mergeWorktreeEffect('feature', 'main', false);
839
+ const result = await Effect.runPromise(Effect.either(effect));
840
+ if (result._tag === 'Left') {
841
+ expect(result.left).toBeInstanceOf(GitError);
842
+ expect(result.left.exitCode).toBe(1);
843
+ expect(result.left.stderr).toContain('Merge conflict');
844
+ }
845
+ else {
846
+ expect.fail('Should have returned Left with GitError');
847
+ }
867
848
  });
868
849
  });
869
850
  });