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

8
git-tools/ATTRIBUTION.md Normal file
View File

@@ -0,0 +1,8 @@
# License and attribution
The Git tools in this directory are maintained by Wanja Krah and distributed
under the MIT License. See [LICENSE.wanjakrah](LICENSE.wanjakrah).
The Java script, integration checks, and documentation were created with
assistance from OpenAI Codex, using a GPT-6-based language model. Wanja Krah
provided the requirements and made the behavior and safety decisions.

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Wanja Krah
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

49
git-tools/README.md Normal file
View File

@@ -0,0 +1,49 @@
# Local branch cleanup
MIT licensed. See [LICENSE.wanjakrah](LICENSE.wanjakrah) and
[ATTRIBUTION.md](ATTRIBUTION.md).
Requires Git, JDK 25 on `PATH`, and a Unix-like system with `env -S`.
Run from the repository you want to clean (including any subdirectory):
```sh
./git-tools/pruneLocalBranches --check
./git-tools/pruneLocalBranches -d feature/example
./git-tools/pruneLocalBranches -d '*'
./git-tools/pruneLocalBranches --help
```
From another repository, use the absolute path to this script. Its location does
not select the repository; the current working directory does. Always quote `'*'`.
Alternatively launch with `java --source 25 /path/to/pruneLocalBranches ...`.
The script fetches all origin branches and prunes stale **local** origin tracking
references. A failed fetch stops the operation. It never pushes, deletes remote
branches, removes folders, or changes working files.
A local branch is eligible only when no same-name branch exists on origin, it is
not checked out in any worktree, and all its commits are reachable from the current
`HEAD` or local `main`. Configured upstream names do not affect this decision:
`S6-deployment` tracking `origin/main` is not protected by that mapping. If local
`main` is missing, only `HEAD` counts. Neither local branch is updated by fetching.
Squash/rebase merges can leave different commit identities, so these branches may
be refused even when their changes were merged. The refusal lists the commits.
Checked-out branches are always refused, protecting their uncommitted work too.
`--check` prints eligibility and protection reasons. Deletion prints the candidates
and requires exactly `DELETE`; any other response or end of input cancels. Bulk
deletion skips protected branches and reports why. Before each deletion the script
rechecks eligibility and verifies that the branch still points to the displayed
commit. Avoid concurrent Git operations while running it; bulk deletion is not
atomic, so an error can leave earlier candidates already deleted.
Implementation: a Java 25 compact source file runs Git through `ProcessBuilder`
without a shell. After the explicit safety checks, `git branch -D` removes the local
ref, bypassing Git's separate upstream-based merge check. No build or dependencies
are needed. Exit codes: `0` success or cancellation, `1` error/blocked deletion,
`2` invalid arguments.
Run the isolated integration checks with
`python3 git-tools/test-prune-local-branches.py`. They create temporary repositories
under the system temporary directory, print their location for inspection, and
exercise single/bulk deletion, protection rules, cancellation and fetch failures.

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);
}
}

View File

@@ -0,0 +1,52 @@
import os, pathlib, subprocess, tempfile
script = str(pathlib.Path(__file__).resolve().with_name('pruneLocalBranches'))
root = pathlib.Path(tempfile.mkdtemp(prefix='git-tools-test-'))
env = dict(os.environ, GIT_AUTHOR_NAME='Test', GIT_AUTHOR_EMAIL='test@example.com', GIT_COMMITTER_NAME='Test', GIT_COMMITTER_EMAIL='test@example.com', GIT_CONFIG_NOSYSTEM='1')
def cmd(*args, cwd=None, expected=0, data=None):
p = subprocess.run(args, cwd=cwd or root, env=env, input=data, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT)
assert p.returncode == expected, (args, p.returncode, p.stdout)
return p.stdout
cmd('git','init','--bare','origin.git')
cmd('git','init','-b','main','repo')
repo=root/'repo'
def git(*args): return cmd('git',*args,cwd=repo)
def run(*args,expected=0,data=None): return cmd(script,*args,cwd=repo,expected=expected,data=data)
git('commit','--allow-empty','-m','initial')
git('remote','add','origin',str(root/'origin.git'))
git('push','-u','origin','main')
git('branch','merged')
git('push','origin','merged')
run('--check')
git('push','origin','--delete','merged')
git('branch','S6-deployment')
git('branch','--set-upstream-to=origin/main','S6-deployment')
git('branch','protected')
git('push','origin','protected')
git('checkout','-b','unique')
git('commit','--allow-empty','-m','unpublished')
git('checkout','main')
git('branch','in-worktree')
git('worktree','add',str(root/'linked'),'in-worktree')
out=run('--check')
for value in ['ELIGIBLE merged','ELIGIBLE S6-deployment','KEEP protected: same-name','KEEP main: same-name','KEEP unique: commits absent','unpublished','KEEP in-worktree: checked out']:
assert value in out, (value,out)
assert 'Cancelled' in run('-d','merged',data='no\n')
git('show-ref','--verify','refs/heads/merged')
assert 'Cancelled' in run('-d','merged',data='')
run('-d','protected',expected=1)
run('-d','unique',expected=1)
run('-d','missing',expected=1)
run('-d','merged',data='DELETE\n')
cmd('git','show-ref','--verify','--quiet','refs/heads/merged',cwd=repo,expected=1)
run('-d','*',data='DELETE\n')
cmd('git','show-ref','--verify','--quiet','refs/heads/S6-deployment',cwd=repo,expected=1)
assert 'unique' in git('branch','--list')
assert 'protected' in git('branch','--list')
assert 'in-worktree' in git('branch','--list')
remote=git('ls-remote','--heads','origin')
assert 'refs/heads/main' in remote and 'refs/heads/protected' in remote
run('--bad',expected=2)
git('remote','set-url','origin',str(root/'does-not-exist'))
run('--check',expected=1)
print('PASS: fetch/prune, same-name protection, upstream-name independence, unique commits, worktree protection, cancellation/EOF, single/bulk deletion, remote preservation, invalid arguments, fetch failure')
print('Temporary fixtures:',root)