source: tailor/vcpx/cvsps.py @ 892

Revision 892, 19.8 KB checked in by lele@…, 8 years ago (diff)

Do not specify multi level subdirs to CVS checkout -d option
This should fix ticket #9, as it is now possible to use arbitrary
subdirectory for the source working dir.

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