source: tailor/vcpx/cvsps.py @ 650

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

Brown bag bug fixed in the CVS source backend
I knew there was a reason for going backward!

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        self.__forceTagOnEachEntry()
313
314        entries = CvsEntries(self.basedir)
315        youngest_entry = entries.getYoungestEntry()
316        if youngest_entry is None:
317            raise EmptyRepositoriesFoolsMe("The working copy '%s' of the "
318                                           "CVS repository seems empty, "
319                                           "don't know how to deal with "
320                                           "that." % self.basedir)
321
322        # loop over the changesets and find the last applied, to find
323        # out the actual cvsps revision
324
325        found = False
326        if cset is None and csets:
327            cset = csets.pop()
328        while cset is not None:
329            for m in cset.entries:
330                info = entries.getFileInfo(m.name)
331                if info:
332                    actualversion = info.cvs_version
333                    found = compare_cvs_revs(actualversion,m.new_revision) >= 0
334                    if not found:
335                        break
336
337            if found:
338                last = cset
339                break
340
341            if csets:
342                cset = csets.pop()
343            else:
344                cset = None
345
346        if not found:
347            raise TargetInitializationFailure(
348                "Something went wrong: unable to determine the exact upstream "
349                "revision of the checked out tree in '%s'" % self.basedir)
350        else:
351            self.log_info("working copy up to cvs revision %s" % last.revision)
352
353        return last
354
355    def _willApplyChangeset(self, changeset, applyable=None):
356        """
357        This gets called just before applying each changeset.
358
359        Since CVS has no "createdir" event, we have to take care
360        of new directories, creating empty-but-reasonable CVS dirs.
361        """
362
363        if UpdatableSourceWorkingDir._willApplyChangeset(self, changeset,
364                                                         applyable):
365            for m in changeset.entries:
366                if m.action_kind == m.ADDED:
367                    self.__createParentCVSDirectories(changeset, m.name)
368
369            return True
370        else:
371            return False
372
373    def __createParentCVSDirectories(self, changeset, entry):
374        """
375        Verify that the hierarchy down to the entry is under CVS.
376
377        If the directory containing the entry does not exists,
378        create it and make it appear as under CVS so that succeding
379        'cvs update' will work.
380        """
381
382        from os.path import split, join, exists
383        from os import mkdir
384
385        path = split(entry)[0]
386        if path:
387            basedir = join(self.basedir, path)
388        else:
389            basedir = self.basedir
390        cvsarea = join(basedir, 'CVS')
391
392        if path and not exists(cvsarea):
393            parentcvs = self.__createParentCVSDirectories(changeset, path)
394
395            assert exists(parentcvs), "Uhm, strange things happen"
396
397            if not exists(basedir):
398                mkdir(basedir)
399
400            # Create fake CVS area
401            mkdir(cvsarea)
402
403            # Create an empty "Entries" file
404            entries = open(join(cvsarea, 'Entries'), 'w')
405            entries.close()
406
407            reposf = open(join(parentcvs, 'Repository'))
408            rep = reposf.readline()[:-1]
409            reposf.close()
410
411            reposf = open(join(cvsarea, 'Repository'), 'w')
412            reposf.write("%s/%s\n" % (rep, split(basedir)[1]))
413            reposf.close()
414
415            rootf = open(join(parentcvs, 'Root'))
416            root = rootf.readline()
417            rootf.close()
418
419            rootf = open(join(cvsarea, 'Root'), 'w')
420            rootf.write(root)
421            rootf.close()
422
423            # Add the "new" directory to the changeset, so that the
424            # replayer get its name
425
426            entry = changeset.addEntry(path, None)
427            entry.action_kind = entry.ADDED
428
429        return cvsarea
430
431    ## SyncronizableTargetWorkingDir
432
433    def _addPathnames(self, names):
434        """
435        Add some new filesystem objects.
436        """
437
438        cmd = [self.repository.CVS_CMD, "-q", "add"]
439        ExternalCommand(cwd=self.basedir, command=cmd).execute(names)
440
441    def __forceTagOnEachEntry(self):
442        """
443        Massage each CVS/Entries file, locking (ie, tagging) each
444        entry to its current CVS version.
445
446        This is to prevent silly errors such those that could arise
447        after a manual ``cvs update`` in the working directory.
448        """
449
450        from os import walk, rename
451        from os.path import join
452
453        for dir, subdirs, files in walk(self.basedir):
454            if dir[-3:] == 'CVS':
455                efn = join(dir, 'Entries')
456                f = open(efn)
457                entries = f.readlines()
458                f.close()
459                rename(efn, efn+'.old')
460
461                newentries = []
462                for e in entries:
463                    if e.startswith('/'):
464                        fields = e.split('/')
465                        fields[-1] = "T%s\n" % fields[2]
466                        newe = '/'.join(fields)
467                        newentries.append(newe)
468                    else:
469                        newentries.append(e)
470
471                f = open(efn, 'w')
472                f.writelines(newentries)
473                f.close()
474
475    def _getCommitEntries(self, changeset):
476        """
477        Extract the names of the entries for the commit phase.  Since CVS
478        does not have a "rename" operation, this is simulated by a
479        remove+add, and both entries must be committed.
480        """
481
482        entries = SyncronizableTargetWorkingDir._getCommitEntries(self,
483                                                                  changeset)
484        entries.extend([e.old_name for e in changeset.renamedEntries()])
485
486        return entries
487
488    def _commit(self, date, author, patchname, changelog=None, entries=None):
489        """
490        Commit the changeset.
491        """
492
493        from shwrap import ReopenableNamedTemporaryFile
494        from sys import getdefaultencoding
495
496        encoding = ExternalCommand.FORCE_ENCODING or getdefaultencoding()
497
498        logmessage = []
499        if patchname:
500            logmessage.append(patchname.encode(encoding))
501        if changelog:
502            logmessage.append(changelog.replace('%', '%%').encode(encoding))
503        logmessage.append('')
504        logmessage.append('Original author: %s' % author.encode(encoding))
505        logmessage.append('Date: %s' % date)
506        logmessage.append('')
507
508        rontf = ReopenableNamedTemporaryFile('cvs', 'tailor')
509        log = open(rontf.name, "w")
510        log.write('\n'.join(log))
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.