ccmanager 4.1.14 → 4.1.17

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.
@@ -37,9 +37,8 @@ const ConfigureMerge = ({ onComplete }) => {
37
37
  onComplete();
38
38
  return;
39
39
  }
40
- const field = item.value;
41
- setEditField(field);
42
- switch (field) {
40
+ setEditField(item.value);
41
+ switch (item.value) {
43
42
  case 'mergeArgs':
44
43
  setInputValue(getMergeArgs().join(' '));
45
44
  break;
@@ -442,7 +442,12 @@ const Dashboard = ({ projectsDir, onSelectSession, onSelectProject, error, onDis
442
442
  });
443
443
  }
444
444
  };
445
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, flexDirection: "column", children: _jsxs(Text, { bold: true, color: "green", children: ["CCManager - Dashboard v", version] }) }), loading ? (_jsx(Box, { children: _jsx(Text, { color: "yellow", children: "Discovering projects..." }) })) : projects.length === 0 && !displayError ? (_jsx(Box, { children: _jsxs(Text, { color: "yellow", children: ["No git repositories found in ", projectsDir] }) })) : (_jsx(SearchableList, { isSearchMode: isSearchMode, searchQuery: searchQuery, onSearchQueryChange: setSearchQuery, selectedIndex: selectedIndex, items: items, limit: limit, placeholder: "Type to filter...", noMatchMessage: "No matches found", children: _jsx(SelectInput, { items: items, onSelect: item => handleSelect(item), isFocused: !displayError, limit: limit, initialIndex: selectedIndex }) })), displayError && (_jsx(Box, { marginTop: 1, paddingX: 1, borderStyle: "round", borderColor: "red", children: _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "red", bold: true, children: ["Error: ", displayError] }), _jsx(Text, { color: "gray", dimColor: true, children: "Press any key to dismiss" })] }) })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["Status: ", STATUS_ICONS.BUSY, " ", STATUS_LABELS.BUSY, ' ', STATUS_ICONS.WAITING, " ", STATUS_LABELS.WAITING, " ", STATUS_ICONS.IDLE, ' ', STATUS_LABELS.IDLE] }), _jsx(Text, { dimColor: true, children: isSearchMode
445
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, flexDirection: "column", children: _jsxs(Text, { bold: true, color: "green", children: ["CCManager - Dashboard v", version] }) }), loading ? (_jsx(Box, { children: _jsx(Text, { color: "yellow", children: "Discovering projects..." }) })) : projects.length === 0 && !displayError ? (_jsx(Box, { children: _jsxs(Text, { color: "yellow", children: ["No git repositories found in ", projectsDir] }) })) : (_jsx(SearchableList, { isSearchMode: isSearchMode, searchQuery: searchQuery, onSearchQueryChange: setSearchQuery, selectedIndex: selectedIndex, items: items, limit: limit, placeholder: "Type to filter...", noMatchMessage: "No matches found", children: _jsx(SelectInput, { items: items, onSelect: raw => {
446
+ const item = items.find(i => i.value === raw?.value);
447
+ if (!item)
448
+ return;
449
+ handleSelect(item);
450
+ }, isFocused: !displayError, limit: limit, initialIndex: selectedIndex }) })), displayError && (_jsx(Box, { marginTop: 1, paddingX: 1, borderStyle: "round", borderColor: "red", children: _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "red", bold: true, children: ["Error: ", displayError] }), _jsx(Text, { color: "gray", dimColor: true, children: "Press any key to dismiss" })] }) })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["Status: ", STATUS_ICONS.BUSY, " ", STATUS_LABELS.BUSY, ' ', STATUS_ICONS.WAITING, " ", STATUS_LABELS.WAITING, " ", STATUS_ICONS.IDLE, ' ', STATUS_LABELS.IDLE] }), _jsx(Text, { dimColor: true, children: isSearchMode
446
451
  ? 'Search Mode: Type to filter, Enter to exit search, ESC to exit search'
447
452
  : searchQuery
448
453
  ? `Filtered: "${searchQuery}" | ↑↓ Navigate Enter Select | /-Search ESC-Clear 0-9 Quick Select R-Refresh Q-Quit`
@@ -430,16 +430,18 @@ const Menu = ({ sessionManager, worktreeService, onMenuAction, onSelectRecentPro
430
430
  });
431
431
  }
432
432
  };
