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
This commit is contained in:
Lukas May
2026-03-05 11:45:09 +01:00
parent 79966cdf20
commit 5e77bf104c
20 changed files with 496 additions and 6 deletions

View File

@@ -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

View File

@@ -1080,6 +1080,76 @@ export function createCli(serverHandler?: (port?: number) => Promise<void>): 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')

View File

@@ -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<Conta
const branchManager = new SimpleGitBranchManager();
log.info('branch manager created');
// Project sync manager
const projectSyncManager = new ProjectSyncManager(
repos.projectRepository,
workspaceRoot,
eventBus,
);
log.info('project sync manager created');
// Dispatch managers
const dispatchManager = new DefaultDispatchManager(
repos.taskRepository,
@@ -212,6 +222,7 @@ export async function createContainer(options?: ContainerOptions): Promise<Conta
repos.projectRepository,
branchManager,
workspaceRoot,
projectSyncManager,
);
log.info('dispatch managers created');
@@ -258,6 +269,7 @@ export async function createContainer(options?: ContainerOptions): Promise<Conta
dispatchManager,
phaseDispatchManager,
branchManager,
projectSyncManager,
executionOrchestrator,
previewManager,
...repos,
@@ -269,6 +281,7 @@ export async function createContainer(options?: ContainerOptions): Promise<Conta
dispatchManager,
phaseDispatchManager,
branchManager,
projectSyncManager,
executionOrchestrator,
previewManager,
workspaceRoot,

View File

@@ -453,6 +453,7 @@ export const projects = sqliteTable('projects', {
name: text('name').notNull().unique(),
url: text('url').notNull().unique(),
defaultBranch: text('default_branch').notNull().default('main'),
lastFetchedAt: integer('last_fetched_at', { mode: 'timestamp' }),
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
});

View File

