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
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:
@@ -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
|
||||
|
||||
@@ -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
8
git-tools/ATTRIBUTION.md
Normal 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.
|
||||
21
git-tools/LICENSE.wanjakrah
Normal file
21
git-tools/LICENSE.wanjakrah
Normal 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
49
git-tools/README.md
Normal 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
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);
|
||||
}
|
||||
}
|
||||
52
git-tools/test-prune-local-branches.py
Normal file
52
git-tools/test-prune-local-branches.py
Normal 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)
|
||||
Reference in New Issue
Block a user