From e01dc843b255d9b6aca9b22336d4f86652fd86fd Mon Sep 17 00:00:00 2001 From: lyuksovannyy Date: Sat, 19 Sep 2026 17:28:51 +0200 Subject: [PATCH] [nobump] Version bump script update --- scripts/post-commit | 12 ++ scripts/pre-commit | 319 +++++++++++++++++++++++++++++++------------- 2 files changed, 235 insertions(+), 96 deletions(-) create mode 100755 scripts/post-commit diff --git a/scripts/post-commit b/scripts/post-commit new file mode 100755 index 0000000..bb14819 --- /dev/null +++ b/scripts/post-commit @@ -0,0 +1,12 @@ +#!/bin/sh +# Companion to scripts/pre-commit. +# +# `git commit -- ` (also how some IDEs commit a file selection) builds +# the commit from a temporary index. The pre-commit bump therefore lands in the +# commit, but the real index keeps the pre-bump blob and plugin.json is left +# looking modified. The real index cannot be updated during the commit because +# git holds index.lock, so refresh it here instead. +# +# Must never fail a commit that already succeeded. +git add -- plugin.json 2>/dev/null || true +exit 0 diff --git a/scripts/pre-commit b/scripts/pre-commit index d22e087..b48deac 100755 --- a/scripts/pre-commit +++ b/scripts/pre-commit @@ -1,133 +1,260 @@ #!/usr/bin/env python3 +"""Increment the patch component of plugin.json's version as part of the commit. + +Runs as a pre-commit hook, which is the only commit hook whose `git add` is +picked up by the commit currently being created: + + * pre-commit -> staged changes land in THIS commit (correct) + * prepare-commit-msg -> staged changes land in the NEXT commit + * commit-msg -> staged changes are ignored entirely + +The commit message is read from the invoking `git` process' argv (-m/-F/-C and +long forms). `.git/COMMIT_EDITMSG` is deliberately NOT consulted: at pre-commit +time it still holds the PREVIOUS commit's message, so a stop word there would +silently suppress every later bump. + +Bump is skipped when: + * the commit message carries a stop word (see STOP_PATTERNS), + * NO_BUMP / GIT_NO_BUMP is set in the environment, + * plugin.json's version already differs from HEAD (manual bump), + * a merge or rebase is in progress, + * plugin.json is absent or its version is not x.y.z. + +For editor-driven commits (`git commit` with no -m) the message does not exist +yet when this hook runs, so use `NO_BUMP=1 git commit` to skip the bump there. +""" + import json +import os import re import subprocess import sys from pathlib import Path -REPO_ROOT = Path(subprocess.check_output(["git", "rev-parse", "--show-toplevel"], text=True).strip()) -PLUGIN_JSON_PATH = REPO_ROOT / "plugin.json" +PLUGIN_FILE = "plugin.json" -STOP_PATTERNS = [ - r"\[no[-_ ]?bump\]", - r"\[skip[-_ ]?bump\]", - r"\[no[-_ ]?version[-_ ]?bump\]", - r"\[skip[-_ ]?version\]", - r"\bno[-_ ]bump\b", - r"\bskip[-_ ]bump\b", - r"\bno[-_ ]version[-_ ]bump\b", -] +STOP_PATTERNS = ( + r"\[\s*no[-_ ]?bump\s*\]", + r"\[\s*skip[-_ ]?bump\s*\]", + r"\[\s*no[-_ ]?version[-_ ]?bump\s*\]", + r"\[\s*skip[-_ ]?version\s*\]", +) +STOP_RE = re.compile("|".join(STOP_PATTERNS), re.IGNORECASE) + +VERSION_RE = re.compile(r"\A(\d+)\.(\d+)\.(\d+)(.*)\Z", re.DOTALL) + +# options whose value IS the message text +TEXT_OPTS = ("-m", "--message") +TEXT_OPTS_EQ = ("--message=",) +# options whose value NAMES a file containing the message +FILE_OPTS = ("-F", "--file") +FILE_OPTS_EQ = ("--file=",) +# options whose value names a commit to take the message from +COMMIT_OPTS = ("-C", "--reuse-message", "-c", "--reedit-message") +COMMIT_OPTS_EQ = ("--reuse-message=", "--reedit-message=") -def increment_patch_version(version_str: str) -> str: - match = re.fullmatch(r"^(\d+)\.(\d+)\.(\d+)(.*)$", version_str.strip()) - if not match: - raise ValueError(f"Version '{version_str}' does not match x.y.z format") - major, minor, patch, suffix = match.groups() - new_patch = int(patch) + 1 - return f"{major}.{minor}.{new_patch}{suffix}" - - -def get_head_version() -> str | None: - res = subprocess.run( - ["git", "rev-parse", "--verify", "HEAD"], - cwd=REPO_ROOT, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - if res.returncode != 0: - return None - - show_res = subprocess.run( - ["git", "show", "HEAD:plugin.json"], - cwd=REPO_ROOT, - stdout=subprocess.PIPE, - stderr=subprocess.DEVNULL, +def repo_root() -> Path: + out = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + capture_output=True, text=True, ) - if show_res.returncode != 0: + if out.returncode != 0: + raise RuntimeError(out.stderr.strip() or "not inside a git work tree") + return Path(out.stdout.strip()) + + +def git_dir() -> Path: + out = subprocess.run( + ["git", "rev-parse", "--absolute-git-dir"], + capture_output=True, + text=True, + ) + if out.returncode != 0: + raise RuntimeError(out.stderr.strip() or "cannot resolve git dir") + return Path(out.stdout.strip()) + + +def bump_patch(version: str) -> str: + m = VERSION_RE.match(version.strip()) + if not m: + raise ValueError(f"version {version!r} is not x.y.z") + major, minor, patch, suffix = m.groups() + return f"{major}.{minor}.{int(patch) + 1}{suffix}" + + +def head_version(root: Path) -> str | None: + """Version recorded for plugin.json in HEAD, or None when unavailable.""" + if subprocess.run( + ["git", "rev-parse", "--verify", "--quiet", "HEAD"], + cwd=root, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ).returncode != 0: return None + show = subprocess.run( + ["git", "show", f"HEAD:{PLUGIN_FILE}"], + cwd=root, + capture_output=True, + text=True, + ) + if show.returncode != 0: + return None try: - head_data = json.loads(show_res.stdout) - return str(head_data.get("version")) if "version" in head_data else None - except json.JSONDecodeError: + return str(json.loads(show.stdout)["version"]) + except (json.JSONDecodeError, KeyError, TypeError): return None -def should_skip_from_commit_msg() -> bool: - # 1. Check GIT_PARAMS / COMMIT_EDITMSG / MERGE_MSG - candidates = [ - REPO_ROOT / ".git" / "COMMIT_EDITMSG", - REPO_ROOT / ".git" / "MERGE_MSG", - ] - for p in candidates: - if p.exists(): - content = p.read_text(encoding="utf-8", errors="replace") - lines = [line for line in content.splitlines() if not line.strip().startswith("#")] - text = " ".join(lines) - for pattern in STOP_PATTERNS: - if re.search(pattern, text, re.IGNORECASE): - return True +def commit_message_text(root: Path) -> str: + """Commit message text of the git process that spawned this hook. - # 2. Check process command-line arguments (e.g. git commit -m "...") + Covers `-m/--message` (inline), `-F/--file` (read the file) and + `-C/-c/--reuse-message/--reedit-message` (read that commit's message). + Returns "" for editor-driven commits, where no message exists yet. + """ try: - # On Linux, inspect parent process /proc cmdline - ppid = subprocess.check_output(["ps", "-o", "ppid=", "-p", str(Path("/proc/self").resolve().name)], text=True).strip() - cmdline_path = Path(f"/proc/{ppid}/cmdline") - if cmdline_path.exists(): - cmd_args = cmdline_path.read_bytes().decode("utf-8", errors="replace") - for pattern in STOP_PATTERNS: - if re.search(pattern, cmd_args, re.IGNORECASE): - return True - except Exception: - pass + argv = Path(f"/proc/{os.getppid()}/cmdline").read_bytes().decode("utf-8", "replace").split("\0") + except OSError: + return "" - return False + texts: list[str] = [] + pending: str | None = None # "text" | "file" | "commit" + + def take(kind: str, value: str) -> None: + if kind == "text": + texts.append(value) + elif kind == "file": + try: + texts.append(Path(value).read_text(encoding="utf-8", errors="replace")) + except OSError: + pass + else: + out = subprocess.run( + ["git", "log", "-1", "--format=%B", value], + cwd=root, + capture_output=True, + text=True, + ) + if out.returncode == 0: + texts.append(out.stdout) + + for arg in argv: + if not arg: + continue + if pending: + take(pending, arg) + pending = None + continue + if arg in TEXT_OPTS: + pending = "text" + elif arg in FILE_OPTS: + pending = "file" + elif arg in COMMIT_OPTS: + pending = "commit" + elif arg.startswith(TEXT_OPTS_EQ): + take("text", arg.split("=", 1)[1]) + elif arg.startswith(FILE_OPTS_EQ): + take("file", arg.split("=", 1)[1]) + elif arg.startswith(COMMIT_OPTS_EQ): + take("commit", arg.split("=", 1)[1]) + elif len(arg) > 1 and arg[0] == "-" and arg[1] != "-": + # clustered short flags, e.g. -am "text" or -am"text" + for kind, letter in (("text", "m"), ("file", "F"), ("commit", "C")): + idx = arg.find(letter, 1) + if idx == -1: + continue + if idx + 1 < len(arg): + take(kind, arg[idx + 1:]) + else: + pending = kind + break + + return "\n".join(texts) + + +def strip_comments(text: str) -> str: + return " ".join( + line for line in text.splitlines() if not line.lstrip().startswith("#") + ) + + +def stop_word_reason(root: Path) -> str | None: + if os.environ.get("NO_BUMP") or os.environ.get("GIT_NO_BUMP"): + return "NO_BUMP is set" + match = STOP_RE.search(strip_comments(commit_message_text(root))) + if match: + return f"commit message contains {match.group(0)!r}" + return None + + +def merge_or_rebase_in_progress(gitdir: Path) -> bool: + return any( + (gitdir / name).exists() + for name in ("MERGE_HEAD", "CHERRY_PICK_HEAD", "REVERT_HEAD", "rebase-merge", "rebase-apply") + ) def main() -> int: - # Don't auto-bump during merge/rebase in progress - if (REPO_ROOT / ".git" / "MERGE_HEAD").exists() or (REPO_ROOT / ".git" / "rebase-merge").exists(): - return 0 - - if should_skip_from_commit_msg(): - print("pre-commit: stop word detected in commit message, skipping version bump") - return 0 - - if not PLUGIN_JSON_PATH.exists(): - return 0 - try: - data = json.loads(PLUGIN_JSON_PATH.read_text(encoding="utf-8")) - current_version = str(data.get("version", "")) - except Exception as e: - print(f"pre-commit: failed to parse {PLUGIN_JSON_PATH}: {e}", file=sys.stderr) + root = repo_root() + gitdir = git_dir() + except RuntimeError as exc: + print(f"pre-commit: {exc}", file=sys.stderr) + return 0 + + if merge_or_rebase_in_progress(gitdir): + return 0 + + reason = stop_word_reason(root) + if reason: + print(f"pre-commit: {reason}, keeping version unchanged") + return 0 + + path = root / PLUGIN_FILE + if not path.exists(): + return 0 + + raw = path.read_text(encoding="utf-8") + try: + data = json.loads(raw) + except json.JSONDecodeError as exc: + print(f"pre-commit: {PLUGIN_FILE} is not valid JSON: {exc}", file=sys.stderr) return 1 - head_version = get_head_version() - - # Skip if version was manually changed (differs from HEAD) - if head_version is not None and current_version != head_version: + current = str(data.get("version", "")) + previous = head_version(root) + if previous is not None and current != previous: print( - f"pre-commit: version already modified ({head_version} -> {current_version}), skipping auto-increment" + f"pre-commit: version already changed ({previous} -> {current}), keeping it" ) return 0 try: - new_version = increment_patch_version(current_version) - data["version"] = new_version - PLUGIN_JSON_PATH.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") - except Exception as e: - print(f"pre-commit: failed to bump version in {PLUGIN_JSON_PATH}: {e}", file=sys.stderr) - return 1 + new_version = bump_patch(current) + except ValueError as exc: + print(f"pre-commit: {exc}, keeping version unchanged", file=sys.stderr) + return 0 - res = subprocess.run(["git", "add", str(PLUGIN_JSON_PATH)], cwd=REPO_ROOT) - if res.returncode != 0: - print("pre-commit: failed to stage plugin.json", file=sys.stderr) - return res.returncode + data["version"] = new_version + trailing = "\n" if raw.endswith("\n") else "" + path.write_text(json.dumps(data, indent=2) + trailing, encoding="utf-8") - print(f"pre-commit: bumped version {current_version} -> {new_version}") + # Stage into the index git is actually building for this commit. For + # `git commit -- ` that is a temporary index named by + # GIT_INDEX_FILE, so honouring the inherited environment is what makes the + # bump part of THIS commit. + add = subprocess.run(["git", "add", "--", PLUGIN_FILE], cwd=root) + if add.returncode != 0: + print(f"pre-commit: failed to stage {PLUGIN_FILE}", file=sys.stderr) + return add.returncode + + # The real index may still hold the pre-bump blob after a pathspec commit; + # scripts/post-commit refreshes it once git releases index.lock. + + print(f"pre-commit: version {current} -> {new_version}") return 0