ccmanager 1.4.4 → 2.0.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.
Files changed (44) hide show
  1. package/README.md +34 -1
  2. package/dist/cli.d.ts +4 -0
  3. package/dist/cli.js +30 -2
  4. package/dist/cli.test.d.ts +1 -0
  5. package/dist/cli.test.js +67 -0
  6. package/dist/components/App.d.ts +1 -0
  7. package/dist/components/App.js +107 -37
  8. package/dist/components/Menu.d.ts +6 -1
  9. package/dist/components/Menu.js +227 -50
  10. package/dist/components/Menu.recent-projects.test.d.ts +1 -0
  11. package/dist/components/Menu.recent-projects.test.js +159 -0
  12. package/dist/components/Menu.test.d.ts +1 -0
  13. package/dist/components/Menu.test.js +196 -0
  14. package/dist/components/ProjectList.d.ts +10 -0
  15. package/dist/components/ProjectList.js +231 -0
  16. package/dist/components/ProjectList.recent-projects.test.d.ts +1 -0
  17. package/dist/components/ProjectList.recent-projects.test.js +186 -0
  18. package/dist/components/ProjectList.test.d.ts +1 -0
  19. package/dist/components/ProjectList.test.js +501 -0
  20. package/dist/components/Session.js +4 -14
  21. package/dist/constants/env.d.ts +3 -0
  22. package/dist/constants/env.js +4 -0
  23. package/dist/constants/error.d.ts +6 -0
  24. package/dist/constants/error.js +7 -0
  25. package/dist/hooks/useSearchMode.d.ts +15 -0
  26. package/dist/hooks/useSearchMode.js +67 -0
  27. package/dist/services/configurationManager.d.ts +1 -0
  28. package/dist/services/configurationManager.js +14 -7
  29. package/dist/services/globalSessionOrchestrator.d.ts +16 -0
  30. package/dist/services/globalSessionOrchestrator.js +73 -0
  31. package/dist/services/globalSessionOrchestrator.test.d.ts +1 -0
  32. package/dist/services/globalSessionOrchestrator.test.js +180 -0
  33. package/dist/services/projectManager.d.ts +60 -0
  34. package/dist/services/projectManager.js +418 -0
  35. package/dist/services/projectManager.test.d.ts +1 -0
  36. package/dist/services/projectManager.test.js +342 -0
  37. package/dist/services/sessionManager.d.ts +8 -0
  38. package/dist/services/sessionManager.js +41 -7
  39. package/dist/services/sessionManager.test.js +79 -0
  40. package/dist/services/worktreeService.d.ts +1 -0
  41. package/dist/services/worktreeService.js +20 -5
  42. package/dist/services/worktreeService.test.js +72 -0
  43. package/dist/types/index.d.ts +55 -0
  44. package/package.json +1 -1
