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
JavaScript
// 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();