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
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Test helper utilities for Effect-ts based testing
3
+ *
4
+ * This module provides utilities to simplify testing Effect and Either types:
5
+ * - Synchronous and asynchronous Effect execution
6
+ * - Assertions for success and failure cases
7
+ * - Pattern matching for specific error types
8
+ *
9
+ * These utilities follow Effect-ts best practices and are designed to work
10
+ * seamlessly with Vitest test framework.
11
+ *
12
+ * @example
13
+ * ```typescript
14
+ * // Test successful Effect
15
+ * const result = expectEffectSuccess(myService.getData());
16
+ * expect(result).toEqual(expectedData);
17
+ *
18
+ * // Test failed Effect
19
+ * const error = expectEffectFailure(myService.failingOp());
20
+ * expect(error._tag).toBe('GitError');
21
+ *
22
+ * // Pattern match on specific error type
23
+ * const command = matchEffectError(effect, {
24
+ * GitError: (err) => err.command
25
+ * });
26
+ * expect(command).toBe('git status');
27
+ * ```
28
+ *
29
+ * @module testHelpers
30
+ */
31
+ import { Effect, Either } from 'effect';
32
+ import type { AppError } from '../types/errors.js';
33
+ /**
34
+ * Run an Effect synchronously and return the success value
35
+ * Throws if the Effect fails
36
+ *
37
+ * @param effect - The Effect to run
38
+ * @returns The success value
39
+ * @throws Error if the Effect fails
40
+ */
41
+ export declare function runEffectSync<A, E>(effect: Effect.Effect<A, E, never>): A;
42
+ /**
43
+ * Run an Effect as a Promise
44
+ *
45
+ * @param effect - The Effect to run
46
+ * @returns Promise that resolves with success value or rejects with error
47
+ */
48
+ export declare function runEffectPromise<A, E>(effect: Effect.Effect<A, E, never>): Promise<A>;
49
+ /**
50
+ * Assert that an Effect succeeds and return the success value
51
+ * Throws an error if the Effect fails
52
+ *
53
+ * @param effect - The Effect to test
54
+ * @returns The success value
55
+ * @throws Error if the Effect fails
56
+ */
57
+ export declare function expectEffectSuccess<A, E>(effect: Effect.Effect<A, E, never>): A;
58
+ /**
59
+ * Assert that an Effect fails and return the error
60
+ * Throws an error if the Effect succeeds
61
+ *
62
+ * @param effect - The Effect to test
63
+ * @returns The error value
64
+ * @throws Error if the Effect succeeds
65
+ */
66
+ export declare function expectEffectFailure<A, E>(effect: Effect.Effect<A, E, never>): E;
67
+ /**
68
+ * Assert that an Either is Right and return the right value
69
+ * Throws an error if the Either is Left
70
+ *
71
+ * @param either - The Either to test
72
+ * @returns The right value
73
+ * @throws Error if the Either is Left
74
+ */
75
+ export declare function expectEitherRight<A, E>(either: Either.Either<A, E>): A;
76
+ /**
77
+ * Assert that an Either is Left and return the left value
78
+ * Throws an error if the Either is Right
79
+ *
80
+ * @param either - The Either to test
81
+ * @returns The left value
82
+ * @throws Error if the Either is Right
83
+ */
84
+ export declare function expectEitherLeft<A, E>(either: Either.Either<A, E>): E;
85
+ /**
86
+ * Pattern match on Effect error and extract specific error type
87
+ * Useful for testing specific error scenarios
88
+ *
89
+ * @param effect - The Effect that should fail
90
+ * @param matchers - Object mapping error tags to extraction functions
91
+ * @returns The extracted value from the matched error
92
+ * @throws Error if the Effect succeeds or error doesn't match
93
+ *
94
+ * @example
95
+ * ```typescript
96
+ * const result = matchEffectError(effect, {
97
+ * GitError: (err) => err.command,
98
+ * ValidationError: (err) => err.field
99
+ * });
100
+ * ```
101
+ */
102
+ export declare function matchEffectError<R>(effect: Effect.Effect<unknown, AppError, never>, matchers: {
103
+ [K in AppError['_tag']]?: (error: Extract<AppError, {
104
+ _tag: K;
105
+ }>) => R;
106
+ }): R;
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Test helper utilities for Effect-ts based testing
3
+ *
4
+ * This module provides utilities to simplify testing Effect and Either types:
5
+ * - Synchronous and asynchronous Effect execution
6
+ * - Assertions for success and failure cases
7
+ * - Pattern matching for specific error types
8
+ *
9
+ * These utilities follow Effect-ts best practices and are designed to work
10
+ * seamlessly with Vitest test framework.
11
+ *
12
+ * @example
13
+ * ```typescript
14
+ * // Test successful Effect
15
+ * const result = expectEffectSuccess(myService.getData());
16
+ * expect(result).toEqual(expectedData);
17
+ *
18
+ * // Test failed Effect
19
+ * const error = expectEffectFailure(myService.failingOp());
20
+ * expect(error._tag).toBe('GitError');
21
+ *
22
+ * // Pattern match on specific error type
23
+ * const command = matchEffectError(effect, {
24
+ * GitError: (err) => err.command
25
+ * });
26
+ * expect(command).toBe('git status');
27
+ * ```
28
+ *
29
+ * @module testHelpers
30
+ */
31
+ import { Effect, Either, Exit } from 'effect';
32
+ /**
33
+ * Run an Effect synchronously and return the success value
34
+ * Throws if the Effect fails
35
+ *
36
+ * @param effect - The Effect to run
37
+ * @returns The success value
38
+ * @throws Error if the Effect fails
39
+ */
40
+ export function runEffectSync(effect) {
41
+ const exit = Effect.runSync(Effect.exit(effect));
42
+ if (Exit.isFailure(exit)) {
43
+ // Extract the error from the Cause
44
+ const cause = exit.cause;
45
+ if (cause._tag === 'Fail') {
46
+ throw cause.error;
47
+ }
48
+ throw new Error(`Unexpected cause type: ${cause._tag}`);
49
+ }
50
+ return exit.value;
51
+ }
52
+ /**
53
+ * Run an Effect as a Promise
54
+ *
55
+ * @param effect - The Effect to run
56
+ * @returns Promise that resolves with success value or rejects with error
57
+ */
58
+ export function runEffectPromise(effect) {
59
+ return Effect.runPromise(effect);
60
+ }
61
+ /**
62
+ * Assert that an Effect succeeds and return the success value
63
+ * Throws an error if the Effect fails
64
+ *
65
+ * @param effect - The Effect to test
66
+ * @returns The success value
67
+ * @throws Error if the Effect fails
68
+ */
69
+ export function expectEffectSuccess(effect) {
70
+ const exit = Effect.runSync(Effect.exit(effect));
71
+ if (Exit.isFailure(exit)) {
72
+ throw new Error(`Expected Effect to succeed, but it failed with: ${JSON.stringify(exit.cause)}`);
73
+ }
74
+ return exit.value;
75
+ }
76
+ /**
77
+ * Assert that an Effect fails and return the error
78
+ * Throws an error if the Effect succeeds
79
+ *
80
+ * @param effect - The Effect to test
81
+ * @returns The error value
82
+ * @throws Error if the Effect succeeds
83
+ */
84
+ export function expectEffectFailure(effect) {
85
+ const exit = Effect.runSync(Effect.exit(effect));
86
+ if (Exit.isSuccess(exit)) {
87
+ throw new Error(`Expected Effect to fail, but it succeeded with: ${JSON.stringify(exit.value)}`);
88
+ }
89
+ // Extract the error from the Cause
90
+ const failure = exit.cause;
91
+ if (failure._tag === 'Fail') {
92
+ return failure.error;
93
+ }
94
+ throw new Error(`Expected Fail cause, got: ${failure._tag}`);
95
+ }
96
+ /**
97
+ * Assert that an Either is Right and return the right value
98
+ * Throws an error if the Either is Left
99
+ *
100
+ * @param either - The Either to test
101
+ * @returns The right value
102
+ * @throws Error if the Either is Left
103
+ */
104
+ export function expectEitherRight(either) {
105
+ if (Either.isLeft(either)) {
106
+ throw new Error(`Expected Either to be Right, but it was Left with: ${JSON.stringify(either.left)}`);
107
+ }
108
+ return either.right;
109
+ }
110
+ /**
111
+ * Assert that an Either is Left and return the left value
112
+ * Throws an error if the Either is Right
113
+ *
114
+ * @param either - The Either to test
115
+ * @returns The left value
116
+ * @throws Error if the Either is Right
117
+ */
118
+ export function expectEitherLeft(either) {
119
+ if (Either.isRight(either)) {
120
+ throw new Error(`Expected Either to be Left, but it was Right with: ${JSON.stringify(either.right)}`);
121
+ }
122
+ return either.left;
123
+ }
124
+ /**
125
+ * Pattern match on Effect error and extract specific error type
126
+ * Useful for testing specific error scenarios
127
+ *
128
+ * @param effect - The Effect that should fail
129
+ * @param matchers - Object mapping error tags to extraction functions
130
+ * @returns The extracted value from the matched error
131
+ * @throws Error if the Effect succeeds or error doesn't match
132
+ *
133
+ * @example
134
+ * ```typescript
135
+ * const result = matchEffectError(effect, {
136
+ * GitError: (err) => err.command,
137
+ * ValidationError: (err) => err.field
138
+ * });
139
+ * ```
140
+ */
141
+ export function matchEffectError(effect, matchers) {
142
+ const error = expectEffectFailure(effect);
143
+ const tag = error._tag;
144
+ const matcher = matchers[tag];
145
+ if (!matcher) {
146
+ throw new Error(`No matcher found for error tag: ${error._tag}. Available matchers: ${Object.keys(matchers).join(', ')}`);
147
+ }
148
+ // Type assertion is safe here because we've verified the tag matches
149
+ // We use 'as any' because TypeScript cannot express the constraint that
150
+ // the error type matches the matcher's expected parameter type
151
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
152
+ return matcher(error);
153
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,114 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { Effect, Either } from 'effect';
3
+ import { runEffectSync, runEffectPromise, expectEffectSuccess, expectEffectFailure, expectEitherRight, expectEitherLeft, matchEffectError, } from './testHelpers.js';
4
+ import { GitError, ValidationError } from '../types/errors.js';
5
+ describe('testHelpers', () => {
6
+ describe('runEffectSync', () => {
7
+ it('should run Effect synchronously and return success value', () => {
8
+ const effect = Effect.succeed(42);
9
+ const result = runEffectSync(effect);
10
+ expect(result).toBe(42);
11
+ });
12
+ it('should throw error when Effect fails', () => {
13
+ const effect = Effect.fail(new Error('test error'));
14
+ expect(() => runEffectSync(effect)).toThrow('test error');
15
+ });
16
+ });
17
+ describe('runEffectPromise', () => {
18
+ it('should run Effect as Promise and resolve with success value', async () => {
19
+ const effect = Effect.succeed(42);
20
+ const result = await runEffectPromise(effect);
21
+ expect(result).toBe(42);
22
+ });
23
+ it('should reject when Effect fails', async () => {
24
+ const effect = Effect.fail(new Error('async error'));
25
+ await expect(runEffectPromise(effect)).rejects.toThrow('async error');
26
+ });
27
+ });
28
+ describe('expectEffectSuccess', () => {
29
+ it('should return success value when Effect succeeds', () => {
30
+ const effect = Effect.succeed('success');
31
+ const result = expectEffectSuccess(effect);
32
+ expect(result).toBe('success');
33
+ });
34
+ it('should throw when Effect fails', () => {
35
+ const effect = Effect.fail(new GitError({ command: 'git test', exitCode: 1, stderr: 'error' }));
36
+ expect(() => expectEffectSuccess(effect)).toThrow();
37
+ });
38
+ });
39
+ describe('expectEffectFailure', () => {
40
+ it('should return error when Effect fails', () => {
41
+ const gitError = new GitError({
42
+ command: 'git test',
43
+ exitCode: 1,
44
+ stderr: 'error',
45
+ });
46
+ const effect = Effect.fail(gitError);
47
+ const error = expectEffectFailure(effect);
48
+ expect(error).toBe(gitError);
49
+ });
50
+ it('should throw when Effect succeeds', () => {
51
+ const effect = Effect.succeed(42);
52
+ expect(() => expectEffectFailure(effect)).toThrow();
53
+ });
54
+ });
55
+ describe('expectEitherRight', () => {
56
+ it('should return right value when Either is Right', () => {
57
+ const either = Either.right('success');
58
+ const result = expectEitherRight(either);
59
+ expect(result).toBe('success');
60
+ });
61
+ it('should throw when Either is Left', () => {
62
+ const either = Either.left('error');
63
+ expect(() => expectEitherRight(either)).toThrow();
64
+ });
65
+ });
66
+ describe('expectEitherLeft', () => {
67
+ it('should return left value when Either is Left', () => {
68
+ const either = Either.left('error');
69
+ const result = expectEitherLeft(either);
70
+ expect(result).toBe('error');
71
+ });
72
+ it('should throw when Either is Right', () => {
73
+ const either = Either.right('success');
74
+ expect(() => expectEitherLeft(either)).toThrow();
75
+ });
76
+ });
77
+ describe('matchEffectError', () => {
78
+ it('should match GitError and return extracted value', () => {
79
+ const gitError = new GitError({
80
+ command: 'git test',
81
+ exitCode: 1,
82
+ stderr: 'error',
83
+ });
84
+ const effect = Effect.fail(gitError);
85
+ const result = matchEffectError(effect, {
86
+ GitError: err => err.command,
87
+ });
88
+ expect(result).toBe('git test');
89
+ });
90
+ it('should match ValidationError and return extracted value', () => {
91
+ const validationError = new ValidationError({
92
+ field: 'test',
93
+ constraint: 'required',
94
+ receivedValue: null,
95
+ });
96
+ const effect = Effect.fail(validationError);
97
+ const result = matchEffectError(effect, {
98
+ ValidationError: err => err.field,
99
+ });
100
+ expect(result).toBe('test');
101
+ });
102
+ it('should throw when error type does not match', () => {
103
+ const gitError = new GitError({
104
+ command: 'git test',
105
+ exitCode: 1,
106
+ stderr: 'error',
107
+ });
108
+ const effect = Effect.fail(gitError);
109
+ expect(() => matchEffectError(effect, {
110
+ ValidationError: err => err.field,
111
+ })).toThrow();
112
+ });
113
+ });
114
+ });
@@ -1,3 +1,78 @@
1
+ import { Effect } from 'effect';
2
+ import { GitError } from '../types/errors.js';
1
3
  export declare function isWorktreeConfigEnabled(gitPath?: string): boolean;
2
- export declare function getWorktreeParentBranch(worktreePath: string, signal?: AbortSignal): Promise<string | null>;
3
- export declare function setWorktreeParentBranch(worktreePath: string, parentBranch: string): void;
4
+ /**
5
+ * Get parent branch for worktree using Effect
6
+ * Returns null if config doesn't exist or worktree config is not available
7
+ *
8
+ * @param {string} worktreePath - Path to the worktree directory
9
+ * @returns {Effect.Effect<string | null, never>} Effect containing parent branch name or null
10
+ *
11
+ * @example
12
+ * ```typescript
13
+ * import {Effect} from 'effect';
14
+ * import {getWorktreeParentBranch} from './utils/worktreeConfig.js';
15
+ *
16
+ * // This function never fails - returns null on error
17
+ * const parentBranch = await Effect.runPromise(
18
+ * getWorktreeParentBranch('/path/to/worktree')
19
+ * );
20
+ *
21
+ * if (parentBranch) {
22
+ * console.log(`Parent branch: ${parentBranch}`);
23
+ * } else {
24
+ * console.log('No parent branch configured');
25
+ * }
26
+ *
27
+ * // Use with Effect.flatMap for chaining
28
+ * const status = await Effect.runPromise(
29
+ * Effect.flatMap(
30
+ * getWorktreeParentBranch('/path/to/worktree'),
31
+ * (branch) => branch
32
+ * ? Effect.succeed(`Tracking ${branch}`)
33
+ * : Effect.succeed('No tracking')
34
+ * )
35
+ * );
36
+ * ```
37
+ */
38
+ export declare function getWorktreeParentBranch(worktreePath: string): Effect.Effect<string | null, never>;
39
+ /**
40
+ * Set parent branch for worktree using Effect
41
+ * Succeeds silently if worktree config is not available
42
+ *
43
+ * @param {string} worktreePath - Path to the worktree directory
44
+ * @param {string} parentBranch - Name of the parent branch to track
45
+ * @returns {Effect.Effect<void, GitError>} Effect that succeeds or fails with GitError
46
+ *
47
+ * @example
48
+ * ```typescript
49
+ * import {Effect} from 'effect';
50
+ * import {setWorktreeParentBranch} from './utils/worktreeConfig.js';
51
+ *
52
+ * // Set parent branch with error handling
53
+ * await Effect.runPromise(
54
+ * Effect.catchTag(
55
+ * setWorktreeParentBranch('/path/to/worktree', 'main'),
56
+ * 'GitError',
57
+ * (error) => {
58
+ * console.error(`Failed to set parent branch: ${error.stderr}`);
59
+ * return Effect.void; // Continue despite error
60
+ * }
61
+ * )
62
+ * );
63
+ *
64
+ * // Or use Effect.orElse for fallback
65
+ * await Effect.runPromise(
66
+ * Effect.orElse(
67
+ * setWorktreeParentBranch('/path/to/worktree', 'develop'),
68
+ * () => {
69
+ * console.log('Using fallback - no parent tracking');
70
+ * return Effect.void;
71
+ * }
72
+ * )
73
+ * );
74
+ * ```
75
+ *
76
+ * @throws {GitError} When git config command fails
77
+ */
78
+ export declare function setWorktreeParentBranch(worktreePath: string, parentBranch: string): Effect.Effect<void, GitError>;
@@ -1,7 +1,9 @@
1
1
  import { promisify } from 'util';
2
- import { exec, execSync, execFileSync } from 'child_process';
2
+ import { execSync, execFile } from 'child_process';
3
+ import { Effect } from 'effect';
4
+ import { GitError } from '../types/errors.js';
3
5
  import { worktreeConfigManager } from '../services/worktreeConfigManager.js';
4
- const execp = promisify(exec);
6
+ const execFileAsync = promisify(execFile);
5
7
  export function isWorktreeConfigEnabled(gitPath) {
6
8
  try {
7
9
  const result = execSync('git config extensions.worktreeConfig', {
@@ -14,30 +16,168 @@ export function isWorktreeConfigEnabled(gitPath) {
14
16
  return false;
15
17
  }
16
18
  }
17
- export async function getWorktreeParentBranch(worktreePath, signal) {
19
+ /**
20
+ * Get parent branch for worktree using Effect
21
+ * Returns null if config doesn't exist or worktree config is not available
22
+ *
23
+ * @param {string} worktreePath - Path to the worktree directory
24
+ * @returns {Effect.Effect<string | null, never>} Effect containing parent branch name or null
25
+ *
26
+ * @example
27
+ * ```typescript
28
+ * import {Effect} from 'effect';
29
+ * import {getWorktreeParentBranch} from './utils/worktreeConfig.js';
30
+ *
31
+ * // This function never fails - returns null on error
32
+ * const parentBranch = await Effect.runPromise(
33
+ * getWorktreeParentBranch('/path/to/worktree')
34
+ * );
35
+ *
36
+ * if (parentBranch) {
37
+ * console.log(`Parent branch: ${parentBranch}`);
38
+ * } else {
39
+ * console.log('No parent branch configured');
40
+ * }
41
+ *
42
+ * // Use with Effect.flatMap for chaining
43
+ * const status = await Effect.runPromise(
44
+ * Effect.flatMap(
45
+ * getWorktreeParentBranch('/path/to/worktree'),
46
+ * (branch) => branch
47
+ * ? Effect.succeed(`Tracking ${branch}`)
48
+ * : Effect.succeed('No tracking')
49
+ * )
50
+ * );
51
+ * ```
52
+ */
53
+ export function getWorktreeParentBranch(worktreePath) {
18
54
  // Return null if worktree config extension is not available
19
55
  if (!worktreeConfigManager.isAvailable()) {
20
- return null;
56
+ return Effect.succeed(null);
21
57
  }
22
- try {
23
- const result = await execp('git config --worktree ccmanager.parentBranch', {
58
+ return Effect.catchAll(Effect.tryPromise({
59
+ try: signal => execFileAsync('git', ['config', '--worktree', 'ccmanager.parentBranch'], {
24
60
  cwd: worktreePath,
25
61
  encoding: 'utf8',
26
62
  signal,
27
- });
28
- return result.stdout.trim() || null;
29
- }
30
- catch {
31
- return null;
32
- }
63
+ }).then(result => result.stdout.trim() || null),
64
+ catch: error => error,
65
+ }), error => {
66
+ // Abort errors should interrupt
67
+ if (isAbortError(error)) {
68
+ return Effect.interrupt;
69
+ }
70
+ // Config not existing is not an error, return null
71
+ return Effect.succeed(null);
72
+ });
33
73
  }
74
+ /**
75
+ * Set parent branch for worktree using Effect
76
+ * Succeeds silently if worktree config is not available
77
+ *
78
+ * @param {string} worktreePath - Path to the worktree directory
79
+ * @param {string} parentBranch - Name of the parent branch to track
80
+ * @returns {Effect.Effect<void, GitError>} Effect that succeeds or fails with GitError
81
+ *
82
+ * @example
83
+ * ```typescript
84
+ * import {Effect} from 'effect';
85
+ * import {setWorktreeParentBranch} from './utils/worktreeConfig.js';
86
+ *
87
+ * // Set parent branch with error handling
88
+ * await Effect.runPromise(
89
+ * Effect.catchTag(
90
+ * setWorktreeParentBranch('/path/to/worktree', 'main'),
91
+ * 'GitError',
92
+ * (error) => {
93
+ * console.error(`Failed to set parent branch: ${error.stderr}`);
94
+ * return Effect.void; // Continue despite error
95
+ * }
96
+ * )
97
+ * );
98
+ *
99
+ * // Or use Effect.orElse for fallback
100
+ * await Effect.runPromise(
101
+ * Effect.orElse(
102
+ * setWorktreeParentBranch('/path/to/worktree', 'develop'),
103
+ * () => {
104
+ * console.log('Using fallback - no parent tracking');
105
+ * return Effect.void;
106
+ * }
107
+ * )
108
+ * );
109
+ * ```
110
+ *
111
+ * @throws {GitError} When git config command fails
112
+ */
34
113
  export function setWorktreeParentBranch(worktreePath, parentBranch) {
35
114
  // Skip if worktree config extension is not available
36
115
  if (!worktreeConfigManager.isAvailable()) {
37
- return;
116
+ return Effect.void;
117
+ }
118
+ const command = `git config --worktree ccmanager.parentBranch ${parentBranch}`;
119
+ return Effect.catchAll(Effect.tryPromise({
120
+ try: signal => execFileAsync('git', ['config', '--worktree', 'ccmanager.parentBranch', parentBranch], {
121
+ cwd: worktreePath,
122
+ encoding: 'utf8',
123
+ signal,
124
+ }).then(() => undefined),
125
+ catch: error => error,
126
+ }), error => {
127
+ // Abort errors should interrupt
128
+ if (isAbortError(error)) {
129
+ return Effect.interrupt;
130
+ }
131
+ // Other errors are git failures
132
+ return Effect.fail(toGitError(command, error));
133
+ });
134
+ }
135
+ function isAbortError(error) {
136
+ if (error instanceof Error && error.name === 'AbortError') {
137
+ return true;
138
+ }
139
+ if (typeof error === 'object' &&
140
+ error !== null &&
141
+ 'code' in error &&
142
+ error.code === 'ABORT_ERR') {
143
+ return true;
144
+ }
145
+ return false;
146
+ }
147
+ function toGitError(command, error) {
148
+ if (error instanceof GitError) {
149
+ return error;
150
+ }
151
+ if (typeof error === 'object' &&
152
+ error !== null &&
153
+ 'code' in error &&
154
+ 'stderr' in error) {
155
+ const execError = error;
156
+ const exitCode = typeof execError.code === 'number'
157
+ ? execError.code
158
+ : Number.parseInt(String(execError.code ?? '-1'), 10) || -1;
159
+ const stderr = typeof execError.stderr === 'string'
160
+ ? execError.stderr
161
+ : (execError.message ?? '');
162
+ return new GitError({
163
+ command,
164
+ exitCode,
165
+ stderr,
166
+ stdout: typeof execError.stdout === 'string' && execError.stdout.length > 0
167
+ ? execError.stdout
168
+ : undefined,
169
+ });
170
+ }
171
+ if (error instanceof Error) {
172
+ return new GitError({
173
+ command,
174
+ exitCode: -1,
175
+ stderr: error.message,
176
+ });
38
177
  }
39
- execFileSync('git', ['config', '--worktree', 'ccmanager.parentBranch', parentBranch], {
40
- cwd: worktreePath,
41
- encoding: 'utf8',
178
+ return new GitError({
179
+ command,
180
+ exitCode: -1,
181
+ stderr: String(error),
42
182
  });
43
183
  }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,39 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { isWorktreeConfigEnabled } from './worktreeConfig.js';
3
+ import * as cp from 'child_process';
4
+ vi.mock('child_process');
5
+ vi.mock('../services/worktreeConfigManager.js', () => ({
6
+ worktreeConfigManager: {
7
+ isAvailable: vi.fn(() => true),
8
+ },
9
+ }));
10
+ describe('worktreeConfig', () => {
11
+ beforeEach(() => {
12
+ vi.clearAllMocks();
13
+ });
14
+ describe('isWorktreeConfigEnabled', () => {
15
+ it('should return true when worktree config is enabled', () => {
16
+ vi.mocked(cp.execSync).mockReturnValue('true\n');
17
+ const result = isWorktreeConfigEnabled('/test/path');
18
+ expect(result).toBe(true);
19
+ expect(cp.execSync).toHaveBeenCalledWith('git config extensions.worktreeConfig', {
20
+ cwd: '/test/path',
21
+ encoding: 'utf8',
22
+ });
23
+ });
24
+ it('should return false when worktree config is disabled', () => {
25
+ vi.mocked(cp.execSync).mockReturnValue('false\n');
26
+ const result = isWorktreeConfigEnabled('/test/path');
27
+ expect(result).toBe(false);
28
+ });
29
+ it('should return false when git config command fails', () => {
30
+ vi.mocked(cp.execSync).mockImplementation(() => {
31
+ throw new Error('Command failed');
32
+ });
33
+ const result = isWorktreeConfigEnabled('/test/path');
34
+ expect(result).toBe(false);
35
+ });
36
+ });
37
+ // Note: getWorktreeParentBranch and setWorktreeParentBranch are tested
38
+ // in integration tests since they use Effect.tryPromise with real execFile
39
+ });