chore(hooks): enforce Conventional Commits and Conventional Branches (#30174)
* chore(hooks): enforce Conventional Commits and Conventional Branches Adds opt-in local git hooks plus a CI PR-title check: - .githooks/commit-msg validates commit subjects against Conventional Commits 1.0.0 (feat|fix|docs|style|refactor|perf|test|build|ci| chore|revert)(scope)!: subject. Merge/revert/fixup!/squash!/amend! messages pass through; --no-verify still works. - .githooks/pre-push validates branch names against Conventional Branches (feature|bugfix|hotfix|release|chore)/desc. Bypasses main, litellm_internal_staging, dependabot/*, gh-readonly-queue/*. Tag pushes and deletions are skipped. - scripts/install_git_hooks.sh sets core.hooksPath=.githooks and is wired up as 'make install-hooks'. Opt-in — not chained into install-dev. - .github/workflows/conventional-commits.yml validates PR titles via amannn/action-semantic-pull-request pinned to v6.1.1's SHA. This is the actual gate since squash-merge uses the PR title as the commit subject. - tests/test_litellm/test_git_hooks.py exercises both hooks via subprocess for accept / reject / bypass / git-generated-message cases. - CONTRIBUTING.md documents the conventions, the install step, the bypass list, and the --no-verify escape hatch. Resolves LIT-3306 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(hooks): address Greptile review on PR #28703 Resolves two findings from the automated code review: 1. CONTRIBUTING.md: shrink the new Conventional Commits / Branches section to a 2-line pointer at docs.litellm.ai. Per the team convention, the full documentation lives in the litellm-docs repo — see BerriAI/litellm-docs#208 for the companion change that adds the section to docs/extras/contributing_code.md. 2. .githooks/commit-msg: tighten the subject regex to also reject an uppercase first letter in the description. CI's subjectPattern is ^(?![A-Z]).+$ so the previous local hook would accept 'feat: Add thing' which would then fail the PR-title check. The local hook is now the strictly tighter of the two gates. Test cases extended to cover both the new rejection and the digit/symbol-start cases that remain allowed. Resolves LIT-3306 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore: trigger ci after branch rename * fix(ci): rerun pr title check when bypass label changes amannn/action-semantic-pull-request only honors ignoreLabels if the workflow retriggers on labeled/unlabeled events; without them a red check stays red after a maintainer applies the bypass label. Also point the CONTRIBUTING.md workflow comments at the conventions section, which now sits above the Development Workflow section. --------- Co-authored-by: Yassin Kortam <yassinkortam@Yassins-MBP.localdomain> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
49ca04d8c3
commit
0d120de785
75
.githooks/commit-msg
Executable file
75
.githooks/commit-msg
Executable file
@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# commit-msg — enforce Conventional Commits 1.0.0
|
||||
# https://www.conventionalcommits.org/en/v1.0.0/
|
||||
#
|
||||
# Subject format: <type>(<scope>)!: <description>
|
||||
# - <type> must be one of the angular types (feat, fix, ...)
|
||||
# - (<scope>) is optional
|
||||
# - ! is optional and marks a breaking change
|
||||
# - <description> is mandatory and must be non-empty
|
||||
#
|
||||
# Bypass: commit with --no-verify.
|
||||
# Merge, revert, fixup!, squash!, and amend! messages are passed through.
|
||||
|
||||
set -eu
|
||||
|
||||
COMMIT_MSG_FILE="${1:-}"
|
||||
if [ -z "$COMMIT_MSG_FILE" ] || [ ! -f "$COMMIT_MSG_FILE" ]; then
|
||||
echo "commit-msg: missing commit message file" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# First non-comment, non-empty line is the subject.
|
||||
subject=""
|
||||
while IFS= read -r line || [ -n "$line" ]; do
|
||||
case "$line" in
|
||||
''|'#'*) continue ;;
|
||||
esac
|
||||
subject="$line"
|
||||
break
|
||||
done < "$COMMIT_MSG_FILE"
|
||||
|
||||
if [ -z "$subject" ]; then
|
||||
echo "commit-msg: empty commit message" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Pass-through commits generated by git itself.
|
||||
case "$subject" in
|
||||
"Merge "*|"Revert \""*|"fixup! "*|"squash! "*|"amend! "*)
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
ALLOWED_TYPES="feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert"
|
||||
# Description must not start with an uppercase letter — kept in sync with the
|
||||
# subjectPattern in .github/workflows/conventional-commits.yml so the local
|
||||
# hook is the strictly tighter of the two gates. (Without this guard, a commit
|
||||
# like "feat: Add thing" passes locally but fails the PR-title CI check.)
|
||||
PATTERN="^(${ALLOWED_TYPES})(\([^)]+\))?!?: [^A-Z].*"
|
||||
|
||||
if printf '%s' "$subject" | grep -Eq "$PATTERN"; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
cat >&2 <<EOF
|
||||
✗ Commit message does not follow Conventional Commits.
|
||||
|
||||
Got: $subject
|
||||
|
||||
Expected: <type>(<scope>)!: <description>
|
||||
(description must start with a lowercase letter)
|
||||
|
||||
Allowed types: feat, fix, docs, style, refactor, perf, test, build, ci, chore, revert
|
||||
Examples:
|
||||
feat(router): add weighted round-robin strategy
|
||||
fix(bedrock): decouple STS region from aws_region_name
|
||||
chore(deps): bump black to 26.3.1
|
||||
refactor!: drop Python 3.8 support
|
||||
|
||||
See https://www.conventionalcommits.org/en/v1.0.0/
|
||||
|
||||
To bypass (use sparingly): git commit --no-verify
|
||||
EOF
|
||||
exit 1
|
||||
92
.githooks/pre-push
Executable file
92
.githooks/pre-push
Executable file
@ -0,0 +1,92 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# pre-push — enforce Conventional Branches
|
||||
# https://conventional-branch.github.io/
|
||||
#
|
||||
# Branch format: <type>/<description>
|
||||
# <type> must be one of: feature, bugfix, hotfix, release, chore
|
||||
#
|
||||
# Protected branches (always allowed):
|
||||
# - main
|
||||
# - litellm_internal_staging
|
||||
# - dependabot/*
|
||||
# - gh-readonly-queue/*
|
||||
#
|
||||
# Tag pushes and branch deletions are skipped.
|
||||
# Bypass: git push --no-verify.
|
||||
|
||||
set -eu
|
||||
|
||||
ZERO_OID="0000000000000000000000000000000000000000"
|
||||
ZERO_OID_SHA256="0000000000000000000000000000000000000000000000000000000000000000"
|
||||
ALLOWED_TYPES="feature|bugfix|hotfix|release|chore"
|
||||
BRANCH_PATTERN="^(${ALLOWED_TYPES})/.+"
|
||||
|
||||
PROTECTED_NAMES="main litellm_internal_staging"
|
||||
PROTECTED_PREFIXES="dependabot/ gh-readonly-queue/"
|
||||
|
||||
is_protected() {
|
||||
branch="$1"
|
||||
for name in $PROTECTED_NAMES; do
|
||||
if [ "$branch" = "$name" ]; then
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
for prefix in $PROTECTED_PREFIXES; do
|
||||
case "$branch" in "$prefix"*) return 0 ;; esac
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
invalid=""
|
||||
|
||||
while read -r local_ref local_oid remote_ref remote_oid; do
|
||||
# Branch deletion (no local commit being pushed).
|
||||
if [ "$local_oid" = "$ZERO_OID" ] || [ "$local_oid" = "$ZERO_OID_SHA256" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Only validate branch pushes; ignore tags and other ref namespaces.
|
||||
case "$remote_ref" in
|
||||
refs/heads/*) ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
|
||||
branch="${remote_ref#refs/heads/}"
|
||||
|
||||
if is_protected "$branch"; then
|
||||
continue
|
||||
fi
|
||||
|
||||
if ! printf '%s' "$branch" | grep -Eq "$BRANCH_PATTERN"; then
|
||||
invalid="$invalid $branch"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -n "$invalid" ]; then
|
||||
cat >&2 <<EOF
|
||||
✗ Branch name does not follow Conventional Branches.
|
||||
|
||||
Invalid:$invalid
|
||||
|
||||
Expected: <type>/<description>
|
||||
|
||||
Allowed types: feature, bugfix, hotfix, release, chore
|
||||
Examples:
|
||||
feature/weighted-round-robin
|
||||
bugfix/streaming-empty-chunks
|
||||
chore/bump-deps
|
||||
hotfix/auth-bypass
|
||||
|
||||
Protected (always allowed): main, litellm_internal_staging,
|
||||
dependabot/*, gh-readonly-queue/*.
|
||||
|
||||
See https://conventional-branch.github.io/
|
||||
|
||||
Rename with: git branch -m <new-name>
|
||||
To bypass (use sparingly): git push --no-verify
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
|
||||
exit 0
|
||||
46
.github/workflows/conventional-commits.yml
vendored
Normal file
46
.github/workflows/conventional-commits.yml
vendored
Normal file
@ -0,0 +1,46 @@
|
||||
name: Conventional PR Title
|
||||
|
||||
# Squash-merge replaces the merge commit subject with the PR title, so
|
||||
# enforcing Conventional Commits at the PR-title level is what actually gates
|
||||
# the commits that land on the default branch. The local commit-msg hook
|
||||
# (.githooks/commit-msg) is a best-effort assist; this workflow is the gate.
|
||||
#
|
||||
# See https://www.conventionalcommits.org/en/v1.0.0/
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, edited, reopened, synchronize, labeled, unlabeled]
|
||||
|
||||
permissions:
|
||||
pull-requests: read
|
||||
|
||||
jobs:
|
||||
lint-pr-title:
|
||||
name: Validate PR title
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check title against Conventional Commits
|
||||
uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
# Must mirror the type list in .githooks/commit-msg.
|
||||
types: |
|
||||
feat
|
||||
fix
|
||||
docs
|
||||
style
|
||||
refactor
|
||||
perf
|
||||
test
|
||||
build
|
||||
ci
|
||||
chore
|
||||
revert
|
||||
requireScope: false
|
||||
subjectPattern: ^(?![A-Z]).+$
|
||||
subjectPatternError: |
|
||||
The subject "{subject}" must start with a lowercase character.
|
||||
# Allow merges/reverts that GitHub generates automatically.
|
||||
ignoreLabels: |
|
||||
ignore-semantic-pull-request
|
||||
@ -38,18 +38,25 @@ Before contributing code to LiteLLM, you must sign our [Contributor License Agre
|
||||
git clone https://github.com/YOUR_USERNAME/litellm.git
|
||||
cd litellm
|
||||
|
||||
# Create a new branch for your feature
|
||||
git checkout -b your-feature-branch
|
||||
# Create a new branch for your feature (see "Commit and Branch Conventions" below)
|
||||
git checkout -b feature/your-feature
|
||||
|
||||
# Install development dependencies
|
||||
make install-dev
|
||||
|
||||
# Install git hooks that enforce commit + branch conventions (one-time, opt-in)
|
||||
make install-hooks
|
||||
|
||||
# Verify your setup works
|
||||
make help
|
||||
```
|
||||
|
||||
That's it! Your local development environment is ready.
|
||||
|
||||
## Commit and Branch Conventions
|
||||
|
||||
Commits follow [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) and branches follow [Conventional Branches](https://conventional-branch.github.io/). Run `make install-hooks` once per clone to enable the local git hooks that enforce these — see the [contributor docs](https://docs.litellm.ai/docs/extras/contributing_code#commit-and-branch-conventions) for the full type list, examples, the protected-branch bypass list, and how to opt out.
|
||||
|
||||
### 2. Development Workflow
|
||||
|
||||
Here's the recommended workflow for making changes:
|
||||
@ -67,12 +74,12 @@ make lint
|
||||
# Run unit tests to ensure nothing is broken
|
||||
make test-unit
|
||||
|
||||
# Commit your changes
|
||||
# Commit your changes (must follow Conventional Commits — see above)
|
||||
git add .
|
||||
git commit -m "Your descriptive commit message"
|
||||
git commit -m "feat(scope): your descriptive commit message"
|
||||
|
||||
# Push and create a PR
|
||||
git push origin your-feature-branch
|
||||
# Push and create a PR (branch must follow Conventional Branches — see above)
|
||||
git push origin feature/your-feature
|
||||
```
|
||||
|
||||
## Adding Testing
|
||||
|
||||
8
Makefile
8
Makefile
@ -5,7 +5,7 @@
|
||||
test-unit-integrations test-unit-core-utils test-unit-other test-unit-root \
|
||||
test-proxy-unit-a test-proxy-unit-b test-integration test-unit-helm \
|
||||
info lint lint-dev format \
|
||||
install-dev install-proxy-dev install-test-deps \
|
||||
install-dev install-proxy-dev install-test-deps install-hooks \
|
||||
install-helm-unittest check-circular-imports check-import-safety
|
||||
|
||||
# Default target
|
||||
@ -17,6 +17,7 @@ help:
|
||||
@echo " make install-proxy-dev-ci - Install proxy dev dependencies (CI-compatible)"
|
||||
@echo " make install-test-deps - Install the full local test environment"
|
||||
@echo " make install-helm-unittest - Install helm unittest plugin"
|
||||
@echo " make install-hooks - Install git hooks (Conventional Commits + Branches)"
|
||||
@echo " make format - Apply Black code formatting"
|
||||
@echo " make format-check - Check Black code formatting (matches CI)"
|
||||
@echo " make lint - Run all linting (Ruff, MyPy, Black check, circular imports, import safety)"
|
||||
@ -68,6 +69,11 @@ install-test-deps: install-proxy-dev
|
||||
install-helm-unittest:
|
||||
helm plugin install https://github.com/helm-unittest/helm-unittest --version v0.4.4 || echo "ignore error if plugin exists"
|
||||
|
||||
# Install git hooks that enforce Conventional Commits and Conventional Branches.
|
||||
# Opt-in: not chained into install-dev.
|
||||
install-hooks:
|
||||
./scripts/install_git_hooks.sh
|
||||
|
||||
# Formatting
|
||||
format: install-dev
|
||||
cd litellm && $(UV_RUN) black . && cd ..
|
||||
|
||||
38
scripts/install_git_hooks.sh
Executable file
38
scripts/install_git_hooks.sh
Executable file
@ -0,0 +1,38 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Install the repo's git hooks by pointing core.hooksPath at .githooks.
|
||||
#
|
||||
# Idempotent: re-running just reaffirms the config and refreshes chmod bits.
|
||||
# Run from anywhere inside the repo.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
echo "install_git_hooks: not inside a git working tree" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
repo_root=$(git rev-parse --show-toplevel)
|
||||
hooks_dir="$repo_root/.githooks"
|
||||
|
||||
if [ ! -d "$hooks_dir" ]; then
|
||||
echo "install_git_hooks: $hooks_dir does not exist" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Ensure the hook scripts are executable. New clones on case-preserving
|
||||
# filesystems sometimes lose the exec bit; this normalizes it.
|
||||
chmod +x "$hooks_dir"/* 2>/dev/null || true
|
||||
|
||||
git config core.hooksPath .githooks
|
||||
|
||||
cat <<EOF
|
||||
✓ Git hooks installed.
|
||||
core.hooksPath = .githooks
|
||||
active hooks: $(ls "$hooks_dir" | tr '\n' ' ')
|
||||
|
||||
These hooks enforce Conventional Commits and Conventional Branches.
|
||||
Bypass with --no-verify when you need to (e.g. for emergency hotfixes).
|
||||
|
||||
To uninstall: git config --unset core.hooksPath
|
||||
EOF
|
||||
286
tests/test_litellm/test_git_hooks.py
Normal file
286
tests/test_litellm/test_git_hooks.py
Normal file
@ -0,0 +1,286 @@
|
||||
"""Tests for the repo's git hook scripts in ``.githooks/``.
|
||||
|
||||
The hooks enforce Conventional Commits 1.0.0 on the commit-msg path and
|
||||
Conventional Branches on the pre-push path. Each hook is exercised here as a
|
||||
subprocess against representative valid / invalid inputs so that any future
|
||||
regex change or accidental edit gets caught by ``make test-unit``.
|
||||
|
||||
The hooks are POSIX-ish bash scripts; the test is skipped on Windows where
|
||||
``bash`` may not be on PATH.
|
||||
"""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
_REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
_HOOKS_DIR = _REPO_ROOT / ".githooks"
|
||||
_COMMIT_MSG_HOOK = _HOOKS_DIR / "commit-msg"
|
||||
_PRE_PUSH_HOOK = _HOOKS_DIR / "pre-push"
|
||||
|
||||
_ZERO_OID = "0" * 40
|
||||
_NONZERO_OID = "abc123abc123abc123abc123abc123abc123abc1"
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
shutil.which("bash") is None,
|
||||
reason="bash not available; git hook scripts are bash-based",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _ensure_hooks_exist():
|
||||
assert _COMMIT_MSG_HOOK.exists(), f"missing hook: {_COMMIT_MSG_HOOK}"
|
||||
assert _PRE_PUSH_HOOK.exists(), f"missing hook: {_PRE_PUSH_HOOK}"
|
||||
# Exec bit may be missing on a fresh clone on case-preserving filesystems;
|
||||
# the installer normalizes this, but the test shouldn't depend on having
|
||||
# run it.
|
||||
for hook in (_COMMIT_MSG_HOOK, _PRE_PUSH_HOOK):
|
||||
mode = hook.stat().st_mode
|
||||
if not (mode & 0o100):
|
||||
hook.chmod(mode | 0o755)
|
||||
|
||||
|
||||
def _run_commit_msg(subject: str, tmp_path: Path) -> subprocess.CompletedProcess:
|
||||
msg_file = tmp_path / "COMMIT_EDITMSG"
|
||||
msg_file.write_text(subject + "\n", encoding="utf-8")
|
||||
return subprocess.run(
|
||||
["bash", str(_COMMIT_MSG_HOOK), str(msg_file)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def _run_pre_push(stdin: str) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
["bash", str(_PRE_PUSH_HOOK)],
|
||||
input=stdin,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
def _ref_line(branch: str, local_oid: str = _NONZERO_OID, remote_oid: str = _ZERO_OID) -> str:
|
||||
ref = f"refs/heads/{branch}"
|
||||
return f"{ref} {local_oid} {ref} {remote_oid}\n"
|
||||
|
||||
|
||||
# ----- commit-msg -----------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"subject",
|
||||
[
|
||||
"feat(router): add weighted round-robin strategy",
|
||||
"fix(bedrock): decouple STS region from aws_region_name",
|
||||
"chore(deps): bump black to 26.3.1",
|
||||
"docs: rewrite contributing guide",
|
||||
"refactor!: drop Python 3.8 support",
|
||||
"feat(api,proxy)!: rename endpoint",
|
||||
"test: cover hook bypass list",
|
||||
"perf(streaming): avoid extra json parse",
|
||||
"revert: feat(router): add weighted round-robin",
|
||||
],
|
||||
)
|
||||
def test_commit_msg_accepts_conventional_subjects(tmp_path, subject):
|
||||
result = _run_commit_msg(subject, tmp_path)
|
||||
assert result.returncode == 0, (
|
||||
f"hook rejected a valid subject:\n subject: {subject!r}\n"
|
||||
f" stderr: {result.stderr}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"subject",
|
||||
[
|
||||
"add stuff", # no type
|
||||
"feat add router strategy", # missing colon
|
||||
"feat:add router strategy", # missing space after colon
|
||||
"feat():", # empty description
|
||||
"ux: thing", # unknown type
|
||||
"Feat(router): capital type", # types are lowercase
|
||||
"feat(router):", # empty description
|
||||
# Description must start with a lowercase letter — kept in sync with
|
||||
# the CI workflow's subjectPattern so the local hook never accepts a
|
||||
# subject that CI will later reject.
|
||||
"feat: Add thing",
|
||||
"fix(router): Decouple something",
|
||||
"chore: BUMP deps",
|
||||
"feat: A",
|
||||
],
|
||||
)
|
||||
def test_commit_msg_rejects_invalid_subjects(tmp_path, subject):
|
||||
result = _run_commit_msg(subject, tmp_path)
|
||||
assert result.returncode == 1, (
|
||||
f"hook accepted an invalid subject:\n subject: {subject!r}\n"
|
||||
f" stderr: {result.stderr}"
|
||||
)
|
||||
assert "Conventional Commits" in result.stderr
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"subject",
|
||||
[
|
||||
# Lowercase letter — the common case.
|
||||
"feat: lowercase start is fine",
|
||||
# The CI's `^(?![A-Z]).+$` rejects only uppercase A-Z, so digits and
|
||||
# symbols are still allowed; mirror that behavior here.
|
||||
"feat: 1-based indexing now works",
|
||||
"fix(deps): @types/node bump",
|
||||
],
|
||||
)
|
||||
def test_commit_msg_accepts_non_uppercase_starts(tmp_path, subject):
|
||||
result = _run_commit_msg(subject, tmp_path)
|
||||
assert result.returncode == 0, (
|
||||
f"hook rejected a valid non-uppercase-start subject:\n"
|
||||
f" subject: {subject!r}\n stderr: {result.stderr}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"subject",
|
||||
[
|
||||
"Merge branch 'main' into feature/foo",
|
||||
'Revert "feat(router): add weighted round-robin strategy"',
|
||||
"fixup! feat(router): add weighted round-robin strategy",
|
||||
"squash! feat(router): add weighted round-robin strategy",
|
||||
"amend! feat(router): add weighted round-robin strategy",
|
||||
],
|
||||
)
|
||||
def test_commit_msg_passes_git_generated_messages(tmp_path, subject):
|
||||
result = _run_commit_msg(subject, tmp_path)
|
||||
assert result.returncode == 0, (
|
||||
f"hook should pass git-generated subject:\n subject: {subject!r}\n"
|
||||
f" stderr: {result.stderr}"
|
||||
)
|
||||
|
||||
|
||||
def test_commit_msg_rejects_empty_message(tmp_path):
|
||||
result = _run_commit_msg("", tmp_path)
|
||||
assert result.returncode == 1
|
||||
assert "empty commit message" in result.stderr
|
||||
|
||||
|
||||
def test_commit_msg_skips_comment_only_lines(tmp_path):
|
||||
# An all-comments file has no subject — should be rejected.
|
||||
msg_file = tmp_path / "COMMIT_EDITMSG"
|
||||
msg_file.write_text("# please enter a commit message\n# above this line\n", encoding="utf-8")
|
||||
result = subprocess.run(
|
||||
["bash", str(_COMMIT_MSG_HOOK), str(msg_file)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 1
|
||||
assert "empty commit message" in result.stderr
|
||||
|
||||
|
||||
def test_commit_msg_uses_first_non_comment_line(tmp_path):
|
||||
# Real git-generated COMMIT_EDITMSG has a status block prefixed with '#'
|
||||
# below the subject. Make sure leading comment lines are skipped too.
|
||||
msg_file = tmp_path / "COMMIT_EDITMSG"
|
||||
msg_file.write_text(
|
||||
"# On branch feature/foo\n"
|
||||
"\n"
|
||||
"feat(router): add weighted round-robin\n"
|
||||
"\n"
|
||||
"# Please enter the commit message...\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
result = subprocess.run(
|
||||
["bash", str(_COMMIT_MSG_HOOK), str(msg_file)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
# ----- pre-push -------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"branch",
|
||||
[
|
||||
"feature/weighted-round-robin",
|
||||
"bugfix/streaming-empty-chunks",
|
||||
"hotfix/auth-bypass",
|
||||
"release/v1.45.0",
|
||||
"chore/bump-deps",
|
||||
"feature/nested/path/ok", # nested slashes after type are fine
|
||||
],
|
||||
)
|
||||
def test_pre_push_accepts_conventional_branches(branch):
|
||||
result = _run_pre_push(_ref_line(branch))
|
||||
assert result.returncode == 0, (
|
||||
f"hook rejected a valid branch:\n branch: {branch!r}\n"
|
||||
f" stderr: {result.stderr}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"branch",
|
||||
[
|
||||
"random-branch-name",
|
||||
"litellm_fix/optimize-streaming", # legacy pattern is now rejected
|
||||
"ui/navbar-notifications", # not in the allow list
|
||||
"feature/", # empty description
|
||||
"Feature/foo", # type is case-sensitive
|
||||
"feat/foo", # angular commit type, not branch type
|
||||
],
|
||||
)
|
||||
def test_pre_push_rejects_non_conventional_branches(branch):
|
||||
result = _run_pre_push(_ref_line(branch))
|
||||
assert result.returncode == 1, (
|
||||
f"hook accepted an invalid branch:\n branch: {branch!r}\n"
|
||||
f" stderr: {result.stderr}"
|
||||
)
|
||||
assert "Conventional Branches" in result.stderr
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"branch",
|
||||
[
|
||||
"main",
|
||||
"litellm_internal_staging",
|
||||
"dependabot/github_actions/foo",
|
||||
"gh-readonly-queue/main/abc123",
|
||||
],
|
||||
)
|
||||
def test_pre_push_bypasses_protected_branches(branch):
|
||||
result = _run_pre_push(_ref_line(branch))
|
||||
assert result.returncode == 0, (
|
||||
f"protected branch was rejected:\n branch: {branch!r}\n"
|
||||
f" stderr: {result.stderr}"
|
||||
)
|
||||
|
||||
|
||||
def test_pre_push_skips_tag_pushes():
|
||||
line = f"refs/tags/v1 {_NONZERO_OID} refs/tags/v1 {_ZERO_OID}\n"
|
||||
result = _run_pre_push(line)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
def test_pre_push_skips_branch_deletions():
|
||||
# local oid all zeros = deletion
|
||||
line = f"refs/heads/whatever {_ZERO_OID} refs/heads/whatever {_NONZERO_OID}\n"
|
||||
result = _run_pre_push(line)
|
||||
assert result.returncode == 0, result.stderr
|
||||
|
||||
|
||||
def test_pre_push_fails_if_any_ref_is_invalid():
|
||||
# Mixed batch: one valid, one invalid — entire push should fail.
|
||||
stdin = _ref_line("feature/ok") + _ref_line("random-bad")
|
||||
result = _run_pre_push(stdin)
|
||||
assert result.returncode == 1
|
||||
assert "random-bad" in result.stderr
|
||||
|
||||
|
||||
def test_pre_push_no_refs_passes():
|
||||
# Empty stdin (no refs being pushed) should pass.
|
||||
result = _run_pre_push("")
|
||||
assert result.returncode == 0, result.stderr
|
||||
Loading…
Reference in New Issue
Block a user