hgext: start building a library for simple hooks
authorJoerg Sonnenberger <joerg@bec.de>
Sat, 07 Sep 2019 14:50:39 +0200
changeset 44413 4cabeea6d214
parent 44412 edc8504bc26b
child 44416 8ac5726d695d
hgext: start building a library for simple hooks Many workflows depend on hooks to enforce certain policies, e.g. to prevent forced pushes. The Mercurial Guide includes some cases and Google can help finding others, but it can save users a lot of time if hg itself has a couple of examples for further customization. Differential Revision: https://phab.mercurial-scm.org/D6825
contrib/import-checker.py
hgext/hooklib/__init__.py
hgext/hooklib/changeset_obsoleted.py
hgext/hooklib/changeset_published.py
hgext/hooklib/enforce_draft_commits.py
hgext/hooklib/reject_merge_commits.py
hgext/hooklib/reject_new_heads.py
setup.py
tests/test-hooklib-changeset_obsoleted.t
tests/test-hooklib-changeset_published.t
tests/test-hooklib-enforce_draft_commits.t
tests/test-hooklib-reject_merge_commits.t
tests/test-hooklib-reject_new_heads.t
--- a/contrib/import-checker.py	Tue Feb 25 20:27:39 2020 -0500
+++ b/contrib/import-checker.py	Sat Sep 07 14:50:39 2019 +0200
@@ -392,9 +392,10 @@
                     modnotfound = True
                     continue
                 yield found[1]
-            if modnotfound:
+            if modnotfound and dottedpath != modulename:
                 # "dottedpath" is a package, but imported because of non-module
                 # lookup
