source: tailor/vcpx/svn.py @ 432

Revision 432, 14.9 KB checked in by lele@…, 8 years ago (diff)

Handle --revision=INITIAL also for Subversion

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, ReopenableNamedTemporaryFile
15from source import UpdatableSourceWorkingDir, \
16     ChangesetApplicationFailure, GetUpstreamChangesetsFailure
17from target import SyncronizableTargetWorkingDir, TargetInitializationFailure
18
19SVN_CMD = "svn"
20
21def changesets_from_svnlog(log, url, repository, module):
22    from xml.sax import parseString
23    from xml.sax.handler import ContentHandler
24    from changes import ChangesetEntry, Changeset
25    from datetime import datetime
26    from string import maketrans
27
28    def get_entry_from_path(path, module=module):
29        # Given the repository url of this wc, say
30        #   "http://server/plone/CMFPlone/branches/Plone-2_0-branch"
31        # extract the "entry" portion (a relative path) from what
32        # svn log --xml says, ie
33        #   "/CMFPlone/branches/Plone-2_0-branch/tests/PloneTestCase.py"
34        # that is to say "tests/PloneTestCase.py"
35
36        if path.startswith(module):
37            relative = path[len(module):]
38            if relative.startswith('/'):
39                return relative[1:]
40            else:
41                return relative
42       
43        # The path is outside our tracked tree...
44        return None
45       
46    class SvnXMLLogHandler(ContentHandler):
47        # Map between svn action and tailor's.
48        # NB: 'R', in svn parlance, means REPLACED, something other
49        # system may view as a simpler ADD, taking the following as
50        # the most common idiom::
51        #
52        #   # Rename the old file with a better name
53        #   $ svn mv somefile nicer-name-scheme.py
54        #
55        #   # Be nice with lazy users
56        #   $ echo "exec nicer-name-scheme.py" > somefile
57        #
58        #   # Add the wrapper with the old name
59        #   $ svn add somefile
60        #
61        #   $ svn commit -m "Longer name for somefile"
62
63        ACTIONSMAP = {'R': 'R', # will be ChangesetEntry.ADDED
64                      'M': ChangesetEntry.UPDATED,
65                      'A': ChangesetEntry.ADDED,
66                      'D': ChangesetEntry.DELETED}
67       
68        def __init__(self):
69            self.changesets = []
70            self.current = None
71            self.current_field = []
72            self.renamed = {}
73           
74        def startElement(self, name, attributes):
75            if name == 'logentry':
76                self.current = {}
77                self.current['revision'] = attributes['revision']
78                self.current['entries'] = []
79            elif name in ['author', 'date', 'msg']:
80                self.current_field = []
81            elif name == 'path':
82                self.current_field = []
83                if attributes.has_key('copyfrom-path'):
84                    self.current_path_action = (
85                        attributes['action'],
86                        attributes['copyfrom-path'],
87                        attributes['copyfrom-rev'])
88                else:
89                    self.current_path_action = attributes['action']
90
91        def endElement(self, name):
92            if name == 'logentry':
93                # Sort the paths to make tests easier
94                self.current['entries'].sort(lambda a,b: cmp(a.name, b.name))
95
96                # Eliminate "useless" entries: SVN does not have atomic
97                # renames, but rather uses a ADD+RM duo.
98                #
99                # So cycle over all entries of this patch, discarding
100                # the deletion of files that were actually renamed, and
101                # at the same time change related entry from ADDED to
102                # RENAMED.
103
104                mv_or_cp = {}
105                for e in self.current['entries']:
106                    if e.action_kind == e.ADDED and e.old_name is not None:
107                        mv_or_cp[e.old_name] = e
108               
109                entries = []
110                for e in self.current['entries']:
111                    if e.action_kind==e.DELETED and mv_or_cp.has_key(e.name):
112                        mv_or_cp[e.name].action_kind = e.RENAMED
113                    elif e.action_kind=='R':
114                        if mv_or_cp.has_key(e.name):
115                            mv_or_cp[e.name].action_kind = e.RENAMED
116                        e.action_kind = e.ADDED
117                        entries.append(e)
118                    else:
119                        entries.append(e)                       
120               
121                svndate = self.current['date']
122                # 2004-04-16T17:12:48.000000Z
123                y,m,d = map(int, svndate[:10].split('-'))
124                hh,mm,ss = map(int, svndate[11:19].split(':'))
125                ms = int(svndate[20:-1])
126                timestamp = datetime(y, m, d, hh, mm, ss, ms)
127               
128                changeset = Changeset(self.current['revision'],
129                                      timestamp,
130                                      self.current['author'],
131                                      self.current['msg'],
132                                      entries)
133                self.changesets.append(changeset)
134                self.current = None
135            elif name in ['author', 'date', 'msg']:
136                self.current[name] = ''.join(self.current_field)
137            elif name == 'path':
138                path = ''.join(self.current_field)
139                entrypath = get_entry_from_path(path)
140                if entrypath:
141                    entry = ChangesetEntry(entrypath)
142
143                    if type(self.current_path_action) == type( () ):
144                        old = get_entry_from_path(self.current_path_action[1])
145                        if old:
146                            entry.action_kind = self.ACTIONSMAP[self.current_path_action[0]]
147                            entry.old_name = old
148                            self.renamed[entry.old_name] = True
149                        else:
150                            entry.action_kind = entry.ADDED
151                    else:
152                        entry.action_kind = self.ACTIONSMAP[self.current_path_action]
153
154                    self.current['entries'].append(entry)
155
156                   
157        def characters(self, data):
158            self.current_field.append(data)
159
160
161    # Apparently some (SVN repo contains)/(SVN server dumps) some characters that
162    # are illegal in an XML stream. This was the case with Twisted Matrix master
163    # repository. To be safe, we replace all of them with a question mark.
164   
165    allbadchars = "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0B\x0C\x0E\x0F\x10\x11" \
166                  "\x12\x13\x14\x15\x16\x17\x18\x19\x1A\x1B\x1C\x1D\x1E\x1F\x7f"
167    tt = maketrans(allbadchars, "?"*len(allbadchars))
168    handler = SvnXMLLogHandler()
169    parseString(log.read().translate(tt), handler)
170    return handler.changesets
171
172
173class SvnWorkingDir(UpdatableSourceWorkingDir, SyncronizableTargetWorkingDir):
174
175    ## UpdatableSourceWorkingDir
176
177    def getUpstreamChangesets(self, root, repository, module, sincerev=None):
178        if sincerev:
179            sincerev = int(sincerev)
180        else:
181            sincerev = 0
182
183        cmd = [SVN_CMD, "log", "--verbose", "--xml",
184               "--revision", "%d:HEAD" % (sincerev+1)]
185        svnlog = ExternalCommand(cwd=root, command=cmd)
186        log = svnlog.execute('.', stdout=PIPE, TZ='UTC')
187       
188        if svnlog.exit_status:
189            return []
190
191        info = self.__getSvnInfo(root)
192
193        return changesets_from_svnlog(log, info['URL'], repository, module)
194
195    def __getSvnInfo(self, root):
196        cmd = [SVN_CMD, "info"]
197        svninfo = ExternalCommand(cwd=root, command=cmd)
198        output = svninfo.execute('.', stdout=PIPE, LANG='')
199
200        if svninfo.exit_status:
201            raise GetUpstreamChangesetsFailure(
202                "%s returned status %d" % (str(svninfo), svninfo.exit_status))
203
204        info = {}
205        for l in output:
206            l = l[:-1]
207            if l:
208                key, value = l.split(':', 1)
209                info[key] = value[1:]
210
211        return info
212
213    def _applyChangeset(self, root, changeset, logger=None):
214        cmd = [SVN_CMD, "update", "--revision", changeset.revision, "."]
215        svnup = ExternalCommand(cwd=root, command=cmd)
216        out = svnup.execute(stdout=PIPE)
217
218        if svnup.exit_status:
219            raise ChangesetApplicationFailure(
220                "%s returned status %s" % (str(svnup), svnup.exit_status))
221           
222        if logger: logger.info("%s updated to %s" % (
223            ','.join([e.name for e in changeset.entries]),
224            changeset.revision))
225       
226        result = []
227        for line in out:
228            if len(line)>2 and line[0] == 'C' and line[1] == ' ':
229                logger.warn("Conflict after 'svn update': '%s'" % line)
230                result.append(line[2:-1])
231           
232        return result
233       
234    def _checkoutUpstreamRevision(self, basedir, repository, module, revision,
235                                  subdir=None, logger=None, **kwargs):
236        """
237        Concretely do the checkout of the upstream revision.
238        """
239       
240        from os.path import join, exists
241
242        if revision == 'INITIAL':
243            initial = True
244            cmd = [SVN_CMD, "log", "--verbose", "--xml", "--limit", "1",
245                   "--revision", "1:HEAD"]
246            svnlog = ExternalCommand(cwd=wdir, command=cmd)
247            output = svnlog.execute("%s%s" % (repository, module), stdout=PIPE)
248
249            if svnlog.exit_status:
250                raise ChangesetApplicationFailure(
251                    "%s returned status %d saying \"%s\"" %
252                    (str(changes), changes.exit_status, output.read()))
253
254            csets = changesets_from_svnlog(output, info['URL'], repository,
255                                           module)
256            revision = escape(csets[0].revision)
257        else:
258            initial = False
259
260        wdir = join(basedir, subdir)
261        if not exists(join(wdir, '.svn')):
262            if logger: logger.info("checking out a working copy")
263            cmd = [SVN_CMD, "co", "--quiet", "--revision", revision]
264            svnco = ExternalCommand(cwd=basedir, command=cmd)
265            svnco.execute("%s%s" % (repository, module), subdir)
266            if svnco.exit_status:
267                raise TargetInitializationFailure(
268                    "%s returned status %s" % (str(svnco), svnco.exit_status))
269        else:
270            if logger: logger.info("%s already exists, assuming it's a svn working dir" % wdir)
271
272        if not initial:
273            info = self.__getSvnInfo(wdir)
274
275            cmd = [SVN_CMD, "log", "--verbose", "--xml", "--revision", revision]
276            svnlog = ExternalCommand(cwd=wdir, command=cmd)
277            output = svnlog.execute(stdout=PIPE)
278
279            if svnlog.exit_status:
280                raise ChangesetApplicationFailure(
281                    "%s returned status %d saying \"%s\"" %
282                    (str(changes), changes.exit_status, output.read()))
283
284            csets = changesets_from_svnlog(output, info['URL'], repository,
285                                           module)
286
287        last = csets[0]
288       
289        if logger: logger.info("working copy up to svn revision %s",
290                               last.revision)
291
292        return last
293   
294    ## SyncronizableTargetWorkingDir
295
296    def _addPathnames(self, root, names):
297        """
298        Add some new filesystem objects.
299        """
300
301        cmd = [SVN_CMD, "add", "--quiet", "--no-auto-props", "--non-recursive"]
302        ExternalCommand(cwd=root, command=cmd).execute(names)
303
304    def _getCommitEntries(self, changeset):
305        """
306        Extract the names of the entries for the commit phase.  Since SVN
307        handles "rename" operations as "remove+add", both entries must be
308        committed.
309        """
310
311        entries = SyncronizableTargetWorkingDir._getCommitEntries(self,
312                                                                  changeset)
313        entries.extend([e.old_name for e in changeset.renamedEntries()])
314
315        return entries
316       
317    def _commit(self,root, date, author, remark, changelog=None, entries=None):
318        """
319        Commit the changeset.
320        """
321
322        from sys import getdefaultencoding
323       
324        encoding = ExternalCommand.FORCE_ENCODING or getdefaultencoding()
325       
326        rontf = ReopenableNamedTemporaryFile('svn', 'tailor')
327        log = open(rontf.name, "w")
328        log.write(remark.encode(encoding))
329        if changelog:
330            log.write('\n')
331            log.write(changelog.encode(encoding))
332
333        # If we cannot use propset, fall back to old behaviour of
334        # appending these info to the changelog
335       
336        if not self.USE_PROPSET:
337            log.write("\n\nOriginal author: %s\nDate: %s\n" % (
338                author.encode(encoding), date))
339
340        log.close()           
341
342        cmd = [SVN_CMD, "commit", "--quiet", "--file", rontf.name]
343        commit = ExternalCommand(cwd=root, command=cmd)
344       
345        if not entries:
346            entries = ['.']
347           
348        commit.execute(entries)
349
350        if self.USE_PROPSET:
351            cmd = [SVN_CMD, "propset", "%(propname)s",
352                   "--quiet", "--revprop", "-rHEAD"]
353            propset = ExternalCommand(cwd=root, command=cmd)
354
355            propset.execute(date.isoformat()+".000000Z", propname='svn:date')
356            propset.execute(author, propname='svn:author')
357
358    def _removePathnames(self, root, names):
359        """
360        Remove some filesystem objects.
361        """
362
363        cmd = [SVN_CMD, "remove", "--quiet", "--force"]
364        remove = ExternalCommand(cwd=root, command=cmd)
365        remove.execute(names)
366
367    def _renamePathname(self, root, oldname, newname):
368        """
369        Rename a filesystem object.
370        """
371
372        cmd = [SVN_CMD, "mv", "--quiet"]
373        move = ExternalCommand(cwd=root, command=cmd)
374        move.execute(oldname, newname)
375        if move.exit_status:
376            # Subversion does not seem to allow
377            #   $ mv a.txt b.txt
378            #   $ svn mv a.txt b.txt
379            # Here we are in this situation, since upstream VCS already
380            # moved the item. OTOH, svn really treats "mv" as "cp+rm",
381            # so we do the same here
382            self._removePathnames(root, [oldname])
383            self._addPathnames(root, [newname])
384
385    def _initializeWorkingDir(self, root, repository, module, subdir):
386        """
387        Add the given directory to an already existing svn working tree.
388        """
389
390        from os.path import exists, join
391
392        if not exists(join(root, '.svn')):
393            raise TargetInitializationFailure("'%s' needs to be an SVN working copy already under SVN" % root)
394
395        SyncronizableTargetWorkingDir._initializeWorkingDir(self, root,
396                                                            repository, module,
397                                                            subdir)
Note: See TracBrowser for help on using the repository browser.