import { normalizeGitRemote } from "../utils/git-remote"; export type ReconciliationMatchType = | "local-git" | "local-name" | "org-git" | "org-name" | "local-new-git" | "local-new-name"; export interface ProjectSuggestion { name: string; gitRemotes: string[]; } export interface RootLike { id: string; path: string; detectedName?: string | null; gitRemote?: string | null; } export interface GroupLike { id: string; name: string; roots: TRoot[]; } export interface ReconciliationTargetGroup { id: string | null; name: string; } export interface UnassignedRootMapping { root: TRoot; targetGroup: ReconciliationTargetGroup; matchType: ReconciliationMatchType; } export interface DuplicatePair { keep: TGroup; merge: TGroup; } export interface OrgOnlyProject { name: string; gitRemoteCount: number; } export interface ReconciliationData< TRoot extends RootLike = RootLike, TGroup extends GroupLike = GroupLike, > { unassigned: UnassignedRootMapping[]; duplicates: DuplicatePair[]; orgOnly: OrgOnlyProject[]; } export interface ClusteredRoots { directory: string; displayDirectory: string; roots: TRoot[]; } interface LocalLookup { localRemoteToGroup: Map; localNameToGroup: Map; } const SESSION_STYLE_NAME_RE = /^(claude|gemini|codex|cursor|opencode)\s+session\b/i; function isVirtualRoutePath(path: string): boolean { return path.startsWith("virtual://"); } function getFolderName(path: string): string { return path.split("/").pop()?.toLowerCase() ?? ""; } function getFolderNameRaw(path: string): string { return path.split("/").pop()?.trim() ?? ""; } function sanitizeDetectedName(root: RootLike): string { const detectedName = root.detectedName?.trim() ?? ""; if (!detectedName) return ""; if ( !isVirtualRoutePath(root.path) && SESSION_STYLE_NAME_RE.test(detectedName) ) { return ""; } return detectedName; } export function getRootDisplayName(root: RootLike): string { const detectedName = sanitizeDetectedName(root); return detectedName || getFolderNameRaw(root.path); } function deriveRemoteRepoName( remote: string | null | undefined, ): string | null { const normalized = normalizeGitRemote(remote); if (!normalized) return null; const repo = normalized.split("/").pop()?.trim() ?? ""; return repo.length > 0 ? repo : null; } function buildLocalLookup( groups: TGroup[], ): LocalLookup { const localRemoteToGroup = new Map(); const localNameToGroup = new Map(); for (const group of groups) { localNameToGroup.set(group.name.toLowerCase(), { id: group.id, name: group.name, }); for (const root of group.roots) { const normalized = normalizeGitRemote(root.gitRemote); if (!normalized) continue; localRemoteToGroup.set(normalized, { id: group.id, name: group.name }); } } return { localRemoteToGroup, localNameToGroup }; } interface UngroupedLookup { ungroupedRemotes: Set; ungroupedNames: Set; } export interface RootMatchKeys { /** Normalized git remotes for this root. */ remotes: string[]; /** Lowercased names this root can match on (folder, detected, remote repo). */ names: string[]; } /** * The normalized keys a root can be matched on — the same name/remote primitives * the reconciliation engine uses internally. Shared so cloud-project matching * stays consistent with the engine instead of re-deriving a name-only heuristic. */ export function getRootMatchKeys(root: RootLike): RootMatchKeys { const remotes: string[] = []; const names: string[] = []; const normalized = normalizeGitRemote(root.gitRemote); if (normalized) { remotes.push(normalized); const remoteName = deriveRemoteRepoName(root.gitRemote); if (remoteName) names.push(remoteName.toLowerCase()); } const folderName = getFolderName(root.path); if (folderName) names.push(folderName); const detectedName = sanitizeDetectedName(root).toLowerCase(); if (detectedName) names.push(detectedName); return { remotes, names }; } /** * Build name/remote sets for discovered-but-ungrouped roots. A cloud project * that shares a git remote or name with one of these roots is matchable — it * should not be reported as "No local match" just because the user hasn't * created groups yet. */ function buildUngroupedLookup( ungrouped: TRoot[], ): UngroupedLookup { const ungroupedRemotes = new Set(); const ungroupedNames = new Set(); for (const root of ungrouped) { const { remotes, names } = getRootMatchKeys(root); for (const remote of remotes) ungroupedRemotes.add(remote); for (const name of names) ungroupedNames.add(name); } return { ungroupedRemotes, ungroupedNames }; } export function computeDuplicateGroupPairs( groups: TGroup[], ): DuplicatePair[] { const duplicateGroupsByName = new Map(); for (const group of groups) { const key = group.name.toLowerCase(); const existing = duplicateGroupsByName.get(key); if (existing) { existing.push(group); } else { duplicateGroupsByName.set(key, [group]); } } const duplicates: DuplicatePair[] = []; for (const sameNameGroups of duplicateGroupsByName.values()) { if (sameNameGroups.length < 2) continue; const sorted = [...sameNameGroups].sort( (a, b) => b.roots.length - a.roots.length, ); for (let i = 1; i < sorted.length; i++) { duplicates.push({ keep: sorted[0], merge: sorted[i] }); } } return duplicates; } export function getAutoCheckedMappingIds( mappings: UnassignedRootMapping[], ): Set { return new Set( mappings // Auto-check only high-confidence git matches that resolve to an // existing group. Suggestions that would create new groups require // explicit user confirmation. .filter( (mapping) => isGitMatch(mapping.matchType) && !!mapping.targetGroup.id, ) .map((mapping) => mapping.root.id), ); } export function isGitMatch(matchType: ReconciliationMatchType): boolean { return ( matchType === "local-git" || matchType === "org-git" || matchType === "local-new-git" ); } // A "residual" root is one the deterministic reconciliation can't resolve to an // existing group — it can only propose forming a NEW group (from a git remote or // a bare name). These are the ambiguous minority where AI clustering/naming adds // value; every other match type already has a confident deterministic target // (auto-applied for git-exact, or offered as a checkable suggestion otherwise). // Scoping the AI request to just these keeps the review small instead of asking // the model to rubber-stamp matches a string compare already nailed. export function isResidualMatchType( matchType: ReconciliationMatchType, ): boolean { return matchType === "local-new-git" || matchType === "local-new-name"; } export function getResidualMappingIds( mappings: UnassignedRootMapping[], ): Set { return new Set( mappings .filter((mapping) => isResidualMatchType(mapping.matchType)) .map((mapping) => mapping.root.id), ); } export function buildReconciliationMap( mappings: UnassignedRootMapping[], ): Map> { const map = new Map>(); for (const mapping of mappings) { map.set(mapping.root.id, mapping); } return map; } export function computeProjectReconciliation< TRoot extends RootLike, TGroup extends GroupLike, >( groups: TGroup[], ungrouped: TRoot[], suggestions?: ProjectSuggestion[], ): ReconciliationData { const duplicates = computeDuplicateGroupPairs(groups); const { localRemoteToGroup, localNameToGroup } = buildLocalLookup(groups); const orgRemoteToName = new Map(); const orgNameToSuggestion = new Map(); for (const suggestion of suggestions ?? []) { orgNameToSuggestion.set(suggestion.name.toLowerCase(), suggestion); for (const remote of suggestion.gitRemotes) { const normalized = normalizeGitRemote(remote); if (!normalized) continue; orgRemoteToName.set(normalized, suggestion.name); } } const unassigned: UnassignedRootMapping[] = []; for (const root of ungrouped) { const rootRemote = normalizeGitRemote(root.gitRemote); const rootRemoteName = deriveRemoteRepoName(root.gitRemote); const folderName = getFolderName(root.path); const folderNameRaw = getFolderNameRaw(root.path); const detectedNameRaw = sanitizeDetectedName(root); const detectedName = detectedNameRaw.toLowerCase(); // Priority: name match > git remote match. // Name matches are higher confidence because they reflect the user's // chosen project naming, while git remotes can be ambiguous (forks, // mirrors, renamed repos). const localNameMatch = localNameToGroup.get(folderName) ?? localNameToGroup.get(detectedName) ?? null; if (localNameMatch) { unassigned.push({ root, targetGroup: localNameMatch, matchType: "local-name", }); continue; } if (!rootRemote) { const orgMatch = orgNameToSuggestion.get(folderName) ?? orgNameToSuggestion.get(detectedName) ?? null; if (orgMatch) { const localMatch = localNameToGroup.get(orgMatch.name.toLowerCase()); unassigned.push({ root, targetGroup: localMatch ?? { id: null, name: orgMatch.name }, matchType: "org-name", }); continue; } } const localRemoteMatch = rootRemote ? localRemoteToGroup.get(rootRemote) : undefined; if (localRemoteMatch) { unassigned.push({ root, targetGroup: localRemoteMatch, matchType: "local-git", }); continue; } const orgRemoteName = rootRemote ? orgRemoteToName.get(rootRemote) : null; if (orgRemoteName) { const localMatch = localNameToGroup.get(orgRemoteName.toLowerCase()) ?? null; unassigned.push({ root, targetGroup: localMatch ?? { id: null, name: orgRemoteName }, matchType: "org-git", }); continue; } // Fallback: suggest creating a new local group when nothing matches. if (rootRemoteName) { const existingLocalByRemoteName = localNameToGroup.get( rootRemoteName.toLowerCase(), ); if (existingLocalByRemoteName) { unassigned.push({ root, targetGroup: existingLocalByRemoteName, matchType: "local-name", }); } else { unassigned.push({ root, targetGroup: { id: null, name: rootRemoteName }, matchType: "local-new-git", }); } continue; } const fallbackName = detectedNameRaw || folderNameRaw; if (!fallbackName) continue; const existingLocalByFallbackName = localNameToGroup.get( fallbackName.toLowerCase(), ); if (existingLocalByFallbackName) { unassigned.push({ root, targetGroup: existingLocalByFallbackName, matchType: "local-name", }); continue; } unassigned.push({ root, targetGroup: { id: null, name: fallbackName }, matchType: "local-new-name", }); } const { ungroupedRemotes, ungroupedNames } = buildUngroupedLookup(ungrouped); const orgOnly: OrgOnlyProject[] = []; for (const suggestion of suggestions ?? []) { const hasLocalName = localNameToGroup.has(suggestion.name.toLowerCase()); const hasLocalRemote = suggestion.gitRemotes.some((remote) => { const normalized = normalizeGitRemote(remote); if (!normalized) return false; return localRemoteToGroup.has(normalized); }); // Ungrouped roots count as a local presence too. A user with many // discovered roots but zero groups would otherwise see every cloud // project flagged "No local match" even when a root shares its repo. const hasUngroupedName = ungroupedNames.has(suggestion.name.toLowerCase()); const hasUngroupedRemote = suggestion.gitRemotes.some((remote) => { const normalized = normalizeGitRemote(remote); if (!normalized) return false; return ungroupedRemotes.has(normalized); }); if ( !hasLocalName && !hasLocalRemote && !hasUngroupedName && !hasUngroupedRemote ) { orgOnly.push({ name: suggestion.name, gitRemoteCount: suggestion.gitRemotes.length, }); } } const matchTypePriority: Record = { "local-name": 0, "org-name": 1, "local-git": 2, "org-git": 3, "local-new-git": 4, "local-new-name": 5, }; unassigned.sort( (a, b) => matchTypePriority[a.matchType] - matchTypePriority[b.matchType], ); return { unassigned, duplicates, orgOnly }; } /** * Per-cloud-project status, derived from name + git-remote matching against the * user's local roots — never a name-only heuristic. * - "linked": a local group already covers this cloud project; nothing to do. * - "mappable": ungrouped local roots match it; one click can link them. * - "unmatched": no local presence at all (mirrors `orgOnly`). */ export type CloudProjectStatus = "linked" | "mappable" | "unmatched"; /** * WHY a cloud project matched a local presence — orthogonal to {@link * CloudProjectStatus} (which says whether it's already grouped). The engine * already derives this internally (name/path equality vs shared git remote); * surfacing it lets the UI distinguish "this repo is present locally" (path) * from "same origin discovered under a different path" (git-remote), and lets * the Cloud panel show a legible matched-by-path / matched-by-git-remote / * unmatched breakdown instead of one opaque "matched" total (OPL-3015). * - "path": matched a local project by name/path presence. * - "git-remote": matched only by a shared git remote (path differs). * - "none": no local presence (mirrors "unmatched"). */ export type CloudMatchBasis = "path" | "git-remote" | "none"; export interface CloudProjectMapping { name: string; gitRemotes: string[]; status: CloudProjectStatus; /** Ungrouped root ids to assign when mapping this cloud project ("mappable"). */ rootIds: string[]; /** How this cloud project matched a local presence (see {@link CloudMatchBasis}). */ matchBasis: CloudMatchBasis; } /** * Classify each cloud suggestion against the local roots, deriving the mappable * roots from the reconciliation engine's own decisions rather than a parallel * name/remote heuristic. * * A cloud project's `rootIds` are exactly the ungrouped roots whose * reconciliation TARGET is that cloud project (the engine sets `targetGroup.name` * to the cloud project's name for org-name / org-git matches, and to the * matching local group's name otherwise). Keying on the reconciliation target — * not on cloud-name equality — guarantees that clicking "Map" on a row assigns * roots to exactly the project the row names. A root whose git remote org-git- * matches cloud project "Bar" is therefore offered only under "Bar", never under * a same-named-but-different cloud project. * * `orgOnly` (from `computeProjectReconciliation`) is the authoritative unmatched * set used to distinguish "unmatched" from "already linked to a local group". */ export function computeCloudProjectMappings< TRoot extends RootLike, TGroup extends GroupLike = GroupLike, >( suggestions: ProjectSuggestion[], unassigned: UnassignedRootMapping[], orgOnly: OrgOnlyProject[], localGroups: TGroup[] = [], ): CloudProjectMapping[] { const orgOnlyNames = new Set(orgOnly.map((p) => p.name.toLowerCase())); // A cloud project is already "linked" when a local group covers it — by name // or by a shared git remote. That must win over "mappable": otherwise a single // stray ungrouped sibling root (the same repo discovered at another path, a // session root, a worktree) that still reconciles to the same name would keep // an already-mapped project showing the "map" chain icon forever. The stray // root can still be mapped from its own inline picker. const localGroupNames = new Set( localGroups.map((g) => g.name.trim().toLowerCase()), ); const localGroupRemotes = new Set(); for (const group of localGroups) { for (const root of group.roots) { const normalized = normalizeGitRemote(root.gitRemote); if (normalized) localGroupRemotes.add(normalized); } } // Record not just THAT a cloud project is covered but HOW: a name/path match // (the repo is present locally) outranks a git-remote-only match (same origin, // different path). Prefer "path" when both hold so the UI can show it as // present rather than merely remote-linked. const coveredCloudNames = new Set(); const coveredBasisByName = new Map(); for (const suggestion of suggestions) { const lower = suggestion.name.trim().toLowerCase(); const coveredByName = localGroupNames.has(lower); const coveredByRemote = suggestion.gitRemotes.some((remote) => { const normalized = normalizeGitRemote(remote); return normalized ? localGroupRemotes.has(normalized) : false; }); if (coveredByName || coveredByRemote) { coveredCloudNames.add(lower); coveredBasisByName.set(lower, coveredByName ? "path" : "git-remote"); } } // Group ungrouped-root mappings by their reconciliation target name. This is // the assignment `applyReconciliation` will actually perform. Track the match // basis alongside: a git-remote match anywhere in the group only sets the // basis to "git-remote" if no name/path match also resolved to it. const rootIdsByTargetName = new Map(); const mappableBasisByName = new Map(); for (const mapping of unassigned) { const key = mapping.targetGroup.name.toLowerCase(); const existing = rootIdsByTargetName.get(key); if (existing) { existing.push(mapping.root.id); } else { rootIdsByTargetName.set(key, [mapping.root.id]); } const basis: CloudMatchBasis = isGitMatch(mapping.matchType) ? "git-remote" : "path"; // "path" wins once seen — a repo present locally by name outranks a // remote-only sibling that reconciled to the same target. if (mappableBasisByName.get(key) !== "path") { mappableBasisByName.set(key, basis); } } return suggestions.map((suggestion) => { const lowerName = suggestion.name.toLowerCase(); const rootIds = rootIdsByTargetName.get(lowerName) ?? []; let status: CloudProjectStatus; if (coveredCloudNames.has(lowerName)) { status = "linked"; } else if (rootIds.length > 0) { status = "mappable"; } else if (orgOnlyNames.has(lowerName)) { status = "unmatched"; } else { status = "linked"; } const matchBasis: CloudMatchBasis = status === "unmatched" ? "none" : (coveredBasisByName.get(lowerName) ?? mappableBasisByName.get(lowerName) ?? "path"); return { name: suggestion.name, gitRemotes: suggestion.gitRemotes, // A covered project is linked; its stray roots map from their own picker. rootIds: status === "linked" ? [] : [...rootIds], status, matchBasis, }; }); } export interface CloudMatchBasisSummary { /** Cloud projects present locally by name/path. */ byPath: number; /** Cloud projects matched only by a shared git remote (path differs). */ byGitRemote: number; /** Cloud projects with no local presence. */ unmatched: number; } /** * Roll up {@link CloudProjectMapping.matchBasis} into a legible breakdown for * the Cloud panel's totals (OPL-3015): how many cloud projects resolved by * local path, by git remote, and how many have no local match — replacing the * single opaque "N matched" number with the granular counts. */ export function summarizeCloudMatchBasis( mappings: CloudProjectMapping[], ): CloudMatchBasisSummary { const summary: CloudMatchBasisSummary = { byPath: 0, byGitRemote: 0, unmatched: 0, }; for (const mapping of mappings) { if (mapping.matchBasis === "path") summary.byPath += 1; else if (mapping.matchBasis === "git-remote") summary.byGitRemote += 1; else summary.unmatched += 1; } return summary; } export function getCloudBackedGroupKeys( groups: TGroup[], suggestions: ProjectSuggestion[], ): Set { const cloudNames = new Set( suggestions.map((suggestion) => suggestion.name.trim().toLowerCase()), ); const cloudRemotes = new Set(); for (const suggestion of suggestions) { for (const remote of suggestion.gitRemotes) { const normalized = normalizeGitRemote(remote); if (normalized) cloudRemotes.add(normalized); } } const keys = new Set(); for (const group of groups) { const nameMatches = cloudNames.has(group.name.trim().toLowerCase()); const remoteMatches = group.roots.some((root) => { const normalized = normalizeGitRemote(root.gitRemote); return normalized ? cloudRemotes.has(normalized) : false; }); if (nameMatches || remoteMatches) keys.add(group.name); } return keys; } function formatDirectory(directory: string): string { return directory.replace(/^\/Users\/[^/]+/, "~"); } function getParentDirectory(path: string): string { const segments = path.split("/"); const parent = segments.slice(0, -1).join("/"); if (parent.length > 0) return parent; if (path.startsWith("/")) return "/"; return "."; } export function clusterRootsByParentDirectory( roots: TRoot[], ): ClusteredRoots[] { const directoryToRoots = new Map(); for (const root of roots) { const directory = getParentDirectory(root.path); const existing = directoryToRoots.get(directory); if (existing) { existing.push(root); } else { directoryToRoots.set(directory, [root]); } } return [...directoryToRoots.entries()] .sort((a, b) => { if (a[1].length !== b[1].length) return b[1].length - a[1].length; return a[0].localeCompare(b[0]); }) .map(([directory, groupedRoots]) => ({ directory, displayDirectory: formatDirectory(directory), roots: groupedRoots, })); }