mozext: add `hg oops` command from `qbackout` (Bug 1565933) r=smacleod
authorConnor Sheehan <sheehan@mozilla.com>
Wed, 31 Jul 2019 21:08:43 +0000
changeset 7134 4ed18e15bb1006047e35cb31f066e853ee915c48
parent 7133 790b45463fd4c7c21e9bcc9872d82a62ddc85f39
child 7135 c3b65e6e8bfbc8c35c2a2980a5f7b001a519c209
push id3551
push usercosheehan@mozilla.com
push dateWed, 31 Jul 2019 21:22:35 +0000
treeherderversion-control-tools@5e857d4f3091 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerssmacleod
bugs1565933
mozext: add `hg oops` command from `qbackout` (Bug 1565933) r=smacleod In changeset b3a88dcabb6cccf0 we removed the `mq` related extensions still checked in to version-control-tools. It turns out that the `qbackout` extension has a command `hg oops` which is not dependent on `mq` and is used heavily in the sheriffing workflow. This commit moves the extension into `mozext` to keep it alive for the sheriffs. Minimal changes have been made from the version in `qbackout`, and the test is completely unmodified. Differential Revision: https://phabricator.services.mozilla.com/D39953
hgext/mozext/__init__.py
hgext/mozext/tests/test-oops.t
--- a/hgext/mozext/__init__.py
+++ b/hgext/mozext/__init__.py
@@ -243,42 +243,50 @@ mozext.reject_pushes_with_repo_names
    and ``inbound/foobar`` will be rejected because they begin with the names
    of official Mozilla repositories. However, pushes to the bookmark
    ``gps/test`` will be allowed.
 """
 
 import calendar
 import datetime
 import errno
+import gc
 import os
-import shutil
+import re
+import sys
 
 from operator import methodcaller
 
 from mercurial.i18n import _
 from mercurial.error import (
     ParseError,
     RepoError,
 )
 from mercurial.localrepo import (
     repofilecache,
 )
 from mercurial.node import (
     bin,
     hex,
+    nullid,
     short,
 )
 from mercurial import (
+    cmdutil,
+    commands,
     configitems,
     demandimport,
     encoding,
     error,
     exchange,
     extensions,
     hg,
+    mdiff,
+    patch,
+    pycompat,
     registrar,
     revset,
     scmutil,
     sshpeer,
     templatefilters,
     templatekw,
     util,
 )
@@ -328,16 +336,17 @@ def makeprogress(ui, *args, **kwargs):
 # Disable demand importing for mozautomation because "requests" doesn't
 # play nice with the demand importer.
 with demandimport.deactivated():
     from mozautomation.changetracker import (
         ChangeTracker,
     )
 
     from mozautomation.commitparser import (
+        BUG_CONSERVATIVE_RE,
         parse_backouts,
         parse_bugs,
         parse_reviewers,
     )
 
     from mozautomation.repository import (
         MercurialRepository,
         RELEASE_TREES,
@@ -376,16 +385,19 @@ configitem('mozext', 'backoutsearchlimit
            default=configitems.dynamicdefault)
 
 colortable = {
     'buildstatus.success': 'green',
     'buildstatus.failed': 'red',
     'buildstatus.testfailed': 'cyan',
 }
 
+backout_re = re.compile(r'[bB]ack(?:ed)?(?: ?out) (?:(?:changeset|revision|rev) )?([a-fA-F0-9]{8,40})')
+reapply_re = re.compile(r'Reapplied (?:(?:changeset|revision|rev) )?([a-fA-F0-9]{8,40})')
+
 
 def get_ircnick(ui):
     headless = ui.configbool('mozext', 'headless')
     ircnick = ui.config('mozext', 'ircnick')
     if not ircnick and not headless:
         raise error.Abort(_('Set "[mozext] ircnick" in your hgrc to your '
             'Mozilla IRC nickname to enable additional functionality.'))
     return ircnick
@@ -1334,16 +1346,205 @@ def template_dates(context, mapping, arg
             args[1][1]))
     if len(args) > 2:
         sep = templatefilters.stringify(args[2][0](context, mapping,
             args[2][1]))
 
     return sep.join(util.datestr(d, fmt) for d in args[0][0](context, mapping,
         args[0][1]))
 
+def do_backout(ui, repo, rev, handle_change, commit_change, use_mq=False, reverse_order=False, **opts):
+    if not opts.get('force'):
+        ui.status('checking for uncommitted changes\n')
+        cmdutil.bailifchanged(repo)
+    backout = not opts.get('apply')
+    desc = {'action': 'backout',
+            'Actioned': 'Backed out',
+            'actioning': 'backing out',
+            'name': 'backout'
+            }
+    if not backout:
+        desc = {'action': 'apply',
+                'Actioned': 'Reapplied',
+                'actioning': 'Reapplying',
+                'name': 'patch'
+                }
+
+    rev = scmutil.revrange(repo, rev)
+    if len(rev) == 0:
+        raise error.Abort('at least one revision required')
+
+    csets = [repo[r] for r in rev]
+    csets.sort(reverse=reverse_order, key=lambda cset: cset.rev())
+
+    new_opts = opts.copy()
+
+    def bugs_suffix(bugs):
+        if len(bugs) == 0:
+            return ''
+        elif len(bugs) == 1:
+            return ' (bug ' + list(bugs)[0] + ')'
+        else:
+            return ' (' + ', '.join(map(lambda b: 'bug %s' % b, bugs)) + ')'
+
+    def parse_bugs(msg):
+        bugs = set()
+        m = BUG_CONSERVATIVE_RE.search(msg)
+        if m:
+            bugs.add(m.group(2))
+        return bugs
+
+    def apply_change(node, reverse, push_patch=True, name=None):
+        p1, p2 = repo.changelog.parents(node)
+        if p2 != nullid:
+            raise error.Abort('cannot %s a merge changeset' % desc['action'])
+
+        opts = mdiff.defaultopts
+        opts.git = True
+        rpatch = pycompat.stringio()
+        orig, mod = (node, p1) if reverse else (p1, node)
+        for chunk in patch.diff(repo, node1=orig, node2=mod, opts=opts):
+            rpatch.write(chunk)
+        rpatch.seek(0)
+
+        saved_stdin = None
+        try:
+            save_fin = ui.fin
+            ui.fin = rpatch
+        except:
+            # Old versions of hg did not use the ui.fin mechanism
+            saved_stdin = sys.stdin
+            sys.stdin = rpatch
+
+        handle_change(desc, node, qimport=(use_mq and new_opts.get('nopush')))
+
+        if saved_stdin is None:
+            ui.fin = save_fin
+        else:
+            sys.stdin = saved_stdin
+
+    allbugs = set()
+    messages = []
+    for cset in csets:
+        # Hunt down original description if we might want to use it
+        orig_desc = None
+        orig_desc_cset = None
+        orig_author = None
+        r = cset
+        while len(csets) == 1 or not opts.get('single'):
+            ui.debug("Parsing message for %s\n" % short(r.node()))
+            m = backout_re.match(r.description())
+            if m:
+                ui.debug("  looks like a backout of %s\n" % m.group(1))
+            else:
+                m = reapply_re.match(r.description())
+                if m:
+                    ui.debug("  looks like a reapply of %s\n" % m.group(1))
+                else:
+                    ui.debug("  looks like the original description\n")
+                    orig_desc = r.description()
+                    orig_desc_cset = r
+                    orig_author = r.user()
+                    break
+            r = scmutil.revsingle(repo, m.group(1))
+
+        bugs = parse_bugs(cset.description())
+        allbugs.update(bugs)
+        node = cset.node()
+        shortnode = short(node)
+        ui.status('%s %s\n' % (desc['actioning'], shortnode))
+
+        apply_change(node, backout, push_patch=(not opts.get('nopush')))
+
+        msg = ('%s changeset %s' % (desc['Actioned'], shortnode)) + bugs_suffix(bugs)
+        user = None
+
+        if backout:
+            # If backing out a backout, reuse the original commit message & author.
+            if orig_desc_cset is not None and orig_desc_cset != cset:
+                msg = orig_desc
+                user = orig_author
+        else:
+            # If reapplying the original change, reuse the original commit message & author.
+            if orig_desc_cset is not None and orig_desc_cset == cset:
+                msg = orig_desc
+                user = orig_author
+
+        messages.append(msg)
+        if not opts.get('single') and not opts.get('nopush'):
+            new_opts['message'] = messages[-1]
+            # Override the user to that of the original patch author in the case of --apply
+            if user is not None:
+                new_opts['user'] = user
+            commit_change(ui, repo, desc['name'], node=node, force_name=opts.get('name'), **new_opts)
+
+        # Iterations of this loop appear to leak memory for unknown reasons.
+        # Work around it by forcing a gc.
+        gc.collect()
+
+    msg = ('%s %d changesets' % (desc['Actioned'], len(rev))) + bugs_suffix(allbugs) + '\n'
+    messages.insert(0, msg)
+    new_opts['message'] = "\n".join(messages)
+    if opts.get('single'):
+
+        commit_change(ui, repo, desc['name'], revisions=rev, force_name=opts.get('name'), **new_opts)
+
+
+@command('oops', [
+    ('r', 'rev', [], _('revisions to backout')),
+    ('s', 'single', None, _('fold all backed out changes into a single changeset')),
+    ('f', 'force', None, _('skip check for outstanding uncommitted changes')),
+    ('e', 'edit', None, _('edit commit messages')),
+    ('m', 'message', '', _('use text as commit message'), _('TEXT')),
+    ('U', 'currentuser', None, _('add "From: <current user>" to patch')),
+    ('u', 'user', '',
+     _('add "From: <USER>" to patch'), _('USER')),
+    ('D', 'currentdate', None, _('add "Date: <current date>" to patch')),
+    ('d', 'date', '',
+     _('add "Date: <DATE>" to patch'), _('DATE'))],
+    _('hg oops -r REVS [-f] [commit options]'))
+def oops(ui, repo, rev, **opts):
+    """backout a change or set of changes
+
+    oops commits a changeset or set of changesets by undoing existing changesets.
+    If the -s/--single option is set, then all backed-out changesets
+    will be rolled up into a single backout changeset. Otherwise, there will
+    be one changeset queued up for each backed-out changeset.
+
+    Note that if you want to reapply a previously backed out patch, use
+    hg graft -f.
+
+    Examples:
+      hg oops -r 20 -r 30    # backout revisions 20 and 30
+
+      hg oops -r 20+30       # backout revisions 20 and 30
+
+      hg oops -r 20+30:32    # backout revisions 20, 30, 31, and 32
+
+      hg oops -r a3a81775    # the usual revision syntax is available
+
+    See "hg help revisions" and "hg help revsets" for more about specifying
+    revisions.
+    """
+    def handle_change(desc, node, **kwargs):
+        commands.import_(ui, repo, '-',
+                         force=True,
+                         no_commit=True,
+                         strip=1,
+                         base='',
+                         prefix='',
+                         obsolete=[])
+
+    def commit_change(ui, repo, action, force_name=None, node=None, revisions=None, **opts):
+        commands.commit(ui, repo, **opts)
+
+    do_backout(ui, repo, rev,
+               handle_change, commit_change, reverse_order=(not opts.get('apply')), **opts)
+
+
 def extsetup(ui):
     extensions.wrapfunction(exchange, 'pull', wrappedpull)
     extensions.wrapfunction(exchange, 'push', wrappedpush)
     extensions.wrapfunction(exchange, '_pullobsolete', exchangepullpushlog)
     extensions.wrapfunction(hg, '_peerorrepo', wrapped_peerorrepo)
 
     if not ui.configbool('mozext', 'disable_local_database'):
         revsetpredicate('pushhead([TREE])')(revset_pushhead)
new file mode 100644
--- /dev/null
+++ b/hgext/mozext/tests/test-oops.t
@@ -0,0 +1,144 @@
+  $ cat >> $HGRCPATH << EOF
+  > [extensions]
+  > mozext = $TESTDIR/hgext/mozext
+  > EOF
+
+  $ hg init repo
+  $ cd repo
+  $ echo line1 > file1.txt
+  $ echo line1 > file2.txt
+  $ hg add file1.txt file2.txt
+  $ hg commit -m initial -u author1 # rev 0
+  $ echo line2 >> file1.txt
+  $ hg commit -m 'commit 2' -u author2 # rev 1
+  $ echo line2 >> file2.txt
+  $ hg commit -m 'commit 3' -u author3 # rev 2
+
+Single-commit backout:
+
+  $ hg oops -r 1
+  checking for uncommitted changes
+  backing out 22355b867c01
+  applying patch from stdin
+  $ hg log -r . --template '{desc}\n'
+  Backed out changeset 22355b867c01
+
+Reapply single-commit backout:
+
+  $ hg graft -f -r 1
+  grafting * (glob)
+  merging file1.txt
+  $ hg log -r . --template '{desc}\n'
+  commit 2
+
+Multi-commit backout:
+
+  $ hg oops -r 1:2
+  checking for uncommitted changes
+  backing out 5b367719b421
+  applying patch from stdin
+  backing out 22355b867c01
+  applying patch from stdin
+  $ hg log --template '{desc}\n' --limit 2
+  Backed out changeset 22355b867c01
+  Backed out changeset 5b367719b421
+
+Reapply buried backout:
+
+  $ hg graft -f -r 1
+  grafting * (glob)
+  merging file1.txt
+
+Get back to original state:
+
+  $ hg graft -f -r 2
+  grafting * (glob)
+  merging file2.txt
+
+Folding together multiple commits into a single backout changeset:
+
+  $ hg oops -r 1:2 -s
+  checking for uncommitted changes
+  backing out 5b367719b421
+  applying patch from stdin
+  backing out 22355b867c01
+  applying patch from stdin
+  $ hg log --template '{desc}\n' -r .
+  Backed out 2 changesets
+  
+  Backed out changeset 5b367719b421
+  Backed out changeset 22355b867c01
+
+Backing out backout
+
+  $ hg oops -r 3
+  checking for uncommitted changes
+  backing out 9dc236bc7914
+  applying patch from stdin
+
+Backouts should be 'test' user, re-applies should be original user:
+
+  $ hg log --template '<{author}> {desc|firstline}\n'
+  <author2> commit 2
+  <test> Backed out 2 changesets
+  <author3> commit 3
+  <author2> commit 2
+  <test> Backed out changeset 22355b867c01
+  <test> Backed out changeset 5b367719b421
+  <author2> commit 2
+  <test> Backed out changeset 22355b867c01
+  <author3> commit 3
+  <author2> commit 2
+  <author1> initial
+
+Clean up
+
+  $ hg graft -f -r 1+2
+  grafting * (glob)
+  note: graft of 1:22355b867c01 created no changes to commit
+  grafting * (glob)
+  merging file2.txt
+
+Patches should be automatically sorted into correct order:
+
+  $ hg oops -r 1+2
+  checking for uncommitted changes
+  backing out 5b367719b421
+  applying patch from stdin
+  backing out 22355b867c01
+  applying patch from stdin
+  $ hg log --template '{desc}\n' --limit 2
+  Backed out changeset 22355b867c01
+  Backed out changeset 5b367719b421
+  $ hg graft -f -r 1+2
+  grafting * (glob)
+  merging file1.txt
+  grafting * (glob)
+  merging file2.txt
+  $ hg oops -r 2+1
+  checking for uncommitted changes
+  backing out 5b367719b421
+  applying patch from stdin
+  backing out 22355b867c01
+  applying patch from stdin
+  $ hg log --template '{desc}\n' --limit 2
+  Backed out changeset 22355b867c01
+  Backed out changeset 5b367719b421
+  $ hg graft -f -r 1+2
+  grafting * (glob)
+  merging file1.txt
+  grafting * (glob)
+  merging file2.txt
+
+Some error cases
+
+  $ hg oops
+  checking for uncommitted changes
+  abort: at least one revision required
+  [255]
+  $ hg oops -s
+  checking for uncommitted changes
+  abort: at least one revision required
+  [255]
+  $ hg oops --nopush -r 1 2>&1 | head -1
+  hg oops: option --nopush not recognized