diff --git a/docs/sprint-7.md b/docs/sprint-7.md index e2eeda5c..f5e19393 100644 --- a/docs/sprint-7.md +++ b/docs/sprint-7.md @@ -19,7 +19,7 @@ | US-706 | Auto-archive suggestions | ✅ Done | US-701, US-703 | Suggest archiving when project complete | | US-707 | GitHub PR creation | ✅ Done | None | Create PR from task detail UI | | US-708 | Preview mode | ✅ Done | None | Embedded browser for dev server preview | -| US-709 | Merge conflict resolution | ⏳ Todo | None | Visual conflict resolver UI | +| US-709 | Merge conflict resolution | ✅ Done | None | Visual conflict resolver UI | | US-710 | Time tracking | ⏳ Todo | None | Start/stop timer, manual entry, reports | | US-711 | Running indicator on cards | ⏳ Todo | None | Spinner/pulse animation when agent running | @@ -145,4 +145,24 @@ - Activity logging for project_archived events - Replaces client-side ProjectArchiveSuggestion with server-side approach +**US-709: Merge conflict resolution** ✅ +- ConflictService for detecting and resolving git conflicts: + - Detect rebase/merge in progress + - List conflicting files + - Get file conflict details (ours, theirs, base versions) + - Parse conflict markers + - Resolve with ours/theirs/manual content + - Abort or continue rebase/merge +- API endpoints: GET /api/conflicts/:taskId, file, resolve, abort, continue +- ConflictResolver component: + - Slide-out panel (90vw, max 1200px) + - File list sidebar with conflict count + - Side-by-side comparison view (Ours vs Theirs) + - Manual edit tab with full content editor + - Accept Ours / Accept Theirs buttons + - Abort and Continue actions with confirmation + - File navigation (prev/next) +- Conflict warning banner in GitSection when conflicts detected +- Auto-polling when conflicts present + (Starting Sprint 7) diff --git a/server/src/index.ts b/server/src/index.ts index 42a0469d..fadd6770 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -13,6 +13,7 @@ import templateRoutes from './routes/templates.js'; import activityRoutes from './routes/activity.js'; import githubRoutes from './routes/github.js'; import previewRoutes from './routes/preview.js'; +import conflictRoutes from './routes/conflicts.js'; import type { AgentOutput } from './services/agent-service.js'; const app = express(); @@ -39,6 +40,7 @@ app.use('/api/templates', templateRoutes); app.use('/api/activity', activityRoutes); app.use('/api/github', githubRoutes); app.use('/api/preview', previewRoutes); +app.use('/api/conflicts', conflictRoutes); // Create HTTP server const server = createServer(app); diff --git a/server/src/routes/conflicts.ts b/server/src/routes/conflicts.ts new file mode 100644 index 00000000..08ab354a --- /dev/null +++ b/server/src/routes/conflicts.ts @@ -0,0 +1,107 @@ +import { Router, type Router as RouterType } from 'express'; +import { z } from 'zod'; +import { ConflictService } from '../services/conflict-service.js'; + +const router: RouterType = Router(); +const conflictService = new ConflictService(); + +// Validation schemas +const resolveSchema = z.object({ + resolution: z.enum(['ours', 'theirs', 'manual']), + manualContent: z.string().optional(), +}); + +// GET /api/conflicts/:taskId - Get conflict status for a task +router.get('/:taskId', async (req, res) => { + try { + const status = await conflictService.getConflictStatus(req.params.taskId); + res.json(status); + } catch (error: any) { + console.error('Error getting conflict status:', error); + res.status(500).json({ error: error.message || 'Failed to get conflict status' }); + } +}); + +// GET /api/conflicts/:taskId/file - Get conflict details for a specific file +router.get('/:taskId/file', async (req, res) => { + try { + const filePath = req.query.path as string; + if (!filePath) { + return res.status(400).json({ error: 'File path required' }); + } + + const conflict = await conflictService.getFileConflict(req.params.taskId, filePath); + res.json(conflict); + } catch (error: any) { + console.error('Error getting file conflict:', error); + res.status(500).json({ error: error.message || 'Failed to get file conflict' }); + } +}); + +// POST /api/conflicts/:taskId/resolve - Resolve a file conflict +router.post('/:taskId/resolve', async (req, res) => { + try { + const filePath = req.query.path as string; + if (!filePath) { + return res.status(400).json({ error: 'File path required' }); + } + + const input = resolveSchema.parse(req.body); + const result = await conflictService.resolveFile( + req.params.taskId, + filePath, + input.resolution, + input.manualContent + ); + res.json(result); + } catch (error: any) { + if (error instanceof z.ZodError) { + return res.status(400).json({ error: 'Validation failed', details: error.errors }); + } + console.error('Error resolving conflict:', error); + res.status(500).json({ error: error.message || 'Failed to resolve conflict' }); + } +}); + +// POST /api/conflicts/:taskId/abort - Abort rebase or merge +router.post('/:taskId/abort', async (req, res) => { + try { + const status = await conflictService.getConflictStatus(req.params.taskId); + + if (status.rebaseInProgress) { + await conflictService.abortRebase(req.params.taskId); + } else if (status.mergeInProgress) { + await conflictService.abortMerge(req.params.taskId); + } else { + return res.status(400).json({ error: 'No rebase or merge in progress' }); + } + + res.json({ success: true }); + } catch (error: any) { + console.error('Error aborting:', error); + res.status(500).json({ error: error.message || 'Failed to abort' }); + } +}); + +// POST /api/conflicts/:taskId/continue - Continue rebase or merge +router.post('/:taskId/continue', async (req, res) => { + try { + const status = await conflictService.getConflictStatus(req.params.taskId); + + let result; + if (status.rebaseInProgress) { + result = await conflictService.continueRebase(req.params.taskId); + } else if (status.mergeInProgress) { + result = await conflictService.continueMerge(req.params.taskId, req.body.message); + } else { + return res.status(400).json({ error: 'No rebase or merge in progress' }); + } + + res.json(result); + } catch (error: any) { + console.error('Error continuing:', error); + res.status(500).json({ error: error.message || 'Failed to continue' }); + } +}); + +export default router; diff --git a/server/src/services/conflict-service.ts b/server/src/services/conflict-service.ts new file mode 100644 index 00000000..26d02ee1 --- /dev/null +++ b/server/src/services/conflict-service.ts @@ -0,0 +1,288 @@ +import { simpleGit, SimpleGit } from 'simple-git'; +import fs from 'fs/promises'; +import path from 'path'; +import { ConfigService } from './config-service.js'; +import { TaskService } from './task-service.js'; + +export interface ConflictFile { + path: string; + content: string; + oursContent: string; + theirsContent: string; + baseContent: string; + markers: ConflictMarker[]; +} + +export interface ConflictMarker { + startLine: number; + separatorLine: number; + endLine: number; + oursLines: string[]; + theirsLines: string[]; +} + +export interface ConflictStatus { + hasConflicts: boolean; + conflictingFiles: string[]; + rebaseInProgress: boolean; + mergeInProgress: boolean; +} + +export interface ResolveResult { + success: boolean; + remainingConflicts: string[]; +} + +export class ConflictService { + private configService: ConfigService; + private taskService: TaskService; + + constructor() { + this.configService = new ConfigService(); + this.taskService = new TaskService(); + } + + private expandPath(p: string): string { + return p.replace(/^~/, process.env.HOME || ''); + } + + /** + * Get the working directory for a task (worktree or repo) + */ + private async getWorkingDir(taskId: string): Promise<{ git: SimpleGit; workDir: string }> { + const task = await this.taskService.getTask(taskId); + if (!task?.git?.repo) { + throw new Error('Task must have a repository configured'); + } + + const config = await this.configService.getConfig(); + const repoConfig = config.repos.find(r => r.name === task.git!.repo); + if (!repoConfig) { + throw new Error(`Repository "${task.git.repo}" not found`); + } + + const workDir = task.git.worktreePath || this.expandPath(repoConfig.path); + const git = simpleGit(workDir); + + return { git, workDir }; + } + + /** + * Check if there are conflicts in the working directory + */ + async getConflictStatus(taskId: string): Promise { + const { git, workDir } = await this.getWorkingDir(taskId); + + // Check for rebase in progress + const rebaseDir = path.join(workDir, '.git', 'rebase-merge'); + const rebaseApplyDir = path.join(workDir, '.git', 'rebase-apply'); + const rebaseInProgress = await fs.access(rebaseDir).then(() => true).catch(() => false) || + await fs.access(rebaseApplyDir).then(() => true).catch(() => false); + + // Check for merge in progress + const mergeHead = path.join(workDir, '.git', 'MERGE_HEAD'); + const mergeInProgress = await fs.access(mergeHead).then(() => true).catch(() => false); + + // Get status to find conflicted files + const status = await git.status(); + const conflictingFiles = status.conflicted || []; + + return { + hasConflicts: conflictingFiles.length > 0, + conflictingFiles, + rebaseInProgress, + mergeInProgress, + }; + } + + /** + * Get detailed conflict information for a file + */ + async getFileConflict(taskId: string, filePath: string): Promise { + const { git, workDir } = await this.getWorkingDir(taskId); + + const fullPath = path.join(workDir, filePath); + const content = await fs.readFile(fullPath, 'utf-8'); + + // Parse conflict markers + const markers = this.parseConflictMarkers(content); + + // Get the different versions + let oursContent = ''; + let theirsContent = ''; + let baseContent = ''; + + try { + // Get our version (HEAD) + oursContent = await git.show([`:2:${filePath}`]); + } catch { + // File might be new on our side + } + + try { + // Get their version (incoming) + theirsContent = await git.show([`:3:${filePath}`]); + } catch { + // File might be new on their side + } + + try { + // Get base version (common ancestor) + baseContent = await git.show([`:1:${filePath}`]); + } catch { + // File might not have a common ancestor + } + + return { + path: filePath, + content, + oursContent, + theirsContent, + baseContent, + markers, + }; + } + + /** + * Parse conflict markers from file content + */ + private parseConflictMarkers(content: string): ConflictMarker[] { + const lines = content.split('\n'); + const markers: ConflictMarker[] = []; + + let i = 0; + while (i < lines.length) { + if (lines[i].startsWith('<<<<<<<')) { + const startLine = i; + const oursLines: string[] = []; + const theirsLines: string[] = []; + + // Collect "ours" lines + i++; + while (i < lines.length && !lines[i].startsWith('=======')) { + oursLines.push(lines[i]); + i++; + } + + const separatorLine = i; + + // Collect "theirs" lines + i++; + while (i < lines.length && !lines[i].startsWith('>>>>>>>')) { + theirsLines.push(lines[i]); + i++; + } + + const endLine = i; + + markers.push({ + startLine, + separatorLine, + endLine, + oursLines, + theirsLines, + }); + } + i++; + } + + return markers; + } + + /** + * Resolve a file conflict by choosing a side + */ + async resolveFile( + taskId: string, + filePath: string, + resolution: 'ours' | 'theirs' | 'manual', + manualContent?: string + ): Promise { + const { git, workDir } = await this.getWorkingDir(taskId); + const fullPath = path.join(workDir, filePath); + + if (resolution === 'manual') { + if (!manualContent) { + throw new Error('Manual content required for manual resolution'); + } + await fs.writeFile(fullPath, manualContent, 'utf-8'); + } else if (resolution === 'ours') { + // Checkout our version + await git.checkout(['--ours', filePath]); + } else if (resolution === 'theirs') { + // Checkout their version + await git.checkout(['--theirs', filePath]); + } + + // Stage the resolved file + await git.add(filePath); + + // Check remaining conflicts + const status = await this.getConflictStatus(taskId); + + return { + success: true, + remainingConflicts: status.conflictingFiles, + }; + } + + /** + * Abort the current rebase + */ + async abortRebase(taskId: string): Promise { + const { git } = await this.getWorkingDir(taskId); + await git.rebase(['--abort']); + } + + /** + * Continue rebase after conflicts are resolved + */ + async continueRebase(taskId: string): Promise<{ success: boolean; error?: string }> { + const { git } = await this.getWorkingDir(taskId); + + try { + await git.rebase(['--continue']); + return { success: true }; + } catch (error: any) { + // Check if there are still conflicts + const status = await this.getConflictStatus(taskId); + if (status.hasConflicts) { + return { + success: false, + error: `Still have ${status.conflictingFiles.length} conflicting file(s)` + }; + } + return { success: false, error: error.message }; + } + } + + /** + * Abort the current merge + */ + async abortMerge(taskId: string): Promise { + const { git } = await this.getWorkingDir(taskId); + await git.merge(['--abort']); + } + + /** + * Continue/finish merge after conflicts are resolved + */ + async continueMerge(taskId: string, commitMessage?: string): Promise<{ success: boolean; error?: string }> { + const { git } = await this.getWorkingDir(taskId); + + try { + // Commit the merge + await git.commit(commitMessage || 'Merge conflict resolution'); + return { success: true }; + } catch (error: any) { + const status = await this.getConflictStatus(taskId); + if (status.hasConflicts) { + return { + success: false, + error: `Still have ${status.conflictingFiles.length} conflicting file(s)` + }; + } + return { success: false, error: error.message }; + } + } +} diff --git a/web/src/components/task/ConflictResolver.tsx b/web/src/components/task/ConflictResolver.tsx new file mode 100644 index 00000000..09d0260c --- /dev/null +++ b/web/src/components/task/ConflictResolver.tsx @@ -0,0 +1,376 @@ +import { useState } from 'react'; +import { Button } from '@/components/ui/button'; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetDescription, +} from '@/components/ui/sheet'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Textarea } from '@/components/ui/textarea'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { + useConflictStatus, + useFileConflict, + useResolveConflict, + useAbortConflict, + useContinueConflict, +} from '@/hooks/useConflicts'; +import { + AlertTriangle, + Check, + X, + ChevronLeft, + ChevronRight, + FileWarning, + Loader2, + ArrowLeft, + ArrowRight, + GitMerge, +} from 'lucide-react'; +import type { Task } from '@veritas-kanban/shared'; +import { cn } from '@/lib/utils'; + +interface ConflictResolverProps { + task: Task; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function ConflictResolver({ task, open, onOpenChange }: ConflictResolverProps) { + const [selectedFile, setSelectedFile] = useState(null); + const [manualContent, setManualContent] = useState(''); + const [showAbortDialog, setShowAbortDialog] = useState(false); + + const { data: status, isLoading: statusLoading } = useConflictStatus(open ? task.id : undefined); + const { data: fileConflict, isLoading: fileLoading } = useFileConflict( + open && selectedFile ? task.id : undefined, + selectedFile || undefined + ); + + const resolveConflict = useResolveConflict(); + const abortConflict = useAbortConflict(); + const continueConflict = useContinueConflict(); + + // Auto-select first file if none selected + if (status?.conflictingFiles.length && !selectedFile) { + setSelectedFile(status.conflictingFiles[0]); + } + + const handleResolve = async (resolution: 'ours' | 'theirs' | 'manual') => { + if (!selectedFile) return; + + await resolveConflict.mutateAsync({ + taskId: task.id, + filePath: selectedFile, + resolution, + manualContent: resolution === 'manual' ? manualContent : undefined, + }); + + // Move to next file or close if done + const remaining = status?.conflictingFiles.filter(f => f !== selectedFile) || []; + if (remaining.length > 0) { + setSelectedFile(remaining[0]); + } else { + setSelectedFile(null); + } + }; + + const handleAbort = async () => { + await abortConflict.mutateAsync(task.id); + setShowAbortDialog(false); + onOpenChange(false); + }; + + const handleContinue = async () => { + const result = await continueConflict.mutateAsync({ taskId: task.id }); + if (result.success) { + onOpenChange(false); + } + }; + + const currentIndex = selectedFile && status?.conflictingFiles + ? status.conflictingFiles.indexOf(selectedFile) + : -1; + + const navigateFile = (direction: 'prev' | 'next') => { + if (!status?.conflictingFiles.length) return; + + let newIndex = currentIndex; + if (direction === 'prev') { + newIndex = currentIndex > 0 ? currentIndex - 1 : status.conflictingFiles.length - 1; + } else { + newIndex = currentIndex < status.conflictingFiles.length - 1 ? currentIndex + 1 : 0; + } + setSelectedFile(status.conflictingFiles[newIndex]); + }; + + // Initialize manual content when file changes + if (fileConflict && manualContent !== fileConflict.content) { + setManualContent(fileConflict.content); + } + + return ( + <> + + + +
+
+ + + Merge Conflicts + + + {status?.rebaseInProgress ? 'Rebase' : 'Merge'} has conflicts that need to be resolved + +
+ +
+ {status?.conflictingFiles.length === 0 && ( + + )} + +
+
+
+ +
+ {/* File list sidebar */} +
+
+

+ Conflicting Files ({status?.conflictingFiles.length || 0}) +

+
+ + {statusLoading ? ( +
+ + Loading... +
+ ) : status?.conflictingFiles.length === 0 ? ( +
+ + All conflicts resolved! +
+ ) : ( +
+ {status?.conflictingFiles.map((file) => ( + + ))} +
+ )} +
+
+ + {/* Main content area */} +
+ {selectedFile && fileConflict ? ( + <> + {/* File header with navigation */} +
+
+ + + {currentIndex + 1} of {status?.conflictingFiles.length} + + +
+ + {selectedFile} + +
+ + {/* Conflict viewer tabs */} + + + Side by Side + Manual Edit + + + {/* Side by side view */} + +
+ {/* Ours */} +
+
+ + + Ours (Current) + + +
+ +
+                              {fileConflict.oursContent || '(empty)'}
+                            
+
+
+ + {/* Theirs */} +
+
+ + + Theirs (Incoming) + + +
+ +
+                              {fileConflict.theirsContent || '(empty)'}
+                            
+
+
+
+
+ + {/* Manual edit view */} + +
+
+ + Manual Resolution + + +
+