source: tailor/vcpx/cvsps.py @ 594

Revision 594, 18.1 KB checked in by lele@…, 8 years ago (diff)

ExternalCommand?.execute() returns now a tuple (stdout, stderr)

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