/** * Batch operations orchestrator * Uses service abstractions for testability */ import type { Project, BatchResult, OperationResult, UnifiedAppAction } from "../types/index.ts"; import type { GitService } from "../services/git.ts"; import { bunGitService, mergeStatus } from "../services/git.ts"; import { chunk } from "../utils/array.ts"; import { processBatch } from "../utils/rate-limiter.ts"; import { GIT } from "../constants.ts"; import { startAction, endAction, updateProgress, setMessage } from "../state/actions.ts"; import { errorToString } from "../utils/errors.ts"; export type ProgressCallback = (completed: number, total: number) => void; export interface BatchOptions { concurrency?: number; minDelay?: number; onProgress?: ProgressCallback; gitService?: GitService; } /** * Filter projects that can be pulled (git with remote) */ function getPullableProjects(projects: Project[]): Project[] { return projects.filter((p) => p.type === "git" && p.status?.hasRemote); } /** * Filter projects that need pushing (git with remote and ahead) */ function getPushableProjects(projects: Project[]): Project[] { return projects.filter( (p) => p.type === "git" && p.status?.hasRemote && p.status?.isAhead ); } /** * Filter projects that can be fetched (git with remote) */ function getFetchableProjects(projects: Project[]): Project[] { return projects.filter((p) => p.type === "git" && p.status?.hasRemote); } /** * Pull changes for multiple projects with rate limiting * * Filters projects to only those that are git repos with remotes, * then pulls changes in parallel with configurable concurrency. * * @param projects - Array of projects to pull * @param options - Batch operation options * @param options.concurrency - Maximum concurrent operations (default: 5) * @param options.onProgress - Progress callback (completed, total) * @param options.gitService - Git service implementation (for testing) * @returns Promise resolving to batch result with success/failure counts * * @example * ```typescript * const result = await batchPull(projects, { * concurrency: 3, * onProgress: (done, total) => console.log(`${done}/${total}`) * }); * console.log(`Pulled ${result.successful}/${result.total} repos`); * ``` */ export async function batchPull( projects: Project[], options: BatchOptions = {} ): Promise { const { concurrency = GIT.DEFAULT_CONCURRENCY, minDelay = 0, onProgress, gitService = bunGitService } = options; const start = Date.now(); const pullable = getPullableProjects(projects); const batchResults = await processBatch( pullable, (p) => gitService.pull(p.path), { concurrency, minDelay, onProgress, } ); // Transform BatchItemResult[] to OperationResult[] const results: OperationResult[] = batchResults.map((batchResult, index) => { if (batchResult.success) { return batchResult.result!; } else { // Create a failed OperationResult for this project const project = pullable[index]!; return { success: false, projectPath: project.path, operation: "pull", error: batchResult.error?.message, duration: 0, }; } }); const successful = results.filter((r) => r.success).length; return { total: pullable.length, successful, failed: pullable.length - successful, results, duration: Date.now() - start, }; } /** * Push changes for multiple projects with rate limiting * * Filters projects to only those that are git repos with remotes and have unpushed commits, * then pushes changes in parallel with configurable concurrency. * * @param projects - Array of projects to push * @param options - Batch operation options * @param options.concurrency - Maximum concurrent operations (default: 5) * @param options.onProgress - Progress callback (completed, total) * @param options.gitService - Git service implementation (for testing) * @returns Promise resolving to batch result with success/failure counts * * @example * ```typescript * const result = await batchPush(projects, { * concurrency: 2, * onProgress: (done, total) => console.log(`${done}/${total}`) * }); * console.log(`Pushed ${result.successful}/${result.total} repos`); * ``` */ export async function batchPush( projects: Project[], options: BatchOptions = {} ): Promise { const { concurrency = GIT.DEFAULT_CONCURRENCY, minDelay = 0, onProgress, gitService = bunGitService } = options; const start = Date.now(); const pushable = getPushableProjects(projects); const batchResults = await processBatch( pushable, (p) => gitService.push(p.path), { concurrency, minDelay, onProgress, } ); // Transform BatchItemResult[] to OperationResult[] const results: OperationResult[] = batchResults.map((batchResult, index) => { if (batchResult.success) { return batchResult.result!; } else { // Create a failed OperationResult for this project const project = pushable[index]!; return { success: false, projectPath: project.path, operation: "push", error: batchResult.error?.message, duration: 0, }; } }); const successful = results.filter((r) => r.success).length; return { total: pushable.length, successful, failed: pushable.length - successful, results, duration: Date.now() - start, }; } /** * Fetch all remotes for multiple projects with rate limiting * * Filters projects to only those that are git repos with remotes, * then fetches updates in parallel with configurable concurrency. * * @param projects - Array of projects to fetch * @param options - Batch operation options * @param options.concurrency - Maximum concurrent operations (default: 5) * @param options.onProgress - Progress callback (completed, total) * @param options.gitService - Git service implementation (for testing) * @returns Promise resolving to batch result with success/failure counts * * @example * ```typescript * const result = await batchFetch(projects, { * concurrency: 4, * onProgress: (done, total) => console.log(`${done}/${total}`) * }); * console.log(`Fetched ${result.successful}/${result.total} repos`); * ``` */ export async function batchFetch( projects: Project[], options: BatchOptions = {} ): Promise { const { concurrency = GIT.DEFAULT_CONCURRENCY, minDelay = 0, onProgress, gitService = bunGitService } = options; const start = Date.now(); const fetchable = getFetchableProjects(projects); const batchResults = await processBatch( fetchable, (p) => gitService.fetchAll(p.path), { concurrency, minDelay, onProgress, } ); // Transform BatchItemResult[] to OperationResult[] const results: OperationResult[] = batchResults.map((batchResult, index) => { if (batchResult.success) { return batchResult.result!; } else { // Create a failed OperationResult for this project const project = fetchable[index]!; return { success: false, projectPath: project.path, operation: "fetch", error: batchResult.error?.message, duration: 0, }; } }); const successful = results.filter((r) => r.success).length; return { total: fetchable.length, successful, failed: fetchable.length - successful, results, duration: Date.now() - start, }; } export type BatchGitOp = ( projects: Project[], options: { concurrency?: number; onProgress?: ProgressCallback }, ) => Promise; export interface RunBatchGitOpOptions { label: string; projects: Project[]; op: BatchGitOp; concurrency?: number; dispatch: (action: UnifiedAppAction) => void; formatSuccess?: (result: BatchResult, projects: Project[]) => string; formatFailure?: (result: BatchResult, projects: Project[]) => string; } function defaultFormatSuccess(result: BatchResult, _projects: Project[]): string { return `${result.successful}/${result.total} succeeded`; } function defaultFormatFailure(result: BatchResult, _projects: Project[]): string { return `${result.successful}/${result.total} (${result.failed} failed)`; } /** * Run a concurrent batch git operation with the standard dispatch lifecycle: * startAction → onProgress → endAction → setMessage. Sibling to runSequentialBatch * (ADR-0010) for the concurrent shape. */ export async function runBatchGitOp(options: RunBatchGitOpOptions): Promise { const { label, projects, op, concurrency, dispatch, formatSuccess = defaultFormatSuccess, formatFailure = defaultFormatFailure, } = options; dispatch(startAction(label)); try { const result = await op(projects, { concurrency, onProgress: (current, total) => dispatch(updateProgress(current, total)), }); dispatch(endAction()); const message = result.failed > 0 ? formatFailure(result, projects) : formatSuccess(result, projects); dispatch(setMessage(message)); return result; } catch (error) { dispatch(endAction()); dispatch(setMessage(`${label} failed: ${errorToString(error)}`)); throw error; } } /** * Refresh git status for multiple projects */ export async function batchRefreshStatus( projects: Project[], options: BatchOptions = {} ): Promise> { const { concurrency = GIT.STATUS_REFRESH_CONCURRENCY, gitService = bunGitService } = options; const gitProjects = projects.filter((p) => p.type !== "non-git"); const results = new Map(); const batches = chunk(gitProjects, concurrency); for (const batch of batches) { const updates = await Promise.all( batch.map(async (p) => { try { const [local, remote] = await Promise.all([ gitService.getLocalStatus(p.path), gitService.getRemoteStatus(p.path), ]); return { ...p, status: mergeStatus(local, remote), lastScanned: new Date() }; } catch { return p; } }) ); for (const p of updates) { results.set(p.id, p); } } return results; }