228 lines
6.8 KiB
TypeScript
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;
|
|
}
|
|
}
|