ccmanager 4.3.0 → 4.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -0
- package/dist/components/App.js +46 -9
- package/dist/components/DeleteWorktree.js +2 -13
- package/dist/components/Menu.js +22 -16
- package/dist/components/Menu.test.js +37 -1
- package/dist/components/NewWorktree.js +66 -10
- package/dist/components/NewWorktree.test.js +37 -0
- package/dist/components/SessionActions.d.ts +18 -2
- package/dist/components/SessionActions.js +19 -17
- package/dist/components/SessionActions.test.d.ts +1 -0
- package/dist/components/SessionActions.test.js +94 -0
- package/dist/services/worktreeService.d.ts +28 -1
- package/dist/services/worktreeService.js +110 -1
- package/dist/services/worktreeService.test.js +227 -4
- package/dist/types/index.d.ts +29 -2
- package/dist/utils/worktreeInclude.d.ts +33 -0
- package/dist/utils/worktreeInclude.js +100 -0
- package/dist/utils/worktreeInclude.test.d.ts +1 -0
- package/dist/utils/worktreeInclude.test.js +91 -0
- package/dist/utils/worktreeUtils.d.ts +13 -0
- package/dist/utils/worktreeUtils.js +23 -0
- package/dist/utils/worktreeUtils.test.js +20 -1
- package/package.json +6 -6
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { render } from 'ink-testing-library';
|
|
3
|
+
import { useInput } from 'ink';
|
|
4
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
5
|
+
import SessionActions from './SessionActions.js';
|
|
6
|
+
const makeKey = (overrides = {}) => ({
|
|
7
|
+
upArrow: false,
|
|
8
|
+
downArrow: false,
|
|
9
|
+
leftArrow: false,
|
|
10
|
+
rightArrow: false,
|
|
11
|
+
pageDown: false,
|
|
12
|
+
pageUp: false,
|
|
13
|
+
home: false,
|
|
14
|
+
end: false,
|
|
15
|
+
return: false,
|
|
16
|
+
escape: false,
|
|
17
|
+
ctrl: false,
|
|
18
|
+
shift: false,
|
|
19
|
+
tab: false,
|
|
20
|
+
backspace: false,
|
|
21
|
+
delete: false,
|
|
22
|
+
meta: false,
|
|
23
|
+
...overrides,
|
|
24
|
+
});
|
|
25
|
+
// Mock ink to avoid stdin issues and to capture the hotkey handler
|
|
26
|
+
vi.mock('ink', async () => {
|
|
27
|
+
const actual = await vi.importActual('ink');
|
|
28
|
+
return {
|
|
29
|
+
...actual,
|
|
30
|
+
useInput: vi.fn(),
|
|
31
|
+
};
|
|
32
|
+
});
|
|
33
|
+
// Mock SelectInput to render items as simple text
|
|
34
|
+
vi.mock('ink-select-input', async () => {
|
|
35
|
+
const React = await vi.importActual('react');
|
|
36
|
+
const { Text, Box } = await vi.importActual('ink');
|
|
37
|
+
return {
|
|
38
|
+
default: ({ items }) => React.createElement(Box, { flexDirection: 'column' }, items.map((item, index) => React.createElement(Text, { key: index }, item.label))),
|
|
39
|
+
};
|
|
40
|
+
});
|
|
41
|
+
const getLastInputHandler = () => {
|
|
42
|
+
const calls = vi.mocked(useInput).mock.calls;
|
|
43
|
+
const handler = calls[calls.length - 1]?.[0];
|
|
44
|
+
expect(handler).toBeDefined();
|
|
45
|
+
return handler;
|
|
46
|
+
};
|
|
47
|
+
describe('SessionActions', () => {
|
|
48
|
+
beforeEach(() => {
|
|
49
|
+
vi.mocked(useInput).mockClear();
|
|
50
|
+
});
|
|
51
|
+
it('should show session actions and the delete entry for a deletable worktree', () => {
|
|
52
|
+
const { lastFrame } = render(_jsx(SessionActions, { sessionLabel: "Session #1", worktreePath: "/repo/worktrees/feature", hasSession: true, canDeleteWorktree: true, onSelect: vi.fn(), onCancel: vi.fn() }));
|
|
53
|
+
const frame = lastFrame();
|
|
54
|
+
expect(frame).toContain('Session Actions');
|
|
55
|
+
expect(frame).toContain('New session in this worktree');
|
|
56
|
+
expect(frame).toContain('Rename this session');
|
|
57
|
+
expect(frame).toContain('Close session');
|
|
58
|
+
expect(frame).toContain('Delete this worktree');
|
|
59
|
+
});
|
|
60
|
+
it('should hide session-specific actions for a worktree without a session', () => {
|
|
61
|
+
const { lastFrame } = render(_jsx(SessionActions, { worktreePath: "/repo/worktrees/feature", hasSession: false, canDeleteWorktree: true, onSelect: vi.fn(), onCancel: vi.fn() }));
|
|
62
|
+
const frame = lastFrame();
|
|
63
|
+
expect(frame).toContain('Worktree Actions');
|
|
64
|
+
expect(frame).toContain('New session in this worktree');
|
|
65
|
+
expect(frame).toContain('Delete this worktree');
|
|
66
|
+
expect(frame).not.toContain('Rename this session');
|
|
67
|
+
expect(frame).not.toContain('Close session');
|
|
68
|
+
});
|
|
69
|
+
it('should hide the delete entry when the worktree cannot be deleted', () => {
|
|
70
|
+
const { lastFrame } = render(_jsx(SessionActions, { sessionLabel: "Session #1", worktreePath: "/repo", hasSession: true, canDeleteWorktree: false, onSelect: vi.fn(), onCancel: vi.fn() }));
|
|
71
|
+
expect(lastFrame()).not.toContain('Delete this worktree');
|
|
72
|
+
});
|
|
73
|
+
it('should dispatch deleteWorktree on the D hotkey when deletion is offered', () => {
|
|
74
|
+
const onSelect = vi.fn();
|
|
75
|
+
render(_jsx(SessionActions, { worktreePath: "/repo/worktrees/feature", hasSession: false, canDeleteWorktree: true, onSelect: onSelect, onCancel: vi.fn() }));
|
|
76
|
+
getLastInputHandler()('d', makeKey());
|
|
77
|
+
expect(onSelect).toHaveBeenCalledWith('deleteWorktree');
|
|
78
|
+
});
|
|
79
|
+
it('should ignore hotkeys of actions that are not offered', () => {
|
|
80
|
+
const onSelect = vi.fn();
|
|
81
|
+
render(_jsx(SessionActions, { sessionLabel: "Session #1", worktreePath: "/repo", hasSession: true, canDeleteWorktree: false, onSelect: onSelect, onCancel: vi.fn() }));
|
|
82
|
+
const handler = getLastInputHandler();
|
|
83
|
+
handler('d', makeKey());
|
|
84
|
+
expect(onSelect).not.toHaveBeenCalled();
|
|
85
|
+
handler('x', makeKey());
|
|
86
|
+
expect(onSelect).toHaveBeenCalledWith('kill');
|
|
87
|
+
});
|
|
88
|
+
it('should cancel on Escape', () => {
|
|
89
|
+
const onCancel = vi.fn();
|
|
90
|
+
render(_jsx(SessionActions, { sessionLabel: "Session #1", worktreePath: "/repo", hasSession: true, canDeleteWorktree: true, onSelect: vi.fn(), onCancel: onCancel }));
|
|
91
|
+
getLastInputHandler()('', makeKey({ escape: true }));
|
|
92
|
+
expect(onCancel).toHaveBeenCalled();
|
|
93
|
+
});
|
|
94
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Effect } from 'effect';
|
|
2
|
-
import { Worktree, CreateWorktreeResult, AmbiguousBranchError, MergeConfig } from '../types/index.js';
|
|
2
|
+
import { Worktree, CreateWorktreeResult, AmbiguousBranchError, BaseBranchResolution, MergeConfig } from '../types/index.js';
|
|
3
3
|
import { GitError, FileSystemError, ProcessError } from '../types/errors.js';
|
|
4
4
|
/**
|
|
5
5
|
* WorktreeService - Git worktree management with Effect-based error handling
|
|
@@ -54,6 +54,21 @@ export declare class WorktreeService {
|
|
|
54
54
|
*/
|
|
55
55
|
private resolveBranchReference;
|
|
56
56
|
private resolveBranchReferenceEffect;
|
|
57
|
+
/**
|
|
58
|
+
* Classifies a base branch picked in the UI, without throwing.
|
|
59
|
+
*
|
|
60
|
+
* Unlike resolveBranchReference(), this is meant to run right after the
|
|
61
|
+
* user selects a base branch so that:
|
|
62
|
+
* - a local branch is confirmed immediately (never routed to the
|
|
63
|
+
* ambiguous-remote confirmation), and
|
|
64
|
+
* - an ambiguous branch (same name in multiple remotes) can be
|
|
65
|
+
* disambiguated right away instead of failing later at creation time.
|
|
66
|
+
*
|
|
67
|
+
* @param {string} branchName - Branch name or remote-qualified ref
|
|
68
|
+
* (e.g. "feature/x" or "origin/feature/x") selected as base branch
|
|
69
|
+
* @returns {BaseBranchResolution} Classification result (see type docs)
|
|
70
|
+
*/
|
|
71
|
+
resolveBaseBranch(branchName: string): BaseBranchResolution;
|
|
57
72
|
/**
|
|
58
73
|
* SYNCHRONOUS HELPER: Gets all git remotes for this repository.
|
|
59
74
|
*
|
|
@@ -140,6 +155,18 @@ export declare class WorktreeService {
|
|
|
140
155
|
* @throws {FileSystemError} When copying the directory fails
|
|
141
156
|
*/
|
|
142
157
|
private copyClaudeDirectoryFromBaseBranchEffect;
|
|
158
|
+
/**
|
|
159
|
+
* Effect-based copyWorktreeIncludeFiles operation.
|
|
160
|
+
* Copies the gitignored files a `.worktreeinclude` file at the repository
|
|
161
|
+
* root selects (see src/utils/worktreeInclude.ts) into the new worktree.
|
|
162
|
+
* A no-op when no `.worktreeinclude` file exists, so this always runs
|
|
163
|
+
* unconditionally rather than being gated by a config flag.
|
|
164
|
+
*
|
|
165
|
+
* @param {string} gitRoot - Absolute path to the main checkout
|
|
166
|
+
* @param {string} targetWorktreePath - Path of the newly created worktree
|
|
167
|
+
* @returns {Effect.Effect<void, FileSystemError, never>} Effect that completes successfully or fails with FileSystemError
|
|
168
|
+
*/
|
|
169
|
+
private copyWorktreeIncludeFilesEffect;
|
|
143
170
|
/**
|
|
144
171
|
* Effect-based getDefaultBranch operation
|
|
145
172
|
* Returns Effect that may fail with GitError
|
|
@@ -7,6 +7,7 @@ import { GitError, FileSystemError } from '../types/errors.js';
|
|
|
7
7
|
import { setWorktreeParentBranch } from '../utils/worktreeConfig.js';
|
|
8
8
|
import { getClaudeProjectsDir, pathToClaudeProjectName, } from '../utils/claudeDir.js';
|
|
9
9
|
import { executeWorktreePostCreationHook, executeWorktreePreCreationHook, } from '../utils/hookExecutor.js';
|
|
10
|
+
import { copyWorktreeIncludeFiles } from '../utils/worktreeInclude.js';
|
|
10
11
|
import { configReader } from './config/configReader.js';
|
|
11
12
|
import { logger } from '../utils/logger.js';
|
|
12
13
|
const CLAUDE_DIR = '.claude';
|
|
@@ -174,6 +175,79 @@ export class WorktreeService {
|
|
|
174
175
|
},
|
|
175
176
|
});
|
|
176
177
|
}
|
|
178
|
+
/**
|
|
179
|
+
* Classifies a base branch picked in the UI, without throwing.
|
|
180
|
+
*
|
|
181
|
+
* Unlike resolveBranchReference(), this is meant to run right after the
|
|
182
|
+
* user selects a base branch so that:
|
|
183
|
+
* - a local branch is confirmed immediately (never routed to the
|
|
184
|
+
* ambiguous-remote confirmation), and
|
|
185
|
+
* - an ambiguous branch (same name in multiple remotes) can be
|
|
186
|
+
* disambiguated right away instead of failing later at creation time.
|
|
187
|
+
*
|
|
188
|
+
* @param {string} branchName - Branch name or remote-qualified ref
|
|
189
|
+
* (e.g. "feature/x" or "origin/feature/x") selected as base branch
|
|
190
|
+
* @returns {BaseBranchResolution} Classification result (see type docs)
|
|
191
|
+
*/
|
|
192
|
+
resolveBaseBranch(branchName) {
|
|
193
|
+
// Local branch has the highest priority
|
|
194
|
+
try {
|
|
195
|
+
execSync(`git show-ref --verify --quiet refs/heads/${branchName}`, {
|
|
196
|
+
cwd: this.rootPath,
|
|
197
|
+
encoding: 'utf8',
|
|
198
|
+
});
|
|
199
|
+
return { kind: 'local', ref: branchName, localName: branchName };
|
|
200
|
+
}
|
|
201
|
+
catch {
|
|
202
|
+
// Not a local branch, check remotes below
|
|
203
|
+
}
|
|
204
|
+
const remotes = this.getAllRemotes();
|
|
205
|
+
// Already remote-qualified (e.g. "origin/feature/x" selected from the
|
|
206
|
+
// remote section of the branch list): not ambiguous by construction.
|
|
207
|
+
for (const remote of remotes) {
|
|
208
|
+
const prefix = `${remote}/`;
|
|
209
|
+
if (!branchName.startsWith(prefix))
|
|
210
|
+
continue;
|
|
211
|
+
try {
|
|
212
|
+
execSync(`git show-ref --verify --quiet refs/remotes/${branchName}`, {
|
|
213
|
+
cwd: this.rootPath,
|
|
214
|
+
encoding: 'utf8',
|
|
215
|
+
});
|
|
216
|
+
return {
|
|
217
|
+
kind: 'remote',
|
|
218
|
+
ref: branchName,
|
|
219
|
+
localName: branchName.slice(prefix.length),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
// Not an existing remote-tracking ref; fall through to matching
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const matches = [];
|
|
227
|
+
for (const remote of remotes) {
|
|
228
|
+
try {
|
|
229
|
+
execSync(`git show-ref --verify --quiet refs/remotes/${remote}/${branchName}`, {
|
|
230
|
+
cwd: this.rootPath,
|
|
231
|
+
encoding: 'utf8',
|
|
232
|
+
});
|
|
233
|
+
matches.push({
|
|
234
|
+
remote,
|
|
235
|
+
branch: branchName,
|
|
236
|
+
fullRef: `${remote}/${branchName}`,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
// This remote doesn't have the branch, continue
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (matches.length === 1) {
|
|
244
|
+
return { kind: 'remote', ref: matches[0].fullRef, localName: branchName };
|
|
245
|
+
}
|
|
246
|
+
if (matches.length > 1) {
|
|
247
|
+
return { kind: 'ambiguous', branchName, matches };
|
|
248
|
+
}
|
|
249
|
+
return { kind: 'none', ref: branchName, localName: branchName };
|
|
250
|
+
}
|
|
177
251
|
/**
|
|
178
252
|
* SYNCHRONOUS HELPER: Gets all git remotes for this repository.
|
|
179
253
|
*
|
|
@@ -375,6 +449,27 @@ export class WorktreeService {
|
|
|
375
449
|
});
|
|
376
450
|
});
|
|
377
451
|
}
|
|
452
|
+
/**
|
|
453
|
+
* Effect-based copyWorktreeIncludeFiles operation.
|
|
454
|
+
* Copies the gitignored files a `.worktreeinclude` file at the repository
|
|
455
|
+
* root selects (see src/utils/worktreeInclude.ts) into the new worktree.
|
|
456
|
+
* A no-op when no `.worktreeinclude` file exists, so this always runs
|
|
457
|
+
* unconditionally rather than being gated by a config flag.
|
|
458
|
+
*
|
|
459
|
+
* @param {string} gitRoot - Absolute path to the main checkout
|
|
460
|
+
* @param {string} targetWorktreePath - Path of the newly created worktree
|
|
461
|
+
* @returns {Effect.Effect<void, FileSystemError, never>} Effect that completes successfully or fails with FileSystemError
|
|
462
|
+
*/
|
|
463
|
+
copyWorktreeIncludeFilesEffect(gitRoot, targetWorktreePath) {
|
|
464
|
+
return Effect.try({
|
|
465
|
+
try: () => copyWorktreeIncludeFiles(gitRoot, targetWorktreePath),
|
|
466
|
+
catch: (error) => new FileSystemError({
|
|
467
|
+
operation: 'write',
|
|
468
|
+
path: targetWorktreePath,
|
|
469
|
+
cause: String(error),
|
|
470
|
+
}),
|
|
471
|
+
});
|
|
472
|
+
}
|
|
378
473
|
/**
|
|
379
474
|
* Effect-based getDefaultBranch operation
|
|
380
475
|
* Returns Effect that may fail with GitError
|
|
@@ -831,7 +926,13 @@ export class WorktreeService {
|
|
|
831
926
|
command = `git worktree add -b "${branch}" "${resolvedPath}" "${baseBranch}"`;
|
|
832
927
|
}
|
|
833
928
|
else {
|
|
834
|
-
|
|
929
|
+
// The new branch name itself may match remote branches (checkout
|
|
930
|
+
// semantics: typing "feature/x" checks out origin/feature/x when it
|
|
931
|
+
// exists on exactly one remote). When the name exists on MULTIPLE
|
|
932
|
+
// remotes, don't fail with AmbiguousBranchError: the user already
|
|
933
|
+
// chose baseBranch explicitly, so create the new branch from
|
|
934
|
+
// baseBranch instead of asking which remote to track.
|
|
935
|
+
const resolvedRef = yield* Effect.catchAll(self.resolveBranchReferenceEffect(branch), () => Effect.succeed(branch));
|
|
835
936
|
const isRemoteBranch = resolvedRef !== branch;
|
|
836
937
|
const startPoint = isRemoteBranch
|
|
837
938
|
? resolvedRef
|
|
@@ -888,6 +989,14 @@ export class WorktreeService {
|
|
|
888
989
|
return Effect.succeed(undefined);
|
|
889
990
|
});
|
|
890
991
|
}
|
|
992
|
+
// Copy files selected by a .worktreeinclude file, if one exists at
|
|
993
|
+
// the repository root. Runs unconditionally (no config flag) and
|
|
994
|
+
// before the post-creation hook, so hook commands can rely on the
|
|
995
|
+
// copied files (e.g. .env) already being in place.
|
|
996
|
+
yield* Effect.catchAll(self.copyWorktreeIncludeFilesEffect(absoluteGitRoot, resolvedPath), (error) => {
|
|
997
|
+
console.error('Warning: Failed to copy .worktreeinclude files:', error);
|
|
998
|
+
return Effect.succeed(undefined);
|
|
999
|
+
});
|
|
891
1000
|
// Execute post-creation hook if configured
|
|
892
1001
|
const worktreeHooks = configReader.getWorktreeHooks();
|
|
893
1002
|
logger.info('Worktree hook config after creation', {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
2
2
|
import { WorktreeService } from './worktreeService.js';
|
|
3
3
|
import { execSync } from 'child_process';
|
|
4
|
-
import { existsSync, statSync } from 'fs';
|
|
4
|
+
import { existsSync, statSync, cpSync, mkdirSync } from 'fs';
|
|
5
|
+
import path from 'path';
|
|
5
6
|
import { configReader } from './config/configReader.js';
|
|
6
7
|
import { Effect } from 'effect';
|
|
7
8
|
import { GitError, ProcessError } from '../types/errors.js';
|
|
@@ -33,11 +34,20 @@ vi.mock('../utils/hookExecutor.js', () => ({
|
|
|
33
34
|
const mockedExecSync = vi.mocked(execSync);
|
|
34
35
|
const mockedExistsSync = vi.mocked(existsSync);
|
|
35
36
|
const mockedStatSync = vi.mocked(statSync);
|
|
37
|
+
const mockedCpSync = vi.mocked(cpSync);
|
|
38
|
+
const mockedMkdirSync = vi.mocked(mkdirSync);
|
|
36
39
|
const mockedGetWorktreeHooks = vi.mocked(configReader.getWorktreeHooks);
|
|
37
40
|
describe('WorktreeService', () => {
|
|
38
41
|
let service;
|
|
39
42
|
beforeEach(() => {
|
|
40
43
|
vi.clearAllMocks();
|
|
44
|
+
// vi.clearAllMocks() clears call history but not custom implementations,
|
|
45
|
+
// so a test-specific existsSync/statSync mock (e.g. in
|
|
46
|
+
// hasClaudeDirectoryInBranchEffect below) would otherwise leak into
|
|
47
|
+
// every later test in this file. Reset them to the automock default
|
|
48
|
+
// (returns undefined) so each test starts from a clean slate.
|
|
49
|
+
mockedExistsSync.mockReset();
|
|
50
|
+
mockedStatSync.mockReset();
|
|
41
51
|
// Mock git rev-parse --git-common-dir to return a predictable path
|
|
42
52
|
mockedExecSync.mockImplementation((cmd, _options) => {
|
|
43
53
|
if (typeof cmd === 'string' && cmd === 'git rev-parse --git-common-dir') {
|
|
@@ -425,6 +435,133 @@ origin/feature/test
|
|
|
425
435
|
}
|
|
426
436
|
});
|
|
427
437
|
});
|
|
438
|
+
describe('resolveBaseBranch', () => {
|
|
439
|
+
it('should classify a local branch as local without checking remotes', () => {
|
|
440
|
+
mockedExecSync.mockImplementation((cmd, _options) => {
|
|
441
|
+
if (typeof cmd === 'string') {
|
|
442
|
+
if (cmd === 'git rev-parse --git-common-dir') {
|
|
443
|
+
return '/fake/path/.git\n';
|
|
444
|
+
}
|
|
445
|
+
if (cmd === 'git show-ref --verify --quiet refs/heads/feature/x') {
|
|
446
|
+
return ''; // Local branch exists
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
throw new Error('Command not mocked: ' + cmd);
|
|
450
|
+
});
|
|
451
|
+
const result = service.resolveBaseBranch('feature/x');
|
|
452
|
+
expect(result).toEqual({
|
|
453
|
+
kind: 'local',
|
|
454
|
+
ref: 'feature/x',
|
|
455
|
+
localName: 'feature/x',
|
|
456
|
+
});
|
|
457
|
+
});
|
|
458
|
+
it('should classify a remote-qualified ref as remote with the short local name', () => {
|
|
459
|
+
mockedExecSync.mockImplementation((cmd, _options) => {
|
|
460
|
+
if (typeof cmd === 'string') {
|
|
461
|
+
if (cmd === 'git rev-parse --git-common-dir') {
|
|
462
|
+
return '/fake/path/.git\n';
|
|
463
|
+
}
|
|
464
|
+
if (cmd.includes('show-ref --verify --quiet refs/heads/')) {
|
|
465
|
+
throw new Error('Local branch not found');
|
|
466
|
+
}
|
|
467
|
+
if (cmd === 'git remote') {
|
|
468
|
+
return 'origin\nupstream\n';
|
|
469
|
+
}
|
|
470
|
+
if (cmd ===
|
|
471
|
+
'git show-ref --verify --quiet refs/remotes/origin/feature/x') {
|
|
472
|
+
return '';
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
throw new Error('Command not mocked: ' + cmd);
|
|
476
|
+
});
|
|
477
|
+
const result = service.resolveBaseBranch('origin/feature/x');
|
|
478
|
+
expect(result).toEqual({
|
|
479
|
+
kind: 'remote',
|
|
480
|
+
ref: 'origin/feature/x',
|
|
481
|
+
localName: 'feature/x',
|
|
482
|
+
});
|
|
483
|
+
});
|
|
484
|
+
it('should classify a branch existing on a single remote as remote', () => {
|
|
485
|
+
mockedExecSync.mockImplementation((cmd, _options) => {
|
|
486
|
+
if (typeof cmd === 'string') {
|
|
487
|
+
if (cmd === 'git rev-parse --git-common-dir') {
|
|
488
|
+
return '/fake/path/.git\n';
|
|
489
|
+
}
|
|
490
|
+
if (cmd.includes('show-ref --verify --quiet refs/heads/')) {
|
|
491
|
+
throw new Error('Local branch not found');
|
|
492
|
+
}
|
|
493
|
+
if (cmd === 'git remote') {
|
|
494
|
+
return 'origin\nupstream\n';
|
|
495
|
+
}
|
|
496
|
+
if (cmd ===
|
|
497
|
+
'git show-ref --verify --quiet refs/remotes/origin/feature/x') {
|
|
498
|
+
return '';
|
|
499
|
+
}
|
|
500
|
+
if (cmd.includes('show-ref --verify --quiet refs/remotes/')) {
|
|
501
|
+
throw new Error('Remote branch not found');
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
throw new Error('Command not mocked: ' + cmd);
|
|
505
|
+
});
|
|
506
|
+
const result = service.resolveBaseBranch('feature/x');
|
|
507
|
+
expect(result).toEqual({
|
|
508
|
+
kind: 'remote',
|
|
509
|
+
ref: 'origin/feature/x',
|
|
510
|
+
localName: 'feature/x',
|
|
511
|
+
});
|
|
512
|
+
});
|
|
513
|
+
it('should classify a branch existing on multiple remotes as ambiguous', () => {
|
|
514
|
+
mockedExecSync.mockImplementation((cmd, _options) => {
|
|
515
|
+
if (typeof cmd === 'string') {
|
|
516
|
+
if (cmd === 'git rev-parse --git-common-dir') {
|
|
517
|
+
return '/fake/path/.git\n';
|
|
518
|
+
}
|
|
519
|
+
if (cmd.includes('show-ref --verify --quiet refs/heads/')) {
|
|
520
|
+
throw new Error('Local branch not found');
|
|
521
|
+
}
|
|
522
|
+
if (cmd === 'git remote') {
|
|
523
|
+
return 'origin\nupstream\n';
|
|
524
|
+
}
|
|
525
|
+
if (cmd.includes('show-ref --verify --quiet refs/remotes/')) {
|
|
526
|
+
return ''; // Both remotes have the branch
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
throw new Error('Command not mocked: ' + cmd);
|
|
530
|
+
});
|
|
531
|
+
const result = service.resolveBaseBranch('feature/x');
|
|
532
|
+
expect(result).toEqual({
|
|
533
|
+
kind: 'ambiguous',
|
|
534
|
+
branchName: 'feature/x',
|
|
535
|
+
matches: [
|
|
536
|
+
{ remote: 'origin', branch: 'feature/x', fullRef: 'origin/feature/x' },
|
|
537
|
+
{
|
|
538
|
+
remote: 'upstream',
|
|
539
|
+
branch: 'feature/x',
|
|
540
|
+
fullRef: 'upstream/feature/x',
|
|
541
|
+
},
|
|
542
|
+
],
|
|
543
|
+
});
|
|
544
|
+
});
|
|
545
|
+
it('should classify an unknown branch as none', () => {
|
|
546
|
+
mockedExecSync.mockImplementation((cmd, _options) => {
|
|
547
|
+
if (typeof cmd === 'string') {
|
|
548
|
+
if (cmd === 'git rev-parse --git-common-dir') {
|
|
549
|
+
return '/fake/path/.git\n';
|
|
550
|
+
}
|
|
551
|
+
if (cmd === 'git remote') {
|
|
552
|
+
return 'origin\n';
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
throw new Error('Branch not found');
|
|
556
|
+
});
|
|
557
|
+
const result = service.resolveBaseBranch('nonexistent');
|
|
558
|
+
expect(result).toEqual({
|
|
559
|
+
kind: 'none',
|
|
560
|
+
ref: 'nonexistent',
|
|
561
|
+
localName: 'nonexistent',
|
|
562
|
+
});
|
|
563
|
+
});
|
|
564
|
+
});
|
|
428
565
|
describe('hasClaudeDirectoryInBranchEffect', () => {
|
|
429
566
|
it('should return Effect with true when .claude directory exists in branch worktree', async () => {
|
|
430
567
|
mockedExecSync.mockImplementation((cmd, _options) => {
|
|
@@ -710,6 +847,50 @@ branch refs/heads/feature
|
|
|
710
847
|
isMainWorktree: false,
|
|
711
848
|
});
|
|
712
849
|
});
|
|
850
|
+
it('should copy .worktreeinclude files from the main checkout into the new worktree', async () => {
|
|
851
|
+
mockedExistsSync.mockImplementation(filePath => {
|
|
852
|
+
const target = String(filePath);
|
|
853
|
+
if (target.endsWith('.worktreeinclude'))
|
|
854
|
+
return true;
|
|
855
|
+
// The source .env lives in the main checkout (gitRoot); the same
|
|
856
|
+
// relative path under the new worktree must not exist yet.
|
|
857
|
+
if (target === path.join('/fake/path', '.env'))
|
|
858
|
+
return true;
|
|
859
|
+
return false;
|
|
860
|
+
});
|
|
861
|
+
mockedStatSync.mockImplementation(() => ({ isFile: () => true }));
|
|
862
|
+
mockedExecSync.mockImplementation((cmd, _options) => {
|
|
863
|
+
if (typeof cmd === 'string') {
|
|
864
|
+
if (cmd === 'git rev-parse --git-common-dir') {
|
|
865
|
+
return '/fake/path/.git\n';
|
|
866
|
+
}
|
|
867
|
+
if (cmd.includes('show-ref --verify --quiet refs/heads/')) {
|
|
868
|
+
throw new Error('Branch not found');
|
|
869
|
+
}
|
|
870
|
+
if (cmd === 'git remote') {
|
|
871
|
+
return 'origin\n';
|
|
872
|
+
}
|
|
873
|
+
if (cmd.includes('show-ref --verify --quiet refs/remotes/')) {
|
|
874
|
+
throw new Error('Remote branch not found');
|
|
875
|
+
}
|
|
876
|
+
if (cmd.includes('git worktree add')) {
|
|
877
|
+
return '';
|
|
878
|
+
}
|
|
879
|
+
if (cmd.includes('git ls-files --others --ignored')) {
|
|
880
|
+
return '.env\0';
|
|
881
|
+
}
|
|
882
|
+
if (cmd === 'git check-ignore --stdin -z') {
|
|
883
|
+
return '.env\0';
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
return '';
|
|
887
|
+
});
|
|
888
|
+
const effect = service.createWorktreeEffect('/path/to/worktree', 'new-feature', 'main');
|
|
889
|
+
const result = await Effect.runPromise(effect);
|
|
890
|
+
expect(result.worktree.path).toBe('/path/to/worktree');
|
|
891
|
+
expect(mockedMkdirSync).toHaveBeenCalledWith(path.dirname(path.join('/path/to/worktree', '.env')), { recursive: true });
|
|
892
|
+
expect(mockedCpSync).toHaveBeenCalledWith(path.join('/fake/path', '.env'), path.join('/path/to/worktree', '.env'), { recursive: true, preserveTimestamps: true });
|
|
893
|
+
});
|
|
713
894
|
it('should create local branch from remote ref when only remote branch exists', async () => {
|
|
714
895
|
const executedCommands = [];
|
|
715
896
|
mockedExecSync.mockImplementation((cmd, _options) => {
|
|
@@ -751,12 +932,18 @@ branch refs/heads/feature
|
|
|
751
932
|
expect(worktreeAddCmd).toContain('-b "feature/remote-only"');
|
|
752
933
|
expect(worktreeAddCmd).toContain('"origin/feature/remote-only"');
|
|
753
934
|
});
|
|
754
|
-
it('should
|
|
935
|
+
it('should fall back to baseBranch when the new branch name exists in multiple remotes', async () => {
|
|
936
|
+
const executedCommands = [];
|
|
755
937
|
mockedExecSync.mockImplementation((cmd, _options) => {
|
|
756
938
|
if (typeof cmd === 'string') {
|
|
939
|
+
executedCommands.push(cmd);
|
|
757
940
|
if (cmd === 'git rev-parse --git-common-dir') {
|
|
758
941
|
return '/fake/path/.git\n';
|
|
759
942
|
}
|
|
943
|
+
// baseBranch "main" exists locally; the new branch does not
|
|
944
|
+
if (cmd === 'git show-ref --verify --quiet refs/heads/main') {
|
|
945
|
+
return '';
|
|
946
|
+
}
|
|
760
947
|
if (cmd.includes('show-ref --verify --quiet refs/heads/')) {
|
|
761
948
|
throw new Error('Branch not found');
|
|
762
949
|
}
|
|
@@ -764,13 +951,49 @@ branch refs/heads/feature
|
|
|
764
951
|
return 'origin\nkbwo-fork\n';
|
|
765
952
|
}
|
|
766
953
|
if (cmd.includes('show-ref --verify --quiet refs/remotes/')) {
|
|
767
|
-
return ''; // Both remotes have the branch
|
|
954
|
+
return ''; // Both remotes have the new branch name
|
|
955
|
+
}
|
|
956
|
+
if (cmd.includes('git worktree add')) {
|
|
957
|
+
return '';
|
|
768
958
|
}
|
|
769
959
|
}
|
|
770
|
-
|
|
960
|
+
return '';
|
|
771
961
|
});
|
|
962
|
+
// The user explicitly chose "main" as base branch; the ambiguity of
|
|
963
|
+
// "feature/feed-mention" across remotes must not fail the creation.
|
|
772
964
|
const effect = service.createWorktreeEffect('/path/to/worktree', 'feature/feed-mention', 'main');
|
|
773
965
|
const result = await Effect.runPromise(Effect.either(effect));
|
|
966
|
+
expect(result._tag).toBe('Right');
|
|
967
|
+
const worktreeAddCmd = executedCommands.find(c => c.includes('git worktree add'));
|
|
968
|
+
expect(worktreeAddCmd).toContain('-b "feature/feed-mention"');
|
|
969
|
+
expect(worktreeAddCmd).toContain('"main"');
|
|
970
|
+
});
|
|
971
|
+
it('should return Effect Left with AmbiguousBranchError when baseBranch exists in multiple remotes', async () => {
|
|
972
|
+
mockedExecSync.mockImplementation((cmd, _options) => {
|
|
973
|
+
if (typeof cmd === 'string') {
|
|
974
|
+
if (cmd === 'git rev-parse --git-common-dir') {
|
|
975
|
+
return '/fake/path/.git\n';
|
|
976
|
+
}
|
|
977
|
+
if (cmd.includes('show-ref --verify --quiet refs/heads/')) {
|
|
978
|
+
throw new Error('Branch not found');
|
|
979
|
+
}
|
|
980
|
+
if (cmd === 'git remote') {
|
|
981
|
+
return 'origin\nkbwo-fork\n';
|
|
982
|
+
}
|
|
983
|
+
// Only the baseBranch exists on the remotes; the new branch
|
|
984
|
+
// name matches nothing anywhere.
|
|
985
|
+
if (cmd.includes('show-ref --verify --quiet refs/remotes/origin/feature/feed-mention') ||
|
|
986
|
+
cmd.includes('show-ref --verify --quiet refs/remotes/kbwo-fork/feature/feed-mention')) {
|
|
987
|
+
return '';
|
|
988
|
+
}
|
|
989
|
+
if (cmd.includes('show-ref --verify --quiet refs/remotes/')) {
|
|
990
|
+
throw new Error('Remote branch not found');
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
throw new Error('Command not mocked: ' + cmd);
|
|
994
|
+
});
|
|
995
|
+
const effect = service.createWorktreeEffect('/path/to/worktree', 'new-feature', 'feature/feed-mention');
|
|
996
|
+
const result = await Effect.runPromise(Effect.either(effect));
|
|
774
997
|
expect(result._tag).toBe('Left');
|
|
775
998
|
if (result._tag === 'Left') {
|
|
776
999
|
expect(result.left).toBeInstanceOf(AmbiguousBranchError);
|
package/dist/types/index.d.ts
CHANGED
|
@@ -75,8 +75,8 @@ export type MenuAction = {
|
|
|
75
75
|
sessionId: string;
|
|
76
76
|
} | {
|
|
77
77
|
type: 'sessionActions';
|
|
78
|
-
|
|
79
|
-
|
|
78
|
+
worktree: Worktree;
|
|
79
|
+
session?: Session;
|
|
80
80
|
} | {
|
|
81
81
|
type: 'deleteWorktree';
|
|
82
82
|
} | {
|
|
@@ -249,6 +249,33 @@ export interface RemoteBranchMatch {
|
|
|
249
249
|
branch: string;
|
|
250
250
|
fullRef: string;
|
|
251
251
|
}
|
|
252
|
+
/**
|
|
253
|
+
* Result of classifying a base branch the user picked in the UI.
|
|
254
|
+
*
|
|
255
|
+
* - 'local': branch exists locally; `ref` is the branch name as-is.
|
|
256
|
+
* - 'remote': resolved to exactly one remote-tracking ref; `ref` is the full
|
|
257
|
+
* ref (e.g. "origin/foo") and `localName` the short branch name to use when
|
|
258
|
+
* creating a local branch from it.
|
|
259
|
+
* - 'ambiguous': branch exists in multiple remotes; the user must pick one.
|
|
260
|
+
* - 'none': nothing matched; pass `ref` through and let git report errors.
|
|
261
|
+
*/
|
|
262
|
+
export type BaseBranchResolution = {
|
|
263
|
+
kind: 'local';
|
|
264
|
+
ref: string;
|
|
265
|
+
localName: string;
|
|
266
|
+
} | {
|
|
267
|
+
kind: 'remote';
|
|
268
|
+
ref: string;
|
|
269
|
+
localName: string;
|
|
270
|
+
} | {
|
|
271
|
+
kind: 'none';
|
|
272
|
+
ref: string;
|
|
273
|
+
localName: string;
|
|
274
|
+
} | {
|
|
275
|
+
kind: 'ambiguous';
|
|
276
|
+
branchName: string;
|
|
277
|
+
matches: RemoteBranchMatch[];
|
|
278
|
+
};
|
|
252
279
|
export declare class AmbiguousBranchError extends Error {
|
|
253
280
|
readonly _tag: "AmbiguousBranchError";
|
|
254
281
|
branchName: string;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Filename for the convention shared across worktree-aware tools (Claude Code,
|
|
3
|
+
* Conductor, OpenAI Codex, git-worktreeinclude, worktrunk): a gitignore-syntax
|
|
4
|
+
* file at the repository root that lists gitignored files to carry into every
|
|
5
|
+
* new worktree.
|
|
6
|
+
*/
|
|
7
|
+
export declare const WORKTREE_INCLUDE_FILENAME = ".worktreeinclude";
|
|
8
|
+
/**
|
|
9
|
+
* Resolves which files a `.worktreeinclude` file selects, relative to `gitRoot`.
|
|
10
|
+
*
|
|
11
|
+
* A file is selected only when both hold:
|
|
12
|
+
* - it matches a pattern in `.worktreeinclude` (gitignore syntax: comments,
|
|
13
|
+
* negation with `!`, anchoring with `/`, `**` globs)
|
|
14
|
+
* - Git already ignores it (nested `.gitignore` files, `.git/info/exclude`,
|
|
15
|
+
* and `core.excludesfile` all apply)
|
|
16
|
+
*
|
|
17
|
+
* This mirrors the safety rule every tool that supports `.worktreeinclude`
|
|
18
|
+
* documents: listing a pattern never makes a tracked file eligible, and it
|
|
19
|
+
* never makes an otherwise-untracked-but-not-ignored file eligible either.
|
|
20
|
+
*
|
|
21
|
+
* @param gitRoot - Absolute path to the main checkout (repository root)
|
|
22
|
+
* @returns Repository-relative paths (forward-slash separated, as Git reports them)
|
|
23
|
+
*/
|
|
24
|
+
export declare function resolveWorktreeIncludeFiles(gitRoot: string): string[];
|
|
25
|
+
/**
|
|
26
|
+
* Copies the files a `.worktreeinclude` file selects from the main checkout
|
|
27
|
+
* into a freshly created worktree. No-ops when no `.worktreeinclude` file
|
|
28
|
+
* exists. Never overwrites a file that already exists at the destination.
|
|
29
|
+
*
|
|
30
|
+
* @param gitRoot - Absolute path to the main checkout (repository root)
|
|
31
|
+
* @param targetWorktreePath - Absolute path to the newly created worktree
|
|
32
|
+
*/
|
|
33
|
+
export declare function copyWorktreeIncludeFiles(gitRoot: string, targetWorktreePath: string): void;
|