source: tailor/vcpx/cvsps.py @ 1129

Revision 1129, 23.4 KB checked in by lele@…, 7 years ago (diff)

Don't necessarily remove an empty directory
Fix CvspsWorkingDir.__maybeDeleteDirectory() so that it does
not remove the directory when the same changeset adds other entries
to it. This fixes #49.

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 SynchronizableTargetWorkingDir, 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        assert l.startswith('Members:'), "Parse error: %s" % l
76
77        entries = []
78        l = log.readline()
79        seen = {}
80        while l.startswith('\t'):
81            if not sincerev or (sincerev<int(pset['revision'])):
82                # Cannot use split here, file may contain ':'
83                cpos = l.rindex(':')
84                file = l[1:cpos]
85                revs = l[cpos+1:-1]
86                fromrev,torev = revs.strip().split('->')
87
88                # Due to the fuzzy mechanism, cvsps may group
89                # together two commits on a single entry, thus
90                # giving something like:
91                #
92                #   Normalizer.py:1.12->1.13
93                #   Registry.py:1.22->1.23
94                #   Registry.py:1.21->1.22
95                #   Stopwords.py:1.9->1.10
96                #
97                # Collapse those into a single one.
98
99                e = seen.get(file)
100                if not e:
101                    e = ChangesetEntry(file)
102                    e.old_revision = fromrev
103                    e.new_revision = torev
104                    seen[file] = e
105                    entries.append(e)
106                else:
107                    if compare_cvs_revs(e.old_revision, fromrev)>0:
108                        e.old_revision = fromrev
109
110                    if compare_cvs_revs(e.new_revision, torev)<0:
111                        e.new_revision = torev
112
113                if fromrev=='INITIAL':
114                    e.action_kind = e.ADDED
115                elif "(DEAD)" in torev:
116                    e.action_kind = e.DELETED
117                    e.new_revision = torev[:torev.index('(DEAD)')]
118                else:
119                    e.action_kind = e.UPDATED
120
121            l = log.readline()
122
123        if not sincerev or (sincerev<int(pset['revision'])):
124            cvsdate = pset['date']
125            y,m,d = map(int, cvsdate[:10].split('/'))
126            hh,mm,ss = map(int, cvsdate[11:19].split(':'))
127            timestamp = datetime(y, m, d, hh, mm, ss)
128            pset['date'] = timestamp
129
130            yield Changeset(pset['revision'], timestamp, pset['author'],
131                            ''.join(msg), entries)
132
133
134class CvspsWorkingDir(UpdatableSourceWorkingDir,
135                      SynchronizableTargetWorkingDir):
136
137    """
138    An instance of this class represents a read/write CVS working
139    directory, so that it can be used both as a source of patches and
140    as a target repository.
141
142    It uses `cvsps` to do the actual fetch of the changesets metadata
143    from the server, so that we can reasonably group together related
144    changes that would otherwise be sparsed, as CVS is file-centric.
145    """
146
147    ## UpdatableSourceWorkingDir
148
149    def _getUpstreamChangesets(self, sincerev=None):
150        from os.path import join, exists
151
152        branch="HEAD"
153        fname = join(self.basedir, 'CVS', 'Tag')
154        if exists(fname):
155            tag = open(fname).read()
156            if tag.startswith('T'):
157                branch=tag[1:-1]
158
159        if sincerev:
160            sincerev = int(sincerev)
161
162        cmd = self.repository.command("--cvs-direct", "-u", "-b", branch,
163                                      "--root", self.repository.repository,
164                                      cvsps=True)
165        cvsps = ExternalCommand(command=cmd)
166        log = cvsps.execute(self.repository.module, stdout=PIPE, TZ='UTC0')[0]
167
168        for cs in changesets_from_cvsps(log, sincerev):
169            yield cs
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            # Oh, the directory is empty: if there are no other added entries
181            # in the directory, insert a REMOVE event against it.
182            for added in changeset.addedEntries():
183                if added.name.startswith(entrydir):
184                    # entrydir got empty, but only temporarily
185                    return
186
187            deldir = changeset.addEntry(entrydir, None)
188            deldir.action_kind = deldir.DELETED
189
190    def _applyChangeset(self, changeset):
191        from os.path import join, exists, dirname, split
192        from os import listdir
193        from shutil import rmtree
194        from cvs import CvsEntries
195        from time import sleep
196
197        entries = CvsEntries(self.basedir)
198
199        for e in changeset.entries:
200            if e.action_kind == e.UPDATED:
201                info = entries.getFileInfo(e.name)
202                if not info:
203                    self.log.debug('promoting "%s" to ADDED at '
204                                   'revision %s', e.name, e.new_revision)
205                    e.action_kind = e.ADDED
206                    self.__createParentCVSDirectories(changeset, e.name)
207                elif info.cvs_version == e.new_revision:
208                    self.log.debug('skipping "%s" since it is already '
209                                   'at revision %s', e.name, e.new_revision)
210                    continue
211            elif e.action_kind == e.DELETED:
212                if not exists(join(self.basedir, e.name)):
213                    self.log.debug('skipping "%s" since it is already '
214                                   'deleted', e.name)
215                    self.__maybeDeleteDirectory(split(e.name)[0], changeset)
216                    continue
217            elif e.action_kind == e.ADDED and e.new_revision is None:
218                # This is a new directory entry, there is no need to update it
219                continue
220
221            # If this is a directory (CVS does not version directories,
222            # and thus new_revision is always None for them), and it's
223            # going to be deleted, do not execute a 'cvs update', that
224            # in some cases does not do what one would expect. Instead,
225            # remove it with everything it contains (that should be
226            # just a single "CVS" subdir, btw)
227
228            if e.action_kind == e.DELETED and e.new_revision is None:
229                assert listdir(join(self.basedir, e.name)) == ['CVS'], '%s should be empty' % e.name
230                rmtree(join(self.basedir, e.name))
231            else:
232                cmd = self.repository.command("-d", "%(repository)s",
233                                              "-q", "update", "-d",
234                                              "-r", e.new_revision)
235                cvsup = ExternalCommand(cwd=self.basedir, command=cmd)
236                retry = 0
237                while True:
238                    cvsup.execute(e.name, repository=self.repository.repository)
239
240                    if cvsup.exit_status:
241                        retry += 1
242                        if retry>3:
243                            break
244                        delay = 2**retry
245                        self.log.warning("%s returned status %s, "
246                                         "retrying in %d seconds...",
247                                         str(cvsup), cvsup.exit_status, delay)
248                        sleep(retry)
249                    else:
250                        break
251
252                if cvsup.exit_status:
253                    raise ChangesetApplicationFailure(
254                        "%s returned status %s" % (str(cvsup),
255                                                   cvsup.exit_status))
256
257                self.log.debug("%s updated to %s", e.name, e.new_revision)
258
259            if e.action_kind == e.DELETED:
260                self.__maybeDeleteDirectory(split(e.name)[0], changeset)
261
262    def _checkoutUpstreamRevision(self, revision):
263        """
264        Concretely do the checkout of the upstream sources. Use
265        `revision` as the name of the tag to get, or as a date if it
266        starts with a number.
267
268        Return the last applied changeset.
269        """
270
271        from os.path import join, exists, split
272        from cvs import CvsEntries, compare_cvs_revs
273        from time import sleep
274
275        if not self.repository.module:
276            raise InvocationError("Must specify a module name")
277
278        timestamp = None
279        if revision is not None:
280            # If the revision contains a space, assume it really
281            # specify a branch and a timestamp. If it starts with
282            # a digit, assume it's a timestamp. Otherwise, it must
283            # be a branch name
284            if revision[0] in '0123456789' or revision == 'INITIAL':
285                timestamp = revision
286                revision = None
287            elif ' ' in revision:
288                revision, timestamp = revision.split(' ', 1)
289
290        csets = self.getPendingChangesets(revision)
291        if not csets:
292            raise TargetInitializationFailure(
293                "Something went wrong: there are no changesets since "
294                "revision '%s'" % revision)
295        if timestamp == 'INITIAL':
296            initialcset = csets.next()
297            timestamp = initialcset.date.isoformat(sep=' ')
298        else:
299            initialcset = None
300
301        if not exists(join(self.basedir, 'CVS')):
302            # CVS does not handle "checkout -d multi/level/subdir", so
303            # split the basedir and use it's parentdir as cwd below.
304            parentdir, subdir = split(self.basedir)
305            cmd = self.repository.command("-q",
306                                          "-d", self.repository.repository,
307                                          "checkout",
308                                          "-d", subdir)
309            if revision:
310                cmd.extend(["-r", revision])
311            if timestamp:
312                cmd.extend(["-D", "%s UTC" % timestamp])
313
314            checkout = ExternalCommand(cwd=parentdir, command=cmd)
315            retry = 0
316            while True:
317                checkout.execute(self.repository.module)
318                if checkout.exit_status:
319                    retry += 1
320                    if retry>3:
321                        break
322                    delay = 2**retry
323                    self.log.warning("%s returned status %s, "
324                                     "retrying in %d seconds...",
325                                     str(checkout), checkout.exit_status,
326                                     delay)
327                    sleep(retry)
328                else:
329                    break
330
331            if checkout.exit_status:
332                raise TargetInitializationFailure(
333                    "%s returned status %s" % (str(checkout),
334                                               checkout.exit_status))
335        else:
336            self.log.info("Using existing %s", self.basedir)
337
338        if self.repository.tag_entries:
339            self.__forceTagOnEachEntry()
340
341        entries = CvsEntries(self.basedir)
342        youngest_entry = entries.getYoungestEntry()
343        if youngest_entry is None:
344            raise EmptyRepositoriesFoolsMe("The working copy '%s' of the "
345                                           "CVS repository seems empty, "
346                                           "don't know how to deal with "
347                                           "that." % self.basedir)
348
349        # loop over the changesets and find the last applied, to find
350        # out the actual cvsps revision
351
352        found = False
353        csets = self.state_file.reversed()
354
355        def already_applied(cs, entries=entries):
356            "Loop over changeset entries to determine if it's already applied."
357
358            applied = False
359            for m in cs.entries:
360                info = entries.getFileInfo(m.name)
361                if info:
362                    odversion = info.cvs_version
363                    applied = compare_cvs_revs(odversion, m.new_revision) >= 0
364                    if not applied:
365                        break
366            return applied
367
368        for cset in csets:
369            found = already_applied(cset)
370            if found:
371                last = cset
372                break
373
374        if not found and initialcset:
375            found = already_applied(initialcset)
376            if found:
377                last = initialcset
378
379        if not found:
380            raise TargetInitializationFailure(
381                "Something went wrong: unable to determine the exact upstream "
382                "revision of the checked out tree in '%s'" % self.basedir)
383        else:
384            self.log.info("Working copy up to revision %s", last.revision)
385
386        return last
387
388    def _willApplyChangeset(self, changeset, applyable=None):
389        """
390        This gets called just before applying each changeset.
391
392        Since CVS has no "createdir" event, we have to take care
393        of new directories, creating empty-but-reasonable CVS dirs.
394        """
395
396        if UpdatableSourceWorkingDir._willApplyChangeset(self, changeset,
397                                                         applyable):
398            for m in changeset.entries:
399                if m.action_kind == m.ADDED:
400                    self.__createParentCVSDirectories(changeset, m.name)
401
402            return True
403        else:
404            return False
405
406    def __createParentCVSDirectories(self, changeset, entry):
407        """
408        Verify that the hierarchy down to the entry is under CVS.
409
410        If the directory containing the entry does not exist,
411        create it and make it appear as under CVS so that a subsequent
412        'cvs update' will work.
413        """
414
415        from os.path import split, join, exists
416        from os import mkdir
417
418        path = split(entry)[0]
419        if path:
420            basedir = join(self.basedir, path)
421        else:
422            basedir = self.basedir
423        cvsarea = join(basedir, 'CVS')
424
425        if path and not exists(cvsarea):
426            parentcvs = self.__createParentCVSDirectories(changeset, path)
427
428            assert exists(parentcvs), "Uhm, strange things happen: " \
429                "unable to find or create parent CVS area for %r" % path
430
431            if not exists(basedir):
432                mkdir(basedir)
433
434            # Create fake CVS area
435            mkdir(cvsarea)
436
437            # Create an empty "Entries" file
438            entries = open(join(cvsarea, 'Entries'), 'w')
439            entries.close()
440
441            reposf = open(join(parentcvs, 'Repository'))
442            rep = reposf.readline()[:-1]
443            reposf.close()
444
445            reposf = open(join(cvsarea, 'Repository'), 'w')
446            reposf.write("%s/%s\n" % (rep, split(basedir)[1]))
447            reposf.close()
448
449            rootf = open(join(parentcvs, 'Root'))
450            root = rootf.readline()
451            rootf.close()
452
453            rootf = open(join(cvsarea, 'Root'), 'w')
454            rootf.write(root)
455            rootf.close()
456
457            # Add the "new" directory to the changeset, so that the
458            # replayer gets its name
459
460            entry = changeset.addEntry(path, None)
461            entry.action_kind = entry.ADDED
462
463        return cvsarea
464
465    ## SynchronizableTargetWorkingDir
466
467    def __createRepository(self, path, target_module):
468        """
469        Create a local CVS repository.
470        """
471
472        from os import rmdir, makedirs
473        from tempfile import mkdtemp
474
475        makedirs(path)
476        cmd = self.repository.command("-d", path, "init")
477        c = ExternalCommand(command=cmd)
478        c.execute()
479        if c.exit_status:
480            raise TargetInitializationFailure("Could not create CVS repository")
481
482        tempwc = mkdtemp('cvs', 'tailor')
483        cmd = self.repository.command("-d", path, "import",
484                                      "-m", "This directory will host the "
485                                      "upstream sources",
486                                      target_module, "tailor", "start")
487        c = ExternalCommand(cwd=tempwc, command=cmd)
488        c.execute()
489        rmdir(tempwc)
490        if c.exit_status:
491            raise TargetInitializationFailure("Could not create initial module")
492
493    def _prepareTargetRepository(self):
494        """
495        Create the CVS repository if it's local and does not exist.
496        """
497
498        from os.path import exists
499
500        if not self.repository.repository:
501            return
502
503        if self.repository.repository.startswith(':local:'):
504            rpath = self.repository.repository[7:]
505        elif self.repository.repository.startswith('/'):
506            rpath = self.repository.repository
507        else:
508            # Remote repository
509            return
510
511        if not exists(rpath):
512            self.__createRepository(rpath, self.repository.module)
513
514    def _prepareWorkingDirectory(self, source_repo):
515        """
516        Checkout a working copy of the target CVS.
517        """
518
519        from os.path import join, exists
520
521        if not self.repository.repository or exists(join(self.basedir, 'CVS')):
522            return
523
524        cmd = self.repository.command("-d", self.repository.repository, "co",
525                                      "-d", self.basedir)
526        cvsco = ExternalCommand(command=cmd)
527        cvsco.execute(self.repository.module)
528
529    def _addPathnames(self, names):
530        """
531        Add some new filesystem objects.
532        """
533
534        cmd = self.repository.command("-q", "add")
535        ExternalCommand(cwd=self.basedir, command=cmd).execute(names)
536
537    def __forceTagOnEachEntry(self):
538        """
539        Massage each CVS/Entries file, locking (ie, tagging) each
540        entry to its current CVS version.
541
542        This is to prevent silly errors such those that could arise
543        after a manual ``cvs update`` in the working directory.
544        """
545
546        from os import walk, rename, remove
547        from os.path import join, exists
548
549        self.log.info("Forcing CVS sticky tag in %s", self.basedir)
550
551        for dir, subdirs, files in walk(self.basedir):
552            if dir[-3:] == 'CVS':
553                efn = join(dir, 'Entries')
554
555                # Strangeness is a foreign word in CVS: sometime
556                # the Entries isn't there...
557                if not exists(efn):
558                    continue
559
560                f = open(efn)
561                entries = f.readlines()
562                f.close()
563
564                newentries = []
565                for e in entries:
566                    if e.startswith('/'):
567                        fields = e.split('/')
568                        fields[-1] = "T%s\n" % fields[2]
569                        newe = '/'.join(fields)
570                        newentries.append(newe)
571                    else:
572                        newentries.append(e)
573
574                rename(efn, efn+'.tailor-old')
575
576                f = open(efn, 'w')
577                f.writelines(newentries)
578                f.close()
579
580                remove(efn+'.tailor-old')
581
582
583    def _getCommitEntries(self, changeset):
584        """
585        Extract the names of the entries for the commit phase.  Since CVS
586        does not have a "rename" operation, this is simulated by a
587        remove+add, and both entries must be committed.
588        """
589
590        entries = SynchronizableTargetWorkingDir._getCommitEntries(self,
591                                                                  changeset)
592        entries.extend([e.old_name for e in changeset.renamedEntries()])
593
594        return entries
595
596    def _commit(self, date, author, patchname, changelog=None, entries=None):
597        """
598        Commit the changeset.
599        """
600
601        from shwrap import ReopenableNamedTemporaryFile
602
603        encode = self.repository.encode
604
605        logmessage = []
606        if patchname:
607            logmessage.append(patchname)
608        if changelog:
609            logmessage.append(changelog)
610        logmessage.append('')
611        logmessage.append('Original author: %s' % author)
612        logmessage.append('Date: %s' % date)
613
614        rontf = ReopenableNamedTemporaryFile('cvs', 'tailor')
615        log = open(rontf.name, "w")
616        log.write(encode('\n'.join(logmessage)))
617        log.close()
618
619        cmd = self.repository.command("-q", "ci", "-F", rontf.name)
620        if not entries:
621            entries = ['.']
622
623        c = ExternalCommand(cwd=self.basedir, command=cmd)
624        c.execute(entries)
625
626        if c.exit_status:
627            raise ChangesetApplicationFailure("%s returned status %d" %
628                                              (str(c), c.exit_status))
629
630    def _removePathnames(self, names):
631        """
632        Remove some filesystem objects.
633        """
634
635        cmd = self.repository.command("-q", "remove")
636        ExternalCommand(cwd=self.basedir, command=cmd).execute(names)
637
638    def _renamePathname(self, oldname, newname):
639        """
640        Rename a filesystem object.
641        """
642
643        self._removePathnames([oldname])
644        self._addPathnames([newname])
645
646    def _tag(self, tagname):
647        """
648        Apply a tag.
649        """
650
651        # Sanitize tagnames for CVS: start with [a-zA-z], only include letters,
652        # numbers, '-' and '_'.
653        # str.isalpha et al are locale-dependent
654        def iscvsalpha(chr):
655            return (chr >= 'a' and chr <= 'z') or (chr >= 'A' and chr <= 'Z')
656        def iscvsdigit(chr):
657            return chr >= '0' and chr <= '9'
658        def iscvschar(chr):
659            return iscvsalpha(chr) or iscvsdigit(chr) or chr == '-' or chr == '_'
660        def cvstagify(chr):
661            if iscvschar(chr):
662                return chr
663            else:
664                return '_'
665
666        tagname = ''.join([cvstagify(chr) for chr in tagname])
667        if not iscvsalpha(tagname[0]):
668            tagname = 'tag-' + tagname
669
670        cmd = self.repository.command("tag")
671        c = ExternalCommand(cwd=self.basedir, command=cmd)
672        c.execute(tagname)
673        if c.exit_status:
674            raise ChangesetApplicationFailure("%s returned status %d" %
675                                              (str(c), c.exit_status))
Note: See TracBrowser for help on using the repository browser.