source: tailor/vcpx/repository/darcs/source.py @ 1579

Revision 1579, 27.3 KB checked in by lele@…, 5 years ago (diff)

Fix a glitch in the darcs 1 code

Line 
1# -*- mode: python; coding: utf-8 -*-
2# :Progetto: Tailor -- Darcs peculiarities when used as a source
3# :Creato:   lun 10 lug 2006 00:04:59 CEST
4# :Autore:   Lele Gaifax <lele@nautilus.homeip.net>
5# :Licenza:  GNU General Public License
6#
7
8"""
9This module contains the source specific bits of the darcs backend.
10"""
11
12__docformat__ = 'reStructuredText'
13
14import re
15
16from vcpx.changes import ChangesetEntry, Changeset
17from vcpx.shwrap import ExternalCommand, PIPE, STDOUT
18from vcpx.source import UpdatableSourceWorkingDir, ChangesetApplicationFailure, \
19                        GetUpstreamChangesetsFailure
20from vcpx.target import TargetInitializationFailure
21from vcpx.tzinfo import UTC
22
23
24class DarcsChangeset(Changeset):
25    """
26    Fixup darcs idiosyncrasies:
27
28    - collapse "add A; rename A B" into "add B"
29    - collapse "rename A B; remove B" into "remove A"
30    """
31
32    def __init__(self, revision, date, author, log, entries=None, **other):
33        """
34        Initialize a new DarcsChangeset.
35        """
36
37        super(DarcsChangeset, self).__init__(revision, date, author, log, entries=None, **other)
38        self.darcs_hash = other.get('darcs_hash')
39        if entries is not None:
40            for e in entries:
41                self.addEntry(e, revision)
42
43    def __eq__(self, other):
44        return (self.revision == other.revision and
45                self.date == other.date and
46                self.author == other.author and
47                self.darcs_hash == getattr(other, 'darcs_hash', None))
48
49    def __ne__(self, other):
50        return (self.revision <> other.revision or
51                self.date <> other.date or
52                self.author <> other.author or
53                self.darcs_hash <> getattr(other, 'darcs_hash', None))
54
55    def addEntry(self, entry, revision):
56        """
57        Fixup darcs idiosyncrasies:
58
59        - collapse "add A; rename A B" into "add B"
60        - collapse "rename A B; add B" into "add B"
61        - annihilate "add A; remove A"
62        - collapse "rename A B; remove B" into "remove A"
63        - collapse "rename A B; rename B C" into "rename A C"
64        """
65
66        # This should not happen, since the parser feeds us an already built
67        # list of ChangesetEntries, anyway...
68        if not isinstance(entry, ChangesetEntry):
69            return super(DarcsChangeset, self).addEntry(entry, revision)
70
71        # Ok, before adding this entry, check it against already
72        # known: if this is an add, and there's a rename (such as "add
73        # A; rename A B; ") then...
74
75        if entry.action_kind == entry.ADDED:
76            # ... we have to check existings, because of a bug in
77            # darcs: `changes --xml` (as of 1.0.7) emits the changes
78            # in the wrong order, that is, it prefers to start with
79            # renames, *always*, even when they obviously follows the
80            # add of the same entry (even, it should apply this "fix"
81            # by its own).
82            #
83            # So, if there's a rename of this entry there, change that
84            # to an addition instead, and don't insert any other entry
85
86            # darcs hopefully use forward slashes also under win
87            dirname = entry.name+'/'
88
89            for i,e in enumerate(self.entries):
90                if e.action_kind == e.RENAMED:
91                    if e.old_name == entry.name:
92                        # Unfortunately we have to check if the order if
93                        # messed up, in that case we should not do anything.
94                        # Example: mv a a2; mkdir a; mv a2 a/b
95                        skip = False
96                        for j in self.entries:
97                            if j.action_kind == j.RENAMED and j.name.startswith(dirname):
98                                skip = True
99                                break
100                        # Luckily enough (since removes are the first entries
101                        # in the list, that is) by anticipating the add we
102                        # cure also the case below, when addition follows
103                        # edit.
104                        if not skip:
105                            e.action_kind = e.ADDED
106                            e.old_name = None
107                            e.is_directory = entry.is_directory
108                            return e
109
110                    # The "rename A B; add B" into "add B"
111                    if e.name == entry.name:
112                        del self.entries[i]
113
114                # Assert also that add_dir events must preceeds any
115                # add_file and ren_file that have that dir as target,
116                # and that add_file preceeds any edit.
117
118                if ((e.name == entry.name or e.name.startswith(dirname))
119                    or (e.action_kind == e.RENAMED and e.old_name.startswith(dirname))):
120                    self.entries.insert(i, entry)
121                    return entry
122
123        # Likewise, if this is a deletion, and there is a rename of
124        # this entry (such as "rename A B; remove B") then turn the
125        # existing rename into a deletion instead.
126
127        # If instead the removed entry was added by the same patch,
128        # annihilate the two: a bug in darcs (possibly fixed in recent
129        # versions) created patches with ADD+EDIT+REMOVE of a single
130        # file (see tailor ticket #71, or darcs issue185). Too bad
131        # another bug (still present in 1.0.8) hides that and makes
132        # very hard (read: impossible) any workaround on the tailor
133        # side. Luckily I learnt another tiny bit of Haskell and
134        # proposed a fix for that: hopefully the patch will be
135        # accepted by darcs developers. In the meantime, I attached it
136        # to ticket #71: without that, tailor does not have enough
137        # information to do the right thing.
138
139        elif entry.action_kind == entry.DELETED:
140            for i,e in enumerate(self.entries):
141                if e.action_kind == e.RENAMED and e.name == entry.name:
142                    e.action_kind = e.DELETED
143                    e.name = e.old_name
144                    e.old_name = None
145                    e.is_directory = entry.is_directory
146                    return e
147                elif e.action_kind == e.ADDED and e.name == entry.name:
148                    del self.entries[i]
149                    return None
150
151        # The "rename A B; rename B C" to "rename A C" part
152        elif entry.action_kind == entry.RENAMED:
153            # Adjust previous renames
154            olddirname = entry.old_name+'/'
155            for e in self.entries:
156                if e.action_kind == e.RENAMED and e.name.startswith(olddirname):
157                    e.name = entry.name + '/' + e.name[len(olddirname):]
158
159            for e in self.entries:
160                if e.action_kind == e.RENAMED and e.name == entry.old_name:
161                    e.name = entry.name
162                    return e
163
164            # The "rename A B; add B" into "add B", part two
165            for i,e in enumerate(self.entries):
166                if e.action_kind == e.ADDED and e.name == entry.name:
167                    return None
168
169        # Ok, it must be either an edit or a rename: the former goes
170        # obviously to the end, and since the latter, as said, come
171        # in very early, appending is just good.
172        self.entries.append(entry)
173        return entry
174
175
176def changesets_from_darcschanges(changes, unidiff=False, repodir=None,
177                                 chunksize=2**15, replace_badchars=None):
178    """
179    Parse XML output of ``darcs changes``.
180
181    Return a list of ``Changeset`` instances.
182
183    Filters out the (currently incorrect) tag info from
184    changesets_from_darcschanges_unsafe.
185    """
186
187    csets = changesets_from_darcschanges_unsafe(changes, unidiff,
188                                                repodir, chunksize,
189                                                replace_badchars)
190    for cs in csets:
191        yield cs
192
193def changesets_from_darcschanges_unsafe(changes, unidiff=False, repodir=None,
194                                        chunksize=2**15, replace_badchars=None):
195    """
196    Do the real work of parsing the change log, including tags.
197    Warning: the tag information in the changsets returned by this
198    function are only correct if each darcs tag in the repo depends on
199    all of the patches that precede it.  This is not a valid
200    assumption in general--a tag that does not depend on patch P can
201    be pulled in from another darcs repo after P.  We collect the tag
202    info anyway because DarcsWorkingDir._currentTags() can use it
203    safely despite this problem.  Hopefully the problem will
204    eventually be fixed and this function can be renamed
205    changesets_from_darcschanges.
206    """
207    from xml.sax import make_parser
208    from xml.sax.handler import ContentHandler, ErrorHandler
209    from datetime import datetime
210
211    class DarcsXMLChangesHandler(ContentHandler):
212        def __init__(self):
213            self.changesets = []
214            self.current = None
215            self.current_field = []
216            if unidiff and repodir:
217                cmd = ["darcs", "diff", "--unified", "--repodir", repodir,
218                       "--patch", "%(patchname)s"]
219                self.darcsdiff = ExternalCommand(command=cmd)
220            else:
221                self.darcsdiff = None
222
223        def startElement(self, name, attributes):
224            if name == 'patch':
225                self.current = {}
226                self.current['author'] = attributes['author']
227                date = attributes['date']
228                from time import strptime
229                try:
230                    # 20040619130027
231                    timestamp = datetime(*strptime(date, '%Y%m%d%H%M%S')[:6])
232                except ValueError:
233                    # Old darcs patches use the form Sun Oct 20 20:01:05 EDT 2002
234                    timestamp = datetime(*strptime(date[:19] + date[-5:], '%a %b %d %H:%M:%S %Y')[:6])
235
236                timestamp = timestamp.replace(tzinfo=UTC) # not true for the ValueError case, but oh well
237
238                self.current['date'] = timestamp
239                self.current['comment'] = ''
240                self.current['hash'] = attributes['hash']
241                self.current['entries'] = []
242                self.inverted = (attributes['inverted'] == "True")
243            elif name in ['name', 'comment', 'add_file', 'add_directory',
244                          'modify_file', 'remove_file', 'remove_directory']:
245                self.current_field = []
246            elif name == 'move':
247                self.old_name = attributes['from']
248                self.new_name = attributes['to']
249
250        def endElement(self, name):
251            if name == 'patch':
252                cset = DarcsChangeset(self.current['name'],
253                                      self.current['date'],
254                                      self.current['author'],
255                                      self.current['comment'],
256                                      self.current['entries'],
257                                      tags=self.current.get('tags',[]),
258                                      darcs_hash=self.current['hash'])
259                if self.darcsdiff:
260                    cset.unidiff = self.darcsdiff.execute(TZ='UTC',
261                        stdout=PIPE, patchname=cset.revision)[0].read()
262
263                self.changesets.append(cset)
264                self.current = None
265            elif name in ['name', 'comment']:
266                val = ''.join(self.current_field)
267                if val[:4] == 'TAG ':
268                    self.current.setdefault('tags',[]).append(val[4:])
269                self.current[name] = val
270            elif name == 'move':
271                entry = ChangesetEntry(self.new_name)
272                entry.action_kind = entry.RENAMED
273                entry.old_name = self.old_name
274                self.current['entries'].append(entry)
275            elif name in ['add_file', 'add_directory', 'modify_file',
276                          'remove_file', 'remove_directory']:
277                current_field = ''.join(self.current_field).strip()
278                if self.inverted:
279                    # the filenames in file modifications are outdated
280                    # if there are renames
281                    for i in self.current['entries']:
282                        if i.action_kind == i.RENAMED and current_field.startswith(i.old_name):
283                            current_field = current_field.replace(i.old_name, i.name)
284                entry = ChangesetEntry(current_field)
285                entry.action_kind = { 'add_file': entry.ADDED,
286                                      'add_directory': entry.ADDED,
287                                      'modify_file': entry.UPDATED,
288                                      'remove_file': entry.DELETED,
289                                      'remove_directory': entry.DELETED
290                                    }[name]
291                entry.is_directory = name.endswith('directory')
292                self.current['entries'].append(entry)
293
294        def characters(self, data):
295            self.current_field.append(data)
296
297    parser = make_parser()
298    handler = DarcsXMLChangesHandler()
299    parser.setContentHandler(handler)
300    parser.setErrorHandler(ErrorHandler())
301
302    def fixup_badchars(s, map):
303        if not map:
304            return s
305
306        ret = [map.get(c, c) for c in s]
307        return "".join(ret)
308
309    chunk = fixup_badchars(changes.read(chunksize), replace_badchars)
310    while chunk:
311        parser.feed(chunk)
312        for cs in handler.changesets:
313            yield cs
314        handler.changesets = []
315        chunk = fixup_badchars(changes.read(chunksize), replace_badchars)
316    parser.close()
317    for cs in handler.changesets:
318        yield cs
319
320
321class DarcsSourceWorkingDir(UpdatableSourceWorkingDir):
322    """
323    A source working directory under ``darcs``.
324    """
325
326    is_hash_rx = re.compile('[0-9a-f]{14}-[0-9a-f]{5}-[0-9a-f]{40}\.gz')
327
328    def _getUpstreamChangesets(self, sincerev):
329        """
330        Do the actual work of fetching the upstream changeset.
331        """
332
333        # Use the newer pull --xml-output, if possible
334        use_xml = False
335        if self.repository.darcs_version.startswith('2'):
336            cmd = self.repository.command("pull", "--dry-run", "--xml-output")
337            pull = ExternalCommand(cwd=self.repository.basedir, command=cmd)
338            output = pull.execute(self.repository.repository,
339                                  stdout=PIPE, stderr=STDOUT, TZ='UTC0')[0]
340            # pull --xml-output was introduced *after* 2.0.0
341            if pull.exit_status:
342                errormsg = output.read()
343                if "unrecognized option `--xml-output'" in errormsg:
344                    self.log.warning('Using darcs 1.0 non-XML parser: it may fail '
345                                     'on patches recorded before november 2003! '
346                                     'I would suggest of upgrading to latest darcs 2.0 '
347                                     '(later than 2.0+233).')
348                    # No way, fall back to old behaviour, that will possibly fail,
349                    # on patches recorded before 2003-11-01... :-|
350                else:
351                    raise GetUpstreamChangesetsFailure(
352                        "%s returned status %d saying\n%s" %
353                        (str(pull), pull.exit_status, errormsg))
354            else:
355                use_xml = True
356
357        if not use_xml:
358            cmd = self.repository.command("pull", "--dry-run")
359            pull = ExternalCommand(cwd=self.repository.basedir, command=cmd)
360            output = pull.execute(self.repository.repository,
361                                  stdout=PIPE, stderr=STDOUT, TZ='UTC0')[0]
362
363            if pull.exit_status:
364                raise GetUpstreamChangesetsFailure(
365                    "%s returned status %d saying\n%s" %
366                    (str(pull), pull.exit_status, output.read()))
367
368            return self._parseDarcsPull(output)
369        else:
370            # Skip initial verbosity, as well as the one at end
371            from cStringIO import StringIO
372
373            output.readline() # Would pull from "/home/lele/wip/darcs-2.0"...
374            output.readline() # Would pull the following changes:
375            xml = StringIO(''.join(output.readlines()[:-2]))
376            xml.seek(0)
377            badchars = self.repository.replace_badchars
378
379            return changesets_from_darcschanges(xml, replace_badchars=badchars)
380
381    def _parseDarcsPull(self, output):
382        """Process 'darcs pull' output to Changesets.
383        """
384        from datetime import datetime
385        from time import strptime
386        from sha import new
387
388        l = output.readline()
389        while l and not (l.startswith('Would pull the following changes:') or
390                         l == 'No remote changes to pull in!\n'):
391            l = output.readline()
392
393        if l <> 'No remote changes to pull in!\n':
394            ## Sat Jul 17 01:22:08 CEST 2004  lele@nautilus
395            ##   * Refix _getUpstreamChangesets for darcs
396
397            fsep = re.compile('[ :]+')
398            l = output.readline()
399            while not l.startswith('Making no changes:  this is a dry run.'):
400                # Assume it's a line like
401                #    Sun Jan  2 00:24:04 UTC 2005  lele@nautilus.homeip.net
402                # Use a regular expression matching multiple spaces or colons
403                # to split it, and use the first 7 fields to build up a datetime.
404                pieces = fsep.split(l.rstrip(), 8)
405                assert len(pieces)>=7, "Cannot parse %r as a patch timestamp" % l
406                date = ' '.join(pieces[:8])
407                try:
408                    author = pieces[8]
409                except IndexError, s:
410                    # darcs allows patches with empty author
411                    author = ""
412                y,m,d,hh,mm,ss,d1,d2,d3 = strptime(date, "%a %b %d %H %M %S %Z %Y")
413                date = datetime(y,m,d,hh,mm,ss,0,UTC)
414                l = output.readline().rstrip()
415                assert (l.startswith('  *') or
416                        l.startswith('  UNDO:') or
417                        l.startswith('  tagged')), \
418                        "Got %r but expected the start of the log" % l
419
420                if l.startswith('  *'):
421                    name = l[4:]
422                else:
423                    name = l[2:]
424
425                changelog = []
426                l = output.readline()
427                while l.startswith('  '):
428                    changelog.append(l[2:-1])
429                    l = output.readline()
430
431                cset = DarcsChangeset(name, date, author, '\n'.join(changelog))
432                compactdate = date.strftime("%Y%m%d%H%M%S")
433                if name.startswith('UNDO: '):
434                    name = name[6:]
435                    inverted = 't'
436                else:
437                    inverted = 'f'
438
439                if name.startswith('tagged '):
440                    name = name[7:]
441                    if cset.tags is None:
442                        cset.tags = [name]
443                    else:
444                        cset.tags.append(name)
445                    name = "TAG " + name
446
447                phash = new()
448                phash.update(name)
449                phash.update(author)
450                phash.update(compactdate)
451                phash.update(''.join(changelog))
452                phash.update(inverted)
453                cset.darcs_hash = '%s-%s-%s.gz' % (compactdate,
454                                                   new(author).hexdigest()[:5],
455                                                   phash.hexdigest())
456
457
458                yield cset
459
460                while not l.strip():
461                    l = output.readline()
462
463    def _applyChangeset(self, changeset):
464        """
465        Do the actual work of applying the changeset to the working copy.
466        """
467
468        needspatchesopt = False
469        if hasattr(changeset, 'darcs_hash'):
470            selector = '--match'
471            revtag = 'hash ' + changeset.darcs_hash
472        elif changeset.revision.startswith('tagged '):
473            selector = '--tag'
474            revtag = changeset.revision[7:]
475        else:
476            selector = '--match'
477            revtag = 'date "%s" && author "%s"' % (
478                changeset.date.strftime("%Y%m%d%H%M%S"),
479                changeset.author)
480            # The 'exact' matcher doesn't groke double quotes:
481            # """currently there is no provision for escaping a double
482            # quote, so you have to choose between matching double
483            # quotes and matching spaces"""
484            if not '"' in changeset.revision:
485                revtag += ' && exact "%s"' % changeset.revision.replace('%', '%%')
486            else:
487                needspatchesopt = True
488
489        cmd = self.repository.command("pull", "--all", "--quiet",
490                                      selector, revtag)
491
492        if needspatchesopt:
493            cmd.extend(['--patches', re.escape(changeset.revision)])
494
495        pull = ExternalCommand(cwd=self.repository.basedir, command=cmd)
496        output = pull.execute(stdout=PIPE, stderr=STDOUT, input='y')[0]
497
498        if pull.exit_status:
499            raise ChangesetApplicationFailure(
500                "%s returned status %d saying\n%s" %
501                (str(pull), pull.exit_status, output.read()))
502
503        conflicts = []
504        line = output.readline()
505        while line:
506            if line.startswith('We have conflicts in the following files:'):
507                files = output.readline()[:-1].split(' ')
508                self.log.warning("Conflict after 'darcs pull': %s",
509                                 ' '.join(files))
510                conflicts.extend(files)
511            line = output.readline()
512
513        # Complete the changeset with its entries
514
515        cmd = self.repository.command("changes", selector, revtag,
516                                      "--xml-output", "--summ")
517        changes = ExternalCommand(cwd=self.repository.basedir, command=cmd)
518        last = changesets_from_darcschanges(changes.execute(stdout=PIPE)[0],
519                                            replace_badchars=self.repository.replace_badchars)
520        try:
521            entries = last.next().entries
522        except StopIteration:
523            entries = None
524
525        if entries:
526            for e in entries:
527                changeset.addEntry(e, changeset.revision)
528
529        return conflicts
530
531    def _handleConflict(self, changeset, conflicts, conflict):
532        """
533        Handle the conflict raised by the application of the upstream changeset.
534
535        Override parent behaviour: with darcs, we need to execute a revert
536        on the conflicted files, **trashing** local changes, but there should
537        be none of them in tailor context.
538        """
539
540        from os import walk, unlink
541        from os.path import join
542        from re import compile
543
544        self.log.info("Reverting changes to %s, to solve the conflict",
545                      ' '.join(conflict))
546        cmd = self.repository.command("revert", "--all")
547        revert = ExternalCommand(cwd=self.repository.basedir, command=cmd)
548        revert.execute(conflict, input="\n")
549
550        # Remove also the backups made by darcs
551        bckre = compile('-darcs-backup[0-9]+$')
552        for root, dirs, files in walk(self.repository.basedir):
553            backups = [f for f in files if bckre.search(f)]
554            for bck in backups:
555                self.log.debug("Removing backup file %r in %r", bck, root)
556                unlink(join(root, bck))
557
558    def _checkoutUpstreamRevision(self, revision):
559        """
560        Concretely do the checkout of the upstream revision and return
561        the last applied changeset.
562        """
563
564        from os.path import join, exists
565        from os import mkdir
566        from vcpx.source import InvocationError
567
568        if not self.repository.repository:
569            raise InvocationError("Must specify a the darcs source repository")
570
571        if revision == 'INITIAL' or self.is_hash_rx.match(revision):
572            initial = True
573
574            if revision == 'INITIAL':
575                cmd = self.repository.command("changes", "--xml-output",
576                                              "--repo", self.repository.repository,
577                                               "--reverse")
578                changes = ExternalCommand(command=cmd)
579                output = changes.execute(stdout=PIPE, stderr=STDOUT)[0]
580
581                if changes.exit_status:
582                    raise ChangesetApplicationFailure(
583                        "%s returned status %d saying\n%s" %
584                        (str(changes), changes.exit_status,
585                         output and output.read() or ''))
586
587                csets = changesets_from_darcschanges(output, replace_badchars=self.repository.replace_badchars)
588                changeset = csets.next()
589
590                revision = 'hash %s' % changeset.darcs_hash
591            else:
592                revision = 'hash %s' % revision
593        else:
594            initial = False
595
596        # Darcs 2.0 fails with "darcs get --to-match", see issue885
597        darcs2 = self.repository.darcs_version.startswith('2')
598        if darcs2 or self.repository.subdir == '.' or exists(self.repository.basedir):
599            # This is currently *very* slow, compared to the darcs get
600            # below!
601            if not exists(join(self.repository.basedir, '_darcs')):
602                if not exists(self.repository.basedir):
603                    mkdir(self.repository.basedir)
604
605                cmd = self.repository.command("initialize")
606                init = ExternalCommand(cwd=self.repository.basedir, command=cmd)
607                init.execute()
608
609                if init.exit_status:
610                    raise TargetInitializationFailure(
611                        "%s returned status %s" % (str(init),
612                                                   init.exit_status))
613
614                cmd = self.repository.command("pull", "--all", "--quiet")
615                if revision and revision<>'HEAD':
616                    cmd.extend([initial and "--match" or "--tag", revision])
617                dpull = ExternalCommand(cwd=self.repository.basedir, command=cmd)
618                output = dpull.execute(self.repository.repository,
619                                       stdout=PIPE, stderr=STDOUT)[0]
620
621                if dpull.exit_status:
622                    raise TargetInitializationFailure(
623                        "%s returned status %d saying\n%s" %
624                        (str(dpull), dpull.exit_status, output.read()))
625        else:
626            # Use much faster 'darcs get'
627            cmd = self.repository.command("get", "--quiet")
628            if revision and revision<>'HEAD':
629                cmd.extend([initial and "--to-match" or "--tag", revision])
630            else:
631                cmd.append("--partial")
632            dget = ExternalCommand(command=cmd)
633            output = dget.execute(self.repository.repository, self.repository.basedir,
634                                  stdout=PIPE, stderr=STDOUT)[0]
635
636            if dget.exit_status:
637                raise TargetInitializationFailure(
638                    "%s returned status %d saying\n%s" %
639                    (str(dget), dget.exit_status, output.read()))
640
641        cmd = self.repository.command("changes", "--last", "1",
642                                      "--xml-output")
643        changes = ExternalCommand(cwd=self.repository.basedir, command=cmd)
644        output = changes.execute(stdout=PIPE, stderr=STDOUT)[0]
645
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        last = changesets_from_darcschanges(output, replace_badchars=self.repository.replace_badchars)
652
653        return last.next()
Note: See TracBrowser for help on using the repository browser.