source: tailor/vcpx/repository/cvsps.py @ 1301

Revision 1301, 27.5 KB checked in by lele@…, 7 years ago (diff)

Drop 'cmp' keyword to [].sort() method for compatibility with Python 2.3.x

Line 
1# -*- mode: python; coding: utf-8 -*-
2# :Progetto: vcpx -- CVSPS 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 vcpx import TailorException
17from vcpx.repository import Repository
18from vcpx.shwrap import ExternalCommand, PIPE
19from vcpx.source import UpdatableSourceWorkingDir, ChangesetApplicationFailure, \
20                        InvocationError
21from vcpx.target import SynchronizableTargetWorkingDir, TargetInitializationFailure
22from vcpx.tzinfo import UTC
23
24
25class EmptyRepositoriesFoolsMe(TailorException):
26    "Cannot handle empty repositories. Maybe wrong module/repository?"
27
28    # This is the exception raised when we try to tailor an empty CVS
29    # repository. This is more a shortcoming of tailor, rather than a
30    # real problem with those repositories.
31
32
33class CvspsRepository(Repository):
34    METADIR = 'CVS'
35
36    def _load(self, project):
37        from datetime import timedelta
38
39        Repository._load(self, project)
40        self.EXECUTABLE = project.config.get(self.name, 'cvs-command', 'cvs')
41        self.__cvsps = project.config.get(self.name, 'cvsps-command', 'cvsps')
42        self.tag_entries = project.config.get(self.name, 'tag-entries', 'True')
43        self.freeze_keywords = project.config.get(self.name, 'freeze-keywords', 'False')
44        threshold = project.config.get(self.name, 'changeset-threshold', '180')
45        self.changeset_threshold = timedelta(seconds=float(threshold))
46
47    def _validateConfiguration(self):
48        from os.path import split
49        from vcpx.config import ConfigurationError
50
51        Repository._validateConfiguration(self)
52
53        if not self.module and self.repository:
54            self.module = split(self.repository)[1]
55
56        if not self.module:
57            self.log.critical('Missing module information in %r', self.name)
58            raise ConfigurationError("Must specify a repository and maybe "
59                                     "a module also")
60
61        if self.module.endswith('/'):
62            self.log.debug("Removing final slash from %r in %r",
63                           self.module, self.name)
64            self.module = self.module.rstrip('/')
65
66    def command(self, *args, **kwargs):
67        if kwargs.get('cvsps', False):
68            kwargs['executable'] = self.__cvsps
69        return Repository.command(self, *args, **kwargs)
70
71    def create(self):
72        """
73        Create a local CVS repository.
74        """
75
76        from os import rmdir, makedirs
77        from tempfile import mkdtemp
78        from os.path import join, exists
79
80        if self.repository.startswith(':local:'):
81            path = self.repository[7:]
82        elif self.repository.startswith('/'):
83            path = self.repository
84        else:
85            # Remote repository
86            return
87
88        if exists(join(path, 'CVSROOT')):
89            return
90
91        makedirs(path)
92        cmd = self.command("-f", "-d", path, "init")
93        c = ExternalCommand(command=cmd)
94        c.execute()
95        if c.exit_status:
96            raise TargetInitializationFailure("Could not create CVS repository at %r",
97                                              path)
98
99        if self.module:
100            tempwc = mkdtemp('cvs', 'tailor')
101            cmd = self.command("-f", "-d", path, "import",
102                               "-m", "This directory will host the "
103                               "upstream sources",
104                               self.module, "tailor", "start")
105            c = ExternalCommand(cwd=tempwc, command=cmd)
106            c.execute()
107            rmdir(tempwc)
108            if c.exit_status:
109                raise TargetInitializationFailure("Could not create initial module")
110
111
112def changesets_from_cvsps(log, sincerev=None):
113    """
114    Parse CVSps log.
115    """
116
117    from datetime import datetime
118    from vcpx.changes import Changeset, ChangesetEntry
119    from vcpx.repository.cvs import compare_cvs_revs
120
121    # cvsps output sample:
122    ## ---------------------
123    ## PatchSet 1500
124    ## Date: 2004/05/09 17:54:22
125    ## Author: grubert
126    ## Branch: HEAD
127    ## Tag: (none)
128    ## Log:
129    ## Tell the reason for using mbox (not wrapping long lines).
130    ##
131    ## Members:
132    ##         docutils/writers/latex2e.py:1.78->1.79
133
134    l = None
135    while 1:
136        l = log.readline()
137        if l <> '---------------------\n':
138            break
139
140        l = log.readline()
141        assert l.startswith('PatchSet '), "Parse error: %s"%l
142
143        pset = {}
144        pset['revision'] = l[9:-1].strip()
145        l = log.readline()
146        while not l.startswith('Log:'):
147            field,value = l.split(':',1)
148            pset[field.lower()] = value.strip()
149            l = log.readline()
150
151        msg = []
152        l = log.readline()
153        msg.append(l)
154        l = log.readline()
155        while l <> 'Members: \n':
156            msg.append(l)
157            l = log.readline()
158
159        assert l.startswith('Members:'), "Parse error: %s" % l
160
161        entries = []
162        l = log.readline()
163        seen = {}
164        while l.startswith('\t'):
165            if not sincerev or (sincerev<int(pset['revision'])):
166                # Cannot use split here, file may contain ':'
167                cpos = l.rindex(':')
168                file = l[1:cpos]
169                revs = l[cpos+1:-1]
170                fromrev,torev = revs.strip().split('->')
171
172                # Due to the fuzzy mechanism, cvsps may group
173                # together two commits on a single entry, thus
174                # giving something like:
175                #
176                #   Normalizer.py:1.12->1.13
177                #   Registry.py:1.22->1.23
178                #   Registry.py:1.21->1.22
179                #   Stopwords.py:1.9->1.10
180                #
181                # Collapse those into a single one.
182
183                e = seen.get(file)
184                if not e:
185                    e = ChangesetEntry(file)
186                    e.old_revision = fromrev
187                    e.new_revision = torev
188                    seen[file] = e
189                    entries.append(e)
190                else:
191                    if compare_cvs_revs(e.old_revision, fromrev)>0:
192                        e.old_revision = fromrev
193
194                    if compare_cvs_revs(e.new_revision, torev)<0:
195                        e.new_revision = torev
196
197                if fromrev=='INITIAL':
198                    e.action_kind = e.ADDED
199                elif "(DEAD)" in torev:
200                    e.action_kind = e.DELETED
201                    e.new_revision = torev[:torev.index('(DEAD)')]
202                else:
203                    e.action_kind = e.UPDATED
204
205            l = log.readline()
206
207        if not sincerev or (sincerev<int(pset['revision'])):
208            cvsdate = pset['date']
209            y,m,d = map(int, cvsdate[:10].split('/'))
210            hh,mm,ss = map(int, cvsdate[11:19].split(':'))
211            timestamp = datetime(y, m, d, hh, mm, ss, 0, UTC)
212            pset['date'] = timestamp
213
214            yield Changeset(pset['revision'], timestamp, pset['author'],
215                            ''.join(msg), entries)
216
217
218class CvspsWorkingDir(UpdatableSourceWorkingDir,
219                      SynchronizableTargetWorkingDir):
220
221    """
222    An instance of this class represents a read/write CVS working
223    directory, so that it can be used both as a source of patches and
224    as a target repository.
225
226    It uses `cvsps` to do the actual fetch of the changesets metadata
227    from the server, so that we can reasonably group together related
228    changes that would otherwise be sparsed, as CVS is file-centric.
229    """
230
231    ## UpdatableSourceWorkingDir
232
233    def _getUpstreamChangesets(self, sincerev=None):
234        from os.path import join, exists
235
236        branch="HEAD"
237        fname = join(self.repository.basedir, 'CVS', 'Tag')
238        if exists(fname):
239            tag = open(fname).read()
240            if tag.startswith('T'):
241                branch=tag[1:-1]
242        else:
243            if sincerev is not None and isinstance(sincerev, basestring) \
244                   and not sincerev[0] in '0123456789':
245                branch = sincerev
246                sincerev = None
247
248        if sincerev:
249            sincerev = int(sincerev)
250
251        cmd = self.repository.command("--norc", "--cvs-direct", "-u", "-b", branch,
252                                      "--root", self.repository.repository,
253                                      cvsps=True)
254        cvsps = ExternalCommand(command=cmd)
255        log = cvsps.execute(self.repository.module, stdout=PIPE, TZ='UTC0')[0]
256
257        for cs in changesets_from_cvsps(log, sincerev):
258            yield cs
259
260    def __maybeDeleteDirectory(self, entrydir, changeset):
261        from os.path import join, exists
262        from vcpx.repository.cvs import CvsEntries
263
264        if not entrydir:
265            return
266
267        absentrydir = join(self.repository.basedir, entrydir)
268        if not exists(absentrydir) or CvsEntries(absentrydir).isEmpty():
269            # Oh, the directory is empty: if there are no other added entries
270            # in the directory, insert a REMOVE event against it.
271            for added in changeset.addedEntries():
272                if added.name.startswith(entrydir):
273                    # entrydir got empty, but only temporarily
274                    return False
275            return True
276        return False
277
278    def _applyChangeset(self, changeset):
279        from os.path import join, exists, split
280        from os import listdir
281        from shutil import rmtree
282        from time import sleep
283        from vcpx.repository.cvs import CvsEntries, compare_cvs_revs
284
285        entries = CvsEntries(self.repository.basedir)
286
287        # Collect added and deleted directories
288        addeddirs = []
289        deleteddirs = []
290
291        # Group entries at the same revision
292        reventries = {}
293
294        for e in changeset.entries:
295            if e.action_kind == e.UPDATED:
296                info = entries.getFileInfo(e.name)
297                if not info:
298                    self.log.debug('promoting "%s" to ADDED at '
299                                   'revision %s', e.name, e.new_revision)
300                    e.action_kind = e.ADDED
301                    for dir in self.__createParentCVSDirectories(changeset, e.name):
302                        addeddirs.append((e, dir))
303                elif info.cvs_version == e.new_revision:
304                    self.log.debug('skipping "%s" since it is already '
305                                   'at revision %s', e.name, e.new_revision)
306                    continue
307            elif e.action_kind == e.DELETED:
308                if not exists(join(self.repository.basedir, e.name)):
309                    self.log.debug('skipping "%s" since it is already '
310                                   'deleted', e.name)
311                    entrydir = split(e.name)[0]
312                    if self.__maybeDeleteDirectory(entrydir, changeset):
313                        deleteddirs.append(entrydir)
314                    continue
315            elif e.action_kind == e.ADDED:
316                if e.new_revision is None:
317                    # This is a new directory entry, there is no need to update it
318                    continue
319                else:
320                    for dir in self.__createParentCVSDirectories(changeset, e.name):
321                        addeddirs.append((e, dir))
322
323            # If this is a directory (CVS does not version directories,
324            # and thus new_revision is always None for them), and it's
325            # going to be deleted, do not execute a 'cvs update', that
326            # in some cases does not do what one would expect. Instead,
327            # remove it with everything it contains (that should be
328            # just a single "CVS" subdir, btw)
329
330            if e.action_kind == e.DELETED and e.new_revision is None:
331                assert CvsEntries(join(self.repository.basedir, e.name)).isEmpty(), '%s should be empty' % e.name
332                rmtree(join(self.repository.basedir, e.name))
333            else:
334                names = reventries.setdefault(e.new_revision, [])
335                names.append(e.name)
336
337            if e.action_kind == e.DELETED:
338                entrydir = split(e.name)[0]
339                if self.__maybeDeleteDirectory(entrydir, changeset):
340                    deleteddirs.append(entrydir)
341
342        revs = reventries.keys()
343        revs.sort(compare_cvs_revs)
344
345        cmd = self.repository.command("-f", "-d", "%(repository)s",
346                                      "-q", "update", "-d",
347                                      "-r", "%(revision)s")
348        if self.repository.freeze_keywords:
349            cmd.append('-kk')
350        cvsup = ExternalCommand(cwd=self.repository.basedir, command=cmd)
351        for rev in revs:
352            names = reventries[rev]
353            retry = 0
354            while True:
355                cvsup.execute(names, repository=self.repository.repository,
356                              revision=rev)
357                if cvsup.exit_status:
358                    retry += 1
359                    if retry>3:
360                        break
361                    delay = 2**retry
362                    self.log.warning("%s returned status %s, "
363                                     "retrying in %d seconds...",
364                                     str(cvsup), cvsup.exit_status, delay)
365                    sleep(retry)
366                else:
367                    break
368
369            if cvsup.exit_status:
370                raise ChangesetApplicationFailure(
371                    "%s returned status %s" % (str(cvsup),
372                                               cvsup.exit_status))
373
374            self.log.debug("%s updated to %s", ','.join(names), e.new_revision)
375
376        # Fake up ADD and DEL events for the directories implicitly
377        # added/removed, so that the replayer gets their name.
378
379        for entry,path in addeddirs:
380            entry = changeset.addEntry(path, None, before=entry)
381            entry.action_kind = entry.ADDED
382            self.log.info("registering new %s directory", entry.name)
383
384        for path in deleteddirs:
385            deldir = changeset.addEntry(path, None)
386            deldir.action_kind = deldir.DELETED
387            self.log.info("registering %s directory deletion", path)
388
389    def _checkoutUpstreamRevision(self, revision):
390        """
391        Concretely do the checkout of the upstream sources. Use
392        `revision` as the name of the tag to get, or as a date if it
393        starts with a number.
394
395        Return the last applied changeset.
396        """
397
398        from os.path import join, exists, split
399        from time import sleep
400        from vcpx.repository.cvs import CvsEntries, compare_cvs_revs
401        from vcpx.changes import ChangesetEntry
402
403        if not self.repository.module:
404            raise InvocationError("Must specify a module name")
405
406        timestamp = None
407        if revision is not None:
408            # If the revision contains a space, assume it really
409            # specify a branch and a timestamp. If it starts with
410            # a digit, assume it's a timestamp. Otherwise, it must
411            # be a branch name
412            if revision[0] in '0123456789' or revision == 'INITIAL':
413                timestamp = revision
414                revision = None
415            elif ' ' in revision:
416                revision, timestamp = revision.split(' ', 1)
417
418        csets = self.getPendingChangesets(revision)
419        if not csets:
420            raise TargetInitializationFailure(
421                "Something went wrong: there are no changesets since "
422                "revision '%s'" % revision)
423        if timestamp == 'INITIAL':
424            initialcset = csets.next()
425            timestamp = initialcset.date.replace(tzinfo=None).isoformat(sep=' ')
426        else:
427            initialcset = None
428
429        if not exists(join(self.repository.basedir, 'CVS')):
430            # CVS does not handle "checkout -d multi/level/subdir", so
431            # split the basedir and use it's parentdir as cwd below.
432            parentdir, subdir = split(self.repository.basedir)
433            cmd = self.repository.command("-f", "-q",
434                                          "-d", self.repository.repository,
435                                          "checkout",
436                                          "-d", subdir)
437            if revision:
438                cmd.extend(["-r", revision])
439            if timestamp:
440                cmd.extend(["-D", "%s UTC" % timestamp])
441            if self.repository.freeze_keywords:
442                cmd.append('-kk')
443
444            checkout = ExternalCommand(cwd=parentdir, command=cmd)
445            retry = 0
446            while True:
447                checkout.execute(self.repository.module)
448                if checkout.exit_status:
449                    retry += 1
450                    if retry>3:
451                        break
452                    delay = 2**retry
453                    self.log.warning("%s returned status %s, "
454                                     "retrying in %d seconds...",
455                                     str(checkout), checkout.exit_status,
456                                     delay)
457                    sleep(retry)
458                else:
459                    break
460
461            if checkout.exit_status:
462                raise TargetInitializationFailure(
463                    "%s returned status %s" % (str(checkout),
464                                               checkout.exit_status))
465        else:
466            self.log.info("Using existing %s", self.repository.basedir)
467
468        if self.repository.tag_entries:
469            self.__forceTagOnEachEntry()
470
471        entries = CvsEntries(self.repository.basedir)
472        youngest_entry = entries.getYoungestEntry()
473        if youngest_entry is None:
474            raise EmptyRepositoriesFoolsMe("The working copy '%s' of the "
475                                           "CVS repository seems empty, "
476                                           "don't know how to deal with "
477                                           "that." % self.repository.basedir)
478
479        # loop over the changesets and find the last applied, to find
480        # out the actual cvsps revision
481
482        found = False
483
484        def already_applied(cs, entries=entries):
485            "Loop over changeset entries to determine if it's already applied."
486
487            applied = False
488            for m in cs.entries:
489                info = entries.getFileInfo(m.name)
490
491                # If the entry's info exists, compare the on-disk
492                # version with what we have: the revision is already
493                # applied if the former is greater or equal than the
494                # latter. The same if the info does not exist and it's
495                # a delete event.
496
497                if info:
498                    odversion = info.cvs_version
499                    applied = compare_cvs_revs(odversion, m.new_revision) >= 0
500
501                    # If only one "hunk" is not yet applied, the whole
502                    # changeset is new.
503                    if not applied:
504                        break
505                elif m.action_kind == ChangesetEntry.DELETED:
506                    applied = True
507            return applied
508
509        # We cannot stop at the first not-applied cset, because it may
510        # old enough to trick already_applied(): an entry may have
511        # been moved in the meantime, and thus the getFileInfo()
512        # method would return None, for example... So we really have
513        # to loop over the whole queue.
514
515        for cset in self.state_file:
516            applied = already_applied(cset)
517            found = found or applied
518            if applied:
519                last = cset
520
521        if not found and initialcset:
522            found = already_applied(initialcset)
523            if found:
524                last = initialcset
525
526        if not found:
527            raise TargetInitializationFailure(
528                "Something went wrong: unable to determine the exact upstream "
529                "revision of the checked out tree in '%s'" % self.repository.basedir)
530        else:
531            self.log.info("Working copy up to revision %s", last.revision)
532
533        return last
534
535    def __createParentCVSDirectories(self, changeset, entry):
536        """
537        Verify that the hierarchy down to the entry is under CVS.
538
539        If the directory containing the entry does not exist,
540        create it and make it appear as under CVS so that a subsequent
541        'cvs update' will work.
542
543        Returns the list of eventually added directories.
544        """
545
546        from os.path import split, join, exists
547        from os import mkdir
548
549        tobeadded = []
550
551        path = split(entry)[0]
552
553        parentcvs = join(self.repository.basedir, path, 'CVS')
554        while not exists(parentcvs):
555            tobeadded.insert(0, path)
556            if not path:
557                break
558            path = split(path)[0]
559            parentcvs = join(self.repository.basedir, path, 'CVS')
560
561        assert exists(parentcvs), "Uhm, strange things happen: " \
562               "unable to find or create parent CVS area for %r" % entry
563
564        if tobeadded:
565            reposf = open(join(parentcvs, 'Repository'))
566            rep = reposf.readline()[:-1]
567            reposf.close()
568
569            rootf = open(join(parentcvs, 'Root'))
570            root = rootf.readline()
571            rootf.close()
572
573        for toadd in tobeadded:
574            basedir = join(self.repository.basedir, toadd)
575            cvsarea = join(basedir, 'CVS')
576
577            if not exists(basedir):
578                mkdir(basedir)
579
580            # Create fake CVS area
581            mkdir(cvsarea)
582
583            # Create an empty "Entries" file
584            entries = open(join(cvsarea, 'Entries'), 'w')
585            entries.close()
586
587            reposf = open(join(cvsarea, 'Repository'), 'w')
588            rep = '/'.join((rep, split(basedir)[1]))
589            reposf.write("%s\n" % rep)
590            reposf.close()
591
592            rootf = open(join(cvsarea, 'Root'), 'w')
593            rootf.write(root)
594            rootf.close()
595
596        return tobeadded
597
598    ## SynchronizableTargetWorkingDir
599
600    def _prepareTargetRepository(self):
601        """
602        Create the CVS repository if it's local and does not exist.
603        """
604
605        self.repository.create()
606
607    def _prepareWorkingDirectory(self, source_repo):
608        """
609        Checkout a working copy of the target CVS.
610        """
611
612        from os.path import join, exists
613
614        if not self.repository.repository or exists(join(self.repository.basedir, 'CVS')):
615            return
616
617        cmd = self.repository.command("-f", "-d", self.repository.repository, "co",
618                                      "-d", self.repository.basedir)
619        cvsco = ExternalCommand(command=cmd)
620        cvsco.execute(self.repository.module)
621
622    def _parents(self, path):
623        from os.path import exists, join, split
624
625        parents = []
626        parent = split(path)[0]
627        while parent:
628            if exists(join(self.repository.basedir, parent, 'CVS')):
629                break
630            parents.insert(0, parent)
631            parent = split(parent)[0]
632
633        return parents
634
635    def _addEntries(self, entries):
636        """
637        Synthesize missing parent directory additions
638        """
639
640        allnames = [e.name for e in entries]
641        newdirs = []
642        for entry in allnames:
643            for parent in [p for p in self._parents(entry) if p not in allnames]:
644                if p not in newdirs:
645                    newdirs.append(parent)
646
647        newdirs.extend(allnames)
648        self._addPathnames(newdirs)
649
650    def _addPathnames(self, names):
651        """
652        Add some new filesystem objects.
653        """
654
655        cmd = self.repository.command("-f", '-q', 'add', '-ko')
656        ExternalCommand(cwd=self.repository.basedir, command=cmd).execute(names)
657
658    def __forceTagOnEachEntry(self):
659        """
660        Massage each CVS/Entries file, locking (ie, tagging) each
661        entry to its current CVS version.
662
663        This is to prevent silly errors such those that could arise
664        after a manual ``cvs update`` in the working directory.
665        """
666
667        from os import walk, rename, remove
668        from os.path import join, exists
669
670        self.log.info("Forcing CVS sticky tag in %s", self.repository.basedir)
671
672        for dir, subdirs, files in walk(self.repository.basedir):
673            if dir[-3:] == 'CVS':
674                efn = join(dir, 'Entries')
675
676                # Strangeness is a foreign word in CVS: sometime
677                # the Entries isn't there...
678                if not exists(efn):
679                    continue
680
681                f = open(efn)
682                entries = f.readlines()
683                f.close()
684
685                newentries = []
686                for e in entries:
687                    if e.startswith('/'):
688                        fields = e.split('/')
689                        fields[-1] = "T%s\n" % fields[2]
690                        newe = '/'.join(fields)
691                        newentries.append(newe)
692                    else:
693                        newentries.append(e)
694
695                rename(efn, efn+'.tailor-old')
696
697                f = open(efn, 'w')
698                f.writelines(newentries)
699                f.close()
700
701                remove(efn+'.tailor-old')
702
703
704    def _commit(self, date, author, patchname, changelog=None, entries=None):
705        """
706        Commit the changeset.
707        """
708
709        from vcpx.shwrap import ReopenableNamedTemporaryFile
710
711        encode = self.repository.encode
712
713        logmessage = []
714        if patchname:
715            logmessage.append(patchname)
716        if changelog:
717            logmessage.append(changelog)
718        logmessage.append('')
719        logmessage.append('Original author: %s' % author)
720        logmessage.append('Date: %s' % date)
721
722        rontf = ReopenableNamedTemporaryFile('cvs', 'tailor')
723        log = open(rontf.name, "w")
724        log.write(encode('\n'.join(logmessage)))
725        log.close()
726
727        cmd = self.repository.command("-f", "-q", "ci", "-F", rontf.name)
728        if not entries:
729            entries = ['.']
730
731        c = ExternalCommand(cwd=self.repository.basedir, command=cmd)
732        c.execute(entries)
733
734        if c.exit_status:
735            raise ChangesetApplicationFailure("%s returned status %d" %
736                                              (str(c), c.exit_status))
737
738    def _removePathnames(self, names):
739        """
740        Remove some filesystem objects.
741        """
742
743        cmd = self.repository.command("-f", "-q", "remove")
744        ExternalCommand(cwd=self.repository.basedir, command=cmd).execute(names)
745
746    def _renamePathname(self, oldname, newname):
747        """
748        Rename a filesystem object.
749        """
750
751        self._removePathnames([oldname])
752        self._addPathnames([newname])
753
754    def _tag(self, tagname):
755        """
756        Apply a tag.
757        """
758
759        # Sanitize tagnames for CVS: start with [a-zA-z], only include letters,
760        # numbers, '-' and '_'.
761        # str.isalpha et al are locale-dependent
762        def iscvsalpha(chr):
763            return (chr >= 'a' and chr <= 'z') or (chr >= 'A' and chr <= 'Z')
764        def iscvsdigit(chr):
765            return chr >= '0' and chr <= '9'
766        def iscvschar(chr):
767            return iscvsalpha(chr) or iscvsdigit(chr) or chr == '-' or chr == '_'
768        def cvstagify(chr):
769            if iscvschar(chr):
770                return chr
771            else:
772                return '_'
773
774        tagname = ''.join([cvstagify(chr) for chr in tagname])
775        if not iscvsalpha(tagname[0]):
776            tagname = 'tag-' + tagname
777
778        cmd = self.repository.command("-f", "tag")
779        c = ExternalCommand(cwd=self.repository.basedir, command=cmd)
780        c.execute(tagname)
781        if c.exit_status:
782            raise ChangesetApplicationFailure("%s returned status %d" %
783                                              (str(c), c.exit_status))
Note: See TracBrowser for help on using the repository browser.