ccmanager 1.1.1 → 1.2.0

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.
@@ -139,11 +139,11 @@ const App = () => {
139
139
  }
140
140
  }, 50); // Small delay to ensure proper cleanup
141
141
  };
142
- const handleCreateWorktree = async (path, branch, baseBranch) => {
142
+ const handleCreateWorktree = async (path, branch, baseBranch, copyClaudeDirectory) => {
143
143
  setView('creating-worktree');
144
144
  setError(null);
145
145
  // Create the worktree
146
- const result = worktreeService.createWorktree(path, branch, baseBranch);
146
+ const result = worktreeService.createWorktree(path, branch, baseBranch, copyClaudeDirectory);
147
147
  if (result.success) {
148
148
  // Success - return to menu
149
149
  handleReturnToMenu();
@@ -1,6 +1,6 @@
1
1
  import React from 'react';
2
2
  interface NewWorktreeProps {
3
- onComplete: (path: string, branch: string, baseBranch: string) => void;
3
+ onComplete: (path: string, branch: string, baseBranch: string, copyClaudeDirectory: boolean) => void;
4
4
  onCancel: () => void;
5
5
  }
6
6
  declare const NewWorktree: React.FC<NewWorktreeProps>;
@@ -13,6 +13,7 @@ const NewWorktree = ({ onComplete, onCancel }) => {
13
13
  const [step, setStep] = useState(isAutoDirectory ? 'branch' : 'path');
14
14
  const [path, setPath] = useState('');
15
15
  const [branch, setBranch] = useState('');
16
+ const [baseBranch, setBaseBranch] = useState('');
16
17
  // Initialize worktree service and load branches (memoized to avoid re-initialization)
17
18
  const { branches, defaultBranch } = useMemo(() => {
18
19
  const service = new WorktreeService();
@@ -48,13 +49,31 @@ const NewWorktree = ({ onComplete, onCancel }) => {
48
49
  }
49
50
  };
50
51
  const handleBaseBranchSelect = (item) => {
52
+ setBaseBranch(item.value);
53
+ // Check if .claude directory exists in the base branch
54
+ const service = new WorktreeService();
55
+ if (service.hasClaudeDirectoryInBranch(item.value)) {
56
+ setStep('copy-settings');
57
+ }
58
+ else {
59
+ // Skip copy-settings step and complete with copySettings = false
60
+ if (isAutoDirectory) {
61
+ const autoPath = generateWorktreeDirectory(branch, worktreeConfig.autoDirectoryPattern);
62
+ onComplete(autoPath, branch, item.value, false);
63
+ }
64
+ else {
65
+ onComplete(path, branch, item.value, false);
66
+ }
67
+ }
68
+ };
69
+ const handleCopySettingsSelect = (item) => {
51
70
  if (isAutoDirectory) {
52
71
  // Generate path from branch name
53
72
  const autoPath = generateWorktreeDirectory(branch, worktreeConfig.autoDirectoryPattern);
54
- onComplete(autoPath, branch, item.value);
73
+ onComplete(autoPath, branch, baseBranch, item.value);
55
74
  }
56
75
  else {
57
- onComplete(path, branch, item.value);
76
+ onComplete(path, branch, baseBranch, item.value);
58
77
  }
59
78
  };
60
79
  // Calculate generated path for preview (memoized to avoid expensive recalculations)
@@ -97,6 +116,19 @@ const NewWorktree = ({ onComplete, onCancel }) => {
97
116
  React.createElement(Text, { color: "cyan" }, branch),
98
117
  ":")),
99
118
  React.createElement(SelectInput, { items: branchItems, onSelect: handleBaseBranchSelect, initialIndex: 0, limit: 10 }))),
119
+ step === 'copy-settings' && (React.createElement(Box, { flexDirection: "column" },
120
+ React.createElement(Box, { marginBottom: 1 },
121
+ React.createElement(Text, null,
122
+ "Copy .claude directory from base branch (",
123
+ React.createElement(Text, { color: "cyan" }, baseBranch),
124
+ ")?")),
125
+ React.createElement(SelectInput, { items: [
126
+ {
127
+ label: 'Yes - Copy .claude directory from base branch',
128
+ value: true,
129
+ },
130
+ { label: 'No - Start without .claude directory', value: false },
131
+ ], onSelect: handleCopySettingsSelect, initialIndex: 0 }))),
100
132
  React.createElement(Box, { marginTop: 1 },
101
133
  React.createElement(Text, { dimColor: true },
102
134
  "Press ",
@@ -9,7 +9,7 @@ export declare class WorktreeService {
9
9
  isGitRepository(): boolean;
10
10
  getDefaultBranch(): string;
11
11
  getAllBranches(): string[];
12
- createWorktree(worktreePath: string, branch: string, baseBranch: string): {
12
+ createWorktree(worktreePath: string, branch: string, baseBranch: string, copyClaudeDirectory?: boolean): {
13
13
  success: boolean;
14
14
  error?: string;
15
15
  };
@@ -27,4 +27,6 @@ export declare class WorktreeService {
27
27
  success: boolean;
28
28
  error?: string;
29
29
  };
30
+ hasClaudeDirectoryInBranch(branchName: string): boolean;
31
+ private copyClaudeDirectoryFromBaseBranch;
30
32
  }
@@ -1,7 +1,8 @@
1
1
  import { execSync } from 'child_process';
2
- import { existsSync } from 'fs';
2
+ import { existsSync, statSync, cpSync } from 'fs';
3
3
  import path from 'path';
4
4
  import { setWorktreeParentBranch } from '../utils/worktreeConfig.js';
5
+ const CLAUDE_DIR = '.claude';
5
6
  export class WorktreeService {
6
7
  constructor(rootPath) {
7
8
  Object.defineProperty(this, "rootPath", {
@@ -171,7 +172,7 @@ export class WorktreeService {
171
172
  return [];
172
173
  }
173
174
  }
174
- createWorktree(worktreePath, branch, baseBranch) {
175
+ createWorktree(worktreePath, branch, baseBranch, copyClaudeDirectory = false) {
175
176
  try {
176
177
  // Resolve the worktree path relative to the git repository root
177
178
  const resolvedPath = path.isAbsolute(worktreePath)
@@ -209,6 +210,15 @@ export class WorktreeService {
209
210
  catch (error) {
210
211
  console.error('Warning: Failed to set parent branch in worktree config:', error);
211
212
  }
213
+ // Copy .claude directory if requested
214
+ if (copyClaudeDirectory) {
215
+ try {
216
+ this.copyClaudeDirectoryFromBaseBranch(resolvedPath, baseBranch);
217
+ }
218
+ catch (error) {
219
+ console.error('Warning: Failed to copy .claude directory:', error);
220
+ }
221
+ }
212
222
  return { success: true };
213
223
  }
214
224
  catch (error) {
@@ -338,4 +348,53 @@ export class WorktreeService {
338
348
  };
339
349
  }
340
350
  }
351
+ hasClaudeDirectoryInBranch(branchName) {
352
+ // Find the worktree directory for the branch
353
+ const worktrees = this.getWorktrees();
354
+ let targetWorktree = worktrees.find(wt => wt.branch && wt.branch.replace('refs/heads/', '') === branchName);
355
+ // If branch worktree not found, try the default branch
356
+ if (!targetWorktree) {
357
+ const defaultBranch = this.getDefaultBranch();
358
+ if (branchName === defaultBranch) {
359
+ targetWorktree = worktrees.find(wt => wt.branch && wt.branch.replace('refs/heads/', '') === defaultBranch);
360
+ }
361
+ }
362
+ // If still not found and it's the default branch, try the main worktree
363
+ if (!targetWorktree && branchName === this.getDefaultBranch()) {
364
+ targetWorktree = worktrees.find(wt => wt.isMainWorktree);
365
+ }
366
+ if (!targetWorktree) {
367
+ return false;
368
+ }
369
+ // Check if .claude directory exists in the worktree
370
+ const claudePath = path.join(targetWorktree.path, CLAUDE_DIR);
371
+ return existsSync(claudePath) && statSync(claudePath).isDirectory();
372
+ }
373
+ copyClaudeDirectoryFromBaseBranch(worktreePath, baseBranch) {
374
+ // Find the worktree directory for the base branch
375
+ const worktrees = this.getWorktrees();
376
+ let baseWorktree = worktrees.find(wt => wt.branch && wt.branch.replace('refs/heads/', '') === baseBranch);
377
+ // If base branch worktree not found, try the default branch
378
+ if (!baseWorktree) {
379
+ const defaultBranch = this.getDefaultBranch();
380
+ baseWorktree = worktrees.find(wt => wt.branch && wt.branch.replace('refs/heads/', '') === defaultBranch);
381
+ }
382
+ // If still not found, try the main worktree
383
+ if (!baseWorktree) {
384
+ baseWorktree = worktrees.find(wt => wt.isMainWorktree);
385
+ }
386
+ if (!baseWorktree) {
387
+ throw new Error('Could not find base worktree to copy settings from');
388
+ }
389
+ // Check if .claude directory exists in base worktree
390
+ const sourceClaudeDir = path.join(baseWorktree.path, CLAUDE_DIR);
391
+ if (!existsSync(sourceClaudeDir) ||
392
+ !statSync(sourceClaudeDir).isDirectory()) {
393
+ // No .claude directory to copy, this is fine
394
+ return;
395
+ }
396
+ // Copy .claude directory to new worktree
397
+ const targetClaudeDir = path.join(worktreePath, CLAUDE_DIR);
398
+ cpSync(sourceClaudeDir, targetClaudeDir, { recursive: true });
399
+ }
341
400
  }
@@ -1,8 +1,11 @@
1
1
  import { describe, it, expect, beforeEach, vi } from 'vitest';
2
2
  import { WorktreeService } from './worktreeService.js';
3
3
  import { execSync } from 'child_process';
4
+ import { existsSync, statSync } from 'fs';
4
5
  // Mock child_process module
5
6
  vi.mock('child_process');
7
+ // Mock fs module
8
+ vi.mock('fs');
6
9
  // Mock worktreeConfigManager
7
10
  vi.mock('./worktreeConfigManager.js', () => ({
8
11
  worktreeConfigManager: {
@@ -13,6 +16,8 @@ vi.mock('./worktreeConfigManager.js', () => ({
13
16
  }));
14
17
  // Get the mocked function with proper typing
15
18
  const mockedExecSync = vi.mocked(execSync);
19
+ const mockedExistsSync = vi.mocked(existsSync);
20
+ const mockedStatSync = vi.mocked(statSync);
16
21
  describe('WorktreeService', () => {
17
22
  let service;
18
23
  beforeEach(() => {
@@ -152,4 +157,164 @@ origin/feature/test
152
157
  expect(execSync).toHaveBeenCalledWith('git worktree add -b "new-feature" "/path/to/worktree" "main"', expect.any(Object));
153
158
  });
154
159
  });
160
+ describe('hasClaudeDirectoryInBranch', () => {
161
+ it('should return true when .claude directory exists in branch worktree', () => {
162
+ mockedExecSync.mockImplementation((cmd, _options) => {
163
+ if (typeof cmd === 'string') {
164
+ if (cmd === 'git rev-parse --git-common-dir') {
165
+ return '/fake/path/.git\n';
166
+ }
167
+ if (cmd === 'git worktree list --porcelain') {
168
+ return `worktree /fake/path
169
+ HEAD abcd1234
170
+ branch refs/heads/main
171
+
172
+ worktree /fake/path/feature-branch
173
+ HEAD efgh5678
174
+ branch refs/heads/feature-branch
175
+ `;
176
+ }
177
+ }
178
+ throw new Error('Command not mocked: ' + cmd);
179
+ });
180
+ mockedExistsSync.mockImplementation(path => {
181
+ return path === '/fake/path/feature-branch/.claude';
182
+ });
183
+ mockedStatSync.mockImplementation(() => ({
184
+ isDirectory: () => true,
185
+ }));
186
+ const result = service.hasClaudeDirectoryInBranch('feature-branch');
187
+ expect(result).toBe(true);
188
+ expect(existsSync).toHaveBeenCalledWith('/fake/path/feature-branch/.claude');
189
+ expect(statSync).toHaveBeenCalledWith('/fake/path/feature-branch/.claude');
190
+ });
191
+ it('should return false when .claude directory does not exist', () => {
192
+ mockedExecSync.mockImplementation((cmd, _options) => {
193
+ if (typeof cmd === 'string') {
194
+ if (cmd === 'git rev-parse --git-common-dir') {
195
+ return '/fake/path/.git\n';
196
+ }
197
+ if (cmd === 'git worktree list --porcelain') {
198
+ return `worktree /fake/path
199
+ HEAD abcd1234
200
+ branch refs/heads/main
201
+
202
+ worktree /fake/path/feature-branch
203
+ HEAD efgh5678
204
+ branch refs/heads/feature-branch
205
+ `;
206
+ }
207
+ }
208
+ throw new Error('Command not mocked: ' + cmd);
209
+ });
210
+ mockedExistsSync.mockReturnValue(false);
211
+ const result = service.hasClaudeDirectoryInBranch('feature-branch');
212
+ expect(result).toBe(false);
213
+ expect(existsSync).toHaveBeenCalledWith('/fake/path/feature-branch/.claude');
214
+ });
215
+ it('should return false when .claude exists but is not a directory', () => {
216
+ mockedExecSync.mockImplementation((cmd, _options) => {
217
+ if (typeof cmd === 'string') {
218
+ if (cmd === 'git rev-parse --git-common-dir') {
219
+ return '/fake/path/.git\n';
220
+ }
221
+ if (cmd === 'git worktree list --porcelain') {
222
+ return `worktree /fake/path
223
+ HEAD abcd1234
224
+ branch refs/heads/main
225
+
226
+ worktree /fake/path/feature-branch
227
+ HEAD efgh5678
228
+ branch refs/heads/feature-branch
229
+ `;
230
+ }
231
+ }
232
+ throw new Error('Command not mocked: ' + cmd);
233
+ });
234
+ mockedExistsSync.mockReturnValue(true);
235
+ mockedStatSync.mockImplementation(() => ({
236
+ isDirectory: () => false,
237
+ }));
238
+ const result = service.hasClaudeDirectoryInBranch('feature-branch');
239
+ expect(result).toBe(false);
240
+ });
241
+ it('should fallback to default branch when branch worktree not found', () => {
242
+ mockedExecSync.mockImplementation((cmd, _options) => {
243
+ if (typeof cmd === 'string') {
244
+ if (cmd === 'git rev-parse --git-common-dir') {
245
+ return '/fake/path/.git\n';
246
+ }
247
+ if (cmd === 'git worktree list --porcelain') {
248
+ return `worktree /fake/path
249
+ HEAD abcd1234
250
+ branch refs/heads/main
251
+ `;
252
+ }
253
+ if (cmd.includes('symbolic-ref')) {
254
+ return 'main\n';
255
+ }
256
+ }
257
+ throw new Error('Command not mocked: ' + cmd);
258
+ });
259
+ mockedExistsSync.mockReturnValue(true);
260
+ mockedStatSync.mockImplementation(() => ({
261
+ isDirectory: () => true,
262
+ }));
263
+ // When asking for main branch that doesn't have a separate worktree
264
+ const result = service.hasClaudeDirectoryInBranch('main');
265
+ expect(result).toBe(true);
266
+ expect(existsSync).toHaveBeenCalledWith('/fake/path/.claude');
267
+ });
268
+ it('should return false when branch not found in any worktree', () => {
269
+ mockedExecSync.mockImplementation((cmd, _options) => {
270
+ if (typeof cmd === 'string') {
271
+ if (cmd === 'git rev-parse --git-common-dir') {
272
+ return '/fake/path/.git\n';
273
+ }
274
+ if (cmd === 'git worktree list --porcelain') {
275
+ return `worktree /fake/path
276
+ HEAD abcd1234
277
+ branch refs/heads/main
278
+ `;
279
+ }
280
+ if (cmd.includes('symbolic-ref')) {
281
+ return 'main\n';
282
+ }
283
+ }
284
+ throw new Error('Command not mocked: ' + cmd);
285
+ });
286
+ const result = service.hasClaudeDirectoryInBranch('non-existent-branch');
287
+ expect(result).toBe(false);
288
+ });
289
+ it('should check main worktree when branch is default branch', () => {
290
+ mockedExecSync.mockImplementation((cmd, _options) => {
291
+ if (typeof cmd === 'string') {
292
+ if (cmd === 'git rev-parse --git-common-dir') {
293
+ return '/fake/path/.git\n';
294
+ }
295
+ if (cmd === 'git worktree list --porcelain') {
296
+ return `worktree /fake/path
297
+ HEAD abcd1234
298
+ branch refs/heads/main
299
+
300
+ worktree /fake/path/other-branch
301
+ HEAD efgh5678
302
+ branch refs/heads/other-branch
303
+ `;
304
+ }
305
+ if (cmd.includes('symbolic-ref')) {
306
+ return 'main\n';
307
+ }
308
+ }
309
+ throw new Error('Command not mocked: ' + cmd);
310
+ });
311
+ mockedExistsSync.mockReturnValue(true);
312
+ mockedStatSync.mockImplementation(() => ({
313
+ isDirectory: () => true,
314
+ }));
315
+ const result = service.hasClaudeDirectoryInBranch('main');
316
+ expect(result).toBe(true);
317
+ expect(existsSync).toHaveBeenCalledWith('/fake/path/.claude');
318
+ });
319
+ });
155
320
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccmanager",
3
- "version": "1.1.1",
3
+ "version": "1.2.0",
4
4
  "description": "TUI application for managing multiple Claude Code sessions across Git worktrees",
5
5
  "license": "MIT",
6
6
  "author": "Kodai Kabasawa",