433
- return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { marginBottom: 1, flexDirection: "column", children: [_jsxs(Text, { bold: true, color: "green", children: ["CCManager - Claude Code Worktree Manager v", version] }), projectName && (_jsx(Text, { bold: true, color: "green", children: projectName }))] }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { dimColor: true, children: "Select a worktree to start or resume a Claude Code session:" }) }), _jsx(SearchableList, { isSearchMode: isSearchMode, searchQuery: searchQuery, onSearchQueryChange: setSearchQuery, selectedIndex: selectedIndex, items: items, limit: limit, placeholder: "Type to filter worktrees...", noMatchMessage: "No worktrees match your search", children: _jsx(SelectInput, { items: items, onSelect: item => handleSelect(item), onHighlight: item => {
434
- // ink-select-input may call onHighlight with undefined when items are empty
435
- // (e.g., during menu re-mount after returning from a session), so guard it.
436
- if (!item) {
433
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { marginBottom: 1, flexDirection: "column", children: [_jsxs(Text, { bold: true, color: "green", children: ["CCManager - Claude Code Worktree Manager v", version] }), projectName && (_jsx(Text, { bold: true, color: "green", children: projectName }))] }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { dimColor: true, children: "Select a worktree to start or resume a Claude Code session:" }) }), _jsx(SearchableList, { isSearchMode: isSearchMode, searchQuery: searchQuery, onSearchQueryChange: setSearchQuery, selectedIndex: selectedIndex, items: items, limit: limit, placeholder: "Type to filter worktrees...", noMatchMessage: "No worktrees match your search", children: _jsx(SelectInput, { items: items, onSelect: raw => {
434
+ const item = items.find(i => i.value === raw?.value);
435
+ if (!item)
437
436
  return;
438
- }
439
- const menuItem = item;
440
- if (menuItem.type === 'worktree') {
441
- setHighlightedWorktreePath(menuItem.worktree.path);
442
- setHighlightedSession(menuItem.session);
437
+ handleSelect(item);
438
+ }, onHighlight: raw => {
439
+ const item = items.find(i => i.value === raw?.value);
440
+ if (!item)
441
+ return;
442
+ if (item.type === 'worktree') {
443
+ setHighlightedWorktreePath(item.worktree.path);
444
+ setHighlightedSession(item.session);
443
445
  }
444
446
  }, isFocused: !error, initialIndex: selectedIndex, limit: limit }) }), (error || loadError) && (_jsx(Box, { marginTop: 1, paddingX: 1, borderStyle: "round", borderColor: "red", children: _jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { color: "red", bold: true, children: ["Error: ", error || loadError] }), _jsx(Text, { color: "gray", dimColor: true, children: "Press any key to dismiss" })] }) })), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsxs(Text, { dimColor: true, children: ["Status: ", STATUS_ICONS.BUSY, " ", STATUS_LABELS.BUSY, ' ', STATUS_ICONS.WAITING, " ", STATUS_LABELS.WAITING, " ", STATUS_ICONS.IDLE, ' ', STATUS_LABELS.IDLE, configReader.isAutoApprovalEnabled() && (_jsxs(_Fragment, { children: [' | ', _jsx(Text, { color: "green", children: "Auto Approval Enabled" })] }))] }), _jsx(Text, { dimColor: true, children: isSearchMode
445
447
  ? 'Search Mode: Type to filter, Enter to exit search, ESC to exit search'
@@ -37,18 +37,11 @@ const PresetSelector = ({ onSelect, onCancel, }) => {
37
37
  };
38
38
  // Find initial index based on default preset
39
39
  const initialIndex = selectItems.findIndex(item => item.value === defaultPresetId);
40
- useInput((input, key) => {
40
+ // ink-select-input v6+ handles number keys 1-9 natively, so only handle ESC here
41
+ // to avoid double-firing onSelect (which would create two sessions for one worktree).
42
+ useInput((_input, key) => {
41
43
  if (key.escape) {
42
44
  onCancel();
43
- return;
44
- }
45
- // Number keys 1-9: immediate launch
46
- if (/^[1-9]$/.test(input)) {
47
- const idx = parseInt(input) - 1;
48
- if (idx < presets.length && presets[idx]) {
49
- onSelect(presets[idx].id);
50
- }
51
- return;
52
45
  }
53
46
  });
54
47
  return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "green", children: "Select Command Preset" }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { dimColor: true, children: "Choose a preset to start the session with" }) }), _jsx(SelectInput, { items: selectItems, onSelect: handleSelectItem, initialIndex: initialIndex >= 0 ? initialIndex : 0 }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "\u2191\u2193 Navigate 1-9 Quick Select Enter Select ESC Cancel" }) })] }));
@@ -86,26 +86,15 @@ describe('PresetSelector component', () => {
86
86
  expect(output).toContain('(default)');
87
87
  expect(output).toContain('← Cancel');
88
88
  });
