// v2.8 Collision detection — pure functions that find file overlaps between // worktrees/agents editing the same files concurrently. Advisory only; writes // are never blocked, but the collision info surfaces in the UI and logs. // // Severity levels: // same_line — the same file, exact same line region // adjacent_line — the same file, lines touch or are within 5 lines // different_area — the same file, distant lines // // Pure functions, no side effects. Testable in isolation. export type ConflictSeverity = 'same_line' | 'adjacent_line' | 'different_area'; export interface ConflictVerdict { filePath: string; worktrees: string[]; severity: ConflictSeverity; agents: string[]; } /** * Registry entry for a single file change recorded by a worktree. * Stored in the ConflictIndex Map value for each file path. */ export interface ConflictEntry { worktreeId: string; agent: string; /** * Approximate line range touched by the change. undefined when the change * creates or deletes the file (full-file collision vs. same-line). */ lineRange?: { start: number; end: number }; status: 'pending' | 'applied' | 'reverted'; timestamp: number; } /** * Shape of the conflict index consumed by findConflicts. * File path → set of entries from different worktrees/agents. */ export type ConflictIndexData = ReadonlyMap>; /** * Find file overlaps between `changedFiles` and the conflict index, excluding * the caller's own worktree. * * Returns one ConflictVerdict per file that has entries from other worktrees. * Severity is the highest found (same_line > adjacent_line > different_area). */ export function findConflicts( changedFiles: string[], worktreeId: string, /** Approximate line range for the proposed changes, keyed by file path */ changedRanges: Map, conflictIndex: ConflictIndexData, ): ConflictVerdict[] { const verdicts: ConflictVerdict[] = []; for (const filePath of changedFiles) { const entries = conflictIndex.get(filePath); if (!entries || entries.size === 0) continue; // Filter to entries from OTHER worktrees const otherEntries = [...entries].filter((e) => e.worktreeId !== worktreeId); if (otherEntries.length === 0) continue; const myRange = changedRanges.get(filePath); let severity: ConflictSeverity = 'different_area'; for (const entry of otherEntries) { if (!myRange || !entry.lineRange) { // Full-file changes (create/delete) always hit at least different_area continue; } const sev = lineOverlapSeverity(myRange, entry.lineRange); if (sev === 'same_line') { severity = 'same_line'; break; // Can't get higher than this } if (sev === 'adjacent_line' && severity === 'different_area') { severity = 'adjacent_line'; } } const worktrees = [...new Set(otherEntries.map((e) => e.worktreeId))]; const agents = [...new Set(otherEntries.map((e) => e.agent))]; verdicts.push({ filePath, worktrees, severity, agents }); } return verdicts; } const ADJACENT_LINE_THRESHOLD = 5; /** * Determine severity of overlap between two line ranges. */ function lineOverlapSeverity( a: { start: number; end: number }, b: { start: number; end: number }, ): ConflictSeverity { // Same_line: ranges intersect if (a.start <= b.end && b.start <= a.end) { return 'same_line'; } // Adjacent: ranges are within ADJACENT_LINE_THRESHOLD lines of each other const gap = a.start > b.end ? a.start - b.end : b.start - a.end; if (gap <= ADJACENT_LINE_THRESHOLD) { return 'adjacent_line'; } return 'different_area'; }