source: tailor/vcpx/cvsps.py @ 1178

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

Use a common ancestor to recognize tailor exceptions

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