Move the shadow git repository to the user's home dir (#1013)
This commit is contained in:
parent
5d4f4f421c
commit
dd679a6cdb
|
@ -5,7 +5,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
import { GitService, historyDirName } from './gitService.js';
|
import { GitService } from './gitService.js';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import type * as FsPromisesModule from 'fs/promises';
|
import type * as FsPromisesModule from 'fs/promises';
|
||||||
import type { ChildProcess } from 'node:child_process';
|
import type { ChildProcess } from 'node:child_process';
|
||||||
|
@ -29,6 +29,7 @@ vi.mock('fs/promises', async (importOriginal) => {
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const hoistedMockEnv = vi.hoisted(() => vi.fn());
|
||||||
const hoistedMockSimpleGit = vi.hoisted(() => vi.fn());
|
const hoistedMockSimpleGit = vi.hoisted(() => vi.fn());
|
||||||
const hoistedMockCheckIsRepo = vi.hoisted(() => vi.fn());
|
const hoistedMockCheckIsRepo = vi.hoisted(() => vi.fn());
|
||||||
const hoistedMockInit = vi.hoisted(() => vi.fn());
|
const hoistedMockInit = vi.hoisted(() => vi.fn());
|
||||||
|
@ -42,6 +43,7 @@ vi.mock('simple-git', () => ({
|
||||||
raw: hoistedMockRaw,
|
raw: hoistedMockRaw,
|
||||||
add: hoistedMockAdd,
|
add: hoistedMockAdd,
|
||||||
commit: hoistedMockCommit,
|
commit: hoistedMockCommit,
|
||||||
|
env: hoistedMockEnv,
|
||||||
})),
|
})),
|
||||||
CheckRepoActions: { IS_REPO_ROOT: 'is-repo-root' },
|
CheckRepoActions: { IS_REPO_ROOT: 'is-repo-root' },
|
||||||
}));
|
}));
|
||||||
|
@ -56,8 +58,31 @@ vi.mock('../utils/errors.js', () => ({
|
||||||
isNodeError: hoistedMockIsNodeError,
|
isNodeError: hoistedMockIsNodeError,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const hoistedMockHomedir = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock('os', () => ({
|
||||||
|
homedir: hoistedMockHomedir,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const hoistedMockCreateHash = vi.hoisted(() => {
|
||||||
|
const mockUpdate = vi.fn().mockReturnThis();
|
||||||
|
const mockDigest = vi.fn();
|
||||||
|
return {
|
||||||
|
createHash: vi.fn(() => ({
|
||||||
|
update: mockUpdate,
|
||||||
|
digest: mockDigest,
|
||||||
|
})),
|
||||||
|
mockUpdate,
|
||||||
|
mockDigest,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
vi.mock('crypto', () => ({
|
||||||
|
createHash: hoistedMockCreateHash.createHash,
|
||||||
|
}));
|
||||||
|
|
||||||
describe('GitService', () => {
|
describe('GitService', () => {
|
||||||
const mockProjectRoot = '/test/project';
|
const mockProjectRoot = '/test/project';
|
||||||
|
const mockHomedir = '/mock/home';
|
||||||
|
const mockHash = 'mock-hash';
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
@ -74,13 +99,24 @@ describe('GitService', () => {
|
||||||
hoistedMockReadFile.mockResolvedValue('');
|
hoistedMockReadFile.mockResolvedValue('');
|
||||||
hoistedMockWriteFile.mockResolvedValue(undefined);
|
hoistedMockWriteFile.mockResolvedValue(undefined);
|
||||||
hoistedMockIsNodeError.mockImplementation((e) => e instanceof Error);
|
hoistedMockIsNodeError.mockImplementation((e) => e instanceof Error);
|
||||||
|
hoistedMockHomedir.mockReturnValue(mockHomedir);
|
||||||
|
hoistedMockCreateHash.mockUpdate.mockReturnThis();
|
||||||
|
hoistedMockCreateHash.mockDigest.mockReturnValue(mockHash);
|
||||||
|
|
||||||
|
hoistedMockEnv.mockImplementation(() => ({
|
||||||
|
checkIsRepo: hoistedMockCheckIsRepo,
|
||||||
|
init: hoistedMockInit,
|
||||||
|
raw: hoistedMockRaw,
|
||||||
|
add: hoistedMockAdd,
|
||||||
|
commit: hoistedMockCommit,
|
||||||
|
}));
|
||||||
hoistedMockSimpleGit.mockImplementation(() => ({
|
hoistedMockSimpleGit.mockImplementation(() => ({
|
||||||
checkIsRepo: hoistedMockCheckIsRepo,
|
checkIsRepo: hoistedMockCheckIsRepo,
|
||||||
init: hoistedMockInit,
|
init: hoistedMockInit,
|
||||||
raw: hoistedMockRaw,
|
raw: hoistedMockRaw,
|
||||||
add: hoistedMockAdd,
|
add: hoistedMockAdd,
|
||||||
commit: hoistedMockCommit,
|
commit: hoistedMockCommit,
|
||||||
|
env: hoistedMockEnv,
|
||||||
}));
|
}));
|
||||||
hoistedMockCheckIsRepo.mockResolvedValue(false);
|
hoistedMockCheckIsRepo.mockResolvedValue(false);
|
||||||
hoistedMockInit.mockResolvedValue(undefined);
|
hoistedMockInit.mockResolvedValue(undefined);
|
||||||
|
@ -136,27 +172,26 @@ describe('GitService', () => {
|
||||||
'GitService requires Git to be installed',
|
'GitService requires Git to be installed',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should call setupShadowGitRepository if Git is available', async () => {
|
||||||
|
const service = new GitService(mockProjectRoot);
|
||||||
|
const setupSpy = vi
|
||||||
|
.spyOn(service, 'setupShadowGitRepository')
|
||||||
|
.mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await service.initialize();
|
||||||
|
expect(setupSpy).toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call setupHiddenGitRepository if Git is available', async () => {
|
describe('setupShadowGitRepository', () => {
|
||||||
const service = new GitService(mockProjectRoot);
|
const repoDir = path.join(mockHomedir, '.gemini', 'history', mockHash);
|
||||||
const setupSpy = vi
|
|
||||||
.spyOn(service, 'setupHiddenGitRepository')
|
|
||||||
.mockResolvedValue(undefined);
|
|
||||||
|
|
||||||
await service.initialize();
|
|
||||||
expect(setupSpy).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('setupHiddenGitRepository', () => {
|
|
||||||
const historyDir = path.join(mockProjectRoot, historyDirName);
|
|
||||||
const repoDir = path.join(historyDir, 'repository');
|
|
||||||
const hiddenGitIgnorePath = path.join(repoDir, '.gitignore');
|
const hiddenGitIgnorePath = path.join(repoDir, '.gitignore');
|
||||||
const visibleGitIgnorePath = path.join(mockProjectRoot, '.gitignore');
|
const visibleGitIgnorePath = path.join(mockProjectRoot, '.gitignore');
|
||||||
|
|
||||||
it('should create history and repository directories', async () => {
|
it('should create history and repository directories', async () => {
|
||||||
const service = new GitService(mockProjectRoot);
|
const service = new GitService(mockProjectRoot);
|
||||||
await service.setupHiddenGitRepository();
|
await service.setupShadowGitRepository();
|
||||||
expect(hoistedMockMkdir).toHaveBeenCalledWith(repoDir, {
|
expect(hoistedMockMkdir).toHaveBeenCalledWith(repoDir, {
|
||||||
recursive: true,
|
recursive: true,
|
||||||
});
|
});
|
||||||
|
@ -165,7 +200,7 @@ describe('GitService', () => {
|
||||||
it('should initialize git repo in historyDir if not already initialized', async () => {
|
it('should initialize git repo in historyDir if not already initialized', async () => {
|
||||||
hoistedMockCheckIsRepo.mockResolvedValue(false);
|
hoistedMockCheckIsRepo.mockResolvedValue(false);
|
||||||
const service = new GitService(mockProjectRoot);
|
const service = new GitService(mockProjectRoot);
|
||||||
await service.setupHiddenGitRepository();
|
await service.setupShadowGitRepository();
|
||||||
expect(hoistedMockSimpleGit).toHaveBeenCalledWith(repoDir);
|
expect(hoistedMockSimpleGit).toHaveBeenCalledWith(repoDir);
|
||||||
expect(hoistedMockInit).toHaveBeenCalled();
|
expect(hoistedMockInit).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
@ -173,7 +208,7 @@ describe('GitService', () => {
|
||||||
it('should not initialize git repo if already initialized', async () => {
|
it('should not initialize git repo if already initialized', async () => {
|
||||||
hoistedMockCheckIsRepo.mockResolvedValue(true);
|
hoistedMockCheckIsRepo.mockResolvedValue(true);
|
||||||
const service = new GitService(mockProjectRoot);
|
const service = new GitService(mockProjectRoot);
|
||||||
await service.setupHiddenGitRepository();
|
await service.setupShadowGitRepository();
|
||||||
expect(hoistedMockInit).not.toHaveBeenCalled();
|
expect(hoistedMockInit).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -186,7 +221,7 @@ describe('GitService', () => {
|
||||||
return '';
|
return '';
|
||||||
});
|
});
|
||||||
const service = new GitService(mockProjectRoot);
|
const service = new GitService(mockProjectRoot);
|
||||||
await service.setupHiddenGitRepository();
|
await service.setupShadowGitRepository();
|
||||||
expect(hoistedMockReadFile).toHaveBeenCalledWith(
|
expect(hoistedMockReadFile).toHaveBeenCalledWith(
|
||||||
visibleGitIgnorePath,
|
visibleGitIgnorePath,
|
||||||
'utf-8',
|
'utf-8',
|
||||||
|
@ -206,48 +241,28 @@ describe('GitService', () => {
|
||||||
return '';
|
return '';
|
||||||
});
|
});
|
||||||
hoistedMockIsNodeError.mockImplementation(
|
hoistedMockIsNodeError.mockImplementation(
|
||||||
(e: unknown): e is NodeJS.ErrnoException =>
|
(e: unknown): e is NodeJS.ErrnoException => e instanceof Error,
|
||||||
e === readError &&
|
|
||||||
e instanceof Error &&
|
|
||||||
(e as NodeJS.ErrnoException).code !== 'ENOENT',
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const service = new GitService(mockProjectRoot);
|
const service = new GitService(mockProjectRoot);
|
||||||
await expect(service.setupHiddenGitRepository()).rejects.toThrow(
|
await expect(service.setupShadowGitRepository()).rejects.toThrow(
|
||||||
'Read permission denied',
|
'Read permission denied',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should add historyDirName to projectRoot .gitignore if not present', async () => {
|
|
||||||
const initialGitignoreContent = 'node_modules/';
|
|
||||||
hoistedMockReadFile.mockImplementation(async (filePath) => {
|
|
||||||
if (filePath === visibleGitIgnorePath) {
|
|
||||||
return initialGitignoreContent;
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
});
|
|
||||||
const service = new GitService(mockProjectRoot);
|
|
||||||
await service.setupHiddenGitRepository();
|
|
||||||
const expectedContent = `${initialGitignoreContent}\n# Gemini CLI history directory\n${historyDirName}\n`;
|
|
||||||
expect(hoistedMockWriteFile).toHaveBeenCalledWith(
|
|
||||||
visibleGitIgnorePath,
|
|
||||||
expectedContent,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should make an initial commit if no commits exist in history repo', async () => {
|
it('should make an initial commit if no commits exist in history repo', async () => {
|
||||||
hoistedMockRaw.mockResolvedValue('');
|
hoistedMockCheckIsRepo.mockResolvedValue(false);
|
||||||
const service = new GitService(mockProjectRoot);
|
const service = new GitService(mockProjectRoot);
|
||||||
await service.setupHiddenGitRepository();
|
await service.setupShadowGitRepository();
|
||||||
expect(hoistedMockAdd).toHaveBeenCalledWith(hiddenGitIgnorePath);
|
expect(hoistedMockCommit).toHaveBeenCalledWith('Initial commit', {
|
||||||
expect(hoistedMockCommit).toHaveBeenCalledWith('Initial commit');
|
'--allow-empty': null,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not make an initial commit if commits already exist', async () => {
|
it('should not make an initial commit if commits already exist', async () => {
|
||||||
hoistedMockRaw.mockResolvedValue('test-commit');
|
hoistedMockCheckIsRepo.mockResolvedValue(true);
|
||||||
const service = new GitService(mockProjectRoot);
|
const service = new GitService(mockProjectRoot);
|
||||||
await service.setupHiddenGitRepository();
|
await service.setupShadowGitRepository();
|
||||||
expect(hoistedMockAdd).not.toHaveBeenCalled();
|
|
||||||
expect(hoistedMockCommit).not.toHaveBeenCalled();
|
expect(hoistedMockCommit).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -6,13 +6,13 @@
|
||||||
|
|
||||||
import * as fs from 'fs/promises';
|
import * as fs from 'fs/promises';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
import * as os from 'os';
|
||||||
|
import * as crypto from 'crypto';
|
||||||
import { isNodeError } from '../utils/errors.js';
|
import { isNodeError } from '../utils/errors.js';
|
||||||
import { isGitRepository } from '../utils/gitUtils.js';
|
import { isGitRepository } from '../utils/gitUtils.js';
|
||||||
import { exec } from 'node:child_process';
|
import { exec } from 'node:child_process';
|
||||||
import { simpleGit, SimpleGit, CheckRepoActions } from 'simple-git';
|
import { simpleGit, SimpleGit, CheckRepoActions } from 'simple-git';
|
||||||
|
|
||||||
export const historyDirName = '.gemini_cli_history';
|
|
||||||
|
|
||||||
export class GitService {
|
export class GitService {
|
||||||
private projectRoot: string;
|
private projectRoot: string;
|
||||||
|
|
||||||
|
@ -20,6 +20,14 @@ export class GitService {
|
||||||
this.projectRoot = path.resolve(projectRoot);
|
this.projectRoot = path.resolve(projectRoot);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getHistoryDir(): string {
|
||||||
|
const hash = crypto
|
||||||
|
.createHash('sha256')
|
||||||
|
.update(this.projectRoot)
|
||||||
|
.digest('hex');
|
||||||
|
return path.join(os.homedir(), '.gemini', 'history', hash);
|
||||||
|
}
|
||||||
|
|
||||||
async initialize(): Promise<void> {
|
async initialize(): Promise<void> {
|
||||||
if (!isGitRepository(this.projectRoot)) {
|
if (!isGitRepository(this.projectRoot)) {
|
||||||
throw new Error('GitService requires a Git repository');
|
throw new Error('GitService requires a Git repository');
|
||||||
|
@ -28,7 +36,7 @@ export class GitService {
|
||||||
if (!gitAvailable) {
|
if (!gitAvailable) {
|
||||||
throw new Error('GitService requires Git to be installed');
|
throw new Error('GitService requires Git to be installed');
|
||||||
}
|
}
|
||||||
this.setupHiddenGitRepository();
|
this.setupShadowGitRepository();
|
||||||
}
|
}
|
||||||
|
|
||||||
verifyGitAvailability(): Promise<boolean> {
|
verifyGitAvailability(): Promise<boolean> {
|
||||||
|
@ -47,66 +55,40 @@ export class GitService {
|
||||||
* 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.
|
||||||
*/
|
*/
|
||||||
async setupHiddenGitRepository() {
|
async setupShadowGitRepository() {
|
||||||
const historyDir = path.join(this.projectRoot, historyDirName);
|
const repoDir = this.getHistoryDir();
|
||||||
const repoDir = path.join(historyDir, 'repository');
|
|
||||||
|
|
||||||
await fs.mkdir(repoDir, { recursive: true });
|
await fs.mkdir(repoDir, { recursive: true });
|
||||||
const repoInstance: SimpleGit = simpleGit(repoDir);
|
const isRepoDefined = await simpleGit(repoDir).checkIsRepo(
|
||||||
const isRepoDefined = await repoInstance.checkIsRepo(
|
|
||||||
CheckRepoActions.IS_REPO_ROOT,
|
CheckRepoActions.IS_REPO_ROOT,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!isRepoDefined) {
|
if (!isRepoDefined) {
|
||||||
await repoInstance.init();
|
await simpleGit(repoDir).init(false, {
|
||||||
try {
|
'--initial-branch': 'main',
|
||||||
await repoInstance.raw([
|
});
|
||||||
'worktree',
|
|
||||||
'add',
|
const repo = simpleGit(repoDir);
|
||||||
this.projectRoot,
|
await repo.commit('Initial commit', { '--allow-empty': null });
|
||||||
'--force',
|
|
||||||
]);
|
|
||||||
} catch (error) {
|
|
||||||
console.log('Failed to add worktree:', error);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const visibileGitIgnorePath = path.join(this.projectRoot, '.gitignore');
|
const userGitIgnorePath = path.join(this.projectRoot, '.gitignore');
|
||||||
const hiddenGitIgnorePath = path.join(repoDir, '.gitignore');
|
const shadowGitIgnorePath = path.join(repoDir, '.gitignore');
|
||||||
|
|
||||||
let visibileGitIgnoreContent = ``;
|
let userGitIgnoreContent = '';
|
||||||
try {
|
try {
|
||||||
visibileGitIgnoreContent = await fs.readFile(
|
userGitIgnoreContent = await fs.readFile(userGitIgnorePath, 'utf-8');
|
||||||
visibileGitIgnorePath,
|
|
||||||
'utf-8',
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isNodeError(error) && error.code !== 'ENOENT') {
|
if (isNodeError(error) && error.code !== 'ENOENT') {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await fs.writeFile(hiddenGitIgnorePath, visibileGitIgnoreContent);
|
await fs.writeFile(shadowGitIgnorePath, userGitIgnoreContent);
|
||||||
|
|
||||||
if (!visibileGitIgnoreContent.includes(historyDirName)) {
|
|
||||||
const updatedContent = `${visibileGitIgnoreContent}\n# Gemini CLI history directory\n${historyDirName}\n`;
|
|
||||||
await fs.writeFile(visibileGitIgnorePath, updatedContent);
|
|
||||||
}
|
|
||||||
|
|
||||||
const commit = await repoInstance.raw([
|
|
||||||
'rev-list',
|
|
||||||
'--all',
|
|
||||||
'--max-count=1',
|
|
||||||
]);
|
|
||||||
if (!commit) {
|
|
||||||
await repoInstance.add(hiddenGitIgnorePath);
|
|
||||||
|
|
||||||
await repoInstance.commit('Initial commit');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private get hiddenGitRepository(): SimpleGit {
|
private get shadowGitRepository(): SimpleGit {
|
||||||
const historyDir = path.join(this.projectRoot, historyDirName);
|
const repoDir = this.getHistoryDir();
|
||||||
const repoDir = path.join(historyDir, 'repository');
|
|
||||||
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,
|
||||||
|
@ -114,19 +96,19 @@ export class GitService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCurrentCommitHash(): Promise<string> {
|
async getCurrentCommitHash(): Promise<string> {
|
||||||
const hash = await this.hiddenGitRepository.raw('rev-parse', 'HEAD');
|
const hash = await this.shadowGitRepository.raw('rev-parse', 'HEAD');
|
||||||
return hash.trim();
|
return hash.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
async createFileSnapshot(message: string): Promise<string> {
|
async createFileSnapshot(message: string): Promise<string> {
|
||||||
const repo = this.hiddenGitRepository;
|
const repo = this.shadowGitRepository;
|
||||||
await repo.add('.');
|
await repo.add('.');
|
||||||
const commitResult = await repo.commit(message);
|
const commitResult = await repo.commit(message);
|
||||||
return commitResult.commit;
|
return commitResult.commit;
|
||||||
}
|
}
|
||||||
|
|
||||||
async restoreProjectFromSnapshot(commitHash: string): Promise<void> {
|
async restoreProjectFromSnapshot(commitHash: string): Promise<void> {
|
||||||
const repo = this.hiddenGitRepository;
|
const repo = this.shadowGitRepository;
|
||||||
await repo.raw(['restore', '--source', commitHash, '.']);
|
await repo.raw(['restore', '--source', commitHash, '.']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue