1# hooks.py -- for dealing with git hooks 2# Copyright (C) 2012-2013 Jelmer Vernooij and others. 3# 4# Dulwich is dual-licensed under the Apache License, Version 2.0 and the GNU 5# General Public License as public by the Free Software Foundation; version 2.0 6# or (at your option) any later version. You can redistribute it and/or 7# modify it under the terms of either of these two licenses. 8# 9# Unless required by applicable law or agreed to in writing, software 10# distributed under the License is distributed on an "AS IS" BASIS, 11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12# See the License for the specific language governing permissions and 13# limitations under the License. 14# 15# You should have received a copy of the licenses; if not, see 16# <http://www.gnu.org/licenses/> for a copy of the GNU General Public License 17# and <http://www.apache.org/licenses/LICENSE-2.0> for a copy of the Apache 18# License, Version 2.0. 19# 20 21"""Access to hooks.""" 22 23import os 24import subprocess 25import sys 26import tempfile 27 28from dulwich.errors import ( 29 HookError, 30) 31 32 33class Hook(object): 34 """Generic hook object.""" 35 36 def execute(self, *args): 37 """Execute the hook with the given args 38 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 48 49class ShellHook(Hook): 50 """Hook by executable file 51 52 Implements standard githooks(5) [0]: 53 54 [0] http://www.kernel.org/pub/software/scm/git/docs/githooks.html 55 """ 56 57 def __init__(self, name, path, numparam, 58 pre_exec_callback=None, post_exec_callback=None, 59 cwd=None): 60 """Setup shell hook definition 61 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 80 self.pre_exec_callback = pre_exec_callback 81 self.post_exec_callback = post_exec_callback 82 83 self.cwd = cwd 84 85 if sys.version_info[0] == 2 and sys.platform == 'win32': 86 # Python 2 on windows does not support unicode file paths 87 # http://bugs.python.org/issue1759845 88 self.filepath = self.filepath.encode(sys.getfilesystemencoding()) 89 90 def execute(self, *args): 91 """Execute the hook with given args""" 92 93 if len(args) != self.numparam: 94 raise HookError("Hook %s executed with wrong number of args. \ 95 Expected %d. Saw %d. args: %s" 96 % (self.name, self.numparam, len(args), args)) 97 98 if (self.pre_exec_callback is not None): 99 args = self.pre_exec_callback(*args) 100 101 try: 102 ret = subprocess.call([self.filepath] + list(args), cwd=self.cwd) 103 if ret != 0: 104 if (self.post_exec_callback is not None): 105 self.post_exec_callback(0, *args) 106 raise HookError("Hook %s exited with non-zero status" 107 % (self.name)) 108 if (self.post_exec_callback is not None): 109 return self.post_exec_callback(1, *args) 110 except OSError: # no file. silent failure. 111 if (self.post_exec_callback is not None): 112 self.post_exec_callback(0, *args) 113 114 115class PreCommitShellHook(ShellHook): 116 """pre-commit shell hook""" 117 118 def __init__(self, controldir): 119 filepath = os.path.join(controldir, 'hooks', 'pre-commit') 120 121 ShellHook.__init__(self, 'pre-commit', filepath, 0, cwd=controldir) 122 123 124class PostCommitShellHook(ShellHook): 125 """post-commit shell hook""" 126 127 def __init__(self, controldir): 128 filepath = os.path.join(controldir, 'hooks', 'post-commit') 129 130 ShellHook.__init__(self, 'post-commit', filepath, 0, cwd=controldir) 131 132 133class CommitMsgShellHook(ShellHook): 134 """commit-msg shell hook 135 136 Args: 137 args[0]: commit message 138 Returns: 139 new commit message or None 140 """ 141 142 def __init__(self, controldir): 143 filepath = os.path.join(controldir, 'hooks', 'commit-msg') 144 145 def prepare_msg(*args): 146 (fd, path) = tempfile.mkstemp() 147 148 with os.fdopen(fd, 'wb') as f: 149 f.write(args[0]) 150 151 return (path,) 152 153 def clean_msg(success, *args): 154 if success: 155 with open(args[0], 'rb') as f: 156 new_msg = f.read() 157 os.unlink(args[0]) 158 return new_msg 159 os.unlink(args[0]) 160 161 ShellHook.__init__(self, 'commit-msg', filepath, 1, 162 prepare_msg, clean_msg, controldir) 163 164 165class PostReceiveShellHook(ShellHook): 166 """post-receive shell hook""" 167 168 def __init__(self, controldir): 169 self.controldir = controldir 170 filepath = os.path.join(controldir, 'hooks', 'post-receive') 171 ShellHook.__init__(self, 'post-receive', filepath, 0) 172 173 def execute(self, client_refs): 174 # do nothing if the script doesn't exist 175 if not os.path.exists(self.filepath): 176 return None 177 178 try: 179 env = os.environ.copy() 180 env['GIT_DIR'] = self.controldir 181 182 p = subprocess.Popen( 183 self.filepath, 184 stdin=subprocess.PIPE, 185 stdout=subprocess.PIPE, 186 stderr=subprocess.PIPE, 187 env=env 188 ) 189 190 # client_refs is a list of (oldsha, newsha, ref) 191 in_data = '\n'.join([' '.join(ref) for ref in client_refs]) 192 193 out_data, err_data = p.communicate(in_data) 194 195 if (p.returncode != 0) or err_data: 196 err_fmt = "post-receive exit code: %d\n" \ 197 + "stdout:\n%s\nstderr:\n%s" 198 err_msg = err_fmt % (p.returncode, out_data, err_data) 199 raise HookError(err_msg) 200 return out_data 201 except OSError as err: 202 raise HookError(repr(err)) 203