├── LICENSE ├── README.md ├── hooklib.py ├── hooklib_git.py ├── hooklib_hg.py ├── hooklib_input.py ├── hooktests.py ├── integrationtests ├── test-git.t └── test-hg.t └── setup.py /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2015 Laurent Charignon 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hooklib: Easy Source Control Hooks 2 | 3 | Hooklib is an Apache2 Licensed library, in Python, to help people write hooks for source control: 4 | - **SCM Agnostic:** hooks can work with different SCM (git, svn, hg), write your hook once and they work on other SCMs 5 | - **Simple API:** don't learn the secret commands to peek inside your source control system, all you need is accessible and computed on the fly 6 | - **Parallel/Sequential execution:** run your hooks in parallel or sequentially 7 | 8 | Supported hooks phases: 9 | 10 | Phase name | SCM | Available fields 11 | ------------- | ------------- | ---------------- 12 | applypatch-msg | Git | reporoot, head, messagefile 13 | pre-applypatch | Git | reporoot, head 14 | post-applypatch | Git | reporoot, head 15 | pre-commit | Git | reporoot, head 16 | prepare-commit-msg | Git | reporoot, head, messagefile, mode, sha 17 | commit-msg | Git | reporoot, head, messagefile 18 | post-commit | Git | reporoot, head 19 | pre-rebase | Git | reporoot, head, upstream, rebased 20 | pre-push | Git | reporoot, head, revstobepushed 21 | pre-receive | Git | reporoot, head, receivedrevs 22 | update | Git, Hg | reporoot(git), head(git), refname(git) old(git), new(git), rev(hg) 23 | post-receive | Git | reporoot, head, receivedrevs 24 | post-update | Git | reporoot, head, revs 25 | pre-auto-gc | Git | reporoot, head 26 | 27 | Currently only supports git and hg 28 | 29 | 30 | Example 1: gate commit on commit message format 31 | - 32 | Feel free to compare this to how you would do this without this library: https://git-scm.com/book/en/v2/Customizing-Git-An-Example-Git-Enforced-Policy 33 | 34 | This hooks works for both git and hg: 35 | - for git: put it in .git/hooks/update and make it executable for git 36 | - for hg: put it wherever your want and reference it from your hg config 37 | 38 | ```python 39 | #!/usr/bin/python 40 | from hooklib import basehook, runhooks 41 | 42 | class commmitmsggatinghook(basehook): 43 | def check(self, log, revdata): 44 | for rev in revdata.revs: 45 | if not 'secretmessage' in revdata.commitmessagefor(rev): 46 | log.write("commit message must contain 'secretmessage'") 47 | return False 48 | return True 49 | 50 | runhooks('update', hooks=[commmitmsggatinghook]) 51 | ``` 52 | 53 | Example 2: only authorize push to master 54 | - 55 | 56 | _Contrary to the example 1, here we reference 'refs/heads/master', a git concept => this hook wouldn't work without code change for hg._ 57 | Save the following file under .git/hooks/update and make it executable to test it: 58 | ```python 59 | #!/usr/bin/python 60 | from hooklib import basehook, runhooks 61 | 62 | class mastergatinghook(basehook): 63 | def check(self, log, revdata): 64 | pushtomaster = revdata.refname == 'refs/heads/master' 65 | if not pushtomaster: 66 | log.write("you can only push master on this repo") 67 | return False 68 | else: 69 | return True 70 | 71 | runhooks('update', hooks=[mastergatinghook]) 72 | ``` 73 | 74 | Example 3: parallel execution 75 | - 76 | Save the following file under .git/hooks/post-update and make it executable to test it: 77 | ```python 78 | #!/usr/bin/python 79 | from hooklib import basehook, runhooks 80 | import time 81 | 82 | class slowhook(basehook): 83 | def check(self, log, revdata): 84 | time.sleep(0.1) 85 | return True 86 | 87 | class veryslowhook(basehook): 88 | def check(self, log, revdata): 89 | time.sleep(0.5) 90 | return True 91 | 92 | # should take roughly as long as the slowest, i.e. 0.5s 93 | runhooks('post-update', hooks=[slowhook]*200+[veryslowhook], parallel=True) 94 | ``` 95 | 96 | Example 4: client side commit message style check 97 | - 98 | The following hooks checks on the client side that the commit message follows the format: "topic: explanation" 99 | I have it enabled for this repo to make sure that I respect the format I intended to keep. 100 | Save the following file under .git/hooks/commit-msg and make it executable to test it: 101 | ```python 102 | #!/usr/bin/python 103 | from hooklib import basehook, runhooks 104 | import re 105 | 106 | class validatecommitmsg(basehook): 107 | def check(self, log, revdata): 108 | with open(revdata.messagefile) as f: 109 | msg = f.read() 110 | if re.match("[a-z]+: .*", msg): 111 | return True 112 | else: 113 | log.write("validatecommit msg rejected your commit message") 114 | log.write("(message must follow format: 'topic: explanation')") 115 | return False 116 | 117 | runhooks('commit-msg', hooks=[validatecommitmsg]) 118 | ``` 119 | 120 | Example 5: validate unit test passing before commiting 121 | - 122 | 123 | The following hooks checks on the client side that the commit about to be made passes all unit tests. 124 | I have it enabled for this repo to make sure that I respect the format I intended to keep. 125 | Save the following file under .git/hooks/pre-commit and make it executable to test it: 126 | 127 | ```python 128 | from hooklib import basehook, runhooks 129 | import os 130 | import subprocess 131 | 132 | class validateunittestpass(basehook): 133 | def check(self, log, revdata): 134 | testrun = "python %s/hooktests.py" % revdata.reporoot 135 | ret = subprocess.call(testrun, 136 | shell=True, 137 | env={"PYTHONPATH":revdata.reporoot}) 138 | if ret == 0: 139 | return True 140 | else: 141 | log.write("unit test failed, please check them") 142 | return False 143 | 144 | runhooks('pre-commit', hooks=[validateunittestpass]) 145 | ``` 146 | 147 | 148 | Installation 149 | - 150 | You can use pip: 151 | ``` 152 | sudo pip install mercurial 153 | sudo pip install hooklib 154 | ``` 155 | 156 | Or install it directly from the repo: 157 | ``` 158 | git clone https://github.com/charignon/hooklib.git 159 | sudo python setup.py install 160 | sudo pip install mercurial 161 | ``` 162 | 163 | User Guide 164 | - 165 | 166 | Once you have installed the library, you can start writing you first hook. 167 | It is easy to just get started by copy-pasting one of the examples. 168 | A hook should is a python class that derives from the base class `basehook`. 169 | 170 | The hook's `check(self, log, revdata)` instance function will get called with a `log` and a `revdata` object: 171 | - The `check` function should return True if the hook passes and False otherwise. 172 | - The `log` object can be used to send feedback to the user, for example, if your hook rejects a commit, you can explain what justifies the rejection. You can use `log.write(str)` as shown in the examples 173 | - The `revdata` object allows you to get all the information that you need about the state of the repo. 174 | 175 | For example, if you are writing a `commit-msg` hook, `revdata.messagefile` will be the filename of the file containing the commit message to validate. 176 | You can get the complete list of the accessible fields by looking at the documentation of the class for the hook in question. 177 | 178 | If you want to know the field available in `revdata` for the `pre-receive` hook for git. Look into `hooklib_git.py`, find the class `gitprereceiveinputparser` and look at its pydoc: 179 | 180 | In a python shell: 181 | 182 | ``` 183 | >>> from hooklib_git import * 184 | >>> help(gitprereceiveinputparser) 185 | Help on class gitprereceiveinputparser in module hooklib_git: 186 | 187 | class gitprereceiveinputparser(gitreceiveinputparser) 188 | | input parser for the 'pre-receive' phase 189 | | 190 | | available fields: 191 | | - reporoot (str) => root of the repo 192 | | - receivedrevs => 193 | | (list of tuples: ( )) 194 | | - head (str) => sha1 of HEAD 195 | | 196 | ... 197 | ``` 198 | 199 | Contributing 200 | - 201 | Before sending a Pull Request please run the tests: 202 | 203 | - To run the unit tests, simply call `python hooktests.py`, let's keep the unit test suite running under 1s 204 | You have to install mock to run the tests: `sudo pip install mock==1.0.0` 205 | - To run the integration tests, download run-tests.py from the mercurial repo "https://selenic.com/hg/file/tip/tests/run-tests.py" 206 | Then you can run the tests with `python run-tests.py test-git.t -l` (I only have tests for git so far) 207 | 208 | 209 | -------------------------------------------------------------------------------- /hooklib.py: -------------------------------------------------------------------------------- 1 | """Hook helpers library 2 | 3 | You can use this library to make it easier to write hooks 4 | that work with multiple source control system (git, svn, hg ...). 5 | It currently only works for git. 6 | See https://github.com/charignon/hooklib for examples""" 7 | import threading 8 | import sys 9 | from Queue import Queue 10 | from hooklib_input import inputparser 11 | 12 | 13 | def runhooks(phase, hooks, parallel=False): 14 | if parallel: 15 | runner = parallelhookrunner(phase) 16 | else: 17 | runner = hookrunner(phase) 18 | for h in hooks: 19 | runner.register(h) 20 | ret = runner.evaluate() 21 | log = runner.log.read() 22 | if log: 23 | sys.stderr.write("\n".join(log)+"\n") 24 | if not ret: 25 | sys.exit(1) 26 | 27 | 28 | class hooklog(object): 29 | """Collect logs from running hooks""" 30 | def __init__(self): 31 | self.msgs = [] 32 | 33 | def write(self, msg): 34 | self.msgs.append(msg) 35 | 36 | def read(self): 37 | return self.msgs 38 | 39 | @staticmethod 40 | def aggregate(logs): 41 | msgs = [] 42 | for l in logs: 43 | msgs.extend(l.read()) 44 | ret = hooklog() 45 | ret.msgs = msgs 46 | return ret 47 | 48 | 49 | class hookrunner(object): 50 | def __init__(self, phase=None, phases=None): 51 | self.runlist = [] 52 | if phases is not None: 53 | self.revdata = inputparser.fromphases(phases).parse() 54 | else: 55 | self.revdata = inputparser.fromphase(phase).parse() 56 | 57 | def register(self, h, blocking=True): 58 | self.runlist.append((h, blocking)) 59 | 60 | def evaluate(self): 61 | self.log = hooklog() 62 | success = True 63 | for h, blocking in self.runlist: 64 | hookpass = h().check(self.log, self.revdata) 65 | # Stop evaluating after failure on blocking hook 66 | if not hookpass and blocking: 67 | return False 68 | 69 | if not hookpass: 70 | success = False 71 | 72 | return success 73 | 74 | 75 | class parallelhookrunner(hookrunner): 76 | def evaluateone(self, hook): 77 | log = hooklog() 78 | hookpass = hook().check(log, self.revdata) 79 | self.resultqueue.put((hookpass, log)) 80 | 81 | def evaluate(self): 82 | threads = [] 83 | self.resultqueue = Queue() 84 | for h, _ in self.runlist: 85 | t = threading.Thread(target=self.evaluateone, args=(h, )) 86 | threads.append(t) 87 | t.start() 88 | for t in threads: 89 | t.join() 90 | res, logs = [], [] 91 | 92 | for h in range(len(self.runlist)): 93 | r, l = self.resultqueue.get() 94 | res.append(r) 95 | logs.append(l) 96 | 97 | self.log = hooklog.aggregate(logs) 98 | return all(res) 99 | 100 | 101 | class basehook(object): 102 | """A basehook to be subclassed by user implemented hooks""" 103 | pass 104 | -------------------------------------------------------------------------------- /hooklib_git.py: -------------------------------------------------------------------------------- 1 | """Package containing all the input parsers specific to git 2 | 3 | Their implementation match what is described at 4 | https://git-scm.com/docs/githooks""" 5 | 6 | from mercurial import util 7 | import hooklib_input 8 | import sys 9 | import os 10 | 11 | 12 | class basegitinputparser(object): 13 | def scm(self): 14 | return 'git' 15 | 16 | 17 | class gitinforesolver(object): 18 | def __init__(self): 19 | self.reporoot = None 20 | if 'GIT_DIR' in os.environ: 21 | gitdir = os.environ["GIT_DIR"] 22 | self.reporoot = os.path.dirname(os.path.abspath(gitdir)) 23 | else: 24 | self.reporoot = util.popen4("git rev-parse --show-toplevel")[1]\ 25 | .read()\ 26 | .strip() 27 | 28 | self._revs = None 29 | 30 | def commitmessagefor(self, rev): 31 | return util.popen4("git cat-file commit %s | sed '1,/^$/d'" % rev)[1]\ 32 | .read().strip() 33 | 34 | @property 35 | def head(self): 36 | return util.popen4("git rev-parse HEAD")[1].read().strip() 37 | 38 | @property 39 | def revs(self): 40 | if self._revs: 41 | return self._revs 42 | raw = util.popen4('git rev-list %s..%s' % (self.old, self.new))[1]\ 43 | .read()\ 44 | .strip() 45 | if raw != '': 46 | return raw.split("\n") 47 | else: 48 | return [] 49 | 50 | def setrevs(self, revs): 51 | self._revs = revs 52 | 53 | 54 | class gitpostupdateinputparser(basegitinputparser): 55 | """Input parser for the 'post-update' phase 56 | 57 | Available fields: 58 | - reporoot (str) => root of the repo 59 | - head (str) => sha1 of HEAD 60 | - revs (list of sha1 (str))""" 61 | def parse(self): 62 | revs = sys.argv[1:] 63 | resolver = gitinforesolver() 64 | resolver.setrevs(revs) 65 | return resolver 66 | 67 | 68 | class gitupdateinputparser(basegitinputparser): 69 | """Input parser for the 'update' phase 70 | 71 | Available fields: 72 | - reporoot (str) => root of the repo 73 | - head (str) => sha1 of HEAD 74 | - refname (str) => refname that is updated, like 'refs/heads/master' 75 | - old (str) => old sha of the ref 76 | - new (str) => new sha of the ref""" 77 | def parse(self): 78 | refname, old, new = sys.argv[1:] 79 | resolver = gitinforesolver() 80 | resolver.refname = refname 81 | resolver.old = old 82 | resolver.new = new 83 | return resolver 84 | 85 | 86 | class gitprecommitinputparser(basegitinputparser): 87 | """Input parser for the 'pre-commit' phase 88 | 89 | Available fields: 90 | - reporoot (str) => root of the repo 91 | - head (str) => sha1 of HEAD""" 92 | def parse(self): 93 | resolver = gitinforesolver() 94 | return resolver 95 | 96 | 97 | class gitpreapplypatchinputparser(basegitinputparser): 98 | """Input parser for the 'pre-applypatch' phase 99 | 100 | Available fields: 101 | - reporoot (str) => root of the repo 102 | - head (str) => sha1 of HEAD""" 103 | def parse(self): 104 | resolver = gitinforesolver() 105 | return resolver 106 | 107 | 108 | class gitpostapplypatchinputparser(basegitinputparser): 109 | """Input parser for the 'post-applypatch' phase 110 | 111 | Available fields: 112 | - reporoot (str) => root of the repo 113 | - head (str) => sha1 of HEAD""" 114 | def parse(self): 115 | resolver = gitinforesolver() 116 | return resolver 117 | 118 | 119 | class gitpostcommitinputparser(basegitinputparser): 120 | """Input parser for the 'post-commit' phase 121 | 122 | Available fields: 123 | - reporoot (str) => root of the repo 124 | - head (str) => sha1 of HEAD""" 125 | def parse(self): 126 | resolver = gitinforesolver() 127 | return resolver 128 | 129 | 130 | class gitpreautogcinputparser(basegitinputparser): 131 | """input parser for the 'pre-autogc' phase 132 | 133 | available fields: 134 | - reporoot (str) => root of the repo 135 | - head (str) => sha1 of HEAD""" 136 | def parse(self): 137 | resolver = gitinforesolver() 138 | return resolver 139 | 140 | 141 | class gitreceiveinputparser(basegitinputparser): 142 | def parse(self): 143 | resolver = gitinforesolver() 144 | rawrevs = hooklib_input.readlines() 145 | revs = tuple([tuple(line.strip().split(' ')) for line in rawrevs]) 146 | resolver.receivedrevs = revs 147 | return resolver 148 | 149 | 150 | class gitpostreceiveinputparser(gitreceiveinputparser): 151 | """input parser for the 'post-receive' phase 152 | 153 | available fields: 154 | - reporoot (str) => root of the repo 155 | - receivedrevs => 156 | (list of tuples: ( )) 157 | - head (str) => sha1 of HEAD""" 158 | pass 159 | 160 | 161 | class gitprereceiveinputparser(gitreceiveinputparser): 162 | """input parser for the 'pre-receive' phase 163 | 164 | available fields: 165 | - reporoot (str) => root of the repo 166 | - receivedrevs => 167 | (list of tuples: ( )) 168 | - head (str) => sha1 of HEAD""" 169 | pass 170 | 171 | 172 | class gitprepushinputparser(basegitinputparser): 173 | """input parser for the 'pre-push' phase 174 | 175 | available fields: 176 | - reporoot (str) => root of the repo 177 | - revstobepushed => 178 | (list of tuples: )) 179 | - head (str) => sha1 of HEAD""" 180 | def parse(self): 181 | resolver = gitinforesolver() 182 | rawrevs = hooklib_input.readlines() 183 | revs = tuple([tuple(line.strip().split(' ')) for line in rawrevs]) 184 | resolver.revstobepushed = revs 185 | return resolver 186 | 187 | 188 | class gitapplypatchmsginputparser(basegitinputparser): 189 | """input parser for the 'applypatch-msg' phase 190 | 191 | available fields: 192 | - reporoot (str) => root of the repo 193 | - messagefile (str) => filename of the file containing the commit message 194 | - head (str) => sha1 of HEAD""" 195 | def parse(self): 196 | messagefile = sys.argv[1] 197 | resolver = gitinforesolver() 198 | resolver.messagefile = messagefile 199 | return resolver 200 | 201 | 202 | class gitprerebaseinputparser(basegitinputparser): 203 | """input parser for the 'pre-rebase' phase 204 | 205 | available fields: 206 | - reporoot (str) => root of the repo 207 | - upstream (str) => upstream from which the serie was forked 208 | - rebased (str) => branch being rebased, None if current branch 209 | - head (str) => sha1 of HEAD""" 210 | def parse(self): 211 | upstream = sys.argv[1] 212 | if len(sys.argv) > 2: 213 | rebased = sys.argv[2] 214 | else: 215 | rebased = None 216 | resolver = gitinforesolver() 217 | resolver.upstream = upstream 218 | resolver.rebased = rebased 219 | return resolver 220 | 221 | 222 | class gitcommitmsginputparser(basegitinputparser): 223 | """input parser for the 'commit-msg' phase 224 | 225 | available fields: 226 | - reporoot (str) => root of the repo 227 | - messagefile (str) => filename of the file containing the commit message 228 | - head (str) => sha1 of HEAD""" 229 | def parse(self): 230 | messagefile = sys.argv[1] 231 | resolver = gitinforesolver() 232 | resolver.messagefile = messagefile 233 | return resolver 234 | 235 | 236 | class gitpreparecommitmsginputparser(basegitinputparser): 237 | """input parser for the 'prepare-commit-msg' phase 238 | 239 | available fields: 240 | - reporoot (str) => root of the repo 241 | - messagefile (str) => filename of the file containing the commit message 242 | - mode (str) => could be one of 243 | (None, 'message', 'template', 'merge', 'squash', 'commit') 244 | - sha (str) => a sha1 or None, not None only when mode == 'commit' 245 | - head (str) => sha1 of HEAD""" 246 | def parse(self): 247 | allowedmodes = ('message', 'template', 'merge', 'squash', 'commit') 248 | messagefile = sys.argv[1] 249 | mode = None 250 | sha = None 251 | if len(sys.argv) > 2: 252 | mode = sys.argv[2] 253 | if len(sys.argv) > 3: 254 | sha = sys.argv[3] 255 | if mode is not None and mode not in allowedmodes: 256 | raise ValueError('Invalid Second Argument: mode') 257 | if mode != 'commit' and sha is not None: 258 | raise ValueError('Invalid Third Argument') 259 | if mode == 'commit' and sha is None: 260 | raise ValueError('Missing Third Argument') 261 | resolver = gitinforesolver() 262 | resolver.messagefile = messagefile 263 | resolver.mode = mode 264 | resolver.sha = sha 265 | return resolver 266 | -------------------------------------------------------------------------------- /hooklib_hg.py: -------------------------------------------------------------------------------- 1 | from mercurial import util 2 | import os 3 | 4 | 5 | class basehginputparser(object): 6 | def scm(self): 7 | return 'hg' 8 | 9 | 10 | class hginforesolver(basehginputparser): 11 | def commitmessagefor(self, rev): 12 | return util.popen4("hg log -r %s -T {desc}" % rev)[1].read() 13 | 14 | 15 | class hgupdateinputparser(basehginputparser): 16 | def parse(self): 17 | rev = os.environ['HG_NODE'] 18 | resolver = hginforesolver() 19 | resolver.revs = [rev] 20 | return resolver 21 | -------------------------------------------------------------------------------- /hooklib_input.py: -------------------------------------------------------------------------------- 1 | from hooklib_git import * 2 | from hooklib_hg import * 3 | import os 4 | import sys 5 | 6 | 7 | def readlines(): 8 | """Extracted to make mocking it easier""" 9 | return sys.stdin.readlines() 10 | 11 | 12 | class prepushinputparser(object): 13 | @staticmethod 14 | def findscm(): 15 | return gitprepushinputparser() 16 | 17 | 18 | class prereceiveinputparser(object): 19 | @staticmethod 20 | def findscm(): 21 | return gitprereceiveinputparser() 22 | 23 | 24 | class postreceiveinputparser(object): 25 | @staticmethod 26 | def findscm(): 27 | return gitpostreceiveinputparser() 28 | 29 | 30 | class preparecommitmsginputparser(object): 31 | @staticmethod 32 | def findscm(): 33 | return gitpreparecommitmsginputparser() 34 | 35 | 36 | class preautogcinputparser(object): 37 | @staticmethod 38 | def findscm(): 39 | return gitpreautogcinputparser() 40 | 41 | 42 | class prerebaseinputparser(object): 43 | @staticmethod 44 | def findscm(): 45 | return gitprerebaseinputparser() 46 | 47 | 48 | class postcommitinputparser(object): 49 | @staticmethod 50 | def findscm(): 51 | return gitpostcommitinputparser() 52 | 53 | 54 | class preapplypatchinputparser(object): 55 | @staticmethod 56 | def findscm(): 57 | return gitpreapplypatchinputparser() 58 | 59 | 60 | class postapplypatchinputparser(object): 61 | @staticmethod 62 | def findscm(): 63 | return gitpostapplypatchinputparser() 64 | 65 | 66 | class applypatchmsginputparser(object): 67 | @staticmethod 68 | def findscm(): 69 | return gitapplypatchmsginputparser() 70 | 71 | 72 | class commitmsginputparser(object): 73 | @staticmethod 74 | def findscm(): 75 | return gitcommitmsginputparser() 76 | 77 | 78 | class postupdateinputparser(object): 79 | @staticmethod 80 | def findscm(): 81 | """Find the correct type of postupdateinputparser 82 | based on the SCM used""" 83 | if 'GIT_DIR' in os.environ: 84 | return gitpostupdateinputparser() 85 | else: 86 | raise NotImplementedError("No implemented for your SCM") 87 | 88 | 89 | class updateinputparser(object): 90 | @staticmethod 91 | def findscm(): 92 | """Find the correct type of updateinputparser based on the SCM used""" 93 | if 'GIT_DIR' in os.environ: 94 | return gitupdateinputparser() 95 | elif 'HG_NODE' in os.environ: 96 | return hgupdateinputparser() 97 | else: 98 | raise NotImplementedError("No implemented for your SCM") 99 | 100 | 101 | class precommitinputparser(object): 102 | @staticmethod 103 | def findscm(): 104 | if 'GIT_DIR' in os.environ: 105 | return gitprecommitinputparser() 106 | else: 107 | raise NotImplementedError("No implemented for your SCM") 108 | 109 | 110 | class dummyinputparser(object): 111 | @staticmethod 112 | def findscm(): 113 | return dummyinputparser() 114 | 115 | def parse(self): 116 | return None 117 | 118 | 119 | class inputparser(object): 120 | @staticmethod 121 | def fromphases(p): 122 | for scm, phasename in p: 123 | try: 124 | parserforphase = inputparser.fromphase(phasename) 125 | if parserforphase.scm() == scm: 126 | return parserforphase 127 | else: 128 | continue 129 | except NotImplementedError: 130 | pass 131 | raise NotImplementedError("Couldn't find phase matching" 132 | " conditions") 133 | 134 | @staticmethod 135 | def fromphase(phase): 136 | """Factory method to return an appropriate input parser 137 | For example if the phase is 'post-update' and that the git env 138 | variables are set, we infer that we need a git postupdate 139 | inputparser""" 140 | phasemapping = { 141 | None: dummyinputparser, 142 | 'applypatch-msg': applypatchmsginputparser, 143 | 'pre-applypatch': preapplypatchinputparser, 144 | 'post-applypatch': postapplypatchinputparser, 145 | 'pre-commit': precommitinputparser, 146 | 'prepare-commit-msg': preparecommitmsginputparser, 147 | 'commit-msg': commitmsginputparser, 148 | 'post-commit': postcommitinputparser, 149 | 'pre-rebase': prerebaseinputparser, 150 | 'pre-push': prepushinputparser, 151 | 'pre-receive': prereceiveinputparser, 152 | 'update': updateinputparser, 153 | 'post-receive': postreceiveinputparser, 154 | 'post-update': postupdateinputparser, 155 | 'pre-auto-gc': preautogcinputparser, 156 | } 157 | try: 158 | return phasemapping[phase].findscm() 159 | except KeyError: 160 | raise NotImplementedError("Unsupported hook type %s" % phase) 161 | -------------------------------------------------------------------------------- /hooktests.py: -------------------------------------------------------------------------------- 1 | import unittest 2 | import time 3 | from mock import MagicMock 4 | import hooklib_input 5 | from hooklib import hookrunner, basehook, parallelhookrunner 6 | from hooklib_input import inputparser 7 | from hooklib_git import * 8 | from hooklib_hg import * 9 | import os 10 | import sys 11 | 12 | 13 | ERROR_MSG = "ERROR ABC" 14 | ERROR_MSG2 = "ERROR XYZ" 15 | 16 | 17 | class passinghook(basehook): 18 | def check(self, log, revdata): 19 | return True 20 | 21 | 22 | class failinghook(basehook): 23 | def check(self, log, revdata): 24 | log.write(ERROR_MSG) 25 | return False 26 | 27 | 28 | class failinghook2(basehook): 29 | def check(self, log, revdata): 30 | log.write(ERROR_MSG2) 31 | return False 32 | 33 | 34 | class slowfailinghook(basehook): 35 | def check(self, log, revdata): 36 | time.sleep(0.1) 37 | log.write(ERROR_MSG) 38 | return False 39 | 40 | 41 | class testhookrunner(unittest.TestCase): 42 | def test_passing_hook(self): 43 | """Passing hook works""" 44 | runner = hookrunner() 45 | runner.register(passinghook) 46 | assert(runner.evaluate()) == True 47 | 48 | def test_failing_hook(self): 49 | """Failing hook fails with error message recorded""" 50 | runner = hookrunner() 51 | runner.register(failinghook) 52 | assert(runner.evaluate()) == False 53 | assert(runner.log.read()) == [ERROR_MSG] 54 | 55 | def test_passing_failing_hook(self): 56 | """Passing than failing hook, fails overall""" 57 | runner = hookrunner() 58 | runner.register(passinghook) 59 | runner.register(failinghook) 60 | assert(runner.evaluate()) == False 61 | 62 | def test_failing_stop_hook(self): 63 | """Two failing hook, second one should not run""" 64 | runner = hookrunner() 65 | runner.register(failinghook) 66 | runner.register(failinghook2) 67 | assert(runner.evaluate()) == False 68 | assert(runner.log.read()) == [ERROR_MSG] 69 | 70 | def test_failing_non_blocking_hook(self): 71 | """Two failing hook, non blocking, all evaluated""" 72 | runner = hookrunner() 73 | runner.register(failinghook, blocking=False) 74 | runner.register(failinghook2) 75 | assert(runner.evaluate()) == False 76 | assert(runner.log.read()) == [ERROR_MSG, ERROR_MSG2] 77 | 78 | 79 | class testparallelhookrunner(unittest.TestCase): 80 | def test_speed(self): 81 | """parallel hook runner should run hooks really in parallel""" 82 | runner = parallelhookrunner() 83 | for i in range(100): 84 | runner.register(slowfailinghook) 85 | t1 = time.time() 86 | assert(runner.evaluate()) == False 87 | t2 = time.time() 88 | # 100 * 0.1 = 10s if the run was not parallel 89 | # here we expect less than 0.5s 90 | assert (t2-t1) < 0.5 91 | 92 | def test_aggregation(self): 93 | """parallel hook runner should aggregate log of all the failures""" 94 | runner = parallelhookrunner() 95 | for i in range(3): 96 | runner.register(slowfailinghook) 97 | runner.register(failinghook2) 98 | runner.evaluate() 99 | assert len(runner.log.read()) == 4 100 | assert ERROR_MSG2 in runner.log.read() 101 | 102 | def test_correctness(self): 103 | """parallel hook runner with failing + passing hook 104 | should return failure""" 105 | runner = parallelhookrunner() 106 | for i in range(3): 107 | runner.register(slowfailinghook) 108 | for i in range(3): 109 | runner.register(passinghook) 110 | assert(runner.evaluate() == False) 111 | 112 | 113 | class testscmresolution(unittest.TestCase): 114 | """Checking that we get the right SCM parser for different hook type""" 115 | 116 | def setUp(self): 117 | self.origargv = list(sys.argv) 118 | self.origenv = os.environ.copy() 119 | 120 | def tearDown(self): 121 | os.environ = self.origenv 122 | sys.argv = self.origargv 123 | 124 | def test_git_postupdate(self): 125 | os.environ["GIT_DIR"] = "." 126 | sys.argv = ["program.name", "a"*40] 127 | revdata = inputparser.fromphase('post-update').parse() 128 | assert(revdata.revs == ["a"*40]) 129 | 130 | def test_hg_postupdate(self): 131 | os.environ["HG_NODE"] = "." 132 | with self.assertRaises(NotImplementedError): 133 | revdata = inputparser.fromphase('post-update') 134 | 135 | def test_git_update(self): 136 | os.environ["GIT_DIR"] = "." 137 | sys.argv = ["program.name", "a"*40, "0"*40, "1"*40] 138 | revdata = inputparser.fromphase('update').parse() 139 | assert(revdata.refname == "a"*40) 140 | assert(revdata.old == "0"*40) 141 | assert(revdata.new == "1"*40) 142 | 143 | def test_hg_update(self): 144 | os.environ["HG_NODE"] = "a"*40 145 | revdata = inputparser.fromphase('update').parse() 146 | assert(revdata.revs == ["a"*40]) 147 | 148 | def test_git_precommit(self): 149 | os.environ["GIT_DIR"] = "." 150 | sys.argv = ["program.name"] 151 | revdata = inputparser.fromphase('pre-commit').parse() 152 | assert(isinstance(revdata, gitinforesolver)) 153 | 154 | def test_hg_precommit(self): 155 | os.environ["HG_NODE"] = "." 156 | with self.assertRaises(NotImplementedError): 157 | revdata = inputparser.fromphase('pre-commit') 158 | 159 | def test_unknown_hookname(self): 160 | with self.assertRaises(NotImplementedError): 161 | revdata = inputparser.fromphase('unknown-phase') 162 | 163 | def test_gitapplypatchmsg(self): 164 | sys.argv = ['program.name', 'messagefile'] 165 | revdata = inputparser.fromphase('applypatch-msg').parse() 166 | assert(revdata.messagefile == 'messagefile') 167 | 168 | def test_gitpreapplypatch(self): 169 | parser = inputparser.fromphase('pre-applypatch') 170 | assert(isinstance(parser, gitpreapplypatchinputparser)) 171 | assert(isinstance(parser.parse(), gitinforesolver)) 172 | 173 | def test_gitpostapplypatch(self): 174 | parser = inputparser.fromphase('post-applypatch') 175 | assert(isinstance(parser, gitpostapplypatchinputparser)) 176 | assert(isinstance(parser.parse(), gitinforesolver)) 177 | 178 | def test_cascade_hook_type(self): 179 | """If you write a hook for hg and git and they don't 180 | have the same hook phase available, you can specify what 181 | phase you want for each SCM 182 | 183 | The following hook will run at the update phase for 184 | hg repos and post-applypatch for git repos 185 | A hg repo. If both are available the order of the tuple 186 | is honored. 187 | """ 188 | os.environ["HG_NODE"] = "a"*40 189 | parser = inputparser.fromphases((('hg', 'update'), 190 | ('git', 'post-applypatch'))) 191 | assert(isinstance(parser, hgupdateinputparser)) 192 | del os.environ["HG_NODE"] 193 | parser = inputparser.fromphases((('hg', 'update'), 194 | ('git', 'post-applypatch'))) 195 | assert(isinstance(parser, gitpostapplypatchinputparser)) 196 | 197 | def test_cascade_hook_notfound(self): 198 | os.environ["HG_NODE"] = "a"*40 199 | with self.assertRaises(NotImplementedError): 200 | parser = inputparser.fromphases((('hg', 'post-applypatch'), 201 | ('git', 'blah'))) 202 | 203 | def test_gitpreparecommitmsg(self): 204 | # possible options 205 | # message, template, merge, squash, commit 206 | cases = ( 207 | # valid arg, list of args 208 | (True, 'commitlogmsg', 'message'), 209 | (False, 'commitlogmsg', 'message', 'a'*40), 210 | (True, 'commitlogmsg', 'template'), 211 | (False, 'commitlogmsg', 'template', 'a'*40), 212 | (True, 'commitlogmsg', 'merge'), 213 | (False, 'commitlogmsg', 'merge', 'a'*40), 214 | (True, 'commitlogmsg', 'squash'), 215 | (False, 'commitlogmsg', 'squash', 'a'*40), 216 | (False, 'commitlogmsg', 'commit'), 217 | (True, 'commitlogmsg', 'commit', 'a'*40), 218 | (False, 'commitlogmsg', 'illegal'), 219 | ) 220 | 221 | for case in cases: 222 | valid = case[0] 223 | args = case[1:] 224 | sys.argv = ['program.name'] + list(args) 225 | parser = inputparser.fromphase('prepare-commit-msg') 226 | assert(isinstance(parser, gitpreparecommitmsginputparser)) 227 | if valid: 228 | parser.parse() # not exception 229 | else: 230 | with self.assertRaises(ValueError): 231 | parser.parse() 232 | 233 | def test_gitcommitmsg(self): 234 | sys.argv = ['program.name', 'messagefile'] 235 | revdata = inputparser.fromphase('commit-msg').parse() 236 | assert(revdata.messagefile == 'messagefile') 237 | 238 | def test_gitpostcommit(self): 239 | parser = inputparser.fromphase('post-commit') 240 | assert(isinstance(parser, gitpostcommitinputparser)) 241 | 242 | def test_gitprerebase(self): 243 | sys.argv = ['program.name', 'upstream', 'rebased'] 244 | parser = inputparser.fromphase('pre-rebase') 245 | assert(isinstance(parser, gitprerebaseinputparser)) 246 | revdata = parser.parse() 247 | assert(revdata.upstream == 'upstream') 248 | assert(revdata.rebased == 'rebased') 249 | 250 | def test_gitprerebasecurrentbranch(self): 251 | sys.argv = ['program.name', 'upstream'] 252 | parser = inputparser.fromphase('pre-rebase') 253 | assert(isinstance(parser, gitprerebaseinputparser)) 254 | revdata = parser.parse() 255 | assert(revdata.upstream == 'upstream') 256 | assert(revdata.rebased is None) 257 | 258 | def test_gitpreautogc(self): 259 | parser = inputparser.fromphase('pre-auto-gc') 260 | assert(isinstance(parser, gitpreautogcinputparser)) 261 | 262 | def test_gitprereceive(self): 263 | revs = (('a'*40, 'b'*40, 'refs/heads/master'), 264 | ('c'*40, 'd'*40, 'refs/heads/stable')) 265 | dummyinput = [' '.join(r)+'\n' for r in revs] 266 | hooklib_input.readlines = MagicMock(return_value=dummyinput) 267 | revdata = inputparser.fromphase('pre-receive').parse() 268 | assert(revdata.receivedrevs == revs) 269 | 270 | def test_gitpostreceive(self): 271 | revs = (('a'*40, 'b'*40, 'refs/heads/master'), 272 | ('c'*40, 'd'*40, 'refs/heads/stable')) 273 | dummyinput = [' '.join(r)+'\n' for r in revs] 274 | hooklib_input.readlines = MagicMock(return_value=dummyinput) 275 | revdata = inputparser.fromphase('post-receive').parse() 276 | assert(revdata.receivedrevs == revs) 277 | 278 | def test_gitprepush(self): 279 | revs = (('refs/heads/master', 'a'*40, 'refs/heads/foreign', 'b'*40), 280 | ('refs/heads/master', 'a'*40, 'refs/heads/foreign', '0'*40)) 281 | dummyinput = [' '.join(r)+'\n' for r in revs] 282 | hooklib_input.readlines = MagicMock(return_value=dummyinput) 283 | revdata = inputparser.fromphase('pre-push').parse() 284 | assert(revdata.revstobepushed == revs) 285 | 286 | # TODO post-checkout, post-merge, push-to-checkout, post-rewrite 287 | # TODO add documentation for what is available for each kind of hooks 288 | # see https://git-scm.com/docs/githooks 289 | 290 | if __name__ == '__main__': 291 | unittest.main() 292 | -------------------------------------------------------------------------------- /integrationtests/test-git.t: -------------------------------------------------------------------------------- 1 | Write a basic git update hook that authorizes only pushing to master 2 | $ mkdir server 3 | $ cd server 4 | $ serverpath=$(pwd) 5 | $ git init --bare . 6 | Initialized empty Git repository in $TESTTMP/server/ 7 | 8 | $ cat <> hooks/update 9 | > #!/usr/bin/python 10 | > from hooklib import basehook, runhooks 11 | > ERROR_MSG = "you can only push master on this repo" 12 | > class mastergatinghook(basehook): 13 | > def check(self, log, revdata): 14 | > check = revdata.refname == 'refs/heads/master' 15 | > if not check: 16 | > log.write(ERROR_MSG) 17 | > return check 18 | > 19 | > runhooks('update', hooks=[mastergatinghook]) 20 | > EOF 21 | 22 | $ chmod +x hooks/update 23 | $ cd .. 24 | $ git init client 25 | Initialized empty Git repository in $TESTTMP/client/.git/ 26 | $ cd client 27 | $ clientpath=$(pwd) 28 | $ git remote add origin file:///$serverpath 29 | $ echo "a" > a 30 | $ git add a 31 | $ git commit -am "Adding a" &> /dev/null 32 | $ git push origin master 33 | To file:///$TESTTMP/server 34 | * [new branch] master -> master 35 | $ git push origin master:stable 36 | remote: you can only push master on this repo 37 | remote: error: hook declined to update refs/heads/stable 38 | To file:///$TESTTMP/server 39 | ! [remote rejected] master -> stable (hook declined) 40 | error: failed to push some refs to 'file:///$TESTTMP/server' 41 | [1] 42 | 43 | Add a post-update hook that prints the refs that are pushed 44 | $ cat <> ../server/hooks/post-update 45 | > #!/usr/bin/python 46 | > from hooklib import basehook, runhooks 47 | > class printinghook(basehook): 48 | > def check(self, log, revdata): 49 | > log.write("New ref: "+'\,'.join(revdata.revs)) 50 | > return True 51 | > 52 | > runhooks('post-update', hooks=[printinghook]) 53 | > EOF 54 | $ chmod +x ../server/hooks/post-update 55 | $ echo "x" > a 56 | $ git add a 57 | $ git commit -am "Adding x" &> /dev/null 58 | 59 | $ git push origin master 60 | remote: New ref: refs/heads/master 61 | To file:///$TESTTMP/server 62 | *..* master -> master (glob) 63 | 64 | Parallel hook execution 65 | $ cat <> ../server/hooks/post-update 66 | > #!/usr/bin/python 67 | > from hooklib import basehook, runhooks 68 | > import time 69 | > class slowhook(basehook): 70 | > def check(self, log, revdata): 71 | > time.sleep(0.1) 72 | > return True 73 | > 74 | > runhooks('post-update', hooks=[slowhook]*200, parallel=True) 75 | > EOF 76 | $ chmod +x ../server/hooks/post-update 77 | $ echo "y" > a 78 | $ git add a 79 | $ git commit -am "Adding y" &> /dev/null 80 | 81 | $ git push origin master 82 | remote: New ref: refs/heads/master 83 | To file:///$TESTTMP/server 84 | *..* master -> master (glob) 85 | 86 | A hook to check that each commit message contains a message 87 | 88 | $ cat <> ../server/hooks/update 89 | > #!/usr/bin/python 90 | > from hooklib import basehook, runhooks 91 | > ERROR_MSG = "you can only push commit with 'secretmessage' in the description" 92 | > class messagegatinghook(basehook): 93 | > def check(self, log, revdata): 94 | > for rev in revdata.revs: 95 | > if not 'secretmessage' in revdata.commitmessagefor(rev): 96 | > log.write(ERROR_MSG) 97 | > return False 98 | > return True 99 | > 100 | > runhooks('update', hooks=[messagegatinghook]) 101 | > EOF 102 | $ echo "z" > a 103 | $ git add a 104 | $ git commit -am "Hello world" &> /dev/null 105 | 106 | $ git push origin master 107 | remote: you can only push commit with 'secretmessage' in the description 108 | remote: error: hook declined to update refs/heads/master 109 | To file:///$TESTTMP/server 110 | ! [remote rejected] master -> master (hook declined) 111 | error: failed to push some refs to 'file:///$TESTTMP/server' 112 | [1] 113 | $ git commit --amend -m "Hello secretmessage world" &>/dev/null 114 | $ git push origin master 115 | remote: New ref: refs/heads/master 116 | To file:///$TESTTMP/server 117 | *..* master -> master (glob) 118 | 119 | All the hooks! 120 | $ for hook in "applypatch-msg" "pre-applypatch" "post-applypatch" "pre-commit" "prepare-commit-msg" "commit-msg" "post-commit" "pre-rebase" "pre-push" "pre-receive" "update" "post-receive" "post-update"; do 121 | > for side in "server" "client"; do 122 | > if [[ $side == "server" ]]; then 123 | > hpath="$serverpath/hooks/$hook" 124 | > else 125 | > hpath="$clientpath/.git/hooks/$hook" 126 | > fi; 127 | > rm -f $hpath 128 | > mkdir -p $(dirname $hpath) 129 | > echo "#!/usr/bin/python" >> $hpath 130 | > echo "from hooklib import basehook, runhooks" >> $hpath 131 | > echo "class loghook(basehook):" >> $hpath 132 | > echo " def check(self, log, revdata):" >> $hpath 133 | > echo " with open('$serverpath/res','a') as k:" >> $hpath 134 | > echo " avalaiablevars = vars(revdata)" >> $hpath 135 | > echo " del avalaiablevars['_revs']" >> $hpath 136 | > echo " k.write('$side: $hook %s head %s\\\n' %(sorted(avalaiablevars), revdata.head))" >> $hpath 137 | > echo " return True" >> $hpath 138 | > echo "runhooks('$hook', hooks=[loghook])" >> $hpath 139 | > chmod +x $hpath 140 | > done; 141 | > done; 142 | $ echo "u" > a 143 | $ git add a 144 | $ echo "op: before first commit" >> $serverpath/res 145 | $ git commit -am "Hello world" &> /dev/null 146 | $ echo "op: after first commit" >> $serverpath/res 147 | $ echo "t" > b 148 | $ git add b 149 | $ echo "op: before second commit" >> $serverpath/res 150 | $ git commit -a --amend &>/dev/null 151 | [1] 152 | $ echo "op: after second commit" >> $serverpath/res 153 | $ echo "op: before push" >> $serverpath/res 154 | $ git push origin master 155 | To file:///$TESTTMP/server 156 | *..* master -> master (glob) 157 | $ echo "op: after push" >> $serverpath/res 158 | $ cat $serverpath/res 159 | op: before first commit 160 | client: pre-commit ['reporoot'] head * (glob) 161 | client: prepare-commit-msg ['messagefile', 'mode', 'reporoot', 'sha'] head * (glob) 162 | client: commit-msg ['messagefile', 'reporoot'] head * (glob) 163 | client: post-commit ['reporoot'] head * (glob) 164 | op: after first commit 165 | op: before second commit 166 | client: pre-commit ['reporoot'] head * (glob) 167 | client: prepare-commit-msg ['messagefile', 'mode', 'reporoot', 'sha'] head * (glob) 168 | op: after second commit 169 | op: before push 170 | client: pre-push ['reporoot', 'revstobepushed'] head * (glob) 171 | server: pre-receive ['receivedrevs', 'reporoot'] head * (glob) 172 | server: update ['new', 'old', 'refname', 'reporoot'] head * (glob) 173 | server: post-receive ['receivedrevs', 'reporoot'] head * (glob) 174 | server: post-update ['reporoot'] head * (glob) 175 | op: after push 176 | -------------------------------------------------------------------------------- /integrationtests/test-hg.t: -------------------------------------------------------------------------------- 1 | Write a basic hg update hook that gates push on condition on commit message 2 | $ mkdir server 3 | $ cd server 4 | $ serverpath=$(pwd) 5 | $ hg init 6 | $ mkdir .hg/hooks 7 | $ cat <> .hg/hooks/update 8 | > #!/usr/bin/python 9 | > from hooklib import basehook, runhooks 10 | > 11 | > class commmitmsggatinghook(basehook): 12 | > def check(self, log, revdata): 13 | > for rev in revdata.revs: 14 | > if not 'secretmessage' in revdata.commitmessagefor(rev): 15 | > log.write("commit message must contain 'secretmessage'") 16 | > return False 17 | > return True 18 | > 19 | > runhooks('update', hooks=[commmitmsggatinghook]) 20 | > EOF 21 | 22 | $ cat <>$HGRCPATH 23 | > [hooks] 24 | > EOF 25 | $ echo "commit=${serverpath}/.hg/hooks/update" >> $HGRCPATH 26 | 27 | $ chmod +x .hg/hooks/update 28 | $ cd .. 29 | $ hg init client 30 | $ cd client 31 | $ echo "a" > a 32 | $ hg add a 33 | $ hg commit -m "Adding a" 34 | commit message must contain 'secretmessage' 35 | warning: commit hook exited with status 1 36 | $ hg push -r. file://${serverpath} 37 | pushing to file://$TESTTMP/server 38 | searching for changes 39 | adding changesets 40 | adding manifests 41 | adding file changes 42 | added 1 changesets with 1 changes to 1 files 43 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | from setuptools import setup 5 | 6 | extra = {} 7 | if sys.version_info >= (3,): 8 | extra['use_2to3'] = True 9 | 10 | setup( 11 | name="hooklib", 12 | version="0.2.2", 13 | author="Laurent Charignon", 14 | author_email="l.charignon@gmail.com", 15 | description="Hook helper library in python", 16 | keywords="hooks", 17 | license='Apache 2.0', 18 | py_modules=['hooklib', 'hooklib_git', 'hooklib_input', 'hooklib_hg'], 19 | **extra 20 | ) 21 | --------------------------------------------------------------------------------