Skip to content

Commit 657d391

Browse files
authored
feat: block force pushes via pre-push hook (#412)
* feat: block force pushes via pre-push hook Adds a `check-no-force-push` pre-push hook that detects and blocks `git push --force` / `git push -f` by inspecting pushed ref ancestry via `git merge-base --is-ancestor`. ## Detection logic Reads git's pre-push stdin (<local ref> <local sha> <remote ref> <remote sha>) and evaluates: - Remote SHA is zero -> new branch push -> pass - merge-base returns 0 -> fast-forward -> pass - Returns 1 -> force push detected -> fail - Returns 128 -> git error, pass (safe default) ## Standalone mode When run without stdin, --no-force-push checks whether pushing HEAD to its configured upstream would require force, using git ls-remote and optional git fetch to resolve the remote commit. Closes #203 * docs: add push safety section to README and examples * test: add coverage for validate_push API and push rule fallback * Add force option to documentation for next version * fix: fetch remote ref before force-push check * chore: revert labeler.yml changes * fix: use pre-commit push metadata * fix: ensure pre-push hook always runs and remove -p short flag - Add always_run: true to check-no-force-push hook in .pre-commit-hooks.yaml to prevent pre-commit from skipping the hook when zero files changed (e.g. force push with identical tree, different history) - Remove -p short flag from --no-force-push CLI argument; the flag is exclusively used in pre-commit hooks, not interactive CLI use
1 parent 505c9eb commit 657d391

19 files changed

Lines changed: 1433 additions & 6 deletions

.pre-commit-hooks.yaml

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,12 @@
2727
args: [--author-email]
2828
pass_filenames: false
2929
language: python
30+
- id: check-no-force-push
31+
name: check no force push
32+
description: prevents force pushes to remote branches
33+
entry: commit-check
34+
args: [--no-force-push]
35+
stages: [pre-push]
36+
pass_filenames: false
37+
always_run: true
38+
language: python

README.rst

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,32 @@ For one-off checks or CI/CD pipelines, you can configure via CLI arguments or en
187187
188188
See the `Configuration documentation <https://commit-check.github.io/commit-check/configuration.html>`_ for all available options.
189189

190+
Check Push Safety
191+
~~~~~~~~~~~~~~~~~
192+
193+
Use ``--no-force-push`` in a ``pre-push`` hook to inspect the ref updates Git
194+
provides on stdin, or run it directly to compare ``HEAD`` with the current
195+
branch's configured upstream:
196+
197+
.. code-block:: bash
198+
199+
# Standalone preflight check against the current branch's upstream
200+
commit-check --no-force-push
201+
202+
.. code-block:: yaml
203+
204+
# In pre-commit hooks (.pre-commit-config.yaml)
205+
repos:
206+
- repo: https://github.com/commit-check/commit-check
207+
rev: v2.6.0
208+
hooks:
209+
- id: check-no-force-push
210+
stages: [pre-push]
211+
212+
Piping ``git push`` into ``commit-check`` is not a prevention mechanism. The
213+
push has already been started, and standard ``git push`` output does not carry
214+
the pre-push ref metadata that ``commit-check`` uses.
215+
190216
AI-Native Usage
191217
---------------
192218

@@ -429,6 +455,10 @@ reflects a DIY approach rather than built-in product features.
429455
- ✅
430456
- ❌
431457
- DIY
458+
* - Force push blocking
459+
- ✅
460+
- ❌
461+
- DIY
432462
* - Author name / email validation
433463
- ✅
434464
- ❌

commit_check/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@
4444
# Additional allowed branch names (e.g., develop, staging)
4545
DEFAULT_BRANCH_NAMES: list[str] = []
4646

47+
# Push-related defaults
48+
DEFAULT_PUSH_RULES = {
49+
"allow_force_push": True,
50+
}
51+
4752
# Handle different default values for different rules
4853
DEFAULT_BOOLEAN_RULES = {
4954
"subject_capitalized": False,

commit_check/api.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,38 @@ def validate_branch(
166166
return _run_checks(["branch", "merge_base"], context, cfg)
167167

168168

169+
def validate_push(
170+
push_refs: Optional[str] = None,
171+
*,
172+
config: Optional[Dict[str, Any]] = None,
173+
) -> Dict[str, Any]:
174+
"""Validate that a push is not a force push.
175+
176+
:param push_refs: Push ref information in the format produced by git's
177+
pre-push hook: ``<local ref> <local sha1> <remote ref> <remote sha1>``,
178+
one entry per line. If *None*, the check is skipped (returns pass).
179+
:param config: Optional configuration override dict. The push check is
180+
always enabled when calling this function; force pushes detected here
181+
will always return ``"fail"``.
182+
:returns: A dict with ``"status"`` (``"pass"``/``"fail"``) and ``"checks"``.
183+
184+
Example::
185+
186+
>>> from commit_check.api import validate_push
187+
>>> zero = "0000000000000000000000000000000000000000"
188+
>>> result = validate_push(f"refs/heads/main abc123 refs/heads/main {zero}")
189+
>>> result["status"]
190+
'pass'
191+
"""
192+
cfg = _merge_config(config)
193+
# Enable force push blocking in the config so the rule is built
194+
if "push" not in cfg:
195+
cfg["push"] = {}
196+
cfg["push"]["allow_force_push"] = False
197+
context = ValidationContext(stdin_text=push_refs, config=cfg)
198+
return _run_checks(["no_force_push"], context, cfg)
199+
200+
169201
def validate_author(
170202
name: Optional[str] = None,
171203
email: Optional[str] = None,

commit_check/config_merger.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
DEFAULT_BRANCH_TYPES,
1212
DEFAULT_BRANCH_NAMES,
1313
DEFAULT_BOOLEAN_RULES,
14+
DEFAULT_PUSH_RULES,
1415
)
1516

1617

@@ -81,6 +82,9 @@ def get_default_config() -> Dict[str, Any]:
8182
"require_rebase_target": "",
8283
"ignore_authors": [],
8384
},
85+
"push": {
86+
"allow_force_push": DEFAULT_PUSH_RULES["allow_force_push"],
87+
},
8488
}
8589

8690

@@ -119,6 +123,8 @@ class ConfigMerger:
119123
"CCHK_ALLOW_BRANCH_NAMES": ("branch", "allow_branch_names", parse_list),
120124
"CCHK_REQUIRE_REBASE_TARGET": ("branch", "require_rebase_target", str),
121125
"CCHK_BRANCH_IGNORE_AUTHORS": ("branch", "ignore_authors", parse_list),
126+
# Push section
127+
"CCHK_ALLOW_FORCE_PUSH": ("push", "allow_force_push", parse_bool),
122128
}
123129

