ccmanager 4.3.1 → 4.3.2

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/README.md CHANGED
@@ -15,6 +15,7 @@ https://github.com/user-attachments/assets/15914a88-e288-4ac9-94d5-8127f2e19dbf
15
15
  - Visual status indicators for session states (busy, waiting, idle)
16
16
  - Create, merge, and delete worktrees from within the app
17
17
  - **Copy Claude Code session data** between worktrees to maintain conversation context
18
+ - **`.worktreeinclude` support**: carry gitignored project files (`.env`, local certs) into newly created worktrees
18
19
  - Configurable keyboard shortcuts
19
20
  - Command presets with automatic fallback support
20
21
  - Configurable state detection strategies for different CLI tools
@@ -209,6 +210,17 @@ The default choice (copy or start fresh) will be pre-selected when creating new
209
210
  - **Context Preservation**: Maintain long conversations across multiple development branches
210
211
 
211
212
 
213
+ ## Copying Gitignored Files into New Worktrees
214
+
215
+ A new Git worktree contains only tracked files, so gitignored files a project needs in order to run — `.env`, local certificates, generated local config — do not come along. If the repository root has a `.worktreeinclude` file (gitignore syntax) listing those files, CCManager copies them into every worktree it creates.
216
+
217
+ - **Shared convention**: same file name and semantics as Claude Code, Conductor, OpenAI Codex, and `git-worktreeinclude`, so an existing `.worktreeinclude` works with CCManager unchanged
218
+ - **No configuration**: the copy runs whenever a `.worktreeinclude` file exists
219
+ - **Safe by construction**: a file is copied only if it both matches a pattern and is actually gitignored, so tracked files are never duplicated
220
+ - **Hook-friendly**: files are copied before the post-creation worktree hook runs, so hook commands can rely on them
221
+
222
+ For pattern syntax, the exact selection rule, and troubleshooting, see [docs/worktree-include.md](docs/worktree-include.md).
223
+
212
224
  ## Status Change Hooks
213
225
 
214
226
  CCManager can execute custom commands when Claude Code session status changes. This enables powerful automation workflows like desktop notifications, logging, or integration with other tools.
@@ -7,6 +7,7 @@ import Dashboard from './Dashboard.js';
7
7
  import Session from './Session.js';
8
8
  import NewWorktree from './NewWorktree.js';
9
9
  import DeleteWorktree from './DeleteWorktree.js';
10
+ import DeleteConfirmation from './DeleteConfirmation.js';
10
11
  import MergeWorktree from './MergeWorktree.js';
11
12
  import Configuration from './Configuration.js';
12
13
  import PresetSelector from './PresetSelector.js';
@@ -22,7 +23,7 @@ import { configReader } from '../services/config/configReader.js';
22
23
  import { ENV_VARS } from '../constants/env.js';
23
24
  import { MULTI_PROJECT_ERRORS } from '../constants/error.js';
24
25
  import { projectManager } from '../services/projectManager.js';
25
- import { generateWorktreeDirectory } from '../utils/worktreeUtils.js';
26
+ import { generateWorktreeDirectory, isDeletableWorktree, } from '../utils/worktreeUtils.js';
26
27
  const App = ({ devcontainerConfig, multiProject, version, }) => {
27
28
  const { exit } = useApp();
28
29
  const [view, setView] = useState(multiProject ? 'project-list' : 'menu');
@@ -36,6 +37,8 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
36
37
  const [selectedWorktree, setSelectedWorktree] = useState(null); // Store selected worktree for preset selection
37
38
  const [renameTarget, setRenameTarget] = useState(null);
38
39
  const [sessionActionsTarget, setSessionActionsTarget] = useState(null);
40
+ // Worktree awaiting confirmation of the per-row delete action
41
+ const [worktreeToDelete, setWorktreeToDelete] = useState(null);
39
42
  const [selectedProject, setSelectedProject] = useState(null); // Store selected project in multi-project mode
40
43
  const [configScope, setConfigScope] = useState('global'); // Store config scope for configuration view
41
44
  const [pendingMenuSessionLaunch, setPendingMenuSessionLaunch] = useState(null);
@@ -303,8 +306,9 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
303
306
  return;
304
307
  case 'sessionActions':
305
308
  setSessionActionsTarget({
309
+ worktreePath: action.worktree.path,
306
310
  session: action.session,
307
- worktreePath: action.worktreePath,
311
+ worktree: action.worktree,
308
312
  });
309
313
  navigateWithClear('session-actions');
310
314
  return;
@@ -520,7 +524,7 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
520
524
  setPendingWorktreeCreation(null);
521
525
  setView('new-worktree');
522
526
  };
523
- const handleDeleteWorktrees = async (worktreePaths, deleteBranch) => {
527
+ const handleDeleteWorktrees = async (worktreePaths, deleteBranch, options) => {
524
528
  // Set loading context before showing loading view
525
529
  setLoadingContext({ deleteBranch });
526
530
  setView('deleting-worktree');
@@ -558,7 +562,12 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
558
562
  }
559
563
  else {
560
564
  // Show error
561
- setView('delete-worktree');
565
+ if (options?.onError) {
566
+ options.onError();
567
+ }
568
+ else {
569
+ setView('delete-worktree');
570
+ }
562
571
  }
563
572
  };
564
573
  const handleCancelDeleteWorktree = () => {
@@ -670,10 +679,14 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
670
679
  } }));
671
680
  }
