source: tailor/vcpx/tailor.py @ 432

Revision 432, 25.4 KB checked in by lele@…, 8 years ago (diff)

Handle --revision=INITIAL also for Subversion

Line 
1# -*- mode: python; coding: utf-8 -*-
2# :Progetto: vcpx -- Frontend capabilities
3# :Creato:   dom 04 lug 2004 00:40:54 CEST
4# :Autore:   Lele Gaifax <lele@nautilus.homeip.net>
5# :Licenza:  GNU General Public License
6#
7
8"""
9Implement the basic capabilities of the frontend.
10
11This implementation stores the relevant project information, needed to
12keep the whole thing going on, such as the last synced revision, in a
13unversioned file named ``tailor.info`` at the root.
14"""
15
16__docformat__ = 'reStructuredText'
17
18__version__ = '0.9.0'
19
20from optparse import OptionParser, OptionGroup, make_option
21from dualwd import DualWorkingDir
22from source import InvocationError
23from session import interactive
24from svn import SvnWorkingDir
25
26STATUS_FILENAME = 'tailor.info'
27LOG_FILENAME = 'tailor.log'
28
29def relpathto(source, dest):
30    """
31    Compute the relative path needed to point ``source`` from ``dest``.
32
33    Warning: ``dest`` is assumed to be a directory.
34    """
35   
36    from os.path import abspath, split, commonprefix
37   
38    source = abspath(source)
39    dest = abspath(dest)
40
41    if source.startswith(dest):
42        return source[len(dest)+1:]
43   
44    prefix = commonprefix([source, dest])
45
46    source = source[len(prefix):]
47    dest = dest[len(prefix):]
48
49    return '../' * len(dest.split('/')) + source
50
51
52class TailorConfig(object):
53    """
54    Configuration of a set of tailorized projects.
55
56    The configuration is stored in a persistent dictionary keyed on the
57    relative path of each project. The information about a single project
58    is another dictionary.
59    """
60   
61    def __init__(self, options):
62        from os.path import abspath, split
63       
64        self.options = options
65        self.configfile = abspath(options.configfile)
66        self.basedir = split(self.configfile)[0]
67       
68    def __call__(self, args):
69        from os.path import join, exists, split
70        from source import ChangesetApplicationFailure
71       
72        self.__load()
73
74        if len(args) == 0:
75            fromconfig = True
76            if self.options.bootstrap:
77                f = lambda x: not exists(x)
78            else:
79                f = exists
80               
81            args = [p for p in [join(self.basedir, r)
82                                for r in self.config.keys()] if f(p)]
83            args.sort()
84        else:
85            fromconfig = False
86           
87        try:
88            for root in args:
89                if self.options.bootstrap:               
90                    if not (fromconfig or self.options.repository):
91                        raise InvocationError('Need a repository to bootstrap '
92                                              '%r' % root, '--bootstrap')
93                else:
94                    if not self.config.has_key(relpathto(root, self.basedir)):
95                        raise UnknownProjectError("Project %r does not exist" %
96                                                  root)
97                   
98                tailored = TailorizedProject(root, self.options.verbose, self)
99
100                if self.options.bootstrap:
101                    if fromconfig:                       
102                        info = self.loadProject(root=root)
103                        self.options.source_kind = info['source_kind']
104                        self.options.target_kind = info['target_kind']
105                        self.options.repository = info['upstream_repos']
106                        self.options.module = info['module']
107                        self.options.subdir = info.get('subdir',
108                                                       split(info['module'])[1])
109                        self.options.revision = info['upstream_revision']
110                       
111                    tailored.bootstrap(self.options.source_kind,
112                                       self.options.target_kind,
113                                       self.options.repository,
114                                       self.options.module,
115                                       self.options.revision,
116                                       self.options.subdir)
117                elif self.options.migrate:
118                    tailored.migrateConfiguration()
119                elif self.options.update:
120                    try:
121                        tailored.update(self.options.single_commit,
122                                        self.options.concatenate_logs)
123                    except ChangesetApplicationFailure, e:
124                        print "Skipping '%s' because of errors:" % root, e
125        finally:
126            self.__save()
127       
128    def __save(self):
129        from pprint import pprint
130
131        configfile = open(self.configfile, 'w')
132        pprint(self.config, configfile)
133        configfile.close()
134
135    def __load(self):
136        from os.path import exists
137
138        if exists(self.options.configfile):
139            configfile = open(self.configfile)
140            self.config = eval(configfile.read())
141            configfile.close()
142        else:
143            self.config = {}
144           
145    def loadProject(self, project=None, root=None):
146        from os.path import split
147       
148        relpath = relpathto(project and project.root or root, self.basedir)
149       
150        info = self.config.get(relpath)
151        if info and project:
152            project.source_kind = info['source_kind']
153            project.target_kind = info['target_kind']
154            project.module = info['module']
155            project.subdir = info.get('subdir', split(project.module)[1])
156            project.upstream_repos = info['upstream_repos']
157            project.upstream_revision = info['upstream_revision']
158
159        return info
160       
161    def saveProject(self, project):
162        relpath = relpathto(project.root, self.basedir)
163       
164        self.config[relpath] = { 
165            'source_kind': project.source_kind,
166            'target_kind': project.target_kind,
167            'module': project.module,
168            'subdir': project.subdir,
169            'upstream_repos': project.upstream_repos,
170            'upstream_revision': project.upstream_revision,
171            }
172
173   
174class TailorizedProject(object):
175    """
176    A TailorizedProject has two main capabilities: it may be bootstrapped
177    from an upstream repository or brought in sync with current upstream
178    revision.
179    """
180   
181    def __init__(self, root, verbose=False, config=None):
182        import logging
183        from os import makedirs
184        from os.path import join, exists, split
185
186        self.root = root
187        if not exists(root):
188            makedirs(root)
189
190        self.verbose = verbose
191        self.logger = logging.getLogger('tailor.%s' % split(root)[1])
192        hdlr = logging.FileHandler(join(root, LOG_FILENAME))
193        formatter = logging.Formatter('%(asctime)s [%(levelname)s] %(message)s')
194        hdlr.setFormatter(formatter)
195        self.logger.addHandler(hdlr) 
196        self.logger.setLevel(logging.INFO)
197
198        self.source_kind = self.target_kind = None
199
200        self.config = config
201
202    def migrateConfiguration(self):
203        self.__loadOldStatus()
204        self.__saveStatus()
205       
206    def __saveOldStatus(self):
207        from os.path import join
208
209        statusfilename = join(self.root, STATUS_FILENAME)
210        f = open(statusfilename, 'w')
211        print >>f, self.source_kind
212        print >>f, self.target_kind       
213        print >>f, self.module       
214        print >>f, self.upstream_repos
215        print >>f, self.upstream_revision
216        print >>f, self.subdir
217        f.close()
218
219    def __saveStatus(self):
220        """
221        Save relevant project information in a persistent way.
222        """
223
224        if self.config:
225            self.config.saveProject(self)
226        else:
227            self.__saveOldStatus()
228
229    def __loadOldStatus(self):
230        from os.path import join, split
231
232        statusfilename = join(self.root, STATUS_FILENAME)
233        f = open(statusfilename)
234        self.source_kind = f.readline()[:-1]
235        self.target_kind = f.readline()[:-1]
236        self.module = f.readline()[:-1]
237        self.upstream_repos = f.readline()[:-1]
238        self.upstream_revision = f.readline()[:-1]
239        subdir = f.readline()
240        if subdir:
241            self.subdir = subdir[:-1]
242        else:
243            self.subdir = split(self.module)[1]           
244        f.close()
245
246    def __loadStatus(self):
247        """
248        Load relevant project information.
249        """
250
251        if self.config:
252            self.config.loadProject(self)
253        else:
254            self.__loadOldStatus()
255
256        # Fix old configs
257       
258        if self.source_kind == 'svn' and not '/' in self.module:
259            self.logger.warning('OLD config values for SVN')
260            print "The project at '%s' contains old values for" % self.root
261            print "the upstream repository (%s)" % self.upstream_repos
262            print "and module (%s)." % self.module
263            print "Please correct them, specifying the exact URL of the"
264            print "root of the SVN repository and then the prefix path up"
265            print "to the point you want, that must start with a slash."
266            print "This usually means splitting the repository URL above in"
267            print "two parts. For example, that could be"
268           
269            crepo = self.upstream_repos
270            example_split = crepo.rfind('/', 6, crepo.rfind('/'))
271            if example_split > 0:
272                example_repo = crepo[:example_split]
273                example_module = crepo[example_split:]
274            else:
275                example_repo = 'http://svn.plone.org/collective'
276                example_module = '/ATContentTypes/trunk'
277           
278            print "  Repository=%s" % example_repo
279            print "  Module=%s" % example_module
280            print "but your situation may vary, that's just an example!"
281            print
282            try:
283                self.repository = raw_input('Repository: ')
284                self.module = raw_input('Module/prefix: ')
285            except KeyboardInterrupt:
286                self.logger.warning("Leaving old config values, stopped by user")
287                raise
288           
289    def bootstrap(self, source_kind, target_kind,
290                  repository, module, revision, subdir):
291        """
292        Bootstrap a new tailorized module.
293
294        Extract a copy of the ``repository`` at given ``revision`` in the
295        ``root`` directory and initialize a target repository with its content.
296
297        The actual information on the project are stored in a text file.
298        """
299
300        from os.path import split
301
302        if source_kind == 'svn':
303            if not (module and module.startswith('/')):
304                raise InvocationError('With SVN the module argument is '
305                                      'mandatory and must start with a "/"')
306
307        if repository.endswith('/'):
308            repository = repository[:-1]
309
310        if module and module.endswith('/'):
311            module = module[:-1]
312           
313        if not subdir:
314            subdir = split(module or repository)[1] or ''
315           
316        self.logger.info("Bootstrapping '%s'" % (self.root,))
317
318        dwd = DualWorkingDir(source_kind, target_kind)
319        self.logger.info("getting %s revision '%s' of '%s' from '%s'" % (
320            source_kind, revision, module, repository))
321
322        try:
323            actual = dwd.checkoutUpstreamRevision(self.root, repository,
324                                                  module, revision,
325                                                  subdir=subdir,
326                                                  logger=self.logger)
327        except:
328            self.logger.exception('Checkout failed!')
329            raise
330       
331        # the above machinery checked out a copy under of the wc
332        # in the directory named as the last component of the module's name
333
334        if not module:
335            module = split(repository)[1]
336
337        try:
338            dwd.initializeNewWorkingDir(self.root, repository, module, subdir,
339                                        actual, revision=='INITIAL')
340        except:
341            self.logger.exception('Working copy initialization failed!')
342            raise
343       
344        self.source_kind = source_kind
345        self.target_kind = target_kind
346        self.upstream_repos = repository
347        self.module = module
348        self.subdir = subdir
349        self.upstream_revision = actual.revision
350
351        self.__saveStatus()
352
353        self.logger.info("Bootstrap completed")
354
355    def applyable(self, root, changeset):
356        """
357        Print the changeset being applied.
358        """
359
360        if self.verbose:
361            print "Changeset %s:" % changeset.revision
362            try:
363                print changeset.log
364            except UnicodeEncodeError:
365                print ">>> Non-printable changelog <<<"
366               
367        return True
368   
369    def applied(self, root, changeset):
370        """
371        Save current status.
372        """
373
374        self.upstream_revision = changeset.revision
375        self.__saveStatus()
376        if self.verbose:
377            print
378
379    def update(self, single_commit, concatenate_logs):
380        """
381        Update an existing tailorized project.
382
383        Fetch the upstream changesets and apply them to the working copy.
384        Use the information stored in the ``tailor.info`` file to ask just
385        the new changeset since last bootstrap/synchronization.
386        """
387       
388        from os.path import join
389
390        self.__loadStatus()
391        proj = join(self.root, self.subdir)
392
393        self.logger.info("Updating '%s' from revision '%s'" % (
394            self.module, self.upstream_revision))
395
396        if self.verbose:
397            print "\nUpdating '%s' from revision '%s'" % (
398                self.module, self.upstream_revision)
399
400        try:
401            dwd = DualWorkingDir(self.source_kind, self.target_kind)
402            changesets = dwd.getUpstreamChangesets(proj,
403                                                   self.upstream_repos,
404                                                   self.module,
405                                                   self.upstream_revision)
406        except KeyboardInterrupt:
407            print "Leaving '%s' unchanged" % proj
408            self.logger.info("Leaving '%s' unchanged, stopped by user" % proj)
409            return
410        except:
411            self.logger.exception("Unable to get changes for '%s'" % proj)
412            raise
413       
414        nchanges = len(changesets)
415        if nchanges:
416            if self.verbose:
417                print "Applying %d upstream changesets" % nchanges
418
419            try:
420                last, conflicts = dwd.applyUpstreamChangesets(
421                    proj, self.module, changesets, applyable=self.applyable,
422                    applied=self.applied, logger=self.logger,
423                    delayed_commit=single_commit)
424            except:
425                self.logger.exception('Upstream change application failed')
426                raise
427           
428            if last:
429                if single_commit:
430                    dwd.commitDelayedChangesets(proj, concatenate_logs)
431
432                self.logger.info("Update completed, now at revision '%s'" % (
433                    self.upstream_revision,))
434        else:
435            self.logger.info("Update completed with no upstream changes")
436
437
438GENERAL_OPTIONS = [
439    make_option("-i", "--interactive", default=False, action="store_true",
440                help="Start an interactive session."),
441    make_option("-D", "--debug", dest="debug",
442                action="store_true", default=False,
443                help="Print each executed command. This also keeps "
444                     "temporary files with the upstream logs, that are "
445                     "otherwise removed after use."),
446    make_option("-v", "--verbose", dest="verbose",
447                action="store_true", default=False,
448                help="Be verbose, echoing the changelog of each applied "
449                     "changeset to stdout."),
450    make_option("--configfile", metavar="CONFNAME",
451                help="Centralized storage of projects info.  With this "
452                     "option and no other arguments tailor will update "
453                     "every project found in the config file."),
454    make_option("--migrate-config", dest="migrate",
455                action="store_true", default=False,
456                help="Migrate old configuration to new centralized storage."),
457    make_option("--encoding", metavar="CHARSET", default=None,
458                help="Force the output encoding to given CHARSET, rather "
459                     "then using the user default settings specified in the "
460                     "environment."),
461]   
462
463UPDATE_OPTIONS = [
464    make_option("--update", action="store_true", default=True,
465                help="Update the given repositories, fetching upstream "
466                     "changesets, applying and re-registering each one. "
467                     "This is the default behaviour."),
468    make_option("-S", "--single-commit", action="store_true", default=False,
469                help="Do a single, final commit on the target VC, effectively "
470                     "grouping together all upstream changeset into a single "
471                     "one, from the target VC point of view."),
472    make_option("-C", "--concatenate-logs", action="store_true", default=False,
473                help="With --single-commit, concatenate each changeset "
474                     "message log to the final changelog, instead of just "
475                     "the name of the patch."),
476    make_option("-F", "--patch-name-format", metavar="FORMAT",
477                help="Specify the prototype that will be used "
478                     "to compute the patch name.  The prototype may contain "
479                     "%(keyword)s such as 'module', 'author', 'date', "
480                     "'revision', 'firstlogline', 'remaininglog' for normal "
481                     "updates, otherwise 'module', 'authors', 'nchangesets', "
482                     "'mindate' and 'maxdate' when using --single-commit."),
483    make_option("-1", "--remove-first-log-line", action="store_true",
484                default=False,
485                help="Remove the first line of the upstream changelog. This "
486                     "is intended to go in pair with --patch-name-format, "
487                     "when using it's 'firstlogline' variable to build the "
488                     "name of the patch."),               
489    make_option("-N", "--dont-refill-changelogs", action="store_true",
490                default=False,
491                help="Do not refill every changelog, but keep them as is. "
492                     "This is usefull when using --patch-name-format, or "
493                     "when upstream developers are already formatting their "
494                     "notes with a consistent layout."),
495]
496
497BOOTSTRAP_OPTIONS = [
498    make_option("-b", "--bootstrap", action="store_true", default=False,
499                help="Bootstrap mode, that is the initial copy of the "
500                     "upstream tree, given as an URI (see -R) and maybe "
501                     "a revision (-r).  This overrides --update."),
502    make_option("-s", "--source-kind", dest="source_kind", metavar="VC-KIND",
503                help="Select the backend for the upstream source "
504                     "version control VC-KIND. Default is 'cvs'.",
505                default="cvs"),
506    make_option("-t", "--target-kind", dest="target_kind", metavar="VC-KIND",
507                help="Select VC-KIND as backend for the shadow repository, "
508                     "with 'darcs' as default.",
509                default="darcs"),
510    make_option("-R", "--repository", dest="repository", metavar="REPOS",
511                help="Specify the upstream repository, from where bootstrap "
512                     "will checkout the module.  REPOS syntax depends on "
513                     "the source version control kind."),
514    make_option("-m", "--module", dest="module", metavar="MODULE",
515                help="Specify the module to checkout at bootstrap time. "
516                     "This has different meanings under the various upstream "
517                     "systems: with CVS it indicates the module, while under "
518                     "SVN it's the prefix of the tree you want and must begin "
519                     "with a slash. Since it's used in the description of the "
520                     "target repository, you may want to give it a value with "
521                     "darcs too even if it is otherwise ignored."),
522    make_option("-r", "--revision", dest="revision", metavar="REV",
523                help="Specify the revision bootstrap should checkout.  REV "
524                     "must be a valid 'name' for a revision in the upstream "
525                     "version control kind. For CVS it may be either a branch "
526                     "name, a timestamp or both separated by a space, and "
527                     "timestamp may be 'INITIAL' to denote the beginning of "
528                     "time for the given branch. Under Darcs, INITIAL is a "
529                     "shortcut for the name of the first patch in the upstream "
530                     "repository, otherwise it is interpreted as the name of "
531                     "a tag. Under Subversion, 'INITIAL' is the first patch "
532                     "that touches given repos/module, otherwise it must be "
533                     "an integer revision number. "
534                     "'HEAD', the default, means the latest version in all "
535                     "backends.",
536                default="HEAD"),
537    make_option("--subdir", metavar="DIR",
538                help="Force the subdirectory where the checkout will happen, "
539                     "by default it's the tail part of the module name."),
540]
541
542VC_SPECIFIC_OPTIONS = [
543    make_option("--use-svn-propset", action="store_true", default=False,
544                help="Use 'svn propset' to set the real date and author of "
545                     "each commit, instead of appending these information to "
546                     "the changelog. This requires some tweaks on the SVN "
547                     "repository to enable revision propchanges."),
548]
549               
550class ExistingProjectError(Exception):
551    "Project seems already tailored"
552
553class UnknownProjectError(Exception):
554    "Project does not exist"
555
556class ProjectNotTailored(Exception):
557    "Not a tailored project"
558   
559def main():
560    """
561    Script entry point.
562
563    Parse the command line options and arguments, and for each
564    specified working copy directory (the current working directory by
565    default) execute the tailorization steps.
566    """
567   
568    from os import getcwd, chdir
569    from os.path import abspath, exists, join
570    from shwrap import ExternalCommand
571    from target import SyncronizableTargetWorkingDir
572    from changes import Changeset
573   
574    parser = OptionParser(usage='%prog [options] [project ...]',
575                          version=__version__,
576                          option_list=GENERAL_OPTIONS)
577   
578    bsoptions = OptionGroup(parser, "Bootstrap options")
579    bsoptions.add_options(BOOTSTRAP_OPTIONS)
580
581    upoptions = OptionGroup(parser, "Update options")
582    upoptions.add_options(UPDATE_OPTIONS)
583
584    vcoptions = OptionGroup(parser, "VC specific options")
585    vcoptions.add_options(VC_SPECIFIC_OPTIONS)
586   
587    parser.add_option_group(bsoptions)
588    parser.add_option_group(upoptions)
589    parser.add_option_group(vcoptions)
590   
591    options, args = parser.parse_args()
592   
593    ExternalCommand.VERBOSE = options.debug
594    if options.encoding:
595        ExternalCommand.FORCE_ENCODING = options.encoding
596
597        # Make printouts be encoded as well. A better solution would be
598        # using the replace mechanism of the encoder, and keep printing
599        # in the user LC_CTYPE/LANG setting.
600       
601        import codecs, sys
602        sys.stdout = codecs.getwriter(options.encoding)(sys.stdout)
603
604    if options.patch_name_format:
605        SyncronizableTargetWorkingDir.PATCH_NAME_FORMAT = options.patch_name_format
606    SyncronizableTargetWorkingDir.REMOVE_FIRST_LOG_LINE = options.remove_first_log_line
607    Changeset.REFILL_MESSAGE = not options.dont_refill_changelogs
608
609    SvnWorkingDir.USE_PROPSET = options.use_svn_propset
610   
611    if options.interactive:
612        interactive(options, args)
613    elif options.configfile:
614        config = TailorConfig(options)
615
616        config(map(abspath, args))
617    else:
618        # Good (?) old way
619       
620        config = None
621       
622        base = getcwd()
623       
624        if len(args) == 0:
625            args.append(base)
626
627        while args:
628            chdir(base)
629
630            proj = args.pop(0)
631            root = abspath(proj)
632
633            if options.bootstrap:
634                if exists(join(root, STATUS_FILENAME)):
635                    raise ExistingProjectError(
636                        "Project %r cannot be bootstrapped twice" % proj)
637
638                if not options.repository:
639                    raise InvocationError('Need a repository to bootstrap %r' %
640                                          proj)
641            else:
642                if not exists(proj):
643                    raise UnknownProjectError("Project %r does not exist" %
644                                              proj)
645
646                if not exists(join(root, STATUS_FILENAME)):
647                    raise UnknownProjectError(
648                        "%r is not a tailorized project" % proj)
649
650            tailored = TailorizedProject(root, options.verbose, config)
651
652            if options.bootstrap:
653                tailored.bootstrap(options.source_kind, options.target_kind,
654                                   options.repository,
655                                   options.module,
656                                   options.revision,
657                                   options.subdir)
658            elif options.update:
659                tailored.update(options.single_commit,
660                                options.concatenate_logs)
Note: See TracBrowser for help on using the repository browser.