1# backout.py - Backout dialog for TortoiseHg
2#
3# Copyright 2010 Yuki KODAMA <endflow.net@gmail.com>
4#
5# This software may be used and distributed according to the terms of the
6# GNU General Public License version 2, incorporated herein by reference.
7
8from __future__ import absolute_import
9
10from mercurial import (
11    pycompat,
12)
13
14from .qtcore import (
15    QSettings,
16    QSize,
17    Qt,
18    pyqtSlot,
19)
20from .qtgui import (
21    QAction,
22    QCheckBox,
23    QHBoxLayout,
24    QLabel,
25    QProgressBar,
26    QVBoxLayout,
27    QWizard,
28    QWizardPage,
29)
30
31from ..util import (
32    hglib,
33    i18n,
34)
35from ..util.i18n import _
36from . import (
37    cmdcore,
38    cmdui,
39    csinfo,
40    qscilib,
41    qtlib,
42    messageentry,
43    resolve,
44    status,
45    thgrepo,
46    wctxcleaner,
47)
48
49if hglib.TYPE_CHECKING:
50    from typing import (
51        Any,
52        Optional,
53        Text,
54    )
55    from mercurial import (
56        localrepo,
57    )
58    from .qtgui import (
59        QWidget,
60    )
61    from .thgrepo import (
62        RepoAgent,
63    )
64    from ..util.typelib import (
65        HgContext,
66    )
67
68
69def checkrev(repo, rev):
70    # type: (localrepo.localrepository, int) -> Optional[Text]
71    op1, op2 = repo.dirstate.parents()
72    if op1 is None:
73        return _('Backout requires a parent revision')
74
75    bctx = repo[rev]
76    a = repo.changelog.ancestor(op1, bctx.node())
77    if a != bctx.node():
78        return _('Cannot backout change on a different branch')
79
80
81class BackoutDialog(QWizard):
82
83    def __init__(self, repoagent, rev, parent=None):
84        # type: (RepoAgent, int, Optional[QWidget]) -> None
85        super(BackoutDialog, self).__init__(parent)
86        self._repoagent = repoagent
87        f = self.windowFlags()
88        self.setWindowFlags(f & ~Qt.WindowContextHelpButtonHint)
89
90        repo = repoagent.rawRepo()
91        parentbackout = repo[rev] == repo[b'.']
92
93        self.setWindowTitle(_('Backout - %s') % repoagent.displayName())
94        self.setWindowIcon(qtlib.geticon('hg-revert'))
95        self.setOption(QWizard.NoBackButtonOnStartPage, True)
96        self.setOption(QWizard.NoBackButtonOnLastPage, True)
97        self.setOption(QWizard.IndependentPages, True)
98
99        self.addPage(SummaryPage(repoagent, rev, parentbackout, self))
100        self.addPage(BackoutPage(repoagent, rev, parentbackout, self))
101        self.addPage(CommitPage(repoagent, rev, parentbackout, self))
102        self.addPage(ResultPage(repoagent, self))
103        self.currentIdChanged.connect(self.pageChanged)
104
105        self.resize(QSize(700, 489).expandedTo(self.minimumSizeHint()))
106
107        repoagent.repositoryChanged.connect(self.repositoryChanged)
108        repoagent.configChanged.connect(self.configChanged)
109
110        self._readSettings()
111
112    def _readSettings(self):
113        # type: () -> None
114        qs = QSettings()
115        qs.beginGroup('backout')
116        for n in ['autoadvance', 'skiplast']:
117            self.setField(n, qs.value(n, False))
118        n = 'autoresolve'
119        self.setField(
120            n,
121            self._repoagent.configBool('tortoisehg', n,
122                                       qtlib.readBool(qs, n, True)))
123        qs.endGroup()
124
125    def _writeSettings(self):
126        # type: () -> None
127        qs = QSettings()
128        qs.beginGroup('backout')
129        for n in ['autoadvance', 'autoresolve', 'skiplast']:
130            qs.setValue(n, self.field(n))
131        qs.endGroup()
132
133    @pyqtSlot()
134    def repositoryChanged(self):
135        # type: () -> None
136        self.currentPage().repositoryChanged()
137
138    @pyqtSlot()
139    def configChanged(self):
140        # type: () -> None
141        self.currentPage().configChanged()
142
143    def pageChanged(self, id):
144        # type: (int) -> None
145        if id != -1:
146            self.currentPage().currentPage()
147
148    def reject(self):
149        # type: () -> None
150        if self.currentPage().canExit():
151            super(BackoutDialog, self).reject()
152
153    def done(self, r):
154        # type: (int) -> None
155        self._writeSettings()
156        super(BackoutDialog, self).done(r)
157
158
159class BasePage(QWizardPage):
160    def __init__(self, repoagent, parent):
161        # type: (RepoAgent, Optional[QWidget]) -> None
162        super(BasePage, self).__init__(parent)
163        self._repoagent = repoagent
164
165    @property
166    def repo(self):
167        # type: () -> localrepo.localrepository
168        return self._repoagent.rawRepo()
169
170    def validatePage(self):
171        # type: () -> bool
172        'user pressed NEXT button, can we proceed?'
173        return True
174
175    def isComplete(self):
176        # type: () -> bool
177        'should NEXT button be sensitive?'
178        return True
179
180    def repositoryChanged(self):
181        # type: () -> None
182        'repository has detected a change to changelog or parents'
183        pass
184
185    def configChanged(self):
186        # type: () -> None
187        'repository has detected a change to config files'
188        pass
189
190    def currentPage(self):
191        # type: () -> None
192        pass
193
194    def canExit(self):
195        # type: () -> bool
196        return True
197
198
199class SummaryPage(BasePage):
200
201    def __init__(self, repoagent, backoutrev, parentbackout, parent):
202        # type: (RepoAgent, int, bool, Optional[QWidget]) -> None
203        super(SummaryPage, self).__init__(repoagent, parent)
204        self._wctxcleaner = wctxcleaner.WctxCleaner(repoagent, self)
205        self._wctxcleaner.checkStarted.connect(self._onCheckStarted)
206        self._wctxcleaner.checkFinished.connect(self._onCheckFinished)
207        self.setTitle(_('Prepare to backout'))
208        self.setSubTitle(_('Verify backout revision and ensure your working '
209                           'directory is clean.'))
210        self.setLayout(QVBoxLayout())
211
212        self.groups = qtlib.WidgetGroups()
213
214        repo = self.repo
215        bctx = repo[backoutrev]
216        pctx = repo[b'.']
217
218        if parentbackout:
219            lbl = _('Backing out a parent revision is a single step operation')
220            self.layout().addWidget(QLabel(u'<b>%s</b>' % lbl))
221
222        ## backout revision
223        style = csinfo.panelstyle(contents=csinfo.PANEL_DEFAULT)
224        create = csinfo.factory(repo, None, style, withupdate=True)
225        sep = qtlib.LabeledSeparator(_('Backout revision'))
226        self.layout().addWidget(sep)
227        backoutCsInfo = create(bctx.rev())
228        self.layout().addWidget(backoutCsInfo)
229
230        ## current revision
231        contents = ('ishead',) + csinfo.PANEL_DEFAULT
232        style = csinfo.panelstyle(contents=contents)
233        def markup_func(widget, item, value):
234            if item == 'ishead' and value is False:
235                text = _('Not a head, backout will create a new head!')
236                return qtlib.markup(text, fg='red', weight='bold')
237            raise csinfo.UnknownItem(item)
238        custom = csinfo.custom(markup=markup_func)
239        create = csinfo.factory(repo, custom, style, withupdate=True)
240
241        sep = qtlib.LabeledSeparator(_('Current local revision'))
242        self.layout().addWidget(sep)
243        localCsInfo = create(pctx.rev())
244        self.layout().addWidget(localCsInfo)
245        self.localCsInfo = localCsInfo
246
247        ## working directory status
248        sep = qtlib.LabeledSeparator(_('Working directory status'))
249        self.layout().addWidget(sep)
250
251        wdbox = QHBoxLayout()
252        self.layout().addLayout(wdbox)
253        self.wd_status = qtlib.StatusLabel()
254        self.wd_status.set_status(_('Checking...'))
255        wdbox.addWidget(self.wd_status)
256        wd_prog = QProgressBar()
257        wd_prog.setMaximum(0)
258        wd_prog.setTextVisible(False)
259        self.groups.add(wd_prog, 'prog')
260        wdbox.addWidget(wd_prog, 1)
261
262        text = _('Before backout, you must <a href="commit"><b>commit</b></a>, '
263                 '<a href="shelve"><b>shelve</b></a> to patch, '
264                 'or <a href="discard"><b>discard</b></a> changes.')
265        wd_text = QLabel(text)
266        wd_text.setWordWrap(True)
267        wd_text.linkActivated.connect(self._wctxcleaner.runCleaner)
268        self.wd_text = wd_text
269        self.groups.add(wd_text, 'dirty')
270        self.layout().addWidget(wd_text)
271
272        ## auto-resolve
273        autoresolve_chk = QCheckBox(_('Automatically resolve merge conflicts '
274                                      'where possible'))
275        self.registerField('autoresolve', autoresolve_chk)
276        self.layout().addWidget(autoresolve_chk)
277        self.groups.set_visible(False, 'dirty')
278
279    def isComplete(self):
280        # type: () -> bool
281        'should Next button be sensitive?'
282        return self._wctxcleaner.isClean()
283
284    def repositoryChanged(self):
285        # type: () -> None
286        'repository has detected a change to changelog or parents'
287        pctx = self.repo[b'.']
288        self.localCsInfo.update(pctx)
289
290    def canExit(self):
291        # type: () -> bool
292        'can backout tool be closed?'
293        if self._wctxcleaner.isChecking():
294            self._wctxcleaner.cancelCheck()
295        return True
296
297    def currentPage(self):
298        # type: () -> None
299        self.refresh()
300
301    def refresh(self):
302        # type: () -> None
303        self._wctxcleaner.check()
304
305    @pyqtSlot()
306    def _onCheckStarted(self):
307        # type: () -> None
308        self.groups.set_visible(True, 'prog')
309
310    @pyqtSlot(bool)
311    def _onCheckFinished(self, clean):
312        # type: (bool) -> None
313        self.groups.set_visible(False, 'prog')
314        if self._wctxcleaner.isCheckCanceled():
315            return
316        if not clean:
317            self.groups.set_visible(True, 'dirty')
318            self.wd_status.set_status(_('<b>Uncommitted local changes '
319                                        'are detected</b>'), 'thg-warning')
320        else:
321            self.groups.set_visible(False, 'dirty')
322            self.wd_status.set_status(_('Clean'), True)
323        self.completeChanged.emit()
324
325
326class BackoutPage(BasePage):
327    def __init__(self, repoagent, backoutrev, parentbackout, parent):
328        # type: (RepoAgent, int, bool, Optional[QWidget]) -> None
329        super(BackoutPage, self).__init__(repoagent, parent)
330        self._backoutrev = backoutrev
331        self._parentbackout = parentbackout
332        self.backoutcomplete = False
333
334        self.setTitle(_('Backing out, then merging...'))
335        self.setSubTitle(_('All conflicting files will be marked unresolved.'))
336        self.setLayout(QVBoxLayout())
337
338        self._cmdlog = cmdui.LogWidget(self)
339        self.layout().addWidget(self._cmdlog)
340
341        self.reslabel = QLabel()
342        self.reslabel.linkActivated.connect(self.onLinkActivated)
343        self.reslabel.setWordWrap(True)
344        self.layout().addWidget(self.reslabel)
345
346        autonext = QCheckBox(_('Automatically advance to next page '
347                               'when backout and merge are complete.'))
348        autonext.clicked.connect(self.tryAutoAdvance)
349        self.registerField('autoadvance', autonext)
350        self.layout().addWidget(autonext)
351
352    def currentPage(self):
353        # type: () -> None
354        if self._parentbackout:
355            self.wizard().next()
356            return
357        tool = self.field('autoresolve') and ':merge' or ':fail'
358        cmdline = hglib.buildcmdargs('backout', self._backoutrev, tool=tool,
359                                     no_commit=True)
360        self._cmdlog.clearLog()
361        sess = self._repoagent.runCommand(cmdline, self)
362        sess.commandFinished.connect(self.onCommandFinished)
363        sess.outputReceived.connect(self._cmdlog.appendLog)
364
365    def isComplete(self):
366        # type: () -> bool
367        'should Next button be sensitive?'
368        if not self.backoutcomplete:
369            return False
370        count = 0
371        for root, path, status in thgrepo.recursiveMergeStatus(self.repo):
372            if status == b'u':
373                count += 1
374        if count:
375            # if autoresolve is enabled, we know these were real conflicts
376            self.reslabel.setText(_('%d files have <b>merge conflicts</b> '
377                                    'that must be <a href="resolve">'
378                                    '<b>resolved</b></a>') % count)
379            return False
380        else:
381            self.reslabel.setText(_('No merge conflicts, ready to commit'))
382            return True
383
384    @pyqtSlot(bool)
385    def tryAutoAdvance(self, checked):
386        # type: (bool) -> None
387        if checked and self.isComplete():
388            self.wizard().next()
389
390    @pyqtSlot(int)
391    def onCommandFinished(self, ret):
392        # type: (int) -> None
393        if ret in (0, 1):
394            self.backoutcomplete = True
395            if self.field('autoadvance'):
396                self.tryAutoAdvance(True)
397            self.completeChanged.emit()
398
399    @pyqtSlot(str)
400    def onLinkActivated(self, cmd):
401        # type: (Text) -> None
402        if cmd == 'resolve':
403            dlg = resolve.ResolveDialog(self._repoagent, self)
404            dlg.exec_()
405            if self.field('autoadvance'):
406                self.tryAutoAdvance(True)
407            self.completeChanged.emit()
408
409
410class CommitPage(BasePage):
411
412    def __init__(self, repoagent, backoutrev, parentbackout, parent):
413        # type: (RepoAgent, int, bool, Optional[QWidget]) -> None
414        super(CommitPage, self).__init__(repoagent, parent)
415        self._backoutrev = backoutrev
416        self._parentbackout = parentbackout
417        self.commitComplete = False
418
419        self.setTitle(_('Commit backout and merge results'))
420        self.setSubTitle(' ')
421        self.setLayout(QVBoxLayout())
422        self.setCommitPage(True)
423
424        repo = repoagent.rawRepo()
425
426        # pytype: disable=redundant-function-type-comment
427        # csinfo
428        def label_func(widget, item, ctx):
429            # type: (Any, Text, HgContext) -> Any
430            if item == 'rev':
431                return _('Revision:')
432            elif item == 'parents':
433                return _('Parents')
434            raise csinfo.UnknownItem()
435        def data_func(widget, item, ctx):
436            # type: (Any, Text, HgContext) -> Any
437            if item == 'rev':
438                return _('Working Directory'), str(ctx)
439            elif item == 'parents':
440                parents = []
441                cbranch = ctx.branch()
442                for pctx in ctx.parents():
443                    branch = None
444                    if hasattr(pctx, 'branch') and pctx.branch() != cbranch:
445                        branch = pctx.branch()
446                    # TODO: convert branch name to str like data_func() in
447                    #    revpanel?  The branch always seems to be None, because
448                    #    ctx always has one parent and is pctx with a '+',
449                    #    unless backing out in the middle of an uncommitted
450                    #    merge.
451                    parents.append((str(pctx.rev()), str(pctx), branch, pctx))
452                return parents
453            raise csinfo.UnknownItem()
454        def markup_func(widget, item, value):
455            if item == 'rev':
456                text, rev = value
457                if parentbackout:
458                    return '%s (%s)' % (text, rev)
459                else:
460                    return '<a href="view">%s</a> (%s)' % (text, rev)
461            elif item == 'parents':
462                def branch_markup(branch):
463                    # type: (Text) -> Text
464                    opts = dict(fg='black', bg='#aaffaa')
465                    return qtlib.markup(' %s ' % branch, **opts)
466                csets = []
467                for rnum, rid, branch, pctx in value:
468                    line = '%s (%s)' % (rnum, rid)
469                    if branch:
470                        line = '%s %s' % (line, branch_markup(branch))
471                    msg = widget.info.get_data('summary', widget,
472                                               pctx, widget.custom)
473                    if msg:
474                        line = '%s %s' % (line, msg)
475                    csets.append(line)
476                return csets
477            raise csinfo.UnknownItem()
478        # pytype: enable=redundant-function-type-comment
479
480        custom = csinfo.custom(label=label_func, data=data_func,
481                               markup=markup_func)
482        contents = ('rev', 'user', 'dateage', 'branch', 'parents')
483        style = csinfo.panelstyle(contents=contents, margin=6)
484
485        # merged files
486        rev_sep = qtlib.LabeledSeparator(_('Working Directory (merged)'))
487        self.layout().addWidget(rev_sep)
488        bkCsInfo = csinfo.create(repo, None, style, custom=custom,
489                                 withupdate=True)
490        bkCsInfo.linkActivated.connect(self.onLinkActivated)
491        self.layout().addWidget(bkCsInfo)
492
493        # commit message area
494        msg_sep = qtlib.LabeledSeparator(_('Commit message'))
495        self.layout().addWidget(msg_sep)
496        msgEntry = messageentry.MessageEntry(self)
497        msgEntry.installEventFilter(qscilib.KeyPressInterceptor(self))
498        msgEntry.refresh(repo)
499        msgEntry.loadSettings(QSettings(), 'backout/message')
500
501        msgEntry.textChanged.connect(self.completeChanged)
502        self.layout().addWidget(msgEntry)
503        self.msgEntry = msgEntry
504
505        self._cmdsession = cmdcore.nullCmdSession()
506        self._cmdlog = cmdui.LogWidget(self)
507        self._cmdlog.hide()
508        self.layout().addWidget(self._cmdlog)
509
510        def tryperform():
511            if self.isComplete():
512                self.wizard().next()
513        actionEnter = QAction('alt-enter', self)
514        actionEnter.setShortcuts([Qt.CTRL+Qt.Key_Return, Qt.CTRL+Qt.Key_Enter])
515        actionEnter.triggered.connect(tryperform)
516        self.addAction(actionEnter)
517
518        skiplast = QCheckBox(_('Skip final confirmation page, '
519                               'close after commit.'))
520        self.registerField('skiplast', skiplast)
521        self.layout().addWidget(skiplast)
522
523        def eng_toggled(checked):
524            if self.isComplete():
525                oldmsg = self.msgEntry.text()
526                msgset = i18n.keepgettext()._('Backed out changeset: ')
527                msg = checked and msgset['id'] or msgset['str']
528                if oldmsg and oldmsg != msg:
529                    if not qtlib.QuestionMsgBox(_('Confirm Discard Message'),
530                         _('Discard current backout message?'), parent=self):
531                        self.engChk.blockSignals(True)
532                        self.engChk.setChecked(not checked)
533                        self.engChk.blockSignals(False)
534                        return
535                self.msgEntry.setText(msg + str(self.repo[self._backoutrev]))
536                self.msgEntry.moveCursorToEnd()
537
538        self.engChk = QCheckBox(_('Use English backout message'))
539        self.engChk.toggled.connect(eng_toggled)
540        engmsg = repoagent.configBool('tortoisehg', 'engmsg')
541        self.engChk.setChecked(engmsg)
542        self.layout().addWidget(self.engChk)
543
544    def refresh(self):
545        # type: () -> None
546        pass
547
548    def cleanupPage(self):
549        # type: () -> None
550        s = QSettings()
551        self.msgEntry.saveSettings(s, 'backout/message')
552
553    def currentPage(self):
554        # type: () -> None
555        engmsg = self._repoagent.configBool('tortoisehg', 'engmsg')
556        msgset = i18n.keepgettext()._('Backed out changeset: ')
557        msg = engmsg and msgset['id'] or msgset['str']
558        self.msgEntry.setText(msg + str(self.repo[self._backoutrev]))
559        self.msgEntry.moveCursorToEnd()
560
561    @pyqtSlot(str)
562    def onLinkActivated(self, cmd):
563        # type: (Text) -> None
564        if cmd == 'view':
565            dlg = status.StatusDialog(self._repoagent, [], {}, self)
566            dlg.exec_()
567            self.refresh()
568
569    def isComplete(self):
570        # type: () -> bool
571        return len(self.msgEntry.text()) > 0
572
573    def validatePage(self):
574        # type: () -> bool
575        if self.commitComplete:
576            # commit succeeded, repositoryChanged() called wizard().next()
577            if self.field('skiplast'):
578                self.wizard().close()
579            return True
580        if not self._cmdsession.isFinished():
581            return False
582
583        user = qtlib.getCurrentUsername(self, self.repo)
584        if not user:
585            return False
586
587        if self._parentbackout:
588            self.setTitle(_('Backing out and committing...'))
589            self.setSubTitle(_('Please wait while making backout.'))
590            message = pycompat.unicode(self.msgEntry.text())
591            cmdline = hglib.buildcmdargs('backout', self._backoutrev,
592                                         verbose=True,
593                                         message=message, user=user)
594        else:
595            self.setTitle(_('Committing...'))
596            self.setSubTitle(_('Please wait while committing merged files.'))
597            message = pycompat.unicode(self.msgEntry.text())
598            cmdline = hglib.buildcmdargs('commit', verbose=True,
599                                         message=message, user=user)
600        commandlines = [cmdline]
601        pushafter = self._repoagent.configString('tortoisehg', 'cipushafter')
602        if pushafter:
603            cmd = ['push', pushafter]
604            commandlines.append(cmd)
605
606        self._cmdlog.show()
607        sess = self._repoagent.runCommandSequence(commandlines, self)
608        self._cmdsession = sess
609        sess.commandFinished.connect(self.onCommandFinished)
610        sess.outputReceived.connect(self._cmdlog.appendLog)
611        return False
612
613    @pyqtSlot(int)
614    def onCommandFinished(self, ret):
615        # type: (int) -> None
616        if ret == 0:
617            self.commitComplete = True
618            self.wizard().next()
619
620
621class ResultPage(BasePage):
622    def __init__(self, repoagent, parent):
623        # type: (RepoAgent, Optional[QWidget]) -> None
624        super(ResultPage, self).__init__(repoagent, parent)
625        self.setTitle(_('Finished'))
626        self.setSubTitle(' ')
627        self.setFinalPage(True)
628
629        self.setLayout(QVBoxLayout())
630        sep = qtlib.LabeledSeparator(_('Backout changeset'))
631        self.layout().addWidget(sep)
632        bkCsInfo = csinfo.create(self.repo, 'tip', withupdate=True)
633        self.layout().addWidget(bkCsInfo)
634        self.bkCsInfo = bkCsInfo
635        self.layout().addStretch(1)
636
637    def currentPage(self):
638        # type: () -> None
639        self.bkCsInfo.update(self.repo[b'tip'])
640        self.wizard().setOption(QWizard.NoCancelButton, True)
641