source: tailor/vcpx/cvsps.py @ 527

Revision 527, 17.0 KB checked in by lele@…, 8 years ago (diff)

Big API change, reducing arguments in favour of instance attributes
This is a big and subtle change that brings nothing in term of
functionality but make it a lot easier maintaining and extending
tailor as a whole.

Basically, 'root' and 'subdir' arguments are gone replaced by a
self.basedir, computed from the configuration; instead of 'logger',
the code uses two new methods, log_info() and log_error() on most
objects. Other arguments are derived from the configuration objects
that hang around.

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