source: tailor/vcpx/cvsps.py @ 680

Revision 680, 18.5 KB checked in by lele@…, 8 years ago (diff)

New option 'tag-entries' on CVS/CVSPS repositories to disable the explicit 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 ala 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        pset['log'] = ''.join(msg)
76
77        assert l.startswith('Members:'), "Parse error: %s" % l
78
79        pset['entries'] = entries = []
80        l = log.readline()
81        seen = {}
82        while l.startswith('\t'):
83            if not sincerev or (sincerev<int(pset['revision'])):
84                file,revs = l[1:-1].split(':')
85                fromrev,torev = revs.strip().split('->')
86
87                # Due to the fuzzy mechanism, cvsps may group
88                # together two commits on a single entry, thus
89                # giving something like:
90                #
91                #   Normalizer.py:1.12->1.13
92                #   Registry.py:1.22->1.23
93                #   Registry.py:1.21->1.22
94                #   Stopwords.py:1.9->1.10
95                #
96                # Collapse those into a single one.
97
98                e = seen.get(file)
99                if not e:
100                    e = ChangesetEntry(file)
101                    e.old_revision = fromrev
102                    e.new_revision = torev
103                    seen[file] = e
104                    entries.append(e)
105                else:
106                    if compare_cvs_revs(e.old_revision, fromrev)>0:
107                        e.old_revision = fromrev
108
109                    if compare_cvs_revs(e.new_revision, torev)<0:
110                        e.new_revision = torev
111
112                if fromrev=='INITIAL':
113                    e.action_kind = e.ADDED
114                elif "(DEAD)" in torev:
115                    e.action_kind = e.DELETED
116                    e.new_revision = torev[:torev.index('(DEAD)')]
117                else:
118                    e.action_kind = e.UPDATED
119
120            l = log.readline()
121
122        if not sincerev or (sincerev<int(pset['revision'])):
123            cvsdate = pset['date']
124            y,m,d = map(int, cvsdate[:10].split('/'))
125            hh,mm,ss = map(int, cvsdate[11:19].split(':'))
126            timestamp = datetime(y, m, d, hh, mm, ss)
127            pset['date'] = timestamp
128
129            yield Changeset(**pset)
130
131
132class CvspsWorkingDir(UpdatableSourceWorkingDir,
133                      SyncronizableTargetWorkingDir):
134
135    """
136    An instance of this class represent a read/write CVS working directory,
137    so that it can be used both as source of patches, or as a target
138    repository.
139
140    It uses `cvsps` to do the actual fetch of the changesets metadata
141    from the server, so that we can reasonably group together related
142    changes that would otherwise be sparsed, as CVS is file-centric.
143    """
144
145    ## UpdatableSourceWorkingDir
146
147    def _getUpstreamChangesets(self, sincerev=None):
148        from os.path import join, exists
149
150        branch="HEAD"
151        fname = join(self.basedir, 'CVS', 'Tag')
152        if exists(fname):
153            tag = open(fname).read()
154            if tag.startswith('T'):
155                branch=tag[1:-1]
156
157        if sincerev:
158            sincerev = int(sincerev)
159
160        changesets = []
161        cmd = [self.repository.CVSPS_CMD, "--cvs-direct", "-u", "-b", branch,
162               "--root", self.repository.repository]
163        cvsps = ExternalCommand(command=cmd)
164        log = cvsps.execute(self.repository.module, stdout=PIPE, TZ='UTC')[0]
165
166        for cs in changesets_from_cvsps(log, sincerev):
167            changesets.append(cs)
168
169        return changesets
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_info("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_info("skipping '%s' since it's 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_info("skipping '%s' since it's 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 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.CVS_CMD, "-q", "update", "-d", "-r", e.new_revision]
226                cvsup = ExternalCommand(cwd=self.basedir, command=cmd)
227                retry = 0
228                while True:
229                    cvsup.execute(e.name)
230
231                    if cvsup.exit_status:
232                        retry += 1
233                        if retry>3:
234                            break
235                        delay = 2**retry
236                        self.log_info("%s returned status %s, "
237                                      "retrying in %d seconds..." %
238                                      (str(cvsup), cvsup.exit_status,
239                                       delay))
240                        sleep(retry)
241                    else:
242                        break
243
244                if cvsup.exit_status:
245                    raise ChangesetApplicationFailure(
246                        "%s returned status %s" % (str(cvsup),
247                                                   cvsup.exit_status))
248
249                self.log_info("%s updated to %s" % (e.name, e.new_revision))
250
251            if e.action_kind == e.DELETED:
252                self.__maybeDeleteDirectory(split(e.name)[0], changeset)
253
254    def _checkoutUpstreamRevision(self, revision):
255        """
256        Concretely do the checkout of the upstream sources. Use `revision` as
257        the name of the tag to get, or as a date if it starts with a number.
258
259        Return the last applied changeset.
260        """
261
262        from os.path import join, exists
263        from cvs import CvsEntries, compare_cvs_revs
264
265        if not self.repository.module:
266            raise InvocationError("Must specify a module name")
267
268        timestamp = None
269        if revision is not None:
270            # If the revision contains a space, assume it really
271            # specify a branch and a timestamp. If it starts with
272            # a digit, assume it's a timestamp. Otherwise, it must
273            # be a branch name
274            if revision[0] in '0123456789' or revision == 'INITIAL':
275                timestamp = revision
276                revision = None
277            elif ' ' in revision:
278                revision, timestamp = revision.split(' ', 1)
279
280        # Trasform the whole history in a list, since we need to
281        # visit it beginning from the last element
282        csets = list(self.getPendingChangesets(revision))
283        if not csets:
284            raise TargetInitializationFailure(
285                "Something went wrong: there are no changesets since "
286                "revision '%s'" % revision)
287        if timestamp == 'INITIAL':
288            cset = csets[0]
289            timestamp = cset.date.isoformat(sep=' ')
290        else:
291            cset = None
292
293        if not exists(join(self.basedir, 'CVS')):
294            cmd = [self.repository.CVS_CMD, "-q", "-d",
295                   self.repository.repository, "checkout",
296                   "-d", self.repository.subdir]
297            if revision:
298                cmd.extend(["-r", revision])
299            if timestamp:
300                cmd.extend(["-D", "%s UTC" % timestamp])
301
302            checkout = ExternalCommand(cwd=self.repository.rootdir, command=cmd)
303            checkout.execute(self.repository.module)
304
305            if checkout.exit_status:
306                raise TargetInitializationFailure(
307                    "%s returned status %s" % (str(checkout),
308                                               checkout.exit_status))
309        else:
310            self.log_info("Using existing %s" % self.basedir)
311
312        if self.repository.tag_entries:
313            self.__forceTagOnEachEntry()
314
315        entries = CvsEntries(self.basedir)
316        youngest_entry = entries.getYoungestEntry()
317        if youngest_entry is None:
318            raise EmptyRepositoriesFoolsMe("The working copy '%s' of the "
319                                           "CVS repository seems empty, "
320                                           "don't know how to deal with "
321                                           "that." % self.basedir)
322
323        # loop over the changesets and find the last applied, to find
324        # out the actual cvsps revision
325
326        found = False
327        if cset is None and csets:
328            cset = csets.pop()
329        while cset is not None:
330            for m in cset.entries:
331                info = entries.getFileInfo(m.name)
332                if info:
333                    actualversion = info.cvs_version
334                    found = compare_cvs_revs(actualversion,m.new_revision) >= 0
335                    if not found:
336                        break
337
338            if found:
339                last = cset
340                break
341
342            if csets:
343                cset = csets.pop()
344            else:
345                cset = None
346
347        if not found:
348            raise TargetInitializationFailure(
349                "Something went wrong: unable to determine the exact upstream "
350                "revision of the checked out tree in '%s'" % self.basedir)
351        else:
352            self.log_info("working copy up to cvs revision %s" % last.revision)
353
354        return last
355
356    def _willApplyChangeset(self, changeset, applyable=None):
357        """
358        This gets called just before applying each changeset.
359
360        Since CVS has no "createdir" event, we have to take care
361        of new directories, creating empty-but-reasonable CVS dirs.
362        """
363
364        if UpdatableSourceWorkingDir._willApplyChangeset(self, changeset,
365                                                         applyable):
366            for m in changeset.entries:
367                if m.action_kind == m.ADDED:
368                    self.__createParentCVSDirectories(changeset, m.name)
369
370            return True
371        else:
372            return False
373
374    def __createParentCVSDirectories(self, changeset, entry):
375        """
376        Verify that the hierarchy down to the entry is under CVS.
377
378        If the directory containing the entry does not exists,
379        create it and make it appear as under CVS so that succeding
380        'cvs update' will work.
381        """
382
383        from os.path import split, join, exists
384        from os import mkdir
385
386        path = split(entry)[0]
387        if path:
388            basedir = join(self.basedir, path)
389        else:
390            basedir = self.basedir
391        cvsarea = join(basedir, 'CVS')
392
393        if path and not exists(cvsarea):
394            parentcvs = self.__createParentCVSDirectories(changeset, path)
395
396            assert exists(parentcvs), "Uhm, strange things happen"
397
398            if not exists(basedir):
399                mkdir(basedir)
400
401            # Create fake CVS area
402            mkdir(cvsarea)
403
404            # Create an empty "Entries" file
405            entries = open(join(cvsarea, 'Entries'), 'w')
406            entries.close()
407
408            reposf = open(join(parentcvs, 'Repository'))
409            rep = reposf.readline()[:-1]
410            reposf.close()
411
412            reposf = open(join(cvsarea, 'Repository'), 'w')
413            reposf.write("%s/%s\n" % (rep, split(basedir)[1]))
414            reposf.close()
415
416            rootf = open(join(parentcvs, 'Root'))
417            root = rootf.readline()
418            rootf.close()
419
420            rootf = open(join(cvsarea, 'Root'), 'w')
421            rootf.write(root)
422            rootf.close()
423
424            # Add the "new" directory to the changeset, so that the
425            # replayer get its name
426
427            entry = changeset.addEntry(path, None)
428            entry.action_kind = entry.ADDED
429
430        return cvsarea
431
432    ## SyncronizableTargetWorkingDir
433
434    def _addPathnames(self, names):
435        """
436        Add some new filesystem objects.
437        """
438
439        cmd = [self.repository.CVS_CMD, "-q", "add"]
440        ExternalCommand(cwd=self.basedir, command=cmd).execute(names)
441
442    def __forceTagOnEachEntry(self):
443        """
444        Massage each CVS/Entries file, locking (ie, tagging) each
445        entry to its current CVS version.
446
447        This is to prevent silly errors such those that could arise
448        after a manual ``cvs update`` in the working directory.
449        """
450
451        from os import walk, rename
452        from os.path import join
453
454        for dir, subdirs, files in walk(self.basedir):
455            if dir[-3:] == 'CVS':
456                efn = join(dir, 'Entries')
457                f = open(efn)
458                entries = f.readlines()
459                f.close()
460                rename(efn, efn+'.old')
461
462                newentries = []
463                for e in entries:
464                    if e.startswith('/'):
465                        fields = e.split('/')
466                        fields[-1] = "T%s\n" % fields[2]
467                        newe = '/'.join(fields)
468                        newentries.append(newe)
469                    else:
470                        newentries.append(e)
471
472                f = open(efn, 'w')
473                f.writelines(newentries)
474                f.close()
475
476    def _getCommitEntries(self, changeset):
477        """
478        Extract the names of the entries for the commit phase.  Since CVS
479        does not have a "rename" operation, this is simulated by a
480        remove+add, and both entries must be committed.
481        """
482
483        entries = SyncronizableTargetWorkingDir._getCommitEntries(self,
484                                                                  changeset)
485        entries.extend([e.old_name for e in changeset.renamedEntries()])
486
487        return entries
488
489    def _commit(self, date, author, patchname, changelog=None, entries=None):
490        """
491        Commit the changeset.
492        """
493
494        from shwrap import ReopenableNamedTemporaryFile
495        from sys import getdefaultencoding
496
497        encoding = ExternalCommand.FORCE_ENCODING or getdefaultencoding()
498
499        logmessage = []
500        if patchname:
501            logmessage.append(patchname.encode(encoding))
502        if changelog:
503            logmessage.append(changelog.encode(encoding))
504        logmessage.append('')
505        logmessage.append('Original author: %s' % author.encode(encoding))
506        logmessage.append('Date: %s' % date)
507
508        rontf = ReopenableNamedTemporaryFile('cvs', 'tailor')
509        log = open(rontf.name, "w")
510        log.write('\n'.join(logmessage))
511        log.close()
512
513        cmd = [self.repository.CVS_CMD, "-q", "ci", "-F", rontf.name]
514        if not entries:
515            entries = ['.']
516
517        c = ExternalCommand(cwd=self.basedir, command=cmd)
518        c.execute(entries)
519
520        if c.exit_status:
521            raise ChangesetApplicationFailure("%s returned status %d" %
522                                              (str(c), c.exit_status))
523
524    def _removePathnames(self, names):
525        """
526        Remove some filesystem objects.
527        """
528
529        cmd = [self.repository.CVS_CMD, "-q", "remove"]
530        ExternalCommand(cwd=self.basedir, command=cmd).execute(names)
531
532    def _renamePathname(self, oldname, newname):
533        """
534        Rename a filesystem object.
535        """
536
537        self._removePathnames([oldname])
538        self._addPathnames([newname])
Note: See TracBrowser for help on using the repository browser.