feat: add git pull functionality to runTask before opening VSCode (#185)

This commit is contained in:
roomote 2025-07-02 09:50:55 -07:00 committed by GitHub
parent 0b5812b01c
commit 495d1bdf51
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 519 additions and 0 deletions

View file

@ -31,6 +31,7 @@
"p-wait-for": "^5.0.2",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"simple-git": "^3.28.0",
"zod": "^3.25.41"
},
"devDependencies": {

View file

@ -0,0 +1,309 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import * as fs from 'node:fs';
import {
gitPullRepo,
gitPullRepoFromConfig,
gitPullAllRepos,
} from '../gitUtils';
import { Logger } from '../logger';
import type { RepoConfig } from '../repoConfig';
// Mock simple-git
const mockGit = {
status: vi.fn(),
revparse: vi.fn(),
pull: vi.fn(),
};
vi.mock('simple-git', () => ({
default: vi.fn(() => mockGit),
}));
// Mock dependencies
vi.mock('node:fs');
vi.mock('../utils', () => ({
findGitRoot: vi.fn(),
}));
const mockFs = vi.mocked(fs);
const mockFindGitRoot = vi.mocked(await import('../utils')).findGitRoot;
const mockSimpleGit = vi.mocked(await import('simple-git')).default;
describe('gitUtils', () => {
let mockLogger: Logger;
beforeEach(() => {
vi.clearAllMocks();
mockLogger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
log: vi.fn(),
close: vi.fn(),
logStream: undefined,
logFilePath: '/test/log.txt',
tag: 'test',
initializeLogger: vi.fn(),
writeToLog: vi.fn(),
} as unknown as Logger;
});
describe('gitPullRepo', () => {
it('should successfully pull from a clean repository', async () => {
const repoPath = '/test/repo';
const gitRoot = '/test/repo';
mockFs.existsSync.mockReturnValue(true);
mockFindGitRoot.mockReturnValue(gitRoot);
// Mock git status (clean)
mockGit.status.mockResolvedValue({
isClean: () => true,
files: [],
});
// Mock git branch
mockGit.revparse.mockResolvedValue('main');
// Mock git pull
mockGit.pull.mockResolvedValue({
summary: {
changes: 1,
insertions: 5,
deletions: 2,
},
});
await gitPullRepo(repoPath, mockLogger);
expect(mockSimpleGit).toHaveBeenCalledWith(gitRoot);
expect(mockGit.status).toHaveBeenCalled();
expect(mockGit.revparse).toHaveBeenCalledWith(['--abbrev-ref', 'HEAD']);
expect(mockGit.pull).toHaveBeenCalled();
expect(mockLogger.info).toHaveBeenCalledWith(
'Git pull completed successfully',
);
});
it('should warn about uncommitted changes but continue', async () => {
const repoPath = '/test/repo';
const gitRoot = '/test/repo';
mockFs.existsSync.mockReturnValue(true);
mockFindGitRoot.mockReturnValue(gitRoot);
// Mock git status (dirty)
mockGit.status.mockResolvedValue({
isClean: () => false,
files: [{ index: ' ', working_dir: 'M', path: 'file.txt' }],
});
// Mock git branch
mockGit.revparse.mockResolvedValue('main');
// Mock git pull
mockGit.pull.mockResolvedValue({
summary: {
changes: 0,
insertions: 0,
deletions: 0,
},
});
await gitPullRepo(repoPath, mockLogger);
expect(mockLogger.warn).toHaveBeenCalledWith(
expect.stringContaining('Repository has uncommitted changes'),
);
expect(mockGit.pull).toHaveBeenCalled();
});
it('should throw error if repository path does not exist', async () => {
const repoPath = '/nonexistent/repo';
mockFs.existsSync.mockReturnValue(false);
await expect(gitPullRepo(repoPath, mockLogger)).rejects.toThrow(
'Repository path does not exist: /nonexistent/repo',
);
});
it('should throw error if git pull fails', async () => {
const repoPath = '/test/repo';
const gitRoot = '/test/repo';
mockFs.existsSync.mockReturnValue(true);
mockFindGitRoot.mockReturnValue(gitRoot);
// Mock git status (clean)
mockGit.status.mockResolvedValue({
isClean: () => true,
files: [],
});
// Mock git branch
mockGit.revparse.mockResolvedValue('main');
// Mock git pull failure
mockGit.pull.mockRejectedValue(new Error('Network error'));
await expect(gitPullRepo(repoPath, mockLogger)).rejects.toThrow(
'Git pull failed for /test/repo: Network error',
);
});
it('should handle repository already up to date', async () => {
const repoPath = '/test/repo';
const gitRoot = '/test/repo';
mockFs.existsSync.mockReturnValue(true);
mockFindGitRoot.mockReturnValue(gitRoot);
// Mock git status (clean)
mockGit.status.mockResolvedValue({
isClean: () => true,
files: [],
});
// Mock git branch
mockGit.revparse.mockResolvedValue('main');
// Mock git pull with no changes
mockGit.pull.mockResolvedValue({
summary: {
changes: 0,
insertions: 0,
deletions: 0,
},
});
await gitPullRepo(repoPath, mockLogger);
expect(mockLogger.info).toHaveBeenCalledWith(
'Repository is already up to date',
);
});
});
describe('gitPullRepoFromConfig', () => {
it('should pull repository using config', async () => {
const repoConfig: RepoConfig = {
name: 'Test Repo',
path: '/test/repo',
defaultBranch: 'main',
};
mockFs.existsSync.mockReturnValue(true);
mockFindGitRoot.mockReturnValue('/test/repo');
// Mock successful git operations
mockGit.status.mockResolvedValue({
isClean: () => true,
files: [],
});
mockGit.revparse.mockResolvedValue('main');
mockGit.pull.mockResolvedValue({
summary: {
changes: 0,
insertions: 0,
deletions: 0,
},
});
await gitPullRepoFromConfig(repoConfig, mockLogger);
expect(mockLogger.info).toHaveBeenCalledWith(
'Updating repository: Test Repo (/test/repo)',
);
});
});
describe('gitPullAllRepos', () => {
it('should pull all repositories successfully', async () => {
const repoConfigs: RepoConfig[] = [
{
name: 'Repo 1',
path: '/test/repo1',
defaultBranch: 'main',
},
{
name: 'Repo 2',
path: '/test/repo2',
defaultBranch: 'main',
},
];
mockFs.existsSync.mockReturnValue(true);
mockFindGitRoot.mockImplementation((path) => path);
// Mock successful git operations
mockGit.status.mockResolvedValue({
isClean: () => true,
files: [],
});
mockGit.revparse.mockResolvedValue('main');
mockGit.pull.mockResolvedValue({
summary: {
changes: 0,
insertions: 0,
deletions: 0,
},
});
await gitPullAllRepos(repoConfigs, mockLogger);
expect(mockLogger.info).toHaveBeenCalledWith(
'Pulling latest changes for all 2 configured repositories',
);
expect(mockLogger.info).toHaveBeenCalledWith(
'Completed pulling all configured repositories',
);
expect(mockSimpleGit).toHaveBeenCalledTimes(2);
});
it('should continue with other repositories if one fails', async () => {
const repoConfigs: RepoConfig[] = [
{
name: 'Repo 1',
path: '/test/repo1',
defaultBranch: 'main',
},
{
name: 'Repo 2',
path: '/test/repo2',
defaultBranch: 'main',
},
];
mockFs.existsSync.mockReturnValue(true);
mockFindGitRoot.mockImplementation((path) => path);
// Mock first repo to fail, second to succeed
mockGit.status.mockResolvedValue({
isClean: () => true,
files: [],
});
mockGit.revparse.mockResolvedValue('main');
mockGit.pull
.mockRejectedValueOnce(new Error('Network error'))
.mockResolvedValueOnce({
summary: {
changes: 0,
insertions: 0,
deletions: 0,
},
});
await gitPullAllRepos(repoConfigs, mockLogger);
expect(mockLogger.error).toHaveBeenCalledWith(
'Failed to pull repository Repo 1: Git pull failed for /test/repo1: Network error',
);
expect(mockLogger.info).toHaveBeenCalledWith(
'Completed pulling all configured repositories',
);
});
});
});

