Add support for auto-updating git extensions (#8511)

This commit is contained in:
Jacob MacDonald
2025-09-18 14:49:47 -07:00
committed by GitHub
parent e94ce7e2fd
commit 22b7d86574
20 changed files with 1314 additions and 529 deletions

View File

@@ -85,9 +85,8 @@ import { useAutoAcceptIndicator } from './hooks/useAutoAcceptIndicator.js';
import { useWorkspaceMigration } from './hooks/useWorkspaceMigration.js';
import { useSessionStats } from './contexts/SessionContext.js';
import { useGitBranchName } from './hooks/useGitBranchName.js';
import { useExtensionUpdates } from './hooks/useExtensionUpdates.js';
import { FocusContext } from './contexts/FocusContext.js';
import type { ExtensionUpdateState } from './state/extensions.js';
import { checkForAllExtensionUpdates } from '../config/extension.js';
const CTRL_EXIT_PROMPT_DURATION_MS = 1000;
@@ -149,9 +148,14 @@ export const AppContainer = (props: AppContainerProps) => {
const [isTrustedFolder, setIsTrustedFolder] = useState<boolean | undefined>(
config.isTrustedFolder(),
);
const [extensionsUpdateState, setExtensionsUpdateState] = useState(
new Map<string, ExtensionUpdateState>(),
);
const extensions = config.getExtensions();
const { extensionsUpdateState, setExtensionsUpdateState } =
useExtensionUpdates(
extensions,
historyManager.addItem,
config.getWorkingDir(),
);
// Helper to determine the effective model, considering the fallback state.
const getEffectiveModel = useCallback(() => {
@@ -1196,11 +1200,6 @@ Logging in with Google... Please restart Gemini CLI to continue.
],
);
const extensions = config.getExtensions();
useEffect(() => {
checkForAllExtensionUpdates(extensions, setExtensionsUpdateState);
}, [extensions, setExtensionsUpdateState]);
return (
<UIStateContext.Provider value={uiState}>
<UIActionsContext.Provider value={uiActions}>

View File

@@ -4,10 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { GeminiCLIExtension } from '@google/gemini-cli-core';
import {
updateAllUpdatableExtensions,
updateExtensionByName,
} from '../../config/extension.js';
updateExtension,
} from '../../config/extensions/update.js';
import { createMockCommandContext } from '../../test-utils/mockCommandContext.js';
import { MessageType } from '../types.js';
import { extensionsCommand } from './extensionsCommand.js';
@@ -20,14 +21,15 @@ import {
beforeEach,
type MockedFunction,
} from 'vitest';
import { ExtensionUpdateState } from '../state/extensions.js';
vi.mock('../../config/extension.js', () => ({
updateExtensionByName: vi.fn(),
vi.mock('../../config/extensions/update.js', () => ({
updateExtension: vi.fn(),
updateAllUpdatableExtensions: vi.fn(),
}));
const mockUpdateExtensionByName = updateExtensionByName as MockedFunction<
typeof updateExtensionByName
const mockUpdateExtension = updateExtension as MockedFunction<
typeof updateExtension
>;
const mockUpdateAllUpdatableExtensions =
@@ -35,6 +37,8 @@ const mockUpdateAllUpdatableExtensions =
typeof updateAllUpdatableExtensions
>;
const mockGetExtensions = vi.fn();
describe('extensionsCommand', () => {
let mockContext: CommandContext;
@@ -43,7 +47,7 @@ describe('extensionsCommand', () => {
mockContext = createMockCommandContext({
services: {
config: {
getExtensions: () => [],
getExtensions: mockGetExtensions,
getWorkingDir: () => '/test/dir',
},
},
@@ -147,36 +151,73 @@ describe('extensionsCommand', () => {
});
it('should update a single extension by name', async () => {
mockUpdateExtensionByName.mockResolvedValue({
const extension: GeminiCLIExtension = {
name: 'ext-one',
originalVersion: '1.0.0',
type: 'git',
version: '1.0.0',
isActive: true,
path: '/test/dir/ext-one',
autoUpdate: false,
};
mockUpdateExtension.mockResolvedValue({
name: extension.name,
originalVersion: extension.version,
updatedVersion: '1.0.1',
});
mockGetExtensions.mockReturnValue([extension]);
mockContext.ui.extensionsUpdateState.set(
extension.name,
ExtensionUpdateState.UPDATE_AVAILABLE,
);
await updateAction(mockContext, 'ext-one');
expect(mockUpdateExtensionByName).toHaveBeenCalledWith(
'ext-one',
expect(mockUpdateExtension).toHaveBeenCalledWith(
extension,
'/test/dir',
[],
ExtensionUpdateState.UPDATE_AVAILABLE,
expect.any(Function),
);
});
it('should handle errors when updating a single extension', async () => {
mockUpdateExtensionByName.mockRejectedValue(
new Error('Extension not found'),
);
mockUpdateExtension.mockRejectedValue(new Error('Extension not found'));
mockGetExtensions.mockReturnValue([]);
await updateAction(mockContext, 'ext-one');
expect(mockContext.ui.addItem).toHaveBeenCalledWith(
{
type: MessageType.ERROR,
text: 'Extension not found',
text: 'Extension ext-one not found.',
},
expect.any(Number),
);
});
it('should update multiple extensions by name', async () => {
mockUpdateExtensionByName
const extensionOne: GeminiCLIExtension = {
name: 'ext-one',
type: 'git',
version: '1.0.0',
isActive: true,
path: '/test/dir/ext-one',
autoUpdate: false,
};
const extensionTwo: GeminiCLIExtension = {
name: 'ext-two',
type: 'git',
version: '1.0.0',
isActive: true,
path: '/test/dir/ext-two',
autoUpdate: false,
};
mockGetExtensions.mockReturnValue([extensionOne, extensionTwo]);
mockContext.ui.extensionsUpdateState.set(
extensionOne.name,
ExtensionUpdateState.UPDATE_AVAILABLE,
);
mockContext.ui.extensionsUpdateState.set(
extensionTwo.name,
ExtensionUpdateState.UPDATE_AVAILABLE,
);
mockUpdateExtension
.mockResolvedValueOnce({
name: 'ext-one',
originalVersion: '1.0.0',
@@ -188,7 +229,7 @@ describe('extensionsCommand', () => {
updatedVersion: '2.0.1',
});
await updateAction(mockContext, 'ext-one ext-two');
expect(mockUpdateExtensionByName).toHaveBeenCalledTimes(2);
expect(mockUpdateExtension).toHaveBeenCalledTimes(2);
expect(mockContext.ui.setPendingItem).toHaveBeenCalledWith({
type: MessageType.EXTENSIONS_LIST,
});

View File

@@ -5,11 +5,12 @@
*/
import {
updateExtensionByName,
updateAllUpdatableExtensions,
type ExtensionUpdateInfo,
} from '../../config/extension.js';
updateExtension,
} from '../../config/extensions/update.js';
import { getErrorMessage } from '../../utils/errors.js';
import { ExtensionUpdateState } from '../state/extensions.js';
import { MessageType } from '../types.js';
import {
type CommandContext,
@@ -55,19 +56,36 @@ async function updateAction(context: CommandContext, args: string) {
context.ui.setExtensionsUpdateState,
);
} else if (names?.length) {
const workingDir = context.services.config!.getWorkingDir();
const extensions = context.services.config!.getExtensions();
for (const name of names) {
updateInfos.push(
await updateExtensionByName(
name,
context.services.config!.getWorkingDir(),
context.services.config!.getExtensions(),
(updateState) => {
const newState = new Map(context.ui.extensionsUpdateState);
newState.set(name, updateState);
context.ui.setExtensionsUpdateState(newState);
},
),
const extension = extensions.find(
(extension) => extension.name === name,
);
if (!extension) {
context.ui.addItem(
{
type: MessageType.ERROR,
text: `Extension ${name} not found.`,
},
Date.now(),
);
continue;
}
const updateInfo = await updateExtension(
extension,
workingDir,
context.ui.extensionsUpdateState.get(extension.name) ??
ExtensionUpdateState.UNKNOWN,
(updateState) => {
context.ui.setExtensionsUpdateState((prev) => {
const newState = new Map(prev);
newState.set(name, updateState);
return newState;
});
},
);
if (updateInfo) updateInfos.push(updateInfo);
}
}

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { ReactNode } from 'react';
import type { Dispatch, ReactNode, SetStateAction } from 'react';
import type { Content, PartListUnion } from '@google/genai';
import type { HistoryItemWithoutId, HistoryItem } from '../types.js';
import type { Config, GitService, Logger } from '@google/gemini-cli-core';
@@ -63,9 +63,9 @@ export interface CommandContext {
setGeminiMdFileCount: (count: number) => void;
reloadCommands: () => void;
extensionsUpdateState: Map<string, ExtensionUpdateState>;
setExtensionsUpdateState: (
updateState: Map<string, ExtensionUpdateState>,
) => void;
setExtensionsUpdateState: Dispatch<
SetStateAction<Map<string, ExtensionUpdateState>>
>;
};
// Session-specific data
session: {

View File

@@ -4,7 +4,14 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useCallback, useMemo, useEffect, useState } from 'react';
import {
useCallback,
useMemo,
useEffect,
useState,
type Dispatch,
type SetStateAction,
} from 'react';
import { type PartListUnion } from '@google/genai';
import process from 'node:process';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
@@ -45,9 +52,9 @@ interface SlashCommandProcessorActions {
quit: (messages: HistoryItem[]) => void;
setDebugMessage: (message: string) => void;
toggleCorgiMode: () => void;
setExtensionsUpdateState: (
updateState: Map<string, ExtensionUpdateState>,
) => void;
setExtensionsUpdateState: Dispatch<
SetStateAction<Map<string, ExtensionUpdateState>>
>;
}
/**

View File

@@ -0,0 +1,211 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi } from 'vitest';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import {
EXTENSIONS_CONFIG_FILENAME,
annotateActiveExtensions,
loadExtension,
} from '../../config/extension.js';
import { createExtension } from '../../test-utils/createExtension.js';
import { useExtensionUpdates } from './useExtensionUpdates.js';
import { GEMINI_DIR, type GeminiCLIExtension } from '@google/gemini-cli-core';
import { isWorkspaceTrusted } from '../../config/trustedFolders.js';
import { renderHook, waitFor } from '@testing-library/react';
import { MessageType } from '../types.js';
const mockGit = {
clone: vi.fn(),
getRemotes: vi.fn(),
fetch: vi.fn(),
checkout: vi.fn(),
listRemote: vi.fn(),
revparse: vi.fn(),
// Not a part of the actual API, but we need to use this to do the correct
// file system interactions.
path: vi.fn(),
};
vi.mock('simple-git', () => ({
simpleGit: vi.fn((path: string) => {
mockGit.path.mockReturnValue(path);
return mockGit;
}),
}));
vi.mock('os', async (importOriginal) => {
const mockedOs = await importOriginal<typeof os>();
return {
...mockedOs,
homedir: vi.fn(),
};
});
vi.mock('../../config/trustedFolders.js', async (importOriginal) => {
const actual =
await importOriginal<typeof import('../../config/trustedFolders.js')>();
return {
...actual,
isWorkspaceTrusted: vi.fn(),
};
});
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
const mockLogExtensionInstallEvent = vi.fn();
const mockLogExtensionUninstallEvent = vi.fn();
return {
...actual,
ClearcutLogger: {
getInstance: vi.fn(() => ({
logExtensionInstallEvent: mockLogExtensionInstallEvent,
logExtensionUninstallEvent: mockLogExtensionUninstallEvent,
})),
},
Config: vi.fn(),
ExtensionInstallEvent: vi.fn(),
ExtensionUninstallEvent: vi.fn(),
};
});
vi.mock('child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('child_process')>();
return {
...actual,
execSync: vi.fn(),
};
});
const mockQuestion = vi.hoisted(() => vi.fn());
const mockClose = vi.hoisted(() => vi.fn());
vi.mock('node:readline', () => ({
createInterface: vi.fn(() => ({
question: mockQuestion,
close: mockClose,
})),
}));
describe('useExtensionUpdates', () => {
let tempHomeDir: string;
let userExtensionsDir: string;
beforeEach(() => {
tempHomeDir = fs.mkdtempSync(
path.join(os.tmpdir(), 'gemini-cli-test-home-'),
);
vi.mocked(os.homedir).mockReturnValue(tempHomeDir);
userExtensionsDir = path.join(tempHomeDir, GEMINI_DIR, 'extensions');
fs.mkdirSync(userExtensionsDir, { recursive: true });
Object.values(mockGit).forEach((fn) => fn.mockReset());
});
afterEach(() => {
fs.rmSync(tempHomeDir, { recursive: true, force: true });
});
it('should check for updates and log a message if an update is available', async () => {
const extensions = [
{
name: 'test-extension',
type: 'git',
version: '1.0.0',
path: '/some/path',
isActive: true,
installMetadata: {
type: 'git',
source: 'https://some/repo',
autoUpdate: false,
},
},
];
const addItem = vi.fn();
const cwd = '/test/cwd';
mockGit.getRemotes.mockResolvedValue([
{
name: 'origin',
refs: {
fetch: 'https://github.com/google/gemini-cli.git',
},
},
]);
mockGit.revparse.mockResolvedValue('local-hash');
mockGit.listRemote.mockResolvedValue('remote-hash\tHEAD');
renderHook(() =>
useExtensionUpdates(extensions as GeminiCLIExtension[], addItem, cwd),
);
await waitFor(() => {
expect(addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: 'Extension test-extension has an update available, run "/extensions update test-extension" to install it.',
},
expect.any(Number),
);
});
});
it('should check for updates and automatically update if autoUpdate is true', async () => {
const extensionDir = createExtension({
extensionsDir: userExtensionsDir,
name: 'test-extension',
version: '1.0.0',
installMetadata: {
source: 'https://some.git/repo',
type: 'git',
autoUpdate: true,
},
});
const extension = annotateActiveExtensions(
[loadExtension({ extensionDir, workspaceDir: tempHomeDir })!],
[],
tempHomeDir,
)[0];
const addItem = vi.fn();
mockGit.getRemotes.mockResolvedValue([
{
name: 'origin',
refs: {
fetch: 'https://github.com/google/gemini-cli.git',
},
},
]);
mockGit.revparse.mockResolvedValue('local-hash');
mockGit.listRemote.mockResolvedValue('remote-hash\tHEAD');
mockGit.clone.mockImplementation(async (_, destination) => {
fs.mkdirSync(path.join(mockGit.path(), destination), {
recursive: true,
});
fs.writeFileSync(
path.join(mockGit.path(), destination, EXTENSIONS_CONFIG_FILENAME),
JSON.stringify({ name: 'test-extension', version: '1.1.0' }),
);
});
vi.mocked(isWorkspaceTrusted).mockReturnValue(true);
renderHook(() => useExtensionUpdates([extension], addItem, tempHomeDir));
await waitFor(
() => {
expect(addItem).toHaveBeenCalledWith(
{
type: MessageType.INFO,
text: 'Extension "test-extension" successfully updated: 1.0.0 → 1.1.0.',
},
expect.any(Number),
);
},
{ timeout: 2000 },
);
});
});

View File

@@ -0,0 +1,88 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import type { GeminiCLIExtension } from '@google/gemini-cli-core';
import { getErrorMessage } from '../../utils/errors.js';
import { ExtensionUpdateState } from '../state/extensions.js';
import { useMemo, useState } from 'react';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
import { MessageType } from '../types.js';
import {
checkForAllExtensionUpdates,
updateExtension,
} from '../../config/extensions/update.js';
export const useExtensionUpdates = (
extensions: GeminiCLIExtension[],
addItem: UseHistoryManagerReturn['addItem'],
cwd: string,
) => {
const [extensionsUpdateState, setExtensionsUpdateState] = useState(
new Map<string, ExtensionUpdateState>(),
);
useMemo(() => {
const checkUpdates = async () => {
const updateState = await checkForAllExtensionUpdates(
extensions,
extensionsUpdateState,
setExtensionsUpdateState,
);
for (const extension of extensions) {
const prevState = extensionsUpdateState.get(extension.name);
const currentState = updateState.get(extension.name);
if (
prevState === currentState ||
currentState !== ExtensionUpdateState.UPDATE_AVAILABLE
) {
continue;
}
if (extension.installMetadata?.autoUpdate) {
updateExtension(extension, cwd, currentState, (newState) => {
setExtensionsUpdateState((prev) => {
const finalState = new Map(prev);
finalState.set(extension.name, newState);
return finalState;
});
})
.then((result) => {
if (!result) return;
addItem(
{
type: MessageType.INFO,
text: `Extension "${extension.name}" successfully updated: ${result.originalVersion}${result.updatedVersion}.`,
},
Date.now(),
);
})
.catch((error) => {
console.error(
`Error updating extension "${extension.name}": ${getErrorMessage(error)}.`,
);
});
} else {
addItem(
{
type: MessageType.INFO,
text: `Extension ${extension.name} has an update available, run "/extensions update ${extension.name}" to install it.`,
},
Date.now(),
);
}
}
};
checkUpdates();
}, [
extensions,
extensionsUpdateState,
setExtensionsUpdateState,
addItem,
cwd,
]);
return {
extensionsUpdateState,
setExtensionsUpdateState,
};
};

View File

@@ -12,4 +12,5 @@ export enum ExtensionUpdateState {
UP_TO_DATE = 'up to date',
ERROR = 'error checking for updates',
NOT_UPDATABLE = 'not updatable',
UNKNOWN = 'unknown',
}