From 8546f222fe34e735513ff73d81feb10f54a2a5a5 Mon Sep 17 00:00:00 2001 From: Wanja Krah Date: Sun, 8 Nov 2026 10:25:37 +0100 Subject: [PATCH] git helper tool added and improved coding-standards --- CODING_STANDARDS.md | 42 +++++-- docs/backend-review.md | 5 +- git-tools/ATTRIBUTION.md | 8 ++ git-tools/LICENSE.wanjakrah | 21 ++++ git-tools/README.md | 49 ++++++++ git-tools/pruneLocalBranches | 167 +++++++++++++++++++++++++ git-tools/test-prune-local-branches.py | 52 ++++++++ 7 files changed, 333 insertions(+), 11 deletions(-) create mode 100644 git-tools/ATTRIBUTION.md create mode 100644 git-tools/LICENSE.wanjakrah create mode 100644 git-tools/README.md create mode 100755 git-tools/pruneLocalBranches create mode 100644 git-tools/test-prune-local-branches.py diff --git a/CODING_STANDARDS.md b/CODING_STANDARDS.md index 27ba6ec..0ea25bf 100644 --- a/CODING_STANDARDS.md +++ b/CODING_STANDARDS.md @@ -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 diff --git a/docs/backend-review.md b/docs/backend-review.md index cdddb86..1cf4e9d 100644 --- a/docs/backend-review.md +++ b/docs/backend-review.md @@ -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. diff --git a/git-tools/ATTRIBUTION.md b/git-tools/ATTRIBUTION.md new file mode 100644 index 0000000..2eb39e3 --- /dev/null +++ b/git-tools/ATTRIBUTION.md @@ -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. diff --git a/git-tools/LICENSE.wanjakrah b/git-tools/LICENSE.wanjakrah new file mode 100644 index 0000000..73f2e1d --- /dev/null +++ b/git-tools/LICENSE.wanjakrah @@ -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. diff --git a/git-tools/README.md b/git-tools/README.md new file mode 100644 index 0000000..f0fc612 --- /dev/null +++ b/git-tools/README.md @@ -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. diff --git a/git-tools/pruneLocalBranches b/git-tools/pruneLocalBranches new file mode 100755 index 0000000..9c7d8e9 --- /dev/null +++ b/git-tools/pruneLocalBranches @@ -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(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(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 + 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(); + 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); + } +} diff --git a/git-tools/test-prune-local-branches.py b/git-tools/test-prune-local-branches.py new file mode 100644 index 0000000..d0e5ff4 --- /dev/null +++ b/git-tools/test-prune-local-branches.py @@ -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) -- 2.49.1