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