diff --git a/packages/cli/src/commands/extensions/install.ts b/packages/cli/src/commands/extensions/install.ts index e33783f2ce..fe43fdbf0a 100644 --- a/packages/cli/src/commands/extensions/install.ts +++ b/packages/cli/src/commands/extensions/install.ts @@ -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, }); }, }; diff --git a/packages/cli/src/config/extension.test.ts b/packages/cli/src/config/extension.test.ts index d647bec4e3..0597d441ec 100644 --- a/packages/cli/src/config/extension.test.ts +++ b/packages/cli/src/config/extension.test.ts @@ -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; - 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; - 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; diff --git a/packages/cli/src/config/extension.ts b/packages/cli/src/config/extension.ts index 7454818512..b03a815621 100644 --- a/packages/cli/src/config/extension.ts +++ b/packages/cli/src/config/extension.ts @@ -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 { 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> { + const results = new Map(); + + 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; +}