Template
This commit is contained in:
1 parent
5e7f722bd9
commit
e01dc843b2
2 files changed
+233
-94
No files matched your search
Executable
+12
@@ -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
@@ -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",
|
STOP_RE = re.compile("|".join(STOP_PATTERNS), re.IGNORECASE)
|
||||||
r"\bno[-_ ]version[-_ ]bump\b",
|
|
||||||
]
|
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:
|
def repo_root() -> Path:
|
||||||
match = re.fullmatch(r"^(\d+)\.(\d+)\.(\d+)(.*)$", version_str.strip())
|
out = subprocess.run(
|
||||||
if not match:
|
["git", "rev-parse", "--show-toplevel"],
|
||||||
raise ValueError(f"Version '{version_str}' does not match x.y.z format")
|
capture_output=True,
|
||||||
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,
|
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user