Files
exteraGramPlugins 787965ebbf Initial commit
2026-10-09 12:25:06 +00:00

266 lines
8.5 KiB
Python
Executable File

#!/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 -- <pathspec>` 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())