Commit e58b8bb9 authored by Dom Sekotill's avatar Dom Sekotill
Browse files

Add a replacement protect-first-parent hook

parent 62982dfb
Loading
Loading
Loading
Loading
+6 −5
Original line number Diff line number Diff line
@@ -42,11 +42,6 @@ repos:
  hooks:
  - id: gitlint

- repo: https://github.com/jumanjihouse/pre-commit-hooks
  rev: 2.1.5
  hooks:
  - id: protect-first-parent

- repo: local
  hooks:
  - id: copyright-notice
@@ -62,6 +57,12 @@ repos:
    entry: hooks/squash.py
    pass_filenames: false
    stages: [push]
  - id: protect-first-parent
    name: Check for "git pull" violations
    language: python
    entry: hooks/first_parent.py
    pass_filenames: false
    stages: [merge-commit, commit, push]

- repo: https://github.com/pre-commit/pygrep-hooks
  rev: v1.8.0
+8 −0
Original line number Diff line number Diff line
@@ -65,3 +65,11 @@
  entry: check-for-squash
  pass_filenames: false
  stages: [push]

- id: protect-first-parent
  name: Check for "git pull" violations
  language: python
  entry: check-first-parent
  always_run: true
  pass_filenames: false
  stages: [merge-commit, commit, push]

hooks/first_parent.py

0 → 100755
+91 −0
Original line number Diff line number Diff line
#!/usr/bin/env python3
#  Copyright 2021  Dominik Sekotill <dom.sekotill@kodo.org.uk>
#
#  Licensed under the Apache License, Version 2.0 (the "License");
#  you may not use this file except in compliance with the License.
#  You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
#  Unless required by applicable law or agreed to in writing, software
#  distributed under the License is distributed on an "AS IS" BASIS,
#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
#  See the License for the specific language governing permissions and
#  limitations under the License.

"""
Check that the first-parent commit graph includes the upstream branch

The first-parent graph is the linear graph of commits which always follows the first parent
of any merge commits.  It is often seen as the canonical commit graph, with any n-th parent
lines (where n is greater than 1) seen as merged topic branches.

This hook checks that the upstream head exists in the first-parent graph and blocks
a commit or push if it does not.  If there is no upstream branch, this hook always allows
the commit/push to go ahead.

For some background reading on the situation where an upstream head is not on the
first-parent graph (dubbed a "foxtrot merge") I suggest the following article:

  - https://bit-booster.blogspot.com/2016/02/no-foxtrots-allowed.html
"""

import sys
from subprocess import PIPE
from subprocess import CalledProcessError
from subprocess import run
from typing import Optional
from typing import Set

FAIL_MSG = """
[FAIL] The upstream branch {upstream} is not on the first-parent linear commit graph.

You probably ran "git pull" without --rebase.  If you see this message as output from
"git pull" then abort with "git merge --abort" and re-run "git pull --rebase"; otherwise try
running "git rebase".
"""


def get_fp_commits(ref: str) -> Set[str]:
	"""
	Return the set of commits that are in the first-parent linear graph from ref
	"""
	cmd = ['git', 'rev-list', '--first-parent', ref]
	proc = run(cmd, stdout=PIPE, text=True, check=True)
	if proc.stdout is None:
		raise RuntimeError
	return set(proc.stdout.split())


def resolve_ref(ref: str) -> Optional[str]:
	"""
	Return a named head for a given ref, or None if there is no head

	If 'ref' does not exist None is also returned.
	"""
	cmd = ['git', 'rev-parse', '--abbrev-ref', ref]
	try:
		proc = run(cmd, stdout=PIPE, text=True, check=True)
	except CalledProcessError as exc:
		if exc.returncode == 128:
			return None
		raise
	return proc.stdout.strip() or None


def main() -> None:
	"""
	CLI entrypoint
	"""
	if not resolve_ref('@{upstream}'):
		return
	head_commits = get_fp_commits('HEAD')
	upstream_commits = get_fp_commits('@{upstream}')
	if not upstream_commits.difference(head_commits):
		return
	sys.stderr.write(FAIL_MSG.format(upstream=resolve_ref('@{upstream}')))
	sys.exit(1)


if __name__ == '__main__':
	main()
+1 −0
Original line number Diff line number Diff line
@@ -25,6 +25,7 @@ requires-python = "~=3.6"
[tool.flit.scripts]
check-copyright-notice = "hooks.copyright:main"
check-for-squash = "hooks.squash:main"
check-first-parent = "hooks.first_parent:main"

[tool.isort]
force_single_line = true