mozbackout: extension to discard changesets (bug 1288845); r?glob draft
authorGregory Szorc <gps@mozilla.com>
Fri, 15 Jul 2016 14:52:51 -0700
changeset 11050 f8a117fa18b4ff4ae8d7bb011b912e0c258df46c
parent 11049 a0f0914fa6d0d913db4eb4216dfbfe603986b41e
child 11051 6bd62db11a38926b61ddca43003f85be9b8f2988
push id1676
push userbmo:gps@mozilla.com
push dateThu, 18 May 2017 00:17:53 +0000
reviewersglob
bugs1288845
mozbackout: extension to discard changesets (bug 1288845); r?glob Currently, backouts on Firefox repos are performed N ways. The closest thing we have to a consistent way to perform backouts is the "qbackout" extension. This is used by sheriffs and possibly others. But, even this extension allows you to perform backouts multiple ways through various arguments. And, the extension uses MQ, which makes it very far from ideal. As part of automating VCS "synchronization" across various projects, we want a mechanism to identify backouts. We also want special UI on e.g. hg.mozilla.org to denote changesets that are backed out. Not having a well-defined and consistent mechanism for performing backouts makes reliably identifying backouts non-trivial. According to glob, our existing commit message parser identifies less than 90% of backouts! We want to standardize on a modern and well-defined mechanism for performing - and annotating - backouts. This commit introduces the "mozbackout" extension to facilitate that role. The extension provides a facility for identifying which changesets should be discarded given an initial revset. The "algorithm" is slightly more complicated than `hg backout` in that it: * consults the pushlog (if available) and automatically discards changesets in the same push following a discarded changeset * traces files that have been changed by a discarded changeset and also discards changesets touching those files This "tainting" behavior ensures that file merge conflicts cannot occur, which means backouts can be fully automated and not require humans to make decisions about how to resolve the state of a file. In other words, this is optimized for sheriffs, who aren't typically the people authoring code. The "mozbackout" command is designed to run on both local machines and on the server. However, the desired use is to only expose this on servers because we want to shift the mindset away from "hg push your changes" to "have automation do it for you." MozReview-Commit-ID: 8DhYtOh9WGe
hgext/mozbackout/__init__.py
hgext/mozbackout/tests/helpers.sh
hgext/mozbackout/tests/test-backout-basic.t
hgext/mozbackout/tests/test-backout-copy-rename.t
hgext/mozbackout/tests/test-backout-executable-file.t
hgext/mozbackout/tests/test-backout-prompts.t
hgext/mozbackout/tests/test-backout-pushlog.t
hgext/mozbackout/tests/test-backout-user-and-date.t
hgext/mozbackout/tests/test-impacted-files.t
hgext/mozbackout/tests/test-impacted-merge.t
hgext/mozbackout/tests/test-impacted-pushlog.t
hgext/mozbackout/tests/test-impacted-repo-preconditions.t
hgext/mozbackout/tests/test-impacted-revision-validation.t
new file mode 100644
--- /dev/null
+++ b/hgext/mozbackout/__init__.py
@@ -0,0 +1,473 @@
+# This software may be used and distributed according to the terms of the
+# GNU General Public License version 2 or any later version.
+
+"""undo effects of changesets in a Mozilla-centric way
+
+It is common for a "bad" changeset to be introduced to a repository. When this
+happens, you often want to undo its impact. There are multiple ways to do this
+in Mercurial, but they often require the user to make decisions, such as how
+to format a commit message or which other changesets to include as part of the
+undo. This extension provides commands that undo the effects of changesets in
+a consistent and well-defined manner.
+
+Changeset Tainting Rules
+========================
+
+Commands take a revset that expands to the initial set of "bad" revisions.
+From there, additional revisions are brought in to the "bad" set by applying
+a set of rules. We call this "tainting."
+
+Changesets are tainted if they meet one of the following conditions:
+
+* They are a descendant of a "bad" revision in the same push.
+* They are a descendant of a "bad" revision that touches files touched by a
+  "bad" revision.
+"""
+
+from __future__ import absolute_import
+
+import collections
+import os
+
+from mercurial.i18n import _
+from mercurial.node import (
+    nullid,
+    short,
+)
+from mercurial import (
+    cmdutil,
+    context,
+    encoding,
+    error,
+    phases,
+    scmutil,
+    util,
+)
+
+OUR_DIR = os.path.dirname(__file__)
+execfile(os.path.join(OUR_DIR, '..', 'bootstrap.py'))
+
+testedwith = '4.1 4.2'
+minimumhgversion = '4.1'
+
+cmdtable = {}
+command = cmdutil.command(cmdtable)
+
+
+def resolve_discards(ui, repo, revs, action, allow_public=False,
+                     require_pushlog=True):
+    """Determine what revisions to discard and why.
+
+    Given an initial revset to evaluate, this also verifies requested
+    revisions can be discarded and also taints other revisions.
+
+    Unless ``allow_public`` is set, public changesets cannot be discarded.
+
+    Returns a dict describing results.
+    """
+    if not revs:
+        raise error.Abort(_('must give at least one argument defining '
+                            'revision to drop'))
+
+    if not len(repo):
+        raise error.Abort(_('cannot run on empty repos'))
+
+    if len(repo.heads()) > 1:
+        raise error.Abort('cannot operate on repo with multiple heads')
+
+
+    discard_revs = scmutil.revrange(repo, revs)
+
+    if not discard_revs:
+        raise error.Abort(_('could not find revisions to %s') % action,
+                          hint=_('arguments must be revisions or revsets '
+                                 'that resolve to known changesets'))
+
+    # pushid -> set(revisions)
+    push_discards = collections.defaultdict(set)
+
+    pushlog = getattr(repo, 'pushlog', None)
+    if not pushlog:
+        if require_pushlog:
+            raise error.Abort(_('pushlog not available; refusing to continue'))
+        else:
+            ui.warn(_('(warning: pushlog not available; unable to identify '
+                      'revisions in same push)\n'))
+
+    warned = False
+    for rev in discard_revs:
+        ctx = repo[rev]
+        desc = ctx.description().splitlines()[0]
+
+        if ctx.phase() == phases.public and not allow_public:
+            ui.warn('cannot %s public changeset: %s %s\n' %
+                    (action, short(ctx.node()), desc))
+            warned = True
+
+        # Merges cannot be discarded easily.
+        #
+        # If you discard a merge, you need to decide what to do with the 2nd
+        # parent and its ancestors up to the brain point.
+        #
+        # You could leave the parent around in the repo as an extra DAG
+        # head. However, lots of automation and workflows assume the presence
+        # of a single head, so this isn't attractive.
+        #
+        # And for the case where obsolescence is in play...
+        #
+        # If a parent is public, you can't obsolete public changesets
+        # because they are immutable. So, we'd either have to retain the
+        # extra DAG head (until the merge is conducted again) or strip the
+        # repo. And stripping is a measure of last resort, as it can cause
+        # havoc for downstream consumers, including the replication system.
+        #
+        # We /could/ drop the merge if both parents contained draft changesets
+        # up to the branch point. But this will likely not happen because
+        # merges to the autoland repo will come from publishing repositories.
+        # The easiest thing to do is refuse to drop merges.
+        if len(ctx.parents()) > 1:
+            ui.warn('cannot %s merge: %s %s\n' %
+                    (action, short(ctx.node()), desc))
+            warned = True
+
+        # While we're iterating, assemble map of pushes to changesets in them
+        # being dropped.
+        if pushlog:
+            push = pushlog.pushfromchangeset(ctx)
+            if push:
+                push_discards[push.pushid].add(ctx.node())
+
+    if warned:
+        raise error.Abort(_('cannot perform %s on selected revisions') % action)
+
+    # Above, we collected the set of revisions that were explicitly requested
+    # to be discarded and the pushlog pushes associated with them.
+    #
+    # Our strategy now is to start with the oldest changeset to be discarded
+    # and walk towards the DAG head. For each changeset, we examine to see if
+    # it is to be discarded. If it is, we record attributes so we can taint
+    # descendants.
+    #
+    # We don't (yet) perform file merges as part of the discard. So, we
+    # record the set of files impacted by discarded changesets. If a
+    # subsequent changeset touches any of those files, we also discard it.
+    #
+    # Pushes work similarly. If we encounter a changeset that is part of a
+    # push that previously had a discard, we discard it. The thinking here
+    # is that changesets from the same push that come after a discarded
+    # changeset are likely impacted by the discarded changeset. While this
+    # may# cause some false# positives, it is better to be safe than sorry.
+
+    # Files modified by discarded changesets.
+    impacted_files = set()
+    # Path to earliest discarded revision it was touched in.
+    first_file_revision = {}
+
+    # Integer rev to reason for discard.
+    discarded = {}
+
+    for ctx in repo.set('%d::', discard_revs.min()):
+        rev = ctx.rev()
+        node = ctx.node()
+
+        # If a merge commit changed files that are impacted by a discard, we
+        # abort because we can't rewrite merge commits.
+        if len(ctx.parents()) > 1 and set(ctx.files()) & impacted_files:
+            raise error.Abort('merge changeset %s changed files impacted by a '
+                              '%s; cannot proceed' % (short(node), action),
+                              hint='perform a traditional backout commit')
+
+        # There are edge cases where ctx.files() may not be accurate. Compute
+        # the file changes from the manifest diff. This is slower but more
+        # accurate.
+        changed_files = set(ctx.manifest().diff(ctx.p1().manifest()).keys())
+
+        def add_discarded(reason):
+            discarded[rev] = reason
+
+            if len(ctx.parents()) > 1:
+                raise error.Abort(_('cannot %s merge changesets') % action,
+                                  hint=_('perform a traditional backout '
+                                         'commit'))
+
+            impacted_files.update(changed_files)
+
+            for f in changed_files:
+                first_file_revision.setdefault(f, rev)
+
+            if pushlog:
+                push = pushlog.pushfromchangeset(ctx)
+                if push:
+                    push_discards[push.pushid].add(node)
+
+        # If node is already marked for discarding, discard it.
+        if rev in discard_revs:
+            add_discarded('explicit')
+            continue
+
+        # If this changeset shares a push that contains a discard and comes
+        # after that discard, discard it. The reasoning here is worth
+        # explaining.
+        #
+        # If a push contains multiple changesets, some may be good and
+        # others bad. We want to make forward progress where possible. If only
+        # the last changeset is bad, we shouldn't penalize the changesets before
+        # it by discarding them.
+        #
+        # However, if the first or a middle changeset is bad, all bets are off
+        # as to the state of the following changesets. In many scenarios, those
+        # subsequent changesets rely on work done by the bad one. So, the only
+        # safe thing to do is drop all changesets in a push coming after a bad
+        # one.
+        if pushlog:
+            push = pushlog.pushfromchangeset(ctx)
+            if push and push.pushid in push_discards:
+                # And the changeset comes *after* a changeset in the push that
+                # was dropped.
+                if ctx.rev() in repo.revs('%ln::', push_discards[push.pushid]):
+                    add_discarded('push')
+                    continue
+
+        # If this changeset touches files impacted by a drop, we also
+        # drop it.
+        if changed_files & impacted_files:
+            add_discarded('files')
+            continue
+
+    return {
+        'discards': discarded,
+        'first_file_revisions': first_file_revision,
+    }
+
+
+def summarize_discards(ui, repo, discards, action):
+    """Print a summary of what we'll discard."""
+
+    reasons = {
+        'explicit': _('requested explicitly'),
+        'push': _('shares push with discarded changeset'),
+        'files': _('changes impacted files'),
+    }
+
+    for ctx in repo.set('%d::', min(discards['discards'].keys())):
+        rev = ctx.rev()
+        desc = ctx.description().splitlines()[0]
+        if rev in discards['discards']:
+            reason = discards['discards'][rev]
+            ui.write('will %s %s (%s): %s\n' %
+                     (action, short(ctx.node()), reasons[reason], desc))
+        else:
+            ui.write('will retain %s: %s\n' % (short(ctx.node()), desc))
+
+
+def schedule_changegroup_close_hooks(repo, tr, hook_args, nodes):
+    def run_hooks():
+        args = hook_args.copy()
+        args['node'] = hook_args['node']
+        repo.hook('changegroup', **args)
+
+        for node in nodes:
+            args = hook_args.copy()
+            args['node'] = node
+            repo.hook('incoming', **args)
+
+    tr.addpostclose('discard-changegroup-hooks',
+                    lambda tr: repo._afterlock(run_hooks))
+
+
+class DryRunAbort(Exception):
+    """Represents an abort due to a dry run requested."""
+
+
+@command('debugimpactedrevs', [
+    ('p', 'public', False, _('allow operating on public revisions')),
+])
+def debugbackoutimpactedrevs(ui, repo, *revs, **opts):
+    """Display information about impacted revisions for backouts.
+
+    This is used to unit test the logic for determining which revisions
+    and files are impacted by a backout.
+    """
+    discards = resolve_discards(ui, repo, revs, 'discard',
+                                allow_public=opts['public'],
+                                require_pushlog=False)
+
+    summarize_discards(ui, repo, discards, 'discard')
+
+
+@command('mozbackout', [
+    ('d', 'date', '',
+     _('record the specified date as commit date'), _('DATE')),
+    ('u', 'user', '',
+     _('record the specified user as committer'), _('USER')),
+    ('m', 'message', '', _('reason for backout'), _('REASON')),
+    ('n', 'dry-run', None, _('do not perform actions; print what would be '
+                             'done')),
+])
+def mozbackout(ui, repo, *revs, **opts):
+    """Backout changesets using Mozilla conventions.
+
+    Revisions to be backed out are resolved from the argument list. Additional
+    revisions are resolved via tainting rules.
+    """
+    date = opts.get('date')
+    if date:
+        opts['date'] = util.parsedate(date)
+    else:
+        opts['date'] = util.makedate()
+
+    if not opts.get('message'):
+        if not ui.interactive():
+            raise error.Abort(_('-m/--message must be given when not running '
+                                'interactively'))
+
+        while True:
+            opts['message'] = ui.prompt(_('reason for backout: '), default='')
+            if opts['message']:
+                break
+            else:
+                ui.warn(_('must specify a reason\n'))
+
+    opts['user'] = opts['user'] or ui.username()
+
+    try:
+        with repo.lock():
+            with repo.transaction('mozbackout') as tr:
+                _backout(ui, repo, tr, revs, **opts)
+    except DryRunAbort:
+        pass
+
+
+def _backout(ui, repo, tr, revs, message=None, date=None,
+             user=None, dry_run=False):
+    discards = resolve_discards(ui, repo, revs, 'discard', allow_public=True)
+    assert discards['discards']
+
+    summarize_discards(ui, repo, discards, 'discard')
+
+    backouts = discards['discards']
+
+    lines = [
+        'Backed out %d changesets for %s' % (len(backouts), message),
+        ''
+    ]
+
+    reasons = {
+        'explicit': 'requested explicitly',
+        'push': 'shares push',
+        'files': 'files impacted',
+    }
+
+    for rev in sorted(backouts):
+        ctx = repo[rev]
+        lines.append('%s (%s) %s' % (
+            short(ctx.node()),
+            reasons[backouts[rev]],
+            encoding.fromlocal(ctx.description()).splitlines()[0]))
+
+    desc = encoding.tolocal('\n'.join(lines))
+
+    hook_args = dict(tr.hookargs)
+    hook_args['source'] = 'mozbackout'
+
+    # Semantically, the precommit hook is better. However, "commit" is
+    # associated with working directories, which we don't have here. Few hooks
+    # on hg.mozilla.org work on the commit level. So we use the changegroup
+    # hooks.
+    repo.hook('prechangegroup', **hook_args)
+
+    # We create a changeset that reverts changes made by the set of discarded
+    # changesets. Unlike `hg backout`, which operates at a diff/merge level to
+    # basically do a reverse diff, we operate at the whole file level. We can
+    # do this because the discarding logic "taints" all subsequent changesets
+    # touching reverted files.
+    #
+    # Our new changeset says there are changes to every file impacted by the
+    # discards. Then, when the callback fires to get details on each impacted
+    # file, we return a file context as it existed in the ancestor of the
+    # first changeset that modified this file.
+
+    tip = repo['tip']
+
+    def get_file_ctx(repo, memctx, path):
+        assert path in discards['first_file_revisions']
+
+        ctx = repo[discards['first_file_revisions'][path]]
+
+        if len(ctx.parents()) > 1:
+            raise error.Abort(_('we should not have encountered a merge '
+                                'for a file being changed during a backout; '
+                                'this is likely a bug'))
+
+            # If this isn't a bug, we should get the fctx for both parents,
+            # verify they are identical, and reuse.
+
+        try:
+            fctx = ctx.p1()[path]
+        except KeyError:
+            return None
+
+        return context.memfilectx(repo, path, fctx.data(),
+                                  islink=fctx.islink(),
+                                  isexec=fctx.isexec(),
+                                  copied=fctx.renamed(),
+                                  memctx=memctx)
+
+    ctx = context.memctx(repo,
+                         parents=[tip.node(), nullid],
+                         text=desc,
+                         files=discards['first_file_revisions'].keys(),
+                         filectxfn=get_file_ctx,
+                         user=user,
+                         date=date,
+                         extra={'branch': tip.branch() or 'default'})
+
+    ctx = repo[ctx.commit()]
+
+    ui.write('created backout changeset %s\n' % (short(ctx.node())))
+
+    # Verify that end state of modified files matches exactly what it was
+    # before. This is a sanity check and could be removed if it never triggers.
+    for path in sorted(discards['first_file_revisions'].keys()):
+        fctx = repo[discards['first_file_revisions'][path]]
+
+        try:
+            expected = fctx.p1()[path]
+        except KeyError:
+            expected = None
+
+        if path in ctx:
+            got = ctx[path]
+        else:
+            got = None
+
+        if expected is None or got is None:
+            if expected != got:
+                raise error.Abort(_('file mismatch for %s; please report this'
+                                    'bug along with steps to reproduce') % path)
+
+            continue
+
+        # We can't straight up compare the file contexts because the nodes
+        # should be different since they take history into account.
+        if (expected.islink() != got.islink() or
+            expected.isexec() != got.isexec() or
+            expected.renamed() != got.renamed() or
+            expected.data() != got.data()):
+            raise error.Abort(_('file mismatch for %s; please report this bug '
+                                'along with steps to reproduce') % path)
+
+    tr.hookargs['node'] = ctx.hex()
+    hook_args['node'] = ctx.hex()
+
+    p = lambda: tr.writepending() and repo.root or ''
+    repo.hook('pretxnchangegroup', throw=True, pending=p, **hook_args)
+
+    # We're still in a transaction. Aborting should roll everything
+    # back. We do this after the pretxnchangegroup hooks because we want
+    # to get as far as possible so the user knows what to expect.
+    if dry_run:
+        ui.write('(aborting because dry run requested)\n')
+        raise DryRunAbort()
+
+    schedule_changegroup_close_hooks(repo, tr, hook_args, [ctx.hex()])
new file mode 100644
--- /dev/null
+++ b/hgext/mozbackout/tests/helpers.sh
@@ -0,0 +1,31 @@
+# This Source Code Form is subject to the terms of the Mozilla Public
+# License, v. 2.0. If a copy of the MPL was not distributed with this
+# file, You can obtain one at http://mozilla.org/MPL/2.0/.
+
+# Use a sensible user name by default.
+export HGUSER='Test User <someone@example.com>'
+
+cat >> $HGRCPATH << EOF
+[alias]
+mozbackout = mozbackout -d '0 0'
+shortlog = log -G -T '{rev}:{node|short} {desc|firstline}'
+
+[diff]
+git = true
+EOF
+
+enablemozbackout() {
+  cat >> $HGRCPATH << EOF
+[extensions]
+mozbackout = $TESTDIR/hgext/mozbackout
+EOF
+}
+
+backoutrepo() {
+  hg init $1
+  cat >> $1/.hg/hgrc << EOF
+[extensions]
+mozbackout = $TESTDIR/hgext/mozbackout
+pushlog = $TESTDIR/hgext/pushlog
+EOF
+}
new file mode 100644
--- /dev/null
+++ b/hgext/mozbackout/tests/test-backout-basic.t
@@ -0,0 +1,66 @@
+  $ . $TESTDIR/hgext/mozbackout/tests/helpers.sh
+  $ export USER=hguser
+
+  $ backoutrepo server
+  $ hg -q clone server client
+  $ cd client
+  $ touch foo
+  $ hg -q commit -A -m initial
+  $ hg -q push
+  recorded push in pushlog
+
+Seed repo with independent changesets
+
+  $ echo file0 > file0
+  $ hg -q commit -A -m commit0
+  $ hg -q push
+  recorded push in pushlog
+  $ echo file1 > file1
+  $ hg -q commit -A -m commit1
+  $ hg -q push
+  recorded push in pushlog
+
+  $ cd ../server
+
+  $ hg shortlog
+  o  2:3570fcb7e2e0 commit1
+  |
+  o  1:62529fb87da7 commit0
+  |
+  o  0:77538e1ce4be initial
+  
+
+--dry-run no-ops
+
+  $ hg mozbackout -m 'testing dry run' --dry-run 62529fb87da7
+  will discard 62529fb87da7 (requested explicitly): commit0
+  will retain 3570fcb7e2e0: commit1
+  created backout changeset 41206f9d36f2
+  (aborting because dry run requested)
+  transaction abort!
+  rollback completed
+
+Backing out a commit independent of others works
+
+  $ hg mozbackout -m 'testing independent' 62529fb87da7
+  will discard 62529fb87da7 (requested explicitly): commit0
+  will retain 3570fcb7e2e0: commit1
+  created backout changeset d53e82cc1ca8
+
+  $ hg export 3
+  # HG changeset patch
+  # User Test User <someone@example.com>
+  # Date 0 0
+  #      Thu Jan 01 00:00:00 1970 +0000
+  # Node ID d53e82cc1ca807f4f0cecc5d9eb3065c2ae4d579
+  # Parent  3570fcb7e2e0a37e7f23e1ec893cbb875b4f9c48
+  Backed out 1 changesets for testing independent
+  
+  62529fb87da7 (requested explicitly) commit0
+  
+  diff --git a/file0 b/file0
+  deleted file mode 100644
+  --- a/file0
+  +++ /dev/null
+  @@ -1,1 +0,0 @@
+  -file0
new file mode 100644
--- /dev/null
+++ b/hgext/mozbackout/tests/test-backout-copy-rename.t
@@ -0,0 +1,163 @@
+  $ . $TESTDIR/hgext/mozbackout/tests/helpers.sh
+
+Backing out a copy should delete the copy
+
+  $ backoutrepo test-copy
+  $ cd test-copy
+
+  $ touch foo
+  $ hg -q commit -A -m initial
+  $ echo 0 > file-orig
+  $ hg -q commit -A -m orig
+  $ hg cp file-orig file-copied
+  $ hg commit -m copied
+  $ echo 1 > file-copied
+  $ hg commit -m 'copied 1'
+
+  $ hg shortlog
+  @  3:a58ffe569151 copied 1
+  |
+  o  2:0f9b20a829f6 copied
+  |
+  o  1:db5f00ccdf2a orig
+  |
+  o  0:77538e1ce4be initial
+  
+
+  $ hg mozbackout 2 -m 'undo copy'
+  will discard 0f9b20a829f6 (requested explicitly): copied
+  will discard a58ffe569151 (changes impacted files): copied 1
+  created backout changeset 833b30e7ec2e
+
+  $ hg export 4
+  # HG changeset patch
+  # User Test User <someone@example.com>
+  # Date 0 0
+  #      Thu Jan 01 00:00:00 1970 +0000
+  # Node ID 833b30e7ec2e3a29c8d0c488cf45098575984a76
+  # Parent  a58ffe56915162412728ede4aa19316fbbf76f7a
+  Backed out 2 changesets for undo copy
+  
+  0f9b20a829f6 (requested explicitly) copied
+  a58ffe569151 (files impacted) copied 1
+  
+  diff --git a/file-copied b/file-copied
+  deleted file mode 100644
+  --- a/file-copied
+  +++ /dev/null
+  @@ -1,1 +0,0 @@
+  -1
+
+Original file should still exist after backout
+
+  $ hg cat -r 4 file-orig
+  0
+
+  $ cd ..
+
+Now back out a rename
+
+  $ backoutrepo test-rename
+  $ cd test-rename
+  $ touch foo
+  $ hg -q commit -A -m initial
+  $ echo 0 > file-orig
+  $ hg -q commit -A -m orig
+  $ hg mv file-orig file-renamed
+  $ hg commit -m renamed
+  $ echo 1 > file-renamed
+  $ hg commit -m 'renamed 1'
+
+  $ hg shortlog
+  @  3:4a19ab819bc8 renamed 1
+  |
+  o  2:dbdca74fa29b renamed
+  |
+  o  1:db5f00ccdf2a orig
+  |
+  o  0:77538e1ce4be initial
+  
+  $ hg mozbackout -m 'undo rename' 2
+  will discard dbdca74fa29b (requested explicitly): renamed
+  will discard 4a19ab819bc8 (changes impacted files): renamed 1
+  created backout changeset 7bd5c0ce655f
+
+  $ hg export 4
+  # HG changeset patch
+  # User Test User <someone@example.com>
+  # Date 0 0
+  #      Thu Jan 01 00:00:00 1970 +0000
+  # Node ID 7bd5c0ce655f627e359de4eeae69878f153d490a
+  # Parent  4a19ab819bc8331fdca0d4b48e8d43c7ba93efa2
+  Backed out 2 changesets for undo rename
+  
+  dbdca74fa29b (requested explicitly) renamed
+  4a19ab819bc8 (files impacted) renamed 1
+  
+  diff --git a/file-orig b/file-orig
+  new file mode 100644
+  --- /dev/null
+  +++ b/file-orig
+  @@ -0,0 +1,1 @@
+  +0
+  diff --git a/file-renamed b/file-renamed
+  deleted file mode 100644
+  --- a/file-renamed
+  +++ /dev/null
+  @@ -1,1 +0,0 @@
+  -1
+
+  $ cd ..
+
+Now do a rename variation but with file history, executable bit, and modification during rename
+
+  $ backoutrepo rename-history
+  $ cd rename-history
+  $ touch foo
+  $ hg -q commit -A -m initial
+  $ echo 0 > file-orig
+  $ chmod +x file-orig
+  $ hg -q commit -A -m orig
+  $ echo 1 > file-orig
+  $ hg commit -m '0 to 1'
+  $ hg mv file-orig file-renamed
+  $ echo 2 > file-renamed
+  $ hg commit -m 'rename and 2 to 1'
+
+  $ hg shortlog
+  @  3:3d0ff821c59a rename and 2 to 1
+  |
+  o  2:2d8e968233b2 0 to 1
+  |
+  o  1:8ce60d3f3346 orig
+  |
+  o  0:77538e1ce4be initial
+  
+
+  $ hg mozbackout -m 'undo rename' 3
+  will discard 3d0ff821c59a (requested explicitly): rename and 2 to 1
+  created backout changeset 5c52048d275d
+
+  $ hg export 5
+  # HG changeset patch
+  # User Test User <someone@example.com>
+  # Date 0 0
+  #      Thu Jan 01 00:00:00 1970 +0000
+  # Node ID 5c52048d275d6258e5b2513b023309e601cf8ee5
+  # Parent  3d0ff821c59a38434bf23dca30c550e3942f5fea
+  Backed out 1 changesets for undo rename
+  
+  3d0ff821c59a (requested explicitly) rename and 2 to 1
+  
+  diff --git a/file-orig b/file-orig
+  new file mode 100755
+  --- /dev/null
+  +++ b/file-orig
+  @@ -0,0 +1,1 @@
+  +1
+  diff --git a/file-renamed b/file-renamed
+  deleted file mode 100755
+  --- a/file-renamed
+  +++ /dev/null
+  @@ -1,1 +0,0 @@
+  -2
new file mode 100644
--- /dev/null
+++ b/hgext/mozbackout/tests/test-backout-executable-file.t
@@ -0,0 +1,68 @@
+  $ . $TESTDIR/hgext/mozbackout/tests/helpers.sh
+
+Construct a repo that modifies the executable status of a file
+
+  $ backoutrepo backout-executable
+  $ cd backout-executable
+  $ touch foo
+  $ hg -q commit -A -m initial
+  $ touch file0
+  $ hg -q commit -A -m 'add file0'
+  $ chmod +x file0
+  $ hg commit -m 'make file0 executable'
+
+  $ hg shortlog
+  @  2:d5f300b6e466 make file0 executable
+  |
+  o  1:442ce5a124e0 add file0
+  |
+  o  0:77538e1ce4be initial
+  
+  $ hg export 2
+  # HG changeset patch
+  # User Test User <someone@example.com>
+  # Date 0 0
+  #      Thu Jan 01 00:00:00 1970 +0000
+  # Node ID d5f300b6e466623cb9b6d5d3d240d3926ba2ef99
+  # Parent  442ce5a124e001862e8bd6a8871d8b85e09bebd7
+  make file0 executable
+  
+  diff --git a/file0 b/file0
+  old mode 100644
+  new mode 100755
+
+Backing out a change to an executable bit should revert the executable status
+
+  $ hg mozbackout -m backout 2
+  will discard d5f300b6e466 (requested explicitly): make file0 executable
+  created backout changeset 3393c206c7b7
+
+  $ hg files -r 2 'set:exec()'
+  file0
+  $ hg files -r 3 'set:exec()'
+  [1]
+
+  $ cd ..
+
+Backout out a change to an executable file should retain the executable bit
+
+  $ backoutrepo executable-modified
+  $ cd executable-modified
+  $ touch foo
+  $ hg -q commit -A -m initial
+  $ echo 0 > file0
+  $ chmod +x file0
+  $ hg -q commit -A -m 'add file0'
+  $ echo 1 > file0
+  $ hg commit -m 'modify file1'
+
+  $ hg mozbackout -m backout 2
+  will discard 0e33bfbd38b5 (requested explicitly): modify file1
+  created backout changeset d9c9594b97c6
+
+  $ hg files -r 2 'set:exec()'
+  file0
+  $ hg files -r 3 'set:exec()'
+  file0
+
+  $ cd ..
new file mode 100644
--- /dev/null
+++ b/hgext/mozbackout/tests/test-backout-prompts.t
@@ -0,0 +1,53 @@
+  $ . $TESTDIR/hgext/mozbackout/tests/helpers.sh
+
+  $ backoutrepo repo
+  $ cd repo
+
+  $ touch foo
+  $ hg -q commit -A -m initial
+  $ echo 0 > file0
+  $ hg -q commit -A -m file0
+
+  $ hg shortlog
+  @  1:9bdfe2de4776 file0
+  |
+  o  0:77538e1ce4be initial
+  
+
+Message must be given when not running interactively
+
+  $ hg mozbackout 9bdfe2de4776
+  abort: -m/--message must be given when not running interactively
+  [255]
+
+We prompt for message when running interactively
+
+  $ hg --config ui.interactive=true mozbackout 9bdfe2de4776 << EOF
+  > 
+  > random reason
+  > EOF
+  reason for backout:  
+  must specify a reason
+  reason for backout:  random reason
+  will discard 9bdfe2de4776 (requested explicitly): file0
+  created backout changeset b668c7d8e4b5
+
+  $ hg export 2
+  # HG changeset patch
+  # User Test User <someone@example.com>
+  # Date 0 0
+  #      Thu Jan 01 00:00:00 1970 +0000
+  # Node ID b668c7d8e4b5c8df42775ada7986c9cb8d8ba93d
+  # Parent  9bdfe2de47766b5ff022d12a58a8ec61f1d36eef
+  Backed out 1 changesets for random reason
+  
+  9bdfe2de4776 (requested explicitly) file0
+  
+  diff --git a/file0 b/file0
+  deleted file mode 100644
+  --- a/file0
+  +++ /dev/null
+  @@ -1,1 +0,0 @@
+  -0
+
+  $ cd ..
new file mode 100644
--- /dev/null
+++ b/hgext/mozbackout/tests/test-backout-pushlog.t
@@ -0,0 +1,47 @@
+  $ . $TESTDIR/hgext/mozbackout/tests/helpers.sh
+  $ export USER=hguser
+
+  $ backoutrepo server
+  $ hg -q clone server client
+  $ cd client
+  $ touch foo
+  $ hg -q commit -A -m initial
+  $ hg -q push
+  recorded push in pushlog
+
+Seed repo with data
+
+  $ echo file0 > file0
+  $ hg -q commit -A -m commit0
+  $ hg -q push
+  recorded push in pushlog
+  $ echo file1 > file1
+  $ hg -q commit -A -m commit1
+  $ hg -q push
+  recorded push in pushlog
+
+  $ cd ../server
+
+  $ hg shortlog
+  o  2:3570fcb7e2e0 commit1
+  |
+  o  1:62529fb87da7 commit0
+  |
+  o  0:77538e1ce4be initial
+  
+
+Back out tip most changesets
+
+  $ hg mozbackout -m 'testing pushlog' 62529fb87da7::
+  will discard 62529fb87da7 (requested explicitly): commit0
+  will discard 3570fcb7e2e0 (requested explicitly): commit1
+  created backout changeset f2bb38dd4877
+
+Pushlog should be retained for original changesets
+New pushlog entry should exist for backout changeset
+
+  $ hg log -T '{rev}:{node|short} {desc|firstline} {pushid} {pushuser}\n'
+  3:f2bb38dd4877 Backed out 2 changesets for testing pushlog  
+  2:3570fcb7e2e0 commit1 3 hguser
+  1:62529fb87da7 commit0 2 hguser
+  0:77538e1ce4be initial 1 hguser
new file mode 100644
--- /dev/null
+++ b/hgext/mozbackout/tests/test-backout-user-and-date.t
@@ -0,0 +1,31 @@
+  $ . $TESTDIR/hgext/mozbackout/tests/helpers.sh
+
+  $ backoutrepo repo
+  $ cd repo
+
+  $ touch foo
+  $ hg -q commit -A -m initial
+  $ echo 0 > file0
+  $ hg -q commit -A -m file0
+
+  $ hg mozbackout -u 'CLI User <cli@example.com>' -d '1495054549 28800' -m 'testing arguments' 1
+  will discard 9bdfe2de4776 (requested explicitly): file0
+  created backout changeset dfdf0dbb5117
+
+  $ hg export 2
+  # HG changeset patch
+  # User CLI User <cli@example.com>
+  # Date 1495054549 28800
+  #      Wed May 17 12:55:49 2017 -0800
+  # Node ID dfdf0dbb51171ce020350262e3cc5ea6107447ab
+  # Parent  9bdfe2de47766b5ff022d12a58a8ec61f1d36eef
+  Backed out 1 changesets for testing arguments
+  
+  9bdfe2de4776 (requested explicitly) file0
+  
+  diff --git a/file0 b/file0
+  deleted file mode 100644
+  --- a/file0
+  +++ /dev/null
+  @@ -1,1 +0,0 @@
+  -0
new file mode 100644
--- /dev/null
+++ b/hgext/mozbackout/tests/test-impacted-files.t
@@ -0,0 +1,152 @@
+  $ . $TESTDIR/hgext/mozbackout/tests/helpers.sh
+  $ enablemozbackout
+
+  $ hg init simple
+  $ cd simple
+
+  $ touch foo
+  $ hg -q commit -A -m initial
+  $ hg phase --public -r .
+  $ echo 0 > file0
+  $ hg -q commit -A -m 'file0 0'
+  $ echo 0 > file1
+  $ hg -q commit -A -m 'file1 0'
+  $ echo 1 > file0
+  $ hg commit -m 'file0 1'
+  $ echo 1 > file1
+  $ hg commit -m 'file1 1'
+  $ echo 2 > file0
+  $ echo 2 > file1
+  $ hg commit -m 'file0/file1 2'
+
+  $ hg shortlog
+  @  5:f96c11382754 file0/file1 2
+  |
+  o  4:2db0e84fc3d7 file1 1
+  |
+  o  3:ab1d7521154c file0 1
+  |
+  o  2:83bb29ac0b25 file1 0
+  |
+  o  1:7a649de10bd2 file0 0
+  |
+  o  0:77538e1ce4be initial
+  
+
+Backing out a changeset touching file X will back out descendants touching that file
+
+  $ hg debugimpactedrevs 7a649de10bd2
+  (warning: pushlog not available; unable to identify revisions in same push)
+  will discard 7a649de10bd2 (requested explicitly): file0 0
+  will retain 83bb29ac0b25: file1 0
+  will discard ab1d7521154c (changes impacted files): file0 1
+  will retain 2db0e84fc3d7: file1 1
+  will discard f96c11382754 (changes impacted files): file0/file1 2
+
+File not modified by descendants should not result in tainting
+
+  $ hg debugimpactedrevs --public 77538e1ce4be
+  (warning: pushlog not available; unable to identify revisions in same push)
+  will discard 77538e1ce4be (requested explicitly): initial
+  will retain 7a649de10bd2: file0 0
+  will retain 83bb29ac0b25: file1 0
+  will retain ab1d7521154c: file0 1
+  will retain 2db0e84fc3d7: file1 1
+  will retain f96c11382754: file0/file1 2
+
+  $ cd ..
+
+Now test multiple files being modified in a changeset
+
+  $ hg init multiple-files
+  $ cd multiple-files
+  $ touch foo
+  $ hg -q commit -A -m initial
+
+  $ echo 0 > file0
+  $ hg -q commit -A -m 'file0 0'
+  $ echo 1 > file0
+  $ echo 0 > file1
+  $ hg -q commit -A -m 'file0 1 file1 0'
+  $ echo 2 > file0
+  $ hg commit -m 'file0 2'
+  $ echo 1 > file1
+  $ hg commit -m 'file1 1'
+  $ echo 0 > file2
+  $ hg -q commit -A -m 'file2 0'
+
+  $ hg shortlog
+  @  5:72fb263e36ff file2 0
+  |
+  o  4:90b2e91ede59 file1 1
+  |
+  o  3:7240bafd09a7 file0 2
+  |
+  o  2:62d0909acd7e file0 1 file1 0
+  |
+  o  1:7a649de10bd2 file0 0
+  |
+  o  0:77538e1ce4be initial
+  
+
+  $ hg debugimpactedrevs 62d0909acd7e
+  (warning: pushlog not available; unable to identify revisions in same push)
+  will discard 62d0909acd7e (requested explicitly): file0 1 file1 0
+  will discard 7240bafd09a7 (changes impacted files): file0 2
+  will discard 90b2e91ede59 (changes impacted files): file1 1
+  will retain 72fb263e36ff: file2 0
+
+  $ cd ..
+
+Files modified by tainted changesets should taint subsequent changesets
+
+  $ hg init taint-chaining
+  $ cd taint-chaining
+  $ touch foo
+  $ hg -q commit -A -m initial
+  $ echo 0 > file0
+  $ hg -q commit -A -m 'file0 0'
+  $ echo 1 > file0
+  $ echo 0 > file1
+  $ hg -q commit -A -m 'file0 1 file1 0'
+  $ echo 2 > file0
+  $ echo 1 > file1
+  $ echo 0 > file2
+  $ hg -q commit -A -m 'file0 2 file1 1 file2 0'
+  $ echo 3 > file0
+  $ hg commit -m 'file0 3'
+  $ echo 2 > file1
+  $ hg commit -m 'file1 2'
+  $ echo 1 > file2
+  $ hg commit -m 'file2 1'
+  $ echo 0 > file3
+  $ hg -q commit -A -m 'file3 0'
+
+  $ hg shortlog
+  @  7:8048f884a24e file3 0
+  |
+  o  6:b08a9de38526 file2 1
+  |
+  o  5:ff9bd664ee33 file1 2
+  |
+  o  4:e219eba74228 file0 3
+  |
+  o  3:2a0ac94cd7fd file0 2 file1 1 file2 0
+  |
+  o  2:62d0909acd7e file0 1 file1 0
+  |
+  o  1:7a649de10bd2 file0 0
+  |
+  o  0:77538e1ce4be initial
+  
+  $ hg debugimpactedrevs 7a649de10bd2
+  (warning: pushlog not available; unable to identify revisions in same push)
+  will discard 7a649de10bd2 (requested explicitly): file0 0
+  will discard 62d0909acd7e (changes impacted files): file0 1 file1 0
+  will discard 2a0ac94cd7fd (changes impacted files): file0 2 file1 1 file2 0
+  will discard e219eba74228 (changes impacted files): file0 3
+  will discard ff9bd664ee33 (changes impacted files): file1 2
+  will discard b08a9de38526 (changes impacted files): file2 1
+  will retain 8048f884a24e: file3 0
+
+  $ cd ..
new file mode 100644
--- /dev/null
+++ b/hgext/mozbackout/tests/test-impacted-merge.t
@@ -0,0 +1,71 @@
+  $ . $TESTDIR/hgext/mozbackout/tests/helpers.sh
+  $ enablemozbackout
+
+  $ hg init repo
+  $ cd repo
+
+  $ touch foo
+  $ hg -q commit -A -m initial
+  $ hg phase --public -r .
+  $ echo 0 > file0
+  $ hg -q commit -A -m 'file0 0'
+  $ echo 0 > file1
+  $ hg -q commit -A -m 'file1 0'
+  $ echo 1a > file0
+  $ hg commit -m 'file0 1a'
+  $ hg -q up 2
+  $ echo 1b > file0
+  $ hg commit -m 'file0 1b'
+  created new head
+  $ echo 2 > file0
+  $ hg commit -m 'file0 2'
+  $ hg -q up 3
+
+  $ hg merge -t :other tip
+  0 files updated, 1 files merged, 0 files removed, 0 files unresolved
+  (branch merge, don't forget to commit)
+  $ hg commit -m merge
+
+  $ hg shortlog
+  @    6:2a969f69b3c8 merge
+  |\
+  | o  5:c59306c59a8f file0 2
+  | |
+  | o  4:431d61777bf6 file0 1b
+  | |
+  o |  3:8aab5419c2fc file0 1a
+  |/
+  o  2:83bb29ac0b25 file1 0
+  |
+  o  1:7a649de10bd2 file0 0
+  |
+  o  0:77538e1ce4be initial
+  
+
+Merges cannot be specifically targeted for discard
+
+  $ hg debugimpactedrevs 2a969f69b3c8
+  (warning: pushlog not available; unable to identify revisions in same push)
+  cannot discard merge: 2a969f69b3c8 merge
+  abort: cannot perform discard on selected revisions
+  [255]
+
+Merges touching files that are impacted are not be allowed
+
+  $ hg debugimpactedrevs 431d61777bf6
+  (warning: pushlog not available; unable to identify revisions in same push)
+  abort: merge changeset 2a969f69b3c8 changed files impacted by a discard; cannot proceed
+  (perform a traditional backout commit)
+  [255]
+
+Merges not touching impacted files are OK
+
+  $ hg debugimpactedrevs 83bb29ac0b25
+  (warning: pushlog not available; unable to identify revisions in same push)
+  will discard 83bb29ac0b25 (requested explicitly): file1 0
+  will retain 8aab5419c2fc: file0 1a
+  will retain 431d61777bf6: file0 1b
+  will retain c59306c59a8f: file0 2
+  will retain 2a969f69b3c8: merge
+
+  $ cd ..
new file mode 100644
--- /dev/null
+++ b/hgext/mozbackout/tests/test-impacted-pushlog.t
@@ -0,0 +1,123 @@
+  $ . $TESTDIR/hgext/mozbackout/tests/helpers.sh
+  $ export USER=hguser
+
+  $ backoutrepo server
+  $ hg -q clone server client
+  $ cd client
+  $ touch foo
+  $ hg -q commit -A -m initial
+  $ hg phase --public -r .
+  $ hg -q push
+  recorded push in pushlog
+
+  $ echo 0 > file0
+  $ hg -q commit -A -m 'push 2 commit 1'
+  $ echo 1 > file1
+  $ hg -q commit -A -m 'push 2 commit 2'
+  $ echo 2 > file2
+  $ hg -q commit -A -m 'push 2 commit 3'
+  $ hg -q push
+  recorded push in pushlog
+
+  $ echo 3 > file0
+  $ hg -q commit -A -m 'push 3 commit 1'
+  $ echo 4 > file4
+  $ hg -q commit -A -m 'push 3 commit 2'
+  $ echo 5 > file5
+  $ hg -q commit -A -m 'push 3 commit 3'
+  $ hg -q push
+  recorded push in pushlog
+
+  $ cd ../server
+
+  $ hg log -G -T '{rev}:{node|short} {desc} {pushid} {pushuser}\n'
+  o  6:7955fa0a974c push 3 commit 3 3 hguser
+  |
+  o  5:e659d32b8f4c push 3 commit 2 3 hguser
+  |
+  o  4:54b41e41f338 push 3 commit 1 3 hguser
+  |
+  o  3:1589f1d81ece push 2 commit 3 2 hguser
+  |
+  o  2:36bfb0990e62 push 2 commit 2 2 hguser
+  |
+  o  1:5087d8fb2e3b push 2 commit 1 2 hguser
+  |
+  o  0:77538e1ce4be initial 1 hguser
+  
+
+Requesting to discard a changeset in middle of push should drop descendants in that push
+
+  $ hg debugimpactedrevs --public 36bfb0990e62
+  will discard 36bfb0990e62 (requested explicitly): push 2 commit 2
+  will discard 1589f1d81ece (shares push with discarded changeset): push 2 commit 3
+  will retain 54b41e41f338: push 3 commit 1
+  will retain e659d32b8f4c: push 3 commit 2
+  will retain 7955fa0a974c: push 3 commit 3
+
+Discarding first changeset in push should discard entire push
+
+  $ hg debugimpactedrevs --public 54b41e41f338
+  will discard 54b41e41f338 (requested explicitly): push 3 commit 1
+  will discard e659d32b8f4c (shares push with discarded changeset): push 3 commit 2
+  will discard 7955fa0a974c (shares push with discarded changeset): push 3 commit 3
+
+Discarding a changeset containing a file in a subsequent push should discard
+subsequent changeset and all changesets after it in push
+
+  $ hg debugimpactedrevs --public 5087d8fb2e3b
+  will discard 5087d8fb2e3b (requested explicitly): push 2 commit 1
+  will discard 36bfb0990e62 (shares push with discarded changeset): push 2 commit 2
+  will discard 1589f1d81ece (shares push with discarded changeset): push 2 commit 3
+  will discard 54b41e41f338 (changes impacted files): push 3 commit 1
+  will discard e659d32b8f4c (shares push with discarded changeset): push 3 commit 2
+  will discard 7955fa0a974c (shares push with discarded changeset): push 3 commit 3
+
+Tip-most changeset in a push can be discarded in isolation
+
+  $ hg debugimpactedrevs --public 1589f1d81ece
+  will discard 1589f1d81ece (requested explicitly): push 2 commit 3
+  will retain 54b41e41f338: push 3 commit 1
+  will retain e659d32b8f4c: push 3 commit 2
+  will retain 7955fa0a974c: push 3 commit 3
+
+  $ hg debugimpactedrevs --public 1589f1d81ece 7955fa0a974c
+  will discard 1589f1d81ece (requested explicitly): push 2 commit 3
+  will retain 54b41e41f338: push 3 commit 1
+  will retain e659d32b8f4c: push 3 commit 2
+  will discard 7955fa0a974c (requested explicitly): push 3 commit 3
+
+Discarding a changeset without a pushlog entry is handled properly
+
+  $ hg -q up tip
+  $ echo 6 > file6
+  $ hg -q commit -A -m 'no push commit 1'
+  $ echo 7 > file7
+  $ hg -q commit -A -m 'no push commit 2'
+
+  $ hg shortlog
+  @  8:98c1495c00a3 no push commit 2
+  |
+  o  7:271e364060fb no push commit 1
+  |
+  o  6:7955fa0a974c push 3 commit 3
+  |
+  o  5:e659d32b8f4c push 3 commit 2
+  |
+  o  4:54b41e41f338 push 3 commit 1
+  |
+  o  3:1589f1d81ece push 2 commit 3
+  |
+  o  2:36bfb0990e62 push 2 commit 2
+  |
+  o  1:5087d8fb2e3b push 2 commit 1
+  |
+  o  0:77538e1ce4be initial
+  
+
+  $ hg debugimpactedrevs 271e364060fb
+  will discard 271e364060fb (requested explicitly): no push commit 1
+  will retain 98c1495c00a3: no push commit 2
+
+  $ hg debugimpactedrevs 98c1495c00a3
+  will discard 98c1495c00a3 (requested explicitly): no push commit 2
new file mode 100644
--- /dev/null
+++ b/hgext/mozbackout/tests/test-impacted-repo-preconditions.t
@@ -0,0 +1,29 @@
+  $ . $TESTDIR/hgext/mozbackout/tests/helpers.sh
+  $ enablemozbackout
+
+Cannot run on empty repo
+
+  $ backoutrepo empty
+  $ cd empty
+  $ hg debugimpactedrevs tip
+  abort: cannot run on empty repos
+  [255]
+  $ cd ..
+
+Cannot operate on repo with multiple heads
+
+  $ backoutrepo multiple-heads
+  $ cd multiple-heads
+  $ touch foo
+  $ hg -q commit -A -m initial
+  $ touch bar
+  $ hg -q commit -A -m head1
+  $ hg -q up -r 0
+  $ touch baz
+  $ hg -q commit -A -m head2
+
+  $ hg debugimpactedrevs tip
+  abort: cannot operate on repo with multiple heads
+  [255]
+
+  $ cd ..
new file mode 100644
--- /dev/null
+++ b/hgext/mozbackout/tests/test-impacted-revision-validation.t
@@ -0,0 +1,45 @@
+  $ . $TESTDIR/hgext/mozbackout/tests/helpers.sh
+  $ enablemozbackout
+
+  $ hg init simple
+  $ cd simple
+
+  $ touch foo
+  $ hg -q commit -A -m initial
+  $ hg phase --public -r .
+  $ echo 0 > file0
+  $ hg -q commit -A -m 'file0 0'
+  $ hg phase --public -r .
+
+  $ hg shortlog
+  @  1:7a649de10bd2 file0 0
+  |
+  o  0:77538e1ce4be initial
+  
+
+Unknown revision aborts
+
+  $ hg debugimpactedrevs unknown
+  abort: unknown revision 'unknown'!
+  [255]
+
+  $ hg debugimpactedrevs 42
+  abort: unknown revision '42'!
+  [255]
+
+  $ hg debugimpactedrevs 'author(gps)'
+  abort: could not find revisions to discard
+  (arguments must be revisions or revsets that resolve to known changesets)
+  [255]
+
+Cannot discard public changesets by default
+
+  $ hg debugimpactedrevs 7a649de10bd2
+  (warning: pushlog not available; unable to identify revisions in same push)
+  cannot discard public changeset: 7a649de10bd2 file0 0
+  abort: cannot perform discard on selected revisions
+  [255]
+
+  $ hg debugimpactedrevs --public 7a649de10bd2
+  (warning: pushlog not available; unable to identify revisions in same push)
+  will discard 7a649de10bd2 (requested explicitly): file0 0