diff --git a/integration-tests/checkpointing.test.ts b/integration-tests/checkpointing.test.ts new file mode 100644 index 0000000000..72277f25da --- /dev/null +++ b/integration-tests/checkpointing.test.ts @@ -0,0 +1,155 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { GitService, Storage } from '@google/gemini-cli-core'; + +describe('Checkpointing Integration', () => { + let tmpDir: string; + let projectRoot: string; + let fakeHome: string; + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'gemini-checkpoint-test-'), + ); + projectRoot = path.join(tmpDir, 'project'); + fakeHome = path.join(tmpDir, 'home'); + + await fs.mkdir(projectRoot, { recursive: true }); + await fs.mkdir(fakeHome, { recursive: true }); + + // Save original env + originalEnv = { ...process.env }; + + // Simulate environment with NO global gitconfig + process.env['HOME'] = fakeHome; + delete process.env['GIT_CONFIG_GLOBAL']; + delete process.env['GIT_CONFIG_SYSTEM']; + }); + + afterEach(async () => { + // Restore env + process.env = originalEnv; + + // Cleanup + try { + await fs.rm(tmpDir, { recursive: true, force: true }); + } catch (e) { + console.error('Failed to cleanup temp dir', e); + } + }); + + it('should successfully create and restore snapshots without global git config', async () => { + const storage = new Storage(projectRoot); + const gitService = new GitService(projectRoot, storage); + + // 1. Initialize + await gitService.initialize(); + + // Verify system config empty file creation + // We need to access getHistoryDir logic or replicate it. + // Since we don't have access to private getHistoryDir, we can infer it or just trust the functional test. + + // 2. Create initial state + await fs.writeFile(path.join(projectRoot, 'file1.txt'), 'version 1'); + await fs.writeFile(path.join(projectRoot, 'file2.txt'), 'permanent file'); + + // 3. Create Snapshot + const snapshotHash = await gitService.createFileSnapshot('Checkpoint 1'); + expect(snapshotHash).toBeDefined(); + + // 4. Modify files + await fs.writeFile( + path.join(projectRoot, 'file1.txt'), + 'version 2 (BAD CHANGE)', + ); + await fs.writeFile( + path.join(projectRoot, 'file3.txt'), + 'new file (SHOULD BE GONE)', + ); + await fs.rm(path.join(projectRoot, 'file2.txt')); + + // 5. Restore + await gitService.restoreProjectFromSnapshot(snapshotHash); + + // 6. Verify state + const file1Content = await fs.readFile( + path.join(projectRoot, 'file1.txt'), + 'utf-8', + ); + expect(file1Content).toBe('version 1'); + + const file2Exists = await fs + .stat(path.join(projectRoot, 'file2.txt')) + .then(() => true) + .catch(() => false); + expect(file2Exists).toBe(true); + const file2Content = await fs.readFile( + path.join(projectRoot, 'file2.txt'), + 'utf-8', + ); + expect(file2Content).toBe('permanent file'); + + const file3Exists = await fs + .stat(path.join(projectRoot, 'file3.txt')) + .then(() => true) + .catch(() => false); + expect(file3Exists).toBe(false); + }); + + it('should ignore user global git config and use isolated identity', async () => { + // 1. Create a fake global gitconfig with a specific user + const globalConfigPath = path.join(fakeHome, '.gitconfig'); + const globalConfigContent = `[user] + name = Global User + email = global@example.com +`; + await fs.writeFile(globalConfigPath, globalConfigContent); + + // Point HOME to fakeHome so git picks up this global config (if we didn't isolate it) + process.env['HOME'] = fakeHome; + // Ensure GIT_CONFIG_GLOBAL is NOT set for the process initially, + // so it would default to HOME/.gitconfig if GitService didn't override it. + delete process.env['GIT_CONFIG_GLOBAL']; + + const storage = new Storage(projectRoot); + const gitService = new GitService(projectRoot, storage); + + await gitService.initialize(); + + // 2. Create a file and snapshot + await fs.writeFile(path.join(projectRoot, 'test.txt'), 'content'); + await gitService.createFileSnapshot('Snapshot with global config present'); + + // 3. Verify the commit author in the shadow repo + const historyDir = storage.getHistoryDir(); + + const { execFileSync } = await import('node:child_process'); + + const logOutput = execFileSync( + 'git', + ['log', '-1', '--pretty=format:%an <%ae>'], + { + cwd: historyDir, + env: { + ...process.env, + GIT_DIR: path.join(historyDir, '.git'), + GIT_CONFIG_GLOBAL: path.join(historyDir, '.gitconfig'), + GIT_CONFIG_SYSTEM: path.join(historyDir, '.gitconfig_system_empty'), + }, + encoding: 'utf-8', + }, + ); + + expect(logOutput).toBe('Gemini CLI '); + expect(logOutput).not.toContain('Global User'); + }); +}); diff --git a/integration-tests/globalSetup.ts b/integration-tests/globalSetup.ts index 18b9d62e87..150fa7316d 100644 --- a/integration-tests/globalSetup.ts +++ b/integration-tests/globalSetup.ts @@ -72,6 +72,13 @@ export async function setup() { } export async function teardown() { + // Disable mouse tracking + if (process.stdout.isTTY) { + process.stdout.write( + '\x1b[?1000l\x1b[?1003l\x1b[?1015l\x1b[?1006l\x1b[?1002l', + ); + } + // Cleanup the test run directory unless KEEP_OUTPUT is set if (process.env['KEEP_OUTPUT'] !== 'true' && runDir) { try { diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 20f022021a..3efa311c3e 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -367,7 +367,7 @@ export async function main() { ) { settings.setValue( SettingScope.User, - 'selectedAuthType', + 'security.auth.selectedType', AuthType.COMPUTE_ADC, ); } diff --git a/packages/cli/src/ui/components/AsciiArt.ts b/packages/cli/src/ui/components/AsciiArt.ts index e1cb5e058b..79eb522c80 100644 --- a/packages/cli/src/ui/components/AsciiArt.ts +++ b/packages/cli/src/ui/components/AsciiArt.ts @@ -36,42 +36,3 @@ export const tinyAsciiLogo = ` ███░ ░░█████████ ░░░ ░░░░░░░░░ `; - -export const shortAsciiLogoIde = ` - ░░░░░░░░░ ░░░░░░░░░░ ░░░░░░ ░░░░░░ ░░░░░ ░░░░░░ ░░░░░ ░░░░░ - ░░░ ░░░ ░░░ ░░░░░░ ░░░░░░ ░░░ ░░░░░░ ░░░░░ ░░░ - ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ - █████████░░██████████ ██████ ░░██████░█████░██████ ░░█████ █████░ - ███░░ ███░███░░ ██████ ░██████░░███░░██████ ░█████ ███░░ - ███░░ ░░███░░ ███░███ ███ ███░░███░░███░███ ███░░ ███░░ - ███░░░░████░██████░░░░░███░░█████ ███░░███░░███░░███ ███░░░ ███░░░ - ███ ███ ███ ███ ███ ███ ███ ███ ██████ ███ - ███ ███ ███ ███ ███ ███ ███ █████ ███ - █████████ ██████████ ███ ███ █████ ███ █████ █████ -`; - -export const longAsciiLogoIde = ` - ░░░ ░░░░░░░░░ ░░░░░░░░░░ ░░░░░░ ░░░░░░ ░░░░░ ░░░░░░ ░░░░░ ░░░░░ - ░░░ ░░░ ░░░ ░░░ ░░░░░░ ░░░░░░ ░░░ ░░░░░░ ░░░░░ ░░░ - ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ ░░░ - ███ ░░░ █████████░░██████████ ██████ ░░██████░█████░██████ ░░█████ █████░ - ███ ░░░ ███░ ███░███░░ ██████ ░██████░░███░░██████ ░█████ ███░░ - ███ ███░░░ ░░███░░ ███░███ ███ ███░░███░░███░███ ███░░ ███░░ - ░░░ ███ ███ ░░░█████░██████░░░░░███░░█████ ███░░███░░███░░███ ███░░░ ███░░░ - ███ ███ ███ ███ ███ ███ ███ ███ ███ ██████ ███ - ███ ███ ███ ███ ███ ███ ███ ███ █████ ███ - ███ █████████ ██████████ ███ ███ █████ ███ █████ █████ -`; - -export const tinyAsciiLogoIde = ` - ░░░ ░░░░░░░░░ - ░░░ ░░░ ░░░ - ░░░ ░░░ - ███ ░░░ █████████░░░ - ███ ░░░ ███░░ ███░░ - ███ ███░░ ░░░ - ░░░ ███ ███░░░░████░ - ███ ███ ███ - ███ ███ ███ - ███ █████████ -`; diff --git a/packages/cli/src/ui/components/Header.test.tsx b/packages/cli/src/ui/components/Header.test.tsx index 5200db17d4..59c04e9938 100644 --- a/packages/cli/src/ui/components/Header.test.tsx +++ b/packages/cli/src/ui/components/Header.test.tsx @@ -8,9 +8,8 @@ import { render } from '../../test-utils/render.js'; import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; import { Header } from './Header.js'; import * as useTerminalSize from '../hooks/useTerminalSize.js'; -import { longAsciiLogo, longAsciiLogoIde } from './AsciiArt.js'; +import { longAsciiLogo } from './AsciiArt.js'; import * as semanticColors from '../semantic-colors.js'; -import * as terminalSetup from '../utils/terminalSetup.js'; import { Text } from 'ink'; import type React from 'react'; @@ -18,9 +17,6 @@ vi.mock('../hooks/useTerminalSize.js'); vi.mock('../hooks/useSnowfall.js', () => ({ useSnowfall: vi.fn((art) => art), })); -vi.mock('../utils/terminalSetup.js', () => ({ - getTerminalProgram: vi.fn(), -})); vi.mock('ink-gradient', () => { const MockGradient = ({ children }: { children: React.ReactNode }) => ( <>{children} @@ -41,7 +37,6 @@ vi.mock('ink', async () => { describe('
', () => { beforeEach(() => { vi.clearAllMocks(); - vi.mocked(terminalSetup.getTerminalProgram).mockReturnValue(null); }); it('renders the long logo on a wide terminal', () => { @@ -58,22 +53,6 @@ describe('
', () => { ); }); - it('uses the IDE logo when running in an IDE', () => { - vi.spyOn(useTerminalSize, 'useTerminalSize').mockReturnValue({ - columns: 120, - rows: 20, - }); - vi.mocked(terminalSetup.getTerminalProgram).mockReturnValue('vscode'); - - render(
); - expect(Text).toHaveBeenCalledWith( - expect.objectContaining({ - children: longAsciiLogoIde, - }), - undefined, - ); - }); - it('renders custom ASCII art when provided', () => { const customArt = 'CUSTOM ART'; render( @@ -87,24 +66,13 @@ describe('
', () => { ); }); - it('renders custom ASCII art as is when running in an IDE', () => { - const customArt = 'CUSTOM ART'; - vi.mocked(terminalSetup.getTerminalProgram).mockReturnValue('vscode'); - render( -
, - ); - expect(Text).toHaveBeenCalledWith( - expect.objectContaining({ - children: customArt, - }), - undefined, - ); - }); - it('displays the version number when nightly is true', () => { render(
); const textCalls = (Text as Mock).mock.calls; - expect(textCalls[1][0].children.join('')).toBe('v1.0.0'); + const versionText = Array.isArray(textCalls[1][0].children) + ? textCalls[1][0].children.join('') + : textCalls[1][0].children; + expect(versionText).toBe('v1.0.0'); }); it('does not display the version number when nightly is false', () => { diff --git a/packages/cli/src/ui/components/Header.tsx b/packages/cli/src/ui/components/Header.tsx index 52fd0175c5..2bf260148e 100644 --- a/packages/cli/src/ui/components/Header.tsx +++ b/packages/cli/src/ui/components/Header.tsx @@ -7,17 +7,9 @@ import type React from 'react'; import { Box } from 'ink'; import { ThemedGradient } from './ThemedGradient.js'; -import { - shortAsciiLogo, - longAsciiLogo, - tinyAsciiLogo, - shortAsciiLogoIde, - longAsciiLogoIde, - tinyAsciiLogoIde, -} from './AsciiArt.js'; +import { shortAsciiLogo, longAsciiLogo, tinyAsciiLogo } from './AsciiArt.js'; import { getAsciiArtWidth } from '../utils/textUtils.js'; import { useTerminalSize } from '../hooks/useTerminalSize.js'; -import { getTerminalProgram } from '../utils/terminalSetup.js'; import { useSnowfall } from '../hooks/useSnowfall.js'; interface HeaderProps { @@ -32,7 +24,6 @@ export const Header: React.FC = ({ nightly, }) => { const { columns: terminalWidth } = useTerminalSize(); - const isIde = getTerminalProgram(); let displayTitle; const widthOfLongLogo = getAsciiArtWidth(longAsciiLogo); const widthOfShortLogo = getAsciiArtWidth(shortAsciiLogo); @@ -40,11 +31,11 @@ export const Header: React.FC = ({ if (customAsciiArt) { displayTitle = customAsciiArt; } else if (terminalWidth >= widthOfLongLogo) { - displayTitle = isIde ? longAsciiLogoIde : longAsciiLogo; + displayTitle = longAsciiLogo; } else if (terminalWidth >= widthOfShortLogo) { - displayTitle = isIde ? shortAsciiLogoIde : shortAsciiLogo; + displayTitle = shortAsciiLogo; } else { - displayTitle = isIde ? tinyAsciiLogoIde : tinyAsciiLogo; + displayTitle = tinyAsciiLogo; } const artWidth = getAsciiArtWidth(displayTitle); diff --git a/packages/core/src/services/gitService.test.ts b/packages/core/src/services/gitService.test.ts index 2990b5553d..3c5d551d1f 100644 --- a/packages/core/src/services/gitService.test.ts +++ b/packages/core/src/services/gitService.test.ts @@ -283,6 +283,25 @@ describe('GitService', () => { expect.stringContaining('checkIsRepo failed'), ); }); + + it('should configure git environment to use local gitconfig', async () => { + hoistedMockCheckIsRepo.mockResolvedValue(false); + const service = new GitService(projectRoot, storage); + await service.setupShadowGitRepository(); + + expect(hoistedMockEnv).toHaveBeenCalledWith( + expect.objectContaining({ + GIT_CONFIG_GLOBAL: gitConfigPath, + GIT_CONFIG_SYSTEM: path.join(repoDir, '.gitconfig_system_empty'), + }), + ); + + const systemConfigContent = await fs.readFile( + path.join(repoDir, '.gitconfig_system_empty'), + 'utf-8', + ); + expect(systemConfigContent).toBe(''); + }); }); describe('createFileSnapshot', () => { diff --git a/packages/core/src/services/gitService.ts b/packages/core/src/services/gitService.ts index a5b36969c3..6418750bbe 100644 --- a/packages/core/src/services/gitService.ts +++ b/packages/core/src/services/gitService.ts @@ -51,6 +51,16 @@ export class GitService { } } + private getShadowRepoEnv(repoDir: string) { + const gitConfigPath = path.join(repoDir, '.gitconfig'); + const systemConfigPath = path.join(repoDir, '.gitconfig_system_empty'); + return { + // Prevent git from using the user's global git config. + GIT_CONFIG_GLOBAL: gitConfigPath, + GIT_CONFIG_SYSTEM: systemConfigPath, + }; + } + /** * Creates a hidden git repository in the project root. * The Git repository is used to support checkpointing. @@ -67,7 +77,9 @@ export class GitService { '[user]\n name = Gemini CLI\n email = gemini-cli@google.com\n[commit]\n gpgsign = false\n'; await fs.writeFile(gitConfigPath, gitConfigContent); - const repo = simpleGit(repoDir); + const shadowRepoEnv = this.getShadowRepoEnv(repoDir); + await fs.writeFile(shadowRepoEnv.GIT_CONFIG_SYSTEM, ''); + const repo = simpleGit(repoDir).env(shadowRepoEnv); let isRepoDefined = false; try { isRepoDefined = await repo.checkIsRepo(CheckRepoActions.IS_REPO_ROOT); @@ -107,9 +119,7 @@ export class GitService { return simpleGit(this.projectRoot).env({ GIT_DIR: path.join(repoDir, '.git'), GIT_WORK_TREE: this.projectRoot, - // Prevent git from using the user's global git config. - HOME: repoDir, - XDG_CONFIG_HOME: repoDir, + ...this.getShadowRepoEnv(repoDir), }); }