ccmanager 3.11.2 → 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.
@@ -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');
@@ -662,21 +662,6 @@ export class WorktreeService {
662
662
  if (mainWorktree && mainWorktree.path.includes('.git/modules')) {
663
663
  mainWorktree.path = self.gitRootPath;
664
664
  }
665
- // Fetch last commit date for each worktree
666
- for (const wt of worktrees) {
667
- try {
668
- const dateStr = execSync('git log -1 --format=%aI', {
669
- cwd: wt.path,
670
- encoding: 'utf8',
671
- }).trim();
672
- if (dateStr) {
673
- wt.lastCommitDate = new Date(dateStr);
674
- }
675
- }
676
- catch {
677
- // Ignore errors (e.g., empty repo)
678
- }
679
- }
680
665
  // Sort worktrees by last session if requested
681
666
  if (sortByLastSession) {
682
667
  worktrees.sort((a, b) => {
@@ -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 = {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccmanager",
3
- "version": "3.11.2",
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.2",
45
- "@kodaikabasawa/ccmanager-darwin-x64": "3.11.2",
46
- "@kodaikabasawa/ccmanager-linux-arm64": "3.11.2",
47
- "@kodaikabasawa/ccmanager-linux-x64": "3.11.2",
48
- "@kodaikabasawa/ccmanager-win32-x64": "3.11.2"
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",