ccmanager 3.7.0 → 3.7.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 +3 -2
- package/dist/components/App.test.js +2 -2
- package/dist/components/DeleteConfirmation.js +54 -3
- package/dist/components/MergeWorktree.js +47 -2
- package/dist/services/stateDetector/base.d.ts +2 -2
- package/dist/services/stateDetector/base.js +2 -2
- package/dist/services/stateDetector/kimi.js +1 -1
- package/dist/utils/gitUtils.d.ts +8 -0
- package/dist/utils/gitUtils.js +21 -0
- package/dist/utils/gitUtils.uncommitted.test.d.ts +1 -0
- package/dist/utils/gitUtils.uncommitted.test.js +92 -0
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/Piebald-AI/awesome-gemini-cli)
|
|
4
4
|
|
|
5
|
-
CCManager is a CLI application for managing multiple AI coding assistant sessions (Claude Code, Gemini CLI, Codex CLI) across Git worktrees and projects.
|
|
5
|
+
CCManager is a CLI application for managing multiple AI coding assistant sessions (Claude Code, Gemini CLI, Codex CLI, Cursor Agent, Copilot CLI, Cline CLI, OpenCode, Kimi CLI) across Git worktrees and projects.
|
|
6
6
|
|
|
7
7
|
https://github.com/user-attachments/assets/15914a88-e288-4ac9-94d5-8127f2e19dbf
|
|
8
8
|
|
|
@@ -10,7 +10,7 @@ https://github.com/user-attachments/assets/15914a88-e288-4ac9-94d5-8127f2e19dbf
|
|
|
10
10
|
|
|
11
11
|
- Run multiple AI assistant sessions in parallel across different Git worktrees
|
|
12
12
|
- **Multi-project support**: Manage multiple git repositories from a single interface
|
|
13
|
-
- Support for multiple AI coding assistants (Claude Code, Gemini CLI)
|
|
13
|
+
- Support for multiple AI coding assistants (Claude Code, Gemini CLI, Codex CLI, Cursor Agent, Copilot CLI, Cline CLI, OpenCode, Kimi CLI)
|
|
14
14
|
- Switch between sessions seamlessly
|
|
15
15
|
- Visual status indicators for session states (busy, waiting, idle)
|
|
16
16
|
- Create, merge, and delete worktrees from within the app
|
|
@@ -129,6 +129,7 @@ CCManager supports multiple AI coding assistants with tailored state detection f
|
|
|
129
129
|
| Copilot CLI | `copilot` | [github.com/github/copilot-cli](https://github.com/github/copilot-cli) |
|
|
130
130
|
| Cline CLI | `cline` | [github.com/cline/cline](https://github.com/cline/cline) |
|
|
131
131
|
| OpenCode | `opencode` | [opencode.ai/docs](https://opencode.ai/docs) |
|
|
132
|
+
| Kimi CLI | `kimi` | [kimi-cli.com](https://www.kimi-cli.com/en/) |
|
|
132
133
|
|
|
133
134
|
Each assistant has its own state detection strategy to properly track:
|
|
134
135
|
- **Idle**: Ready for new input
|
|
@@ -172,7 +172,7 @@ describe('App component loading state machine', () => {
|
|
|
172
172
|
resolveWorktree?.();
|
|
173
173
|
await createPromise;
|
|
174
174
|
await selectPromise;
|
|
175
|
-
await
|
|
175
|
+
await waitForCondition(() => lastFrame()?.includes('Menu View') ?? false);
|
|
176
176
|
expect(lastFrame()).toContain('Menu View');
|
|
177
177
|
unmount();
|
|
178
178
|
});
|
|
@@ -201,7 +201,7 @@ describe('App component loading state machine', () => {
|
|
|
201
201
|
resolveDelete?.();
|
|
202
202
|
await deletePromise;
|
|
203
203
|
await selectPromise;
|
|
204
|
-
await
|
|
204
|
+
await waitForCondition(() => lastFrame()?.includes('Menu View') ?? false);
|
|
205
205
|
expect(lastFrame()).toContain('Menu View');
|
|
206
206
|
unmount();
|
|
207
207
|
});
|
|
@@ -1,15 +1,34 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
-
import { useState } from 'react';
|
|
2
|
+
import { useState, useEffect } from 'react';
|
|
3
3
|
import { Box, Text, useInput } from 'ink';
|
|
4
4
|
import SelectInput from 'ink-select-input';
|
|
5
5
|
import { shortcutManager } from '../services/shortcutManager.js';
|
|
6
6
|
import Confirmation from './Confirmation.js';
|
|
7
|
+
import { hasUncommittedChanges } from '../utils/gitUtils.js';
|
|
7
8
|
const DeleteConfirmation = ({ worktrees, onConfirm, onCancel, }) => {
|
|
8
9
|
// Check if any worktrees have branches
|
|
9
10
|
const hasAnyBranches = worktrees.some(wt => wt.branch);
|
|
10
11
|
const [deleteBranch, setDeleteBranch] = useState(true);
|
|
11
|
-
const [view, setView] = useState(
|
|
12
|
+
const [view, setView] = useState('checking-uncommitted');
|
|
12
13
|
const [focusedOption, setFocusedOption] = useState(deleteBranch ? 'deleteBranch' : 'keepBranch');
|
|
14
|
+
const [worktreesWithChanges, setWorktreesWithChanges] = useState([]);
|
|
15
|
+
// Check for uncommitted changes on mount
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
const checkUncommittedChanges = () => {
|
|
18
|
+
const withChanges = worktrees.filter(wt => hasUncommittedChanges(wt.path));
|
|
19
|
+
setWorktreesWithChanges(withChanges);
|
|
20
|
+
if (withChanges.length > 0) {
|
|
21
|
+
setView('uncommitted-warning');
|
|
22
|
+
}
|
|
23
|
+
else if (hasAnyBranches) {
|
|
24
|
+
setView('options');
|
|
25
|
+
}
|
|
26
|
+
else {
|
|
27
|
+
setView('confirm');
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
checkUncommittedChanges();
|
|
31
|
+
}, [worktrees, hasAnyBranches]);
|
|
13
32
|
// Menu items for branch options
|
|
14
33
|
const branchOptions = [
|
|
15
34
|
{
|
|
@@ -53,7 +72,12 @@ const DeleteConfirmation = ({ worktrees, onConfirm, onCancel, }) => {
|
|
|
53
72
|
return false;
|
|
54
73
|
};
|
|
55
74
|
useInput((input, key) => {
|
|
56
|
-
if (
|
|
75
|
+
if (view === 'uncommitted-warning') {
|
|
76
|
+
if (shortcutManager.matchesShortcut('cancel', input, key)) {
|
|
77
|
+
onCancel();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
else if (hasAnyBranches && view === 'options') {
|
|
57
81
|
if (handleBranchOptionsInput(input, key)) {
|
|
58
82
|
return;
|
|
59
83
|
}
|
|
@@ -66,6 +90,33 @@ const DeleteConfirmation = ({ worktrees, onConfirm, onCancel, }) => {
|
|
|
66
90
|
const title = (_jsx(Text, { bold: true, color: "red", children: "\u26A0\uFE0F Delete Confirmation" }));
|
|
67
91
|
// Message component
|
|
68
92
|
const message = (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "You are about to delete the following worktrees:" }), worktrees.length <= 10 ? (worktrees.map(wt => (_jsxs(Text, { color: "red", children: ["\u2022 ", wt.branch ? wt.branch.replace('refs/heads/', '') : 'detached', " (", wt.path, ")"] }, wt.path)))) : (_jsxs(_Fragment, { children: [worktrees.slice(0, 8).map(wt => (_jsxs(Text, { color: "red", children: ["\u2022 ", wt.branch ? wt.branch.replace('refs/heads/', '') : 'detached', ' ', "(", wt.path, ")"] }, wt.path))), _jsxs(Text, { color: "red", dimColor: true, children: ["... and ", worktrees.length - 8, " more worktrees"] })] }))] }));
|
|
93
|
+
// Loading state while checking uncommitted changes
|
|
94
|
+
if (view === 'checking-uncommitted') {
|
|
95
|
+
return (_jsx(Box, { flexDirection: "column", children: _jsx(Text, { color: "cyan", children: "Checking for uncommitted changes..." }) }));
|
|
96
|
+
}
|
|
97
|
+
// Uncommitted changes warning view
|
|
98
|
+
if (view === 'uncommitted-warning') {
|
|
99
|
+
const warningTitle = (_jsx(Text, { bold: true, color: "yellow", children: "\u26A0\uFE0F Warning: Uncommitted Changes" }));
|
|
100
|
+
const warningMessage = (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "The following worktrees have uncommitted changes that will be lost:" }), worktreesWithChanges.length <= 10 ? (worktreesWithChanges.map(wt => (_jsxs(Text, { color: "yellow", children: ["\u2022 ", wt.branch ? wt.branch.replace('refs/heads/', '') : 'detached', ' ', "(", wt.path, ")"] }, wt.path)))) : (_jsxs(_Fragment, { children: [worktreesWithChanges.slice(0, 8).map(wt => (_jsxs(Text, { color: "yellow", children: ["\u2022", ' ', wt.branch ? wt.branch.replace('refs/heads/', '') : 'detached', ' ', "(", wt.path, ")"] }, wt.path))), _jsxs(Text, { color: "yellow", dimColor: true, children: ["... and ", worktreesWithChanges.length - 8, " more worktrees"] })] })), _jsx(Box, { marginTop: 1, children: _jsx(Text, { children: "Do you want to continue?" }) })] }));
|
|
101
|
+
const warningHint = (_jsxs(Text, { dimColor: true, children: ["Use \u2191\u2193/j/k to navigate, Enter to select,", ' ', shortcutManager.getShortcutDisplay('cancel'), " to cancel"] }));
|
|
102
|
+
const handleWarningSelect = (value) => {
|
|
103
|
+
if (value === 'yes') {
|
|
104
|
+
if (hasAnyBranches) {
|
|
105
|
+
setView('options');
|
|
106
|
+
}
|
|
107
|
+
else {
|
|
108
|
+
setView('confirm');
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
onCancel();
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
return (_jsx(Confirmation, { title: warningTitle, message: warningMessage, options: [
|
|
116
|
+
{ label: 'Yes', value: 'yes', color: 'green' },
|
|
117
|
+
{ label: 'No', value: 'no', color: 'red' },
|
|
118
|
+
], onSelect: handleWarningSelect, initialIndex: 1, hint: warningHint, onCancel: onCancel }));
|
|
119
|
+
}
|
|
69
120
|
if (hasAnyBranches && view === 'options') {
|
|
70
121
|
return (_jsxs(Box, { flexDirection: "column", children: [title, _jsx(Box, { marginTop: 1, marginBottom: 1, children: message }), _jsxs(Box, { marginBottom: 1, flexDirection: "column", children: [_jsx(Text, { bold: true, children: "What do you want to do with the associated branches?" }), _jsx(Box, { marginTop: 1, children: _jsx(SelectInput, { items: branchOptions, onSelect: handleBranchSelect, onHighlight: (item) => {
|
|
71
122
|
setFocusedOption(item.value);
|
|
@@ -7,6 +7,7 @@ import { WorktreeService } from '../services/worktreeService.js';
|
|
|
7
7
|
import Confirmation, { SimpleConfirmation } from './Confirmation.js';
|
|
8
8
|
import { shortcutManager } from '../services/shortcutManager.js';
|
|
9
9
|
import { GitError } from '../types/errors.js';
|
|
10
|
+
import { hasUncommittedChanges } from '../utils/gitUtils.js';
|
|
10
11
|
const MergeWorktree = ({ onComplete, onCancel, }) => {
|
|
11
12
|
const [step, setStep] = useState('select-source');
|
|
12
13
|
const [sourceBranch, setSourceBranch] = useState('');
|
|
@@ -83,8 +84,8 @@ const MergeWorktree = ({ onComplete, onCancel, }) => {
|
|
|
83
84
|
const performMerge = async () => {
|
|
84
85
|
try {
|
|
85
86
|
await Effect.runPromise(worktreeService.mergeWorktreeEffect(sourceBranch, targetBranch, useRebase));
|
|
86
|
-
// Merge successful,
|
|
87
|
-
setStep('
|
|
87
|
+
// Merge successful, check for uncommitted changes before asking about deletion
|
|
88
|
+
setStep('check-uncommitted');
|
|
88
89
|
}
|
|
89
90
|
catch (err) {
|
|
90
91
|
// Merge failed, show error
|
|
@@ -99,6 +100,29 @@ const MergeWorktree = ({ onComplete, onCancel, }) => {
|
|
|
99
100
|
};
|
|
100
101
|
performMerge();
|
|
101
102
|
}, [step, sourceBranch, targetBranch, useRebase, worktreeService]);
|
|
103
|
+
// Check for uncommitted changes in source worktree when entering check-uncommitted step
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
if (step !== 'check-uncommitted')
|
|
106
|
+
return;
|
|
107
|
+
const checkUncommitted = async () => {
|
|
108
|
+
try {
|
|
109
|
+
// Find the worktree path for the source branch
|
|
110
|
+
const worktrees = await Effect.runPromise(worktreeService.getWorktreesEffect());
|
|
111
|
+
const sourceWorktree = worktrees.find(wt => wt.branch && wt.branch.replace('refs/heads/', '') === sourceBranch);
|
|
112
|
+
if (sourceWorktree && hasUncommittedChanges(sourceWorktree.path)) {
|
|
113
|
+
setStep('uncommitted-warning');
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
setStep('delete-confirm');
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
// On error, proceed to delete-confirm
|
|
121
|
+
setStep('delete-confirm');
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
checkUncommitted();
|
|
125
|
+
}, [step, sourceBranch, worktreeService]);
|
|
102
126
|
if (isLoading) {
|
|
103
127
|
return (_jsx(Box, { flexDirection: "column", children: _jsx(Text, { color: "cyan", children: "Loading worktrees..." }) }));
|
|
104
128
|
}
|
|
@@ -134,6 +158,27 @@ const MergeWorktree = ({ onComplete, onCancel, }) => {
|
|
|
134
158
|
if (step === 'merge-error') {
|
|
135
159
|
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { bold: true, color: "red", children: [useRebase ? 'Rebase' : 'Merge', " Failed"] }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: "red", children: mergeError }) }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { dimColor: true, children: "Press any key to return to menu" }) })] }));
|
|
136
160
|
}
|
|
161
|
+
if (step === 'check-uncommitted') {
|
|
162
|
+
return (_jsx(Box, { flexDirection: "column", children: _jsx(Text, { color: "cyan", children: "Checking for uncommitted changes..." }) }));
|
|
163
|
+
}
|
|
164
|
+
if (step === 'uncommitted-warning') {
|
|
165
|
+
const warningTitle = (_jsx(Text, { bold: true, color: "yellow", children: "Warning: Uncommitted Changes" }));
|
|
166
|
+
const warningMessage = (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: ["The source branch ", _jsx(Text, { color: "yellow", children: sourceBranch }), " has uncommitted changes that will be lost if you delete it."] }), _jsx(Box, { marginTop: 1, children: _jsx(Text, { children: "Do you still want to delete it?" }) })] }));
|
|
167
|
+
const warningHint = (_jsxs(Text, { dimColor: true, children: ["Use \u2191\u2193/j/k to navigate, Enter to select,", ' ', shortcutManager.getShortcutDisplay('cancel'), " to cancel"] }));
|
|
168
|
+
const handleWarningSelect = (value) => {
|
|
169
|
+
if (value === 'yes') {
|
|
170
|
+
setStep('delete-confirm');
|
|
171
|
+
}
|
|
172
|
+
else {
|
|
173
|
+
// User chose not to delete, complete without deletion
|
|
174
|
+
onComplete();
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
return (_jsx(Confirmation, { title: warningTitle, message: warningMessage, options: [
|
|
178
|
+
{ label: 'Yes', value: 'yes', color: 'green' },
|
|
179
|
+
{ label: 'No', value: 'no', color: 'red' },
|
|
180
|
+
], onSelect: handleWarningSelect, initialIndex: 1, hint: warningHint, onCancel: onCancel }));
|
|
181
|
+
}
|
|
137
182
|
if (step === 'delete-confirm') {
|
|
138
183
|
const deleteMessage = (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "green", children: "Delete Source Branch?" }) }), _jsxs(Text, { children: ["Delete the merged branch ", _jsx(Text, { color: "yellow", children: sourceBranch }), ' ', "and its worktree?"] })] }));
|
|
139
184
|
return (_jsx(SimpleConfirmation, { message: deleteMessage, onConfirm: async () => {
|
|
@@ -2,7 +2,7 @@ import { SessionState, Terminal } from '../../types/index.js';
|
|
|
2
2
|
import { StateDetector } from './types.js';
|
|
3
3
|
export declare abstract class BaseStateDetector implements StateDetector {
|
|
4
4
|
abstract detectState(terminal: Terminal, currentState: SessionState): SessionState;
|
|
5
|
-
protected getTerminalLines(terminal: Terminal, maxLines
|
|
6
|
-
protected getTerminalContent(terminal: Terminal, maxLines
|
|
5
|
+
protected getTerminalLines(terminal: Terminal, maxLines: number): string[];
|
|
6
|
+
protected getTerminalContent(terminal: Terminal, maxLines: number): string;
|
|
7
7
|
abstract detectBackgroundTask(terminal: Terminal): number;
|
|
8
8
|
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { getTerminalScreenContent } from '../../utils/screenCapture.js';
|
|
2
2
|
export class BaseStateDetector {
|
|
3
|
-
getTerminalLines(terminal, maxLines
|
|
3
|
+
getTerminalLines(terminal, maxLines) {
|
|
4
4
|
const content = getTerminalScreenContent(terminal, maxLines);
|
|
5
5
|
return content.split('\n');
|
|
6
6
|
}
|
|
7
|
-
getTerminalContent(terminal, maxLines
|
|
7
|
+
getTerminalContent(terminal, maxLines) {
|
|
8
8
|
return getTerminalScreenContent(terminal, maxLines);
|
|
9
9
|
}
|
|
10
10
|
}
|
|
@@ -7,7 +7,7 @@ import { BaseStateDetector } from './base.js';
|
|
|
7
7
|
*/
|
|
8
8
|
export class KimiStateDetector extends BaseStateDetector {
|
|
9
9
|
detectState(terminal, _currentState) {
|
|
10
|
-
const content = this.getTerminalContent(terminal);
|
|
10
|
+
const content = this.getTerminalContent(terminal, 30);
|
|
11
11
|
const lowerContent = content.toLowerCase();
|
|
12
12
|
// Check for permission/confirmation prompts - waiting_input state
|
|
13
13
|
// Kimi CLI uses prompts like "Allow?", "Confirm?", "Yes/No" patterns
|
package/dist/utils/gitUtils.d.ts
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Check if a worktree or repository has uncommitted changes.
|
|
3
|
+
* This includes unstaged changes, staged changes, and untracked files.
|
|
4
|
+
*
|
|
5
|
+
* @param worktreePath - The path to the worktree or repository
|
|
6
|
+
* @returns true if there are uncommitted changes, false if clean
|
|
7
|
+
*/
|
|
8
|
+
export declare function hasUncommittedChanges(worktreePath: string): boolean;
|
|
1
9
|
/**
|
|
2
10
|
* Get the git repository root path from a given directory.
|
|
3
11
|
* For worktrees, this returns the main repository root (parent of .git).
|
package/dist/utils/gitUtils.js
CHANGED
|
@@ -1,5 +1,26 @@
|
|
|
1
1
|
import path from 'path';
|
|
2
2
|
import { execSync } from 'child_process';
|
|
3
|
+
/**
|
|
4
|
+
* Check if a worktree or repository has uncommitted changes.
|
|
5
|
+
* This includes unstaged changes, staged changes, and untracked files.
|
|
6
|
+
*
|
|
7
|
+
* @param worktreePath - The path to the worktree or repository
|
|
8
|
+
* @returns true if there are uncommitted changes, false if clean
|
|
9
|
+
*/
|
|
10
|
+
export function hasUncommittedChanges(worktreePath) {
|
|
11
|
+
try {
|
|
12
|
+
const output = execSync('git status --porcelain', {
|
|
13
|
+
cwd: worktreePath,
|
|
14
|
+
encoding: 'utf8',
|
|
15
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
16
|
+
}).trim();
|
|
17
|
+
return output.length > 0;
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
// Conservative default on error - treat as having changes
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
3
24
|
/**
|
|
4
25
|
* Get the git repository root path from a given directory.
|
|
5
26
|
* For worktrees, this returns the main repository root (parent of .git).
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, it, expect, beforeAll, 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 { hasUncommittedChanges } from './gitUtils.js';
|
|
7
|
+
describe('hasUncommittedChanges', () => {
|
|
8
|
+
// Use os.tmpdir() and unique suffix to avoid conflicts with parallel tests
|
|
9
|
+
// Use realpathSync to resolve symlinks (e.g., /var -> /private/var on macOS)
|
|
10
|
+
const testDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'ccmanager-uncommitted-test-')));
|
|
11
|
+
const mainRepoDir = path.join(testDir, 'main-repo');
|
|
12
|
+
const worktreeDir = path.join(testDir, 'worktree-1');
|
|
13
|
+
beforeAll(() => {
|
|
14
|
+
// Clean up if exists
|
|
15
|
+
if (fs.existsSync(testDir)) {
|
|
16
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
17
|
+
}
|
|
18
|
+
// Create test directory structure
|
|
19
|
+
fs.mkdirSync(testDir, { recursive: true });
|
|
20
|
+
// Create main repository
|
|
21
|
+
fs.mkdirSync(mainRepoDir, { recursive: true });
|
|
22
|
+
execSync('git init', { cwd: mainRepoDir });
|
|
23
|
+
// Set git user for CI environment
|
|
24
|
+
execSync('git config user.email "test@test.com"', { cwd: mainRepoDir });
|
|
25
|
+
execSync('git config user.name "Test User"', { cwd: mainRepoDir });
|
|
26
|
+
fs.writeFileSync(path.join(mainRepoDir, 'README.md'), '# Main Repo');
|
|
27
|
+
execSync('git add README.md', { cwd: mainRepoDir });
|
|
28
|
+
execSync('git commit -m "Initial commit"', { cwd: mainRepoDir });
|
|
29
|
+
// Create a branch and worktree
|
|
30
|
+
execSync('git branch feature-branch', { cwd: mainRepoDir });
|
|
31
|
+
execSync(`git worktree add ${worktreeDir} feature-branch`, {
|
|
32
|
+
cwd: mainRepoDir,
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
afterAll(() => {
|
|
36
|
+
// Clean up worktree first
|
|
37
|
+
try {
|
|
38
|
+
execSync(`git worktree remove ${worktreeDir} --force`, {
|
|
39
|
+
cwd: mainRepoDir,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
// Ignore errors
|
|
44
|
+
}
|
|
45
|
+
// Clean up
|
|
46
|
+
if (fs.existsSync(testDir)) {
|
|
47
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
it('should return false for a clean worktree', () => {
|
|
51
|
+
const result = hasUncommittedChanges(worktreeDir);
|
|
52
|
+
expect(result).toBe(false);
|
|
53
|
+
});
|
|
54
|
+
it('should return true for a worktree with unstaged changes', () => {
|
|
55
|
+
// Create an unstaged change
|
|
56
|
+
fs.writeFileSync(path.join(worktreeDir, 'README.md'), '# Modified');
|
|
57
|
+
const result = hasUncommittedChanges(worktreeDir);
|
|
58
|
+
expect(result).toBe(true);
|
|
59
|
+
// Restore the file
|
|
60
|
+
execSync('git checkout README.md', { cwd: worktreeDir });
|
|
61
|
+
});
|
|
62
|
+
it('should return true for a worktree with staged but uncommitted changes', () => {
|
|
63
|
+
// Create a staged change
|
|
64
|
+
fs.writeFileSync(path.join(worktreeDir, 'new-file.txt'), 'new content');
|
|
65
|
+
execSync('git add new-file.txt', { cwd: worktreeDir });
|
|
66
|
+
const result = hasUncommittedChanges(worktreeDir);
|
|
67
|
+
expect(result).toBe(true);
|
|
68
|
+
// Reset the change
|
|
69
|
+
execSync('git reset HEAD new-file.txt', { cwd: worktreeDir });
|
|
70
|
+
fs.unlinkSync(path.join(worktreeDir, 'new-file.txt'));
|
|
71
|
+
});
|
|
72
|
+
it('should return true for a worktree with untracked files', () => {
|
|
73
|
+
// Create an untracked file
|
|
74
|
+
fs.writeFileSync(path.join(worktreeDir, 'untracked.txt'), 'untracked');
|
|
75
|
+
const result = hasUncommittedChanges(worktreeDir);
|
|
76
|
+
expect(result).toBe(true);
|
|
77
|
+
// Clean up
|
|
78
|
+
fs.unlinkSync(path.join(worktreeDir, 'untracked.txt'));
|
|
79
|
+
});
|
|
80
|
+
it('should return false for a clean main repository', () => {
|
|
81
|
+
const result = hasUncommittedChanges(mainRepoDir);
|
|
82
|
+
expect(result).toBe(false);
|
|
83
|
+
});
|
|
84
|
+
it('should return true for a main repository with uncommitted changes', () => {
|
|
85
|
+
// Create an unstaged change
|
|
86
|
+
fs.writeFileSync(path.join(mainRepoDir, 'README.md'), '# Modified Main');
|
|
87
|
+
const result = hasUncommittedChanges(mainRepoDir);
|
|
88
|
+
expect(result).toBe(true);
|
|
89
|
+
// Restore the file
|
|
90
|
+
execSync('git checkout README.md', { cwd: mainRepoDir });
|
|
91
|
+
});
|
|
92
|
+
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccmanager",
|
|
3
|
-
"version": "3.7.
|
|
3
|
+
"version": "3.7.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.7.
|
|
45
|
-
"@kodaikabasawa/ccmanager-darwin-x64": "3.7.
|
|
46
|
-
"@kodaikabasawa/ccmanager-linux-arm64": "3.7.
|
|
47
|
-
"@kodaikabasawa/ccmanager-linux-x64": "3.7.
|
|
48
|
-
"@kodaikabasawa/ccmanager-win32-x64": "3.7.
|
|
44
|
+
"@kodaikabasawa/ccmanager-darwin-arm64": "3.7.2",
|
|
45
|
+
"@kodaikabasawa/ccmanager-darwin-x64": "3.7.2",
|
|
46
|
+
"@kodaikabasawa/ccmanager-linux-arm64": "3.7.2",
|
|
47
|
+
"@kodaikabasawa/ccmanager-linux-x64": "3.7.2",
|
|
48
|
+
"@kodaikabasawa/ccmanager-win32-x64": "3.7.2"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@eslint/js": "^9.28.0",
|