[nobump] Version bump script update
Publish plugin / publish (push) Successful in 29s

This commit is contained in:
lyuksovannyy committed 2026-09-19 17:28:51 +02:00
1 parent 5e7f722bd9
commit e01dc843b2
2 files changed
+233 -94

No files matched your search

+12
View File
@@ -0,0 +1,12 @@
#!/bin/sh
# Companion to scripts/pre-commit.
#
# `git commit -- <pathspec>` (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
+221 -94
View File
@@ -1,133 +1,260 @@
#!/usr/bin/env python3 #!/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 json
import os
import re import re
import subprocess import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
REPO_ROOT = Path(subprocess.check_output(["git", "rev-parse", "--show-toplevel"], text=True).strip()) PLUGIN_FILE = "plugin.json"
PLUGIN_JSON_PATH = REPO_ROOT / "plugin.json"
STOP_PATTERNS = [ STOP_PATTERNS = (
r"\[no[-_ ]?bump\]", r"\[\s*no[-_ ]?bump\s*\]",
r"\[skip[-_ ]?bump\]", r"\[\s*skip[-_ ]?bump\s*\]",
r"\[no[-_ ]?version[-_ ]?bump\]", r"\[\s*no[-_ ]?version[-_ ]?bump\s*\]",
r"\[skip[-_ ]?version\]", r"\[\s*skip[-_ ]?version\s*\]",
r"\bno[-_ ]bump\b",
r"\bskip[-_ ]bump\b",
r"\bno[-_ ]version[-_ ]bump\b",
]
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: STOP_RE = re.compile("|".join(STOP_PATTERNS), re.IGNORECASE)
return None
show_res = subprocess.run( VERSION_RE = re.compile(r"\A(\d+)\.(\d+)\.(\d+)(.*)\Z", re.DOTALL)
["git", "show", "HEAD:plugin.json"],
cwd=REPO_ROOT, # options whose value IS the message text
stdout=subprocess.PIPE, TEXT_OPTS = ("-m", "--message")
stderr=subprocess.DEVNULL, 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, 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 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: try:
head_data = json.loads(show_res.stdout) return str(json.loads(show.stdout)["version"])
return str(head_data.get("version")) if "version" in head_data else None except (json.JSONDecodeError, KeyError, TypeError):
except json.JSONDecodeError:
return None return None
def should_skip_from_commit_msg() -> bool: def commit_message_text(root: Path) -> str:
# 1. Check GIT_PARAMS / COMMIT_EDITMSG / MERGE_MSG """Commit message text of the git process that spawned this hook.
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
# 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: try:
# On Linux, inspect parent process /proc cmdline argv = Path(f"/proc/{os.getppid()}/cmdline").read_bytes().decode("utf-8", "replace").split("\0")
ppid = subprocess.check_output(["ps", "-o", "ppid=", "-p", str(Path("/proc/self").resolve().name)], text=True).strip() except OSError:
cmdline_path = Path(f"/proc/{ppid}/cmdline") return ""
if cmdline_path.exists():
cmd_args = cmdline_path.read_bytes().decode("utf-8", errors="replace") texts: list[str] = []
for pattern in STOP_PATTERNS: pending: str | None = None # "text" | "file" | "commit"
if re.search(pattern, cmd_args, re.IGNORECASE):
return True def take(kind: str, value: str) -> None:
except Exception: if kind == "text":
texts.append(value)
elif kind == "file":
try:
texts.append(Path(value).read_text(encoding="utf-8", errors="replace"))
except OSError:
pass 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)
return False 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: 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: try:
data = json.loads(PLUGIN_JSON_PATH.read_text(encoding="utf-8")) root = repo_root()
current_version = str(data.get("version", "")) gitdir = git_dir()
except Exception as e: except RuntimeError as exc:
print(f"pre-commit: failed to parse {PLUGIN_JSON_PATH}: {e}", file=sys.stderr) 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 return 1
head_version = get_head_version() current = str(data.get("version", ""))
previous = head_version(root)
# Skip if version was manually changed (differs from HEAD) if previous is not None and current != previous:
if head_version is not None and current_version != head_version:
print( 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 return 0
try: try:
new_version = increment_patch_version(current_version) 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 data["version"] = new_version
PLUGIN_JSON_PATH.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8") trailing = "\n" if raw.endswith("\n") else ""
except Exception as e: path.write_text(json.dumps(data, indent=2) + trailing, encoding="utf-8")
print(f"pre-commit: failed to bump version in {PLUGIN_JSON_PATH}: {e}", file=sys.stderr)
return 1
res = subprocess.run(["git", "add", str(PLUGIN_JSON_PATH)], cwd=REPO_ROOT) # Stage into the index git is actually building for this commit. For
if res.returncode != 0: # `git commit -- <pathspec>` that is a temporary index named by
print("pre-commit: failed to stage plugin.json", file=sys.stderr) # GIT_INDEX_FILE, so honouring the inherited environment is what makes the
return res.returncode # 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
print(f"pre-commit: bumped version {current_version} -> {new_version}") # 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 return 0