89
- it('pressing 1 calls onSelect with first preset id immediately', () => {
89
+ // Number key selection is handled by ink-select-input v6+ natively.
90
+ // PresetSelector's useInput only handles ESC to avoid double-firing onSelect
91
+ // (which would create two sessions for one worktree).
92
+ it('pressing number keys via useInput does NOT trigger onSelect', () => {
90
93
  render(_jsx(PresetSelector, { onSelect: onSelect, onCancel: onCancel }));
91
94
  expect(capturedHandlers.inputHandler).not.toBeNull();
92
95
  capturedHandlers.inputHandler('1', makeKey());
93
- expect(onSelect).toHaveBeenCalledWith('preset-1');
94
- expect(onCancel).not.toHaveBeenCalled();
95
- });
96
- it('pressing 2 calls onSelect with second preset id immediately', () => {
97
- render(_jsx(PresetSelector, { onSelect: onSelect, onCancel: onCancel }));
98
96
  capturedHandlers.inputHandler('2', makeKey());
99
- expect(onSelect).toHaveBeenCalledWith('preset-2');
100
- });
101
- it('pressing 3 calls onSelect with third preset id immediately', () => {
102
- render(_jsx(PresetSelector, { onSelect: onSelect, onCancel: onCancel }));
103
97
  capturedHandlers.inputHandler('3', makeKey());
104
- expect(onSelect).toHaveBeenCalledWith('preset-3');
105
- });
106
- it('pressing a number beyond preset count does nothing', () => {
107
- render(_jsx(PresetSelector, { onSelect: onSelect, onCancel: onCancel }));
108
- capturedHandlers.inputHandler('9', makeKey());
109
98
  expect(onSelect).not.toHaveBeenCalled();
110
99
  expect(onCancel).not.toHaveBeenCalled();
111
100
  });
@@ -566,7 +566,7 @@ export class AutoApprovalVerifier {
566
566
  : await this.runClaudePrompt(prompt, jsonSchema, signal);
567
567
  return JSON.parse(responseText);
568
568
  },
569
- catch: (error) => error,
569
+ catch: (error) => error instanceof Error ? error : new Error(String(error)),
570
570
  });