View file

@ -0,0 +1,108 @@
import simpleGit from 'simple-git';
import * as fs from 'node:fs';
import type { Logger } from './logger';
import { findGitRoot } from './utils';
import type { RepoConfig } from './repoConfig';
/**
* Performs a git pull operation on the specified repository
* @param repoPath - The path to the repository
* @param logger - Logger instance for logging operations
* @returns Promise that resolves when git pull is complete
*/
export const gitPullRepo = async (
repoPath: string,
logger?: Logger,
): Promise<void> => {
try {
// Verify the path exists
if (!fs.existsSync(repoPath)) {
throw new Error(`Repository path does not exist: ${repoPath}`);
}
// Find the git root to ensure we're in a git repository
const gitRoot = findGitRoot(repoPath);
logger?.info(`Found git repository at: ${gitRoot}`);
// Initialize simple-git with the repository path
const git = simpleGit(gitRoot);
// Check if we're in a clean state (no uncommitted changes)
const status = await git.status();
if (!status.isClean()) {
logger?.warn(`Repository has uncommitted changes: ${gitRoot}`);
logger?.warn(
'Uncommitted changes:',
status.files
.map((f) => `${f.index}${f.working_dir} ${f.path}`)
.join('\n'),
);
// Continue with pull anyway, but log the warning
}
// Get current branch
const currentBranch = await git.revparse(['--abbrev-ref', 'HEAD']);
logger?.info(`Current branch: ${currentBranch}`);
// Perform git pull
logger?.info(`Pulling latest changes for ${gitRoot}...`);
const pullResult = await git.pull();
logger?.info(`Git pull completed successfully`);
if (pullResult.summary.changes) {
logger?.info(
`Git pull summary: ${pullResult.summary.changes} changes, ${pullResult.summary.insertions} insertions, ${pullResult.summary.deletions} deletions`,
);
} else {
logger?.info('Repository is already up to date');
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger?.error(`Failed to pull repository ${repoPath}: ${errorMessage}`);
throw new Error(`Git pull failed for ${repoPath}: ${errorMessage}`);
}
};
/**
* Performs git pull on a repository using its configuration
* @param repoConfig - Repository configuration
* @param logger - Logger instance for logging operations
*/
export const gitPullRepoFromConfig = async (
repoConfig: RepoConfig,
logger?: Logger,
): Promise<void> => {
logger?.info(`Updating repository: ${repoConfig.name} (${repoConfig.path})`);
await gitPullRepo(repoConfig.path, logger);
};
/**
* Performs git pull on all configured repositories
* @param repoConfigs - Array of repository configurations
* @param logger - Logger instance for logging operations
*/
export const gitPullAllRepos = async (
repoConfigs: RepoConfig[],
logger?: Logger,
): Promise<void> => {
logger?.info(
`Pulling latest changes for all ${repoConfigs.length} configured repositories`,
);
for (const repoConfig of repoConfigs) {
try {
await gitPullRepoFromConfig(repoConfig, logger);
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : String(error);
logger?.error(
`Failed to pull repository ${repoConfig.name}: ${errorMessage}`,
);
// Continue with other repositories even if one fails
}
}
logger?.info('Completed pulling all configured repositories');
};

View file

@ -0,0 +1,50 @@
/**
* Configuration for available repositories
*/
export interface RepoConfig {
/** Human-readable name of the repository */
name: string;
/** Absolute path to the repository */
path: string;
/** Git remote URL (optional, for validation) */
remoteUrl?: string;
/** Default branch name (defaults to 'main') */
defaultBranch?: string;
}
/**
* Available repositories configuration
*/
export const REPO_CONFIGS: Record<string, RepoConfig> = {
'roo-code': {
name: 'Roo Code',
path: '/roo/repos/Roo-Code',
defaultBranch: 'main',
},
'roo-code-cloud': {
name: 'Roo Code Cloud',
path: '/roo/repos/Roo-Code-Cloud',
defaultBranch: 'main',
},
};
/**
* Get repository configuration by path
*/
export const getRepoConfigByPath = (path: string): RepoConfig | undefined => {
return Object.values(REPO_CONFIGS).find((config) => config.path === path);
};
/**
* Get repository configuration by key
*/
export const getRepoConfig = (key: string): RepoConfig | undefined => {
return REPO_CONFIGS[key];
};
/**
* Get all available repository paths
*/
export const getAllRepoPaths = (): string[] => {
return Object.values(REPO_CONFIGS).map((config) => config.path);
};

View file

@ -21,6 +21,8 @@ import type { JobPayload, JobType } from '@roo-code-cloud/db';
import { Logger } from './logger';
import { isFlyMachine, isDockerContainer } from './utils';
import { SlackNotifier } from './slack';
import { getRepoConfigByPath, REPO_CONFIGS } from './repoConfig';
import { gitPullRepoFromConfig, gitPullAllRepos } from './gitUtils';
const TIMEOUT = 30 * 60 * 1_000;
@ -92,6 +94,27 @@ export const runTask = async <T extends JobType>({
logger.info(codeCommand);
// Pull latest changes from git before opening VSCode
try {
const repoConfig = getRepoConfigByPath(workspacePath);
if (repoConfig) {
logger.info(`Pulling latest changes for repository: ${repoConfig.name}`);
await gitPullRepoFromConfig(repoConfig, logger);
} else {
logger.warn(
`No repository configuration found for path: ${workspacePath}`,
);
logger.info('Pulling latest changes for all configured repositories');
const allRepoConfigs = Object.values(REPO_CONFIGS);
await gitPullAllRepos(allRepoConfigs, logger);
}
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
logger.error(`Failed to pull git changes: ${errorMessage}`);
// Continue with task execution even if git pull fails
logger.info('Continuing with task execution despite git pull failure');
}
const subprocess = execa({
shell: '/bin/bash',
cwd: workspacePath,

28
pnpm-lock.yaml generated
View file

@ -78,6 +78,9 @@ importers:
react-dom:
specifier: ^19.1.0
version: 19.1.0(react@19.1.0)
simple-git:
specifier: ^3.28.0
version: 3.28.0
zod:
specifier: ^3.25.41
version: 3.25.41
@ -1351,6 +1354,12 @@ packages:
'@jridgewell/trace-mapping@0.3.25':
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
'@kwsites/file-exists@1.1.1':
resolution: {integrity: sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==}
'@kwsites/promise-deferred@1.1.1':
resolution: {integrity: sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==}
'@libsql/client-wasm@0.15.5':
resolution: {integrity: sha512-JPjnGnLGQu36SPDskXSgyLoVBA0/IgcEC52MKnIa7/rKGIY1I4WKMIYrl2hkbw7+xYzWrGk6Vr8AZ9PeNEtgVA==}
bundledDependencies:
@ -6140,6 +6149,9 @@ packages:
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
engines: {node: '>=14'}
simple-git@3.28.0:
resolution: {integrity: sha512-Rs/vQRwsn1ILH1oBUy8NucJlXmnnLeLCfcvbSehkPzbv3wwoFWIdtfd6Ndo6ZPhlPsCZ60CPI4rxurnwAa+a2w==}
simple-swizzle@0.2.2:
resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==}
@ -7755,6 +7767,14 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.0
'@kwsites/file-exists@1.1.1':
dependencies:
debug: 4.4.1
transitivePeerDependencies:
- supports-color
'@kwsites/promise-deferred@1.1.1': {}
'@libsql/client-wasm@0.15.5':
dependencies:
'@libsql/core': 0.15.9
@ -13194,6 +13214,14 @@ snapshots:
signal-exit@4.1.0: {}
simple-git@3.28.0:
dependencies:
'@kwsites/file-exists': 1.1.1
'@kwsites/promise-deferred': 1.1.1
debug: 4.4.1
transitivePeerDependencies:
- supports-color
simple-swizzle@0.2.2:
dependencies:
is-arrayish: 0.3.2