All checks were successful
Build, test and publish / verify (push) Successful in 6m57s
168 lines
6.9 KiB
Plaintext
Executable File
168 lines
6.9 KiB
Plaintext
Executable File
#!/usr/bin/env -S java --source 25
|
|
|
|
import java.nio.charset.StandardCharsets;
|
|
import java.util.ArrayList;
|
|
import java.util.Arrays;
|
|
import java.util.List;
|
|
|
|
record Result(int status, String output) {}
|
|
record Branch(String name, String oid, String blocker) {}
|
|
|
|
/** Runs Git, capturing merged standard output/error as UTF-8 and its exit status. */
|
|
Result git(String... arguments) throws Exception {
|
|
var command = new ArrayList<String>(List.of("git"));
|
|
command.addAll(Arrays.asList(arguments));
|
|
var process = new ProcessBuilder(command).redirectErrorStream(true).start();
|
|
// Process pipes expose stream APIs; there is no equivalent NIO process-pipe API.
|
|
String output;
|
|
try (var outputStream = process.getInputStream()) {
|
|
output = new String(outputStream.readAllBytes(), StandardCharsets.UTF_8).strip();
|
|
}
|
|
return new Result(process.waitFor(), output);
|
|
}
|
|
|
|
/** Runs Git and returns its output, failing when Git reports an unsuccessful exit status. */
|
|
String require(String... arguments) throws Exception {
|
|
Result result = git(arguments);
|
|
if (result.status() != 0) {
|
|
throw new IllegalStateException("git " + String.join(" ", arguments) + ": " + result.output());
|
|
}
|
|
return result.output();
|
|
}
|
|
|
|
/** Checks an exact ref, distinguishing a missing ref from a Git failure. */
|
|
boolean exists(String ref) throws Exception {
|
|
Result result = git("show-ref", "--verify", "--quiet", ref);
|
|
if (result.status() > 1) {
|
|
throw new IllegalStateException("Cannot inspect " + ref + ": " + result.output());
|
|
}
|
|
return result.status() == 0;
|
|
}
|
|
|
|
/** Checks origin, worktrees and commit reachability for reasons to retain a local branch. */
|
|
Branch inspect(String name, String oid) throws Exception {
|
|
if (exists("refs/remotes/origin/" + name)) {
|
|
return new Branch(name, oid, "same-name branch exists on origin");
|
|
}
|
|
// NUL-delimited porcelain also handles worktree paths containing newlines.
|
|
String worktrees = require("worktree", "list", "--porcelain", "-z");
|
|
for (String field : worktrees.split("\u0000")) {
|
|
if (field.equals("branch refs/heads/" + name)) {
|
|
return new Branch(name, oid, "checked out in a worktree (possibly the current one)");
|
|
}
|
|
}
|
|
var arguments = new ArrayList<String>(List.of("log", "--format=%h %s", oid, "--not", "HEAD"));
|
|
if (exists("refs/heads/main")) {
|
|
arguments.add("refs/heads/main");
|
|
}
|
|
String unique = require(arguments.toArray(String[]::new));
|
|
if (!unique.isEmpty()) {
|
|
return new Branch(name, oid, "commits absent from both HEAD and local main:\n "
|
|
+ unique.replace("\n", "\n "));
|
|
}
|
|
return new Branch(name, oid, null);
|
|
}
|
|
|
|
/** Prints usage, deletion safeguards and exit codes. */
|
|
void help() {
|
|
IO.println("""
|
|
Usage: pruneLocalBranches --check
|
|
pruneLocalBranches -d <branch-name>
|
|
pruneLocalBranches -d '*'
|
|
pruneLocalBranches --help
|
|
|
|
Operates on the repository in the current directory. Requires Git and JDK 25.
|
|
Fetches origin branches and prunes local origin tracking refs; never changes
|
|
remote branches. A same-name origin branch protects a local branch.
|
|
Checked-out branches and commits absent from both HEAD and local main block
|
|
deletion. Missing local main means HEAD alone must contain all commits.
|
|
Deletion shows candidates and requires the exact confirmation DELETE.
|
|
Quote '*' so your shell does not expand it. No folders are deleted.
|
|
Exit codes: 0 success/cancelled, 1 error or blocked deletion, 2 invalid usage.
|
|
""");
|
|
}
|
|
|
|
/** Fetches origin, reports candidates and deletes eligible branches only after confirmation. */
|
|
void run(String... args) throws Exception {
|
|
if (args.length == 1 && args[0].equals("--help")) {
|
|
help();
|
|
return;
|
|
}
|
|
boolean check = args.length == 1 && args[0].equals("--check");
|
|
boolean delete = args.length == 2 && args[0].equals("-d");
|
|
if (!check && !delete) {
|
|
help();
|
|
System.exit(2);
|
|
}
|
|
if (!require("rev-parse", "--is-inside-work-tree").equals("true")) {
|
|
throw new IllegalStateException("Run inside a working Git repository.");
|
|
}
|
|
require("rev-parse", "--verify", "HEAD");
|
|
// An explicit refspec ignores narrow fetch configuration and fetches every branch.
|
|
require("fetch", "--prune", "--no-tags", "--no-recurse-submodules", "origin",
|
|
"+refs/heads/*:refs/remotes/origin/*");
|
|
String refs = require("for-each-ref", "--format=%(refname:strip=2) %(objectname)", "refs/heads/");
|
|
var eligible = new ArrayList<Branch>();
|
|
boolean found = false;
|
|
boolean blocked = false;
|
|
for (String line : refs.lines().toList()) {
|
|
String[] fields = line.split(" ", 2);
|
|
String name = fields[0];
|
|
if (delete && !args[1].equals("*") && !args[1].equals(name)) {
|
|
continue;
|
|
}
|
|
found = true;
|
|
Branch branch = inspect(name, fields[1]);
|
|
if (branch.blocker() == null) {
|
|
eligible.add(branch);
|
|
IO.println("ELIGIBLE " + name);
|
|
} else {
|
|
blocked = true;
|
|
IO.println("KEEP " + name + ": " + branch.blocker());
|
|
}
|
|
}
|
|
if (check) {
|
|
IO.println(eligible.size() + " local branch(es) eligible for deletion.");
|
|
return;
|
|
}
|
|
if (!found) {
|
|
throw new IllegalStateException("No such local branch: " + args[1]);
|
|
}
|
|
if (eligible.isEmpty()) {
|
|
if (blocked) {
|
|
throw new IllegalStateException("No eligible branches; see KEEP reasons above.");
|
|
}
|
|
return;
|
|
}
|
|
IO.println("Will delete these local branches: "
|
|
+ String.join(", ", eligible.stream().map(Branch::name).toList()));
|
|
String answer = IO.readln("Are you absolutely sure? Type DELETE to continue: ");
|
|
if (!"DELETE".equals(answer)) {
|
|
IO.println("Cancelled; no branches deleted.");
|
|
return;
|
|
}
|
|
for (Branch branch : eligible) {
|
|
String currentOid = require("rev-parse", "--verify", "refs/heads/" + branch.name());
|
|
if (!currentOid.equals(branch.oid())) {
|
|
throw new IllegalStateException("Branch changed since confirmation: " + branch.name());
|
|
}
|
|
Branch fresh = inspect(branch.name(), currentOid);
|
|
if (fresh.blocker() != null) {
|
|
throw new IllegalStateException("Refusing " + branch.name() + ": " + fresh.blocker());
|
|
}
|
|
// -D avoids Git's upstream-based merge test; our HEAD/main test above is stricter
|
|
// about where commits must be preserved and deliberately ignores upstream names.
|
|
IO.println(require("branch", "-D", "--", branch.name()));
|
|
}
|
|
}
|
|
|
|
/** Runs the command, reporting failures on standard error with exit status one. */
|
|
void main(String... args) {
|
|
try {
|
|
run(args);
|
|
} catch (Exception exception) {
|
|
System.err.println("Error: " + exception.getMessage());
|
|
System.exit(1);
|
|
}
|
|
}
|