1# thgrepo.py - TortoiseHg additions to key Mercurial classes 2# 3# Copyright 2010 George Marrows <george.marrows@gmail.com> 4# 5# This software may be used and distributed according to the terms of the 6# GNU General Public License version 2 or any later version. 7# 8# See mercurial/extensions.py, comments to wrapfunction, for this approach 9# to extending repositories and change contexts. 10 11from __future__ import absolute_import 12 13import os 14import sys 15import shutil 16import tempfile 17import re 18import time 19 20from .qtcore import ( 21 QFile, 22 QFileSystemWatcher, 23 QIODevice, 24 QObject, 25 QSignalMapper, 26 pyqtSignal, 27 pyqtSlot, 28) 29 30from hgext import mq 31from mercurial import ( 32 bundlerepo, 33 error, 34 extensions, 35 filemerge, 36 hg, 37 localrepo, 38 node, 39 pycompat, 40 subrepo, 41) 42 43from ..util import ( 44 hglib, 45 paths, 46) 47from ..util.patchctx import patchctx 48from . import ( 49 cmdcore, 50 hgconfig, 51) 52 53if hglib.TYPE_CHECKING: 54 from typing import ( 55 List, 56 Optional, 57 Text, 58 Tuple, 59 Union, 60 ) 61 from .qtgui import ( 62 QWidget, 63 ) 64 65_repocache = {} 66_kbfregex = re.compile(br'^\.kbf/') 67_lfregex = re.compile(br'^\.hglf/') 68 69# thgrepo.repository() will be deprecated 70def repository(_ui=None, path=b''): 71 '''Returns a subclassed Mercurial repository to which new 72 THG-specific methods have been added. The repository object 73 is obtained using mercurial.hg.repository()''' 74 assert isinstance(path, bytes), repr(path) 75 if path not in _repocache: 76 if _ui is None: 77 _ui = hglib.loadui() 78 try: 79 repo = hg.repository(_ui, path) 80 repo = repo.unfiltered() 81 repo.__class__ = _extendrepo(repo) 82 repo = repo.filtered(b'visible') 83 agent = RepoAgent(repo) 84 _repocache[path] = agent.rawRepo() 85 return agent.rawRepo() 86 except EnvironmentError: 87 raise error.RepoError(b'Cannot open repository at %s' % path) 88 if not os.path.exists(os.path.join(path, b'.hg/')): 89 del _repocache[path] 90 raise error.RepoError(b'%s is not a valid repository' % path) 91 return _repocache[path] 92 93def _filteredrepo(repo, hiddenincluded): 94 if hiddenincluded: 95 return repo.unfiltered() 96 else: 97 return repo.filtered(b'visible') 98 99 100# flags describing changes that could occur in repository 101LogChanged = 0x1 102WorkingParentChanged = 0x2 103WorkingBranchChanged = 0x4 104WorkingStateChanged = 0x8 # internal flag to invalidate dirstate cache 105 106_PollDeferred = 0x1 # flag to defer polling 107_PollFsChangesPending = 0x2 108_PollStatusPending = 0x4 109 110 111class RepoWatcher(QObject): 112 """Notify changes of repository by optionally monitoring filesystem""" 113 114 configChanged = pyqtSignal() 115 repositoryChanged = pyqtSignal(int) 116 repositoryDestroyed = pyqtSignal() 117 118 def __init__(self, repo, parent=None): 119 super(RepoWatcher, self).__init__(parent) 120 self._repo = repo 121 self._ui = repo.ui 122 self._fswatcher = None 123 self._deferredpoll = 0 # _Poll* flags 124 self._filesmap = {} # path: (flag, watched) 125 self._datamap = {} # readmeth: (flag, dep-path) 126 self._laststats = {} # path: (size, ctime, mtime) 127 self._lastdata = {} # readmeth: content 128 self._fixState() 129 self._uimtime = time.time() 130 131 def startMonitoring(self): 132 """Start filesystem monitoring to notify changes automatically""" 133 if not self._fswatcher: 134 self._fswatcher = QFileSystemWatcher(self) 135 self._fswatcher.directoryChanged.connect(self._onFsChanged) 136 self._fswatcher.fileChanged.connect(self._onFsChanged) 137 self._fswatcher.addPath(hglib.tounicode(self._repo.path)) 138 self._fswatcher.addPath(hglib.tounicode(self._repo.spath)) 139 self._addMissingPaths() 140 self._fswatcher.blockSignals(False) 141 142 def stopMonitoring(self): 143 """Stop filesystem monitoring by removing all watched paths 144 145 This will release OS resources held by filesystem watcher, so good 146 for disabling change notification for a long time. 147 """ 148 if not self._fswatcher: 149 return 150 self._fswatcher.blockSignals(True) # ignore pending events 151 dirs = self._fswatcher.directories() 152 if dirs: 153 self._fswatcher.removePaths(dirs) 154 files = self._fswatcher.files() 155 if files: 156 self._fswatcher.removePaths(files) 157 158 # QTBUG-32917: On Windows, removePaths() fails to remove ".hg" and 159 # ".hg/store" from the list, but actually they are not watched. 160 # Thus, they cannot be watched again by the same fswatcher instance. 161 if self._fswatcher.directories() or self._fswatcher.files(): 162 self._ui.debug(b'failed to remove paths - destroying watcher\n') 163 self._fswatcher.setParent(None) 164 self._fswatcher = None 165 166 def isMonitoring(self): 167 """True if filesystem monitor is running""" 168 if not self._fswatcher: 169 return False 170 return not self._fswatcher.signalsBlocked() 171 172 def resumeStatusPolling(self): 173 """Execute deferred status checks to emit notification signals""" 174 self._deferredpoll &= ~_PollDeferred 175 if self._deferredpoll & _PollFsChangesPending: 176 self._pollFsChanges() 177 self._deferredpoll &= ~(_PollFsChangesPending | _PollStatusPending) 178 if self._deferredpoll & _PollStatusPending: 179 self._pollStatus() 180 self._deferredpoll &= ~_PollStatusPending 181 182 def suspendStatusPolling(self): 183 """Defer status checks until resumed 184 185 Resuming from suspended state should be cheaper, but no OS resources 186 will be released. This is good for short-time suspend. 187 """ 188 self._deferredpoll |= _PollDeferred 189 190 @pyqtSlot() 191 def _onFsChanged(self): 192 if self._deferredpoll: 193 self._ui.debug(b'filesystem change detected, but poll deferred\n') 194 self._deferredpoll |= _PollFsChangesPending 195 return 196 self._pollFsChanges() 197 198 def _pollFsChanges(self): 199 '''Catch writes or deletions of files, or writes to .hg/ folder, 200 most importantly lock files''' 201 self._pollStatus() 202 # filesystem monitor may be stopped inside _pollStatus() 203 if self.isMonitoring(): 204 self._addMissingPaths() 205 206 def _addMissingPaths(self): 207 'Add files to watcher that may have been added or replaced' 208 existing = [f for f, (_flag, watched) in self._filesmap.items() 209 if watched and f in self._laststats] 210 files = [pycompat.unicode(f) for f in self._fswatcher.files()] 211 for f in existing: 212 if hglib.tounicode(f) not in files: 213 self._ui.debug(b'add file to watcher: %s\n' % f) 214 self._fswatcher.addPath(hglib.tounicode(f)) 215 for f in self._repo.uifiles(): 216 if f and os.path.exists(f) and hglib.tounicode(f) not in files: 217 self._ui.debug(b'add ui file to watcher: %s\n' % f) 218 self._fswatcher.addPath(hglib.tounicode(f)) 219 220 def clearStatus(self): 221 self._laststats.clear() 222 self._lastdata.clear() 223 224 def pollStatus(self): 225 if self._deferredpoll: 226 self._ui.debug(b'poll request deferred\n') 227 self._deferredpoll |= _PollStatusPending 228 return 229 self._pollStatus() 230 231 def _pollStatus(self): 232 if not os.path.exists(self._repo.path): 233 self._ui.debug(b'repository destroyed: %s\n' % self._repo.root) 234 self.repositoryDestroyed.emit() 235 return 236 if self._locked(): 237 self._ui.debug(b'locked, aborting\n') 238 return 239 curstats, curdata = self._readState() 240 changeflags = self._calculateChangeFlags(curstats, curdata) 241 if self._locked(): 242 self._ui.debug(b'lock still held - ignoring for now\n') 243 return 244 self._laststats = curstats 245 self._lastdata = curdata 246 if changeflags: 247 self._ui.debug(b'change found (flags = 0x%x)\n' % changeflags) 248 self.repositoryChanged.emit(changeflags) # may update repo paths 249 self._fixState() 250 self._checkuimtime() 251 252 def _locked(self): 253 if os.path.lexists(self._repo.vfs.join(b'wlock')): 254 return True 255 if os.path.lexists(self._repo.svfs.join(b'lock')): 256 return True 257 return False 258 259 def _fixState(self): 260 """Update paths to be checked and record state of new paths""" 261 repo = self._repo 262 q = getattr(repo, 'mq', None) 263 newfilesmap = { 264 repo.vfs.join(b'bookmarks'): (LogChanged, False), 265 repo.vfs.join(b'bookmarks.current'): (LogChanged, False), 266 repo.vfs.join(b'branch'): (0, False), 267 repo.vfs.join(b'topic'): (LogChanged, False), 268 repo.vfs.join(b'dirstate'): (WorkingStateChanged, False), 269 repo.vfs.join(b'localtags'): (LogChanged, False), 270 repo.svfs.join(b'00changelog.i'): (LogChanged, False), 271 repo.svfs.join(b'obsstore'): (LogChanged, False), 272 repo.svfs.join(b'phaseroots'): (LogChanged, False), 273 } 274 if q: 275 newfilesmap.update({ 276 q.join(b'guards'): (LogChanged, True), 277 q.join(b'series'): (LogChanged, True), 278 q.join(b'status'): (LogChanged, True), 279 repo.vfs.join(b'patches.queue'): (LogChanged, True), 280 repo.vfs.join(b'patches.queues'): (LogChanged, True), 281 }) 282 newpaths = set(newfilesmap) - set(self._filesmap) 283 if not newpaths: 284 return 285 self._filesmap = newfilesmap 286 self._datamap = { 287 RepoWatcher._readbranch: (WorkingBranchChanged, 288 repo.vfs.join(b'branch')), 289 RepoWatcher._readparents: (WorkingParentChanged, 290 repo.vfs.join(b'dirstate')), 291 } 292 newstats, newdata = self._readState(newpaths) 293 self._laststats.update(newstats) 294 self._lastdata.update(newdata) 295 296 def _readState(self, targetpaths=None): 297 if targetpaths is None: 298 targetpaths = self._filesmap 299 300 curstats = {} 301 for path in targetpaths: 302 try: 303 # see mercurial.util.filestat for details what attributes 304 # are needed an how ambiguity is resolved 305 st = os.stat(path) 306 curstats[path] = (st.st_size, st.st_ctime, st.st_mtime) 307 except EnvironmentError: 308 pass 309 310 curdata = {} 311 for readmeth, (_flag, path) in self._datamap.items(): 312 if path not in targetpaths: 313 continue 314 last = self._laststats.get(path) 315 cur = curstats.get(path) 316 if last != cur: 317 try: 318 curdata[readmeth] = readmeth(self) 319 except EnvironmentError: 320 pass 321 elif cur is not None and readmeth in self._lastdata: 322 curdata[readmeth] = self._lastdata[readmeth] 323 324 return curstats, curdata 325 326 def _calculateChangeFlags(self, curstats, curdata): 327 changeflags = 0 328 for path, (flag, _watched) in self._filesmap.items(): 329 last = self._laststats.get(path) 330 cur = curstats.get(path) 331 if last != cur: 332 self._ui.debug(b' stat: %s (%r -> %r)\n' % (path, last, cur)) 333 changeflags |= flag 334 for readmeth, (flag, _path) in self._datamap.items(): 335 last = self._lastdata.get(readmeth) 336 cur = curdata.get(readmeth) 337 if last != cur: 338 self._ui.debug(b' data: %s (%r -> %r)\n' 339 % (pycompat.sysbytes(readmeth.__name__), last, 340 cur)) 341 changeflags |= flag 342 return changeflags 343 344 def _readparents(self): 345 return self._repo.vfs(b'dirstate').read(40) 346 347 def _readbranch(self): 348 return self._repo.vfs(b'branch').read() 349 350 def _checkuimtime(self): 351 'Check for modified config files, or a new .hg/hgrc file' 352 try: 353 files = self._repo.uifiles() 354 mtime = max(os.path.getmtime(f) for f in files if os.path.isfile(f)) 355 if mtime > self._uimtime: 356 self._ui.debug(b'config change detected\n') 357 self._uimtime = mtime 358 self.configChanged.emit() 359 except (EnvironmentError, ValueError): 360 pass 361 362 363class RepoAgent(QObject): 364 """Proxy access to repository and keep its states up-to-date""" 365 366 # change notifications are not emitted while command is running because 367 # repository files are likely to be modified 368 configChanged = pyqtSignal() 369 repositoryChanged = pyqtSignal(int) 370 repositoryDestroyed = pyqtSignal() 371 372 serviceStopped = pyqtSignal() 373 busyChanged = pyqtSignal(bool) 374 375 commandFinished = pyqtSignal(cmdcore.CmdSession) 376 outputReceived = pyqtSignal(str, str) 377 progressReceived = pyqtSignal(cmdcore.ProgressMessage) 378 379 def __init__(self, repo): 380 QObject.__init__(self) 381 self._repo = self._baserepo = repo 382 # TODO: remove repo-to-agent references later; all widgets should own 383 # RepoAgent instead of thgrepository. 384 repo._pyqtobj = self 385 # base repository for bundle or union (set in dispatch._dispatch) 386 repo.ui.setconfig(b'bundle', b'mainreporoot', repo.root) 387 self._config = hgconfig.HgConfig(repo.ui) 388 # keep url separately from repo.url() because it is abbreviated to 389 # relative path to cwd in bundle or union repo 390 self._overlayurl = '' 391 self._repochanging = 0 392 393 self._watcher = watcher = RepoWatcher(repo, self) 394 watcher.configChanged.connect(self._onConfigChanged) 395 watcher.repositoryChanged.connect(self._onRepositoryChanged) 396 watcher.repositoryDestroyed.connect(self._onRepositoryDestroyed) 397 398 self._cmdagent = cmdagent = cmdcore.CmdAgent(repo.ui, self, 399 cwd=self.rootPath()) 400 cmdagent.outputReceived.connect(self.outputReceived) 401 cmdagent.progressReceived.connect(self.progressReceived) 402 cmdagent.serviceStopped.connect(self._tryEmitServiceStopped) 403 cmdagent.busyChanged.connect(self._onBusyChanged) 404 cmdagent.commandFinished.connect(self._onCommandFinished) 405 406 self._subrepoagents = {} # path: agent 407 408 def startMonitoringIfEnabled(self): 409 """Start filesystem monitoring on repository open by RepoManager""" 410 repo = self._repo 411 ui = repo.ui 412 monitorrepo = self.configString('tortoisehg', 'monitorrepo') 413 if monitorrepo == 'never': 414 ui.debug(b'watching of F/S events is disabled by configuration\n') 415 elif (monitorrepo == 'localonly' 416 and not paths.is_on_fixed_drive(repo.path)): 417 ui.debug(b'not watching F/S events for network drive\n') 418 else: 419 self._watcher.startMonitoring() 420 421 def isServiceRunning(self): 422 return self._watcher.isMonitoring() or self._cmdagent.isServiceRunning() 423 424 def stopService(self): 425 """Shut down back-end services on repository closed by RepoManager""" 426 if self._watcher.isMonitoring(): 427 self._watcher.stopMonitoring() 428 self._tryEmitServiceStopped() 429 self._cmdagent.stopService() 430 431 @pyqtSlot() 432 def _tryEmitServiceStopped(self): 433 if not self.isServiceRunning(): 434 self.serviceStopped.emit() 435 436 def suspendMonitoring(self): 437 """Stop filesystem monitoring and release OS resources""" 438 self._watcher.stopMonitoring() 439 440 def resumeMonitoring(self): 441 """Resume filesystem monitoring if possible""" 442 if self._watcher.isMonitoring(): 443 return 444 self.pollStatus() 445 self.startMonitoringIfEnabled() 446 447 def rawRepo(self): 448 return self._repo 449 450 def rootPath(self): 451 return hglib.tounicode(self._repo.root) 452 453 def configBool(self, section, name, default=hgconfig.UNSET_DEFAULT): 454 # type: (Text, Text, bool) -> bool 455 return self._config.configBool(section, name, default) 456 457 def configInt(self, section, name, default=hgconfig.UNSET_DEFAULT): 458 # type: (Text, Text, int) -> int 459 return self._config.configInt(section, name, default) 460 461 def configString(self, section, name, default=hgconfig.UNSET_DEFAULT): 462 # type: (Text, Text, Text) -> Text 463 return self._config.configString(section, name, default) 464 465 def configStringList(self, section, name, default=hgconfig.UNSET_DEFAULT): 466 # type: (Text, Text, List[Text]) -> List[Text] 467 return self._config.configStringList(section, name, default) 468 469 def configStringItems(self, section): 470 # type: (Text) -> List[Tuple[Text, Text]] 471 return self._config.configStringItems(section) 472 473 def hasConfig(self, section, name): 474 # type: (Text, Text) -> bool 475 return self._config.hasConfig(section, name) 476 477 def displayName(self): 478 """Name for window titles and similar""" 479 if self.configBool('tortoisehg', 'fullpath'): 480 return self.rootPath() 481 else: 482 return self.shortName() 483 484 def shortName(self): 485 """Name for tables, tabs, and sentences""" 486 webname = hglib.shortreponame(self._repo.ui) 487 if webname: 488 return hglib.tounicode(webname) 489 else: 490 return os.path.basename(self.rootPath()) 491 492 def hiddenRevsIncluded(self): 493 return self._repo.filtername != b'visible' 494 495 def setHiddenRevsIncluded(self, included): 496 """Switch visibility of hidden (i.e. pruned) changesets""" 497 if self.hiddenRevsIncluded() == included: 498 return 499 self._changeRepo(_filteredrepo(self._repo, included)) 500 self._flushRepositoryChanged() 501 502 def overlayUrl(self): 503 return self._overlayurl 504 505 def setOverlay(self, url): 506 """Switch to bundle or union repository overlaying this""" 507 url = pycompat.unicode(url) 508 if self._overlayurl == url: 509 return 510 repo = hg.repository(self._baserepo.ui, hglib.fromunicode(url)) 511 if repo.root != self._baserepo.root: 512 raise ValueError('invalid overlay repository: %s' % url) 513 repo = repo.unfiltered() 514 repo.__class__ = _extendrepo(repo) 515 repo._pyqtobj = self # TODO: remove repo-to-agent references 516 repo = repo.filtered(b'visible') 517 self._changeRepo(_filteredrepo(repo, self.hiddenRevsIncluded())) 518 self._overlayurl = url 519 self._watcher.suspendStatusPolling() 520 self._flushRepositoryChanged() 521 522 def clearOverlay(self): 523 if not self._overlayurl: 524 return 525 repo = self._baserepo 526 repo.thginvalidate() # take changes during overlaid 527 self._changeRepo(_filteredrepo(repo, self.hiddenRevsIncluded())) 528 self._overlayurl = '' 529 self._watcher.resumeStatusPolling() 530 self._flushRepositoryChanged() 531 532 def _changeRepo(self, repo): 533 # bundle/union repo will append temporary revisions to changelog 534 self._repochanging = LogChanged 535 self._repo = repo 536 537 def _emitRepositoryChanged(self, flags): 538 flags |= self._repochanging 539 self._repochanging = 0 540 self.repositoryChanged.emit(flags) 541 542 def _flushRepositoryChanged(self): 543 if self._cmdagent.isBusy(): 544 return # delayed until _onBusyChanged(False) 545 if self._repochanging: 546 self._emitRepositoryChanged(0) 547 548 def clearStatus(self): 549 """Forget last status so that next poll should emit change signals""" 550 self._watcher.clearStatus() 551 552 def pollStatus(self): 553 """Force checking changes to emit corresponding signals; this will be 554 deferred if command is running""" 555 self._watcher.pollStatus() 556 self._flushRepositoryChanged() 557 558 @pyqtSlot() 559 def _onConfigChanged(self): 560 self._repo.invalidateui() 561 self._config = hgconfig.HgConfig(self._repo.ui) 562 assert not self._cmdagent.isBusy() 563 self._cmdagent.stopService() # to reload config 564 self.configChanged.emit() 565 566 @pyqtSlot(int) 567 def _onRepositoryChanged(self, flags): 568 self._repo.thginvalidate() 569 # ignore signal that just contains internal flags 570 if flags & ~WorkingStateChanged: 571 self._emitRepositoryChanged(flags) 572 573 @pyqtSlot() 574 def _onRepositoryDestroyed(self): 575 if self._repo.root in _repocache: 576 del _repocache[self._repo.root] 577 # avoid further changed/destroyed signals 578 self._watcher.stopMonitoring() 579 self.repositoryDestroyed.emit() 580 581 def isBusy(self): 582 # type: () -> bool 583 return self._cmdagent.isBusy() 584 585 def _preinvalidateCache(self): 586 if self._cmdagent.isBusy(): 587 # A lot of logic will depend on invalidation happening within 588 # the context of this call. Signals will not be emitted till later, 589 # but we at least invalidate cached data in the repository 590 self._repo.thginvalidate() 591 592 @pyqtSlot(bool) 593 def _onBusyChanged(self, busy): 594 if busy: 595 self._watcher.suspendStatusPolling() 596 else: 597 self._watcher.resumeStatusPolling() 598 if not self._watcher.isMonitoring(): 599 # detect changes made by the last command even if monitoring 600 # is disabled 601 self._watcher.pollStatus() 602 self._flushRepositoryChanged() 603 self.busyChanged.emit(busy) 604 605 def runCommand(self, cmdline, uihandler=None, overlay=True): 606 # type: (List[Text], Optional[Union[QWidget, cmdcore.UiHandler]], bool) -> cmdcore.CmdSession 607 """Executes a single command asynchronously in this repository""" 608 cmdline = self._extendCmdline(cmdline, overlay) 609 return self._cmdagent.runCommand(cmdline, uihandler) 610 611 def runCommandSequence(self, cmdlines, uihandler=None, overlay=True): 612 # type: (List[List[Text]], Optional[Union[QWidget, cmdcore.UiHandler]], bool) -> cmdcore.CmdSession 613 """Executes a series of commands asynchronously in this repository""" 614 cmdlines = [self._extendCmdline(l, overlay) for l in cmdlines] 615 return self._cmdagent.runCommandSequence(cmdlines, uihandler) 616 617 def _extendCmdline(self, cmdline, overlay): 618 # type: (List[Text], bool) -> List[Text] 619 if self.hiddenRevsIncluded(): 620 cmdline = ['--hidden'] + cmdline 621 if overlay and self._overlayurl: 622 cmdline = ['-R', self._overlayurl] + cmdline 623 return cmdline 624 625 def abortCommands(self): 626 # type: () -> None 627 """Abort running and queued commands""" 628 self._cmdagent.abortCommands() 629 630 @pyqtSlot(cmdcore.CmdSession) 631 def _onCommandFinished(self, sess): 632 self._preinvalidateCache() 633 self.commandFinished.emit(sess) 634 635 def subRepoAgent(self, path): 636 """Return RepoAgent of sub or patch repository""" 637 root = self.rootPath() 638 path = hglib.normreporoot(os.path.join(root, path)) 639 if path == root or not path.startswith(root.rstrip(os.sep) + os.sep): 640 # only sub path is allowed to avoid circular references 641 raise ValueError('invalid sub path: %s' % path) 642 try: 643 return self._subrepoagents[path] 644 except KeyError: 645 pass 646 647 manager = self.parent() 648 if not manager: 649 raise RuntimeError('cannot open sub agent of unmanaged repo') 650 assert isinstance(manager, RepoManager), manager 651 self._subrepoagents[path] = agent = manager.openRepoAgent(path) 652 return agent 653 654 def releaseSubRepoAgents(self): 655 """Release RepoAgents referenced by this when repository closed by 656 RepoManager""" 657 if not self._subrepoagents: 658 return 659 manager = self.parent() 660 if not manager: 661 raise RuntimeError('cannot release sub agents of unmanaged repo') 662 assert isinstance(manager, RepoManager), manager 663 for path in self._subrepoagents: 664 manager.releaseRepoAgent(path) 665 self._subrepoagents.clear() 666 667 668class RepoManager(QObject): 669 """Cache open RepoAgent instances and bundle their signals""" 670 671 repositoryOpened = pyqtSignal(str) 672 repositoryClosed = pyqtSignal(str) 673 674 configChanged = pyqtSignal(str) 675 repositoryChanged = pyqtSignal(str, int) 676 repositoryDestroyed = pyqtSignal(str) 677 678 busyChanged = pyqtSignal(str, bool) 679 progressReceived = pyqtSignal(str, cmdcore.ProgressMessage) 680 681 _SIGNALMAP = [ 682 # source, dest 683 ('configChanged', 'configChanged'), 684 ('repositoryDestroyed', 'repositoryDestroyed'), 685 ('serviceStopped', '_tryCloseRepoAgent'), 686 ('busyChanged', '_mapBusyChanged'), 687 ] 688 689 def __init__(self, ui, parent=None): 690 super(RepoManager, self).__init__(parent) 691 self._ui = ui 692 self._openagents = {} # path: (agent, refcount) 693 # refcount=0 means the repo is about to be closed 694 695 self._sigmappers = [] 696 for _sig, slot in self._SIGNALMAP: 697 mapper = QSignalMapper(self) 698 self._sigmappers.append(mapper) 699 mapper.mapped[str].connect(getattr(self, slot)) 700 701 def openRepoAgent(self, path): 702 """Return RepoAgent for the specified path and increment refcount""" 703 path = hglib.normreporoot(path) 704 if path in self._openagents: 705 agent, refcount = self._openagents[path] 706 self._openagents[path] = (agent, refcount + 1) 707 return agent 708 709 # TODO: move repository creation from thgrepo.repository() 710 self._ui.debug(b'opening repo: %s\n' % hglib.fromunicode(path)) 711 agent = repository(self._ui, hglib.fromunicode(path))._pyqtobj 712 assert agent.parent() is None 713 agent.setParent(self) 714 for (sig, _slot), mapper in zip(self._SIGNALMAP, self._sigmappers): 715 getattr(agent, sig).connect(mapper.map) 716 mapper.setMapping(agent, agent.rootPath()) 717 agent.repositoryChanged.connect(self._mapRepositoryChanged) 718 agent.progressReceived.connect(self._mapProgressReceived) 719 agent.startMonitoringIfEnabled() 720 721 assert agent.rootPath() == path 722 self._openagents[path] = (agent, 1) 723 self.repositoryOpened.emit(path) 724 return agent 725 726 @pyqtSlot(str) 727 def releaseRepoAgent(self, path): 728 """Decrement refcount of RepoAgent and close it if possible""" 729 path = hglib.normreporoot(path) 730 agent, refcount = self._openagents[path] 731 self._openagents[path] = (agent, refcount - 1) 732 if refcount > 1: 733 return 734 735 # close child agents first, which may reenter to releaseRepoAgent() 736 agent.releaseSubRepoAgents() 737 738 if agent.isServiceRunning(): 739 self._ui.debug(b'stopping service: %s\n' % hglib.fromunicode(path)) 740 agent.stopService() 741 else: 742 self._tryCloseRepoAgent(path) 743 744 @pyqtSlot(str) 745 def _tryCloseRepoAgent(self, path): 746 path = pycompat.unicode(path) 747 agent, refcount = self._openagents[path] 748 if refcount > 0: 749 # repo may be reopen before its services stopped 750 return 751 self._ui.debug(b'closing repo: %s\n' % hglib.fromunicode(path)) 752 del self._openagents[path] 753 # TODO: disconnected automatically if _repocache does not exist 754 for (sig, _slot), mapper in zip(self._SIGNALMAP, self._sigmappers): 755 getattr(agent, sig).disconnect(mapper.map) 756 mapper.removeMappings(agent) 757 agent.repositoryChanged.disconnect(self._mapRepositoryChanged) 758 agent.progressReceived.disconnect(self._mapProgressReceived) 759 agent.setParent(None) 760 self.repositoryClosed.emit(path) 761 762 def repoAgent(self, path): 763 """Peek open RepoAgent for the specified path without refcount change; 764 None for unknown path""" 765 path = hglib.normreporoot(path) 766 return self._openagents.get(path, (None, 0))[0] 767 768 def repoRootPaths(self): 769 """Return list of root paths of open repositories""" 770 return list(self._openagents.keys()) 771 772 @pyqtSlot(int) 773 def _mapRepositoryChanged(self, flags): 774 agent = self.sender() 775 assert isinstance(agent, RepoAgent), agent 776 self.repositoryChanged.emit(agent.rootPath(), flags) 777 778 @pyqtSlot(str) 779 def _mapBusyChanged(self, path): 780 agent, _refcount = self._openagents[pycompat.unicode(path)] 781 self.busyChanged.emit(path, agent.isBusy()) 782 783 @pyqtSlot(cmdcore.ProgressMessage) 784 def _mapProgressReceived(self, progress): 785 agent = self.sender() 786 assert isinstance(agent, RepoAgent), agent 787 self.progressReceived.emit(agent.rootPath(), progress) 788 789 790_uiprops = '''_uifiles postpull tabwidth maxdiff 791 deadbranches _exts _thghiddentags summarylen 792 mergetools'''.split() 793_thgrepoprops = '''_thgmqpatchnames thgmqunappliedpatches'''.split() 794 795def _extendrepo(repo): 796 class thgrepository(repo.__class__): 797 798 def __getitem__(self, changeid): 799 '''Extends Mercurial's standard __getitem__() method to 800 a) return a thgchangectx with additional methods 801 b) return a patchctx if changeid is the name of an MQ 802 unapplied patch 803 c) return a patchctx if changeid is an absolute patch path 804 ''' 805 806 # Mercurial's standard changectx() (rather, lookup()) 807 # implies that tags and branch names live in the same namespace. 808 # This code throws patch names in the same namespace, but as 809 # applied patches have a tag that matches their patch name this 810 # seems safe. 811 if changeid in self.thgmqunappliedpatches: 812 q = self.mq # must have mq to pass the previous if 813 return genPatchContext(self, q.join(changeid), rev=changeid) 814 elif isinstance(changeid, bytes) and b'\0' not in changeid and \ 815 os.path.isabs(changeid) and os.path.isfile(changeid): 816 return genPatchContext(repo, changeid) 817 818 # If changeid is a basectx, repo[changeid] returns the same object. 819 # We assumes changectx is already wrapped in that case; otherwise, 820 # changectx would be double wrapped by thgchangectx. 821 changectx = super(thgrepository, self).__getitem__(changeid) 822 if changectx is changeid: 823 return changectx 824 changectx.__class__ = _extendchangectx(changectx) 825 return changectx 826 827 def hgchangectx(self, changeid): 828 '''Returns unwrapped changectx or workingctx object''' 829 # This provides temporary workaround for troubles caused by class 830 # extension: e.g. changectx(n) != thgchangectx(n). 831 # thgrepository and thgchangectx should be removed in some way. 832 return super(thgrepository, self).__getitem__(changeid) 833 834 @localrepo.unfilteredpropertycache 835 def _thghiddentags(self): 836 ht = self.ui.config(b'tortoisehg', b'hidetags', b'') 837 return [t.strip() for t in ht.split()] 838 839 @localrepo.unfilteredpropertycache 840 def thgmqunappliedpatches(self): 841 '''Returns a list of (patch name, patch path) of all self's 842 unapplied MQ patches, in patch series order, first unapplied 843 patch first.''' 844 if not hasattr(self, 'mq'): return [] 845 846 q = self.mq 847 applied = set([p.name for p in q.applied]) 848 849 return [pname for pname in q.series if pname not in applied] 850 851 @localrepo.unfilteredpropertycache 852 def _thgmqpatchnames(self): 853 '''Returns all tag names used by MQ patches. Returns [] 854 if MQ not in use.''' 855 return hglib.getmqpatchtags(self) 856 857 @property 858 def thgactivemqname(self): 859 '''Currenty-active qqueue name (see hgext/mq.py:qqueue)''' 860 return hglib.getcurrentqqueue(self) 861 862 @localrepo.unfilteredpropertycache 863 def _uifiles(self): 864 cfg = self.ui._ucfg 865 files = set() 866 867 for section in cfg: 868 for item in cfg.items(section): 869 src = cfg.source(section, item[0]) 870 871 # Skip sourceless items like ui.{verbose,debug} 872 if not src: 873 continue 874 875 f = src.rsplit(b':', 1)[0] 876 files.add(f) 877 878 files.add(self.vfs.join(b'hgrc')) 879 return files 880 881 @localrepo.unfilteredpropertycache 882 def _exts(self): 883 lclexts = [] 884 allexts = [n for n,m in extensions.extensions()] 885 for name, path in self.ui.configitems(b'extensions'): 886 if name.startswith(b'hgext.'): 887 name = name[6:] 888 if name in allexts: 889 lclexts.append(name) 890 return lclexts 891 892 @localrepo.unfilteredpropertycache 893 def postpull(self): 894 pp = self.ui.config(b'tortoisehg', b'postpull') 895 if pp in (b'rebase', b'update', b'fetch', b'updateorrebase'): 896 return pp 897 return b'none' 898 899 @localrepo.unfilteredpropertycache 900 def tabwidth(self): 901 tw = self.ui.config(b'tortoisehg', b'tabwidth') 902 try: 903 tw = int(tw) 904 tw = min(tw, 16) 905 return max(tw, 2) 906 except (ValueError, TypeError): 907 return 8 908 909 @localrepo.unfilteredpropertycache 910 def maxdiff(self): 911 maxdiff = self.ui.config(b'tortoisehg', b'maxdiff') 912 try: 913 maxdiff = int(maxdiff) 914 if maxdiff < 1: 915 return sys.maxsize 916 except (ValueError, TypeError): 917 maxdiff = 1024 # 1MB by default 918 return maxdiff * 1024 919 920 @localrepo.unfilteredpropertycache 921 def summarylen(self): 922 slen = self.ui.config(b'tortoisehg', b'summarylen') 923 try: 924 slen = int(slen) 925 if slen < 10: 926 return 80 927 except (ValueError, TypeError): 928 slen = 80 929 return slen 930 931 @localrepo.unfilteredpropertycache 932 def deadbranches(self): 933 db = self.ui.config(b'tortoisehg', b'deadbranch', b'') 934 return [b.strip() for b in db.split(b',')] 935 936 @localrepo.unfilteredpropertycache 937 def mergetools(self): 938 seen, installed = [], [] 939 for key, value in self.ui.configitems(b'merge-tools'): 940 t = key.split(b'.')[0] 941 if t not in seen: 942 seen.append(t) 943 if filemerge._findtool(self.ui, t): 944 installed.append(t) 945 return installed 946 947 def uifiles(self): 948 'Returns complete list of config files' 949 return self._uifiles 950 951 def extensions(self): 952 'Returns list of extensions enabled in this repository' 953 return self._exts 954 955 def thgmqtag(self, tag): 956 'Returns true if `tag` marks an applied MQ patch' 957 return tag in self._thgmqpatchnames 958 959 def thgshelves(self): 960 self.shelfdir = sdir = self.vfs.join(b'shelves') 961 if os.path.isdir(sdir): 962 def getModificationTime(x): 963 try: 964 return os.path.getmtime(os.path.join(sdir, x)) 965 except EnvironmentError: 966 return 0 967 shelves = sorted(os.listdir(sdir), 968 key=getModificationTime, reverse=True) 969 return [s for s in shelves if \ 970 os.path.isfile(os.path.join(self.shelfdir, s))] 971 return [] 972 973 def makeshelf(self, patch): 974 if not os.path.exists(self.shelfdir): 975 os.mkdir(self.shelfdir) 976 f = open(os.path.join(self.shelfdir, patch), "wb") 977 f.close() 978 979 def thginvalidate(self): 980 'Should be called when mtime of repo store/dirstate are changed' 981 self.invalidatedirstate() 982 983 if not isinstance(repo, bundlerepo.bundlerepository): 984 self.invalidate() 985 # mq.queue.invalidate does not handle queue changes, so force 986 # the queue object to be rebuilt 987 if localrepo.hasunfilteredcache(self, 'mq'): 988 delattr(self.unfiltered(), 'mq') 989 for a in _thgrepoprops + _uiprops: 990 if localrepo.hasunfilteredcache(self, a): 991 delattr(self.unfiltered(), a) 992 993 def invalidateui(self): 994 'Should be called when mtime of ui files are changed' 995 origui = self.ui 996 self.ui = hglib.loadui() 997 self.ui.readconfig(self.vfs.join(b'hgrc')) 998 hglib.copydynamicconfig(origui, self.ui) 999 for a in _uiprops: 1000 if localrepo.hasunfilteredcache(self, a): 1001 delattr(self.unfiltered(), a) 1002 1003 def thgbackup(self, path): 1004 '''Make a backup of the given file in the directory "Trashcan" if 1005 it exists''' 1006 # The backup name will be similar to the orginal file name plus 1007 # '.bak' and characters to make it unique 1008 trashcan = hglib.tounicode(self.vfs.join(b'Trashcan')) 1009 if not os.path.isdir(trashcan): 1010 os.mkdir(trashcan) 1011 path = hglib.tounicode(path) 1012 if not os.path.exists(path): 1013 return 1014 name = os.path.basename(path) 1015 root, ext = os.path.splitext(name) 1016 fd, dest = tempfile.mkstemp(suffix=ext + '.bak', prefix=root + '_', 1017 dir=trashcan) 1018 os.close(fd) 1019 shutil.copyfile(path, dest) 1020 1021 def isStandin(self, path): 1022 if b'largefiles' in self.extensions(): 1023 if _lfregex.match(path): 1024 return True 1025 if b'largefiles' in self.extensions() or b'kbfiles' in self.extensions(): 1026 if _kbfregex.match(path): 1027 return True 1028 return False 1029 1030 def bfStandin(self, path): 1031 return b'.kbf/' + path 1032 1033 def lfStandin(self, path): 1034 return b'.hglf/' + path 1035 1036 return thgrepository 1037 1038_changectxclscache = {} # parentcls: extendedcls 1039 1040def _extendchangectx(changectx): 1041 # cache extended changectx class, since we may create bunch of instances 1042 parentcls = changectx.__class__ 1043 try: 1044 return _changectxclscache[parentcls] 1045 except KeyError: 1046 pass 1047 1048 assert parentcls not in _changectxclscache.values(), 'double thgchangectx' 1049 _changectxclscache[parentcls] = cls = _createchangectxcls(parentcls) 1050 return cls 1051 1052def _createchangectxcls(parentcls): 1053 class thgchangectx(parentcls): 1054 def sub(self, path): 1055 srepo = super(thgchangectx, self).sub(path) 1056 if isinstance(srepo, subrepo.hgsubrepo): 1057 r = srepo._repo 1058 r = r.unfiltered() 1059 r.__class__ = _extendrepo(r) 1060 srepo._repo = r.filtered(b'visible') 1061 return srepo 1062 1063 def thgtags(self): 1064 '''Returns all unhidden tags for self''' 1065 htlist = self._repo._thghiddentags 1066 return [tag for tag in self.tags() if tag not in htlist] 1067 1068 def _thgmqpatchtags(self): 1069 '''Returns the set of self's tags which are MQ patch names''' 1070 mytags = set(self.tags()) 1071 patchtags = self._repo._thgmqpatchnames 1072 result = mytags.intersection(patchtags) 1073 assert len(result) <= 1, "thgmqpatchname: rev has more than one tag in series" 1074 return result 1075 1076 def thgmqappliedpatch(self): 1077 '''True if self is an MQ applied patch''' 1078 return self.rev() is not None and bool(self._thgmqpatchtags()) 1079 1080 def thgmqunappliedpatch(self): 1081 return False 1082 1083 def thgmqpatchname(self): 1084 '''Return self's MQ patch name. AssertionError if self not an MQ patch''' 1085 patchtags = self._thgmqpatchtags() 1086 assert len(patchtags) == 1, "thgmqpatchname: called on non-mq patch" 1087 return list(patchtags)[0] 1088 1089 def thgmqoriginalparent(self): 1090 '''The revisionid of the original patch parent''' 1091 if not self.thgmqunappliedpatch() and not self.thgmqappliedpatch(): 1092 return '' 1093 try: 1094 patchpath = self._repo.mq.join(self.thgmqpatchname()) 1095 mqoriginalparent = mq.patchheader(patchpath).parent 1096 except EnvironmentError: 1097 return '' 1098 return mqoriginalparent 1099 1100 def changesToParent(self, whichparent): 1101 parent = self.parents()[whichparent] 1102 status = self._repo.status(parent.node(), self.node()) 1103 return status.modified, status.added, status.removed 1104 1105 def longsummary(self): 1106 if self._repo.ui.configbool(b'tortoisehg', b'longsummary'): 1107 limit = 80 1108 else: 1109 limit = None 1110 return hglib.longsummary(self.description(), limit) 1111 1112 def hasStandin(self, file): 1113 if b'largefiles' in self._repo.extensions(): 1114 if self._repo.lfStandin(file) in self.manifest(): 1115 return True 1116 elif b'largefiles' in self._repo.extensions() or b'kbfiles' in self._repo.extensions(): 1117 if self._repo.bfStandin(file) in self.manifest(): 1118 return True 1119 return False 1120 1121 def isStandin(self, path): 1122 return self._repo.isStandin(path) 1123 1124 def findStandin(self, file): 1125 if b'largefiles' in self._repo.extensions(): 1126 if self._repo.lfStandin(file) in self.manifest(): 1127 return self._repo.lfStandin(file) 1128 return self._repo.bfStandin(file) 1129 1130 return thgchangectx 1131 1132_pctxcache = {} 1133def genPatchContext(repo, patchpath, rev=None): 1134 global _pctxcache 1135 try: 1136 if os.path.exists(patchpath) and patchpath in _pctxcache: 1137 cachedctx = _pctxcache[patchpath] 1138 if cachedctx._mtime == os.path.getmtime(patchpath) and \ 1139 cachedctx._fsize == os.path.getsize(patchpath): 1140 return cachedctx 1141 except EnvironmentError: 1142 pass 1143 # create a new context object 1144 ctx = patchctx(patchpath, repo, rev=rev) 1145 _pctxcache[patchpath] = ctx 1146 return ctx 1147 1148def recursiveMergeStatus(repo): 1149 ms = hglib.readmergestate(repo) 1150 for wfile in ms: 1151 yield repo.root, wfile, ms[wfile] 1152 try: 1153 wctx = repo[None] 1154 for s in wctx.substate: 1155 sub = wctx.sub(s) 1156 if isinstance(sub, subrepo.hgsubrepo): 1157 for root, file, status in recursiveMergeStatus(sub._repo): 1158 yield root, file, status 1159 except (EnvironmentError, error.Abort, error.RepoError): 1160 pass 1161 1162def relatedRepositories(repoid): 1163 'Yields root paths for local related repositories' 1164 from tortoisehg.hgqt import reporegistry, repotreemodel 1165 if repoid == node.nullid: # empty repositories shouldn't be related 1166 return 1167 1168 f = QFile(reporegistry.settingsfilename()) 1169 f.open(QIODevice.ReadOnly) 1170 try: 1171 for e in repotreemodel.iterRepoItemFromXml(f): 1172 if e.basenode() == repoid: 1173 # TODO: both in unicode because this is Qt-layer function? 1174 yield (hglib.fromunicode(e.rootpath()), 1175 hglib.fromunicode(e.shortname())) 1176 except: 1177 f.close() 1178 raise 1179 else: 1180 f.close() 1181 1182def isBfStandin(path): 1183 return _kbfregex.match(path) 1184 1185def isLfStandin(path): 1186 return _lfregex.match(path) 1187