git-merged-branches
Version:
CLI tool to list all Git branches merged into a base branch with issue link formatting
192 lines (184 loc) • 5.93 kB
JavaScript
// src/index.ts
import process2 from "node:process";
// src/repo.ts
import { execSync } from "node:child_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(process.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" });
}
// 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}'.`);
}
console.info(`${pluralize(branches.length, ["branch", "branches"])} 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) {
console.info("\nRun the following to delete branches, or use the --delete option to delete them automatically:");
console.info(`locally:
git branch --delete ${branches.join(" ")}`);
if (remoteMerged.length) {
console.info(`remotely:
git push origin --delete ${remoteMerged.join(" ")}`);
}
} else {
try {
console.info("\nDeleting branches locally...");
deleteLocalBranches(branches);
if (remoteMerged.length) {
console.info("\nDeleting branches remotely...");
deleteRemoteBranches(remoteMerged);
}
console.info("Branches deleted successfully.");
} catch (error) {
logError("Failed to delete branches", error);
}
}
}
// src/index.ts
function main() {
if (!isGitRepo()) {
console.error("Not a git repository.");
process2.exit(1);
}
if (isDetachedHead()) {
console.error("HEAD is detached (e.g., after checkout of a commit). Please switch to a branch.");
process2.exit(1);
}
const targetBranch = getDefaultTargetBranch();
if (!targetBranch) {
console.error("No 'master' or 'main' branch found.");
process2.exit(1);
}
try {
const mergedBranches = getMergedBranches(targetBranch);
const deleteBranches = process2.argv.includes("--delete");
outputMergedBranches(mergedBranches, targetBranch, getConfig(), { deleteBranches });
} catch (error) {
logError("Error executing 'git-merged-branches' command", error);
process2.exit(1);
}
}
main();