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