source: tailor/vcpx/cvsps.py @ 426

Revision 426, 17.8 KB checked in by lele@…, 8 years ago (diff)

Use the last applied patch timestamp for the bootstrap date

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
21CVS_CMD = 'cvs'
22CVSPS_CMD = 'cvsps'   
23
24def changesets_from_cvsps(log, sincerev=None):
25    """
26    Parse CVSps log.
27    """
28
29    from changes import Changeset, ChangesetEntry
30    from datetime import datetime
31    from cvs import compare_cvs_revs
32   
33    # cvsps output sample:
34    ## ---------------------
35    ## PatchSet 1500
36    ## Date: 2004/05/09 17:54:22
37    ## Author: grubert
38    ## Branch: HEAD
39    ## Tag: (none)
40    ## Log:
41    ## Tell the reason for using mbox (not wrapping long lines).
42    ##
43    ## Members:
44    ##         docutils/writers/latex2e.py:1.78->1.79
45
46    l = None
47    while 1:
48        l = log.readline()
49        if l <> '---------------------\n':
50            break
51
52        l = log.readline()
53        assert l.startswith('PatchSet '), "Parse error: %s"%l
54
55        pset = {}
56        pset['revision'] = l[9:-1].strip()
57        l = log.readline()
58        while not l.startswith('Log:'):
59            field,value = l.split(':',1)
60            pset[field.lower()] = value.strip()
61            l = log.readline()
62
63        msg = []
64        l = log.readline()
65        msg.append(l)
66        l = log.readline()
67        while l <> 'Members: \n':
68            msg.append(l)
69            l = log.readline()
70
71        pset['log'] = ''.join(msg)
72
73        assert l.startswith('Members:'), "Parse error: %s" % l
74
75        pset['entries'] = entries = []
76        l = log.readline()
77        seen = {}
78        while l.startswith('\t'):
79            if not sincerev or (sincerev<int(pset['revision'])):
80                file,revs = l[1:-1].split(':')
81                fromrev,torev = revs.split('->')
82
83                # Due to the fuzzy mechanism, cvsps may group
84                # together two commits on a single entry, thus
85                # giving something like:
86                #
87                #   Normalizer.py:1.12->1.13
88                #   Registry.py:1.22->1.23
89                #   Registry.py:1.21->1.22
90                #   Stopwords.py:1.9->1.10
91                #
92                # Collapse those into a single one.
93
94                e = seen.get(file)
95                if not e:
96                    e = ChangesetEntry(file)
97                    e.old_revision = fromrev
98                    e.new_revision = torev
99                    seen[file] = e
100                    entries.append(e)
101                else:
102                    if compare_cvs_revs(e.old_revision, fromrev)>0:
103                        e.old_revision = fromrev
104
105                    if compare_cvs_revs(e.new_revision, torev)<0:
106                        e.new_revision = torev
107
108                if fromrev=='INITIAL':
109                    e.action_kind = e.ADDED
110                elif "(DEAD)" in torev:
111                    e.action_kind = e.DELETED
112                    e.new_revision = torev[:torev.index('(DEAD)')]
113                else:
114                    e.action_kind = e.UPDATED
115
116            l = log.readline()
117
118        if not sincerev or (sincerev<int(pset['revision'])):
119            cvsdate = pset['date']
120            y,m,d = map(int, cvsdate[:10].split('/'))
121            hh,mm,ss = map(int, cvsdate[11:19].split(':'))
122            timestamp = datetime(y, m, d, hh, mm, ss)
123            pset['date'] = timestamp
124
125            yield Changeset(**pset)
126
127
128class CvspsWorkingDir(UpdatableSourceWorkingDir,
129                      SyncronizableTargetWorkingDir):
130
131    """
132    An instance of this class represent a read/write CVS working directory,
133    so that it can be used both as source of patches, or as a target
134    repository.
135
136    It uses `cvsps` to do the actual fetch of the changesets metadata
137    from the server, so that we can reasonably group together related
138    changes that would otherwise be sparsed, as CVS is file-centric.
139    """
140
141    ## UpdatableSourceWorkingDir
142   
143    def getUpstreamChangesets(self, root, repository, module, sincerev=None,
144                              branch=None):
145        from os.path import join, exists
146
147        if branch is None:
148            branch="HEAD"
149            fname = join(root, 'CVS', 'Tag')
150            if exists(fname):
151                tag = open(fname).read()
152                if tag.startswith('T'):
153                    branch=tag[1:-1]
154
155        if sincerev:
156            sincerev = int(sincerev)
157           
158        changesets = []
159        cmd = [CVSPS_CMD, "-u", "-b", branch]
160        cvsps = ExternalCommand(cwd=root, command=cmd)
161        log = cvsps.execute(stdout=PIPE)
162       
163        for cs in changesets_from_cvsps(log, sincerev):
164            changesets.append(cs)
165
166        return changesets
167
168    def __maybeDeleteDirectory(self, root, entrydir, changeset):
169        from os.path import join, exists
170        from os import listdir
171       
172        if not entrydir:
173            return
174
175        absentrydir = join(root, entrydir)
176        if not exists(absentrydir) or listdir(absentrydir) == ['CVS']:
177            deldir = changeset.addEntry(entrydir, None)
178            deldir.action_kind = deldir.DELETED
179       
180    def _applyChangeset(self, root, changeset, logger=None):
181        from os.path import join, exists, dirname, split
182        from os import makedirs, listdir
183        from shutil import rmtree
184        from cvs import CvsEntries
185        from time import sleep
186       
187        entries = CvsEntries(root)
188
189        for e in changeset.entries:
190            if e.action_kind == e.UPDATED:
191                info = entries.getFileInfo(e.name)
192                if not info:
193                    if logger: logger.info("promoting '%s' to ADDED at "
194                                           "revision %s", e.name,
195                                           e.new_revision)
196                    e.action_kind = e.ADDED
197                    self.__createParentCVSDirectories(changeset, root, e.name)
198                elif info.cvs_version == e.new_revision:
199                    if logger: logger.debug("skipping '%s' since it's already "
200                                            "at revision %s", e.name,
201                                            e.new_revision)
202                    continue
203            elif e.action_kind == e.DELETED:
204                if not exists(join(root, e.name)):
205                    if logger: logger.debug("skipping '%s' since it's already "
206                                            "deleted", e.name)
207                    self.__maybeDeleteDirectory(root, split(e.name)[0],
208                                                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(root, e.name)) == ['CVS'], '%s should be empty' % e.name
223                rmtree(join(root, e.name))
224            else:
225                cmd = [CVS_CMD, "-q", "update", "-d", "-r%s" % e.new_revision]
226                cvsup = ExternalCommand(cwd=root, command=cmd)
227                retry = 0
228                while True:
229                    cvsup.execute(e.name, stdout=PIPE)
230           
231                    if cvsup.exit_status:
232                        retry += 1
233                        if retry>3:
234                            break
235                        delay = 2**retry
236                        if logger:
237                            logger.warning("%s returned status %s, "
238                                           "retrying in %d seconds..." %
239                                           (str(cvsup), cvsup.exit_status,
240                                            delay))
241                        sleep(retry)
242                    else:
243                        break
244                   
245                if cvsup.exit_status:
246                    raise ChangesetApplicationFailure(
247                        "%s returned status %s" % (str(cvsup),
248                                                   cvsup.exit_status))
249
250                if logger: logger.info("%s updated to %s" % (e.name,
251                                                             e.new_revision))
252
253            if e.action_kind == e.DELETED:
254                self.__maybeDeleteDirectory(root, split(e.name)[0], changeset)
255
256    def _checkoutUpstreamRevision(self, basedir, repository, module, revision,
257                                  subdir=None, logger=None, **kwargs):
258        """
259        Concretely do the checkout of the upstream sources. Use `revision` as
260        the name of the tag to get, or as a date if it 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 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        wdir = join(basedir, subdir)
284        csets = self.getUpstreamChangesets(wdir, repository, module,
285                                           branch=revision or 'HEAD')
286        csets.reverse()
287
288        if timestamp == 'INITIAL':
289            timestamp = csets[-1].date.isoformat(sep=' ')
290           
291        if not exists(join(wdir, 'CVS')):
292            cmd = [CVS_CMD, "-q", "-d", repository, "checkout",
293                   "-d", subdir]
294            if revision:
295                cmd.extend(["-r", revision])
296            if timestamp:
297                cmd.extend(["-D", "%s UTC" % timestamp])
298           
299            checkout = ExternalCommand(cwd=basedir, command=cmd)
300            checkout.execute(module)
301           
302            if checkout.exit_status:
303                raise TargetInitializationFailure(
304                    "%s returned status %s" % (str(checkout),
305                                               checkout.exit_status))
306        else:
307            if logger: logger.info("Using existing %s", wdir)
308           
309        self.__forceTagOnEachEntry(wdir)
310       
311        entries = CvsEntries(wdir)
312       
313        # update cvsps cache, then loop over the changesets and find the
314        # last applied, to find out the actual cvsps revision
315
316        found = False
317        for cset in csets:
318            for m in cset.entries:
319                info = entries.getFileInfo(m.name)
320                if info:
321                    actualversion = info.cvs_version
322                    found = compare_cvs_revs(actualversion,m.new_revision)>=0
323                    if not found:
324                        break
325               
326            if found:
327                last = cset
328                break
329
330        if not found:
331            raise TargetInitializationFailure(
332                "Something went wrong: unable to determine the exact upstream "
333                "revision of the checked out tree in '%s'" % wdir)
334        else:
335            if logger: logger.info("working copy up to cvsps revision %s",
336                                   last.revision)
337           
338        return last
339   
340    def _willApplyChangeset(self, root, changeset, applyable=None):
341        """
342        This gets called just before applying each changeset.
343       
344        Since CVS has no "createdir" event, we have to take care
345        of new directories, creating empty-but-reasonable CVS dirs.
346        """
347
348        if UpdatableSourceWorkingDir._willApplyChangeset(self, root, changeset,
349                                                         applyable):
350            for m in changeset.entries:
351                if m.action_kind == m.ADDED:
352                    self.__createParentCVSDirectories(changeset, root, m.name)
353           
354            return True
355        else:
356            return False
357       
358    def __createParentCVSDirectories(self, changeset, root, entry):
359        """
360        Verify that the hierarchy down to the entry is under CVS.
361
362        If the directory containing the entry does not exists,
363        create it and make it appear as under CVS so that succeding
364        'cvs update' will work.
365        """
366       
367        from os.path import split, join, exists
368        from os import mkdir
369
370        path = split(entry)[0]
371        if path:
372            basedir = join(root, path)
373        else:
374            basedir = root           
375        cvsarea = join(basedir, 'CVS')
376       
377        if path and not exists(cvsarea):
378            parentcvs = self.__createParentCVSDirectories(changeset,
379                                                          root, path)
380
381            assert exists(parentcvs), "Uhm, strange things happen"
382           
383            if not exists(basedir):
384                mkdir(basedir)
385
386            # Create fake CVS area
387            mkdir(cvsarea)
388
389            # Create an empty "Entries" file
390            entries = open(join(cvsarea, 'Entries'), 'w')
391            entries.close()
392
393            reposf = open(join(parentcvs, 'Repository'))
394            rep = reposf.readline()[:-1]
395            reposf.close()
396
397            reposf = open(join(cvsarea, 'Repository'), 'w')
398            reposf.write("%s/%s\n" % (rep, split(basedir)[1]))
399            reposf.close()
400
401            rootf = open(join(parentcvs, 'Root'))
402            root = rootf.readline()
403            rootf.close()
404
405            rootf = open(join(cvsarea, 'Root'), 'w')
406            rootf.write(root)
407            rootf.close()
408
409            # Add the "new" directory to the changeset, so that the
410            # replayer get its name
411
412            entry = changeset.addEntry(path, None)
413            entry.action_kind = entry.ADDED
414           
415        return cvsarea
416   
417    ## SyncronizableTargetWorkingDir
418
419    def _addPathnames(self, root, names):
420        """
421        Add some new filesystem objects.
422        """
423
424        cmd = [CVS_CMD, "-q", "add"]
425        ExternalCommand(cwd=root, command=cmd).execute(names)
426
427    def __forceTagOnEachEntry(self, root):
428        """
429        Massage each CVS/Entries file, locking (ie, tagging) each
430        entry to its current CVS version.
431
432        This is to prevent silly errors such those that could arise
433        after a manual ``cvs update`` in the working directory.
434        """
435       
436        from os import walk, rename
437        from os.path import join
438
439        for dir, subdirs, files in walk(root):
440            if dir[-3:] == 'CVS':
441                efn = join(dir, 'Entries')
442                f = open(efn)
443                entries = f.readlines()
444                f.close()
445                rename(efn, efn+'.old')
446               
447                newentries = []
448                for e in entries:
449                    if e.startswith('/'):
450                        fields = e.split('/')
451                        fields[-1] = "T%s\n" % fields[2]
452                        newe = '/'.join(fields)
453                        newentries.append(newe)
454                    else:
455                        newentries.append(e)
456
457                f = open(efn, 'w')
458                f.writelines(newentries)
459                f.close()
460   
461    def _getCommitEntries(self, changeset):
462        """
463        Extract the names of the entries for the commit phase.  Since CVS
464        does not have a "rename" operation, this is simulated by a
465        remove+add, and both entries must be committed.
466        """
467
468        entries = SyncronizableTargetWorkingDir._getCommitEntries(self,
469                                                                  changeset)
470        entries.extend([e.old_name for e in changeset.renamedEntries()])
471
472        return entries
473       
474    def _commit(self,root, date, author, remark, changelog=None, entries=None):
475        """
476        Commit the changeset.
477        """
478
479        from shwrap import ReopenableNamedTemporaryFile
480        from sys import getdefaultencoding
481       
482        encoding = ExternalCommand.FORCE_ENCODING or getdefaultencoding()
483       
484        rontf = ReopenableNamedTemporaryFile('cvs', 'tailor')
485        log = open(rontf.name, "w")
486        log.write(remark.encode(encoding))
487        if changelog:
488            log.write('\n')
489            log.write(changelog.encode(encoding))
490        log.write("\n\nOriginal author: %s\nDate: %s\n" % (
491            author.encode(encoding), date))
492        log.close()           
493
494        cmd = [CVS_CMD, "-q", "ci", "-F", rontf.name]
495        if not entries:
496            entries = ['.']
497         
498        ExternalCommand(cwd=root, command=cmd).execute(entries)
499       
500    def _removePathnames(self, root, names):
501        """
502        Remove some filesystem objects.
503        """
504
505        cmd = [CVS_CMD, "-q", "remove"]
506        ExternalCommand(cwd=root, command=cmd).execute(names)
507
508    def _renamePathname(self, root, oldname, newname):
509        """
510        Rename a filesystem object.
511        """
512
513        self._removePathnames(root, [oldname])
514        self._addPathnames(root, [newname])
Note: See TracBrowser for help on using the repository browser.