source: tailor/vcpx/svn.py @ 813

Revision 813, 19.7 KB checked in by lele@…, 8 years ago (diff)

Ignore empty lines that 'svn commit' may print

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                        if mv_or_cp.has_key(e.name):
113                            mv_or_cp[e.name].action_kind = e.RENAMED
114                        e.action_kind = e.ADDED
115                        entries.append(e)
116                    else:
117                        entries.append(e)
118
119                svndate = self.current['date']
120                # 2004-04-16T17:12:48.000000Z
121                y,m,d = map(int, svndate[:10].split('-'))
122                hh,mm,ss = map(int, svndate[11:19].split(':'))
123                ms = int(svndate[20:-1])
124                timestamp = datetime(y, m, d, hh, mm, ss, ms)
125
126                changeset = Changeset(self.current['revision'],
127                                      timestamp,
128                                      self.current.get('author'),
129                                      self.current['msg'],
130                                      entries)
131                self.changesets.append(changeset)
132                self.current = None
133            elif name in ['author', 'date', 'msg']:
134                self.current[name] = ''.join(self.current_field)
135            elif name == 'path':
136                path = ''.join(self.current_field)
137                entrypath = get_entry_from_path(path)
138                if entrypath:
139                    entry = ChangesetEntry(entrypath)
140
141                    if type(self.current_path_action) == type( () ):
142                        old = get_entry_from_path(self.current_path_action[1])
143                        if old:
144                            entry.action_kind = self.ACTIONSMAP[self.current_path_action[0]]
145                            entry.old_name = old
146                            self.renamed[entry.old_name] = True
147                        else:
148                            entry.action_kind = entry.ADDED
149                    else:
150                        entry.action_kind = self.ACTIONSMAP[self.current_path_action]
151
152                    self.current['entries'].append(entry)
153
154
155        def characters(self, data):
156            self.current_field.append(data)
157
158
159    # Apparently some (SVN repo contains)/(SVN server dumps) some characters that
160    # are illegal in an XML stream. This was the case with Twisted Matrix master
161    # repository. To be safe, we replace all of them with a question mark.
162
163    allbadchars = "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0B\x0C\x0E\x0F\x10\x11" \
164                  "\x12\x13\x14\x15\x16\x17\x18\x19\x1A\x1B\x1C\x1D\x1E\x1F\x7f"
165    tt = maketrans(allbadchars, "?"*len(allbadchars))
166    handler = SvnXMLLogHandler()
167    parseString(log.read().translate(tt), handler)
168    return handler.changesets
169
170
171class SvnWorkingDir(UpdatableSourceWorkingDir, SyncronizableTargetWorkingDir):
172
173    ## UpdatableSourceWorkingDir
174
175    def _getUpstreamChangesets(self, sincerev=None):
176        if sincerev:
177            sincerev = int(sincerev)
178        else:
179            sincerev = 0
180
181        cmd = self.repository.command("log", "--verbose", "--xml",
182                                      "--revision", "%d:HEAD" % (sincerev+1))
183        svnlog = ExternalCommand(cwd=self.basedir, command=cmd)
184        log = svnlog.execute('.', stdout=PIPE, TZ='UTC')[0]
185
186        if svnlog.exit_status:
187            return []
188
189        return changesets_from_svnlog(log,
190                                      self.repository.repository,
191                                      self.repository.module)
192
193    def _applyChangeset(self, changeset):
194        from time import sleep
195
196        cmd = self.repository.command("update",
197                                      "--revision", changeset.revision, ".")
198        svnup = ExternalCommand(cwd=self.basedir, command=cmd)
199
200        retry = 0
201        while True:
202            out = svnup.execute(stdout=PIPE)[0]
203
204            if svnup.exit_status == 1:
205                retry += 1
206                if retry>3:
207                    break
208                delay = 2**retry
209                self.log_info("%s returned status %s, "
210                              "retrying in %d seconds..." %
211                              (str(svnup), svnup.exit_status, delay))
212                sleep(delay)
213            else:
214                break
215
216        if svnup.exit_status:
217            raise ChangesetApplicationFailure(
218                "%s returned status %s" % (str(svnup), svnup.exit_status))
219
220        self.log_info("%s updated to %s" % (
221            ','.join([e.name for e in changeset.entries]),
222            changeset.revision))
223
224        result = []
225        for line in out:
226            if len(line)>2 and line[0] == 'C' and line[1] == ' ':
227                self.log_info("Conflict after 'svn update': '%s'" % line)
228                result.append(line[2:-1])
229
230        return result
231
232    def _checkoutUpstreamRevision(self, revision):
233        """
234        Concretely do the checkout of the upstream revision.
235        """
236
237        from os.path import join, exists
238
239        if revision == 'INITIAL':
240            initial = True
241            cmd = self.repository.command("log", "--verbose", "--xml",
242                                          "--limit", "1",
243                                          "--revision", "1:HEAD")
244            svnlog = ExternalCommand(command=cmd)
245            output = svnlog.execute("%s%s" % (self.repository.repository,
246                                              self.repository.module),
247                                    stdout=PIPE)[0]
248
249            if svnlog.exit_status:
250                raise TargetInitializationFailure(
251                    "%s returned status %d saying \"%s\"" %
252                    (str(output), changes.exit_status, output.read()))
253
254            csets = changesets_from_svnlog(output,
255                                           self.repository.repository,
256                                           self.repository.module)
257            revision = csets[0].revision
258        else:
259            initial = False
260
261        if not exists(join(self.basedir, '.svn')):
262            self.log_info("checking out a working copy")
263            cmd = self.repository.command("co", "--quiet",
264                                          "--revision", revision)
265            svnco = ExternalCommand(command=cmd)
266            svnco.execute("%s%s" % (self.repository.repository,
267                                    self.repository.module), self.basedir)
268            if svnco.exit_status:
269                raise TargetInitializationFailure(
270                    "%s returned status %s" % (str(svnco), svnco.exit_status))
271        else:
272            self.log_info("%s already exists, assuming it's a svn working dir" % self.basedir)
273
274        if not initial:
275            cmd = self.repository.command("log", "--verbose", "--xml",
276                                          "--revision", revision=='HEAD' and 'COMMITTED' or revision)
277            svnlog = ExternalCommand(cwd=self.basedir, command=cmd)
278            output = svnlog.execute(stdout=PIPE)[0]
279
280            if svnlog.exit_status:
281                raise TargetInitializationFailure(
282                    "%s returned status %d saying \"%s\"" %
283                    (str(changes), changes.exit_status, output.read()))
284
285            csets = changesets_from_svnlog(output,
286                                           self.repository.repository,
287                                           self.repository.module)
288
289        last = csets[0]
290
291        self.log_info("working copy up to svn revision %s" % last.revision)
292
293        return last
294
295    ## SyncronizableTargetWorkingDir
296
297    def _addPathnames(self, names):
298        """
299        Add some new filesystem objects.
300        """
301
302        cmd = self.repository.command("add", "--quiet", "--no-auto-props",
303                                      "--non-recursive")
304        ExternalCommand(cwd=self.basedir, command=cmd).execute(names)
305
306    def _getCommitEntries(self, changeset):
307        """
308        Extract the names of the entries for the commit phase.  Since SVN
309        handles "rename" operations as "remove+add", both entries must be
310        committed.
311        """
312
313        entries = SyncronizableTargetWorkingDir._getCommitEntries(self,
314                                                                  changeset)
315        entries.extend([e.old_name for e in changeset.renamedEntries()])
316
317        return entries
318
319    def _commit(self, date, author, patchname, changelog=None, entries=None):
320        """
321        Commit the changeset.
322        """
323
324        from sys import getdefaultencoding
325
326        encoding = ExternalCommand.FORCE_ENCODING or getdefaultencoding()
327
328        logmessage = []
329        if patchname:
330            logmessage.append(patchname.encode(encoding))
331        if changelog:
332            logmessage.append(changelog.encode(encoding))
333
334        # If we cannot use propset, fall back to old behaviour of
335        # appending these info to the changelog
336
337        if not self.USE_PROPSET:
338            logmessage.append('')
339            logmessage.append('Original author: %s' % author.encode(encoding))
340            logmessage.append('Date: %s' % date)
341
342        rontf = ReopenableNamedTemporaryFile('svn', 'tailor')
343        log = open(rontf.name, "w")
344        log.write('\n'.join(logmessage))
345        log.close()
346
347        cmd = self.repository.command("commit", "--file", rontf.name)
348        commit = ExternalCommand(cwd=self.basedir, command=cmd)
349
350        if not entries:
351            entries = ['.']
352
353        out = commit.execute(entries, stdout=PIPE, LANG='C')[0]
354
355        if commit.exit_status:
356            raise ChangesetApplicationFailure("%s returned status %d" %
357                                              (str(commit), commit.exit_status))
358        line = out.readline()
359        if not line:
360            # svn did not find anything to commit
361            return
362
363        while line and not line.startswith('Committed revision '):
364            if line <> '\n' and not line.startswith('Sending ') and \
365               not line.startswith('Transmitting file data ') and \
366               not line.startswith('Adding ') and \
367               not line.startswith('Deleting '):
368                break
369            line = out.readline()
370
371        if not line.startswith('Committed revision '):
372            out.seek(0)
373            raise ChangesetApplicationFailure("%s wrote unexpected line %r. "
374                                              "This the whole output:\n%s" %
375                                              (str(commit), line, out.read()))
376        revision = line[19:-2]
377
378        if self.USE_PROPSET:
379            cmd = self.repository.command("propset", "%(propname)s",
380                                          "--quiet", "--revprop",
381                                          "--revision", revision)
382            propset = ExternalCommand(cwd=self.basedir, command=cmd)
383
384            propset.execute(date.isoformat()+".000000Z", propname='svn:date')
385            propset.execute(author, propname='svn:author')
386
387        cmd = self.repository.command("update", "--quiet",
388                                      "--revision", revision)
389        ExternalCommand(cwd=self.basedir, command=cmd).execute()
390
391    def _removePathnames(self, names):
392        """
393        Remove some filesystem objects.
394        """
395
396        cmd = self.repository.command("remove", "--quiet", "--force")
397        remove = ExternalCommand(cwd=self.basedir, command=cmd)
398        remove.execute(names)
399
400    def _renamePathname(self, oldname, newname):
401        """
402        Rename a filesystem object.
403        """
404
405        cmd = self.repository.command("mv", "--quiet")
406        move = ExternalCommand(cwd=self.basedir, command=cmd)
407        move.execute(oldname, newname)
408        if move.exit_status:
409            # Subversion does not seem to allow
410            #   $ mv a.txt b.txt
411            #   $ svn mv a.txt b.txt
412            # Here we are in this situation, since upstream VCS already
413            # moved the item. OTOH, svn really treats "mv" as "cp+rm",
414            # so we do the same here
415            self._removePathnames([oldname])
416            self._addPathnames([newname])
417
418    def __createRepository(self, target_repository, target_module):
419        """
420        Create a local repository.
421        """
422
423        from os.path import join
424        from sys import platform
425
426        assert target_repository.startswith('file:///')
427        repodir = target_repository[7:]
428        cmd = self.repository.command("create", "--fs-type", "fsfs",
429                                      svnadmin=True)
430        svnadmin = ExternalCommand(command=cmd)
431        svnadmin.execute(repodir)
432
433        if svnadmin.exit_status:
434            raise TargetInitializationFailure("Was not able to create a 'fsfs' "
435                                              "svn repository at %r" %
436                                              target_repository)
437        if self.USE_PROPSET:
438            hookname = join(repodir, 'hooks', 'pre-revprop-change')
439            if platform == 'win32':
440                hookname += '.bat'
441            prehook = open(hookname, 'wU')
442            if platform <> 'win32':
443                prehook.write('#!/bin/sh\n')
444            prehook.write('exit 0\n')
445            prehook.close()
446            if platform <> 'win32':
447                from os import chmod
448                chmod(hookname, 0755)
449
450        if target_module and target_module <> '/':
451            cmd = self.repository.command("mkdir", "-m",
452                                          "This directory will host the "
453                                          "upstream sources")
454            svnmkdir = ExternalCommand(command=cmd)
455            svnmkdir.execute(target_repository + target_module)
456            if svnmkdir.exit_status:
457                raise TargetInitializationFailure("Was not able to create the "
458                                                  "module %r, maybe more than "
459                                                  "one level directory?" %
460                                                  target_module)
461
462    def _prepareTargetRepository(self):
463        """
464        Check for target repository existence, eventually create it.
465        """
466
467        if not self.repository.repository:
468            return
469
470        # Verify the existence of repository by listing its root
471        cmd = self.repository.command("ls")
472        svnls = ExternalCommand(command=cmd)
473        svnls.execute(self.repository.repository)
474
475        if svnls.exit_status:
476            if self.repository.repository.startswith('file:///'):
477                self.__createRepository(self.repository.repository,
478                                        self.repository.module)
479            else:
480                raise TargetInitializationFailure("%r does not exist and "
481                                                  "cannot be created since "
482                                                  "it's not a local (file:///) "
483                                                  "repository" %
484                                                  self.repository.repository)
485
486    def _prepareWorkingDirectory(self, source_repo):
487        """
488        Checkout a working copy of the target SVN repository.
489        """
490
491        from os.path import join, exists
492
493        if not self.repository.repository or exists(join(self.basedir, '.svn')):
494            return
495
496        cmd = self.repository.command("co", "--quiet")
497        svnco = ExternalCommand(command=cmd)
498        svnco.execute("%s%s" % (self.repository.repository,
499                                self.repository.module), self.basedir)
500
501    def _initializeWorkingDir(self):
502        """
503        Add the given directory to an already existing svn working tree.
504        """
505
506        from os.path import exists, join
507
508        if not exists(join(self.basedir, '.svn')):
509            raise TargetInitializationFailure("'%s' needs to be an SVN working copy already under SVN" % self.basedir)
510
511        SyncronizableTargetWorkingDir._initializeWorkingDir(self)
Note: See TracBrowser for help on using the repository browser.