Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .pre-commit-hooks.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,11 @@
args: [--author-email]
pass_filenames: false
language: python
- id: check-no-force-push
name: check no force push
description: prevents force pushes to remote branches
entry: commit-check
args: [--no-force-push]
stages: [pre-push]
pass_filenames: false
language: python
26 changes: 26 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,32 @@ For one-off checks or CI/CD pipelines, you can configure via CLI arguments or en

See the `Configuration documentation <https://commit-check.github.io/commit-check/configuration.html>`_ for all available options.

Check Push Safety
~~~~~~~~~~~~~~~~~

Use ``--no-force-push`` in a ``pre-push`` hook to inspect the ref updates Git
provides on stdin, or run it directly to compare ``HEAD`` with the current
branch's configured upstream:

.. code-block:: bash

# Standalone preflight check against the current branch's upstream
commit-check --no-force-push

.. code-block:: yaml

# In pre-commit hooks (.pre-commit-config.yaml)
repos:
- repo: https://github.com/commit-check/commit-check
rev: v2.6.0
hooks:
- id: check-no-force-push
stages: [pre-push]

Piping ``git push`` into ``commit-check`` is not a prevention mechanism. The
push has already been started, and standard ``git push`` output does not carry
the pre-push ref metadata that ``commit-check`` uses.

AI-Native Usage
---------------

Expand Down
5 changes: 5 additions & 0 deletions commit_check/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@
# Additional allowed branch names (e.g., develop, staging)
DEFAULT_BRANCH_NAMES: list[str] = []

# Push-related defaults
DEFAULT_PUSH_RULES = {
"allow_force_push": True,
}