571
571
  return Effect.catchAll(attemptVerification, (error) => {
572
572
  if (error.name === 'AbortError') {
@@ -183,7 +183,10 @@ export class ProjectManager {
183
183
  }
184
184
  catch (error) {
185
185
  // Silently skip directories we can't read
186
- if (error.code !== 'EACCES') {
186
+ if (!(typeof error === 'object' &&
187
+ error !== null &&
188
+ 'code' in error &&
189
+ error.code === 'EACCES')) {
187
190
  console.error(`Error scanning directory ${dir}:`, error);
188
191
  }
189
192
  }
@@ -267,7 +270,7 @@ export class ProjectManager {
267
270
  }
268
271
  }
269
272
  catch (error) {
270
- result.error = `Failed to process: ${error.message}`;
273
+ result.error = `Failed to process: ${error instanceof Error ? error.message : String(error)}`;
271
274
  }
272
275
  return result;
273
276
  }
@@ -363,8 +366,11 @@ export class ProjectManager {
363
366
  if (error instanceof FileSystemError) {
364
367
  return error;
365
368
  }
366
- const nodeError = error;
367
- const cause = nodeError.code === 'ENOENT'
369
+ const isEnoent = typeof error === 'object' &&
370
+ error !== null &&
371
+ 'code' in error &&
372
+ error.code === 'ENOENT';
373
+ const cause = isEnoent
368
374
  ? `Projects directory does not exist: ${projectsDir}`
369
375
  : String(error);
370
376
  return new FileSystemError({
@@ -144,7 +144,7 @@ export class SessionManager extends EventEmitter {
144
144
  })
145
145
  .catch(async (error) => {
146
146
  if (abortController.signal.aborted) {
147
- logger.debug(`[${session.id}] Auto-approval verification aborted (${error?.message ?? 'aborted'})`);
147
+ logger.debug(`[${session.id}] Auto-approval verification aborted (${error instanceof Error ? error.message : 'aborted'})`);
148
148
  return;
149
149
  }
150
150
  // On failure, fall back to requiring explicit permission
@@ -153,8 +153,9 @@ export class SessionManager extends EventEmitter {
153
153
  if (currentState === 'pending_auto_approval') {
154
154
  await this.updateSessionState(session, 'waiting_input', {
155
155
  autoApprovalFailed: true,
156
- autoApprovalReason: error?.message ??
157
- 'Auto-approval verification failed',
156
+ autoApprovalReason: error instanceof Error
157
+ ? error.message
158
+ : 'Auto-approval verification failed',
158
159
  });
159
160
  }
160
161
  })
@@ -775,10 +775,14 @@ export class WorktreeService {
775
775
  copySessionData,
776
776
  copyClaudeDirectory,
777
777
  });
778
- // Check if branch exists
779
- const branchExists = yield* Effect.catchAll(Effect.try({
778
+ // Check if a LOCAL branch exists (refs/heads/ only).
779
+ // Using `git rev-parse --verify` without qualifying the ref would also
780
+ // match remote-tracking refs (e.g. refs/remotes/origin/<branch>), which
781
+ // causes `git worktree add` to create the worktree in detached-HEAD
782
+ // state instead of on a proper local branch.
783
+ const localBranchExists = yield* Effect.catchAll(Effect.try({
780
784
  try: () => {
781
- execSync(`git rev-parse --verify ${branch}`, {
785
+ execSync(`git show-ref --verify --quiet refs/heads/${branch}`, {
782
786
  cwd: self.rootPath,
783
787
  encoding: 'utf8',
784
788
  });
@@ -798,15 +802,23 @@ export class WorktreeService {
798
802
  worktreeHooksConfig.pre_creation?.command) {
799
803
  yield* executeWorktreePreCreationHook(worktreeHooksConfig.pre_creation.command, resolvedPath, branch, absoluteGitRoot, baseBranch);
800
804
  }
801
- // Create the worktree command
805
+ // Create the worktree command.
806
+ // Three cases:
807
+ // 1. Local branch exists → attach worktree directly
808
+ // 2. No local branch, but remote-tracking branch exists → create
809
+ // local branch from the remote ref (avoids detached HEAD)
810
+ // 3. Branch is brand-new → create from baseBranch
802
811
  let command;
803
- if (branchExists) {
812
+ if (localBranchExists) {
804
813
  command = `git worktree add "${resolvedPath}" "${branch}"`;
805
814
  }
806
815
  else {
807
- // Resolve the base branch to its proper git reference
808
- const resolvedBaseBranch = self.resolveBranchReference(baseBranch);
809
- command = `git worktree add -b "${branch}" "${resolvedPath}" "${resolvedBaseBranch}"`;
816
+ const resolvedRef = self.resolveBranchReference(branch);
817
+ const isRemoteBranch = resolvedRef !== branch;
818
+ const startPoint = isRemoteBranch
819
+ ? resolvedRef
820
+ : self.resolveBranchReference(baseBranch);
821
+ command = `git worktree add -b "${branch}" "${resolvedPath}" "${startPoint}"`;
810
822
  }
811
823
  // Execute the worktree creation command
812
824
  yield* Effect.try({
@@ -631,9 +631,15 @@ branch refs/heads/feature
631
631
  if (cmd === 'git rev-parse --git-common-dir') {
632
632
  return '/fake/path/.git\n';
633
633
  }
634
- if (cmd.includes('rev-parse --verify')) {
634
+ if (cmd.includes('show-ref --verify --quiet refs/heads/')) {
635
635
  throw new Error('Branch not found');
636
636
  }
637
+ if (cmd === 'git remote') {
638
+ return 'origin\n';
639
+ }
640
+ if (cmd.includes('show-ref --verify --quiet refs/remotes/')) {
641
+ throw new Error('Remote branch not found');
642
+ }
637
643
  if (cmd.includes('git worktree add')) {
638
644
  return '';
639
645
  }
@@ -648,15 +654,62 @@ branch refs/heads/feature
648
654
  isMainWorktree: false,
649
655
  });
650
656
  });
657
+ it('should create local branch from remote ref when only remote branch exists', async () => {
658
+ const executedCommands = [];
659
+ mockedExecSync.mockImplementation((cmd, _options) => {
660
+ if (typeof cmd === 'string') {
661
+ executedCommands.push(cmd);
662
+ if (cmd === 'git rev-parse --git-common-dir') {
663
+ return '/fake/path/.git\n';
664
+ }
665
+ // No local branch
666
+ if (cmd.includes('show-ref --verify --quiet refs/heads/')) {
667
+ throw new Error('Branch not found');
668
+ }
669
+ if (cmd === 'git remote') {
670
+ return 'origin\n';
671
+ }
672
+ // Remote branch exists
673
+ if (cmd ===
674
+ 'git show-ref --verify --quiet refs/remotes/origin/feature/remote-only') {
675
+ return '';
676
+ }
677
+ if (cmd.includes('show-ref --verify --quiet refs/remotes/')) {
678
+ throw new Error('Remote branch not found');
679
+ }
680
+ if (cmd.includes('git worktree add')) {
681
+ return '';
682
+ }
683
+ }
684
+ return '';
685
+ });
686
+ const effect = service.createWorktreeEffect('/path/to/worktree', 'feature/remote-only', 'main');
687
+ const result = await Effect.runPromise(effect);
688
+ expect(result.worktree).toMatchObject({
689
+ path: '/path/to/worktree',
690
+ branch: 'feature/remote-only',
691
+ isMainWorktree: false,
692
+ });
693
+ // Should use -b with the remote ref as start point, not baseBranch
694
+ const worktreeAddCmd = executedCommands.find(c => c.includes('git worktree add'));
695
+ expect(worktreeAddCmd).toContain('-b "feature/remote-only"');
696
+ expect(worktreeAddCmd).toContain('"origin/feature/remote-only"');
697
+ });
651
698
  it('should return Effect that fails with GitError on git command failure', async () => {
652
699
  mockedExecSync.mockImplementation((cmd, _options) => {
653
700
  if (typeof cmd === 'string') {
654
701
  if (cmd === 'git rev-parse --git-common-dir') {
655
702
  return '/fake/path/.git\n';
656
703
  }
657
- if (cmd.includes('rev-parse --verify')) {
704
+ if (cmd.includes('show-ref --verify --quiet refs/heads/')) {
658
705
  throw new Error('Branch not found');
659
706
  }
707
+ if (cmd === 'git remote') {
708
+ return 'origin\n';
709
+ }
710
+ if (cmd.includes('show-ref --verify --quiet refs/remotes/')) {
711
+ throw new Error('Remote branch not found');
712
+ }
660
713
  if (cmd.includes('git worktree add')) {
661
714
  const error = new Error('fatal: invalid reference: main');
662
715
  error.status = 128;
@@ -696,9 +749,15 @@ branch refs/heads/feature
696
749
  if (cmd === 'git rev-parse --git-common-dir') {
697
750
  return '/fake/path/.git\n';
698
751
  }
699
- if (cmd.includes('rev-parse --verify')) {
752
+ if (cmd.includes('show-ref --verify --quiet refs/heads/')) {
700
753
  throw new Error('Branch not found');
701
754
  }
755
+ if (cmd === 'git remote') {
756
+ return 'origin\n';
757
+ }
758
+ if (cmd.includes('show-ref --verify --quiet refs/remotes/')) {
759
+ throw new Error('Remote branch not found');
760
+ }
702
761
  if (cmd.includes('git worktree add')) {
703
762
  throw new Error('git worktree add should not be called');
704
763
  }
@@ -732,9 +791,15 @@ branch refs/heads/feature
732
791
  if (cmd === 'git rev-parse --git-common-dir') {
733
792
  return '/fake/path/.git\n';
734
793
  }
735
- if (cmd.includes('rev-parse --verify')) {
794
+ if (cmd.includes('show-ref --verify --quiet refs/heads/')) {
736
795
  throw new Error('Branch not found');
737
796
  }
797
+ if (cmd === 'git remote') {
798
+ return 'origin\n';
799
+ }
800
+ if (cmd.includes('show-ref --verify --quiet refs/remotes/')) {
801
+ throw new Error('Remote branch not found');
802
+ }
738
803
  if (cmd.includes('git worktree add')) {
739
804
  return '';
740
805
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccmanager",
3
- "version": "4.1.14",
3
+ "version": "4.1.17",
4
4
  "description": "TUI application for managing multiple Claude Code sessions across Git worktrees",
5
5
  "license": "MIT",
6
6
  "author": "Kodai Kabasawa",
@@ -41,11 +41,11 @@
41
41
  "bin"
42
42
  ],
43
43
  "optionalDependencies": {
44
- "@kodaikabasawa/ccmanager-darwin-arm64": "4.1.14",
45
- "@kodaikabasawa/ccmanager-darwin-x64": "4.1.14",
46
- "@kodaikabasawa/ccmanager-linux-arm64": "4.1.14",
47
- "@kodaikabasawa/ccmanager-linux-x64": "4.1.14",
48
- "@kodaikabasawa/ccmanager-win32-x64": "4.1.14"
44
+ "@kodaikabasawa/ccmanager-darwin-arm64": "4.1.17",
45
+ "@kodaikabasawa/ccmanager-darwin-x64": "4.1.17",
46
+ "@kodaikabasawa/ccmanager-linux-arm64": "4.1.17",
47
+ "@kodaikabasawa/ccmanager-linux-x64": "4.1.17",
48
+ "@kodaikabasawa/ccmanager-win32-x64": "4.1.17"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@eslint/js": "^9.28.0",