@akiojin/gwt 4.6.0 → 4.7.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 (71) hide show
  1. package/dist/claude.d.ts.map +1 -1
  2. package/dist/claude.js +38 -17
  3. package/dist/claude.js.map +1 -1
  4. package/dist/cli/ui/components/App.d.ts.map +1 -1
  5. package/dist/cli/ui/components/App.js +102 -1
  6. package/dist/cli/ui/components/App.js.map +1 -1
  7. package/dist/cli/ui/components/screens/BranchListScreen.d.ts +1 -0
  8. package/dist/cli/ui/components/screens/BranchListScreen.d.ts.map +1 -1
  9. package/dist/cli/ui/components/screens/BranchListScreen.js +6 -2
  10. package/dist/cli/ui/components/screens/BranchListScreen.js.map +1 -1
  11. package/dist/cli/ui/components/screens/LogDatePickerScreen.d.ts +10 -0
  12. package/dist/cli/ui/components/screens/LogDatePickerScreen.d.ts.map +1 -0
  13. package/dist/cli/ui/components/screens/LogDatePickerScreen.js +44 -0
  14. package/dist/cli/ui/components/screens/LogDatePickerScreen.js.map +1 -0
  15. package/dist/cli/ui/components/screens/LogDetailScreen.d.ts +14 -0
  16. package/dist/cli/ui/components/screens/LogDetailScreen.d.ts.map +1 -0
  17. package/dist/cli/ui/components/screens/LogDetailScreen.js +34 -0
  18. package/dist/cli/ui/components/screens/LogDetailScreen.js.map +1 -0
  19. package/dist/cli/ui/components/screens/LogListScreen.d.ts +19 -0
  20. package/dist/cli/ui/components/screens/LogListScreen.d.ts.map +1 -0
  21. package/dist/cli/ui/components/screens/LogListScreen.js +107 -0
  22. package/dist/cli/ui/components/screens/LogListScreen.js.map +1 -0
  23. package/dist/cli/ui/hooks/useGitData.d.ts.map +1 -1
  24. package/dist/cli/ui/hooks/useGitData.js +10 -3
  25. package/dist/cli/ui/hooks/useGitData.js.map +1 -1
  26. package/dist/cli/ui/types.d.ts +1 -1
  27. package/dist/cli/ui/types.d.ts.map +1 -1
  28. package/dist/cli/ui/utils/clipboard.d.ts +7 -0
  29. package/dist/cli/ui/utils/clipboard.d.ts.map +1 -0
  30. package/dist/cli/ui/utils/clipboard.js +21 -0
  31. package/dist/cli/ui/utils/clipboard.js.map +1 -0
  32. package/dist/gemini.d.ts.map +1 -1
  33. package/dist/gemini.js +12 -6
  34. package/dist/gemini.js.map +1 -1
  35. package/dist/index.d.ts.map +1 -1
  36. package/dist/index.js +9 -2
  37. package/dist/index.js.map +1 -1
  38. package/dist/logging/formatter.d.ts +15 -0
  39. package/dist/logging/formatter.d.ts.map +1 -0
  40. package/dist/logging/formatter.js +81 -0
  41. package/dist/logging/formatter.js.map +1 -0
  42. package/dist/logging/reader.d.ts +12 -0
  43. package/dist/logging/reader.d.ts.map +1 -0
  44. package/dist/logging/reader.js +63 -0
  45. package/dist/logging/reader.js.map +1 -0
  46. package/dist/worktree.d.ts.map +1 -1
  47. package/dist/worktree.js +57 -0
  48. package/dist/worktree.js.map +1 -1
  49. package/package.json +1 -1
  50. package/src/claude.ts +51 -22
  51. package/src/cli/ui/__tests__/components/App.shortcuts.test.tsx +7 -3
  52. package/src/cli/ui/__tests__/components/common/Select.test.tsx +17 -11
  53. package/src/cli/ui/__tests__/components/screens/BranchListScreen.test.tsx +28 -2
  54. package/src/cli/ui/__tests__/components/screens/LogDetailScreen.test.tsx +57 -0
  55. package/src/cli/ui/__tests__/components/screens/LogListScreen.test.tsx +102 -0
  56. package/src/cli/ui/__tests__/hooks/useGitData.test.ts +197 -0
  57. package/src/cli/ui/__tests__/integration/edgeCases.test.tsx +19 -9
  58. package/src/cli/ui/__tests__/utils/clipboard.test.ts +65 -0
  59. package/src/cli/ui/components/App.tsx +177 -0
  60. package/src/cli/ui/components/screens/BranchListScreen.tsx +6 -1
  61. package/src/cli/ui/components/screens/LogDatePickerScreen.tsx +83 -0
  62. package/src/cli/ui/components/screens/LogDetailScreen.tsx +67 -0
  63. package/src/cli/ui/components/screens/LogListScreen.tsx +192 -0
  64. package/src/cli/ui/hooks/useGitData.ts +12 -3
  65. package/src/cli/ui/types.ts +3 -0
  66. package/src/cli/ui/utils/clipboard.ts +31 -0
  67. package/src/gemini.ts +14 -6
  68. package/src/index.ts +11 -2
  69. package/src/logging/formatter.ts +106 -0
  70. package/src/logging/reader.ts +76 -0
  71. package/src/worktree.ts +77 -0
