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
224 lines
6.6 KiB
TypeScript
224 lines
6.6 KiB
TypeScript
/**
|
|
* 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 };
|
|
}
|
|
}
|
|
}
|