ccmanager 0.1.5 → 0.1.6

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.
@@ -102,11 +102,11 @@ const App = () => {
102
102
  }
103
103
  }, 50); // Small delay to ensure proper cleanup
104
104
  };
105
- const handleCreateWorktree = async (path, branch) => {
105
+ const handleCreateWorktree = async (path, branch, baseBranch) => {
106
106
  setView('creating-worktree');
107
107
  setError(null);
108
108
  // Create the worktree
109
- const result = worktreeService.createWorktree(path, branch);
109
+ const result = worktreeService.createWorktree(path, branch, baseBranch);
110
110
  if (result.success) {
111
111
  // Success - return to menu
112
112
  handleReturnToMenu();
@@ -1,6 +1,6 @@
1
1
  import React from 'react';
2
2
  interface NewWorktreeProps {
3
- onComplete: (path: string, branch: string) => void;
3
+ onComplete: (path: string, branch: string, baseBranch: string) => void;
4
4
  onCancel: () => void;
5
5
  }
6
6
  declare const NewWorktree: React.FC<NewWorktreeProps>;
@@ -1,9 +1,11 @@
1
1
  import React, { useState, useEffect } from 'react';
2
2
  import { Box, Text, useInput } from 'ink';
3
3
  import TextInput from 'ink-text-input';
4
+ import SelectInput from 'ink-select-input';
4
5
  import { shortcutManager } from '../services/shortcutManager.js';
5
6
  import { configurationManager } from '../services/configurationManager.js';
6
7
  import { generateWorktreeDirectory } from '../utils/worktreeUtils.js';
8
+ import { WorktreeService } from '../services/worktreeService.js';
7
9
  const NewWorktree = ({ onComplete, onCancel }) => {
8
10
  const worktreeConfig = configurationManager.getWorktreeConfig();
9
11
  const isAutoDirectory = worktreeConfig.autoDirectory;
@@ -12,6 +14,17 @@ const NewWorktree = ({ onComplete, onCancel }) => {
12
14
  const [path, setPath] = useState('');
13
15
  const [branch, setBranch] = useState('');
14
16
  const [generatedPath, setGeneratedPath] = useState('');
17
+ // Initialize worktree service and load branches
18
+ const worktreeService = new WorktreeService();
19
+ const branches = worktreeService.getAllBranches();
20
+ const defaultBranch = worktreeService.getDefaultBranch();
21
+ // Create branch items with default branch first
22
+ const branchItems = [
23
+ { label: `${defaultBranch} (default)`, value: defaultBranch },
24
+ ...branches
25
+ .filter(br => br !== defaultBranch)
26
+ .map(br => ({ label: br, value: br })),
27
+ ];
15
28
  useInput((input, key) => {
16
29
  if (shortcutManager.matchesShortcut('cancel', input, key)) {
17
30
  onCancel();
@@ -26,14 +39,17 @@ const NewWorktree = ({ onComplete, onCancel }) => {
26
39
  const handleBranchSubmit = (value) => {
27
40
  if (value.trim()) {
28
41
  setBranch(value.trim());
29
- if (isAutoDirectory) {
30
- // Generate path from branch name
31
- const autoPath = generateWorktreeDirectory(value.trim(), worktreeConfig.autoDirectoryPattern);
32
- onComplete(autoPath, value.trim());
33
- }
34
- else {
35
- onComplete(path, value.trim());
36
- }
42
+ setStep('base-branch');
43
+ }
44
+ };
45
+ const handleBaseBranchSelect = (item) => {
46
+ if (isAutoDirectory) {
47
+ // Generate path from branch name
48
+ const autoPath = generateWorktreeDirectory(branch, worktreeConfig.autoDirectoryPattern);
49
+ onComplete(autoPath, branch, item.value);
50
+ }
51
+ else {
52
+ onComplete(path, branch, item.value);
37
53
  }
38
54
  };
39
55
  // Update generated path preview when branch changes in auto mode
@@ -70,6 +86,13 @@ const NewWorktree = ({ onComplete, onCancel }) => {
70
86
  "Worktree will be created at:",
71
87
  ' ',
72
88
  React.createElement(Text, { color: "green" }, generatedPath)))))),
89
+ step === 'base-branch' && (React.createElement(Box, { flexDirection: "column" },
90
+ React.createElement(Box, { marginBottom: 1 },
91
+ React.createElement(Text, null,
92
+ "Select base branch for ",
93
+ React.createElement(Text, { color: "cyan" }, branch),
94
+ ":")),
95
+ React.createElement(SelectInput, { items: branchItems, onSelect: handleBaseBranchSelect, initialIndex: 0 }))),
73
96
  React.createElement(Box, { marginTop: 1 },
74
97
  React.createElement(Text, { dimColor: true },
75
98
  "Press ",
@@ -1,11 +1,15 @@
1
1
  import { Worktree } from '../types/index.js';
2
2
  export declare class WorktreeService {
3
3
  private rootPath;
4
+ private gitRootPath;
4
5
  constructor(rootPath?: string);
6
+ private getGitRepositoryRoot;
5
7
  getWorktrees(): Worktree[];
6
8
  private getCurrentBranch;
7
9
  isGitRepository(): boolean;
8
- createWorktree(worktreePath: string, branch: string): {
10
+ getDefaultBranch(): string;
11
+ getAllBranches(): string[];
12
+ createWorktree(worktreePath: string, branch: string, baseBranch: string): {
9
13
  success: boolean;
10
14
  error?: string;
11
15
  };
@@ -9,7 +9,30 @@ export class WorktreeService {
9
9
  writable: true,
10
10
  value: void 0
11
11
  });
12
+ Object.defineProperty(this, "gitRootPath", {
13
+ enumerable: true,
14
+ configurable: true,
15
+ writable: true,
16
+ value: void 0
17
+ });
12
18
  this.rootPath = rootPath || process.cwd();
19
+ // Get the actual git repository root for worktree operations
20
+ this.gitRootPath = this.getGitRepositoryRoot();
21
+ }
22
+ getGitRepositoryRoot() {
23
+ try {
24
+ // Get the common git directory
25
+ const gitCommonDir = execSync('git rev-parse --git-common-dir', {
26
+ cwd: this.rootPath,
27
+ encoding: 'utf8',
28
+ }).trim();
29
+ // The parent of .git is the actual repository root
30
+ return path.dirname(gitCommonDir);
31
+ }
32
+ catch {
33
+ // Fallback to current directory if command fails
34
+ return this.rootPath;
35
+ }
13
36
  }
14
37
  getWorktrees() {
15
38
  try {
@@ -79,8 +102,71 @@ export class WorktreeService {
79
102
  isGitRepository() {
80
103
  return existsSync(path.join(this.rootPath, '.git'));
81
104
  }
82
- createWorktree(worktreePath, branch) {
105
+ getDefaultBranch() {
106
+ try {
107
+ // Try to get the default branch from origin
108
+ const defaultBranch = execSync("git symbolic-ref refs/remotes/origin/HEAD | sed 's@^refs/remotes/origin/@@'", {
109
+ cwd: this.rootPath,
110
+ encoding: 'utf8',
111
+ shell: '/bin/bash',
112
+ }).trim();
113
+ return defaultBranch || 'main';
114
+ }
115
+ catch {
116
+ // Fallback to common default branch names
117
+ try {
118
+ execSync('git rev-parse --verify main', {
119
+ cwd: this.rootPath,
120
+ encoding: 'utf8',
121
+ });
122
+ return 'main';
123
+ }
124
+ catch {
125
+ try {
126
+ execSync('git rev-parse --verify master', {
127
+ cwd: this.rootPath,
128
+ encoding: 'utf8',
129
+ });
130
+ return 'master';
131
+ }
132
+ catch {
133
+ return 'main';
134
+ }
135
+ }
136
+ }
137
+ }
138
+ getAllBranches() {
83
139
  try {
140
+ const output = execSync("git branch -a --format='%(refname:short)' | grep -v HEAD | sort -u", {
141
+ cwd: this.rootPath,
142
+ encoding: 'utf8',
143
+ shell: '/bin/bash',
144
+ });
145
+ const branches = output
146
+ .trim()
147
+ .split('\n')
148
+ .filter(branch => branch && !branch.startsWith('origin/'))
149
+ .map(branch => branch.trim());
150
+ // Also include remote branches without origin/ prefix
151
+ const remoteBranches = output
152
+ .trim()
153
+ .split('\n')
154
+ .filter(branch => branch.startsWith('origin/'))
155
+ .map(branch => branch.replace('origin/', ''));
156
+ // Merge and deduplicate
157
+ const allBranches = [...new Set([...branches, ...remoteBranches])];
158
+ return allBranches.filter(branch => branch);
159
+ }
160
+ catch {
161
+ return [];
162
+ }
163
+ }
164
+ createWorktree(worktreePath, branch, baseBranch) {
165
+ try {
166
+ // Resolve the worktree path relative to the git repository root
167
+ const resolvedPath = path.isAbsolute(worktreePath)
168
+ ? worktreePath
169
+ : path.join(this.gitRootPath, worktreePath);
84
170
  // Check if branch exists
85
171
  let branchExists = false;
86
172
  try {
@@ -94,11 +180,16 @@ export class WorktreeService {
94
180
  // Branch doesn't exist
95
181
  }
96
182
  // Create the worktree
97
- const command = branchExists
98
- ? `git worktree add "${worktreePath}" "${branch}"`
99
- : `git worktree add -b "${branch}" "${worktreePath}"`;
183
+ let command;
184
+ if (branchExists) {
185
+ command = `git worktree add "${resolvedPath}" "${branch}"`;
186
+ }
187
+ else {
188
+ // Create new branch from specified base branch
189
+ command = `git worktree add -b "${branch}" "${resolvedPath}" "${baseBranch}"`;
190
+ }
100
191
  execSync(command, {
101
- cwd: this.rootPath,
192
+ cwd: this.gitRootPath, // Execute from git root to ensure proper resolution
102
193
  encoding: 'utf8',
103
194
  });
104
195
  return { success: true };
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,147 @@
1
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
2
+ import { WorktreeService } from './worktreeService.js';
3
+ import { execSync } from 'child_process';
4
+ // Mock child_process module
5
+ vi.mock('child_process');
6
+ // Get the mocked function with proper typing
7
+ const mockedExecSync = vi.mocked(execSync);
8
+ describe('WorktreeService', () => {
9
+ let service;
10
+ beforeEach(() => {
11
+ vi.clearAllMocks();
12
+ // Mock git rev-parse --git-common-dir to return a predictable path
13
+ mockedExecSync.mockImplementation((cmd, _options) => {
14
+ if (typeof cmd === 'string' && cmd === 'git rev-parse --git-common-dir') {
15
+ return '/fake/path/.git\n';
16
+ }
17
+ throw new Error('Command not mocked: ' + cmd);
18
+ });
19
+ service = new WorktreeService('/fake/path');
20
+ });
21
+ describe('getDefaultBranch', () => {
22
+ it('should return default branch from origin', () => {
23
+ mockedExecSync.mockImplementation((cmd, _options) => {
24
+ if (typeof cmd === 'string') {
25
+ if (cmd === 'git rev-parse --git-common-dir') {
26
+ return '/fake/path/.git\n';
27
+ }
28
+ if (cmd.includes('symbolic-ref')) {
29
+ return 'main\n';
30
+ }
31
+ }
32
+ throw new Error('Command not mocked: ' + cmd);
33
+ });
34
+ const result = service.getDefaultBranch();
35
+ expect(result).toBe('main');
36
+ expect(execSync).toHaveBeenCalledWith("git symbolic-ref refs/remotes/origin/HEAD | sed 's@^refs/remotes/origin/@@'", expect.objectContaining({
37
+ cwd: '/fake/path',
38
+ encoding: 'utf8',
39
+ shell: '/bin/bash',
40
+ }));
41
+ });
42
+ it('should fallback to main if origin HEAD fails', () => {
43
+ mockedExecSync.mockImplementation((cmd, _options) => {
44
+ if (typeof cmd === 'string') {
45
+ if (cmd === 'git rev-parse --git-common-dir') {
46
+ return '/fake/path/.git\n';
47
+ }
48
+ if (cmd.includes('symbolic-ref')) {
49
+ throw new Error('No origin');
50
+ }
51
+ if (cmd.includes('rev-parse --verify main')) {
52
+ return 'hash';
53
+ }
54
+ }
55
+ throw new Error('Not found');
56
+ });
57
+ const result = service.getDefaultBranch();
58
+ expect(result).toBe('main');
59
+ });
60
+ });
61
+ describe('getAllBranches', () => {
62
+ it('should return all branches without duplicates', () => {
63
+ mockedExecSync.mockImplementation((cmd, _options) => {
64
+ if (typeof cmd === 'string') {
65
+ if (cmd === 'git rev-parse --git-common-dir') {
66
+ return '/fake/path/.git\n';
67
+ }
68
+ if (cmd.includes('branch -a')) {
69
+ return `main
70
+ feature/test
71
+ origin/main
72
+ origin/feature/remote
73
+ origin/feature/test
74
+ `;
75
+ }
76
+ }
77
+ throw new Error('Command not mocked: ' + cmd);
78
+ });
79
+ const result = service.getAllBranches();
80
+ expect(result).toEqual(['main', 'feature/test', 'feature/remote']);
81
+ });
82
+ it('should return empty array on error', () => {
83
+ mockedExecSync.mockImplementation((cmd, _options) => {
84
+ if (typeof cmd === 'string' &&
85
+ cmd === 'git rev-parse --git-common-dir') {
86
+ return '/fake/path/.git\n';
87
+ }
88
+ throw new Error('Git error');
89
+ });
90
+ const result = service.getAllBranches();
91
+ expect(result).toEqual([]);
92
+ });
93
+ });
94
+ describe('createWorktree', () => {
95
+ it('should create worktree with base branch when branch does not exist', () => {
96
+ mockedExecSync.mockImplementation((cmd, _options) => {
97
+ if (typeof cmd === 'string') {
98
+ if (cmd === 'git rev-parse --git-common-dir') {
99
+ return '/fake/path/.git\n';
100
+ }
101
+ if (cmd.includes('rev-parse --verify')) {
102
+ throw new Error('Branch not found');
103
+ }
104
+ return '';
105
+ }
106
+ throw new Error('Unexpected command');
107
+ });
108
+ const result = service.createWorktree('/path/to/worktree', 'new-feature', 'develop');
109
+ expect(result).toEqual({ success: true });
110
+ expect(execSync).toHaveBeenCalledWith('git worktree add -b "new-feature" "/path/to/worktree" "develop"', expect.any(Object));
111
+ });
112
+ it('should create worktree without base branch when branch exists', () => {
113
+ mockedExecSync.mockImplementation((cmd, _options) => {
114
+ if (typeof cmd === 'string') {
115
+ if (cmd === 'git rev-parse --git-common-dir') {
116
+ return '/fake/path/.git\n';
117
+ }
118
+ if (cmd.includes('rev-parse --verify')) {
119
+ return 'hash';
120
+ }
121
+ return '';
122
+ }
123
+ throw new Error('Unexpected command');
124
+ });
125
+ const result = service.createWorktree('/path/to/worktree', 'existing-feature', 'main');
126
+ expect(result).toEqual({ success: true });
127
+ expect(execSync).toHaveBeenCalledWith('git worktree add "/path/to/worktree" "existing-feature"', expect.any(Object));
128
+ });
129
+ it('should create worktree from specified base branch when branch does not exist', () => {
130
+ mockedExecSync.mockImplementation((cmd, _options) => {
131
+ if (typeof cmd === 'string') {
132
+ if (cmd === 'git rev-parse --git-common-dir') {
133
+ return '/fake/path/.git\n';
134
+ }
135
+ if (cmd.includes('rev-parse --verify')) {
136
+ throw new Error('Branch not found');
137
+ }
138
+ return '';
139
+ }
140
+ throw new Error('Unexpected command');
141
+ });
142
+ const result = service.createWorktree('/path/to/worktree', 'new-feature', 'main');
143
+ expect(result).toEqual({ success: true });
144
+ expect(execSync).toHaveBeenCalledWith('git worktree add -b "new-feature" "/path/to/worktree" "main"', expect.any(Object));
145
+ });
146
+ });
147
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccmanager",
3
- "version": "0.1.5",
3
+ "version": "0.1.6",
4
4
  "description": "TUI application for managing multiple Claude Code sessions across Git worktrees",
5
5
  "license": "MIT",
6
6
  "author": "Kodai Kabasawa",