From 5e77bf104c8d173948a622de3b078354814bcdf9 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Thu, 5 Mar 2026 11:45:09 +0100 Subject: [PATCH] feat: Add remote sync for project clones Fetch remote changes before agents start working so they build on up-to-date code. Adds ProjectSyncManager with git fetch + ff-only merge of defaultBranch, integrated into phase dispatch to sync before branch creation. - Schema: lastFetchedAt column on projects table (migration 0029) - Events: project:synced, project:sync_failed - Phase dispatch: sync all linked projects before creating branches - tRPC: syncProject, syncAllProjects, getProjectSyncStatus - CLI: cw project sync [name] --all, cw project status [name] - Frontend: sync button + ahead/behind badge on projects settings --- apps/server/agent/process-manager.test.ts | 2 +- apps/server/cli/index.ts | 70 ++++++ apps/server/container.ts | 13 + apps/server/db/schema.ts | 1 + apps/server/dispatch/phase-manager.ts | 20 ++ .../drizzle/0028_add_review_comments.sql | 2 +- .../0029_add_project_last_fetched_at.sql | 1 + apps/server/drizzle/meta/_journal.json | 14 ++ apps/server/events/types.ts | 25 ++ apps/server/git/index.ts | 4 + apps/server/git/remote-sync.ts | 223 ++++++++++++++++++ apps/server/server/trpc-adapter.ts | 4 + apps/server/trpc/context.ts | 5 + apps/server/trpc/routers/_helpers.ts | 11 + apps/server/trpc/routers/project.ts | 22 +- apps/web/src/routes/settings/projects.tsx | 59 ++++- docs/database.md | 1 + docs/dispatch-events.md | 1 + docs/git-process-logging.md | 21 +- docs/server-api.md | 3 + 20 files changed, 496 insertions(+), 6 deletions(-) create mode 100644 apps/server/drizzle/0029_add_project_last_fetched_at.sql create mode 100644 apps/server/git/remote-sync.ts diff --git a/apps/server/agent/process-manager.test.ts b/apps/server/agent/process-manager.test.ts index 4bd39e4..2bc566b 100644 --- a/apps/server/agent/process-manager.test.ts +++ b/apps/server/agent/process-manager.test.ts @@ -132,7 +132,7 @@ describe('ProcessManager', () => { // Mock project repository vi.mocked(mockProjectRepository.findProjectsByInitiativeId).mockResolvedValue([ - { id: '1', name: 'project1', url: 'https://github.com/user/project1.git', defaultBranch: 'main', createdAt: new Date(), updatedAt: new Date() } + { id: '1', name: 'project1', url: 'https://github.com/user/project1.git', defaultBranch: 'main', lastFetchedAt: null, createdAt: new Date(), updatedAt: new Date() } ]); // Mock existsSync to return true for worktree paths diff --git a/apps/server/cli/index.ts b/apps/server/cli/index.ts index 6c51494..f276ff7 100644 --- a/apps/server/cli/index.ts +++ b/apps/server/cli/index.ts @@ -1080,6 +1080,76 @@ export function createCli(serverHandler?: (port?: number) => Promise): Com } }); + // cw project sync [name] --all + projectCommand + .command('sync [name]') + .description('Sync project clone(s) from remote') + .option('--all', 'Sync all projects') + .action(async (name: string | undefined, options: { all?: boolean }) => { + try { + const client = createDefaultTrpcClient(); + if (options.all) { + const results = await client.syncAllProjects.mutate(); + for (const r of results) { + const status = r.success ? 'ok' : `FAILED: ${r.error}`; + console.log(`${r.projectName}: ${status}`); + } + } else if (name) { + const projects = await client.listProjects.query(); + const project = projects.find((p) => p.name === name || p.id === name); + if (!project) { + console.error(`Project not found: ${name}`); + process.exit(1); + } + const result = await client.syncProject.mutate({ id: project.id }); + if (result.success) { + console.log(`Synced ${result.projectName}: fetched=${result.fetched}, fast-forwarded=${result.fastForwarded}`); + } else { + console.error(`Sync failed: ${result.error}`); + process.exit(1); + } + } else { + console.error('Specify a project name or use --all'); + process.exit(1); + } + } catch (error) { + console.error('Failed to sync:', (error as Error).message); + process.exit(1); + } + }); + + // cw project status [name] + projectCommand + .command('status [name]') + .description('Show sync status for a project') + .action(async (name: string | undefined) => { + try { + const client = createDefaultTrpcClient(); + const projects = await client.listProjects.query(); + const targets = name + ? projects.filter((p) => p.name === name || p.id === name) + : projects; + + if (targets.length === 0) { + console.log(name ? `Project not found: ${name}` : 'No projects registered'); + return; + } + + for (const project of targets) { + const status = await client.getProjectSyncStatus.query({ id: project.id }); + const fetchedStr = status.lastFetchedAt + ? new Date(status.lastFetchedAt).toLocaleString() + : 'never'; + console.log(`${project.name}:`); + console.log(` Last fetched: ${fetchedStr}`); + console.log(` Ahead: ${status.ahead} Behind: ${status.behind}`); + } + } catch (error) { + console.error('Failed to get status:', (error as Error).message); + process.exit(1); + } + }); + // Account command group const accountCommand = program .command('account') diff --git a/apps/server/container.ts b/apps/server/container.ts index 2251ab4..5ea243a 100644 --- a/apps/server/container.ts +++ b/apps/server/container.ts @@ -48,6 +48,7 @@ import { DefaultPhaseDispatchManager } from './dispatch/phase-manager.js'; import type { DispatchManager, PhaseDispatchManager } from './dispatch/types.js'; import { SimpleGitBranchManager } from './git/simple-git-branch-manager.js'; import type { BranchManager } from './git/branch-manager.js'; +import { ProjectSyncManager } from './git/remote-sync.js'; import { ExecutionOrchestrator } from './execution/orchestrator.js'; import { DefaultConflictResolutionService } from './coordination/conflict-resolution-service.js'; import { PreviewManager } from './preview/index.js'; @@ -118,6 +119,7 @@ export interface Container extends Repositories { dispatchManager: DispatchManager; phaseDispatchManager: PhaseDispatchManager; branchManager: BranchManager; + projectSyncManager: ProjectSyncManager; executionOrchestrator: ExecutionOrchestrator; previewManager: PreviewManager; @@ -192,6 +194,14 @@ export async function createContainer(options?: ContainerOptions): Promise statement-breakpoint CREATE INDEX IF NOT EXISTS review_comments_phase_id_idx ON review_comments (phase_id); diff --git a/apps/server/drizzle/0029_add_project_last_fetched_at.sql b/apps/server/drizzle/0029_add_project_last_fetched_at.sql new file mode 100644 index 0000000..c1384e2 --- /dev/null +++ b/apps/server/drizzle/0029_add_project_last_fetched_at.sql @@ -0,0 +1 @@ +ALTER TABLE projects ADD COLUMN last_fetched_at INTEGER; diff --git a/apps/server/drizzle/meta/_journal.json b/apps/server/drizzle/meta/_journal.json index fe80b39..b2df298 100644 --- a/apps/server/drizzle/meta/_journal.json +++ b/apps/server/drizzle/meta/_journal.json @@ -197,6 +197,20 @@ "when": 1771891200000, "tag": "0027_add_chat_sessions", "breakpoints": true + }, + { + "idx": 28, + "version": "6", + "when": 1771977600000, + "tag": "0028_add_review_comments", + "breakpoints": true + }, + { + "idx": 29, + "version": "6", + "when": 1772064000000, + "tag": "0029_add_project_last_fetched_at", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/server/events/types.ts b/apps/server/events/types.ts index d73ad94..ccfae95 100644 --- a/apps/server/events/types.ts +++ b/apps/server/events/types.ts @@ -553,6 +553,29 @@ export interface ConversationAnsweredEvent extends DomainEvent { }; } +/** + * Project Sync Events + */ + +export interface ProjectSyncedEvent extends DomainEvent { + type: 'project:synced'; + payload: { + projectId: string; + projectName: string; + fetched: boolean; + fastForwarded: boolean; + }; +} + +export interface ProjectSyncFailedEvent extends DomainEvent { + type: 'project:sync_failed'; + payload: { + projectId: string; + projectName: string; + error: string; + }; +} + /** * Chat Session Events */ @@ -626,6 +649,8 @@ export type DomainEventMap = | PreviewFailedEvent | ConversationCreatedEvent | ConversationAnsweredEvent + | ProjectSyncedEvent + | ProjectSyncFailedEvent | ChatMessageCreatedEvent | ChatSessionClosedEvent; diff --git a/apps/server/git/index.ts b/apps/server/git/index.ts index a340f47..41bd3b0 100644 --- a/apps/server/git/index.ts +++ b/apps/server/git/index.ts @@ -21,3 +21,7 @@ export { SimpleGitWorktreeManager } from './manager.js'; // Utilities export { cloneProject } from './clone.js'; export { ensureProjectClone, getProjectCloneDir } from './project-clones.js'; + +// Remote sync +export { ProjectSyncManager } from './remote-sync.js'; +export type { SyncResult, SyncStatus, BranchDivergence } from './remote-sync.js'; diff --git a/apps/server/git/remote-sync.ts b/apps/server/git/remote-sync.ts new file mode 100644 index 0000000..3dbfc6d --- /dev/null +++ b/apps/server/git/remote-sync.ts @@ -0,0 +1,223 @@ +/** + * 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 }; + } + } +} diff --git a/apps/server/server/trpc-adapter.ts b/apps/server/server/trpc-adapter.ts index 40fd6b2..4102068 100644 --- a/apps/server/server/trpc-adapter.ts +++ b/apps/server/server/trpc-adapter.ts @@ -28,6 +28,7 @@ import type { CoordinationManager } from '../coordination/types.js'; import type { BranchManager } from '../git/branch-manager.js'; import type { ExecutionOrchestrator } from '../execution/orchestrator.js'; import type { PreviewManager } from '../preview/index.js'; +import type { ProjectSyncManager } from '../git/remote-sync.js'; /** * Options for creating the tRPC request handler. @@ -79,6 +80,8 @@ export interface TrpcAdapterOptions { chatSessionRepository?: ChatSessionRepository; /** Review comment repository for inline review comments on phase diffs */ reviewCommentRepository?: ReviewCommentRepository; + /** Project sync manager for remote fetch/sync operations */ + projectSyncManager?: ProjectSyncManager; /** Absolute path to the workspace root (.cwrc directory) */ workspaceRoot?: string; } @@ -162,6 +165,7 @@ export function createTrpcHandler(options: TrpcAdapterOptions) { conversationRepository: options.conversationRepository, chatSessionRepository: options.chatSessionRepository, reviewCommentRepository: options.reviewCommentRepository, + projectSyncManager: options.projectSyncManager, workspaceRoot: options.workspaceRoot, }), }); diff --git a/apps/server/trpc/context.ts b/apps/server/trpc/context.ts index 9995662..f4889d6 100644 --- a/apps/server/trpc/context.ts +++ b/apps/server/trpc/context.ts @@ -25,6 +25,7 @@ import type { CoordinationManager } from '../coordination/types.js'; import type { BranchManager } from '../git/branch-manager.js'; import type { ExecutionOrchestrator } from '../execution/orchestrator.js'; import type { PreviewManager } from '../preview/index.js'; +import type { ProjectSyncManager } from '../git/remote-sync.js'; // Re-export for convenience export type { EventBus, DomainEvent }; @@ -79,6 +80,8 @@ export interface TRPCContext { chatSessionRepository?: ChatSessionRepository; /** Review comment repository for inline review comments on phase diffs */ reviewCommentRepository?: ReviewCommentRepository; + /** Project sync manager for remote fetch/sync operations */ + projectSyncManager?: ProjectSyncManager; /** Absolute path to the workspace root (.cwrc directory) */ workspaceRoot?: string; } @@ -110,6 +113,7 @@ export interface CreateContextOptions { conversationRepository?: ConversationRepository; chatSessionRepository?: ChatSessionRepository; reviewCommentRepository?: ReviewCommentRepository; + projectSyncManager?: ProjectSyncManager; workspaceRoot?: string; } @@ -144,6 +148,7 @@ export function createContext(options: CreateContextOptions): TRPCContext { conversationRepository: options.conversationRepository, chatSessionRepository: options.chatSessionRepository, reviewCommentRepository: options.reviewCommentRepository, + projectSyncManager: options.projectSyncManager, workspaceRoot: options.workspaceRoot, }; } diff --git a/apps/server/trpc/routers/_helpers.ts b/apps/server/trpc/routers/_helpers.ts index 3948ce6..67fa3ef 100644 --- a/apps/server/trpc/routers/_helpers.ts +++ b/apps/server/trpc/routers/_helpers.ts @@ -24,6 +24,7 @@ import type { CoordinationManager } from '../../coordination/types.js'; import type { BranchManager } from '../../git/branch-manager.js'; import type { ExecutionOrchestrator } from '../../execution/orchestrator.js'; import type { PreviewManager } from '../../preview/index.js'; +import type { ProjectSyncManager } from '../../git/remote-sync.js'; export function requireAgentManager(ctx: TRPCContext) { if (!ctx.agentManager) { @@ -214,3 +215,13 @@ export function requireReviewCommentRepository(ctx: TRPCContext): ReviewCommentR } return ctx.reviewCommentRepository; } + +export function requireProjectSyncManager(ctx: TRPCContext): ProjectSyncManager { + if (!ctx.projectSyncManager) { + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Project sync manager not available', + }); + } + return ctx.projectSyncManager; +} diff --git a/apps/server/trpc/routers/project.ts b/apps/server/trpc/routers/project.ts index b8d34c2..63f3474 100644 --- a/apps/server/trpc/routers/project.ts +++ b/apps/server/trpc/routers/project.ts @@ -7,7 +7,7 @@ import { z } from 'zod'; import { join } from 'node:path'; import { rm } from 'node:fs/promises'; import type { ProcedureBuilder } from '../trpc.js'; -import { requireProjectRepository } from './_helpers.js'; +import { requireProjectRepository, requireProjectSyncManager } from './_helpers.js'; import { cloneProject } from '../../git/clone.js'; import { getProjectCloneDir } from '../../git/project-clones.js'; @@ -153,5 +153,25 @@ export function projectProcedures(publicProcedure: ProcedureBuilder) { await repo.setInitiativeProjects(input.initiativeId, input.projectIds); return { success: true }; }), + + syncProject: publicProcedure + .input(z.object({ id: z.string().min(1) })) + .mutation(async ({ ctx, input }) => { + const syncManager = requireProjectSyncManager(ctx); + return syncManager.syncProject(input.id); + }), + + syncAllProjects: publicProcedure + .mutation(async ({ ctx }) => { + const syncManager = requireProjectSyncManager(ctx); + return syncManager.syncAllProjects(); + }), + + getProjectSyncStatus: publicProcedure + .input(z.object({ id: z.string().min(1) })) + .query(async ({ ctx, input }) => { + const syncManager = requireProjectSyncManager(ctx); + return syncManager.getSyncStatus(input.id); + }), }; } diff --git a/apps/web/src/routes/settings/projects.tsx b/apps/web/src/routes/settings/projects.tsx index a5dded0..230ceb2 100644 --- a/apps/web/src/routes/settings/projects.tsx +++ b/apps/web/src/routes/settings/projects.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import { createFileRoute } from '@tanstack/react-router' -import { Pencil, Plus, Trash2 } from 'lucide-react' +import { Pencil, Plus, RefreshCw, Trash2 } from 'lucide-react' import { trpc } from '@/lib/trpc' import { Card, CardContent } from '@/components/ui/card' import { Button } from '@/components/ui/button' @@ -92,11 +92,24 @@ function ProjectsSettingsPage() { ) } +function formatRelativeTime(date: Date | string | null): string { + if (!date) return 'never synced' + const d = typeof date === 'string' ? new Date(date) : date + const seconds = Math.floor((Date.now() - d.getTime()) / 1000) + if (seconds < 60) return 'just now' + const minutes = Math.floor(seconds / 60) + if (minutes < 60) return `${minutes}m ago` + const hours = Math.floor(minutes / 60) + if (hours < 24) return `${hours}h ago` + const days = Math.floor(hours / 24) + return `${days}d ago` +} + function ProjectCard({ project, onDelete, }: { - project: { id: string; name: string; url: string; defaultBranch: string } + project: { id: string; name: string; url: string; defaultBranch: string; lastFetchedAt: Date | null } onDelete: () => void }) { const [editing, setEditing] = useState(false) @@ -115,6 +128,26 @@ function ProjectCard({ }, }) + const syncMutation = trpc.syncProject.useMutation({ + onSuccess: (result) => { + void utils.listProjects.invalidate() + void utils.getProjectSyncStatus.invalidate({ id: project.id }) + if (result.success) { + toast.success(`Synced ${result.projectName}`) + } else { + toast.error(`Sync failed: ${result.error}`) + } + }, + onError: (err) => { + toast.error(`Sync failed: ${err.message}`) + }, + }) + + const syncStatusQuery = trpc.getProjectSyncStatus.useQuery( + { id: project.id }, + { refetchInterval: 60_000 }, + ) + function saveEdit() { const trimmed = editValue.trim() if (!trimmed || trimmed === project.defaultBranch) { @@ -125,6 +158,8 @@ function ProjectCard({ updateMutation.mutate({ id: project.id, defaultBranch: trimmed }) } + const syncStatus = syncStatusQuery.data + return ( @@ -164,7 +199,27 @@ function ProjectCard({ )} +
+ Synced: {formatRelativeTime(project.lastFetchedAt)} + {syncStatus && (syncStatus.ahead > 0 || syncStatus.behind > 0) && ( + + {syncStatus.ahead > 0 && +{syncStatus.ahead}} + {syncStatus.ahead > 0 && syncStatus.behind > 0 && ' / '} + {syncStatus.behind > 0 && -{syncStatus.behind}} + + )} +
+