Merge pull request 'git helper tool added and improved coding-standards' (#19) from git-tools into main
All checks were successful
Build, test and publish / verify (push) Successful in 6m46s

Reviewed-on: #19
This commit was merged in pull request #19.
This commit is contained in:
2026-11-08 11:43:46 +01:00
7 changed files with 333 additions and 11 deletions

View File

@@ -1,14 +1,16 @@
# Backend coding standards
This document is the authoritative policy for handwritten production and test Java
in `agiledancers-be`. Generated code and Java helpers outside that module are out
of scope. Existing frontend conventions in README.md and agiledancers-fe remain in force.
in `agiledancers-be`. Generated code is out of scope. The Java conventions below also apply to
handwritten Java helpers outside that module. Existing frontend conventions in README.md and agiledancers-fe remain in force.
## Scope and authority
- Cover handwritten production and test Java in `agiledancers-be`. Exclude
generated code. Dependency injection and production architecture rules apply
where relevant. Frontend and Java helpers outside the backend are out of scope.
where relevant. The Java conventions also cover Java helpers outside the backend; backend
architecture, metrics, coverage and automated checks remain backend-only.
Frontend code is out of scope.
- [Java coding guidelines](https://www.geeksforgeeks.org/java/coding-guidelines-in-java/)
supplied source material. Only the rules adopted in this local document apply;
changes to the article do not change project policy.
@@ -16,6 +18,15 @@ of scope. Existing frontend conventions in README.md and agiledancers-fe remain
comment layout. S8 remains responsible for formatter integration.
- Do not adopt unrelated static-analysis rule bundles.
## General language guidance
- Always prefer newer stable official APIs, language features and officially
recommended coding practices over older alternatives when they suit the task
and are supported by the project's configured language/runtime version.
Preserve required behavior and compatibility; document concrete reasons for
retaining an older approach. This guidance applies to every language used in
the project.
## Java conventions
- Types use UpperCamelCase; methods and variables use lowerCamelCase; constants
@@ -25,9 +36,19 @@ of scope. Existing frontend conventions in README.md and agiledancers-fe remain
- Opening braces stay on the declaration/control-statement line. All `if`, `for`
and `while` bodies require braces, including enhanced for and do/while bodies.
- No wildcard imports. Static imports are allowed only in test classes.
- Comments explain intent or constraints. Document public contracts with Javadoc
where their meaning is not clear from the declaration; do not require boilerplate
documentation for every accessor.
- Prefer `java.nio` APIs (including `java.nio.file` and `java.nio.charset`) over
equivalent `java.io` APIs. Use stream APIs where no suitable NIO equivalent
exists, such as process pipes or APIs requiring streams.
- Use varargs (`Type... values`) when an array is the last method or constructor
parameter, including `main(String... args)`. Preserve required override signatures
and framework/API compatibility.
- Prefer Java 25 `IO.print`/`IO.println` for direct standard output and `IO.readln`
for line-oriented standard input. `IO` has no standard-error variant; retain
`System.err` for error output. Use application logging where appropriate.
- Comments explain intent or constraints. Require Javadoc on public classes and
public methods that do more than basic POJO/data-carrier work. Trivial accessors
and plain data carriers do not need boilerplate documentation. Describe behavior,
constraints and failure conditions where relevant.
- Block comments use the following layout, subject to Palantir formatting:
```java
@@ -193,10 +214,11 @@ loops/catches and lambdas) and initialized fields. PMD's three selected finality
rules additionally cover control-flow assignment and private constructor-initialized
fields. Framework/lifecycle requirements, generated Lombok setters, and external
writes to public/protected fields need review before changing field finality.
No tool can infer whether a comment explains intent, whether a public contract
needs Javadoc, whether a record is appropriate, or whether mutable components are
sufficiently protected. These are **manual rules**, as are ambiguous type inference
and architecture/DI design decisions. Review all `DESIGN` record-component entries
Comment intent, Javadoc adequacy for nontrivial public contracts, record suitability
and protection of mutable components require manual review. These are **manual
rules**, as are ambiguous type inference, architecture/DI design decisions, NIO
API selection, trailing-array varargs, console I/O selection and the POJO
documentation boundary. Review all `DESIGN` record-component entries
and `REVIEW` signals, even when a build passes, using `docs/backend-review.md`.
Reports never rewrite Java. Formatter application remains S8's separate validate

View File

@@ -6,7 +6,10 @@ Review changed code for intent-revealing comments, missing public-contract Javad
appropriate record use, defensive copying of mutable record components, and
compatibility with APIs, JSON and persistence. Assess ambiguous `var` initializers
and framework/lifecycle exceptions to field finality. Confirm architecture and
injection decisions make sense for production and tests.
injection decisions make sense for production and tests. Check NIO API preference,
varargs for trailing array parameters, and Java 25 IO use for direct console
input/output; retain System.err for diagnostics. Require Javadoc for public
classes and methods beyond basic POJO/data-carrier behavior.
Read standards reports even when verification passes. Assess every review signal
in changed code, including fan-out, public methods and record component counts.

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)