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
|
git clone https://github.com/YOUR_USERNAME/litellm.git
|
||||||
cd litellm
|
cd litellm
|
||||||
|
|
||||||
# Create a new branch for your feature
|
# Create a new branch for your feature (see "Commit and Branch Conventions" below)
|
||||||
git checkout -b your-feature-branch
|
git checkout -b feature/your-feature
|
||||||
|
|
||||||
# Install development dependencies
|
# Install development dependencies
|
||||||
make install-dev
|
make install-dev
|
||||||
|
|
||||||
|
# Install git hooks that enforce commit + branch conventions (one-time, opt-in)
|
||||||
|
make install-hooks
|
||||||
|
|
||||||
# Verify your setup works
|
# Verify your setup works
|
||||||
make help
|
make help
|
||||||
```
|
```
|
||||||
|
|
||||||
That's it! Your local development environment is ready.
|
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
|
### 2. Development Workflow
|
||||||
|
|
||||||
Here's the recommended workflow for making changes:
|
Here's the recommended workflow for making changes:
|
||||||
@ -67,12 +74,12 @@ make lint
|
|||||||
# Run unit tests to ensure nothing is broken
|
# Run unit tests to ensure nothing is broken
|
||||||
make test-unit
|
make test-unit
|
||||||
|
|
||||||
# Commit your changes
|
# Commit your changes (must follow Conventional Commits — see above)
|
||||||
git add .
|
git add .
|
||||||
git commit -m "Your descriptive commit message"
|
git commit -m "feat(scope): your descriptive commit message"
|
||||||
|
|
||||||
# Push and create a PR
|
# Push and create a PR (branch must follow Conventional Branches — see above)
|
||||||
git push origin your-feature-branch
|
git push origin feature/your-feature
|
||||||
```
|
```
|
||||||
|
|
||||||
## Adding Testing
|
## Adding Testing
|
||||||
|
|||||||
8
Makefile
8
Makefile
@ -5,7 +5,7 @@
|
|||||||
test-unit-integrations test-unit-core-utils test-unit-other test-unit-root \
|
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 \
|
test-proxy-unit-a test-proxy-unit-b test-integration test-unit-helm \
|
||||||
info lint lint-dev format \
|
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
|
install-helm-unittest check-circular-imports check-import-safety
|
||||||
|
|
||||||
# Default target
|
# Default target
|
||||||
@ -17,6 +17,7 @@ help:
|
|||||||
@echo " make install-proxy-dev-ci - Install proxy dev dependencies (CI-compatible)"
|
@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-test-deps - Install the full local test environment"
|
||||||
@echo " make install-helm-unittest - Install helm unittest plugin"
|
@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 - Apply Black code formatting"
|
||||||
@echo " make format-check - Check Black code formatting (matches CI)"
|
@echo " make format-check - Check Black code formatting (matches CI)"
|
||||||
@echo " make lint - Run all linting (Ruff, MyPy, Black check, circular imports, import safety)"
|
@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:
|
install-helm-unittest:
|
||||||
helm plugin install https://github.com/helm-unittest/helm-unittest --version v0.4.4 || echo "ignore error if plugin exists"
|
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
|
# Formatting
|
||||||
format: install-dev
|
format: install-dev
|
||||||
cd litellm && $(UV_RUN) black . && cd ..
|
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