hooks.py 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246
  1. # hooks.py -- for dealing with git hooks
  2. # Copyright (C) 2012-2013 Jelmer Vernooij and others.
  3. #
  4. # SPDX-License-Identifier: Apache-2.0 OR GPL-2.0-or-later
  5. # Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU
  6. # General Public License as published by the Free Software Foundation; version 2.0
  7. # or (at your option) any later version. You can redistribute it and/or
  8. # modify it under the terms of either of these two licenses.
  9. #
  10. # Unless required by applicable law or agreed to in writing, software
  11. # distributed under the License is distributed on an "AS IS" BASIS,
  12. # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13. # See the License for the specific language governing permissions and
  14. # limitations under the License.
  15. #
  16. # You should have received a copy of the licenses; if not, see
  17. # <http://www.gnu.org/licenses/> for a copy of the GNU General Public License
  18. # and <http://www.apache.org/licenses/LICENSE-2.0> for a copy of the Apache
  19. # License, Version 2.0.
  20. #
  21. """Access to hooks."""
  22. __all__ = [
  23. "CommitMsgShellHook",
  24. "Hook",
  25. "PostCommitShellHook",
  26. "PostReceiveShellHook",
  27. "PreCommitShellHook",
  28. "ShellHook",
  29. ]
  30. import os
  31. import subprocess
  32. from collections.abc import Callable, Sequence
  33. from typing import Any
  34. from .errors import HookError
  35. class Hook:
  36. """Generic hook object."""
  37. def execute(self, *args: Any) -> Any: # noqa: ANN401
  38. """Execute the hook with the given args.
  39. Args:
  40. args: argument list to hook
  41. Raises:
  42. HookError: hook execution failure
  43. Returns:
  44. a hook may return a useful value
  45. """
  46. raise NotImplementedError(self.execute)
  47. class ShellHook(Hook):
  48. """Hook by executable file.
  49. Implements standard githooks(5) [0]:
  50. [0] http://www.kernel.org/pub/software/scm/git/docs/githooks.html
  51. """
  52. def __init__(
  53. self,
  54. name: str,
  55. path: str,
  56. numparam: int,
  57. pre_exec_callback: Callable[..., Any] | None = None,
  58. post_exec_callback: Callable[..., Any] | None = None,
  59. cwd: str | None = None,
  60. ) -> None:
  61. """Setup shell hook definition.
  62. Args:
  63. name: name of hook for error messages
  64. path: absolute path to executable file
  65. numparam: number of requirements parameters
  66. pre_exec_callback: closure for setup before execution
  67. Defaults to None. Takes in the variable argument list from the
  68. execute functions and returns a modified argument list for the
  69. shell hook.
  70. post_exec_callback: closure for cleanup after execution
  71. Defaults to None. Takes in a boolean for hook success and the
  72. modified argument list and returns the final hook return value
  73. if applicable
  74. cwd: working directory to switch to when executing the hook
  75. """
  76. self.name = name
  77. self.filepath = path
  78. self.numparam = numparam
  79. self.pre_exec_callback = pre_exec_callback
  80. self.post_exec_callback = post_exec_callback
  81. self.cwd = cwd
  82. def execute(self, *args: Any) -> Any: # noqa: ANN401
  83. """Execute the hook with given args."""
  84. if len(args) != self.numparam:
  85. raise HookError(
  86. f"Hook {self.name} executed with wrong number of args. Expected {self.numparam}. Saw {len(args)}. args: {args}"
  87. )
  88. if self.pre_exec_callback is not None:
  89. args = self.pre_exec_callback(*args)
  90. try:
  91. ret = subprocess.call(
  92. [os.path.relpath(self.filepath, self.cwd), *list(args)], cwd=self.cwd
  93. )
  94. if ret != 0:
  95. if self.post_exec_callback is not None:
  96. self.post_exec_callback(0, *args)
  97. raise HookError(f"Hook {self.name} exited with non-zero status {ret}")
  98. if self.post_exec_callback is not None:
  99. return self.post_exec_callback(1, *args)
  100. except FileNotFoundError: # no file. silent failure.
  101. if self.post_exec_callback is not None:
  102. self.post_exec_callback(0, *args)
  103. class PreCommitShellHook(ShellHook):
  104. """pre-commit shell hook."""
  105. def __init__(self, cwd: str, controldir: str) -> None:
  106. """Initialize pre-commit hook.
  107. Args:
  108. cwd: Working directory for hook execution
  109. controldir: Path to the git control directory (.git)
  110. """
  111. filepath = os.path.join(controldir, "hooks", "pre-commit")
  112. ShellHook.__init__(self, "pre-commit", filepath, 0, cwd=cwd)
  113. class PostCommitShellHook(ShellHook):
  114. """post-commit shell hook."""
  115. def __init__(self, controldir: str) -> None:
  116. """Initialize post-commit hook.
  117. Args:
  118. controldir: Path to the git control directory (.git)
  119. """
  120. filepath = os.path.join(controldir, "hooks", "post-commit")
  121. ShellHook.__init__(self, "post-commit", filepath, 0, cwd=controldir)
  122. class CommitMsgShellHook(ShellHook):
  123. """commit-msg shell hook."""
  124. def __init__(self, controldir: str) -> None:
  125. """Initialize commit-msg hook.
  126. Args:
  127. controldir: Path to the git control directory (.git)
  128. """
  129. filepath = os.path.join(controldir, "hooks", "commit-msg")
  130. def prepare_msg(*args: bytes) -> tuple[str, ...]:
  131. import tempfile
  132. (fd, path) = tempfile.mkstemp()
  133. with os.fdopen(fd, "wb") as f:
  134. f.write(args[0])
  135. return (path,)
  136. def clean_msg(success: int, *args: str) -> bytes | None:
  137. if success:
  138. with open(args[0], "rb") as f:
  139. new_msg = f.read()
  140. os.unlink(args[0])
  141. return new_msg
  142. os.unlink(args[0])
  143. return None
  144. ShellHook.__init__(
  145. self, "commit-msg", filepath, 1, prepare_msg, clean_msg, controldir
  146. )
  147. class PostReceiveShellHook(ShellHook):
  148. """post-receive shell hook."""
  149. def __init__(self, controldir: str) -> None:
  150. """Initialize post-receive hook.
  151. Args:
  152. controldir: Path to the git control directory (.git)
  153. """
  154. self.controldir = controldir
  155. filepath = os.path.join(controldir, "hooks", "post-receive")
  156. ShellHook.__init__(self, "post-receive", path=filepath, numparam=0)
  157. def execute(
  158. self, client_refs: Sequence[tuple[bytes, bytes, bytes]]
  159. ) -> bytes | None:
  160. """Execute the post-receive hook.
  161. Args:
  162. client_refs: List of tuples containing (old_sha, new_sha, ref_name)
  163. for each updated reference
  164. Returns:
  165. Output from the hook execution or None if hook doesn't exist
  166. Raises:
  167. HookError: If hook execution fails
  168. """
  169. # do nothing if the script doesn't exist
  170. if not os.path.exists(self.filepath):
  171. return None
  172. try:
  173. env = os.environ.copy()
  174. env["GIT_DIR"] = self.controldir
  175. p = subprocess.Popen(
  176. self.filepath,
  177. stdin=subprocess.PIPE,
  178. stdout=subprocess.PIPE,
  179. stderr=subprocess.PIPE,
  180. env=env,
  181. )
  182. # client_refs is a list of (oldsha, newsha, ref)
  183. in_data = b"\n".join([b" ".join(ref) for ref in client_refs])
  184. out_data, err_data = p.communicate(in_data)
  185. if (p.returncode != 0) or err_data:
  186. err_msg = (
  187. f"post-receive exit code: {p.returncode}\n"
  188. f"stdout:\n{out_data.decode('utf-8', 'backslashreplace')}\n"
  189. f"stderr:\n{err_data.decode('utf-8', 'backslashreplace')}"
  190. )
  191. raise HookError(err_msg)
  192. return out_data
  193. except OSError as err:
  194. raise HookError(repr(err)) from err