|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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<void> { |
|
|
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<boolean> { |
|
|
return new Promise((resolve) => { |
|
|
exec('git --version', (error) => { |
|
|
if (error) { |
|
|
resolve(false); |
|
|
} else { |
|
|
resolve(true); |
|
|
} |
|
|
}); |
|
|
}); |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async setupShadowGitRepository() { |
|
|
const repoDir = this.getHistoryDir(); |
|
|
const gitConfigPath = path.join(repoDir, '.gitconfig'); |
|
|
|
|
|
await fs.mkdir(repoDir, { recursive: true }); |
|
|
|
|
|
|
|
|
|
|
|
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, |
|
|
|
|
|
HOME: repoDir, |
|
|
XDG_CONFIG_HOME: repoDir, |
|
|
}); |
|
|
} |
|
|
|
|
|
async getCurrentCommitHash(): Promise<string> { |
|
|
const hash = await this.shadowGitRepository.raw('rev-parse', 'HEAD'); |
|
|
return hash.trim(); |
|
|
} |
|
|
|
|
|
async createFileSnapshot(message: string): Promise<string> { |
|
|
const repo = this.shadowGitRepository; |
|
|
await repo.add('.'); |
|
|
const commitResult = await repo.commit(message); |
|
|
return commitResult.commit; |
|
|
} |
|
|
|
|
|
async restoreProjectFromSnapshot(commitHash: string): Promise<void> { |
|
|
const repo = this.shadowGitRepository; |
|
|
await repo.raw(['restore', '--source', commitHash, '.']); |
|
|
|
|
|
await repo.clean('f', ['-d']); |
|
|
} |
|
|
} |
|
|
|