import simpleGit, { SimpleGit } from "simple-git"; import fs from "fs/promises"; import { REPOS, GIT_PROVIDER_URL, type RepoName } from "../config/repos.js"; import { getRepoPath, readConfig } from "./workspace.service.js"; import { broadcast } from "./ws.service.js"; function git(repoPath: string): SimpleGit { return simpleGit(repoPath); } function getRepoDef(name: RepoName) { const def = REPOS.find((r) => r.name === name); if (!def) throw new Error(`Unknown repo: ${name}`); return def; } export async function cloneRepo( repoUrl: string, targetDir: string, opts?: { depth?: number; branch?: string }, ) { const args: string[] = []; if (opts?.depth) args.push("--depth", String(opts.depth)); if (opts?.branch) args.push("--branch", opts.branch); await simpleGit().clone(repoUrl, targetDir, args); } export async function getRepoStatus(repoPath: string) { const g = git(repoPath); const status = await g.status(); let ahead = 0; let behind = 0; try { const raw = await g.raw(["rev-list", "--left-right", "--count", `HEAD...@{u}`]); const parts = raw.trim().split(/\s+/); ahead = parseInt(parts[0], 10) || 0; behind = parseInt(parts[1], 10) || 0; } catch { // no upstream tracking set } return { modified: status.modified, staged: status.staged, untracked: status.not_added, ahead, behind, branch: status.current || "unknown", }; } export async function pull(repoPath: string) { const g = git(repoPath); return g.pull(); } const COMMIT_PATTERN = /^(feat|fix|refactor|docs|chore|test|style|perf|ci|build|revert)(\(.+\))?: .{1,100}$/; export function validateCommitMessage(message: string): { valid: boolean; error?: string } { const firstLine = message.split("\n")[0]; if (!COMMIT_PATTERN.test(firstLine)) { return { valid: false, error: `Invalid format. Expected: type(scope): description. Valid types: feat, fix, refactor, docs, chore, test, style, perf, ci, build, revert`, }; } return { valid: true }; } export async function commitFiles(repoPath: string, message: string, files?: string[]) { const validation = validateCommitMessage(message); if (!validation.valid) { throw new Error(validation.error); } const g = git(repoPath); if (files && files.length > 0) { await g.add(files); } else { await g.add("-A"); } return g.commit(message); } export async function push(repoPath: string) { const g = git(repoPath); return g.push(); } export async function fetchUpstream(repoPath: string): Promise { const g = git(repoPath); await g.fetch("upstream"); const branch = (await g.status()).current || "main"; try { const raw = await g.raw(["rev-list", "--count", `HEAD..upstream/${branch}`]); return parseInt(raw.trim(), 10) || 0; } catch { return 0; } } export async function mergeUpstream(repoPath: string, branch: string) { const g = git(repoPath); return g.merge([`upstream/${branch}`]); } export async function getDiff(repoPath: string, file?: string) { const g = git(repoPath); if (file) { return g.diff(["HEAD", "--", file]); } return g.diff(["HEAD"]); } function getCloneUrl(token: string, user: string, repoName: string, provider: string): string { const host = provider === "github" ? "github.com" : new URL(GIT_PROVIDER_URL).host; return `https://${token}@${host}/${user}/${repoName}.git`; } function getUpstreamUrl(owner: string, repo: string, provider: string, token?: string): string { if (provider === "github") { return `https://github.com/${owner}/${repo}.git`; } const host = new URL(GIT_PROVIDER_URL).host; if (token) { return `https://${token}@${host}/${owner}/${repo}.git`; } return `https://${host}/${owner}/${repo}.git`; } export async function setupClone(repoName: RepoName) { const config = await readConfig(); const pat = config.githubPat; if (!pat) throw new Error("Git provider token not configured"); const repoDef = getRepoDef(repoName); const [upOwner, upRepo] = repoDef.upstream.split("/"); const targetDir = getRepoPath(repoName); try { await fs.access(targetDir); throw new Error(`Repo directory already exists: ${targetDir}`); } catch (err: unknown) { if (err instanceof Error && err.message.startsWith("Repo directory")) throw err; } if (repoDef.provider === "github") { const upstreamUrl = getUpstreamUrl(upOwner, upRepo, "github"); await cloneRepo(upstreamUrl, targetDir, { branch: repoDef.branch, depth: 1 }); } else { const { validateToken } = await import("./git-provider.service.js"); const { login } = await validateToken(pat as string); const forkUrl = getCloneUrl(pat as string, login, upRepo, repoDef.provider); const upstreamUrl = getUpstreamUrl(upOwner, upRepo, repoDef.provider, pat as string); await cloneRepo(forkUrl, targetDir, { branch: repoDef.branch, depth: 1 }); const g = git(targetDir); await g.addRemote("upstream", upstreamUrl); await g.fetch("upstream"); } return { path: targetDir, branch: repoDef.branch }; } export async function listReposWithStatus() { const results: Array<{ name: string; branch: string; upstream: string; cloned: boolean; status?: Awaited>; }> = []; for (const repo of REPOS) { const repoPath = getRepoPath(repo.name); let cloned = false; try { await fs.access(repoPath); cloned = true; } catch { // not cloned } if (cloned) { try { const status = await getRepoStatus(repoPath); results.push({ name: repo.name, branch: repo.branch, upstream: repo.upstream, cloned, status }); } catch { results.push({ name: repo.name, branch: repo.branch, upstream: repo.upstream, cloned }); } } else { results.push({ name: repo.name, branch: repo.branch, upstream: repo.upstream, cloned }); } } return results; } const lastKnownBehind = new Map(); let pollingInterval: ReturnType | null = null; export function startUpstreamPolling(intervalMs: number = 900000): void { if (pollingInterval) return; pollingInterval = setInterval(async () => { for (const repo of REPOS) { try { const repoPath = getRepoPath(repo.name); await fs.access(repoPath); const behind = await fetchUpstream(repoPath); const prev = lastKnownBehind.get(repo.name) || 0; if (behind !== prev) { lastKnownBehind.set(repo.name, behind); if (behind > 0) { broadcast("git", "upstream-update", { repo: repo.name, behind }); } } } catch { // repo not cloned or fetch failed } } }, intervalMs); } export function stopUpstreamPolling(): void { if (pollingInterval) { clearInterval(pollingInterval); pollingInterval = null; } }