fix(core): use GIT_CONFIG_GLOBAL to isolate shadow git repo configuration - Fixes #17877 (#17803)

This commit is contained in:
Coco Sheng
2026-01-29 14:08:34 -05:00
committed by GitHub
parent bc258eba4c
commit bcc6ee596a
3 changed files with 188 additions and 4 deletions

View File

@@ -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 <gemini-cli@google.com>');
expect(logOutput).not.toContain('Global User');
});
});

View File

@@ -283,6 +283,25 @@ describe('GitService', () => {
expect.stringContaining('checkIsRepo failed'), 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', () => { describe('createFileSnapshot', () => {

View File

@@ -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. * Creates a hidden git repository in the project root.
* The Git repository is used to support checkpointing. * 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'; '[user]\n name = Gemini CLI\n email = gemini-cli@google.com\n[commit]\n gpgsign = false\n';
await fs.writeFile(gitConfigPath, gitConfigContent); 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; let isRepoDefined = false;
try { try {
isRepoDefined = await repo.checkIsRepo(CheckRepoActions.IS_REPO_ROOT); isRepoDefined = await repo.checkIsRepo(CheckRepoActions.IS_REPO_ROOT);
@@ -107,9 +119,7 @@ export class GitService {
return simpleGit(this.projectRoot).env({ return simpleGit(this.projectRoot).env({
GIT_DIR: path.join(repoDir, '.git'), GIT_DIR: path.join(repoDir, '.git'),
GIT_WORK_TREE: this.projectRoot, GIT_WORK_TREE: this.projectRoot,
// Prevent git from using the user's global git config. ...this.getShadowRepoEnv(repoDir),
HOME: repoDir,
XDG_CONFIG_HOME: repoDir,
}); });
} }