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,4 +1,9 @@
1
+ import { Effect } from 'effect';
1
2
  /**
2
3
  * Create a function that limits concurrent executions
3
4
  */
4
5
  export declare function createConcurrencyLimited<TArgs extends unknown[], TResult>(fn: (...args: TArgs) => Promise<TResult>, maxConcurrent: number): (...args: TArgs) => Promise<TResult>;
6
+ /**
7
+ * Create a function that limits concurrent Effect executions
8
+ */
9
+ export declare function createEffectConcurrencyLimited<TArgs extends unknown[], A, E>(fn: (...args: TArgs) => Effect.Effect<A, E>, maxConcurrent: number): (...args: TArgs) => Effect.Effect<A, E>;
@@ -1,3 +1,4 @@
1
+ import { Effect } from 'effect';
1
2
  /**
2
3
  * Create a function that limits concurrent executions
3
4
  */
@@ -28,3 +29,13 @@ export function createConcurrencyLimited(fn, maxConcurrent) {
28
29
  }
29
30
  };
30
31
  }
32
+ /**
33
+ * Create a function that limits concurrent Effect executions
34
+ */
35
+ export function createEffectConcurrencyLimited(fn, maxConcurrent) {
36
+ if (maxConcurrent < 1) {
37
+ throw new RangeError('maxConcurrent must be at least 1');
38
+ }
39
+ const semaphore = Effect.unsafeMakeSemaphore(maxConcurrent);
40
+ return (...args) => semaphore.withPermits(1)(fn(...args));
41
+ }
@@ -1,5 +1,6 @@
1
1
  import { describe, it, expect } from 'vitest';
