ccmanager 3.11.0 → 3.11.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/dist/components/App.js +15 -10
- package/dist/components/Dashboard.js +7 -1
- package/dist/components/DeleteWorktree.js +13 -2
- package/dist/components/Menu.js +2 -1
- package/dist/components/TextInputWrapper.d.ts +0 -3
- package/dist/components/TextInputWrapper.js +120 -11
- package/dist/services/sessionManager.test.js +0 -12
- package/dist/services/worktreeNameGenerator.d.ts +1 -0
- package/dist/services/worktreeNameGenerator.js +19 -1
- package/dist/services/worktreeNameGenerator.test.js +13 -1
- package/dist/services/worktreeService.js +26 -0
- package/dist/services/worktreeService.test.js +30 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/utils/presetPrompt.js +7 -5
- package/dist/utils/presetPrompt.test.js +2 -14
- package/dist/utils/worktreeUtils.d.ts +7 -0
- package/dist/utils/worktreeUtils.js +42 -0
- package/dist/utils/worktreeUtils.test.js +4 -0
- package/package.json +6 -6
package/dist/components/App.js
CHANGED
|
@@ -14,7 +14,8 @@ import RemoteBranchSelector from './RemoteBranchSelector.js';
|
|
|
14
14
|
import LoadingSpinner from './LoadingSpinner.js';
|
|
15
15
|
import { globalSessionOrchestrator } from '../services/globalSessionOrchestrator.js';
|
|
16
16
|
import { WorktreeService } from '../services/worktreeService.js';
|
|
17
|
-
import { worktreeNameGenerator } from '../services/worktreeNameGenerator.js';
|
|
17
|
+
import { worktreeNameGenerator, generateFallbackBranchName, } from '../services/worktreeNameGenerator.js';
|
|
18
|
+
import { logger } from '../utils/logger.js';
|
|
18
19
|
import { AmbiguousBranchError, } from '../types/index.js';
|
|
19
20
|
import { configReader } from '../services/config/configReader.js';
|
|
20
21
|
import { ENV_VARS } from '../constants/env.js';
|
|
@@ -53,26 +54,24 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
|
|
|
53
54
|
}
|
|
54
55
|
};
|
|
55
56
|
// Helper function to create session with Effect-based error handling
|
|
56
|
-
const createSessionWithEffect = async (worktreePath, presetId, initialPrompt) => {
|
|
57
|
+
const createSessionWithEffect = useCallback(async (worktreePath, presetId, initialPrompt) => {
|
|
57
58
|
const sessionEffect = devcontainerConfig
|
|
58
59
|
? sessionManager.createSessionWithDevcontainerEffect(worktreePath, devcontainerConfig, presetId, initialPrompt)
|
|
59
60
|
: sessionManager.createSessionWithPresetEffect(worktreePath, presetId, initialPrompt);
|
|
60
61
|
// Execute the Effect and handle both success and failure cases
|
|
61
62
|
const result = await Effect.runPromise(Effect.either(sessionEffect));
|
|
62
63
|
if (result._tag === 'Left') {
|
|
63
|
-
// Handle error using pattern matching on _tag
|
|
64
64
|
const errorMessage = formatErrorMessage(result.left);
|
|
65
65
|
return {
|
|
66
66
|
success: false,
|
|
67
67
|
errorMessage: `Failed to create session: ${errorMessage}`,
|
|
68
68
|
};
|
|
69
69
|
}
|
|
70
|
-
// Success case - extract session from Right
|
|
71
70
|
return {
|
|
72
71
|
success: true,
|
|
73
72
|
session: result.right,
|
|
74
73
|
};
|
|
75
|
-
};
|
|
74
|
+
}, [sessionManager, devcontainerConfig]);
|
|
76
75
|
// Helper function to clear terminal screen
|
|
77
76
|
const clearScreen = () => {
|
|
78
77
|
if (process.stdout.isTTY) {
|
|
@@ -115,7 +114,12 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
|
|
|
115
114
|
session = result.session;
|
|
116
115
|
}
|
|
117
116
|
navigateToSession(session);
|
|
118
|
-
}, [
|
|
117
|
+
}, [
|
|
118
|
+
sessionManager,
|
|
119
|
+
navigateWithClear,
|
|
120
|
+
navigateToSession,
|
|
121
|
+
createSessionWithEffect,
|
|
122
|
+
]);
|
|
119
123
|
useEffect(() => {
|
|
120
124
|
// Listen for session exits to return to menu automatically
|
|
121
125
|
const handleSessionExit = (session) => {
|
|
@@ -324,11 +328,12 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
|
|
|
324
328
|
const existingBranches = allBranches._tag === 'Right' ? allBranches.right : [];
|
|
325
329
|
const generatedBranch = await Effect.runPromise(Effect.either(worktreeNameGenerator.generateBranchNameEffect(request.initialPrompt, request.baseBranch, existingBranches)));
|
|
326
330
|
if (generatedBranch._tag === 'Left') {
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
331
|
+
logger.warn(`Branch name generation failed, using fallback: ${formatErrorMessage(generatedBranch.left)}`);
|
|
332
|
+
branch = generateFallbackBranchName(existingBranches);
|
|
333
|
+
}
|
|
334
|
+
else {
|
|
335
|
+
branch = generatedBranch.right;
|
|
330
336
|
}
|
|
331
|
-
branch = generatedBranch.right;
|
|
332
337
|
if (request.autoDirectoryPattern) {
|
|
333
338
|
targetPath = generateWorktreeDirectory(request.projectPath, branch, request.autoDirectoryPattern);
|
|
334
339
|
}
|
|
@@ -11,7 +11,7 @@ import { WorktreeService } from '../services/worktreeService.js';
|
|
|
11
11
|
import { STATUS_ICONS, STATUS_LABELS, MENU_ICONS, getStatusDisplay, } from '../constants/statusIcons.js';
|
|
12
12
|
import { useSearchMode } from '../hooks/useSearchMode.js';
|
|
13
13
|
import { useGitStatus } from '../hooks/useGitStatus.js';
|
|
14
|
-
import { truncateString, calculateColumnPositions, assembleWorktreeLabel, } from '../utils/worktreeUtils.js';
|
|
14
|
+
import { truncateString, calculateColumnPositions, assembleWorktreeLabel, formatRelativeDate, } from '../utils/worktreeUtils.js';
|
|
15
15
|
import { formatGitFileChanges, formatGitAheadBehind, formatParentBranch, } from '../utils/gitStatus.js';
|
|
16
16
|
import TextInputWrapper from './TextInputWrapper.js';
|
|
17
17
|
const MAX_BRANCH_NAME_LENGTH = 70;
|
|
@@ -241,12 +241,18 @@ const Dashboard = ({ projectsDir, onSelectSession, onSelectProject, error, onDis
|
|
|
241
241
|
fileChanges,
|
|
242
242
|
aheadBehind,
|
|
243
243
|
parentBranch,
|
|
244
|
+
lastCommitDate: wt.lastCommitDate
|
|
245
|
+
? `\x1b[90m${formatRelativeDate(wt.lastCommitDate)}\x1b[0m`
|
|
246
|
+
: '',
|
|
244
247
|
error: itemError,
|
|
245
248
|
lengths: {
|
|
246
249
|
base: stripAnsi(baseLabel).length,
|
|
247
250
|
fileChanges: stripAnsi(fileChanges).length,
|
|
248
251
|
aheadBehind: stripAnsi(aheadBehind).length,
|
|
249
252
|
parentBranch: stripAnsi(parentBranch).length,
|
|
253
|
+
lastCommitDate: wt.lastCommitDate
|
|
254
|
+
? formatRelativeDate(wt.lastCommitDate).length
|
|
255
|
+
: 0,
|
|
250
256
|
},
|
|
251
257
|
};
|
|
252
258
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
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';
|
|
3
4
|
import { Box, Text, useInput } from 'ink';
|
|
4
5
|
import SelectInput from 'ink-select-input';
|
|
5
6
|
import { Effect } from 'effect';
|
|
@@ -20,8 +21,18 @@ const DeleteWorktree = ({ projectPath, onComplete, onCancel, }) => {
|
|
|
20
21
|
try {
|
|
21
22
|
const allWorktrees = await Effect.runPromise(worktreeService.getWorktreesEffect());
|
|
22
23
|
if (!cancelled) {
|
|
23
|
-
// Filter out main worktree
|
|
24
|
-
const
|
|
24
|
+
// Filter out main worktree and current working directory worktree
|
|
25
|
+
const resolvedCwd = path.resolve(process.cwd());
|
|
26
|
+
const deletableWorktrees = allWorktrees.filter(wt => {
|
|
27
|
+
if (wt.isMainWorktree)
|
|
28
|
+
return false;
|
|
29
|
+
const resolvedPath = path.resolve(wt.path);
|
|
30
|
+
if (resolvedCwd === resolvedPath ||
|
|
31
|
+
resolvedCwd.startsWith(resolvedPath + path.sep)) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
return true;
|
|
35
|
+
});
|
|
25
36
|
setWorktrees(deletableWorktrees);
|
|
26
37
|
setIsLoading(false);
|
|
27
38
|
}
|
package/dist/components/Menu.js
CHANGED
|
@@ -144,7 +144,8 @@ const Menu = ({ sessionManager, worktreeService, onSelectWorktree, onSelectRecen
|
|
|
144
144
|
sessionManager.isAutoApprovalDisabledForWorktree(item.worktree.path);
|
|
145
145
|
const label = baseLabel + (aaDisabled ? ' [Auto Approval Off]' : '');
|
|
146
146
|
// Only show numbers for worktrees (0-9) when not in search mode
|
|
147
|
-
|
|
147
|
+
// Use fixed-width prefix to prevent flicker at scroll boundary
|
|
148
|
+
const numberPrefix = !isSearchMode && index < 10 ? `${index} ❯ ` : ' ❯ ';
|
|
148
149
|
return {
|
|
149
150
|
type: 'worktree',
|
|
150
151
|
label: numberPrefix + label,
|
|
@@ -5,9 +5,6 @@ interface TextInputWrapperProps {
|
|
|
5
5
|
onSubmit?: (value: string) => void;
|
|
6
6
|
placeholder?: string;
|
|
7
7
|
focus?: boolean;
|
|
8
|
-
mask?: string;
|
|
9
|
-
showCursor?: boolean;
|
|
10
|
-
highlightPastedText?: boolean;
|
|
11
8
|
}
|
|
12
9
|
declare const TextInputWrapper: React.FC<TextInputWrapperProps>;
|
|
13
10
|
export default TextInputWrapper;
|
|
@@ -1,15 +1,124 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
import
|
|
2
|
+
import { useReducer, useEffect, useRef, useMemo, } from 'react';
|
|
3
|
+
import { Text, useInput } from 'ink';
|
|
4
|
+
import chalk from 'chalk';
|
|
3
5
|
import stripAnsi from 'strip-ansi';
|
|
4
|
-
const
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
6
|
+
const reducer = (state, action) => {
|
|
7
|
+
switch (action.type) {
|
|
8
|
+
case 'move-cursor-left': {
|
|
9
|
+
return {
|
|
10
|
+
...state,
|
|
11
|
+
cursorOffset: Math.max(0, state.cursorOffset - 1),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
case 'move-cursor-right': {
|
|
15
|
+
return {
|
|
16
|
+
...state,
|
|
17
|
+
cursorOffset: Math.min(state.value.length, state.cursorOffset + 1),
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
case 'insert': {
|
|
21
|
+
return {
|
|
22
|
+
value: state.value.slice(0, state.cursorOffset) +
|
|
23
|
+
action.text +
|
|
24
|
+
state.value.slice(state.cursorOffset),
|
|
25
|
+
cursorOffset: state.cursorOffset + action.text.length,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
case 'delete': {
|
|
29
|
+
if (state.cursorOffset === 0)
|
|
30
|
+
return state;
|
|
31
|
+
const newOffset = state.cursorOffset - 1;
|
|
32
|
+
return {
|
|
33
|
+
value: state.value.slice(0, newOffset) + state.value.slice(newOffset + 1),
|
|
34
|
+
cursorOffset: newOffset,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
case 'set': {
|
|
38
|
+
return {
|
|
39
|
+
value: action.value,
|
|
40
|
+
cursorOffset: action.value.length,
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
function cleanInput(input) {
|
|
46
|
+
let cleaned = stripAnsi(input);
|
|
47
|
+
cleaned = cleaned.replace(/\[200~/g, '').replace(/\[201~/g, '');
|
|
48
|
+
return cleaned;
|
|
49
|
+
}
|
|
50
|
+
const cursor = chalk.inverse(' ');
|
|
51
|
+
const TextInputWrapper = ({ value, onChange, onSubmit, placeholder = '', focus = true, }) => {
|
|
52
|
+
const [state, dispatch] = useReducer(reducer, {
|
|
53
|
+
value,
|
|
54
|
+
cursorOffset: value.length,
|
|
55
|
+
});
|
|
56
|
+
const lastReportedValue = useRef(value);
|
|
57
|
+
// Sync external value changes into internal state
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (value !== lastReportedValue.current) {
|
|
60
|
+
lastReportedValue.current = value;
|
|
61
|
+
dispatch({ type: 'set', value });
|
|
62
|
+
}
|
|
63
|
+
}, [value]);
|
|
64
|
+
// Report internal state changes to parent
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (state.value !== lastReportedValue.current) {
|
|
67
|
+
lastReportedValue.current = state.value;
|
|
68
|
+
onChange(state.value);
|
|
69
|
+
}
|
|
70
|
+
}, [state.value, onChange]);
|
|
71
|
+
useInput((input, key) => {
|
|
72
|
+
if (key.upArrow ||
|
|
73
|
+
key.downArrow ||
|
|
74
|
+
(key.ctrl && input === 'c') ||
|
|
75
|
+
key.tab ||
|
|
76
|
+
(key.shift && key.tab)) {
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (key.return) {
|
|
80
|
+
onSubmit?.(state.value);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (key.leftArrow) {
|
|
84
|
+
dispatch({ type: 'move-cursor-left' });
|
|
85
|
+
}
|
|
86
|
+
else if (key.rightArrow) {
|
|
87
|
+
dispatch({ type: 'move-cursor-right' });
|
|
88
|
+
}
|
|
89
|
+
else if (key.backspace || key.delete) {
|
|
90
|
+
dispatch({ type: 'delete' });
|
|
91
|
+
}
|
|
92
|
+
else {
|
|
93
|
+
const cleaned = cleanInput(input);
|
|
94
|
+
if (cleaned) {
|
|
95
|
+
dispatch({ type: 'insert', text: cleaned });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}, { isActive: focus });
|
|
99
|
+
const renderedPlaceholder = useMemo(() => {
|
|
100
|
+
if (!focus) {
|
|
101
|
+
return placeholder ? chalk.dim(placeholder) : '';
|
|
102
|
+
}
|
|
103
|
+
return placeholder.length > 0
|
|
104
|
+
? chalk.inverse(placeholder[0]) + chalk.dim(placeholder.slice(1))
|
|
105
|
+
: cursor;
|
|
106
|
+
}, [focus, placeholder]);
|
|
107
|
+
const renderedValue = useMemo(() => {
|
|
108
|
+
if (!focus) {
|
|
109
|
+
return state.value;
|
|
110
|
+
}
|
|
111
|
+
let result = state.value.length > 0 ? '' : cursor;
|
|
112
|
+
let index = 0;
|
|
113
|
+
for (const char of state.value) {
|
|
114
|
+
result += index === state.cursorOffset ? chalk.inverse(char) : char;
|
|
115
|
+
index++;
|
|
116
|
+
}
|
|
117
|
+
if (state.value.length > 0 && state.cursorOffset === state.value.length) {
|
|
118
|
+
result += cursor;
|
|
119
|
+
}
|
|
120
|
+
return result;
|
|
121
|
+
}, [focus, state.value, state.cursorOffset]);
|
|
122
|
+
return (_jsx(Text, { children: state.value.length > 0 ? renderedValue : renderedPlaceholder }));
|
|
14
123
|
};
|
|
15
124
|
export default TextInputWrapper;
|
|
@@ -164,18 +164,6 @@ describe('SessionManager', () => {
|
|
|
164
164
|
expect(spawn).toHaveBeenCalledWith('opencode', ['run', '--prompt', 'implement prompt flow'], expect.any(Object));
|
|
165
165
|
expect(mockPty.write).not.toHaveBeenCalled();
|
|
166
166
|
});
|
|
167
|
-
it('writes the initial prompt to stdin for unknown commands', async () => {
|
|
168
|
-
vi.mocked(configReader.getDefaultPreset).mockReturnValue({
|
|
169
|
-
id: '1',
|
|
170
|
-
name: 'Custom',
|
|
171
|
-
command: 'custom-agent',
|
|
172
|
-
args: ['--interactive'],
|
|
173
|
-
});
|
|
174
|
-
vi.mocked(spawn).mockReturnValue(mockPty);
|
|
175
|
-
await Effect.runPromise(sessionManager.createSessionWithPresetEffect('/test/worktree', undefined, 'implement prompt flow'));
|
|
176
|
-
expect(spawn).toHaveBeenCalledWith('custom-agent', ['--interactive'], expect.any(Object));
|
|
177
|
-
expect(mockPty.write).toHaveBeenCalledWith('implement prompt flow\r');
|
|
178
|
-
});
|
|
179
167
|
it('should fall back to default preset if specified preset not found', async () => {
|
|
180
168
|
// Setup mocks
|
|
181
169
|
vi.mocked(configReader.getPresetByIdEffect).mockReturnValue(Either.left(new ValidationError({
|
|
@@ -2,6 +2,7 @@ import { Effect } from 'effect';
|
|
|
2
2
|
import { ProcessError } from '../types/errors.js';
|
|
3
3
|
export declare const extractBranchNameFromOutput: (stdout: string) => string;
|
|
4
4
|
export declare const deduplicateBranchName: (name: string, existingBranches: string[]) => string;
|
|
5
|
+
export declare const generateFallbackBranchName: (existingBranches?: string[]) => string;
|
|
5
6
|
export declare class WorktreeNameGenerator {
|
|
6
7
|
generateBranchNameEffect(userPrompt: string, baseBranch: string, existingBranches?: string[]): Effect.Effect<string, ProcessError, never>;
|
|
7
8
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomBytes } from 'crypto';
|
|
1
2
|
import { Effect } from 'effect';
|
|
2
3
|
import { execFile } from 'child_process';
|
|
3
4
|
import { ProcessError } from '../types/errors.js';
|
|
@@ -121,6 +122,15 @@ export const deduplicateBranchName = (name, existingBranches) => {
|
|
|
121
122
|
}
|
|
122
123
|
}
|
|
123
124
|
};
|
|
125
|
+
export const generateFallbackBranchName = (existingBranches) => {
|
|
126
|
+
const date = new Date();
|
|
127
|
+
const dateStr = `${date.getFullYear()}${String(date.getMonth() + 1).padStart(2, '0')}${String(date.getDate()).padStart(2, '0')}`;
|
|
128
|
+
const randomSuffix = randomBytes(3).toString('hex');
|
|
129
|
+
const name = `${dateStr}-${randomSuffix}`;
|
|
130
|
+
return existingBranches
|
|
131
|
+
? deduplicateBranchName(name, existingBranches)
|
|
132
|
+
: name;
|
|
133
|
+
};
|
|
124
134
|
export class WorktreeNameGenerator {
|
|
125
135
|
generateBranchNameEffect(userPrompt, baseBranch, existingBranches) {
|
|
126
136
|
return Effect.tryPromise({
|
|
@@ -141,7 +151,15 @@ export class WorktreeNameGenerator {
|
|
|
141
151
|
reject(new Error('Timed out while generating a branch name with `claude -p`'));
|
|
142
152
|
});
|
|
143
153
|
}, DEFAULT_TIMEOUT_MS);
|
|
144
|
-
child = execFile('claude', [
|
|
154
|
+
child = execFile('claude', [
|
|
155
|
+
'-p',
|
|
156
|
+
'--model',
|
|
157
|
+
'haiku',
|
|
158
|
+
'--output-format',
|
|
159
|
+
'json',
|
|
160
|
+
'--json-schema',
|
|
161
|
+
JSON_SCHEMA,
|
|
162
|
+
], {
|
|
145
163
|
encoding: 'utf8',
|
|
146
164
|
maxBuffer: 1024 * 1024,
|
|
147
165
|
}, (error, stdout) => {
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { deduplicateBranchName, extractBranchNameFromOutput, worktreeNameGenerator as generator, } from './worktreeNameGenerator.js';
|
|
2
|
+
import { deduplicateBranchName, extractBranchNameFromOutput, generateFallbackBranchName, worktreeNameGenerator as generator, } from './worktreeNameGenerator.js';
|
|
3
3
|
describe('WorktreeNameGenerator output parsing', () => {
|
|
4
4
|
it('normalizes direct json branchName responses', async () => {
|
|
5
5
|
const value = extractBranchNameFromOutput('{"branchName":"feature/add prompt"}');
|
|
@@ -33,3 +33,15 @@ describe('deduplicateBranchName', () => {
|
|
|
33
33
|
expect(deduplicateBranchName('Feature/New', ['feature/new'])).toBe('Feature/New-2');
|
|
34
34
|
});
|
|
35
35
|
});
|
|
36
|
+
describe('generateFallbackBranchName', () => {
|
|
37
|
+
it('returns a name matching YYYYMMDD-hex pattern', () => {
|
|
38
|
+
const name = generateFallbackBranchName();
|
|
39
|
+
expect(name).toMatch(/^\d{8}-[0-9a-f]{6}$/);
|
|
40
|
+
});
|
|
41
|
+
it('deduplicates against existing branches', () => {
|
|
42
|
+
const first = generateFallbackBranchName();
|
|
43
|
+
const name = generateFallbackBranchName([first]);
|
|
44
|
+
// Either it's different (random collision unlikely) or it has a -2 suffix
|
|
45
|
+
expect(name).not.toBe(first);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -662,6 +662,21 @@ export class WorktreeService {
|
|
|
662
662
|
if (mainWorktree && mainWorktree.path.includes('.git/modules')) {
|
|
663
663
|
mainWorktree.path = self.gitRootPath;
|
|
664
664
|
}
|
|
665
|
+
// Fetch last commit date for each worktree
|
|
666
|
+
for (const wt of worktrees) {
|
|
667
|
+
try {
|
|
668
|
+
const dateStr = execSync('git log -1 --format=%aI', {
|
|
669
|
+
cwd: wt.path,
|
|
670
|
+
encoding: 'utf8',
|
|
671
|
+
}).trim();
|
|
672
|
+
if (dateStr) {
|
|
673
|
+
wt.lastCommitDate = new Date(dateStr);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
catch {
|
|
677
|
+
// Ignore errors (e.g., empty repo)
|
|
678
|
+
}
|
|
679
|
+
}
|
|
665
680
|
// Sort worktrees by last session if requested
|
|
666
681
|
if (sortByLastSession) {
|
|
667
682
|
worktrees.sort((a, b) => {
|
|
@@ -900,6 +915,17 @@ export class WorktreeService {
|
|
|
900
915
|
stderr: 'Cannot delete the main worktree',
|
|
901
916
|
}));
|
|
902
917
|
}
|
|
918
|
+
// Prevent deleting the worktree that contains the current working directory
|
|
919
|
+
const resolvedWorktreePath = path.resolve(worktreePath);
|
|
920
|
+
const resolvedCwd = path.resolve(process.cwd());
|
|
921
|
+
if (resolvedCwd === resolvedWorktreePath ||
|
|
922
|
+
resolvedCwd.startsWith(resolvedWorktreePath + path.sep)) {
|
|
923
|
+
return yield* Effect.fail(new GitError({
|
|
924
|
+
command: 'git worktree remove',
|
|
925
|
+
exitCode: 1,
|
|
926
|
+
stderr: `Cannot delete the worktree at "${worktreePath}" because it is the current working directory`,
|
|
927
|
+
}));
|
|
928
|
+
}
|
|
903
929
|
// Remove the worktree
|
|
904
930
|
yield* Effect.try({
|
|
905
931
|
try: () => {
|
|
@@ -757,6 +757,36 @@ branch refs/heads/main
|
|
|
757
757
|
expect.fail('Should have returned Left with GitError');
|
|
758
758
|
}
|
|
759
759
|
});
|
|
760
|
+
it('should return Effect that fails with GitError when trying to delete worktree matching cwd', async () => {
|
|
761
|
+
const cwdPath = process.cwd();
|
|
762
|
+
mockedExecSync.mockImplementation((cmd, _options) => {
|
|
763
|
+
if (typeof cmd === 'string') {
|
|
764
|
+
if (cmd === 'git rev-parse --git-common-dir') {
|
|
765
|
+
return '/fake/path/.git\n';
|
|
766
|
+
}
|
|
767
|
+
if (cmd === 'git worktree list --porcelain') {
|
|
768
|
+
return `worktree /fake/path
|
|
769
|
+
HEAD abcd1234
|
|
770
|
+
branch refs/heads/main
|
|
771
|
+
|
|
772
|
+
worktree ${cwdPath}
|
|
773
|
+
HEAD efgh5678
|
|
774
|
+
branch refs/heads/feature
|
|
775
|
+
`;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
throw new Error('Command not mocked: ' + cmd);
|
|
779
|
+
});
|
|
780
|
+
const effect = service.deleteWorktreeEffect(cwdPath);
|
|
781
|
+
const result = await Effect.runPromise(Effect.either(effect));
|
|
782
|
+
if (result._tag === 'Left') {
|
|
783
|
+
expect(result.left).toBeInstanceOf(GitError);
|
|
784
|
+
expect(result.left.stderr).toContain('because it is the current working directory');
|
|
785
|
+
}
|
|
786
|
+
else {
|
|
787
|
+
expect.fail('Should have returned Left with GitError');
|
|
788
|
+
}
|
|
789
|
+
});
|
|
760
790
|
});
|
|
761
791
|
describe('Effect-based mergeWorktree', () => {
|
|
762
792
|
it('should return Effect with void on successful merge', async () => {
|
package/dist/types/index.d.ts
CHANGED
|
@@ -13,14 +13,16 @@ const PROMPT_FLAG = {
|
|
|
13
13
|
'github-copilot': '-i',
|
|
14
14
|
kimi: '-p',
|
|
15
15
|
};
|
|
16
|
+
const DEFAULT_DETECTION_STRATEGY = 'claude';
|
|
16
17
|
export const getPromptInjectionMethod = (preset) => {
|
|
17
|
-
|
|
18
|
+
const strategy = preset.detectionStrategy ?? DEFAULT_DETECTION_STRATEGY;
|
|
19
|
+
if (PROMPT_FLAG[strategy]) {
|
|
18
20
|
return 'flag';
|
|
19
21
|
}
|
|
20
|
-
if (
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
22
|
+
if (strategy === 'claude' ||
|
|
23
|
+
strategy === 'codex' ||
|
|
24
|
+
strategy === 'cursor' ||
|
|
25
|
+
strategy === 'cline') {
|
|
24
26
|
return 'final-arg';
|
|
25
27
|
}
|
|
26
28
|
return 'stdin';
|
|
@@ -49,13 +49,6 @@ describe('presetPrompt', () => {
|
|
|
49
49
|
method: 'flag',
|
|
50
50
|
});
|
|
51
51
|
});
|
|
52
|
-
it('falls back to stdin for unknown commands', () => {
|
|
53
|
-
expect(preparePresetLaunch({ command: 'custom-agent', args: ['--interactive'] }, 'hello')).toEqual({
|
|
54
|
-
args: ['--interactive'],
|
|
55
|
-
method: 'stdin',
|
|
56
|
-
stdinPayload: 'hello\r',
|
|
57
|
-
});
|
|
58
|
-
});
|
|
59
52
|
describe('describePromptInjection', () => {
|
|
60
53
|
it('describes final-arg for claude', () => {
|
|
61
54
|
expect(describePromptInjection({
|
|
@@ -105,9 +98,6 @@ describe('presetPrompt', () => {
|
|
|
105
98
|
detectionStrategy: 'kimi',
|
|
106
99
|
})).toContain('-p');
|
|
107
100
|
});
|
|
108
|
-
it('describes stdin for unknown strategy', () => {
|
|
109
|
-
expect(describePromptInjection({ command: 'custom-agent' })).toContain('standard input');
|
|
110
|
-
});
|
|
111
101
|
});
|
|
112
102
|
describe('getPromptInjectionMethod', () => {
|
|
113
103
|
it('returns final-arg for claude', () => {
|
|
@@ -158,10 +148,8 @@ describe('presetPrompt', () => {
|
|
|
158
148
|
detectionStrategy: 'kimi',
|
|
159
149
|
})).toBe('flag');
|
|
160
150
|
});
|
|
161
|
-
it('
|
|
162
|
-
expect(getPromptInjectionMethod({ command: 'claude' })).toBe('
|
|
163
|
-
expect(getPromptInjectionMethod({ command: 'opencode' })).toBe('stdin');
|
|
164
|
-
expect(getPromptInjectionMethod({ command: 'custom-agent' })).toBe('stdin');
|
|
151
|
+
it('falls back to claude strategy when detectionStrategy is not set', () => {
|
|
152
|
+
expect(getPromptInjectionMethod({ command: 'claude' })).toBe('final-arg');
|
|
165
153
|
});
|
|
166
154
|
});
|
|
167
155
|
});
|
|
@@ -9,14 +9,20 @@ export interface WorktreeItem {
|
|
|
9
9
|
fileChanges: string;
|
|
10
10
|
aheadBehind: string;
|
|
11
11
|
parentBranch: string;
|
|
12
|
+
lastCommitDate: string;
|
|
12
13
|
error?: string;
|
|
13
14
|
lengths: {
|
|
14
15
|
base: number;
|
|
15
16
|
fileChanges: number;
|
|
16
17
|
aheadBehind: number;
|
|
17
18
|
parentBranch: number;
|
|
19
|
+
lastCommitDate: number;
|
|
18
20
|
};
|
|
19
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Format a date as a relative time string (e.g., "2h ago", "3d ago").
|
|
24
|
+
*/
|
|
25
|
+
export declare function formatRelativeDate(date: Date): string;
|
|
20
26
|
export declare function truncateString(str: string, maxLength: number): string;
|
|
21
27
|
export declare function generateWorktreeDirectory(projectPath: string, branchName: string, pattern?: string): string;
|
|
22
28
|
export declare function extractBranchParts(branchName: string): {
|
|
@@ -34,6 +40,7 @@ export declare function calculateColumnPositions(items: WorktreeItem[]): {
|
|
|
34
40
|
fileChanges: number;
|
|
35
41
|
aheadBehind: number;
|
|
36
42
|
parentBranch: number;
|
|
43
|
+
lastCommitDate: number;
|
|
37
44
|
};
|
|
38
45
|
/**
|
|
39
46
|
* Assembles the final worktree label with proper column alignment
|
|
@@ -6,6 +6,33 @@ import { formatGitFileChanges, formatGitAheadBehind, formatParentBranch, } from
|
|
|
6
6
|
// Constants
|
|
7
7
|
const MAX_BRANCH_NAME_LENGTH = 70; // Maximum characters for branch name display
|
|
8
8
|
const MIN_COLUMN_PADDING = 2; // Minimum spaces between columns
|
|
9
|
+
/**
|
|
10
|
+
* Format a date as a relative time string (e.g., "2h ago", "3d ago").
|
|
11
|
+
*/
|
|
12
|
+
export function formatRelativeDate(date) {
|
|
13
|
+
const now = Date.now();
|
|
14
|
+
const diffMs = now - date.getTime();
|
|
15
|
+
const diffSec = Math.floor(diffMs / 1000);
|
|
16
|
+
const diffMin = Math.floor(diffSec / 60);
|
|
17
|
+
const diffHour = Math.floor(diffMin / 60);
|
|
18
|
+
const diffDay = Math.floor(diffHour / 24);
|
|
19
|
+
const diffWeek = Math.floor(diffDay / 7);
|
|
20
|
+
const diffMonth = Math.floor(diffDay / 30);
|
|
21
|
+
const diffYear = Math.floor(diffDay / 365);
|
|
22
|
+
if (diffYear > 0)
|
|
23
|
+
return `${diffYear}y ago`;
|
|
24
|
+
if (diffMonth > 0)
|
|
25
|
+
return `${diffMonth}mo ago`;
|
|
26
|
+
if (diffWeek > 0)
|
|
27
|
+
return `${diffWeek}w ago`;
|
|
28
|
+
if (diffDay > 0)
|
|
29
|
+
return `${diffDay}d ago`;
|
|
30
|
+
if (diffHour > 0)
|
|
31
|
+
return `${diffHour}h ago`;
|
|
32
|
+
if (diffMin > 0)
|
|
33
|
+
return `${diffMin}m ago`;
|
|
34
|
+
return 'just now';
|
|
35
|
+
}
|
|
9
36
|
// Utility function to truncate strings with ellipsis
|
|
10
37
|
export function truncateString(str, maxLength) {
|
|
11
38
|
if (str.length <= maxLength)
|
|
@@ -107,6 +134,10 @@ export function prepareWorktreeItems(worktrees, sessions) {
|
|
|
107
134
|
// Show fetching status in dim gray
|
|
108
135
|
fileChanges = '\x1b[90m[fetching...]\x1b[0m';
|
|
109
136
|
}
|
|
137
|
+
// Format last commit date as dim relative time
|
|
138
|
+
const lastCommitDate = wt.lastCommitDate
|
|
139
|
+
? `\x1b[90m${formatRelativeDate(wt.lastCommitDate)}\x1b[0m`
|
|
140
|
+
: '';
|
|
110
141
|
return {
|
|
111
142
|
worktree: wt,
|
|
112
143
|
session,
|
|
@@ -114,12 +145,14 @@ export function prepareWorktreeItems(worktrees, sessions) {
|
|
|
114
145
|
fileChanges,
|
|
115
146
|
aheadBehind,
|
|
116
147
|
parentBranch,
|
|
148
|
+
lastCommitDate,
|
|
117
149
|
error,
|
|
118
150
|
lengths: {
|
|
119
151
|
base: stripAnsi(baseLabel).length,
|
|
120
152
|
fileChanges: stripAnsi(fileChanges).length,
|
|
121
153
|
aheadBehind: stripAnsi(aheadBehind).length,
|
|
122
154
|
parentBranch: stripAnsi(parentBranch).length,
|
|
155
|
+
lastCommitDate: stripAnsi(lastCommitDate).length,
|
|
123
156
|
},
|
|
124
157
|
};
|
|
125
158
|
});
|
|
@@ -132,6 +165,7 @@ export function calculateColumnPositions(items) {
|
|
|
132
165
|
let maxBranchLength = 0;
|
|
133
166
|
let maxFileChangesLength = 0;
|
|
134
167
|
let maxAheadBehindLength = 0;
|
|
168
|
+
let maxParentBranchLength = 0;
|
|
135
169
|
items.forEach(item => {
|
|
136
170
|
// Skip items with errors for alignment calculation
|
|
137
171
|
if (item.error)
|
|
@@ -139,15 +173,18 @@ export function calculateColumnPositions(items) {
|
|
|
139
173
|
maxBranchLength = Math.max(maxBranchLength, item.lengths.base);
|
|
140
174
|
maxFileChangesLength = Math.max(maxFileChangesLength, item.lengths.fileChanges);
|
|
141
175
|
maxAheadBehindLength = Math.max(maxAheadBehindLength, item.lengths.aheadBehind);
|
|
176
|
+
maxParentBranchLength = Math.max(maxParentBranchLength, item.lengths.parentBranch);
|
|
142
177
|
});
|
|
143
178
|
// Simple column positioning
|
|
144
179
|
const fileChangesColumn = maxBranchLength + MIN_COLUMN_PADDING;
|
|
145
180
|
const aheadBehindColumn = fileChangesColumn + maxFileChangesLength + MIN_COLUMN_PADDING + 2;
|
|
146
181
|
const parentBranchColumn = aheadBehindColumn + maxAheadBehindLength + MIN_COLUMN_PADDING + 2;
|
|
182
|
+
const lastCommitDateColumn = parentBranchColumn + maxParentBranchLength + MIN_COLUMN_PADDING + 2;
|
|
147
183
|
return {
|
|
148
184
|
fileChanges: fileChangesColumn,
|
|
149
185
|
aheadBehind: aheadBehindColumn,
|
|
150
186
|
parentBranch: parentBranchColumn,
|
|
187
|
+
lastCommitDate: lastCommitDateColumn,
|
|
151
188
|
};
|
|
152
189
|
}
|
|
153
190
|
// Pad string to column position
|
|
@@ -175,6 +212,11 @@ export function assembleWorktreeLabel(item, columns) {
|
|
|
175
212
|
if (item.parentBranch) {
|
|
176
213
|
label =
|
|
177
214
|
padTo(label, currentLength, columns.parentBranch) + item.parentBranch;
|
|
215
|
+
currentLength = columns.parentBranch + item.lengths.parentBranch;
|
|
216
|
+
}
|
|
217
|
+
if (item.lastCommitDate) {
|
|
218
|
+
label =
|
|
219
|
+
padTo(label, currentLength, columns.lastCommitDate) + item.lastCommitDate;
|
|
178
220
|
}
|
|
179
221
|
return label;
|
|
180
222
|
}
|
|
@@ -170,11 +170,13 @@ describe('column alignment', () => {
|
|
|
170
170
|
fileChanges: '\x1b[32m+10\x1b[0m \x1b[31m-5\x1b[0m',
|
|
171
171
|
aheadBehind: '\x1b[33m↑2 ↓3\x1b[0m',
|
|
172
172
|
parentBranch: '',
|
|
173
|
+
lastCommitDate: '',
|
|
173
174
|
lengths: {
|
|
174
175
|
base: 19, // 'feature/test-branch'.length
|
|
175
176
|
fileChanges: 6, // '+10 -5'.length
|
|
176
177
|
aheadBehind: 5, // '↑2 ↓3'.length
|
|
177
178
|
parentBranch: 0,
|
|
179
|
+
lastCommitDate: 0,
|
|
178
180
|
},
|
|
179
181
|
},
|
|
180
182
|
{
|
|
@@ -183,11 +185,13 @@ describe('column alignment', () => {
|
|
|
183
185
|
fileChanges: '\x1b[32m+2\x1b[0m \x1b[31m-1\x1b[0m',
|
|
184
186
|
aheadBehind: '\x1b[33m↑1\x1b[0m',
|
|
185
187
|
parentBranch: '',
|
|
188
|
+
lastCommitDate: '',
|
|
186
189
|
lengths: {
|
|
187
190
|
base: 4, // 'main'.length
|
|
188
191
|
fileChanges: 5, // '+2 -1'.length
|
|
189
192
|
aheadBehind: 2, // '↑1'.length
|
|
190
193
|
parentBranch: 0,
|
|
194
|
+
lastCommitDate: 0,
|
|
191
195
|
},
|
|
192
196
|
},
|
|
193
197
|
];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccmanager",
|
|
3
|
-
"version": "3.11.
|
|
3
|
+
"version": "3.11.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": "3.11.
|
|
45
|
-
"@kodaikabasawa/ccmanager-darwin-x64": "3.11.
|
|
46
|
-
"@kodaikabasawa/ccmanager-linux-arm64": "3.11.
|
|
47
|
-
"@kodaikabasawa/ccmanager-linux-x64": "3.11.
|
|
48
|
-
"@kodaikabasawa/ccmanager-win32-x64": "3.11.
|
|
44
|
+
"@kodaikabasawa/ccmanager-darwin-arm64": "3.11.2",
|
|
45
|
+
"@kodaikabasawa/ccmanager-darwin-x64": "3.11.2",
|
|
46
|
+
"@kodaikabasawa/ccmanager-linux-arm64": "3.11.2",
|
|
47
|
+
"@kodaikabasawa/ccmanager-linux-x64": "3.11.2",
|
|
48
|
+
"@kodaikabasawa/ccmanager-win32-x64": "3.11.2"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@eslint/js": "^9.28.0",
|