source: tailor/vcpx/svn.py @ 820

Revision 820, 19.9 KB checked in by lele@…, 8 years ago (diff)

Clarify and test svn 'R' event

Line 
1# -*- mode: python; coding: utf-8 -*-
2# :Progetto: vcpx -- Subversion details
3# :Creato:   ven 18 giu 2004 15:00:52 CEST
4# :Autore:   Lele Gaifax <lele@nautilus.homeip.net>
5# :Licenza:  GNU General Public License
6#
7
8"""
9This module contains supporting classes for Subversion.
10"""
11
12__docformat__ = 'reStructuredText'
13
14from shwrap import ExternalCommand, PIPE, STDOUT, ReopenableNamedTemporaryFile
15from source import UpdatableSourceWorkingDir, \
16     ChangesetApplicationFailure, GetUpstreamChangesetsFailure
17from target import SyncronizableTargetWorkingDir, TargetInitializationFailure
18
19def changesets_from_svnlog(log, repository, module):
20    from xml.sax import parseString
21    from xml.sax.handler import ContentHandler
22    from changes import ChangesetEntry, Changeset
23    from datetime import datetime
24    from string import maketrans
25
26    def get_entry_from_path(path, module=module):
27        # Given the repository url of this wc, say
28        #   "http://server/plone/CMFPlone/branches/Plone-2_0-branch"
29        # extract the "entry" portion (a relative path) from what
30        # svn log --xml says, ie
31        #   "/CMFPlone/branches/Plone-2_0-branch/tests/PloneTestCase.py"
32        # that is to say "tests/PloneTestCase.py"
33
34        if path.startswith(module):
35            relative = path[len(module):]
36            if relative.startswith('/'):
37                return relative[1:]
38            else:
39                return relative
40
41        # The path is outside our tracked tree...
42        return None
43
44    class SvnXMLLogHandler(ContentHandler):
45        # Map between svn action and tailor's.
46        # NB: 'R', in svn parlance, means REPLACED, something other
47        # system may view as a simpler ADD, taking the following as
48        # the most common idiom::
49        #
50        #   # Rename the old file with a better name
51        #   $ svn mv somefile nicer-name-scheme.py
52        #
53        #   # Be nice with lazy users
54        #   $ echo "exec nicer-name-scheme.py" > somefile
55        #
56        #   # Add the wrapper with the old name
57        #   $ svn add somefile
58        #
59        #   $ svn commit -m "Longer name for somefile"
60
61        ACTIONSMAP = {'R': 'R', # will be ChangesetEntry.ADDED
62                      'M': ChangesetEntry.UPDATED,
63                      'A': ChangesetEntry.ADDED,
64                      'D': ChangesetEntry.DELETED}
65
66        def __init__(self):
67            self.changesets = []
68            self.current = None
69            self.current_field = []
70            self.renamed = {}
71
72        def startElement(self, name, attributes):
73            if name == 'logentry':
74                self.current = {}
75                self.current['revision'] = attributes['revision']
76                self.current['entries'] = []
77            elif name in ['author', 'date', 'msg']:
78                self.current_field = []
79            elif name == 'path':
80                self.current_field = []
81                if attributes.has_key('copyfrom-path'):
82                    self.current_path_action = (
83                        attributes['action'],
84                        attributes['copyfrom-path'],
85                        attributes['copyfrom-rev'])
86                else:
87                    self.current_path_action = attributes['action']
88
89        def endElement(self, name):
90            if name == 'logentry':
91                # Sort the paths to make tests easier
92                self.current['entries'].sort(lambda a,b: cmp(a.name, b.name))
93
94                # Eliminate "useless" entries: SVN does not have atomic
95                # renames, but rather uses a ADD+RM duo.
96                #
97                # So cycle over all entries of this patch, discarding
98                # the deletion of files that were actually renamed, and
99                # at the same time change related entry from ADDED to
100                # RENAMED.
101
102                mv_or_cp = {}
103                for e in self.current['entries']:
104                    if e.action_kind == e.ADDED and e.old_name is not None:
105                        mv_or_cp[e.old_name] = e
106
107                entries = []
108                for e in self.current['entries']:
109                    if e.action_kind==e.DELETED and mv_or_cp.has_key(e.name):
110                        mv_or_cp[e.name].action_kind = e.RENAMED
111                    elif e.action_kind=='R':
112                        # In svn parlance, 'R' means Replaced: a tipical
113                        # scenario is
114                        #   $ svn mv a.txt b.txt
115                        #   $ touch a.txt
116                        #   $ svn add a.txt
117                        if mv_or_cp.has_key(e.name):
118                            mv_or_cp[e.name].action_kind = e.RENAMED
119                        e.action_kind = e.ADDED
120                        entries.append(e)
121                    else:
122                        entries.append(e)
123
124                svndate = self.current['date']
125                # 2004-04-16T17:12:48.000000Z
126                y,m,d = map(int, svndate[:10].split('-'))
127                hh,mm,ss = map(int, svndate[11:19].split(':'))
128                ms = int(svndate[20:-1])
129                timestamp = datetime(y, m, d, hh, mm, ss, ms)
130
131                changeset = Changeset(self.current['revision'],
132                                      timestamp,
133                                      self.current.get('author'),
134                                      self.current['msg'],
135                                      entries)
136                self.changesets.append(changeset)
137                self.current = None
138            elif name in ['author', 'date', 'msg']:
139                self.current[name] = ''.join(self.current_field)
140            elif name == 'path':
141                path = ''.join(self.current_field)
142                entrypath = get_entry_from_path(path)
143                if entrypath:
144                    entry = ChangesetEntry(entrypath)
145
146                    if type(self.current_path_action) == type( () ):
147                        old = get_entry_from_path(self.current_path_action[1])
148                        if old:
149                            entry.action_kind = self.ACTIONSMAP[self.current_path_action[0]]
150                            entry.old_name = old
151                            self.renamed[entry.old_name] = True
152                        else:
153                            entry.action_kind = entry.ADDED
154                    else:
155                        entry.action_kind = self.ACTIONSMAP[self.current_path_action]
156
157                    self.current['entries'].append(entry)
158
159
160        def characters(self, data):
161            self.current_field.append(data)
162
163
164    # Apparently some (SVN repo contains)/(SVN server dumps) some characters that
165    # are illegal in an XML stream. This was the case with Twisted Matrix master
166    # repository. To be safe, we replace all of them with a question mark.
167
168    allbadchars = "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0B\x0C\x0E\x0F\x10\x11" \
169                  "\x12\x13\x14\x15\x16\x17\x18\x19\x1A\x1B\x1C\x1D\x1E\x1F\x7f"
170    tt = maketrans(allbadchars, "?"*len(allbadchars))
171    handler = SvnXMLLogHandler()
172    parseString(log.read().translate(tt), handler)
173    return handler.changesets
174
175
176class SvnWorkingDir(UpdatableSourceWorkingDir, SyncronizableTargetWorkingDir):
177
178    ## UpdatableSourceWorkingDir
179
180    def _getUpstreamChangesets(self, sincerev=None):
181        if sincerev:
182            sincerev = int(sincerev)
183        else:
184            sincerev = 0
185
186        cmd = self.repository.command("log", "--verbose", "--xml",
187                                      "--revision", "%d:HEAD" % (sincerev+1))
188        svnlog = ExternalCommand(cwd=self.basedir, command=cmd)
189        log = svnlog.execute('.', stdout=PIPE, TZ='UTC')[0]
190
191        if svnlog.exit_status:
192            return []
193
194        return changesets_from_svnlog(log,
195                                      self.repository.repository,
196                                      self.repository.module)
197
198    def _applyChangeset(self, changeset):
199        from time import sleep
200
201        cmd = self.repository.command("update",
202                                      "--revision", changeset.revision, ".")
203        svnup = ExternalCommand(cwd=self.basedir, command=cmd)
204
205        retry = 0
206        while True:
207            out = svnup.execute(stdout=PIPE)[0]
208
209            if svnup.exit_status == 1:
210                retry += 1
211                if retry>3:
212                    break
213                delay = 2**retry
214                self.log_info("%s returned status %s, "
215                              "retrying in %d seconds..." %
216                              (str(svnup), svnup.exit_status, delay))
217                sleep(delay)
218            else:
219                break
220
221        if svnup.exit_status:
222            raise ChangesetApplicationFailure(
223                "%s returned status %s" % (str(svnup), svnup.exit_status))
224
225        self.log_info("%s updated to %s" % (
226            ','.join([e.name for e in changeset.entries]),
227            changeset.revision))
228
229        result = []
230        for line in out:
231            if len(line)>2 and line[0] == 'C' and line[1] == ' ':
232                self.log_info("Conflict after 'svn update': '%s'" % line)
233                result.append(line[2:-1])
234
235        return result
236
237    def _checkoutUpstreamRevision(self, revision):
238        """
239        Concretely do the checkout of the upstream revision.
240        """
241
242        from os.path import join, exists
243
244        if revision == 'INITIAL':
245            initial = True
246            cmd = self.repository.command("log", "--verbose", "--xml",
247                                          "--limit", "1",
248                                          "--revision", "1:HEAD")
249            svnlog = ExternalCommand(command=cmd)
250            output = svnlog.execute("%s%s" % (self.repository.repository,
251                                              self.repository.module),
252                                    stdout=PIPE)[0]
253
254            if svnlog.exit_status:
255                raise TargetInitializationFailure(
256                    "%s returned status %d saying \"%s\"" %
257                    (str(output), changes.exit_status, output.read()))
258
259            csets = changesets_from_svnlog(output,
260                                           self.repository.repository,
261                                           self.repository.module)
262            revision = csets[0].revision
263        else:
264            initial = False
265
266        if not exists(join(self.basedir, '.svn')):
267            self.log_info("checking out a working copy")
268            cmd = self.repository.command("co", "--quiet",
269                                          "--revision", revision)
270            svnco = ExternalCommand(command=cmd)
271            svnco.execute("%s%s" % (self.repository.repository,
272                                    self.repository.module), self.basedir)
273            if svnco.exit_status:
274                raise TargetInitializationFailure(
275                    "%s returned status %s" % (str(svnco), svnco.exit_status))
276        else:
277            self.log_info("%s already exists, assuming it's a svn working dir" % self.basedir)
278
279        if not initial:
280            cmd = self.repository.command("log", "--verbose", "--xml",
281                                          "--revision", revision=='HEAD' and 'COMMITTED' or revision)
282            svnlog = ExternalCommand(cwd=self.basedir, command=cmd)
283            output = svnlog.execute(stdout=PIPE)[0]
284
285            if svnlog.exit_status:
286                raise TargetInitializationFailure(
287                    "%s returned status %d saying \"%s\"" %
288                    (str(changes), changes.exit_status, output.read()))
289
290            csets = changesets_from_svnlog(output,
291                                           self.repository.repository,
292                                           self.repository.module)
293
294        last = csets[0]
295
296        self.log_info("working copy up to svn revision %s" % last.revision)
297
298        return last
299
300    ## SyncronizableTargetWorkingDir
301
302    def _addPathnames(self, names):
303        """
304        Add some new filesystem objects.
305        """
306
307        cmd = self.repository.command("add", "--quiet", "--no-auto-props",
308                                      "--non-recursive")
309        ExternalCommand(cwd=self.basedir, command=cmd).execute(names)
310
311    def _getCommitEntries(self, changeset):
312        """
313        Extract the names of the entries for the commit phase.  Since SVN
314        handles "rename" operations as "remove+add", both entries must be
315        committed.
316        """
317
318        entries = SyncronizableTargetWorkingDir._getCommitEntries(self,
319                                                                  changeset)
320        entries.extend([e.old_name for e in changeset.renamedEntries()])
321
322        return entries
323
324    def _commit(self, date, author, patchname, changelog=None, entries=None):
325        """
326        Commit the changeset.
327        """
328
329        from sys import getdefaultencoding
330
331        encoding = ExternalCommand.FORCE_ENCODING or getdefaultencoding()
332
333        logmessage = []
334        if patchname:
335            logmessage.append(patchname.encode(encoding))
336        if changelog:
337            logmessage.append(changelog.encode(encoding))
338
339        # If we cannot use propset, fall back to old behaviour of
340        # appending these info to the changelog
341
342        if not self.USE_PROPSET:
343            logmessage.append('')
344            logmessage.append('Original author: %s' % author.encode(encoding))
345            logmessage.append('Date: %s' % date)
346
347        rontf = ReopenableNamedTemporaryFile('svn', 'tailor')
348        log = open(rontf.name, "w")
349        log.write('\n'.join(logmessage))
350        log.close()
351
352        cmd = self.repository.command("commit", "--file", rontf.name)
353        commit = ExternalCommand(cwd=self.basedir, command=cmd)
354
355        if not entries:
356            entries = ['.']
357
358        out = commit.execute(entries, stdout=PIPE, LANG='C')[0]
359
360        if commit.exit_status:
361            raise ChangesetApplicationFailure("%s returned status %d" %
362                                              (str(commit), commit.exit_status))
363        line = out.readline()
364        if not line:
365            # svn did not find anything to commit
366            return
367
368        while line and not line.startswith('Committed revision '):
369            if line <> '\n' and not line.startswith('Sending ') and \
370               not line.startswith('Transmitting file data ') and \
371               not line.startswith('Adding ') and \
372               not line.startswith('Deleting '):
373                break
374            line = out.readline()
375
376        if not line.startswith('Committed revision '):
377            out.seek(0)
378            raise ChangesetApplicationFailure("%s wrote unexpected line %r. "
379                                              "This the whole output:\n%s" %
380                                              (str(commit), line, out.read()))
381        revision = line[19:-2]
382
383        if self.USE_PROPSET:
384            cmd = self.repository.command("propset", "%(propname)s",
385                                          "--quiet", "--revprop",
386                                          "--revision", revision)
387            propset = ExternalCommand(cwd=self.basedir, command=cmd)
388
389            propset.execute(date.isoformat()+".000000Z", propname='svn:date')
390            propset.execute(author, propname='svn:author')
391
392        cmd = self.repository.command("update", "--quiet",
393                                      "--revision", revision)
394        ExternalCommand(cwd=self.basedir, command=cmd).execute()
395
396    def _removePathnames(self, names):
397        """
398        Remove some filesystem objects.
399        """
400
401        cmd = self.repository.command("remove", "--quiet", "--force")
402        remove = ExternalCommand(cwd=self.basedir, command=cmd)
403        remove.execute(names)
404
405    def _renamePathname(self, oldname, newname):
406        """
407        Rename a filesystem object.
408        """
409
410        cmd = self.repository.command("mv", "--quiet")
411        move = ExternalCommand(cwd=self.basedir, command=cmd)
412        move.execute(oldname, newname)
413        if move.exit_status:
414            # Subversion does not seem to allow
415            #   $ mv a.txt b.txt
416            #   $ svn mv a.txt b.txt
417            # Here we are in this situation, since upstream VCS already
418            # moved the item. OTOH, svn really treats "mv" as "cp+rm",
419            # so we do the same here
420            self._removePathnames([oldname])
421            self._addPathnames([newname])
422
423    def __createRepository(self, target_repository, target_module):
424        """
425        Create a local repository.
426        """
427
428        from os.path import join
429        from sys import platform
430
431        assert target_repository.startswith('file:///')
432        repodir = target_repository[7:]
433        cmd = self.repository.command("create", "--fs-type", "fsfs",
434                                      svnadmin=True)
435        svnadmin = ExternalCommand(command=cmd)
436        svnadmin.execute(repodir)
437
438        if svnadmin.exit_status:
439            raise TargetInitializationFailure("Was not able to create a 'fsfs' "
440                                              "svn repository at %r" %
441                                              target_repository)
442        if self.USE_PROPSET:
443            hookname = join(repodir, 'hooks', 'pre-revprop-change')
444            if platform == 'win32':
445                hookname += '.bat'
446            prehook = open(hookname, 'wU')
447            if platform <> 'win32':
448                prehook.write('#!/bin/sh\n')
449            prehook.write('exit 0\n')
450            prehook.close()
451            if platform <> 'win32':
452                from os import chmod
453                chmod(hookname, 0755)
454
455        if target_module and target_module <> '/':
456            cmd = self.repository.command("mkdir", "-m",
457                                          "This directory will host the "
458                                          "upstream sources")
459            svnmkdir = ExternalCommand(command=cmd)
460            svnmkdir.execute(target_repository + target_module)
461            if svnmkdir.exit_status:
462                raise TargetInitializationFailure("Was not able to create the "
463                                                  "module %r, maybe more than "
464                                                  "one level directory?" %
465                                                  target_module)
466
467    def _prepareTargetRepository(self):
468        """
469        Check for target repository existence, eventually create it.
470        """
471
472        if not self.repository.repository:
473            return
474
475        # Verify the existence of repository by listing its root
476        cmd = self.repository.command("ls")
477        svnls = ExternalCommand(command=cmd)
478        svnls.execute(self.repository.repository)
479
480        if svnls.exit_status:
481            if self.repository.repository.startswith('file:///'):
482                self.__createRepository(self.repository.repository,
483                                        self.repository.module)
484            else:
485                raise TargetInitializationFailure("%r does not exist and "
486                                                  "cannot be created since "
487                                                  "it's not a local (file:///) "
488                                                  "repository" %
489                                                  self.repository.repository)
490
491    def _prepareWorkingDirectory(self, source_repo):
492        """
493        Checkout a working copy of the target SVN repository.
494        """
495
496        from os.path import join, exists
497
498        if not self.repository.repository or exists(join(self.basedir, '.svn')):
499            return
500
501        cmd = self.repository.command("co", "--quiet")
502        svnco = ExternalCommand(command=cmd)
503        svnco.execute("%s%s" % (self.repository.repository,
504                                self.repository.module), self.basedir)
505
506    def _initializeWorkingDir(self):
507        """
508        Add the given directory to an already existing svn working tree.
509        """
510
511        from os.path import exists, join
512
513        if not exists(join(self.basedir, '.svn')):
514            raise TargetInitializationFailure("'%s' needs to be an SVN working copy already under SVN" % self.basedir)
515
516        SyncronizableTargetWorkingDir._initializeWorkingDir(self)
Note: See TracBrowser for help on using the repository browser.