- Writing custom git flow scripts - a start

This commit is contained in:
Josako
2025-12-11 09:27:21 +01:00
parent 0f8bda0aef
commit fe9fc047ff
14 changed files with 720 additions and 0 deletions

0
scripts/git/__init__.py Normal file
View File

View File

View File

@@ -0,0 +1,70 @@
from __future__ import annotations
from ..core import config, git_api, output
def _bugfix_branch_name(name: str) -> str:
return f"{config.CONFIG.bugfix_prefix}{name}"
def handle_bugfix_start(args) -> int:
name: str = args.name
cfg = config.CONFIG
try:
git_api.ensure_clean_working_tree()
git_api.fetch_remote(cfg.remote_name)
git_api.ensure_not_behind_remote(cfg.develop_branch, cfg.remote_name)
branch_name = _bugfix_branch_name(name)
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.")
return 0
except git_api.GitError as exc:
output.error(str(exc))
return 1
def handle_bugfix_finish(args) -> int:
cfg = config.CONFIG
name: str | None = args.name
try:
git_api.ensure_clean_working_tree()
git_api.fetch_remote(cfg.remote_name)
if name is None:
current = git_api.get_current_branch()
if not current.startswith(cfg.bugfix_prefix):
raise git_api.GitError(
"Je zit niet op een bugfix branch. Geef de bugfix-naam expliciet door (zonder prefix)."
)
bugfix_branch = current
else:
bugfix_branch = _bugfix_branch_name(name)
git_api.ensure_not_behind_remote(bugfix_branch, cfg.remote_name)
git_api.ensure_not_behind_remote(cfg.develop_branch, cfg.remote_name)
output.info(f"Mergen van '{bugfix_branch}' naar '{cfg.develop_branch}'")
git_api.checkout_branch(cfg.develop_branch)
merge_args = ["merge"]
if cfg.use_no_ff_for_feature:
merge_args.append("--no-ff")
merge_args.append(bugfix_branch)
try:
git_api._run_git(merge_args) # type: ignore[attr-defined]
except git_api.GitError as exc:
raise git_api.GitError(
"Merge is mislukt (mogelijk conflicten). Los de conflicten op en voltooi de merge handmatig."
) from exc
output.success(f"Bugfix branch '{bugfix_branch}' is gemerged naar '{cfg.develop_branch}'.")
return 0
except git_api.GitError as exc:
output.error(str(exc))
return 1

View File

@@ -0,0 +1,71 @@
from __future__ import annotations
from ..core import config, git_api, output
def _feature_branch_name(name: str) -> str:
return f"{config.CONFIG.feature_prefix}{name}"
def handle_feature_start(args) -> int:
name: str = args.name
cfg = config.CONFIG
try:
git_api.ensure_clean_working_tree()
git_api.fetch_remote(cfg.remote_name)
git_api.ensure_not_behind_remote(cfg.develop_branch, cfg.remote_name)
branch_name = _feature_branch_name(name)
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.")
return 0
except git_api.GitError as exc:
output.error(str(exc))
return 1
def handle_feature_finish(args) -> int:
cfg = config.CONFIG
name: str | None = args.name
try:
git_api.ensure_clean_working_tree()
git_api.fetch_remote(cfg.remote_name)
if name is None:
current = git_api.get_current_branch()
if not current.startswith(cfg.feature_prefix):
raise git_api.GitError(
"Je zit niet op een feature branch. Geef de feature-naam expliciet door (zonder prefix)."
)
feature_branch = current
else:
feature_branch = _feature_branch_name(name)
# Zorg dat betrokken branches niet achterlopen op remote
git_api.ensure_not_behind_remote(feature_branch, cfg.remote_name)
git_api.ensure_not_behind_remote(cfg.develop_branch, cfg.remote_name)
output.info(f"Mergen van '{feature_branch}' naar '{cfg.develop_branch}'")
git_api.checkout_branch(cfg.develop_branch)
merge_args = ["merge"]
if cfg.use_no_ff_for_feature:
merge_args.append("--no-ff")
merge_args.append(feature_branch)
try:
git_api._run_git(merge_args) # type: ignore[attr-defined]
except git_api.GitError as exc:
raise git_api.GitError(
"Merge is mislukt (mogelijk conflicten). Los de conflicten op en voltooi de merge handmatig."
) from exc
output.success(f"Feature branch '{feature_branch}' is gemerged naar '{cfg.develop_branch}'.")
return 0
except git_api.GitError as exc:
output.error(str(exc))
return 1