124130
# Mapping of CLI argument names to config keys
@@ -144,12 +150,14 @@ class ConfigMerger:
144150
"allow_branch_names": ("branch", "allow_branch_names"),
145151
"require_rebase_target": ("branch", "require_rebase_target"),
146152
"branch_ignore_authors": ("branch", "ignore_authors"),
153+
# Push section
154+
"allow_force_push": ("push", "allow_force_push"),
147155
}
148156

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

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

173181
for arg_name, (section, key) in ConfigMerger.CLI_ARG_MAPPING.items():
174182
if hasattr(args, arg_name):

commit_check/engine.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,14 @@
88

99
from commit_check.rule_builder import ValidationRule
1010
from commit_check.util import (
11+
fetch_remote_ref,
12+
fetch_upstream_ref,
1113
get_commit_info,
1214
get_git_config_value,
1315
get_branch_name,
16+
get_git_remotes,
17+
get_upstream_branch,
18+
get_upstream_remote_sha,
1419
has_commits,
1520
git_merge_base,
1621
)
@@ -33,6 +38,7 @@ class ValidationContext:
3338
config: Dict = field(default_factory=dict)
3439
no_banner: bool = False
3540
compact: bool = False
41+
push_upstream_fallback: bool = False
3642

3743

3844
@dataclass
@@ -529,6 +535,107 @@ def _get_commit_message(self, context: ValidationContext) -> str:
529535
return f"{subject}\n\n{body}".strip()
530536

531537