2
- import { createConcurrencyLimited } from './concurrencyLimit.js';
2
+ import { Effect, Exit } from 'effect';
3
+ import { createConcurrencyLimited, createEffectConcurrencyLimited, } from './concurrencyLimit.js';
3
4
  describe('createConcurrencyLimited', () => {
4
5
  it('should limit concurrent executions', async () => {
5
6
  let running = 0;
@@ -61,3 +62,41 @@ describe('createConcurrencyLimited', () => {
61
62
  expect(() => createConcurrencyLimited(fn, -1)).toThrow('maxConcurrent must be at least 1');
62
63
  });
63
64
  });
65
+ describe('createEffectConcurrencyLimited', () => {
66
+ it('should limit concurrent Effect executions', async () => {
67
+ let running = 0;
68
+ let maxRunning = 0;
69
+ const task = (id) => Effect.gen(function* () {
70
+ running++;
71
+ maxRunning = Math.max(maxRunning, running);
72
+ yield* Effect.sleep('10 millis');
73
+ running--;
74
+ return id;
75
+ });
76
+ const limited = createEffectConcurrencyLimited(task, 2);
77
+ const results = await Promise.all([
78
+ Effect.runPromise(limited(1)),
79
+ Effect.runPromise(limited(2)),
80
+ Effect.runPromise(limited(3)),
81
+ Effect.runPromise(limited(4)),
82
+ ]);
83
+ expect(results).toEqual([1, 2, 3, 4]);
84
+ expect(maxRunning).toBeLessThanOrEqual(2);
85
+ expect(running).toBe(0);
86
+ });
87
+ it('should release permits after failures', async () => {
88
+ const original = (shouldFail) => shouldFail ? Effect.fail('Task failed') : Effect.succeed('success');
89
+ const limited = createEffectConcurrencyLimited(original, 1);
90
+ const [firstExit, secondExit] = await Promise.all([
91
+ Effect.runPromiseExit(limited(true)),
92
+ Effect.runPromiseExit(limited(false)),
93
+ ]);
94
+ expect(Exit.isFailure(firstExit)).toBe(true);
95
+ expect(Exit.isSuccess(secondExit)).toBe(true);
96
+ });
97
+ it('should throw for invalid maxConcurrent', () => {
98
+ const fn = () => Effect.succeed('test');
99
+ expect(() => createEffectConcurrencyLimited(fn, 0)).toThrow('maxConcurrent must be at least 1');
100
+ expect(() => createEffectConcurrencyLimited(fn, -1)).toThrow('maxConcurrent must be at least 1');
101
+ });
102
+ });
@@ -1,3 +1,5 @@
1
+ import { Effect } from 'effect';
2
+ import { GitError } from '../types/errors.js';
1
3
  export interface GitStatus {
2
4
  filesAdded: number;
3
5
  filesDeleted: number;
@@ -5,15 +7,41 @@ export interface GitStatus {
5
7
  behindCount: number;
6
8
  parentBranch: string | null;
7
9
  }
8
- export interface GitOperationResult<T> {
9
- success: boolean;
10
- data?: T;
11
- error?: string;
12
- skipped?: boolean;
13
- }
14
- export declare function getGitStatus(worktreePath: string, signal: AbortSignal): Promise<GitOperationResult<GitStatus>>;
10
+ /**
11
+ * Get comprehensive Git status for a worktree
12
+ *
13
+ * Retrieves file changes, ahead/behind counts, and parent branch information
14
+ * using Effect-based error handling.
15
+ *
16
+ * @param {string} worktreePath - Absolute path to the worktree directory
17
+ * @returns {Effect.Effect<GitStatus, GitError>} Effect containing git status or GitError
18
+ *
19
+ * @example
20
+ * ```typescript
21
+ * import {Effect} from 'effect';
22
+ * import {getGitStatus} from './utils/gitStatus.js';
23
+ *
24
+ * // Execute with Effect.runPromise
25
+ * const status = await Effect.runPromise(
26
+ * getGitStatus('/path/to/worktree')
27
+ * );
28
+ * console.log(`Files added: ${status.filesAdded}, deleted: ${status.filesDeleted}`);
29
+ * console.log(`Ahead: ${status.aheadCount}, behind: ${status.behindCount}`);
30
+ *
31
+ * // Or use Effect.map for transformation
32
+ * const formatted = await Effect.runPromise(
33
+ * Effect.map(
34
+ * getGitStatus('/path/to/worktree'),
35
+ * (status) => `+${status.filesAdded} -${status.filesDeleted}`
36
+ * )
37
+ * );
38
+ * ```
39
+ *
40
+ * @throws {GitError} When git commands fail or worktree path is invalid
41
+ */
42
+ export declare const getGitStatus: (worktreePath: string) => Effect.Effect<GitStatus, GitError>;
43
+ export declare const getGitStatusLimited: (worktreePath: string) => Effect.Effect<GitStatus, GitError, never>;
15
44
  export declare function formatGitFileChanges(status: GitStatus): string;
16
45
  export declare function formatGitAheadBehind(status: GitStatus): string;
17
46
  export declare function formatGitStatus(status: GitStatus): string;
18
47
  export declare function formatParentBranch(parentBranch: string | null, currentBranch: string): string;
19
- export declare const getGitStatusLimited: (worktreePath: string, signal: AbortSignal) => Promise<GitOperationResult<GitStatus>>;
@@ -1,78 +1,67 @@
1
1
  import { promisify } from 'util';
2
- import { exec, execFile } from 'child_process';
2
+ import { execFile } from 'child_process';
3
+ import { Effect, Either } from 'effect';
4
+ import { pipe } from 'effect/Function';
5
+ import { GitError } from '../types/errors.js';
3
6
  import { getWorktreeParentBranch } from './worktreeConfig.js';
4
- import { createConcurrencyLimited } from './concurrencyLimit.js';
5
- const execp = promisify(exec);
6
- const execFilePromisified = promisify(execFile);
7
- export async function getGitStatus(worktreePath, signal) {
8
- try {
9
- // Get unstaged changes
10
- const [diffResult, stagedResult, branchResult, parentBranch] = await Promise.all([
11
- execp('git diff --shortstat', { cwd: worktreePath, signal }).catch(() => EMPTY_EXEC_RESULT),
12
- execp('git diff --staged --shortstat', {
13
- cwd: worktreePath,
14
- signal,
15
- }).catch(() => EMPTY_EXEC_RESULT),
16
- execp('git branch --show-current', { cwd: worktreePath, signal }).catch(() => EMPTY_EXEC_RESULT),
17
- getWorktreeParentBranch(worktreePath, signal),
18
- ]);
19
- // Parse file changes
20
- let filesAdded = 0;
21
- let filesDeleted = 0;
22
- if (diffResult.stdout) {
23
- const stats = parseGitStats(diffResult.stdout);
24
- filesAdded += stats.insertions;
25
- filesDeleted += stats.deletions;
26
- }
27
- if (stagedResult.stdout) {
28
- const stats = parseGitStats(stagedResult.stdout);
29
- filesAdded += stats.insertions;
30
- filesDeleted += stats.deletions;
31
- }
32
- // Get ahead/behind counts
33
- let aheadCount = 0;
34
- let behindCount = 0;
35
- const currentBranch = branchResult.stdout.trim();
36
- if (currentBranch && parentBranch && currentBranch !== parentBranch) {
37
- try {
38
- const aheadBehindResult = await execFilePromisified('git', ['rev-list', '--left-right', '--count', `${parentBranch}...HEAD`], { cwd: worktreePath, signal });
39
- const [behind, ahead] = aheadBehindResult.stdout
40
- .trim()
41
- .split('\t')
42
- .map(n => parseInt(n, 10));
43
- aheadCount = ahead || 0;
44
- behindCount = behind || 0;
45
- }
46
- catch {
47
- // Branch comparison might fail
48
- }
49
- }
50
- return {
51
- success: true,
52
- data: {
53
- filesAdded,
54
- filesDeleted,
55
- aheadCount,
56
- behindCount,
57
- parentBranch,
58
- },
59
- };
60
- }
61
- catch (error) {
62
- let errorMessage = '';
63
- if (error instanceof Error) {
64
- errorMessage = error.message;
65
- }
66
- else {
67
- errorMessage = String(error);
68
- }
69
- return {
70
- success: false,
71
- error: errorMessage,
72
- };
73
- }
74
- }
75
- // Split git status formatting into file changes and ahead/behind
7
+ import { createEffectConcurrencyLimited } from './concurrencyLimit.js';
8
+ const execFileAsync = promisify(execFile);
9
+ const DEFAULT_GIT_STATS = { insertions: 0, deletions: 0 };
10
+ /**
11
+ * Get comprehensive Git status for a worktree
12
+ *
13
+ * Retrieves file changes, ahead/behind counts, and parent branch information
14
+ * using Effect-based error handling.
15
+ *
16
+ * @param {string} worktreePath - Absolute path to the worktree directory
17
+ * @returns {Effect.Effect<GitStatus, GitError>} Effect containing git status or GitError
18
+ *
19
+ * @example
20
+ * ```typescript
21
+ * import {Effect} from 'effect';
22
+ * import {getGitStatus} from './utils/gitStatus.js';
23
+ *
24
+ * // Execute with Effect.runPromise
25
+ * const status = await Effect.runPromise(
26
+ * getGitStatus('/path/to/worktree')
27
+ * );
28
+ * console.log(`Files added: ${status.filesAdded}, deleted: ${status.filesDeleted}`);
29
+ * console.log(`Ahead: ${status.aheadCount}, behind: ${status.behindCount}`);
30
+ *
31
+ * // Or use Effect.map for transformation
32
+ * const formatted = await Effect.runPromise(
33
+ * Effect.map(
34
+ * getGitStatus('/path/to/worktree'),
35
+ * (status) => `+${status.filesAdded} -${status.filesDeleted}`
36
+ * )
37
+ * );
38
+ * ```
39
+ *
40
+ * @throws {GitError} When git commands fail or worktree path is invalid
41
+ */
42
+ export const getGitStatus = (worktreePath) => Effect.gen(function* () {
43
+ const diffResult = yield* runGit(['diff', '--shortstat'], worktreePath);
44
+ const stagedResult = yield* runGit(['diff', '--staged', '--shortstat'], worktreePath);
45
+ const branchResult = yield* runGit(['branch', '--show-current'], worktreePath);
46
+ const parentBranch = yield* fetchParentBranch(worktreePath);
47
+ const diffStats = decodeGitStats(diffResult.stdout);
48
+ const stagedStats = decodeGitStats(stagedResult.stdout);
49
+ const filesAdded = diffStats.insertions + stagedStats.insertions;
50
+ const filesDeleted = diffStats.deletions + stagedStats.deletions;
51
+ const { aheadCount, behindCount } = yield* computeAheadBehind({
52
+ worktreePath,
53
+ currentBranch: branchResult.stdout.trim(),
54
+ parentBranch,
55
+ });
56
+ return {
57
+ filesAdded,
58
+ filesDeleted,
59
+ aheadCount,
60
+ behindCount,
61
+ parentBranch,
62
+ };
63
+ });
64
+ export const getGitStatusLimited = createEffectConcurrencyLimited((worktreePath) => getGitStatus(worktreePath), 10);
76
65
  export function formatGitFileChanges(status) {
77
66
  const parts = [];
78
67
  const colors = {
@@ -80,7 +69,6 @@ export function formatGitFileChanges(status) {
80
69
  red: '\x1b[31m',
81
70
  reset: '\x1b[0m',
82
71
  };
83
- // File changes
84
72
  if (status.filesAdded > 0) {
85
73
  parts.push(`${colors.green}+${status.filesAdded}${colors.reset}`);
86
74
  }
@@ -96,7 +84,6 @@ export function formatGitAheadBehind(status) {
96
84
  magenta: '\x1b[35m',
97
85
  reset: '\x1b[0m',
98
86
  };
99
- // Ahead/behind - compact format with arrows
100
87
  if (status.aheadCount > 0) {
101
88
  parts.push(`${colors.cyan}↑${status.aheadCount}${colors.reset}`);
102
89
  }
@@ -105,7 +92,6 @@ export function formatGitAheadBehind(status) {
105
92
  }
106
93
  return parts.join(' ');
107
94
  }
108
- // Keep the original function for backward compatibility
109
95
  export function formatGitStatus(status) {
110
96
  const fileChanges = formatGitFileChanges(status);
111
97
  const aheadBehind = formatGitAheadBehind(status);
@@ -117,7 +103,6 @@ export function formatGitStatus(status) {
117
103
  return parts.join(' ');
118
104
  }
119
105
  export function formatParentBranch(parentBranch, currentBranch) {
120
- // Only show parent branch if it exists and is different from current branch
121
106
  if (!parentBranch || parentBranch === currentBranch) {
122
107
  return '';
123
108
  }
@@ -127,20 +112,117 @@ export function formatParentBranch(parentBranch, currentBranch) {
127
112
  };
128
113
  return `${colors.dim}(${parentBranch})${colors.reset}`;
129
114
  }
130
- const EMPTY_EXEC_RESULT = { stdout: '', stderr: '' };
115
+ function runGit(args, worktreePath) {
116
+ const command = `git ${args.join(' ')}`.trim();
117
+ return Effect.catchAll(Effect.tryPromise({
118
+ try: signal => execFileAsync('git', args, {
119
+ cwd: worktreePath,
120
+ encoding: 'utf8',
121
+ maxBuffer: 5 * 1024 * 1024,
122
+ signal,
123
+ }),
124
+ catch: error => error,
125
+ }), error => handleExecFailure(command, error));
126
+ }
127
+ function fetchParentBranch(worktreePath) {
128
+ return Effect.catchAll(getWorktreeParentBranch(worktreePath), () => Effect.succeed(null));
129
+ }
130
+ function computeAheadBehind({ worktreePath, currentBranch, parentBranch, }) {
131
+ if (!currentBranch || !parentBranch || currentBranch === parentBranch) {
132
+ return Effect.succeed({ aheadCount: 0, behindCount: 0 });
133
+ }
134
+ return Effect.map(Effect.catchAll(runGit(['rev-list', '--left-right', '--count', `${parentBranch}...HEAD`], worktreePath), () => Effect.succeed({ stdout: '', stderr: '' })), result => decodeAheadBehind(result.stdout));
135
+ }
131
136
  function parseGitStats(statLine) {
132
- let insertions = 0;
133
- let deletions = 0;
134
- // Parse git diff --shortstat output
135
- // Example: " 3 files changed, 42 insertions(+), 10 deletions(-)"
136
137
  const insertMatch = statLine.match(/(\d+) insertion/);
137
138
  const deleteMatch = statLine.match(/(\d+) deletion/);
138
- if (insertMatch && insertMatch[1]) {
139
- insertions = parseInt(insertMatch[1], 10);
139
+ const insertions = insertMatch?.[1]
140
+ ? Number.parseInt(insertMatch[1], 10)
141
+ : 0;
142
+ const deletions = deleteMatch?.[1] ? Number.parseInt(deleteMatch[1], 10) : 0;
143
+ if (Number.isNaN(insertions) || Number.isNaN(deletions)) {
144
+ return Either.left(`Unable to parse git diff stats from "${statLine.trim()}"`);
145
+ }
146
+ return Either.right({ insertions, deletions });
147
+ }
148
+ function decodeGitStats(statLine) {
149
+ return pipe(parseGitStats(statLine), Either.getOrElse(() => DEFAULT_GIT_STATS));
150
+ }
151
+ function parseAheadBehind(stats) {
152
+ const trimmed = stats.trim();
153
+ if (!trimmed) {
154
+ return Either.right({ aheadCount: 0, behindCount: 0 });
155
+ }
156
+ const [behindRaw, aheadRaw] = trimmed.split('\t');
157
+ const behind = behindRaw ? Number.parseInt(behindRaw, 10) : 0;
158
+ const ahead = aheadRaw ? Number.parseInt(aheadRaw, 10) : 0;
159
+ if (Number.isNaN(behind) || Number.isNaN(ahead)) {
160
+ return Either.left(`Unable to parse ahead/behind stats from "${trimmed}"`);
161
+ }
162
+ return Either.right({
163
+ aheadCount: Math.max(ahead, 0),
164
+ behindCount: Math.max(behind, 0),
165
+ });
166
+ }
167
+ function decodeAheadBehind(stats) {
168
+ return pipe(parseAheadBehind(stats), Either.getOrElse(() => ({ aheadCount: 0, behindCount: 0 })));
169
+ }
170
+ function handleExecFailure(command, error) {
171
+ if (isAbortError(error)) {
172
+ return Effect.interrupt;
173
+ }
174
+ return Effect.fail(toGitError(command, error));
175
+ }
176
+ function isExecError(error) {
177
+ return (typeof error === 'object' &&
178
+ error !== null &&
179
+ 'message' in error &&
180
+ 'code' in error);
181
+ }
182
+ function isAbortError(error) {
183
+ if (error instanceof Error && error.name === 'AbortError') {
184
+ return true;
185
+ }
186
+ if (typeof error === 'object' &&
187
+ error !== null &&
188
+ 'code' in error &&
189
+ error.code === 'ABORT_ERR') {
190
+ return true;
191
+ }
192
+ if (isExecError(error)) {
193
+ return Boolean(error.killed && error.signal);
194
+ }
195
+ return false;
196
+ }
197
+ function toGitError(command, error) {
198
+ if (error instanceof GitError) {
199
+ return error;
200
+ }
201
+ if (isExecError(error)) {
202
+ const exitCodeRaw = error.code;
203
+ const exitCode = typeof exitCodeRaw === 'number'
204
+ ? exitCodeRaw
205
+ : Number.parseInt(String(exitCodeRaw ?? '-1'), 10) || -1;
206
+ const stderr = typeof error.stderr === 'string' ? error.stderr : (error.message ?? '');
207
+ return new GitError({
208
+ command,
209
+ exitCode,
210
+ stderr,
211
+ stdout: typeof error.stdout === 'string' && error.stdout.length > 0
212
+ ? error.stdout
213
+ : undefined,
214
+ });
140
215
  }
141
- if (deleteMatch && deleteMatch[1]) {
142
- deletions = parseInt(deleteMatch[1], 10);
216
+ if (error instanceof Error) {
217
+ return new GitError({
218
+ command,
219
+ exitCode: -1,
220
+ stderr: error.message,
221
+ });
143
222
  }
144
- return { insertions, deletions };
223
+ return new GitError({
224
+ command,
225
+ exitCode: -1,
226
+ stderr: String(error),
227
+ });
145
228
  }
146
- export const getGitStatusLimited = createConcurrencyLimited(getGitStatus, 10);
@@ -1,4 +1,5 @@
1
1
  import { describe, it, expect, vi } from 'vitest';
2
+ import { Effect } from 'effect';
2
3
  import { formatGitStatus, formatGitFileChanges, formatGitAheadBehind, formatParentBranch, getGitStatus, } from './gitStatus.js';
3
4
  import { exec } from 'child_process';
4
5
  import { promisify } from 'util';
@@ -61,18 +62,20 @@ describe('GitService Integration Tests', { timeout: 10000 }, () => {
61
62
  const controller2 = new AbortController();
62
63
  const controller3 = new AbortController();
63
64
  const results = await Promise.all([
64
- getGitStatus(tmpDir, controller1.signal),
65
- getGitStatus(tmpDir, controller2.signal),
66
- getGitStatus(tmpDir, controller3.signal),
65
+ Effect.runPromise(getGitStatus(tmpDir), {
66
+ signal: controller1.signal,
67
+ }),
68
+ Effect.runPromise(getGitStatus(tmpDir), {
69
+ signal: controller2.signal,
70
+ }),
71
+ Effect.runPromise(getGitStatus(tmpDir), {
72
+ signal: controller3.signal,
73
+ }),
67
74
  ]);
68
- // All should succeed
69
- const successCount = results.filter(r => r.success).length;
70
- expect(successCount).toBe(3);
71
75
  // All results should have the same data
72
- const firstData = results[0].data;
76
+ const firstData = results[0];
73
77
  results.forEach(result => {
74
- expect(result.success).toBe(true);
75
- expect(result.data).toEqual(firstData);
78
+ expect(result).toEqual(firstData);
76
79
  });
77
80
  }
78
81
  finally {
@@ -1,3 +1,5 @@
1
+ import { Effect } from 'effect';
2
+ import { ProcessError } from '../types/errors.js';
1
3
  import { Worktree, Session, SessionState } from '../types/index.js';
2
4
  export interface HookEnvironment {
3
5
  CCMANAGER_WORKTREE_PATH: string;
@@ -7,14 +9,47 @@ export interface HookEnvironment {
7
9
  [key: string]: string | undefined;
8
10
  }
9
11
  /**
10
- * Execute a hook command with the provided environment variables
12
+ * Execute a hook command with the provided environment variables using Effect
13
+ *
14
+ * Spawns a shell process to run the hook command with custom environment.
15
+ * Errors are captured but hook failures don't propagate to prevent breaking main flow.
16
+ *
17
+ * @param {string} command - Shell command to execute
18
+ * @param {string} cwd - Working directory for command execution
19
+ * @param {HookEnvironment} environment - Environment variables for the hook
20
+ * @returns {Effect.Effect<void, ProcessError, never>} Effect that succeeds on hook completion or fails with ProcessError
21
+ *
22
+ * @example
23
+ * ```typescript
24
+ * import {Effect} from 'effect';
25
+ * import {executeHook} from './utils/hookExecutor.js';
26
+ *
27
+ * const env = {
28
+ * CCMANAGER_WORKTREE_PATH: '/path/to/worktree',
29
+ * CCMANAGER_WORKTREE_BRANCH: 'feature-branch',
30
+ * CCMANAGER_GIT_ROOT: '/path/to/repo'
31
+ * };
32
+ *
33
+ * // Execute hook with error recovery
34
+ * const result = await Effect.runPromise(
35
+ * Effect.catchAll(
36
+ * executeHook('npm install', '/path/to/worktree', env),
37
+ * (error) => {
38
+ * console.error(`Hook failed: ${error.message}`);
39
+ * return Effect.succeed(undefined); // Continue despite error
40
+ * }
41
+ * )
42
+ * );
43
+ * ```
11
44
  */
12
- export declare function executeHook(command: string, cwd: string, environment: HookEnvironment): Promise<void>;
45
+ export declare function executeHook(command: string, cwd: string, environment: HookEnvironment): Effect.Effect<void, ProcessError>;
13
46
  /**
14
- * Execute a worktree post-creation hook
47
+ * Execute a worktree post-creation hook using Effect
48
+ * Errors are caught and logged but do not break the main flow
15
49
  */
16
- export declare function executeWorktreePostCreationHook(command: string, worktree: Worktree, gitRoot: string, baseBranch?: string): Promise<void>;
50
+ export declare function executeWorktreePostCreationHook(command: string, worktree: Worktree, gitRoot: string, baseBranch?: string): Effect.Effect<void, never>;
17
51
  /**
18
- * Execute a session status change hook
52
+ * Execute a session status change hook using Effect
53
+ * Errors are caught and logged but do not break the main flow
19
54
  */
20
- export declare function executeStatusHook(oldState: SessionState, newState: SessionState, session: Session): Promise<void>;
55
+ export declare function executeStatusHook(oldState: SessionState, newState: SessionState, session: Session): Effect.Effect<void, never>;