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.
- prepush_ci-0.0.0/PKG-INFO +66 -0
- prepush_ci-0.0.0/README.md +45 -0
- prepush_ci-0.0.0/pyproject.toml +87 -0
- prepush_ci-0.0.0/pyproject.toml.orig +70 -0
- prepush_ci-0.0.0/src/prepush_ci/__init__.py +1 -0
- prepush_ci-0.0.0/src/prepush_ci/__main__.py +5 -0
- prepush_ci-0.0.0/src/prepush_ci/checking.py +52 -0
- prepush_ci-0.0.0/src/prepush_ci/cli.py +79 -0
- prepush_ci-0.0.0/src/prepush_ci/commit_msg_hook.py +116 -0
- prepush_ci-0.0.0/src/prepush_ci/core/__init__.py +5 -0
- prepush_ci-0.0.0/src/prepush_ci/core/commit_msg.py +144 -0
- prepush_ci-0.0.0/src/prepush_ci/core/config.py +169 -0
- prepush_ci-0.0.0/src/prepush_ci/core/config_source.py +59 -0
- prepush_ci-0.0.0/src/prepush_ci/core/install.py +138 -0
- prepush_ci-0.0.0/src/prepush_ci/core/pre_push.py +185 -0
- prepush_ci-0.0.0/src/prepush_ci/core/preflight.py +64 -0
- prepush_ci-0.0.0/src/prepush_ci/core/run_cache.py +56 -0
- prepush_ci-0.0.0/src/prepush_ci/core/strip.py +155 -0
- prepush_ci-0.0.0/src/prepush_ci/core/verify.py +104 -0
- prepush_ci-0.0.0/src/prepush_ci/git.py +99 -0
- prepush_ci-0.0.0/src/prepush_ci/install_command.py +105 -0
- prepush_ci-0.0.0/src/prepush_ci/pass_cache.py +62 -0
- prepush_ci-0.0.0/src/prepush_ci/pre_push_hook.py +121 -0
- prepush_ci-0.0.0/src/prepush_ci/preflight.py +73 -0
- prepush_ci-0.0.0/src/prepush_ci/repo_config.py +34 -0
- prepush_ci-0.0.0/src/prepush_ci/run_command.py +39 -0
- prepush_ci-0.0.0/src/prepush_ci/strip_command.py +168 -0
- prepush_ci-0.0.0/src/prepush_ci/verify_command.py +82 -0
|
@@ -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
|
+
[](https://github.com/CapeOfGoodCode/prepush-ci/actions/workflows/ci.yml)
|
|
29
|
+
[](https://github.com/CapeOfGoodCode/prepush-ci/tree/python-coverage-comment-action-data)
|
|
30
|
+
[](https://pypi.org/project/prepush-ci/)
|
|
31
|
+
[](https://pypi.org/project/prepush-ci/)
|
|
32
|
+
[](LICENSE)
|
|
33
|
+
[](LOOP.md)
|
|
34
|
+
[](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
|
+
[](https://github.com/CapeOfGoodCode/prepush-ci/actions/workflows/ci.yml)
|
|
8
|
+
[](https://github.com/CapeOfGoodCode/prepush-ci/tree/python-coverage-comment-action-data)
|
|
9
|
+
[](https://pypi.org/project/prepush-ci/)
|
|
10
|
+
[](https://pypi.org/project/prepush-ci/)
|
|
11
|
+
[](LICENSE)
|
|
12
|
+
[](LOOP.md)
|
|
13
|
+
[](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,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
|
+
)
|