@akiojin/gwt 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/README.ja.md +323 -0
  2. package/README.md +347 -0
  3. package/bin/gwt.js +5 -0
  4. package/package.json +125 -0
  5. package/src/claude-history.ts +717 -0
  6. package/src/claude.ts +292 -0
  7. package/src/cli/ui/__tests__/SKIPPED_TESTS.md +119 -0
  8. package/src/cli/ui/__tests__/acceptance/branchList.acceptance.test.tsx.skip +239 -0
  9. package/src/cli/ui/__tests__/acceptance/navigation.acceptance.test.tsx +214 -0
  10. package/src/cli/ui/__tests__/acceptance/realtimeUpdate.acceptance.test.tsx.skip +219 -0
  11. package/src/cli/ui/__tests__/components/App.protected-branch.test.tsx +183 -0
  12. package/src/cli/ui/__tests__/components/App.shortcuts.test.tsx +313 -0
  13. package/src/cli/ui/__tests__/components/App.test.tsx +270 -0
  14. package/src/cli/ui/__tests__/components/common/Confirm.test.tsx +66 -0
  15. package/src/cli/ui/__tests__/components/common/ErrorBoundary.test.tsx +103 -0
  16. package/src/cli/ui/__tests__/components/common/Input.test.tsx +92 -0
  17. package/src/cli/ui/__tests__/components/common/LoadingIndicator.test.tsx +127 -0
  18. package/src/cli/ui/__tests__/components/common/Select.memo.test.tsx +264 -0
  19. package/src/cli/ui/__tests__/components/common/Select.test.tsx +246 -0
  20. package/src/cli/ui/__tests__/components/parts/Footer.test.tsx +62 -0
  21. package/src/cli/ui/__tests__/components/parts/Header.test.tsx +54 -0
  22. package/src/cli/ui/__tests__/components/parts/ScrollableList.test.tsx +68 -0
  23. package/src/cli/ui/__tests__/components/parts/Stats.test.tsx +135 -0
  24. package/src/cli/ui/__tests__/components/screens/AIToolSelectorScreen.test.tsx +153 -0
  25. package/src/cli/ui/__tests__/components/screens/BranchCreatorScreen.test.tsx +215 -0
  26. package/src/cli/ui/__tests__/components/screens/BranchListScreen.test.tsx +293 -0
  27. package/src/cli/ui/__tests__/components/screens/ExecutionModeSelectorScreen.test.tsx +161 -0
  28. package/src/cli/ui/__tests__/components/screens/PRCleanupScreen.test.tsx +215 -0
  29. package/src/cli/ui/__tests__/components/screens/SessionSelectorScreen.test.tsx +99 -0
  30. package/src/cli/ui/__tests__/components/screens/WorktreeManagerScreen.test.tsx +127 -0
  31. package/src/cli/ui/__tests__/hooks/useGitData.test.ts.skip +228 -0
  32. package/src/cli/ui/__tests__/hooks/useScreenState.test.ts +146 -0
  33. package/src/cli/ui/__tests__/hooks/useTerminalSize.test.ts +98 -0
  34. package/src/cli/ui/__tests__/integration/branchList.test.tsx.skip +253 -0
  35. package/src/cli/ui/__tests__/integration/edgeCases.test.tsx +306 -0
  36. package/src/cli/ui/__tests__/integration/navigation.test.tsx +405 -0
  37. package/src/cli/ui/__tests__/integration/realtimeUpdate.test.tsx +505 -0
  38. package/src/cli/ui/__tests__/integration/realtimeUpdate.test.tsx.skip +216 -0
  39. package/src/cli/ui/__tests__/performance/branchList.performance.test.tsx +180 -0
  40. package/src/cli/ui/__tests__/performance/useMemoOptimization.test.tsx +237 -0
  41. package/src/cli/ui/__tests__/utils/branchFormatter.test.ts +775 -0
  42. package/src/cli/ui/__tests__/utils/statisticsCalculator.test.ts +243 -0
  43. package/src/cli/ui/components/App.tsx +793 -0
  44. package/src/cli/ui/components/common/Confirm.tsx +40 -0
  45. package/src/cli/ui/components/common/ErrorBoundary.tsx +57 -0
  46. package/src/cli/ui/components/common/Input.tsx +36 -0
  47. package/src/cli/ui/components/common/LoadingIndicator.tsx +95 -0
  48. package/src/cli/ui/components/common/Select.tsx +216 -0
  49. package/src/cli/ui/components/parts/Footer.tsx +41 -0
  50. package/src/cli/ui/components/parts/Header.test.tsx +85 -0
  51. package/src/cli/ui/components/parts/Header.tsx +63 -0
  52. package/src/cli/ui/components/parts/MergeStatusList.tsx +75 -0
  53. package/src/cli/ui/components/parts/ProgressBar.tsx +73 -0
  54. package/src/cli/ui/components/parts/ScrollableList.tsx +24 -0
  55. package/src/cli/ui/components/parts/Stats.tsx +67 -0
  56. package/src/cli/ui/components/screens/AIToolSelectorScreen.tsx +116 -0
  57. package/src/cli/ui/components/screens/BatchMergeProgressScreen.tsx +70 -0
  58. package/src/cli/ui/components/screens/BatchMergeResultScreen.tsx +104 -0
  59. package/src/cli/ui/components/screens/BranchCreatorScreen.tsx +213 -0
  60. package/src/cli/ui/components/screens/BranchListScreen.tsx +299 -0
  61. package/src/cli/ui/components/screens/ExecutionModeSelectorScreen.tsx +149 -0
  62. package/src/cli/ui/components/screens/PRCleanupScreen.tsx +167 -0
  63. package/src/cli/ui/components/screens/SessionSelectorScreen.tsx +100 -0
  64. package/src/cli/ui/components/screens/WorktreeManagerScreen.tsx +117 -0
  65. package/src/cli/ui/hooks/useBatchMerge.ts +96 -0
  66. package/src/cli/ui/hooks/useGitData.ts +157 -0
  67. package/src/cli/ui/hooks/useScreenState.ts +44 -0
  68. package/src/cli/ui/hooks/useTerminalSize.ts +33 -0
  69. package/src/cli/ui/screens/BranchActionSelectorScreen.tsx +102 -0
  70. package/src/cli/ui/screens/__tests__/BranchActionSelectorScreen.test.tsx +151 -0
  71. package/src/cli/ui/types.ts +295 -0
  72. package/src/cli/ui/utils/baseBranch.ts +34 -0
  73. package/src/cli/ui/utils/branchFormatter.ts +222 -0
  74. package/src/cli/ui/utils/statisticsCalculator.ts +44 -0
  75. package/src/codex.ts +139 -0
  76. package/src/config/builtin-tools.ts +44 -0
  77. package/src/config/constants.ts +100 -0
  78. package/src/config/env-history.ts +45 -0
  79. package/src/config/index.ts +204 -0
  80. package/src/config/tools.ts +293 -0
  81. package/src/git.ts +1102 -0
  82. package/src/github.ts +158 -0
  83. package/src/index.test.ts +87 -0
  84. package/src/index.ts +684 -0
  85. package/src/index.ts.backup +1543 -0
  86. package/src/launcher.ts +142 -0
  87. package/src/repositories/git.repository.ts +129 -0
  88. package/src/repositories/github.repository.ts +83 -0
  89. package/src/repositories/worktree.repository.ts +69 -0
  90. package/src/services/BatchMergeService.ts +251 -0
  91. package/src/services/WorktreeOrchestrator.ts +115 -0
  92. package/src/services/__tests__/BatchMergeService.test.ts +518 -0
  93. package/src/services/__tests__/WorktreeOrchestrator.test.ts +258 -0
  94. package/src/services/dependency-installer.ts +199 -0
  95. package/src/services/git.service.ts +113 -0
  96. package/src/services/github.service.ts +61 -0
  97. package/src/services/worktree.service.ts +66 -0
  98. package/src/types/api.ts +241 -0
  99. package/src/types/tools.ts +235 -0
  100. package/src/utils/spinner.ts +54 -0
  101. package/src/utils/terminal.ts +272 -0
  102. package/src/utils.test.ts +43 -0
  103. package/src/utils.ts +60 -0
  104. package/src/web/client/index.html +12 -0
  105. package/src/web/client/src/components/BranchGraph.tsx +231 -0
  106. package/src/web/client/src/components/EnvEditor.tsx +145 -0
  107. package/src/web/client/src/components/Terminal.tsx +137 -0
  108. package/src/web/client/src/hooks/useBranches.ts +41 -0
  109. package/src/web/client/src/hooks/useConfig.ts +31 -0
  110. package/src/web/client/src/hooks/useSessions.ts +59 -0
  111. package/src/web/client/src/hooks/useWorktrees.ts +47 -0
  112. package/src/web/client/src/index.css +834 -0
  113. package/src/web/client/src/lib/api.ts +184 -0
  114. package/src/web/client/src/lib/websocket.ts +174 -0
  115. package/src/web/client/src/main.tsx +29 -0
  116. package/src/web/client/src/pages/BranchDetailPage.tsx +847 -0
  117. package/src/web/client/src/pages/BranchListPage.tsx +264 -0
  118. package/src/web/client/src/pages/ConfigManagementPage.tsx +203 -0
  119. package/src/web/client/src/router.tsx +27 -0
  120. package/src/web/client/vite.config.ts +21 -0
  121. package/src/web/server/env/importer.ts +54 -0
  122. package/src/web/server/index.ts +74 -0
  123. package/src/web/server/pty/manager.ts +189 -0
  124. package/src/web/server/routes/branches.ts +126 -0
  125. package/src/web/server/routes/config.ts +220 -0
  126. package/src/web/server/routes/index.ts +37 -0
  127. package/src/web/server/routes/sessions.ts +130 -0
  128. package/src/web/server/routes/worktrees.ts +108 -0
  129. package/src/web/server/services/branches.ts +368 -0
  130. package/src/web/server/services/worktrees.ts +85 -0
  131. package/src/web/server/websocket/handler.ts +180 -0
  132. package/src/worktree.ts +703 -0