package/src/claude.ts CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  getTerminalStreams,
7
7
  resetTerminalModes,
8
8
  } from "./utils/terminal.js";
9
- import { isCommandAvailable } from "./utils/command.js";
9
+ import { findCommand } from "./utils/command.js";
10
10
  import { findLatestClaudeSession } from "./utils/session.js";
11
11
 
12
12
  const CLAUDE_CLI_PACKAGE = "@anthropic-ai/claude-code@latest";
@@ -232,7 +232,9 @@ export async function launchClaudeCode(
232
232
  const childStdio = createChildStdio();
233
233
 
234
234
  // Auto-detect locally installed claude command
235
- const hasLocalClaude = await isClaudeCommandAvailable();
235
+ const claudeLookup = await findCommand("claude");
236
+ const npxLookup =
237
+ process.platform === "win32" ? await findCommand("npx") : null;
236
238
 
237
239
  const execInteractive = async (
238
240
  file: string,
@@ -258,11 +260,12 @@ export async function launchClaudeCode(
258
260
  };
259
261
 
260
262
  try {
261
- if (hasLocalClaude) {
263
+ if (claudeLookup.source === "installed" && claudeLookup.path) {
264
+ // Use the full path to avoid PATH issues in non-interactive shells
262
265
  console.log(
263
266
  chalk.green(" ✨ Using locally installed claude command"),
264
267
  );
265
- await execInteractive("claude", args, {
268
+ await execInteractive(claudeLookup.path, args, {
266
269
  cwd: worktreePath,
267
270
  stdin: childStdio.stdin,
268
271
  stdout: childStdio.stdout,
@@ -270,11 +273,20 @@ export async function launchClaudeCode(
270
273
  env: launchEnv,
271
274
  });
272
275
  } else {
273
- console.log(
274
- chalk.cyan(
275
- " 🔄 Falling back to bunx @anthropic-ai/claude-code@latest",
276
- ),
277
- );
276
+ const useNpx = npxLookup?.source === "installed" && npxLookup?.path;
277
+ if (useNpx) {
278
+ console.log(
279
+ chalk.cyan(
280
+ " 🔄 Falling back to npx @anthropic-ai/claude-code@latest",
281
+ ),
282
+ );
283
+ } else {
284
+ console.log(
285
+ chalk.cyan(
286
+ " 🔄 Falling back to bunx @anthropic-ai/claude-code@latest",
287
+ ),
288
+ );
289
+ }
278
290
  console.log(
279
291
  chalk.yellow(
280
292
  " 💡 Recommended: Install Claude Code via official method for faster startup",
@@ -294,14 +306,33 @@ export async function launchClaudeCode(
294
306
  ),
295
307
  );
296
308
  console.log("");
297
- await new Promise((resolve) => setTimeout(resolve, 2000));
298
- await execInteractive("bunx", [CLAUDE_CLI_PACKAGE, ...args], {
299
- cwd: worktreePath,
300
- stdin: childStdio.stdin,
301
- stdout: childStdio.stdout,
302
- stderr: childStdio.stderr,
303
- env: launchEnv,
304
- });
309
+ const shouldSkipDelay =
310
+ typeof process !== "undefined" &&
311
+ (process.env?.NODE_ENV === "test" || Boolean(process.env?.VITEST));
312
+ if (!shouldSkipDelay) {
313
+ await new Promise((resolve) => setTimeout(resolve, 2000));
314
+ }
315
+ if (useNpx && npxLookup?.path) {
316
+ await execInteractive(
317
+ npxLookup.path,
318
+ ["-y", CLAUDE_CLI_PACKAGE, ...args],
319
+ {
320
+ cwd: worktreePath,
321
+ stdin: childStdio.stdin,
322
+ stdout: childStdio.stdout,
323
+ stderr: childStdio.stderr,
324
+ env: launchEnv,
325
+ },
326
+ );
327
+ } else {
328
+ await execInteractive("bunx", [CLAUDE_CLI_PACKAGE, ...args], {
329
+ cwd: worktreePath,
330
+ stdin: childStdio.stdin,
331
+ stdout: childStdio.stdout,
332
+ stderr: childStdio.stderr,
333
+ env: launchEnv,
334
+ });
335
+ }
305
336
  }
306
337
  } finally {
307
338
  childStdio.cleanup();
@@ -342,7 +373,9 @@ export async function launchClaudeCode(
342
373
 
343
374
  return capturedSessionId ? { sessionId: capturedSessionId } : {};
344
375
  } catch (error: unknown) {
345
- const hasLocalClaude = await isClaudeCommandAvailable();
376
+ const claudeCheck = await findCommand("claude");
377
+ const hasLocalClaude =
378
+ claudeCheck.source === "installed" && claudeCheck.path !== null;
346
379
  let errorMessage: string;
347
380
  const err = error as NodeJS.ErrnoException;
348
381
 
@@ -394,10 +427,6 @@ export async function launchClaudeCode(
394
427
  }
395
428
  }
396
429
 
397
- async function isClaudeCommandAvailable(): Promise<boolean> {
398
- return isCommandAvailable("claude");
399
- }
400
-
401
430
  /**
402
431
  * Checks whether Claude Code is available via `bunx` in the current environment.
403
432
  *
@@ -176,7 +176,7 @@ describe("App shortcuts integration", () => {
176
176
  getRepositoryRootMock.mockResolvedValue("/repo");
177
177
  deleteBranchMock.mockResolvedValue(undefined);
178
178
  App = (await import("../../components/App.js")).App;
179
- });
179
+ }, 30000);
180
180
 
181
181
  afterEach(() => {
182
182
  useGitDataMock.mockReset();
@@ -380,9 +380,13 @@ describe("App shortcuts integration", () => {
380
380
  expect(latestProps?.cleanupUI?.footerMessage?.text).toBeTruthy();
381
381
  expect(latestProps?.cleanupUI?.indicators).toMatchObject({
382
382
  "feature/add-new-feature": expect.objectContaining({
383
- icon: expect.stringMatching(/⠋|⠙|⠹|⠸|⠼|⠴|⠦|⠧/),
383
+ isSpinning: true,
384
+ color: "cyan",
385
+ }),
386
+ "hotfix/urgent-fix": expect.objectContaining({
387
+ icon: "⏳",
388
+ color: "yellow",
384
389
  }),
385
- "hotfix/urgent-fix": expect.objectContaining({ icon: "⏳" }),
386
390
  });
387
391
 
388
392
  resolveRemoveWorktree?.();
@@ -6,6 +6,7 @@ import { describe, it, expect, vi } from "vitest";
6
6
  import { render } from "ink-testing-library";
7
7
  import React from "react";
8
8
  import { Select } from "../../../components/common/Select.js";
9
+ import { ESCAPE_SEQUENCE_TIMEOUT_MS } from "../../../hooks/useAppInput.js";
9
10
 
10
11
  interface TestItem {
11
12
  label: string;
@@ -285,43 +286,48 @@ describe("Select", () => {
285
286
  });
286
287
  });
287
288
 
288
- describe('Space/Escape handlers', () => {
289
- it('should call onSpace with the currently highlighted item', async () => {
289
+ describe("Space/Escape handlers", () => {
290
+ it("should call onSpace with the currently highlighted item", async () => {
290
291
  const onSelect = vi.fn();
291
292
  const onSpace = vi.fn();
292
293
  const { stdin } = render(
293
- <Select items={mockItems} onSelect={onSelect} onSpace={onSpace} />
294
+ <Select items={mockItems} onSelect={onSelect} onSpace={onSpace} />,
294
295
  );
295
296
 
296
- stdin.write(' ');
297
+ stdin.write(" ");
297
298
  await delay(10);
298
299
 
299
300
  expect(onSpace).toHaveBeenCalledTimes(1);
300
301
  expect(onSpace).toHaveBeenCalledWith(mockItems[0]);
301
302
  });
302
303
 
303
- it('should not trigger onSpace when disabled', async () => {
304
+ it("should not trigger onSpace when disabled", async () => {
304
305
  const onSelect = vi.fn();
305
306
  const onSpace = vi.fn();
306
307
  const { stdin } = render(
307
- <Select items={mockItems} onSelect={onSelect} onSpace={onSpace} disabled />
308
+ <Select
309
+ items={mockItems}
310
+ onSelect={onSelect}
311
+ onSpace={onSpace}
312
+ disabled
313
+ />,
308
314
  );
309
315
 
310
- stdin.write(' ');
316
+ stdin.write(" ");
311
317
  await delay(10);
312
318
 
313
319
  expect(onSpace).not.toHaveBeenCalled();
314
320
  });
315
321
 
316
- it('should call onEscape when escape key is pressed', async () => {
322
+ it("should call onEscape when escape key is pressed", async () => {
317
323
  const onSelect = vi.fn();
318
324
  const onEscape = vi.fn();
319
325
  const { stdin } = render(
320
- <Select items={mockItems} onSelect={onSelect} onEscape={onEscape} />
326
+ <Select items={mockItems} onSelect={onSelect} onEscape={onEscape} />,
321
327
  );
322
328
 
323
- stdin.write('\u001B');
324
- await delay(30);
329
+ stdin.write("\u001B");
330
+ await delay(ESCAPE_SEQUENCE_TIMEOUT_MS + 20);
325
331
 
326
332
  expect(onEscape).toHaveBeenCalledTimes(1);
327
333
  });
@@ -210,7 +210,7 @@ describe("BranchListScreen", () => {
210
210
  />,
211
211
  );
212
212
 
213
- const text = container.textContent ?? "";
213
+ const text = stripAnsi(container.textContent ?? "");
214
214
  expect(text).toMatch(/\[ \]\s(🟢|🔴|⚪)\s(🛡|⚠)/); // state cluster with spacing
215
215
  });
216
216
 
@@ -724,10 +724,11 @@ describe("BranchListScreen", () => {
724
724
  expect(container.textContent).toContain("feature/test");
725
725
  });
726
726
 
727
- it("should disable other key bindings (c, r) while typing in filter", () => {
727
+ it("should disable other key bindings (c, r, l) while typing in filter", () => {
728
728
  const onSelect = vi.fn();
729
729
  const onCleanupCommand = vi.fn();
730
730
  const onRefresh = vi.fn();
731
+ const onOpenLogs = vi.fn();
731
732
 
732
733
  const inkApp = inkRender(
733
734
  <BranchListScreen
@@ -736,6 +737,7 @@ describe("BranchListScreen", () => {
736
737
  onSelect={onSelect}
737
738
  onCleanupCommand={onCleanupCommand}
738
739
  onRefresh={onRefresh}
740
+ onOpenLogs={onOpenLogs}
739
741
  />,
740
742
  );
741
743
 
@@ -746,10 +748,34 @@ describe("BranchListScreen", () => {
746
748
  act(() => {
747
749
  inkApp.stdin.write("c");
748
750
  inkApp.stdin.write("r");
751
+ inkApp.stdin.write("l");
749
752
  });
750
753
 
751
754
  expect(onCleanupCommand).not.toHaveBeenCalled();
752
755
  expect(onRefresh).not.toHaveBeenCalled();
756
+ expect(onOpenLogs).not.toHaveBeenCalled();
757
+
758
+ inkApp.unmount();
759
+ });
760
+
761
+ it("should open logs on l key", () => {
762
+ const onSelect = vi.fn();
763
+ const onOpenLogs = vi.fn();
764
+
765
+ const inkApp = inkRender(
766
+ <BranchListScreen
767
+ branches={mockBranches}
768
+ stats={mockStats}
769
+ onSelect={onSelect}
770
+ onOpenLogs={onOpenLogs}
771
+ />,
772
+ );
773
+
774
+ act(() => {
775
+ inkApp.stdin.write("l");
776
+ });
777
+
778
+ expect(onOpenLogs).toHaveBeenCalled();
753
779
 
754
780
  inkApp.unmount();
755
781
  });
@@ -0,0 +1,57 @@
1
+ /**
2
+ * @vitest-environment happy-dom
3
+ */
4
+ import { describe, it, expect, vi } from "vitest";
5
+ import { act } from "@testing-library/react";
6
+ import { render as inkRender } from "ink-testing-library";
7
+ import React from "react";
8
+ import { LogDetailScreen } from "../../../components/screens/LogDetailScreen.js";
9
+ import type { FormattedLogEntry } from "../../../../../logging/formatter.js";
10
+
11
+ const entry: FormattedLogEntry = {
12
+ id: "entry-1",
13
+ raw: {
14
+ time: "2025-12-25T10:00:00.000Z",
15
+ level: 30,
16
+ category: "cli",
17
+ msg: "hello",
18
+ },
19
+ timestamp: 1_767_015_200_000,
20
+ timeLabel: "10:00:00",
21
+ levelLabel: "INFO",
22
+ category: "cli",
23
+ message: "hello",
24
+ summary: "[10:00:00] [INFO] [cli] hello",
25
+ json: '{\n "msg": "hello"\n}',
26
+ };
27
+
28
+ describe("LogDetailScreen", () => {
29
+ it("renders formatted JSON and handles shortcuts", () => {
30
+ const onBack = vi.fn();
31
+ const onCopy = vi.fn();
32
+
33
+ const { stdin, lastFrame } = inkRender(
34
+ <LogDetailScreen entry={entry} onBack={onBack} onCopy={onCopy} />,
35
+ );
36
+
37
+ expect(lastFrame()).toContain('"msg": "hello"');
38
+
39
+ act(() => {
40
+ stdin.write("c");
41
+ });
42
+ expect(onCopy).toHaveBeenCalledWith(entry);
43
+
44
+ act(() => {
45
+ stdin.write("q");
46
+ });
47
+ expect(onBack).toHaveBeenCalled();
48
+ });
49
+
50
+ it("shows fallback when entry is missing", () => {
51
+ const { lastFrame } = inkRender(
52
+ <LogDetailScreen entry={null} onBack={vi.fn()} onCopy={vi.fn()} />,
53
+ );
54
+
55
+ expect(lastFrame()).toContain("ログがありません");
56
+ });
57
+ });
@@ -0,0 +1,102 @@
1
+ /**
2
+ * @vitest-environment happy-dom
3
+ */
4
+ import { describe, it, expect, vi } from "vitest";
5
+ import { act } from "@testing-library/react";
6
+ import { render as inkRender } from "ink-testing-library";
7
+ import React from "react";
8
+ import { LogListScreen } from "../../../components/screens/LogListScreen.js";
9
+ import type { FormattedLogEntry } from "../../../../../logging/formatter.js";
10
+
11
+ const buildEntry = (
12
+ overrides: Partial<FormattedLogEntry>,
13
+ ): FormattedLogEntry => ({
14
+ id: "entry-1",
15
+ raw: {
16
+ time: "2025-12-25T10:00:00.000Z",
17
+ level: 30,
18
+ category: "cli",
19
+ msg: "hello",
20
+ },
21
+ timestamp: 1_767_015_200_000,
22
+ timeLabel: "10:00:00",
23
+ levelLabel: "INFO",
24
+ category: "cli",
25
+ message: "hello",
26
+ summary: "[10:00:00] [INFO] [cli] hello",
27
+ json: '{\n "msg": "hello"\n}',
28
+ ...overrides,
29
+ });
30
+
31
+ describe("LogListScreen", () => {
32
+ it("renders log entries and handles shortcuts", () => {
33
+ const entries: FormattedLogEntry[] = [
34
+ buildEntry({ id: "entry-1" }),
35
+ buildEntry({
36
+ id: "entry-2",
37
+ summary: "[10:01:00] [WARN] [server] warn",
38
+ levelLabel: "WARN",
39
+ category: "server",
40
+ message: "warn",
41
+ }),
42
+ ];
43
+
44
+ const onSelect = vi.fn();
45
+ const onBack = vi.fn();
46
+ const onCopy = vi.fn();
47
+ const onPickDate = vi.fn();
48
+
49
+ const { stdin, lastFrame } = inkRender(
50
+ <LogListScreen
51
+ entries={entries}
52
+ loading={false}
53
+ error={null}
54
+ onBack={onBack}
55
+ onSelect={onSelect}
56
+ onCopy={onCopy}
57
+ onPickDate={onPickDate}
58
+ selectedDate="2025-12-25"
59
+ />,
60
+ );
61
+
62
+ const frame = lastFrame();
63
+ expect(frame).toContain("[10:00:00] [INFO] [cli] hello");
64
+ expect(frame).toContain("[10:01:00] [WARN] [server] warn");
65
+
66
+ act(() => {
67
+ stdin.write("\r");
68
+ });
69
+ expect(onSelect).toHaveBeenCalledWith(entries[0]);
70
+
71
+ act(() => {
72
+ stdin.write("c");
73
+ });
74
+ expect(onCopy).toHaveBeenCalledWith(entries[0]);
75
+
76
+ act(() => {
77
+ stdin.write("d");
78
+ });
79
+ expect(onPickDate).toHaveBeenCalled();
80
+
81
+ act(() => {
82
+ stdin.write("q");
83
+ });
84
+ expect(onBack).toHaveBeenCalled();
85
+ });
86
+
87
+ it("shows empty message when no logs", () => {
88
+ const { lastFrame } = inkRender(
89
+ <LogListScreen
90
+ entries={[]}
91
+ loading={false}
92
+ error={null}
93
+ onBack={vi.fn()}
94
+ onSelect={vi.fn()}
95
+ onCopy={vi.fn()}
96
+ selectedDate="2025-12-25"
97
+ />,
98
+ );
99
+
100
+ expect(lastFrame()).toContain("ログがありません");
101
+ });
102
+ });
@@ -0,0 +1,197 @@
1
+ /**
2
+ * @vitest-environment happy-dom
3
+ */
4
+ import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
5
+ import { renderHook, act, waitFor } from "@testing-library/react";
6
+ import { Window } from "happy-dom";
7
+
8
+ // モジュールをモック
9
+ vi.mock("../../../../git.js", () => ({
10
+ getAllBranches: vi.fn(),
11
+ hasUnpushedCommitsInRepo: vi.fn(),
12
+ getRepositoryRoot: vi.fn(),
13
+ fetchAllRemotes: vi.fn(),
14
+ collectUpstreamMap: vi.fn(),
15
+ getBranchDivergenceStatuses: vi.fn(),
16
+ hasUncommittedChanges: vi.fn(),
17
+ }));
18
+
19
+ vi.mock("../../../../worktree.js", () => ({
20
+ listAdditionalWorktrees: vi.fn(),
21
+ }));
22
+
23
+ vi.mock("../../../../github.js", () => ({
24
+ getPullRequestByBranch: vi.fn(),
25
+ }));
26
+
27
+ vi.mock("../../../../config/index.js", () => ({
28
+ getLastToolUsageMap: vi.fn(),
29
+ }));
30
+
31
+ import { useGitData } from "../../hooks/useGitData.js";
32
+ import {
33
+ getAllBranches,
34
+ getRepositoryRoot,
35
+ fetchAllRemotes,
36
+ collectUpstreamMap,
37
+ getBranchDivergenceStatuses,
38
+ } from "../../../../git.js";
39
+ import { listAdditionalWorktrees } from "../../../../worktree.js";
40
+ import { getLastToolUsageMap } from "../../../../config/index.js";
41
+
42
+ const mockGetAllBranches = getAllBranches as ReturnType<typeof vi.fn>;
43
+ const mockGetRepositoryRoot = getRepositoryRoot as ReturnType<typeof vi.fn>;
44
+ const mockFetchAllRemotes = fetchAllRemotes as ReturnType<typeof vi.fn>;
45
+ const mockCollectUpstreamMap = collectUpstreamMap as ReturnType<typeof vi.fn>;
46
+ const mockGetBranchDivergenceStatuses =
47
+ getBranchDivergenceStatuses as ReturnType<typeof vi.fn>;
48
+ const mockListAdditionalWorktrees = listAdditionalWorktrees as ReturnType<
49
+ typeof vi.fn
50
+ >;
51
+ const mockGetLastToolUsageMap = getLastToolUsageMap as ReturnType<typeof vi.fn>;
52
+
53
+ describe("useGitData", () => {
54
+ beforeEach(() => {
55
+ // Setup happy-dom
56
+ const window = new Window();
57
+ globalThis.window = window as unknown as typeof globalThis.window;
58
+ globalThis.document =
59
+ window.document as unknown as typeof globalThis.document;
60
+
61
+ // Reset all mocks
62
+ vi.clearAllMocks();
63
+
64
+ // Default mock implementations
65
+ mockGetRepositoryRoot.mockResolvedValue("/mock/repo");
66
+ mockFetchAllRemotes.mockResolvedValue(undefined);
67
+ mockGetAllBranches.mockResolvedValue([
68
+ {
69
+ name: "main",
70
+ type: "local",
71
+ isCurrent: true,
72
+ lastCommitDate: "2025-01-01",
73
+ },
74
+ {
75
+ name: "develop",
76
+ type: "local",
77
+ isCurrent: false,
78
+ lastCommitDate: "2025-01-02",
79
+ },
80
+ ]);
81
+ mockListAdditionalWorktrees.mockResolvedValue([]);
82
+ mockCollectUpstreamMap.mockResolvedValue(new Map());
83
+ mockGetBranchDivergenceStatuses.mockResolvedValue([]);
84
+ mockGetLastToolUsageMap.mockResolvedValue(new Map());
85
+ });
86
+
87
+ afterEach(() => {
88
+ vi.restoreAllMocks();
89
+ });
90
+
91
+ describe("キャッシュ機構", () => {
92
+ it("初回マウント時にGitデータを取得する", async () => {
93
+ const { result } = renderHook(() => useGitData());
94
+
95
+ // 初回ロード中
96
+ expect(result.current.loading).toBe(true);
97
+
98
+ await waitFor(() => {
99
+ expect(result.current.loading).toBe(false);
100
+ });
101
+
102
+ // Gitデータが取得されていることを確認
103
+ expect(mockGetAllBranches).toHaveBeenCalledTimes(1);
104
+ expect(result.current.branches).toHaveLength(2);
105
+ });
106
+
107
+ it("refresh()を呼び出すとGitデータを再取得する", async () => {
108
+ const { result } = renderHook(() => useGitData());
109
+
110
+ await waitFor(() => {
111
+ expect(result.current.loading).toBe(false);
112
+ });
113
+
114
+ // 初回ロードで1回呼ばれている
115
+ expect(mockGetAllBranches).toHaveBeenCalledTimes(1);
116
+
117
+ // refresh()を呼び出す
118
+ await act(async () => {
119
+ result.current.refresh();
120
+ });
121
+
122
+ await waitFor(() => {
123
+ expect(result.current.loading).toBe(false);
124
+ });
125
+
126
+ // refresh後に再度呼ばれている(forceRefresh=true)
127
+ expect(mockGetAllBranches).toHaveBeenCalledTimes(2);
128
+ });
129
+
130
+ it("キャッシュ済みの場合、再マウント時にGitデータを再取得しない", async () => {
131
+ // 注: useGitData は内部で useRef を使ってキャッシュ状態を管理
132
+ // 同一コンポーネント内での再レンダリングではキャッシュが効く
133
+ const { result, rerender } = renderHook(() => useGitData());
134
+
135
+ await waitFor(() => {
136
+ expect(result.current.loading).toBe(false);
137
+ });
138
+
139
+ // 初回ロードで1回呼ばれている
140
+ expect(mockGetAllBranches).toHaveBeenCalledTimes(1);
141
+
142
+ // 再レンダリング
143
+ rerender();
144
+
145
+ // キャッシュされているため追加の呼び出しはない
146
+ expect(mockGetAllBranches).toHaveBeenCalledTimes(1);
147
+ });
148
+ });
149
+
150
+ describe("lastUpdated", () => {
151
+ it("データ取得成功後にlastUpdatedが更新される", async () => {
152
+ const { result } = renderHook(() => useGitData());
153
+
154
+ // 初期状態ではnull
155
+ expect(result.current.lastUpdated).toBeNull();
156
+
157
+ await waitFor(() => {
158
+ expect(result.current.loading).toBe(false);
159
+ });
160
+
161
+ // ロード完了後にlastUpdatedが設定される
162
+ expect(result.current.lastUpdated).toBeInstanceOf(Date);
163
+ });
164
+ });
165
+
166
+ describe("エラーハンドリング", () => {
167
+ it("getAllBranchesがエラーを投げた場合、フォールバック値(空配列)が使用される", async () => {
168
+ // withTimeout がエラーをキャッチしてフォールバック値を返すため、
169
+ // エラー状態にはならず、空配列が設定される
170
+ mockGetAllBranches.mockRejectedValue(new Error("Git error"));
171
+
172
+ const { result } = renderHook(() => useGitData());
173
+
174
+ await waitFor(() => {
175
+ expect(result.current.loading).toBe(false);
176
+ });
177
+
178
+ // エラーはキャッチされ、フォールバック値が使用される
179
+ expect(result.current.error).toBeNull();
180
+ expect(result.current.branches).toHaveLength(0);
181
+ });
182
+
183
+ it("fetchAllRemotesがエラーを投げてもローカル表示は継続される", async () => {
184
+ mockFetchAllRemotes.mockRejectedValue(new Error("Network error"));
185
+
186
+ const { result } = renderHook(() => useGitData());
187
+
188
+ await waitFor(() => {
189
+ expect(result.current.loading).toBe(false);
190
+ });
191
+
192
+ // リモート取得失敗でもローカルブランチは表示される
193
+ expect(result.current.branches).toHaveLength(2);
194
+ expect(result.current.error).toBeNull();
195
+ });
196
+ });
197
+ });