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,12 +1,38 @@
|
|
|
1
1
|
import { execSync } from 'child_process';
|
|
2
2
|
import { existsSync, statSync, cpSync } from 'fs';
|
|
3
3
|
import path from 'path';
|
|
4
|
+
import { Effect, Either } from 'effect';
|
|
4
5
|
import { AmbiguousBranchError, } from '../types/index.js';
|
|
6
|
+
import { GitError, FileSystemError } from '../types/errors.js';
|
|
5
7
|
import { setWorktreeParentBranch } from '../utils/worktreeConfig.js';
|
|
6
8
|
import { getClaudeProjectsDir, pathToClaudeProjectName, } from '../utils/claudeDir.js';
|
|
7
9
|
import { executeWorktreePostCreationHook } from '../utils/hookExecutor.js';
|
|
8
10
|
import { configurationManager } from './configurationManager.js';
|
|
9
11
|
const CLAUDE_DIR = '.claude';
|
|
12
|
+
/**
|
|
13
|
+
* WorktreeService - Git worktree management with Effect-based error handling
|
|
14
|
+
*
|
|
15
|
+
* All public methods return Effect types for type-safe, composable error handling.
|
|
16
|
+
* See CLAUDE.md for complete examples and patterns.
|
|
17
|
+
*
|
|
18
|
+
* ## Effect-ts Resources
|
|
19
|
+
* - Effect Type: https://effect.website/docs/effect/effect-type
|
|
20
|
+
* - Error Management: https://effect.website/docs/error-management/error-handling
|
|
21
|
+
* - Effect Execution: https://effect.website/docs/guides/running-effects
|
|
22
|
+
* - Tagged Errors: https://effect.website/docs/error-management/expected-errors#tagged-errors
|
|
23
|
+
*
|
|
24
|
+
* ## Key Concepts
|
|
25
|
+
* - All operations return `Effect.Effect<T, E, never>` where T is success type, E is error type
|
|
26
|
+
* - Execute Effects with `Effect.runPromise()` or `Effect.match()` for type-safe handling
|
|
27
|
+
* - Use error discrimination via `error._tag` property for TypeScript type narrowing
|
|
28
|
+
* - Compose Effects with `Effect.flatMap()`, `Effect.all()`, `Effect.catchTag()`, etc.
|
|
29
|
+
*
|
|
30
|
+
* @example
|
|
31
|
+
* ```typescript
|
|
32
|
+
* // See individual method JSDoc for specific usage examples
|
|
33
|
+
* const service = new WorktreeService();
|
|
34
|
+
* ```
|
|
35
|
+
*/
|
|
10
36
|
export class WorktreeService {
|
|
11
37
|
constructor(rootPath) {
|
|
12
38
|
Object.defineProperty(this, "rootPath", {
|
|
@@ -52,147 +78,19 @@ export class WorktreeService {
|
|
|
52
78
|
return path.resolve(this.rootPath);
|
|
53
79
|
}
|
|
54
80
|
}
|
|
55
|
-
getWorktrees() {
|
|
56
|
-
try {
|
|
57
|
-
const output = execSync('git worktree list --porcelain', {
|
|
58
|
-
cwd: this.rootPath,
|
|
59
|
-
encoding: 'utf8',
|
|
60
|
-
});
|
|
61
|
-
const worktrees = [];
|
|
62
|
-
const lines = output.trim().split('\n');
|
|
63
|
-
const parseWorktree = (lines, startIndex) => {
|
|
64
|
-
const worktreeLine = lines[startIndex];
|
|
65
|
-
if (!worktreeLine?.startsWith('worktree ')) {
|
|
66
|
-
return [null, startIndex];
|
|
67
|
-
}
|
|
68
|
-
const worktree = {
|
|
69
|
-
path: worktreeLine.substring(9),
|
|
70
|
-
isMainWorktree: false,
|
|
71
|
-
hasSession: false,
|
|
72
|
-
};
|
|
73
|
-
let i = startIndex + 1;
|
|
74
|
-
while (i < lines.length &&
|
|
75
|
-
lines[i] &&
|
|
76
|
-
!lines[i].startsWith('worktree ')) {
|
|
77
|
-
const line = lines[i];
|
|
78
|
-
if (line && line.startsWith('branch ')) {
|
|
79
|
-
const branch = line.substring(7);
|
|
80
|
-
worktree.branch = branch.startsWith('refs/heads/')
|
|
81
|
-
? branch.substring(11)
|
|
82
|
-
: branch;
|
|
83
|
-
}
|
|
84
|
-
else if (line === 'bare') {
|
|
85
|
-
worktree.isMainWorktree = true;
|
|
86
|
-
}
|
|
87
|
-
i++;
|
|
88
|
-
}
|
|
89
|
-
return [worktree, i];
|
|
90
|
-
};
|
|
91
|
-
let index = 0;
|
|
92
|
-
while (index < lines.length) {
|
|
93
|
-
const [worktree, nextIndex] = parseWorktree(lines, index);
|
|
94
|
-
if (worktree) {
|
|
95
|
-
worktrees.push(worktree);
|
|
96
|
-
}
|
|
97
|
-
index = nextIndex > index ? nextIndex : index + 1;
|
|
98
|
-
}
|
|
99
|
-
// Mark the first worktree as main if none are marked
|
|
100
|
-
if (worktrees.length > 0 && !worktrees.some(w => w.isMainWorktree)) {
|
|
101
|
-
worktrees[0].isMainWorktree = true;
|
|
102
|
-
}
|
|
103
|
-
return worktrees;
|
|
104
|
-
}
|
|
105
|
-
catch (_error) {
|
|
106
|
-
// If git worktree command fails, assume we're in a regular git repo
|
|
107
|
-
return [
|
|
108
|
-
{
|
|
109
|
-
path: this.rootPath,
|
|
110
|
-
branch: this.getCurrentBranch(),
|
|
111
|
-
isMainWorktree: true,
|
|
112
|
-
hasSession: false,
|
|
113
|
-
},
|
|
114
|
-
];
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
getCurrentBranch() {
|
|
118
|
-
try {
|
|
119
|
-
const branch = execSync('git rev-parse --abbrev-ref HEAD', {
|
|
120
|
-
cwd: this.rootPath,
|
|
121
|
-
encoding: 'utf8',
|
|
122
|
-
}).trim();
|
|
123
|
-
return branch;
|
|
124
|
-
}
|
|
125
|
-
catch {
|
|
126
|
-
return 'unknown';
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
81
|
isGitRepository() {
|
|
130
82
|
return existsSync(path.join(this.rootPath, '.git'));
|
|
131
83
|
}
|
|
132
84
|
getGitRootPath() {
|
|
133
85
|
return this.gitRootPath;
|
|
134
86
|
}
|
|
135
|
-
getDefaultBranch() {
|
|
136
|
-
try {
|
|
137
|
-
// Try to get the default branch from origin
|
|
138
|
-
const defaultBranch = execSync("git symbolic-ref refs/remotes/origin/HEAD | sed 's@^refs/remotes/origin/@@'", {
|
|
139
|
-
cwd: this.rootPath,
|
|
140
|
-
encoding: 'utf8',
|
|
141
|
-
shell: '/bin/bash',
|
|
142
|
-
}).trim();
|
|
143
|
-
return defaultBranch || 'main';
|
|
144
|
-
}
|
|
145
|
-
catch {
|
|
146
|
-
// Fallback to common default branch names
|
|
147
|
-
try {
|
|
148
|
-
execSync('git rev-parse --verify main', {
|
|
149
|
-
cwd: this.rootPath,
|
|
150
|
-
encoding: 'utf8',
|
|
151
|
-
});
|
|
152
|
-
return 'main';
|
|
153
|
-
}
|
|
154
|
-
catch {
|
|
155
|
-
try {
|
|
156
|
-
execSync('git rev-parse --verify master', {
|
|
157
|
-
cwd: this.rootPath,
|
|
158
|
-
encoding: 'utf8',
|
|
159
|
-
});
|
|
160
|
-
return 'master';
|
|
161
|
-
}
|
|
162
|
-
catch {
|
|
163
|
-
return 'main';
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
getAllBranches() {
|
|
169
|
-
try {
|
|
170
|
-
const output = execSync("git branch -a --format='%(refname:short)' | grep -v HEAD | sort -u", {
|
|
171
|
-
cwd: this.rootPath,
|
|
172
|
-
encoding: 'utf8',
|
|
173
|
-
shell: '/bin/bash',
|
|
174
|
-
});
|
|
175
|
-
const branches = output
|
|
176
|
-
.trim()
|
|
177
|
-
.split('\n')
|
|
178
|
-
.filter(branch => branch && !branch.startsWith('origin/'))
|
|
179
|
-
.map(branch => branch.trim());
|
|
180
|
-
// Also include remote branches without origin/ prefix
|
|
181
|
-
const remoteBranches = output
|
|
182
|
-
.trim()
|
|
183
|
-
.split('\n')
|
|
184
|
-
.filter(branch => branch.startsWith('origin/'))
|
|
185
|
-
.map(branch => branch.replace('origin/', ''));
|
|
186
|
-
// Merge and deduplicate
|
|
187
|
-
const allBranches = [...new Set([...branches, ...remoteBranches])];
|
|
188
|
-
return allBranches.filter(branch => branch);
|
|
189
|
-
}
|
|
190
|
-
catch {
|
|
191
|
-
return [];
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
87
|
/**
|
|
195
|
-
* Resolves a branch name to its proper git reference.
|
|
88
|
+
* SYNCHRONOUS HELPER: Resolves a branch name to its proper git reference.
|
|
89
|
+
*
|
|
90
|
+
* This method remains synchronous as it's called within Effect.gen contexts
|
|
91
|
+
* but doesn't need to be wrapped in Effect itself. It's used by createWorktreeEffect()
|
|
92
|
+
* to resolve branch references before creating worktrees.
|
|
93
|
+
*
|
|
196
94
|
* Handles multiple remotes and throws AmbiguousBranchError when disambiguation is needed.
|
|
197
95
|
*
|
|
198
96
|
* Priority order:
|
|
@@ -200,6 +98,11 @@ export class WorktreeService {
|
|
|
200
98
|
* 2. Single remote branch -> return remote/branch
|
|
201
99
|
* 3. Multiple remote branches -> throw AmbiguousBranchError
|
|
202
100
|
* 4. No branches found -> return original (let git handle error)
|
|
101
|
+
*
|
|
102
|
+
* @private
|
|
103
|
+
* @param {string} branchName - Branch name to resolve
|
|
104
|
+
* @returns {string} Resolved branch reference
|
|
105
|
+
* @throws {AmbiguousBranchError} When branch exists in multiple remotes
|
|
203
106
|
*/
|
|
204
107
|
resolveBranchReference(branchName) {
|
|
205
108
|
try {
|
|
@@ -260,7 +163,13 @@ export class WorktreeService {
|
|
|
260
163
|
}
|
|
261
164
|
}
|
|
262
165
|
/**
|
|
263
|
-
* Gets all git remotes for this repository.
|
|
166
|
+
* SYNCHRONOUS HELPER: Gets all git remotes for this repository.
|
|
167
|
+
*
|
|
168
|
+
* This method remains synchronous as it's a simple utility used by resolveBranchReference().
|
|
169
|
+
* No need for Effect version since it's a pure read operation with no complex error handling.
|
|
170
|
+
*
|
|
171
|
+
* @private
|
|
172
|
+
* @returns {string[]} Array of remote names, empty array on error
|
|
264
173
|
*/
|
|
265
174
|
getAllRemotes() {
|
|
266
175
|
try {
|
|
@@ -278,293 +187,824 @@ export class WorktreeService {
|
|
|
278
187
|
return [];
|
|
279
188
|
}
|
|
280
189
|
}
|
|
281
|
-
|
|
190
|
+
/**
|
|
191
|
+
* SYNCHRONOUS HELPER: Copies Claude Code session data between worktrees.
|
|
192
|
+
*
|
|
193
|
+
* This method remains synchronous and is wrapped in Effect.try when called from
|
|
194
|
+
* createWorktreeEffect() (line ~676). This provides proper error handling while
|
|
195
|
+
* keeping the implementation simple.
|
|
196
|
+
*
|
|
197
|
+
* @private
|
|
198
|
+
* @param {string} sourceWorktreePath - Source worktree path
|
|
199
|
+
* @param {string} targetWorktreePath - Target worktree path
|
|
200
|
+
* @throws {Error} When copy operation fails
|
|
201
|
+
*/
|
|
202
|
+
copyClaudeSessionData(sourceWorktreePath, targetWorktreePath) {
|
|
282
203
|
try {
|
|
204
|
+
const projectsDirEither = getClaudeProjectsDir();
|
|
205
|
+
if (Either.isLeft(projectsDirEither)) {
|
|
206
|
+
throw new Error(`Could not determine Claude projects directory: ${projectsDirEither.left.field} ${projectsDirEither.left.constraint}`);
|
|
207
|
+
}
|
|
208
|
+
const projectsDir = projectsDirEither.right;
|
|
209
|
+
if (!existsSync(projectsDir)) {
|
|
210
|
+
throw new Error(`Claude projects directory does not exist: ${projectsDir}`);
|
|
211
|
+
}
|
|
212
|
+
// Convert paths to Claude's naming convention
|
|
213
|
+
const sourceProjectName = pathToClaudeProjectName(sourceWorktreePath);
|
|
214
|
+
const targetProjectName = pathToClaudeProjectName(targetWorktreePath);
|
|
215
|
+
const sourceProjectDir = path.join(projectsDir, sourceProjectName);
|
|
216
|
+
const targetProjectDir = path.join(projectsDir, targetProjectName);
|
|
217
|
+
// Only copy if source project exists
|
|
218
|
+
if (existsSync(sourceProjectDir)) {
|
|
219
|
+
cpSync(sourceProjectDir, targetProjectDir, {
|
|
220
|
+
recursive: true,
|
|
221
|
+
force: true,
|
|
222
|
+
errorOnExist: false,
|
|
223
|
+
preserveTimestamps: true,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
catch (error) {
|
|
228
|
+
console.error(`Failed to copy Claude session data: ${error}`);
|
|
229
|
+
throw new Error(`Failed to copy Claude session data: ${error}`);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Effect-based hasClaudeDirectoryInBranch operation
|
|
234
|
+
* Checks if a .claude directory exists in the worktree for the specified branch
|
|
235
|
+
*
|
|
236
|
+
* @param {string} branchName - Name of the branch to check
|
|
237
|
+
* @returns {Effect.Effect<boolean, GitError, never>} Effect containing true if .claude directory exists, false otherwise
|
|
238
|
+
*
|
|
239
|
+
* @example
|
|
240
|
+
* ```typescript
|
|
241
|
+
* // Check if branch has .claude directory
|
|
242
|
+
* const hasClaudeDir = await Effect.runPromise(
|
|
243
|
+
* effect
|
|
244
|
+
* );
|
|
245
|
+
*
|
|
246
|
+
* // Or use Effect.match for error handling
|
|
247
|
+
* const result = await Effect.runPromise(
|
|
248
|
+
* Effect.match(effect, {
|
|
249
|
+
* onFailure: (error: GitError) => ({
|
|
250
|
+
* type: 'error' as const,
|
|
251
|
+
* message: error.stderr
|
|
252
|
+
* }),
|
|
253
|
+
* onSuccess: (hasDir: boolean) => ({
|
|
254
|
+
* type: 'success' as const,
|
|
255
|
+
* data: hasDir
|
|
256
|
+
* })
|
|
257
|
+
* })
|
|
258
|
+
* );
|
|
259
|
+
* ```
|
|
260
|
+
*
|
|
261
|
+
* @throws {GitError} When git operations fail
|
|
262
|
+
*/
|
|
263
|
+
hasClaudeDirectoryInBranchEffect(branchName) {
|
|
264
|
+
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
265
|
+
const self = this;
|
|
266
|
+
return Effect.gen(function* () {
|
|
267
|
+
// Get all worktrees
|
|
268
|
+
const worktrees = yield* self.getWorktreesEffect();
|
|
269
|
+
// Try to find worktree for the branch
|
|
270
|
+
let targetWorktree = worktrees.find(wt => wt.branch && wt.branch.replace('refs/heads/', '') === branchName);
|
|
271
|
+
// If branch worktree not found, try the default branch
|
|
272
|
+
if (!targetWorktree) {
|
|
273
|
+
const defaultBranch = yield* self.getDefaultBranchEffect();
|
|
274
|
+
if (branchName === defaultBranch) {
|
|
275
|
+
targetWorktree = worktrees.find(wt => wt.branch &&
|
|
276
|
+
wt.branch.replace('refs/heads/', '') === defaultBranch);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
// If still not found and it's the default branch, try the main worktree
|
|
280
|
+
if (!targetWorktree) {
|
|
281
|
+
const defaultBranch = yield* self.getDefaultBranchEffect();
|
|
282
|
+
if (branchName === defaultBranch) {
|
|
283
|
+
targetWorktree = worktrees.find(wt => wt.isMainWorktree);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
if (!targetWorktree) {
|
|
287
|
+
return false;
|
|
288
|
+
}
|
|
289
|
+
// Check if .claude directory exists in the worktree
|
|
290
|
+
const claudePath = path.join(targetWorktree.path, CLAUDE_DIR);
|
|
291
|
+
return existsSync(claudePath) && statSync(claudePath).isDirectory();
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Effect-based copyClaudeDirectoryFromBaseBranch operation
|
|
296
|
+
* Copies .claude directory from base branch worktree to target worktree
|
|
297
|
+
*
|
|
298
|
+
* @param {string} worktreePath - Path of the target worktree
|
|
299
|
+
* @param {string} baseBranch - Name of the base branch to copy from
|
|
300
|
+
* @returns {Effect.Effect<void, GitError | FileSystemError, never>} Effect that completes successfully or fails with error
|
|
301
|
+
*
|
|
302
|
+
* @example
|
|
303
|
+
* ```typescript
|
|
304
|
+
* // Copy .claude directory from main branch
|
|
305
|
+
* await Effect.runPromise(
|
|
306
|
+
* effect
|
|
307
|
+
* );
|
|
308
|
+
*
|
|
309
|
+
* // With error handling
|
|
310
|
+
* const result = await Effect.runPromise(
|
|
311
|
+
* Effect.catchAll(
|
|
312
|
+
* effect,
|
|
313
|
+
* (error) => {
|
|
314
|
+
* console.warn('Could not copy .claude directory:', error);
|
|
315
|
+
* return Effect.succeed(undefined); // Continue despite error
|
|
316
|
+
* }
|
|
317
|
+
* )
|
|
318
|
+
* );
|
|
319
|
+
* ```
|
|
320
|
+
*
|
|
321
|
+
* @throws {GitError} When base worktree cannot be found
|
|
322
|
+
* @throws {FileSystemError} When copying the directory fails
|
|
323
|
+
*/
|
|
324
|
+
copyClaudeDirectoryFromBaseBranchEffect(worktreePath, baseBranch) {
|
|
325
|
+
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
326
|
+
const self = this;
|
|
327
|
+
return Effect.gen(function* () {
|
|
328
|
+
// Find the worktree directory for the base branch
|
|
329
|
+
const worktrees = yield* self.getWorktreesEffect();
|
|
330
|
+
let baseWorktree = worktrees.find(wt => wt.branch && wt.branch.replace('refs/heads/', '') === baseBranch);
|
|
331
|
+
// If base branch worktree not found, try the default branch
|
|
332
|
+
if (!baseWorktree) {
|
|
333
|
+
const defaultBranch = yield* self.getDefaultBranchEffect();
|
|
334
|
+
baseWorktree = worktrees.find(wt => wt.branch && wt.branch.replace('refs/heads/', '') === defaultBranch);
|
|
335
|
+
}
|
|
336
|
+
// If still not found, try the main worktree
|
|
337
|
+
if (!baseWorktree) {
|
|
338
|
+
baseWorktree = worktrees.find(wt => wt.isMainWorktree);
|
|
339
|
+
}
|
|
340
|
+
if (!baseWorktree) {
|
|
341
|
+
return yield* Effect.fail(new GitError({
|
|
342
|
+
command: 'find base worktree',
|
|
343
|
+
exitCode: 1,
|
|
344
|
+
stderr: 'Could not find base worktree to copy settings from',
|
|
345
|
+
}));
|
|
346
|
+
}
|
|
347
|
+
// Check if .claude directory exists in base worktree
|
|
348
|
+
const sourceClaudeDir = path.join(baseWorktree.path, CLAUDE_DIR);
|
|
349
|
+
if (!existsSync(sourceClaudeDir) ||
|
|
350
|
+
!statSync(sourceClaudeDir).isDirectory()) {
|
|
351
|
+
// No .claude directory to copy, this is fine
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
// Copy .claude directory to new worktree
|
|
355
|
+
const targetClaudeDir = path.join(worktreePath, CLAUDE_DIR);
|
|
356
|
+
yield* Effect.try({
|
|
357
|
+
try: () => cpSync(sourceClaudeDir, targetClaudeDir, { recursive: true }),
|
|
358
|
+
catch: (error) => new FileSystemError({
|
|
359
|
+
operation: 'write',
|
|
360
|
+
path: targetClaudeDir,
|
|
361
|
+
cause: String(error),
|
|
362
|
+
}),
|
|
363
|
+
});
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Effect-based getDefaultBranch operation
|
|
368
|
+
* Returns Effect that may fail with GitError
|
|
369
|
+
*
|
|
370
|
+
* @returns {Effect.Effect<string, GitError, never>} Effect containing default branch name or GitError
|
|
371
|
+
*
|
|
372
|
+
* @example
|
|
373
|
+
* ```typescript
|
|
374
|
+
* // Use Effect.match for type-safe error handling
|
|
375
|
+
* const result = await Effect.runPromise(
|
|
376
|
+
* Effect.match(effect, {
|
|
377
|
+
* onFailure: (error: GitError) => ({
|
|
378
|
+
* type: 'error' as const,
|
|
379
|
+
* message: `Failed to get default branch: ${error.stderr}`
|
|
380
|
+
* }),
|
|
381
|
+
* onSuccess: (branch: string) => ({
|
|
382
|
+
* type: 'success' as const,
|
|
383
|
+
* data: branch
|
|
384
|
+
* })
|
|
385
|
+
* })
|
|
386
|
+
* );
|
|
387
|
+
*
|
|
388
|
+
* if (result.type === 'success') {
|
|
389
|
+
* console.log(`Default branch is: ${result.data}`);
|
|
390
|
+
* }
|
|
391
|
+
* ```
|
|
392
|
+
*
|
|
393
|
+
* @throws {GitError} When git symbolic-ref command fails and fallback detection also fails
|
|
394
|
+
*/
|
|
395
|
+
getDefaultBranchEffect() {
|
|
396
|
+
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
397
|
+
const self = this;
|
|
398
|
+
return Effect.catchAll(Effect.try({
|
|
399
|
+
try: () => {
|
|
400
|
+
// Try to get the default branch from origin
|
|
401
|
+
const defaultBranch = execSync("git symbolic-ref refs/remotes/origin/HEAD | sed 's@^refs/remotes/origin/@@'", {
|
|
402
|
+
cwd: self.rootPath,
|
|
403
|
+
encoding: 'utf8',
|
|
404
|
+
shell: '/bin/bash',
|
|
405
|
+
}).trim();
|
|
406
|
+
if (!defaultBranch) {
|
|
407
|
+
throw new Error('No default branch from symbolic-ref');
|
|
408
|
+
}
|
|
409
|
+
return defaultBranch;
|
|
410
|
+
},
|
|
411
|
+
catch: (error) => error,
|
|
412
|
+
}), (_error) => {
|
|
413
|
+
// Fallback to checking for main/master branches
|
|
414
|
+
return Effect.catchAll(Effect.try({
|
|
415
|
+
try: () => {
|
|
416
|
+
execSync('git rev-parse --verify main', {
|
|
417
|
+
cwd: self.rootPath,
|
|
418
|
+
encoding: 'utf8',
|
|
419
|
+
});
|
|
420
|
+
return 'main';
|
|
421
|
+
},
|
|
422
|
+
catch: (error) => error,
|
|
423
|
+
}), (_mainError) => {
|
|
424
|
+
return Effect.catchAll(Effect.try({
|
|
425
|
+
try: () => {
|
|
426
|
+
execSync('git rev-parse --verify master', {
|
|
427
|
+
cwd: self.rootPath,
|
|
428
|
+
encoding: 'utf8',
|
|
429
|
+
});
|
|
430
|
+
return 'master';
|
|
431
|
+
},
|
|
432
|
+
catch: (error) => error,
|
|
433
|
+
}), (_masterError) => {
|
|
434
|
+
// All attempts failed, return 'main' as default
|
|
435
|
+
// This is acceptable behavior for new repositories
|
|
436
|
+
return Effect.succeed('main');
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Effect-based getAllBranches operation
|
|
443
|
+
* Returns Effect that succeeds with array of branches (empty on failure for non-critical operation)
|
|
444
|
+
*
|
|
445
|
+
* @returns {Effect.Effect<string[], GitError, never>} Effect containing array of branch names
|
|
446
|
+
*
|
|
447
|
+
* @example
|
|
448
|
+
* ```typescript
|
|
449
|
+
* // Execute in async context - this operation returns empty array on failure
|
|
450
|
+
* const branches = await Effect.runPromise(
|
|
451
|
+
* effect
|
|
452
|
+
* );
|
|
453
|
+
* console.log(`Found ${branches.length} branches`);
|
|
454
|
+
*
|
|
455
|
+
* // Or use Effect.match for explicit error handling
|
|
456
|
+
* const result = await Effect.runPromise(
|
|
457
|
+
* Effect.match(effect, {
|
|
458
|
+
* onFailure: (error: GitError) => ({
|
|
459
|
+
* type: 'error' as const,
|
|
460
|
+
* message: error.stderr
|
|
461
|
+
* }),
|
|
462
|
+
* onSuccess: (branches: string[]) => ({
|
|
463
|
+
* type: 'success' as const,
|
|
464
|
+
* data: branches
|
|
465
|
+
* })
|
|
466
|
+
* })
|
|
467
|
+
* );
|
|
468
|
+
* ```
|
|
469
|
+
*
|
|
470
|
+
* @throws {GitError} When git branch command fails (but falls back to empty array)
|
|
471
|
+
*/
|
|
472
|
+
getAllBranchesEffect() {
|
|
473
|
+
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
474
|
+
const self = this;
|
|
475
|
+
return Effect.catchAll(Effect.try({
|
|
476
|
+
try: () => {
|
|
477
|
+
const output = execSync("git branch -a --format='%(refname:short)' | grep -v HEAD | sort -u", {
|
|
478
|
+
cwd: self.rootPath,
|
|
479
|
+
encoding: 'utf8',
|
|
480
|
+
shell: '/bin/bash',
|
|
481
|
+
});
|
|
482
|
+
const branches = output
|
|
483
|
+
.trim()
|
|
484
|
+
.split('\n')
|
|
485
|
+
.filter(branch => branch && !branch.startsWith('origin/'))
|
|
486
|
+
.map(branch => branch.trim());
|
|
487
|
+
// Also include remote branches without origin/ prefix
|
|
488
|
+
const remoteBranches = output
|
|
489
|
+
.trim()
|
|
490
|
+
.split('\n')
|
|
491
|
+
.filter(branch => branch.startsWith('origin/'))
|
|
492
|
+
.map(branch => branch.replace('origin/', ''));
|
|
493
|
+
// Merge and deduplicate
|
|
494
|
+
const allBranches = [...new Set([...branches, ...remoteBranches])];
|
|
495
|
+
return allBranches.filter(branch => branch);
|
|
496
|
+
},
|
|
497
|
+
catch: (error) => error,
|
|
498
|
+
}), (_error) => {
|
|
499
|
+
// Return empty array on failure (non-critical operation)
|
|
500
|
+
return Effect.succeed([]);
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Effect-based getCurrentBranch operation
|
|
505
|
+
* Returns Effect that may fail with GitError
|
|
506
|
+
*
|
|
507
|
+
* @returns {Effect.Effect<string, GitError, never>} Effect containing current branch name or GitError
|
|
508
|
+
*
|
|
509
|
+
* @example
|
|
510
|
+
* ```typescript
|
|
511
|
+
* // Use Effect.match for type-safe error handling
|
|
512
|
+
* const result = await Effect.runPromise(
|
|
513
|
+
* Effect.match(effect, {
|
|
514
|
+
* onFailure: (error: GitError) => ({
|
|
515
|
+
* type: 'error' as const,
|
|
516
|
+
* message: `Failed to get current branch: ${error.stderr}`
|
|
517
|
+
* }),
|
|
518
|
+
* onSuccess: (branch: string) => ({
|
|
519
|
+
* type: 'success' as const,
|
|
520
|
+
* data: branch
|
|
521
|
+
* })
|
|
522
|
+
* })
|
|
523
|
+
* );
|
|
524
|
+
*
|
|
525
|
+
* if (result.type === 'success') {
|
|
526
|
+
* console.log(`Current branch: ${result.data}`);
|
|
527
|
+
* }
|
|
528
|
+
* ```
|
|
529
|
+
*
|
|
530
|
+
* @throws {GitError} When git rev-parse command fails
|
|
531
|
+
*/
|
|
532
|
+
getCurrentBranchEffect() {
|
|
533
|
+
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
534
|
+
const self = this;
|
|
535
|
+
return Effect.catchAll(Effect.try({
|
|
536
|
+
try: () => {
|
|
537
|
+
const branch = execSync('git rev-parse --abbrev-ref HEAD', {
|
|
538
|
+
cwd: self.rootPath,
|
|
539
|
+
encoding: 'utf8',
|
|
540
|
+
}).trim();
|
|
541
|
+
if (!branch) {
|
|
542
|
+
throw new Error('No current branch returned');
|
|
543
|
+
}
|
|
544
|
+
return branch;
|
|
545
|
+
},
|
|
546
|
+
catch: (error) => error,
|
|
547
|
+
}), (_error) => {
|
|
548
|
+
// Return 'unknown' as fallback for compatibility
|
|
549
|
+
return Effect.succeed('unknown');
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
/**
|
|
553
|
+
* Effect-based getWorktrees operation
|
|
554
|
+
* Returns Effect that may fail with GitError
|
|
555
|
+
*
|
|
556
|
+
* @returns {Effect.Effect<Worktree[], GitError, never>} Effect containing array of worktrees or GitError
|
|
557
|
+
*
|
|
558
|
+
* @example
|
|
559
|
+
* ```typescript
|
|
560
|
+
* // Execute in async context
|
|
561
|
+
* const worktrees = await Effect.runPromise(
|
|
562
|
+
* effect
|
|
563
|
+
* );
|
|
564
|
+
*
|
|
565
|
+
* // Or use Effect.match for type-safe error handling
|
|
566
|
+
* const result = await Effect.runPromise(
|
|
567
|
+
* Effect.match(effect, {
|
|
568
|
+
* onFailure: (error: GitError) => ({
|
|
569
|
+
* type: 'error' as const,
|
|
570
|
+
* message: `Git error: ${error.stderr}`
|
|
571
|
+
* }),
|
|
572
|
+
* onSuccess: (worktrees: Worktree[]) => ({
|
|
573
|
+
* type: 'success' as const,
|
|
574
|
+
* data: worktrees
|
|
575
|
+
* })
|
|
576
|
+
* })
|
|
577
|
+
* );
|
|
578
|
+
*
|
|
579
|
+
* if (result.type === 'error') {
|
|
580
|
+
* console.error(result.message);
|
|
581
|
+
* } else {
|
|
582
|
+
* console.log(`Found ${result.data.length} worktrees`);
|
|
583
|
+
* }
|
|
584
|
+
* ```
|
|
585
|
+
*
|
|
586
|
+
* @throws {GitError} When git worktree list command fails
|
|
587
|
+
*/
|
|
588
|
+
getWorktreesEffect() {
|
|
589
|
+
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
590
|
+
const self = this;
|
|
591
|
+
return Effect.catchAll(Effect.try({
|
|
592
|
+
try: () => {
|
|
593
|
+
const output = execSync('git worktree list --porcelain', {
|
|
594
|
+
cwd: self.rootPath,
|
|
595
|
+
encoding: 'utf8',
|
|
596
|
+
});
|
|
597
|
+
const worktrees = [];
|
|
598
|
+
const lines = output.trim().split('\n');
|
|
599
|
+
const parseWorktree = (lines, startIndex) => {
|
|
600
|
+
const worktreeLine = lines[startIndex];
|
|
601
|
+
if (!worktreeLine?.startsWith('worktree ')) {
|
|
602
|
+
return [null, startIndex];
|
|
603
|
+
}
|
|
604
|
+
const worktree = {
|
|
605
|
+
path: worktreeLine.substring(9),
|
|
606
|
+
isMainWorktree: false,
|
|
607
|
+
hasSession: false,
|
|
608
|
+
};
|
|
609
|
+
let i = startIndex + 1;
|
|
610
|
+
while (i < lines.length &&
|
|
611
|
+
lines[i] &&
|
|
612
|
+
!lines[i].startsWith('worktree ')) {
|
|
613
|
+
const line = lines[i];
|
|
614
|
+
if (line && line.startsWith('branch ')) {
|
|
615
|
+
const branch = line.substring(7);
|
|
616
|
+
worktree.branch = branch.startsWith('refs/heads/')
|
|
617
|
+
? branch.substring(11)
|
|
618
|
+
: branch;
|
|
619
|
+
}
|
|
620
|
+
else if (line === 'bare') {
|
|
621
|
+
worktree.isMainWorktree = true;
|
|
622
|
+
}
|
|
623
|
+
i++;
|
|
624
|
+
}
|
|
625
|
+
return [worktree, i];
|
|
626
|
+
};
|
|
627
|
+
let index = 0;
|
|
628
|
+
while (index < lines.length) {
|
|
629
|
+
const [worktree, nextIndex] = parseWorktree(lines, index);
|
|
630
|
+
if (worktree) {
|
|
631
|
+
worktrees.push(worktree);
|
|
632
|
+
}
|
|
633
|
+
index = nextIndex > index ? nextIndex : index + 1;
|
|
634
|
+
}
|
|
635
|
+
// Mark the first worktree as main if none are marked
|
|
636
|
+
if (worktrees.length > 0 && !worktrees.some(w => w.isMainWorktree)) {
|
|
637
|
+
worktrees[0].isMainWorktree = true;
|
|
638
|
+
}
|
|
639
|
+
return worktrees;
|
|
640
|
+
},
|
|
641
|
+
catch: (error) => error,
|
|
642
|
+
}), (error) => {
|
|
643
|
+
// If git worktree command not supported, fallback to single worktree
|
|
644
|
+
const execError = error;
|
|
645
|
+
if (execError.status === 1 ||
|
|
646
|
+
execError.stderr?.includes('unknown command')) {
|
|
647
|
+
// Use Effect-based getCurrentBranchEffect() instead of synchronous getCurrentBranch()
|
|
648
|
+
return Effect.map(self.getCurrentBranchEffect(), branch => [
|
|
649
|
+
{
|
|
650
|
+
path: self.rootPath,
|
|
651
|
+
branch,
|
|
652
|
+
isMainWorktree: true,
|
|
653
|
+
hasSession: false,
|
|
654
|
+
},
|
|
655
|
+
]);
|
|
656
|
+
}
|
|
657
|
+
// For other errors, wrap in GitError
|
|
658
|
+
return Effect.fail(new GitError({
|
|
659
|
+
command: 'git worktree list --porcelain',
|
|
660
|
+
exitCode: execError.status || 1,
|
|
661
|
+
stderr: execError.stderr || String(error),
|
|
662
|
+
stdout: execError.stdout,
|
|
663
|
+
}));
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
/**
|
|
667
|
+
* Effect-based createWorktree operation
|
|
668
|
+
* May fail with GitError or FileSystemError
|
|
669
|
+
*
|
|
670
|
+
* @param {string} worktreePath - Path where the new worktree will be created
|
|
671
|
+
* @param {string} branch - Name of the branch for the new worktree
|
|
672
|
+
* @param {string} baseBranch - Base branch to create the new branch from
|
|
673
|
+
* @param {boolean} copySessionData - Whether to copy Claude session data (default: false)
|
|
674
|
+
* @param {boolean} copyClaudeDirectory - Whether to copy .claude directory (default: false)
|
|
675
|
+
* @returns {Effect.Effect<Worktree, GitError | FileSystemError, never>} Effect containing created worktree or error
|
|
676
|
+
*
|
|
677
|
+
* @example
|
|
678
|
+
* ```typescript
|
|
679
|
+
* // Create new worktree with Effect.match for error handling
|
|
680
|
+
* const result = await Effect.runPromise(
|
|
681
|
+
* Effect.match(
|
|
682
|
+
* effect,
|
|
683
|
+
* {
|
|
684
|
+
* onFailure: (error: GitError | FileSystemError) => {
|
|
685
|
+
* switch (error._tag) {
|
|
686
|
+
* case 'GitError':
|
|
687
|
+
* return {type: 'error' as const, msg: `Git failed: ${error.stderr}`};
|
|
688
|
+
* case 'FileSystemError':
|
|
689
|
+
* return {type: 'error' as const, msg: `FS failed: ${error.cause}`};
|
|
690
|
+
* }
|
|
691
|
+
* },
|
|
692
|
+
* onSuccess: (worktree: Worktree) => ({
|
|
693
|
+
* type: 'success' as const,
|
|
694
|
+
* data: worktree
|
|
695
|
+
* })
|
|
696
|
+
* }
|
|
697
|
+
* )
|
|
698
|
+
* );
|
|
699
|
+
*
|
|
700
|
+
* if (result.type === 'error') {
|
|
701
|
+
* console.error(result.msg);
|
|
702
|
+
* } else {
|
|
703
|
+
* console.log(`Created worktree at ${result.data.path}`);
|
|
704
|
+
* }
|
|
705
|
+
* ```
|
|
706
|
+
*
|
|
707
|
+
* @throws {GitError} When git worktree add command fails
|
|
708
|
+
* @throws {FileSystemError} When session data copy fails
|
|
709
|
+
*/
|
|
710
|
+
createWorktreeEffect(worktreePath, branch, baseBranch, copySessionData = false, copyClaudeDirectory = false) {
|
|
711
|
+
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
712
|
+
const self = this;
|
|
713
|
+
return Effect.gen(function* () {
|
|
283
714
|
// Resolve the worktree path relative to the git repository root
|
|
715
|
+
const gitRootPath = yield* Effect.sync(() => execSync('git rev-parse --git-common-dir', {
|
|
716
|
+
cwd: self.rootPath,
|
|
717
|
+
encoding: 'utf8',
|
|
718
|
+
}).trim());
|
|
719
|
+
const absoluteGitRoot = path.isAbsolute(gitRootPath)
|
|
720
|
+
? path.dirname(gitRootPath)
|
|
721
|
+
: path.resolve(self.rootPath, path.dirname(gitRootPath));
|
|
284
722
|
const resolvedPath = path.isAbsolute(worktreePath)
|
|
285
723
|
? worktreePath
|
|
286
|
-
: path.join(
|
|
724
|
+
: path.join(absoluteGitRoot, worktreePath);
|
|
287
725
|
// Check if branch exists
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
// Create the worktree
|
|
726
|
+
const branchExists = yield* Effect.catchAll(Effect.try({
|
|
727
|
+
try: () => {
|
|
728
|
+
execSync(`git rev-parse --verify ${branch}`, {
|
|
729
|
+
cwd: self.rootPath,
|
|
730
|
+
encoding: 'utf8',
|
|
731
|
+
});
|
|
732
|
+
return true;
|
|
733
|
+
},
|
|
734
|
+
catch: (error) => error,
|
|
735
|
+
}), () => Effect.succeed(false));
|
|
736
|
+
// Create the worktree command
|
|
300
737
|
let command;
|
|
301
738
|
if (branchExists) {
|
|
302
739
|
command = `git worktree add "${resolvedPath}" "${branch}"`;
|
|
303
740
|
}
|
|
304
741
|
else {
|
|
305
742
|
// Resolve the base branch to its proper git reference
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
// Create new branch from specified base branch
|
|
309
|
-
command = `git worktree add -b "${branch}" "${resolvedPath}" "${resolvedBaseBranch}"`;
|
|
310
|
-
}
|
|
311
|
-
catch (error) {
|
|
312
|
-
if (error instanceof AmbiguousBranchError) {
|
|
313
|
-
// TODO: Future enhancement - show disambiguation modal in UI
|
|
314
|
-
// The UI should present the available remote options to the user:
|
|
315
|
-
// - origin/foo/bar-xyz
|
|
316
|
-
// - upstream/foo/bar-xyz
|
|
317
|
-
// For now, return error message to be displayed to user
|
|
318
|
-
return {
|
|
319
|
-
success: false,
|
|
320
|
-
error: error.message,
|
|
321
|
-
};
|
|
322
|
-
}
|
|
323
|
-
// Re-throw any other errors
|
|
324
|
-
throw error;
|
|
325
|
-
}
|
|
743
|
+
const resolvedBaseBranch = self.resolveBranchReference(baseBranch);
|
|
744
|
+
command = `git worktree add -b "${branch}" "${resolvedPath}" "${resolvedBaseBranch}"`;
|
|
326
745
|
}
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
746
|
+
// Execute the worktree creation command
|
|
747
|
+
yield* Effect.try({
|
|
748
|
+
try: () => {
|
|
749
|
+
execSync(command, {
|
|
750
|
+
cwd: absoluteGitRoot,
|
|
751
|
+
encoding: 'utf8',
|
|
752
|
+
});
|
|
753
|
+
},
|
|
754
|
+
catch: (error) => {
|
|
755
|
+
const execError = error;
|
|
756
|
+
return new GitError({
|
|
757
|
+
command,
|
|
758
|
+
exitCode: execError.status || 1,
|
|
759
|
+
stderr: execError.stderr || String(error),
|
|
760
|
+
stdout: execError.stdout,
|
|
761
|
+
});
|
|
762
|
+
},
|
|
330
763
|
});
|
|
331
764
|
// Copy session data if requested
|
|
332
765
|
if (copySessionData) {
|
|
333
|
-
|
|
766
|
+
yield* Effect.try({
|
|
767
|
+
try: () => self.copyClaudeSessionData(self.rootPath, resolvedPath),
|
|
768
|
+
catch: (error) => new FileSystemError({
|
|
769
|
+
operation: 'write',
|
|
770
|
+
path: resolvedPath,
|
|
771
|
+
cause: String(error),
|
|
772
|
+
}),
|
|
773
|
+
});
|
|
334
774
|
}
|
|
335
775
|
// Store the parent branch in worktree config
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
}
|
|
776
|
+
yield* Effect.catchAll(setWorktreeParentBranch(resolvedPath, baseBranch), (_error) => {
|
|
777
|
+
// Log warning but don't fail
|
|
778
|
+
console.error('Warning: Failed to set parent branch in worktree config:', _error);
|
|
779
|
+
return Effect.succeed(undefined);
|
|
780
|
+
});
|
|
342
781
|
// Copy .claude directory if requested
|
|
343
782
|
if (copyClaudeDirectory) {
|
|
344
|
-
|
|
345
|
-
this.copyClaudeDirectoryFromBaseBranch(resolvedPath, baseBranch);
|
|
346
|
-
}
|
|
347
|
-
catch (error) {
|
|
783
|
+
yield* Effect.catchAll(self.copyClaudeDirectoryFromBaseBranchEffect(resolvedPath, baseBranch), (error) => {
|
|
348
784
|
console.error('Warning: Failed to copy .claude directory:', error);
|
|
349
|
-
|
|
785
|
+
return Effect.succeed(undefined);
|
|
786
|
+
});
|
|
350
787
|
}
|
|
351
788
|
// Execute post-creation hook if configured
|
|
352
789
|
const worktreeHooks = configurationManager.getWorktreeHooks();
|
|
353
790
|
if (worktreeHooks.post_creation?.enabled &&
|
|
354
791
|
worktreeHooks.post_creation?.command) {
|
|
355
|
-
// Create a worktree object for the hook
|
|
356
792
|
const newWorktree = {
|
|
357
793
|
path: resolvedPath,
|
|
358
794
|
branch: branch,
|
|
359
795
|
isMainWorktree: false,
|
|
360
796
|
hasSession: false,
|
|
361
797
|
};
|
|
362
|
-
|
|
363
|
-
// Wait for the hook to complete before returning
|
|
364
|
-
await executeWorktreePostCreationHook(worktreeHooks.post_creation.command, newWorktree, this.gitRootPath, baseBranch);
|
|
798
|
+
yield* executeWorktreePostCreationHook(worktreeHooks.post_creation.command, newWorktree, absoluteGitRoot, baseBranch);
|
|
365
799
|
}
|
|
366
|
-
return { success: true };
|
|
367
|
-
}
|
|
368
|
-
catch (error) {
|
|
369
800
|
return {
|
|
370
|
-
|
|
371
|
-
|
|
801
|
+
path: resolvedPath,
|
|
802
|
+
branch,
|
|
803
|
+
isMainWorktree: false,
|
|
804
|
+
hasSession: false,
|
|
372
805
|
};
|
|
373
|
-
}
|
|
806
|
+
});
|
|
374
807
|
}
|
|
375
|
-
|
|
376
|
-
|
|
808
|
+
/**
|
|
809
|
+
* Effect-based deleteWorktree operation
|
|
810
|
+
* May fail with GitError
|
|
811
|
+
*
|
|
812
|
+
* @param {string} worktreePath - Path of the worktree to delete
|
|
813
|
+
* @param {{deleteBranch?: boolean}} options - Options for deletion (default: deleteBranch = true)
|
|
814
|
+
* @returns {Effect.Effect<void, GitError, never>} Effect that completes successfully or fails with GitError
|
|
815
|
+
*
|
|
816
|
+
* @example
|
|
817
|
+
* ```typescript
|
|
818
|
+
* // Delete worktree with Effect.catchTag for specific error handling
|
|
819
|
+
* await Effect.runPromise(
|
|
820
|
+
* Effect.catchTag(
|
|
821
|
+
* effect,
|
|
822
|
+
* 'GitError',
|
|
823
|
+
* (error) => {
|
|
824
|
+
* console.error(`Failed to delete worktree: ${error.stderr}`);
|
|
825
|
+
* return Effect.succeed(undefined); // Continue despite error
|
|
826
|
+
* }
|
|
827
|
+
* )
|
|
828
|
+
* );
|
|
829
|
+
* ```
|
|
830
|
+
*
|
|
831
|
+
* @throws {GitError} When git worktree remove command fails or worktree not found
|
|
832
|
+
*/
|
|
833
|
+
deleteWorktreeEffect(worktreePath, options) {
|
|
834
|
+
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
835
|
+
const self = this;
|
|
836
|
+
return Effect.gen(function* () {
|
|
377
837
|
// Get the worktree info to find the branch
|
|
378
|
-
const worktrees =
|
|
838
|
+
const worktrees = yield* self.getWorktreesEffect();
|
|
379
839
|
const worktree = worktrees.find(wt => wt.path === worktreePath);
|
|
380
840
|
if (!worktree) {
|
|
381
|
-
return {
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
841
|
+
return yield* Effect.fail(new GitError({
|
|
842
|
+
command: 'git worktree remove',
|
|
843
|
+
exitCode: 1,
|
|
844
|
+
stderr: 'Worktree not found',
|
|
845
|
+
}));
|
|
385
846
|
}
|
|
386
847
|
if (worktree.isMainWorktree) {
|
|
387
|
-
return {
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
848
|
+
return yield* Effect.fail(new GitError({
|
|
849
|
+
command: 'git worktree remove',
|
|
850
|
+
exitCode: 1,
|
|
851
|
+
stderr: 'Cannot delete the main worktree',
|
|
852
|
+
}));
|
|
391
853
|
}
|
|
392
854
|
// Remove the worktree
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
855
|
+
yield* Effect.try({
|
|
856
|
+
try: () => {
|
|
857
|
+
execSync(`git worktree remove "${worktreePath}" --force`, {
|
|
858
|
+
cwd: self.rootPath,
|
|
859
|
+
encoding: 'utf8',
|
|
860
|
+
});
|
|
861
|
+
},
|
|
862
|
+
catch: (error) => {
|
|
863
|
+
const execError = error;
|
|
864
|
+
return new GitError({
|
|
865
|
+
command: `git worktree remove "${worktreePath}" --force`,
|
|
866
|
+
exitCode: execError.status || 1,
|
|
867
|
+
stderr: execError.stderr || String(error),
|
|
868
|
+
stdout: execError.stdout,
|
|
869
|
+
});
|
|
870
|
+
},
|
|
396
871
|
});
|
|
397
872
|
// Delete the branch if requested (default to true for backward compatibility)
|
|
398
873
|
const deleteBranch = options?.deleteBranch ?? true;
|
|
399
874
|
if (deleteBranch && worktree.branch) {
|
|
400
875
|
const branchName = worktree.branch.replace('refs/heads/', '');
|
|
401
|
-
try
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
876
|
+
yield* Effect.catchAll(Effect.try({
|
|
877
|
+
try: () => {
|
|
878
|
+
execSync(`git branch -D "${branchName}"`, {
|
|
879
|
+
cwd: self.rootPath,
|
|
880
|
+
encoding: 'utf8',
|
|
881
|
+
});
|
|
882
|
+
},
|
|
883
|
+
catch: (error) => error,
|
|
884
|
+
}), (_error) => {
|
|
408
885
|
// Branch might not exist or might be checked out elsewhere
|
|
409
886
|
// This is not a fatal error
|
|
410
|
-
|
|
887
|
+
return Effect.succeed(undefined);
|
|
888
|
+
});
|
|
411
889
|
}
|
|
412
|
-
|
|
413
|
-
}
|
|
414
|
-
catch (error) {
|
|
415
|
-
return {
|
|
416
|
-
success: false,
|
|
417
|
-
error: error instanceof Error ? error.message : 'Failed to delete worktree',
|
|
418
|
-
};
|
|
419
|
-
}
|
|
890
|
+
});
|
|
420
891
|
}
|
|
421
|
-
|
|
422
|
-
|
|
892
|
+
/**
|
|
893
|
+
* Effect-based mergeWorktree operation
|
|
894
|
+
* May fail with GitError
|
|
895
|
+
*
|
|
896
|
+
* @param {string} sourceBranch - Branch to merge from
|
|
897
|
+
* @param {string} targetBranch - Branch to merge into
|
|
898
|
+
* @param {boolean} useRebase - Whether to use rebase instead of merge (default: false)
|
|
899
|
+
* @returns {Effect.Effect<void, GitError, never>} Effect that completes successfully or fails with GitError
|
|
900
|
+
*
|
|
901
|
+
* @example
|
|
902
|
+
* ```typescript
|
|
903
|
+
* // Merge with Effect.all for parallel operations
|
|
904
|
+
* await Effect.runPromise(
|
|
905
|
+
* Effect.all([
|
|
906
|
+
* effect1,
|
|
907
|
+
* effect2
|
|
908
|
+
* ], {concurrency: 1}) // Sequential to avoid conflicts
|
|
909
|
+
* );
|
|
910
|
+
*
|
|
911
|
+
* // Or use Effect.catchAll for fallback behavior
|
|
912
|
+
* const result = await Effect.runPromise(
|
|
913
|
+
* Effect.catchAll(
|
|
914
|
+
* effect,
|
|
915
|
+
* (error: GitError) => {
|
|
916
|
+
* console.error(`Merge failed: ${error.stderr}`);
|
|
917
|
+
* // Return alternative Effect or rethrow
|
|
918
|
+
* return Effect.fail(error);
|
|
919
|
+
* }
|
|
920
|
+
* )
|
|
921
|
+
* );
|
|
922
|
+
* ```
|
|
923
|
+
*
|
|
924
|
+
* @throws {GitError} When git merge/rebase command fails or worktrees not found
|
|
925
|
+
*/
|
|
926
|
+
mergeWorktreeEffect(sourceBranch, targetBranch, useRebase = false) {
|
|
927
|
+
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
928
|
+
const self = this;
|
|
929
|
+
return Effect.gen(function* () {
|
|
423
930
|
// Get worktrees to find the target worktree path
|
|
424
|
-
const worktrees =
|
|
931
|
+
const worktrees = yield* self.getWorktreesEffect();
|
|
425
932
|
const targetWorktree = worktrees.find(wt => wt.branch && wt.branch.replace('refs/heads/', '') === targetBranch);
|
|
426
933
|
if (!targetWorktree) {
|
|
427
|
-
return {
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
934
|
+
return yield* Effect.fail(new GitError({
|
|
935
|
+
command: useRebase ? 'git rebase' : 'git merge',
|
|
936
|
+
exitCode: 1,
|
|
937
|
+
stderr: 'Target branch worktree not found',
|
|
938
|
+
}));
|
|
431
939
|
}
|
|
432
940
|
// Perform the merge or rebase in the target worktree
|
|
433
941
|
if (useRebase) {
|
|
434
942
|
// For rebase, we need to checkout source branch and rebase it onto target
|
|
435
943
|
const sourceWorktree = worktrees.find(wt => wt.branch && wt.branch.replace('refs/heads/', '') === sourceBranch);
|
|
436
944
|
if (!sourceWorktree) {
|
|
437
|
-
return {
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
945
|
+
return yield* Effect.fail(new GitError({
|
|
946
|
+
command: 'git rebase',
|
|
947
|
+
exitCode: 1,
|
|
948
|
+
stderr: 'Source branch worktree not found',
|
|
949
|
+
}));
|
|
441
950
|
}
|
|
442
951
|
// Rebase source branch onto target branch
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
952
|
+
yield* Effect.try({
|
|
953
|
+
try: () => {
|
|
954
|
+
execSync(`git rebase "${targetBranch}"`, {
|
|
955
|
+
cwd: sourceWorktree.path,
|
|
956
|
+
encoding: 'utf8',
|
|
957
|
+
});
|
|
958
|
+
},
|
|
959
|
+
catch: (error) => {
|
|
960
|
+
const execError = error;
|
|
961
|
+
return new GitError({
|
|
962
|
+
command: `git rebase "${targetBranch}"`,
|
|
963
|
+
exitCode: execError.status || 1,
|
|
964
|
+
stderr: execError.stderr || String(error),
|
|
965
|
+
stdout: execError.stdout,
|
|
966
|
+
});
|
|
967
|
+
},
|
|
446
968
|
});
|
|
447
969
|
// After rebase, merge the rebased source branch into target branch
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
970
|
+
yield* Effect.try({
|
|
971
|
+
try: () => {
|
|
972
|
+
execSync(`git merge --ff-only "${sourceBranch}"`, {
|
|
973
|
+
cwd: targetWorktree.path,
|
|
974
|
+
encoding: 'utf8',
|
|
975
|
+
});
|
|
976
|
+
},
|
|
977
|
+
catch: (error) => {
|
|
978
|
+
const execError = error;
|
|
979
|
+
return new GitError({
|
|
980
|
+
command: `git merge --ff-only "${sourceBranch}"`,
|
|
981
|
+
exitCode: execError.status || 1,
|
|
982
|
+
stderr: execError.stderr || String(error),
|
|
983
|
+
stdout: execError.stdout,
|
|
984
|
+
});
|
|
985
|
+
},
|
|
451
986
|
});
|
|
452
987
|
}
|
|
453
988
|
else {
|
|
454
989
|
// Regular merge
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
deleteWorktreeByBranch(branch) {
|
|
474
|
-
try {
|
|
475
|
-
// Get worktrees to find the worktree by branch
|
|
476
|
-
const worktrees = this.getWorktrees();
|
|
477
|
-
const worktree = worktrees.find(wt => wt.branch && wt.branch.replace('refs/heads/', '') === branch);
|
|
478
|
-
if (!worktree) {
|
|
479
|
-
return {
|
|
480
|
-
success: false,
|
|
481
|
-
error: 'Worktree not found for branch',
|
|
482
|
-
};
|
|
483
|
-
}
|
|
484
|
-
return this.deleteWorktree(worktree.path);
|
|
485
|
-
}
|
|
486
|
-
catch (error) {
|
|
487
|
-
return {
|
|
488
|
-
success: false,
|
|
489
|
-
error: error instanceof Error
|
|
490
|
-
? error.message
|
|
491
|
-
: 'Failed to delete worktree by branch',
|
|
492
|
-
};
|
|
493
|
-
}
|
|
494
|
-
}
|
|
495
|
-
copyClaudeSessionData(sourceWorktreePath, targetWorktreePath) {
|
|
496
|
-
try {
|
|
497
|
-
const projectsDir = getClaudeProjectsDir();
|
|
498
|
-
if (!existsSync(projectsDir)) {
|
|
499
|
-
throw new Error(`Claude projects directory does not exist: ${projectsDir}`);
|
|
500
|
-
}
|
|
501
|
-
// Convert paths to Claude's naming convention
|
|
502
|
-
const sourceProjectName = pathToClaudeProjectName(sourceWorktreePath);
|
|
503
|
-
const targetProjectName = pathToClaudeProjectName(targetWorktreePath);
|
|
504
|
-
const sourceProjectDir = path.join(projectsDir, sourceProjectName);
|
|
505
|
-
const targetProjectDir = path.join(projectsDir, targetProjectName);
|
|
506
|
-
// Only copy if source project exists
|
|
507
|
-
if (existsSync(sourceProjectDir)) {
|
|
508
|
-
cpSync(sourceProjectDir, targetProjectDir, {
|
|
509
|
-
recursive: true,
|
|
510
|
-
force: true,
|
|
511
|
-
errorOnExist: false,
|
|
512
|
-
preserveTimestamps: true,
|
|
990
|
+
yield* Effect.try({
|
|
991
|
+
try: () => {
|
|
992
|
+
execSync(`git merge --no-ff "${sourceBranch}"`, {
|
|
993
|
+
cwd: targetWorktree.path,
|
|
994
|
+
encoding: 'utf8',
|
|
995
|
+
});
|
|
996
|
+
},
|
|
997
|
+
catch: (error) => {
|
|
998
|
+
const execError = error;
|
|
999
|
+
return new GitError({
|
|
1000
|
+
command: `git merge --no-ff "${sourceBranch}"`,
|
|
1001
|
+
exitCode: execError.status || 1,
|
|
1002
|
+
stderr: execError.stderr || String(error),
|
|
1003
|
+
stdout: execError.stdout,
|
|
1004
|
+
});
|
|
1005
|
+
},
|
|
513
1006
|
});
|
|
514
1007
|
}
|
|
515
|
-
}
|
|
516
|
-
catch (error) {
|
|
517
|
-
console.error(`Failed to copy Claude session data: ${error}`);
|
|
518
|
-
throw new Error(`Failed to copy Claude session data: ${error}`);
|
|
519
|
-
}
|
|
520
|
-
}
|
|
521
|
-
hasClaudeDirectoryInBranch(branchName) {
|
|
522
|
-
// Find the worktree directory for the branch
|
|
523
|
-
const worktrees = this.getWorktrees();
|
|
524
|
-
let targetWorktree = worktrees.find(wt => wt.branch && wt.branch.replace('refs/heads/', '') === branchName);
|
|
525
|
-
// If branch worktree not found, try the default branch
|
|
526
|
-
if (!targetWorktree) {
|
|
527
|
-
const defaultBranch = this.getDefaultBranch();
|
|
528
|
-
if (branchName === defaultBranch) {
|
|
529
|
-
targetWorktree = worktrees.find(wt => wt.branch && wt.branch.replace('refs/heads/', '') === defaultBranch);
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
// If still not found and it's the default branch, try the main worktree
|
|
533
|
-
if (!targetWorktree && branchName === this.getDefaultBranch()) {
|
|
534
|
-
targetWorktree = worktrees.find(wt => wt.isMainWorktree);
|
|
535
|
-
}
|
|
536
|
-
if (!targetWorktree) {
|
|
537
|
-
return false;
|
|
538
|
-
}
|
|
539
|
-
// Check if .claude directory exists in the worktree
|
|
540
|
-
const claudePath = path.join(targetWorktree.path, CLAUDE_DIR);
|
|
541
|
-
return existsSync(claudePath) && statSync(claudePath).isDirectory();
|
|
542
|
-
}
|
|
543
|
-
copyClaudeDirectoryFromBaseBranch(worktreePath, baseBranch) {
|
|
544
|
-
// Find the worktree directory for the base branch
|
|
545
|
-
const worktrees = this.getWorktrees();
|
|
546
|
-
let baseWorktree = worktrees.find(wt => wt.branch && wt.branch.replace('refs/heads/', '') === baseBranch);
|
|
547
|
-
// If base branch worktree not found, try the default branch
|
|
548
|
-
if (!baseWorktree) {
|
|
549
|
-
const defaultBranch = this.getDefaultBranch();
|
|
550
|
-
baseWorktree = worktrees.find(wt => wt.branch && wt.branch.replace('refs/heads/', '') === defaultBranch);
|
|
551
|
-
}
|
|
552
|
-
// If still not found, try the main worktree
|
|
553
|
-
if (!baseWorktree) {
|
|
554
|
-
baseWorktree = worktrees.find(wt => wt.isMainWorktree);
|
|
555
|
-
}
|
|
556
|
-
if (!baseWorktree) {
|
|
557
|
-
throw new Error('Could not find base worktree to copy settings from');
|
|
558
|
-
}
|
|
559
|
-
// Check if .claude directory exists in base worktree
|
|
560
|
-
const sourceClaudeDir = path.join(baseWorktree.path, CLAUDE_DIR);
|
|
561
|
-
if (!existsSync(sourceClaudeDir) ||
|
|
562
|
-
!statSync(sourceClaudeDir).isDirectory()) {
|
|
563
|
-
// No .claude directory to copy, this is fine
|
|
564
|
-
return;
|
|
565
|
-
}
|
|
566
|
-
// Copy .claude directory to new worktree
|
|
567
|
-
const targetClaudeDir = path.join(worktreePath, CLAUDE_DIR);
|
|
568
|
-
cpSync(sourceClaudeDir, targetClaudeDir, { recursive: true });
|
|
1008
|
+
});
|
|
569
1009
|
}
|
|
570
1010
|
}
|