| 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 | """ |
|---|
| 9 | This module contains supporting classes for Subversion. |
|---|
| 10 | """ |
|---|
| 11 | |
|---|
| 12 | __docformat__ = 'reStructuredText' |
|---|
| 13 | |
|---|
| 14 | from vcpx.changes import ChangesetEntry |
|---|
| 15 | from vcpx.config import ConfigurationError |
|---|
| 16 | from vcpx.repository import Repository |
|---|
| 17 | from vcpx.shwrap import ExternalCommand, PIPE, STDOUT, ReopenableNamedTemporaryFile |
|---|
| 18 | from vcpx.source import UpdatableSourceWorkingDir, ChangesetApplicationFailure |
|---|
| 19 | from vcpx.target import SynchronizableTargetWorkingDir, TargetInitializationFailure, \ |
|---|
| 20 | PostCommitCheckFailure |
|---|
| 21 | from vcpx.tzinfo import UTC |
|---|
| 22 | |
|---|
| 23 | |
|---|
| 24 | class SvnRepository(Repository): |
|---|
| 25 | METADIR = '.svn' |
|---|
| 26 | |
|---|
| 27 | def command(self, *args, **kwargs): |
|---|
| 28 | if kwargs.get('svnadmin', False): |
|---|
| 29 | kwargs['executable'] = self.__svnadmin |
|---|
| 30 | return Repository.command(self, *args, **kwargs) |
|---|
| 31 | |
|---|
| 32 | def _load(self, project): |
|---|
| 33 | Repository._load(self, project) |
|---|
| 34 | cget = project.config.get |
|---|
| 35 | self.EXECUTABLE = cget(self.name, 'svn-command', 'svn') |
|---|
| 36 | self.__svnadmin = cget(self.name, 'svnadmin-command', 'svnadmin') |
|---|
| 37 | self.use_propset = cget(self.name, 'use-propset', False) |
|---|
| 38 | self.propset_date = cget(self.name, 'propset-date', True) |
|---|
| 39 | self.filter_badchars = cget(self.name, 'filter-badchars', False) |
|---|
| 40 | self.use_limit = cget(self.name, 'use-limit', True) |
|---|
| 41 | self.trust_root = cget(self.name, 'trust-root', False) |
|---|
| 42 | self.ignore_externals = cget(self.name, 'ignore-externals', True) |
|---|
| 43 | self.commit_all_files = cget(self.name, 'commit-all-files', True) |
|---|
| 44 | self.tags_path = cget(self.name, 'svn-tags', '/tags') |
|---|
| 45 | self.branches_path = cget(self.name, 'svn-branches', '/branches') |
|---|
| 46 | self._setupTagsDirectory = None |
|---|
| 47 | |
|---|
| 48 | def setupTagsDirectory(self): |
|---|
| 49 | if self._setupTagsDirectory == None: |
|---|
| 50 | self._setupTagsDirectory = False |
|---|
| 51 | if self.module and self.module <> '/': |
|---|
| 52 | |
|---|
| 53 | # Check the existing tags directory |
|---|
| 54 | cmd = self.command("ls") |
|---|
| 55 | svnls = ExternalCommand(command=cmd) |
|---|
| 56 | svnls.execute(self.repository + self.tags_path) |
|---|
| 57 | if svnls.exit_status: |
|---|
| 58 | # create it, if not exist |
|---|
| 59 | cmd = self.command("mkdir", "-m", |
|---|
| 60 | "This directory will host the tags") |
|---|
| 61 | svnmkdir = ExternalCommand(command=cmd) |
|---|
| 62 | svnmkdir.execute(self.repository + self.tags_path) |
|---|
| 63 | if svnmkdir.exit_status: |
|---|
| 64 | raise TargetInitializationFailure( |
|---|
| 65 | "Was not able to create tags directory '%s'" |
|---|
| 66 | % self.tags_path) |
|---|
| 67 | else: |
|---|
| 68 | self.log.debug("Directory '%s' already exists" |
|---|
| 69 | % self.tags_path) |
|---|
| 70 | self._setupTagsDirectory = True |
|---|
| 71 | else: |
|---|
| 72 | self.log.debug("Tags needs module setup other than '/'") |
|---|
| 73 | |
|---|
| 74 | return self._setupTagsDirectory |
|---|
| 75 | |
|---|
| 76 | |
|---|
| 77 | def _validateConfiguration(self): |
|---|
| 78 | from vcpx.config import ConfigurationError |
|---|
| 79 | |
|---|
| 80 | Repository._validateConfiguration(self) |
|---|
| 81 | |
|---|
| 82 | if not self.repository: |
|---|
| 83 | self.log.critical('Missing repository information in %r', self.name) |
|---|
| 84 | raise ConfigurationError("Must specify the root of the " |
|---|
| 85 | "Subversion repository used " |
|---|
| 86 | "as %s with the option " |
|---|
| 87 | "'repository'" % self.which) |
|---|
| 88 | elif self.repository.endswith('/'): |
|---|
| 89 | self.log.debug("Removing final slash from %r in %r", |
|---|
| 90 | self.repository, self.name) |
|---|
| 91 | self.repository = self.repository.rstrip('/') |
|---|
| 92 | |
|---|
| 93 | if not self.module: |
|---|
| 94 | self.log.critical('Missing module information in %r', self.name) |
|---|
| 95 | raise ConfigurationError("Must specify the path within the " |
|---|
| 96 | "Subversion repository as 'module'") |
|---|
| 97 | |
|---|
| 98 | if self.module == '.': |
|---|
| 99 | self.log.warning("Replacing '.' with '/' in module name in %r", |
|---|
| 100 | self.name) |
|---|
| 101 | self.module = '/' |
|---|
| 102 | elif not self.module.startswith('/'): |
|---|
| 103 | self.log.debug("Prepending '/' to module %r in %r", |
|---|
| 104 | self.module, self.name) |
|---|
| 105 | self.module = '/' + self.module |
|---|
| 106 | |
|---|
| 107 | if not self.tags_path.startswith('/'): |
|---|
| 108 | self.log.debug("Prepending '/' to svn-tags %r in %r", |
|---|
| 109 | self.tags_path, self.name) |
|---|
| 110 | self.tags_path = '/' + self.tags_path |
|---|
| 111 | |
|---|
| 112 | if not self.branches_path.startswith('/'): |
|---|
| 113 | self.log.debug("Prepending '/' to svn-branches %r in %r", |
|---|
| 114 | self.branches_path, self.name) |
|---|
| 115 | self.branches_path = '/' + self.branches_path |
|---|
| 116 | |
|---|
| 117 | def create(self): |
|---|
| 118 | """ |
|---|
| 119 | Create a local SVN repository, if it does not exist, and configure it. |
|---|
| 120 | """ |
|---|
| 121 | |
|---|
| 122 | from os.path import join, exists |
|---|
| 123 | from sys import platform |
|---|
| 124 | |
|---|
| 125 | # Verify the existence of repository by listing its root |
|---|
| 126 | cmd = self.command("ls") |
|---|
| 127 | svnls = ExternalCommand(command=cmd) |
|---|
| 128 | svnls.execute(self.repository) |
|---|
| 129 | |
|---|
| 130 | # Create it if it isn't a valid repository |
|---|
| 131 | if svnls.exit_status: |
|---|
| 132 | if not self.repository.startswith('file:///'): |
|---|
| 133 | raise TargetInitializationFailure("%r does not exist and " |
|---|
| 134 | "cannot be created since " |
|---|
| 135 | "it's not a local (file:///) " |
|---|
| 136 | "repository" % |
|---|
| 137 | self.repository) |
|---|
| 138 | |
|---|
| 139 | if platform != 'win32': |
|---|
| 140 | repodir = self.repository[7:] |
|---|
| 141 | else: |
|---|
| 142 | repodir = self.repository[8:] |
|---|
| 143 | cmd = self.command("create", "--fs-type", "fsfs", svnadmin=True) |
|---|
| 144 | svnadmin = ExternalCommand(command=cmd) |
|---|
| 145 | svnadmin.execute(repodir) |
|---|
| 146 | |
|---|
| 147 | if svnadmin.exit_status: |
|---|
| 148 | raise TargetInitializationFailure("Was not able to create a 'fsfs' " |
|---|
| 149 | "svn repository at %r" % |
|---|
| 150 | self.repository) |
|---|
| 151 | if self.use_propset: |
|---|
| 152 | if not self.repository.startswith('file:///'): |
|---|
| 153 | self.log.warning("Repository is remote, cannot verify if it " |
|---|
| 154 | "has the 'pre-revprop-change' hook active, needed " |
|---|
| 155 | "by 'use-propset=True'. Assuming it does...") |
|---|
| 156 | else: |
|---|
| 157 | if platform != 'win32': |
|---|
| 158 | repodir = self.repository[7:] |
|---|
| 159 | else: |
|---|
| 160 | repodir = self.repository[8:] |
|---|
| 161 | hookname = join(repodir, 'hooks', 'pre-revprop-change') |
|---|
| 162 | if platform == 'win32': |
|---|
| 163 | hookname += '.bat' |
|---|
| 164 | if not exists(hookname): |
|---|
| 165 | prehook = open(hookname, 'w') |
|---|
| 166 | if platform <> 'win32': |
|---|
| 167 | prehook.write('#!/bin/sh\n') |
|---|
| 168 | prehook.write('exit 0\n') |
|---|
| 169 | prehook.close() |
|---|
| 170 | if platform <> 'win32': |
|---|
| 171 | from os import chmod |
|---|
| 172 | chmod(hookname, 0755) |
|---|
| 173 | |
|---|
| 174 | if self.module and self.module <> '/': |
|---|
| 175 | cmd = self.command("ls") |
|---|
| 176 | svnls = ExternalCommand(command=cmd) |
|---|
| 177 | svnls.execute(self.repository + self.module) |
|---|
| 178 | if svnls.exit_status: |
|---|
| 179 | |
|---|
| 180 | paths = [] |
|---|
| 181 | |
|---|
| 182 | # Auto detect missing "branches/" |
|---|
| 183 | if self.module.startswith(self.branches_path + '/'): |
|---|
| 184 | path = self.repository + self.branches_path |
|---|
| 185 | cmd = self.command("ls") |
|---|
| 186 | svnls = ExternalCommand(command=cmd) |
|---|
| 187 | svnls.execute(path) |
|---|
| 188 | if svnls.exit_status: |
|---|
| 189 | paths.append(path) |
|---|
| 190 | |
|---|
| 191 | paths.append(self.repository + self.module) |
|---|
| 192 | cmd = self.command("mkdir", "-m", |
|---|
| 193 | "This directory will host the upstream sources") |
|---|
| 194 | svnmkdir = ExternalCommand(command=cmd) |
|---|
| 195 | svnmkdir.execute(paths) |
|---|
| 196 | if svnmkdir.exit_status: |
|---|
| 197 | raise TargetInitializationFailure("Was not able to create the " |
|---|
| 198 | "module %r, maybe more than " |
|---|
| 199 | "one level directory?" % |
|---|
| 200 | self.module) |
|---|
| 201 | |
|---|
| 202 | def changesets_from_svnlog(log, repository, chunksize=2**15): |
|---|
| 203 | from xml.sax import make_parser |
|---|
| 204 | from xml.sax.handler import ContentHandler, ErrorHandler |
|---|
| 205 | from datetime import datetime |
|---|
| 206 | from vcpx.changes import ChangesetEntry, Changeset |
|---|
| 207 | |
|---|
| 208 | def get_entry_from_path(path, module=repository.module): |
|---|
| 209 | # Given the repository url of this wc, say |
|---|
| 210 | # "http://server/plone/CMFPlone/branches/Plone-2_0-branch" |
|---|
| 211 | # extract the "entry" portion (a relative path) from what |
|---|
| 212 | # svn log --xml says, ie |
|---|
| 213 | # "/CMFPlone/branches/Plone-2_0-branch/tests/PloneTestCase.py" |
|---|
| 214 | # that is to say "tests/PloneTestCase.py" |
|---|
| 215 | |
|---|
| 216 | if not module.endswith('/'): |
|---|
| 217 | module = module + '/' |
|---|
| 218 | if path.startswith(module): |
|---|
| 219 | relative = path[len(module):] |
|---|
| 220 | return relative |
|---|
| 221 | |
|---|
| 222 | # The path is outside our tracked tree... |
|---|
| 223 | repository.log.debug('Ignoring "%s" since it is not under "%s"', |
|---|
| 224 | path, module) |
|---|
| 225 | return None |
|---|
| 226 | |
|---|
| 227 | class SvnXMLLogHandler(ContentHandler): |
|---|
| 228 | # Map between svn action and tailor's. |
|---|
| 229 | # NB: 'R', in svn parlance, means REPLACED, something other |
|---|
| 230 | # system may view as a simpler ADD, taking the following as |
|---|
| 231 | # the most common idiom:: |
|---|
| 232 | # |
|---|
| 233 | # # Rename the old file with a better name |
|---|
| 234 | # $ svn mv somefile nicer-name-scheme.py |
|---|
| 235 | # |
|---|
| 236 | # # Be nice with lazy users |
|---|
| 237 | # $ echo "exec nicer-name-scheme.py" > somefile |
|---|
| 238 | # |
|---|
| 239 | # # Add the wrapper with the old name |
|---|
| 240 | # $ svn add somefile |
|---|
| 241 | # |
|---|
| 242 | # $ svn commit -m "Longer name for somefile" |
|---|
| 243 | |
|---|
| 244 | ACTIONSMAP = {'R': 'R', # will be ChangesetEntry.ADDED |
|---|
| 245 | 'M': ChangesetEntry.UPDATED, |
|---|
| 246 | 'A': ChangesetEntry.ADDED, |
|---|
| 247 | 'D': ChangesetEntry.DELETED} |
|---|
| 248 | |
|---|
| 249 | def __init__(self): |
|---|
| 250 | self.changesets = [] |
|---|
| 251 | self.current = None |
|---|
| 252 | self.current_field = [] |
|---|
| 253 | self.renamed = {} |
|---|
| 254 | self.copies = [] |
|---|
| 255 | |
|---|
| 256 | def startElement(self, name, attributes): |
|---|
| 257 | if name == 'logentry': |
|---|
| 258 | self.current = {} |
|---|
| 259 | self.current['revision'] = attributes['revision'] |
|---|
| 260 | self.current['entries'] = [] |
|---|
| 261 | self.copies = [] |
|---|
| 262 | elif name in ['author', 'date', 'msg']: |
|---|
| 263 | self.current_field = [] |
|---|
| 264 | elif name == 'path': |
|---|
| 265 | self.current_field = [] |
|---|
| 266 | if attributes.has_key('copyfrom-path'): |
|---|
| 267 | self.current_path_action = ( |
|---|
| 268 | attributes['action'], |
|---|
| 269 | attributes['copyfrom-path'], |
|---|
| 270 | attributes['copyfrom-rev']) |
|---|
| 271 | else: |
|---|
| 272 | self.current_path_action = attributes['action'] |
|---|
| 273 | |
|---|
| 274 | def endElement(self, name): |
|---|
| 275 | if name == 'logentry': |
|---|
| 276 | # Sort the paths to make tests easier |
|---|
| 277 | self.current['entries'].sort(lambda a,b: cmp(a.name, b.name)) |
|---|
| 278 | |
|---|
| 279 | # Eliminate "useless" entries: SVN does not have atomic |
|---|
| 280 | # renames, but rather uses a ADD+RM duo. |
|---|
| 281 | # |
|---|
| 282 | # So cycle over all entries of this patch, discarding |
|---|
| 283 | # the deletion of files that were actually renamed, and |
|---|
| 284 | # at the same time change related entry from ADDED to |
|---|
| 285 | # RENAMED. |
|---|
| 286 | |
|---|
| 287 | # When copying a directory from another location in the |
|---|
| 288 | # repository (outside the tracked tree), SVN will report files |
|---|
| 289 | # below this dir that are not being committed as being |
|---|
| 290 | # removed. |
|---|
| 291 | |
|---|
| 292 | # We thus need to change the action_kind for all entries |
|---|
| 293 | # that are below a dir that was "copyfrom" from a path |
|---|
| 294 | # outside of this module: |
|---|
| 295 | # D -> Remove entry completely (it's not going to be in here) |
|---|
| 296 | # (M,A,R) -> A |
|---|
| 297 | |
|---|
| 298 | mv_or_cp = {} |
|---|
| 299 | for e in self.current['entries']: |
|---|
| 300 | if e.action_kind == e.ADDED and e.old_name is not None: |
|---|
| 301 | mv_or_cp[e.old_name] = e |
|---|
| 302 | |
|---|
| 303 | def parent_was_copied(n): |
|---|
| 304 | for p in self.copies: |
|---|
| 305 | if n.startswith(p+'/'): |
|---|
| 306 | return True |
|---|
| 307 | return False |
|---|
| 308 | |
|---|
| 309 | # Find renames from deleted directories: |
|---|
| 310 | # $ svn mv dir/a.txt a.txt |
|---|
| 311 | # $ svn del dir |
|---|
| 312 | def check_renames_from_dir(name): |
|---|
| 313 | for e in mv_or_cp.values(): |
|---|
| 314 | if e.old_name.startswith(name+'/'): |
|---|
| 315 | e.action_kind = e.RENAMED |
|---|
| 316 | |
|---|
| 317 | entries = [] |
|---|
| 318 | entries2 = [] |
|---|
| 319 | for e in self.current['entries']: |
|---|
| 320 | if e.action_kind==e.DELETED: |
|---|
| 321 | if mv_or_cp.has_key(e.name): |
|---|
| 322 | mv_or_cp[e.name].action_kind = e.RENAMED |
|---|
| 323 | else: |
|---|
| 324 | check_renames_from_dir(e.name) |
|---|
| 325 | entries2.append(e) |
|---|
| 326 | elif e.action_kind=='R': |
|---|
| 327 | # In svn parlance, 'R' means Replaced: a typical |
|---|
| 328 | # scenario is |
|---|
| 329 | # $ svn mv a.txt b.txt |
|---|
| 330 | # $ touch a.txt |
|---|
| 331 | # $ svn add a.txt |
|---|
| 332 | if mv_or_cp.has_key(e.name): |
|---|
| 333 | mv_or_cp[e.name].action_kind = e.RENAMED |
|---|
| 334 | else: |
|---|
| 335 | check_renames_from_dir(e.name) |
|---|
| 336 | e.action_kind = e.ADDED |
|---|
| 337 | entries2.append(e) |
|---|
| 338 | elif parent_was_copied(e.name): |
|---|
| 339 | if e.action_kind != e.DELETED: |
|---|
| 340 | e.action_kind = e.ADDED |
|---|
| 341 | entries.append(e) |
|---|
| 342 | else: |
|---|
| 343 | entries.append(e) |
|---|
| 344 | |
|---|
| 345 | # Changes sort: first MODIFY|ADD|RENAME, than REPLACE|DELETE |
|---|
| 346 | for e in entries2: |
|---|
| 347 | entries.append(e) |
|---|
| 348 | |
|---|
| 349 | svndate = self.current['date'] |
|---|
| 350 | # 2004-04-16T17:12:48.000000Z |
|---|
| 351 | y,m,d = map(int, svndate[:10].split('-')) |
|---|
| 352 | hh,mm,ss = map(int, svndate[11:19].split(':')) |
|---|
| 353 | ms = int(svndate[20:-1]) |
|---|
| 354 | timestamp = datetime(y, m, d, hh, mm, ss, ms, UTC) |
|---|
| 355 | |
|---|
| 356 | changeset = Changeset(self.current['revision'], |
|---|
| 357 | timestamp, |
|---|
| 358 | self.current.get('author'), |
|---|
| 359 | self.current['msg'], |
|---|
| 360 | entries) |
|---|
| 361 | self.changesets.append(changeset) |
|---|
| 362 | self.current = None |
|---|
| 363 | elif name in ['author', 'date', 'msg']: |
|---|
| 364 | self.current[name] = ''.join(self.current_field) |
|---|
| 365 | elif name == 'path': |
|---|
| 366 | path = ''.join(self.current_field) |
|---|
| 367 | entrypath = get_entry_from_path(path) |
|---|
| 368 | if entrypath: |
|---|
| 369 | entry = ChangesetEntry(entrypath) |
|---|
| 370 | if type(self.current_path_action) == type( () ): |
|---|
| 371 | self.copies.append(entry.name) |
|---|
| 372 | old = get_entry_from_path(self.current_path_action[1]) |
|---|
| 373 | if old: |
|---|
| 374 | entry.action_kind = self.ACTIONSMAP[self.current_path_action[0]] |
|---|
| 375 | entry.old_name = old |
|---|
| 376 | self.renamed[entry.old_name] = True |
|---|
| 377 | else: |
|---|
| 378 | entry.action_kind = entry.ADDED |
|---|
| 379 | else: |
|---|
| 380 | entry.action_kind = self.ACTIONSMAP[self.current_path_action] |
|---|
| 381 | |
|---|
| 382 | self.current['entries'].append(entry) |
|---|
| 383 | |
|---|
| 384 | def characters(self, data): |
|---|
| 385 | self.current_field.append(data) |
|---|
| 386 | |
|---|
| 387 | parser = make_parser() |
|---|
| 388 | handler = SvnXMLLogHandler() |
|---|
| 389 | parser.setContentHandler(handler) |
|---|
| 390 | parser.setErrorHandler(ErrorHandler()) |
|---|
| 391 | |
|---|
| 392 | chunk = log.read(chunksize) |
|---|
| 393 | while chunk: |
|---|
| 394 | parser.feed(chunk) |
|---|
| 395 | for cs in handler.changesets: |
|---|
| 396 | yield cs |
|---|
| 397 | handler.changesets = [] |
|---|
| 398 | chunk = log.read(chunksize) |
|---|
| 399 | parser.close() |
|---|
| 400 | for cs in handler.changesets: |
|---|
| 401 | yield cs |
|---|
| 402 | |
|---|
| 403 | |
|---|
| 404 | class SvnWorkingDir(UpdatableSourceWorkingDir, SynchronizableTargetWorkingDir): |
|---|
| 405 | |
|---|
| 406 | ## UpdatableSourceWorkingDir |
|---|
| 407 | |
|---|
| 408 | def _getUpstreamChangesets(self, sincerev=None): |
|---|
| 409 | if sincerev: |
|---|
| 410 | sincerev = int(sincerev) |
|---|
| 411 | else: |
|---|
| 412 | sincerev = 0 |
|---|
| 413 | |
|---|
| 414 | cmd = self.repository.command("log", "--verbose", "--xml", "--non-interactive", |
|---|
| 415 | "--revision", "%d:HEAD" % (sincerev+1)) |
|---|
| 416 | svnlog = ExternalCommand(cwd=self.repository.basedir, command=cmd) |
|---|
| 417 | log = svnlog.execute('.', stdout=PIPE, TZ='UTC0')[0] |
|---|
| 418 | |
|---|
| 419 | if svnlog.exit_status: |
|---|
| 420 | return [] |
|---|
| 421 | |
|---|
| 422 | if self.repository.filter_badchars: |
|---|
| 423 | from string import maketrans |
|---|
| 424 | from cStringIO import StringIO |
|---|
| 425 | |
|---|
| 426 | # Apparently some (SVN repo contains)/(SVN server dumps) some |
|---|
| 427 | # characters that are illegal in an XML stream. This was the case |
|---|
| 428 | # with Twisted Matrix master repository. To be safe, we replace |
|---|
| 429 | # all of them with a question mark. |
|---|
| 430 | |
|---|
| 431 | if isinstance(self.repository.filter_badchars, basestring): |
|---|
| 432 | allbadchars = self.repository.filter_badchars |
|---|
| 433 | else: |
|---|
| 434 | allbadchars = "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09" \ |
|---|
| 435 | "\x0B\x0C\x0E\x0F\x10\x11\x12\x13\x14\x15" \ |
|---|
| 436 | "\x16\x17\x18\x19\x1A\x1B\x1C\x1D\x1E\x1F\x7f" |
|---|
| 437 | |
|---|
| 438 | tt = maketrans(allbadchars, "?"*len(allbadchars)) |
|---|
| 439 | log = StringIO(log.read().translate(tt)) |
|---|
| 440 | |
|---|
| 441 | return changesets_from_svnlog(log, self.repository) |
|---|
| 442 | |
|---|
| 443 | def _applyChangeset(self, changeset): |
|---|
| 444 | from os import walk |
|---|
| 445 | from os.path import join, isdir |
|---|
| 446 | from time import sleep |
|---|
| 447 | |
|---|
| 448 | # Complete changeset information, determining the is_directory |
|---|
| 449 | # flag of the removed entries, before updating to the given revision |
|---|
| 450 | for entry in changeset.entries: |
|---|
| 451 | if entry.action_kind == entry.DELETED: |
|---|
| 452 | entry.is_directory = isdir(join(self.repository.basedir, entry.name)) |
|---|
| 453 | |
|---|
| 454 | cmd = self.repository.command("update") |
|---|
| 455 | if self.repository.ignore_externals: |
|---|
| 456 | cmd.append("--ignore-externals") |
|---|
| 457 | cmd.extend(["--revision", changeset.revision]) |
|---|
| 458 | svnup = ExternalCommand(cwd=self.repository.basedir, command=cmd) |
|---|
| 459 | |
|---|
| 460 | retry = 0 |
|---|
| 461 | while True: |
|---|
| 462 | out, err = svnup.execute(".", stdout=PIPE, stderr=PIPE) |
|---|
| 463 | |
|---|
| 464 | if svnup.exit_status == 1: |
|---|
| 465 | retry += 1 |
|---|
| 466 | if retry>3: |
|---|
| 467 | break |
|---|
| 468 | delay = 2**retry |
|---|
| 469 | self.log.error("%s returned status %s saying\n%s", |
|---|
| 470 | str(svnup), svnup.exit_status, err.read()) |
|---|
| 471 | self.log.warning("Retrying in %d seconds...", delay) |
|---|
| 472 | sleep(delay) |
|---|
| 473 | else: |
|---|
| 474 | break |
|---|
| 475 | |
|---|
| 476 | if svnup.exit_status: |
|---|
| 477 | raise ChangesetApplicationFailure( |
|---|
| 478 | "%s returned status %s saying\n%s" % (str(svnup), |
|---|
| 479 | svnup.exit_status, |
|---|
| 480 | err.read())) |
|---|
| 481 | |
|---|
| 482 | self.log.debug("%s updated to %s", |
|---|
| 483 | ','.join([e.name for e in changeset.entries]), |
|---|
| 484 | changeset.revision) |
|---|
| 485 | |
|---|
| 486 | # Complete changeset information, determining the is_directory |
|---|
| 487 | # flag of the added entries |
|---|
| 488 | implicitly_added_entries = [] |
|---|
| 489 | known_added_entries = set() |
|---|
| 490 | for entry in changeset.entries: |
|---|
| 491 | if entry.action_kind == entry.ADDED: |
|---|
| 492 | known_added_entries.add(entry.name) |
|---|
| 493 | fullname = join(self.repository.basedir, entry.name) |
|---|
| 494 | entry.is_directory = isdir(fullname) |
|---|
| 495 | # If it is a directory, extend the entries of the |
|---|
| 496 | # changeset with all its contents, if not already there. |
|---|
| 497 | if entry.is_directory: |
|---|
| 498 | for root, subdirs, files in walk(fullname): |
|---|
| 499 | if '.svn' in subdirs: |
|---|
| 500 | subdirs.remove('.svn') |
|---|
| 501 | for f in files: |
|---|
| 502 | name = join(root, f)[len(self.repository.basedir)+1:] |
|---|
| 503 | newe = ChangesetEntry(name) |
|---|
| 504 | newe.action_kind = newe.ADDED |
|---|
| 505 | implicitly_added_entries.append(newe) |
|---|
| 506 | for d in subdirs: |
|---|
| 507 | name = join(root, d)[len(self.repository.basedir)+1:] |
|---|
| 508 | newe = ChangesetEntry(name) |
|---|
| 509 | newe.action_kind = newe.ADDED |
|---|
| 510 | newe.is_directory = True |
|---|
| 511 | implicitly_added_entries.append(newe) |
|---|
| 512 | |
|---|
| 513 | for e in implicitly_added_entries: |
|---|
| 514 | if not e.name in known_added_entries: |
|---|
| 515 | changeset.entries.append(e) |
|---|
| 516 | |
|---|
| 517 | result = [] |
|---|
| 518 | for line in out: |
|---|
| 519 | if len(line)>2 and line[0] == 'C' and line[1] == ' ': |
|---|
| 520 | self.log.warning("Conflict after svn update: %r", line) |
|---|
| 521 | result.append(line[2:-1]) |
|---|
| 522 | |
|---|
| 523 | return result |
|---|
| 524 | |
|---|
| 525 | def _checkoutUpstreamRevision(self, revision): |
|---|
| 526 | """ |
|---|
| 527 | Concretely do the checkout of the upstream revision. |
|---|
| 528 | """ |
|---|
| 529 | |
|---|
| 530 | from os.path import join, exists |
|---|
| 531 | |
|---|
| 532 | # Verify that the we have the root of the repository: do that |
|---|
| 533 | # iterating an "svn ls" over the hierarchy until one fails |
|---|
| 534 | |
|---|
| 535 | lastok = self.repository.repository |
|---|
| 536 | if not self.repository.trust_root: |
|---|
| 537 | # Use --non-interactive, so that it fails if credentials |
|---|
| 538 | # are needed. |
|---|
| 539 | cmd = self.repository.command("ls", "--non-interactive") |
|---|
| 540 | svnls = ExternalCommand(command=cmd) |
|---|
| 541 | |
|---|
| 542 | # First verify that we have a valid repository |
|---|
| 543 | svnls.execute(self.repository.repository) |
|---|
| 544 | if svnls.exit_status: |
|---|
| 545 | lastok = None |
|---|
| 546 | else: |
|---|
| 547 | # Then verify it really points to the root of the |
|---|
| 548 | # repository: this is needed because later the svn log |
|---|
| 549 | # parser needs to know the "offset". |
|---|
| 550 | |
|---|
| 551 | reporoot = lastok[:lastok.rfind('/')] |
|---|
| 552 | |
|---|
| 553 | # Even if it would be enough asserting that the uplevel |
|---|
| 554 | # directory is not a repository, find the real root to |
|---|
| 555 | # suggest it in the exception. But don't go too far, that |
|---|
| 556 | # is, stop when you hit schema://... |
|---|
| 557 | while '//' in reporoot: |
|---|
| 558 | svnls.execute(reporoot) |
|---|
| 559 | if svnls.exit_status: |
|---|
| 560 | break |
|---|
| 561 | lastok = reporoot |
|---|
| 562 | reporoot = reporoot[:reporoot.rfind('/')] |
|---|
| 563 | |
|---|
| 564 | if lastok is None: |
|---|
| 565 | raise ConfigurationError('%r is not the root of a svn repository. If ' |
|---|
| 566 | 'you are sure it is indeed, you may try setting ' |
|---|
| 567 | 'the option "trust-root" to "True".' % |
|---|
| 568 | self.repository.repository) |
|---|
| 569 | elif lastok <> self.repository.repository: |
|---|
| 570 | module = self.repository.repository[len(lastok):] |
|---|
| 571 | module += self.repository.module |
|---|
| 572 | raise ConfigurationError('Non-root svn repository %r. ' |
|---|
| 573 | 'Please specify that as "repository=%s" ' |
|---|
| 574 | 'and "module=%s".' % |
|---|
| 575 | (self.repository.repository, |
|---|
| 576 | lastok, module.rstrip('/'))) |
|---|
| 577 | |
|---|
| 578 | if revision == 'INITIAL': |
|---|
| 579 | initial = True |
|---|
| 580 | cmd = self.repository.command("log", "--verbose", "--xml", |
|---|
| 581 | "--non-interactive", "--stop-on-copy", |
|---|
| 582 | "--revision", "1:HEAD") |
|---|
| 583 | if self.repository.use_limit: |
|---|
| 584 | cmd.extend(["--limit", "1"]) |
|---|
| 585 | svnlog = ExternalCommand(command=cmd) |
|---|
| 586 | out, err = svnlog.execute("%s%s" % (self.repository.repository, |
|---|
| 587 | self.repository.module), |
|---|
| 588 | stdout=PIPE, stderr=PIPE) |
|---|
| 589 | |
|---|
| 590 | if svnlog.exit_status: |
|---|
| 591 | raise TargetInitializationFailure( |
|---|
| 592 | "%s returned status %d saying\n%s" % |
|---|
| 593 | (str(svnlog), svnlog.exit_status, err.read())) |
|---|
| 594 | |
|---|
| 595 | csets = changesets_from_svnlog(out, self.repository) |
|---|
| 596 | last = csets.next() |
|---|
| 597 | revision = last.revision |
|---|
| 598 | else: |
|---|
| 599 | initial = False |
|---|
| 600 | |
|---|
| 601 | if not exists(join(self.repository.basedir, self.repository.METADIR)): |
|---|
| 602 | self.log.debug("Checking out a working copy") |
|---|
| 603 | |
|---|
| 604 | cmd = self.repository.command("co", "--quiet") |
|---|
| 605 | if self.repository.ignore_externals: |
|---|
| 606 | cmd.append("--ignore-externals") |
|---|
| 607 | cmd.extend(["--revision", revision]) |
|---|
| 608 | svnco = ExternalCommand(command=cmd) |
|---|
| 609 | |
|---|
| 610 | out, err = svnco.execute("%s%s@%s" % (self.repository.repository, |
|---|
| 611 | self.repository.module, |
|---|
| 612 | revision), |
|---|
| 613 | self.repository.basedir, stdout=PIPE, stderr=PIPE) |
|---|
| 614 | if svnco.exit_status: |
|---|
| 615 | raise TargetInitializationFailure( |
|---|
| 616 | "%s returned status %s saying\n%s" % (str(svnco), |
|---|
| 617 | svnco.exit_status, |
|---|
| 618 | err.read())) |
|---|
| 619 | else: |
|---|
| 620 | self.log.debug("%r already exists, assuming it's " |
|---|
| 621 | "a svn working dir", self.repository.basedir) |
|---|
| 622 | |
|---|
| 623 | if not initial: |
|---|
| 624 | if revision=='HEAD': |
|---|
| 625 | revision = 'COMMITTED' |
|---|
| 626 | cmd = self.repository.command("log", "--verbose", "--xml", |
|---|
| 627 | "--non-interactive", |
|---|
| 628 | "--revision", revision) |
|---|
| 629 | svnlog = ExternalCommand(cwd=self.repository.basedir, command=cmd) |
|---|
| 630 | out, err = svnlog.execute(stdout=PIPE, stderr=PIPE) |
|---|
| 631 | |
|---|
| 632 | if svnlog.exit_status: |
|---|
| 633 | raise TargetInitializationFailure( |
|---|
| 634 | "%s returned status %d saying\n%s" % |
|---|
| 635 | (str(svnlog), svnlog.exit_status, err.read())) |
|---|
| 636 | |
|---|
| 637 | csets = changesets_from_svnlog(out, self.repository) |
|---|
| 638 | last = csets.next() |
|---|
| 639 | |
|---|
| 640 | self.log.debug("Working copy up to svn revision %s", last.revision) |
|---|
| 641 | |
|---|
| 642 | return last |
|---|
| 643 | |
|---|
| 644 | ## SynchronizableTargetWorkingDir |
|---|
| 645 | |
|---|
| 646 | def _addPathnames(self, names): |
|---|
| 647 | """ |
|---|
| 648 | Add some new filesystem objects. |
|---|
| 649 | """ |
|---|
| 650 | |
|---|
| 651 | cmd = self.repository.command("add", "--quiet", "--no-auto-props", |
|---|
| 652 | "--non-recursive") |
|---|
| 653 | ExternalCommand(cwd=self.repository.basedir, command=cmd).execute(names) |
|---|
| 654 | |
|---|
| 655 | def _propsetRevision(self, out, command, date, author): |
|---|
| 656 | |
|---|
| 657 | from re import search |
|---|
| 658 | |
|---|
| 659 | encode = self.repository.encode |
|---|
| 660 | |
|---|
| 661 | line = out.readline() |
|---|
| 662 | if not line: |
|---|
| 663 | # svn did not find anything to commit |
|---|
| 664 | self.log.warning('svn did not find anything to commit') |
|---|
| 665 | return |
|---|
| 666 | |
|---|
| 667 | # Assume svn output the revision number in the last output line |
|---|
| 668 | while line: |
|---|
| 669 | lastline = line |
|---|
| 670 | line = out.readline() |
|---|
| 671 | revno = search('\d+', lastline) |
|---|
| 672 | if revno is None: |
|---|
| 673 | out.seek(0) |
|---|
| 674 | raise ChangesetApplicationFailure("%s wrote unrecognizable " |
|---|
| 675 | "revision number:\n%s" % |
|---|
| 676 | (str(command), out.read())) |
|---|
| 677 | |
|---|
| 678 | revision = revno.group(0) |
|---|
| 679 | |
|---|
| 680 | if self.repository.use_propset: |
|---|
| 681 | |
|---|
| 682 | cmd = self.repository.command("propset", "%(propname)s", |
|---|
| 683 | "--quiet", "--revprop", |
|---|
| 684 | "--revision", revision) |
|---|
| 685 | pset = ExternalCommand(cwd=self.repository.basedir, command=cmd) |
|---|
| 686 | if self.repository.propset_date: |
|---|
| 687 | date = date.astimezone(UTC).replace(microsecond=0, tzinfo=None) |
|---|
| 688 | pset.execute(date.isoformat()+".000000Z", propname='svn:date') |
|---|
| 689 | pset.execute(encode(author), propname='svn:author') |
|---|
| 690 | |
|---|
| 691 | return revision |
|---|
| 692 | |
|---|
| 693 | def _tag(self, tag, date, author): |
|---|
| 694 | """ |
|---|
| 695 | TAG current revision. |
|---|
| 696 | """ |
|---|
| 697 | if self.repository.setupTagsDirectory(): |
|---|
| 698 | src = self.repository.repository + self.repository.module |
|---|
| 699 | dest = self.repository.repository + self.repository.tags_path \ |
|---|
| 700 | + '/' + tag.replace('/', '_') |
|---|
| 701 | |
|---|
| 702 | cmd = self.repository.command("copy", src, dest, "-m", tag) |
|---|
| 703 | svntag = ExternalCommand(cwd=self.repository.basedir, command=cmd) |
|---|
| 704 | out, err = svntag.execute(stdout=PIPE, stderr=PIPE) |
|---|
| 705 | |
|---|
| 706 | if svntag.exit_status: |
|---|
| 707 | raise ChangesetApplicationFailure("%s returned status %d saying\n%s" |
|---|
| 708 | % (str(svntag), |
|---|
| 709 | svntag.exit_status, |
|---|
| 710 | err.read())) |
|---|
| 711 | |
|---|
| 712 | self._propsetRevision(out, svntag, date, author) |
|---|
| 713 | |
|---|
| 714 | def _commit(self, date, author, patchname, changelog=None, entries=None, |
|---|
| 715 | tags = [], isinitialcommit = False): |
|---|
| 716 | """ |
|---|
| 717 | Commit the changeset. |
|---|
| 718 | """ |
|---|
| 719 | |
|---|
| 720 | encode = self.repository.encode |
|---|
| 721 | |
|---|
| 722 | logmessage = [] |
|---|
| 723 | if patchname: |
|---|
| 724 | logmessage.append(patchname) |
|---|
| 725 | if changelog: |
|---|
| 726 | logmessage.append(changelog) |
|---|
| 727 | |
|---|
| 728 | # If we cannot use propset, fall back to old behaviour of |
|---|
| 729 | # appending these info to the changelog |
|---|
| 730 | |
|---|
| 731 | if not self.repository.use_propset: |
|---|
| 732 | logmessage.append('') |
|---|
| 733 | logmessage.append('Original author: %s' % encode(author)) |
|---|
| 734 | logmessage.append('Date: %s' % date) |
|---|
| 735 | elif not self.repository.propset_date: |
|---|
| 736 | logmessage.append('') |
|---|
| 737 | logmessage.append('Date: %s' % date) |
|---|
| 738 | |
|---|
| 739 | rontf = ReopenableNamedTemporaryFile('svn', 'tailor') |
|---|
| 740 | log = open(rontf.name, "w") |
|---|
| 741 | log.write(encode('\n'.join(logmessage))) |
|---|
| 742 | log.close() |
|---|
| 743 | |
|---|
| 744 | cmd = self.repository.command("commit", "--file", rontf.name) |
|---|
| 745 | commit = ExternalCommand(cwd=self.repository.basedir, command=cmd) |
|---|
| 746 | |
|---|
| 747 | if not entries or self.repository.commit_all_files: |
|---|
| 748 | entries = ['.'] |
|---|
| 749 | |
|---|
| 750 | out, err = commit.execute(entries, stdout=PIPE, stderr=PIPE) |
|---|
| 751 | |
|---|
| 752 | if commit.exit_status: |
|---|
| 753 | raise ChangesetApplicationFailure("%s returned status %d saying\n%s" |
|---|
| 754 | % (str(commit), |
|---|
| 755 | commit.exit_status, |
|---|
| 756 | err.read())) |
|---|
| 757 | |
|---|
| 758 | revision = self._propsetRevision(out, commit, date, author) |
|---|
| 759 | if not revision: |
|---|
| 760 | # svn did not find anything to commit |
|---|
| 761 | return |
|---|
| 762 | |
|---|
| 763 | cmd = self.repository.command("update", "--quiet") |
|---|
| 764 | if self.repository.ignore_externals: |
|---|
| 765 | cmd.append("--ignore-externals") |
|---|
| 766 | cmd.extend(["--revision", revision]) |
|---|
| 767 | |
|---|
| 768 | ExternalCommand(cwd=self.repository.basedir, command=cmd).execute() |
|---|
| 769 | |
|---|
| 770 | def _postCommitCheck(self): |
|---|
| 771 | """ |
|---|
| 772 | Assert that all the entries in the working dir are versioned. |
|---|
| 773 | """ |
|---|
| 774 | |
|---|
| 775 | cmd = self.repository.command("status") |
|---|
| 776 | whatsnew = ExternalCommand(cwd=self.repository.basedir, command=cmd) |
|---|
| 777 | output = whatsnew.execute(stdout=PIPE, stderr=STDOUT)[0] |
|---|
| 778 | unknown = [l for l in output.readlines() if l.startswith('?')] |
|---|
| 779 | if unknown: |
|---|
| 780 | raise PostCommitCheckFailure( |
|---|
| 781 | "Changes left in working dir after commit:\n%s" % ''.join(unknown)) |
|---|
| 782 | |
|---|
| 783 | def _removePathnames(self, names): |
|---|
| 784 | """ |
|---|
| 785 | Remove some filesystem objects. |
|---|
| 786 | """ |
|---|
| 787 | |
|---|
| 788 | cmd = self.repository.command("remove", "--quiet", "--force") |
|---|
| 789 | remove = ExternalCommand(cwd=self.repository.basedir, command=cmd) |
|---|
| 790 | remove.execute(names) |
|---|
| 791 | |
|---|
| 792 | def _renamePathname(self, oldname, newname): |
|---|
| 793 | """ |
|---|
| 794 | Rename a filesystem object. |
|---|
| 795 | """ |
|---|
| 796 | |
|---|
| 797 | from os import rename |
|---|
| 798 | from os.path import join, exists, isdir |
|---|
| 799 | from time import sleep |
|---|
| 800 | from datetime import datetime |
|---|
| 801 | |
|---|
| 802 | # --force in case the file has been changed and moved in one revision |
|---|
| 803 | cmd = self.repository.command("mv", "--quiet", "--force") |
|---|
| 804 | # Subversion does not seem to allow |
|---|
| 805 | # $ mv a.txt b.txt |
|---|
| 806 | # $ svn mv a.txt b.txt |
|---|
| 807 | # Here we are in this situation, since upstream VCS already |
|---|
| 808 | # moved the item. |
|---|
| 809 | # It may be better to let subversion do the move itself. For one thing, |
|---|
| 810 | # svn's cp+rm is different from rm+add (cp preserves history). |
|---|
| 811 | unmoved = False |
|---|
| 812 | oldpath = join(self.repository.basedir, oldname) |
|---|
| 813 | newpath = join(self.repository.basedir, newname) |
|---|
| 814 | if not exists(oldpath): |
|---|
| 815 | try: |
|---|
| 816 | rename(newpath, oldpath) |
|---|
| 817 | except OSError: |
|---|
| 818 | self.log.critical('Cannot rename %r back to %r', |
|---|
| 819 | newpath, oldpath) |
|---|
| 820 | raise |
|---|
| 821 | unmoved = True |
|---|
| 822 | |
|---|
| 823 | # Ticket #135: Need a timediff between rsync and directory move |
|---|
| 824 | if isdir(oldpath): |
|---|
| 825 | now = datetime.now() |
|---|
| 826 | if hasattr(self, '_last_svn_move'): |
|---|
| 827 | last = self._last_svn_move |
|---|
| 828 | else: |
|---|
| 829 | last = now |
|---|
| 830 | if not (now-last).seconds: |
|---|
| 831 | sleep(1) |
|---|
| 832 | self._last_svn_move = now |
|---|
| 833 | |
|---|
| 834 | move = ExternalCommand(cwd=self.repository.basedir, command=cmd) |
|---|
| 835 | out, err = move.execute(oldname, newname, stdout=PIPE, stderr=PIPE) |
|---|
| 836 | if move.exit_status: |
|---|
| 837 | if unmoved: |
|---|
| 838 | rename(oldpath, newpath) |
|---|
| 839 | raise ChangesetApplicationFailure("%s returned status %d saying\n%s" |
|---|
| 840 | % (str(move), move.exit_status, |
|---|
| 841 | err.read())) |
|---|
| 842 | |
|---|
| 843 | def _prepareTargetRepository(self): |
|---|
| 844 | """ |
|---|
| 845 | Check for target repository existence, eventually create it. |
|---|
| 846 | """ |
|---|
| 847 | |
|---|
| 848 | if not self.repository.repository: |
|---|
| 849 | return |
|---|
| 850 | |
|---|
| 851 | self.repository.create() |
|---|
| 852 | |
|---|
| 853 | def _prepareWorkingDirectory(self, source_repo): |
|---|
| 854 | """ |
|---|
| 855 | Checkout a working copy of the target SVN repository. |
|---|
| 856 | """ |
|---|
| 857 | |
|---|
| 858 | from os.path import join, exists |
|---|
| 859 | from vcpx.dualwd import IGNORED_METADIRS |
|---|
| 860 | |
|---|
| 861 | if not self.repository.repository or exists(join(self.repository.basedir, self.repository.METADIR)): |
|---|
| 862 | return |
|---|
| 863 | |
|---|
| 864 | cmd = self.repository.command("co", "--quiet") |
|---|
| 865 | if self.repository.ignore_externals: |
|---|
| 866 | cmd.append("--ignore-externals") |
|---|
| 867 | |
|---|
| 868 | svnco = ExternalCommand(command=cmd) |
|---|
| 869 | svnco.execute("%s%s" % (self.repository.repository, |
|---|
| 870 | self.repository.module), self.repository.basedir) |
|---|
| 871 | |
|---|
| 872 | ignore = [md for md in IGNORED_METADIRS] |
|---|
| 873 | |
|---|
| 874 | if self.logfile.startswith(self.repository.basedir): |
|---|
| 875 | ignore.append(self.logfile[len(self.repository.basedir)+1:]) |
|---|
| 876 | if self.state_file.filename.startswith(self.repository.basedir): |
|---|
| 877 | sfrelname = self.state_file.filename[len(self.repository.basedir)+1:] |
|---|
| 878 | ignore.append(sfrelname) |
|---|
| 879 | ignore.append(sfrelname+'.old') |
|---|
| 880 | ignore.append(sfrelname+'.journal') |
|---|
| 881 | |
|---|
| 882 | cmd = self.repository.command("propset", "%(propname)s", "--quiet") |
|---|
| 883 | pset = ExternalCommand(cwd=self.repository.basedir, command=cmd) |
|---|
| 884 | pset.execute('\n'.join(ignore), '.', propname='svn:ignore') |
|---|
| 885 | |
|---|
| 886 | def _initializeWorkingDir(self): |
|---|
| 887 | """ |
|---|
| 888 | Add the given directory to an already existing svn working tree. |
|---|
| 889 | """ |
|---|
| 890 | |
|---|
| 891 | from os.path import exists, join |
|---|
| 892 | |
|---|
| 893 | if not exists(join(self.repository.basedir, self.repository.METADIR)): |
|---|
| 894 | raise TargetInitializationFailure("'%s' needs to be an SVN working copy already under SVN" % self.repository.basedir) |
|---|
| 895 | |
|---|
| 896 | SynchronizableTargetWorkingDir._initializeWorkingDir(self) |
|---|