refactor: Replace exec with spawn (#8510)

This commit is contained in:
Gal Zahavi
2025-09-16 12:03:17 -07:00
committed by GitHub
parent a015ea203f
commit 986b9fe7e9
11 changed files with 330 additions and 295 deletions

View File

@@ -7,20 +7,31 @@
import type { MockedFunction } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { act } from 'react';
import { renderHook } from '@testing-library/react';
import { renderHook, waitFor } 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');
import type { FSWatcher } from 'memfs/lib/volume.js';
import { spawnAsync as mockSpawnAsync } from '@google/gemini-cli-core';
// Mock @google/gemini-cli-core
vi.mock('@google/gemini-cli-core', async () => {
const original = await vi.importActual<
typeof import('@google/gemini-cli-core')
>('@google/gemini-cli-core');
return {
...original,
spawnAsync: vi.fn(),
};
});
// Mock fs and fs/promises
vi.mock('node:fs', async () => {
const memfs = await vi.importActual<typeof import('memfs')>('memfs');
return memfs.fs;
return {
...memfs.fs,
default: memfs.fs,
};
});
vi.mock('node:fs/promises', async () => {
@@ -29,13 +40,13 @@ vi.mock('node:fs/promises', async () => {
});
const CWD = '/test/project';
const GIT_HEAD_PATH = `${CWD}/.git/HEAD`;
const GIT_LOGS_HEAD_PATH = `${CWD}/.git/logs/HEAD`;
describe('useGitBranchName', () => {
beforeEach(() => {
vol.reset(); // Reset in-memory filesystem
vol.fromJSON({
[GIT_HEAD_PATH]: 'ref: refs/heads/main',
[GIT_LOGS_HEAD_PATH]: 'ref: refs/heads/main',
});
vi.useFakeTimers(); // Use fake timers for async operations
});
@@ -46,13 +57,11 @@ describe('useGitBranchName', () => {
});
it('should return branch name', async () => {
(mockExec as MockedFunction<typeof mockExec>).mockImplementation(
(_command, _options, callback) => {
callback?.(null, 'main\n', '');
return new EventEmitter() as ChildProcess;
},
(mockSpawnAsync as MockedFunction<typeof mockSpawnAsync>).mockResolvedValue(
{
stdout: 'main\n',
} as { stdout: string; stderr: string },
);
const { result, rerender } = renderHook(() => useGitBranchName(CWD));
await act(async () => {
@@ -64,11 +73,8 @@ describe('useGitBranchName', () => {
});
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;
},
(mockSpawnAsync as MockedFunction<typeof mockSpawnAsync>).mockRejectedValue(
new Error('Git error'),
);
const { result, rerender } = renderHook(() => useGitBranchName(CWD));
@@ -82,16 +88,16 @@ describe('useGitBranchName', () => {
});
it('should return short commit hash if branch is HEAD (detached state)', async () => {
(mockExec as MockedFunction<typeof mockExec>).mockImplementation(
(command, _options, callback) => {
if (command === 'git rev-parse --abbrev-ref HEAD') {
callback?.(null, 'HEAD\n', '');
} else if (command === 'git rev-parse --short HEAD') {
callback?.(null, 'a1b2c3d\n', '');
}
return new EventEmitter() as ChildProcess;
},
);
(
mockSpawnAsync as MockedFunction<typeof mockSpawnAsync>
).mockImplementation(async (command: string, args: string[]) => {
if (args.includes('--abbrev-ref')) {
return { stdout: 'HEAD\n' } as { stdout: string; stderr: string };
} else if (args.includes('--short')) {
return { stdout: 'a1b2c3d\n' } as { stdout: string; stderr: string };
}
return { stdout: '' } as { stdout: string; stderr: string };
});
const { result, rerender } = renderHook(() => useGitBranchName(CWD));
await act(async () => {
@@ -102,16 +108,16 @@ describe('useGitBranchName', () => {
});
it('should return undefined if branch is HEAD and getting commit hash fails', async () => {
(mockExec as MockedFunction<typeof mockExec>).mockImplementation(
(command, _options, callback) => {
if (command === 'git rev-parse --abbrev-ref HEAD') {
callback?.(null, 'HEAD\n', '');
} else if (command === 'git rev-parse --short HEAD') {
callback?.(new Error('Git error'), '', 'error output');
}
return new EventEmitter() as ChildProcess;
},
);
(
mockSpawnAsync as MockedFunction<typeof mockSpawnAsync>
).mockImplementation(async (command: string, args: string[]) => {
if (args.includes('--abbrev-ref')) {
return { stdout: 'HEAD\n' } as { stdout: string; stderr: string };
} else if (args.includes('--short')) {
throw new Error('Git error');
}
return { stdout: '' } as { stdout: string; stderr: string };
});
const { result, rerender } = renderHook(() => useGitBranchName(CWD));
await act(async () => {
@@ -123,12 +129,15 @@ describe('useGitBranchName', () => {
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;
},
);
(mockSpawnAsync as MockedFunction<typeof mockSpawnAsync>)
.mockResolvedValueOnce({ stdout: 'main\n' } as {
stdout: string;
stderr: string;
})
.mockResolvedValueOnce({ stdout: 'develop\n' } as {
stdout: string;
stderr: string;
});
const { result, rerender } = renderHook(() => useGitBranchName(CWD));
@@ -138,34 +147,27 @@ describe('useGitBranchName', () => {
});
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
fs.writeFileSync(GIT_LOGS_HEAD_PATH, 'ref: refs/heads/develop'); // Trigger watcher
vi.runAllTimers(); // Process timers for watcher and exec
rerender();
});
expect(result.current).toBe('develop');
await waitFor(() => {
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);
// Remove .git/logs/HEAD to cause an error in fs.watch setup
vol.unlinkSync(GIT_LOGS_HEAD_PATH);
(mockExec as MockedFunction<typeof mockExec>).mockImplementation(
(_command, _options, callback) => {
callback?.(null, 'main\n', '');
return new EventEmitter() as ChildProcess;
},
(mockSpawnAsync as MockedFunction<typeof mockSpawnAsync>).mockResolvedValue(
{
stdout: 'main\n',
} as { stdout: string; stderr: string },
);
const { result, rerender } = renderHook(() => useGitBranchName(CWD));
@@ -177,23 +179,21 @@ describe('useGitBranchName', () => {
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;
},
);
(
mockSpawnAsync as MockedFunction<typeof mockSpawnAsync>
).mockResolvedValueOnce({
stdout: 'develop\n',
} as { stdout: string; stderr: string });
// 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',
[GIT_LOGS_HEAD_PATH]: 'ref: refs/heads/develop',
});
await act(async () => {
fs.writeFileSync(GIT_HEAD_PATH, 'ref: refs/heads/develop');
fs.writeFileSync(GIT_LOGS_HEAD_PATH, 'ref: refs/heads/develop');
vi.runAllTimers();
rerender();
});
@@ -209,11 +209,10 @@ describe('useGitBranchName', () => {
close: closeMock,
} as unknown as FSWatcher);
(mockExec as MockedFunction<typeof mockExec>).mockImplementation(
(_command, _options, callback) => {
callback?.(null, 'main\n', '');
return new EventEmitter() as ChildProcess;
},
(mockSpawnAsync as MockedFunction<typeof mockSpawnAsync>).mockResolvedValue(
{
stdout: 'main\n',
} as { stdout: string; stderr: string },
);
const { unmount, rerender } = renderHook(() => useGitBranchName(CWD));
@@ -224,7 +223,10 @@ describe('useGitBranchName', () => {
});
unmount();
expect(watchMock).toHaveBeenCalledWith(GIT_HEAD_PATH, expect.any(Function));
expect(watchMock).toHaveBeenCalledWith(
GIT_LOGS_HEAD_PATH,
expect.any(Function),
);
expect(closeMock).toHaveBeenCalled();
});
});

View File

@@ -5,7 +5,7 @@
*/
import { useState, useEffect, useCallback } from 'react';
import { exec } from 'node:child_process';
import { spawnAsync } from '@google/gemini-cli-core';
import fs from 'node:fs';
import fsPromises from 'node:fs/promises';
import path from 'node:path';
@@ -13,36 +13,28 @@ import path from 'node: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',
const fetchBranchName = useCallback(async () => {
try {
const { stdout } = await spawnAsync(
'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 {
exec(
'git rev-parse --short HEAD',
{ cwd },
(error, stdout, _stderr) => {
if (error) {
setBranchName(undefined);
return;
}
setBranchName(stdout.toString().trim());
},
);
}
},
),
[cwd, setBranchName],
);
);
const branch = stdout.toString().trim();
if (branch && branch !== 'HEAD') {
setBranchName(branch);
} else {
const { stdout: hashStdout } = await spawnAsync(
'git',
['rev-parse', '--short', 'HEAD'],
{ cwd },
);
setBranchName(hashStdout.toString().trim());
}
} catch (_error) {
setBranchName(undefined);
}
}, [cwd, setBranchName]);
useEffect(() => {
fetchBranchName(); // Initial fetch

View File

@@ -4,12 +4,9 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { exec } from 'node:child_process';
import { promisify } from 'node:util';
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
const execAsync = promisify(exec);
import { spawnAsync } from '@google/gemini-cli-core';
/**
* Checks if the system clipboard contains an image (macOS only for now)
@@ -22,11 +19,10 @@ export async function clipboardHasImage(): Promise<boolean> {
try {
// Use osascript to check clipboard type
const { stdout } = await execAsync(
`osascript -e 'clipboard info' 2>/dev/null | grep -qE "«class PNGf»|TIFF picture|JPEG picture|GIF picture|«class JPEG»|«class TIFF»" && echo "true" || echo "false"`,
{ shell: '/bin/bash' },
);
return stdout.trim() === 'true';
const { stdout } = await spawnAsync('osascript', ['-e', 'clipboard info']);
const imageRegex =
/«class PNGf»|TIFF picture|JPEG picture|GIF picture|«class JPEG»|«class TIFF»/;
return imageRegex.test(stdout);
} catch {
return false;
}
@@ -84,7 +80,7 @@ export async function saveClipboardImage(
end try
`;
const { stdout } = await execAsync(`osascript -e '${script}'`);
const { stdout } = await spawnAsync('osascript', ['-e', script]);
if (stdout.trim() === 'success') {
// Verify the file was created and has content