ccmanager 3.8.1 โ 3.9.0
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/Configuration.js +14 -0
- package/dist/components/ConfigureMerge.d.ts +6 -0
- package/dist/components/ConfigureMerge.js +81 -0
- package/dist/components/MergeWorktree.js +20 -7
- package/dist/services/config/configEditor.d.ts +3 -1
- package/dist/services/config/configEditor.js +13 -0
- package/dist/services/config/configReader.d.ts +2 -1
- package/dist/services/config/configReader.js +12 -0
- package/dist/services/config/globalConfigManager.d.ts +3 -1
- package/dist/services/config/globalConfigManager.js +7 -0
- package/dist/services/config/projectConfigManager.d.ts +3 -1
- package/dist/services/config/projectConfigManager.js +8 -0
- package/dist/services/worktreeService.d.ts +4 -26
- package/dist/services/worktreeService.js +15 -32
- package/dist/services/worktreeService.merge.test.d.ts +1 -0
- package/dist/services/worktreeService.merge.test.js +179 -0
- package/dist/services/worktreeService.test.js +149 -3
- package/dist/types/index.d.ts +9 -1
- package/package.json +6 -6
|
@@ -7,6 +7,7 @@ import ConfigureStatusHooks from './ConfigureStatusHooks.js';
|
|
|
7
7
|
import ConfigureWorktreeHooks from './ConfigureWorktreeHooks.js';
|
|
8
8
|
import ConfigureWorktree from './ConfigureWorktree.js';
|
|
9
9
|
import ConfigureCommand from './ConfigureCommand.js';
|
|
10
|
+
import ConfigureMerge from './ConfigureMerge.js';
|
|
10
11
|
import ConfigureOther from './ConfigureOther.js';
|
|
11
12
|
import { shortcutManager } from '../services/shortcutManager.js';
|
|
12
13
|
import { ConfigEditorProvider } from '../contexts/ConfigEditorContext.js';
|
|
@@ -34,6 +35,10 @@ const ConfigurationContent = ({ scope, onComplete }) => {
|
|
|
34
35
|
label: 'C ๐ Configure Command Presets',
|
|
35
36
|
value: 'presets',
|
|
36
37
|
},
|
|
38
|
+
{
|
|
39
|
+
label: 'M ๐ Configure Merge/Rebase',
|
|
40
|
+
value: 'mergeConfig',
|
|
41
|
+
},
|
|
37
42
|
{
|
|
38
43
|
label: 'O ๐งช Other & Experimental',
|
|
39
44
|
value: 'other',
|
|
@@ -62,6 +67,9 @@ const ConfigurationContent = ({ scope, onComplete }) => {
|
|
|
62
67
|
else if (item.value === 'presets') {
|
|
63
68
|
setView('presets');
|
|
64
69
|
}
|
|
70
|
+
else if (item.value === 'mergeConfig') {
|
|
71
|
+
setView('mergeConfig');
|
|
72
|
+
}
|
|
65
73
|
else if (item.value === 'other') {
|
|
66
74
|
setView('other');
|
|
67
75
|
}
|
|
@@ -90,6 +98,9 @@ const ConfigurationContent = ({ scope, onComplete }) => {
|
|
|
90
98
|
case 'c':
|
|
91
99
|
setView('presets');
|
|
92
100
|
break;
|
|
101
|
+
case 'm':
|
|
102
|
+
setView('mergeConfig');
|
|
103
|
+
break;
|
|
93
104
|
case 'o':
|
|
94
105
|
setView('other');
|
|
95
106
|
break;
|
|
@@ -117,6 +128,9 @@ const ConfigurationContent = ({ scope, onComplete }) => {
|
|
|
117
128
|
if (view === 'presets') {
|
|
118
129
|
return _jsx(ConfigureCommand, { onComplete: handleSubMenuComplete });
|
|
119
130
|
}
|
|
131
|
+
if (view === 'mergeConfig') {
|
|
132
|
+
return _jsx(ConfigureMerge, { onComplete: handleSubMenuComplete });
|
|
133
|
+
}
|
|
120
134
|
if (view === 'other') {
|
|
121
135
|
return _jsx(ConfigureOther, { onComplete: handleSubMenuComplete });
|
|
122
136
|
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { Box, Text, useInput } from 'ink';
|
|
4
|
+
import SelectInput from 'ink-select-input';
|
|
5
|
+
import TextInputWrapper from './TextInputWrapper.js';
|
|
6
|
+
import { useConfigEditor } from '../contexts/ConfigEditorContext.js';
|
|
7
|
+
import { shortcutManager } from '../services/shortcutManager.js';
|
|
8
|
+
const DEFAULT_MERGE_ARGS = ['--no-ff'];
|
|
9
|
+
const DEFAULT_REBASE_ARGS = [];
|
|
10
|
+
const ConfigureMerge = ({ onComplete }) => {
|
|
11
|
+
const configEditor = useConfigEditor();
|
|
12
|
+
const scope = configEditor.getScope();
|
|
13
|
+
const currentConfig = configEditor.getMergeConfig() || {};
|
|
14
|
+
const [mergeConfig, setMergeConfig] = useState(currentConfig);
|
|
15
|
+
const [editField, setEditField] = useState(null);
|
|
16
|
+
const [inputValue, setInputValue] = useState('');
|
|
17
|
+
const isInheriting = scope === 'project' && !configEditor.hasProjectOverride('mergeConfig');
|
|
18
|
+
const getMergeArgs = () => mergeConfig.mergeArgs ?? DEFAULT_MERGE_ARGS;
|
|
19
|
+
const getRebaseArgs = () => mergeConfig.rebaseArgs ?? DEFAULT_REBASE_ARGS;
|
|
20
|
+
const formatArgs = (args) => args.length > 0 ? args.join(' ') : '(none)';
|
|
21
|
+
const menuItems = [
|
|
22
|
+
{
|
|
23
|
+
label: `Merge Arguments: ${formatArgs(getMergeArgs())}`,
|
|
24
|
+
value: 'mergeArgs',
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
label: `Rebase Arguments: ${formatArgs(getRebaseArgs())}`,
|
|
28
|
+
value: 'rebaseArgs',
|
|
29
|
+
},
|
|
30
|
+
{ label: '-----', value: 'separator' },
|
|
31
|
+
{ label: '<- Back', value: 'back' },
|
|
32
|
+
];
|
|
33
|
+
const handleSelect = (item) => {
|
|
34
|
+
if (item.value === 'separator')
|
|
35
|
+
return;
|
|
36
|
+
if (item.value === 'back') {
|
|
37
|
+
onComplete();
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const field = item.value;
|
|
41
|
+
setEditField(field);
|
|
42
|
+
switch (field) {
|
|
43
|
+
case 'mergeArgs':
|
|
44
|
+
setInputValue(getMergeArgs().join(' '));
|
|
45
|
+
break;
|
|
46
|
+
case 'rebaseArgs':
|
|
47
|
+
setInputValue(getRebaseArgs().join(' '));
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
const handleFieldUpdate = (value) => {
|
|
52
|
+
const args = value.trim() ? value.trim().split(/\s+/) : [];
|
|
53
|
+
const updated = { ...mergeConfig, [editField]: args };
|
|
54
|
+
setMergeConfig(updated);
|
|
55
|
+
configEditor.setMergeConfig(updated);
|
|
56
|
+
setEditField(null);
|
|
57
|
+
setInputValue('');
|
|
58
|
+
};
|
|
59
|
+
useInput((input, key) => {
|
|
60
|
+
if (shortcutManager.matchesShortcut('cancel', input, key)) {
|
|
61
|
+
if (editField) {
|
|
62
|
+
setEditField(null);
|
|
63
|
+
setInputValue('');
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
onComplete();
|
|
67
|
+
}
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
if (editField) {
|
|
72
|
+
const titles = {
|
|
73
|
+
mergeArgs: 'Enter merge arguments (space-separated, default: --no-ff):',
|
|
74
|
+
rebaseArgs: 'Enter rebase arguments (space-separated, default: none):',
|
|
75
|
+
};
|
|
76
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsx(Text, { bold: true, color: "green", children: "Edit Merge Config" }) }), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { children: titles[editField] }) }), _jsx(Box, { children: _jsx(TextInputWrapper, { value: inputValue, onChange: setInputValue, onSubmit: handleFieldUpdate, placeholder: "e.g., --no-ff or leave empty" }) }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["Press Enter to save, ", shortcutManager.getShortcutDisplay('cancel'), ' ', "to cancel"] }) })] }));
|
|
77
|
+
}
|
|
78
|
+
const scopeLabel = scope === 'project' ? 'Project' : 'Global';
|
|
79
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { bold: true, color: "green", children: ["Configure Merge/Rebase (", scopeLabel, ")"] }) }), isInheriting && (_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { backgroundColor: "cyan", color: "black", children: [' ', "Inheriting from global configuration", ' '] }) })), _jsx(Box, { marginBottom: 1, children: _jsx(Text, { dimColor: true, children: "Configure arguments for merge and rebase operations" }) }), _jsx(SelectInput, { items: menuItems, onSelect: handleSelect }), _jsx(Box, { marginTop: 1, children: _jsxs(Text, { dimColor: true, children: ["Press Enter to edit, ", shortcutManager.getShortcutDisplay('cancel'), " to go back"] }) })] }));
|
|
80
|
+
};
|
|
81
|
+
export default ConfigureMerge;
|
|
@@ -4,6 +4,7 @@ import { Box, Text, useInput } from 'ink';
|
|
|
4
4
|
import SelectInput from 'ink-select-input';
|
|
5
5
|
import { Effect } from 'effect';
|
|
6
6
|
import { WorktreeService } from '../services/worktreeService.js';
|
|
7
|
+
import { configReader } from '../services/config/configReader.js';
|
|
7
8
|
import Confirmation, { SimpleConfirmation } from './Confirmation.js';
|
|
8
9
|
import { shortcutManager } from '../services/shortcutManager.js';
|
|
9
10
|
import { GitError } from '../types/errors.js';
|
|
@@ -14,9 +15,10 @@ const MergeWorktree = ({ onComplete, onCancel, }) => {
|
|
|
14
15
|
const [targetBranch, setTargetBranch] = useState('');
|
|
15
16
|
const [branchItems, setBranchItems] = useState([]);
|
|
16
17
|
const [originalBranchItems, setOriginalBranchItems] = useState([]);
|
|
17
|
-
const [
|
|
18
|
+
const [operation, setOperation] = useState('merge');
|
|
18
19
|
const [mergeError, setMergeError] = useState(null);
|
|
19
20
|
const [worktreeService] = useState(() => new WorktreeService());
|
|
21
|
+
const [mergeConfig] = useState(() => configReader.getMergeConfig());
|
|
20
22
|
const [isLoading, setIsLoading] = useState(true);
|
|
21
23
|
const [loadError, setLoadError] = useState(null);
|
|
22
24
|
useEffect(() => {
|
|
@@ -83,7 +85,7 @@ const MergeWorktree = ({ onComplete, onCancel, }) => {
|
|
|
83
85
|
return;
|
|
84
86
|
const performMerge = async () => {
|
|
85
87
|
try {
|
|
86
|
-
await Effect.runPromise(worktreeService.mergeWorktreeEffect(sourceBranch, targetBranch,
|
|
88
|
+
await Effect.runPromise(worktreeService.mergeWorktreeEffect(sourceBranch, targetBranch, operation, mergeConfig));
|
|
87
89
|
// Merge successful, check for uncommitted changes before asking about deletion
|
|
88
90
|
setStep('check-uncommitted');
|
|
89
91
|
}
|
|
@@ -99,7 +101,14 @@ const MergeWorktree = ({ onComplete, onCancel, }) => {
|
|
|
99
101
|
}
|
|
100
102
|
};
|
|
101
103
|
performMerge();
|
|
102
|
-
}, [
|
|
104
|
+
}, [
|
|
105
|
+
step,
|
|
106
|
+
sourceBranch,
|
|
107
|
+
targetBranch,
|
|
108
|
+
operation,
|
|
109
|
+
mergeConfig,
|
|
110
|
+
worktreeService,
|
|
111
|
+
]);
|
|
103
112
|
// Check for uncommitted changes in source worktree when entering check-uncommitted step
|
|
104
113
|
useEffect(() => {
|
|
105
114
|
if (step !== 'check-uncommitted')
|
|
@@ -140,7 +149,7 @@ const MergeWorktree = ({ onComplete, onCancel, }) => {
|
|
|
140
149
|
const message = (_jsxs(Text, { children: ["Choose how to integrate ", _jsx(Text, { color: "yellow", children: sourceBranch }), " into", ' ', _jsx(Text, { color: "yellow", children: targetBranch }), ":"] }));
|
|
141
150
|
const hint = (_jsxs(Text, { dimColor: true, children: ["Use \u2191\u2193/j/k to navigate, Enter to select,", ' ', shortcutManager.getShortcutDisplay('cancel'), " to cancel"] }));
|
|
142
151
|
const handleOperationSelect = (value) => {
|
|
143
|
-
|
|
152
|
+
setOperation(value);
|
|
144
153
|
setStep('confirm-merge');
|
|
145
154
|
};
|
|
146
155
|
return (_jsx(Confirmation, { title: title, message: message, options: [
|
|
@@ -149,14 +158,18 @@ const MergeWorktree = ({ onComplete, onCancel, }) => {
|
|
|
149
158
|
], onSelect: handleOperationSelect, initialIndex: 0, hint: hint }));
|
|
150
159
|
}
|
|
151
160
|
if (step === 'confirm-merge') {
|
|
152
|
-
const
|
|
161
|
+
const operationLabel = operation === 'rebase' ? 'Rebase' : 'Merge';
|
|
162
|
+
const preposition = operation === 'rebase' ? 'onto' : 'into';
|
|
163
|
+
const confirmMessage = (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { bold: true, color: "green", children: ["Confirm ", operationLabel] }) }), _jsxs(Text, { children: [operationLabel, " ", _jsx(Text, { color: "yellow", children: sourceBranch }), ' ', preposition, " ", _jsx(Text, { color: "yellow", children: targetBranch }), "?"] })] }));
|
|
153
164
|
return (_jsx(SimpleConfirmation, { message: confirmMessage, onConfirm: () => setStep('executing-merge'), onCancel: onCancel }));
|
|
154
165
|
}
|
|
155
166
|
if (step === 'executing-merge') {
|
|
156
|
-
|
|
167
|
+
const executingLabel = operation === 'rebase' ? 'Rebasing' : 'Merging';
|
|
168
|
+
return (_jsx(Box, { flexDirection: "column", children: _jsxs(Text, { color: "green", children: [executingLabel, " branches..."] }) }));
|
|
157
169
|
}
|
|
158
170
|
if (step === 'merge-error') {
|
|
159
|
-
|
|
171
|
+
const errorLabel = operation === 'rebase' ? 'Rebase' : 'Merge';
|
|
172
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Box, { marginBottom: 1, children: _jsxs(Text, { bold: true, color: "red", children: [errorLabel, " 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" }) })] }));
|
|
160
173
|
}
|
|
161
174
|
if (step === 'check-uncommitted') {
|
|
162
175
|
return (_jsx(Box, { flexDirection: "column", children: _jsx(Text, { color: "cyan", children: "Checking for uncommitted changes..." }) }));
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ConfigScope, ProjectConfigurationData, ShortcutConfig, StatusHookConfig, WorktreeHookConfig, WorktreeConfig, CommandPresetsConfig, IConfigEditor, AutoApprovalConfig } from '../../types/index.js';
|
|
1
|
+
import { ConfigScope, ProjectConfigurationData, ShortcutConfig, StatusHookConfig, WorktreeHookConfig, WorktreeConfig, CommandPresetsConfig, MergeConfig, IConfigEditor, AutoApprovalConfig } from '../../types/index.js';
|
|
2
2
|
/**
|
|
3
3
|
* ConfigEditor provides scope-aware configuration editing.
|
|
4
4
|
* The scope is determined at construction time.
|
|
@@ -24,6 +24,8 @@ export declare class ConfigEditor implements IConfigEditor {
|
|
|
24
24
|
setWorktreeConfig(value: WorktreeConfig): void;
|
|
25
25
|
getCommandPresets(): CommandPresetsConfig | undefined;
|
|
26
26
|
setCommandPresets(value: CommandPresetsConfig): void;
|
|
27
|
+
getMergeConfig(): MergeConfig | undefined;
|
|
28
|
+
setMergeConfig(value: MergeConfig): void;
|
|
27
29
|
getAutoApprovalConfig(): AutoApprovalConfig | undefined;
|
|
28
30
|
setAutoApprovalConfig(value: AutoApprovalConfig): void;
|
|
29
31
|
reload(): void;
|
|
@@ -77,6 +77,19 @@ export class ConfigEditor {
|
|
|
77
77
|
setCommandPresets(value) {
|
|
78
78
|
this.configEditor.setCommandPresets(value);
|
|
79
79
|
}
|
|
80
|
+
getMergeConfig() {
|
|
81
|
+
const globalConfig = globalConfigManager.getMergeConfig();
|
|
82
|
+
const scopedConfig = this.configEditor.getMergeConfig();
|
|
83
|
+
if (!globalConfig && !scopedConfig)
|
|
84
|
+
return undefined;
|
|
85
|
+
return {
|
|
86
|
+
...(globalConfig || {}),
|
|
87
|
+
...(scopedConfig || {}),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
setMergeConfig(value) {
|
|
91
|
+
this.configEditor.setMergeConfig(value);
|
|
92
|
+
}
|
|
80
93
|
getAutoApprovalConfig() {
|
|
81
94
|
const globalConfig = globalConfigManager.getAutoApprovalConfig();
|
|
82
95
|
const scopedConfig = this.configEditor.getAutoApprovalConfig();
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Either } from 'effect';
|
|
2
|
-
import { ShortcutConfig, StatusHookConfig, WorktreeHookConfig, WorktreeConfig, CommandPresetsConfig, CommandPreset, ConfigurationData, IConfigReader } from '../../types/index.js';
|
|
2
|
+
import { ShortcutConfig, StatusHookConfig, WorktreeHookConfig, WorktreeConfig, CommandPresetsConfig, MergeConfig, CommandPreset, ConfigurationData, IConfigReader } from '../../types/index.js';
|
|
3
3
|
import { ValidationError } from '../../types/errors.js';
|
|
4
4
|
/**
|
|
5
5
|
* ConfigReader provides merged configuration reading for runtime components.
|
|
@@ -14,6 +14,7 @@ export declare class ConfigReader implements IConfigReader {
|
|
|
14
14
|
getWorktreeHooks(): WorktreeHookConfig;
|
|
15
15
|
getWorktreeConfig(): WorktreeConfig;
|
|
16
16
|
getCommandPresets(): CommandPresetsConfig;
|
|
17
|
+
getMergeConfig(): MergeConfig | undefined;
|
|
17
18
|
getConfiguration(): ConfigurationData;
|
|
18
19
|
getAutoApprovalConfig(): NonNullable<ConfigurationData['autoApproval']>;
|
|
19
20
|
isAutoApprovalEnabled(): boolean;
|
|
@@ -57,6 +57,17 @@ export class ConfigReader {
|
|
|
57
57
|
...(projectConfig || {}),
|
|
58
58
|
};
|
|
59
59
|
}
|
|
60
|
+
// Merge Config - returns merged value (project fields override global fields)
|
|
61
|
+
getMergeConfig() {
|
|
62
|
+
const globalConfig = globalConfigManager.getMergeConfig();
|
|
63
|
+
const projectConfig = projectConfigManager.getMergeConfig();
|
|
64
|
+
if (!globalConfig && !projectConfig)
|
|
65
|
+
return undefined;
|
|
66
|
+
return {
|
|
67
|
+
...(globalConfig || {}),
|
|
68
|
+
...(projectConfig || {}),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
60
71
|
// Get full merged configuration
|
|
61
72
|
getConfiguration() {
|
|
62
73
|
return {
|
|
@@ -65,6 +76,7 @@ export class ConfigReader {
|
|
|
65
76
|
worktreeHooks: this.getWorktreeHooks(),
|
|
66
77
|
worktree: this.getWorktreeConfig(),
|
|
67
78
|
commandPresets: this.getCommandPresets(),
|
|
79
|
+
mergeConfig: this.getMergeConfig(),
|
|
68
80
|
autoApproval: this.getAutoApprovalConfig(),
|
|
69
81
|
};
|
|
70
82
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ConfigurationData, StatusHookConfig, WorktreeHookConfig, ShortcutConfig, WorktreeConfig, CommandPresetsConfig, IConfigEditor } from '../../types/index.js';
|
|
1
|
+
import { ConfigurationData, StatusHookConfig, WorktreeHookConfig, ShortcutConfig, WorktreeConfig, CommandPresetsConfig, MergeConfig, IConfigEditor } from '../../types/index.js';
|
|
2
2
|
declare class GlobalConfigManager implements IConfigEditor {
|
|
3
3
|
private configPath;
|
|
4
4
|
private legacyShortcutsPath;
|
|
@@ -21,6 +21,8 @@ declare class GlobalConfigManager implements IConfigEditor {
|
|
|
21
21
|
private ensureDefaultPresets;
|
|
22
22
|
getCommandPresets(): CommandPresetsConfig;
|
|
23
23
|
setCommandPresets(presets: CommandPresetsConfig): void;
|
|
24
|
+
getMergeConfig(): MergeConfig | undefined;
|
|
25
|
+
setMergeConfig(mergeConfig: MergeConfig): void;
|
|
24
26
|
/**
|
|
25
27
|
* Reload configuration from disk
|
|
26
28
|
*/
|
|
@@ -191,6 +191,13 @@ class GlobalConfigManager {
|
|
|
191
191
|
this.config.commandPresets = presets;
|
|
192
192
|
this.saveConfig();
|
|
193
193
|
}
|
|
194
|
+
getMergeConfig() {
|
|
195
|
+
return this.config.mergeConfig;
|
|
196
|
+
}
|
|
197
|
+
setMergeConfig(mergeConfig) {
|
|
198
|
+
this.config.mergeConfig = mergeConfig;
|
|
199
|
+
this.saveConfig();
|
|
200
|
+
}
|
|
194
201
|
/**
|
|
195
202
|
* Reload configuration from disk
|
|
196
203
|
*/
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ProjectConfigurationData, ShortcutConfig, StatusHookConfig, WorktreeHookConfig, WorktreeConfig, CommandPresetsConfig, IConfigEditor, AutoApprovalConfig } from '../../types/index.js';
|
|
1
|
+
import { ProjectConfigurationData, ShortcutConfig, StatusHookConfig, WorktreeHookConfig, WorktreeConfig, CommandPresetsConfig, MergeConfig, IConfigEditor, AutoApprovalConfig } from '../../types/index.js';
|
|
2
2
|
/**
|
|
3
3
|
* ProjectConfigManager handles project-specific configuration.
|
|
4
4
|
* Reads/writes from `<git repository root>/.ccmanager.json`.
|
|
@@ -22,6 +22,8 @@ declare class ProjectConfigManager implements IConfigEditor {
|
|
|
22
22
|
setWorktreeConfig(value: WorktreeConfig): void;
|
|
23
23
|
getCommandPresets(): CommandPresetsConfig | undefined;
|
|
24
24
|
setCommandPresets(value: CommandPresetsConfig): void;
|
|
25
|
+
getMergeConfig(): MergeConfig | undefined;
|
|
26
|
+
setMergeConfig(value: MergeConfig): void;
|
|
25
27
|
getAutoApprovalConfig(): AutoApprovalConfig | undefined;
|
|
26
28
|
setAutoApprovalConfig(value: AutoApprovalConfig): void;
|
|
27
29
|
reload(): void;
|
|
@@ -110,6 +110,14 @@ class ProjectConfigManager {
|
|
|
110
110
|
config.commandPresets = value;
|
|
111
111
|
this.saveProjectConfig();
|
|
112
112
|
}
|
|
113
|
+
getMergeConfig() {
|
|
114
|
+
return this.projectConfig?.mergeConfig;
|
|
115
|
+
}
|
|
116
|
+
setMergeConfig(value) {
|
|
117
|
+
const config = this.ensureProjectConfig();
|
|
118
|
+
config.mergeConfig = value;
|
|
119
|
+
this.saveProjectConfig();
|
|
120
|
+
}
|
|
113
121
|
getAutoApprovalConfig() {
|
|
114
122
|
return this.projectConfig?.autoApproval;
|
|
115
123
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Effect } from 'effect';
|
|
2
|
-
import { Worktree } from '../types/index.js';
|
|
2
|
+
import { Worktree, MergeConfig } from '../types/index.js';
|
|
3
3
|
import { GitError, FileSystemError, ProcessError } from '../types/errors.js';
|
|
4
4
|
/**
|
|
5
5
|
* Get all worktree last opened timestamps
|
|
@@ -361,33 +361,11 @@ export declare class WorktreeService {
|
|
|
361
361
|
*
|
|
362
362
|
* @param {string} sourceBranch - Branch to merge from
|
|
363
363
|
* @param {string} targetBranch - Branch to merge into
|
|
364
|
-
* @param {
|
|
364
|
+
* @param {'merge' | 'rebase'} operation - The merge operation to perform (default: 'merge')
|
|
365
|
+
* @param {MergeConfig} mergeConfig - Optional configuration for merge/rebase arguments
|
|
365
366
|
* @returns {Effect.Effect<void, GitError, never>} Effect that completes successfully or fails with GitError
|
|
366
367
|
*
|
|
367
|
-
* @example
|
|
368
|
-
* ```typescript
|
|
369
|
-
* // Merge with Effect.all for parallel operations
|
|
370
|
-
* await Effect.runPromise(
|
|
371
|
-
* Effect.all([
|
|
372
|
-
* effect1,
|
|
373
|
-
* effect2
|
|
374
|
-
* ], {concurrency: 1}) // Sequential to avoid conflicts
|
|
375
|
-
* );
|
|
376
|
-
*
|
|
377
|
-
* // Or use Effect.catchAll for fallback behavior
|
|
378
|
-
* const result = await Effect.runPromise(
|
|
379
|
-
* Effect.catchAll(
|
|
380
|
-
* effect,
|
|
381
|
-
* (error: GitError) => {
|
|
382
|
-
* console.error(`Merge failed: ${error.stderr}`);
|
|
383
|
-
* // Return alternative Effect or rethrow
|
|
384
|
-
* return Effect.fail(error);
|
|
385
|
-
* }
|
|
386
|
-
* )
|
|
387
|
-
* );
|
|
388
|
-
* ```
|
|
389
|
-
*
|
|
390
368
|
* @throws {GitError} When git merge/rebase command fails or worktrees not found
|
|
391
369
|
*/
|
|
392
|
-
mergeWorktreeEffect(sourceBranch: string, targetBranch: string,
|
|
370
|
+
mergeWorktreeEffect(sourceBranch: string, targetBranch: string, operation?: 'merge' | 'rebase', mergeConfig?: MergeConfig): Effect.Effect<void, GitError, never>;
|
|
393
371
|
}
|
|
@@ -944,35 +944,13 @@ export class WorktreeService {
|
|
|
944
944
|
*
|
|
945
945
|
* @param {string} sourceBranch - Branch to merge from
|
|
946
946
|
* @param {string} targetBranch - Branch to merge into
|
|
947
|
-
* @param {
|
|
947
|
+
* @param {'merge' | 'rebase'} operation - The merge operation to perform (default: 'merge')
|
|
948
|
+
* @param {MergeConfig} mergeConfig - Optional configuration for merge/rebase arguments
|
|
948
949
|
* @returns {Effect.Effect<void, GitError, never>} Effect that completes successfully or fails with GitError
|
|
949
950
|
*
|
|
950
|
-
* @example
|
|
951
|
-
* ```typescript
|
|
952
|
-
* // Merge with Effect.all for parallel operations
|
|
953
|
-
* await Effect.runPromise(
|
|
954
|
-
* Effect.all([
|
|
955
|
-
* effect1,
|
|
956
|
-
* effect2
|
|
957
|
-
* ], {concurrency: 1}) // Sequential to avoid conflicts
|
|
958
|
-
* );
|
|
959
|
-
*
|
|
960
|
-
* // Or use Effect.catchAll for fallback behavior
|
|
961
|
-
* const result = await Effect.runPromise(
|
|
962
|
-
* Effect.catchAll(
|
|
963
|
-
* effect,
|
|
964
|
-
* (error: GitError) => {
|
|
965
|
-
* console.error(`Merge failed: ${error.stderr}`);
|
|
966
|
-
* // Return alternative Effect or rethrow
|
|
967
|
-
* return Effect.fail(error);
|
|
968
|
-
* }
|
|
969
|
-
* )
|
|
970
|
-
* );
|
|
971
|
-
* ```
|
|
972
|
-
*
|
|
973
951
|
* @throws {GitError} When git merge/rebase command fails or worktrees not found
|
|
974
952
|
*/
|
|
975
|
-
mergeWorktreeEffect(sourceBranch, targetBranch,
|
|
953
|
+
mergeWorktreeEffect(sourceBranch, targetBranch, operation = 'merge', mergeConfig) {
|
|
976
954
|
// eslint-disable-next-line @typescript-eslint/no-this-alias
|
|
977
955
|
const self = this;
|
|
978
956
|
return Effect.gen(function* () {
|
|
@@ -981,13 +959,13 @@ export class WorktreeService {
|
|
|
981
959
|
const targetWorktree = worktrees.find(wt => wt.branch && wt.branch.replace('refs/heads/', '') === targetBranch);
|
|
982
960
|
if (!targetWorktree) {
|
|
983
961
|
return yield* Effect.fail(new GitError({
|
|
984
|
-
command:
|
|
962
|
+
command: operation === 'rebase' ? 'git rebase' : 'git merge',
|
|
985
963
|
exitCode: 1,
|
|
986
964
|
stderr: 'Target branch worktree not found',
|
|
987
965
|
}));
|
|
988
966
|
}
|
|
989
|
-
|
|
990
|
-
|
|
967
|
+
if (operation === 'rebase') {
|
|
968
|
+
const rebaseArgs = mergeConfig?.rebaseArgs ?? [];
|
|
991
969
|
// For rebase, we need to checkout source branch and rebase it onto target
|
|
992
970
|
const sourceWorktree = worktrees.find(wt => wt.branch && wt.branch.replace('refs/heads/', '') === sourceBranch);
|
|
993
971
|
if (!sourceWorktree) {
|
|
@@ -998,9 +976,12 @@ export class WorktreeService {
|
|
|
998
976
|
}));
|
|
999
977
|
}
|
|
1000
978
|
// Rebase source branch onto target branch
|
|
979
|
+
const rebaseCmd = `git rebase ${rebaseArgs.join(' ')} "${targetBranch}"`
|
|
980
|
+
.replace(/\s+/g, ' ')
|
|
981
|
+
.trim();
|
|
1001
982
|
yield* Effect.try({
|
|
1002
983
|
try: () => {
|
|
1003
|
-
execSync(
|
|
984
|
+
execSync(rebaseCmd, {
|
|
1004
985
|
cwd: sourceWorktree.path,
|
|
1005
986
|
encoding: 'utf8',
|
|
1006
987
|
});
|
|
@@ -1008,7 +989,7 @@ export class WorktreeService {
|
|
|
1008
989
|
catch: (error) => {
|
|
1009
990
|
const execError = error;
|
|
1010
991
|
return new GitError({
|
|
1011
|
-
command:
|
|
992
|
+
command: rebaseCmd,
|
|
1012
993
|
exitCode: execError.status || 1,
|
|
1013
994
|
stderr: execError.stderr || String(error),
|
|
1014
995
|
stdout: execError.stdout,
|
|
@@ -1036,9 +1017,11 @@ export class WorktreeService {
|
|
|
1036
1017
|
}
|
|
1037
1018
|
else {
|
|
1038
1019
|
// Regular merge
|
|
1020
|
+
const mergeArgs = mergeConfig?.mergeArgs ?? ['--no-ff'];
|
|
1021
|
+
const mergeCmd = `git merge ${mergeArgs.join(' ')} "${sourceBranch}"`;
|
|
1039
1022
|
yield* Effect.try({
|
|
1040
1023
|
try: () => {
|
|
1041
|
-
execSync(
|
|
1024
|
+
execSync(mergeCmd, {
|
|
1042
1025
|
cwd: targetWorktree.path,
|
|
1043
1026
|
encoding: 'utf8',
|
|
1044
1027
|
});
|
|
@@ -1046,7 +1029,7 @@ export class WorktreeService {
|
|
|
1046
1029
|
catch: (error) => {
|
|
1047
1030
|
const execError = error;
|
|
1048
1031
|
return new GitError({
|
|
1049
|
-
command:
|
|
1032
|
+
command: mergeCmd,
|
|
1050
1033
|
exitCode: execError.status || 1,
|
|
1051
1034
|
stderr: execError.stderr || String(error),
|
|
1052
1035
|
stdout: execError.stdout,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import { describe, it, expect, afterAll, beforeEach } 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 { Effect } from 'effect';
|
|
7
|
+
import { WorktreeService } from './worktreeService.js';
|
|
8
|
+
describe('WorktreeService mergeWorktreeEffect (real git)', () => {
|
|
9
|
+
const testDir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'ccmanager-merge-test-')));
|
|
10
|
+
const mainRepoDir = path.join(testDir, 'main-repo');
|
|
11
|
+
const sourceWorktreeDir = path.join(testDir, 'wt-source');
|
|
12
|
+
const targetWorktreeDir = path.join(testDir, 'wt-target');
|
|
13
|
+
const gitOpts = (cwd) => ({ cwd, encoding: 'utf8' });
|
|
14
|
+
const getCommitCount = (cwd) => {
|
|
15
|
+
const log = execSync('git log --oneline', gitOpts(cwd)).trim();
|
|
16
|
+
return log.split('\n').filter(l => l.length > 0).length;
|
|
17
|
+
};
|
|
18
|
+
const getLastCommitMessage = (cwd) => {
|
|
19
|
+
return execSync('git log -1 --format=%s', gitOpts(cwd)).trim();
|
|
20
|
+
};
|
|
21
|
+
const branchContainsFile = (cwd, filename) => {
|
|
22
|
+
return fs.existsSync(path.join(cwd, filename));
|
|
23
|
+
};
|
|
24
|
+
// Set up a fresh repo with source and target worktrees before each test
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
// Clean up previous iteration
|
|
27
|
+
if (fs.existsSync(mainRepoDir)) {
|
|
28
|
+
// Remove worktrees before deleting repo
|
|
29
|
+
try {
|
|
30
|
+
execSync(`git worktree remove "${sourceWorktreeDir}" --force`, gitOpts(mainRepoDir));
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
/* ignore */
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
execSync(`git worktree remove "${targetWorktreeDir}" --force`, gitOpts(mainRepoDir));
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
/* ignore */
|
|
40
|
+
}
|
|
41
|
+
fs.rmSync(mainRepoDir, { recursive: true, force: true });
|
|
42
|
+
}
|
|
43
|
+
if (fs.existsSync(sourceWorktreeDir)) {
|
|
44
|
+
fs.rmSync(sourceWorktreeDir, { recursive: true, force: true });
|
|
45
|
+
}
|
|
46
|
+
if (fs.existsSync(targetWorktreeDir)) {
|
|
47
|
+
fs.rmSync(targetWorktreeDir, { recursive: true, force: true });
|
|
48
|
+
}
|
|
49
|
+
// Create main repository with initial commit
|
|
50
|
+
fs.mkdirSync(mainRepoDir, { recursive: true });
|
|
51
|
+
execSync('git init', gitOpts(mainRepoDir));
|
|
52
|
+
execSync('git config user.email "test@test.com"', gitOpts(mainRepoDir));
|
|
53
|
+
execSync('git config user.name "Test User"', gitOpts(mainRepoDir));
|
|
54
|
+
fs.writeFileSync(path.join(mainRepoDir, 'README.md'), '# Test Repo');
|
|
55
|
+
execSync('git add README.md', gitOpts(mainRepoDir));
|
|
56
|
+
execSync('git commit -m "Initial commit"', gitOpts(mainRepoDir));
|
|
57
|
+
// Create target branch + worktree
|
|
58
|
+
execSync('git branch target-branch', gitOpts(mainRepoDir));
|
|
59
|
+
execSync(`git worktree add "${targetWorktreeDir}" target-branch`, gitOpts(mainRepoDir));
|
|
60
|
+
// Create source branch + worktree
|
|
61
|
+
execSync('git branch source-branch', gitOpts(mainRepoDir));
|
|
62
|
+
execSync(`git worktree add "${sourceWorktreeDir}" source-branch`, gitOpts(mainRepoDir));
|
|
63
|
+
// Add commits to source branch
|
|
64
|
+
fs.writeFileSync(path.join(sourceWorktreeDir, 'feature-1.txt'), 'feature 1');
|
|
65
|
+
execSync('git add feature-1.txt', gitOpts(sourceWorktreeDir));
|
|
66
|
+
execSync('git commit -m "feat: add feature 1"', gitOpts(sourceWorktreeDir));
|
|
67
|
+
fs.writeFileSync(path.join(sourceWorktreeDir, 'feature-2.txt'), 'feature 2');
|
|
68
|
+
execSync('git add feature-2.txt', gitOpts(sourceWorktreeDir));
|
|
69
|
+
execSync('git commit -m "feat: add feature 2"', gitOpts(sourceWorktreeDir));
|
|
70
|
+
});
|
|
71
|
+
afterAll(() => {
|
|
72
|
+
if (fs.existsSync(testDir)) {
|
|
73
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
it('should merge with default --no-ff args', async () => {
|
|
77
|
+
const service = new WorktreeService(mainRepoDir);
|
|
78
|
+
await Effect.runPromise(service.mergeWorktreeEffect('source-branch', 'target-branch'));
|
|
79
|
+
// Target should now have the source files
|
|
80
|
+
expect(branchContainsFile(targetWorktreeDir, 'feature-1.txt')).toBe(true);
|
|
81
|
+
expect(branchContainsFile(targetWorktreeDir, 'feature-2.txt')).toBe(true);
|
|
82
|
+
// --no-ff creates a merge commit, so target should have:
|
|
83
|
+
// initial commit + merge commit = 4 total (initial + 2 from source + merge commit)
|
|
84
|
+
const count = getCommitCount(targetWorktreeDir);
|
|
85
|
+
expect(count).toBe(4);
|
|
86
|
+
// Last commit should be a merge commit
|
|
87
|
+
const lastMsg = getLastCommitMessage(targetWorktreeDir);
|
|
88
|
+
expect(lastMsg).toContain('Merge');
|
|
89
|
+
});
|
|
90
|
+
it('should merge with custom mergeArgs from MergeConfig', async () => {
|
|
91
|
+
const service = new WorktreeService(mainRepoDir);
|
|
92
|
+
await Effect.runPromise(service.mergeWorktreeEffect('source-branch', 'target-branch', 'merge', {
|
|
93
|
+
mergeArgs: ['--squash'],
|
|
94
|
+
}));
|
|
95
|
+
// Target should have the source files (staged by --squash)
|
|
96
|
+
expect(branchContainsFile(targetWorktreeDir, 'feature-1.txt')).toBe(true);
|
|
97
|
+
expect(branchContainsFile(targetWorktreeDir, 'feature-2.txt')).toBe(true);
|
|
98
|
+
// --squash does not create a merge commit automatically;
|
|
99
|
+
// git merge --squash stages changes but does not commit.
|
|
100
|
+
// The service does NOT auto-commit for plain merge, so check that
|
|
101
|
+
// there is no merge commit โ changes are just staged.
|
|
102
|
+
// Actually let's verify: with --squash there's no extra merge commit
|
|
103
|
+
// The commit count on target should still be 1 (initial) since squash only stages
|
|
104
|
+
const count = getCommitCount(targetWorktreeDir);
|
|
105
|
+
expect(count).toBe(1);
|
|
106
|
+
// Verify there are staged changes ready to commit
|
|
107
|
+
const status = execSync('git status --porcelain', gitOpts(targetWorktreeDir)).trim();
|
|
108
|
+
expect(status).toContain('feature-1.txt');
|
|
109
|
+
expect(status).toContain('feature-2.txt');
|
|
110
|
+
});
|
|
111
|
+
it('should rebase with default (empty) rebaseArgs', async () => {
|
|
112
|
+
const service = new WorktreeService(mainRepoDir);
|
|
113
|
+
await Effect.runPromise(service.mergeWorktreeEffect('source-branch', 'target-branch', 'rebase'));
|
|
114
|
+
// Target should now have the source files via ff-only merge after rebase
|
|
115
|
+
expect(branchContainsFile(targetWorktreeDir, 'feature-1.txt')).toBe(true);
|
|
116
|
+
expect(branchContainsFile(targetWorktreeDir, 'feature-2.txt')).toBe(true);
|
|
117
|
+
// Rebase + ff-only: no merge commit, linear history
|
|
118
|
+
// initial commit + 2 feature commits = 3
|
|
119
|
+
const count = getCommitCount(targetWorktreeDir);
|
|
120
|
+
expect(count).toBe(3);
|
|
121
|
+
// Commit messages should be preserved
|
|
122
|
+
const log = execSync('git log --oneline', gitOpts(targetWorktreeDir)).trim();
|
|
123
|
+
expect(log).toContain('feat: add feature 1');
|
|
124
|
+
expect(log).toContain('feat: add feature 2');
|
|
125
|
+
});
|
|
126
|
+
it('should rebase with custom rebaseArgs from MergeConfig', async () => {
|
|
127
|
+
const service = new WorktreeService(mainRepoDir);
|
|
128
|
+
// --no-stat is a harmless rebase flag that suppresses diffstat
|
|
129
|
+
await Effect.runPromise(service.mergeWorktreeEffect('source-branch', 'target-branch', 'rebase', {
|
|
130
|
+
rebaseArgs: ['--no-stat'],
|
|
131
|
+
}));
|
|
132
|
+
// Should still produce the same result โ linear history
|
|
133
|
+
expect(branchContainsFile(targetWorktreeDir, 'feature-1.txt')).toBe(true);
|
|
134
|
+
expect(branchContainsFile(targetWorktreeDir, 'feature-2.txt')).toBe(true);
|
|
135
|
+
const count = getCommitCount(targetWorktreeDir);
|
|
136
|
+
expect(count).toBe(3);
|
|
137
|
+
});
|
|
138
|
+
it('should use default args when MergeConfig is provided but mergeArgs is undefined', async () => {
|
|
139
|
+
const service = new WorktreeService(mainRepoDir);
|
|
140
|
+
// Provide MergeConfig with only rebaseArgs โ mergeArgs should default to --no-ff
|
|
141
|
+
await Effect.runPromise(service.mergeWorktreeEffect('source-branch', 'target-branch', 'merge', {
|
|
142
|
+
rebaseArgs: ['--no-stat'],
|
|
143
|
+
}));
|
|
144
|
+
expect(branchContainsFile(targetWorktreeDir, 'feature-1.txt')).toBe(true);
|
|
145
|
+
expect(branchContainsFile(targetWorktreeDir, 'feature-2.txt')).toBe(true);
|
|
146
|
+
// --no-ff creates a merge commit: initial + 2 source + merge = 4
|
|
147
|
+
const count = getCommitCount(targetWorktreeDir);
|
|
148
|
+
expect(count).toBe(4);
|
|
149
|
+
const lastMsg = getLastCommitMessage(targetWorktreeDir);
|
|
150
|
+
expect(lastMsg).toContain('Merge');
|
|
151
|
+
});
|
|
152
|
+
it('should fail with GitError when target worktree not found', async () => {
|
|
153
|
+
const service = new WorktreeService(mainRepoDir);
|
|
154
|
+
const result = await Effect.runPromise(Effect.either(service.mergeWorktreeEffect('source-branch', 'nonexistent-branch')));
|
|
155
|
+
expect(result._tag).toBe('Left');
|
|
156
|
+
if (result._tag === 'Left') {
|
|
157
|
+
expect(result.left.stderr).toContain('Target branch worktree not found');
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
it('should fail with GitError on merge conflict', async () => {
|
|
161
|
+
// Create a conflicting commit on the target branch
|
|
162
|
+
fs.writeFileSync(path.join(targetWorktreeDir, 'feature-1.txt'), 'conflicting content');
|
|
163
|
+
execSync('git add feature-1.txt', gitOpts(targetWorktreeDir));
|
|
164
|
+
execSync('git commit -m "conflict: add feature-1 on target"', gitOpts(targetWorktreeDir));
|
|
165
|
+
const service = new WorktreeService(mainRepoDir);
|
|
166
|
+
const result = await Effect.runPromise(Effect.either(service.mergeWorktreeEffect('source-branch', 'target-branch')));
|
|
167
|
+
expect(result._tag).toBe('Left');
|
|
168
|
+
if (result._tag === 'Left') {
|
|
169
|
+
expect(result.left._tag).toBe('GitError');
|
|
170
|
+
}
|
|
171
|
+
// Abort the failed merge to leave repo in clean state
|
|
172
|
+
try {
|
|
173
|
+
execSync('git merge --abort', gitOpts(targetWorktreeDir));
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
/* ignore */
|
|
177
|
+
}
|
|
178
|
+
});
|
|
179
|
+
});
|
|
@@ -781,7 +781,7 @@ branch refs/heads/feature
|
|
|
781
781
|
}
|
|
782
782
|
throw new Error('Command not mocked: ' + cmd);
|
|
783
783
|
});
|
|
784
|
-
const effect = service.mergeWorktreeEffect('feature', 'main',
|
|
784
|
+
const effect = service.mergeWorktreeEffect('feature', 'main', 'merge');
|
|
785
785
|
await Effect.runPromise(effect);
|
|
786
786
|
expect(execSync).toHaveBeenCalledWith('git merge --no-ff "feature"', expect.any(Object));
|
|
787
787
|
});
|
|
@@ -800,7 +800,7 @@ branch refs/heads/main
|
|
|
800
800
|
}
|
|
801
801
|
throw new Error('Command not mocked: ' + cmd);
|
|
802
802
|
});
|
|
803
|
-
const effect = service.mergeWorktreeEffect('feature', 'nonexistent',
|
|
803
|
+
const effect = service.mergeWorktreeEffect('feature', 'nonexistent', 'merge');
|
|
804
804
|
const result = await Effect.runPromise(Effect.either(effect));
|
|
805
805
|
if (result._tag === 'Left') {
|
|
806
806
|
expect(result.left).toBeInstanceOf(GitError);
|
|
@@ -810,6 +810,152 @@ branch refs/heads/main
|
|
|
810
810
|
expect.fail('Should have returned Left with GitError');
|
|
811
811
|
}
|
|
812
812
|
});
|
|
813
|
+
it('should use custom merge args from MergeConfig', async () => {
|
|
814
|
+
mockedExecSync.mockImplementation((cmd, _options) => {
|
|
815
|
+
if (typeof cmd === 'string') {
|
|
816
|
+
if (cmd === 'git rev-parse --git-common-dir') {
|
|
817
|
+
return '/fake/path/.git\n';
|
|
818
|
+
}
|
|
819
|
+
if (cmd === 'git worktree list --porcelain') {
|
|
820
|
+
return `worktree /fake/path
|
|
821
|
+
HEAD abcd1234
|
|
822
|
+
branch refs/heads/main
|
|
823
|
+
|
|
824
|
+
worktree /fake/path/feature
|
|
825
|
+
HEAD efgh5678
|
|
826
|
+
branch refs/heads/feature
|
|
827
|
+
`;
|
|
828
|
+
}
|
|
829
|
+
if (cmd.includes('git merge')) {
|
|
830
|
+
return 'Merge successful';
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
throw new Error('Command not mocked: ' + cmd);
|
|
834
|
+
});
|
|
835
|
+
const effect = service.mergeWorktreeEffect('feature', 'main', 'merge', {
|
|
836
|
+
mergeArgs: ['--squash'],
|
|
837
|
+
});
|
|
838
|
+
await Effect.runPromise(effect);
|
|
839
|
+
expect(execSync).toHaveBeenCalledWith('git merge --squash "feature"', expect.any(Object));
|
|
840
|
+
});
|
|
841
|
+
it('should use default --no-ff when mergeArgs not specified in MergeConfig', async () => {
|
|
842
|
+
mockedExecSync.mockImplementation((cmd, _options) => {
|
|
843
|
+
if (typeof cmd === 'string') {
|
|
844
|
+
if (cmd === 'git rev-parse --git-common-dir') {
|
|
845
|
+
return '/fake/path/.git\n';
|
|
846
|
+
}
|
|
847
|
+
if (cmd === 'git worktree list --porcelain') {
|
|
848
|
+
return `worktree /fake/path
|
|
849
|
+
HEAD abcd1234
|
|
850
|
+
branch refs/heads/main
|
|
851
|
+
|
|
852
|
+
worktree /fake/path/feature
|
|
853
|
+
HEAD efgh5678
|
|
854
|
+
branch refs/heads/feature
|
|
855
|
+
`;
|
|
856
|
+
}
|
|
857
|
+
if (cmd.includes('git merge')) {
|
|
858
|
+
return 'Merge successful';
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
throw new Error('Command not mocked: ' + cmd);
|
|
862
|
+
});
|
|
863
|
+
const effect = service.mergeWorktreeEffect('feature', 'main', 'merge', {
|
|
864
|
+
rebaseArgs: ['--autosquash'],
|
|
865
|
+
});
|
|
866
|
+
await Effect.runPromise(effect);
|
|
867
|
+
expect(execSync).toHaveBeenCalledWith('git merge --no-ff "feature"', expect.any(Object));
|
|
868
|
+
});
|
|
869
|
+
it('should use custom rebase args from MergeConfig', async () => {
|
|
870
|
+
mockedExecSync.mockImplementation((cmd, _options) => {
|
|
871
|
+
if (typeof cmd === 'string') {
|
|
872
|
+
if (cmd === 'git rev-parse --git-common-dir') {
|
|
873
|
+
return '/fake/path/.git\n';
|
|
874
|
+
}
|
|
875
|
+
if (cmd === 'git worktree list --porcelain') {
|
|
876
|
+
return `worktree /fake/path
|
|
877
|
+
HEAD abcd1234
|
|
878
|
+
branch refs/heads/main
|
|
879
|
+
|
|
880
|
+
worktree /fake/path/feature
|
|
881
|
+
HEAD efgh5678
|
|
882
|
+
branch refs/heads/feature
|
|
883
|
+
`;
|
|
884
|
+
}
|
|
885
|
+
if (cmd.includes('git rebase')) {
|
|
886
|
+
return 'Rebase successful';
|
|
887
|
+
}
|
|
888
|
+
if (cmd.includes('git merge --ff-only')) {
|
|
889
|
+
return 'Fast-forward merge successful';
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
throw new Error('Command not mocked: ' + cmd);
|
|
893
|
+
});
|
|
894
|
+
const effect = service.mergeWorktreeEffect('feature', 'main', 'rebase', {
|
|
895
|
+
rebaseArgs: ['--autosquash', '--interactive'],
|
|
896
|
+
});
|
|
897
|
+
await Effect.runPromise(effect);
|
|
898
|
+
expect(execSync).toHaveBeenCalledWith('git rebase --autosquash --interactive "main"', expect.any(Object));
|
|
899
|
+
});
|
|
900
|
+
it('should use empty rebase args by default when MergeConfig has no rebaseArgs', async () => {
|
|
901
|
+
mockedExecSync.mockImplementation((cmd, _options) => {
|
|
902
|
+
if (typeof cmd === 'string') {
|
|
903
|
+
if (cmd === 'git rev-parse --git-common-dir') {
|
|
904
|
+
return '/fake/path/.git\n';
|
|
905
|
+
}
|
|
906
|
+
if (cmd === 'git worktree list --porcelain') {
|
|
907
|
+
return `worktree /fake/path
|
|
908
|
+
HEAD abcd1234
|
|
909
|
+
branch refs/heads/main
|
|
910
|
+
|
|
911
|
+
worktree /fake/path/feature
|
|
912
|
+
HEAD efgh5678
|
|
913
|
+
branch refs/heads/feature
|
|
914
|
+
`;
|
|
915
|
+
}
|
|
916
|
+
if (cmd.includes('git rebase')) {
|
|
917
|
+
return 'Rebase successful';
|
|
918
|
+
}
|
|
919
|
+
if (cmd.includes('git merge --ff-only')) {
|
|
920
|
+
return 'Fast-forward merge successful';
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
throw new Error('Command not mocked: ' + cmd);
|
|
924
|
+
});
|
|
925
|
+
const effect = service.mergeWorktreeEffect('feature', 'main', 'rebase', {
|
|
926
|
+
mergeArgs: ['--squash'],
|
|
927
|
+
});
|
|
928
|
+
await Effect.runPromise(effect);
|
|
929
|
+
expect(execSync).toHaveBeenCalledWith('git rebase "main"', expect.any(Object));
|
|
930
|
+
});
|
|
931
|
+
it('should use multiple merge args from MergeConfig', async () => {
|
|
932
|
+
mockedExecSync.mockImplementation((cmd, _options) => {
|
|
933
|
+
if (typeof cmd === 'string') {
|
|
934
|
+
if (cmd === 'git rev-parse --git-common-dir') {
|
|
935
|
+
return '/fake/path/.git\n';
|
|
936
|
+
}
|
|
937
|
+
if (cmd === 'git worktree list --porcelain') {
|
|
938
|
+
return `worktree /fake/path
|
|
939
|
+
HEAD abcd1234
|
|
940
|
+
branch refs/heads/main
|
|
941
|
+
|
|
942
|
+
worktree /fake/path/feature
|
|
943
|
+
HEAD efgh5678
|
|
944
|
+
branch refs/heads/feature
|
|
945
|
+
`;
|
|
946
|
+
}
|
|
947
|
+
if (cmd.includes('git merge')) {
|
|
948
|
+
return 'Merge successful';
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
throw new Error('Command not mocked: ' + cmd);
|
|
952
|
+
});
|
|
953
|
+
const effect = service.mergeWorktreeEffect('feature', 'main', 'merge', {
|
|
954
|
+
mergeArgs: ['--no-ff', '--no-edit'],
|
|
955
|
+
});
|
|
956
|
+
await Effect.runPromise(effect);
|
|
957
|
+
expect(execSync).toHaveBeenCalledWith('git merge --no-ff --no-edit "feature"', expect.any(Object));
|
|
958
|
+
});
|
|
813
959
|
it('should return Effect that fails with GitError on merge conflict', async () => {
|
|
814
960
|
mockedExecSync.mockImplementation((cmd, _options) => {
|
|
815
961
|
if (typeof cmd === 'string') {
|
|
@@ -835,7 +981,7 @@ branch refs/heads/feature
|
|
|
835
981
|
}
|
|
836
982
|
throw new Error('Command not mocked: ' + cmd);
|
|
837
983
|
});
|
|
838
|
-
const effect = service.mergeWorktreeEffect('feature', 'main',
|
|
984
|
+
const effect = service.mergeWorktreeEffect('feature', 'main', 'merge');
|
|
839
985
|
const result = await Effect.runPromise(Effect.either(effect));
|
|
840
986
|
if (result._tag === 'Left') {
|
|
841
987
|
expect(result.left).toBeInstanceOf(GitError);
|
package/dist/types/index.d.ts
CHANGED
|
@@ -86,6 +86,10 @@ export interface WorktreeConfig {
|
|
|
86
86
|
sortByLastSession?: boolean;
|
|
87
87
|
autoUseDefaultBranch?: boolean;
|
|
88
88
|
}
|
|
89
|
+
export interface MergeConfig {
|
|
90
|
+
mergeArgs?: string[];
|
|
91
|
+
rebaseArgs?: string[];
|
|
92
|
+
}
|
|
89
93
|
export interface CommandPreset {
|
|
90
94
|
id: string;
|
|
91
95
|
name: string;
|
|
@@ -109,6 +113,7 @@ export interface ConfigurationData {
|
|
|
109
113
|
worktreeHooks?: WorktreeHookConfig;
|
|
110
114
|
worktree?: WorktreeConfig;
|
|
111
115
|
commandPresets?: CommandPresetsConfig;
|
|
116
|
+
mergeConfig?: MergeConfig;
|
|
112
117
|
autoApproval?: {
|
|
113
118
|
enabled: boolean;
|
|
114
119
|
customCommand?: string;
|
|
@@ -129,6 +134,7 @@ export interface ProjectConfigurationData {
|
|
|
129
134
|
worktreeHooks?: WorktreeHookConfig;
|
|
130
135
|
worktree?: WorktreeConfig;
|
|
131
136
|
commandPresets?: CommandPresetsConfig;
|
|
137
|
+
mergeConfig?: MergeConfig;
|
|
132
138
|
autoApproval?: AutoApprovalConfig;
|
|
133
139
|
}
|
|
134
140
|
/**
|
|
@@ -142,6 +148,7 @@ export interface IConfigReader {
|
|
|
142
148
|
getWorktreeHooks(): WorktreeHookConfig | undefined;
|
|
143
149
|
getWorktreeConfig(): WorktreeConfig | undefined;
|
|
144
150
|
getCommandPresets(): CommandPresetsConfig | undefined;
|
|
151
|
+
getMergeConfig(): MergeConfig | undefined;
|
|
145
152
|
getAutoApprovalConfig(): AutoApprovalConfig | undefined;
|
|
146
153
|
reload(): void;
|
|
147
154
|
}
|
|
@@ -156,6 +163,7 @@ export interface IConfigEditor extends IConfigReader {
|
|
|
156
163
|
setWorktreeHooks(value: WorktreeHookConfig): void;
|
|
157
164
|
setWorktreeConfig(value: WorktreeConfig): void;
|
|
158
165
|
setCommandPresets(value: CommandPresetsConfig): void;
|
|
166
|
+
setMergeConfig(value: MergeConfig): void;
|
|
159
167
|
setAutoApprovalConfig(value: AutoApprovalConfig): void;
|
|
160
168
|
}
|
|
161
169
|
export interface GitProject {
|
|
@@ -211,5 +219,5 @@ export interface IWorktreeService {
|
|
|
211
219
|
deleteWorktreeEffect(worktreePath: string, options?: {
|
|
212
220
|
deleteBranch?: boolean;
|
|
213
221
|
}): import('effect').Effect.Effect<void, import('../types/errors.js').GitError, never>;
|
|
214
|
-
mergeWorktreeEffect(sourceBranch: string, targetBranch: string,
|
|
222
|
+
mergeWorktreeEffect(sourceBranch: string, targetBranch: string, operation?: 'merge' | 'rebase', mergeConfig?: MergeConfig): import('effect').Effect.Effect<void, import('../types/errors.js').GitError, never>;
|
|
215
223
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccmanager",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.9.0",
|
|
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.
|
|
45
|
-
"@kodaikabasawa/ccmanager-darwin-x64": "3.
|
|
46
|
-
"@kodaikabasawa/ccmanager-linux-arm64": "3.
|
|
47
|
-
"@kodaikabasawa/ccmanager-linux-x64": "3.
|
|
48
|
-
"@kodaikabasawa/ccmanager-win32-x64": "3.
|
|
44
|
+
"@kodaikabasawa/ccmanager-darwin-arm64": "3.9.0",
|
|
45
|
+
"@kodaikabasawa/ccmanager-darwin-x64": "3.9.0",
|
|
46
|
+
"@kodaikabasawa/ccmanager-linux-arm64": "3.9.0",
|
|
47
|
+
"@kodaikabasawa/ccmanager-linux-x64": "3.9.0",
|
|
48
|
+
"@kodaikabasawa/ccmanager-win32-x64": "3.9.0"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@eslint/js": "^9.28.0",
|