From b2105ca7539f7da473d637fbfbfc44242c0ad47f Mon Sep 17 00:00:00 2001 From: lyuksovannyy Date: Wed, 23 Sep 2026 12:56:10 +0200 Subject: [PATCH] [no bump] Enable git hooks automatically on first bundle run tools/bundle.py now sets `core.hooksPath scripts` the first time it runs in a clone (skipped in CI, outside a git work tree, or when core.hooksPath is already set), so the version auto-bump hooks work in fresh clones and repos created from the template without a manual setup step. README documents the hooks, the self-enabling behaviour and the bump stop words. Co-Authored-By: Claude Opus 5.5 (1M context) --- README.md | 54 ++++++++++++++++++++++++++++++++++++++++----- scripts/post-commit | 3 ++- scripts/pre-commit | 3 +++ tools/bundle.py | 46 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 99 insertions(+), 7 deletions(-) mode change 100755 => 100644 tools/bundle.py diff --git a/README.md b/README.md index e2851ec..062b393 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ tools/ verify_bundle.py offline harness that proves a built bundle behaves correctly, no phone needed release.py publishes a built bundle to a Gitea release (used by CI) .gitea/workflows/ CI that builds, verifies and publishes the .plugin to Releases -scripts/ bash automation for the ADB debug loop (see below) +scripts/ ADB debug-loop automation and the git hooks (see below) docs/sdk-notes.md condensed SDK API surface gathered from the official docs ``` @@ -65,6 +65,8 @@ a private import hook (scoped to a per-module `__builtins__` dict, never touchin files were still on disk. `--dev` (the default) embeds absolute local paths so a debugger resolves breakpoints against your real source files; `--release` embeds repo-relative paths for distribution. Both modes produce byte-reproducible output for a given `src/` + `plugin.json`. +Its first run in a clone also enables the repo's git hooks (see +[Git hooks and versioning](#git-hooks-and-versioning)). ## Device prerequisites @@ -97,6 +99,43 @@ standard `ANDROID_SERIAL` env var for device selection. - `scripts/fetch-sdk-stubs.sh [--beta]` — downloads the latest SDK type stubs into `stubs/` for editor autocomplete. +## Git hooks and versioning + +`scripts/pre-commit` bumps the patch number of `version` in `plugin.json` on every commit +(`1.0.0` → `1.0.1`) and stages it into that same commit. `scripts/post-commit` re-stages +`plugin.json` afterwards, because commits of a file selection (`git commit -- `, which +some IDEs use) are built from a temporary index and would otherwise leave it looking modified. + +**The hooks enable themselves.** Git never activates hooks from repository content, so a +fresh clone, or a repo created from this template, starts without them. The first run of +`tools/bundle.py` fixes that by running `git config core.hooksPath scripts`, and every +workflow runs it: directly, or through `scripts/deploy.sh` and `scripts/watch.sh`. That +includes `--check`, so after cloning run + +```bash +python3 tools/bundle.py --check +``` + +once, before your first commit. A commit made before any build runs without the hooks. +`bundle.py` leaves `core.hooksPath` alone when it is already set (so pointing it elsewhere on +purpose sticks), skips the step in CI (whenever `CI` is set) and outside a git work tree, and +never fails a build over it. To turn the hooks off for a clone, run +`git config core.hooksPath .git/hooks`; `git config --unset core.hooksPath` only lasts +until the next build. + +The bump is skipped when: + +- the commit message contains `[no bump]`, `[skip bump]`, `[no version bump]` or + `[skip version]` (case-insensitive; `nobump`, `no-bump` and `no_bump` also work), +- `NO_BUMP=1` (or `GIT_NO_BUMP=1`) is set, e.g. `NO_BUMP=1 git commit`, which is the only way + for commits written in an editor, since their message doesn't exist yet when the hook runs, +- `version` in `plugin.json` already differs from `HEAD` (you bumped it by hand, e.g. for a + minor or major release), +- a merge, rebase, cherry-pick or revert is in progress. + +Because every version change publishes a release (see below), a normal commit + push to +`main` ships a new patch release. Use a stop word for commits that should not. + ## Publishing a release `.gitea/workflows/release.yml` builds, verifies and publishes the `.plugin` to the repo's @@ -111,7 +150,8 @@ The `version` field in `plugin.json` is the single source of truth: | the same `version` | rebuilds the existing release, replacing its attachment | So every push leaves a downloadable build attached to the current version, and bumping -`version` is what cuts a new release. Pushing a `v*` tag by hand still works and uses that +`version` is what cuts a new release. With the git hooks enabled every commit bumps it, +so every push of a normal commit cuts a new patch release. Pushing a `v*` tag by hand still works and uses that tag verbatim, as does a manual run (Actions → Publish plugin → Run workflow). Every path runs `--check`, builds with `--release`, and gates the upload on @@ -143,9 +183,11 @@ you want when publishing needs to reach beyond this repo. ## Writing your own plugin -1. Edit `plugin.json` (id, name, description, etc.). -2. Write your plugin under `src/`, with `src/main.py` as the entry module (absolute imports +1. Run `python3 tools/bundle.py --check` once. It enables the git hooks that auto-bump the + version on commit (see [Git hooks and versioning](#git-hooks-and-versioning)). +2. Edit `plugin.json` (id, name, description, etc.). +3. Write your plugin under `src/`, with `src/main.py` as the entry module (absolute imports only — it's executed at plugin-module top level, not inside a package). -3. `python3 tools/bundle.py --release && python3 tools/verify_bundle.py dist/.plugin` +4. `python3 tools/bundle.py --release && python3 tools/verify_bundle.py dist/.plugin` (substitute your `id` from `plugin.json`). -4. `bash scripts/deploy.sh --enable` with a device connected. +5. `bash scripts/deploy.sh --enable` with a device connected. diff --git a/scripts/post-commit b/scripts/post-commit index bb14819..b44c59a 100755 --- a/scripts/post-commit +++ b/scripts/post-commit @@ -1,5 +1,6 @@ #!/bin/sh -# Companion to scripts/pre-commit. +# Companion to scripts/pre-commit. Both are enabled per clone by tools/bundle.py +# (it sets `core.hooksPath scripts` on its first run). # # `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 diff --git a/scripts/pre-commit b/scripts/pre-commit index b48deac..9d7743a 100755 --- a/scripts/pre-commit +++ b/scripts/pre-commit @@ -1,6 +1,9 @@ #!/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: diff --git a/tools/bundle.py b/tools/bundle.py old mode 100755 new mode 100644 index 72678be..0060891 --- a/tools/bundle.py +++ b/tools/bundle.py @@ -2,6 +2,9 @@ """Bundle a multi-file exteraGram plugin (plugin.json + src/) into a single .plugin file. Usage: python3 tools/bundle.py [--dev|--release] [--out DIR] [--check] + +Also enables the repo's git hooks (scripts/pre-commit, scripts/post-commit) on +first run; see ensure_git_hooks(). """ from __future__ import annotations @@ -9,13 +12,18 @@ import argparse import ast import base64 import json +import os import re +import stat +import subprocess import sys import zlib from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent SRC_DIR = REPO_ROOT / "src" +HOOKS_DIR = "scripts" +HOOK_NAMES = ("pre-commit", "post-commit") PLUGIN_JSON = REPO_ROOT / "plugin.json" RECOGNIZED_KEYS = { @@ -300,6 +308,43 @@ def assemble_bundle(data: dict, modules: dict, release: bool) -> str: return text +def ensure_git_hooks() -> None: + """Point git at scripts/ for hooks, once per clone. + + Git never enables hooks from repository content, so a fresh clone (or a + repo created from the template) starts without them. Every workflow runs + this bundler first, directly or via scripts/deploy.sh and scripts/watch.sh, + which makes it the one place that can switch them on without an extra + setup step. Skipped in CI, outside a git work tree, and when + core.hooksPath is already set (including to something else on purpose). + Never fails the build. + """ + if os.environ.get("CI"): + return + hooks = [REPO_ROOT / HOOKS_DIR / name for name in HOOK_NAMES] + if not any(h.is_file() for h in hooks): + return + + def git(*args): + return subprocess.run(["git", *args], cwd=REPO_ROOT, capture_output=True, text=True) + + try: + top = git("rev-parse", "--show-toplevel") + if top.returncode != 0 or Path(top.stdout.strip()).resolve() != REPO_ROOT: + return + if git("config", "--get", "core.hooksPath").stdout.strip(): + return + # Git silently skips hooks that are not executable. + for hook in hooks: + if hook.is_file(): + mode = hook.stat().st_mode + hook.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + if git("config", "core.hooksPath", HOOKS_DIR).returncode == 0: + print(f"enabled git hooks from {HOOKS_DIR}/ (git config core.hooksPath {HOOKS_DIR})", file=sys.stderr) + except (OSError, subprocess.SubprocessError): + pass + + def parse_args(argv): parser = argparse.ArgumentParser( description="Bundle a multi-file exteraGram plugin into a single .plugin file." @@ -318,6 +363,7 @@ def parse_args(argv): def main(argv=None) -> int: args = parse_args(sys.argv[1:] if argv is None else argv) + ensure_git_hooks() release = bool(args.release) try: data = load_metadata()