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:
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
1
apps/server/drizzle/0029_add_project_last_fetched_at.sql
Normal file
1
apps/server/drizzle/0029_add_project_last_fetched_at.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE projects ADD COLUMN last_fetched_at INTEGER;
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
223
apps/server/git/remote-sync.ts
Normal file
223
apps/server/git/remote-sync.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user