672
681
  if (view === 'session-actions' && sessionActionsTarget) {
673
- const { session: targetSession, worktreePath } = sessionActionsTarget;
674
- const label = targetSession.sessionName
675
- ? targetSession.sessionName
676
- : `Session #${targetSession.sessionNumber}`;
682
+ const { session: targetSession, worktreePath, worktree: targetWorktree, } = sessionActionsTarget;
683
+ // A worktree row without a session has no session name to show; the
684
+ // worktree path is rendered on its own line by SessionActions.
685
+ const label = !targetSession
686
+ ? undefined
687
+ : targetSession.sessionName
688
+ ? targetSession.sessionName
689
+ : `Session #${targetSession.sessionNumber}`;
677
690
  const handleSessionAction = async (action) => {
678
691
  setSessionActionsTarget(null);
679
692
  switch (action) {
@@ -686,6 +699,8 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
686
699
  }, { forceNew: true });
687
700
  return;
688
701
  case 'rename':
702
+ if (!targetSession)
703
+ return;
689
704
  setRenameTarget({
690
705
  id: targetSession.id,
691
706
  name: targetSession.sessionName,
@@ -693,16 +708,38 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
693
708
  navigateWithClear('rename-session');
694
709
  return;
695
710
  case 'kill':
711
+ if (!targetSession)
712
+ return;
696
713
  sessionManager.destroySession(targetSession.id);
697
714
  handleReturnToMenu();
698
715
  return;
716
+ case 'deleteWorktree':
717
+ if (!targetWorktree)
718
+ return;
719
+ setWorktreeToDelete(targetWorktree);
720
+ navigateWithClear('confirm-delete-worktree');
721
+ return;
699
722
  }
700
723
  };
701
- return (_jsx(SessionActions, { sessionLabel: label, worktreePath: worktreePath, onSelect: handleSessionAction, onCancel: () => {
724
+ return (_jsx(SessionActions, { sessionLabel: label, worktreePath: worktreePath, hasSession: !!targetSession, canDeleteWorktree: !!targetWorktree && isDeletableWorktree(targetWorktree), onSelect: handleSessionAction, onCancel: () => {
702
725
  setSessionActionsTarget(null);
703
726
  handleReturnToMenu();
704
727
  } }));
705
728
  }
729
+ if (view === 'confirm-delete-worktree' && worktreeToDelete) {
730
+ const target = worktreeToDelete;
731
+ return (_jsx(DeleteConfirmation, { worktrees: [target], onConfirm: deleteBranch => {
732
+ setWorktreeToDelete(null);
733
+ void handleDeleteWorktrees([target.path], deleteBranch, {
734
+ // The multi-select delete screen was never opened in this flow,
735
+ // so surface the failure on the menu instead.
736
+ onError: handleReturnToMenu,
737
+ });
738
+ }, onCancel: () => {
739
+ setWorktreeToDelete(null);
740
+ handleReturnToMenu();
741
+ } }));
742
+ }
706
743
  if (view === 'preset-selector') {
707
744
  return (_jsx(PresetSelector, { onSelect: handlePresetSelected, onCancel: handlePresetSelectorCancel }));
708
745
  }
@@ -1,6 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useState, useEffect } from 'react';
3
- import path from 'path';
4
3
  import { Box, Text, useInput } from 'ink';
5
4
  import SelectInput from 'ink-select-input';
6
5
  import { Effect } from 'effect';
@@ -11,6 +10,7 @@ import { useSearchMode } from '../hooks/useSearchMode.js';
11
10
  import { useDynamicLimit } from '../hooks/useDynamicLimit.js';
12
11
  import { filterWorktreesByQuery } from '../utils/filterByQuery.js';
13
12
  import SearchableList from './SearchableList.js';
13
+ import { isDeletableWorktree } from '../utils/worktreeUtils.js';
14
14
  const DeleteWorktree = ({ projectPath, onComplete, onCancel, }) => {
15
15
  const [worktrees, setWorktrees] = useState([]);
16
16
  const [selectedIndices, setSelectedIndices] = useState(new Set());
@@ -34,18 +34,7 @@ const DeleteWorktree = ({ projectPath, onComplete, onCancel, }) => {
34
34
  try {
35
35
  const allWorktrees = await Effect.runPromise(worktreeService.getWorktreesEffect());
36
36
  if (!cancelled) {
37
- // Filter out main worktree and current working directory worktree
38
- const resolvedCwd = path.resolve(process.cwd());
39
- const deletableWorktrees = allWorktrees.filter(wt => {
40
- if (wt.isMainWorktree)
41
- return false;
42
- const resolvedPath = path.resolve(wt.path);
43
- if (resolvedCwd === resolvedPath ||
44
- resolvedCwd.startsWith(resolvedPath + path.sep)) {
45
- return false;
46
- }
47
- return true;
48
- });
37
+ const deletableWorktrees = allWorktrees.filter(wt => isDeletableWorktree(wt));
49
38
  setWorktrees(deletableWorktrees);
50
39
  setIsLoading(false);
51
40
  }
@@ -41,11 +41,12 @@ const Menu = ({ sessionManager, worktreeService, initialSnapshot, onSnapshotChan
41
41
  const worktrees = useGitStatus(baseWorktrees, defaultBranch);
42
42
  // Seed from the in-memory session list so the cached snapshot renders with its
43
43
  // sessions attached. Waiting for the async git load would leave every row
44
- // session-less for that window, disabling the Space session-actions shortcut.
44
+ // session-less for that window, hiding the session entries of the Space
45
+ // actions menu.
45
46
  const [sessions, setSessions] = useState(() => sessionManager.getAllSessions());
46
47
  const [items, setItems] = useState([]);
47
48
  const [recentProjects, setRecentProjects] = useState([]);
48
- const [highlightedWorktreePath, setHighlightedWorktreePath] = useState(null);
49
+ const [highlightedWorktree, setHighlightedWorktree] = useState(null);
49
50
  const [highlightedSession, setHighlightedSession] = useState(undefined);
50
51
  const [autoApprovalToggleCounter, setAutoApprovalToggleCounter] = useState(0);
51
52
  const [stateFilter, setStateFilter] = useState('all');
@@ -288,16 +289,20 @@ const Menu = ({ sessionManager, worktreeService, initialSnapshot, onSnapshotChan
288
289
  }
289
290
  }
290
291
  setItems(menuItems);
291
- // Ensure highlighted worktree path is valid for hotkey support
292
- setHighlightedWorktreePath(prev => {
293
- if (prev &&
294
- menuItems.some(item => item.type === 'worktree' && item.worktree.path === prev)) {
295
- return prev;
292
+ // Ensure highlighted worktree is valid for hotkey support
293
+ setHighlightedWorktree(prev => {
294
+ const stillListed = prev
295
+ ? menuItems.find(item => item.type === 'worktree' && item.worktree.path === prev.path)
296
+ : undefined;
297
+ if (stillListed && stillListed.type === 'worktree') {
298
+ // Re-read the item so the highlighted worktree keeps up with
299
+ // refreshed git status instead of pinning the stale object.
300
+ return stillListed.worktree;
296
301
  }
297
302
  const first = menuItems.find(item => item.type === 'worktree');
298
303
  if (first && first.type === 'worktree') {
299
304
  setHighlightedSession(first.session);
300
- return first.worktree.path;
305
+ return first.worktree;
301
306
  }
302
307
  setHighlightedSession(undefined);
303
308
  return null;
@@ -371,18 +376,19 @@ const Menu = ({ sessionManager, worktreeService, initialSnapshot, onSnapshotChan
371
376
  switch (keyPressed) {
372
377
  case 'a':
373
378
  // Toggle auto-approval for the currently highlighted worktree
374
- if (configReader.isAutoApprovalEnabled() && highlightedWorktreePath) {
375
- sessionManager.toggleAutoApprovalForWorktree(highlightedWorktreePath);
379
+ if (configReader.isAutoApprovalEnabled() && highlightedWorktree) {
380
+ sessionManager.toggleAutoApprovalForWorktree(highlightedWorktree.path);
376
381
  setAutoApprovalToggleCounter(c => c + 1);
377
382
  }
378
383
  break;
379
384
  case ' ':
380
- // Open session actions for highlighted session
381
- if (highlightedSession && highlightedWorktreePath) {
385
+ // Open the row actions for the highlighted worktree. Rows without a
386
+ // session open the same menu with only the worktree-level entries.
387
+ if (highlightedWorktree) {
382
388
  onMenuAction({
383
389
  type: 'sessionActions',
390
+ worktree: highlightedWorktree,
384
391
  session: highlightedSession,
385
- worktreePath: highlightedWorktreePath,
386
392
  });
387
393
  }
388
394
  break;
@@ -477,13 +483,13 @@ const Menu = ({ sessionManager, worktreeService, initialSnapshot, onSnapshotChan
477
483
  if (!item)
478
484
  return;
479
485
  if (item.type === 'worktree') {
480
- setHighlightedWorktreePath(item.worktree.path);
486
+ setHighlightedWorktree(item.worktree);
481
487
  setHighlightedSession(item.session);
482
488
  }
483
489
  }, 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
484
490
  ? 'Search Mode: Type to filter, Enter to exit search, ESC to exit search'
485
491
  : searchQuery
486
- ? `Controls: ↑↓ Navigate Enter Select | /-Search ESC-Clear 0-9 Quick Select Tab-State Filter Space-Session actions (session rows only) N-New M-Merge D-Delete ${configReader.isAutoApprovalEnabled() ? 'A-AutoApproval ' : ''}${multiProject ? 'C-Config' : 'P-ProjConfig C-GlobalConfig'} ${projectName ? 'B-Back' : 'Q-Quit'}`
487
- : `Controls: ↑↓ Navigate Enter Select | Hotkeys: 0-9 Quick Select /-Search Tab-State Filter Space-Session actions (session rows only) N-New M-Merge D-Delete ${configReader.isAutoApprovalEnabled() ? 'A-AutoApproval ' : ''}${multiProject ? 'C-Config' : 'P-ProjConfig C-GlobalConfig'} ${projectName ? 'B-Back' : 'Q-Quit'}` })] })] }));
492
+ ? `Controls: ↑↓ Navigate Enter Select | /-Search ESC-Clear 0-9 Quick Select Tab-State Filter Space-Worktree actions N-New M-Merge D-Delete ${configReader.isAutoApprovalEnabled() ? 'A-AutoApproval ' : ''}${multiProject ? 'C-Config' : 'P-ProjConfig C-GlobalConfig'} ${projectName ? 'B-Back' : 'Q-Quit'}`
493
+ : `Controls: ↑↓ Navigate Enter Select | Hotkeys: 0-9 Quick Select /-Search Tab-State Filter Space-Worktree actions N-New M-Merge D-Delete ${configReader.isAutoApprovalEnabled() ? 'A-AutoApproval ' : ''}${multiProject ? 'C-Config' : 'P-ProjConfig C-GlobalConfig'} ${projectName ? 'B-Back' : 'Q-Quit'}` })] })] }));
488
494
  };
