From 2c8347c91b11b5482153a149b5d8a5ddae43be9f Mon Sep 17 00:00:00 2001 From: Josako Date: Thu, 11 Dec 2025 09:47:19 +0100 Subject: [PATCH] - Writing custom git flow scripts - finishing up without extensive testing --- scripts/git/commands/bugfix.py | 47 +++++++++++- scripts/git/commands/feature.py | 49 +++++++++++- scripts/git/commands/hotfix.py | 60 +++++++++++++-- scripts/git/commands/release.py | 39 +++++++++- scripts/git/core/config.py | 128 +++++++++++++++++++++++++++++++- scripts/git/core/git_api.py | 24 +++++- scripts/git/core/hooks.py | 43 +++++++++++ scripts/git/gitflow.py | 23 +++++- 8 files changed, 389 insertions(+), 24 deletions(-) create mode 100644 scripts/git/core/hooks.py diff --git a/scripts/git/commands/bugfix.py b/scripts/git/commands/bugfix.py index a13be64..21cac3b 100644 --- a/scripts/git/commands/bugfix.py +++ b/scripts/git/commands/bugfix.py @@ -1,6 +1,6 @@ from __future__ import annotations -from ..core import config, git_api, output +from ..core import config, git_api, hooks, output def _bugfix_branch_name(name: str) -> str: @@ -20,6 +20,15 @@ def handle_bugfix_start(args) -> int: output.info(f"Aanmaken van bugfix branch '{branch_name}' vanaf '{cfg.develop_branch}'") git_api.create_branch(branch_name, cfg.develop_branch) output.success(f"Bugfix branch '{branch_name}' is aangemaakt en gecheckt out.") + + # Hooks na succesvol aanmaken van een bugfix branch + hooks.run_hooks( + "bugfix_start", + { + "branch": branch_name, + "base_branch": cfg.develop_branch, + }, + ) return 0 except git_api.GitError as exc: output.error(str(exc)) @@ -36,11 +45,24 @@ def handle_bugfix_finish(args) -> int: if name is None: current = git_api.get_current_branch() - if not current.startswith(cfg.bugfix_prefix): + if current.startswith(cfg.bugfix_prefix): + bugfix_branch = current + else: + branches = git_api.list_local_branches_with_prefix(cfg.bugfix_prefix) + if not branches: + raise git_api.GitError( + "Er zijn geen lokale bugfix branches gevonden. " + "Maak eerst een bugfix branch aan of geef een naam op." + ) + + output.heading("Beschikbare bugfix branches") + for b in branches: + output.plain(f"- {b}") + raise git_api.GitError( - "Je zit niet op een bugfix branch. Geef de bugfix-naam expliciet door (zonder prefix)." + "Je zit niet op een bugfix branch. Kies een van de bovenstaande namen " + "en voer het commando opnieuw uit, bv.: gitflow bugfix finish ." ) - bugfix_branch = current else: bugfix_branch = _bugfix_branch_name(name) @@ -63,6 +85,23 @@ def handle_bugfix_finish(args) -> int: ) from exc output.success(f"Bugfix branch '{bugfix_branch}' is gemerged naar '{cfg.develop_branch}'.") + + # Optionele cleanup + if cfg.delete_bugfix_after_finish: + output.info(f"Opruimen van lokale bugfix branch '{bugfix_branch}'") + try: + git_api._run_git(["branch", "-d", bugfix_branch]) # type: ignore[attr-defined] + except git_api.GitError as exc: + output.warning(f"Kon bugfix branch '{bugfix_branch}' niet verwijderen: {exc}") + + # Hooks na succesvolle bugfix-finish + hooks.run_hooks( + "bugfix_finish", + { + "branch": bugfix_branch, + "base_branch": cfg.develop_branch, + }, + ) return 0 except git_api.GitError as exc: output.error(str(exc)) diff --git a/scripts/git/commands/feature.py b/scripts/git/commands/feature.py index 5cfdaf3..ba68720 100644 --- a/scripts/git/commands/feature.py +++ b/scripts/git/commands/feature.py @@ -1,6 +1,6 @@ from __future__ import annotations -from ..core import config, git_api, output +from ..core import config, git_api, hooks, output def _feature_branch_name(name: str) -> str: @@ -20,6 +20,15 @@ def handle_feature_start(args) -> int: output.info(f"Aanmaken van feature branch '{branch_name}' vanaf '{cfg.develop_branch}'") git_api.create_branch(branch_name, cfg.develop_branch) output.success(f"Feature branch '{branch_name}' is aangemaakt en gecheckt out.") + + # Hooks na succesvol aanmaken van een feature branch + hooks.run_hooks( + "feature_start", + { + "branch": branch_name, + "base_branch": cfg.develop_branch, + }, + ) return 0 except git_api.GitError as exc: output.error(str(exc)) @@ -36,11 +45,26 @@ def handle_feature_finish(args) -> int: if name is None: current = git_api.get_current_branch() - if not current.startswith(cfg.feature_prefix): + if current.startswith(cfg.feature_prefix): + feature_branch = current + else: + # Geen naam en we zitten niet op een feature-branch: toon een lijst + # met beschikbare feature-branches om de gebruiker te helpen kiezen. + branches = git_api.list_local_branches_with_prefix(cfg.feature_prefix) + if not branches: + raise git_api.GitError( + "Er zijn geen lokale feature branches gevonden. " + "Maak eerst een feature branch aan of geef een naam op." + ) + + output.heading("Beschikbare feature branches") + for b in branches: + output.plain(f"- {b}") + raise git_api.GitError( - "Je zit niet op een feature branch. Geef de feature-naam expliciet door (zonder prefix)." + "Je zit niet op een feature branch. Kies een van de bovenstaande namen " + "en voer het commando opnieuw uit, bv.: gitflow feature finish ." ) - feature_branch = current else: feature_branch = _feature_branch_name(name) @@ -64,6 +88,23 @@ def handle_feature_finish(args) -> int: ) from exc output.success(f"Feature branch '{feature_branch}' is gemerged naar '{cfg.develop_branch}'.") + + # Optionele cleanup van de feature branch + if cfg.delete_feature_after_finish: + output.info(f"Opruimen van lokale feature branch '{feature_branch}'") + try: + git_api._run_git(["branch", "-d", feature_branch]) # type: ignore[attr-defined] + except git_api.GitError as exc: + output.warning(f"Kon feature branch '{feature_branch}' niet verwijderen: {exc}") + + # Hooks na succesvolle feature-finish + hooks.run_hooks( + "feature_finish", + { + "branch": feature_branch, + "base_branch": cfg.develop_branch, + }, + ) return 0 except git_api.GitError as exc: output.error(str(exc)) diff --git a/scripts/git/commands/hotfix.py b/scripts/git/commands/hotfix.py index d1eba01..352f845 100644 --- a/scripts/git/commands/hotfix.py +++ b/scripts/git/commands/hotfix.py @@ -1,6 +1,6 @@ from __future__ import annotations -from ..core import config, git_api, output +from ..core import config, git_api, hooks, output def _hotfix_branch_name(name: str) -> str: @@ -32,6 +32,16 @@ def handle_hotfix_start(args) -> int: output.info(f"Aanmaken van hotfix branch '{branch_name}' vanaf '{cfg.main_branch}'") git_api.create_branch(branch_name, cfg.main_branch) output.success(f"Hotfix branch '{branch_name}' is aangemaakt en gecheckt out.") + + # Hooks na succesvol aanmaken van een hotfix branch + hooks.run_hooks( + "hotfix_start", + { + "branch": branch_name, + "base_branch": cfg.main_branch, + "version": name, + }, + ) return 0 except git_api.GitError as exc: output.error(str(exc)) @@ -42,6 +52,7 @@ def handle_hotfix_finish(args) -> int: cfg = config.CONFIG try: name_arg = getattr(args, "name", None) + env_arg = getattr(args, "env", None) git_api.ensure_clean_working_tree() git_api.fetch_remote(cfg.remote_name) @@ -56,17 +67,32 @@ def handle_hotfix_finish(args) -> int: name = current[len(prefix) :] hotfix_branch = current else: - name = _ensure_version(None) - hotfix_branch = _hotfix_branch_name(name) + # Toon lijst van beschikbare hotfix branches + branches = git_api.list_local_branches_with_prefix(cfg.hotfix_prefix) + if not branches: + raise git_api.GitError( + "Er zijn geen lokale hotfix branches gevonden. " + "Maak eerst een hotfix branch aan of geef een naam op." + ) + + output.heading("Beschikbare hotfix branches") + for b in branches: + output.plain(f"- {b}") + + raise git_api.GitError( + "Je zit niet op een hotfix branch. Kies een van de bovenstaande namen " + "en voer het commando opnieuw uit, bv.: gitflow hotfix finish ." + ) git_api.ensure_not_behind_remote(hotfix_branch, cfg.remote_name) git_api.ensure_not_behind_remote(cfg.main_branch, cfg.remote_name) git_api.ensure_not_behind_remote(cfg.develop_branch, cfg.remote_name) - # Voor nu vragen we ook hier de versie/naam en gebruiken die voor de tag. - # In een latere iteratie kunnen we hier automatische patch-bumping doen. - version_for_tag = name - tag_name = cfg.tag_format.format(version=version_for_tag) + # Bepaal omgeving en tagnaam + env = env_arg or cfg.hotfix_default_env + suffix = cfg.environments.get(env, "") + base_tag = cfg.tag_format.format(version=name) + tag_name = f"{base_tag}{suffix}" # Merge naar main output.info(f"Mergen van '{hotfix_branch}' naar '{cfg.main_branch}' en tag '{tag_name}' aanmaken") @@ -101,6 +127,26 @@ def handle_hotfix_finish(args) -> int: output.success( f"Hotfix '{name}' is voltooid: gemerged naar '{cfg.main_branch}' en '{cfg.develop_branch}' en getagd als '{tag_name}'." ) + + # Optionele cleanup + if cfg.delete_hotfix_after_finish: + output.info(f"Opruimen van lokale hotfix branch '{hotfix_branch}'") + try: + git_api._run_git(["branch", "-d", hotfix_branch]) # type: ignore[attr-defined] + except git_api.GitError as exc: + output.warning(f"Kon hotfix branch '{hotfix_branch}' niet verwijderen: {exc}") + + # Hooks na succesvolle hotfix-finish + hooks.run_hooks( + "hotfix_finish", + { + "branch": hotfix_branch, + "base_branch": cfg.main_branch, + "version": name, + "env": env, + "tag": tag_name, + }, + ) return 0 except git_api.GitError as exc: output.error(str(exc)) diff --git a/scripts/git/commands/release.py b/scripts/git/commands/release.py index cf40f0d..7da7cf0 100644 --- a/scripts/git/commands/release.py +++ b/scripts/git/commands/release.py @@ -1,6 +1,6 @@ from __future__ import annotations -from ..core import config, git_api, output +from ..core import config, git_api, hooks, output def _release_branch_name(version: str) -> str: @@ -31,6 +31,16 @@ def handle_release_start(args) -> int: output.info(f"Aanmaken van release branch '{branch_name}' vanaf '{cfg.develop_branch}'") git_api.create_branch(branch_name, cfg.develop_branch) output.success(f"Release branch '{branch_name}' is aangemaakt en gecheckt out.") + + # Hooks na succesvol aanmaken van een release branch + hooks.run_hooks( + "release_start", + { + "branch": branch_name, + "base_branch": cfg.develop_branch, + "version": version, + }, + ) return 0 except git_api.GitError as exc: output.error(str(exc)) @@ -41,6 +51,7 @@ def handle_release_finish(args) -> int: cfg = config.CONFIG try: version_arg = getattr(args, "version", None) + env_arg = getattr(args, "env", None) git_api.ensure_clean_working_tree() git_api.fetch_remote(cfg.remote_name) @@ -64,7 +75,11 @@ def handle_release_finish(args) -> int: git_api.ensure_not_behind_remote(cfg.main_branch, cfg.remote_name) git_api.ensure_not_behind_remote(cfg.develop_branch, cfg.remote_name) - tag_name = cfg.tag_format.format(version=version) + # Bepaal omgeving en uiteindelijke tagnaam + env = env_arg or cfg.release_default_env + suffix = cfg.environments.get(env, "") + base_tag = cfg.tag_format.format(version=version) + tag_name = f"{base_tag}{suffix}" # Merge naar main output.info(f"Mergen van '{release_branch}' naar '{cfg.main_branch}' en tag '{tag_name}' aanmaken") @@ -100,6 +115,26 @@ def handle_release_finish(args) -> int: output.success( f"Release '{version}' is voltooid: gemerged naar '{cfg.main_branch}' en '{cfg.develop_branch}' en getagd als '{tag_name}'." ) + + # Optionele cleanup van release branch + if cfg.delete_release_after_finish: + output.info(f"Opruimen van lokale release branch '{release_branch}'") + try: + git_api._run_git(["branch", "-d", release_branch]) # type: ignore[attr-defined] + except git_api.GitError as exc: + output.warning(f"Kon release branch '{release_branch}' niet verwijderen: {exc}") + + # Hooks na succesvolle release-finish + hooks.run_hooks( + "release_finish", + { + "branch": release_branch, + "base_branch": cfg.main_branch, + "version": version, + "env": env, + "tag": tag_name, + }, + ) return 0 except git_api.GitError as exc: output.error(str(exc)) diff --git a/scripts/git/core/config.py b/scripts/git/core/config.py index a133f9b..f4c5113 100644 --- a/scripts/git/core/config.py +++ b/scripts/git/core/config.py @@ -1,6 +1,15 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field +from pathlib import Path +from typing import Dict, List + +import os + +try: # yaml is optioneel; bij ontbreken vallen we terug op defaults + import yaml # type: ignore[import] +except Exception: # pragma: no cover - defensieve fallback + yaml = None @dataclass @@ -17,6 +26,29 @@ class GitFlowConfig: use_no_ff_for_feature: bool = True use_no_ff_for_release: bool = True use_no_ff_for_hotfix: bool = True + # Globale dry-run vlag: wanneer True worden muterende git-acties niet + # echt uitgevoerd, maar enkel gelogd. + dry_run: bool = False + + # Hooks per event (feature_start, feature_finish, ...) + hooks: Dict[str, List[str]] = field(default_factory=dict) + + # Cleanup-instellingen: lokale branches verwijderen na succesvolle finish + delete_feature_after_finish: bool = False + delete_bugfix_after_finish: bool = False + delete_release_after_finish: bool = False + delete_hotfix_after_finish: bool = False + + # Omgevingen voor tagging (suffixen) en defaults + environments: Dict[str, str] = field( + default_factory=lambda: { + "test": "-test", + "staging": "-staging", + "production": "", + } + ) + release_default_env: str = "production" + hotfix_default_env: str = "production" CONFIG = GitFlowConfig() @@ -25,10 +57,98 @@ CONFIG = GitFlowConfig() def load_config() -> None: """Laad configuratie. - Voor nu gebruiken we enkel harde defaults. Later kunnen we hier - een bestand (bv. yaml/toml) inlezen en `CONFIG` overschrijven. + We lezen optioneel `scripts/git/gitflow.yaml` in en overschrijven + velden in `CONFIG` op basis van de inhoud. Als het bestand of de + yaml-lib ontbreekt, blijven de defaults gelden. """ - # TODO: optioneel configuratiebestand ondersteunen. + if yaml is None: + return None + + # Zoek het config-bestand relatief t.o.v. de huidige werkdirectory. + # We gaan ervan uit dat gitflow vanuit de projectroot draait (wrapper). + cfg_path = Path("scripts") / "git" / "gitflow.yaml" + if not cfg_path.is_file(): + return None + + with cfg_path.open("r", encoding="utf-8") as fh: + data = yaml.safe_load(fh) or {} + + # Eenvoudige mapping: alleen bekende keys worden overgenomen. + if not isinstance(data, dict): + return None + + # Basisvelden + CONFIG.main_branch = str(data.get("main_branch", CONFIG.main_branch)) + CONFIG.develop_branch = str(data.get("develop_branch", CONFIG.develop_branch)) + CONFIG.remote_name = str(data.get("remote_name", CONFIG.remote_name)) + + # Prefixes + prefixes = data.get("branch_prefixes") or {} + if isinstance(prefixes, dict): + CONFIG.feature_prefix = str(prefixes.get("feature", CONFIG.feature_prefix)) + CONFIG.bugfix_prefix = str(prefixes.get("bugfix", CONFIG.bugfix_prefix)) + CONFIG.hotfix_prefix = str(prefixes.get("hotfix", CONFIG.hotfix_prefix)) + CONFIG.release_prefix = str(prefixes.get("release", CONFIG.release_prefix)) + + # Merge-strategieën + strategies = data.get("merge_strategies") or {} + if isinstance(strategies, dict): + CONFIG.use_no_ff_for_feature = strategies.get( + "feature_finish", "no-ff" if CONFIG.use_no_ff_for_feature else "ff" + ) == "no-ff" + CONFIG.use_no_ff_for_release = strategies.get( + "release_finish", "no-ff" if CONFIG.use_no_ff_for_release else "ff" + ) == "no-ff" + CONFIG.use_no_ff_for_hotfix = strategies.get( + "hotfix_finish", "no-ff" if CONFIG.use_no_ff_for_hotfix else "ff" + ) == "no-ff" + + # Tag-format + if "tag_format" in data: + CONFIG.tag_format = str(data["tag_format"]) + + # Cleanup-instellingen + cleanup = data.get("cleanup") or {} + if isinstance(cleanup, dict): + CONFIG.delete_feature_after_finish = bool( + cleanup.get("delete_feature_after_finish", CONFIG.delete_feature_after_finish) + ) + CONFIG.delete_bugfix_after_finish = bool( + cleanup.get("delete_bugfix_after_finish", CONFIG.delete_bugfix_after_finish) + ) + CONFIG.delete_release_after_finish = bool( + cleanup.get("delete_release_after_finish", CONFIG.delete_release_after_finish) + ) + CONFIG.delete_hotfix_after_finish = bool( + cleanup.get("delete_hotfix_after_finish", CONFIG.delete_hotfix_after_finish) + ) + + # Hooks + hooks = data.get("hooks") or {} + if isinstance(hooks, dict): + normalized: Dict[str, List[str]] = {} + for key, value in hooks.items(): + if isinstance(value, list): + normalized[key] = [str(cmd) for cmd in value] + elif isinstance(value, str): + normalized[key] = [value] + CONFIG.hooks = normalized + + # Omgevingen en defaults + envs = data.get("environments") or {} + if isinstance(envs, dict): + CONFIG.environments = {str(k): str(v) for k, v in envs.items()} + + if "release_default_env" in data: + CONFIG.release_default_env = str(data["release_default_env"]) + if "hotfix_default_env" in data: + CONFIG.hotfix_default_env = str(data["hotfix_default_env"]) + + # Dry-run kan ook via config gezet worden, maar CLI-flag heeft prioriteit; + # daarom overschrijven we hier niet expliciet als het al gezet is. + if "dry_run" in data and not CONFIG.dry_run: + CONFIG.dry_run = bool(data["dry_run"]) + return None diff --git a/scripts/git/core/git_api.py b/scripts/git/core/git_api.py index ed074b6..e7344e3 100644 --- a/scripts/git/core/git_api.py +++ b/scripts/git/core/git_api.py @@ -3,7 +3,7 @@ from __future__ import annotations import subprocess from typing import Iterable -from . import output +from . import config, output class GitError(RuntimeError): @@ -12,6 +12,16 @@ class GitError(RuntimeError): def _run_git(args: Iterable[str], *, capture_output: bool = False, check: bool = True) -> subprocess.CompletedProcess: cmd = ["git", *args] + # Dry-run ondersteuning: voer geen echte git-commando's uit, maar log enkel + # wat er zou gebeuren en geef een "geslaagde" CompletedProcess terug. + if config.CONFIG.dry_run: + output.info(f"[DRY-RUN] zou uitvoeren: {' '.join(cmd)}") + return subprocess.CompletedProcess( + cmd, + 0, + stdout="" if capture_output else None, + stderr="", + ) result = subprocess.run( cmd, text=True, @@ -92,3 +102,15 @@ def ensure_not_behind_remote(branch: str, remote: str) -> None: f"Doe eerst een 'git pull --ff-only' of werk te wijzigingen lokaal bij." ) + +def list_local_branches_with_prefix(prefix: str) -> list[str]: + """Geef een gesorteerde lijst van lokale branches die met het prefix starten.""" + + result = _run_git( + ["for-each-ref", "--format=%(refname:short)", "refs/heads"], + capture_output=True, + ) + lines = (result.stdout or "").splitlines() + branches = sorted(b for b in (ln.strip() for ln in lines) if b.startswith(prefix)) + return branches + diff --git a/scripts/git/core/hooks.py b/scripts/git/core/hooks.py new file mode 100644 index 0000000..4bd9f4e --- /dev/null +++ b/scripts/git/core/hooks.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +import os +import shlex +import subprocess +from typing import Dict + +from . import config, output + + +def run_hooks(event: str, context: Dict[str, str] | None = None) -> None: + """Voer alle geconfigureerde hooks voor een gegeven event uit. + + Hooks worden gedefinieerd in `CONFIG.hooks[event]` als een lijst van + shell-commando's. We geven context door via environment-variabelen. + """ + + hooks = config.CONFIG.hooks.get(event) or [] + if not hooks: + return + + base_env = os.environ.copy() + base_env.setdefault("GITFLOW_EVENT", event) + + if context: + for key, value in context.items(): + base_env[f"GITFLOW_{key.upper()}"] = value + + for cmd in hooks: + # Eenvoudige logging + output.info(f"[HOOK {event}] {cmd}") + + if config.CONFIG.dry_run: + output.info("[DRY-RUN] Hook niet echt uitgevoerd.") + continue + + # Gebruik shlex.split zodat eenvoudige strings netjes opgesplitst worden. + args = shlex.split(cmd) + result = subprocess.run(args, env=base_env, text=True) + if result.returncode != 0: + raise RuntimeError( + f"Hook voor event '{event}' faalde met exitcode {result.returncode}: {cmd}" + ) diff --git a/scripts/git/gitflow.py b/scripts/git/gitflow.py index 7115cec..5fb7f94 100644 --- a/scripts/git/gitflow.py +++ b/scripts/git/gitflow.py @@ -29,6 +29,12 @@ def _build_parser() -> argparse.ArgumentParser: description="Git Flow helper voor deze repo", ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Toon welke git-commando's uitgevoerd zouden worden, zonder echt te veranderen", + ) + subparsers = parser.add_subparsers(dest="command", required=True) # status @@ -85,6 +91,11 @@ def _build_parser() -> argparse.ArgumentParser: nargs="?", help="Versienummer (bijv. 1.2.0). Laat leeg om af te leiden van de huidige branch of interactief te vragen.", ) + release_finish.add_argument( + "--env", + dest="env", + help="Omgeving voor tagging (bijv. test, staging, production). Laat leeg voor default uit config.", + ) release_finish.set_defaults(func=release_cmd.handle_release_finish) # hotfix @@ -101,6 +112,11 @@ def _build_parser() -> argparse.ArgumentParser: nargs="?", help="Naam of versie van de hotfix (zonder prefix). Laat leeg om huidige branch te gebruiken.", ) + hotfix_finish.add_argument( + "--env", + dest="env", + help="Omgeving voor tagging (bijv. test, staging, production). Laat leeg voor default uit config.", + ) hotfix_finish.set_defaults(func=hotfix_cmd.handle_hotfix_finish) return parser @@ -110,8 +126,6 @@ def main(argv: list[str] | None = None) -> int: if argv is None: argv = sys.argv[1:] - cfg.load_config() # voor nu alleen defaults, later evt. bestand - parser = _build_parser() try: @@ -119,6 +133,11 @@ def main(argv: list[str] | None = None) -> int: except SystemExit as exc: # argparse gebruikt SystemExit return exc.code + # Dry-run vlag doorgeven aan configuratie *voor* we commands uitvoeren + cfg.CONFIG.dry_run = bool(getattr(args, "dry_run", False)) + + cfg.load_config() # later kan dit config-bestand inladen en overrides toepassen + func = getattr(args, "func", None) if func is None: parser.print_help()