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

Revision 1573, 26.8 KB checked in by lele@…, 5 years ago (diff)

Compare also the darcs_hash attribute

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        cmd = self.repository.command("pull", "--dry-run", "--xml-output")
335        pull = ExternalCommand(cwd=self.repository.basedir, command=cmd)
336        output = pull.execute(self.repository.repository,
337                              stdout=PIPE, stderr=STDOUT, TZ='UTC0')[0]
338        if pull.exit_status:
339            errormsg = output.read()
340            if "unrecognized option `--xml-output'" in errormsg:
341                self.log.warning('Using darcs 1.0 non-XML parser: it may fail '
342                                 'on patches recorded before november 2003! '
343                                 'I would suggest of upgrading to latest darcs 2.0 '
344                                 '(later than 2.0+233).')
345                # No way, fall back to old behaviour, that will possibly fail,
346                # on patches recorded before 2003-11-01... :-|
347                cmd = self.repository.command("pull", "--dry-run")
348                pull = ExternalCommand(cwd=self.repository.basedir, command=cmd)
349                output = pull.execute(self.repository.repository,
350                                      stdout=PIPE, stderr=STDOUT, TZ='UTC0')[0]
351
352                if pull.exit_status:
353                    raise GetUpstreamChangesetsFailure(
354                        "%s returned status %d saying\n%s" %
355                        (str(pull), pull.exit_status, output.read()))
356
357                return self._parseDarcsPull(output)
358            else:
359                raise GetUpstreamChangesetsFailure(
360                    "%s returned status %d saying\n%s" %
361                    (str(pull), pull.exit_status, errormsg))
362
363        else:
364            # Skip initial verbosity, as well as the one at end
365            from cStringIO import StringIO
366
367            output.readline() # Would pull from "/home/lele/wip/darcs-2.0"...
368            output.readline() # Would pull the following changes:
369            xml = StringIO(''.join(output.readlines()[:-2]))
370            xml.seek(0)
371            badchars = self.repository.replace_badchars
372            return changesets_from_darcschanges(xml, replace_badchars=badchars)
373
374    def _parseDarcsPull(self, output):
375        """Process 'darcs pull' output to Changesets.
376        """
377        from datetime import datetime
378        from time import strptime
379        from sha import new
380        from vcpx.changes import Changeset
381
382        l = output.readline()
383        while l and not (l.startswith('Would pull the following changes:') or
384                         l == 'No remote changes to pull in!\n'):
385            l = output.readline()
386
387        if l <> 'No remote changes to pull in!\n':
388            ## Sat Jul 17 01:22:08 CEST 2004  lele@nautilus
389            ##   * Refix _getUpstreamChangesets for darcs
390
391            fsep = re.compile('[ :]+')
392            l = output.readline()
393            while not l.startswith('Making no changes:  this is a dry run.'):
394                # Assume it's a line like
395                #    Sun Jan  2 00:24:04 UTC 2005  lele@nautilus.homeip.net
396                # Use a regular expression matching multiple spaces or colons
397                # to split it, and use the first 7 fields to build up a datetime.
398                pieces = fsep.split(l.rstrip(), 8)
399                assert len(pieces)>=7, "Cannot parse %r as a patch timestamp" % l
400                date = ' '.join(pieces[:8])
401                try:
402                    author = pieces[8]
403                except IndexError, s:
404                    # darcs allows patches with empty author
405                    author = ""
406                y,m,d,hh,mm,ss,d1,d2,d3 = strptime(date, "%a %b %d %H %M %S %Z %Y")
407                date = datetime(y,m,d,hh,mm,ss,0,UTC)
408                l = output.readline().rstrip()
409                assert (l.startswith('  *') or
410                        l.startswith('  UNDO:') or
411                        l.startswith('  tagged')), \
412                        "Got %r but expected the start of the log" % l
413
414                if l.startswith('  *'):
415                    name = l[4:]
416                else:
417                    name = l[2:]
418
419                changelog = []
420                l = output.readline()
421                while l.startswith('  '):
422                    changelog.append(l[2:-1])
423                    l = output.readline()
424
425                cset = Changeset(name, date, author, '\n'.join(changelog))
426                compactdate = date.strftime("%Y%m%d%H%M%S")
427                if name.startswith('UNDO: '):
428                    name = name[6:]
429                    inverted = 't'
430                else:
431                    inverted = 'f'
432
433                if name.startswith('tagged '):
434                    name = name[7:]
435                    if cset.tags is None:
436                        cset.tags = [name]
437                    else:
438                        cset.tags.append(name)
439                    name = "TAG " + name
440
441                phash = new()
442                phash.update(name)
443                phash.update(author)
444                phash.update(compactdate)
445                phash.update(''.join(changelog))
446                phash.update(inverted)
447                cset.darcs_hash = '%s-%s-%s.gz' % (compactdate,
448                                                   new(author).hexdigest()[:5],
449                                                   phash.hexdigest())
450
451
452                yield cset
453
454                while not l.strip():
455                    l = output.readline()
456
457    def _applyChangeset(self, changeset):
458        """
459        Do the actual work of applying the changeset to the working copy.
460        """
461
462        needspatchesopt = False
463        if hasattr(changeset, 'darcs_hash'):
464            selector = '--match'
465            revtag = 'hash ' + changeset.darcs_hash
466        elif changeset.revision.startswith('tagged '):
467            selector = '--tag'
468            revtag = changeset.revision[7:]
469        else:
470            selector = '--match'
471            revtag = 'date "%s" && author "%s"' % (
472                changeset.date.strftime("%Y%m%d%H%M%S"),
473                changeset.author)
474            # The 'exact' matcher doesn't groke double quotes:
475            # """currently there is no provision for escaping a double
476            # quote, so you have to choose between matching double
477            # quotes and matching spaces"""
478            if not '"' in changeset.revision:
479                revtag += ' && exact "%s"' % changeset.revision.replace('%', '%%')
480            else:
481                needspatchesopt = True
482
483        cmd = self.repository.command("pull", "--all", "--quiet",
484                                      selector, revtag)
485
486        if needspatchesopt:
487            cmd.extend(['--patches', re.escape(changeset.revision)])
488
489        pull = ExternalCommand(cwd=self.repository.basedir, command=cmd)
490        output = pull.execute(stdout=PIPE, stderr=STDOUT, input='y')[0]
491
492        if pull.exit_status:
493            raise ChangesetApplicationFailure(
494                "%s returned status %d saying\n%s" %
495                (str(pull), pull.exit_status, output.read()))
496
497        conflicts = []
498        line = output.readline()
499        while line:
500            if line.startswith('We have conflicts in the following files:'):
501                files = output.readline()[:-1].split(' ')
502                self.log.warning("Conflict after 'darcs pull': %s",
503                                 ' '.join(files))
504                conflicts.extend(files)
505            line = output.readline()
506
507        cmd = self.repository.command("changes", selector, revtag,
508                                      "--xml-output", "--summ")
509        changes = ExternalCommand(cwd=self.repository.basedir, command=cmd)
510        last = changesets_from_darcschanges(changes.execute(stdout=PIPE)[0],
511                                            replace_badchars=self.repository.replace_badchars)
512        try:
513            changeset.entries.extend(last.next().entries)
514        except StopIteration:
515            pass
516
517        return conflicts
518
519    def _handleConflict(self, changeset, conflicts, conflict):
520        """
521        Handle the conflict raised by the application of the upstream changeset.
522
523        Override parent behaviour: with darcs, we need to execute a revert
524        on the conflicted files, **trashing** local changes, but there should
525        be none of them in tailor context.
526        """
527
528        from os import walk, unlink
529        from os.path import join
530        from re import compile
531
532        self.log.info("Reverting changes to %s, to solve the conflict",
533                      ' '.join(conflict))
534        cmd = self.repository.command("revert", "--all")
535        revert = ExternalCommand(cwd=self.repository.basedir, command=cmd)
536        revert.execute(conflict, input="\n")
537
538        # Remove also the backups made by darcs
539        bckre = compile('-darcs-backup[0-9]+$')
540        for root, dirs, files in walk(self.repository.basedir):
541            backups = [f for f in files if bckre.search(f)]
542            for bck in backups:
543                self.log.debug("Removing backup file %r in %r", bck, root)
544                unlink(join(root, bck))
545
546    def _checkoutUpstreamRevision(self, revision):
547        """
548        Concretely do the checkout of the upstream revision and return
549        the last applied changeset.
550        """
551
552        from os.path import join, exists
553        from os import mkdir
554        from vcpx.source import InvocationError
555
556        if not self.repository.repository:
557            raise InvocationError("Must specify a the darcs source repository")
558
559        if revision == 'INITIAL' or self.is_hash_rx.match(revision):
560            initial = True
561
562            if revision == 'INITIAL':
563                cmd = self.repository.command("changes", "--xml-output",
564                                              "--repo", self.repository.repository,
565                                               "--reverse")
566                changes = ExternalCommand(command=cmd)
567                output = changes.execute(stdout=PIPE, stderr=STDOUT)[0]
568
569                if changes.exit_status:
570                    raise ChangesetApplicationFailure(
571                        "%s returned status %d saying\n%s" %
572                        (str(changes), changes.exit_status,
573                         output and output.read() or ''))
574
575                csets = changesets_from_darcschanges(output, replace_badchars=self.repository.replace_badchars)
576                changeset = csets.next()
577
578                revision = 'hash %s' % changeset.darcs_hash
579            else:
580                revision = 'hash %s' % revision
581        else:
582            initial = False
583
584        if self.repository.subdir == '.' or exists(self.repository.basedir):
585            # This is currently *very* slow, compared to the darcs get
586            # below!
587            if not exists(join(self.repository.basedir, '_darcs')):
588                if not exists(self.repository.basedir):
589                    mkdir(self.repository.basedir)
590
591                cmd = self.repository.command("initialize")
592                init = ExternalCommand(cwd=self.repository.basedir, command=cmd)
593                init.execute()
594
595                if init.exit_status:
596                    raise TargetInitializationFailure(
597                        "%s returned status %s" % (str(init),
598                                                   init.exit_status))
599
600                cmd = self.repository.command("pull", "--all", "--quiet")
601                if revision and revision<>'HEAD':
602                    cmd.extend([initial and "--match" or "--tag", revision])
603                dpull = ExternalCommand(cwd=self.repository.basedir, command=cmd)
604                output = dpull.execute(self.repository.repository,
605                                       stdout=PIPE, stderr=STDOUT)[0]
606
607                if dpull.exit_status:
608                    raise TargetInitializationFailure(
609                        "%s returned status %d saying\n%s" %
610                        (str(dpull), dpull.exit_status, output.read()))
611        else:
612            # Use much faster 'darcs get'
613            cmd = self.repository.command("get", "--quiet")
614            if revision and revision<>'HEAD':
615                cmd.extend([initial and "--to-match" or "--tag", revision])
616            else:
617                cmd.append("--partial")
618            dget = ExternalCommand(command=cmd)
619            output = dget.execute(self.repository.repository, self.repository.basedir,
620                                  stdout=PIPE, stderr=STDOUT)[0]
621
622            if dget.exit_status:
623                raise TargetInitializationFailure(
624                    "%s returned status %d saying\n%s" %
625                    (str(dget), dget.exit_status, output.read()))
626
627        cmd = self.repository.command("changes", "--last", "1",
628                                      "--xml-output")
629        changes = ExternalCommand(cwd=self.repository.basedir, command=cmd)
630        output = changes.execute(stdout=PIPE, stderr=STDOUT)[0]
631
632        if changes.exit_status:
633            raise ChangesetApplicationFailure(
634                "%s returned status %d saying\n%s" %
635                (str(changes), changes.exit_status, output.read()))
636
637        last = changesets_from_darcschanges(output, replace_badchars=self.repository.replace_badchars)
638
639        return last.next()
Note: See TracBrowser for help on using the repository browser.