View File

@@ -0,0 +1,108 @@
from __future__ import annotations
from ..core import config, git_api, output
def _hotfix_branch_name(name: str) -> str:
return f"{config.CONFIG.hotfix_prefix}{name}"
def _ensure_version(name: str | None) -> str:
# Voor hotfix kunnen we dezelfde prompt gebruiken indien geen naam/versie is opgegeven
if name:
return name
output.info("Geen hotfix-naam/versie opgegeven. Gelieve een identificatie in te geven (bijv. 1.2.1 of short-name):")
entered = input("Hotfix: ").strip()
if not entered:
raise git_api.GitError("Geen hotfix-naam/versie opgegeven.")
return entered
def handle_hotfix_start(args) -> int:
cfg = config.CONFIG
try:
raw_name: str = args.name
name = _ensure_version(raw_name)
branch_name = _hotfix_branch_name(name)
git_api.ensure_clean_working_tree()
git_api.fetch_remote(cfg.remote_name)
git_api.ensure_not_behind_remote(cfg.main_branch, cfg.remote_name)
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.")
return 0
except git_api.GitError as exc:
output.error(str(exc))
return 1
def handle_hotfix_finish(args) -> int:
cfg = config.CONFIG
try:
name_arg = getattr(args, "name", None)
git_api.ensure_clean_working_tree()
git_api.fetch_remote(cfg.remote_name)
if name_arg:
name = name_arg
hotfix_branch = _hotfix_branch_name(name)
else:
current = git_api.get_current_branch()
prefix = cfg.hotfix_prefix
if current.startswith(prefix):
name = current[len(prefix) :]
hotfix_branch = current
else:
name = _ensure_version(None)
hotfix_branch = _hotfix_branch_name(name)
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)
# Merge naar main
output.info(f"Mergen van '{hotfix_branch}' naar '{cfg.main_branch}' en tag '{tag_name}' aanmaken")
git_api.checkout_branch(cfg.main_branch)
merge_args = ["merge"]
if cfg.use_no_ff_for_hotfix:
merge_args.append("--no-ff")
merge_args.append(hotfix_branch)
try:
git_api._run_git(merge_args) # type: ignore[attr-defined]
except git_api.GitError as exc:
raise git_api.GitError(
"Merge van hotfix naar main is mislukt (mogelijk conflicten). Los de conflicten op en voltooi de merge handmatig."
) from exc
git_api._run_git(["tag", tag_name]) # type: ignore[attr-defined]
# Merge naar develop
output.info(f"Mergen van '{hotfix_branch}' naar '{cfg.develop_branch}'")
git_api.checkout_branch(cfg.develop_branch)
merge_args = ["merge"]
if cfg.use_no_ff_for_hotfix:
merge_args.append("--no-ff")
merge_args.append(hotfix_branch)
try:
git_api._run_git(merge_args) # type: ignore[attr-defined]
except git_api.GitError as exc:
raise git_api.GitError(
"Merge van hotfix naar develop is mislukt (mogelijk conflicten). Los de conflicten op en voltooi de merge handmatig."
) from exc
output.success(
f"Hotfix '{name}' is voltooid: gemerged naar '{cfg.main_branch}' en '{cfg.develop_branch}' en getagd als '{tag_name}'."
)
return 0
except git_api.GitError as exc:
output.error(str(exc))
return 1

View File