# Handle different default values for different rules
DEFAULT_BOOLEAN_RULES = {
"subject_capitalized": False,
Expand Down
32 changes: 32 additions & 0 deletions commit_check/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,38 @@ def validate_author(
return _run_checks(checks, context, cfg)


def validate_push(
push_refs: Optional[str] = None,
*,
config: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""Validate that a push is not a force push.

:param push_refs: Push ref information in the format produced by git's
pre-push hook: ``<local ref> <local sha1> <remote ref> <remote sha1>``,
one entry per line. If *None*, the check is skipped (returns pass).
:param config: Optional configuration override dict. The push check is
always enabled when calling this function; force pushes detected here
will always return ``"fail"``.
:returns: A dict with ``"status"`` (``"pass"``/``"fail"``) and ``"checks"``.

Example::

>>> from commit_check.api import validate_push
>>> zero = "0000000000000000000000000000000000000000"
>>> result = validate_push(f"refs/heads/main abc123 refs/heads/main {zero}")
>>> result["status"]
'pass'
"""
cfg = _merge_config(config)
# Enable force push blocking in the config so the rule is built
if "push" not in cfg:
cfg["push"] = {}
cfg["push"]["allow_force_push"] = False
context = ValidationContext(stdin_text=push_refs, config=cfg)
return _run_checks(["no_force_push"], context, cfg)


def validate_all(
message: Optional[str] = None,
branch: Optional[str] = None,
Expand Down
12 changes: 10 additions & 2 deletions commit_check/config_merger.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
DEFAULT_BRANCH_TYPES,
DEFAULT_BRANCH_NAMES,
DEFAULT_BOOLEAN_RULES,
DEFAULT_PUSH_RULES,
)


Expand Down Expand Up @@ -81,6 +82,9 @@ def get_default_config() -> Dict[str, Any]:
"require_rebase_target": "",
"ignore_authors": [],
},
"push": {
"allow_force_push": DEFAULT_PUSH_RULES["allow_force_push"],
},
}


Expand Down Expand Up @@ -119,6 +123,8 @@ class ConfigMerger:
"CCHK_ALLOW_BRANCH_NAMES": ("branch", "allow_branch_names", parse_list),
"CCHK_REQUIRE_REBASE_TARGET": ("branch", "require_rebase_target", str),
"CCHK_BRANCH_IGNORE_AUTHORS": ("branch", "ignore_authors", parse_list),
# Push section
"CCHK_ALLOW_FORCE_PUSH": ("push", "allow_force_push", parse_bool),
}

# Mapping of CLI argument names to config keys
Expand All @@ -144,12 +150,14 @@ class ConfigMerger:
"allow_branch_names": ("branch", "allow_branch_names"),
"require_rebase_target": ("branch", "require_rebase_target"),
"branch_ignore_authors": ("branch", "ignore_authors"),
# Push section
"allow_force_push": ("push", "allow_force_push"),
}

@staticmethod
def parse_env_vars() -> Dict[str, Any]:
"""Parse environment variables with CCHK_ prefix into config dict."""
config: Dict[str, Any] = {"commit": {}, "branch": {}}
config: Dict[str, Any] = {"commit": {}, "branch": {}, "push": {}}

for env_var, (section, key, parser) in ConfigMerger.ENV_VAR_MAPPING.items():
value = os.environ.get(env_var)
Expand All @@ -168,7 +176,7 @@ def parse_env_vars() -> Dict[str, Any]:
@staticmethod
def parse_cli_args(args: argparse.Namespace) -> Dict[str, Any]:
"""Parse CLI arguments into config dict."""
config: Dict[str, Any] = {"commit": {}, "branch": {}}
config: Dict[str, Any] = {"commit": {}, "branch": {}, "push": {}}

for arg_name, (section, key) in ConfigMerger.CLI_ARG_MAPPING.items():
if hasattr(args, arg_name):
Expand Down
84 changes: 84 additions & 0 deletions commit_check/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,12 @@

from commit_check.rule_builder import ValidationRule
from commit_check.util import (
fetch_upstream_ref,
get_commit_info,
get_git_config_value,
get_branch_name,
get_upstream_branch,
get_upstream_remote_sha,
has_commits,
git_merge_base,
)
Expand All @@ -31,6 +34,7 @@ class ValidationContext:
stdin_text: Optional[str] = None
commit_file: Optional[str] = None
config: Dict = field(default_factory=dict)
push_upstream_fallback: bool = False


@dataclass
Expand Down Expand Up @@ -601,6 +605,85 @@ def _get_commit_message(self, context: ValidationContext) -> str:
return f"{subject}\n\n{body}".strip()


class ForcePushValidator(BaseValidator):
"""Validates that no force push is being performed.

Reads pushed ref information from stdin (provided by git's pre-push hook)
in the format::

<local ref> <local sha1> <remote ref> <remote sha1>

A force push is detected when the remote SHA is not an ancestor of the
local SHA, meaning local history would overwrite the remote.
"""

ZERO_SHA = "0000000000000000000000000000000000000000"

def validate(self, context: ValidationContext) -> ValidationResult:
if not context.stdin_text:
if context.push_upstream_fallback:
return self._check_current_branch_against_upstream()
return ValidationResult.PASS

for line in context.stdin_text.splitlines():
result = self._check_push_line(line.strip())
if result == ValidationResult.FAIL:
return ValidationResult.FAIL

return ValidationResult.PASS

def _check_current_branch_against_upstream(self) -> ValidationResult:
"""Check whether pushing HEAD to its upstream would require force."""
upstream_ref = get_upstream_branch()
if not upstream_ref:
return ValidationResult.PASS

target_ref = get_upstream_remote_sha(upstream_ref) or upstream_ref
returncode = git_merge_base(target_ref, "HEAD")
if (
returncode == 128
and target_ref != upstream_ref
and fetch_upstream_ref(upstream_ref)
):
returncode = git_merge_base(target_ref, "HEAD")
if returncode == 1:
self._print_failure(f"{get_branch_name()} -> {upstream_ref}")
return ValidationResult.FAIL

return ValidationResult.PASS

def _check_push_line(self, line: str) -> ValidationResult:
"""Check a single pushed ref line for force push."""
if not line:
return ValidationResult.PASS

parts = line.split()
if len(parts) < 4:
return ValidationResult.PASS

local_ref, local_sha, remote_ref, remote_sha = (
parts[0],
parts[1],
parts[2],
parts[3],
)

# Zero SHA for remote means a new branch push (not a force push)
if remote_sha == self.ZERO_SHA:
return ValidationResult.PASS

# Check if the remote SHA is an ancestor of the local SHA.
# returncode 0 → remote is ancestor of local (fast-forward push, OK)
# returncode 1 → not an ancestor (force push detected)
# returncode 128 → git error / SHA unknown (cannot determine; allow)
returncode = git_merge_base(remote_sha, local_sha)
if returncode == 1:
self._print_failure(f"{local_ref} -> {remote_ref}")
return ValidationResult.FAIL

return ValidationResult.PASS


class ValidationEngine:
"""Main validation engine that orchestrates all validations."""

Expand All @@ -622,6 +705,7 @@ class ValidationEngine:
"allow_fixup_commits": CommitTypeValidator,
"allow_wip_commits": CommitTypeValidator,
"ignore_authors": CommitTypeValidator,
"no_force_push": ForcePushValidator,
}

def __init__(self, rules: List[ValidationRule]):
Expand Down
22 changes: 20 additions & 2 deletions commit_check/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,14 @@ def _get_parser() -> argparse.ArgumentParser:
required=False,
)

check_group.add_argument(
"-p",
"--no-force-push",
help="check that no force push is being performed (uses pre-push hook stdin when available, otherwise checks the current branch against its upstream)",
action="store_true",
required=False,
)

check_group.add_argument(
"--format",
choices=["text", "json"],
Expand Down Expand Up @@ -328,6 +336,11 @@ def main() -> int:
# Load and merge configuration from all sources: CLI > Env > TOML > Defaults
config_data = ConfigMerger.from_all_sources(args, args.config)

# When --no-force-push is explicitly passed, override allow_force_push to
# False so the rule is built even if the TOML config defaults to True.
if args.no_force_push:
config_data.setdefault("push", {})["allow_force_push"] = False

# Build validation rules from config
rule_builder = RuleBuilder(config_data)
all_rules = rule_builder.build_all_rules()
Expand Down Expand Up @@ -366,6 +379,8 @@ def main() -> int:
requested_checks.append("author_name")
if args.author_email:
requested_checks.append("author_email")
if args.no_force_push:
requested_checks.append("no_force_push")

# If no specific checks requested, show help
if not requested_checks:
Expand All @@ -392,17 +407,20 @@ def main() -> int:
if not stdin_content:
# No stdin and no file - let validators get data from git themselves
stdin_content = None
elif not any([args.branch, args.author_name, args.author_email]):
elif not any(
[args.branch, args.author_name, args.author_email, args.no_force_push]
):
# If no specific validation type is requested, don't read stdin
pass
else:
# For non-message validations (branch, author), check for stdin input
# For non-message validations (branch, author, push), check for stdin input
stdin_content = stdin_reader.read_piped_input()

context = ValidationContext(
stdin_text=stdin_content,
commit_file=commit_file_path,
config=config_data,
push_upstream_fallback=args.no_force_push and stdin_content is None,
)

# Run validation – choose output mode based on --format
Expand Down
45 changes: 44 additions & 1 deletion commit_check/rule_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,18 @@

from typing import Dict, Any, List, Optional
from dataclasses import dataclass
from commit_check.rules_catalog import COMMIT_RULES, BRANCH_RULES, RuleCatalogEntry
from commit_check.rules_catalog import (
COMMIT_RULES,
BRANCH_RULES,
PUSH_RULES,
RuleCatalogEntry,
)
from commit_check import (
DEFAULT_COMMIT_TYPES,
DEFAULT_BRANCH_TYPES,
DEFAULT_BRANCH_NAMES,
DEFAULT_BOOLEAN_RULES,
DEFAULT_PUSH_RULES,
)


Expand Down Expand Up @@ -48,12 +54,14 @@ def __init__(self, config: Dict[str, Any]):
self.config = config
self.commit_config = config.get("commit", {})
self.branch_config = config.get("branch", {})
self.push_config = config.get("push", {})

def build_all_rules(self) -> List[ValidationRule]:
"""Build all validation rules from config."""
rules = []
rules.extend(self._build_commit_rules())
rules.extend(self._build_branch_rules())
rules.extend(self._build_push_rules())
return rules

def _build_commit_rules(self) -> List[ValidationRule]:
Expand All @@ -78,6 +86,41 @@ def _build_branch_rules(self) -> List[ValidationRule]:

return rules

def _build_push_rules(self) -> List[ValidationRule]:
"""Build push-related validation rules."""
rules = []

for catalog_entry in PUSH_RULES:
rule = self._build_push_rule(catalog_entry)
if rule:
rules.append(rule)

return rules

def _build_push_rule(
self, catalog_entry: RuleCatalogEntry
) -> Optional[ValidationRule]:
"""Build a single push validation rule from catalog entry and config."""
check = catalog_entry.check

if check == "no_force_push":
allow = self.push_config.get(
"allow_force_push", DEFAULT_PUSH_RULES["allow_force_push"]
)
# When allow_force_push is True (default), force pushes are permitted
# so no blocking rule is needed. Only build the rule when it is
# False, i.e. the user has explicitly opted in to blocking.
if allow:
return None
return ValidationRule(
check=catalog_entry.check,
error=catalog_entry.error,
suggest=catalog_entry.suggest,
value=False,
)

return None

def _build_single_rule(
self, catalog_entry: RuleCatalogEntry, section_config: Dict[str, Any]
) -> Optional[ValidationRule]:
Expand Down
Loading
Loading