From f412f9e745448a375aee4d15d9eba8d8c7054dfa Mon Sep 17 00:00:00 2001
From: Derek Chen-Becker <oss@chen-becker.org>
Date: Thu, 6 Nov 2025 18:52:04 -0700
Subject: [PATCH 1/3] git-hooks: Add git hook scripts along with instructions
 for use

* git-hooks/commit-msg: Add commit-msg hook script.  Add a check for two
spaces between sentences.  Update the abort message to reference
`CONTRIBUTE.org' instead of `CONTRIBUTE'.
* git-hooks/commit-msg-files.awk: Add commit-msg-files.awk hook script.
Update the abort message to reference `CONTRIBUTE.org' instead of
`CONTRIBUTE'.
* git-hooks/post-commit: Add post-commit hook script.
* git-hooks/pre-push: Add pre-push hook script.
* git-hooks/prepare-commit-msg: Add prepare-commit-msg hook script.  Update
the abort message to reference `CONTRIBUTE.org' instead of `CONTRIBUTE'.
* mk/targets.mk: Add a `git' and `cleangit' target to install and remove
the new git hook scripts, respectively.  Also add `git' as a dependency for
`all' and `cleangit' as a dependency for `cleanall'.
* CONTRIBUTE.org: Add instructions for utilizing the git hook scripts.
---
 CONTRIBUTE.org                 |  11 ++
 git-hooks/commit-msg           | 187 +++++++++++++++++++++++++++++++++
 git-hooks/commit-msg-files.awk | 128 ++++++++++++++++++++++
 git-hooks/post-commit          |  47 +++++++++
 git-hooks/pre-commit           |  83 +++++++++++++++
 git-hooks/pre-push             |  88 ++++++++++++++++
 git-hooks/prepare-commit-msg   |  49 +++++++++
 mk/targets.mk                  |  16 ++-
 8 files changed, 607 insertions(+), 2 deletions(-)
 create mode 100755 git-hooks/commit-msg
 create mode 100644 git-hooks/commit-msg-files.awk
 create mode 100755 git-hooks/post-commit
 create mode 100755 git-hooks/pre-commit
 create mode 100755 git-hooks/pre-push
 create mode 100755 git-hooks/prepare-commit-msg

diff --git a/CONTRIBUTE.org b/CONTRIBUTE.org
index 8f94e875b..bdd19438c 100644
--- a/CONTRIBUTE.org
+++ b/CONTRIBUTE.org
@@ -16,6 +16,17 @@ You can contribute with bug reports and patches.
 
 See these [[https://orgmode.org/worg/org-contribute.html#org069b83a][directions]].
 
+** Git Hooks
+
+If you would like to utilize the git hook scripts to provide
+validation on commits, you can link the existing files under the
+~git-hooks~ directory (this is done automatically with ~make~). If you
+want to use all of the hooks, the simple way to do this is to run:
+
+#+begin_src shell
+make git
+#+end_src
+
 * As an Org maintainer
 
 We encourage you to volunteer to maintain one of the Org files.
diff --git a/git-hooks/commit-msg b/git-hooks/commit-msg
new file mode 100755
index 000000000..3cd6d8af7
--- /dev/null
+++ b/git-hooks/commit-msg
@@ -0,0 +1,187 @@
+#!/bin/sh
+# Check the format of GNU Emacs change log entries.
+
+# Copyright 2014-2025 Free Software Foundation, Inc.
+
+# This file is part of GNU Emacs.
+
+# GNU Emacs is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# GNU Emacs is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+
+# You should have received a copy of the GNU General Public License
+# along with GNU Emacs.  If not, see <https://www.gnu.org/licenses/>.
+
+# Written by Paul Eggert.
+
+# Prefer gawk if available, as it handles NUL bytes properly.
+if type gawk >/dev/null 2>&1; then
+  awk=gawk
+else
+  awk=awk
+fi
+
+# Use a UTF-8 locale if available, so that the UTF-8 check works.
+# Use U+00A2 CENT SIGN to test whether the locale works.
+cent_sign_utf8_format='\302\242\n'
+cent_sign=`printf "$cent_sign_utf8_format"`
+replacement_character_utf8_format='\357\277\275\n'
+replacement_character=`printf "$replacement_character_utf8_format"`
+print_at_sign='BEGIN {print substr("'$cent_sign'@", 2)}'
+at_sign=`$awk "$print_at_sign" </dev/null 2>/dev/null`
+if test "$at_sign" != @; then
+  at_sign=`LC_ALL=en_US.UTF-8 $awk "$print_at_sign" </dev/null 2>/dev/null`
+  if test "$at_sign" = @; then
+    LC_ALL=en_US.UTF-8
+  else
+    LC_ALL=C
+  fi
+  export LC_ALL
+fi
+
+# Check the log entry.
+exec $awk \
+     -v at_sign="$at_sign" \
+     -v cent_sign="$cent_sign" \
+     -v file="$1" \
+     -v replacement_character="$replacement_character" \
+'
+  BEGIN {
+    # These regular expressions assume traditional Unix unibyte behavior.
+    # They are needed for old or broken versions of awk, e.g.,
+    # mawk 1.3.3 (1996), or gawk on MSYS (2015), and/or for systems that
+    # cannot use UTF-8 as the codeset for the locale.
+    space = "[ \f\n\r\t\v]"
+    non_space = "[^ \f\n\r\t\v]"
+    # The non_print below rejects control characters and surrogates
+    # UTF-8 for: 0x01-0x1f 0x7f   0x80-0x9f  0xd800-0xdbff  0xdc00-0xdfff
+    non_print = "[\1-\37\177]|\302[\200-\237]|\355[\240-\277][\200-\277]"
+
+    # Prefer POSIX regular expressions if available, as they do a
+    # better job of checking.  Similarly, prefer POSIX negated
+    # expressions if UTF-8 also works.
+    if (" " ~ /[[:space:]]/) {
+      space = "[[:space:]]"
+      if (at_sign == "@" && cent_sign ~ /^[[:print:]]$/) {
+        non_space = "[^[:space:]]"
+        non_print = "[^[:print:]]"
+      }
+    }
+    c_lower = "abcdefghijklmnopqrstuvwxyz"
+    unsafe_gnu_url = "(http|ftp)://([" c_lower ".]*\\.)?(gnu|fsf)\\.org"
+  }
+
+  { input[NR] = $0 }
+
+  /^#/ {
+    # Ignore every line after a scissors line.
+    if (/^# *---* *(>[8%]|[8%]<) *---* *$/) { exit }
+
+    # Ignore comment lines.
+    next
+  }
+
+  !/^.*$/ {
+    print "Invalid character (not UTF-8) in commit message"
+    status = 1
+  }
+
+  /(^|[^\\])`[^'\''`]+`/ {
+    print "Markdown-style quotes in commit message"
+    status = 1
+  }
+
+  /\. [[:alnum:]]/ {
+    print "Two spaces required between sentences."
+    status = 1
+  }
+
+  nlines == 0 && $0 !~ non_space { next }
+
+  { nlines++ }
+
+  nlines == 1 {
+    # Ignore special markers used by "git rebase --autosquash".
+    if (! sub(/^fixup! /, ""))
+      sub(/^squash! /, "")
+
+    if ($0 ~ "^" space) {
+      print "White space at start of commit message'\''s first line"
+      status = 1
+    }
+  }
+
+  nlines == 2 && $0 ~ non_space {
+    print "Nonempty second line in commit message"
+    status = 1
+  }
+
+  {
+    # Expand tabs to spaces for length calculations etc.
+    while (match($0, /\t/)) {
+      before_tab = substr($0, 1, RSTART - 1)
+      after_tab = substr($0, RSTART + 1)
+      $0 = sprintf("%s%*s%s", before_tab, 8 - (RSTART - 1) % 8, "", after_tab)
+    }
+  }
+
+  78 < length && $0 ~ space {
+    print "Line longer than 78 characters in commit message"
+    status = 1
+  }
+
+  140 < length {
+    print "Word longer than 140 characters in commit message"
+    status = 1
+  }
+
+  /^Signed-off-by: / {
+    print "'\''Signed-off-by:'\'' in commit message"
+    status = 1
+  }
+
+  $0 ~ unsafe_gnu_url {
+    needs_rewriting = 1
+  }
+
+  $0 ~ non_print {
+    print "Unprintable character in commit message"
+    status = 1
+  }
+  $0 ~ replacement_character {
+    print "Replacement character in commit message"
+    status = 1
+  }
+
+  END {
+    if (nlines == 0) {
+      print "Empty commit message"
+      status = 1
+    }
+    if (status == 0 && needs_rewriting) {
+      for (i = 1; i <= NR; i++) {
+	line = input[i]
+	while (match(line, unsafe_gnu_url)) {
+	  prefix = substr(line, 1, RSTART - 1)
+	  suffix = substr(line, RSTART)
+	  line = prefix "https:" substr(suffix, 5 + (suffix ~ /^http:/))
+	}
+	print line >file
+      }
+      if (close(file) != 0) {
+	print "Cannot rewrite: " file
+	status = 1
+      }
+    }
+    if (status != 0) {
+      print "Commit aborted; please see the file 'CONTRIBUTE.org'"
+    }
+    exit status
+  }
+' <"$1"
diff --git a/git-hooks/commit-msg-files.awk b/git-hooks/commit-msg-files.awk
new file mode 100644
index 000000000..693c6bd00
--- /dev/null
+++ b/git-hooks/commit-msg-files.awk
@@ -0,0 +1,128 @@
+# Check the file list of GNU Emacs change log entries for each commit SHA.
+
+# Copyright 2023-2025 Free Software Foundation, Inc.
+
+# This file is part of GNU Emacs.
+
+# GNU Emacs is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# GNU Emacs is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+
+# You should have received a copy of the GNU General Public License
+# along with GNU Emacs.  If not, see <https://www.gnu.org/licenses/>.
+
+### Commentary:
+
+# This script accepts a list of (unabbreviated) Git commit SHAs, and
+# will then iterate over them to check that any files mentioned in the
+# commit message are actually present in the commit's diff.  If not,
+# it will print out the incorrect file names and return 1.
+
+# You can also pass "-v reason=pre-push", which will add more-verbose
+# output, indicating the abbreviated commit SHA and first line of the
+# commit message for any improper commits.
+
+### Code:
+
+function get_commit_changes(commit_sha, changes,    cmd, i, j, len, \
+                            bits, filename) {
+  # Collect all the files touched in the specified commit.
+  cmd = ("git show --name-status --first-parent --format= " commit_sha)
+  while ((cmd | getline) > 0) {
+    for (i = 2; i <= NF; i++) {
+      len = split($i, bits, "/")
+      for (j = 1; j <= len; j++) {
+        if (j == 1)
+          filename = bits[j]
+        else
+          filename = filename "/" bits[j]
+        changes[filename] = 1
+      }
+    }
+  }
+  close(cmd)
+}
+
+function check_commit_msg_files(commit_sha, verbose,    changes, good, \
+                                cmd, msg, filenames_str, filenames, i) {
+  get_commit_changes(commit_sha, changes)
+  good = 1
+
+  cmd = ("git log -1 --format=%B " commit_sha)
+  while ((cmd | getline) > 0) {
+    if (verbose && ! msg)
+      msg = $0
+
+    # Find file entries in the commit message.  We look at any line
+    # starting with "*" (possibly prefixed by "; ") followed by a ":",
+    # possibly on a different line.  If we encounter a blank line
+    # without seeing a ":", then we don't treat that as a file entry.
+
+    # Accumulate the contents of a (possible) file entry.
+    if (/^[ \t]*$/)
+      filenames_str = ""
+    else if (/^(; )?\*[ \t]+[[:alnum:]]/)
+      filenames_str = $0
+    else if (filenames_str)
+      filenames_str = (filenames_str $0)
+
+    # We have a file entry; analyze it.
+    if (filenames_str && /:/) {
+      # Delete the leading "*" and any trailing information.
+      sub(/^(; )?\*[ \t]+/, "", filenames_str)
+      sub(/[ \t]*[[(<:].*$/, "", filenames_str)
+
+      # There might be multiple files listed in this entry, separated
+      # by spaces (and possibly a comma).  Iterate over each of them.
+      split(filenames_str, filenames, ",[ \t]+")
+      for (i in filenames) {
+        # Remove trailing slashes from any directory entries.
+        sub(/\/$/, "", filenames[i])
+
+        if (length(filenames[i]) && ! (filenames[i] in changes)) {
+          if (good) {
+            # Print a header describing the error.
+            if (verbose)
+              printf("In commit %s \"%s\"...\n", substr(commit_sha, 1, 10), msg)
+            printf("Files listed in commit message, but not in diff:\n")
+          }
+          printf("  %s\n", filenames[i])
+          good = 0
+        }
+      }
+
+      filenames_str = ""
+    }
+  }
+  close(cmd)
+
+  return good
+}
+
+BEGIN {
+  if (reason == "pre-push")
+    verbose = 1
+}
+
+/^[a-z0-9]{40}$/ {
+  if (! check_commit_msg_files($0, verbose)) {
+    status = 1
+  }
+}
+
+END {
+  if (status != 0) {
+    if (reason == "pre-push")
+      error_msg = "Push aborted"
+    else
+      error_msg = "Bad commit message"
+    printf("%s; please see the file 'CONTRIBUTE.org'\n", error_msg)
+  }
+  exit status
+}
diff --git a/git-hooks/post-commit b/git-hooks/post-commit
new file mode 100755
index 000000000..d5d7b585f
--- /dev/null
+++ b/git-hooks/post-commit
@@ -0,0 +1,47 @@
+#!/bin/sh
+# Check the file list of GNU Emacs change log entries after committing.
+
+# Copyright 2023-2025 Free Software Foundation, Inc.
+
+# This file is part of GNU Emacs.
+
+# GNU Emacs is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# GNU Emacs is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+
+# You should have received a copy of the GNU General Public License
+# along with GNU Emacs.  If not, see <https://www.gnu.org/licenses/>.
+
+### Commentary:
+
+# This hook runs after a commit is finalized and checks that the files
+# mentioned in the commit message match the diff.  We perform this in
+# the post-commit phase so that we can be sure we properly detect all
+# the files in the diff (this is difficult during the commit-msg hook,
+# since there's no cross-platform way to detect when a commit is being
+# amended).
+
+# However, since this is a post-commit hook, it's too late to error
+# out and abort the commit: it's already done!  As a result, this hook
+# is purely advisory, and instead we error out when trying to push
+# (see "pre-push" in this directory).
+
+### Code:
+
+HOOKS_DIR=`dirname "$0"`
+
+# Prefer gawk if available, as it handles NUL bytes properly.
+if type gawk >/dev/null 2>&1; then
+  awk="gawk"
+else
+  awk="awk"
+fi
+
+git rev-parse HEAD | $awk -v reason=post-commit \
+                          -f "$HOOKS_DIR"/commit-msg-files.awk
diff --git a/git-hooks/pre-commit b/git-hooks/pre-commit
new file mode 100755
index 000000000..b38c06175
--- /dev/null
+++ b/git-hooks/pre-commit
@@ -0,0 +1,83 @@
+#!/bin/sh
+# Check file names in git commits for GNU Emacs.
+
+# Copyright 2014-2025 Free Software Foundation, Inc.
+
+# This file is part of GNU Emacs.
+
+# GNU Emacs is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# GNU Emacs is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+
+# You should have received a copy of the GNU General Public License
+# along with GNU Emacs.  If not, see <https://www.gnu.org/licenses/>.
+
+LC_ALL=C
+export LC_ALL
+
+# If this is a system where /bin/sh isn't sufficient to
+# run git-sh-setup, use a working shell as a recourse.
+if test -x "/usr/xpg4/bin/sh" && test -z "$POSIX_SHELL"; then
+    POSIX_SHELL=1
+    export POSIX_SHELL
+    exec "/usr/xpg4/bin/sh" `dirname $0`/pre-commit
+fi
+
+exec >&2
+
+. git-sh-setup
+
+# When doing a two-way merge, ignore problems that came from the other
+# side of the merge.
+head=HEAD
+if test -r "$GIT_DIR"/MERGE_HEAD && test "$GIT_MERGE_CHECK_OTHER" != true; then
+  merge_heads=`cat "$GIT_DIR"/MERGE_HEAD` || exit
+  for merge_head in $merge_heads; do
+    case $head in
+      HEAD) head=$merge_head;;
+      # For multi-head merges, there's no easy way to ignore merged-in
+      # changes.  But if you're doing multi-head merges, presumably
+      # you know how to handle any ensuing problems.
+      *) head=HEAD; break;;
+    esac
+  done
+fi
+
+git_diff='git diff --cached --name-only --diff-filter=A'
+
+# 'git diff' will backslash escape tabs and newlines, so we don't have
+# to worry about word splitting here.
+$git_diff $head |
+LC_ALL=C grep -E 'ChangeLog|^-|/-|[^-+./_0-9A-Z_a-z]' |
+while IFS= read -r new_name; do
+  case $new_name in
+    -* | */-*)
+      echo "$new_name: File name component begins with '-'."
+      exit 1;;
+    ChangeLog.android)
+      # This file is explicitly ok.
+      ;;
+    ChangeLog | */ChangeLog)
+      echo "$new_name: Please use git commit messages, not ChangeLog files."
+      exit 1;;
+    *)
+      echo "$new_name: File name does not consist of -+./_ or ASCII letters or digits."
+      exit 1;;
+  esac
+done
+
+# The '--check' option of git diff-index makes Git complain if changes
+# introduce whitespace errors.  This can be a pain when editing test
+# files that deliberately contain lines with trailing whitespace.
+# To work around the problem you can run a command like 'git config
+# core.whitespace -trailing-space'.  It may be better to revamp the
+# tests so that trailing spaces are generated on the fly rather than
+# being committed as source.
+
+exec git diff-index --check --cached $head --
diff --git a/git-hooks/pre-push b/git-hooks/pre-push
new file mode 100755
index 000000000..b88a8ed85
--- /dev/null
+++ b/git-hooks/pre-push
@@ -0,0 +1,88 @@
+#!/bin/sh
+# Check the file list of GNU Emacs change log entries before pushing.
+
+# Copyright 2023-2025 Free Software Foundation, Inc.
+
+# This file is part of GNU Emacs.
+
+# GNU Emacs is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# GNU Emacs is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+
+# You should have received a copy of the GNU General Public License
+# along with GNU Emacs.  If not, see <https://www.gnu.org/licenses/>.
+
+### Commentary:
+
+# This hook runs before pushing a series of commits and checks that
+# the files mentioned in each commit message match the diffs.  This
+# helps ensure that the resulting change logs are correct, which
+# should prevent errors when generating etc/AUTHORS.
+
+# These checks also happen in the "post-commit" hook (which see), but
+# that hook can't abort a commit; it just advises the committer to fix
+# the commit so that this hook runs without errors.
+
+### Code:
+
+HOOKS_DIR=`dirname "$0"`
+
+# Prefer gawk if available, as it handles NUL bytes properly.
+if type gawk >/dev/null 2>&1; then
+  awk="gawk"
+else
+  awk="awk"
+fi
+
+# Standard input receives lines of the form:
+#   <local ref> SP <local sha> SP <remote ref> SP <remote sha> LF
+$awk -v origin_name="$1" '
+  # If the local SHA is all zeroes, ignore it.
+  $2 ~ /^0{40}$/ {
+    next
+  }
+
+  # Check any lines with a valid local SHA and whose remote ref is
+  # master or an emacs-NN release branch.  (We want to avoid checking
+  # feature or scratch branches here.)
+  $2 ~ /^[a-z0-9]{40}$/ && $3 ~ /^refs\/heads\/(master|emacs-[0-9]+)$/ {
+    newref = $2
+    # If the remote SHA is all zeroes, this is a new object to be
+    # pushed (likely a branch)...
+    if ($4 ~ /^0{40}$/) {
+      back = 0
+      # ... Go backwards until we find a SHA on an origin branch.
+      # Stop trying after 1000 commits, just in case...
+      for (back = 0; back < 1000; back++) {
+        cmd = ("git branch -r -l '\''" origin_name "/*'\''" \
+               " --contains " newref "~" back)
+        rv = (cmd | getline)
+        close(cmd)
+        if (rv > 0)
+          break;
+      }
+
+      cmd = ("git rev-parse " newref "~" back)
+      cmd | getline oldref
+      if (!(oldref ~ /^[a-z0-9]{40}$/)) {
+        # The SHA is misformatted!  Skip this line.
+        next
+      }
+      close(cmd)
+    } else if ($4 ~ /^[a-z0-9]{40}$/)  {
+      oldref = $4
+    } else {
+      # The SHA is misformatted!  Skip this line.
+      next
+    }
+
+    # Print every SHA after oldref, up to (and including) newref.
+    system("git rev-list --first-parent --reverse " oldref ".." newref)
+  }
+' | $awk -v reason=pre-push -f "$HOOKS_DIR"/commit-msg-files.awk
diff --git a/git-hooks/prepare-commit-msg b/git-hooks/prepare-commit-msg
new file mode 100755
index 000000000..a6fabdb3d
--- /dev/null
+++ b/git-hooks/prepare-commit-msg
@@ -0,0 +1,49 @@
+#!/bin/sh
+# Check the format of GNU Emacs change log entries.
+
+# Copyright 2019-2025 Free Software Foundation, Inc.
+
+# This file is part of GNU Emacs.
+
+# GNU Emacs is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# GNU Emacs is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+
+# You should have received a copy of the GNU General Public License
+# along with GNU Emacs.  If not, see <https://www.gnu.org/licenses/>.
+
+COMMIT_MSG_FILE=$1
+COMMIT_SOURCE=$2
+SHA1=$3
+
+# Prefer gawk if available, as it handles NUL bytes properly.
+if type gawk >/dev/null 2>&1; then
+  awk="gawk"
+# Next use /usr/xpg4/bin/awk if available, since the script
+# doesn't support Unix awk.
+elif test -x /usr/xpg4/bin/awk; then
+  awk="/usr/xpg4/bin/awk"
+else
+  awk="awk"
+fi
+
+exec $awk "
+  # Catch the case when someone ran git-commit with -s option,
+  # which automatically adds Signed-off-by.
+  /^Signed-off-by: / {
+    print \"'Signed-off-by:' in commit message\"
+    status = 1
+  }
+  END {
+    if (status != 0) {
+      print \"Commit aborted; please see the file 'CONTRIBUTE.org'\"
+    }
+    exit status
+  }
+" <"$COMMIT_MSG_FILE"
diff --git a/mk/targets.mk b/mk/targets.mk
index 20f2ae504..05503449b 100644
--- a/mk/targets.mk
+++ b/mk/targets.mk
@@ -9,6 +9,9 @@ SUBDIRS       = $(OTHERDIRS) $(LISPDIRS)
 INSTSUB       = $(SUBDIRS:%=install-%)
 ORG_MAKE_DOC ?= info html pdf
 
