diff options
Diffstat (limited to 'packages/cli/src/utils')
| -rw-r--r-- | packages/cli/src/utils/gitUtils.test.ts | 115 | ||||
| -rw-r--r-- | packages/cli/src/utils/gitUtils.ts | 77 |
2 files changed, 187 insertions, 5 deletions
diff --git a/packages/cli/src/utils/gitUtils.test.ts b/packages/cli/src/utils/gitUtils.test.ts new file mode 100644 index 00000000..4a29f589 --- /dev/null +++ b/packages/cli/src/utils/gitUtils.test.ts @@ -0,0 +1,115 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, expect, it, afterEach, beforeEach } from 'vitest'; +import * as child_process from 'child_process'; +import { + isGitHubRepository, + getGitRepoRoot, + getLatestGitHubRelease, +} from './gitUtils.js'; + +vi.mock('child_process'); + +describe('isGitHubRepository', async () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns false if the git command fails', async () => { + vi.mocked(child_process.execSync).mockImplementation((): string => { + throw new Error('oops'); + }); + expect(isGitHubRepository()).toBe(false); + }); + + it('returns false if the remote is not github.com', async () => { + vi.mocked(child_process.execSync).mockReturnValueOnce('https://gitlab.com'); + expect(isGitHubRepository()).toBe(false); + }); + + it('returns true if the remote is github.com', async () => { + vi.mocked(child_process.execSync).mockReturnValueOnce(` + origin https://github.com/sethvargo/gemini-cli (fetch) + origin https://github.com/sethvargo/gemini-cli (push) + `); + expect(isGitHubRepository()).toBe(true); + }); +}); + +describe('getGitRepoRoot', async () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('throws an error if git root cannot be determined', async () => { + vi.mocked(child_process.execSync).mockImplementation((): string => { + throw new Error('oops'); + }); + expect(() => { + getGitRepoRoot(); + }).toThrowError(/oops/); + }); + + it('throws an error if git root is empty', async () => { + vi.mocked(child_process.execSync).mockReturnValueOnce(''); + expect(() => { + getGitRepoRoot(); + }).toThrowError(/Git repo returned empty value/); + }); + + it('returns the root', async () => { + vi.mocked(child_process.execSync).mockReturnValueOnce('/path/to/git/repo'); + expect(getGitRepoRoot()).toBe('/path/to/git/repo'); + }); +}); + +describe('getLatestRelease', async () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('throws an error if the fetch fails', async () => { + global.fetch = vi.fn(() => Promise.reject('nope')); + expect(getLatestGitHubRelease()).rejects.toThrowError( + /Unable to determine the latest/, + ); + }); + + it('throws an error if the fetch does not return a json body', async () => { + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ foo: 'bar' }), + } as Response), + ); + expect(getLatestGitHubRelease()).rejects.toThrowError( + /Unable to determine the latest/, + ); + }); + + it('returns the release version', async () => { + global.fetch = vi.fn(() => + Promise.resolve({ + ok: true, + json: () => Promise.resolve({ tag_name: 'v1.2.3' }), + } as Response), + ); + expect(getLatestGitHubRelease()).resolves.toBe('v1.2.3'); + }); +}); diff --git a/packages/cli/src/utils/gitUtils.ts b/packages/cli/src/utils/gitUtils.ts index d510008c..30ca2245 100644 --- a/packages/cli/src/utils/gitUtils.ts +++ b/packages/cli/src/utils/gitUtils.ts @@ -5,22 +5,89 @@ */ import { execSync } from 'child_process'; +import { ProxyAgent, setGlobalDispatcher } from 'undici'; /** * Checks if a directory is within a git repository hosted on GitHub. * @returns true if the directory is in a git repository with a github.com remote, false otherwise */ -export function isGitHubRepository(): boolean { +export const isGitHubRepository = (): boolean => { try { - const remotes = execSync('git remote -v', { - encoding: 'utf-8', - }); + const remotes = ( + execSync('git remote -v', { + encoding: 'utf-8', + }) || '' + ).trim(); const pattern = /github\.com/; return pattern.test(remotes); } catch (_error) { // If any filesystem error occurs, assume not a git repo + console.debug(`Failed to get git remote:`, _error); return false; } -} +}; + +/** + * getGitRepoRoot returns the root directory of the git repository. + * @returns the path to the root of the git repo. + * @throws error if the exec command fails. + */ +export const getGitRepoRoot = (): string => { + const gitRepoRoot = ( + execSync('git rev-parse --show-toplevel', { + encoding: 'utf-8', + }) || '' + ).trim(); + + if (!gitRepoRoot) { + throw new Error(`Git repo returned empty value`); + } + + return gitRepoRoot; +}; + +/** + * getLatestGitHubRelease returns the release tag as a string. + * @returns string of the release tag (e.g. "v1.2.3"). + */ +export const getLatestGitHubRelease = async ( + proxy?: string, +): Promise<string> => { + try { + const controller = new AbortController(); + if (proxy) { + setGlobalDispatcher(new ProxyAgent(proxy)); + } + + const endpoint = `https://api.github.com/repos/google-github-actions/run-gemini-cli/releases/latest`; + + const response = await fetch(endpoint, { + method: 'GET', + headers: { + Accept: 'application/vnd.github+json', + 'Content-Type': 'application/json', + 'X-GitHub-Api-Version': '2022-11-28', + }, + signal: controller.signal, + }); + + if (!response.ok) { + throw new Error( + `Invalid response code: ${response.status} - ${response.statusText}`, + ); + } + + const releaseTag = (await response.json()).tag_name; + if (!releaseTag) { + throw new Error(`Response did not include tag_name field`); + } + return releaseTag; + } catch (_error) { + console.debug(`Failed to determine latest run-gemini-cli release:`, _error); + throw new Error( + `Unable to determine the latest run-gemini-cli release on GitHub.`, + ); + } +}; |