489
495
  export default Menu;
@@ -319,8 +319,44 @@ describe('Menu component Effect-based error handling', () => {
319
319
  }
320
320
  expect(onMenuAction).toHaveBeenCalledWith({
321
321
  type: 'sessionActions',
322
+ worktree: cachedWorktree,
322
323
  session: cachedSession,
323
- worktreePath: '/test/cached',
324
+ });
325
+ });
326
+ it('should open the actions menu with Space on a worktree row that has no session', async () => {
327
+ const { Effect } = await import('effect');
328
+ const sessionlessWorktree = {
329
+ path: '/test/no-session',
330
+ branch: 'feature/no-session',
331
+ isMainWorktree: false,
332
+ hasSession: false,
333
+ };
334
+ vi.spyOn(sessionManager, 'getAllSessions').mockReturnValue([]);
335
+ vi.spyOn(worktreeService, 'getWorktreesEffect').mockReturnValue(Effect.succeed([sessionlessWorktree]));
336
+ vi.spyOn(worktreeService, 'getDefaultBranchEffect').mockReturnValue(Effect.succeed('main'));
337
+ const onMenuAction = vi.fn();
338
+ vi.mocked(useInput).mockClear();
339
+ render(_jsx(Menu, { sessionManager: sessionManager, worktreeService: worktreeService, initialSnapshot: {
340
+ worktrees: [sessionlessWorktree],
341
+ defaultBranch: 'main',
342
+ }, onMenuAction: onMenuAction, version: "test" }));
343
+ await new Promise(resolve => setTimeout(resolve, 0));
344
+ // Menu's hotkey handler bails out when raw mode is unavailable.
345
+ const origSetRawMode = process.stdin.setRawMode;
346
+ process.stdin.setRawMode = vi.fn();
347
+ try {
348
+ const calls = vi.mocked(useInput).mock.calls;
349
+ const handler = calls[calls.length - 1]?.[0];
350
+ expect(handler).toBeDefined();
351
+ handler(' ', makeKey());
352
+ }
353
+ finally {
354
+ process.stdin.setRawMode = origSetRawMode;
355
+ }
356
+ expect(onMenuAction).toHaveBeenCalledWith({
357
+ type: 'sessionActions',
358
+ worktree: sessionlessWorktree,
359
+ session: undefined,
324
360
  });
325
361
  });
326
362
  });
@@ -1,8 +1,23 @@
1
1
  import React from 'react';
2
- export type SessionActionType = 'newSession' | 'rename' | 'kill';
2
+ export type SessionActionType = 'newSession' | 'rename' | 'kill' | 'deleteWorktree';
3
3
  interface SessionActionsProps {
4
- sessionLabel: string;
4
+ /**
5
+ * Name of the session this menu was opened from. Absent for a worktree row
6
+ * that has no session yet.
7
+ */
8
+ sessionLabel?: string;
5
9
  worktreePath: string;
10
+ /**
11
+ * Whether the row this menu was opened from has a running session. Session
12
+ * specific actions (rename, close) are hidden when it does not.
13
+ */
14
+ hasSession?: boolean;
15
+ /**
16
+ * Whether the worktree of this row may be deleted; see isDeletableWorktree.
17
+ * The delete entry is hidden rather than shown-and-rejected so no
18
+ * unselectable option appears.
19
+ */
20
+ canDeleteWorktree?: boolean;
6
21
  onSelect: (action: SessionActionType) => void;
7
22
  onCancel: () => void;
8
23
  }
@@ -1,29 +1,31 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text, useInput } from 'ink';
3
3
  import SelectInput from 'ink-select-input';