538+
class ForcePushValidator(BaseValidator):
539+
"""Validates that no force push is being performed.
540+
541+
Reads pushed ref information from stdin (provided by git's pre-push hook)
542+
in the format::
543+
544+
<local ref> <local sha1> <remote ref> <remote sha1>
545+
546+
A force push is detected when the remote SHA is not an ancestor of the
547+
local SHA, meaning local history would overwrite the remote.
548+
"""
549+
550+
ZERO_SHA = "0000000000000000000000000000000000000000"
551+
552+
def validate(self, context: ValidationContext) -> ValidationResult:
553+
if not context.stdin_text:
554+
if context.push_upstream_fallback:
555+
return self._check_current_branch_against_upstream()
556+
return ValidationResult.PASS
557+
558+
for line in context.stdin_text.splitlines():
559+
result = self._check_push_line(line.strip())
560+
if result == ValidationResult.FAIL:
561+
return ValidationResult.FAIL
562+
563+
return ValidationResult.PASS
564+
565+
def _check_current_branch_against_upstream(self) -> ValidationResult:
566+
"""Check whether pushing HEAD to its upstream would require force."""
567+
upstream_ref = get_upstream_branch()
568+
if not upstream_ref:
569+
return ValidationResult.PASS
570+
571+
target_ref = get_upstream_remote_sha(upstream_ref) or upstream_ref
572+
returncode = git_merge_base(target_ref, "HEAD")
573+
if (
574+
returncode == 128
575+
and target_ref != upstream_ref
576+
and fetch_upstream_ref(upstream_ref)
577+
):
578+
returncode = git_merge_base(target_ref, "HEAD")
579+
if returncode == 1:
580+
self._print_failure(f"{get_branch_name()} -> {upstream_ref}")
581+
return ValidationResult.FAIL
582+
583+
return ValidationResult.PASS
584+
585+
def _check_push_line(self, line: str) -> ValidationResult:
586+
"""Check a single pushed ref line for force push."""
587+
if not line:
588+
return ValidationResult.PASS
589+
590+
parts = line.split()
591+
if len(parts) < 4:
592+
return ValidationResult.PASS
593+
594+
local_ref, local_sha, remote_ref, remote_sha = (
595+
parts[0],
596+
parts[1],
597+
parts[2],
598+
parts[3],
599+
)
600+
601+
# Zero SHA for remote means a new branch push (not a force push)
602+
if remote_sha == self.ZERO_SHA:
603+
return ValidationResult.PASS
604+
605+
# Check if the remote SHA is an ancestor of the local SHA.
606+
# returncode 0 -> remote is ancestor of local (fast-forward push, OK)
607+
# returncode 1 -> not an ancestor (force push detected)
608+
# returncode 128 -> SHA may be unknown locally; fetch remote ref and retry
609+
returncode = git_merge_base(remote_sha, local_sha)
610+
if returncode == 128:
611+
for remote in self._remote_candidates_for_push(remote_ref):
612+
if not fetch_remote_ref(remote, remote_ref):
613+
continue
614+
returncode = git_merge_base(remote_sha, local_sha)
615+
if returncode != 128:
616+
break
617+
if returncode == 1:
618+
self._print_failure(f"{local_ref} -> {remote_ref}")
619+
return ValidationResult.FAIL
620+
621+
return ValidationResult.PASS
622+
623+
def _remote_candidates_for_push(self, remote_ref: str) -> List[str]:
624+
"""Return remotes worth fetching for a pushed branch ref."""
625+
if not remote_ref.startswith("refs/heads/"):
626+
return []
627+
628+
remotes: List[str] = []
629+
upstream_ref = get_upstream_branch()
630+
upstream_parts = upstream_ref.split("/", 1)
631+
remote_branch = remote_ref.removeprefix("refs/heads/")
632+
if len(upstream_parts) == 2 and upstream_parts[1] == remote_branch:
633+
remotes.append(upstream_parts[0])
634+
635+
remotes.extend(remote for remote in get_git_remotes() if remote not in remotes)
636+
return remotes
637+
638+
532639
class CommitTypeValidator(BaseValidator):
533640
"""Base validator for special commit types (merge, revert, fixup, WIP, empty)."""
534641

@@ -631,6 +738,7 @@ class ValidationEngine:
631738
"allow_fixup_commits": CommitTypeValidator,
632739
"allow_wip_commits": CommitTypeValidator,
633740
"ignore_authors": CommitTypeValidator,
741+
"no_force_push": ForcePushValidator,
634742
}
635743

636744
def __init__(self, rules: List[ValidationRule]):

0 commit comments

Comments
 (0)