import type { DetectedChange, MoveCandidate } from './types'; import { stringSimilarity } from './utils/string-similarity'; const DEFAULT_THRESHOLD = 0.7; const LARGE_FILE_BYTES = 4096; const SAMPLE_WINDOW = 100; export interface MoveDetectionResult { moves: MoveCandidate[]; remainingChanges: DetectedChange[]; } /** * Detect renames/moves by pairing local deletions with local additions * that have sufficiently similar content (Sørensen–Dice ≥ threshold). * * @param changes - All locally detected changes. * @param getContent - Async getter for current FS content. Returns null for * unreadable / binary files (those are skipped as non-movable). * @param threshold - Minimum similarity score. Default 0.7. */ export async function detectMoves( changes: DetectedChange[], getContent: (path: string) => Promise, threshold = DEFAULT_THRESHOLD, ): Promise { const deletions = changes.filter( (c) => c.type === 'delete' && !c.isDirectory, ); const additions = changes.filter((c) => c.type === 'add' && !c.isDirectory); const others = changes.filter( (c) => (c.type !== 'delete' && c.type !== 'add') || c.isDirectory, ); const deleteContents = new Map(); const addContents = new Map(); await Promise.all([ ...deletions.map(async (c) => { deleteContents.set(c.path, await getContent(c.path).catch(() => null)); }), ...additions.map(async (c) => { addContents.set(c.path, await getContent(c.path).catch(() => null)); }), ]); const claimed = new Set(); const moves: MoveCandidate[] = []; const matchedDeletions = new Set(); for (const deletion of deletions) { const delContent = deleteContents.get(deletion.path); if (delContent === null || delContent === undefined) continue; let bestScore = 0; let bestAddition: DetectedChange | null = null; for (const addition of additions) { if (claimed.has(addition.path)) continue; const addContent = addContents.get(addition.path); if (addContent === null || addContent === undefined) continue; const score = computeSimilarity(delContent, addContent); if (score > bestScore) { bestScore = score; bestAddition = addition; } } if (bestScore >= threshold && bestAddition) { moves.push({ oldPath: deletion.path, newPath: bestAddition.path, similarity: bestScore, }); claimed.add(bestAddition.path); matchedDeletions.add(deletion.path); } } const remainingChanges: DetectedChange[] = [ ...others, ...deletions.filter((d) => !matchedDeletions.has(d.path)), ...additions.filter((a) => !claimed.has(a.path)), ]; return { moves, remainingChanges }; } function computeSimilarity(a: string, b: string): number { if (a.length > LARGE_FILE_BYTES || b.length > LARGE_FILE_BYTES) { const len = Math.min(a.length, b.length); const scores = [0.25, 0.5, 0.75].map((pos) => { const start = Math.floor(pos * len); const end = Math.min(start + SAMPLE_WINDOW, len); const wa = a.slice(start, end); const wb = b.slice(start, end); if (wa.length < 2 || wb.length < 2) return 0; return stringSimilarity(wa, wb); }); return scores.reduce((sum, s) => sum + s, 0) / scores.length; } return stringSimilarity(a, b); }