feat: add Git service for clone, pull, commit, push operations

This commit is contained in:
plenarius
2026-04-20 21:54:56 -04:00
parent f54816a622
commit 7f848aad5d
3 changed files with 309 additions and 0 deletions
+2
View File
@@ -13,6 +13,7 @@ import buildRouter from "./routes/build.js";
import serverRouter from "./routes/server.js";
import toolsetRouter from "./routes/toolset.js";
import githubRouter from "./routes/github.js";
import reposRouter from "./routes/repos.js";
import { attachWebSocket, createTerminalSession } from "./services/terminal.service.js";
import { attachLspWebSocket } from "./services/lsp.service.js";
@@ -41,6 +42,7 @@ app.use("/api/build", buildRouter);
app.use("/api/server", serverRouter);
app.use("/api/toolset", toolsetRouter);
app.use("/api/github", githubRouter);
app.use("/api/repos", reposRouter);
const frontendDist = path.resolve(__dirname, "../../frontend/dist");
app.use(express.static(frontendDist));
+149
View File
@@ -0,0 +1,149 @@
import { Router } from "express";
import { getRepoPath } from "../services/workspace.service.js";
import { REPOS } from "../config/repos.js";
import {
setupClone,
listReposWithStatus,
getRepoStatus,
pull,
commitFiles,
push,
getDiff,
} from "../services/git.service.js";
import type { RepoName } from "../config/repos.js";
const router = Router();
function findRepo(name: string) {
return REPOS.find((r) => r.name === name);
}
router.post("/clone", async (req, res) => {
try {
const { repo } = req.body;
if (!findRepo(repo)) {
res.status(400).json({ error: `Unknown repo: ${repo}` });
return;
}
const result = await setupClone(repo as RepoName);
res.json(result);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Clone failed";
res.status(500).json({ error: message });
}
});
router.get("/", async (_req, res) => {
try {
const repos = await listReposWithStatus();
res.json(repos);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Failed to list repos";
res.status(500).json({ error: message });
}
});
router.get("/:repo/status", async (req, res) => {
try {
const repoName = req.params.repo;
if (!findRepo(repoName)) {
res.status(400).json({ error: `Unknown repo: ${repoName}` });
return;
}
const repoPath = getRepoPath(repoName);
const status = await getRepoStatus(repoPath);
res.json(status);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Failed to get status";
res.status(500).json({ error: message });
}
});
router.post("/:repo/pull", async (req, res) => {
try {
const repoName = req.params.repo;
if (!findRepo(repoName)) {
res.status(400).json({ error: `Unknown repo: ${repoName}` });
return;
}
const repoPath = getRepoPath(repoName);
const result = await pull(repoPath);
res.json(result);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Pull failed";
res.status(500).json({ error: message });
}
});
router.post("/:repo/commit", async (req, res) => {
try {
const repoName = req.params.repo;
if (!findRepo(repoName)) {
res.status(400).json({ error: `Unknown repo: ${repoName}` });
return;
}
const { message, type, scope, body, issueRef, files } = req.body;
let fullMessage = type ? `${type}${scope ? `(${scope})` : ""}: ${message}` : message;
if (body) fullMessage += `\n\n${body}`;
if (issueRef) fullMessage += `\n\nFixes #${issueRef}`;
const repoPath = getRepoPath(repoName);
const result = await commitFiles(repoPath, fullMessage, files);
res.json(result);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Commit failed";
res.status(500).json({ error: message });
}
});
router.post("/:repo/push", async (req, res) => {
try {
const repoName = req.params.repo;
if (!findRepo(repoName)) {
res.status(400).json({ error: `Unknown repo: ${repoName}` });
return;
}
const repoPath = getRepoPath(repoName);
const result = await push(repoPath);
res.json(result);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Push failed";
res.status(500).json({ error: message });
}
});
router.get("/:repo/diff", async (req, res) => {
try {
const repoName = req.params.repo;
if (!findRepo(repoName)) {
res.status(400).json({ error: `Unknown repo: ${repoName}` });
return;
}
const repoPath = getRepoPath(repoName);
const diff = await getDiff(repoPath);
res.json({ diff });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Failed to get diff";
res.status(500).json({ error: message });
}
});
router.get("/:repo/diff/*path", async (req, res) => {
try {
const repoName = req.params.repo;
if (!findRepo(repoName)) {
res.status(400).json({ error: `Unknown repo: ${repoName}` });
return;
}
const filePath = req.params.path;
const repoPath = getRepoPath(repoName);
const diff = await getDiff(repoPath, filePath);
res.json({ diff });
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Failed to get diff";
res.status(500).json({ error: message });
}
});
export default router;
@@ -0,0 +1,158 @@
import simpleGit, { SimpleGit } from "simple-git";
import fs from "fs/promises";
import { REPOS, type RepoName } from "../config/repos.js";
import { getRepoPath, readConfig } from "./workspace.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();
}
export async function commitFiles(repoPath: string, message: string, files?: string[]) {
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"]);
}
export async function setupClone(repoName: RepoName) {
const config = await readConfig();
const pat = config.githubPat;
if (!pat) throw new Error("GitHub PAT not configured");
const repoDef = getRepoDef(repoName);
const [upOwner, upRepo] = repoDef.upstream.split("/");
const { validatePat } = await import("./github.service.js");
const { login } = await validatePat(pat);
const forkUrl = `https://${pat}@github.com/${login}/${upRepo}.git`;
const upstreamUrl = `https://github.com/${upOwner}/${upRepo}.git`;
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;
}
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;
}