ccmanager 0.1.14 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +3 -0
- package/dist/components/Configuration.js +34 -6
- package/dist/components/Menu.js +87 -25
- package/dist/hooks/useGitStatus.d.ts +2 -0
- package/dist/hooks/useGitStatus.js +52 -0
- package/dist/hooks/useGitStatus.test.d.ts +1 -0
- package/dist/hooks/useGitStatus.test.js +186 -0
- package/dist/services/worktreeConfigManager.d.ts +10 -0
- package/dist/services/worktreeConfigManager.js +27 -0
- package/dist/services/worktreeService.js +8 -0
- package/dist/services/worktreeService.test.js +8 -0
- package/dist/types/index.d.ts +3 -0
- package/dist/utils/concurrencyLimit.d.ts +4 -0
- package/dist/utils/concurrencyLimit.js +30 -0
- package/dist/utils/concurrencyLimit.test.d.ts +1 -0
- package/dist/utils/concurrencyLimit.test.js +63 -0
- package/dist/utils/gitStatus.d.ts +19 -0
- package/dist/utils/gitStatus.js +146 -0
- package/dist/utils/gitStatus.test.d.ts +1 -0
- package/dist/utils/gitStatus.test.js +141 -0
- package/dist/utils/worktreeConfig.d.ts +3 -0
- package/dist/utils/worktreeConfig.js +43 -0
- package/dist/utils/worktreeUtils.d.ts +37 -0
- package/dist/utils/worktreeUtils.js +114 -0
- package/dist/utils/worktreeUtils.test.js +105 -1
- package/package.json +1 -1
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { createConcurrencyLimited } from './concurrencyLimit.js';
|
|
3
|
+
describe('createConcurrencyLimited', () => {
|
|
4
|
+
it('should limit concurrent executions', async () => {
|
|
5
|
+
let running = 0;
|
|
6
|
+
let maxRunning = 0;
|
|
7
|
+
const task = async (id) => {
|
|
8
|
+
running++;
|
|
9
|
+
maxRunning = Math.max(maxRunning, running);
|
|
10
|
+
// Simulate work
|
|
11
|
+
await new Promise(resolve => setTimeout(resolve, 10));
|
|
12
|
+
running--;
|
|
13
|
+
return id;
|
|
14
|
+
};
|
|
15
|
+
const limitedTask = createConcurrencyLimited(task, 2);
|
|
16
|
+
// Start 5 tasks
|
|
17
|
+
const promises = [
|
|
18
|
+
limitedTask(1),
|
|
19
|
+
limitedTask(2),
|
|
20
|
+
limitedTask(3),
|
|
21
|
+
limitedTask(4),
|
|
22
|
+
limitedTask(5),
|
|
23
|
+
];
|
|
24
|
+
const results = await Promise.all(promises);
|
|
25
|
+
// All tasks should complete
|
|
26
|
+
expect(results).toEqual([1, 2, 3, 4, 5]);
|
|
27
|
+
// Max concurrent should not exceed limit
|
|
28
|
+
expect(maxRunning).toBeLessThanOrEqual(2);
|
|
29
|
+
// All tasks should have finished
|
|
30
|
+
expect(running).toBe(0);
|
|
31
|
+
});
|
|
32
|
+
it('should handle errors without blocking queue', async () => {
|
|
33
|
+
let callCount = 0;
|
|
34
|
+
const original = async () => {
|
|
35
|
+
callCount++;
|
|
36
|
+
if (callCount === 1) {
|
|
37
|
+
throw new Error('Task failed');
|
|
38
|
+
}
|
|
39
|
+
return 'success';
|
|
40
|
+
};
|
|
41
|
+
const limited = createConcurrencyLimited(original, 1);
|
|
42
|
+
// Start failing task first
|
|
43
|
+
const promise1 = limited().catch(e => e.message);
|
|
44
|
+
// Queue successful task
|
|
45
|
+
const promise2 = limited();
|
|
46
|
+
const results = await Promise.all([promise1, promise2]);
|
|
47
|
+
expect(results[0]).toBe('Task failed');
|
|
48
|
+
expect(results[1]).toBe('success');
|
|
49
|
+
});
|
|
50
|
+
it('should preserve function arguments', async () => {
|
|
51
|
+
const original = async (a, b, c) => {
|
|
52
|
+
return `${a}-${b}-${c}`;
|
|
53
|
+
};
|
|
54
|
+
const limited = createConcurrencyLimited(original, 1);
|
|
55
|
+
const result = await limited(42, 'test', true);
|
|
56
|
+
expect(result).toBe('42-test-true');
|
|
57
|
+
});
|
|
58
|
+
it('should throw for invalid maxConcurrent', () => {
|
|
59
|
+
const fn = async () => 'test';
|
|
60
|
+
expect(() => createConcurrencyLimited(fn, 0)).toThrow('maxConcurrent must be at least 1');
|
|
61
|
+
expect(() => createConcurrencyLimited(fn, -1)).toThrow('maxConcurrent must be at least 1');
|
|
62
|
+
});
|
|
63
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface GitStatus {
|
|
2
|
+
filesAdded: number;
|
|
3
|
+
filesDeleted: number;
|
|
4
|
+
aheadCount: number;
|
|
5
|
+
behindCount: number;
|
|
6
|
+
parentBranch: string | null;
|
|
7
|
+
}
|
|
8
|
+
export interface GitOperationResult<T> {
|
|
9
|
+
success: boolean;
|
|
10
|
+
data?: T;
|
|
11
|
+
error?: string;
|
|
12
|
+
skipped?: boolean;
|
|
13
|
+
}
|
|
14
|
+
export declare function getGitStatus(worktreePath: string, signal: AbortSignal): Promise<GitOperationResult<GitStatus>>;
|
|
15
|
+
export declare function formatGitFileChanges(status: GitStatus): string;
|
|
16
|
+
export declare function formatGitAheadBehind(status: GitStatus): string;
|
|
17
|
+
export declare function formatGitStatus(status: GitStatus): string;
|
|
18
|
+
export declare function formatParentBranch(parentBranch: string | null, currentBranch: string): string;
|
|
19
|
+
export declare const getGitStatusLimited: (worktreePath: string, signal: AbortSignal) => Promise<GitOperationResult<GitStatus>>;
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { promisify } from 'util';
|
|
2
|
+
import { exec, execFile } from 'child_process';
|
|
3
|
+
import { getWorktreeParentBranch } from './worktreeConfig.js';
|
|
4
|
+
import { createConcurrencyLimited } from './concurrencyLimit.js';
|
|
5
|
+
const execp = promisify(exec);
|
|
6
|
+
const execFilePromisified = promisify(execFile);
|
|
7
|
+
export async function getGitStatus(worktreePath, signal) {
|
|
8
|
+
try {
|
|
9
|
+
// Get unstaged changes
|
|
10
|
+
const [diffResult, stagedResult, branchResult, parentBranch] = await Promise.all([
|
|
11
|
+
execp('git diff --shortstat', { cwd: worktreePath, signal }).catch(() => EMPTY_EXEC_RESULT),
|
|
12
|
+
execp('git diff --staged --shortstat', {
|
|
13
|
+
cwd: worktreePath,
|
|
14
|
+
signal,
|
|
15
|
+
}).catch(() => EMPTY_EXEC_RESULT),
|
|
16
|
+
execp('git branch --show-current', { cwd: worktreePath, signal }).catch(() => EMPTY_EXEC_RESULT),
|
|
17
|
+
getWorktreeParentBranch(worktreePath, signal),
|
|
18
|
+
]);
|
|
19
|
+
// Parse file changes
|
|
20
|
+
let filesAdded = 0;
|
|
21
|
+
let filesDeleted = 0;
|
|
22
|
+
if (diffResult.stdout) {
|
|
23
|
+
const stats = parseGitStats(diffResult.stdout);
|
|
24
|
+
filesAdded += stats.insertions;
|
|
25
|
+
filesDeleted += stats.deletions;
|
|
26
|
+
}
|
|
27
|
+
if (stagedResult.stdout) {
|
|
28
|
+
const stats = parseGitStats(stagedResult.stdout);
|
|
29
|
+
filesAdded += stats.insertions;
|
|
30
|
+
filesDeleted += stats.deletions;
|
|
31
|
+
}
|
|
32
|
+
// Get ahead/behind counts
|
|
33
|
+
let aheadCount = 0;
|
|
34
|
+
let behindCount = 0;
|
|
35
|
+
const currentBranch = branchResult.stdout.trim();
|
|
36
|
+
if (currentBranch && parentBranch && currentBranch !== parentBranch) {
|
|
37
|
+
try {
|
|
38
|
+
const aheadBehindResult = await execFilePromisified('git', ['rev-list', '--left-right', '--count', `${parentBranch}...HEAD`], { cwd: worktreePath, signal });
|
|
39
|
+
const [behind, ahead] = aheadBehindResult.stdout
|
|
40
|
+
.trim()
|
|
41
|
+
.split('\t')
|
|
42
|
+
.map(n => parseInt(n, 10));
|
|
43
|
+
aheadCount = ahead || 0;
|
|
44
|
+
behindCount = behind || 0;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// Branch comparison might fail
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
success: true,
|
|
52
|
+
data: {
|
|
53
|
+
filesAdded,
|
|
54
|
+
filesDeleted,
|
|
55
|
+
aheadCount,
|
|
56
|
+
behindCount,
|
|
57
|
+
parentBranch,
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
catch (error) {
|
|
62
|
+
let errorMessage = '';
|
|
63
|
+
if (error instanceof Error) {
|
|
64
|
+
errorMessage = error.message;
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
errorMessage = String(error);
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
success: false,
|
|
71
|
+
error: errorMessage,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
// Split git status formatting into file changes and ahead/behind
|
|
76
|
+
export function formatGitFileChanges(status) {
|
|
77
|
+
const parts = [];
|
|
78
|
+
const colors = {
|
|
79
|
+
green: '\x1b[32m',
|
|
80
|
+
red: '\x1b[31m',
|
|
81
|
+
reset: '\x1b[0m',
|
|
82
|
+
};
|
|
83
|
+
// File changes
|
|
84
|
+
if (status.filesAdded > 0) {
|
|
85
|
+
parts.push(`${colors.green}+${status.filesAdded}${colors.reset}`);
|
|
86
|
+
}
|
|
87
|
+
if (status.filesDeleted > 0) {
|
|
88
|
+
parts.push(`${colors.red}-${status.filesDeleted}${colors.reset}`);
|
|
89
|
+
}
|
|
90
|
+
return parts.join(' ');
|
|
91
|
+
}
|
|
92
|
+
export function formatGitAheadBehind(status) {
|
|
93
|
+
const parts = [];
|
|
94
|
+
const colors = {
|
|
95
|
+
cyan: '\x1b[36m',
|
|
96
|
+
magenta: '\x1b[35m',
|
|
97
|
+
reset: '\x1b[0m',
|
|
98
|
+
};
|
|
99
|
+
// Ahead/behind - compact format with arrows
|
|
100
|
+
if (status.aheadCount > 0) {
|
|
101
|
+
parts.push(`${colors.cyan}↑${status.aheadCount}${colors.reset}`);
|
|
102
|
+
}
|
|
103
|
+
if (status.behindCount > 0) {
|
|
104
|
+
parts.push(`${colors.magenta}↓${status.behindCount}${colors.reset}`);
|
|
105
|
+
}
|
|
106
|
+
return parts.join(' ');
|
|
107
|
+
}
|
|
108
|
+
// Keep the original function for backward compatibility
|
|
109
|
+
export function formatGitStatus(status) {
|
|
110
|
+
const fileChanges = formatGitFileChanges(status);
|
|
111
|
+
const aheadBehind = formatGitAheadBehind(status);
|
|
112
|
+
const parts = [];
|
|
113
|
+
if (fileChanges)
|
|
114
|
+
parts.push(fileChanges);
|
|
115
|
+
if (aheadBehind)
|
|
116
|
+
parts.push(aheadBehind);
|
|
117
|
+
return parts.join(' ');
|
|
118
|
+
}
|
|
119
|
+
export function formatParentBranch(parentBranch, currentBranch) {
|
|
120
|
+
// Only show parent branch if it exists and is different from current branch
|
|
121
|
+
if (!parentBranch || parentBranch === currentBranch) {
|
|
122
|
+
return '';
|
|
123
|
+
}
|
|
124
|
+
const colors = {
|
|
125
|
+
dim: '\x1b[90m',
|
|
126
|
+
reset: '\x1b[0m',
|
|
127
|
+
};
|
|
128
|
+
return `${colors.dim}(${parentBranch})${colors.reset}`;
|
|
129
|
+
}
|
|
130
|
+
const EMPTY_EXEC_RESULT = { stdout: '', stderr: '' };
|
|
131
|
+
function parseGitStats(statLine) {
|
|
132
|
+
let insertions = 0;
|
|
133
|
+
let deletions = 0;
|
|
134
|
+
// Parse git diff --shortstat output
|
|
135
|
+
// Example: " 3 files changed, 42 insertions(+), 10 deletions(-)"
|
|
136
|
+
const insertMatch = statLine.match(/(\d+) insertion/);
|
|
137
|
+
const deleteMatch = statLine.match(/(\d+) deletion/);
|
|
138
|
+
if (insertMatch && insertMatch[1]) {
|
|
139
|
+
insertions = parseInt(insertMatch[1], 10);
|
|
140
|
+
}
|
|
141
|
+
if (deleteMatch && deleteMatch[1]) {
|
|
142
|
+
deletions = parseInt(deleteMatch[1], 10);
|
|
143
|
+
}
|
|
144
|
+
return { insertions, deletions };
|
|
145
|
+
}
|
|
146
|
+
export const getGitStatusLimited = createConcurrencyLimited(getGitStatus, 10);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { formatGitStatus, formatGitFileChanges, formatGitAheadBehind, formatParentBranch, getGitStatus, } from './gitStatus.js';
|
|
3
|
+
import { exec } from 'child_process';
|
|
4
|
+
import { promisify } from 'util';
|
|
5
|
+
import * as fs from 'fs';
|
|
6
|
+
import * as path from 'path';
|
|
7
|
+
import * as os from 'os';
|
|
8
|
+
// Mock worktreeConfigManager
|
|
9
|
+
vi.mock('../services/worktreeConfigManager.js', () => ({
|
|
10
|
+
worktreeConfigManager: {
|
|
11
|
+
initialize: vi.fn(),
|
|
12
|
+
isAvailable: vi.fn(() => true),
|
|
13
|
+
reset: vi.fn(),
|
|
14
|
+
},
|
|
15
|
+
}));
|
|
16
|
+
const execAsync = promisify(exec);
|
|
17
|
+
describe('formatGitStatus', () => {
|
|
18
|
+
it('should format status with ANSI colors', () => {
|
|
19
|
+
const status = {
|
|
20
|
+
filesAdded: 42,
|
|
21
|
+
filesDeleted: 10,
|
|
22
|
+
aheadCount: 5,
|
|
23
|
+
behindCount: 3,
|
|
24
|
+
parentBranch: 'main',
|
|
25
|
+
};
|
|
26
|
+
const formatted = formatGitStatus(status);
|
|
27
|
+
expect(formatted).toBe('\x1b[32m+42\x1b[0m \x1b[31m-10\x1b[0m \x1b[36m↑5\x1b[0m \x1b[35m↓3\x1b[0m');
|
|
28
|
+
});
|
|
29
|
+
it('should use formatGitStatusWithColors as alias', () => {
|
|
30
|
+
const status = {
|
|
31
|
+
filesAdded: 1,
|
|
32
|
+
filesDeleted: 2,
|
|
33
|
+
aheadCount: 3,
|
|
34
|
+
behindCount: 4,
|
|
35
|
+
parentBranch: 'main',
|
|
36
|
+
};
|
|
37
|
+
const withColors = formatGitStatus(status);
|
|
38
|
+
const withColorsParam = formatGitStatus(status);
|
|
39
|
+
expect(withColors).toBe(withColorsParam);
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
describe('GitService Integration Tests', { timeout: 10000 }, () => {
|
|
43
|
+
it('should handle concurrent calls correctly', async () => {
|
|
44
|
+
// Create a temporary git repo for testing
|
|
45
|
+
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccmanager-test-'));
|
|
46
|
+
try {
|
|
47
|
+
// Initialize git repo
|
|
48
|
+
await execAsync('git init', { cwd: tmpDir });
|
|
49
|
+
await execAsync('git config user.email "test@example.com"', {
|
|
50
|
+
cwd: tmpDir,
|
|
51
|
+
});
|
|
52
|
+
await execAsync('git config user.name "Test User"', { cwd: tmpDir });
|
|
53
|
+
await execAsync('git config commit.gpgsign false', { cwd: tmpDir });
|
|
54
|
+
// Create a file and commit
|
|
55
|
+
fs.writeFileSync(path.join(tmpDir, 'test.txt'), 'Hello World');
|
|
56
|
+
await execAsync('git add test.txt', { cwd: tmpDir });
|
|
57
|
+
await execAsync('git commit -m "Initial commit"', { cwd: tmpDir });
|
|
58
|
+
// Test concurrent calls - all should succeed now without locking
|
|
59
|
+
// Create abort controllers for each call
|
|
60
|
+
const controller1 = new AbortController();
|
|
61
|
+
const controller2 = new AbortController();
|
|
62
|
+
const controller3 = new AbortController();
|
|
63
|
+
const results = await Promise.all([
|
|
64
|
+
getGitStatus(tmpDir, controller1.signal),
|
|
65
|
+
getGitStatus(tmpDir, controller2.signal),
|
|
66
|
+
getGitStatus(tmpDir, controller3.signal),
|
|
67
|
+
]);
|
|
68
|
+
// All should succeed
|
|
69
|
+
const successCount = results.filter(r => r.success).length;
|
|
70
|
+
expect(successCount).toBe(3);
|
|
71
|
+
// All results should have the same data
|
|
72
|
+
const firstData = results[0].data;
|
|
73
|
+
results.forEach(result => {
|
|
74
|
+
expect(result.success).toBe(true);
|
|
75
|
+
expect(result.data).toEqual(firstData);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
// Cleanup
|
|
80
|
+
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
describe('formatGitFileChanges', () => {
|
|
85
|
+
it('should format only file changes', () => {
|
|
86
|
+
const status = {
|
|
87
|
+
filesAdded: 10,
|
|
88
|
+
filesDeleted: 5,
|
|
89
|
+
aheadCount: 3,
|
|
90
|
+
behindCount: 2,
|
|
91
|
+
parentBranch: 'main',
|
|
92
|
+
};
|
|
93
|
+
expect(formatGitFileChanges(status)).toBe('\x1b[32m+10\x1b[0m \x1b[31m-5\x1b[0m');
|
|
94
|
+
});
|
|
95
|
+
it('should handle zero file changes', () => {
|
|
96
|
+
const status = {
|
|
97
|
+
filesAdded: 0,
|
|
98
|
+
filesDeleted: 0,
|
|
99
|
+
aheadCount: 3,
|
|
100
|
+
behindCount: 2,
|
|
101
|
+
parentBranch: 'main',
|
|
102
|
+
};
|
|
103
|
+
expect(formatGitFileChanges(status)).toBe('');
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
describe('formatGitAheadBehind', () => {
|
|
107
|
+
it('should format only ahead/behind markers', () => {
|
|
108
|
+
const status = {
|
|
109
|
+
filesAdded: 10,
|
|
110
|
+
filesDeleted: 5,
|
|
111
|
+
aheadCount: 3,
|
|
112
|
+
behindCount: 2,
|
|
113
|
+
parentBranch: 'main',
|
|
114
|
+
};
|
|
115
|
+
expect(formatGitAheadBehind(status)).toBe('\x1b[36m↑3\x1b[0m \x1b[35m↓2\x1b[0m');
|
|
116
|
+
});
|
|
117
|
+
it('should handle zero ahead/behind', () => {
|
|
118
|
+
const status = {
|
|
119
|
+
filesAdded: 10,
|
|
120
|
+
filesDeleted: 5,
|
|
121
|
+
aheadCount: 0,
|
|
122
|
+
behindCount: 0,
|
|
123
|
+
parentBranch: 'main',
|
|
124
|
+
};
|
|
125
|
+
expect(formatGitAheadBehind(status)).toBe('');
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
describe('formatParentBranch', () => {
|
|
129
|
+
it('should return empty string when parent and current branch are the same', () => {
|
|
130
|
+
expect(formatParentBranch('main', 'main')).toBe('');
|
|
131
|
+
expect(formatParentBranch('feature', 'feature')).toBe('');
|
|
132
|
+
});
|
|
133
|
+
it('should format parent branch when different from current', () => {
|
|
134
|
+
expect(formatParentBranch('main', 'feature')).toBe('\x1b[90m(main)\x1b[0m');
|
|
135
|
+
expect(formatParentBranch('develop', 'feature-123')).toBe('\x1b[90m(develop)\x1b[0m');
|
|
136
|
+
});
|
|
137
|
+
it('should include color codes', () => {
|
|
138
|
+
const formatted = formatParentBranch('main', 'feature');
|
|
139
|
+
expect(formatted).toBe('\x1b[90m(main)\x1b[0m');
|
|
140
|
+
});
|
|
141
|
+
});
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export declare function isWorktreeConfigEnabled(gitPath?: string): boolean;
|
|
2
|
+
export declare function getWorktreeParentBranch(worktreePath: string, signal?: AbortSignal): Promise<string | null>;
|
|
3
|
+
export declare function setWorktreeParentBranch(worktreePath: string, parentBranch: string): void;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { promisify } from 'util';
|
|
2
|
+
import { exec, execSync, execFileSync } from 'child_process';
|
|
3
|
+
import { worktreeConfigManager } from '../services/worktreeConfigManager.js';
|
|
4
|
+
const execp = promisify(exec);
|
|
5
|
+
export function isWorktreeConfigEnabled(gitPath) {
|
|
6
|
+
try {
|
|
7
|
+
const result = execSync('git config extensions.worktreeConfig', {
|
|
8
|
+
cwd: gitPath || process.cwd(),
|
|
9
|
+
encoding: 'utf8',
|
|
10
|
+
}).trim();
|
|
11
|
+
return result === 'true';
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export async function getWorktreeParentBranch(worktreePath, signal) {
|
|
18
|
+
// Return null if worktree config extension is not available
|
|
19
|
+
if (!worktreeConfigManager.isAvailable()) {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
const result = await execp('git config --worktree ccmanager.parentBranch', {
|
|
24
|
+
cwd: worktreePath,
|
|
25
|
+
encoding: 'utf8',
|
|
26
|
+
signal,
|
|
27
|
+
});
|
|
28
|
+
return result.stdout.trim() || null;
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export function setWorktreeParentBranch(worktreePath, parentBranch) {
|
|
35
|
+
// Skip if worktree config extension is not available
|
|
36
|
+
if (!worktreeConfigManager.isAvailable()) {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
execFileSync('git', ['config', '--worktree', 'ccmanager.parentBranch', parentBranch], {
|
|
40
|
+
cwd: worktreePath,
|
|
41
|
+
encoding: 'utf8',
|
|
42
|
+
});
|
|
43
|
+
}
|
|
@@ -1,5 +1,42 @@
|
|
|
1
|
+
import { Worktree, Session } from '../types/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* Worktree item with formatted content for display.
|
|
4
|
+
*/
|
|
5
|
+
interface WorktreeItem {
|
|
6
|
+
worktree: Worktree;
|
|
7
|
+
session?: Session;
|
|
8
|
+
baseLabel: string;
|
|
9
|
+
fileChanges: string;
|
|
10
|
+
aheadBehind: string;
|
|
11
|
+
parentBranch: string;
|
|
12
|
+
error?: string;
|
|
13
|
+
lengths: {
|
|
14
|
+
base: number;
|
|
15
|
+
fileChanges: number;
|
|
16
|
+
aheadBehind: number;
|
|
17
|
+
parentBranch: number;
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
export declare function truncateString(str: string, maxLength: number): string;
|
|
1
21
|
export declare function generateWorktreeDirectory(branchName: string, pattern?: string): string;
|
|
2
22
|
export declare function extractBranchParts(branchName: string): {
|
|
3
23
|
prefix?: string;
|
|
4
24
|
name: string;
|
|
5
25
|
};
|
|
26
|
+
/**
|
|
27
|
+
* Prepares worktree content for display with plain and colored versions.
|
|
28
|
+
*/
|
|
29
|
+
export declare function prepareWorktreeItems(worktrees: Worktree[], sessions: Session[]): WorktreeItem[];
|
|
30
|
+
/**
|
|
31
|
+
* Calculates column positions based on content widths.
|
|
32
|
+
*/
|
|
33
|
+
export declare function calculateColumnPositions(items: WorktreeItem[]): {
|
|
34
|
+
fileChanges: number;
|
|
35
|
+
aheadBehind: number;
|
|
36
|
+
parentBranch: number;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Assembles the final worktree label with proper column alignment
|
|
40
|
+
*/
|
|
41
|
+
export declare function assembleWorktreeLabel(item: WorktreeItem, columns: ReturnType<typeof calculateColumnPositions>): string;
|
|
42
|
+
export {};
|
|
@@ -1,4 +1,17 @@
|
|
|
1
1
|
import path from 'path';
|
|
2
|
+
import { getStatusDisplay } from '../constants/statusIcons.js';
|
|
3
|
+
import { formatGitFileChanges, formatGitAheadBehind, formatParentBranch, } from './gitStatus.js';
|
|
4
|
+
// Constants
|
|
5
|
+
const MAX_BRANCH_NAME_LENGTH = 40; // Maximum characters for branch name display
|
|
6
|
+
const MIN_COLUMN_PADDING = 2; // Minimum spaces between columns
|
|
7
|
+
// Strip ANSI escape codes for length calculation
|
|
8
|
+
const stripAnsi = (str) => str.replace(/\x1b\[[0-9;]*m/g, '');
|
|
9
|
+
// Utility function to truncate strings with ellipsis
|
|
10
|
+
export function truncateString(str, maxLength) {
|
|
11
|
+
if (str.length <= maxLength)
|
|
12
|
+
return str;
|
|
13
|
+
return str.substring(0, maxLength - 3) + '...';
|
|
14
|
+
}
|
|
2
15
|
export function generateWorktreeDirectory(branchName, pattern) {
|
|
3
16
|
// Default pattern if not specified
|
|
4
17
|
const defaultPattern = '../{branch}';
|
|
@@ -27,3 +40,104 @@ export function extractBranchParts(branchName) {
|
|
|
27
40
|
}
|
|
28
41
|
return { name: branchName };
|
|
29
42
|
}
|
|
43
|
+
/**
|
|
44
|
+
* Prepares worktree content for display with plain and colored versions.
|
|
45
|
+
*/
|
|
46
|
+
export function prepareWorktreeItems(worktrees, sessions) {
|
|
47
|
+
return worktrees.map(wt => {
|
|
48
|
+
const session = sessions.find(s => s.worktreePath === wt.path);
|
|
49
|
+
const status = session ? ` [${getStatusDisplay(session.state)}]` : '';
|
|
50
|
+
const fullBranchName = wt.branch
|
|
51
|
+
? wt.branch.replace('refs/heads/', '')
|
|
52
|
+
: 'detached';
|
|
53
|
+
const branchName = truncateString(fullBranchName, MAX_BRANCH_NAME_LENGTH);
|
|
54
|
+
const isMain = wt.isMainWorktree ? ' (main)' : '';
|
|
55
|
+
const baseLabel = `${branchName}${isMain}${status}`;
|
|
56
|
+
let fileChanges = '';
|
|
57
|
+
let aheadBehind = '';
|
|
58
|
+
let parentBranch = '';
|
|
59
|
+
let error = '';
|
|
60
|
+
if (wt.gitStatus) {
|
|
61
|
+
fileChanges = formatGitFileChanges(wt.gitStatus);
|
|
62
|
+
aheadBehind = formatGitAheadBehind(wt.gitStatus);
|
|
63
|
+
parentBranch = formatParentBranch(wt.gitStatus.parentBranch, fullBranchName);
|
|
64
|
+
}
|
|
65
|
+
else if (wt.gitStatusError) {
|
|
66
|
+
// Format error in red
|
|
67
|
+
error = `\x1b[31m[git error]\x1b[0m`;
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
// Show fetching status in dim gray
|
|
71
|
+
fileChanges = '\x1b[90m[fetching...]\x1b[0m';
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
worktree: wt,
|
|
75
|
+
session,
|
|
76
|
+
baseLabel,
|
|
77
|
+
fileChanges,
|
|
78
|
+
aheadBehind,
|
|
79
|
+
parentBranch,
|
|
80
|
+
error,
|
|
81
|
+
lengths: {
|
|
82
|
+
base: stripAnsi(baseLabel).length,
|
|
83
|
+
fileChanges: stripAnsi(fileChanges).length,
|
|
84
|
+
aheadBehind: stripAnsi(aheadBehind).length,
|
|
85
|
+
parentBranch: stripAnsi(parentBranch).length,
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Calculates column positions based on content widths.
|
|
92
|
+
*/
|
|
93
|
+
export function calculateColumnPositions(items) {
|
|
94
|
+
// Calculate maximum widths from pre-calculated lengths
|
|
95
|
+
let maxBranchLength = 0;
|
|
96
|
+
let maxFileChangesLength = 0;
|
|
97
|
+
let maxAheadBehindLength = 0;
|
|
98
|
+
items.forEach(item => {
|
|
99
|
+
// Skip items with errors for alignment calculation
|
|
100
|
+
if (item.error)
|
|
101
|
+
return;
|
|
102
|
+
maxBranchLength = Math.max(maxBranchLength, item.lengths.base);
|
|
103
|
+
maxFileChangesLength = Math.max(maxFileChangesLength, item.lengths.fileChanges);
|
|
104
|
+
maxAheadBehindLength = Math.max(maxAheadBehindLength, item.lengths.aheadBehind);
|
|
105
|
+
});
|
|
106
|
+
// Simple column positioning
|
|
107
|
+
const fileChangesColumn = maxBranchLength + MIN_COLUMN_PADDING;
|
|
108
|
+
const aheadBehindColumn = fileChangesColumn + maxFileChangesLength + MIN_COLUMN_PADDING + 2;
|
|
109
|
+
const parentBranchColumn = aheadBehindColumn + maxAheadBehindLength + MIN_COLUMN_PADDING + 2;
|
|
110
|
+
return {
|
|
111
|
+
fileChanges: fileChangesColumn,
|
|
112
|
+
aheadBehind: aheadBehindColumn,
|
|
113
|
+
parentBranch: parentBranchColumn,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
// Pad string to column position
|
|
117
|
+
function padTo(str, visibleLength, column) {
|
|
118
|
+
return str + ' '.repeat(Math.max(0, column - visibleLength));
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Assembles the final worktree label with proper column alignment
|
|
122
|
+
*/
|
|
123
|
+
export function assembleWorktreeLabel(item, columns) {
|
|
124
|
+
// If there's an error, just show the base label with error appended
|
|
125
|
+
if (item.error) {
|
|
126
|
+
return `${item.baseLabel} ${item.error}`;
|
|
127
|
+
}
|
|
128
|
+
let label = item.baseLabel;
|
|
129
|
+
let currentLength = item.lengths.base;
|
|
130
|
+
if (item.fileChanges) {
|
|
131
|
+
label = padTo(label, currentLength, columns.fileChanges) + item.fileChanges;
|
|
132
|
+
currentLength = columns.fileChanges + item.lengths.fileChanges;
|
|
133
|
+
}
|
|
134
|
+
if (item.aheadBehind) {
|
|
135
|
+
label = padTo(label, currentLength, columns.aheadBehind) + item.aheadBehind;
|
|
136
|
+
currentLength = columns.aheadBehind + item.lengths.aheadBehind;
|
|
137
|
+
}
|
|
138
|
+
if (item.parentBranch) {
|
|
139
|
+
label =
|
|
140
|
+
padTo(label, currentLength, columns.parentBranch) + item.parentBranch;
|
|
141
|
+
}
|
|
142
|
+
return label;
|
|
143
|
+
}
|