#!/usr/bin/env python3 """Increment the patch component of plugin.json's version as part of the commit. Enabled per clone by tools/bundle.py, which sets `core.hooksPath scripts` on its first run (git never enables hooks from repository content by itself). 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 PLUGIN_FILE = "plugin.json" 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 repo_root() -> Path: out = subprocess.run( ["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True, ) 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: return str(json.loads(show.stdout)["version"]) except (json.JSONDecodeError, KeyError, TypeError): return None def commit_message_text(root: Path) -> str: """Commit message text of the git process that spawned this hook. 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: argv = Path(f"/proc/{os.getppid()}/cmdline").read_bytes().decode("utf-8", "replace").split("\0") except OSError: return "" 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: try: 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 current = str(data.get("version", "")) previous = head_version(root) if previous is not None and current != previous: print( f"pre-commit: version already changed ({previous} -> {current}), keeping it" ) return 0 try: new_version = bump_patch(current) except ValueError as exc: print(f"pre-commit: {exc}, keeping version unchanged", file=sys.stderr) return 0 data["version"] = new_version trailing = "\n" if raw.endswith("\n") else "" path.write_text(json.dumps(data, indent=2) + trailing, encoding="utf-8") # 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 if __name__ == "__main__": sys.exit(main())