4
- const items = [
5
- { label: 'S New session in same directory', value: 'newSession' },
6
- { label: 'R Rename this session', value: 'rename' },
7
- { label: 'X Close session', value: 'kill' },
8
- ];
9
- const SessionActions = ({ sessionLabel, worktreePath, onSelect, onCancel, }) => {
4
+ const buildItems = (hasSession, canDeleteWorktree) => {
5
+ const items = [
6
+ { label: 'S New session in this worktree', value: 'newSession' },
7
+ ];
8
+ if (hasSession) {
9
+ items.push({ label: 'R Rename this session', value: 'rename' });
10
+ items.push({ label: 'X Close session', value: 'kill' });
11
+ }
12
+ if (canDeleteWorktree) {
13
+ items.push({ label: 'D Delete this worktree', value: 'deleteWorktree' });
14
+ }
15
+ return items;
16
+ };
17
+ const SessionActions = ({ sessionLabel, worktreePath, hasSession = true, canDeleteWorktree = false, onSelect, onCancel, }) => {
18
+ const items = buildItems(hasSession, canDeleteWorktree);
10
19
  useInput((input, key) => {
11
20
  if (key.escape) {
12
21
  onCancel();
13
22
  return;
14
23
  }
15
- switch (input.toLowerCase()) {
16
- case 's':
17
- onSelect('newSession');
18
- break;
19
- case 'r':
20
- onSelect('rename');
21
- break;
22
- case 'x':
23
- onSelect('kill');
24
- break;
24
+ const shortcut = items.find(item => item.label[0]?.toLowerCase() === input.toLowerCase());
25
+ if (shortcut) {
26
+ onSelect(shortcut.value);
25
27
  }
26
28
  });
27
- return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: "Session Actions" }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [_jsx(Text, { dimColor: true, children: sessionLabel }), _jsxs(Text, { dimColor: true, children: ["Directory: ", worktreePath] })] }), _jsx(Box, { marginTop: 1, children: _jsx(SelectInput, { items: items, onSelect: item => onSelect(item.value) }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "S/R/X or arrow keys + Enter | Escape to cancel" }) })] }));
29
+ return (_jsxs(Box, { flexDirection: "column", padding: 1, children: [_jsx(Text, { bold: true, color: "cyan", children: hasSession ? 'Session Actions' : 'Worktree Actions' }), _jsxs(Box, { marginTop: 1, flexDirection: "column", children: [sessionLabel && _jsx(Text, { dimColor: true, children: sessionLabel }), _jsxs(Text, { dimColor: true, children: ["Directory: ", worktreePath] })] }), _jsx(Box, { marginTop: 1, children: _jsx(SelectInput, { items: items, onSelect: item => onSelect(item.value) }) }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: [items.map(item => item.label[0]).join('/'), " or arrow keys + Enter | Escape to cancel"] }) })] }));
28
30
  };
29
31
  export default SessionActions;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,94 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { render } from 'ink-testing-library';
