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

Revision 1228, 27.1 KB checked in by lele@…, 7 years ago (diff)

Explain a bit more the code

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("-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("-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("--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 os import listdir
263
264        if not entrydir:
265            return
266
267        absentrydir = join(self.repository.basedir, entrydir)
268        if not exists(absentrydir) or listdir(absentrydir) == ['CVS']:
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
284
285        entries = CvsEntries(self.repository.basedir)
286
287        # Collect added and deleted directories
288        addeddirs = []
289        deleteddirs = []
290
291        for e in changeset.entries:
292            if e.action_kind == e.UPDATED:
293                info = entries.getFileInfo(e.name)
294                if not info:
295                    self.log.debug('promoting "%s" to ADDED at '
296                                   'revision %s', e.name, e.new_revision)
297                    e.action_kind = e.ADDED
298                    addeddirs.extend(self.__createParentCVSDirectories(changeset, e.name))
299                elif info.cvs_version == e.new_revision:
300                    self.log.debug('skipping "%s" since it is already '
301                                   'at revision %s', e.name, e.new_revision)
302                    continue
303            elif e.action_kind == e.DELETED:
304                if not exists(join(self.repository.basedir, e.name)):
305                    self.log.debug('skipping "%s" since it is already '
306                                   'deleted', e.name)
307                    entrydir = split(e.name)[0]
308                    if self.__maybeDeleteDirectory(entrydir, changeset):
309                        deleteddirs.append(entrydir)
310                    continue
311            elif e.action_kind == e.ADDED and e.new_revision is None:
312                # This is a new directory entry, there is no need to update it
313                continue
314
315            # If this is a directory (CVS does not version directories,
316            # and thus new_revision is always None for them), and it's
317            # going to be deleted, do not execute a 'cvs update', that
318            # in some cases does not do what one would expect. Instead,
319            # remove it with everything it contains (that should be
320            # just a single "CVS" subdir, btw)
321
322            if e.action_kind == e.DELETED and e.new_revision is None:
323                assert listdir(join(self.repository.basedir, e.name)) == ['CVS'], '%s should be empty' % e.name
324                rmtree(join(self.repository.basedir, e.name))
325            else:
326                cmd = self.repository.command("-d", "%(repository)s",
327                                              "-q", "update", "-d",
328                                              "-r", e.new_revision)
329                if self.repository.freeze_keywords:
330                    cmd.append('-kk')
331                cvsup = ExternalCommand(cwd=self.repository.basedir, command=cmd)
332                retry = 0
333                while True:
334                    cvsup.execute(e.name, repository=self.repository.repository)
335
336                    if cvsup.exit_status:
337                        retry += 1
338                        if retry>3:
339                            break
340                        delay = 2**retry
341                        self.log.warning("%s returned status %s, "
342                                         "retrying in %d seconds...",
343                                         str(cvsup), cvsup.exit_status, delay)
344                        sleep(retry)
345                    else:
346                        break
347
348                if cvsup.exit_status:
349                    raise ChangesetApplicationFailure(
350                        "%s returned status %s" % (str(cvsup),
351                                                   cvsup.exit_status))
352
353                self.log.debug("%s updated to %s", e.name, e.new_revision)
354
355            if e.action_kind == e.DELETED:
356                entrydir = split(e.name)[0]
357                if self.__maybeDeleteDirectory(entrydir, changeset):
358                    deleteddirs.append(entrydir)
359
360        # Fake up ADD and DEL events for the directories implicitly
361        # added/removed, so that the replayer gets their name.
362
363        for path in addeddirs:
364            entry = changeset.addEntry(path, None)
365            entry.action_kind = entry.ADDED
366
367        for path in deleteddirs:
368            deldir = changeset.addEntry(path, None)
369            deldir.action_kind = deldir.DELETED
370
371    def _checkoutUpstreamRevision(self, revision):
372        """
373        Concretely do the checkout of the upstream sources. Use
374        `revision` as the name of the tag to get, or as a date if it
375        starts with a number.
376
377        Return the last applied changeset.
378        """
379
380        from os.path import join, exists, split
381        from time import sleep
382        from vcpx.repository.cvs import CvsEntries, compare_cvs_revs
383        from vcpx.changes import ChangesetEntry
384
385        if not self.repository.module:
386            raise InvocationError("Must specify a module name")
387
388        timestamp = None
389        if revision is not None:
390            # If the revision contains a space, assume it really
391            # specify a branch and a timestamp. If it starts with
392            # a digit, assume it's a timestamp. Otherwise, it must
393            # be a branch name
394            if revision[0] in '0123456789' or revision == 'INITIAL':
395                timestamp = revision
396                revision = None
397            elif ' ' in revision:
398                revision, timestamp = revision.split(' ', 1)
399
400        csets = self.getPendingChangesets(revision)
401        if not csets:
402            raise TargetInitializationFailure(
403                "Something went wrong: there are no changesets since "
404                "revision '%s'" % revision)
405        if timestamp == 'INITIAL':
406            initialcset = csets.next()
407            timestamp = initialcset.date.replace(tzinfo=None).isoformat(sep=' ')
408        else:
409            initialcset = None
410
411        if not exists(join(self.repository.basedir, 'CVS')):
412            # CVS does not handle "checkout -d multi/level/subdir", so
413            # split the basedir and use it's parentdir as cwd below.
414            parentdir, subdir = split(self.repository.basedir)
415            cmd = self.repository.command("-q",
416                                          "-d", self.repository.repository,
417                                          "checkout",
418                                          "-d", subdir)
419            if revision:
420                cmd.extend(["-r", revision])
421            if timestamp:
422                cmd.extend(["-D", "%s UTC" % timestamp])
423            if self.repository.freeze_keywords:
424                cmd.append('-kk')
425
426            checkout = ExternalCommand(cwd=parentdir, command=cmd)
427            retry = 0
428            while True:
429                checkout.execute(self.repository.module)
430                if checkout.exit_status:
431                    retry += 1
432                    if retry>3:
433                        break
434                    delay = 2**retry
435                    self.log.warning("%s returned status %s, "
436                                     "retrying in %d seconds...",
437                                     str(checkout), checkout.exit_status,
438                                     delay)
439                    sleep(retry)
440                else:
441                    break
442
443            if checkout.exit_status:
444                raise TargetInitializationFailure(
445                    "%s returned status %s" % (str(checkout),
446                                               checkout.exit_status))
447        else:
448            self.log.info("Using existing %s", self.repository.basedir)
449
450        if self.repository.tag_entries:
451            self.__forceTagOnEachEntry()
452
453        entries = CvsEntries(self.repository.basedir)
454        youngest_entry = entries.getYoungestEntry()
455        if youngest_entry is None:
456            raise EmptyRepositoriesFoolsMe("The working copy '%s' of the "
457                                           "CVS repository seems empty, "
458                                           "don't know how to deal with "
459                                           "that." % self.repository.basedir)
460
461        # loop over the changesets and find the last applied, to find
462        # out the actual cvsps revision
463
464        found = False
465        csets = self.state_file.reversed()
466
467        def already_applied(cs, entries=entries):
468            "Loop over changeset entries to determine if it's already applied."
469
470            applied = False
471            for m in cs.entries:
472                info = entries.getFileInfo(m.name)
473
474                # If the entry's info exists, compare the on-disk
475                # version with what we have: the revision is already
476                # applied if the former is greater or equal than the
477                # latter. The same if the info does not exist and it's
478                # a delete event.
479
480                if info:
481                    odversion = info.cvs_version
482                    applied = compare_cvs_revs(odversion, m.new_revision) >= 0
483
484                    # If only one "hunk" is not yet applied, the whole
485                    # changeset is new.
486                    if not applied:
487                        break
488                elif m.action_kind == ChangesetEntry.DELETED:
489                    applied = True
490            return applied
491
492        for cset in csets:
493            found = already_applied(cset)
494            if found:
495                last = cset
496                break
497
498        if not found and initialcset:
499            found = already_applied(initialcset)
500            if found:
501                last = initialcset
502
503        if not found:
504            raise TargetInitializationFailure(
505                "Something went wrong: unable to determine the exact upstream "
506                "revision of the checked out tree in '%s'" % self.repository.basedir)
507        else:
508            self.log.info("Working copy up to revision %s", last.revision)
509
510        return last
511
512    def _willApplyChangeset(self, changeset, applyable=None):
513        """
514        This gets called just before applying each changeset.
515
516        Since CVS has no "createdir" event, we have to take care
517        of new directories, creating empty-but-reasonable CVS dirs.
518        """
519
520        if UpdatableSourceWorkingDir._willApplyChangeset(self, changeset,
521                                                         applyable):
522            for m in changeset.entries:
523                if m.action_kind == m.ADDED:
524                    self.__createParentCVSDirectories(changeset, m.name)
525
526            return True
527        else:
528            return False
529
530    def __createParentCVSDirectories(self, changeset, entry):
531        """
532        Verify that the hierarchy down to the entry is under CVS.
533
534        If the directory containing the entry does not exist,
535        create it and make it appear as under CVS so that a subsequent
536        'cvs update' will work.
537
538        Returns the list of eventually added directories.
539        """
540
541        from os.path import split, join, exists
542        from os import mkdir
543
544        tobeadded = []
545
546        path = split(entry)[0]
547
548        parentcvs = join(self.repository.basedir, path, 'CVS')
549        while not exists(parentcvs):
550            tobeadded.insert(0, join(self.repository.basedir, path))
551            if not path:
552                break
553            path = split(path)[0]
554            parentcvs = join(self.repository.basedir, path, 'CVS')
555
556        assert exists(parentcvs), "Uhm, strange things happen: " \
557               "unable to find or create parent CVS area for %r" % entry
558
559        if tobeadded:
560            reposf = open(join(parentcvs, 'Repository'))
561            rep = reposf.readline()[:-1]
562            reposf.close()
563
564            rootf = open(join(parentcvs, 'Root'))
565            root = rootf.readline()
566            rootf.close()
567
568        for basedir in tobeadded:
569            cvsarea = join(basedir, 'CVS')
570
571            if not exists(basedir):
572                mkdir(basedir)
573
574            # Create fake CVS area
575            mkdir(cvsarea)
576
577            # Create an empty "Entries" file
578            entries = open(join(cvsarea, 'Entries'), 'w')
579            entries.close()
580
581            reposf = open(join(cvsarea, 'Repository'), 'w')
582            rep = '/'.join((rep, split(basedir)[1]))
583            reposf.write("%s\n" % rep)
584            reposf.close()
585
586            rootf = open(join(cvsarea, 'Root'), 'w')
587            rootf.write(root)
588            rootf.close()
589
590        return tobeadded
591
592    ## SynchronizableTargetWorkingDir
593
594    def _prepareTargetRepository(self):
595        """
596        Create the CVS repository if it's local and does not exist.
597        """
598
599        self.repository.create()
600
601    def _prepareWorkingDirectory(self, source_repo):
602        """
603        Checkout a working copy of the target CVS.
604        """
605
606        from os.path import join, exists
607
608        if not self.repository.repository or exists(join(self.repository.basedir, 'CVS')):
609            return
610
611        cmd = self.repository.command("-d", self.repository.repository, "co",
612                                      "-d", self.repository.basedir)
613        cvsco = ExternalCommand(command=cmd)
614        cvsco.execute(self.repository.module)
615
616    def _parents(self, path):
617        from os.path import exists, join, split
618
619        parents = []
620        parent = split(path)[0]
621        while parent:
622            if exists(join(self.repository.basedir, parent, 'CVS')):
623                break
624            parents.insert(0, parent)
625            parent = split(parent)[0]
626
627        return parents
628
629    def _addEntries(self, entries):
630        """
631        Synthesize missing parent directory additions
632        """
633
634        allnames = [e.name for e in entries]
635        newdirs = []
636        for entry in allnames:
637            for parent in [p for p in self._parents(entry) if p not in allnames]:
638                if p not in newdirs:
639                    newdirs.append(parent)
640
641        newdirs.extend(allnames)
642        self._addPathnames(newdirs)
643
644    def _addPathnames(self, names):
645        """
646        Add some new filesystem objects.
647        """
648
649        cmd = self.repository.command('-q', 'add', '-ko')
650        ExternalCommand(cwd=self.repository.basedir, command=cmd).execute(names)
651
652    def __forceTagOnEachEntry(self):
653        """
654        Massage each CVS/Entries file, locking (ie, tagging) each
655        entry to its current CVS version.
656
657        This is to prevent silly errors such those that could arise
658        after a manual ``cvs update`` in the working directory.
659        """
660
661        from os import walk, rename, remove
662        from os.path import join, exists
663
664        self.log.info("Forcing CVS sticky tag in %s", self.repository.basedir)
665
666        for dir, subdirs, files in walk(self.repository.basedir):
667            if dir[-3:] == 'CVS':
668                efn = join(dir, 'Entries')
669
670                # Strangeness is a foreign word in CVS: sometime
671                # the Entries isn't there...
672                if not exists(efn):
673                    continue
674
675                f = open(efn)
676                entries = f.readlines()
677                f.close()
678
679                newentries = []
680                for e in entries:
681                    if e.startswith('/'):
682                        fields = e.split('/')
683                        fields[-1] = "T%s\n" % fields[2]
684                        newe = '/'.join(fields)
685                        newentries.append(newe)
686                    else:
687                        newentries.append(e)
688
689                rename(efn, efn+'.tailor-old')
690
691                f = open(efn, 'w')
692                f.writelines(newentries)
693                f.close()
694
695                remove(efn+'.tailor-old')
696
697
698    def _commit(self, date, author, patchname, changelog=None, entries=None):
699        """
700        Commit the changeset.
701        """
702
703        from vcpx.shwrap import ReopenableNamedTemporaryFile
704
705        encode = self.repository.encode
706
707        logmessage = []
708        if patchname:
709            logmessage.append(patchname)
710        if changelog:
711            logmessage.append(changelog)
712        logmessage.append('')
713        logmessage.append('Original author: %s' % author)
714        logmessage.append('Date: %s' % date)
715
716        rontf = ReopenableNamedTemporaryFile('cvs', 'tailor')
717        log = open(rontf.name, "w")
718        log.write(encode('\n'.join(logmessage)))
719        log.close()
720
721        cmd = self.repository.command("-q", "ci", "-F", rontf.name)
722        if not entries:
723            entries = ['.']
724
725        c = ExternalCommand(cwd=self.repository.basedir, command=cmd)
726        c.execute(entries)
727
728        if c.exit_status:
729            raise ChangesetApplicationFailure("%s returned status %d" %
730                                              (str(c), c.exit_status))
731
732    def _removePathnames(self, names):
733        """
734        Remove some filesystem objects.
735        """
736
737        cmd = self.repository.command("-q", "remove")
738        ExternalCommand(cwd=self.repository.basedir, command=cmd).execute(names)
739
740    def _renamePathname(self, oldname, newname):
741        """
742        Rename a filesystem object.
743        """
744
745        self._removePathnames([oldname])
746        self._addPathnames([newname])
747
748    def _tag(self, tagname):
749        """
750        Apply a tag.
751        """
752
753        # Sanitize tagnames for CVS: start with [a-zA-z], only include letters,
754        # numbers, '-' and '_'.
755        # str.isalpha et al are locale-dependent
756        def iscvsalpha(chr):
757            return (chr >= 'a' and chr <= 'z') or (chr >= 'A' and chr <= 'Z')
758        def iscvsdigit(chr):
759            return chr >= '0' and chr <= '9'
760        def iscvschar(chr):
761            return iscvsalpha(chr) or iscvsdigit(chr) or chr == '-' or chr == '_'
762        def cvstagify(chr):
763            if iscvschar(chr):
764                return chr
765            else:
766                return '_'
767
768        tagname = ''.join([cvstagify(chr) for chr in tagname])
769        if not iscvsalpha(tagname[0]):
770            tagname = 'tag-' + tagname
771
772        cmd = self.repository.command("tag")
773        c = ExternalCommand(cwd=self.repository.basedir, command=cmd)
774        c.execute(tagname)
775        if c.exit_status:
776            raise ChangesetApplicationFailure("%s returned status %d" %
777                                              (str(c), c.exit_status))
Note: See TracBrowser for help on using the repository browser.