UNPKG

git-merged-branches

Version:

CLI tool to list all Git branches merged into a base branch with issue link formatting

198 lines (190 loc) 6.11 kB
#!/usr/bin/env node // src/index.ts import process from "node:process"; // src/repo.ts import { execSync } from "node:child_process"; import { cwd } from "node:process"; import { readFileSync } from "node:fs"; import { join } from "node:path"; // src/helpers.ts function logError(prefix, error) { if (error instanceof Error) { console.warn(`${prefix}: ${error.message}`); } else { console.warn(`${prefix}: ${String(error)}`); } } function pluralize(count, words) { return `${count} ${count === 1 ? words[0] : words[1]}`; } // src/repo.ts function isGitRepo() { try { execSync("git rev-parse --is-inside-work-tree", { stdio: "ignore" }); return true; } catch { return false; } } function isDetachedHead() { try { const output = execSync("git symbolic-ref --quiet --short HEAD", { encoding: "utf-8" }).trim(); return output === ""; } catch { return true; } } function isBranchExists(branch) { try { execSync(`git show-ref --verify --quiet refs/heads/${branch}`); return true; } catch { return false; } } function getDefaultTargetBranch() { if (isBranchExists("main")) { return "main"; } else if (isBranchExists("master")) { return "master"; } else { return null; } } function getMergedBranches(targetBranch) { const baseCommit = execSync(`git rev-parse ${targetBranch}`, { encoding: "utf-8" }).trim(); const output = execSync(`git branch --merged ${targetBranch}`, { encoding: "utf-8" }); return output.split("\n").reduce((acc, line) => { const branch = line.replace("*", "").trim(); if (!branch) { return acc; } const branchCommit = execSync(`git rev-parse ${branch}`, { encoding: "utf-8" }).trim(); if (branchCommit === baseCommit) { return acc; } return [...acc, branch]; }, []); } function fetchRemoteBranches(remote = "origin") { try { const output = execSync(`git ls-remote --heads ${remote}`, { encoding: "utf-8" }); return output.split("\n").map((line) => line.split(" ")[1]).filter((ref) => ref?.startsWith("refs/heads/")).map((ref) => ref.replace("refs/heads/", "")); } catch (error) { logError(`Could not fetch remote branches from '${remote}'`, error); return []; } } function getConfig() { try { const pkgPath = join(cwd(), "package.json"); const pkg = JSON.parse(readFileSync(pkgPath, "utf-8")); return pkg["git-merged-branches"] || {}; } catch (error) { logError("Could not read package.json", error); return {}; } } function deleteLocalBranches(branches) { execSync(`git branch --delete ${branches.join(" ")}`, { stdio: "inherit" }); } function deleteRemoteBranches(branches) { execSync(`git push origin --delete ${branches.join(" ")}`, { stdio: "inherit" }); } function deleteBranches(localBranches, remoteBranches) { try { console.info("\nDeleting branches locally\u2026"); deleteLocalBranches(localBranches); if (remoteBranches.length) { console.info("\nDeleting branches remotely\u2026"); deleteRemoteBranches(remoteBranches); } } catch (error) { logError("Failed to delete branches", error); } } // src/validate.ts function isValidURL(payload) { if (!payload) return false; try { const url = new URL(payload); return url.protocol === "http:" || url.protocol === "https:"; } catch { return false; } } // src/output.ts function formatSingleBranch(branch, issueUrlFormat, issueUrlPrefix) { for (const prefix of issueUrlPrefix) { const prefixRegex = new RegExp(`\\b${prefix}(\\d+)`, "i"); const match = branch.match(prefixRegex); if (!match) { continue; } const url = issueUrlFormat.replace("{{prefix}}", prefix).replace("{{id}}", match[1]); return `${branch} <${url}>`; } return branch; } function formatTaskBranches(branches, { issueUrlFormat, issueUrlPrefix }) { if (!issueUrlFormat || !issueUrlPrefix) { return branches; } if (!isValidURL(issueUrlFormat)) { console.warn(`'${issueUrlFormat}' is not a valid URL. Skipped formatting.`); return branches; } if (!Array.isArray(issueUrlPrefix)) { console.warn(`'${issueUrlPrefix}' is not an array. Skipped formatting.`); return branches; } return branches.map((branch) => formatSingleBranch(branch, issueUrlFormat, issueUrlPrefix)); } function outputMergedBranches(branches, targetBranch, config, options = {}) { if (!branches.length) { return console.info(`No branches merged into '${targetBranch}'.`); } const pluralized = pluralize(branches.length, ["branch", "branches"]); console.info(`${pluralized} merged into '${targetBranch}':`); console.info(formatTaskBranches(branches, config).join("\n")); const remoteBranches = fetchRemoteBranches("origin"); const remoteMerged = branches.filter((branch) => remoteBranches.includes(branch)); if (options.deleteBranches) { deleteBranches(branches, remoteMerged); console.info("Branches deleted successfully."); return; } console.info(` Use --delete to delete ${pluralized} automatically.`); console.info("\nDelete locally:"); console.info(` git branch --delete ${branches.join(" ")}`); if (remoteMerged.length) { console.info("\nDelete remotely:"); console.info(` git push origin --delete ${remoteMerged.join(" ")}`); } } // src/index.ts function main() { if (!isGitRepo()) { console.error("Not a git repository."); process.exit(1); } if (isDetachedHead()) { console.error("HEAD is detached (e.g., after checkout of a commit). Please switch to a branch."); process.exit(1); } const targetBranch = getDefaultTargetBranch(); if (!targetBranch) { console.error("No 'master' or 'main' branch found."); process.exit(1); } try { const mergedBranches = getMergedBranches(targetBranch); const deleteBranches2 = process.argv.includes("--delete"); outputMergedBranches(mergedBranches, targetBranch, getConfig(), { deleteBranches: deleteBranches2 }); } catch (error) { logError("Error executing 'git-merged-branches' command", error); process.exit(1); } } main();