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.
@@ -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
- const resolvedRef = yield* self.resolveBranchReferenceEffect(branch);
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 return Effect Left with AmbiguousBranchError when branch exists in multiple remotes', async () => {
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
- throw new Error('Command not mocked: ' + cmd);
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);
@@ -75,8 +75,8 @@ export type MenuAction = {
75
75
  sessionId: string;
76
76
  } | {
77
77
  type: 'sessionActions';
78
- session: Session;
79
- worktreePath: string;
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;