feat: add Git service for clone, pull, commit, push operations
This commit is contained in:
@@ -13,6 +13,7 @@ import buildRouter from "./routes/build.js";
|
|||||||
import serverRouter from "./routes/server.js";
|
import serverRouter from "./routes/server.js";
|
||||||
import toolsetRouter from "./routes/toolset.js";
|
import toolsetRouter from "./routes/toolset.js";
|
||||||
import githubRouter from "./routes/github.js";
|
import githubRouter from "./routes/github.js";
|
||||||
|
import reposRouter from "./routes/repos.js";
|
||||||
import { attachWebSocket, createTerminalSession } from "./services/terminal.service.js";
|
import { attachWebSocket, createTerminalSession } from "./services/terminal.service.js";
|
||||||
import { attachLspWebSocket } from "./services/lsp.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/server", serverRouter);
|
||||||
app.use("/api/toolset", toolsetRouter);
|
app.use("/api/toolset", toolsetRouter);
|
||||||
app.use("/api/github", githubRouter);
|
app.use("/api/github", githubRouter);
|
||||||
|
app.use("/api/repos", reposRouter);
|
||||||
|
|
||||||
const frontendDist = path.resolve(__dirname, "../../frontend/dist");
|
const frontendDist = path.resolve(__dirname, "../../frontend/dist");
|
||||||
app.use(express.static(frontendDist));
|
app.use(express.static(frontendDist));
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user