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