@@ -0,0 +1,214 @@
1
+ /**
2
+ * @vitest-environment happy-dom
3
+ * Acceptance tests for User Story 2: Sub-screen Navigation
4
+ */
5
+ import { describe, it, expect, beforeEach, afterAll, vi } from 'vitest';
6
+ import type { Mock } from 'vitest';
7
+ import { render, waitFor } from '@testing-library/react';
8
+ import React from 'react';
9
+ import { App } from '../../components/App.js';
10
+ import { Window } from 'happy-dom';
11
+ import type { BranchInfo } from '../../types.js';
12
+
13
+ // Mock git.ts and worktree.ts
14
+ vi.mock('../../../../git.ts', () => ({
15
+ __esModule: true,
16
+ getAllBranches: vi.fn(),
17
+ getRepositoryRoot: vi.fn(async () => '/repo'),
18
+ deleteBranch: vi.fn(async () => undefined),
19
+ }));
20
+
21
+ const { acceptanceIsProtectedBranchName, acceptanceSwitchToProtectedBranch } = vi.hoisted(() => ({
22
+ acceptanceIsProtectedBranchName: vi.fn(() => false),
23
+ acceptanceSwitchToProtectedBranch: vi.fn(async () => 'none' as const),
24
+ }));
25
+
26
+ vi.mock('../../../../worktree.ts', () => ({
27
+ __esModule: true,
28
+ listAdditionalWorktrees: vi.fn(),
29
+ createWorktree: vi.fn(async () => undefined),
30
+ generateWorktreePath: vi.fn(async () => '/repo/.git/worktree/test'),
31
+ getMergedPRWorktrees: vi.fn(async () => []),
32
+ removeWorktree: vi.fn(async () => undefined),
33
+ isProtectedBranchName: acceptanceIsProtectedBranchName,
34
+ switchToProtectedBranch: acceptanceSwitchToProtectedBranch,
35
+ }));
36
+
37
+ import { getAllBranches, getRepositoryRoot, deleteBranch } from '../../../../git.ts';
38
+ import {
39
+ listAdditionalWorktrees,
40
+ createWorktree,
41
+ generateWorktreePath,
42
+ getMergedPRWorktrees,
43
+ removeWorktree,
44
+ } from '../../../../worktree.ts';
45
+
46
+ const mockedGetAllBranches = getAllBranches as Mock;
47
+ const mockedGetRepositoryRoot = getRepositoryRoot as Mock;
48
+ const mockedDeleteBranch = deleteBranch as Mock;
49
+ const mockedListAdditionalWorktrees = listAdditionalWorktrees as Mock;
50
+ const mockedCreateWorktree = createWorktree as Mock;
51
+ const mockedGenerateWorktreePath = generateWorktreePath as Mock;
52
+ const mockedGetMergedPRWorktrees = getMergedPRWorktrees as Mock;
53
+ const mockedRemoveWorktree = removeWorktree as Mock;
54
+ const mockedIsProtectedBranchName = acceptanceIsProtectedBranchName as Mock;
55
+ const mockedSwitchToProtectedBranch = acceptanceSwitchToProtectedBranch as Mock;
56
+
57
+ describe('Acceptance: Navigation (User Story 2)', () => {
58
+ beforeEach(() => {
59
+ // Setup happy-dom
60
+ const window = new Window();
61
+ globalThis.window = window as any;
62
+ globalThis.document = window.document as any;
63
+
64
+ // Reset mocks
65
+ mockedGetAllBranches.mockReset();
66
+ mockedListAdditionalWorktrees.mockReset();
67
+ mockedGetRepositoryRoot.mockReset();
68
+ mockedDeleteBranch.mockReset();
69
+ mockedCreateWorktree.mockReset();
70
+ mockedGenerateWorktreePath.mockReset();
71
+ mockedGetMergedPRWorktrees.mockReset();
72
+ mockedRemoveWorktree.mockReset();
73
+ mockedIsProtectedBranchName.mockReset();
74
+ mockedSwitchToProtectedBranch.mockReset();
75
+ mockedGetRepositoryRoot.mockResolvedValue('/repo');
76
+ mockedSwitchToProtectedBranch.mockResolvedValue('none');
77
+ });
78
+
79
+ const mockBranches: BranchInfo[] = [
80
+ {
81
+ name: 'main',
82
+ type: 'local',
83
+ branchType: 'main',
84
+ isCurrent: true,
85
+ },
86
+ {
87
+ name: 'feature/test',
88
+ type: 'local',
89
+ branchType: 'feature',
90
+ isCurrent: false,
91
+ },
92
+ ];
93
+
94
+ /**
95
+ * T074: Acceptance Scenario 1
96
+ * nキーで新規ブランチ作成画面に遷移
97
+ */
98
+ it('[AC1] should navigate to branch creator on n key', async () => {
99
+ (getAllBranches as ReturnType<typeof vi.fn>).mockResolvedValue(mockBranches);
100
+ (listAdditionalWorktrees as ReturnType<typeof vi.fn>).mockResolvedValue([]);
101
+
102
+ const onExit = vi.fn();
103
+ const { getByText, container } = render(<App onExit={onExit} />);
104
+
105
+ await waitFor(() => {
106
+ expect(getByText(/gwt - Branch Selection/i)).toBeDefined();
107
+ });
108
+
109
+ // Verify n key action is available in footer
110
+ const nKeyElements = container.querySelectorAll('*');
111
+ let hasNKey = false;
112
+ nKeyElements.forEach((el) => {
113
+ if (el.textContent?.toLowerCase().includes('new branch')) {
114
+ hasNKey = true;
115
+ }
116
+ });
117
+
118
+ expect(hasNKey || container.textContent?.toLowerCase().includes('n')).toBe(true);
119
+ });
120
+
121
+ /**
122
+ * T075: Acceptance Scenario 2
123
+ * メイン画面にはqキーが存在しない(終了はCtrl+Cのみ)
124
+ */
125
+ it('[AC2] should not have q key on main screen', async () => {
126
+ (getAllBranches as ReturnType<typeof vi.fn>).mockResolvedValue(mockBranches);
127
+ (listAdditionalWorktrees as ReturnType<typeof vi.fn>).mockResolvedValue([]);
128
+
129
+ const onExit = vi.fn();
130
+ const { container } = render(<App onExit={onExit} />);
131
+
132
+ await waitFor(() => {
133
+ expect(container).toBeDefined();
134
+ });
135
+
136
+ // Verify q key is NOT in the footer (main screen uses Ctrl+C for exit)
137
+ const footerText = container.textContent || '';
138
+ // Main screen should not have 'q' for quit, but should have other keys
139
+ expect(footerText.toLowerCase()).not.toMatch(/\[q\]/);
140
+ expect(footerText.toLowerCase()).toContain('enter');
141
+ });
142
+
143
+ /**
144
+ * T076: Acceptance Scenario 3
145
+ * Worktree管理でアクション実行後に適切に遷移
146
+ */
147
+ it('[AC3] should handle worktree management navigation', async () => {
148
+ (getAllBranches as ReturnType<typeof vi.fn>).mockResolvedValue(mockBranches);
149
+ (listAdditionalWorktrees as ReturnType<typeof vi.fn>).mockResolvedValue([
150
+ {
151
+ branch: 'feature/test',
152
+ path: '/path/to/worktree',
153
+ head: 'abc123',
154
+ isAccessible: true,
155
+ },
156
+ ]);
157
+
158
+ const onExit = vi.fn();
159
+ const { getByText, container } = render(<App onExit={onExit} />);
160
+
161
+ await waitFor(() => {
162
+ expect(getByText(/gwt - Branch Selection/i)).toBeDefined();
163
+ });
164
+
165
+ // Verify m key action is available for worktree management
166
+ const mKeyElements = container.querySelectorAll('*');
167
+ let hasMKey = false;
168
+ mKeyElements.forEach((el) => {
169
+ if (el.textContent?.toLowerCase().includes('manage worktrees')) {
170
+ hasMKey = true;
171
+ }
172
+ });
173
+
174
+ expect(hasMKey || container.textContent?.toLowerCase().includes('m')).toBe(true);
175
+ });
176
+
177
+ it('[Integration] should support all navigation keys', async () => {
178
+ (getAllBranches as ReturnType<typeof vi.fn>).mockResolvedValue(mockBranches);
179
+ (listAdditionalWorktrees as ReturnType<typeof vi.fn>).mockResolvedValue([]);
180
+
181
+ const onExit = vi.fn();
182
+ const { getByText, getAllByText } = render(<App onExit={onExit} />);
183
+
184
+ await waitFor(() => {
185
+ expect(getByText(/gwt - Branch Selection/i)).toBeDefined();
186
+ });
187
+
188
+ // Verify navigation keys are available (main screen doesn't have q key)
189
+ const enterKeys = getAllByText(/enter/i);
190
+
191
+ expect(enterKeys.length).toBeGreaterThan(0);
192
+ });
193
+
194
+ it('[Integration] should display correct footer actions', async () => {
195
+ (getAllBranches as ReturnType<typeof vi.fn>).mockResolvedValue(mockBranches);
196
+ (listAdditionalWorktrees as ReturnType<typeof vi.fn>).mockResolvedValue([]);
197
+
198
+ const onExit = vi.fn();
199
+ const { container } = render(<App onExit={onExit} />);
200
+
201
+ await waitFor(() => {
202
+ expect(container).toBeDefined();
203
+ });
204
+
205
+ // Verify footer has multiple action keys (main screen doesn't have q key)
206
+ const footerText = container.textContent || '';
207
+ expect(footerText.toLowerCase()).toContain('enter');
208
+ expect(footerText.toLowerCase()).toContain('m'); // Manage worktrees
209
+ });
210
+ });
211
+
212
+ afterAll(() => {
213
+ vi.restoreAllMocks();
214
+ });
@@ -0,0 +1,219 @@
1
+ /**
2
+ * @vitest-environment happy-dom
3
+ * Acceptance tests for User Story 3: Realtime Statistics Update
4
+ */
5
+ import { describe, it, expect, beforeEach, vi } from 'vitest';
6
+ import { renderHook, waitFor } from '@testing-library/react';
7
+ import { useGitData } from '../../hooks/useGitData.js';
8
+ import { Window } from 'happy-dom';
9
+ import type { BranchInfo } from '../../types.js';
10
+
11
+ // Mock git.js and worktree.js
12
+ vi.mock('../../../git.js', () => ({
13
+ getAllBranches: vi.fn(),
14
+ }));
15
+
16
+ vi.mock('../../../worktree.js', () => ({
17
+ listAdditionalWorktrees: vi.fn(),
18
+ }));
19
+
20
+ import { getAllBranches } from '../../../git.js';
21
+ import { listAdditionalWorktrees } from '../../../worktree.js';
22
+
23
+ describe('Acceptance: Realtime Update (User Story 3)', () => {
24
+ beforeEach(() => {
25
+ // Setup happy-dom
26
+ const window = new Window();
27
+ globalThis.window = window as any;
28
+ globalThis.document = window.document as any;
29
+
30
+ // Reset mocks
31
+ (getAllBranches as ReturnType<typeof vi.fn>).mockReset();
32
+ (listAdditionalWorktrees as ReturnType<typeof vi.fn>).mockReset();
33
+ });
34
+
35
+ /**
36
+ * T085: Acceptance Scenario 1
37
+ * 別ターミナルでGit操作後、数秒以内に統計情報が更新される
38
+ */
39
+ it('[AC1] should update statistics within seconds after Git operations', async () => {
40
+ // Simulate initial state
41
+ const initialBranches: BranchInfo[] = [
42
+ { name: 'main', type: 'local', branchType: 'main', isCurrent: true },
43
+ { name: 'feature/a', type: 'local', branchType: 'feature', isCurrent: false },
44
+ ];
45
+
46
+ let callCount = 0;
47
+ (getAllBranches as ReturnType<typeof vi.fn>).mockImplementation(async () => {
48
+ callCount++;
49
+ if (callCount === 1) {
50
+ return initialBranches;
51
+ }
52
+ // Simulate Git operation: new branch created
53
+ return [
54
+ ...initialBranches,
55
+ { name: 'feature/b', type: 'local', branchType: 'feature', isCurrent: false },
56
+ ];
57
+ });
58
+ (listAdditionalWorktrees as ReturnType<typeof vi.fn>).mockResolvedValue([]);
59
+
60
+ // Enable auto-refresh with 1 second interval
61
+ const { result } = renderHook(() =>
62
+ useGitData({ enableAutoRefresh: true, refreshInterval: 1000 })
63
+ );
64
+
65
+ // Wait for initial load
66
+ await waitFor(() => {
67
+ expect(result.current.loading).toBe(false);
68
+ });
69
+
70
+ expect(result.current.branches).toHaveLength(2);
71
+ const initialLastUpdated = result.current.lastUpdated;
72
+
73
+ // Simulate Git operation happening in another terminal
74
+ // Wait for auto-refresh (slightly more than 1 second)
75
+ await new Promise((resolve) => setTimeout(resolve, 1100));
76
+
77
+ // Statistics should be updated
78
+ await waitFor(
79
+ () => {
80
+ expect(result.current.branches).toHaveLength(3);
81
+ },
82
+ { timeout: 2000 }
83
+ );
84
+
85
+ expect(result.current.branches[2].name).toBe('feature/b');
86
+ expect(result.current.lastUpdated!.getTime()).toBeGreaterThan(
87
+ initialLastUpdated!.getTime()
88
+ );
89
+ });
90
+
91
+ /**
92
+ * T086: Acceptance Scenario 2
93
+ * Worktree作成/削除後、統計情報が即座に更新される
94
+ */
95
+ it('[AC2] should update statistics immediately after worktree operations', async () => {
96
+ const mockBranches: BranchInfo[] = [
97
+ { name: 'main', type: 'local', branchType: 'main', isCurrent: true },
98
+ { name: 'feature/test', type: 'local', branchType: 'feature', isCurrent: false },
99
+ ];
100
+
101
+ let worktreeCallCount = 0;
102
+ (getAllBranches as ReturnType<typeof vi.fn>).mockResolvedValue(mockBranches);
103
+ (listAdditionalWorktrees as ReturnType<typeof vi.fn>).mockImplementation(async () => {
104
+ worktreeCallCount++;
105
+ if (worktreeCallCount === 1) {
106
+ return [];
107
+ }
108
+ // Simulate worktree creation
109
+ return [
110
+ {
111
+ branch: 'feature/test',
112
+ path: '/path/to/worktree',
113
+ head: 'abc123',
114
+ isAccessible: true,
115
+ },
116
+ ];
117
+ });
118
+
119
+ // Enable auto-refresh with 500ms interval
120
+ const { result } = renderHook(() =>
121
+ useGitData({ enableAutoRefresh: true, refreshInterval: 500 })
122
+ );
123
+
124
+ // Wait for initial load
125
+ await waitFor(() => {
126
+ expect(result.current.loading).toBe(false);
127
+ });
128
+
129
+ expect(result.current.worktrees).toHaveLength(0);
130
+
131
+ // Simulate worktree creation in another terminal
132
+ // Wait for auto-refresh (slightly more than 500ms)
133
+ await new Promise((resolve) => setTimeout(resolve, 600));
134
+
135
+ // Worktree statistics should be updated
136
+ await waitFor(
137
+ () => {
138
+ expect(result.current.worktrees).toHaveLength(1);
139
+ },
140
+ { timeout: 1000 }
141
+ );
142
+
143
+ expect(result.current.worktrees[0].branch).toBe('feature/test');
144
+ expect(result.current.worktrees[0].path).toBe('/path/to/worktree');
145
+ });
146
+
147
+ /**
148
+ * Additional: Verify lastUpdated display behavior
149
+ */
150
+ it('[AC3] should display lastUpdated timestamp after each refresh', async () => {
151
+ const mockBranches: BranchInfo[] = [
152
+ { name: 'main', type: 'local', branchType: 'main', isCurrent: true },
153
+ ];
154
+
155
+ (getAllBranches as ReturnType<typeof vi.fn>).mockResolvedValue(mockBranches);
156
+ (listAdditionalWorktrees as ReturnType<typeof vi.fn>).mockResolvedValue([]);
157
+
158
+ const { result } = renderHook(() =>
159
+ useGitData({ enableAutoRefresh: true, refreshInterval: 200 })
160
+ );
161
+
162
+ // Wait for initial load
163
+ await waitFor(() => {
164
+ expect(result.current.loading).toBe(false);
165
+ });
166
+
167
+ const firstTimestamp = result.current.lastUpdated;
168
+ expect(firstTimestamp).toBeInstanceOf(Date);
169
+
170
+ // Wait for auto-refresh
171
+ await new Promise((resolve) => setTimeout(resolve, 250));
172
+
173
+ await waitFor(() => {
174
+ expect(result.current.lastUpdated!.getTime()).toBeGreaterThan(firstTimestamp!.getTime());
175
+ });
176
+
177
+ const secondTimestamp = result.current.lastUpdated;
178
+ expect(secondTimestamp).toBeInstanceOf(Date);
179
+
180
+ // Verify timestamps are different
181
+ expect(secondTimestamp!.getTime()).toBeGreaterThan(firstTimestamp!.getTime());
182
+ });
183
+
184
+ /**
185
+ * Additional: Verify manual refresh updates lastUpdated
186
+ */
187
+ it('[AC4] should update lastUpdated on manual refresh', async () => {
188
+ const mockBranches: BranchInfo[] = [
189
+ { name: 'main', type: 'local', branchType: 'main', isCurrent: true },
190
+ ];
191
+
192
+ (getAllBranches as ReturnType<typeof vi.fn>).mockResolvedValue(mockBranches);
193
+ (listAdditionalWorktrees as ReturnType<typeof vi.fn>).mockResolvedValue([]);
194
+
195
+ const { result } = renderHook(() => useGitData({ enableAutoRefresh: false }));
196
+
197
+ // Wait for initial load
198
+ await waitFor(() => {
199
+ expect(result.current.loading).toBe(false);
200
+ });
201
+
202
+ const firstTimestamp = result.current.lastUpdated;
203
+ expect(firstTimestamp).toBeInstanceOf(Date);
204
+
205
+ // Wait to ensure timestamp difference
206
+ await new Promise((resolve) => setTimeout(resolve, 50));
207
+
208
+ // Manual refresh
209
+ result.current.refresh();
210
+
211
+ await waitFor(() => {
212
+ expect(result.current.loading).toBe(false);
213
+ });
214
+
215
+ const secondTimestamp = result.current.lastUpdated;
216
+ expect(secondTimestamp).toBeInstanceOf(Date);
217
+ expect(secondTimestamp!.getTime()).toBeGreaterThan(firstTimestamp!.getTime());
218
+ });
219
+ });
@@ -0,0 +1,183 @@
1
+ /**
2
+ * @vitest-environment happy-dom
3
+ */
4
+ import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from 'vitest';
5
+ import { act, render } from '@testing-library/react';
6
+ import React from 'react';
7
+ import { Window } from 'happy-dom';
8
+ import { App } from '../../components/App.js';
9
+ import type { BranchInfo, BranchItem } from '../../types.js';
10
+ import * as useGitDataModule from '../../hooks/useGitData.js';
11
+ import * as useScreenStateModule from '../../hooks/useScreenState.js';
12
+ import * as BranchListScreenModule from '../../components/screens/BranchListScreen.js';
13
+ import * as BranchActionSelectorScreenModule from '../../screens/BranchActionSelectorScreen.js';
14
+ import * as worktreeModule from '../../../../worktree.ts';
15
+ import * as gitModule from '../../../../git.ts';
16
+ import type { ScreenType } from '../../types.js';
17
+
18
+ const navigateToMock = vi.fn();
19
+ const goBackMock = vi.fn();
20
+ const resetMock = vi.fn();
21
+
22
+ const originalUseGitData = useGitDataModule.useGitData;
23
+ const originalUseScreenState = useScreenStateModule.useScreenState;
24
+ const originalBranchListScreen = BranchListScreenModule.BranchListScreen;
25
+ const originalBranchActionSelector = BranchActionSelectorScreenModule.BranchActionSelectorScreen;
26
+ const originalGetRepositoryRoot = gitModule.getRepositoryRoot;
27
+
28
+ const useGitDataSpy = vi.spyOn(useGitDataModule, 'useGitData');
29
+ const useScreenStateSpy = vi.spyOn(useScreenStateModule, 'useScreenState');
30
+ const branchListScreenSpy = vi.spyOn(BranchListScreenModule, 'BranchListScreen');
31
+ const branchActionSelectorSpy = vi.spyOn(BranchActionSelectorScreenModule, 'BranchActionSelectorScreen');
32
+ const switchToProtectedBranchSpy = vi.spyOn(worktreeModule, 'switchToProtectedBranch');
33
+ const getRepositoryRootSpy = vi.spyOn(gitModule, 'getRepositoryRoot');
34
+
35
+ const branchListProps: any[] = [];
36
+ const branchActionProps: any[] = [];
37
+ const aiToolProps: any[] = [];
38
+ let currentScreenState: ScreenType;
39
+
40
+ vi.mock('../../components/screens/AIToolSelectorScreen.js', () => {
41
+ return {
42
+ AIToolSelectorScreen: (props: unknown) => {
43
+ aiToolProps.push(props);
44
+ return React.createElement('div');
45
+ },
46
+ };
47
+ });
48
+
49
+ describe('App protected branch handling', () => {
50
+ beforeEach(() => {
51
+ const window = new Window();
52
+ globalThis.window = window as any;
53
+ globalThis.document = window.document as any;
54
+
55
+ currentScreenState = 'branch-list';
56
+ navigateToMock.mockReset();
57
+ goBackMock.mockReset();
58
+ resetMock.mockReset();
59
+ branchListProps.length = 0;
60
+ branchActionProps.length = 0;
61
+ aiToolProps.length = 0;
62
+
63
+ useGitDataSpy.mockReset();
64
+ switchToProtectedBranchSpy.mockReset();
65
+ getRepositoryRootSpy.mockReset();
66
+
67
+ useScreenStateSpy.mockImplementation(() => ({
68
+ currentScreen: currentScreenState,
69
+ navigateTo: (screen: ScreenType) => {
70
+ navigateToMock(screen);
71
+ currentScreenState = screen;
72
+ },
73
+ goBack: goBackMock,
74
+ reset: () => {
75
+ resetMock();
76
+ currentScreenState = 'branch-list';
77
+ },
78
+ }));
79
+
80
+ branchListScreenSpy.mockImplementation((props: any) => {
81
+ branchListProps.push(props);
82
+ return React.createElement(originalBranchListScreen, props);
83
+ });
84
+ branchActionSelectorSpy.mockImplementation((props: any) => {
85
+ branchActionProps.push(props);
86
+ return React.createElement(originalBranchActionSelector, props);
87
+ });
88
+ switchToProtectedBranchSpy.mockResolvedValue('local');
89
+ getRepositoryRootSpy.mockResolvedValue('/repo');
90
+ });
91
+
92
+ afterEach(() => {
93
+ useGitDataSpy.mockReset();
94
+ useGitDataSpy.mockImplementation(originalUseGitData);
95
+ useScreenStateSpy.mockReset();
96
+ useScreenStateSpy.mockImplementation(originalUseScreenState);
97
+ branchListScreenSpy.mockImplementation(originalBranchListScreen as any);
98
+ branchActionSelectorSpy.mockImplementation(originalBranchActionSelector as any);
99
+ switchToProtectedBranchSpy.mockReset();
100
+ getRepositoryRootSpy.mockReset();
101
+ branchActionProps.length = 0;
102
+ });
103
+
104
+ afterAll(() => {
105
+ useGitDataSpy.mockRestore();
106
+ useScreenStateSpy.mockRestore();
107
+ branchListScreenSpy.mockRestore();
108
+ branchActionSelectorSpy.mockRestore();
109
+ switchToProtectedBranchSpy.mockRestore();
110
+ getRepositoryRootSpy.mockRestore();
111
+ });
112
+
113
+ it('shows protected branch warning and switches root without launching AI tool', async () => {
114
+ const branches: BranchInfo[] = [
115
+ {
116
+ name: 'main',
117
+ type: 'local',
118
+ branchType: 'main',
119
+ isCurrent: false,
120
+ },
121
+ {
122
+ name: 'feature/example',
123
+ type: 'local',
124
+ branchType: 'feature',
125
+ isCurrent: true,
126
+ },
127
+ ];
128
+
129
+ useGitDataSpy.mockImplementation(() => ({
130
+ branches,
131
+ worktrees: [],
132
+ loading: false,
133
+ error: null,
134
+ refresh: vi.fn(),
135
+ lastUpdated: null,
136
+ }));
137
+
138
+ render(<App onExit={vi.fn()} />);
139
+
140
+ expect(branchListProps).not.toHaveLength(0);
141
+ const latestProps = branchListProps.at(-1);
142
+ expect(latestProps).toBeDefined();
143
+ if (!latestProps) {
144
+ throw new Error('BranchListScreen props missing');
145
+ }
146
+
147
+ const protectedBranch = (latestProps.branches as BranchItem[]).find(
148
+ (item) => item.name === 'main'
149
+ );
150
+ expect(protectedBranch).toBeDefined();
151
+ if (!protectedBranch) {
152
+ throw new Error('Protected branch item not found');
153
+ }
154
+
155
+ await act(async () => {
156
+ latestProps.onSelect(protectedBranch);
157
+ await Promise.resolve();
158
+ });
159
+
160
+ expect(navigateToMock).toHaveBeenCalledWith('branch-action-selector');
161
+ expect(branchActionProps).not.toHaveLength(0);
162
+ const actionProps = branchActionProps.at(-1);
163
+ expect(actionProps?.mode).toBe('protected');
164
+ expect(actionProps?.infoMessage).toContain('is a root branch');
165
+ expect(actionProps?.primaryLabel).toBe('Use root branch (no worktree)');
166
+ expect(actionProps?.secondaryLabel).toBe('Create new branch from this branch');
167
+
168
+ await act(async () => {
169
+ actionProps?.onUseExisting();
170
+ await Promise.resolve();
171
+ await Promise.resolve();
172
+ });
173
+
174
+ expect(switchToProtectedBranchSpy).toHaveBeenCalledWith({
175
+ branchName: 'main',
176
+ repoRoot: expect.any(String),
177
+ remoteRef: null,
178
+ });
179
+
180
+ expect(navigateToMock).toHaveBeenCalledWith('ai-tool-selector');
181
+ expect(aiToolProps).not.toHaveLength(0);
182
+ });
183
+ });