source: tailor/vcpx/cvsps.py @ 777

Revision 777, 18.7 KB checked in by lele@…, 8 years ago (diff)

Move the external command name detail into the Repository class

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