source: tailor/vcpx/cvsps.py @ 367

Revision 367, 18.3 KB checked in by lele@…, 8 years ago (diff)

Reimplement 'Recognize new entries not reported by CVS log' patch
The patch in question tried to fix the case where an entry was labelled
as MODIFIED where it really should be ADDED. It looped over entries looking
up each of them in the CVS/Entries file, and when not found changed the
entry's action. This was horribly wrong, since it does not make any sense
looking there when collecting the changesets; instead, the check must be
done just before changeset application.

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