From 7f848aad5d145c667e4f002fa5889d5881402284 Mon Sep 17 00:00:00 2001 From: plenarius Date: Mon, 20 Apr 2026 21:54:56 -0400 Subject: [PATCH] feat: add Git service for clone, pull, commit, push operations --- packages/backend/src/index.ts | 2 + packages/backend/src/routes/repos.ts | 149 +++++++++++++++++ packages/backend/src/services/git.service.ts | 158 +++++++++++++++++++ 3 files changed, 309 insertions(+) create mode 100644 packages/backend/src/routes/repos.ts create mode 100644 packages/backend/src/services/git.service.ts diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 733af4d..4af401c 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -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)); diff --git a/packages/backend/src/routes/repos.ts b/packages/backend/src/routes/repos.ts new file mode 100644 index 0000000..f2e38af --- /dev/null +++ b/packages/backend/src/routes/repos.ts @@ -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; diff --git a/packages/backend/src/services/git.service.ts b/packages/backend/src/services/git.service.ts new file mode 100644 index 0000000..fb505bf --- /dev/null +++ b/packages/backend/src/services/git.service.ts @@ -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 { + 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>; + }> = []; + + 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; +}