+                # specifically allow "from . import foo" from __init__.py
                 yield dottedpath
 
 
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/hgext/hooklib/__init__.py	Sat Sep 07 14:50:39 2019 +0200
@@ -0,0 +1,26 @@
+"""collection of simple hooks for common tasks (EXPERIMENTAL)
+
+This extension provides a number of simple hooks to handle issues
+commonly found in repositories with many contributors:
+- email notification when changesets move from draft to public phase
+- email notification when changesets are obsoleted
+- enforcement of draft phase for all incoming changesets
+- enforcement of a no-branch-merge policy
+- enforcement of a no-multiple-heads policy
+
+The implementation of the hooks is subject to change, e.g. whether to
+implement them as individual hooks or merge them into the notify
+extension as option. The functionality itself is planned to be supported
+long-term.
+"""
+from __future__ import absolute_import
+from . import (
+    changeset_obsoleted,
+    changeset_published,
+)
+
+# configtable is only picked up from the "top-level" module of the extension,
+# so expand it here to ensure all items are properly loaded
+configtable = {}
+configtable.update(changeset_published.configtable)
+configtable.update(changeset_obsoleted.configtable)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/hgext/hooklib/changeset_obsoleted.py	Sat Sep 07 14:50:39 2019 +0200
@@ -0,0 +1,131 @@
+# Copyright 2020 Joerg Sonnenberger <joerg@bec.de>
+#
+# This software may be used and distributed according to the terms of the
+# GNU General Public License version 2 or any later version.
+"""changeset_obsoleted is a hook to send a mail when an
+existing draft changeset is obsoleted by an obsmarker without successor.
+
+Correct message threading requires the same messageidseed to be used for both
+the original notification and the new mail.
+
+Usage:
+  [notify]
+  messageidseed = myseed
+
+  [hooks]
+  pretxnclose.changeset_obsoleted = \
+    python:hgext.hooklib.changeset_obsoleted.hook
+"""
+
+from __future__ import absolute_import
+
+import email.errors as emailerrors
+import email.utils as emailutils
+
+from mercurial.i18n import _
+from mercurial import (
+    encoding,
+    error,
+    logcmdutil,
+    mail,
+    obsutil,
+    pycompat,
+    registrar,
+)
+from mercurial.utils import dateutil
+from .. import notify
+
+configtable = {}
+configitem = registrar.configitem(configtable)
+
+configitem(
+    b'notify_obsoleted', b'domain', default=None,
+)
+configitem(
+    b'notify_obsoleted', b'messageidseed', default=None,
+)
+configitem(
+    b'notify_obsoleted',
+    b'template',
+    default=b'''Subject: changeset abandoned
+
+This changeset has been abandoned.
+''',
+)
+
+
+def _report_commit(ui, repo, ctx):
+    domain = ui.config(b'notify_obsoleted', b'domain') or ui.config(
+        b'notify', b'domain'
+    )
+    messageidseed = ui.config(
+        b'notify_obsoleted', b'messageidseed'
+    ) or ui.config(b'notify', b'messageidseed')
+    template = ui.config(b'notify_obsoleted', b'template')
+    spec = logcmdutil.templatespec(template, None)
+    templater = logcmdutil.changesettemplater(ui, repo, spec)
+    ui.pushbuffer()
+    n = notify.notifier(ui, repo, b'incoming')
+
+    subs = set()
+    for sub, spec in n.subs:
+        if spec is None:
+            subs.add(sub)
+            continue
+        revs = repo.revs(b'%r and %d:', spec, ctx.rev())
+        if len(revs):
+            subs.add(sub)
+            continue
+    if len(subs) == 0:
+        ui.debug(
+            b'notify_obsoleted: no subscribers to selected repo and revset\n'
+        )
+        return
+
+    templater.show(
+        ctx,
+        changes=ctx.changeset(),
+        baseurl=ui.config(b'web', b'baseurl'),
+        root=repo.root,
+        webroot=n.root,
+    )
+    data = ui.popbuffer()
+
+    try:
+        msg = mail.parsebytes(data)
+    except emailerrors.MessageParseError as inst:
+        raise error.Abort(inst)
+
+    msg['In-reply-to'] = notify.messageid(ctx, domain, messageidseed)
+    msg['Message-Id'] = notify.messageid(
+        ctx, domain, messageidseed + b'-obsoleted'
+    )
+    msg['Date'] = encoding.strfromlocal(
+        dateutil.datestr(format=b"%a, %d %b %Y %H:%M:%S %1%2")
+    )
+    if not msg['From']:
+        sender = ui.config(b'email', b'from') or ui.username()
+        if b'@' not in sender or b'@localhost' in sender:
+            sender = n.fixmail(sender)
+        msg['From'] = mail.addressencode(ui, sender, n.charsets, n.test)
+    msg['To'] = ', '.join(sorted(subs))
+
+    msgtext = msg.as_bytes() if pycompat.ispy3 else msg.as_string()
+    if ui.configbool(b'notify', b'test'):
+        ui.write(msgtext)
+        if not msgtext.endswith(b'\n'):
+            ui.write(b'\n')
+    else:
+        ui.status(_(b'notify_obsoleted: sending mail for %d\n') % ctx.rev())
+        mail.sendmail(
+            ui, emailutils.parseaddr(msg['From'])[1], subs, msgtext, mbox=n.mbox
+        )
+
+
+def hook(ui, repo, hooktype, node=None, **kwargs):
+    if hooktype != b"pretxnclose":
+        raise error.Abort(
+            _(b'Unsupported hook type %r') % pycompat.bytestr(hooktype)
+        )
+    for rev in obsutil.getobsoleted(repo, repo.currenttransaction()):
+        _report_commit(ui, repo, repo.unfiltered()[rev])
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/hgext/hooklib/changeset_published.py	Sat Sep 07 14:50:39 2019 +0200
@@ -0,0 +1,131 @@
+# Copyright 2020 Joerg Sonnenberger <joerg@bec.de>
+#
+# This software may be used and distributed according to the terms of the
+# GNU General Public License version 2 or any later version.
+"""changeset_published is a hook to send a mail when an
+existing draft changeset is moved to the public phase.
+
+Correct message threading requires the same messageidseed to be used for both
+the original notification and the new mail.
+
+Usage:
+  [notify]
+  messageidseed = myseed
+
+  [hooks]
+  txnclose-phase.changeset_published = \
+    python:hgext.hooklib.changeset_published.hook
+"""
+
+from __future__ import absolute_import
+
+import email.errors as emailerrors
+import email.utils as emailutils
+
+from mercurial.i18n import _
+from mercurial import (
+    encoding,
+    error,
+    logcmdutil,
+    mail,
+    pycompat,
+    registrar,
+)
+from mercurial.utils import dateutil
+from .. import notify
+
+configtable = {}
+configitem = registrar.configitem(configtable)
+
+configitem(
+    b'notify_published', b'domain', default=None,
+)
+configitem(
+    b'notify_published', b'messageidseed', default=None,
+)
+configitem(
+    b'notify_published',
+    b'template',
+    default=b'''Subject: changeset published
+
+This changeset has been published.
+''',
+)
+
+
+def _report_commit(ui, repo, ctx):
+    domain = ui.config(b'notify_published', b'domain') or ui.config(
+        b'notify', b'domain'
+    )
+    messageidseed = ui.config(
+        b'notify_published', b'messageidseed'
+    ) or ui.config(b'notify', b'messageidseed')
+    template = ui.config(b'notify_published', b'template')
+    spec = logcmdutil.templatespec(template, None)
+    templater = logcmdutil.changesettemplater(ui, repo, spec)
+    ui.pushbuffer()
+    n = notify.notifier(ui, repo, b'incoming')
+
+    subs = set()
+    for sub, spec in n.subs:
+        if spec is None:
+            subs.add(sub)
+            continue
+        revs = repo.revs(b'%r and %d:', spec, ctx.rev())
+        if len(revs):
+            subs.add(sub)
+            continue
+    if len(subs) == 0:
+        ui.debug(
+            b'notify_published: no subscribers to selected repo and revset\n'
+        )
+        return
+
+    templater.show(
+        ctx,
+        changes=ctx.changeset(),
+        baseurl=ui.config(b'web', b'baseurl'),
+        root=repo.root,
+        webroot=n.root,
+    )
+    data = ui.popbuffer()
+
+    try:
+        msg = mail.parsebytes(data)
+    except emailerrors.MessageParseError as inst:
+        raise error.Abort(inst)
+
+    msg['In-reply-to'] = notify.messageid(ctx, domain, messageidseed)
+    msg['Message-Id'] = notify.messageid(
+        ctx, domain, messageidseed + b'-published'
+    )
+    msg['Date'] = encoding.strfromlocal(
+        dateutil.datestr(format=b"%a, %d %b %Y %H:%M:%S %1%2")
+    )
+    if not msg['From']:
+        sender = ui.config(b'email', b'from') or ui.username()
+        if b'@' not in sender or b'@localhost' in sender:
+            sender = n.fixmail(sender)
+        msg['From'] = mail.addressencode(ui, sender, n.charsets, n.test)
+    msg['To'] = ', '.join(sorted(subs))
+
+    msgtext = msg.as_bytes() if pycompat.ispy3 else msg.as_string()
+    if ui.configbool(b'notify', b'test'):
+        ui.write(msgtext)
+        if not msgtext.endswith(b'\n'):
+            ui.write(b'\n')
+    else:
+        ui.status(_(b'notify_published: sending mail for %d\n') % ctx.rev())
+        mail.sendmail(
+            ui, emailutils.parseaddr(msg['From'])[1], subs, msgtext, mbox=n.mbox
+        )
+
+
+def hook(ui, repo, hooktype, node=None, **kwargs):
+    if hooktype != b"txnclose-phase":
+        raise error.Abort(
+            _(b'Unsupported hook type %r') % pycompat.bytestr(hooktype)
+        )
+    ctx = repo.unfiltered()[node]
+    if kwargs['oldphase'] == b'draft' and kwargs['phase'] == b'public':
+        _report_commit(ui, repo, ctx)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/hgext/hooklib/enforce_draft_commits.py	Sat Sep 07 14:50:39 2019 +0200
@@ -0,0 +1,45 @@
+# Copyright 2020 Joerg Sonnenberger <joerg@bec.de>
+#
+# This software may be used and distributed according to the terms of the
+# GNU General Public License version 2 or any later version.
+
+"""enforce_draft_commits us a hook to ensure that all new changesets are
+in the draft phase. This allows enforcing policies for work-in-progress
+changes in overlay repositories, i.e. a shared hidden repositories with
+different views for work-in-progress code and public history.
+
+Usage:
+  [hooks]
+  pretxnclose-phase.enforce_draft_commits = \
+    python:hgext.hooklib.enforce_draft_commits.hook
+"""
+
+from __future__ import absolute_import
+
+from mercurial.i18n import _
+from mercurial import (
+    error,
+    pycompat,
+)
+
+
+def hook(ui, repo, hooktype, node=None, **kwargs):
+    if hooktype != b"pretxnclose-phase":
+        raise error.Abort(
+            _(b'Unsupported hook type %r') % pycompat.bytestr(hooktype)
+        )
+    ctx = repo.unfiltered()[node]
+    if kwargs['oldphase']:
+        raise error.Abort(
+            _(b'Phase change from %r to %r for %s rejected')
+            % (
+                pycompat.bytestr(kwargs['oldphase']),
+                pycompat.bytestr(kwargs['phase']),
+                ctx,
+            )
+        )
+    elif kwargs['phase'] != b'draft':
+        raise error.Abort(
+            _(b'New changeset %s in phase %r rejected')
+            % (ctx, pycompat.bytestr(kwargs['phase']))
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/hgext/hooklib/reject_merge_commits.py	Sat Sep 07 14:50:39 2019 +0200
@@ -0,0 +1,45 @@
+# Copyright 2020 Joerg Sonnenberger <joerg@bec.de>
+#
+# This software may be used and distributed according to the terms of the
+# GNU General Public License version 2 or any later version.
+
+"""reject_merge_commits is a hook to check new changesets for merge commits.
+Merge commits are allowed only between different branches, i.e. merging
+a feature branch into the main development branch. This can be used to
+enforce policies for linear commit histories.
+
+Usage:
+  [hooks]
+  pretxnchangegroup.reject_merge_commits = \
+    python:hgext.hooklib.reject_merge_commits.hook
+"""
+
+from __future__ import absolute_import
+
+from mercurial.i18n import _
+from mercurial import (
+    error,
+    pycompat,
+)
+
+
+def hook(ui, repo, hooktype, node=None, **kwargs):
+    if hooktype != b"pretxnchangegroup":
+        raise error.Abort(
+            _(b'Unsupported hook type %r') % pycompat.bytestr(hooktype)
+        )
+
+    ctx = repo.unfiltered()[node]
+    for rev in repo.changelog.revs(start=ctx.rev()):
+        rev = repo[rev]
+        parents = rev.parents()
+        if len(parents) < 2:
+            continue
+        if all(repo[p].branch() == rev.branch() for p in parents):
+            raise error.Abort(
+                _(
+                    b'%s rejected as merge on the same branch. '
+                    b'Please consider rebase.'
+                )
+                % rev
+            )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/hgext/hooklib/reject_new_heads.py	Sat Sep 07 14:50:39 2019 +0200
@@ -0,0 +1,41 @@
+# Copyright 2020 Joerg Sonnenberger <joerg@bec.de>
+#
+# This software may be used and distributed according to the terms of the
+# GNU General Public License version 2 or any later version.
+
+"""reject_new_heads is a hook to check that branches touched by new changesets
+have at most one open head. It can be used to enforce policies for
+merge-before-push or rebase-before-push. It does not handle pre-existing
+hydras.
+
+Usage:
+  [hooks]
+  pretxnclose.reject_new_heads = \
+    python:hgext.hooklib.reject_new_heads.hook
+"""
+
+from __future__ import absolute_import
+
+from mercurial.i18n import _
+from mercurial import (
+    error,
+    pycompat,
+)
+
+
+def hook(ui, repo, hooktype, node=None, **kwargs):
+    if hooktype != b"pretxnclose":
+        raise error.Abort(
+            _(b'Unsupported hook type %r') % pycompat.bytestr(hooktype)
+        )
+    ctx = repo.unfiltered()[node]
+    branches = set()
+    for rev in repo.changelog.revs(start=ctx.rev()):
+        rev = repo[rev]
+        branches.add(rev.branch())
+    for branch in branches:
+        if len(repo.revs("head() and not closed() and branch(%s)", branch)) > 1:
+            raise error.Abort(
+                _(b'Changes on branch %r resulted in multiple heads')
+                % pycompat.bytestr(branch)
+            )
--- a/setup.py	Tue Feb 25 20:27:39 2020 -0500
+++ b/setup.py	Sat Sep 07 14:50:39 2019 +0200
@@ -1210,6 +1210,7 @@
     'hgext.fastannotate',
     'hgext.fsmonitor.pywatchman',
     'hgext.highlight',
+    'hgext.hooklib',
     'hgext.infinitepush',
     'hgext.largefiles',
     'hgext.lfs',
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/test-hooklib-changeset_obsoleted.t	Sat Sep 07 14:50:39 2019 +0200
@@ -0,0 +1,84 @@
+  $ cat <<EOF >> $HGRCPATH
+  > [experimental]
+  > evolution = true
+  > 
+  > [extensions]
+  > notify =
+  > hooklib =
+  > 
+  > [phases]
+  > publish = False
+  > 
+  > [notify]
+  > sources = pull
+  > diffstat = False
+  > messageidseed = example
+  > domain = example.com
+  > 
+  > [reposubs]
+  > * = baz
+  > EOF
+  $ hg init a
+  $ hg --cwd a debugbuilddag +2
+  $ hg init b
+  $ cat <<EOF >> b/.hg/hgrc
+  > [hooks]
+  > incoming.notify = python:hgext.notify.hook
+  > pretxnclose.changeset_obsoleted = python:hgext.hooklib.changeset_obsoleted.hook
+  > EOF
+  $ hg --cwd b pull ../a | "$PYTHON" $TESTDIR/unwrap-message-id.py
+  pulling from ../a
+  requesting all changes
+  adding changesets
+  adding manifests
+  adding file changes
+  added 2 changesets with 0 changes to 0 files
+  new changesets 1ea73414a91b:66f7d451a68b (2 drafts)
+  MIME-Version: 1.0
+  Content-Type: text/plain; charset="us-ascii"
+  Content-Transfer-Encoding: 7bit
+  Date: * (glob)
+  Subject: changeset in * (glob)
+  From: debugbuilddag@example.com
+  X-Hg-Notification: changeset 1ea73414a91b
+  Message-Id: <hg.81c297828fd2d5afaadf2775a6a71b74143b6451dfaac09fac939e9107a50d01@example.com>
+  To: baz@example.com
+  
+  changeset 1ea73414a91b in $TESTTMP/b
+  details: $TESTTMP/b?cmd=changeset;node=1ea73414a91b
+  description:
+  	r0
+  MIME-Version: 1.0
+  Content-Type: text/plain; charset="us-ascii"
+  Content-Transfer-Encoding: 7bit
+  Date: * (glob)
+  Subject: changeset in * (glob)
+  From: debugbuilddag@example.com
+  X-Hg-Notification: changeset 66f7d451a68b
+  Message-Id: <hg.364d03da7dc13829eb779a805be7e37f54f572e9afcea7d2626856a794d3e8f3@example.com>
+  To: baz@example.com
+  
+  changeset 66f7d451a68b in $TESTTMP/b
+  details: $TESTTMP/b?cmd=changeset;node=66f7d451a68b
+  description:
+  	r1
+  (run 'hg update' to get a working copy)
+  $ hg --cwd a debugobsolete 1ea73414a91b0920940797d8fc6a11e447f8ea1e
+  1 new obsolescence markers
+  obsoleted 1 changesets
+  1 new orphan changesets
+  $ hg --cwd a push ../b --hidden | "$PYTHON" $TESTDIR/unwrap-message-id.py
+  1 new orphan changesets
+  pushing to ../b
+  searching for changes
+  no changes found
+  Subject: changeset abandoned
+  In-reply-to: <hg.81c297828fd2d5afaadf2775a6a71b74143b6451dfaac09fac939e9107a50d01@example.com>
+  Message-Id: <hg.d6329e9481594f0f3c8a84362b3511318bfbce50748ab1123f909eb6fbcab018@example.com>
+  Date: * (glob)
+  From: test@example.com
+  To: baz@example.com
+  
+  This changeset has been abandoned.
+  1 new obsolescence markers
+  obsoleted 1 changesets
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/test-hooklib-changeset_published.t	Sat Sep 07 14:50:39 2019 +0200
@@ -0,0 +1,84 @@
+  $ cat <<EOF >> $HGRCPATH
+  > [extensions]
+  > notify =
+  > hooklib =
+  > 
+  > [phases]
+  > publish = False
+  > 
+  > [notify]
+  > sources = pull
+  > diffstat = False
+  > messageidseed = example
+  > domain = example.com
+  > 
+  > [reposubs]
+  > * = baz
+  > EOF
+  $ hg init a
+  $ hg --cwd a debugbuilddag .
+  $ hg init b
+  $ cat <<EOF >> b/.hg/hgrc
+  > [hooks]
+  > incoming.notify = python:hgext.notify.hook
+  > txnclose-phase.changeset_published = python:hgext.hooklib.changeset_published.hook
+  > EOF
+  $ hg --cwd b pull ../a | "$PYTHON" $TESTDIR/unwrap-message-id.py
+  pulling from ../a
+  requesting all changes
+  adding changesets
+  adding manifests
+  adding file changes
+  added 1 changesets with 0 changes to 0 files
+  new changesets 1ea73414a91b (1 drafts)
+  MIME-Version: 1.0
+  Content-Type: text/plain; charset="us-ascii"
+  Content-Transfer-Encoding: 7bit
+  Date: * (glob)
+  Subject: changeset in * (glob)
+  From: debugbuilddag@example.com
+  X-Hg-Notification: changeset 1ea73414a91b
+  Message-Id: <hg.81c297828fd2d5afaadf2775a6a71b74143b6451dfaac09fac939e9107a50d01@example.com>
+  To: baz@example.com
+  
+  changeset 1ea73414a91b in $TESTTMP/b
+  details: $TESTTMP/b?cmd=changeset;node=1ea73414a91b
+  description:
+  	r0
+  (run 'hg update' to get a working copy)
+  $ hg --cwd a phase --public 0
+  $ hg --cwd b pull ../a | "$PYTHON" $TESTDIR/unwrap-message-id.py
+  pulling from ../a
+  searching for changes
+  no changes found
+  1 local changesets published
+  Subject: changeset published
+  In-reply-to: <hg.81c297828fd2d5afaadf2775a6a71b74143b6451dfaac09fac939e9107a50d01@example.com>
+  Message-Id: <hg.2ec19bbddee5b542442bf5e1aed97bf706afff6aa765629883fbd1f4edd6fcb0@example.com>
+  Date: * (glob)
+  From: test@example.com
+  To: baz@example.com
+  
+  This changeset has been published.
+  $ hg --cwd b phase --force --draft 0
+  $ cat <<EOF >> b/.hg/hgrc
+  > [notify_published]
+  > messageidseed = example2
+  > domain = alt.example.com
+  > template = Subject: changeset published
+  >            From: hg@example.com\n
+  >            This draft changeset has been published.\n
+  > EOF
+  $ hg --cwd b pull ../a | "$PYTHON" $TESTDIR/unwrap-message-id.py
+  pulling from ../a
+  searching for changes
+  no changes found
+  1 local changesets published
+  Subject: changeset published
+  From: hg@example.com
+  In-reply-to: <hg.e3381dc41c051215e50b1c166a72949d0fff99609eb373420bcb763af80ef230@alt.example.com>
+  Message-Id: <hg.c927f3d324e645a4245bfed20b0efb5b9582999d6be9bef45a37e7ec21208b24@alt.example.com>
+  Date: * (glob)
+  To: baz@example.com
+  
+  This draft changeset has been published.
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/test-hooklib-enforce_draft_commits.t	Sat Sep 07 14:50:39 2019 +0200
@@ -0,0 +1,45 @@
+  $ cat <<EOF >> $HGRCPATH
+  > [extensions]
+  > hooklib =
+  > 
+  > [phases]
+  > publish = False
+  > EOF
+  $ hg init a
+  $ hg --cwd a debugbuilddag .
+  $ hg --cwd a phase --public 0
+  $ hg init b
+  $ cat <<EOF >> b/.hg/hgrc
+  > [hooks]
+  > pretxnclose-phase.enforce_draft_commits = \
+  >   python:hgext.hooklib.enforce_draft_commits.hook
+  > EOF
+  $ hg --cwd b pull ../a
+  pulling from ../a
+  requesting all changes
+  adding changesets
+  adding manifests
+  adding file changes
+  error: pretxnclose-phase.enforce_draft_commits hook failed: New changeset 1ea73414a91b in phase 'public' rejected
+  transaction abort!
+  rollback completed
+  abort: New changeset 1ea73414a91b in phase 'public' rejected
+  [255]
+  $ hg --cwd a phase --force --draft 0
+  $ hg --cwd b pull ../a
+  pulling from ../a
+  requesting all changes
+  adding changesets
+  adding manifests
+  adding file changes
+  added 1 changesets with 0 changes to 0 files
+  new changesets 1ea73414a91b (1 drafts)
+  (run 'hg update' to get a working copy)
+  $ hg --cwd a phase --public 0
+  $ hg --cwd b pull ../a
+  pulling from ../a
+  searching for changes
+  no changes found
+  error: pretxnclose-phase.enforce_draft_commits hook failed: Phase change from 'draft' to 'public' for 1ea73414a91b rejected
+  abort: Phase change from 'draft' to 'public' for 1ea73414a91b rejected
+  [255]
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/test-hooklib-reject_merge_commits.t	Sat Sep 07 14:50:39 2019 +0200
@@ -0,0 +1,78 @@
+  $ cat <<EOF >> $HGRCPATH
+  > [extensions]
+  > hooklib =
+  > 
+  > [phases]
+  > publish = False
+  > EOF
+  $ hg init a
+  $ hg --cwd a debugbuilddag '.:parent.:childa*parent/childa<parent@otherbranch./childa'
+  $ hg --cwd a log -G
+  o    changeset:   4:a9fb040caedd
+  |\   branch:      otherbranch
+  | |  tag:         tip
+  | |  parent:      3:af739dfc49b4
+  | |  parent:      1:66f7d451a68b
+  | |  user:        debugbuilddag
+  | |  date:        Thu Jan 01 00:00:04 1970 +0000
+  | |  summary:     r4
+  | |
+  | o  changeset:   3:af739dfc49b4
+  | |  branch:      otherbranch
+  | |  parent:      0:1ea73414a91b
+  | |  user:        debugbuilddag
+  | |  date:        Thu Jan 01 00:00:03 1970 +0000
+  | |  summary:     r3
+  | |
+  +---o  changeset:   2:a6b287721c3b
+  | |/   parent:      0:1ea73414a91b
+  | |    parent:      1:66f7d451a68b
+  | |    user:        debugbuilddag
+  | |    date:        Thu Jan 01 00:00:02 1970 +0000
+  | |    summary:     r2
+  | |
+  o |  changeset:   1:66f7d451a68b
+  |/   tag:         childa
+  |    user:        debugbuilddag
+  |    date:        Thu Jan 01 00:00:01 1970 +0000
+  |    summary:     r1
+  |
+  o  changeset:   0:1ea73414a91b
+     tag:         parent
+     user:        debugbuilddag
+     date:        Thu Jan 01 00:00:00 1970 +0000
+     summary:     r0
+  
+  $ hg init b
+  $ cat <<EOF >> b/.hg/hgrc
+  > [hooks]
+  > pretxnchangegroup.reject_merge_commits = \
+  >   python:hgext.hooklib.reject_merge_commits.hook
+  > EOF
+  $ hg --cwd b pull ../a -r a6b287721c3b
+  pulling from ../a
+  adding changesets
+  adding manifests
+  adding file changes
+  error: pretxnchangegroup.reject_merge_commits hook failed: a6b287721c3b rejected as merge on the same branch. Please consider rebase.
+  transaction abort!
+  rollback completed
+  abort: a6b287721c3b rejected as merge on the same branch. Please consider rebase.
+  [255]
+  $ hg --cwd b pull ../a -r 1ea73414a91b
+  pulling from ../a
+  adding changesets
+  adding manifests
+  adding file changes
+  added 1 changesets with 0 changes to 0 files
+  new changesets 1ea73414a91b (1 drafts)
+  (run 'hg update' to get a working copy)
+  $ hg --cwd b pull ../a -r a9fb040caedd
+  pulling from ../a
+  searching for changes
+  adding changesets
+  adding manifests
+  adding file changes
+  added 3 changesets with 0 changes to 0 files
+  new changesets 66f7d451a68b:a9fb040caedd (3 drafts)
+  (run 'hg update' to get a working copy)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/tests/test-hooklib-reject_new_heads.t	Sat Sep 07 14:50:39 2019 +0200
@@ -0,0 +1,53 @@
+  $ cat <<EOF >> $HGRCPATH
+  > [extensions]
+  > hooklib =
+  > 
+  > [phases]
+  > publish = False
+  > EOF
+  $ hg init a
+  $ hg --cwd a debugbuilddag '.:parent.*parent'
+  $ hg --cwd a log -G
+  o  changeset:   2:fa942426a6fd
+  |  tag:         tip
+  |  parent:      0:1ea73414a91b
+  |  user:        debugbuilddag
+  |  date:        Thu Jan 01 00:00:02 1970 +0000
+  |  summary:     r2
+  |
+  | o  changeset:   1:66f7d451a68b
+  |/   user:        debugbuilddag
+  |    date:        Thu Jan 01 00:00:01 1970 +0000
+  |    summary:     r1
+  |
+  o  changeset:   0:1ea73414a91b
+     tag:         parent
+     user:        debugbuilddag
+     date:        Thu Jan 01 00:00:00 1970 +0000
+     summary:     r0
+  
+  $ hg init b
+  $ cat <<EOF >> b/.hg/hgrc
+  > [hooks]
+  > pretxnclose.reject_new_heads = \
+  >   python:hgext.hooklib.reject_new_heads.hook
+  > EOF
+  $ hg --cwd b pull ../a
+  pulling from ../a
+  requesting all changes
+  adding changesets
+  adding manifests
+  adding file changes
+  error: pretxnclose.reject_new_heads hook failed: Changes on branch 'default' resulted in multiple heads
+  transaction abort!
+  rollback completed
+  abort: Changes on branch 'default' resulted in multiple heads
+  [255]
+  $ hg --cwd b pull ../a -r 1ea73414a91b
+  pulling from ../a
+  adding changesets
+  adding manifests
+  adding file changes
+  added 1 changesets with 0 changes to 0 files
+  new changesets 1ea73414a91b (1 drafts)
+  (run 'hg update' to get a working copy)