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