git helper tool added and improved coding-standards
All checks were successful
Build, test and publish / verify (push) Successful in 6m57s

This commit is contained in:
2026-11-08 10:25:37 +01:00
parent 3c3ca1949c
commit 8546f222fe
7 changed files with 333 additions and 11 deletions

167
git-tools/pruneLocalBranches Executable file
View File

@@ -0,0 +1,167 @@
#!/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);
}
}