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

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