Files
Codewalkers/apps/server/git/remote-sync.ts
Lukas May 5e77bf104c 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
2026-03-05 11:45:09 +01:00

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