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 3296d056bd
commit 970e0f7e64
20 changed files with 1314 additions and 529 deletions
+109 -299
View File
@@ -12,7 +12,6 @@ import {
EXTENSIONS_CONFIG_FILENAME,
INSTALL_METADATA_FILENAME,
annotateActiveExtensions,
checkForAllExtensionUpdates,
disableExtension,
enableExtension,
installExtension,
@@ -20,22 +19,19 @@ import {
loadExtensions,
performWorkspaceExtensionMigration,
uninstallExtension,
updateExtension,
type Extension,
} from './extension.js';
import {
GEMINI_DIR,
type GeminiCLIExtension,
type MCPServerConfig,
ClearcutLogger,
type Config,
ExtensionUninstallEvent,
type ExtensionInstallMetadata,
} from '@google/gemini-cli-core';
import { execSync } from 'node:child_process';
import { SettingScope } from './settings.js';
import { isWorkspaceTrusted } from './trustedFolders.js';
import { ExtensionUpdateState } from '../ui/state/extensions.js';
import { createExtension } from '../test-utils/createExtension.js';
import { ExtensionEnablementManager } from './extensions/extensionEnablement.js';
const mockGit = {
@@ -58,9 +54,9 @@ vi.mock('simple-git', () => ({
}));
vi.mock('os', async (importOriginal) => {
const os = await importOriginal<typeof os>();
const mockedOs = await importOriginal<typeof os>();
return {
...os,
...mockedOs,
homedir: vi.fn(),
};
});
@@ -454,6 +450,86 @@ describe('extension tests', () => {
expect(consoleSpy).toHaveBeenCalledWith('Extension not found: ext4');
consoleSpy.mockRestore();
});
describe('autoUpdate', () => {
it('should be false if autoUpdate is not set in install metadata', () => {
const activeExtensions = annotateActiveExtensions(
extensions,
[],
tempHomeDir,
);
expect(
activeExtensions.every(
(e) => e.installMetadata?.autoUpdate === false,
),
).toBe(false);
});
it('should be true if autoUpdate is true in install metadata', () => {
const extensionsWithAutoUpdate: Extension[] = extensions.map((e) => ({
...e,
installMetadata: {
...e.installMetadata!,
autoUpdate: true,
},
}));
const activeExtensions = annotateActiveExtensions(
extensionsWithAutoUpdate,
[],
tempHomeDir,
);
expect(
activeExtensions.every((e) => e.installMetadata?.autoUpdate === true),
).toBe(true);
});
it('should respect the per-extension settings from install metadata', () => {
const extensionsWithAutoUpdate: Extension[] = [
{
path: '/path/to/ext1',
config: { name: 'ext1', version: '1.0.0' },
contextFiles: [],
installMetadata: {
source: 'test',
type: 'local',
autoUpdate: true,
},
},
{
path: '/path/to/ext2',
config: { name: 'ext2', version: '1.0.0' },
contextFiles: [],
installMetadata: {
source: 'test',
type: 'local',
autoUpdate: false,
},
},
{
path: '/path/to/ext3',
config: { name: 'ext3', version: '1.0.0' },
contextFiles: [],
},
];
const activeExtensions = annotateActiveExtensions(
extensionsWithAutoUpdate,
[],
tempHomeDir,
);
expect(
activeExtensions.find((e) => e.name === 'ext1')?.installMetadata
?.autoUpdate,
).toBe(true);
expect(
activeExtensions.find((e) => e.name === 'ext2')?.installMetadata
?.autoUpdate,
).toBe(false);
expect(
activeExtensions.find((e) => e.name === 'ext3')?.installMetadata
?.autoUpdate,
).toBe(undefined);
});
});
});
describe('installExtension', () => {
@@ -662,6 +738,32 @@ describe('extension tests', () => {
);
});
it('should save the autoUpdate flag to the install metadata', async () => {
const sourceExtDir = createExtension({
extensionsDir: tempHomeDir,
name: 'my-local-extension',
version: '1.0.0',
});
const targetExtDir = path.join(userExtensionsDir, 'my-local-extension');
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
await installExtension({
source: sourceExtDir,
type: 'local',
autoUpdate: true,
});
expect(fs.existsSync(targetExtDir)).toBe(true);
expect(fs.existsSync(metadataPath)).toBe(true);
const metadata = JSON.parse(fs.readFileSync(metadataPath, 'utf-8'));
expect(metadata).toEqual({
source: sourceExtDir,
type: 'local',
autoUpdate: true,
});
fs.rmSync(targetExtDir, { recursive: true, force: true });
});
it('should ignore consent flow if not required', async () => {
const sourceExtDir = createExtension({
extensionsDir: tempHomeDir,
@@ -914,265 +1016,6 @@ describe('extension tests', () => {
});
});
describe('updateExtension', () => {
it('should update a git-installed extension', async () => {
const gitUrl = 'https://github.com/google/gemini-extensions.git';
const extensionName = 'gemini-extensions';
const targetExtDir = path.join(userExtensionsDir, extensionName);
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
fs.mkdirSync(targetExtDir, { recursive: true });
fs.writeFileSync(
path.join(targetExtDir, EXTENSIONS_CONFIG_FILENAME),
JSON.stringify({ name: extensionName, version: '1.0.0' }),
);
fs.writeFileSync(
metadataPath,
JSON.stringify({ source: gitUrl, type: 'git' }),
);
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: extensionName, version: '1.1.0' }),
);
});
mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
const extension = annotateActiveExtensions(
[
loadExtension({
extensionDir: targetExtDir,
workspaceDir: tempWorkspaceDir,
})!,
],
[],
process.cwd(),
)[0];
const updateInfo = await updateExtension(
extension,
tempHomeDir,
() => {},
);
expect(updateInfo).toEqual({
name: 'gemini-extensions',
originalVersion: '1.0.0',
updatedVersion: '1.1.0',
});
const updatedConfig = JSON.parse(
fs.readFileSync(
path.join(targetExtDir, EXTENSIONS_CONFIG_FILENAME),
'utf-8',
),
);
expect(updatedConfig.version).toBe('1.1.0');
});
it('should call setExtensionUpdateState with UPDATING and then UPDATED_NEEDS_RESTART on success', async () => {
const extensionName = 'test-extension';
const extensionDir = createExtension({
extensionsDir: userExtensionsDir,
name: extensionName,
version: '1.0.0',
installMetadata: {
source: 'https://some.git/repo',
type: 'git',
},
});
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: extensionName, version: '1.1.0' }),
);
});
mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
const setExtensionUpdateState = vi.fn();
const extension = annotateActiveExtensions(
[
loadExtension({
extensionDir,
workspaceDir: tempWorkspaceDir,
})!,
],
[],
process.cwd(),
)[0];
await updateExtension(extension, tempHomeDir, setExtensionUpdateState);
expect(setExtensionUpdateState).toHaveBeenCalledWith(
ExtensionUpdateState.UPDATING,
);
expect(setExtensionUpdateState).toHaveBeenCalledWith(
ExtensionUpdateState.UPDATED_NEEDS_RESTART,
);
});
it('should call setExtensionUpdateState with ERROR on failure', async () => {
const extensionName = 'test-extension';
const extensionDir = createExtension({
extensionsDir: userExtensionsDir,
name: extensionName,
version: '1.0.0',
installMetadata: {
source: 'https://some.git/repo',
type: 'git',
},
});
mockGit.clone.mockRejectedValue(new Error('Git clone failed'));
mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
const setExtensionUpdateState = vi.fn();
const extension = annotateActiveExtensions(
[
loadExtension({
extensionDir,
workspaceDir: tempWorkspaceDir,
})!,
],
[],
process.cwd(),
)[0];
await expect(
updateExtension(extension, tempHomeDir, setExtensionUpdateState),
).rejects.toThrow();
expect(setExtensionUpdateState).toHaveBeenCalledWith(
ExtensionUpdateState.UPDATING,
);
expect(setExtensionUpdateState).toHaveBeenCalledWith(
ExtensionUpdateState.ERROR,
);
});
});
describe('checkForAllExtensionUpdates', () => {
it('should return UpdateAvailable for a git extension with updates', async () => {
const extensionDir = createExtension({
extensionsDir: userExtensionsDir,
name: 'test-extension',
version: '1.0.0',
installMetadata: {
source: 'https://some.git/repo',
type: 'git',
},
});
const extension = annotateActiveExtensions(
[
loadExtension({
extensionDir,
workspaceDir: tempWorkspaceDir,
})!,
],
[],
process.cwd(),
)[0];
mockGit.getRemotes.mockResolvedValue([
{ name: 'origin', refs: { fetch: 'https://some.git/repo' } },
]);
mockGit.listRemote.mockResolvedValue('remoteHash HEAD');
mockGit.revparse.mockResolvedValue('localHash');
const results = await checkForAllExtensionUpdates([extension], () => {});
const result = results.get('test-extension');
expect(result).toBe(ExtensionUpdateState.UPDATE_AVAILABLE);
});
it('should return UpToDate for a git extension with no updates', async () => {
const extensionDir = createExtension({
extensionsDir: userExtensionsDir,
name: 'test-extension',
version: '1.0.0',
installMetadata: {
source: 'https://some.git/repo',
type: 'git',
},
});
const extension = annotateActiveExtensions(
[
loadExtension({
extensionDir,
workspaceDir: tempWorkspaceDir,
})!,
],
[],
process.cwd(),
)[0];
mockGit.getRemotes.mockResolvedValue([
{ name: 'origin', refs: { fetch: 'https://some.git/repo' } },
]);
mockGit.listRemote.mockResolvedValue('sameHash HEAD');
mockGit.revparse.mockResolvedValue('sameHash');
const results = await checkForAllExtensionUpdates([extension], () => {});
const result = results.get('test-extension');
expect(result).toBe(ExtensionUpdateState.UP_TO_DATE);
});
it('should return NotUpdatable for a non-git extension', async () => {
const extensionDir = createExtension({
extensionsDir: userExtensionsDir,
name: 'local-extension',
version: '1.0.0',
installMetadata: { source: '/local/path', type: 'local' },
});
const extension = annotateActiveExtensions(
[
loadExtension({
extensionDir,
workspaceDir: tempWorkspaceDir,
})!,
],
[],
process.cwd(),
)[0];
const results = await checkForAllExtensionUpdates([extension], () => {});
const result = results.get('local-extension');
expect(result).toBe(ExtensionUpdateState.NOT_UPDATABLE);
});
it('should return Error when git check fails', async () => {
const extensionDir = createExtension({
extensionsDir: userExtensionsDir,
name: 'error-extension',
version: '1.0.0',
installMetadata: {
source: 'https://some.git/repo',
type: 'git',
},
});
const extension = annotateActiveExtensions(
[
loadExtension({
extensionDir,
workspaceDir: tempWorkspaceDir,
})!,
],
[],
process.cwd(),
)[0];
mockGit.getRemotes.mockRejectedValue(new Error('Git error'));
const results = await checkForAllExtensionUpdates([extension], () => {});
const result = results.get('error-extension');
expect(result).toBe(ExtensionUpdateState.ERROR);
});
});
describe('disableExtension', () => {
it('should disable an extension at the user scope', () => {
disableExtension('my-extension', SettingScope.User);
@@ -1275,39 +1118,6 @@ describe('extension tests', () => {
});
});
function createExtension({
extensionsDir = 'extensions-dir',
name = 'my-extension',
version = '1.0.0',
addContextFile = false,
contextFileName = undefined as string | undefined,
mcpServers = {} as Record<string, MCPServerConfig>,
installMetadata = undefined as ExtensionInstallMetadata | undefined,
} = {}): string {
const extDir = path.join(extensionsDir, name);
fs.mkdirSync(extDir, { recursive: true });
fs.writeFileSync(
path.join(extDir, EXTENSIONS_CONFIG_FILENAME),
JSON.stringify({ name, version, contextFileName, mcpServers }),
);
if (addContextFile) {
fs.writeFileSync(path.join(extDir, 'GEMINI.md'), 'context');
}
if (contextFileName) {
fs.writeFileSync(path.join(extDir, contextFileName), 'context');
}
if (installMetadata) {
fs.writeFileSync(
path.join(extDir, INSTALL_METADATA_FILENAME),
JSON.stringify(installMetadata),
);
}
return extDir;
}
function isEnabled(options: {
name: string;
configDir: string;