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.
@@ -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
- setError(formatErrorMessage(generatedBranch.left));
331
- setView('new-worktree');
332
- return;
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 - we shouldn't delete it
24
- const deletableWorktrees = allWorktrees.filter(wt => !wt.isMainWorktree);
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
  }
@@ -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
- const numberPrefix = !isSearchMode && index < 10 ? `${index} ❯ ` : '❯ ';
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 using Effect.runPromiseExit
6
- * and updates worktree state with results. Handles cancellation via AbortController.
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 gitStatusError fields
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 using Effect.runPromiseExit
8
- * and updates worktree state with results. Handles cancellation via AbortController.
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 gitStatusError fields
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
- // Execute the Effect to get git status with cancellation support
26
- const exit = await Effect.runPromiseExit(getGitStatusLimited(worktree.path), {
27
- signal: abortController.signal,
28
- });
29
- // Update worktree state based on exit result
30
- handleStatusExit(exit, worktree.path, setWorktreesWithStatus);
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 result from Effect.runPromiseExit and update worktree state
72
+ * Handle the Exit results from Effect.runPromiseExit and update worktree state
66
73
  *
67
- * Uses pattern matching on Exit to distinguish between success, failure, and interruption.
68
- * Success updates gitStatus, failure updates gitStatusError, interruption is ignored.
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 exit - Exit result from Effect execution
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(exit, worktreePath, setWorktreesWithStatus) {
75
- if (Exit.isSuccess(exit)) {
76
- // Success: update gitStatus and clear error
77
- const gitStatus = exit.value;
78
- setWorktreesWithStatus(prev => prev.map(wt => wt.path === worktreePath
79
- ? { ...wt, gitStatus, gitStatusError: undefined }
80
- : wt));
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(exit)) {
83
- // Failure: extract error and update gitStatusError
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
- const errorMessage = formatGitError(gitError);
88
- setWorktreesWithStatus(prev => prev.map(wt => wt.path === worktreePath
89
- ? { ...wt, gitStatus: undefined, gitStatusError: errorMessage }
90
- : wt));
95
+ update.gitStatus = undefined;
96
+ update.gitStatusError = formatGitError(gitError);
97
+ hasUpdate = true;
91
98
  }
92
99
  }
93
- // Interruption: no state update - the request was cancelled
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 () => {
@@ -13,6 +13,7 @@ export interface Worktree {
13
13
  hasSession: boolean;
14
14
  gitStatus?: GitStatus;
15
15
  gitStatusError?: string;
16
+ lastCommitDate?: Date;
16
17
  }
17
18
  export interface Session {
18
19
  id: string;
@@ -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;
@@ -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.1",
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.1",
45
- "@kodaikabasawa/ccmanager-darwin-x64": "3.11.1",
46
- "@kodaikabasawa/ccmanager-linux-arm64": "3.11.1",
47
- "@kodaikabasawa/ccmanager-linux-x64": "3.11.1",
48
- "@kodaikabasawa/ccmanager-win32-x64": "3.11.1"
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",