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 +12 -0
- package/dist/components/App.js +46 -9
- package/dist/components/DeleteWorktree.js +2 -13
- package/dist/components/Menu.js +22 -16
- package/dist/components/Menu.test.js +37 -1
- package/dist/components/SessionActions.d.ts +17 -2
- package/dist/components/SessionActions.js +19 -17
- package/dist/components/SessionActions.test.d.ts +1 -0
- package/dist/components/SessionActions.test.js +94 -0
- package/dist/services/worktreeService.d.ts +12 -0
- package/dist/services/worktreeService.js +30 -0
- package/dist/services/worktreeService.test.js +55 -1
- package/dist/types/index.d.ts +2 -2
- package/dist/utils/worktreeInclude.d.ts +33 -0
- package/dist/utils/worktreeInclude.js +100 -0
- package/dist/utils/worktreeInclude.test.d.ts +1 -0
- package/dist/utils/worktreeInclude.test.js +91 -0
- package/dist/utils/worktreeUtils.d.ts +13 -0
- package/dist/utils/worktreeUtils.js +23 -0
- package/dist/utils/worktreeUtils.test.js +20 -1
- package/package.json +6 -6
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.
|
package/dist/components/App.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
675
|
-
|
|
676
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/components/Menu.js
CHANGED
|
@@ -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,
|
|
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 [
|
|
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
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
menuItems.
|
|
295
|
-
|
|
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
|
|
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() &&
|
|
375
|
-
sessionManager.toggleAutoApprovalForWorktree(
|
|
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
|
|
381
|
-
|
|
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
|
-
|
|
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-
|
|
487
|
-
: `Controls: ↑↓ Navigate Enter Select | Hotkeys: 0-9 Quick Select /-Search Tab-State Filter Space-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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:
|
|
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) => {
|
package/dist/types/index.d.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
45
|
-
"@kodaikabasawa/ccmanager-darwin-x64": "4.3.
|
|
46
|
-
"@kodaikabasawa/ccmanager-linux-arm64": "4.3.
|
|
47
|
-
"@kodaikabasawa/ccmanager-linux-x64": "4.3.
|
|
48
|
-
"@kodaikabasawa/ccmanager-win32-x64": "4.3.
|
|
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",
|