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:
Jacob MacDonald
2025-09-10 09:35:48 -07:00
committed by GitHub
parent 69b2d77df5
commit ef70c17936
3 changed files with 253 additions and 25 deletions

View File

@@ -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,
});
},
};

View File

@@ -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;

View File

@@ -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;
}