diff --git a/apps/roomote/package.json b/apps/roomote/package.json index c7cc001fa5..646d19f9de 100644 --- a/apps/roomote/package.json +++ b/apps/roomote/package.json @@ -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": { diff --git a/apps/roomote/src/lib/__tests__/gitUtils.test.ts b/apps/roomote/src/lib/__tests__/gitUtils.test.ts new file mode 100644 index 0000000000..87ac0dac75 --- /dev/null +++ b/apps/roomote/src/lib/__tests__/gitUtils.test.ts @@ -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', + ); + }); + }); +}); diff --git a/apps/roomote/src/lib/gitUtils.ts b/apps/roomote/src/lib/gitUtils.ts new file mode 100644 index 0000000000..0238554da3 --- /dev/null +++ b/apps/roomote/src/lib/gitUtils.ts @@ -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 => { + 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 => { + 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 => { + 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'); +}; diff --git a/apps/roomote/src/lib/repoConfig.ts b/apps/roomote/src/lib/repoConfig.ts new file mode 100644 index 0000000000..9701d9feb6 --- /dev/null +++ b/apps/roomote/src/lib/repoConfig.ts @@ -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 = { + '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); +}; diff --git a/apps/roomote/src/lib/runTask.ts b/apps/roomote/src/lib/runTask.ts index 4189cc69cd..6a38ce9f08 100644 --- a/apps/roomote/src/lib/runTask.ts +++ b/apps/roomote/src/lib/runTask.ts @@ -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 ({ 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, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bacb36f329..132706db8c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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