Skip to content
Merged
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
9 changes: 9 additions & 0 deletions .pre-commit-hooks.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,12 @@
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
always_run: true
language: python
30 changes: 30 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 Expand Up @@ -429,6 +455,10 @@ reflects a DIY approach rather than built-in product features.
- ✅
- ❌
- DIY
* - Force push blocking
- ✅
- ❌
- DIY
* - Author name / email validation
- ✅
- ❌
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 @@ -166,6 +166,38 @@ def validate_branch(
return _run_checks(["branch", "merge_base"], 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_author(
name: Optional[str] = None,
email: 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
108 changes: 108 additions & 0 deletions commit_check/engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,14 @@

from commit_check.rule_builder import ValidationRule
from commit_check.util import (
fetch_remote_ref,
fetch_upstream_ref,
get_commit_info,
get_git_config_value,
get_branch_name,
get_git_remotes,
get_upstream_branch,
get_upstream_remote_sha,
has_commits,
git_merge_base,
)
Expand All @@ -33,6 +38,7 @@ class ValidationContext:
config: Dict = field(default_factory=dict)
no_banner: bool = False
compact: bool = False
push_upstream_fallback: bool = False


@dataclass
Expand Down Expand Up @@ -529,6 +535,107 @@ 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 -> SHA may be unknown locally; fetch remote ref and retry
returncode = git_merge_base(remote_sha, local_sha)
if returncode == 128:
for remote in self._remote_candidates_for_push(remote_ref):
if not fetch_remote_ref(remote, remote_ref):
continue
returncode = git_merge_base(remote_sha, local_sha)
if returncode != 128:
break
if returncode == 1:
self._print_failure(f"{local_ref} -> {remote_ref}")
return ValidationResult.FAIL

return ValidationResult.PASS

def _remote_candidates_for_push(self, remote_ref: str) -> List[str]:
"""Return remotes worth fetching for a pushed branch ref."""
if not remote_ref.startswith("refs/heads/"):
return []

remotes: List[str] = []
upstream_ref = get_upstream_branch()
upstream_parts = upstream_ref.split("/", 1)
remote_branch = remote_ref.removeprefix("refs/heads/")
if len(upstream_parts) == 2 and upstream_parts[1] == remote_branch:
remotes.append(upstream_parts[0])

remotes.extend(remote for remote in get_git_remotes() if remote not in remotes)
return remotes


class CommitTypeValidator(BaseValidator):
"""Base validator for special commit types (merge, revert, fixup, WIP, empty)."""

Expand Down Expand Up @@ -631,6 +738,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
Loading
Loading