import type { Database } from "bun:sqlite"; import { normalizeGitRemote } from "../utils/git-remote"; type ProjectRootRow = { id: string; scanRootId: string | null; projectId: string | null; deviceId: string | null; path: string; detectedName: string | null; gitRemote: string | null; peacockColor: string | null; createdAt: number | null; }; export type ProjectRootDedupResult = { groupsMerged: number; rootsRemoved: number; normalizedRemotes: number; associationsRepointed: number; eventsRepointed: number; liveSessionsRepointed: number; skippedRemoteConflicts: number; skippedProjectConflicts: number; }; function groupKey(root: ProjectRootRow): string { return JSON.stringify([root.deviceId, root.path]); } function selectSurvivor(roots: ProjectRootRow[]): ProjectRootRow { return [...roots].sort((a, b) => { const remoteDifference = Number(Boolean(normalizeGitRemote(b.gitRemote))) - Number(Boolean(normalizeGitRemote(a.gitRemote))); if (remoteDifference !== 0) return remoteDifference; const projectDifference = Number(Boolean(b.projectId)) - Number(Boolean(a.projectId)); if (projectDifference !== 0) return projectDifference; const createdDifference = (a.createdAt ?? Number.MAX_SAFE_INTEGER) - (b.createdAt ?? Number.MAX_SAFE_INTEGER); return createdDifference || a.id.localeCompare(b.id); })[0]; } /** * Merge only unambiguous duplicate roots. Conflicting remotes or project * assignments remain untouched because choosing either would discard meaning. */ export function deduplicateProjectRootsByPath( sqlite: Database, ): ProjectRootDedupResult { const result: ProjectRootDedupResult = { groupsMerged: 0, rootsRemoved: 0, normalizedRemotes: 0, associationsRepointed: 0, eventsRepointed: 0, liveSessionsRepointed: 0, skippedRemoteConflicts: 0, skippedProjectConflicts: 0, }; const roots = sqlite .prepare( `SELECT id, scan_root_id AS scanRootId, project_id AS projectId, device_id AS deviceId, path, detected_name AS detectedName, git_remote AS gitRemote, peacock_color AS peacockColor, created_at AS createdAt FROM project_roots ORDER BY path, device_id, created_at, id`, ) .all() as ProjectRootRow[]; const groups = new Map(); for (const root of roots) { const key = groupKey(root); const group = groups.get(key); if (group) group.push(root); else groups.set(key, [root]); } const updateSurvivor = sqlite.prepare( `UPDATE project_roots SET scan_root_id = ?, project_id = ?, detected_name = ?, git_remote = ?, peacock_color = ? WHERE id = ?`, ); const repointAssociation = sqlite.prepare( "UPDATE project_associations SET project_root_id = ? WHERE project_root_id = ?", ); const repointEvent = sqlite.prepare( "UPDATE timeline_events SET project_root_id = ? WHERE project_root_id = ?", ); const repointLiveSession = sqlite.prepare( "UPDATE live_activity_sessions SET project_root_id = ? WHERE project_root_id = ?", ); const deleteRoot = sqlite.prepare("DELETE FROM project_roots WHERE id = ?"); const merge = sqlite.transaction(() => { for (const group of groups.values()) { if (group.length < 2) continue; const normalizedRemotes = new Set( group .map((root) => normalizeGitRemote(root.gitRemote)) .filter((remote): remote is string => remote !== null), ); if (normalizedRemotes.size > 1) { result.skippedRemoteConflicts++; continue; } const projectIds = new Set( group .map((root) => root.projectId) .filter((projectId): projectId is string => projectId !== null), ); if (projectIds.size > 1) { result.skippedProjectConflicts++; continue; } const survivor = selectSurvivor(group); const duplicates = group.filter((root) => root.id !== survivor.id); const gitRemote = normalizedRemotes.values().next().value ?? null; const projectId = projectIds.values().next().value ?? null; const detectedName = survivor.detectedName ?? group.find((root) => root.detectedName)?.detectedName ?? null; const peacockColor = survivor.peacockColor ?? group.find((root) => root.peacockColor)?.peacockColor ?? null; const scanRootId = survivor.scanRootId ?? group.find((root) => root.scanRootId)?.scanRootId ?? null; updateSurvivor.run( scanRootId, projectId, detectedName, gitRemote, peacockColor, survivor.id, ); if (gitRemote && survivor.gitRemote !== gitRemote) { result.normalizedRemotes++; } for (const duplicate of duplicates) { result.associationsRepointed += repointAssociation.run( survivor.id, duplicate.id, ).changes; result.eventsRepointed += repointEvent.run( survivor.id, duplicate.id, ).changes; result.liveSessionsRepointed += repointLiveSession.run( survivor.id, duplicate.id, ).changes; result.rootsRemoved += deleteRoot.run(duplicate.id).changes; } result.groupsMerged++; } }); merge(); return result; }