mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-10 22:21:22 -07:00
Add functionality to check for git extension updates, as well as support for installing a specific ref (#8018)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
This commit is contained in:
@@ -15,6 +15,7 @@ import { getErrorMessage } from '../../utils/errors.js';
|
||||
interface InstallArgs {
|
||||
source?: string;
|
||||
path?: string;
|
||||
ref?: string;
|
||||
}
|
||||
|
||||
export async function handleInstall(args: InstallArgs) {
|
||||
@@ -31,6 +32,7 @@ export async function handleInstall(args: InstallArgs) {
|
||||
installMetadata = {
|
||||
source,
|
||||
type: 'git',
|
||||
ref: args.ref,
|
||||
};
|
||||
} else {
|
||||
throw new Error(`The source "${source}" is not a valid URL format.`);
|
||||
@@ -66,7 +68,12 @@ export const installCommand: CommandModule = {
|
||||
describe: 'Path to a local extension directory.',
|
||||
type: 'string',
|
||||
})
|
||||
.option('ref', {
|
||||
describe: 'The git ref to install from.',
|
||||
type: 'string',
|
||||
})
|
||||
.conflicts('source', 'path')
|
||||
.conflicts('path', 'ref')
|
||||
.check((argv) => {
|
||||
if (!argv.source && !argv.path) {
|
||||
throw new Error('Either source or --path must be provided.');
|
||||
@@ -77,6 +84,7 @@ export const installCommand: CommandModule = {
|
||||
await handleInstall({
|
||||
source: argv['source'] as string | undefined,
|
||||
path: argv['path'] as string | undefined,
|
||||
ref: argv['ref'] as string | undefined,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -12,8 +12,10 @@ import {
|
||||
EXTENSIONS_CONFIG_FILENAME,
|
||||
INSTALL_METADATA_FILENAME,
|
||||
annotateActiveExtensions,
|
||||
checkForExtensionUpdates,
|
||||
disableExtension,
|
||||
enableExtension,
|
||||
ExtensionUpdateStatus,
|
||||
installExtension,
|
||||
loadExtension,
|
||||
loadExtensions,
|
||||
@@ -31,11 +33,25 @@ import {
|
||||
} from '@google/gemini-cli-core';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { SettingScope, loadSettings } from './settings.js';
|
||||
import { type SimpleGit, simpleGit } from 'simple-git';
|
||||
import { isWorkspaceTrusted } from './trustedFolders.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(),
|
||||
simpleGit: vi.fn((path: string) => {
|
||||
mockGit.path.mockReturnValue(path);
|
||||
return mockGit;
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('os', async (importOriginal) => {
|
||||
@@ -428,6 +444,7 @@ describe('installExtension', () => {
|
||||
fs.mkdirSync(userExtensionsDir, { recursive: true });
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue(true);
|
||||
vi.mocked(execSync).mockClear();
|
||||
Object.values(mockGit).forEach((fn) => fn.mockReset());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -490,16 +507,14 @@ describe('installExtension', () => {
|
||||
const targetExtDir = path.join(userExtensionsDir, extensionName);
|
||||
const metadataPath = path.join(targetExtDir, INSTALL_METADATA_FILENAME);
|
||||
|
||||
const clone = vi.fn().mockImplementation(async (_, destination) => {
|
||||
fs.mkdirSync(destination, { recursive: true });
|
||||
mockGit.clone.mockImplementation(async (_, destination) => {
|
||||
fs.mkdirSync(path.join(mockGit.path(), destination), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(destination, EXTENSIONS_CONFIG_FILENAME),
|
||||
path.join(mockGit.path(), destination, EXTENSIONS_CONFIG_FILENAME),
|
||||
JSON.stringify({ name: extensionName, version: '1.0.0' }),
|
||||
);
|
||||
});
|
||||
|
||||
const mockedSimpleGit = simpleGit as vi.MockedFunction<typeof simpleGit>;
|
||||
mockedSimpleGit.mockReturnValue({ clone } as unknown as SimpleGit);
|
||||
mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
|
||||
|
||||
await installExtension({ source: gitUrl, type: 'git' });
|
||||
|
||||
@@ -787,6 +802,7 @@ describe('updateExtension', () => {
|
||||
vi.mocked(isWorkspaceTrusted).mockReturnValue(true);
|
||||
|
||||
vi.mocked(execSync).mockClear();
|
||||
Object.values(mockGit).forEach((fn) => fn.mockReset());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -809,20 +825,16 @@ describe('updateExtension', () => {
|
||||
JSON.stringify({ source: gitUrl, type: 'git' }),
|
||||
);
|
||||
|
||||
const clone = vi.fn().mockImplementation(async (_, destination) => {
|
||||
fs.mkdirSync(destination, { recursive: true });
|
||||
mockGit.clone.mockImplementation(async (_, destination) => {
|
||||
fs.mkdirSync(path.join(mockGit.path(), destination), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(destination, EXTENSIONS_CONFIG_FILENAME),
|
||||
path.join(mockGit.path(), destination, EXTENSIONS_CONFIG_FILENAME),
|
||||
JSON.stringify({ name: extensionName, version: '1.1.0' }),
|
||||
);
|
||||
});
|
||||
mockGit.getRemotes.mockResolvedValue([{ name: 'origin' }]);
|
||||
|
||||
const mockedSimpleGit = simpleGit as vi.MockedFunction<typeof simpleGit>;
|
||||
mockedSimpleGit.mockReturnValue({
|
||||
clone,
|
||||
} as unknown as SimpleGit);
|
||||
|
||||
const updateInfo = await updateExtension(loadExtension(targetExtDir));
|
||||
const updateInfo = await updateExtension(loadExtension(targetExtDir)!);
|
||||
|
||||
expect(updateInfo).toEqual({
|
||||
name: 'gemini-extensions',
|
||||
@@ -840,6 +852,105 @@ describe('updateExtension', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkForExtensionUpdates', () => {
|
||||
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 return UpdateAvailable for a git extension with updates', async () => {
|
||||
const extensionDir = createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'test-extension',
|
||||
version: '1.0.0',
|
||||
});
|
||||
const extension = loadExtension(extensionDir)!;
|
||||
extension.installMetadata = {
|
||||
source: 'https://some.git/repo',
|
||||
type: 'git',
|
||||
};
|
||||
|
||||
mockGit.getRemotes.mockResolvedValue([
|
||||
{ name: 'origin', refs: { fetch: 'https://some.git/repo' } },
|
||||
]);
|
||||
mockGit.listRemote.mockResolvedValue('remoteHash HEAD');
|
||||
mockGit.revparse.mockResolvedValue('localHash');
|
||||
|
||||
const results = await checkForExtensionUpdates([extension]);
|
||||
const result = results.get('test-extension');
|
||||
expect(result?.status).toBe(ExtensionUpdateStatus.UpdateAvailable);
|
||||
});
|
||||
|
||||
it('should return UpToDate for a git extension with no updates', async () => {
|
||||
const extensionDir = createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'test-extension',
|
||||
version: '1.0.0',
|
||||
});
|
||||
const extension = loadExtension(extensionDir)!;
|
||||
extension.installMetadata = {
|
||||
source: 'https://some.git/repo',
|
||||
type: 'git',
|
||||
};
|
||||
|
||||
mockGit.getRemotes.mockResolvedValue([
|
||||
{ name: 'origin', refs: { fetch: 'https://some.git/repo' } },
|
||||
]);
|
||||
mockGit.listRemote.mockResolvedValue('sameHash HEAD');
|
||||
mockGit.revparse.mockResolvedValue('sameHash');
|
||||
|
||||
const results = await checkForExtensionUpdates([extension]);
|
||||
const result = results.get('test-extension');
|
||||
expect(result?.status).toBe(ExtensionUpdateStatus.UpToDate);
|
||||
});
|
||||
|
||||
it('should return NotUpdatable for a non-git extension', async () => {
|
||||
const extensionDir = createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'local-extension',
|
||||
version: '1.0.0',
|
||||
});
|
||||
const extension = loadExtension(extensionDir)!;
|
||||
extension.installMetadata = { source: '/local/path', type: 'local' };
|
||||
|
||||
const results = await checkForExtensionUpdates([extension]);
|
||||
const result = results.get('local-extension');
|
||||
expect(result?.status).toBe(ExtensionUpdateStatus.NotUpdatable);
|
||||
});
|
||||
|
||||
it('should return Error when git check fails', async () => {
|
||||
const extensionDir = createExtension({
|
||||
extensionsDir: userExtensionsDir,
|
||||
name: 'error-extension',
|
||||
version: '1.0.0',
|
||||
});
|
||||
const extension = loadExtension(extensionDir)!;
|
||||
extension.installMetadata = {
|
||||
source: 'https://some.git/repo',
|
||||
type: 'git',
|
||||
};
|
||||
|
||||
mockGit.getRemotes.mockRejectedValue(new Error('Git error'));
|
||||
|
||||
const results = await checkForExtensionUpdates([extension]);
|
||||
const result = results.get('error-extension');
|
||||
expect(result?.status).toBe(ExtensionUpdateStatus.Error);
|
||||
expect(result?.error).toContain('Failed to check for updates');
|
||||
});
|
||||
});
|
||||
|
||||
describe('disableExtension', () => {
|
||||
let tempWorkspaceDir: string;
|
||||
let tempHomeDir: string;
|
||||
|
||||
@@ -49,6 +49,7 @@ export interface ExtensionConfig {
|
||||
export interface ExtensionInstallMetadata {
|
||||
source: string;
|
||||
type: 'git' | 'local' | 'link';
|
||||
ref?: string;
|
||||
}
|
||||
|
||||
export interface ExtensionUpdateInfo {
|
||||
@@ -336,20 +337,38 @@ export function annotateActiveExtensions(
|
||||
|
||||
/**
|
||||
* Clones a Git repository to a specified local path.
|
||||
* @param gitUrl The Git URL to clone.
|
||||
* @param installMetadata The metadata for the extension to install.
|
||||
* @param destination The destination path to clone the repository to.
|
||||
*/
|
||||
async function cloneFromGit(
|
||||
gitUrl: string,
|
||||
installMetadata: ExtensionInstallMetadata,
|
||||
destination: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
// TODO(chrstnb): Download the archive instead to avoid unnecessary .git info.
|
||||
await simpleGit().clone(gitUrl, destination, ['--depth', '1']);
|
||||
const git = simpleGit(destination);
|
||||
await git.clone(installMetadata.source, './', ['--depth', '1']);
|
||||
|
||||
const remotes = await git.getRemotes(true);
|
||||
if (remotes.length === 0) {
|
||||
throw new Error(
|
||||
`Unable to find any remotes for repo ${installMetadata.source}`,
|
||||
);
|
||||
}
|
||||
|
||||
const refToFetch = installMetadata.ref || 'HEAD';
|
||||
|
||||
await git.fetch(remotes[0].name, refToFetch);
|
||||
|
||||
// After fetching, checkout FETCH_HEAD to get the content of the fetched ref.
|
||||
// This results in a detached HEAD state, which is fine for this purpose.
|
||||
await git.checkout('FETCH_HEAD');
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to clone Git repository from ${gitUrl}`, {
|
||||
cause: error,
|
||||
});
|
||||
throw new Error(
|
||||
`Failed to clone Git repository from ${installMetadata.source}`,
|
||||
{
|
||||
cause: error,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,7 +409,7 @@ export async function installExtension(
|
||||
|
||||
if (installMetadata.type === 'git') {
|
||||
tempDir = await ExtensionStorage.createTmpDir();
|
||||
await cloneFromGit(installMetadata.source, tempDir);
|
||||
await cloneFromGit(installMetadata, tempDir);
|
||||
localSourcePath = tempDir;
|
||||
} else if (
|
||||
installMetadata.type === 'local' ||
|
||||
@@ -522,6 +541,9 @@ export function toOutputString(extension: Extension): string {
|
||||
output += `\n Path: ${extension.path}`;
|
||||
if (extension.installMetadata) {
|
||||
output += `\n Source: ${extension.installMetadata.source} (Type: ${extension.installMetadata.type})`;
|
||||
if (extension.installMetadata.ref) {
|
||||
output += `\n Ref: ${extension.installMetadata.ref}`;
|
||||
}
|
||||
}
|
||||
if (extension.contextFiles.length > 0) {
|
||||
output += `\n Context files:`;
|
||||
@@ -661,3 +683,90 @@ export async function updateAllUpdatableExtensions(
|
||||
extensions.map((extension) => updateExtension(extension, cwd)),
|
||||
);
|
||||
}
|
||||
|
||||
export enum ExtensionUpdateStatus {
|
||||
UpdateAvailable,
|
||||
UpToDate,
|
||||
Error,
|
||||
NotUpdatable,
|
||||
}
|
||||
|
||||
export interface ExtensionUpdateCheckResult {
|
||||
status: ExtensionUpdateStatus;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function checkForExtensionUpdates(
|
||||
extensions: Extension[],
|
||||
): Promise<Map<string, ExtensionUpdateCheckResult>> {
|
||||
const results = new Map<string, ExtensionUpdateCheckResult>();
|
||||
|
||||
for (const extension of extensions) {
|
||||
if (extension.installMetadata?.type !== 'git') {
|
||||
results.set(extension.config.name, {
|
||||
status: ExtensionUpdateStatus.NotUpdatable,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const git = simpleGit(extension.path);
|
||||
const remotes = await git.getRemotes(true);
|
||||
if (remotes.length === 0) {
|
||||
results.set(extension.config.name, {
|
||||
status: ExtensionUpdateStatus.Error,
|
||||
error: 'No git remotes found.',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const remoteUrl = remotes[0].refs.fetch;
|
||||
if (!remoteUrl) {
|
||||
results.set(extension.config.name, {
|
||||
status: ExtensionUpdateStatus.Error,
|
||||
error: `No fetch URL found for git remote ${remotes[0].name}.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Determine the ref to check on the remote.
|
||||
const refToCheck = extension.installMetadata.ref || 'HEAD';
|
||||
|
||||
const lsRemoteOutput = await git.listRemote([remoteUrl, refToCheck]);
|
||||
|
||||
if (typeof lsRemoteOutput !== 'string' || lsRemoteOutput.trim() === '') {
|
||||
results.set(extension.config.name, {
|
||||
status: ExtensionUpdateStatus.Error,
|
||||
error: `Git ref ${refToCheck} not found.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const remoteHash = lsRemoteOutput.split('\t')[0];
|
||||
const localHash = await git.revparse(['HEAD']);
|
||||
|
||||
if (!remoteHash) {
|
||||
results.set(extension.config.name, {
|
||||
status: ExtensionUpdateStatus.Error,
|
||||
error: `Unable to parse hash from git ls-remote output "${lsRemoteOutput}"`,
|
||||
});
|
||||
} else if (remoteHash === localHash) {
|
||||
results.set(extension.config.name, {
|
||||
status: ExtensionUpdateStatus.UpToDate,
|
||||
});
|
||||
} else {
|
||||
results.set(extension.config.name, {
|
||||
status: ExtensionUpdateStatus.UpdateAvailable,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
results.set(extension.config.name, {
|
||||
status: ExtensionUpdateStatus.Error,
|
||||
error: `Failed to check for updates for extension "${
|
||||
extension.config.name
|
||||
}": ${getErrorMessage(error)}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user