Files
layonara-forge/packages/backend/src/services/git.service.ts
T

228 lines
6.8 KiB
TypeScript

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<number> {
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<ReturnType<typeof getRepoStatus>>;
}> = [];
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<string, number>();
let pollingInterval: ReturnType<typeof setInterval> | 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;
}
}