source: tracdarcs/repos.py @ 3

Revision 3, 27.0 KB checked in by lele@…, 7 years ago (diff)

Reworked the trac+darcs backend into a TracPlugin?

Line 
1# -*- coding: iso-8859-1 -*-
2#
3# Copyright (C) 2005 Edgewall Software
4# Copyright (C) 2005,2006 Lele Gaifax <lele@metapensiero.it>
5#
6# This software is licensed as described in the file COPYING, which
7# you should have received as part of this distribution. The terms
8# are also available at http://trac.edgewall.com/license.html.
9#
10# This software consists of voluntary contributions made by many
11# individuals. For the exact contribution history, see the revision
12# history and logs, available at http://projects.edgewall.com/trac/.
13#
14# Author: Lele Gaifax <lele@metapensiero.it>
15
16from __future__ import generators
17
18# Python 2.3+ compatibility
19try:
20    reversed
21except:
22    def reversed(x):
23        if hasattr(x, 'keys'):
24            raise ValueError("mappings do not support reverse iteration")
25        i = len(x)
26        while i > 0:
27            i -= 1
28            yield x[i]
29
30from os import listdir, makedirs, utime, stat
31from os.path import join, isdir, split, exists
32from time import gmtime, mktime, strftime, timezone
33from shutil import copyfile
34
35from trac.util import TracError, NaivePopen
36from trac.versioncontrol import Repository
37from trac.versioncontrol.cache import CachedRepository
38
39from node import Node, DarcsNode
40from changeset import Changeset, DarcsChangeset, DarcsCachedChangeset, \
41                      changesets_from_darcschanges
42
43class DarcsRepository(Repository):
44    """
45    Darcs concrete implementation of a Repository.
46
47    Darcs (http://www.abridgegame.org/darcs/) is a distribuited SCM,
48    patch-centric instead of snapshot-centric like Subversion. The
49    approach here is to assume that the history in the repository
50    followed by Trac is immutable, which is not a requirement for the
51    underlaying darcs. This means that on that repository should never
52    be executed a `darcs unpull`, or `darcs unrecord` and the like.
53
54    Given it's nature, this class tries to cache as much as possible
55    the requests coming from the above Trac machinery, to avoid or
56    minimize the external calls to Darcs that tend to be somewhat time
57    expensive.  In particular, _getNodeContent() caches any particular
58    version of any file Trac asks to it, kept on the filesystem,
59    inside the ``_darcs`` metadir of the controlled repository in the
60    directory ``trac_cache`` (or where specified by the option
61    ``cachedir`` in the section ``darcs`` of the configuration.  This
62    may require some kind of control over the size of the cache, that
63    OTOH may be completely removed whenever needed, it will be
64    recreated as soon as the first request come in.
65    """
66
67    def __init__(self, db, path, log, config):
68        Repository.__init__(self, path, None, log)
69        self.path = path
70        self.db = db
71        self.__youngest_rev = 0
72        self.__history = None
73        self.__history_start = 0
74        self.__no_pristine = exists(join(self.path, '_darcs', 'current.none'))
75        self.__darcs = config.get('darcs', 'command', 'darcs')
76        self.__encoding = config.get('darcs', 'encoding', None)
77        if config.get('darcs', 'dont_escape_8bit'):
78            self.__darcs = "DARCS_DONT_ESCAPE_8BIT=1 " + self.__darcs
79        self.__cachedir = config.get('darcs', 'cachedir',
80                                     join(self.path, '_darcs', 'trac_cache'))
81
82    ## Low level stuff: CamelCase is used to avoid clash with inherited
83    ## interface namespace.
84
85    def _darcs (self, command, args=''):
86        """
87        Execute a some query on the repository running an external darcs.
88
89        Return the XML output of the command.
90        """
91
92        command = "cd %r; TZ=UTC %s %s %s" % \
93                  (self.path, self.__darcs, command, args)
94        self.log.debug(command)
95        np = NaivePopen (command, capturestderr=True)
96        if np.errorlevel:
97            err = 'Running (%s) failed: %s, %s.' % (command,
98                                                    np.errorlevel, np.err)
99            raise TracError (err, title='Darcs execution failed')
100        return np.out
101
102    def _changes(self, args, startfrom=1):
103        """
104        Get changes information parsing the XML output of ``darcs changes``.
105
106        Return a sequence of Changesets.
107        """
108
109        return changesets_from_darcschanges(
110            self._darcs('changes --reverse --summary --xml', args),
111            self, startfrom, force_encoding=self.__encoding)
112
113    def _diff(self, path, rev1, rev2=None, patch=None):
114        """
115        Return a darcs diff between two revisions.
116        """
117
118        diff = "diff --unified"
119        rev1 = self.normalize_rev(rev1)
120        cset1 = DarcsCachedChangeset(rev1, self.db)
121        diff += " --from-match 'hash %s'" % cset1.hash
122        if rev2:
123            rev2 = self.normalize_rev(rev2)
124            cset2 = DarcsCachedChangeset(rev2, self.db)
125            diff += "  --to-match 'hash %s'" % cset2.hash
126        if patch:
127            path = path + patch
128        return self._darcs(diff, path)
129
130    def _parseInventory(self, inventory):
131        """
132        Parse an inventory, and return a dictionary of its content.
133        """
134
135        from sha import new
136
137        csmap = {}
138        start = 0
139        length = len(inventory)
140        index = self.__youngest_rev
141        while start<length:
142            start = inventory.find('[', start)
143            if start<0:
144                break
145            end = inventory.index('\n', start)
146            patchname = inventory[start+1:end]
147            start = end
148            end = inventory.index('*', start)
149            author = inventory[start+1:end]
150            inv = inventory[end+1] == '*' and 'f' or 't'
151            date = inventory[end+2:end+16]
152            y = int(date[:4])
153            m = int(date[4:6])
154            d = int(date[6:8])
155            hh = int(date[8:10])
156            mm = int(date[10:12])
157            ss = int(date[12:14])
158            unixtime = int(mktime((y, m, d, hh, mm, ss, 0, 0, 0)))-timezone
159            end += 16
160            log = ''
161            if inventory[end] <> ']':
162                start = end
163                end = inventory.index('\n', start+1)
164                while True:
165                    log += inventory[start+2:end]
166                    start = end
167                    if inventory[end+1] == ']':
168                        break
169                    end = inventory.index('\n', start+1)
170                end += 1
171
172            index += 1
173            phash = new()
174            phash.update(patchname)
175            phash.update(author)
176            phash.update(date)
177            phash.update(log)
178            phash.update(inv)
179            patchid = '%s-%s-%s.gz' % (date,
180                                       new(author).hexdigest()[:5],
181                                       phash.hexdigest())
182            csmap[index] = patchid
183            csmap[patchid] = index
184            start = end+1
185        self.__youngest_rev = index
186        return csmap
187
188    def _loadChangesetsIndex(self):
189        """
190        Load the index of changesets in the repository, assigning an unique
191        integer number to each one, ala Subversion, for easier references.
192
193        This is done by parsing the ``_darcs/inventory`` file using the
194        position of each changeset as its `revision number`, **assuming**
195        that **nobody's never** going to do anything that alters its order,
196        such as ``darcs optimize`` or ``darcs unpull``.
197        """
198
199        self.__youngest_rev = 0
200        inventories = [join(self.path, '_darcs', 'inventories', i) for i
201                       in listdir(join(self.path, '_darcs', 'inventories'))]
202        inventories.sort()
203        inventories.append(join(self.path, '_darcs', 'inventory'))
204        for inventory in inventories:
205            f = open(inventory, 'rU')
206            try:
207                index = self._parseInventory(f.read())
208            finally:
209                f.close()
210
211    ## Medium level, work-horse methods
212
213    def _getCachedContentLocation(self, node):
214        """
215        Return the location of the cache for the given node. If it does
216        not exist, compute it by applying a diff to the current version.
217        This may return None, if the node does not actually exist.
218        """
219
220        rev = self.normalize_rev(node.rev)
221
222        if self.__no_pristine:
223            current = join(self.path, node.path)
224        else:
225            current = join(self.path, '_darcs', 'current', node.path)
226
227        # Iterate over history to find out which is the revision of
228        # the given path that last changed the it. We need to find
229        # both a 'last revision' and 'second last', because later
230        # we may apply either a r1:last diff or a 2nd:current diff.
231        history = self._getPathHistory(node.path, None)
232        try:
233            lastnode = history.next()
234        except StopIteration:
235            lastnode = None
236
237        if lastnode is None:
238            return None
239        elif lastnode.rev <= rev:
240            # Content hasn't changed, return current version
241            if exists(current):
242                return current
243
244        prevlast = lastnode
245        for oldnode in history:
246            if oldnode.rev <= rev:
247                lastnode = oldnode
248                break
249            prevlast = oldnode
250
251        cachedir = join(self.__cachedir, str(lastnode.rev))
252        cache = join(cachedir, lastnode.path)
253
254        # One may never know: should by any chance an absolute path survived
255        # in lastnode.path, or in some clever way introduced some trick like
256        # 'somepath/../../../etc/passwd'...
257        from os.path import normpath
258        assert normpath(cache).startswith(cachedir)
259
260        if not exists(cache):
261            self.log.debug('Caching revision %d of %s' % (lastnode.rev,
262                                                          lastnode.path))
263            dir = split(cache)[0]
264            if not exists(dir):
265                makedirs(dir)
266
267            # If the file doesn't current exist, create an empty file
268            # and apply a patch from revision 1 to the node revision,
269            # otherwise apply a reversed patch from the current revision
270            # and to node revision+1.
271            try:
272                if not exists(current):
273                    self.log.debug('Applying a direct patch from revision 1 up'
274                                   ' to %d to %s' % (lastnode.rev, node.path))
275                    open(cache, 'w').close()
276                    patch = "| patch -p1 -d %s" % cachedir
277                    self._diff(node.path, 1, lastnode.rev, patch=patch)
278                else:
279                    self.log.debug('Applying a reverse patch from current'
280                                   ' revision back to %d to %s' %
281                                   (lastnode.rev, node.path))
282                    copyfile(current, cache)
283                    patch = "| patch -p1 -R -d %s" % cachedir
284                    self._diff(node.path, prevlast.rev, patch=patch)
285            except TracError, exc:
286                if 'Only garbage was found in the patch input' in exc.message:
287                    pass
288                else:
289                    raise
290
291            # Adjust the times of the just created cache file, to match
292            # the timestamp of the associated changeset.
293            cursor = self.db.cursor()
294            cursor.execute("SELECT time FROM revision "
295                           "WHERE rev = %s", (lastnode.rev,))
296            cstimestamp = int(cursor.fetchone()[0])
297            utime(cache, (cstimestamp, cstimestamp))
298
299        if exists(cache):
300            return cache
301        else:
302            return None
303
304    def _getNodeContent(self, node):
305        """
306        Return the content of the node, loading it from the cache.
307        """
308
309        from cStringIO import StringIO
310
311        location = self._getCachedContentLocation(node)
312        if location:
313            return file(location)
314        else:
315            return StringIO('')
316
317    def _getNodeSize(self, node):
318        """
319        Return the content of the node, loading it from the cache.
320        """
321
322        location = self._getCachedContentLocation(node)
323        if location:
324            return stat(location).st_size
325        else:
326            return None
327
328    def _getNodeEntries(self, node):
329        """
330        Generate the the immediate child entries of a directory at given
331        revision, in alpha order.
332        """
333
334        from trac.versioncontrol.cache import _actionmap
335
336        # Loop over nodes touched before given rev that falls in the
337        # given path. We effectively want to look at the whole subtree,
338        # because when a child is a directory we annotate it with the
339        # latest change happened below that, instead with the revision
340        # that actually touched the directory itself.
341
342        cursor = self.db.cursor()
343        path = node.path.strip('/')
344        cursor.execute("SELECT rev, path, change, base_path "
345                       "  FROM node_change "
346                       "WHERE ((LENGTH(rev)<LENGTH(%s)) OR "
347                       "       (LENGTH(rev)=LENGTH(%s) AND rev<=%s)) "
348                       "  AND (path GLOB %s OR base_path GLOB %s) "
349                       "ORDER BY -LENGTH(rev),rev DESC,path ",
350                       (node.rev, node.rev, node.rev, path+'*', path+'*'))
351
352        if path:
353            path += '/'
354
355        done = {}
356        for entry in cursor:
357            rev, subtreepath, change, oldpath = entry
358            if oldpath and oldpath.startswith(path):
359                subpath = oldpath[len(path):].lstrip('/')
360            elif subtreepath.startswith(path):
361                subpath = subtreepath[len(path):].lstrip('/')
362            else:
363                # The GLOB above may return either entries in the
364                # directory or entries in other directories (usually
365                # the one that contains the folder we are listing)
366                # that share a common chunk of the name with the
367                # directory itself.
368                continue
369
370            if '/' in subpath:
371                child, rest = subpath.split('/', 1)
372            else:
373                child, rest = subpath, None
374
375            if not child or child in done:
376                continue
377
378            done[child] = True
379
380            # Return the node only if the entry is not a direct child
381            # of the directory, otherwise only if it's not a deletion
382            # or a rename.
383            if rest or not ((_actionmap[change]==Changeset.MOVE
384                             and oldpath and oldpath.startswith(path)) or
385                            _actionmap[change]==Changeset.DELETE):
386                yield self.get_node(path + child, rev)
387
388    def _getNodeLastModified(self, node):
389        """
390        Return the timestamp of the last modification to the given node.
391        """
392
393        location = self._getCachedContentLocation(node)
394        if location:
395            return stat(location).st_mtime
396        else:
397            return 0
398
399    def _getPathHistory(self, path, rev=None, limit=None):
400        """
401        Iterate over the nodes that compose the history of the given
402        path not newer than rev.
403        """
404
405        from trac.versioncontrol.cache import _kindmap, _actionmap
406
407        rev = self.normalize_rev(rev)
408
409        # Start with the concrete history, if present
410        if self.__history and rev>self.__history_start:
411            node = None
412            for cs in reversed(self.__history):
413                if cs.rev > rev:
414                    continue
415                node = cs.get_node(path)
416                if node:
417                    yield node
418                    if limit:
419                        limit -= 1
420                        if limit==0:
421                            break
422                    # Expand renames
423                    if node.change == Changeset.MOVE:
424                        for node in self._getPathHistory(node.oldpath,
425                                                         node.rev-1, limit):
426                            yield node
427                            if limit:
428                                limit -= 1
429            if node is not None:
430                rev = node.rev-1
431
432        # Keep going with the cache stored in the DB
433        kind = self._getNodeKind(path, rev)
434        cursor = self.db.cursor()
435
436        path = path.rstrip('/')
437        if kind == Node.DIRECTORY:
438            revdone = {}
439            cursor.execute("SELECT rev,kind,change,base_path"
440                           "  FROM node_change "
441                           "WHERE ((LENGTH(rev)<LENGTH(%s)) OR "
442                           "       (LENGTH(rev)=LENGTH(%s) AND rev<=%s)) "
443                           "  AND (path=%s OR path GLOB %s) "
444                           "ORDER BY -LENGTH(rev),rev DESC ",
445                           (rev, rev, rev, path, path+'/*'))
446            for row in cursor:
447                rev, kind, change, base_path = row
448                if not rev in revdone:
449                    revdone[rev] = True
450                    node = DarcsNode(path, rev, _kindmap[kind],
451                                     _actionmap[change], self,
452                                     oldpath=base_path)
453                    yield node
454                    if limit:
455                        limit -= 1
456                        if limit==0:
457                            break
458        else:
459            while rev>=1:
460                cursor.execute("SELECT kind,change,base_path,base_rev "
461                               "FROM node_change WHERE rev=%s AND path=%s",
462                               (rev, path))
463                base_rev = None
464                for row in cursor:
465                    kind, change, base_path, base_rev = row
466                    node = DarcsNode(path, rev, _kindmap[kind],
467                                     _actionmap[change], self,
468                                     oldpath=base_path)
469                    yield node
470                    if limit:
471                        limit -= 1
472                        if limit==0:
473                            break
474                    base_rev = base_rev and int(base_rev) or 0
475                    # Expand renames
476                    if node.change == Changeset.MOVE:
477                        for node in self._getPathHistory(node.oldpath,
478                                                         base_rev, limit):
479                            yield node
480                            if limit:
481                                limit -= 1
482
483                if base_rev is None:
484                    rev -= 1
485                else:
486                    rev = base_rev
487
488    def _getNodeKind(self, path, rev):
489        """
490        Determine the kind of the path at given revision.
491        """
492
493        # Determine if the path is really a directory, except when it's
494        # already known: it is, when its name ends with a slash (a fake
495        # one introduced by changesets_from_darcschanges()) or it is the
496        # empty string, resulted from normalize_path('/').
497        if not path.endswith("/") and path <> "":
498            cursor = self.db.cursor()
499            cursor.execute("SELECT path "
500                           "FROM node_change "
501                           "WHERE ((LENGTH(rev)<LENGTH(%s)) OR "
502                           "       (LENGTH(rev)=LENGTH(%s) AND rev<=%s)) "
503                           "  AND path=%s "
504                           "ORDER BY -LENGTH(rev),rev DESC "
505                           "LIMIT 1", (rev, rev, rev, path+'/'))
506            if cursor.fetchone():
507                kind = Node.DIRECTORY
508            else:
509                kind = Node.FILE
510        else:
511            kind = Node.DIRECTORY
512        return kind
513
514    ## Interface API
515
516    def close(self):
517        """
518        Close the connection to the repository.
519
520        Darcs: no-op.
521        """
522
523    def get_changeset(self, rev):
524        """
525        Retrieve a Changeset object that describes the changes made in
526        revision 'rev'.
527        """
528
529        if not self.__history:
530            youngest = self.normalize_rev(self.youngest_rev)
531            youngest_cache = self.get_youngest_rev_in_cache(self.db) or '0'
532            self.__history_start = int(youngest_cache)
533            self.__history = self._changes('--last=%d' %
534                                           (youngest - self.__history_start,),
535                                           self.__history_start+1)
536        rev = self.normalize_rev(rev)
537        return self.__history[rev-self.__history_start-1]
538
539    def get_node(self, path, rev=None):
540        """
541        Retrieve a Node (directory or file) from the repository at the
542        given path. If the rev parameter is specified, the version of the
543        node at that revision is returned, otherwise the latest version
544        of the node is returned.
545        """
546
547        rev = self.normalize_rev(rev)
548        path = self.normalize_path(path)
549
550        if path == '':
551            return DarcsNode('', rev, Node.DIRECTORY, None, self)
552
553        kind = self._getNodeKind(path, rev)
554
555        if kind == Node.DIRECTORY:
556            cursor = self.db.cursor()
557            path = path.rstrip('/')
558            cursor.execute("SELECT rev,change "
559                           "  FROM node_change "
560                           "WHERE ((LENGTH(rev)<LENGTH(%s)) OR "
561                           "       (LENGTH(rev)=LENGTH(%s) AND rev<=%s)) "
562                           "  AND (path=%s OR path GLOB %s) "
563                           "ORDER BY -LENGTH(rev),rev DESC "
564                           "LIMIT 1", (rev, rev, rev, path, path+'/*'))
565            lastchange = cursor.fetchone()
566            if lastchange:
567                rev, change = lastchange
568            else:
569                raise TracError, "No node at %r in revision %s" % (path, rev)
570            node = DarcsNode(path, rev, Node.DIRECTORY, change, self)
571        else:
572            history = self._getPathHistory(path, rev, limit=1)
573            try:
574                node = history.next()
575            except StopIteration:
576                raise TracError, "No node at %r in revision %s" % (path, rev)
577
578        return node
579
580    def get_oldest_rev(self):
581        """
582        Return the oldest revision stored in the repository.
583        """
584
585        # If the repository is empty, return None
586        if not self.__youngest_rev:
587            return None
588        return 1
589
590    def get_youngest_rev(self):
591        """
592        Return the youngest revision in the repository.
593        """
594
595        if not self.__youngest_rev:
596            self._loadChangesetsIndex()
597        return self.__youngest_rev
598
599    def previous_rev(self, rev):
600        """
601        Return the revision immediately preceding the specified revision.
602        """
603
604        rev = self.normalize_rev(rev)
605        if rev == 1:
606            return None
607        else:
608            return rev-1
609
610    def next_rev(self, rev):
611        """
612        Return the revision immediately following the specified revision.
613        """
614
615        rev = self.normalize_rev(rev)
616        if rev < self.get_youngest_rev():
617            return rev+1
618        else:
619            return None
620
621    def rev_older_than(self, rev1, rev2):
622        """
623        Return True if rev1 is older than rev2, i.e. if rev1 comes before rev2
624        in the revision sequence.
625        """
626
627        return self.normalize_rev(rev1) < self.normalize_rev(rev2)
628
629    def get_youngest_rev_in_cache(self, db):
630        """
631        Return the youngest revision currently cached.
632
633        For darcs, this is the last applied revision, not necessarily
634        the youngest one. We are numbering darcs patches in order of
635        application.
636        """
637
638        cursor = db.cursor()
639        cursor.execute("SELECT rev "
640                       "FROM revision "
641                       "ORDER BY -LENGTH(rev),rev DESC " # rev is a string
642                       "LIMIT 1")                        # in the database
643        row = cursor.fetchone()
644        return row and row[0] or None
645
646    def get_path_history(self, path, rev=None, limit=None):
647        """
648        Retrieve all the revisions containing this path (no newer than 'rev').
649        The result format should be the same as the one of Node.get_history()
650        """
651
652        path = self.normalize_path(path)
653        rev = self.normalize_rev(rev)
654        for node in self._getPathHistory(path, rev, limit):
655            yield (node.path, node.rev, node.change)
656
657    def normalize_path(self, path):
658        """
659        Return a canonical representation of path in the repos.
660        """
661
662        if path is None:
663            return '/'
664        elif path.startswith('/'):
665            return path[1:]
666        else:
667            return path
668
669    def normalize_rev(self, rev):
670        """
671        Return a canonical representation of a revision in the repos.
672        'None' is a valid revision value and represents the youngest revision.
673        """
674
675        try:
676            rev =  int(rev)
677        except (ValueError, TypeError):
678            rev = None
679        if rev is None:
680            rev = self.get_youngest_rev()
681        elif rev > self.get_youngest_rev():
682            rev = self.get_youngest_rev()
683        return rev
684
685    def get_changes(self, old_path, old_rev, new_path, new_rev,
686                    ignore_ancestry=1):
687        """
688        Generator that yields change tuples (old_node, new_node, kind, change)
689        for each node change between the two arbitrary (path,rev) pairs.
690
691        The old_node is assumed to be None when the change is an ADD,
692        the new_node is assumed to be None when the change is a DELETE.
693        """
694        old_node = new_node = None
695        old_rev = self.normalize_rev(old_rev)
696        new_rev = self.normalize_rev(new_rev)
697        if self.has_node(old_path, old_rev):
698            old_node = self.get_node(old_path, old_rev)
699        else:
700            raise TracError, ('The Base for Diff is invalid: path %s'
701                              ' doesn\'t exist in revision %s' \
702                              % (old_path, old_rev))
703        if self.has_node(new_path, new_rev):
704            new_node = self.get_node(new_path, new_rev)
705        else:
706            raise TracError, ('The Target for Diff is invalid: path %s'
707                              ' doesn\'t exist in revision %s' \
708                              % (new_path, new_rev))
709        if new_node.kind != old_node.kind:
710            raise TracError, ('Diff mismatch: Base is a %s (%s in revision %s) '
711                              'and Target is a %s (%s in revision %s).' \
712                              % (old_node.kind, old_path, old_rev,
713                                 new_node.kind, new_path, new_rev))
714
715        changes = []
716        for path, rev, change in new_node.get_history():
717            changes.append((path, rev, change))
718            if path == old_path and rev == old_rev:
719                break
720        oldnode = None
721        yielded = False
722        for path, rev, change in reversed(changes):
723            node = self.get_node(path, rev)
724            if oldnode:
725                yield (oldnode, node, node.kind, node.change)
726                yielded = True
727            oldnode = node
728        if not yielded and oldnode is not None:
729            yield (oldnode, None, oldnode.kind, oldnode.change)
730
731class DarcsCachedRepository(CachedRepository):
732    """
733    Darcs version of the cached repository, that serves DarcsCachedChangesets
734    """
735
736    def get_changeset(self, rev):
737        if not self.synced:
738            self.sync()
739            self.synced = 1
740        return DarcsCachedChangeset(self.repos.normalize_rev(rev), self.db,
741                                    self.authz)
Note: See TracBrowser for help on using the repository browser.