/** * Project Sync Manager * * Fetches remote changes for project clones and optionally fast-forwards * the local defaultBranch. Safe to run with active worktrees — only * updates remote-tracking refs and the base branch (never checked out * directly by any worktree). */ import { simpleGit } from 'simple-git'; import type { ProjectRepository } from '../db/repositories/project-repository.js'; import type { EventBus, ProjectSyncedEvent, ProjectSyncFailedEvent } from '../events/types.js'; import { ensureProjectClone } from './project-clones.js'; import { createModuleLogger } from '../logger/index.js'; const log = createModuleLogger('remote-sync'); // ============================================================================= // Types // ============================================================================= export interface SyncResult { projectId: string; projectName: string; success: boolean; fetched: boolean; fastForwarded: boolean; error?: string; } export interface SyncStatus { ahead: number; behind: number; lastFetchedAt: Date | null; } export interface BranchDivergence { ahead: number; behind: number; } // ============================================================================= // ProjectSyncManager // ============================================================================= export class ProjectSyncManager { constructor( private projectRepository: ProjectRepository, private workspaceRoot: string, private eventBus?: EventBus, ) {} /** * Sync a single project: git fetch + ff-only merge of defaultBranch. */ async syncProject(projectId: string): Promise { const project = await this.projectRepository.findById(projectId); if (!project) { return { projectId, projectName: 'unknown', success: false, fetched: false, fastForwarded: false, error: `Project not found: ${projectId}`, }; } const result: SyncResult = { projectId: project.id, projectName: project.name, success: false, fetched: false, fastForwarded: false, }; try { const clonePath = await ensureProjectClone(project, this.workspaceRoot); const git = simpleGit(clonePath); // 1. git fetch origin await git.fetch('origin'); result.fetched = true; log.info({ project: project.name }, 'fetched remote'); // 2. Fast-forward defaultBranch to origin/ const remoteBranch = `origin/${project.defaultBranch}`; try { await git.raw(['merge', '--ff-only', remoteBranch, project.defaultBranch]); result.fastForwarded = true; log.info({ project: project.name, branch: project.defaultBranch }, 'fast-forwarded default branch'); } catch (ffErr) { // ff-only failed — local branch has diverged or doesn't exist yet. // This is non-fatal; fetch alone still updated remote-tracking refs. log.warn( { project: project.name, err: (ffErr as Error).message }, 'fast-forward failed (non-fatal)', ); } // 3. Update lastFetchedAt await this.projectRepository.update(project.id, { lastFetchedAt: new Date() }); result.success = true; // 4. Emit event if (this.eventBus) { const event: ProjectSyncedEvent = { type: 'project:synced', timestamp: new Date(), payload: { projectId: project.id, projectName: project.name, fetched: result.fetched, fastForwarded: result.fastForwarded, }, }; this.eventBus.emit(event); } } catch (err) { result.error = (err as Error).message; log.error({ project: project.name, err: result.error }, 'sync failed'); if (this.eventBus) { const event: ProjectSyncFailedEvent = { type: 'project:sync_failed', timestamp: new Date(), payload: { projectId: project.id, projectName: project.name, error: result.error, }, }; this.eventBus.emit(event); } } return result; } /** * Sync all registered projects. */ async syncAllProjects(): Promise { const projects = await this.projectRepository.findAll(); const results: SyncResult[] = []; for (const project of projects) { results.push(await this.syncProject(project.id)); } return results; } /** * Get sync status for a project: ahead/behind vs origin and lastFetchedAt. */ async getSyncStatus(projectId: string): Promise { const project = await this.projectRepository.findById(projectId); if (!project) { return { ahead: 0, behind: 0, lastFetchedAt: null }; } try { const clonePath = await ensureProjectClone(project, this.workspaceRoot); const git = simpleGit(clonePath); const { ahead, behind } = await this.revListCount( git, `origin/${project.defaultBranch}`, project.defaultBranch, ); return { ahead, behind, lastFetchedAt: project.lastFetchedAt }; } catch { return { ahead: 0, behind: 0, lastFetchedAt: project.lastFetchedAt }; } } /** * Get divergence between defaultBranch and an initiative branch. */ async getInitiativeDivergence( projectId: string, initiativeBranch: string, ): Promise { const project = await this.projectRepository.findById(projectId); if (!project) { return { ahead: 0, behind: 0 }; } try { const clonePath = await ensureProjectClone(project, this.workspaceRoot); const git = simpleGit(clonePath); return this.revListCount(git, project.defaultBranch, initiativeBranch); } catch { return { ahead: 0, behind: 0 }; } } // =========================================================================== // Private // =========================================================================== /** * Count commits ahead/behind between two refs using rev-list --left-right --count. */ private async revListCount( git: ReturnType, left: string, right: string, ): Promise<{ ahead: number; behind: number }> { try { const raw = await git.raw([ 'rev-list', '--left-right', '--count', `${left}...${right}`, ]); const [leftCount, rightCount] = raw.trim().split(/\s+/).map(Number); // left-right: leftCount = commits in left not in right (behind), rightCount = commits in right not in left (ahead) return { ahead: rightCount ?? 0, behind: leftCount ?? 0 }; } catch { return { ahead: 0, behind: 0 }; } } }