import type { GitBranch, GitHostingProvider, GitStatusLocalResult, GitStatusRemoteResult, GitStatusResult, GitStatusStreamEvent, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Random from "effect/Random"; export const WORKTREE_BRANCH_PREFIX = "t3code"; const TEMP_WORKTREE_BRANCH_PATTERN = new RegExp(`^${WORKTREE_BRANCH_PREFIX}\\/[0-9a-f]{8}$`); /** * Sanitize an arbitrary string into a valid, lowercase git branch fragment. * Strips quotes, collapses separators, limits to 64 chars. */ export function sanitizeBranchFragment(raw: string): string { const normalized = raw .trim() .toLowerCase() .replace(/['"`]/g, "") .replace(/^[./\s_-]+|[./\s_-]+$/g, ""); const branchFragment = normalized .replace(/[^a-z0-9/_-]+/g, "-") .replace(/\/+/g, "/") .replace(/-+/g, "-") .replace(/^[./_-]+|[./_-]+$/g, "") .slice(0, 64) .replace(/[./_-]+$/g, ""); return branchFragment.length > 0 ? branchFragment : "update"; } /** * Sanitize a string into a `feature/…` branch name. * Preserves an existing `feature/` prefix or slash-separated namespace. */ export function sanitizeFeatureBranchName(raw: string): string { const sanitized = sanitizeBranchFragment(raw); if (sanitized.includes("/")) { return sanitized.startsWith("feature/") ? sanitized : `feature/${sanitized}`; } return `feature/${sanitized}`; } const AUTO_FEATURE_BRANCH_FALLBACK = "feature/update"; /** * Resolve a unique `feature/…` branch name that doesn't collide with * any existing branch. Appends a numeric suffix when needed. */ export function resolveAutoFeatureBranchName( existingBranchNames: readonly string[], preferredBranch?: string, ): string { const preferred = preferredBranch?.trim(); const resolvedBase = sanitizeFeatureBranchName( preferred && preferred.length > 0 ? preferred : AUTO_FEATURE_BRANCH_FALLBACK, ); const existingNames = new Set(existingBranchNames.map((branch) => branch.toLowerCase())); if (!existingNames.has(resolvedBase)) { return resolvedBase; } let suffix = 2; while (existingNames.has(`${resolvedBase}-${suffix}`)) { suffix += 1; } return `${resolvedBase}-${suffix}`; } /** * Strip the remote prefix from a remote ref such as `origin/feature/demo`. */ export function deriveLocalBranchNameFromRemoteRef(branchName: string): string { const firstSeparatorIndex = branchName.indexOf("/"); if (firstSeparatorIndex <= 0 || firstSeparatorIndex === branchName.length - 1) { return branchName; } return branchName.slice(firstSeparatorIndex + 1); } export function buildTemporaryWorktreeBranchName(): string { const token = Effect.runSync(Random.nextUUIDv4).replace(/-/g, "").slice(0, 8).toLowerCase(); return `${WORKTREE_BRANCH_PREFIX}/${token}`; } export function isTemporaryWorktreeBranch(branch: string): boolean { return TEMP_WORKTREE_BRANCH_PATTERN.test(branch.trim().toLowerCase()); } /** * Normalize a git remote URL into a stable comparison key. */ export function normalizeGitRemoteUrl(value: string): string { const normalized = value .trim() .replace(/\/+$/g, "") .replace(/\.git$/i, "") .toLowerCase(); if (/^(?:ssh|https?|git):\/\//i.test(normalized)) { try { const url = new URL(normalized); const repositoryPath = url.pathname .split("/") .filter((segment) => segment.length > 0) .join("/"); if (url.hostname && repositoryPath.includes("/")) { return `${url.hostname}/${repositoryPath}`; } } catch { return normalized; } } const scpStyleHostAndPath = /^git@([^:/\s]+)[:/]([^/\s]+(?:\/[^/\s]+)+)$/i.exec(normalized); if (scpStyleHostAndPath?.[1] && scpStyleHostAndPath[2]) { return `${scpStyleHostAndPath[1]}/${scpStyleHostAndPath[2]}`; } return normalized; } /** * Best-effort parse of a GitHub `owner/repo` identifier from common remote URL shapes. */ export function parseGitHubRepositoryNameWithOwnerFromRemoteUrl(url: string | null): string | null { const trimmed = url?.trim() ?? ""; if (trimmed.length === 0) { return null; } const match = /^(?:git@github\.com:|ssh:\/\/git@github\.com\/|https:\/\/github\.com\/|git:\/\/github\.com\/)([^/\s]+\/[^/\s]+?)(?:\.git)?\/?$/i.exec( trimmed, ); const repositoryNameWithOwner = match?.[1]?.trim() ?? ""; return repositoryNameWithOwner.length > 0 ? repositoryNameWithOwner : null; } function deriveLocalBranchNameCandidatesFromRemoteRef( branchName: string, remoteName?: string, ): ReadonlyArray { const candidates = new Set(); const firstSlashCandidate = deriveLocalBranchNameFromRemoteRef(branchName); if (firstSlashCandidate.length > 0) { candidates.add(firstSlashCandidate); } if (remoteName) { const remotePrefix = `${remoteName}/`; if (branchName.startsWith(remotePrefix) && branchName.length > remotePrefix.length) { candidates.add(branchName.slice(remotePrefix.length)); } } return [...candidates]; } /** * Hide `origin/*` remote refs when a matching local branch already exists. */ export function dedupeRemoteBranchesWithLocalMatches( branches: ReadonlyArray, ): ReadonlyArray { const localBranchNames = new Set( branches.filter((branch) => !branch.isRemote).map((branch) => branch.name), ); return branches.filter((branch) => { if (!branch.isRemote) { return true; } if (branch.remoteName !== "origin") { return true; } const localBranchCandidates = deriveLocalBranchNameCandidatesFromRemoteRef( branch.name, branch.remoteName, ); return !localBranchCandidates.some((candidate) => localBranchNames.has(candidate)); }); } function parseGitRemoteHost(remoteUrl: string): string | null { const trimmed = remoteUrl.trim(); if (trimmed.length === 0) { return null; } if (trimmed.startsWith("git@")) { const hostWithPath = trimmed.slice("git@".length); const separatorIndex = hostWithPath.search(/[:/]/); if (separatorIndex <= 0) { return null; } return hostWithPath.slice(0, separatorIndex).toLowerCase(); } try { return new URL(trimmed).hostname.toLowerCase(); } catch { return null; } } function toBaseUrl(host: string): string { return `https://${host}`; } function isGitHubHost(host: string): boolean { return host === "github.com" || host.includes("github"); } function isGitLabHost(host: string): boolean { return host === "gitlab.com" || host.includes("gitlab"); } export function detectGitHostingProviderFromRemoteUrl( remoteUrl: string, ): GitHostingProvider | null { const host = parseGitRemoteHost(remoteUrl); if (!host) { return null; } if (isGitHubHost(host)) { return { kind: "github", name: host === "github.com" ? "GitHub" : "GitHub Self-Hosted", baseUrl: toBaseUrl(host), }; } if (isGitLabHost(host)) { return { kind: "gitlab", name: host === "gitlab.com" ? "GitLab" : "GitLab Self-Hosted", baseUrl: toBaseUrl(host), }; } return { kind: "unknown", name: host, baseUrl: toBaseUrl(host), }; } const EMPTY_GIT_STATUS_REMOTE: GitStatusRemoteResult = { hasUpstream: false, aheadCount: 0, behindCount: 0, pr: null, }; export function mergeGitStatusParts( local: GitStatusLocalResult, remote: GitStatusRemoteResult | null, ): GitStatusResult { return { ...local, ...(remote ?? EMPTY_GIT_STATUS_REMOTE), }; } function toRemoteStatusPart(status: GitStatusResult): GitStatusRemoteResult { return { hasUpstream: status.hasUpstream, aheadCount: status.aheadCount, behindCount: status.behindCount, pr: status.pr, }; } function toLocalStatusPart(status: GitStatusResult): GitStatusLocalResult { return { isRepo: status.isRepo, ...(status.hostingProvider ? { hostingProvider: status.hostingProvider } : {}), hasOriginRemote: status.hasOriginRemote, isDefaultBranch: status.isDefaultBranch, branch: status.branch, hasWorkingTreeChanges: status.hasWorkingTreeChanges, workingTree: status.workingTree, }; } export function applyGitStatusStreamEvent( current: GitStatusResult | null, event: GitStatusStreamEvent, ): GitStatusResult { switch (event._tag) { case "snapshot": return mergeGitStatusParts(event.local, event.remote); case "localUpdated": return mergeGitStatusParts(event.local, current ? toRemoteStatusPart(current) : null); case "remoteUpdated": if (current === null) { return mergeGitStatusParts( { isRepo: true, hasOriginRemote: false, isDefaultBranch: false, branch: null, hasWorkingTreeChanges: false, workingTree: { files: [], insertions: 0, deletions: 0 }, }, event.remote, ); } return mergeGitStatusParts(toLocalStatusPart(current), event.remote); } }