+GITDIR        = .git/hooks
+GITHOOKS      = commit-msg commit-msg-files.awk post-commit pre-commit prepare-commit-msg pre-push
+
 ifneq ($(wildcard .git),)
   # Use the org.el header.
   ORGVERSION := $(patsubst %-dev,%,$(shell $(BATCH) --eval "(require 'lisp-mnt)" \
@@ -86,7 +89,7 @@ local.mk:
 	$(info ======================================================)
 	-@$(MAKE_LOCAL_MK)
 
-all compile::
+all compile:: git
 	$(foreach dir, doc lisp, $(MAKE) -C $(dir) clean;)
 compile compile-dirty::
 	$(MAKE) -C lisp $@
@@ -130,12 +133,21 @@ autoloads: lisp
 repro: cleanall autoloads
 	-@$(REPRO) &
 
+# Implicit rule to copy Git hooks in
+$(GITDIR)/%: git-hooks/%
+	cp -f $< $@
+
+git: $(addprefix $(GITDIR)/,$(GITHOOKS))
+
+cleangit:
+	$(RM) $(addprefix $(GITDIR)/,$(GITHOOKS))
+
 cleandirs:
 	$(foreach dir, $(SUBDIRS), $(MAKE) -C $(dir) cleanall;)
 
 clean:	cleanlisp cleandoc
 
-cleanall: cleandirs cleantest
+cleanall: cleandirs cleantest cleangit
 	-$(FIND) . \( -name \*~ -o -name \*# -o -name .#\* \) -exec $(RM) {} +
 	-$(FIND) $(CLEANDIRS) \( -name \*~ -o -name \*.elc \) -exec $(RM) {} +
 
-- 
2.43.0

