| |
| |
| |
| |
| |
|
|
| import * as fs from 'node:fs/promises'; |
| import * as path from 'node:path'; |
| import { isNodeError } from '../utils/errors.js'; |
| import { spawnAsync } from '../utils/shell-utils.js'; |
| import { |
| simpleGit, |
| CheckRepoActions, |
| type SimpleGit, |
| type SimpleGitOptions, |
| } from 'simple-git'; |
| import type { Storage } from '../config/storage.js'; |
| import { debugLogger } from '../utils/debugLogger.js'; |
| import { |
| sanitizeEnvironment, |
| getSecureSanitizationConfig, |
| } from './environmentSanitization.js'; |
| import { getSafeGitEnv } from '../utils/gitUtils.js'; |
|
|
| export const SHADOW_REPO_AUTHOR_NAME = 'Gemini CLI'; |
| export const SHADOW_REPO_AUTHOR_EMAIL = 'gemini-cli@google.com'; |
|
|
| const SHADOW_REPO_UNSAFE_OPTIONS = { |
| allowUnsafeAlias: true, |
| allowUnsafeAskPass: true, |
| allowUnsafeConfigEnvCount: true, |
| allowUnsafeConfigPaths: true, |
| allowUnsafeCredentialHelper: true, |
| allowUnsafeCustomBinary: true, |
| allowUnsafeDiffExternal: true, |
| allowUnsafeDiffTextConv: true, |
| allowUnsafeEditor: true, |
| allowUnsafeFilter: true, |
| allowUnsafeFsMonitor: true, |
| allowUnsafeGitProxy: true, |
| allowUnsafeGpgProgram: true, |
| allowUnsafeHooksPath: true, |
| allowUnsafeMergeDriver: true, |
| allowUnsafePack: true, |
| allowUnsafePager: true, |
| allowUnsafeProtocolOverride: true, |
| allowUnsafeSshCommand: true, |
| allowUnsafeTemplateDir: true, |
| } satisfies NonNullable<SimpleGitOptions['unsafe']> & |
| Record<`allowUnsafe${string}`, boolean>; |
|
|
| |
| |
| |
| |
| |
| |
| |
| const SHADOW_REPO_GIT_OPTIONS: Partial<SimpleGitOptions> = { |
| unsafe: SHADOW_REPO_UNSAFE_OPTIONS, |
| }; |
|
|
| export class GitService { |
| private projectRoot: string; |
| private storage: Storage; |
|
|
| constructor(projectRoot: string, storage: Storage) { |
| this.projectRoot = path.resolve(projectRoot); |
| this.storage = storage; |
| } |
|
|
| private getHistoryDir(): string { |
| return this.storage.getHistoryDir(); |
| } |
|
|
| async initialize(): Promise<void> { |
| const gitAvailable = await GitService.verifyGitAvailability(); |
| if (!gitAvailable) { |
| throw new Error( |
| 'Checkpointing is enabled, but Git is not installed. Please install Git or disable checkpointing to continue.', |
| ); |
| } |
| await this.storage.initialize(); |
| try { |
| await this.setupShadowGitRepository(); |
| } catch (error) { |
| throw new Error( |
| `Failed to initialize checkpointing: ${error instanceof Error ? error.message : 'Unknown error'}. Please check that Git is working properly or disable checkpointing.`, |
| ); |
| } |
| } |
|
|
| static async verifyGitAvailability(): Promise<boolean> { |
| try { |
| await spawnAsync('git', ['--version'], { env: getSafeGitEnv() }); |
| return true; |
| } catch { |
| return false; |
| } |
| } |
|
|
| private getShadowRepoEnv(repoDir: string) { |
| const gitConfigPath = path.join(repoDir, '.gitconfig'); |
| const systemConfigPath = path.join(repoDir, '.gitconfig_system_empty'); |
| return { |
| ...getSafeGitEnv( |
| sanitizeEnvironment( |
| process.env, |
| getSecureSanitizationConfig({ |
| enableEnvironmentVariableRedaction: true, |
| }), |
| ), |
| ), |
| |
| GIT_CONFIG_GLOBAL: gitConfigPath, |
| GIT_CONFIG_SYSTEM: systemConfigPath, |
| |
| GIT_DIR: undefined, |
| GIT_WORK_TREE: undefined, |
| |
| |
| |
| GIT_AUTHOR_NAME: SHADOW_REPO_AUTHOR_NAME, |
| GIT_AUTHOR_EMAIL: SHADOW_REPO_AUTHOR_EMAIL, |
| GIT_COMMITTER_NAME: SHADOW_REPO_AUTHOR_NAME, |
| GIT_COMMITTER_EMAIL: SHADOW_REPO_AUTHOR_EMAIL, |
| }; |
| } |
|
|
| |
| |
| |
| |
| async setupShadowGitRepository() { |
| const repoDir = this.getHistoryDir(); |
| const gitConfigPath = path.join(repoDir, '.gitconfig'); |
|
|
| await fs.mkdir(repoDir, { recursive: true }); |
|
|
| |
| |
| const gitConfigContent = `[user]\n name = ${SHADOW_REPO_AUTHOR_NAME}\n email = ${SHADOW_REPO_AUTHOR_EMAIL}\n[commit]\n gpgsign = false\n`; |
| await fs.writeFile(gitConfigPath, gitConfigContent); |
|
|
| const shadowRepoEnv = this.getShadowRepoEnv(repoDir); |
| await fs.writeFile(shadowRepoEnv.GIT_CONFIG_SYSTEM, ''); |
| const repo = simpleGit(repoDir, SHADOW_REPO_GIT_OPTIONS).env(shadowRepoEnv); |
| let isRepoDefined = false; |
| try { |
| isRepoDefined = await repo.checkIsRepo(CheckRepoActions.IS_REPO_ROOT); |
| } catch (error) { |
| |
| |
| debugLogger.debug( |
| `checkIsRepo failed, will initialize repository: ${error instanceof Error ? error.message : String(error)}`, |
| ); |
| } |
|
|
| if (!isRepoDefined) { |
| await repo.init(false, { |
| '--initial-branch': 'main', |
| }); |
|
|
| await repo.commit('Initial commit', { '--allow-empty': null }); |
| } |
|
|
| const userGitIgnorePath = path.join(this.projectRoot, '.gitignore'); |
| const shadowGitIgnorePath = path.join(repoDir, '.gitignore'); |
|
|
| let userGitIgnoreContent = ''; |
| try { |
| userGitIgnoreContent = await fs.readFile(userGitIgnorePath, 'utf-8'); |
| } catch (error) { |
| if (isNodeError(error) && error.code !== 'ENOENT') { |
| throw error; |
| } |
| } |
|
|
| await fs.writeFile(shadowGitIgnorePath, userGitIgnoreContent); |
| } |
|
|
| private get shadowGitRepository(): SimpleGit { |
| const repoDir = this.getHistoryDir(); |
| return simpleGit(this.projectRoot, SHADOW_REPO_GIT_OPTIONS).env({ |
| ...this.getShadowRepoEnv(repoDir), |
| GIT_DIR: path.join(repoDir, '.git'), |
| GIT_WORK_TREE: this.projectRoot, |
| }); |
| } |
|
|
| async getCurrentCommitHash(): Promise<string> { |
| const hash = await this.shadowGitRepository.raw('rev-parse', 'HEAD'); |
| return hash.trim(); |
| } |
|
|
| async createFileSnapshot(message: string): Promise<string> { |
| try { |
| const repo = this.shadowGitRepository; |
| await repo.add('.'); |
| const status = await repo.status(); |
| if (status.isClean()) { |
| |
| return await this.getCurrentCommitHash(); |
| } |
| const commitResult = await repo.commit(message, { |
| '--no-verify': null, |
| }); |
| return commitResult.commit; |
| } catch (error) { |
| throw new Error( |
| `Failed to create checkpoint snapshot: ${error instanceof Error ? error.message : 'Unknown error'}. Checkpointing may not be working properly.`, |
| ); |
| } |
| } |
|
|
| async restoreProjectFromSnapshot(commitHash: string): Promise<void> { |
| const repo = this.shadowGitRepository; |
| await repo.raw(['restore', '--source', commitHash, '.']); |
| |
| await repo.clean('f', ['-d']); |
| } |
| } |
|
|