3
+ import { useInput } from 'ink';
4
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
5
+ import SessionActions from './SessionActions.js';
6
+ const makeKey = (overrides = {}) => ({
7
+ upArrow: false,
8
+ downArrow: false,
9
+ leftArrow: false,
10
+ rightArrow: false,
11
+ pageDown: false,
12
+ pageUp: false,
13
+ home: false,
14
+ end: false,
15
+ return: false,
16
+ escape: false,
17
+ ctrl: false,
18
+ shift: false,
19
+ tab: false,
20
+ backspace: false,
21
+ delete: false,
22
+ meta: false,
23
+ ...overrides,
24
+ });
25
+ // Mock ink to avoid stdin issues and to capture the hotkey handler
26
+ vi.mock('ink', async () => {
27
+ const actual = await vi.importActual('ink');
28
+ return {
29
+ ...actual,
30
+ useInput: vi.fn(),
31
+ };
32
+ });
33
+ // Mock SelectInput to render items as simple text
34
+ vi.mock('ink-select-input', async () => {
35
+ const React = await vi.importActual('react');
36
+ const { Text, Box } = await vi.importActual('ink');
37
+ return {
38
+ default: ({ items }) => React.createElement(Box, { flexDirection: 'column' }, items.map((item, index) => React.createElement(Text, { key: index }, item.label))),
39
+ };
40
+ });
41
+ const getLastInputHandler = () => {
42
+ const calls = vi.mocked(useInput).mock.calls;
43
+ const handler = calls[calls.length - 1]?.[0];
44
+ expect(handler).toBeDefined();
45
+ return handler;
46
+ };
47
+ describe('SessionActions', () => {
48
+ beforeEach(() => {
49
+ vi.mocked(useInput).mockClear();
50
+ });
51
+ it('should show session actions and the delete entry for a deletable worktree', () => {
52
+ const { lastFrame } = render(_jsx(SessionActions, { sessionLabel: "Session #1", worktreePath: "/repo/worktrees/feature", hasSession: true, canDeleteWorktree: true, onSelect: vi.fn(), onCancel: vi.fn() }));
53
+ const frame = lastFrame();
54
+ expect(frame).toContain('Session Actions');
55
+ expect(frame).toContain('New session in this worktree');
56
+ expect(frame).toContain('Rename this session');
57
+ expect(frame).toContain('Close session');
58
+ expect(frame).toContain('Delete this worktree');
59
+ });
60
+ it('should hide session-specific actions for a worktree without a session', () => {
61
+ const { lastFrame } = render(_jsx(SessionActions, { worktreePath: "/repo/worktrees/feature", hasSession: false, canDeleteWorktree: true, onSelect: vi.fn(), onCancel: vi.fn() }));
62
+ const frame = lastFrame();
63
+ expect(frame).toContain('Worktree Actions');
64
+ expect(frame).toContain('New session in this worktree');
65
+ expect(frame).toContain('Delete this worktree');
66
+ expect(frame).not.toContain('Rename this session');
67
+ expect(frame).not.toContain('Close session');
68
+ });
69
+ it('should hide the delete entry when the worktree cannot be deleted', () => {
70
+ const { lastFrame } = render(_jsx(SessionActions, { sessionLabel: "Session #1", worktreePath: "/repo", hasSession: true, canDeleteWorktree: false, onSelect: vi.fn(), onCancel: vi.fn() }));
71
+ expect(lastFrame()).not.toContain('Delete this worktree');
72
+ });
73
+ it('should dispatch deleteWorktree on the D hotkey when deletion is offered', () => {
74
+ const onSelect = vi.fn();
75
+ render(_jsx(SessionActions, { worktreePath: "/repo/worktrees/feature", hasSession: false, canDeleteWorktree: true, onSelect: onSelect, onCancel: vi.fn() }));
76
+ getLastInputHandler()('d', makeKey());
77
+ expect(onSelect).toHaveBeenCalledWith('deleteWorktree');
78
+ });
79
+ it('should ignore hotkeys of actions that are not offered', () => {
80
+ const onSelect = vi.fn();
81
+ render(_jsx(SessionActions, { sessionLabel: "Session #1", worktreePath: "/repo", hasSession: true, canDeleteWorktree: false, onSelect: onSelect, onCancel: vi.fn() }));
82
+ const handler = getLastInputHandler();
83
+ handler('d', makeKey());
84
+ expect(onSelect).not.toHaveBeenCalled();
85
+ handler('x', makeKey());
86
+ expect(onSelect).toHaveBeenCalledWith('kill');
87
+ });
88
+ it('should cancel on Escape', () => {
89
+ const onCancel = vi.fn();
90
+ render(_jsx(SessionActions, { sessionLabel: "Session #1", worktreePath: "/repo", hasSession: true, canDeleteWorktree: true, onSelect: vi.fn(), onCancel: onCancel }));
91
+ getLastInputHandler()('', makeKey({ escape: true }));
92
+ expect(onCancel).toHaveBeenCalled();
93
+ });
94
+ });
@@ -155,6 +155,18 @@ export declare class WorktreeService {
155
155
  * @throws {FileSystemError} When copying the directory fails
156
156
  */
157
157
  private copyClaudeDirectoryFromBaseBranchEffect;
158
+ /**
159
+ * Effect-based copyWorktreeIncludeFiles operation.
160
+ * Copies the gitignored files a `.worktreeinclude` file at the repository
161
+ * root selects (see src/utils/worktreeInclude.ts) into the new worktree.
162
+ * A no-op when no `.worktreeinclude` file exists, so this always runs
163
+ * unconditionally rather than being gated by a config flag.
164
+ *
165
+ * @param {string} gitRoot - Absolute path to the main checkout
166
+ * @param {string} targetWorktreePath - Path of the newly created worktree
167
+ * @returns {Effect.Effect<void, FileSystemError, never>} Effect that completes successfully or fails with FileSystemError
168
+ */
169
+ private copyWorktreeIncludeFilesEffect;
158
170
  /**
159
171
  * Effect-based getDefaultBranch operation
160
172
  * Returns Effect that may fail with GitError
@@ -7,6 +7,7 @@ import { GitError, FileSystemError } from '../types/errors.js';
7
7
  import { setWorktreeParentBranch } from '../utils/worktreeConfig.js';
8
8
  import { getClaudeProjectsDir, pathToClaudeProjectName, } from '../utils/claudeDir.js';
9
9
  import { executeWorktreePostCreationHook, executeWorktreePreCreationHook, } from '../utils/hookExecutor.js';
10
+ import { copyWorktreeIncludeFiles } from '../utils/worktreeInclude.js';
10
11
  import { configReader } from './config/configReader.js';
11
12
  import { logger } from '../utils/logger.js';
12
13
  const CLAUDE_DIR = '.claude';
@@ -448,6 +449,27 @@ export class WorktreeService {
448
449
  });
449
450
  });
450
451
  }
452
+ /**
453
+ * Effect-based copyWorktreeIncludeFiles operation.
454
+ * Copies the gitignored files a `.worktreeinclude` file at the repository
455
+ * root selects (see src/utils/worktreeInclude.ts) into the new worktree.
456
+ * A no-op when no `.worktreeinclude` file exists, so this always runs
457
+ * unconditionally rather than being gated by a config flag.
458
+ *
459
+ * @param {string} gitRoot - Absolute path to the main checkout
460
+ * @param {string} targetWorktreePath - Path of the newly created worktree
461
+ * @returns {Effect.Effect<void, FileSystemError, never>} Effect that completes successfully or fails with FileSystemError
462
+ */
463
+ copyWorktreeIncludeFilesEffect(gitRoot, targetWorktreePath) {
464
+ return Effect.try({
465
+ try: () => copyWorktreeIncludeFiles(gitRoot, targetWorktreePath),
466
+ catch: (error) => new FileSystemError({
467
+ operation: 'write',
468
+ path: targetWorktreePath,
469
+ cause: String(error),
470
+ }),
471
+ });
472
+ }
451
473
  /**
452
474
  * Effect-based getDefaultBranch operation
453
475
  * Returns Effect that may fail with GitError
@@ -967,6 +989,14 @@ export class WorktreeService {
967
989
  return Effect.succeed(undefined);
968
990
  });
969
991
  }
992
+ // Copy files selected by a .worktreeinclude file, if one exists at
993
+ // the repository root. Runs unconditionally (no config flag) and
994
+ // before the post-creation hook, so hook commands can rely on the
995
+ // copied files (e.g. .env) already being in place.
996
+ yield* Effect.catchAll(self.copyWorktreeIncludeFilesEffect(absoluteGitRoot, resolvedPath), (error) => {
997
+ console.error('Warning: Failed to copy .worktreeinclude files:', error);
998
+ return Effect.succeed(undefined);
999
+ });
970
1000
  // Execute post-creation hook if configured
971
1001
  const worktreeHooks = configReader.getWorktreeHooks();
972
1002
  logger.info('Worktree hook config after creation', {
@@ -1,7 +1,8 @@
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
+ import { existsSync, statSync, cpSync, mkdirSync } from 'fs';
5
+ import path from 'path';
5
6
  import { configReader } from './config/configReader.js';
6
7
  import { Effect } from 'effect';
7
8
  import { GitError, ProcessError } from '../types/errors.js';
@@ -33,11 +34,20 @@ vi.mock('../utils/hookExecutor.js', () => ({
33
34
  const mockedExecSync = vi.mocked(execSync);
34
35
  const mockedExistsSync = vi.mocked(existsSync);
35
36
  const mockedStatSync = vi.mocked(statSync);
37
+ const mockedCpSync = vi.mocked(cpSync);
38
+ const mockedMkdirSync = vi.mocked(mkdirSync);
36
39
  const mockedGetWorktreeHooks = vi.mocked(configReader.getWorktreeHooks);
37
40
  describe('WorktreeService', () => {
38
41
  let service;
39
42
  beforeEach(() => {
40
43
  vi.clearAllMocks();
44
+ // vi.clearAllMocks() clears call history but not custom implementations,
45
+ // so a test-specific existsSync/statSync mock (e.g. in
46
+ // hasClaudeDirectoryInBranchEffect below) would otherwise leak into
47
+ // every later test in this file. Reset them to the automock default
48
+ // (returns undefined) so each test starts from a clean slate.
49
+ mockedExistsSync.mockReset();
50
+ mockedStatSync.mockReset();
41
51
  // Mock git rev-parse --git-common-dir to return a predictable path
42
52
  mockedExecSync.mockImplementation((cmd, _options) => {
43
53
  if (typeof cmd === 'string' && cmd === 'git rev-parse --git-common-dir') {
@@ -837,6 +847,50 @@ branch refs/heads/feature
837
847
  isMainWorktree: false,
838
848
  });
839
849
  });
850
+ it('should copy .worktreeinclude files from the main checkout into the new worktree', async () => {
851
+ mockedExistsSync.mockImplementation(filePath => {
852
+ const target = String(filePath);
853
+ if (target.endsWith('.worktreeinclude'))
854
+ return true;
855
+ // The source .env lives in the main checkout (gitRoot); the same
856
+ // relative path under the new worktree must not exist yet.
857
+ if (target === path.join('/fake/path', '.env'))
858
+ return true;
859
+ return false;
860
+ });
861
+ mockedStatSync.mockImplementation(() => ({ isFile: () => true }));
862
+ mockedExecSync.mockImplementation((cmd, _options) => {
863
+ if (typeof cmd === 'string') {
864
+ if (cmd === 'git rev-parse --git-common-dir') {
865
+ return '/fake/path/.git\n';
866
+ }
867
+ if (cmd.includes('show-ref --verify --quiet refs/heads/')) {
868
+ throw new Error('Branch not found');
869
+ }
870
+ if (cmd === 'git remote') {
871
+ return 'origin\n';
872
+ }
873
+ if (cmd.includes('show-ref --verify --quiet refs/remotes/')) {
874
+ throw new Error('Remote branch not found');
875
+ }
876
+ if (cmd.includes('git worktree add')) {
877
+ return '';
878
+ }
879
+ if (cmd.includes('git ls-files --others --ignored')) {
880
+ return '.env\0';
881
+ }
882
+ if (cmd === 'git check-ignore --stdin -z') {
883
+ return '.env\0';
884
+ }
885
+ }
886
+ return '';
887
+ });
888
+ const effect = service.createWorktreeEffect('/path/to/worktree', 'new-feature', 'main');
889
+ const result = await Effect.runPromise(effect);
890
+ expect(result.worktree.path).toBe('/path/to/worktree');
891
+ expect(mockedMkdirSync).toHaveBeenCalledWith(path.dirname(path.join('/path/to/worktree', '.env')), { recursive: true });
892
+ expect(mockedCpSync).toHaveBeenCalledWith(path.join('/fake/path', '.env'), path.join('/path/to/worktree', '.env'), { recursive: true, preserveTimestamps: true });
893
+ });
840
894
  it('should create local branch from remote ref when only remote branch exists', async () => {
841
895
  const executedCommands = [];
842
896
  mockedExecSync.mockImplementation((cmd, _options) => {
@@ -75,8 +75,8 @@ export type MenuAction = {
75
75
  sessionId: string;
76
76
  } | {
77
77
  type: 'sessionActions';
78
- session: Session;
79
- worktreePath: string;
78
+ worktree: Worktree;
79
+ session?: Session;
80
80
  } | {
81
81
  type: 'deleteWorktree';
82
82
  } | {
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Filename for the convention shared across worktree-aware tools (Claude Code,
3
+ * Conductor, OpenAI Codex, git-worktreeinclude, worktrunk): a gitignore-syntax
4
+ * file at the repository root that lists gitignored files to carry into every
5
+ * new worktree.
6
+ */
7
+ export declare const WORKTREE_INCLUDE_FILENAME = ".worktreeinclude";
8
+ /**
9
+ * Resolves which files a `.worktreeinclude` file selects, relative to `gitRoot`.
10
+ *
11
+ * A file is selected only when both hold:
12
+ * - it matches a pattern in `.worktreeinclude` (gitignore syntax: comments,
13
+ * negation with `!`, anchoring with `/`, `**` globs)
14
+ * - Git already ignores it (nested `.gitignore` files, `.git/info/exclude`,
15
+ * and `core.excludesfile` all apply)
16
+ *
17
+ * This mirrors the safety rule every tool that supports `.worktreeinclude`
18
+ * documents: listing a pattern never makes a tracked file eligible, and it
19
+ * never makes an otherwise-untracked-but-not-ignored file eligible either.
20
+ *
21
+ * @param gitRoot - Absolute path to the main checkout (repository root)
22
+ * @returns Repository-relative paths (forward-slash separated, as Git reports them)
23
+ */
24
+ export declare function resolveWorktreeIncludeFiles(gitRoot: string): string[];
25
+ /**
26
+ * Copies the files a `.worktreeinclude` file selects from the main checkout
27
+ * into a freshly created worktree. No-ops when no `.worktreeinclude` file
28
+ * exists. Never overwrites a file that already exists at the destination.
29
+ *
30
+ * @param gitRoot - Absolute path to the main checkout (repository root)
31
+ * @param targetWorktreePath - Absolute path to the newly created worktree
32
+ */
33
+ export declare function copyWorktreeIncludeFiles(gitRoot: string, targetWorktreePath: string): void;
@@ -0,0 +1,100 @@
1
+ import { execSync } from 'child_process';
2
+ import { existsSync, statSync, mkdirSync, cpSync } from 'fs';
3
+ import path from 'path';
4
+ import { logger } from './logger.js';
5
+ /**
6
+ * Filename for the convention shared across worktree-aware tools (Claude Code,
7
+ * Conductor, OpenAI Codex, git-worktreeinclude, worktrunk): a gitignore-syntax
8
+ * file at the repository root that lists gitignored files to carry into every
9
+ * new worktree.
10
+ */
11
+ export const WORKTREE_INCLUDE_FILENAME = '.worktreeinclude';
12
+ /**
13
+ * Resolves which files a `.worktreeinclude` file selects, relative to `gitRoot`.
14
+ *
15
+ * A file is selected only when both hold:
16
+ * - it matches a pattern in `.worktreeinclude` (gitignore syntax: comments,
17
+ * negation with `!`, anchoring with `/`, `**` globs)
18
+ * - Git already ignores it (nested `.gitignore` files, `.git/info/exclude`,
19
+ * and `core.excludesfile` all apply)
20
+ *
21
+ * This mirrors the safety rule every tool that supports `.worktreeinclude`
22
+ * documents: listing a pattern never makes a tracked file eligible, and it
23
+ * never makes an otherwise-untracked-but-not-ignored file eligible either.
24
+ *
25
+ * @param gitRoot - Absolute path to the main checkout (repository root)
26
+ * @returns Repository-relative paths (forward-slash separated, as Git reports them)
27
+ */
28
+ export function resolveWorktreeIncludeFiles(gitRoot) {
29
+ const includeFilePath = path.join(gitRoot, WORKTREE_INCLUDE_FILENAME);
30
+ if (!existsSync(includeFilePath) || !statSync(includeFilePath).isFile()) {
31
+ return [];
32
+ }
33
+ let candidatesOutput;
34
+ try {
35
+ // --exclude-from applies ONLY .worktreeinclude's own patterns (no
36
+ // --exclude-standard), so this lists untracked files matching those
37
+ // patterns regardless of whether the repository's real .gitignore
38
+ // covers them.
39
+ candidatesOutput = execSync(`git ls-files --others --ignored --exclude-from="${WORKTREE_INCLUDE_FILENAME}" -z`, { cwd: gitRoot, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] });
40
+ }
41
+ catch (error) {
42
+ logger.error('Failed to resolve .worktreeinclude candidates', {
43
+ gitRoot,
44
+ error: String(error),
45
+ });
46
+ return [];
47
+ }
48
+ const candidates = candidatesOutput
49
+ .split('\0')
50
+ .filter(entry => entry.length > 0);
51
+ if (candidates.length === 0) {
52
+ return [];
53
+ }
54
+ // Confirm each candidate against the repository's real ignore rules.
55
+ // git check-ignore --stdin echoes back only the paths it is asked about
56
+ // that ARE ignored, so this is the second half of the intersection.
57
+ let ignoredOutput;
58
+ try {
59
+ ignoredOutput = execSync('git check-ignore --stdin -z', {
60
+ cwd: gitRoot,
61
+ encoding: 'utf8',
62
+ stdio: ['pipe', 'pipe', 'pipe'],
63
+ input: candidates.join('\0') + '\0',
64
+ });
65
+ }
66
+ catch (error) {
67
+ // Exit code 1 (no matches) surfaces as a thrown error; anything already
68
+ // written to stdout before that is still the correct partial result.
69
+ const execError = error;
70
+ ignoredOutput = execError.stdout ?? '';
71
+ }
72
+ return ignoredOutput.split('\0').filter(entry => entry.length > 0);
73
+ }
74
+ /**
75
+ * Copies the files a `.worktreeinclude` file selects from the main checkout
76
+ * into a freshly created worktree. No-ops when no `.worktreeinclude` file
77
+ * exists. Never overwrites a file that already exists at the destination.
78
+ *
79
+ * @param gitRoot - Absolute path to the main checkout (repository root)
80
+ * @param targetWorktreePath - Absolute path to the newly created worktree
81
+ */
82
+ export function copyWorktreeIncludeFiles(gitRoot, targetWorktreePath) {
83
+ const relativePaths = resolveWorktreeIncludeFiles(gitRoot);
84
+ for (const relativePath of relativePaths) {
85
+ const sourcePath = path.join(gitRoot, relativePath);
86
+ const targetPath = path.join(targetWorktreePath, relativePath);
87
+ if (!existsSync(sourcePath)) {
88
+ continue;
89
+ }
90
+ if (existsSync(targetPath)) {
91
+ logger.warn('Skipping .worktreeinclude copy, destination already exists', {
92
+ relativePath,
93
+ targetPath,
94
+ });
95
+ continue;
96
+ }
97
+ mkdirSync(path.dirname(targetPath), { recursive: true });
98
+ cpSync(sourcePath, targetPath, { recursive: true, preserveTimestamps: true });
99
+ }
100
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,91 @@
1
+ import { describe, it, expect, beforeEach, afterAll } from 'vitest';
2
+ import { execSync } from 'child_process';
3
+ import path from 'path';
4
+ import fs from 'fs';
5
+ import os from 'os';
6
+ import { resolveWorktreeIncludeFiles, copyWorktreeIncludeFiles, } from './worktreeInclude.js';
7
+ describe('worktreeInclude', () => {
8
+ const testDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'ccmanager-worktreeinclude-test-')));
9
+ let repoCount = 0;
10
+ let gitRoot;
11
+ beforeEach(() => {
12
+ repoCount += 1;
13
+ gitRoot = path.join(testDir, `repo-${repoCount}`);
14
+ fs.mkdirSync(gitRoot, { recursive: true });
15
+ execSync('git init', { cwd: gitRoot });
16
+ execSync('git config user.email "test@test.com"', { cwd: gitRoot });
17
+ execSync('git config user.name "Test User"', { cwd: gitRoot });
18
+ fs.writeFileSync(path.join(gitRoot, 'README.md'), '# repo');
19
+ execSync('git add README.md', { cwd: gitRoot });
20
+ execSync('git commit -m "initial commit"', { cwd: gitRoot });
21
+ });
22
+ afterAll(() => {
23
+ fs.rmSync(testDir, { recursive: true, force: true });
24
+ });
25
+ it('returns an empty list when no .worktreeinclude file exists', () => {
26
+ fs.writeFileSync(path.join(gitRoot, '.env'), 'SECRET=1');
27
+ expect(resolveWorktreeIncludeFiles(gitRoot)).toEqual([]);
28
+ });
29
+ it('selects a file that matches .worktreeinclude and is gitignored', () => {
30
+ fs.writeFileSync(path.join(gitRoot, '.gitignore'), '.env\n');
31
+ fs.writeFileSync(path.join(gitRoot, '.worktreeinclude'), '.env\n');
32
+ fs.writeFileSync(path.join(gitRoot, '.env'), 'SECRET=1');
33
+ expect(resolveWorktreeIncludeFiles(gitRoot)).toEqual(['.env']);
34
+ });
35
+ it('excludes a file that matches .worktreeinclude but is not gitignored', () => {
36
+ fs.writeFileSync(path.join(gitRoot, '.worktreeinclude'), 'notes.txt\n');
37
+ fs.writeFileSync(path.join(gitRoot, 'notes.txt'), 'not ignored');
38
+ expect(resolveWorktreeIncludeFiles(gitRoot)).toEqual([]);
39
+ });
40
+ it('excludes a tracked file even when it matches .worktreeinclude', () => {
41
+ fs.writeFileSync(path.join(gitRoot, '.gitignore'), 'tracked.env\n');
42
+ fs.writeFileSync(path.join(gitRoot, '.worktreeinclude'), 'tracked.env\n');
43
+ fs.writeFileSync(path.join(gitRoot, 'tracked.env'), 'SECRET=1');
44
+ execSync('git add -f tracked.env', { cwd: gitRoot });
45
+ execSync('git commit -m "track tracked.env"', { cwd: gitRoot });
46
+ expect(resolveWorktreeIncludeFiles(gitRoot)).toEqual([]);
47
+ });
48
+ it('resolves every file under a directory glob pattern', () => {
49
+ fs.writeFileSync(path.join(gitRoot, '.gitignore'), 'certs/\n');
50
+ fs.writeFileSync(path.join(gitRoot, '.worktreeinclude'), 'certs/local/**\n');
51
+ fs.mkdirSync(path.join(gitRoot, 'certs', 'local', 'nested'), {
52
+ recursive: true,
53
+ });
54
+ fs.writeFileSync(path.join(gitRoot, 'certs', 'local', 'cert.pem'), 'cert');
55
+ fs.writeFileSync(path.join(gitRoot, 'certs', 'local', 'nested', 'key.pem'), 'key');
56
+ expect(resolveWorktreeIncludeFiles(gitRoot).sort()).toEqual([
57
+ 'certs/local/cert.pem',
58
+ 'certs/local/nested/key.pem',
59
+ ]);
60
+ });
61
+ describe('copyWorktreeIncludeFiles', () => {
62
+ it('copies selected files into the target worktree, recreating nested directories', () => {
63
+ fs.writeFileSync(path.join(gitRoot, '.gitignore'), '.env\ncerts/\n');
64
+ fs.writeFileSync(path.join(gitRoot, '.worktreeinclude'), '.env\ncerts/local/**\n');
65
+ fs.writeFileSync(path.join(gitRoot, '.env'), 'SECRET=1');
66
+ fs.mkdirSync(path.join(gitRoot, 'certs', 'local'), { recursive: true });
67
+ fs.writeFileSync(path.join(gitRoot, 'certs', 'local', 'cert.pem'), 'cert');
68
+ const targetWorktreePath = path.join(testDir, `target-${repoCount}`);
69
+ fs.mkdirSync(targetWorktreePath, { recursive: true });
70
+ copyWorktreeIncludeFiles(gitRoot, targetWorktreePath);
71
+ expect(fs.readFileSync(path.join(targetWorktreePath, '.env'), 'utf8')).toBe('SECRET=1');
72
+ expect(fs.readFileSync(path.join(targetWorktreePath, 'certs', 'local', 'cert.pem'), 'utf8')).toBe('cert');
73
+ });
74
+ it('does not overwrite a file that already exists at the destination', () => {
75
+ fs.writeFileSync(path.join(gitRoot, '.gitignore'), '.env\n');
76
+ fs.writeFileSync(path.join(gitRoot, '.worktreeinclude'), '.env\n');
77
+ fs.writeFileSync(path.join(gitRoot, '.env'), 'SOURCE');
78
+ const targetWorktreePath = path.join(testDir, `target-${repoCount}`);
79
+ fs.mkdirSync(targetWorktreePath, { recursive: true });
80
+ fs.writeFileSync(path.join(targetWorktreePath, '.env'), 'EXISTING');
81
+ copyWorktreeIncludeFiles(gitRoot, targetWorktreePath);
82
+ expect(fs.readFileSync(path.join(targetWorktreePath, '.env'), 'utf8')).toBe('EXISTING');
83
+ });
84
+ it('is a no-op when no .worktreeinclude file exists', () => {
85
+ const targetWorktreePath = path.join(testDir, `target-${repoCount}`);
86
+ fs.mkdirSync(targetWorktreePath, { recursive: true });
87
+ expect(() => copyWorktreeIncludeFiles(gitRoot, targetWorktreePath)).not.toThrow();
88
+ expect(fs.readdirSync(targetWorktreePath)).toEqual([]);
89
+ });
90
+ });
91
+ });
@@ -78,3 +78,16 @@ export declare function calculateColumnPositions(items: SessionItem[]): {
78
78
  * Assembles the final worktree label with proper column alignment
79
79
  */
80
80
  export declare function assembleSessionLabel(item: SessionItem, columns: ReturnType<typeof calculateColumnPositions>): string;
81
+ /**
82
+ * Whether a worktree may be deleted by CCManager.
83
+ *
84
+ * Two worktrees are off limits:
85
+ * - the main worktree, because git refuses to remove it and the repository
86
+ * would be left without a checkout;
87
+ * - the worktree that contains the current working directory, because removing
88
+ * the directory CCManager is running in breaks the running process.
89
+ *
90
+ * Single source of truth for the rule, shared by the multi-select delete screen
91
+ * and the per-row delete action.
92
+ */
93
+ export declare function isDeletableWorktree(worktree: Pick<Worktree, 'path' | 'isMainWorktree'>, cwd?: string): boolean;
@@ -340,3 +340,26 @@ export function assembleSessionLabel(item, columns) {
340
340
  }
341
341
  return label;
342
342
  }