@@ -0,0 +1,342 @@
1
+ import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
2
+ import { ProjectManager } from './projectManager.js';
3
+ import { ENV_VARS } from '../constants/env.js';
4
+ import * as fs from 'fs';
5
+ import * as path from 'path';
6
+ import * as os from 'os';
7
+ // Mock fs module
8
+ vi.mock('fs');
9
+ vi.mock('os');
10
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
11
+ const mockFs = fs;
12
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
13
+ const mockOs = os;
14
+ describe('ProjectManager', () => {
15
+ let projectManager;
16
+ const mockProjectsDir = '/home/user/projects';
17
+ const mockConfigDir = '/home/user/.config/ccmanager';
18
+ const mockRecentProjectsPath = '/home/user/.config/ccmanager/recent-projects.json';
19
+ beforeEach(() => {
20
+ vi.clearAllMocks();
21
+ // Reset environment variables
22
+ delete process.env[ENV_VARS.MULTI_PROJECT_ROOT];
23
+ // Mock os.homedir
24
+ mockOs.homedir.mockReturnValue('/home/user');
25
+ // Mock fs methods for config directory
26
+ mockFs.existsSync.mockImplementation((path) => {
27
+ if (path === mockConfigDir)
28
+ return true;
29
+ if (path === mockRecentProjectsPath)
30
+ return false;
31
+ return false;
32
+ });
33
+ mockFs.mkdirSync.mockImplementation(() => { });
34
+ mockFs.readFileSync.mockImplementation(() => '[]');
35
+ mockFs.writeFileSync.mockImplementation(() => { });
36
+ });
37
+ afterEach(() => {
38
+ vi.restoreAllMocks();
39
+ });
40
+ describe('initialization', () => {
41
+ it('should initialize in normal mode when no multi-project root is set', () => {
42
+ projectManager = new ProjectManager();
43
+ expect(projectManager.currentMode).toBe('normal');
44
+ expect(projectManager.currentProject).toBeUndefined();
45
+ expect(projectManager.projects).toEqual([]);
46
+ });
47
+ it('should initialize in multi-project mode when multi-project root is set', () => {
48
+ process.env[ENV_VARS.MULTI_PROJECT_ROOT] = mockProjectsDir;
49
+ projectManager = new ProjectManager();
50
+ expect(projectManager.currentMode).toBe('multi-project');
51
+ });
52
+ });
53
+ describe('project discovery', () => {
54
+ beforeEach(() => {
55
+ process.env[ENV_VARS.MULTI_PROJECT_ROOT] = mockProjectsDir;
56
+ projectManager = new ProjectManager();
57
+ });
58
+ it('should discover git projects in the projects directory', async () => {
59
+ // Mock file system for project discovery
60
+ mockFs.promises = {
61
+ access: vi.fn().mockResolvedValue(undefined),
62
+ readdir: vi.fn().mockImplementation((dir) => {
63
+ if (dir === mockProjectsDir) {
64
+ return Promise.resolve([
65
+ { name: 'project1', isDirectory: () => true },
66
+ { name: 'project2', isDirectory: () => true },
67
+ { name: 'not-a-project.txt', isDirectory: () => false },
68
+ ]);
69
+ }
70
+ return Promise.resolve([]);
71
+ }),
72
+ stat: vi.fn().mockImplementation((path) => {
73
+ if (path.endsWith('.git')) {
74
+ return Promise.resolve({
75
+ isDirectory: () => true,
76
+ isFile: () => false,
77
+ });
78
+ }
79
+ throw new Error('Not found');
80
+ }),
81
+ };
82
+ await projectManager.refreshProjects();
83
+ expect(projectManager.projects).toHaveLength(2);
84
+ expect(projectManager.projects[0]).toMatchObject({
85
+ name: 'project1',
86
+ path: path.join(mockProjectsDir, 'project1'),
87
+ isValid: true,
88
+ });
89
+ expect(projectManager.projects[1]).toMatchObject({
90
+ name: 'project2',
91
+ path: path.join(mockProjectsDir, 'project2'),
92
+ isValid: true,
93
+ });
94
+ });
95
+ it('should handle projects directory not existing', async () => {
96
+ mockFs.promises = {
97
+ access: vi.fn().mockRejectedValue({ code: 'ENOENT' }),
98
+ };
99
+ await expect(projectManager.refreshProjects()).rejects.toThrow(`Projects directory does not exist: ${mockProjectsDir}`);
100
+ });
101
+ });
102
+ describe('recent projects', () => {
103
+ beforeEach(() => {
104
+ projectManager = new ProjectManager();
105
+ });
106
+ it('should get recent projects with default limit', () => {
107
+ const mockRecentProjects = [
108
+ { path: '/path/to/project1', name: 'project1', lastAccessed: Date.now() },
109
+ {
110
+ path: '/path/to/project2',
111
+ name: 'project2',
112
+ lastAccessed: Date.now() - 1000,
113
+ },
114
+ {
115
+ path: '/path/to/project3',
116
+ name: 'project3',
117
+ lastAccessed: Date.now() - 2000,
118
+ },
119
+ {
120
+ path: '/path/to/project4',
121
+ name: 'project4',
122
+ lastAccessed: Date.now() - 3000,
123
+ },
124
+ {
125
+ path: '/path/to/project5',
126
+ name: 'project5',
127
+ lastAccessed: Date.now() - 4000,
128
+ },
129
+ {
130
+ path: '/path/to/project6',
131
+ name: 'project6',
132
+ lastAccessed: Date.now() - 5000,
133
+ },
134
+ ];
135
+ mockFs.readFileSync.mockReturnValue(JSON.stringify(mockRecentProjects));
136
+ mockFs.existsSync.mockReturnValue(true);
137
+ // Re-create to load recent projects
138
+ projectManager = new ProjectManager();
139
+ const recent = projectManager.getRecentProjects();
140
+ expect(recent).toHaveLength(5); // Default limit
141
+ expect(recent[0]?.name).toBe('project1');
142
+ expect(recent[4]?.name).toBe('project5');
143
+ });
144
+ it('should get recent projects with custom limit', () => {
145
+ const mockRecentProjects = [
146
+ { path: '/path/to/project1', name: 'project1', lastAccessed: Date.now() },
147
+ {
148
+ path: '/path/to/project2',
149
+ name: 'project2',
150
+ lastAccessed: Date.now() - 1000,
151
+ },
152
+ {
153
+ path: '/path/to/project3',
154
+ name: 'project3',
155
+ lastAccessed: Date.now() - 2000,
156
+ },
157
+ ];
158
+ mockFs.readFileSync.mockReturnValue(JSON.stringify(mockRecentProjects));
159
+ mockFs.existsSync.mockReturnValue(true);
160
+ projectManager = new ProjectManager();
161
+ const recent = projectManager.getRecentProjects(2);
162
+ expect(recent).toHaveLength(2);
163
+ });
164
+ it('should add a recent project', () => {
165
+ const project = {
166
+ name: 'test-project',
167
+ path: '/path/to/test-project',
168
+ relativePath: 'test-project',
169
+ isValid: true,
170
+ };
171
+ projectManager.addRecentProject(project);
172
+ expect(mockFs.writeFileSync).toHaveBeenCalled();
173
+ const writtenData = JSON.parse(mockFs.writeFileSync.mock.calls[0][1]);
174
+ expect(writtenData).toHaveLength(1);
175
+ expect(writtenData[0]).toMatchObject({
176
+ path: project.path,
177
+ name: project.name,
178
+ });
179
+ });
180
+ it('should update existing recent project', () => {
181
+ const existingProject = {
182
+ path: '/path/to/project1',
183
+ name: 'project1',
184
+ lastAccessed: Date.now() - 10000,
185
+ };
186
+ mockFs.readFileSync.mockReturnValue(JSON.stringify([existingProject]));
187
+ mockFs.existsSync.mockReturnValue(true);
188
+ projectManager = new ProjectManager();
189
+ const updatedProject = {
190
+ name: 'project1',
191
+ path: '/path/to/project1',
192
+ relativePath: 'project1',
193
+ isValid: true,
194
+ };
195
+ projectManager.addRecentProject(updatedProject);
196
+ const writtenData = JSON.parse(mockFs.writeFileSync.mock.calls[0][1]);
197
+ expect(writtenData).toHaveLength(1);
198
+ expect(writtenData[0].lastAccessed).toBeGreaterThan(existingProject.lastAccessed);
199
+ });
200
+ it('should not add EXIT_APPLICATION to recent projects', () => {
201
+ const exitProject = {
202
+ name: 'Exit',
203
+ path: 'EXIT_APPLICATION',
204
+ relativePath: '',
205
+ isValid: true,
206
+ };
207
+ projectManager.addRecentProject(exitProject);
208
+ expect(mockFs.writeFileSync).not.toHaveBeenCalled();
209
+ });
210
+ it('should clear recent projects', () => {
211
+ projectManager.clearRecentProjects();
212
+ expect(mockFs.writeFileSync).toHaveBeenCalledWith(mockRecentProjectsPath, JSON.stringify([], null, 2));
213
+ });
214
+ });
215
+ describe('mode management', () => {
216
+ beforeEach(() => {
217
+ process.env[ENV_VARS.MULTI_PROJECT_ROOT] = mockProjectsDir;
218
+ projectManager = new ProjectManager();
219
+ });
220
+ it('should set mode correctly', () => {
221
+ projectManager.setMode('normal');
222
+ expect(projectManager.currentMode).toBe('normal');
223
+ projectManager.setMode('multi-project');
224
+ expect(projectManager.currentMode).toBe('multi-project');
225
+ });
226
+ it('should clear current project when switching to normal mode', () => {
227
+ const project = {
228
+ name: 'test',
229
+ path: '/test',
230
+ relativePath: 'test',
231
+ isValid: true,
232
+ };
233
+ projectManager.selectProject(project);
234
+ expect(projectManager.currentProject).toBe(project);
235
+ projectManager.setMode('normal');
236
+ expect(projectManager.currentProject).toBeUndefined();
237
+ });
238
+ });
239
+ describe('project selection', () => {
240
+ beforeEach(() => {
241
+ projectManager = new ProjectManager();
242
+ });
243
+ it('should select a project', () => {
244
+ const project = {
245
+ name: 'test',
246
+ path: '/test',
247
+ relativePath: 'test',
248
+ isValid: true,
249
+ };
250
+ projectManager.selectProject(project);
251
+ expect(projectManager.currentProject).toBe(project);
252
+ });
253
+ });
254
+ describe('worktree service management', () => {
255
+ beforeEach(() => {
256
+ projectManager = new ProjectManager();
257
+ });
258
+ it('should get worktree service for current project', () => {
259
+ const project = {
260
+ name: 'test',
261
+ path: '/test/project',
262
+ relativePath: 'test',
263
+ isValid: true,
264
+ };
265
+ projectManager.selectProject(project);
266
+ const service = projectManager.getWorktreeService();
267
+ expect(service).toBeDefined();
268
+ expect(service.getGitRootPath()).toBe('/test/project');
269
+ });
270
+ it('should cache worktree services', () => {
271
+ const service1 = projectManager.getWorktreeService('/test/path1');
272
+ const service2 = projectManager.getWorktreeService('/test/path1');
273
+ expect(service1).toBe(service2);
274
+ });
275
+ it('should clear worktree service cache', () => {
276
+ projectManager.getWorktreeService('/test/path1');
277
+ projectManager.getWorktreeService('/test/path2');
278
+ projectManager.clearWorktreeServiceCache('/test/path1');
279
+ const cachedServices = projectManager.getCachedServices();
280
+ expect(cachedServices.size).toBe(1);
281
+ expect(cachedServices.has('/test/path2')).toBe(true);
282
+ });
283
+ it('should clear all worktree service cache', () => {
284
+ projectManager.getWorktreeService('/test/path1');
285
+ projectManager.getWorktreeService('/test/path2');
286
+ projectManager.clearWorktreeServiceCache();
287
+ const cachedServices = projectManager.getCachedServices();
288
+ expect(cachedServices.size).toBe(0);
289
+ });
290
+ });
291
+ describe('helper methods', () => {
292
+ beforeEach(() => {
293
+ projectManager = new ProjectManager();
294
+ });
295
+ it('should check if multi-project is enabled', () => {
296
+ expect(projectManager.isMultiProjectEnabled()).toBe(false);
297
+ process.env[ENV_VARS.MULTI_PROJECT_ROOT] = mockProjectsDir;
298
+ projectManager = new ProjectManager();
299
+ expect(projectManager.isMultiProjectEnabled()).toBe(true);
300
+ });
301
+ it('should get projects directory', () => {
302
+ expect(projectManager.getProjectsDir()).toBeUndefined();
303
+ process.env[ENV_VARS.MULTI_PROJECT_ROOT] = mockProjectsDir;
304
+ projectManager = new ProjectManager();
305
+ expect(projectManager.getProjectsDir()).toBe(mockProjectsDir);
306
+ });
307
+ it('should get current project path', () => {
308
+ const cwd = process.cwd();
309
+ expect(projectManager.getCurrentProjectPath()).toBe(cwd);
310
+ const project = {
311
+ name: 'test',
312
+ path: '/test/project',
313
+ relativePath: 'test',
314
+ isValid: true,
315
+ };
316
+ projectManager.selectProject(project);
317
+ expect(projectManager.getCurrentProjectPath()).toBe('/test/project');
318
+ });
319
+ });
320
+ describe('project validation', () => {
321
+ beforeEach(() => {
322
+ projectManager = new ProjectManager();
323
+ });
324
+ it('should validate a git repository', async () => {
325
+ mockFs.promises = {
326
+ stat: vi.fn().mockResolvedValue({
327
+ isDirectory: () => true,
328
+ isFile: () => false,
329
+ }),
330
+ };
331
+ const isValid = await projectManager.validateGitRepository('/test/repo');
332
+ expect(isValid).toBe(true);
333
+ });
334
+ it('should invalidate non-git repository', async () => {
335
+ mockFs.promises = {
336
+ stat: vi.fn().mockRejectedValue(new Error('Not found')),
337
+ };
338
+ const isValid = await projectManager.validateGitRepository('/test/not-repo');
339
+ expect(isValid).toBe(false);
340
+ });
341
+ });
342
+ });
@@ -1,5 +1,11 @@
1
1
  import { Session, SessionManager as ISessionManager, SessionState, DevcontainerConfig } from '../types/index.js';
