diff options
Diffstat (limited to 'packages/cli/src/ui/hooks')
| -rw-r--r-- | packages/cli/src/ui/hooks/useGitBranchName.test.ts | 214 | ||||
| -rw-r--r-- | packages/cli/src/ui/hooks/useGitBranchName.ts | 66 |
2 files changed, 280 insertions, 0 deletions
diff --git a/packages/cli/src/ui/hooks/useGitBranchName.test.ts b/packages/cli/src/ui/hooks/useGitBranchName.test.ts new file mode 100644 index 00000000..f5e71fbf --- /dev/null +++ b/packages/cli/src/ui/hooks/useGitBranchName.test.ts @@ -0,0 +1,214 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + afterEach, + beforeEach, + describe, + expect, + it, + vi, + MockedFunction, +} from 'vitest'; +import { act } from 'react'; +import { renderHook } from '@testing-library/react'; +import { useGitBranchName } from './useGitBranchName.js'; +import { fs, vol } from 'memfs'; // For mocking fs +import { EventEmitter } from 'node:events'; +import { exec as mockExec, type ChildProcess } from 'node:child_process'; +import type { FSWatcher } from 'memfs/lib/volume.js'; + +// Mock child_process +vi.mock('child_process'); + +// Mock fs and fs/promises +vi.mock('node:fs', async () => { + const memfs = await vi.importActual<typeof import('memfs')>('memfs'); + return memfs.fs; +}); + +vi.mock('node:fs/promises', async () => { + const memfs = await vi.importActual<typeof import('memfs')>('memfs'); + return memfs.fs.promises; +}); + +const CWD = '/test/project'; +const GIT_HEAD_PATH = `${CWD}/.git/HEAD`; + +describe('useGitBranchName', () => { + beforeEach(() => { + vol.reset(); // Reset in-memory filesystem + vol.fromJSON({ + [GIT_HEAD_PATH]: 'ref: refs/heads/main', + }); + vi.useFakeTimers(); // Use fake timers for async operations + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.clearAllTimers(); + }); + + it('should return branch name', async () => { + (mockExec as MockedFunction<typeof mockExec>).mockImplementation( + (_command, _options, callback) => { + callback?.(null, 'main\n', ''); + return new EventEmitter() as ChildProcess; + }, + ); + + const { result, rerender } = renderHook(() => useGitBranchName(CWD)); + + await act(async () => { + vi.runAllTimers(); // Advance timers to trigger useEffect and exec callback + rerender(); // Rerender to get the updated state + }); + + expect(result.current).toBe('main'); + }); + + it('should return undefined if git command fails', async () => { + (mockExec as MockedFunction<typeof mockExec>).mockImplementation( + (_command, _options, callback) => { + callback?.(new Error('Git error'), '', 'error output'); + return new EventEmitter() as ChildProcess; + }, + ); + + const { result, rerender } = renderHook(() => useGitBranchName(CWD)); + expect(result.current).toBeUndefined(); + + await act(async () => { + vi.runAllTimers(); + rerender(); + }); + expect(result.current).toBeUndefined(); + }); + + it('should return undefined if branch is HEAD (detached state)', async () => { + (mockExec as MockedFunction<typeof mockExec>).mockImplementation( + (_command, _options, callback) => { + callback?.(null, 'HEAD\n', ''); + return new EventEmitter() as ChildProcess; + }, + ); + + const { result, rerender } = renderHook(() => useGitBranchName(CWD)); + expect(result.current).toBeUndefined(); + await act(async () => { + vi.runAllTimers(); + rerender(); + }); + expect(result.current).toBeUndefined(); + }); + + it('should update branch name when .git/HEAD changes', async ({ skip }) => { + skip(); // TODO: fix + (mockExec as MockedFunction<typeof mockExec>).mockImplementationOnce( + (_command, _options, callback) => { + callback?.(null, 'main\n', ''); + return new EventEmitter() as ChildProcess; + }, + ); + + const { result, rerender } = renderHook(() => useGitBranchName(CWD)); + + await act(async () => { + vi.runAllTimers(); + rerender(); + }); + expect(result.current).toBe('main'); + + // Simulate a branch change + (mockExec as MockedFunction<typeof mockExec>).mockImplementationOnce( + (_command, _options, callback) => { + callback?.(null, 'develop\n', ''); + return new EventEmitter() as ChildProcess; + }, + ); + + // Simulate file change event + // Ensure the watcher is set up before triggering the change + await act(async () => { + fs.writeFileSync(GIT_HEAD_PATH, 'ref: refs/heads/develop'); // Trigger watcher + vi.runAllTimers(); // Process timers for watcher and exec + rerender(); + }); + + expect(result.current).toBe('develop'); + }); + + it('should handle watcher setup error silently', async () => { + // Remove .git/HEAD to cause an error in fs.watch setup + vol.unlinkSync(GIT_HEAD_PATH); + + (mockExec as MockedFunction<typeof mockExec>).mockImplementation( + (_command, _options, callback) => { + callback?.(null, 'main\n', ''); + return new EventEmitter() as ChildProcess; + }, + ); + + const { result, rerender } = renderHook(() => useGitBranchName(CWD)); + + await act(async () => { + vi.runAllTimers(); + rerender(); + }); + + expect(result.current).toBe('main'); // Branch name should still be fetched initially + + // Try to trigger a change that would normally be caught by the watcher + (mockExec as MockedFunction<typeof mockExec>).mockImplementationOnce( + (_command, _options, callback) => { + callback?.(null, 'develop\n', ''); + return new EventEmitter() as ChildProcess; + }, + ); + + // This write would trigger the watcher if it was set up + // but since it failed, the branch name should not update + // We need to create the file again for writeFileSync to not throw + vol.fromJSON({ + [GIT_HEAD_PATH]: 'ref: refs/heads/develop', + }); + + await act(async () => { + fs.writeFileSync(GIT_HEAD_PATH, 'ref: refs/heads/develop'); + vi.runAllTimers(); + rerender(); + }); + + // Branch name should not change because watcher setup failed + expect(result.current).toBe('main'); + }); + + it('should cleanup watcher on unmount', async ({ skip }) => { + skip(); // TODO: fix + const closeMock = vi.fn(); + const watchMock = vi.spyOn(fs, 'watch').mockReturnValue({ + close: closeMock, + } as unknown as FSWatcher); + + (mockExec as MockedFunction<typeof mockExec>).mockImplementation( + (_command, _options, callback) => { + callback?.(null, 'main\n', ''); + return new EventEmitter() as ChildProcess; + }, + ); + + const { unmount, rerender } = renderHook(() => useGitBranchName(CWD)); + + await act(async () => { + vi.runAllTimers(); + rerender(); + }); + + unmount(); + expect(watchMock).toHaveBeenCalledWith(GIT_HEAD_PATH, expect.any(Function)); + expect(closeMock).toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/ui/hooks/useGitBranchName.ts b/packages/cli/src/ui/hooks/useGitBranchName.ts new file mode 100644 index 00000000..6b7b2769 --- /dev/null +++ b/packages/cli/src/ui/hooks/useGitBranchName.ts @@ -0,0 +1,66 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useEffect, useCallback } from 'react'; +import { exec } from 'node:child_process'; +import fs from 'node:fs'; +import fsPromises from 'node:fs/promises'; +import path from 'path'; + +export function useGitBranchName(cwd: string): string | undefined { + const [branchName, setBranchName] = useState<string | undefined>(undefined); + + const fetchBranchName = useCallback( + () => + exec( + 'git rev-parse --abbrev-ref HEAD', + { cwd }, + (error, stdout, _stderr) => { + if (error) { + setBranchName(undefined); + return; + } + const branch = stdout.toString().trim(); + if (branch && branch !== 'HEAD') { + setBranchName(branch); + } else { + setBranchName(undefined); + } + }, + ), + [cwd, setBranchName], + ); + + useEffect(() => { + fetchBranchName(); // Initial fetch + + const gitHeadPath = path.join(cwd, '.git', 'HEAD'); + let watcher: fs.FSWatcher | undefined; + + const setupWatcher = async () => { + try { + await fsPromises.access(gitHeadPath, fs.constants.F_OK); + watcher = fs.watch(gitHeadPath, (eventType) => { + if (eventType === 'change') { + fetchBranchName(); + } + }); + } catch (_watchError) { + // Silently ignore watcher errors (e.g. permissions or file not existing), + // similar to how exec errors are handled. + // The branch name will simply not update automatically. + } + }; + + setupWatcher(); + + return () => { + watcher?.close(); + }; + }, [cwd, fetchBranchName]); + + return branchName; +} |
