source: tailor/vcpx/cvsps.py @ 905

Revision 905, 20.0 KB checked in by lele@…, 8 years ago (diff)

Don't relay on dictionary keys to call the constructor, they may be unicode

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