@@ -22,6 +22,7 @@ import type { BranchManager } from '../git/branch-manager.js';
import type { PhaseDispatchManager, DispatchManager, QueuedPhase, PhaseDispatchResult } from './types.js';
import { phaseBranchName, isPlanningCategory } from '../git/branch-naming.js';
import { ensureProjectClone } from '../git/project-clones.js';
import type { ProjectSyncManager } from '../git/remote-sync.js';
import { createModuleLogger } from '../logger/index.js';
const log = createModuleLogger('phase-dispatch');
@@ -64,6 +65,7 @@ export class DefaultPhaseDispatchManager implements PhaseDispatchManager {
private projectRepository?: ProjectRepository,
private branchManager?: BranchManager,
private workspaceRoot?: string,
private projectSyncManager?: ProjectSyncManager,
) {}
/**
@@ -176,6 +178,24 @@ export class DefaultPhaseDispatchManager implements PhaseDispatchManager {
const initBranch = initiative.branch;
const phBranch = phaseBranchName(initBranch, phase.name);
const projects = await this.projectRepository.findProjectsByInitiativeId(phase.initiativeId);
// 1. Sync project clones (git fetch + ff-only defaultBranch) before branching
if (this.projectSyncManager) {
for (const project of projects) {
try {
const result = await this.projectSyncManager.syncProject(project.id);
if (result.success) {
log.info({ project: project.name, fetched: result.fetched, ff: result.fastForwarded }, 'synced before phase dispatch');
} else {
log.warn({ project: project.name, err: result.error }, 'sync failed before phase dispatch (continuing)');
}
} catch (syncErr) {
log.warn({ project: project.name, err: syncErr instanceof Error ? syncErr.message : String(syncErr) }, 'sync error (continuing)');
}
}
}
// 2. Create initiative and phase branches from (now up-to-date) defaultBranch
for (const project of projects) {
const clonePath = await ensureProjectClone(project, this.workspaceRoot);
await this.branchManager.ensureBranch(clonePath, initBranch, project.defaultBranch);

View File

@@ -10,6 +10,6 @@ CREATE TABLE IF NOT EXISTS review_comments (
resolved INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
);--> statement-breakpoint
CREATE INDEX IF NOT EXISTS review_comments_phase_id_idx ON review_comments (phase_id);

View File

@@ -0,0 +1 @@
ALTER TABLE projects ADD COLUMN last_fetched_at INTEGER;

View File

@@ -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
}
]
}

View File

@@ -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;

View File

@@ -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';

View File

@@ -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<SyncResult> {
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/<defaultBranch>
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<SyncResult[]> {
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<SyncStatus> {
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<BranchDivergence> {
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<typeof simpleGit>,
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 };
}
}
}

View File

@@ -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,
}),
});

View File

@@ -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,
};
}

View File

@@ -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;
}

View File

@@ -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);
}),
};
}

View File

@@ -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 (
<Card>
<CardContent className="flex items-center gap-4 py-4">
@@ -164,7 +199,27 @@ function ProjectCard({
</button>
)}
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span>Synced: {formatRelativeTime(project.lastFetchedAt)}</span>
{syncStatus && (syncStatus.ahead > 0 || syncStatus.behind > 0) && (
<span className="font-mono">
{syncStatus.ahead > 0 && <span className="text-status-success-fg">+{syncStatus.ahead}</span>}
{syncStatus.ahead > 0 && syncStatus.behind > 0 && ' / '}
{syncStatus.behind > 0 && <span className="text-status-warning-fg">-{syncStatus.behind}</span>}
</span>
)}
</div>
</div>
<Button
variant="ghost"
size="icon"
className="shrink-0 text-muted-foreground"
onClick={() => syncMutation.mutate({ id: project.id })}
disabled={syncMutation.isPending}
title="Sync from remote"
>
<RefreshCw className={`h-4 w-4 ${syncMutation.isPending ? 'animate-spin' : ''}`} />
</Button>
<Button
variant="ghost"
size="icon"

View File

@@ -124,6 +124,7 @@ All adapters use nanoid() for IDs, auto-manage timestamps, and use Drizzle's `.r
| name | text NOT NULL UNIQUE | |
| url | text NOT NULL UNIQUE | git repo URL |
| defaultBranch | text NOT NULL | default 'main' |
| lastFetchedAt | integer/timestamp | nullable, updated by ProjectSyncManager |
| createdAt, updatedAt | integer/timestamp | |
### initiative_projects (junction)

View File

@@ -27,6 +27,7 @@
| **Preview** | `preview:building`, `preview:ready`, `preview:stopped`, `preview:failed` | 4 |
| **Conversation** | `conversation:created`, `conversation:answered` | 2 | `conversation:created` triggers auto-resume of idle target agents via `resumeForConversation()` |
| **Chat** | `chat:message_created`, `chat:session_closed` | 2 | Chat session lifecycle events |
| **Project** | `project:synced`, `project:sync_failed` | 2 | Remote sync results from `ProjectSyncManager` |
| **Log** | `log:entry` | 1 |
### Key Event Payloads

View File

@@ -53,8 +53,27 @@ Worktrees stored in `.cw-worktrees/` subdirectory of the repo. Each agent gets a
- `ensureProjectClone(project, workspaceRoot)` — Idempotent: checks if clone exists, clones if not
- `getProjectCloneDir(name, id)` — Canonical path: `repos/<sanitized-name>-<id>/`
### ProjectSyncManager (`apps/server/git/remote-sync.ts`)
Fetches remote changes for project clones and 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).
**Constructor**: `ProjectSyncManager(projectRepository, workspaceRoot, eventBus?)`
| Method | Purpose |
|--------|---------|
| `syncProject(projectId)` | `git fetch origin` + `git merge --ff-only origin/<defaultBranch>`, updates `lastFetchedAt` |
| `syncAllProjects()` | Sync all registered projects sequentially |
| `getSyncStatus(projectId)` | Returns `{ ahead, behind, lastFetchedAt }` via `rev-list --left-right --count` |
| `getInitiativeDivergence(projectId, branch)` | Ahead/behind between `defaultBranch` and an initiative branch |
**Sync flow during phase dispatch**: `dispatchNextPhase()` syncs all linked project clones *before* creating initiative/phase branches, so branches fork from up-to-date remote state. Sync is best-effort — failures are logged but don't block dispatch.
**CLI**: `cw project sync [name] --all`, `cw project status [name]`
**tRPC**: `syncProject`, `syncAllProjects`, `getProjectSyncStatus`
### Events Emitted
`worktree:created`, `worktree:removed`, `worktree:merged`, `worktree:conflict`
`worktree:created`, `worktree:removed`, `worktree:merged`, `worktree:conflict`, `project:synced`, `project:sync_failed`
---

View File

@@ -162,6 +162,9 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE
| deleteProject | mutation | Delete clone and record |
| getInitiativeProjects | query | Projects for initiative |
| updateInitiativeProjects | mutation | Sync junction table |
| syncProject | mutation | `git fetch` + ff-only merge of defaultBranch, updates `lastFetchedAt` |
| syncAllProjects | mutation | Sync all registered projects |
| getProjectSyncStatus | query | Returns `{ ahead, behind, lastFetchedAt }` for a project |
### Pages
| Procedure | Type | Description |