source: tailor/vcpx/darcs.py @ 1130

Revision 1130, 25.2 KB checked in by lele@…, 7 years ago (diff)

Recognize patch hash as darcs start-revision
Now it's possible to specify a specific patch hash instead of only a tag
name: tailor will use darcs get --to-match to fetch the first version
at bootstrap time.

Line 
1# -*- mode: python; coding: utf-8 -*-
2# :Progetto: vcpx -- Darcs details
3# :Creato:   ven 18 giu 2004 14:45:28 CEST
4# :Autore:   Lele Gaifax <lele@nautilus.homeip.net>
5# :Licenza:  GNU General Public License
6#
7
8"""
9This module contains supporting classes for the ``darcs`` versioning system.
10"""
11
12__docformat__ = 'reStructuredText'
13
14from shwrap import ExternalCommand, PIPE, STDOUT
15from source import UpdatableSourceWorkingDir, ChangesetApplicationFailure, \
16     GetUpstreamChangesetsFailure
17from target import SynchronizableTargetWorkingDir, TargetInitializationFailure
18from xml.sax import SAXException
19import re
20
21MOTD = """\
22Tailorized equivalent of
23%s
24"""
25
26def changesets_from_darcschanges(changes, unidiff=False, repodir=None,
27                                 chunksize=2**15):
28    """
29    Parse XML output of ``darcs changes``.
30
31    Return a list of ``Changeset`` instances.
32
33    Filters out the (currently incorrect) tag info from
34    changesets_from_darcschanges_unsafe.
35    """
36
37    csets = changesets_from_darcschanges_unsafe(changes, unidiff,
38                                                repodir, chunksize)
39    for cs in csets:
40        cs.tags = None
41        yield cs
42
43def changesets_from_darcschanges_unsafe(changes, unidiff=False, repodir=None,
44                                        chunksize=2**15):
45    """
46    Do the real work of parsing the change log, including tags.
47    Warning: the tag information in the changsets returned by this
48    function are only correct if each darcs tag in the repo depends on
49    all of the patches that precede it.  This is not a valid
50    assumption in general--a tag that does not depend on patch P can
51    be pulled in from another darcs repo after P.  We collect the tag
52    info anyway because DarcsWorkingDir._currentTags() can use it
53    safely despite this problem.  Hopefully the problem will
54    eventually be fixed and this function can be renamed
55    changesets_from_darcschanges.
56    """
57    from xml.sax import make_parser
58    from xml.sax.handler import ContentHandler, ErrorHandler
59    from changes import ChangesetEntry, Changeset
60    from datetime import datetime
61
62    class DarcsXMLChangesHandler(ContentHandler):
63        def __init__(self):
64            self.changesets = []
65            self.current = None
66            self.current_field = []
67            if unidiff and repodir:
68                cmd = ["darcs", "diff", "--unified", "--repodir", repodir,
69                       "--patch", "%(patchname)s"]
70                self.darcsdiff = ExternalCommand(command=cmd)
71            else:
72                self.darcsdiff = None
73
74        def startElement(self, name, attributes):
75            if name == 'patch':
76                self.current = {}
77                self.current['author'] = attributes['author']
78                date = attributes['date']
79                from time import strptime
80                try:
81                    # 20040619130027
82                    timestamp = datetime(*strptime(date, '%Y%m%d%H%M%S')[:6])
83                except ValueError:
84                    # Old darcs patches use the form Sun Oct 20 20:01:05 EDT 2002
85                    timestamp = datetime(*strptime(date[:19] + date[-5:], '%a %b %d %H:%M:%S %Y')[:6])
86                self.current['date'] = timestamp
87                self.current['comment'] = ''
88                self.current['hash'] = attributes['hash']
89                self.current['entries'] = []
90            elif name in ['name', 'comment', 'add_file', 'add_directory',
91                          'modify_file', 'remove_file', 'remove_directory']:
92                self.current_field = []
93            elif name == 'move':
94                self.old_name = attributes['from']
95                self.new_name = attributes['to']
96
97        def endElement(self, name):
98            if name == 'patch':
99                # Sort the paths to make tests easier
100                self.current['entries'].sort(lambda x,y: cmp(x.name, y.name))
101                name = self.current['name']
102                log = self.current['comment']
103                if log:
104                    changelog = name + '\n' + log
105                else:
106                    changelog = name
107                cset = Changeset(name,
108                                 self.current['date'],
109                                 self.current['author'],
110                                 changelog,
111                                 self.current['entries'],
112                                 tags=self.current.get('tags',[]))
113                cset.darcs_hash = self.current['hash']
114                if self.darcsdiff:
115                    cset.unidiff = self.darcsdiff.execute(
116                        stdout=PIPE, patchname=cset.revision)[0].read()
117
118                self.changesets.append(cset)
119                self.current = None
120            elif name in ['name', 'comment']:
121                val = ''.join(self.current_field)
122                if val[:4] == 'TAG ':
123                    self.current.setdefault('tags',[]).append(val[4:])
124                self.current[name] = val
125            elif name == 'move':
126                entry = ChangesetEntry(self.new_name)
127                entry.action_kind = entry.RENAMED
128                entry.old_name = self.old_name
129                self.current['entries'].append(entry)
130            elif name in ['add_file', 'add_directory', 'modify_file',
131                          'remove_file', 'remove_directory']:
132                entry = ChangesetEntry(''.join(self.current_field).strip())
133                entry.action_kind = { 'add_file': entry.ADDED,
134                                      'add_directory': entry.ADDED,
135                                      'modify_file': entry.UPDATED,
136                                      'remove_file': entry.DELETED,
137                                      'remove_directory': entry.DELETED
138                                    }[name]
139
140                self.current['entries'].append(entry)
141
142        def characters(self, data):
143            self.current_field.append(data)
144
145    parser = make_parser()
146    handler = DarcsXMLChangesHandler()
147    parser.setContentHandler(handler)
148    parser.setErrorHandler(ErrorHandler())
149
150    chunk = changes.read(chunksize)
151    while chunk:
152        parser.feed(chunk)
153        for cs in handler.changesets:
154            yield cs
155        handler.changesets = []
156        chunk = changes.read(chunksize)
157    parser.close()
158    for cs in handler.changesets:
159        yield cs
160
161class DarcsWorkingDir(UpdatableSourceWorkingDir,SynchronizableTargetWorkingDir):
162    """
163    A working directory under ``darcs``.
164    """
165
166    is_hash_rx = re.compile('[0-9a-f]{14}-[0-9a-f]{5}-[0-9a-f]{40}\.gz')
167
168    ## UpdatableSourceWorkingDir
169
170    def _getUpstreamChangesets(self, sincerev):
171        """
172        Do the actual work of fetching the upstream changeset.
173        """
174
175        from datetime import datetime
176        from time import strptime
177        from changes import Changeset
178        from sha import new
179
180        cmd = self.repository.command("pull", "--dry-run")
181        pull = ExternalCommand(cwd=self.basedir, command=cmd)
182        output = pull.execute(self.repository.repository,
183                              stdout=PIPE, stderr=STDOUT, TZ='UTC0')[0]
184
185        if pull.exit_status:
186            raise GetUpstreamChangesetsFailure(
187                "%s returned status %d saying\n%s" %
188                (str(pull), pull.exit_status, output.read()))
189
190        l = output.readline()
191        while l and not (l.startswith('Would pull the following changes:') or
192                         l == 'No remote changes to pull in!\n'):
193            l = output.readline()
194
195        if l <> 'No remote changes to pull in!\n':
196            ## Sat Jul 17 01:22:08 CEST 2004  lele@nautilus
197            ##   * Refix _getUpstreamChangesets for darcs
198
199            l = output.readline()
200            while not l.startswith('Making no changes:  this is a dry run.'):
201                # Assume it's a line like
202                #    Sun Jan  2 00:24:04 UTC 2005  lele@nautilus.homeip.net
203                # we used to split on the double space before the email,
204                # but in this case this is wrong. Waiting for xml output,
205                # is it really sane asserting date's length to 28 chars?
206                date = l[:28]
207                author = l[30:-1]
208                y,m,d,hh,mm,ss,d1,d2,d3 = strptime(date, "%a %b %d %H:%M:%S %Z %Y")
209                date = datetime(y,m,d,hh,mm,ss)
210                l = output.readline()
211                assert (l.startswith('  * ') or
212                        l.startswith('  UNDO:') or
213                        l.startswith('  tagged'))
214
215                if l.startswith('  *'):
216                    name = l[4:-1]
217                else:
218                    name = l[2:-1]
219
220                changelog = []
221                l = output.readline()
222                while l.startswith('  '):
223                    changelog.append(l[2:-1])
224                    l = output.readline()
225
226                cset = Changeset(name, date, author, '\n'.join(changelog))
227                compactdate = date.strftime("%Y%m%d%H%M%S")
228                if name.startswith('UNDO: '):
229                    name = name[6:]
230                    inverted = 't'
231                else:
232                    inverted = 'f'
233                phash = new()
234                phash.update(name)
235                phash.update(author)
236                phash.update(compactdate)
237                phash.update(''.join(changelog))
238                phash.update(inverted)
239                cset.darcs_hash = '%s-%s-%s.gz' % (compactdate,
240                                                   new(author).hexdigest()[:5],
241                                                   phash.hexdigest())
242
243                if name.startswith('tagged'):
244                    self.log.warning("Skipping tag %s because I don't "
245                                     "propagate tags from darcs.", name)
246                else:
247                    yield cset
248
249                while not l.strip():
250                    l = output.readline()
251
252    def _applyChangeset(self, changeset):
253        """
254        Do the actual work of applying the changeset to the working copy.
255        """
256
257        needspatchesopt = False
258        if hasattr(changeset, 'darcs_hash'):
259            selector = '--match'
260            revtag = 'hash ' + changeset.darcs_hash
261        elif changeset.revision.startswith('tagged '):
262            selector = '--tag'
263            revtag = changeset.revision[7:]
264        else:
265            selector = '--match'
266            revtag = 'date "%s" && author "%s"' % (
267                changeset.date.strftime("%Y%m%d%H%M%S"),
268                changeset.author)
269            # The 'exact' matcher doesn't groke double quotes:
270            # """currently there is no provision for escaping a double
271            # quote, so you have to choose between matching double
272            # quotes and matching spaces"""
273            if not '"' in changeset.revision:
274                revtag += ' && exact "%s"' % changeset.revision.replace('%', '%%')
275            else:
276                needspatchesopt = True
277
278        cmd = self.repository.command("pull", "--all", "--quiet",
279                                      selector, revtag)
280
281        if needspatchesopt:
282            cmd.extend(['--patches', re.escape(changeset.revision)])
283
284        pull = ExternalCommand(cwd=self.basedir, command=cmd)
285        output = pull.execute(stdout=PIPE, stderr=STDOUT, input='y')[0]
286
287        if pull.exit_status:
288            raise ChangesetApplicationFailure(
289                "%s returned status %d saying\n%s" %
290                (str(pull), pull.exit_status, output.read()))
291
292        conflicts = []
293        line = output.readline()
294        while line:
295            if line.startswith('We have conflicts in the following files:'):
296                files = output.readline()[:-1].split('./')[1:]
297                self.log.warning("Conflict after 'darcs pull': %s",
298                                 ' '.join(files))
299                conflicts.extend(['./' + f for f in files])
300            line = output.readline()
301
302        cmd = self.repository.command("changes", selector, revtag,
303                                      "--xml-output", "--summ")
304        changes = ExternalCommand(cwd=self.basedir, command=cmd)
305        last = changesets_from_darcschanges(changes.execute(stdout=PIPE)[0])
306        try:
307            changeset.entries.extend(last.next().entries)
308        except StopIteration:
309            pass
310
311        return conflicts
312
313    def _handleConflict(self, changeset, conflicts, conflict):
314        """
315        Handle the conflict raised by the application of the upstream changeset.
316
317        Override parent behaviour: with darcs, we need to execute a revert
318        on the conflicted files, **trashing** local changes, but there should
319        be none of them in tailor context.
320        """
321
322        self.log.info("Reverting changes to %s, to solve the conflict",
323                      ' '.join(conflict))
324        cmd = self.repository.command("revert", "--all")
325        revert = ExternalCommand(cwd=self.basedir, command=cmd)
326        revert.execute(conflict)
327
328    def _checkoutUpstreamRevision(self, revision):
329        """
330        Concretely do the checkout of the upstream revision and return
331        the last applied changeset.
332        """
333
334        from os.path import join, exists
335        from os import mkdir
336        from source import InvocationError
337
338        if not self.repository.repository:
339            raise InvocationError("Must specify a the darcs source repository")
340
341        if revision == 'INITIAL' or self.is_hash_rx.match(revision):
342            initial = True
343
344            if revision == 'INITIAL':
345                cmd = self.repository.command("changes", "--xml-output",
346                                              "--repo", self.repository.repository,
347                                               "--reverse")
348                changes = ExternalCommand(command=cmd)
349                output = changes.execute(stdout=PIPE, stderr=STDOUT)[0]
350
351                if changes.exit_status:
352                    raise ChangesetApplicationFailure(
353                        "%s returned status %d saying\n%s" %
354                        (str(changes), changes.exit_status,
355                         output and output.read() or ''))
356
357                csets = changesets_from_darcschanges(output)
358                changeset = csets.next()
359
360                revision = 'hash %s' % changeset.darcs_hash
361            else:
362                revision = 'hash %s' % revision
363        else:
364            initial = False
365
366        if self.repository.subdir == '.' or exists(self.basedir):
367            # This is currently *very* slow, compared to the darcs get
368            # below!
369            if not exists(join(self.basedir, '_darcs')):
370                if not exists(self.basedir):
371                    mkdir(self.basedir)
372
373                cmd = self.repository.command("initialize")
374                init = ExternalCommand(cwd=self.basedir, command=cmd)
375                init.execute()
376
377                if init.exit_status:
378                    raise TargetInitializationFailure(
379                        "%s returned status %s" % (str(init),
380                                                   init.exit_status))
381
382                cmd = self.repository.command("pull", "--all", "--quiet")
383                if revision and revision<>'HEAD':
384                    cmd.extend([initial and "--match" or "--tag", revision])
385                dpull = ExternalCommand(cwd=self.basedir, command=cmd)
386                output = dpull.execute(self.repository.repository,
387                                       stdout=PIPE, stderr=STDOUT)[0]
388
389                if dpull.exit_status:
390                    raise TargetInitializationFailure(
391                        "%s returned status %d saying\n%s" %
392                        (str(dpull), dpull.exit_status, output.read()))
393        else:
394            # Use much faster 'darcs get'
395            cmd = self.repository.command("get", "--quiet")
396            if revision and revision<>'HEAD':
397                cmd.extend([initial and "--to-match" or "--tag", revision])
398            else:
399                cmd.append("--partial")
400            dget = ExternalCommand(command=cmd)
401            output = dget.execute(self.repository.repository, self.basedir,
402                                  stdout=PIPE, stderr=STDOUT)[0]
403
404            if dget.exit_status:
405                raise TargetInitializationFailure(
406                    "%s returned status %d saying\n%s" %
407                    (str(dget), dget.exit_status, output.read()))
408
409        cmd = self.repository.command("changes", "--last", "1",
410                                      "--xml-output")
411        changes = ExternalCommand(cwd=self.basedir, command=cmd)
412        output = changes.execute(stdout=PIPE, stderr=STDOUT)[0]
413
414        if changes.exit_status:
415            raise ChangesetApplicationFailure(
416                "%s returned status %d saying\n%s" %
417                (str(changes), changes.exit_status, output.read()))
418
419        last = changesets_from_darcschanges(output)
420
421        return last.next()
422
423
424    ## SynchronizableTargetWorkingDir
425
426    def _addPathnames(self, names):
427        """
428        Add some new filesystem objects.
429        """
430
431        cmd = self.repository.command("add", "--case-ok", "--not-recursive",
432                                      "--quiet")
433        ExternalCommand(cwd=self.basedir, command=cmd).execute(names)
434
435    def _addSubtree(self, subdir):
436        """
437        Use the --recursive variant of ``darcs add`` to add a subtree.
438        """
439
440        cmd = self.repository.command("add", "--case-ok", "--recursive",
441                                      "--quiet")
442        ExternalCommand(cwd=self.basedir, command=cmd).execute(subdir)
443
444    def _commit(self, date, author, patchname, changelog=None, entries=None):
445        """
446        Commit the changeset.
447        """
448
449        logmessage = []
450
451        logmessage.append(date.strftime('%Y/%m/%d %H:%M:%S UTC'))
452        logmessage.append(author)
453        if patchname:
454            logmessage.append(patchname)
455        if changelog:
456            logmessage.append(changelog)
457        if not patchname and not changelog:
458            logmessage.append('Unnamed patch')
459
460        cmd = self.repository.command("record", "--all", "--pipe")
461        if not entries:
462            entries = ['.']
463
464        record = ExternalCommand(cwd=self.basedir, command=cmd)
465        record.execute(input=self.repository.encode('\n'.join(logmessage)))
466
467        if record.exit_status:
468            raise ChangesetApplicationFailure(
469                "%s returned status %d" % (str(record), record.exit_status))
470
471    def _removePathnames(self, names):
472        """
473        Remove some filesystem object.
474        """
475
476        from os.path import join, exists
477
478        # darcs raises status 512 when it does not find the entry,
479        # removed by source. Since sometime a directory is left there
480        # because it's not empty, darcs fails. So, do an explicit
481        # remove on items that are still there.
482
483        c = ExternalCommand(cwd=self.basedir,
484                            command=self.repository.command("remove"))
485        existing = [n for n in names if exists(join(self.basedir, n))]
486        if existing:
487            c.execute(existing)
488
489    def _renamePathname(self, oldname, newname):
490        """
491        Rename a filesystem object.
492        """
493
494        cmd = self.repository.command("mv")
495        ExternalCommand(cwd=self.basedir, command=cmd).execute(oldname, newname)
496
497    def _prepareTargetRepository(self):
498        """
499        Create the base directory if it doesn't exist, and execute
500        ``darcs initialize`` if needed.
501        """
502
503        from os.path import join, exists
504        from dualwd import IGNORED_METADIRS
505
506        metadir = join(self.basedir, '_darcs')
507        prefsdir = join(metadir, 'prefs')
508        prefsname = join(prefsdir, 'prefs')
509        boringname = join(prefsdir, 'boring')
510        if exists(prefsname):
511            for pref in open(prefsname, 'rU'):
512                if pref:
513                    pname, pvalue = pref.split(' ', 1)
514                    if pname == 'boringfile':
515                        boringname = join(self.basedir, pvalue[:-1])
516
517        if not exists(metadir):
518            cmd = self.repository.command("initialize")
519            init = ExternalCommand(cwd=self.basedir, command=cmd)
520            init.execute()
521
522            if init.exit_status:
523                raise TargetInitializationFailure(
524                    "%s returned status %s" % (str(init), init.exit_status))
525
526            boring = open(boringname, 'rU')
527            ignored = boring.read().rstrip().split('\n')
528            boring.close()
529
530            # Augment the boring file, that contains a regexp per line
531            # with all known VCs metadirs to be skipped.
532            ignored.extend(['(^|/)%s($|/)' % re.escape(md)
533                            for md in IGNORED_METADIRS])
534
535            # Eventually omit our own log...
536            logfile = self.repository.projectref().logfile
537            if logfile.startswith(self.basedir):
538                ignored.append('^%s$' %
539                               re.escape(logfile[len(self.basedir)+1:]))
540
541            # ... and state file
542            sfname = self.repository.projectref().state_file.filename
543            if sfname.startswith(self.basedir):
544                sfrelname = sfname[len(self.basedir)+1:]
545                ignored.append('^%s$' % re.escape(sfrelname))
546                ignored.append('^%s$' % re.escape(sfrelname+'.old'))
547                ignored.append('^%s$' % re.escape(sfrelname+'.journal'))
548
549            boring = open(boringname, 'wU')
550            boring.write('\n'.join(ignored))
551            boring.write('\n')
552            boring.close()
553        else:
554            boring = open(boringname, 'rU')
555            ignored = boring.read().rstrip().split('\n')
556            boring.close()
557
558        # Build a list of compiled regular expressions, that will be
559        # used later to filter the entries.
560        self.__unwanted_entries = [re.compile(rx) for rx in ignored
561                                   if rx and not rx.startswith('#')]
562
563    def _prepareWorkingDirectory(self, source_repo):
564        """
565        Tweak the default settings of the repository.
566        """
567
568        from os.path import join
569
570        motd = open(join(self.basedir, '_darcs/prefs/motd'), 'w')
571        motd.write(MOTD % str(source_repo))
572        motd.close()
573
574    def _adaptEntries(self, changeset):
575        """
576        Filter out boring files.
577        """
578
579        from copy import copy
580
581        adapted = SynchronizableTargetWorkingDir._adaptEntries(self, changeset)
582
583        # If there are no entries or no rules, there's nothing to do
584        if not adapted or not adapted.entries or not self.__unwanted_entries:
585            return adapted
586
587        entries = []
588        skipped = False
589        for e in adapted.entries:
590            skip = False
591            for rx in self.__unwanted_entries:
592                if rx.search(e.name):
593                    skip = True
594                    break
595            if skip:
596                self.log.info('Entry "%s" skipped per boring rules', e.name)
597                skipped = True
598            else:
599                entries.append(e)
600
601        # All entries are gone, don't commit this changeset
602        if not entries:
603            self.log.info('All entries ignored, skipping whole '
604                          'changeset "%s"', changeset.revision)
605            return None
606
607        if skipped:
608            adapted = copy(adapted)
609            adapted.entries = entries
610
611        return adapted
612
613    def _tag(self, tag):
614        """
615        Apply the given tag to the repository, unless it has already
616        been applied to the current state. (If it has been applied to
617        an earlier state, do apply it; the later tag overrides the
618        earlier one.
619        """
620        if tag not in self._currentTags():
621            cmd = self.repository.command("tag", "--author", "Unknown tagger")
622            ExternalCommand(cwd=self.basedir, command=cmd).execute(tag)
623
624    def _currentTags(self):
625        """
626        Return a list of tags that refer to the repository's current
627        state.  Does not consider tags themselves to be part of the
628        state, so if the repo was tagged with T1 and then T2, then
629        both T1 and T2 are considered to refer to the current state,
630        even though 'darcs get --tag=T1' and 'darcs get --tag=T2'
631        would have different results (the latter creates a repo that
632        contains tag T2, but the former does not).
633
634        This function assumes that a tag depends on all patches that
635        precede it in the "darcs changes" list.  This assumption is
636        valid if tags only come into the repository via tailor; if the
637        user applies a tag by hand in the hybrid repository, or pulls
638        in a tag from another darcs repository, then the assumption
639        could be violated and mistagging could result.
640        """
641        cmd = self.repository.command("changes",
642                                      "--from-match", "not name ^TAG",
643                                      "--xml-output", "--reverse")
644        changes =  ExternalCommand(cwd=self.basedir, command=cmd)
645        output = changes.execute(stdout=PIPE, stderr=STDOUT)[0]
646        if changes.exit_status:
647            raise ChangesetApplicationFailure(
648                "%s returned status %d saying\n%s" %
649                (str(changes), changes.exit_status, output.read()))
650
651        tags = []
652        for cs in changesets_from_darcschanges_unsafe(output):
653            for tag in cs.tags:
654                if tag not in tags:
655                    tags.append(tag)
656        return tags
Note: See TracBrowser for help on using the repository browser.