343
+ /**
344
+ * Whether a worktree may be deleted by CCManager.
345
+ *
346
+ * Two worktrees are off limits:
347
+ * - the main worktree, because git refuses to remove it and the repository
348
+ * would be left without a checkout;
349
+ * - the worktree that contains the current working directory, because removing
350
+ * the directory CCManager is running in breaks the running process.
351
+ *
352
+ * Single source of truth for the rule, shared by the multi-select delete screen
353
+ * and the per-row delete action.
354
+ */
355
+ export function isDeletableWorktree(worktree, cwd = process.cwd()) {
356
+ if (worktree.isMainWorktree)
357
+ return false;
358
+ const resolvedCwd = path.resolve(cwd);
359
+ const resolvedPath = path.resolve(worktree.path);
360
+ if (resolvedCwd === resolvedPath ||
361
+ resolvedCwd.startsWith(resolvedPath + path.sep)) {
362
+ return false;
363
+ }
364
+ return true;
365
+ }
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
- import { generateWorktreeDirectory, extractBranchParts, truncateString, prepareSessionItems, calculateColumnPositions, assembleSessionLabel, } from './worktreeUtils.js';
2
+ import { generateWorktreeDirectory, extractBranchParts, truncateString, prepareSessionItems, calculateColumnPositions, assembleSessionLabel, isDeletableWorktree, } from './worktreeUtils.js';
3
3
  import { execSync } from 'child_process';
