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:
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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user