2
2
  import { EventEmitter } from 'events';
3
+ export interface SessionCounts {
4
+ idle: number;
5
+ busy: number;
6
+ waiting_input: number;
7
+ total: number;
8
+ }
3
9
  export declare class SessionManager extends EventEmitter implements ISessionManager {
4
10
  sessions: Map<string, Session>;
5
11
  private waitingWithBottomBorder;
@@ -29,4 +35,6 @@ export declare class SessionManager extends EventEmitter implements ISessionMana
29
35
  private executeStatusHook;
30
36
  createSessionWithDevcontainer(worktreePath: string, devcontainerConfig: DevcontainerConfig, presetId?: string): Promise<Session>;
31
37
  destroy(): void;
38
+ static getSessionCounts(sessions: Session[]): SessionCounts;
39
+ static formatSessionCounts(counts: SessionCounts): string;
32
40
  }
@@ -55,6 +55,7 @@ export class SessionManager extends EventEmitter {
55
55
  cols: process.stdout.columns || 80,
56
56
  rows: process.stdout.rows || 24,
57
57
  allowProposedApi: true,
58
+ logLevel: 'off',
58
59
  });
59
60
  }
60
61
  async createSessionInternal(worktreePath, ptyProcess, commandConfig, options = {}) {
@@ -109,13 +110,8 @@ export class SessionManager extends EventEmitter {
109
110
  setupDataHandler(session) {
110
111
  // This handler always runs for all data
111
112
  session.process.onData((data) => {
112
- // Write data to virtual terminal with error suppression
113
- try {
114
- session.terminal.write(data);
115
- }
116
- catch {
117
- // Suppress xterm.js parsing errors
118
- }
113
+ // Write data to virtual terminal
114
+ session.terminal.write(data);
119
115
  // Store in output history as Buffer
120
116
  const buffer = Buffer.from(data, 'utf8');
121
117
  session.outputHistory.push(buffer);
@@ -334,4 +330,42 @@ export class SessionManager extends EventEmitter {
334
330
  this.destroySession(worktreePath);
335
331
  }
336
332
  }
333
+ static getSessionCounts(sessions) {
334
+ const counts = {
335
+ idle: 0,
336
+ busy: 0,
337
+ waiting_input: 0,
338
+ total: sessions.length,
339
+ };
340
+ sessions.forEach(session => {
341
+ switch (session.state) {
342
+ case 'idle':
343
+ counts.idle++;
344
+ break;
345
+ case 'busy':
346
+ counts.busy++;
347
+ break;
348
+ case 'waiting_input':
349
+ counts.waiting_input++;
350
+ break;
351
+ }
352
+ });
353
+ return counts;
354
+ }
355
+ static formatSessionCounts(counts) {
356
+ if (counts.total === 0) {
357
+ return '';
358
+ }
359
+ const parts = [];
360
+ if (counts.idle > 0) {
361
+ parts.push(`${counts.idle} Idle`);
362
+ }
363
+ if (counts.busy > 0) {
364
+ parts.push(`${counts.busy} Busy`);
365
+ }
366
+ if (counts.waiting_input > 0) {
367
+ parts.push(`${counts.waiting_input} Waiting`);
368
+ }
369
+ return parts.length > 0 ? ` (${parts.join(' / ')})` : '';
370
+ }
337
371
  }
@@ -694,4 +694,83 @@ describe('SessionManager', () => {
694
694
  expect(session.isPrimaryCommand).toBe(false);
695
695
  });
696
696
  });
697
+ describe('static methods', () => {
698
+ describe('getSessionCounts', () => {
699
+ it('should count sessions by state', () => {
700
+ const sessions = [
701
+ { id: '1', state: 'idle' },
702
+ { id: '2', state: 'busy' },
703
+ { id: '3', state: 'busy' },
704
+ { id: '4', state: 'waiting_input' },
705
+ { id: '5', state: 'idle' },
706
+ ];
707
+ const counts = SessionManager.getSessionCounts(sessions);
708
+ expect(counts.idle).toBe(2);
709
+ expect(counts.busy).toBe(2);
710
+ expect(counts.waiting_input).toBe(1);
711
+ expect(counts.total).toBe(5);
712
+ });
713
+ it('should handle empty sessions array', () => {
714
+ const counts = SessionManager.getSessionCounts([]);
715
+ expect(counts.idle).toBe(0);
716
+ expect(counts.busy).toBe(0);
717
+ expect(counts.waiting_input).toBe(0);
718
+ expect(counts.total).toBe(0);
719
+ });
720
+ it('should handle sessions with single state', () => {
721
+ const sessions = [
722
+ { id: '1', state: 'busy' },
723
+ { id: '2', state: 'busy' },
724
+ { id: '3', state: 'busy' },
725
+ ];
726
+ const counts = SessionManager.getSessionCounts(sessions);
727
+ expect(counts.idle).toBe(0);
728
+ expect(counts.busy).toBe(3);
729
+ expect(counts.waiting_input).toBe(0);
730
+ expect(counts.total).toBe(3);
731
+ });
732
+ });
733
+ describe('formatSessionCounts', () => {
734
+ it('should format counts with all states', () => {
735
+ const counts = {
736
+ idle: 1,
737
+ busy: 2,
738
+ waiting_input: 1,
739
+ total: 4,
740
+ };
741
+ const formatted = SessionManager.formatSessionCounts(counts);
742
+ expect(formatted).toBe(' (1 Idle / 2 Busy / 1 Waiting)');
743
+ });
744
+ it('should format counts with some states', () => {
745
+ const counts = {
746
+ idle: 2,
747
+ busy: 0,
748
+ waiting_input: 1,
749
+ total: 3,
750
+ };
751
+ const formatted = SessionManager.formatSessionCounts(counts);
752
+ expect(formatted).toBe(' (2 Idle / 1 Waiting)');
753
+ });
754
+ it('should format counts with single state', () => {
755
+ const counts = {
756
+ idle: 0,
757
+ busy: 3,
758
+ waiting_input: 0,
759
+ total: 3,
760
+ };
761
+ const formatted = SessionManager.formatSessionCounts(counts);
762
+ expect(formatted).toBe(' (3 Busy)');
763
+ });
764
+ it('should return empty string for zero sessions', () => {
765
+ const counts = {
766
+ idle: 0,
767
+ busy: 0,
768
+ waiting_input: 0,
769
+ total: 0,
770
+ };
771
+ const formatted = SessionManager.formatSessionCounts(counts);
772
+ expect(formatted).toBe('');
773
+ });
774
+ });
775
+ });
697
776
  });