4
4
  import { Mutex, createInitialSessionStateData } from './mutex.js';
5
5
  import { createStateDetector } from '../services/stateDetector/index.js';
@@ -325,3 +325,22 @@ describe('column alignment', () => {
325
325
  expect(plain.indexOf('+10 -5')).toBe(21); // Should start at column 21
326
326
  });
327
327
  });
328
+ describe('isDeletableWorktree', () => {
329
+ it('should reject the main worktree', () => {
330
+ expect(isDeletableWorktree({ path: '/repo', isMainWorktree: true }, '/somewhere/else')).toBe(false);
331
+ });
332
+ it('should reject the worktree holding the current working directory', () => {
333
+ expect(isDeletableWorktree({ path: '/repo/worktrees/feature', isMainWorktree: false }, '/repo/worktrees/feature')).toBe(false);
334
+ });
335
+ it('should reject a worktree that is an ancestor of the current working directory', () => {
336
+ expect(isDeletableWorktree({ path: '/repo/worktrees/feature', isMainWorktree: false }, '/repo/worktrees/feature/src/components')).toBe(false);
337
+ });
338
+ it('should accept a sibling worktree with a shared path prefix', () => {
339
+ // '/repo/worktrees/feature-2' starts with the '/repo/worktrees/feature'
340
+ // string but is a different directory, so it stays deletable.
341
+ expect(isDeletableWorktree({ path: '/repo/worktrees/feature', isMainWorktree: false }, '/repo/worktrees/feature-2')).toBe(true);
342
+ });
343
+ it('should accept an unrelated linked worktree', () => {
344
+ expect(isDeletableWorktree({ path: '/repo/worktrees/feature', isMainWorktree: false }, '/repo')).toBe(true);
345
+ });
346
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccmanager",
3
- "version": "4.3.1",
3
+ "version": "4.3.2",
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.3.1",
45
- "@kodaikabasawa/ccmanager-darwin-x64": "4.3.1",
46
- "@kodaikabasawa/ccmanager-linux-arm64": "4.3.1",
47
- "@kodaikabasawa/ccmanager-linux-x64": "4.3.1",
48
- "@kodaikabasawa/ccmanager-win32-x64": "4.3.1"
44
+ "@kodaikabasawa/ccmanager-darwin-arm64": "4.3.2",
45
+ "@kodaikabasawa/ccmanager-darwin-x64": "4.3.2",
46
+ "@kodaikabasawa/ccmanager-linux-arm64": "4.3.2",
47
+ "@kodaikabasawa/ccmanager-linux-x64": "4.3.2",
48
+ "@kodaikabasawa/ccmanager-win32-x64": "4.3.2"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@eslint/js": "^9.28.0",