prepush-ci 0.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,66 @@
1
+ Metadata-Version: 2.4
2
+ Name: prepush-ci
3
+ Version: 0.0.0
4
+ Summary: Run the full CI check in the pre-push hook and let pull request CI skip commits that provably went through it
5
+ Keywords: git,pre-push,ci,git-hooks,ai-agents,claude-code
6
+ License-Expression: MIT
7
+ Classifier: Development Status :: 2 - Pre-Alpha
8
+ Classifier: Environment :: Console
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.11
12
+ Classifier: Programming Language :: Python :: 3.12
13
+ Classifier: Programming Language :: Python :: 3.13
14
+ Classifier: Programming Language :: Python :: 3.14
15
+ Classifier: Topic :: Software Development :: Quality Assurance
16
+ Classifier: Topic :: Software Development :: Version Control :: Git
17
+ Requires-Python: >=3.11
18
+ Project-URL: Homepage, https://github.com/CapeOfGoodCode/prepush-ci
19
+ Project-URL: Issues, https://github.com/CapeOfGoodCode/prepush-ci/issues
20
+ Description-Content-Type: text/markdown
21
+
22
+ <p align="center">
23
+ <img src="https://raw.githubusercontent.com/CapeOfGoodCode/prepush-ci/main/docs/assets/logo.svg" alt="prepush-ci logo" width="128">
24
+ </p>
25
+
26
+ # prepush-ci
27
+
28
+ [![CI](https://github.com/CapeOfGoodCode/prepush-ci/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/CapeOfGoodCode/prepush-ci/actions/workflows/ci.yml)
29
+ [![Coverage](https://raw.githubusercontent.com/CapeOfGoodCode/prepush-ci/python-coverage-comment-action-data/badge.svg)](https://github.com/CapeOfGoodCode/prepush-ci/tree/python-coverage-comment-action-data)
30
+ [![PyPI](https://img.shields.io/pypi/v/prepush-ci)](https://pypi.org/project/prepush-ci/)
31
+ [![Python](https://img.shields.io/pypi/pyversions/prepush-ci)](https://pypi.org/project/prepush-ci/)
32
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
33
+ [![Built by loops](https://img.shields.io/badge/built%20by-5%20AI%20loops-8a2be2)](LOOP.md)
34
+ [![Loops run on Spool](https://img.shields.io/badge/loops%20run%20on-Spool-0f172a)](https://github.com/enes-alatas/spool)
35
+
36
+ Run the full CI check in the `pre-push` hook of an AI agent's cloud session (or any machine that opts in), and let pull request CI skip commits that provably went through it. The default branch always runs CI in full.
37
+
38
+ The full check can run on any machine that opts in, for example on the VM of a [Claude Code cloud session](https://code.claude.com/docs/en/claude-code-on-the-web), which has no separate VM charge; pull request CI then does not repeat it on billed runners.
39
+
40
+ > **Status: under construction.** The tool is being built by a small fleet of AI coding loops from the intent in [issue #1](https://github.com/CapeOfGoodCode/prepush-ci/issues/1). Nothing here is released yet.
41
+
42
+ ## How it works (in one paragraph)
43
+
44
+ When `PREPUSH_CI=full` is set, a `commit-msg` hook writes the trailer `Prepush-CI: full` into each commit. The `pre-push` hook refuses to push a tip commit that carries the trailer unless the full check passes on it. A small gate in pull request CI reads the trailer of the head commit and skips the remaining jobs. The default branch ignores the trailer and runs CI in full. See [issue #1](https://github.com/CapeOfGoodCode/prepush-ci/issues/1) for the full design.
45
+
46
+ ## How this repository is built
47
+
48
+ This repository is also an example of agentic engineering:
49
+
50
+ - [`AGENTS.md`](AGENTS.md) is the rulebook for every contributor, human or agent (`CLAUDE.md` imports it).
51
+ - [`LOOP.md`](LOOP.md) describes the loops that build the tool: their roles, the label state machine they share, what each may touch, and where a human decides.
52
+ - [`openspec/`](openspec/) holds the specifications. Every feature starts as an OpenSpec change.
53
+ - [`scripts/ci`](scripts/ci) is the one entry point for the full check; CI and the pre-push hook run the same script.
54
+ - [`docs/agentic-engineering.md`](docs/agentic-engineering.md) maps the practices from [loop-engineering](https://github.com/cobusgreyling/loop-engineering) this repository adopts, and where it deviates.
55
+
56
+ ## Development
57
+
58
+ ```sh
59
+ uv sync && npm ci # Python tooling, OpenSpec CLI
60
+ uv run pre-commit install # commit-msg, pre-commit and pre-push hooks
61
+ sh scripts/ci # the full check, exactly as CI runs it
62
+ ```
63
+
64
+ ## License
65
+
66
+ [MIT](LICENSE)
@@ -0,0 +1,45 @@
1
+ <p align="center">
2
+ <img src="https://raw.githubusercontent.com/CapeOfGoodCode/prepush-ci/main/docs/assets/logo.svg" alt="prepush-ci logo" width="128">
3
+ </p>
4
+
5
+ # prepush-ci
6
+
7
+ [![CI](https://github.com/CapeOfGoodCode/prepush-ci/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/CapeOfGoodCode/prepush-ci/actions/workflows/ci.yml)
8
+ [![Coverage](https://raw.githubusercontent.com/CapeOfGoodCode/prepush-ci/python-coverage-comment-action-data/badge.svg)](https://github.com/CapeOfGoodCode/prepush-ci/tree/python-coverage-comment-action-data)
9
+ [![PyPI](https://img.shields.io/pypi/v/prepush-ci)](https://pypi.org/project/prepush-ci/)
10
+ [![Python](https://img.shields.io/pypi/pyversions/prepush-ci)](https://pypi.org/project/prepush-ci/)
11
+ [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
12
+ [![Built by loops](https://img.shields.io/badge/built%20by-5%20AI%20loops-8a2be2)](LOOP.md)
13
+ [![Loops run on Spool](https://img.shields.io/badge/loops%20run%20on-Spool-0f172a)](https://github.com/enes-alatas/spool)
14
+
15
+ Run the full CI check in the `pre-push` hook of an AI agent's cloud session (or any machine that opts in), and let pull request CI skip commits that provably went through it. The default branch always runs CI in full.
16
+
17
+ The full check can run on any machine that opts in, for example on the VM of a [Claude Code cloud session](https://code.claude.com/docs/en/claude-code-on-the-web), which has no separate VM charge; pull request CI then does not repeat it on billed runners.
18
+
19
+ > **Status: under construction.** The tool is being built by a small fleet of AI coding loops from the intent in [issue #1](https://github.com/CapeOfGoodCode/prepush-ci/issues/1). Nothing here is released yet.
20
+
21
+ ## How it works (in one paragraph)
22
+
23
+ When `PREPUSH_CI=full` is set, a `commit-msg` hook writes the trailer `Prepush-CI: full` into each commit. The `pre-push` hook refuses to push a tip commit that carries the trailer unless the full check passes on it. A small gate in pull request CI reads the trailer of the head commit and skips the remaining jobs. The default branch ignores the trailer and runs CI in full. See [issue #1](https://github.com/CapeOfGoodCode/prepush-ci/issues/1) for the full design.
24
+
25
+ ## How this repository is built
26
+
27
+ This repository is also an example of agentic engineering:
28
+
29
+ - [`AGENTS.md`](AGENTS.md) is the rulebook for every contributor, human or agent (`CLAUDE.md` imports it).
30
+ - [`LOOP.md`](LOOP.md) describes the loops that build the tool: their roles, the label state machine they share, what each may touch, and where a human decides.
31
+ - [`openspec/`](openspec/) holds the specifications. Every feature starts as an OpenSpec change.
32
+ - [`scripts/ci`](scripts/ci) is the one entry point for the full check; CI and the pre-push hook run the same script.
33
+ - [`docs/agentic-engineering.md`](docs/agentic-engineering.md) maps the practices from [loop-engineering](https://github.com/cobusgreyling/loop-engineering) this repository adopts, and where it deviates.
34
+
35
+ ## Development
36
+
37
+ ```sh
38
+ uv sync && npm ci # Python tooling, OpenSpec CLI
39
+ uv run pre-commit install # commit-msg, pre-commit and pre-push hooks
40
+ sh scripts/ci # the full check, exactly as CI runs it
41
+ ```
42
+
43
+ ## License
44
+
45
+ [MIT](LICENSE)
@@ -0,0 +1,87 @@
1
+ [project]
2
+ name = "prepush-ci"
3
+ version = "0.0.0"
4
+ description = "Run the full CI check in the pre-push hook and let pull request CI skip commits that provably went through it"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.11"
8
+ dependencies = []
9
+ keywords = [
10
+ "git",
11
+ "pre-push",
12
+ "ci",
13
+ "git-hooks",
14
+ "ai-agents",
15
+ "claude-code",
16
+ ]
17
+ classifiers = [
18
+ "Development Status :: 2 - Pre-Alpha",
19
+ "Environment :: Console",
20
+ "Intended Audience :: Developers",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.11",
23
+ "Programming Language :: Python :: 3.12",
24
+ "Programming Language :: Python :: 3.13",
25
+ "Programming Language :: Python :: 3.14",
26
+ "Topic :: Software Development :: Quality Assurance",
27
+ "Topic :: Software Development :: Version Control :: Git",
28
+ ]
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/CapeOfGoodCode/prepush-ci"
32
+ Issues = "https://github.com/CapeOfGoodCode/prepush-ci/issues"
33
+
34
+ [project.scripts]
35
+ prepush-ci = "prepush_ci.cli:main"
36
+
37
+ [build-system]
38
+ requires = ["uv_build>=0.8,<0.13"]
39
+ build-backend = "uv_build"
40
+
41
+ [dependency-groups]
42
+ dev = [
43
+ "coverage[toml]>=7.10",
44
+ "mypy>=1.18",
45
+ "pre-commit>=4.3",
46
+ "pytest>=8.4",
47
+ "ruff>=0.13",
48
+ ]
49
+
50
+ [tool.pytest.ini_options]
51
+ testpaths = ["tests"]
52
+ python_files = "*_spec.py"
53
+ addopts = "--import-mode=importlib"
54
+
55
+ [tool.coverage.run]
56
+ source = ["prepush_ci"]
57
+ branch = true
58
+ relative_files = true
59
+ patch = ["subprocess"]
60
+
61
+ [tool.coverage.report]
62
+ show_missing = true
63
+ exclude_lines = [
64
+ "pragma: no cover",
65
+ "if TYPE_CHECKING:",
66
+ ]
67
+
68
+ [tool.ruff]
69
+ target-version = "py311"
70
+
71
+ [tool.ruff.lint]
72
+ select = [
73
+ "F",
74
+ "E",
75
+ "W",
76
+ "I",
77
+ "UP",
78
+ "B",
79
+ "C4",
80
+ "SIM",
81
+ ]
82
+ ignore = ["E501"]
83
+
84
+ [tool.mypy]
85
+ strict = true
86
+ files = ["src"]
87
+ python_version = "3.11"
@@ -0,0 +1,70 @@
1
+ [project]
2
+ name = "prepush-ci"
3
+ version = "0.0.0"
4
+ description = "Run the full CI check in the pre-push hook and let pull request CI skip commits that provably went through it"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ requires-python = ">=3.11" # tomllib in the standard library; uvx brings its own Python anyway
8
+ dependencies = []
9
+ keywords = ["git", "pre-push", "ci", "git-hooks", "ai-agents", "claude-code"]
10
+ classifiers = [
11
+ "Development Status :: 2 - Pre-Alpha",
12
+ "Environment :: Console",
13
+ "Intended Audience :: Developers",
14
+ "Programming Language :: Python :: 3",
15
+ "Programming Language :: Python :: 3.11",
16
+ "Programming Language :: Python :: 3.12",
17
+ "Programming Language :: Python :: 3.13",
18
+ "Programming Language :: Python :: 3.14",
19
+ "Topic :: Software Development :: Quality Assurance",
20
+ "Topic :: Software Development :: Version Control :: Git",
21
+ ]
22
+
23
+ [project.urls]
24
+ Homepage = "https://github.com/CapeOfGoodCode/prepush-ci"
25
+ Issues = "https://github.com/CapeOfGoodCode/prepush-ci/issues"
26
+
27
+ [project.scripts]
28
+ prepush-ci = "prepush_ci.cli:main"
29
+
30
+ [build-system]
31
+ requires = ["uv_build>=0.8,<0.13"]
32
+ build-backend = "uv_build"
33
+
34
+ [dependency-groups]
35
+ dev = [
36
+ "coverage[toml]>=7.10",
37
+ "mypy>=1.18",
38
+ "pre-commit>=4.3",
39
+ "pytest>=8.4",
40
+ "ruff>=0.13",
41
+ ]
42
+
43
+ [tool.pytest.ini_options]
44
+ testpaths = ["tests"]
45
+ python_files = "*_spec.py" # tests are specs; pytest collects nothing else
46
+ addopts = "--import-mode=importlib"
47
+
48
+ [tool.coverage.run]
49
+ source = ["prepush_ci"]
50
+ branch = true
51
+ relative_files = true # the coverage comment action reads paths relative to the repository
52
+ patch = ["subprocess"] # hooks run the CLI in a subprocess via git; measure that too
53
+
54
+ [tool.coverage.report]
55
+ # Gates live in scripts/ci: 100% of core from tests/unit alone, 95% overall. patch=subprocess
56
+ # measures the CLI when tests start it as `sys.executable -m prepush_ci`, and combines automatically.
57
+ show_missing = true
58
+ exclude_lines = ["pragma: no cover", "if TYPE_CHECKING:"]
59
+
60
+ [tool.ruff]
61
+ target-version = "py311"
62
+
63
+ [tool.ruff.lint]
64
+ select = ["F", "E", "W", "I", "UP", "B", "C4", "SIM"]
65
+ ignore = ["E501"] # the formatter owns line length
66
+
67
+ [tool.mypy]
68
+ strict = true
69
+ files = ["src"]
70
+ python_version = "3.11"
@@ -0,0 +1 @@
1
+ """prepush-ci: run the full CI check before push, let pull request CI skip what it proved."""
@@ -0,0 +1,5 @@
1
+ """`python -m prepush_ci`: how hooks and integration tests start the CLI (coverage follows it)."""
2
+
3
+ from prepush_ci.cli import main
4
+
5
+ raise SystemExit(main())
@@ -0,0 +1,52 @@
1
+ """The steps `run` and the pre-push hook share: what must hold before the check, and the check."""
2
+
3
+ import subprocess
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from prepush_ci.core.config import Config
8
+ from prepush_ci.core.pre_push import message_dirty, message_hidden
9
+ from prepush_ci.git import dirty, git, hidden
10
+
11
+
12
+ class Refused(Exception):
13
+ """The check cannot run on this state; the message says why."""
14
+
15
+
16
+ def head(cwd: Path) -> str:
17
+ result = git(cwd, "rev-parse", "--verify", "--quiet", "HEAD^{commit}")
18
+ if result.returncode != 0:
19
+ raise Refused("HEAD points to no commit, so there is nothing to check")
20
+ return result.stdout.strip()
21
+
22
+
23
+ def require_clean(root: Path) -> None:
24
+ """Refuse a worktree whose state is not the commit: changes, or changes git hides."""
25
+ dirty_files = dirty(root)
26
+ if dirty_files:
27
+ raise Refused(message_dirty(dirty_files))
28
+ hidden_files = hidden(root)
29
+ if hidden_files:
30
+ raise Refused(message_hidden(hidden_files))
31
+
32
+
33
+ def worktree_root(cwd: Path) -> Path:
34
+ result = git(cwd, "rev-parse", "--show-toplevel")
35
+ if result.returncode != 0:
36
+ raise Refused(
37
+ "the repository has no worktree, so there is no commit to check here"
38
+ )
39
+ return Path(result.stdout.strip())
40
+
41
+
42
+ def run_check(config: Config, root: Path) -> int:
43
+ outcome = subprocess.run(
44
+ config.check, shell=True, cwd=root, stdin=subprocess.DEVNULL, check=False
45
+ )
46
+ if outcome.returncode != 0:
47
+ print(
48
+ f"prepush-ci: the check failed (exit {outcome.returncode}): {config.check}",
49
+ file=sys.stderr,
50
+ )
51
+ return 1
52
+ return 0
@@ -0,0 +1,79 @@
1
+ """Command-line entry point."""
2
+
3
+ import argparse
4
+ import os
5
+ import sys
6
+ from importlib.metadata import version
7
+ from pathlib import Path
8
+
9
+ from prepush_ci import (
10
+ commit_msg_hook,
11
+ install_command,
12
+ pre_push_hook,
13
+ run_command,
14
+ strip_command,
15
+ verify_command,
16
+ )
17
+
18
+
19
+ def main(argv: list[str] | None = None) -> int:
20
+ args_in = sys.argv[1:] if argv is None else argv
21
+ if args_in[:1] == ["--"]:
22
+ args_in = args_in[
23
+ 1:
24
+ ] # `prepush-ci -- verify "$SHA"` must reach the route below
25
+ if args_in[:1] == ["verify"]:
26
+ # Before argparse: its usage errors exit 2 and `--help` exits 0, but a CI gate must only
27
+ # ever see 0 (verified) or 1.
28
+ return verify_command.run(args_in[1:], Path.cwd())
29
+ parser = argparse.ArgumentParser(
30
+ prog="prepush-ci",
31
+ epilog="also: verify <sha> exit 0 when a commit carries the trailer and was not "
32
+ "made by a server", # not a subparser: argparse could never be allowed to answer for it
33
+ )
34
+ parser.add_argument(
35
+ "--version", action="version", version=f"%(prog)s {version('prepush-ci')}"
36
+ )
37
+ commands = parser.add_subparsers(dest="command")
38
+ hook = commands.add_parser("hook", help="entry points that git hooks call")
39
+ hooks = hook.add_subparsers(dest="hook", required=True)
40
+ commands.add_parser(
41
+ "run",
42
+ help="run the preflight and the check on HEAD, as the pre-push hook would",
43
+ )
44
+ commands.add_parser("install", help="point git at hooks that run this tool")
45
+ commit_msg = hooks.add_parser(
46
+ "commit-msg", help="write the trailer when PREPUSH_CI=full"
47
+ )
48
+ commit_msg.add_argument(
49
+ "file", type=Path, help="the commit message file git passes"
50
+ )
51
+ pre_push = hooks.add_parser(
52
+ "pre-push",
53
+ help="refuse to push a commit with the trailer unless the check passes",
54
+ )
55
+ pre_push.add_argument(
56
+ "git_args", nargs="*", help="the remote name and url git passes (unused)"
57
+ )
58
+ strip = commands.add_parser(
59
+ "strip",
60
+ help="remove the trailer from the commits since <base>, so a push needs no check",
61
+ )
62
+ strip.add_argument(
63
+ "base",
64
+ nargs="*",
65
+ help="the commit before yours (default: the merge base with the default branch)",
66
+ )
67
+ args = parser.parse_args(args_in)
68
+ if args.command == "strip":
69
+ return strip_command.run(args.base, Path.cwd())
70
+ if args.command == "run":
71
+ return run_command.run(os.environ, Path.cwd())
72
+ if args.command == "install":
73
+ return install_command.run(Path.cwd())
74
+ if args.command == "hook" and args.hook == "pre-push":
75
+ return pre_push_hook.run(sys.stdin, os.environ, Path.cwd())
76
+ if args.command == "hook":
77
+ return commit_msg_hook.run(args.file, os.environ, Path.cwd())
78
+ parser.print_help()
79
+ return 0
@@ -0,0 +1,116 @@
1
+ """`prepush-ci hook commit-msg <file>`: write the trailer that demands the full check."""
2
+
3
+ import subprocess
4
+ import sys
5
+ from collections.abc import Mapping
6
+ from pathlib import Path
7
+
8
+ from prepush_ci.core.commit_msg import (
9
+ InvalidMode,
10
+ Mode,
11
+ UnsupportedCommentChar,
12
+ editor_was_used,
13
+ is_blank_message,
14
+ is_template_untouched,
15
+ parse_mode,
16
+ require_known_comment_setting,
17
+ resolve_cleanup,
18
+ )
19
+ from prepush_ci.core.config import ConfigError
20
+ from prepush_ci.repo_config import load_repository_config
21
+
22
+ ENV_VAR = "PREPUSH_CI"
23
+
24
+
25
+ def run(message_file: Path, env: Mapping[str, str], cwd: Path) -> int:
26
+ """Run the hook; return the exit code (non-zero makes git abort the commit)."""
27
+ try:
28
+ mode = parse_mode(env.get(ENV_VAR))
29
+ except InvalidMode as error:
30
+ print(f"prepush-ci: {error}", file=sys.stderr)
31
+ return 1
32
+ if mode is Mode.OFF:
33
+ return 0
34
+ try:
35
+ config = load_repository_config(cwd)
36
+ except ConfigError as error:
37
+ for problem in error.problems:
38
+ print(f"prepush-ci: {problem}", file=sys.stderr)
39
+ return 1
40
+ try:
41
+ require_known_comment_setting(_comment_setting(cwd))
42
+ except UnsupportedCommentChar as error:
43
+ print(f"prepush-ci: {error}", file=sys.stderr)
44
+ return 1
45
+ text = message_file.read_text(encoding="utf-8")
46
+ editor_used = editor_was_used(env.get("GIT_EDITOR"), text)
47
+ cleanup = resolve_cleanup(_git_config(cwd, "commit.cleanup"), editor_used)
48
+ comment = _comment_prefix(cwd)
49
+ if is_blank_message(text, cleanup, comment):
50
+ return 0
51
+ template = (
52
+ _commit_template(cwd) if editor_used else None
53
+ ) # git checks only after an editor
54
+ if template is not None and is_template_untouched(text, template, cleanup, comment):
55
+ return 0
56
+ subprocess.run(
57
+ [
58
+ "git",
59
+ "interpret-trailers",
60
+ "--in-place",
61
+ "--no-divider",
62
+ "--where",
63
+ "start",
64
+ "--if-exists",
65
+ "doNothing",
66
+ "--trailer",
67
+ config.trailer.line(),
68
+ str(message_file),
69
+ ],
70
+ check=True,
71
+ )
72
+ return 0
73
+
74
+
75
+ def _comment_setting(cwd: Path) -> str | None:
76
+ """What the repository configures as comment prefix (`commentString` is newer git)."""
77
+ for name in ("core.commentString", "core.commentChar"):
78
+ value = _git_config(cwd, name)
79
+ if value is not None:
80
+ return value
81
+ return None
82
+
83
+
84
+ def _git_config(cwd: Path, name: str, *, as_path: bool = False) -> str | None:
85
+ result = subprocess.run(
86
+ ["git", "config", "--get", *(["--type=path"] if as_path else []), name],
87
+ cwd=cwd,
88
+ capture_output=True,
89
+ text=True,
90
+ check=False,
91
+ )
92
+ return result.stdout.removesuffix("\n") if result.returncode == 0 else None
93
+
94
+
95
+ def _comment_prefix(cwd: Path) -> str:
96
+ """The comment prefix of the git that runs the hook, whichever settings it honours."""
97
+ result = subprocess.run(
98
+ ["git", "stripspace", "--comment-lines"],
99
+ cwd=cwd,
100
+ input="x\n",
101
+ capture_output=True,
102
+ text=True,
103
+ check=True,
104
+ )
105
+ return result.stdout.removesuffix(" x\n")
106
+
107
+
108
+ def _commit_template(cwd: Path) -> str | None:
109
+ """The text of `commit.template`, or None when unset or unreadable (git reports that itself)."""
110
+ path = _git_config(cwd, "commit.template", as_path=True)
111
+ if path is None:
112
+ return None
113
+ try:
114
+ return (cwd / path).read_text(encoding="utf-8", errors="replace")
115
+ except OSError:
116
+ return None
@@ -0,0 +1,5 @@
1
+ """The pure core: decisions without I/O (trailers, config values, ref lines, verify).
2
+
3
+ Nothing here touches the file system, processes, the environment or the network; the CLI and
4
+ hook modules do that and pass plain values in. tests/unit covers this package to 100% on its own.
5
+ """
@@ -0,0 +1,144 @@
1
+ """Decisions of the commit-msg hook: what `PREPUSH_CI` asks for, and whether a message is empty."""
2
+
3
+ from enum import Enum
4
+
5
+
6
+ class Mode(Enum):
7
+ OFF = "off"
8
+ FULL = "full"
9
+
10
+
11
+ class InvalidMode(Exception):
12
+ """`PREPUSH_CI` holds a value other than `full`, `off` or the empty string."""
13
+
14
+
15
+ def parse_mode(value: str | None) -> Mode:
16
+ """Unset and empty mean `off`; values are compared exactly, so `FULL` is refused."""
17
+ if value is None or value == "":
18
+ return Mode.OFF
19
+ try:
20
+ return Mode(value)
21
+ except ValueError:
22
+ raise InvalidMode(
23
+ f"PREPUSH_CI={value!r} is not valid: use `full` to demand the full check "
24
+ "on this commit, or `off` (or leave it unset) to commit as usual"
25
+ ) from None
26
+
27
+
28
+ SIGN_OFF = (
29
+ "Signed-off-by: " # `git commit -s`; git does not count these lines as a message
30
+ )
31
+
32
+
33
+ class UnsupportedCommentChar(Exception):
34
+ """`core.commentChar` holds a value the hook cannot interpret."""
35
+
36
+
37
+ def require_known_comment_setting(setting: str | None) -> None:
38
+ """Refuse `auto` and empty: git picks the comment prefix per commit, so it cannot be known here."""
39
+ if setting in ("", "auto"):
40
+ raise UnsupportedCommentChar(
41
+ f"core.commentChar={setting!r} is not supported with PREPUSH_CI=full: "
42
+ "git picks the comment character per commit, so the hook cannot tell an "
43
+ "empty message from one that holds only the trailer; set core.commentChar "
44
+ "to a fixed character such as `#`, or commit with PREPUSH_CI=off"
45
+ )
46
+
47
+
48
+ class Cleanup(Enum):
49
+ """How git cleans the message up after the `commit-msg` hook (`commit.cleanup`)."""
50
+
51
+ STRIP = "strip"
52
+ WHITESPACE = "whitespace"
53
+ VERBATIM = "verbatim"
54
+ SCISSORS = "scissors"
55
+
56
+
57
+ def editor_was_used(git_editor: str | None, text: str) -> bool:
58
+ """Whether git launched an editor, which hooks cannot be told directly.
59
+
60
+ Git sets `GIT_EDITOR=:` for its hooks when it launches none. A user who chose the no-op
61
+ editor `:` still leaves git's template in the message, which starts with an empty line.
62
+ """
63
+ return git_editor != ":" or text.startswith("\n")
64
+
65
+
66
+ def resolve_cleanup(configured: str | None, editor_used: bool) -> Cleanup:
67
+ """The cleanup git applies: `commit.cleanup`, else `strip` with an editor, `whitespace` without.
68
+
69
+ An explicit `git commit --cleanup=<mode>` is invisible to hooks and not accounted for.
70
+ """
71
+ try:
72
+ return Cleanup(configured)
73
+ except ValueError: # unset or "default"
74
+ return Cleanup.STRIP if editor_used else Cleanup.WHITESPACE
75
+
76
+
77
+ def is_blank_message(
78
+ text: str, cleanup: Cleanup = Cleanup.STRIP, comment: str = "#"
79
+ ) -> bool:
80
+ """True when git will find nothing in the message after its cleanup and abort the commit.
81
+
82
+ Like git, ignore everything from the scissors line on (the diff of `git commit -v`) and
83
+ sign-off lines (`git commit -s` adds one to an empty message).
84
+ Only `strip` drops comment lines; in the other modes they are the message.
85
+ """
86
+ scissors = f"{comment} ------------------------ >8 ------------------------"
87
+ kept: list[str] = []
88
+ for line in text.splitlines():
89
+ if line.startswith(scissors):
90
+ break
91
+ kept.append(line)
92
+ if cleanup is Cleanup.VERBATIM:
93
+ return not kept
94
+ drops_comments = cleanup is Cleanup.STRIP
95
+ return not any(
96
+ line.strip()
97
+ and not line.startswith(SIGN_OFF)
98
+ and not (drops_comments and line.startswith(comment))
99
+ for line in kept
100
+ )
101
+
102
+
103
+ def clean_up(text: str, cleanup: Cleanup, comment: str = "#") -> str:
104
+ """The message the way git's cleanup leaves it (`git stripspace`), for comparing messages.
105
+
106
+ Like git, cut at the scissors line, trim trailing whitespace, collapse runs of empty lines
107
+ and drop leading and trailing ones; `strip` also drops comment lines. `verbatim` keeps the text.
108
+ """
109
+ scissors = f"{comment} ------------------------ >8 ------------------------"
110
+ if cleanup is Cleanup.VERBATIM:
111
+ return text
112
+ lines: list[str] = []
113
+ for line in text.splitlines():
114
+ if line.startswith(scissors):
115
+ break
116
+ if cleanup is Cleanup.STRIP and line.startswith(comment):
117
+ continue
118
+ line = line.rstrip()
119
+ if line or (lines and lines[-1]):
120
+ lines.append(line)
121
+ while lines and not lines[-1]:
122
+ lines.pop()
123
+ return "".join(f"{line}\n" for line in lines)
124
+
125
+
126
+ def is_template_untouched(
127
+ text: str, template: str, cleanup: Cleanup, comment: str = "#"
128
+ ) -> bool:
129
+ """True when git will find the message unchanged from `commit.template` and abort the commit.
130
+
131
+ Git compares the cleaned message with the cleaned template, so a trailer added by the hook
132
+ would make an unedited template look edited. Sign-off lines after the template do not count
133
+ as an edit, and a verbatim message is never untouched.
134
+ """
135
+ if cleanup is Cleanup.VERBATIM or not template:
136
+ return False
137
+ cleaned_template = clean_up(template, cleanup, comment)
138
+ cleaned = clean_up(text, cleanup, comment)
139
+ if not cleaned.startswith(cleaned_template):
140
+ return False
141
+ rest = cleaned.removeprefix(cleaned_template)
142
+ return all(
143
+ not line.strip() or line.startswith(SIGN_OFF) for line in rest.splitlines()
144
+ )