source: tailor/vcpx/cvsps.py @ 1116

Revision 1116, 23.1 KB checked in by lele@…, 7 years ago (diff)

Emit some noise when forcing CVS sticky tag

Line 
1# -*- mode: python; coding: utf-8 -*-
2# :Progetto: vcpx -- CVS 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, STDOUT
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
159        if sincerev:
160            sincerev = int(sincerev)
161
162        cmd = self.repository.command("--cvs-direct", "-u", "-b", branch,
163                                      "--root", self.repository.repository,
164                                      cvsps=True)
165        cvsps = ExternalCommand(command=cmd)
166        log = cvsps.execute(self.repository.module, stdout=PIPE, TZ='UTC0')[0]
167
168        for cs in changesets_from_cvsps(log, sincerev):
169            yield cs
170
171    def __maybeDeleteDirectory(self, entrydir, changeset):
172        from os.path import join, exists
173        from os import listdir
174
175        if not entrydir:
176            return
177
178        absentrydir = join(self.basedir, entrydir)
179        if not exists(absentrydir) or listdir(absentrydir) == ['CVS']:
180            deldir = changeset.addEntry(entrydir, None)
181            deldir.action_kind = deldir.DELETED
182
183    def _applyChangeset(self, changeset):
184        from os.path import join, exists, dirname, split
185        from os import listdir
186        from shutil import rmtree
187        from cvs import CvsEntries
188        from time import sleep
189
190        entries = CvsEntries(self.basedir)
191
192        for e in changeset.entries:
193            if e.action_kind == e.UPDATED:
194                info = entries.getFileInfo(e.name)
195                if not info:
196                    self.log.debug('promoting "%s" to ADDED at '
197                                   'revision %s', e.name, e.new_revision)
198                    e.action_kind = e.ADDED
199                    self.__createParentCVSDirectories(changeset, e.name)
200                elif info.cvs_version == e.new_revision:
201                    self.log.debug('skipping "%s" since it is already '
202                                   'at revision %s', e.name, e.new_revision)
203                    continue
204            elif e.action_kind == e.DELETED:
205                if not exists(join(self.basedir, e.name)):
206                    self.log.debug('skipping "%s" since it is already '
207                                   'deleted', e.name)
208                    self.__maybeDeleteDirectory(split(e.name)[0], changeset)
209                    continue
210            elif e.action_kind == e.ADDED and e.new_revision is None:
211                # This is a new directory entry, there is no need to update it
212                continue
213
214            # If this is a directory (CVS does not version directories,
215            # and thus new_revision is always None for them), and it's
216            # going to be deleted, do not execute a 'cvs update', that
217            # in some cases does not do what one would expect. Instead,
218            # remove it with everything it contains (that should be
219            # just a single "CVS" subdir, btw)
220
221            if e.action_kind == e.DELETED and e.new_revision is None:
222                assert listdir(join(self.basedir, e.name)) == ['CVS'], '%s should be empty' % e.name
223                rmtree(join(self.basedir, e.name))
224            else:
225                cmd = self.repository.command("-d", "%(repository)s",
226                                              "-q", "update", "-d",
227                                              "-r", e.new_revision)
228                cvsup = ExternalCommand(cwd=self.basedir, command=cmd)
229                retry = 0
230                while True:
231                    cvsup.execute(e.name, repository=self.repository.repository)
232
233                    if cvsup.exit_status:
234                        retry += 1
235                        if retry>3:
236                            break
237                        delay = 2**retry
238                        self.log.warning("%s returned status %s, "
239                                         "retrying in %d seconds...",
240                                         str(cvsup), cvsup.exit_status, delay)
241                        sleep(retry)
242                    else:
243                        break
244
245                if cvsup.exit_status:
246                    raise ChangesetApplicationFailure(
247                        "%s returned status %s" % (str(cvsup),
248                                                   cvsup.exit_status))
249
250                self.log.debug("%s updated to %s", e.name, e.new_revision)
251
252            if e.action_kind == e.DELETED:
253                self.__maybeDeleteDirectory(split(e.name)[0], changeset)
254
255    def _checkoutUpstreamRevision(self, revision):
256        """
257        Concretely do the checkout of the upstream sources. Use
258        `revision` as the name of the tag to get, or as a date if it
259        starts with a number.
260
261        Return the last applied changeset.
262        """
263
264        from os.path import join, exists, split
265        from cvs import CvsEntries, compare_cvs_revs
266        from time import sleep
267
268        if not self.repository.module:
269            raise InvocationError("Must specify a module name")
270
271        timestamp = None
272        if revision is not None:
273            # If the revision contains a space, assume it really
274            # specify a branch and a timestamp. If it starts with
275            # a digit, assume it's a timestamp. Otherwise, it must
276            # be a branch name
277            if revision[0] in '0123456789' or revision == 'INITIAL':
278                timestamp = revision
279                revision = None
280            elif ' ' in revision:
281                revision, timestamp = revision.split(' ', 1)
282
283        csets = self.getPendingChangesets(revision)
284        if not csets:
285            raise TargetInitializationFailure(
286                "Something went wrong: there are no changesets since "
287                "revision '%s'" % revision)
288        if timestamp == 'INITIAL':
289            initialcset = csets.next()
290            timestamp = initialcset.date.isoformat(sep=' ')
291        else:
292            initialcset = None
293
294        if not exists(join(self.basedir, 'CVS')):
295            # CVS does not handle "checkout -d multi/level/subdir", so
296            # split the basedir and use it's parentdir as cwd below.
297            parentdir, subdir = split(self.basedir)
298            cmd = self.repository.command("-q",
299                                          "-d", self.repository.repository,
300                                          "checkout",
301                                          "-d", subdir)
302            if revision:
303                cmd.extend(["-r", revision])
304            if timestamp:
305                cmd.extend(["-D", "%s UTC" % timestamp])
306
307            checkout = ExternalCommand(cwd=parentdir, command=cmd)
308            retry = 0
309            while True:
310                checkout.execute(self.repository.module)
311                if checkout.exit_status:
312                    retry += 1
313                    if retry>3:
314                        break
315                    delay = 2**retry
316                    self.log.warning("%s returned status %s, "
317                                     "retrying in %d seconds...",
318                                     str(checkout), checkout.exit_status,
319                                     delay)
320                    sleep(retry)
321                else:
322                    break
323
324            if checkout.exit_status:
325                raise TargetInitializationFailure(
326                    "%s returned status %s" % (str(checkout),
327                                               checkout.exit_status))
328        else:
329            self.log.info("Using existing %s", self.basedir)
330
331        if self.repository.tag_entries:
332            self.__forceTagOnEachEntry()
333
334        entries = CvsEntries(self.basedir)
335        youngest_entry = entries.getYoungestEntry()
336        if youngest_entry is None:
337            raise EmptyRepositoriesFoolsMe("The working copy '%s' of the "
338                                           "CVS repository seems empty, "
339                                           "don't know how to deal with "
340                                           "that." % self.basedir)
341
342        # loop over the changesets and find the last applied, to find
343        # out the actual cvsps revision
344
345        found = False
346        csets = self.state_file.reversed()
347
348        def already_applied(cs, entries=entries):
349            "Loop over changeset entries to determine if it's already applied."
350
351            applied = False
352            for m in cs.entries:
353                info = entries.getFileInfo(m.name)
354                if info:
355                    odversion = info.cvs_version
356                    applied = compare_cvs_revs(odversion, m.new_revision) >= 0
357                    if not applied:
358                        break
359            return applied
360
361        for cset in csets:
362            found = already_applied(cset)
363            if found:
364                last = cset
365                break
366
367        if not found and initialcset:
368            found = already_applied(initialcset)
369            if found:
370                last = initialcset
371
372        if not found:
373            raise TargetInitializationFailure(
374                "Something went wrong: unable to determine the exact upstream "
375                "revision of the checked out tree in '%s'" % self.basedir)
376        else:
377            self.log.info("Working copy up to revision %s", last.revision)
378
379        return last
380
381    def _willApplyChangeset(self, changeset, applyable=None):
382        """
383        This gets called just before applying each changeset.
384
385        Since CVS has no "createdir" event, we have to take care
386        of new directories, creating empty-but-reasonable CVS dirs.
387        """
388
389        if UpdatableSourceWorkingDir._willApplyChangeset(self, changeset,
390                                                         applyable):
391            for m in changeset.entries:
392                if m.action_kind == m.ADDED:
393                    self.__createParentCVSDirectories(changeset, m.name)
394
395            return True
396        else:
397            return False
398
399    def __createParentCVSDirectories(self, changeset, entry):
400        """
401        Verify that the hierarchy down to the entry is under CVS.
402
403        If the directory containing the entry does not exist,
404        create it and make it appear as under CVS so that a subsequent
405        'cvs update' will work.
406        """
407
408        from os.path import split, join, exists
409        from os import mkdir
410
411        path = split(entry)[0]
412        if path:
413            basedir = join(self.basedir, path)
414        else:
415            basedir = self.basedir
416        cvsarea = join(basedir, 'CVS')
417
418        if path and not exists(cvsarea):
419            parentcvs = self.__createParentCVSDirectories(changeset, path)
420
421            assert exists(parentcvs), "Uhm, strange things happen: " \
422                "unable to find or create parent CVS area for %r" % path
423
424            if not exists(basedir):
425                mkdir(basedir)
426
427            # Create fake CVS area
428            mkdir(cvsarea)
429
430            # Create an empty "Entries" file
431            entries = open(join(cvsarea, 'Entries'), 'w')
432            entries.close()
433
434            reposf = open(join(parentcvs, 'Repository'))
435            rep = reposf.readline()[:-1]
436            reposf.close()
437
438            reposf = open(join(cvsarea, 'Repository'), 'w')
439            reposf.write("%s/%s\n" % (rep, split(basedir)[1]))
440            reposf.close()
441
442            rootf = open(join(parentcvs, 'Root'))
443            root = rootf.readline()
444            rootf.close()
445
446            rootf = open(join(cvsarea, 'Root'), 'w')
447            rootf.write(root)
448            rootf.close()
449
450            # Add the "new" directory to the changeset, so that the
451            # replayer gets its name
452
453            entry = changeset.addEntry(path, None)
454            entry.action_kind = entry.ADDED
455
456        return cvsarea
457
458    ## SynchronizableTargetWorkingDir
459
460    def __createRepository(self, path, target_module):
461        """
462        Create a local CVS repository.
463        """
464
465        from os import rmdir, makedirs
466        from tempfile import mkdtemp
467
468        makedirs(path)
469        cmd = self.repository.command("-d", path, "init")
470        c = ExternalCommand(command=cmd)
471        c.execute()
472        if c.exit_status:
473            raise TargetInitializationFailure("Could not create CVS repository")
474
475        tempwc = mkdtemp('cvs', 'tailor')
476        cmd = self.repository.command("-d", path, "import",
477                                      "-m", "This directory will host the "
478                                      "upstream sources",
479                                      target_module, "tailor", "start")
480        c = ExternalCommand(cwd=tempwc, command=cmd)
481        c.execute()
482        rmdir(tempwc)
483        if c.exit_status:
484            raise TargetInitializationFailure("Could not create initial module")
485
486    def _prepareTargetRepository(self):
487        """
488        Create the CVS repository if it's local and does not exist.
489        """
490
491        from os.path import exists
492
493        if not self.repository.repository:
494            return
495
496        if self.repository.repository.startswith(':local:'):
497            rpath = self.repository.repository[7:]
498        elif self.repository.repository.startswith('/'):
499            rpath = self.repository.repository
500        else:
501            # Remote repository
502            return
503
504        if not exists(rpath):
505            self.__createRepository(rpath, self.repository.module)
506
507    def _prepareWorkingDirectory(self, source_repo):
508        """
509        Checkout a working copy of the target CVS.
510        """
511
512        from os.path import join, exists
513
514        if not self.repository.repository or exists(join(self.basedir, 'CVS')):
515            return
516
517        cmd = self.repository.command("-d", self.repository.repository, "co",
518                                      "-d", self.basedir)
519        cvsco = ExternalCommand(command=cmd)
520        cvsco.execute(self.repository.module)
521
522    def _addPathnames(self, names):
523        """
524        Add some new filesystem objects.
525        """
526
527        cmd = self.repository.command("-q", "add")
528        ExternalCommand(cwd=self.basedir, command=cmd).execute(names)
529
530    def __forceTagOnEachEntry(self):
531        """
532        Massage each CVS/Entries file, locking (ie, tagging) each
533        entry to its current CVS version.
534
535        This is to prevent silly errors such those that could arise
536        after a manual ``cvs update`` in the working directory.
537        """
538
539        from os import walk, rename, remove
540        from os.path import join, exists
541
542        self.log.info("Forcing CVS sticky tag in %s", self.basedir)
543
544        for dir, subdirs, files in walk(self.basedir):
545            if dir[-3:] == 'CVS':
546                efn = join(dir, 'Entries')
547
548                # Strangeness is a foreign word in CVS: sometime
549                # the Entries isn't there...
550                if not exists(efn):
551                    continue
552
553                f = open(efn)
554                entries = f.readlines()
555                f.close()
556
557                newentries = []
558                for e in entries:
559                    if e.startswith('/'):
560                        fields = e.split('/')
561                        fields[-1] = "T%s\n" % fields[2]
562                        newe = '/'.join(fields)
563                        newentries.append(newe)
564                    else:
565                        newentries.append(e)
566
567                rename(efn, efn+'.tailor-old')
568
569                f = open(efn, 'w')
570                f.writelines(newentries)
571                f.close()
572
573                remove(efn+'.tailor-old')
574
575
576    def _getCommitEntries(self, changeset):
577        """
578        Extract the names of the entries for the commit phase.  Since CVS
579        does not have a "rename" operation, this is simulated by a
580        remove+add, and both entries must be committed.
581        """
582
583        entries = SynchronizableTargetWorkingDir._getCommitEntries(self,
584                                                                  changeset)
585        entries.extend([e.old_name for e in changeset.renamedEntries()])
586
587        return entries
588
589    def _commit(self, date, author, patchname, changelog=None, entries=None):
590        """
591        Commit the changeset.
592        """
593
594        from shwrap import ReopenableNamedTemporaryFile
595
596        encode = self.repository.encode
597
598        logmessage = []
599        if patchname:
600            logmessage.append(patchname)
601        if changelog:
602            logmessage.append(changelog)
603        logmessage.append('')
604        logmessage.append('Original author: %s' % author)
605        logmessage.append('Date: %s' % date)
606
607        rontf = ReopenableNamedTemporaryFile('cvs', 'tailor')
608        log = open(rontf.name, "w")
609        log.write(encode('\n'.join(logmessage)))
610        log.close()
611
612        cmd = self.repository.command("-q", "ci", "-F", rontf.name)
613        if not entries:
614            entries = ['.']
615
616        c = ExternalCommand(cwd=self.basedir, command=cmd)
617        c.execute(entries)
618
619        if c.exit_status:
620            raise ChangesetApplicationFailure("%s returned status %d" %
621                                              (str(c), c.exit_status))
622
623    def _removePathnames(self, names):
624        """
625        Remove some filesystem objects.
626        """
627
628        cmd = self.repository.command("-q", "remove")
629        ExternalCommand(cwd=self.basedir, command=cmd).execute(names)
630
631    def _renamePathname(self, oldname, newname):
632        """
633        Rename a filesystem object.
634        """
635
636        self._removePathnames([oldname])
637        self._addPathnames([newname])
638
639    def _tag(self, tagname):
640        """
641        Apply a tag.
642        """
643
644        # Sanitize tagnames for CVS: start with [a-zA-z], only include letters,
645        # numbers, '-' and '_'.
646        # str.isalpha et al are locale-dependent
647        def iscvsalpha(chr):
648            return (chr >= 'a' and chr <= 'z') or (chr >= 'A' and chr <= 'Z')
649        def iscvsdigit(chr):
650            return chr >= '0' and chr <= '9'
651        def iscvschar(chr):
652            return iscvsalpha(chr) or iscvsdigit(chr) or chr == '-' or chr == '_'
653        def cvstagify(chr):
654            if iscvschar(chr):
655                return chr
656            else:
657                return '_'
658
659        tagname = ''.join([cvstagify(chr) for chr in tagname])
660        if not iscvsalpha(tagname[0]):
661            tagname = 'tag-' + tagname
662
663        cmd = self.repository.command("tag")
664        c = ExternalCommand(cwd=self.basedir, command=cmd)
665        c.execute(tagname)
666        if c.exit_status:
667            raise ChangesetApplicationFailure("%s returned status %d" %
668                                              (str(c), c.exit_status))
Note: See TracBrowser for help on using the repository browser.