-
-
Notifications
You must be signed in to change notification settings - Fork 16
feat: block force pushes via pre-push hook #410
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
2c53614
d04a25b
2fff414
aa10484
a60289b
f35edd2
c615e63
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,14 +1,13 @@ | ||
| name: PR Autolabeler | ||
|
|
||
| permissions: | ||
| contents: write | ||
| pull-requests: write | ||
|
|
||
| on: | ||
| # pull_request event is required for autolabeler | ||
| pull_request: | ||
| types: [opened, reopened, synchronize] | ||
|
|
||
| jobs: | ||
| draft-release: | ||
| permissions: | ||
| contents: write | ||
| pull-requests: write | ||
| uses: commit-check/.github/.github/workflows/pr-labeler.yml@main |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
| ) | ||
|
|
@@ -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 | ||
|
|
@@ -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 | ||
|
Comment on lines
+565
to
+583
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fetch retry skipped when remote SHA lookup fails. When 🐛 Proposed fix: fetch when returncode is 128 regardless of target_ref source 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 == 128 and fetch_upstream_ref(upstream_ref):
+ # Re-resolve target_ref after fetch if we didn't have a SHA
+ if target_ref == upstream_ref:
+ target_ref = get_upstream_remote_sha(upstream_ref) or 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🤖 Prompt for AI Agents |
||
|
|
||
| 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).""" | ||
|
|
||
|
|
@@ -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]): | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Use a release tag that actually contains
check-no-force-push.The snippet defines
id: check-no-force-pushbut pinsrev: v2.6.0. That version predates this hook, so copy/paste users can get pre-commit hook resolution errors. Update to the release that includes this feature (e.g.,v2.7.0).Suggested fix
🤖 Prompt for AI Agents