@@ -7,6 +7,7 @@ export declare class WorktreeService {
7
7
  getWorktrees(): Worktree[];
8
8
  private getCurrentBranch;
9
9
  isGitRepository(): boolean;
10
+ getGitRootPath(): string;
10
11
  getDefaultBranch(): string;
11
12
  getAllBranches(): string[];
12
13
  createWorktree(worktreePath: string, branch: string, baseBranch: string, copySessionData?: boolean, copyClaudeDirectory?: boolean): {
@@ -18,7 +18,7 @@ export class WorktreeService {
18
18
  writable: true,
19
19
  value: void 0
20
20
  });
21
- this.rootPath = rootPath || process.cwd();
21
+ this.rootPath = path.resolve(rootPath || process.cwd());
22
22
  // Get the actual git repository root for worktree operations
23
23
  this.gitRootPath = this.getGitRepositoryRoot();
24
24
  }
@@ -29,12 +29,24 @@ export class WorktreeService {
29
29
  cwd: this.rootPath,
30
30
  encoding: 'utf8',
31
31
  }).trim();
32
- // The parent of .git is the actual repository root
33
- return path.dirname(gitCommonDir);
32
+ // Make sure we have an absolute path
33
+ const absoluteGitCommonDir = path.isAbsolute(gitCommonDir)
34
+ ? gitCommonDir
35
+ : path.resolve(this.rootPath, gitCommonDir);
36
+ // Handle worktree paths: if path contains .git/worktrees, we need to find the real .git parent
37
+ if (absoluteGitCommonDir.includes('.git/worktrees')) {
38
+ // Extract the path up to and including .git
39
+ const gitIndex = absoluteGitCommonDir.indexOf('.git');
40
+ const gitPath = absoluteGitCommonDir.substring(0, gitIndex + 4);
41
+ // The parent of .git is the actual repository root
42
+ return path.dirname(gitPath);
43
+ }
44
+ // For regular .git directories, the parent is the repository root
45
+ return path.dirname(absoluteGitCommonDir);
34
46
  }
