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

Revision 1528, 26.1 KB checked in by lele@…, 5 years ago (diff)

Remove the backups made by darcs on conflicts

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