Commit 428148d2 authored by Dom Sekotill's avatar Dom Sekotill
Browse files

Add a hook checking for commits that need squashing

parent 634715da
Loading
Loading
Loading
Loading
+6 −0
Original line number Diff line number Diff line
@@ -25,6 +25,12 @@ repos:
    require_serial: true
    types: [python]
    stages: [commit]
  - id: check-for-squash
    name: Check for commits that need squashing
    language: system
    entry: hooks/squash.py
    pass_filenames: false
    stages: [push]

- repo: https://github.com/pre-commit/pygrep-hooks
  rev: v1.5.1
+7 −0
Original line number Diff line number Diff line
@@ -8,3 +8,10 @@
  require_serial: true
  types: [python]
  stages: [commit]

- id: check-for-squash
  name: Check for commits that need squashing
  language: python
  entry: check-for-squash
  pass_filenames: false
  stages: [push]

hooks/squash.py

0 → 100755
+139 −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.

"""
Block commit starting with "squash!" or "fixup!" from being pushed

These commits must be squashed before a push to a remote
"""


import os
import sys
from collections import defaultdict
from pathlib import Path
from subprocess import PIPE
from subprocess import Popen

Z40 = '0' * 40
GIT_EXEC_PATH = 'GIT_EXEC_PATH'
PRE_COMMIT_TO_REF = 'PRE_COMMIT_TO_REF'
PRE_COMMIT_FROM_REF = 'PRE_COMMIT_FROM_REF'


class CommitDict(defaultdict):
	"""
	A dict for holding Commit objects mapped from their subject lines
	"""

	def __missing__(self, subject):
		self[subject] = commit = Commit(subject)
		return commit


class Commit:
	"""
	A class for holding information about regular commits
	"""

	__slots__ = 'subject', 'sha', 'updates'

	def __init__(self, subject: str, sha: str = Z40):
		if len(sha) != 40 or not sha.isalnum():
			raise ValueError("'sha' must be a SHA-1 hash")
		self.subject = subject.strip()
		self.sha = sha
		self.updates = []


def git_log_cmd():
	"""
	Return the first arguments for running a git-log command
	"""
	if GIT_EXEC_PATH in os.environ:
		git_log = Path.cwd() / os.environ[GIT_EXEC_PATH] / 'git-log'
		if git_log.exists() and git_log.stat().st_mode & os.X_OK:
			return [git_log]
	return ['git', 'log']


def catalogue_commits():
	"""
	Yield all regular commits as Commit objects, containing any update (squash) commits
	"""
	cmd = git_log_cmd()
	cmd.extend([
		'--reverse',
		'--format=format:%H %s',
		f"{os.environ[PRE_COMMIT_FROM_REF]}...{os.environ[PRE_COMMIT_TO_REF]}",
	])

	commits = CommitDict()

	with Popen(cmd, stdout=PIPE, text=True) as proc:
		for line in proc.stdout.readlines():
			sha, _, subject = line.partition(' ')
			keyword, has_marker, target = subject.partition('! ')

			if has_marker and keyword in ('squash', 'fixup'):
				# Squash commit, add it to its target list
				commits[target].updates.append(sha)
				continue

			if subject in commits:
				# Regular commit, but another exists with the same message, so yield the
				# previous one first
				yield commits.pop(subject)

			commits[subject] = Commit(subject, sha)

	yield from commits.values()


def main():
	"""
	CLI entrypoint
	"""
	retcode = 0
	output = sys.stderr.write
	for commit in catalogue_commits():
		if not commit.updates:
			continue
		if not retcode:
			retcode = 1
			output("The following commits need to be squashed before pushing:\n\n")

		output("  target:\n")
		output(
			f"    {commit.subject[:60]}\n" if commit.sha == Z40 else
			f"    {commit.subject[:60]} ({commit.sha[:8]})\n",
		)

		output("  commits:\n")
		output(f"    {', '.join(commit.updates)}\n")

		if commit.sha == Z40:
			output("  note:\n")
			output("    The target for the above commits is not part of this update set.\n")
			output("    You should squash them into a new commit.\n")

		output("\n")

	sys.exit(retcode)


if __name__ == '__main__':
	main()
+1 −0
Original line number Diff line number Diff line
@@ -24,6 +24,7 @@ requires-python = "~=3.6"

[tool.flit.scripts]
check-copyright-notice = "hooks.copyright:main"
check-for-squash = "hooks.squash:main"

[tool.isort]
force_single_line = true