/** * @license * Copyright 2025 Google LLC * SPDX-License-Identifier: Apache-2.0 */ import * as fs from 'fs/promises'; import * as path from 'path'; import * as os from 'os'; import { isNodeError } from '../utils/errors.js'; import { isGitRepository } from '../utils/gitUtils.js'; import { exec } from 'node:child_process'; import { simpleGit, SimpleGit, CheckRepoActions } from 'simple-git'; import { getProjectHash, GEMINI_DIR } from '../utils/paths.js'; export class GitService { private projectRoot: string; constructor(projectRoot: string) { this.projectRoot = path.resolve(projectRoot); } private getHistoryDir(): string { const hash = getProjectHash(this.projectRoot); return path.join(os.homedir(), GEMINI_DIR, 'history', hash); } async initialize(): Promise { if (!isGitRepository(this.projectRoot)) { throw new Error('GitService requires a Git repository'); } const gitAvailable = await this.verifyGitAvailability(); if (!gitAvailable) { throw new Error('GitService requires Git to be installed'); } this.setupShadowGitRepository(); } verifyGitAvailability(): Promise { return new Promise((resolve) => { exec('git --version', (error) => { if (error) { resolve(false); } else { resolve(true); } }); }); } /** * Creates a hidden git repository in the project root. * The Git repository is used to support checkpointing. */ async setupShadowGitRepository() { const repoDir = this.getHistoryDir(); const gitConfigPath = path.join(repoDir, '.gitconfig'); await fs.mkdir(repoDir, { recursive: true }); // We don't want to inherit the user's name, email, or gpg signing // preferences for the shadow repository, so we create a dedicated gitconfig. const gitConfigContent = '[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 isRepoDefined = await repo.checkIsRepo(CheckRepoActions.IS_REPO_ROOT); 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).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, }); } async getCurrentCommitHash(): Promise { const hash = await this.shadowGitRepository.raw('rev-parse', 'HEAD'); return hash.trim(); } async createFileSnapshot(message: string): Promise { const repo = this.shadowGitRepository; await repo.add('.'); const commitResult = await repo.commit(message); return commitResult.commit; } async restoreProjectFromSnapshot(commitHash: string): Promise { const repo = this.shadowGitRepository; await repo.raw(['restore', '--source', commitHash, '.']); // Removes any untracked files that were introduced post snapshot. await repo.clean('f', ['-d']); } }