Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions packages/cli/src/config/extensions/github.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,105 @@ describe('github.ts', () => {
ExtensionUpdateState.UPDATE_AVAILABLE,
);
});

it('sets non-interactive environment variables on git instance', async () => {
mockGit.getRemotes.mockResolvedValue([
{ name: 'origin', refs: { fetch: 'https://example.com/repo.git' } },
]);
mockGit.listRemote.mockResolvedValue('hash123\tHEAD');
mockGit.revparse.mockResolvedValue('hash123');

const extension = {
name: 'test-ext',
path: '/path/to/ext',
installMetadata: {
type: 'git',
source: 'https://example.com/repo.git',
},
version: '1.0.0',
config: { name: 'test-ext', version: '1.0.0' },
} as unknown as GeminiCLIExtension;

const state = await checkForExtensionUpdate(
extension,
mockExtensionManager,
);

expect(mockGit.env).toHaveBeenCalledWith(
expect.objectContaining({
GIT_TERMINAL_PROMPT: '0',
GIT_SSH_COMMAND: expect.stringContaining('BatchMode=yes'),
}),
);
expect(state).toBe(ExtensionUpdateState.UP_TO_DATE);
});

it('gracefully handles terminal prompt suppression error without throwing', async () => {
mockGit.getRemotes.mockResolvedValue([
{
name: 'origin',
refs: { fetch: 'https://example.com/private-repo.git' },
},
]);
mockGit.listRemote.mockRejectedValue(
new Error('fatal: could not read Username: terminal prompts disabled'),
);

const extension = {
name: 'private-ext',
path: '/path/to/ext',
installMetadata: {
type: 'git',
source: 'https://example.com/private-repo.git',
},
version: '1.0.0',
config: { name: 'private-ext', version: '1.0.0' },
} as unknown as GeminiCLIExtension;

const state = await checkForExtensionUpdate(
extension,
mockExtensionManager,
);

expect(state).toBe(ExtensionUpdateState.ERROR);
});

it('should clear the timeout timer when git.listRemote resolves', async () => {
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
mockGit.getRemotes.mockResolvedValue([
{ name: 'origin', refs: { fetch: 'url' } },
]);
mockGit.listRemote.mockResolvedValue('hash\tHEAD');
mockGit.revparse.mockResolvedValue('hash');

const ext = {
path: '/path',
installMetadata: { type: 'git', source: 'url' },
} as unknown as GeminiCLIExtension;

await checkForExtensionUpdate(ext, mockExtensionManager);

expect(clearTimeoutSpy).toHaveBeenCalled();
clearTimeoutSpy.mockRestore();
});

it('should clear the timeout timer when git.listRemote rejects', async () => {
const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout');
mockGit.getRemotes.mockResolvedValue([
{ name: 'origin', refs: { fetch: 'url' } },
]);
mockGit.listRemote.mockRejectedValue(new Error('git error'));

const ext = {
path: '/path',
installMetadata: { type: 'git', source: 'url' },
} as unknown as GeminiCLIExtension;

await checkForExtensionUpdate(ext, mockExtensionManager);

expect(clearTimeoutSpy).toHaveBeenCalled();
clearTimeoutSpy.mockRestore();
});
});

describe('downloadFromGitHubRelease', () => {
Expand Down
55 changes: 51 additions & 4 deletions packages/cli/src/config/extensions/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/

import { simpleGit } from 'simple-git';
import { simpleGit, type SimpleGit } from 'simple-git';
import {
debugLogger,
getErrorMessage,
Expand All @@ -24,6 +24,33 @@ import type { ExtensionConfig } from '../extension.js';
import type { ExtensionManager } from '../extension-manager.js';
import { EXTENSIONS_CONFIG_FILENAME } from './variables.js';

const GIT_BACKGROUND_TIMEOUT_MS = 15_000;

/**
* Instantiates a SimpleGit client configured for non-interactive execution.
*
* Prevents git and ssh subprocesses from opening /dev/tty or blocking on stdin
* during background extension operations.
*/
export function createNonInteractiveGit(baseDir: string): SimpleGit {
const safeEnv = getSafeGitEnv();

// Preserve existing GIT_SSH_COMMAND if configured by user, but enforce BatchMode=yes
const existingSsh =
safeEnv['GIT_SSH_COMMAND'] || process.env['GIT_SSH_COMMAND'];
const sshCommand = existingSsh
? `${existingSsh} -o BatchMode=yes`
: 'ssh -o BatchMode=yes';

return simpleGit(baseDir).env({
...safeEnv,
// Native Git flag (>=2.3) disabling terminal prompts
GIT_TERMINAL_PROMPT: '0',
// Prevents SSH from prompting for passphrases or host key confirmation
GIT_SSH_COMMAND: sshCommand,
});
}

/**
* Clones a Git repository to a specified local path.
* @param installMetadata The metadata for the extension to install.
Expand All @@ -34,7 +61,7 @@ export async function cloneFromGit(
destination: string,
): Promise<void> {
try {
const git = simpleGit(destination).env(getSafeGitEnv());
const git = createNonInteractiveGit(destination);
let sourceUrl = installMetadata.source;
const token = getGitHubToken();
if (token) {
Expand Down Expand Up @@ -224,7 +251,7 @@ export async function checkForExtensionUpdate(

try {
if (installMetadata.type === 'git') {
const git = simpleGit(extension.path).env(getSafeGitEnv());
const git = createNonInteractiveGit(extension.path);
const remotes = await git.getRemotes(true);
if (remotes.length === 0) {
debugLogger.error('No git remotes found.');
Expand All @@ -241,7 +268,27 @@ export async function checkForExtensionUpdate(
// Determine the ref to check on the remote.
const refToCheck = installMetadata.ref || 'HEAD';

const lsRemoteOutput = await git.listRemote([remoteUrl, refToCheck]);
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(
() =>
reject(
new Error(
`git ls-remote timed out after ${GIT_BACKGROUND_TIMEOUT_MS}ms`,
),
),
GIT_BACKGROUND_TIMEOUT_MS,
);
});

const lsRemoteOutput = await Promise.race([
git.listRemote([remoteUrl, refToCheck]),
timeoutPromise,
Comment thread
DavidAPierce marked this conversation as resolved.
]).finally(() => {
if (timeoutId) {
clearTimeout(timeoutId);
}
});

if (typeof lsRemoteOutput !== 'string' || lsRemoteOutput.trim() === '') {
debugLogger.error(`Git ref ${refToCheck} not found.`);
Expand Down
Loading