UNPKG

pr-desc-cli

Version:
277 lines (276 loc) 9.9 kB
import { spawn } from "child_process"; import simpleGit from "simple-git"; import { GhError, GhNeedsPushError } from "./types.js"; const git = simpleGit(); export async function getGitChanges(baseBranch, maxFiles, mode = "branch") { try { await git.fetch(); const currentBranch = (await git.revparse(["--abbrev-ref", "HEAD"])).trim(); // mode base let diffRangeArg = `${baseBranch}...HEAD`; let log = await git.log({ from: baseBranch, to: "HEAD", maxCount: 10, }); const files = []; if (mode === "branch") { const diffSummary = await git.diffSummary([diffRangeArg]); const numstatArgs = ["diff", diffRangeArg, "--numstat"]; const numstatOutput = await git.raw(numstatArgs); const numstatMap = {}; numstatOutput.split("\n").forEach((line) => { if (!line.trim()) return; const [add, del, path] = line.split("\t"); numstatMap[path] = { additions: add === "-" ? 0 : parseInt(add, 10), deletions: del === "-" ? 0 : parseInt(del, 10), }; }); const diffFiles = diffSummary.files.slice(0, maxFiles); for (const file of diffFiles) { const stats = numstatMap[file.file] || { additions: 0, deletions: 0 }; let patch = null; try { patch = await git.diff([diffRangeArg, "--", file.file]); } catch { patch = null; } files.push({ path: file.file, status: getFileStatus(stats), additions: stats.additions, deletions: stats.deletions, patch, }); } return { baseBranch, currentBranch, files, commits: (log?.all || []).map((commit) => ({ hash: commit.hash, message: commit.message, author: commit.author_name || "Unknown", date: commit.date, })), stats: { insertions: diffSummary.insertions, deletions: diffSummary.deletions, filesChanged: diffSummary.files.length, }, mode, }; } // mode === "staged" -> merge branch + staged const branchSummary = await git.diffSummary([diffRangeArg]); const stagedSummary = await git.diffSummary(["--cached"]); const branchNumstat = await git.raw(["diff", diffRangeArg, "--numstat"]); const stagedNumstat = await git.raw(["diff", "--cached", "--numstat"]); const branchMap = {}; branchNumstat.split("\n").forEach((line) => { if (!line.trim()) return; const [add, del, path] = line.split("\t"); branchMap[path] = { additions: add === "-" ? 0 : parseInt(add, 10), deletions: del === "-" ? 0 : parseInt(del, 10), }; }); const stagedMap = {}; stagedNumstat.split("\n").forEach((line) => { if (!line.trim()) return; const [add, del, path] = line.split("\t"); stagedMap[path] = { additions: add === "-" ? 0 : parseInt(add, 10), deletions: del === "-" ? 0 : parseInt(del, 10), }; }); const branchFiles = (branchSummary.files || []).map((f) => f.file); const stagedFiles = (stagedSummary.files || []).map((f) => f.file); const allFiles = Array.from(new Set([...branchFiles, ...stagedFiles])).slice(0, maxFiles); for (const filePath of allFiles) { const b = branchMap[filePath] || { additions: 0, deletions: 0 }; const s = stagedMap[filePath] || { additions: 0, deletions: 0 }; const combined = { additions: (b.additions || 0) + (s.additions || 0), deletions: (b.deletions || 0) + (s.deletions || 0), }; let committedPatch = null; let stagedPatch = null; try { if (branchFiles.includes(filePath)) { committedPatch = await git.diff([diffRangeArg, "--", filePath]); } } catch { committedPatch = null; } try { if (stagedFiles.includes(filePath)) { stagedPatch = await git.diff(["--cached", "--", filePath]); } } catch { stagedPatch = null; } const parts = []; if (committedPatch) { parts.push("--- COMMITTED PATCH (base...HEAD) ---\n" + committedPatch); } if (stagedPatch) { parts.push("--- STAGED PATCH (index vs HEAD) ---\n" + stagedPatch); } const patch = parts.length ? parts.join("\n\n") : null; files.push({ path: filePath, status: getFileStatus(combined), additions: combined.additions, deletions: combined.deletions, patch, }); } const combinedInsertions = (branchSummary.insertions || 0) + (stagedSummary.insertions || 0); const combinedDeletions = (branchSummary.deletions || 0) + (stagedSummary.deletions || 0); const combinedFilesChanged = Array.from(new Set([...branchFiles, ...stagedFiles])).length; return { baseBranch, currentBranch, files, commits: (log?.all || []).map((commit) => ({ hash: commit.hash, message: commit.message, author: commit.author_name || "Unknown", date: commit.date, })), stats: { insertions: combinedInsertions, deletions: combinedDeletions, filesChanged: combinedFilesChanged, }, mode, }; } catch (error) { throw new Error(`Failed to get git changes: ${error instanceof Error ? error.message : "Unknown error"}`); } } function getFileStatus(file) { if (file.additions > 0 && file.deletions === 0) return "added"; if (file.additions === 0 && file.deletions > 0) return "deleted"; if (file.additions > 0 && file.deletions > 0) return "modified"; return "unknown"; } // Using gh cli export async function isGhCliInstalled() { try { await runGhCommand(["--version"]); return true; } catch (error) { return false; } } export async function getPRForCurrentBranch(currentBranch) { try { const output = await runGhCommand([ "pr", "list", "--head", currentBranch, "--json", "number,url", ]); const prs = JSON.parse(output); if (prs.length > 0) { return prs[0]; } return null; } catch (error) { if (error instanceof Error && error.message.includes("Could not find a repository for")) { throw new Error("Not a GitHub repository or no remote configured."); } return null; } } export async function createPR(body) { const args = ["pr", "create", "--fill", "--body-file", "-"]; return runGhCommand(args, body); } export async function updatePR(prNumber, body) { const args = ["pr", "edit", String(prNumber), "--body-file", "-"]; await runGhCommand(args, body); } function runGhCommand(args, body) { return new Promise((resolve, reject) => { const gh = spawn("gh", args); let stdout = ""; let stderr = ""; if (body) { gh.stdin.write(body); gh.stdin.end(); } gh.stdout.on("data", (data) => { stdout += data.toString(); }); gh.stderr.on("data", (data) => { stderr += data.toString(); }); gh.on("close", (code) => { if (code === 0) { resolve(stdout.trim()); } else { if (stderr.includes("must first push the current branch")) { reject(new GhNeedsPushError()); } else { reject(new GhError(`gh command failed with code ${code}: ${stderr}`)); } } }); gh.on("error", (err) => { reject(err); }); }); } export function runGitCommand(args) { return new Promise((resolve, reject) => { const git = spawn("git", args); let stdout = ""; let stderr = ""; git.stdout.on("data", (data) => { stdout += data.toString(); }); git.stderr.on("data", (data) => { stderr += data.toString(); }); git.on("close", (code) => { if (code === 0) { resolve(stdout.trim()); } else { reject(new Error(`git command failed with code ${code}: ${stderr}`)); } }); git.on("error", (err) => { reject(err); }); }); } export async function pushCurrentBranch(branchName) { try { await runGitCommand(["push", "--set-upstream", "origin", branchName]); } catch (error) { throw new Error(`Failed to push current branch '${branchName}': ${error instanceof Error ? error.message : "Unknown error"}`); } }