@@ -0,0 +1,107 @@
from __future__ import annotations
from ..core import config, git_api, output
def _release_branch_name(version: str) -> str:
return f"{config.CONFIG.release_prefix}{version}"
def _ensure_version(version: str | None) -> str:
if version:
return version
# Eenvoudige interactieve prompt; later kunnen we validatie uitbreiden
output.info("Geen versie opgegeven. Gelieve een versie in te geven (bijv. 1.2.0):")
entered = input("Versie: ").strip()
if not entered:
raise git_api.GitError("Geen versie opgegeven.")
return entered
def handle_release_start(args) -> int:
cfg = config.CONFIG
try:
version = _ensure_version(getattr(args, "version", None))
branch_name = _release_branch_name(version)
git_api.ensure_clean_working_tree()
git_api.fetch_remote(cfg.remote_name)
git_api.ensure_not_behind_remote(cfg.develop_branch, cfg.remote_name)
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.")
return 0
except git_api.GitError as exc:
output.error(str(exc))
return 1
def handle_release_finish(args) -> int:
cfg = config.CONFIG
try:
version_arg = getattr(args, "version", None)
git_api.ensure_clean_working_tree()
git_api.fetch_remote(cfg.remote_name)
if version_arg:
version = version_arg
release_branch = _release_branch_name(version)
else:
# Probeer huidige branch te gebruiken
current = git_api.get_current_branch()
prefix = cfg.release_prefix
if current.startswith(prefix):
version = current[len(prefix) :]
release_branch = current
else:
# Geen logische branch, vraag versie interactief
version = _ensure_version(None)
release_branch = _release_branch_name(version)
# Zorg dat betrokken branches niet achterlopen
git_api.ensure_not_behind_remote(release_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)
tag_name = cfg.tag_format.format(version=version)
# Merge naar main
output.info(f"Mergen van '{release_branch}' naar '{cfg.main_branch}' en tag '{tag_name}' aanmaken")
git_api.checkout_branch(cfg.main_branch)
merge_args = ["merge"]
if cfg.use_no_ff_for_release:
merge_args.append("--no-ff")
merge_args.append(release_branch)
try:
git_api._run_git(merge_args) # type: ignore[attr-defined]
except git_api.GitError as exc:
raise git_api.GitError(
"Merge naar main is mislukt (mogelijk conflicten). Los de conflicten op en voltooi de merge handmatig."
) from exc
# Tag aanmaken op main
git_api._run_git(["tag", tag_name]) # type: ignore[attr-defined]
# Merge terug naar develop
output.info(f"Mergen van '{release_branch}' terug naar '{cfg.develop_branch}'")
git_api.checkout_branch(cfg.develop_branch)
merge_args = ["merge"]
if cfg.use_no_ff_for_release:
merge_args.append("--no-ff")
merge_args.append(release_branch)
try:
git_api._run_git(merge_args) # type: ignore[attr-defined]
except git_api.GitError as exc:
raise git_api.GitError(
"Merge naar develop is mislukt (mogelijk conflicten). Los de conflicten op en voltooi de merge handmatig."
) from exc
output.success(
f"Release '{version}' is voltooid: gemerged naar '{cfg.main_branch}' en '{cfg.develop_branch}' en getagd als '{tag_name}'."
)
return 0
except git_api.GitError as exc:
output.error(str(exc))
return 1

View File

@@ -0,0 +1,30 @@
from __future__ import annotations
from ..core import config, git_api, output
def handle_status(args) -> int: # noqa: ARG001 - argparse API
"""Toon huidige branch en eenvoudige status-info."""
try:
branch = git_api.get_current_branch()
except git_api.GitError as exc:
output.error(str(exc))
return 1
clean = git_api.is_clean_working_tree()
output.heading("Repo status")
output.plain(f"Huidige branch : {branch}")
output.plain(f"Working tree : {'clean' if clean else 'NIET clean'}")
# Optionele remote-checks
cfg = config.CONFIG
for important_branch in {cfg.main_branch, cfg.develop_branch, branch}:
try:
git_api.ensure_not_behind_remote(important_branch, cfg.remote_name)
except git_api.GitError as exc:
output.warning(str(exc))
return 0

View File

View File

