/** * Normalize git remote strings into a stable comparable form. * * Examples: * - git@github.com:owner/repo.git -> github.com/owner/repo * - https://github.com/owner/repo.git -> github.com/owner/repo * - ssh://git@github.com/owner/repo -> github.com/owner/repo */ export function normalizeGitRemote( remote: string | null | undefined, ): string | null { if (!remote) return null; let value = remote.trim(); if (!value) return null; // npm-style git URLs can include `git+` prefix. value = value.replace(/^git\+/i, ""); // SCP-like syntax: git@github.com:owner/repo if (/^[^@]+@[^/:\s]+:[^/\s].+/.test(value) && !value.includes("://")) { const atIndex = value.indexOf("@"); const colonIndex = value.indexOf(":", atIndex + 1); const host = value.slice(atIndex + 1, colonIndex); const path = value.slice(colonIndex + 1); value = `${host}/${path}`; } else { // URL-like syntax: https://, ssh://, git:// value = value.replace(/^[a-z][a-z0-9+.-]*:\/\//i, ""); // Strip possible userinfo (git@, token@, user:pass@) value = value.replace(/^[^@/]+@/, ""); } value = value .replace(/\/+$/, "") .replace(/\.git$/i, "") .replace(/\/+$/, ""); return value ? value.toLowerCase() : null; } /** * Resolve a project's canonical git identity as the UNION of every attached * root's normalized remote — not whichever single root happens to be attached. * * A project's git identity should never depend on root-attachment luck: today * a project is treated as gitless whenever the first/only root the caller looks * at has no remote, even when a sibling root carries one. Passing every root's * remote here makes gitless twins (null/empty) simply drop out instead of * erasing the identity. Remotes are normalized (so equivalent SSH/HTTPS forms * collapse) and de-duplicated; the result is sorted for stable output. */ export function resolveGitRemotes( remotes: readonly (string | null | undefined)[], ): string[] { const resolved = new Set(); for (const remote of remotes) { const normalized = normalizeGitRemote(remote); if (normalized) resolved.add(normalized); } return Array.from(resolved).sort(); } /** * Canonical, ingester-independent identity for a git commit: * `:`. The same commit synced from multiple * devices/users or pulled from GitHub yields the same key, so it can be * deduplicated/grouped regardless of provenance. * * Returns null when the remote or sha is missing — without a remote a bare sha * isn't safely comparable across repos, so such commits stay ungrouped. */ export function commitGroupingKey( remote: string | null | undefined, sha: string | null | undefined, ): string | null { const normalized = normalizeGitRemote(remote); const trimmedSha = sha?.trim(); if (!normalized || !trimmedSha) return null; return `${normalized}:${trimmedSha.toLowerCase()}`; } /** * Timeline git events normally use the raw commit SHA as sourceId. Older * GitHub-imported rows used `github:/:` to avoid collisions; * canonical commit grouping needs the raw SHA regardless of which form is * stored. */ export function gitShaFromSourceId( sourceId: string | null | undefined, ): string | null { const value = sourceId?.trim(); if (!value) return null; if (!value.startsWith("github:")) return value; const sha = value.split(":").pop()?.trim(); return sha || null; } /** * Whether a normalized remote looks like a real repository reference. * * normalizeGitRemote only canonicalizes — arbitrary text passes through it * unchanged — so user-supplied remotes must also pass this check before * being persisted. Expects `host.tld[:port]/path` with no whitespace, * e.g. `github.com/owner/repo`. */ export function isLikelyGitRemote(normalized: string): boolean { return /^[a-z0-9][a-z0-9.-]*\.[a-z]{2,}(:\d+)?\/\S+$/.test(normalized); }