source: tailor/vcpx/cvsps.py @ 1177

Revision 1177, 24.7 KB checked in by lele@…, 7 years ago (diff)

Fix comment

Line 
1# -*- mode: python; coding: utf-8 -*-
2# :Progetto: vcpx -- CVSPS details
3# :Creato:   mer 16 giu 2004 00:46:12 CEST
4# :Autore:   Lele Gaifax <lele@nautilus.homeip.net>
5# :Licenza:  GNU General Public License
6#
7
8"""
9This module contains supporting classes for CVS. To get a
10cross-repository revision number a la Subversion, the implementation
11uses `cvsps` to fetch the changes from the upstream repository.
12"""
13
14__docformat__ = 'reStructuredText'
15
16from shwrap import ExternalCommand, PIPE
17from source import UpdatableSourceWorkingDir, ChangesetApplicationFailure, \
18     InvocationError
19from target import SynchronizableTargetWorkingDir, TargetInitializationFailure
20
21class EmptyRepositoriesFoolsMe(Exception):
22    "Cannot handle empty repositories. Maybe wrong module/repository?"
23
24    # This is the exception raised when we try to tailor an empty CVS
25    # repository. This is more a shortcoming of tailor, rather than a
26    # real problem with those repositories.
27
28def changesets_from_cvsps(log, sincerev=None):
29    """
30    Parse CVSps log.
31    """
32
33    from changes import Changeset, ChangesetEntry
34    from datetime import datetime
35    from cvs import compare_cvs_revs
36
37    # cvsps output sample:
38    ## ---------------------
39    ## PatchSet 1500
40    ## Date: 2004/05/09 17:54:22
41    ## Author: grubert
42    ## Branch: HEAD
43    ## Tag: (none)
44    ## Log:
45    ## Tell the reason for using mbox (not wrapping long lines).
46    ##
47    ## Members:
48    ##         docutils/writers/latex2e.py:1.78->1.79
49
50    l = None
51    while 1:
52        l = log.readline()
53        if l <> '---------------------\n':
54            break
55
56        l = log.readline()
57        assert l.startswith('PatchSet '), "Parse error: %s"%l
58
59        pset = {}
60        pset['revision'] = l[9:-1].strip()
61        l = log.readline()
62        while not l.startswith('Log:'):
63            field,value = l.split(':',1)
64            pset[field.lower()] = value.strip()
65            l = log.readline()
66
67        msg = []
68        l = log.readline()
69        msg.append(l)
70        l = log.readline()
71        while l <> 'Members: \n':
72            msg.append(l)
73            l = log.readline()
74
75        assert l.startswith('Members:'), "Parse error: %s" % l
76
77        entries = []
78        l = log.readline()
79        seen = {}
80        while l.startswith('\t'):
81            if not sincerev or (sincerev<int(pset['revision'])):
82                # Cannot use split here, file may contain ':'
83                cpos = l.rindex(':')
84                file = l[1:cpos]
85                revs = l[cpos+1:-1]
86                fromrev,torev = revs.strip().split('->')
87
88                # Due to the fuzzy mechanism, cvsps may group
89                # together two commits on a single entry, thus
90                # giving something like:
91                #
92                #   Normalizer.py:1.12->1.13
93                #   Registry.py:1.22->1.23
94                #   Registry.py:1.21->1.22
95                #   Stopwords.py:1.9->1.10
96                #
97                # Collapse those into a single one.
98
99                e = seen.get(file)
100                if not e:
101                    e = ChangesetEntry(file)
102                    e.old_revision = fromrev
103                    e.new_revision = torev
104                    seen[file] = e
105                    entries.append(e)
106                else:
107                    if compare_cvs_revs(e.old_revision, fromrev)>0:
108                        e.old_revision = fromrev
109
110                    if compare_cvs_revs(e.new_revision, torev)<0:
111                        e.new_revision = torev
112
113                if fromrev=='INITIAL':
114                    e.action_kind = e.ADDED
115                elif "(DEAD)" in torev:
116                    e.action_kind = e.DELETED
117                    e.new_revision = torev[:torev.index('(DEAD)')]
118                else:
119                    e.action_kind = e.UPDATED
120
121            l = log.readline()
122
123        if not sincerev or (sincerev<int(pset['revision'])):
124            cvsdate = pset['date']
125            y,m,d = map(int, cvsdate[:10].split('/'))
126            hh,mm,ss = map(int, cvsdate[11:19].split(':'))
127            timestamp = datetime(y, m, d, hh, mm, ss)
128            pset['date'] = timestamp
129
130            yield Changeset(pset['revision'], timestamp, pset['author'],
131                            ''.join(msg), entries)
132
133
134class CvspsWorkingDir(UpdatableSourceWorkingDir,
135                      SynchronizableTargetWorkingDir):
136
137    """
138    An instance of this class represents a read/write CVS working
139    directory, so that it can be used both as a source of patches and
140    as a target repository.
141
142    It uses `cvsps` to do the actual fetch of the changesets metadata
143    from the server, so that we can reasonably group together related
144    changes that would otherwise be sparsed, as CVS is file-centric.
145    """
146
147    ## UpdatableSourceWorkingDir
148
149    def _getUpstreamChangesets(self, sincerev=None):
150        from os.path import join, exists
151
152        branch="HEAD"
153        fname = join(self.basedir, 'CVS', 'Tag')
154        if exists(fname):
155            tag = open(fname).read()
156            if tag.startswith('T'):
157                branch=tag[1:-1]
158        else:
159            if sincerev is not None and isinstance(sincerev, basestring) \
160                   and not sincerev[0] in '0123456789':
161                branch = sincerev
162                sincerev = None
163
164        if sincerev:
165            sincerev = int(sincerev)
166
167        cmd = self.repository.command("--cvs-direct", "-u", "-b", branch,
168                                      "--root", self.repository.repository,
169                                      cvsps=True)
170        cvsps = ExternalCommand(command=cmd)
171        log = cvsps.execute(self.repository.module, stdout=PIPE, TZ='UTC0')[0]
172
173        for cs in changesets_from_cvsps(log, sincerev):
174            yield cs
175
176    def __maybeDeleteDirectory(self, entrydir, changeset):
177        from os.path import join, exists
178        from os import listdir
179
180        if not entrydir:
181            return
182
183        absentrydir = join(self.basedir, entrydir)
184        if not exists(absentrydir) or listdir(absentrydir) == ['CVS']:
185            # Oh, the directory is empty: if there are no other added entries
186            # in the directory, insert a REMOVE event against it.
187            for added in changeset.addedEntries():
188                if added.name.startswith(entrydir):
189                    # entrydir got empty, but only temporarily
190                    return False
191            return True
192        return False
193
194    def _applyChangeset(self, changeset):
195        from os.path import join, exists, split
196        from os import listdir
197        from shutil import rmtree
198        from cvs import CvsEntries
199        from time import sleep
200
201        entries = CvsEntries(self.basedir)
202
203        # Collect added and deleted directories
204        addeddirs = []
205        deleteddirs = []
206
207        for e in changeset.entries:
208            if e.action_kind == e.UPDATED:
209                info = entries.getFileInfo(e.name)
210                if not info:
211                    self.log.debug('promoting "%s" to ADDED at '
212                                   'revision %s', e.name, e.new_revision)
213                    e.action_kind = e.ADDED
214                    addeddirs.extend(self.__createParentCVSDirectories(changeset, e.name))
215                elif info.cvs_version == e.new_revision:
216                    self.log.debug('skipping "%s" since it is already '
217                                   'at revision %s', e.name, e.new_revision)
218                    continue
219            elif e.action_kind == e.DELETED:
220                if not exists(join(self.basedir, e.name)):
221                    self.log.debug('skipping "%s" since it is already '
222                                   'deleted', e.name)
223                    entrydir = split(e.name)[0]
224                    if self.__maybeDeleteDirectory(entrydir, changeset):
225                        deleteddirs.append(entrydir)
226                    continue
227            elif e.action_kind == e.ADDED and e.new_revision is None:
228                # This is a new directory entry, there is no need to update it
229                continue
230
231            # If this is a directory (CVS does not version directories,
232            # and thus new_revision is always None for them), and it's
233            # going to be deleted, do not execute a 'cvs update', that
234            # in some cases does not do what one would expect. Instead,
235            # remove it with everything it contains (that should be
236            # just a single "CVS" subdir, btw)
237
238            if e.action_kind == e.DELETED and e.new_revision is None:
239                assert listdir(join(self.basedir, e.name)) == ['CVS'], '%s should be empty' % e.name
240                rmtree(join(self.basedir, e.name))
241            else:
242                cmd = self.repository.command("-d", "%(repository)s",
243                                              "-q", "update", "-d",
244                                              "-r", e.new_revision)
245                if self.repository.freeze_keywords:
246                    cmd.append('-kk')
247                cvsup = ExternalCommand(cwd=self.basedir, command=cmd)
248                retry = 0
249                while True:
250                    cvsup.execute(e.name, repository=self.repository.repository)
251
252                    if cvsup.exit_status:
253                        retry += 1
254                        if retry>3:
255                            break
256                        delay = 2**retry
257                        self.log.warning("%s returned status %s, "
258                                         "retrying in %d seconds...",
259                                         str(cvsup), cvsup.exit_status, delay)
260                        sleep(retry)
261                    else:
262                        break
263
264                if cvsup.exit_status:
265                    raise ChangesetApplicationFailure(
266                        "%s returned status %s" % (str(cvsup),
267                                                   cvsup.exit_status))
268
269                self.log.debug("%s updated to %s", e.name, e.new_revision)
270
271            if e.action_kind == e.DELETED:
272                entrydir = split(e.name)[0]
273                if self.__maybeDeleteDirectory(entrydir, changeset):
274                    deleteddirs.append(entrydir)
275
276        # Fake up ADD and DEL events for the directories implicitly
277        # added/removed, so that the replayer gets their name.
278
279        for path in addeddirs:
280            entry = changeset.addEntry(path, None)
281            entry.action_kind = entry.ADDED
282
283        for path in deleteddirs:
284            deldir = changeset.addEntry(path, None)
285            deldir.action_kind = deldir.DELETED
286
287    def _checkoutUpstreamRevision(self, revision):
288        """
289        Concretely do the checkout of the upstream sources. Use
290        `revision` as the name of the tag to get, or as a date if it
291        starts with a number.
292
293        Return the last applied changeset.
294        """
295
296        from os.path import join, exists, split
297        from cvs import CvsEntries, compare_cvs_revs
298        from time import sleep
299
300        if not self.repository.module:
301            raise InvocationError("Must specify a module name")
302
303        timestamp = None
304        if revision is not None:
305            # If the revision contains a space, assume it really
306            # specify a branch and a timestamp. If it starts with
307            # a digit, assume it's a timestamp. Otherwise, it must
308            # be a branch name
309            if revision[0] in '0123456789' or revision == 'INITIAL':
310                timestamp = revision
311                revision = None
312            elif ' ' in revision:
313                revision, timestamp = revision.split(' ', 1)
314
315        csets = self.getPendingChangesets(revision)
316        if not csets:
317            raise TargetInitializationFailure(
318                "Something went wrong: there are no changesets since "
319                "revision '%s'" % revision)
320        if timestamp == 'INITIAL':
321            initialcset = csets.next()
322            timestamp = initialcset.date.isoformat(sep=' ')
323        else:
324            initialcset = None
325
326        if not exists(join(self.basedir, 'CVS')):
327            # CVS does not handle "checkout -d multi/level/subdir", so
328            # split the basedir and use it's parentdir as cwd below.
329            parentdir, subdir = split(self.basedir)
330            cmd = self.repository.command("-q",
331                                          "-d", self.repository.repository,
332                                          "checkout",
333                                          "-d", subdir)
334            if revision:
335                cmd.extend(["-r", revision])
336            if timestamp:
337                cmd.extend(["-D", "%s UTC" % timestamp])
338            if self.repository.freeze_keywords:
339                cmd.append('-kk')
340
341            checkout = ExternalCommand(cwd=parentdir, command=cmd)
342            retry = 0
343            while True:
344                checkout.execute(self.repository.module)
345                if checkout.exit_status:
346                    retry += 1
347                    if retry>3:
348                        break
349                    delay = 2**retry
350                    self.log.warning("%s returned status %s, "
351                                     "retrying in %d seconds...",
352                                     str(checkout), checkout.exit_status,
353                                     delay)
354                    sleep(retry)
355                else:
356                    break
357
358            if checkout.exit_status:
359                raise TargetInitializationFailure(
360                    "%s returned status %s" % (str(checkout),
361                                               checkout.exit_status))
362        else:
363            self.log.info("Using existing %s", self.basedir)
364
365        if self.repository.tag_entries:
366            self.__forceTagOnEachEntry()
367
368        entries = CvsEntries(self.basedir)
369        youngest_entry = entries.getYoungestEntry()
370        if youngest_entry is None:
371            raise EmptyRepositoriesFoolsMe("The working copy '%s' of the "
372                                           "CVS repository seems empty, "
373                                           "don't know how to deal with "
374                                           "that." % self.basedir)
375
376        # loop over the changesets and find the last applied, to find
377        # out the actual cvsps revision
378
379        found = False
380        csets = self.state_file.reversed()
381
382        def already_applied(cs, entries=entries):
383            "Loop over changeset entries to determine if it's already applied."
384
385            applied = False
386            for m in cs.entries:
387                info = entries.getFileInfo(m.name)
388                if info:
389                    odversion = info.cvs_version
390                    applied = compare_cvs_revs(odversion, m.new_revision) >= 0
391                    if not applied:
392                        break
393            return applied
394
395        for cset in csets:
396            found = already_applied(cset)
397            if found:
398                last = cset
399                break
400
401        if not found and initialcset:
402            found = already_applied(initialcset)
403            if found:
404                last = initialcset
405
406        if not found:
407            raise TargetInitializationFailure(
408                "Something went wrong: unable to determine the exact upstream "
409                "revision of the checked out tree in '%s'" % self.basedir)
410        else:
411            self.log.info("Working copy up to revision %s", last.revision)
412
413        return last
414
415    def _willApplyChangeset(self, changeset, applyable=None):
416        """
417        This gets called just before applying each changeset.
418
419        Since CVS has no "createdir" event, we have to take care
420        of new directories, creating empty-but-reasonable CVS dirs.
421        """
422
423        if UpdatableSourceWorkingDir._willApplyChangeset(self, changeset,
424                                                         applyable):
425            for m in changeset.entries:
426                if m.action_kind == m.ADDED:
427                    self.__createParentCVSDirectories(changeset, m.name)
428
429            return True
430        else:
431            return False
432
433    def __createParentCVSDirectories(self, changeset, entry):
434        """
435        Verify that the hierarchy down to the entry is under CVS.
436
437        If the directory containing the entry does not exist,
438        create it and make it appear as under CVS so that a subsequent
439        'cvs update' will work.
440
441        Returns the list of eventually added directories.
442        """
443
444        from os.path import split, join, exists
445        from os import mkdir
446
447        tobeadded = []
448
449        path = split(entry)[0]
450
451        parentcvs = join(self.basedir, path, 'CVS')
452        while not exists(parentcvs):
453            tobeadded.insert(0, join(self.basedir, path))
454            if not path:
455                break
456            path = split(path)[0]
457            parentcvs = join(self.basedir, path, 'CVS')
458
459        assert exists(parentcvs), "Uhm, strange things happen: " \
460               "unable to find or create parent CVS area for %r" % entry
461
462        if tobeadded:
463            reposf = open(join(parentcvs, 'Repository'))
464            rep = reposf.readline()[:-1]
465            reposf.close()
466
467            rootf = open(join(parentcvs, 'Root'))
468            root = rootf.readline()
469            rootf.close()
470
471        for basedir in tobeadded:
472            cvsarea = join(basedir, 'CVS')
473
474            if not exists(basedir):
475                mkdir(basedir)
476
477            # Create fake CVS area
478            mkdir(cvsarea)
479
480            # Create an empty "Entries" file
481            entries = open(join(cvsarea, 'Entries'), 'w')
482            entries.close()
483
484            reposf = open(join(cvsarea, 'Repository'), 'w')
485            rep = '/'.join((rep, split(basedir)[1]))
486            reposf.write("%s\n" % rep)
487            reposf.close()
488
489            rootf = open(join(cvsarea, 'Root'), 'w')
490            rootf.write(root)
491            rootf.close()
492
493        return tobeadded
494
495    ## SynchronizableTargetWorkingDir
496
497    def __createRepository(self, path, target_module):
498        """
499        Create a local CVS repository.
500        """
501
502        from os import rmdir, makedirs
503        from tempfile import mkdtemp
504
505        makedirs(path)
506        cmd = self.repository.command("-d", path, "init")
507        c = ExternalCommand(command=cmd)
508        c.execute()
509        if c.exit_status:
510            raise TargetInitializationFailure("Could not create CVS repository")
511
512        tempwc = mkdtemp('cvs', 'tailor')
513        cmd = self.repository.command("-d", path, "import",
514                                      "-m", "This directory will host the "
515                                      "upstream sources",
516                                      target_module, "tailor", "start")
517        c = ExternalCommand(cwd=tempwc, command=cmd)
518        c.execute()
519        rmdir(tempwc)
520        if c.exit_status:
521            raise TargetInitializationFailure("Could not create initial module")
522
523    def _prepareTargetRepository(self):
524        """
525        Create the CVS repository if it's local and does not exist.
526        """
527
528        from os.path import exists
529
530        if not self.repository.repository:
531            return
532
533        if self.repository.repository.startswith(':local:'):
534            rpath = self.repository.repository[7:]
535        elif self.repository.repository.startswith('/'):
536            rpath = self.repository.repository
537        else:
538            # Remote repository
539            return
540
541        if not exists(rpath):
542            self.__createRepository(rpath, self.repository.module)
543
544    def _prepareWorkingDirectory(self, source_repo):
545        """
546        Checkout a working copy of the target CVS.
547        """
548
549        from os.path import join, exists
550
551        if not self.repository.repository or exists(join(self.basedir, 'CVS')):
552            return
553
554        cmd = self.repository.command("-d", self.repository.repository, "co",
555                                      "-d", self.basedir)
556        cvsco = ExternalCommand(command=cmd)
557        cvsco.execute(self.repository.module)
558
559    def _parents(self, path):
560        from os.path import exists, join, split
561
562        parents = []
563        parent = split(path)[0]
564        while parent:
565            if exists(join(self.basedir, parent, 'CVS')):
566                break
567            parents.insert(0, parent)
568            parent = split(parent)[0]
569
570        return parents
571
572    def _addEntries(self, entries):
573        """
574        Synthesize missing parent directory additions
575        """
576
577        allnames = [e.name for e in entries]
578        newdirs = []
579        for entry in allnames:
580            for parent in [p for p in self._parents(entry) if p not in allnames]:
581                if p not in newdirs:
582                    newdirs.append(parent)
583
584        newdirs.extend(allnames)
585        self._addPathnames(newdirs)
586
587    def _addPathnames(self, names):
588        """
589        Add some new filesystem objects.
590        """
591
592        cmd = self.repository.command('-q', 'add', '-ko')
593        ExternalCommand(cwd=self.basedir, command=cmd).execute(names)
594
595    def __forceTagOnEachEntry(self):
596        """
597        Massage each CVS/Entries file, locking (ie, tagging) each
598        entry to its current CVS version.
599
600        This is to prevent silly errors such those that could arise
601        after a manual ``cvs update`` in the working directory.
602        """
603
604        from os import walk, rename, remove
605        from os.path import join, exists
606
607        self.log.info("Forcing CVS sticky tag in %s", self.basedir)
608
609        for dir, subdirs, files in walk(self.basedir):
610            if dir[-3:] == 'CVS':
611                efn = join(dir, 'Entries')
612
613                # Strangeness is a foreign word in CVS: sometime
614                # the Entries isn't there...
615                if not exists(efn):
616                    continue
617
618                f = open(efn)
619                entries = f.readlines()
620                f.close()
621
622                newentries = []
623                for e in entries:
624                    if e.startswith('/'):
625                        fields = e.split('/')
626                        fields[-1] = "T%s\n" % fields[2]
627                        newe = '/'.join(fields)
628                        newentries.append(newe)
629                    else:
630                        newentries.append(e)
631
632                rename(efn, efn+'.tailor-old')
633
634                f = open(efn, 'w')
635                f.writelines(newentries)
636                f.close()
637
638                remove(efn+'.tailor-old')
639
640
641    def _commit(self, date, author, patchname, changelog=None, entries=None):
642        """
643        Commit the changeset.
644        """
645
646        from shwrap import ReopenableNamedTemporaryFile
647
648        encode = self.repository.encode
649
650        logmessage = []
651        if patchname:
652            logmessage.append(patchname)
653        if changelog:
654            logmessage.append(changelog)
655        logmessage.append('')
656        logmessage.append('Original author: %s' % author)
657        logmessage.append('Date: %s' % date)
658
659        rontf = ReopenableNamedTemporaryFile('cvs', 'tailor')
660        log = open(rontf.name, "w")
661        log.write(encode('\n'.join(logmessage)))
662        log.close()
663
664        cmd = self.repository.command("-q", "ci", "-F", rontf.name)
665        if not entries:
666            entries = ['.']
667
668        c = ExternalCommand(cwd=self.basedir, command=cmd)
669        c.execute(entries)
670
671        if c.exit_status:
672            raise ChangesetApplicationFailure("%s returned status %d" %
673                                              (str(c), c.exit_status))
674
675    def _removePathnames(self, names):
676        """
677        Remove some filesystem objects.
678        """
679
680        cmd = self.repository.command("-q", "remove")
681        ExternalCommand(cwd=self.basedir, command=cmd).execute(names)
682
683    def _renamePathname(self, oldname, newname):
684        """
685        Rename a filesystem object.
686        """
687
688        self._removePathnames([oldname])
689        self._addPathnames([newname])
690
691    def _tag(self, tagname):
692        """
693        Apply a tag.
694        """
695
696        # Sanitize tagnames for CVS: start with [a-zA-z], only include letters,
697        # numbers, '-' and '_'.
698        # str.isalpha et al are locale-dependent
699        def iscvsalpha(chr):
700            return (chr >= 'a' and chr <= 'z') or (chr >= 'A' and chr <= 'Z')
701        def iscvsdigit(chr):
702            return chr >= '0' and chr <= '9'
703        def iscvschar(chr):
704            return iscvsalpha(chr) or iscvsdigit(chr) or chr == '-' or chr == '_'
705        def cvstagify(chr):
706            if iscvschar(chr):
707                return chr
708            else:
709                return '_'
710
711        tagname = ''.join([cvstagify(chr) for chr in tagname])
712        if not iscvsalpha(tagname[0]):
713            tagname = 'tag-' + tagname
714
715        cmd = self.repository.command("tag")
716        c = ExternalCommand(cwd=self.basedir, command=cmd)
717        c.execute(tagname)
718        if c.exit_status:
719            raise ChangesetApplicationFailure("%s returned status %d" %
720                                              (str(c), c.exit_status))
Note: See TracBrowser for help on using the repository browser.