ccmanager 3.11.1 → 3.11.3
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/components/App.js +7 -5
- package/dist/components/Dashboard.js +7 -1
- package/dist/components/DeleteWorktree.js +13 -2
- package/dist/components/Menu.js +2 -1
- package/dist/hooks/useGitStatus.d.ts +6 -4
- package/dist/hooks/useGitStatus.js +44 -30
- package/dist/hooks/useGitStatus.test.js +21 -2
- package/dist/services/worktreeNameGenerator.d.ts +1 -0
- package/dist/services/worktreeNameGenerator.js +10 -0
- package/dist/services/worktreeNameGenerator.test.js +13 -1
- package/dist/services/worktreeService.js +11 -0
- package/dist/services/worktreeService.test.js +30 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/utils/gitStatus.d.ts +8 -0
- package/dist/utils/gitStatus.js +18 -0
- package/dist/utils/worktreeUtils.d.ts +7 -0
- package/dist/utils/worktreeUtils.js +42 -0
- package/dist/utils/worktreeUtils.test.js +4 -0
- package/package.json +6 -6
package/dist/components/App.js
CHANGED
|
@@ -14,7 +14,8 @@ import RemoteBranchSelector from './RemoteBranchSelector.js';
|
|
|
14
14
|
import LoadingSpinner from './LoadingSpinner.js';
|
|
15
15
|
import { globalSessionOrchestrator } from '../services/globalSessionOrchestrator.js';
|
|
16
16
|
import { WorktreeService } from '../services/worktreeService.js';
|
|
17
|
-
import { worktreeNameGenerator } from '../services/worktreeNameGenerator.js';
|
|
17
|
+
import { worktreeNameGenerator, generateFallbackBranchName, } from '../services/worktreeNameGenerator.js';
|
|
18
|
+
import { logger } from '../utils/logger.js';
|
|
18
19
|
import { AmbiguousBranchError, } from '../types/index.js';
|
|
19
20
|
import { configReader } from '../services/config/configReader.js';
|
|
20
21
|
import { ENV_VARS } from '../constants/env.js';
|
|
@@ -327,11 +328,12 @@ const App = ({ devcontainerConfig, multiProject, version, }) => {
|
|
|
327
328
|
const existingBranches = allBranches._tag === 'Right' ? allBranches.right : [];
|
|
328
329
|
const generatedBranch = await Effect.runPromise(Effect.either(worktreeNameGenerator.generateBranchNameEffect(request.initialPrompt, request.baseBranch, existingBranches)));
|
|
329
330
|
if (generatedBranch._tag === 'Left') {
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
331
|
+
logger.warn(`Branch name generation failed, using fallback: ${formatErrorMessage(generatedBranch.left)}`);
|
|
332
|
+
branch = generateFallbackBranchName(existingBranches);
|
|
333
|
+
}
|
|
334
|
+
else {
|
|
335
|
+
branch = generatedBranch.right;
|
|
333
336
|
}
|
|
334
|
-
branch = generatedBranch.right;
|
|
335
337
|
if (request.autoDirectoryPattern) {
|
|
336
338
|
targetPath = generateWorktreeDirectory(request.projectPath, branch, request.autoDirectoryPattern);
|
|
337
339
|
}
|
|
@@ -11,7 +11,7 @@ import { WorktreeService } from '../services/worktreeService.js';
|
|
|
11
11
|
import { STATUS_ICONS, STATUS_LABELS, MENU_ICONS, getStatusDisplay, } from '../constants/statusIcons.js';
|
|
12
12
|
import { useSearchMode } from '../hooks/useSearchMode.js';
|
|
13
13
|
import { useGitStatus } from '../hooks/useGitStatus.js';
|
|
14
|
-
import { truncateString, calculateColumnPositions, assembleWorktreeLabel, } from '../utils/worktreeUtils.js';
|
|
14
|
+
import { truncateString, calculateColumnPositions, assembleWorktreeLabel, formatRelativeDate, } from '../utils/worktreeUtils.js';
|
|
15
15
|
import { formatGitFileChanges, formatGitAheadBehind, formatParentBranch, } from '../utils/gitStatus.js';
|
|
16
16
|
import TextInputWrapper from './TextInputWrapper.js';
|
|
17
17
|
const MAX_BRANCH_NAME_LENGTH = 70;
|
|
@@ -241,12 +241,18 @@ const Dashboard = ({ projectsDir, onSelectSession, onSelectProject, error, onDis
|
|
|
241
241
|
fileChanges,
|
|
242
242
|
aheadBehind,
|
|
243
243
|
parentBranch,
|
|
244
|
+
lastCommitDate: wt.lastCommitDate
|
|
245
|
+
? `\x1b[90m${formatRelativeDate(wt.lastCommitDate)}\x1b[0m`
|
|
246
|
+
: '',
|
|
244
247
|
error: itemError,
|
|
245
248
|
lengths: {
|
|
246
249
|
base: stripAnsi(baseLabel).length,
|
|
247
250
|
fileChanges: stripAnsi(fileChanges).length,
|
|
248
251
|
aheadBehind: stripAnsi(aheadBehind).length,
|
|
249
252
|
parentBranch: stripAnsi(parentBranch).length,
|
|
253
|
+
lastCommitDate: wt.lastCommitDate
|
|
254
|
+
? formatRelativeDate(wt.lastCommitDate).length
|
|
255
|
+
: 0,
|
|
250
256
|
},
|
|
251
257
|
};
|
|
252
258
|
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { useState, useEffect } from 'react';
|
|
3
|
+
import path from 'path';
|
|
3
4
|
import { Box, Text, useInput } from 'ink';
|
|
4
5
|
import SelectInput from 'ink-select-input';
|
|
5
6
|
import { Effect } from 'effect';
|
|
@@ -20,8 +21,18 @@ const DeleteWorktree = ({ projectPath, onComplete, onCancel, }) => {
|
|
|
20
21
|
try {
|
|
21
22
|
const allWorktrees = await Effect.runPromise(worktreeService.getWorktreesEffect());
|
|
22
23
|
if (!cancelled) {
|
|
23
|
-
// Filter out main worktree
|
|
24
|
-
const
|
|
24
|
+
// Filter out main worktree and current working directory worktree
|
|
25
|
+
const resolvedCwd = path.resolve(process.cwd());
|
|
26
|
+
const deletableWorktrees = allWorktrees.filter(wt => {
|
|
27
|
+
if (wt.isMainWorktree)
|
|
28
|
+
return false;
|
|
29
|
+
const resolvedPath = path.resolve(wt.path);
|
|
30
|
+
if (resolvedCwd === resolvedPath ||
|
|
31
|
+
resolvedCwd.startsWith(resolvedPath + path.sep)) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
return true;
|
|
35
|
+
});
|
|
25
36
|
setWorktrees(deletableWorktrees);
|
|
26
37
|
setIsLoading(false);
|
|
27
38
|
}
|
package/dist/components/Menu.js
CHANGED
|
@@ -144,7 +144,8 @@ const Menu = ({ sessionManager, worktreeService, onSelectWorktree, onSelectRecen
|
|
|
144
144
|
sessionManager.isAutoApprovalDisabledForWorktree(item.worktree.path);
|
|
145
145
|
const label = baseLabel + (aaDisabled ? ' [Auto Approval Off]' : '');
|
|
146
146
|
// Only show numbers for worktrees (0-9) when not in search mode
|
|
147
|
-
|
|
147
|
+
// Use fixed-width prefix to prevent flicker at scroll boundary
|
|
148
|
+
const numberPrefix = !isSearchMode && index < 10 ? `${index} ❯ ` : ' ❯ ';
|
|
148
149
|
return {
|
|
149
150
|
type: 'worktree',
|
|
150
151
|
label: numberPrefix + label,
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import { Worktree } from '../types/index.js';
|
|
2
2
|
/**
|
|
3
|
-
* Custom hook for polling git status of worktrees with Effect-based execution
|
|
3
|
+
* Custom hook for polling git status and commit dates of worktrees with Effect-based execution
|
|
4
4
|
*
|
|
5
|
-
* Fetches git status for each worktree at regular intervals
|
|
6
|
-
* and updates worktree state with results.
|
|
5
|
+
* Fetches git status and last commit date for each worktree at regular intervals
|
|
6
|
+
* using Effect.runPromiseExit and updates worktree state with results.
|
|
7
|
+
* Both are fetched together so they appear at the same time.
|
|
8
|
+
* Handles cancellation via AbortController.
|
|
7
9
|
*
|
|
8
10
|
* @param worktrees - Array of worktrees to monitor
|
|
9
11
|
* @param defaultBranch - Default branch for comparisons (null disables polling)
|
|
10
12
|
* @param updateInterval - Polling interval in milliseconds (default: 5000)
|
|
11
|
-
* @returns Array of worktrees with updated gitStatus and
|
|
13
|
+
* @returns Array of worktrees with updated gitStatus, gitStatusError, and lastCommitDate fields
|
|
12
14
|
*/
|
|
13
15
|
export declare function useGitStatus(worktrees: Worktree[], defaultBranch: string | null, updateInterval?: number): Worktree[];
|
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react';
|
|
2
2
|
import { Effect, Exit, Cause, Option } from 'effect';
|
|
3
|
-
import { getGitStatusLimited } from '../utils/gitStatus.js';
|
|
3
|
+
import { getGitStatusLimited, getLastCommitDateLimited, } from '../utils/gitStatus.js';
|
|
4
4
|
/**
|
|
5
|
-
* Custom hook for polling git status of worktrees with Effect-based execution
|
|
5
|
+
* Custom hook for polling git status and commit dates of worktrees with Effect-based execution
|
|
6
6
|
*
|
|
7
|
-
* Fetches git status for each worktree at regular intervals
|
|
8
|
-
* and updates worktree state with results.
|
|
7
|
+
* Fetches git status and last commit date for each worktree at regular intervals
|
|
8
|
+
* using Effect.runPromiseExit and updates worktree state with results.
|
|
9
|
+
* Both are fetched together so they appear at the same time.
|
|
10
|
+
* Handles cancellation via AbortController.
|
|
9
11
|
*
|
|
10
12
|
* @param worktrees - Array of worktrees to monitor
|
|
11
13
|
* @param defaultBranch - Default branch for comparisons (null disables polling)
|
|
12
14
|
* @param updateInterval - Polling interval in milliseconds (default: 5000)
|
|
13
|
-
* @returns Array of worktrees with updated gitStatus and
|
|
15
|
+
* @returns Array of worktrees with updated gitStatus, gitStatusError, and lastCommitDate fields
|
|
14
16
|
*/
|
|
15
17
|
export function useGitStatus(worktrees, defaultBranch, updateInterval = 5000) {
|
|
16
18
|
const [worktreesWithStatus, setWorktreesWithStatus] = useState(worktrees);
|
|
@@ -22,12 +24,17 @@ export function useGitStatus(worktrees, defaultBranch, updateInterval = 5000) {
|
|
|
22
24
|
const activeRequests = new Map();
|
|
23
25
|
let isCleanedUp = false;
|
|
24
26
|
const fetchStatus = async (worktree, abortController) => {
|
|
25
|
-
//
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
// Fetch git status and last commit date in parallel
|
|
28
|
+
const [statusExit, dateExit] = await Promise.all([
|
|
29
|
+
Effect.runPromiseExit(getGitStatusLimited(worktree.path), {
|
|
30
|
+
signal: abortController.signal,
|
|
31
|
+
}),
|
|
32
|
+
Effect.runPromiseExit(getLastCommitDateLimited(worktree.path), {
|
|
33
|
+
signal: abortController.signal,
|
|
34
|
+
}),
|
|
35
|
+
]);
|
|
36
|
+
// Update worktree state with both results at once
|
|
37
|
+
handleStatusExit(statusExit, dateExit, worktree.path, setWorktreesWithStatus);
|
|
31
38
|
};
|
|
32
39
|
const scheduleUpdate = (worktree) => {
|
|
33
40
|
const abortController = new AbortController();
|
|
@@ -62,35 +69,42 @@ export function useGitStatus(worktrees, defaultBranch, updateInterval = 5000) {
|
|
|
62
69
|
return worktreesWithStatus;
|
|
63
70
|
}
|
|
64
71
|
/**
|
|
65
|
-
* Handle the Exit
|
|
72
|
+
* Handle the Exit results from Effect.runPromiseExit and update worktree state
|
|
66
73
|
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
74
|
+
* Updates both gitStatus and lastCommitDate in a single state update so they
|
|
75
|
+
* appear at the same time in the UI.
|
|
69
76
|
*
|
|
70
|
-
* @param
|
|
77
|
+
* @param statusExit - Exit result from git status Effect
|
|
78
|
+
* @param dateExit - Exit result from commit date Effect
|
|
71
79
|
* @param worktreePath - Path of the worktree being updated
|
|
72
80
|
* @param setWorktreesWithStatus - State setter function
|
|
73
81
|
*/
|
|
74
|
-
function handleStatusExit(
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
82
|
+
function handleStatusExit(statusExit, dateExit, worktreePath, setWorktreesWithStatus) {
|
|
83
|
+
// Build the update object from both results
|
|
84
|
+
const update = {};
|
|
85
|
+
let hasUpdate = false;
|
|
86
|
+
if (Exit.isSuccess(statusExit)) {
|
|
87
|
+
update.gitStatus = statusExit.value;
|
|
88
|
+
update.gitStatusError = undefined;
|
|
89
|
+
hasUpdate = true;
|
|
81
90
|
}
|
|
82
|
-
else if (Exit.isFailure(
|
|
83
|
-
|
|
84
|
-
const failure = Cause.failureOption(exit.cause);
|
|
91
|
+
else if (Exit.isFailure(statusExit)) {
|
|
92
|
+
const failure = Cause.failureOption(statusExit.cause);
|
|
85
93
|
if (Option.isSome(failure)) {
|
|
86
94
|
const gitError = failure.value;
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
: wt));
|
|
95
|
+
update.gitStatus = undefined;
|
|
96
|
+
update.gitStatusError = formatGitError(gitError);
|
|
97
|
+
hasUpdate = true;
|
|
91
98
|
}
|
|
92
99
|
}
|
|
93
|
-
|
|
100
|
+
if (Exit.isSuccess(dateExit)) {
|
|
101
|
+
update.lastCommitDate = dateExit.value;
|
|
102
|
+
hasUpdate = true;
|
|
103
|
+
}
|
|
104
|
+
// Silently ignore commit date errors (e.g., empty repo)
|
|
105
|
+
if (hasUpdate) {
|
|
106
|
+
setWorktreesWithStatus(prev => prev.map(wt => (wt.path === worktreePath ? { ...wt, ...update } : wt)));
|
|
107
|
+
}
|
|
94
108
|
}
|
|
95
109
|
/**
|
|
96
110
|
* Format GitError into a user-friendly error message
|
|
@@ -4,14 +4,16 @@ import { render, cleanup } from 'ink-testing-library';
|
|
|
4
4
|
import { Text } from 'ink';
|
|
5
5
|
import { Effect, Exit } from 'effect';
|
|
6
6
|
import { useGitStatus } from './useGitStatus.js';
|
|
7
|
-
import { getGitStatusLimited } from '../utils/gitStatus.js';
|
|
7
|
+
import { getGitStatusLimited, getLastCommitDateLimited, } from '../utils/gitStatus.js';
|
|
8
8
|
import { GitError } from '../types/errors.js';
|
|
9
9
|
// Mock the gitStatus module
|
|
10
10
|
vi.mock('../utils/gitStatus.js', () => ({
|
|
11
11
|
getGitStatusLimited: vi.fn(),
|
|
12
|
+
getLastCommitDateLimited: vi.fn(),
|
|
12
13
|
}));
|
|
13
14
|
describe('useGitStatus', () => {
|
|
14
15
|
const mockGetGitStatus = getGitStatusLimited;
|
|
16
|
+
const mockGetLastCommitDate = getLastCommitDateLimited;
|
|
15
17
|
const createWorktree = (path) => ({
|
|
16
18
|
path,
|
|
17
19
|
branch: 'main',
|
|
@@ -28,6 +30,9 @@ describe('useGitStatus', () => {
|
|
|
28
30
|
beforeEach(() => {
|
|
29
31
|
vi.useFakeTimers();
|
|
30
32
|
mockGetGitStatus.mockClear();
|
|
33
|
+
mockGetLastCommitDate.mockClear();
|
|
34
|
+
// Default: return a date for all worktrees
|
|
35
|
+
mockGetLastCommitDate.mockReturnValue(Effect.succeed(new Date('2025-01-01T00:00:00Z')));
|
|
31
36
|
});
|
|
32
37
|
afterEach(() => {
|
|
33
38
|
vi.useRealTimers();
|
|
@@ -123,6 +128,13 @@ describe('useGitStatus', () => {
|
|
|
123
128
|
resolveEffect = resume;
|
|
124
129
|
});
|
|
125
130
|
});
|
|
131
|
+
// Also make commit date async so Promise.all waits for both
|
|
132
|
+
let resolveDateEffect = null;
|
|
133
|
+
mockGetLastCommitDate.mockImplementation(() => {
|
|
134
|
+
return Effect.async(resume => {
|
|
135
|
+
resolveDateEffect = resume;
|
|
136
|
+
});
|
|
137
|
+
});
|
|
126
138
|
const TestComponent = () => {
|
|
127
139
|
useGitStatus(worktrees, 'main', 100);
|
|
128
140
|
return React.createElement(Text, null, 'test');
|
|
@@ -136,8 +148,9 @@ describe('useGitStatus', () => {
|
|
|
136
148
|
await vi.advanceTimersByTimeAsync(250);
|
|
137
149
|
// Should not have started a second fetch yet
|
|
138
150
|
expect(mockGetGitStatus).toHaveBeenCalledTimes(1);
|
|
139
|
-
// Complete the first fetch
|
|
151
|
+
// Complete the first fetch (both status and date)
|
|
140
152
|
resolveEffect(Exit.succeed(createGitStatus(1, 0)));
|
|
153
|
+
resolveDateEffect(Exit.succeed(new Date('2025-01-01T00:00:00Z')));
|
|
141
154
|
// Wait for the promise to resolve
|
|
142
155
|
await vi.waitFor(() => {
|
|
143
156
|
expect(fetchCount).toBe(1);
|
|
@@ -162,6 +175,12 @@ describe('useGitStatus', () => {
|
|
|
162
175
|
});
|
|
163
176
|
});
|
|
164
177
|
});
|
|
178
|
+
// Also make commit date async so it doesn't resolve before status
|
|
179
|
+
mockGetLastCommitDate.mockImplementation(() => {
|
|
180
|
+
return Effect.async(_resume => {
|
|
181
|
+
return Effect.sync(() => { });
|
|
182
|
+
});
|
|
183
|
+
});
|
|
165
184
|
const TestComponent = ({ worktrees }) => {
|
|
166
185
|
useGitStatus(worktrees, 'main', 100);
|
|
167
186
|
return React.createElement(Text, null, 'test');
|
|
@@ -2,6 +2,7 @@ import { Effect } from 'effect';
|
|
|
2
2
|
import { ProcessError } from '../types/errors.js';
|
|
3
3
|
export declare const extractBranchNameFromOutput: (stdout: string) => string;
|
|
4
4
|
export declare const deduplicateBranchName: (name: string, existingBranches: string[]) => string;
|
|
5
|
+
export declare const generateFallbackBranchName: (existingBranches?: string[]) => string;
|
|
5
6
|
export declare class WorktreeNameGenerator {
|
|
6
7
|
generateBranchNameEffect(userPrompt: string, baseBranch: string, existingBranches?: string[]): Effect.Effect<string, ProcessError, never>;
|
|
7
8
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { randomBytes } from 'crypto';
|
|
1
2
|
import { Effect } from 'effect';
|
|
2
3
|
import { execFile } from 'child_process';
|
|
3
4
|
import { ProcessError } from '../types/errors.js';
|
|
@@ -121,6 +122,15 @@ export const deduplicateBranchName = (name, existingBranches) => {
|
|
|
121
122
|
}
|
|
122
123
|
}
|
|
123
124
|
};
|
|
125
|
+
export const generateFallbackBranchName = (existingBranches) => {
|
|
126
|
+
const date = new Date();
|
|
127
|
+
const dateStr = `${date.getFullYear()}${String(date.getMonth() + 1).padStart(2, '0')}${String(date.getDate()).padStart(2, '0')}`;
|
|
128
|
+
const randomSuffix = randomBytes(3).toString('hex');
|
|
129
|
+
const name = `${dateStr}-${randomSuffix}`;
|
|
130
|
+
return existingBranches
|
|
131
|
+
? deduplicateBranchName(name, existingBranches)
|
|
132
|
+
: name;
|
|
133
|
+
};
|
|
124
134
|
export class WorktreeNameGenerator {
|
|
125
135
|
generateBranchNameEffect(userPrompt, baseBranch, existingBranches) {
|
|
126
136
|
return Effect.tryPromise({
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { deduplicateBranchName, extractBranchNameFromOutput, worktreeNameGenerator as generator, } from './worktreeNameGenerator.js';
|
|
2
|
+
import { deduplicateBranchName, extractBranchNameFromOutput, generateFallbackBranchName, worktreeNameGenerator as generator, } from './worktreeNameGenerator.js';
|
|
3
3
|
describe('WorktreeNameGenerator output parsing', () => {
|
|
4
4
|
it('normalizes direct json branchName responses', async () => {
|
|
5
5
|
const value = extractBranchNameFromOutput('{"branchName":"feature/add prompt"}');
|
|
@@ -33,3 +33,15 @@ describe('deduplicateBranchName', () => {
|
|
|
33
33
|
expect(deduplicateBranchName('Feature/New', ['feature/new'])).toBe('Feature/New-2');
|
|
34
34
|
});
|
|
35
35
|
});
|
|
36
|
+
describe('generateFallbackBranchName', () => {
|
|
37
|
+
it('returns a name matching YYYYMMDD-hex pattern', () => {
|
|
38
|
+
const name = generateFallbackBranchName();
|
|
39
|
+
expect(name).toMatch(/^\d{8}-[0-9a-f]{6}$/);
|
|
40
|
+
});
|
|
41
|
+
it('deduplicates against existing branches', () => {
|
|
42
|
+
const first = generateFallbackBranchName();
|
|
43
|
+
const name = generateFallbackBranchName([first]);
|
|
44
|
+
// Either it's different (random collision unlikely) or it has a -2 suffix
|
|
45
|
+
expect(name).not.toBe(first);
|
|
46
|
+
});
|
|
47
|
+
});
|
|
@@ -900,6 +900,17 @@ export class WorktreeService {
|
|
|
900
900
|
stderr: 'Cannot delete the main worktree',
|
|
901
901
|
}));
|
|
902
902
|
}
|
|
903
|
+
// Prevent deleting the worktree that contains the current working directory
|
|
904
|
+
const resolvedWorktreePath = path.resolve(worktreePath);
|
|
905
|
+
const resolvedCwd = path.resolve(process.cwd());
|
|
906
|
+
if (resolvedCwd === resolvedWorktreePath ||
|
|
907
|
+
resolvedCwd.startsWith(resolvedWorktreePath + path.sep)) {
|
|
908
|
+
return yield* Effect.fail(new GitError({
|
|
909
|
+
command: 'git worktree remove',
|
|
910
|
+
exitCode: 1,
|
|
911
|
+
stderr: `Cannot delete the worktree at "${worktreePath}" because it is the current working directory`,
|
|
912
|
+
}));
|
|
913
|
+
}
|
|
903
914
|
// Remove the worktree
|
|
904
915
|
yield* Effect.try({
|
|
905
916
|
try: () => {
|
|
@@ -757,6 +757,36 @@ branch refs/heads/main
|
|
|
757
757
|
expect.fail('Should have returned Left with GitError');
|
|
758
758
|
}
|
|
759
759
|
});
|
|
760
|
+
it('should return Effect that fails with GitError when trying to delete worktree matching cwd', async () => {
|
|
761
|
+
const cwdPath = process.cwd();
|
|
762
|
+
mockedExecSync.mockImplementation((cmd, _options) => {
|
|
763
|
+
if (typeof cmd === 'string') {
|
|
764
|
+
if (cmd === 'git rev-parse --git-common-dir') {
|
|
765
|
+
return '/fake/path/.git\n';
|
|
766
|
+
}
|
|
767
|
+
if (cmd === 'git worktree list --porcelain') {
|
|
768
|
+
return `worktree /fake/path
|
|
769
|
+
HEAD abcd1234
|
|
770
|
+
branch refs/heads/main
|
|
771
|
+
|
|
772
|
+
worktree ${cwdPath}
|
|
773
|
+
HEAD efgh5678
|
|
774
|
+
branch refs/heads/feature
|
|
775
|
+
`;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
throw new Error('Command not mocked: ' + cmd);
|
|
779
|
+
});
|
|
780
|
+
const effect = service.deleteWorktreeEffect(cwdPath);
|
|
781
|
+
const result = await Effect.runPromise(Effect.either(effect));
|
|
782
|
+
if (result._tag === 'Left') {
|
|
783
|
+
expect(result.left).toBeInstanceOf(GitError);
|
|
784
|
+
expect(result.left.stderr).toContain('because it is the current working directory');
|
|
785
|
+
}
|
|
786
|
+
else {
|
|
787
|
+
expect.fail('Should have returned Left with GitError');
|
|
788
|
+
}
|
|
789
|
+
});
|
|
760
790
|
});
|
|
761
791
|
describe('Effect-based mergeWorktree', () => {
|
|
762
792
|
it('should return Effect with void on successful merge', async () => {
|
package/dist/types/index.d.ts
CHANGED
|
@@ -41,6 +41,14 @@ export interface GitStatus {
|
|
|
41
41
|
*/
|
|
42
42
|
export declare const getGitStatus: (worktreePath: string) => Effect.Effect<GitStatus, GitError>;
|
|
43
43
|
export declare const getGitStatusLimited: (worktreePath: string) => Effect.Effect<GitStatus, GitError, never>;
|
|
44
|
+
/**
|
|
45
|
+
* Get the last commit date for a worktree
|
|
46
|
+
*
|
|
47
|
+
* @param worktreePath - Absolute path to the worktree directory
|
|
48
|
+
* @returns Effect containing the commit date or GitError
|
|
49
|
+
*/
|
|
50
|
+
export declare const getLastCommitDate: (worktreePath: string) => Effect.Effect<Date, GitError>;
|
|
51
|
+
export declare const getLastCommitDateLimited: (worktreePath: string) => Effect.Effect<Date, GitError, never>;
|
|
44
52
|
export declare function formatGitFileChanges(status: GitStatus): string;
|
|
45
53
|
export declare function formatGitAheadBehind(status: GitStatus): string;
|
|
46
54
|
export declare function formatGitStatus(status: GitStatus): string;
|
package/dist/utils/gitStatus.js
CHANGED
|
@@ -62,6 +62,24 @@ export const getGitStatus = (worktreePath) => Effect.gen(function* () {
|
|
|
62
62
|
};
|
|
63
63
|
});
|
|
64
64
|
export const getGitStatusLimited = createEffectConcurrencyLimited((worktreePath) => getGitStatus(worktreePath), 10);
|
|
65
|
+
/**
|
|
66
|
+
* Get the last commit date for a worktree
|
|
67
|
+
*
|
|
68
|
+
* @param worktreePath - Absolute path to the worktree directory
|
|
69
|
+
* @returns Effect containing the commit date or GitError
|
|
70
|
+
*/
|
|
71
|
+
export const getLastCommitDate = (worktreePath) => Effect.flatMap(runGit(['log', '-1', '--format=%aI'], worktreePath), result => {
|
|
72
|
+
const dateStr = result.stdout.trim();
|
|
73
|
+
if (dateStr) {
|
|
74
|
+
return Effect.succeed(new Date(dateStr));
|
|
75
|
+
}
|
|
76
|
+
return Effect.fail(new GitError({
|
|
77
|
+
command: 'git log -1 --format=%aI',
|
|
78
|
+
exitCode: 0,
|
|
79
|
+
stderr: 'No commits found',
|
|
80
|
+
}));
|
|
81
|
+
});
|
|
82
|
+
export const getLastCommitDateLimited = createEffectConcurrencyLimited((worktreePath) => getLastCommitDate(worktreePath), 10);
|
|
65
83
|
export function formatGitFileChanges(status) {
|
|
66
84
|
const parts = [];
|
|
67
85
|
const colors = {
|
|
@@ -9,14 +9,20 @@ export interface WorktreeItem {
|
|
|
9
9
|
fileChanges: string;
|
|
10
10
|
aheadBehind: string;
|
|
11
11
|
parentBranch: string;
|
|
12
|
+
lastCommitDate: string;
|
|
12
13
|
error?: string;
|
|
13
14
|
lengths: {
|
|
14
15
|
base: number;
|
|
15
16
|
fileChanges: number;
|
|
16
17
|
aheadBehind: number;
|
|
17
18
|
parentBranch: number;
|
|
19
|
+
lastCommitDate: number;
|
|
18
20
|
};
|
|
19
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* Format a date as a relative time string (e.g., "2h ago", "3d ago").
|
|
24
|
+
*/
|
|
25
|
+
export declare function formatRelativeDate(date: Date): string;
|
|
20
26
|
export declare function truncateString(str: string, maxLength: number): string;
|
|
21
27
|
export declare function generateWorktreeDirectory(projectPath: string, branchName: string, pattern?: string): string;
|
|
22
28
|
export declare function extractBranchParts(branchName: string): {
|
|
@@ -34,6 +40,7 @@ export declare function calculateColumnPositions(items: WorktreeItem[]): {
|
|
|
34
40
|
fileChanges: number;
|
|
35
41
|
aheadBehind: number;
|
|
36
42
|
parentBranch: number;
|
|
43
|
+
lastCommitDate: number;
|
|
37
44
|
};
|
|
38
45
|
/**
|
|
39
46
|
* Assembles the final worktree label with proper column alignment
|
|
@@ -6,6 +6,33 @@ import { formatGitFileChanges, formatGitAheadBehind, formatParentBranch, } from
|
|
|
6
6
|
// Constants
|
|
7
7
|
const MAX_BRANCH_NAME_LENGTH = 70; // Maximum characters for branch name display
|
|
8
8
|
const MIN_COLUMN_PADDING = 2; // Minimum spaces between columns
|
|
9
|
+
/**
|
|
10
|
+
* Format a date as a relative time string (e.g., "2h ago", "3d ago").
|
|
11
|
+
*/
|
|
12
|
+
export function formatRelativeDate(date) {
|
|
13
|
+
const now = Date.now();
|
|
14
|
+
const diffMs = now - date.getTime();
|
|
15
|
+
const diffSec = Math.floor(diffMs / 1000);
|
|
16
|
+
const diffMin = Math.floor(diffSec / 60);
|
|
17
|
+
const diffHour = Math.floor(diffMin / 60);
|
|
18
|
+
const diffDay = Math.floor(diffHour / 24);
|
|
19
|
+
const diffWeek = Math.floor(diffDay / 7);
|
|
20
|
+
const diffMonth = Math.floor(diffDay / 30);
|
|
21
|
+
const diffYear = Math.floor(diffDay / 365);
|
|
22
|
+
if (diffYear > 0)
|
|
23
|
+
return `${diffYear}y ago`;
|
|
24
|
+
if (diffMonth > 0)
|
|
25
|
+
return `${diffMonth}mo ago`;
|
|
26
|
+
if (diffWeek > 0)
|
|
27
|
+
return `${diffWeek}w ago`;
|
|
28
|
+
if (diffDay > 0)
|
|
29
|
+
return `${diffDay}d ago`;
|
|
30
|
+
if (diffHour > 0)
|
|
31
|
+
return `${diffHour}h ago`;
|
|
32
|
+
if (diffMin > 0)
|
|
33
|
+
return `${diffMin}m ago`;
|
|
34
|
+
return 'just now';
|
|
35
|
+
}
|
|
9
36
|
// Utility function to truncate strings with ellipsis
|
|
10
37
|
export function truncateString(str, maxLength) {
|
|
11
38
|
if (str.length <= maxLength)
|
|
@@ -107,6 +134,10 @@ export function prepareWorktreeItems(worktrees, sessions) {
|
|
|
107
134
|
// Show fetching status in dim gray
|
|
108
135
|
fileChanges = '\x1b[90m[fetching...]\x1b[0m';
|
|
109
136
|
}
|
|
137
|
+
// Format last commit date as dim relative time
|
|
138
|
+
const lastCommitDate = wt.lastCommitDate
|
|
139
|
+
? `\x1b[90m${formatRelativeDate(wt.lastCommitDate)}\x1b[0m`
|
|
140
|
+
: '';
|
|
110
141
|
return {
|
|
111
142
|
worktree: wt,
|
|
112
143
|
session,
|
|
@@ -114,12 +145,14 @@ export function prepareWorktreeItems(worktrees, sessions) {
|
|
|
114
145
|
fileChanges,
|
|
115
146
|
aheadBehind,
|
|
116
147
|
parentBranch,
|
|
148
|
+
lastCommitDate,
|
|
117
149
|
error,
|
|
118
150
|
lengths: {
|
|
119
151
|
base: stripAnsi(baseLabel).length,
|
|
120
152
|
fileChanges: stripAnsi(fileChanges).length,
|
|
121
153
|
aheadBehind: stripAnsi(aheadBehind).length,
|
|
122
154
|
parentBranch: stripAnsi(parentBranch).length,
|
|
155
|
+
lastCommitDate: stripAnsi(lastCommitDate).length,
|
|
123
156
|
},
|
|
124
157
|
};
|
|
125
158
|
});
|
|
@@ -132,6 +165,7 @@ export function calculateColumnPositions(items) {
|
|
|
132
165
|
let maxBranchLength = 0;
|
|
133
166
|
let maxFileChangesLength = 0;
|
|
134
167
|
let maxAheadBehindLength = 0;
|
|
168
|
+
let maxParentBranchLength = 0;
|
|
135
169
|
items.forEach(item => {
|
|
136
170
|
// Skip items with errors for alignment calculation
|
|
137
171
|
if (item.error)
|
|
@@ -139,15 +173,18 @@ export function calculateColumnPositions(items) {
|
|
|
139
173
|
maxBranchLength = Math.max(maxBranchLength, item.lengths.base);
|
|
140
174
|
maxFileChangesLength = Math.max(maxFileChangesLength, item.lengths.fileChanges);
|
|
141
175
|
maxAheadBehindLength = Math.max(maxAheadBehindLength, item.lengths.aheadBehind);
|
|
176
|
+
maxParentBranchLength = Math.max(maxParentBranchLength, item.lengths.parentBranch);
|
|
142
177
|
});
|
|
143
178
|
// Simple column positioning
|
|
144
179
|
const fileChangesColumn = maxBranchLength + MIN_COLUMN_PADDING;
|
|
145
180
|
const aheadBehindColumn = fileChangesColumn + maxFileChangesLength + MIN_COLUMN_PADDING + 2;
|
|
146
181
|
const parentBranchColumn = aheadBehindColumn + maxAheadBehindLength + MIN_COLUMN_PADDING + 2;
|
|
182
|
+
const lastCommitDateColumn = parentBranchColumn + maxParentBranchLength + MIN_COLUMN_PADDING + 2;
|
|
147
183
|
return {
|
|
148
184
|
fileChanges: fileChangesColumn,
|
|
149
185
|
aheadBehind: aheadBehindColumn,
|
|
150
186
|
parentBranch: parentBranchColumn,
|
|
187
|
+
lastCommitDate: lastCommitDateColumn,
|
|
151
188
|
};
|
|
152
189
|
}
|
|
153
190
|
// Pad string to column position
|
|
@@ -175,6 +212,11 @@ export function assembleWorktreeLabel(item, columns) {
|
|
|
175
212
|
if (item.parentBranch) {
|
|
176
213
|
label =
|
|
177
214
|
padTo(label, currentLength, columns.parentBranch) + item.parentBranch;
|
|
215
|
+
currentLength = columns.parentBranch + item.lengths.parentBranch;
|
|
216
|
+
}
|
|
217
|
+
if (item.lastCommitDate) {
|
|
218
|
+
label =
|
|
219
|
+
padTo(label, currentLength, columns.lastCommitDate) + item.lastCommitDate;
|
|
178
220
|
}
|
|
179
221
|
return label;
|
|
180
222
|
}
|
|
@@ -170,11 +170,13 @@ describe('column alignment', () => {
|
|
|
170
170
|
fileChanges: '\x1b[32m+10\x1b[0m \x1b[31m-5\x1b[0m',
|
|
171
171
|
aheadBehind: '\x1b[33m↑2 ↓3\x1b[0m',
|
|
172
172
|
parentBranch: '',
|
|
173
|
+
lastCommitDate: '',
|
|
173
174
|
lengths: {
|
|
174
175
|
base: 19, // 'feature/test-branch'.length
|
|
175
176
|
fileChanges: 6, // '+10 -5'.length
|
|
176
177
|
aheadBehind: 5, // '↑2 ↓3'.length
|
|
177
178
|
parentBranch: 0,
|
|
179
|
+
lastCommitDate: 0,
|
|
178
180
|
},
|
|
179
181
|
},
|
|
180
182
|
{
|
|
@@ -183,11 +185,13 @@ describe('column alignment', () => {
|
|
|
183
185
|
fileChanges: '\x1b[32m+2\x1b[0m \x1b[31m-1\x1b[0m',
|
|
184
186
|
aheadBehind: '\x1b[33m↑1\x1b[0m',
|
|
185
187
|
parentBranch: '',
|
|
188
|
+
lastCommitDate: '',
|
|
186
189
|
lengths: {
|
|
187
190
|
base: 4, // 'main'.length
|
|
188
191
|
fileChanges: 5, // '+2 -1'.length
|
|
189
192
|
aheadBehind: 2, // '↑1'.length
|
|
190
193
|
parentBranch: 0,
|
|
194
|
+
lastCommitDate: 0,
|
|
191
195
|
},
|
|
192
196
|
},
|
|
193
197
|
];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccmanager",
|
|
3
|
-
"version": "3.11.
|
|
3
|
+
"version": "3.11.3",
|
|
4
4
|
"description": "TUI application for managing multiple Claude Code sessions across Git worktrees",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Kodai Kabasawa",
|
|
@@ -41,11 +41,11 @@
|
|
|
41
41
|
"bin"
|
|
42
42
|
],
|
|
43
43
|
"optionalDependencies": {
|
|
44
|
-
"@kodaikabasawa/ccmanager-darwin-arm64": "3.11.
|
|
45
|
-
"@kodaikabasawa/ccmanager-darwin-x64": "3.11.
|
|
46
|
-
"@kodaikabasawa/ccmanager-linux-arm64": "3.11.
|
|
47
|
-
"@kodaikabasawa/ccmanager-linux-x64": "3.11.
|
|
48
|
-
"@kodaikabasawa/ccmanager-win32-x64": "3.11.
|
|
44
|
+
"@kodaikabasawa/ccmanager-darwin-arm64": "3.11.3",
|
|
45
|
+
"@kodaikabasawa/ccmanager-darwin-x64": "3.11.3",
|
|
46
|
+
"@kodaikabasawa/ccmanager-linux-arm64": "3.11.3",
|
|
47
|
+
"@kodaikabasawa/ccmanager-linux-x64": "3.11.3",
|
|
48
|
+
"@kodaikabasawa/ccmanager-win32-x64": "3.11.3"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@eslint/js": "^9.28.0",
|