git helper tool added and improved coding-standards
All checks were successful
Build, test and publish / verify (push) Successful in 6m57s
All checks were successful
Build, test and publish / verify (push) Successful in 6m57s
This commit is contained in:
167
git-tools/pruneLocalBranches
Executable file
167
git-tools/pruneLocalBranches
Executable 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user