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.
- package/dist/cli.test.js +13 -2
- package/dist/components/App.js +125 -50
- package/dist/components/App.test.js +270 -0
- package/dist/components/ConfigureShortcuts.js +82 -8
- package/dist/components/DeleteWorktree.js +39 -5
- package/dist/components/DeleteWorktree.test.d.ts +1 -0
- package/dist/components/DeleteWorktree.test.js +128 -0
- package/dist/components/LoadingSpinner.d.ts +8 -0
- package/dist/components/LoadingSpinner.js +37 -0
- package/dist/components/LoadingSpinner.test.d.ts +1 -0
- package/dist/components/LoadingSpinner.test.js +187 -0
- package/dist/components/Menu.js +64 -16
- package/dist/components/Menu.recent-projects.test.js +32 -11
- package/dist/components/Menu.test.js +136 -4
- package/dist/components/MergeWorktree.js +79 -18
- package/dist/components/MergeWorktree.test.d.ts +1 -0
- package/dist/components/MergeWorktree.test.js +227 -0
- package/dist/components/NewWorktree.js +88 -9
- package/dist/components/NewWorktree.test.d.ts +1 -0
- package/dist/components/NewWorktree.test.js +244 -0
- package/dist/components/ProjectList.js +44 -13
- package/dist/components/ProjectList.recent-projects.test.js +8 -3
- package/dist/components/ProjectList.test.js +105 -8
- package/dist/components/RemoteBranchSelector.test.js +3 -1
- package/dist/components/Session.js +11 -6
- package/dist/hooks/useGitStatus.d.ts +11 -0
- package/dist/hooks/useGitStatus.js +70 -12
- package/dist/hooks/useGitStatus.test.js +30 -23
- package/dist/services/configurationManager.d.ts +75 -0
- package/dist/services/configurationManager.effect.test.d.ts +1 -0
- package/dist/services/configurationManager.effect.test.js +407 -0
- package/dist/services/configurationManager.js +246 -0
- package/dist/services/globalSessionOrchestrator.test.js +0 -8
- package/dist/services/projectManager.d.ts +98 -2
- package/dist/services/projectManager.js +228 -59
- package/dist/services/projectManager.test.js +242 -2
- package/dist/services/sessionManager.d.ts +44 -2
- package/dist/services/sessionManager.effect.test.d.ts +1 -0
- package/dist/services/sessionManager.effect.test.js +321 -0
- package/dist/services/sessionManager.js +216 -65
- package/dist/services/sessionManager.statePersistence.test.js +18 -9
- package/dist/services/sessionManager.test.js +40 -36
- package/dist/services/shortcutManager.d.ts +2 -0
- package/dist/services/shortcutManager.js +53 -0
- package/dist/services/shortcutManager.test.d.ts +1 -0
- package/dist/services/shortcutManager.test.js +30 -0
- package/dist/services/worktreeService.d.ts +356 -26
- package/dist/services/worktreeService.js +793 -353
- package/dist/services/worktreeService.test.js +294 -313
- package/dist/types/errors.d.ts +74 -0
- package/dist/types/errors.js +31 -0
- package/dist/types/errors.test.d.ts +1 -0
- package/dist/types/errors.test.js +201 -0
- package/dist/types/index.d.ts +5 -17
- package/dist/utils/claudeDir.d.ts +58 -6
- package/dist/utils/claudeDir.js +103 -8
- package/dist/utils/claudeDir.test.d.ts +1 -0
- package/dist/utils/claudeDir.test.js +108 -0
- package/dist/utils/concurrencyLimit.d.ts +5 -0
- package/dist/utils/concurrencyLimit.js +11 -0
- package/dist/utils/concurrencyLimit.test.js +40 -1
- package/dist/utils/gitStatus.d.ts +36 -8
- package/dist/utils/gitStatus.js +170 -88
- package/dist/utils/gitStatus.test.js +12 -9
- package/dist/utils/hookExecutor.d.ts +41 -6
- package/dist/utils/hookExecutor.js +75 -32
- package/dist/utils/hookExecutor.test.js +73 -20
- package/dist/utils/terminalCapabilities.d.ts +18 -0
- package/dist/utils/terminalCapabilities.js +81 -0
- package/dist/utils/terminalCapabilities.test.d.ts +1 -0
- package/dist/utils/terminalCapabilities.test.js +104 -0
- package/dist/utils/testHelpers.d.ts +106 -0
- package/dist/utils/testHelpers.js +153 -0
- package/dist/utils/testHelpers.test.d.ts +1 -0
- package/dist/utils/testHelpers.test.js +114 -0
- package/dist/utils/worktreeConfig.d.ts +77 -2
- package/dist/utils/worktreeConfig.js +156 -16
- package/dist/utils/worktreeConfig.test.d.ts +1 -0
- package/dist/utils/worktreeConfig.test.js +39 -0
- package/package.json +4 -4
- package/dist/integration-tests/devcontainer.integration.test.js +0 -101
- /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 {
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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>>;
|
package/dist/utils/gitStatus.js
CHANGED
|
@@ -1,78 +1,67 @@
|
|
|
1
1
|
import { promisify } from 'util';
|
|
2
|
-
import {
|
|
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 {
|
|
5
|
-
const
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
|
|
139
|
-
|
|
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 (
|
|
142
|
-
|
|
216
|
+
if (error instanceof Error) {
|
|
217
|
+
return new GitError({
|
|
218
|
+
command,
|
|
219
|
+
exitCode: -1,
|
|
220
|
+
stderr: error.message,
|
|
221
|
+
});
|
|
143
222
|
}
|
|
144
|
-
return {
|
|
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,
|
|
65
|
-
|
|
66
|
-
|
|
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]
|
|
76
|
+
const firstData = results[0];
|
|
73
77
|
results.forEach(result => {
|
|
74
|
-
expect(result
|
|
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):
|
|
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):
|
|
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):
|
|
55
|
+
export declare function executeStatusHook(oldState: SessionState, newState: SessionState, session: Session): Effect.Effect<void, never>;
|