35
47
  catch {
36
- // Fallback to current directory if command fails
37
- return this.rootPath;
48
+ // Fallback to current directory if command fails - ensure it's absolute
49
+ return path.resolve(this.rootPath);
38
50
  }
39
51
  }
40
52
  getWorktrees() {
@@ -114,6 +126,9 @@ export class WorktreeService {
114
126
  isGitRepository() {
115
127
  return existsSync(path.join(this.rootPath, '.git'));
116
128
  }
129
+ getGitRootPath() {
130
+ return this.gitRootPath;
131
+ }
117
132
  getDefaultBranch() {
118
133
  try {
119
134
  // Try to get the default branch from origin
@@ -31,6 +31,78 @@ describe('WorktreeService', () => {
31
31
  });
32
32
  service = new WorktreeService('/fake/path');
33
33
  });
34
+ describe('getGitRootPath', () => {
35
+ it('should always return an absolute path when git command returns absolute path', () => {
36
+ mockedExecSync.mockImplementation((cmd, _options) => {
37
+ if (typeof cmd === 'string' &&
38
+ cmd === 'git rev-parse --git-common-dir') {
39
+ return '/absolute/repo/.git\n';
40
+ }
41
+ throw new Error('Command not mocked: ' + cmd);
42
+ });
43
+ const service = new WorktreeService('/some/path');
44
+ const result = service.getGitRootPath();
45
+ expect(result).toBe('/absolute/repo');
46
+ expect(result.startsWith('/')).toBe(true);
47
+ });
48
+ it('should convert relative path to absolute path', () => {
49
+ mockedExecSync.mockImplementation((cmd, _options) => {
50
+ if (typeof cmd === 'string' &&
51
+ cmd === 'git rev-parse --git-common-dir') {
52
+ return '.git\n';
53
+ }
54
+ throw new Error('Command not mocked: ' + cmd);
55
+ });
56
+ const service = new WorktreeService('/work/project');
57
+ const result = service.getGitRootPath();
58
+ // Should resolve relative .git to absolute path
59
+ expect(result).toBe('/work/project');
60
+ expect(result.startsWith('/')).toBe(true);
61
+ });
62
+ it('should handle relative paths with subdirectories', () => {
63
+ mockedExecSync.mockImplementation((cmd, _options) => {
64
+ if (typeof cmd === 'string' &&
65
+ cmd === 'git rev-parse --git-common-dir') {
66
+ return '../.git\n';
67
+ }
68
+ throw new Error('Command not mocked: ' + cmd);
69
+ });
70
+ const service = new WorktreeService('/work/project/subdir');
71
+ const result = service.getGitRootPath();
72
+ // Should resolve relative ../.git to absolute path
73
+ expect(result).toBe('/work/project');
74
+ expect(result.startsWith('/')).toBe(true);
75
+ });
76
+ it('should return absolute path on git command failure', () => {
77
+ mockedExecSync.mockImplementation((cmd, _options) => {
78
+ if (typeof cmd === 'string' &&
79
+ cmd === 'git rev-parse --git-common-dir') {
80
+ throw new Error('Not a git repository');
81
+ }
82
+ throw new Error('Command not mocked: ' + cmd);
83
+ });
84
+ const service = new WorktreeService('relative/path');
85
+ const result = service.getGitRootPath();
86
+ // Should convert relative path to absolute path
87
+ expect(result.startsWith('/')).toBe(true);
88
+ expect(result.endsWith('relative/path')).toBe(true);
89
+ });
90
+ it('should handle worktree paths correctly', () => {
91
+ mockedExecSync.mockImplementation((cmd, _options) => {
92
+ if (typeof cmd === 'string' &&
93
+ cmd === 'git rev-parse --git-common-dir') {
94
+ // Worktrees often return paths like: /path/to/main/.git/worktrees/feature
95
+ return '/main/repo/.git/worktrees/feature\n';
96
+ }
97
+ throw new Error('Command not mocked: ' + cmd);
98
+ });
99
+ const service = new WorktreeService('/main/repo/feature-worktree');
100
+ const result = service.getGitRootPath();
101
+ // Should get the parent of .git directory
102
+ expect(result).toBe('/main/repo');
103
+ expect(result.startsWith('/')).toBe(true);
104
+ });
105
+ });
34
106
  describe('getDefaultBranch', () => {
35
107
  it('should return default branch from origin', () => {
36
108
  mockedExecSync.mockImplementation((cmd, _options) => {