@@ -0,0 +1,34 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass
class GitFlowConfig:
main_branch: str = "main"
develop_branch: str = "develop"
remote_name: str = "origin"
feature_prefix: str = "feature/"
bugfix_prefix: str = "bugfix/"
hotfix_prefix: str = "hotfix/"
release_prefix: str = "release/"
tag_format: str = "v{version}"
# Merge-strategie kan later per actie configureerbaar worden
use_no_ff_for_feature: bool = True
use_no_ff_for_release: bool = True
use_no_ff_for_hotfix: bool = True
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.
"""
# TODO: optioneel configuratiebestand ondersteunen.
return None

View File

@@ -0,0 +1,94 @@
from __future__ import annotations
import subprocess
from typing import Iterable
from . import output
class GitError(RuntimeError):
pass
def _run_git(args: Iterable[str], *, capture_output: bool = False, check: bool = True) -> subprocess.CompletedProcess:
cmd = ["git", *args]
result = subprocess.run(
cmd,
text=True,
capture_output=capture_output,
check=False,
)
if check and result.returncode != 0:
stderr = result.stderr.strip() if result.stderr else ""
raise GitError(f"git {' '.join(args)} failed ({result.returncode}): {stderr}")
return result
def get_current_branch() -> str:
result = _run_git(["rev-parse", "--abbrev-ref", "HEAD"], capture_output=True)
return (result.stdout or "").strip()
def is_clean_working_tree() -> bool:
result = _run_git(["status", "--porcelain"], capture_output=True)
return (result.stdout or "").strip() == ""
def ensure_clean_working_tree() -> None:
if not is_clean_working_tree():
raise GitError(
"Working tree is niet clean. Commit of stash je wijzigingen voor je deze actie uitvoert."
)
def ensure_branch_exists(branch: str) -> None:
try:
_run_git(["show-ref", "--verify", f"refs/heads/{branch}"], capture_output=True)
except GitError as exc:
raise GitError(f"Branch '{branch}' bestaat niet lokaal.") from exc
def checkout_branch(branch: str) -> None:
_run_git(["checkout", branch])
def create_branch(branch: str, base: str) -> None:
_run_git(["checkout", base])
_run_git(["checkout", "-b", branch])
def fetch_remote(remote: str) -> None:
_run_git(["fetch", remote])
def ensure_not_behind_remote(branch: str, remote: str) -> None:
"""Controleer of branch niet achterloopt op remote.
We gebruiken `git rev-list --left-right --count local...remote` om
ahead/behind te bepalen.
"""
remote_ref = f"{remote}/{branch}"
try:
result = _run_git(
["rev-list", "--left-right", "--count", f"{branch}...{remote_ref}"],
capture_output=True,
)
except GitError:
# Geen tracking remote; in die gevallen doen we geen check.
return
output_str = (result.stdout or "").strip()
if not output_str:
return
ahead_str, behind_str = output_str.split()
ahead = int(ahead_str)
behind = int(behind_str)
if behind > 0:
raise GitError(
f"Branch '{branch}' loopt {behind} commit(s) achter op {remote_ref}. "
f"Doe eerst een 'git pull --ff-only' of werk te wijzigingen lokaal bij."
)

View File

@@ -0,0 +1,61 @@
from __future__ import annotations
import sys
class _Colors:
RESET = "\033[0m"
BOLD = "\033[1m"
RED = "\033[31m"
GREEN = "\033[32m"
YELLOW = "\033[33m"
BLUE = "\033[34m"
def _print(message: str, *, prefix: str = "", color: str | None = None, stream=None) -> None:
if stream is None:
stream = sys.stdout
text = f"{prefix} {message}" if prefix else message
if color:
text = f"{color}{text}{_Colors.RESET}"
print(text, file=stream)
def info(message: str) -> None:
_print(message, prefix="", color=_Colors.BLUE)
def success(message: str) -> None:
_print(message, prefix="", color=_Colors.GREEN)
def warning(message: str) -> None:
_print(message, prefix="⚠️", color=_Colors.YELLOW, stream=sys.stderr)
def error(message: str) -> None:
_print(message, prefix="", color=_Colors.RED, stream=sys.stderr)
def heading(message: str) -> None:
_print(message, prefix="", color=_Colors.BOLD)
def plain(message: str) -> None:
_print(message)
class Notifier:
"""Abstractielaag voor toekomstige auditieve output.
Voor nu enkel console-notificaties; later kan dit uitgebreid
worden met TTS, systeemmeldingen, ...
"""
@staticmethod
def notify_event(event: str, detail: str | None = None) -> None: # pragma: no cover - placeholder
if detail:
info(f"[{event}] {detail}")
else:
info(f"[{event}]")

10
scripts/git/gitflow Executable file
View File

@@ -0,0 +1,10 @@
#!/usr/bin/env bash
# Kleine wrapper zodat je gewoon `scripts/git/gitflow ...` kunt aanroepen
# zonder expliciet `python` te typen.
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
cd "${PROJECT_ROOT}" || exit 1
exec python -m scripts.git.gitflow "$@"

135
scripts/git/gitflow.py Normal file
View File

@@ -0,0 +1,135 @@
#!/usr/bin/env python
"""Git Flow helper CLI for this repository.
Eerste versie:
- status
- feature start/finish
- bugfix start/finish
Andere flows (release/hotfix, hooks, enz.) volgen later.
"""
from __future__ import annotations
import argparse
import sys
from .core import config as cfg
from .core import output
from .commands import status as status_cmd
from .commands import feature as feature_cmd
from .commands import bugfix as bugfix_cmd
from .commands import release as release_cmd
from .commands import hotfix as hotfix_cmd
def _build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="gitflow",
description="Git Flow helper voor deze repo",
)
subparsers = parser.add_subparsers(dest="command", required=True)
# status
status_parser = subparsers.add_parser("status", help="Toon huidige branch en repo-status")
status_parser.set_defaults(func=status_cmd.handle_status)
# feature
feature_parser = subparsers.add_parser("feature", help="Feature branches beheren")
feature_sub = feature_parser.add_subparsers(dest="action", required=True)
feature_start = feature_sub.add_parser("start", help="Start een nieuwe feature branch vanaf develop")
feature_start.add_argument("name", help="Naam van de feature (zonder prefix)")
feature_start.set_defaults(func=feature_cmd.handle_feature_start)
feature_finish = feature_sub.add_parser("finish", help="Merge een feature branch terug naar develop")
feature_finish.add_argument(
"name",
nargs="?",
help="Naam van de feature (zonder prefix). Laat leeg om huidige branch te gebruiken.",
)
feature_finish.set_defaults(func=feature_cmd.handle_feature_finish)
# bugfix
bugfix_parser = subparsers.add_parser("bugfix", help="Bugfix branches beheren (op develop)")
bugfix_sub = bugfix_parser.add_subparsers(dest="action", required=True)
bugfix_start = bugfix_sub.add_parser("start", help="Start een nieuwe bugfix branch vanaf develop")
bugfix_start.add_argument("name", help="Naam van de bugfix (zonder prefix)")
bugfix_start.set_defaults(func=bugfix_cmd.handle_bugfix_start)
bugfix_finish = bugfix_sub.add_parser("finish", help="Merge een bugfix branch terug naar develop")
bugfix_finish.add_argument(
"name",
nargs="?",
help="Naam van de bugfix (zonder prefix). Laat leeg om huidige branch te gebruiken.",
)
bugfix_finish.set_defaults(func=bugfix_cmd.handle_bugfix_finish)
# release
release_parser = subparsers.add_parser("release", help="Release branches beheren (main <-> develop)")
release_sub = release_parser.add_subparsers(dest="action", required=True)
release_start = release_sub.add_parser("start", help="Start een nieuwe release branch vanaf develop")
release_start.add_argument(
"version",
nargs="?",
help="Versienummer (bijv. 1.2.0). Laat leeg om interactief in te geven.",
)
release_start.set_defaults(func=release_cmd.handle_release_start)
release_finish = release_sub.add_parser("finish", help="Voltooi een release: merge naar main en develop + tag")
release_finish.add_argument(
"version",
nargs="?",
help="Versienummer (bijv. 1.2.0). Laat leeg om af te leiden van de huidige branch of interactief te vragen.",
)
release_finish.set_defaults(func=release_cmd.handle_release_finish)
# hotfix
hotfix_parser = subparsers.add_parser("hotfix", help="Hotfix branches beheren (vanaf main)")
hotfix_sub = hotfix_parser.add_subparsers(dest="action", required=True)
hotfix_start = hotfix_sub.add_parser("start", help="Start een nieuwe hotfix branch vanaf main")
hotfix_start.add_argument("name", help="Naam of versie van de hotfix (zonder prefix)")
hotfix_start.set_defaults(func=hotfix_cmd.handle_hotfix_start)
hotfix_finish = hotfix_sub.add_parser("finish", help="Voltooi een hotfix: merge naar main en develop + tag")
hotfix_finish.add_argument(
"name",
nargs="?",
help="Naam of versie van de hotfix (zonder prefix). Laat leeg om huidige branch te gebruiken.",
)
hotfix_finish.set_defaults(func=hotfix_cmd.handle_hotfix_finish)
return parser
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:
args = parser.parse_args(argv)
except SystemExit as exc: # argparse gebruikt SystemExit
return exc.code
func = getattr(args, "func", None)
if func is None:
parser.print_help()
return 1
try:
return func(args)
except KeyboardInterrupt:
output.error("Afgebroken door gebruiker (Ctrl+C)")
return 130
if __name__ == "__main__": # pragma: no cover - directe CLI entry
raise SystemExit(main())