| 1 | # -*- mode: python; coding: utf-8 -*- |
|---|
| 2 | # :Progetto: vcpx -- Mercurial native backend |
|---|
| 3 | # :Creato: dom 11 set 2005 22:58:38 CEST |
|---|
| 4 | # :Autore: Lele Gaifax <lele@nautilus.homeip.net> |
|---|
| 5 | # Brendan Cully <brendan@kublai.com> |
|---|
| 6 | # :Licenza: GNU General Public License |
|---|
| 7 | # |
|---|
| 8 | |
|---|
| 9 | """ |
|---|
| 10 | This module implements the backends for Mercurial, using its native API |
|---|
| 11 | instead of thru the command line. |
|---|
| 12 | """ |
|---|
| 13 | |
|---|
| 14 | __docformat__ = 'reStructuredText' |
|---|
| 15 | |
|---|
| 16 | from mercurial import ui, hg, commands |
|---|
| 17 | |
|---|
| 18 | from vcpx.repository import Repository |
|---|
| 19 | from vcpx.source import UpdatableSourceWorkingDir |
|---|
| 20 | from vcpx.target import SynchronizableTargetWorkingDir |
|---|
| 21 | |
|---|
| 22 | |
|---|
| 23 | class HgRepository(Repository): |
|---|
| 24 | METADIR = '.hg' |
|---|
| 25 | |
|---|
| 26 | def _load(self, project): |
|---|
| 27 | Repository._load(self, project) |
|---|
| 28 | ppath = project.config.get(self.name, 'python-path') |
|---|
| 29 | if ppath: |
|---|
| 30 | from sys import path |
|---|
| 31 | |
|---|
| 32 | if ppath not in path: |
|---|
| 33 | path.insert(0, ppath) |
|---|
| 34 | self.EXTRA_METADIRS = ['.hgtags'] |
|---|
| 35 | |
|---|
| 36 | def _validateConfiguration(self): |
|---|
| 37 | """ |
|---|
| 38 | Mercurial expects all data to be in utf-8, so we disallow other encodings |
|---|
| 39 | """ |
|---|
| 40 | Repository._validateConfiguration(self) |
|---|
| 41 | |
|---|
| 42 | if self.encoding.upper() != 'UTF-8': |
|---|
| 43 | self.log.warning("Forcing UTF-8 encoding instead of " + self.encoding) |
|---|
| 44 | self.encoding = 'UTF-8' |
|---|
| 45 | |
|---|
| 46 | |
|---|
| 47 | class HgWorkingDir(UpdatableSourceWorkingDir, SynchronizableTargetWorkingDir): |
|---|
| 48 | # UpdatableSourceWorkingDir |
|---|
| 49 | def _checkoutUpstreamRevision(self, revision): |
|---|
| 50 | """ |
|---|
| 51 | Initial checkout (hg clone) |
|---|
| 52 | """ |
|---|
| 53 | |
|---|
| 54 | from os import mkdir, rename, rmdir |
|---|
| 55 | from os.path import exists, join |
|---|
| 56 | |
|---|
| 57 | self._getUI() |
|---|
| 58 | # We have to clone the entire repository to be able to pull from it |
|---|
| 59 | # later. So a partial checkout is a full clone followed by an update |
|---|
| 60 | # directly to the desired revision. |
|---|
| 61 | |
|---|
| 62 | # If the basedir does not exist, create it |
|---|
| 63 | if not exists(self.repository.basedir): |
|---|
| 64 | mkdir(self.repository.basedir) |
|---|
| 65 | |
|---|
| 66 | # clone it only if .hg does not exist |
|---|
| 67 | if not exists(join(self.repository.basedir, ".hg")): |
|---|
| 68 | # Hg won't check out into an existing directory |
|---|
| 69 | checkoutdir = join(self.repository.basedir,".hgtmp") |
|---|
| 70 | opts = self._defaultOpts('clone') |
|---|
| 71 | opts['noupdate'] = True |
|---|
| 72 | commands.clone(self._ui, self.repository.repository, checkoutdir, |
|---|
| 73 | **opts) |
|---|
| 74 | rename(join(checkoutdir, ".hg"), join(self.repository.basedir,".hg")) |
|---|
| 75 | rmdir(checkoutdir) |
|---|
| 76 | else: |
|---|
| 77 | # Does hgrc exist? If not, we write one |
|---|
| 78 | hgrc = join(self.repository.basedir, ".hg", "hgrc") |
|---|
| 79 | if not exists(hgrc): |
|---|
| 80 | hgrc = file(hgrc, "w") |
|---|
| 81 | hgrc.write("[paths]\ndefault = %s\ndefault-push = %s\n" % |
|---|
| 82 | (self.repository.repository, |
|---|
| 83 | self.repository.repository)) |
|---|
| 84 | hgrc.close() |
|---|
| 85 | |
|---|
| 86 | repo = self._getRepo() |
|---|
| 87 | node = self._getNode(repo, revision) |
|---|
| 88 | |
|---|
| 89 | self.log.info('Extracting revision %r from %r into %r', |
|---|
| 90 | revision, self.repository.repository, self.repository.basedir) |
|---|
| 91 | repo.update(node) |
|---|
| 92 | |
|---|
| 93 | return self._changesetForRevision(repo, revision) |
|---|
| 94 | |
|---|
| 95 | def _getUpstreamChangesets(self, sincerev): |
|---|
| 96 | """Fetch new changesets from the source""" |
|---|
| 97 | repo = self._getRepo() |
|---|
| 98 | |
|---|
| 99 | self._hgCommand('pull', 'default') |
|---|
| 100 | |
|---|
| 101 | from mercurial.node import bin |
|---|
| 102 | for rev in xrange(repo.changelog.rev(bin(sincerev)) + 1, |
|---|
| 103 | repo.changelog.count()): |
|---|
| 104 | yield self._changesetForRevision(repo, str(rev)) |
|---|
| 105 | |
|---|
| 106 | def _applyChangeset(self, changeset): |
|---|
| 107 | repo = self._getRepo() |
|---|
| 108 | node = self._getNode(repo, changeset.revision) |
|---|
| 109 | |
|---|
| 110 | self.log.info('Updating to %r', changeset.revision) |
|---|
| 111 | res = repo.update(node) |
|---|
| 112 | if res: |
|---|
| 113 | # Files in to-be-merged changesets not on the trunk will |
|---|
| 114 | # cause a merge error on update. If no files are modified, |
|---|
| 115 | # added, removed, or deleted, do update -C |
|---|
| 116 | modified, added, removed, deleted = repo.changes()[0:4] |
|---|
| 117 | conflicting = modified + added + removed + deleted |
|---|
| 118 | if conflicting: |
|---|
| 119 | return conflicting |
|---|
| 120 | return repo.update(node, force=True) |
|---|
| 121 | |
|---|
| 122 | def _changesetForRevision(self, repo, revision): |
|---|
| 123 | from datetime import datetime |
|---|
| 124 | from vcpx.changes import Changeset, ChangesetEntry |
|---|
| 125 | from vcpx.tzinfo import FixedOffset |
|---|
| 126 | |
|---|
| 127 | entries = [] |
|---|
| 128 | node = self._getNode(repo, revision) |
|---|
| 129 | parents = repo.changelog.parents(node) |
|---|
| 130 | (manifest, user, date, files, message) = repo.changelog.read(node) |
|---|
| 131 | |
|---|
| 132 | dt, tz = date |
|---|
| 133 | date = datetime.fromtimestamp(dt, FixedOffset(-tz/60)) # note the minus sign! |
|---|
| 134 | |
|---|
| 135 | manifest = repo.manifest.read(manifest) |
|---|
| 136 | |
|---|
| 137 | # To find adds, we get the manifests of any parents. If a file doesn't |
|---|
| 138 | # occur there, it's new. |
|---|
| 139 | pms = {} |
|---|
| 140 | for parent in repo.changelog.parents(node): |
|---|
| 141 | pms.update(repo.manifest.read(repo.changelog.read(parent)[0])) |
|---|
| 142 | |
|---|
| 143 | # if files contains only '.hgtags', this is probably a tag cset. |
|---|
| 144 | # Tailor appears to only support tagging the current version, so only |
|---|
| 145 | # pass on tags that are for the immediate parents of the current node |
|---|
| 146 | tags = None |
|---|
| 147 | if files == ['.hgtags']: |
|---|
| 148 | tags = [tag for (tag, tagnode) in repo.tags().iteritems() |
|---|
| 149 | if tagnode in parents] |
|---|
| 150 | |
|---|
| 151 | # Don't include the file itself in the changeset. It's only useful |
|---|
| 152 | # to mercurial, and if we do end up making a tailor round trip |
|---|
| 153 | # the nodes will be wrong anyway. |
|---|
| 154 | if '.hgtags' in files: |
|---|
| 155 | files.remove('.hgtags') |
|---|
| 156 | if pms.has_key('.hgtags'): |
|---|
| 157 | del pms['.hgtags'] |
|---|
| 158 | |
|---|
| 159 | for f in files: |
|---|
| 160 | e = ChangesetEntry(f) |
|---|
| 161 | # find renames |
|---|
| 162 | fl = repo.file(f) |
|---|
| 163 | oldname = f in manifest and fl.renamed(manifest[f]) |
|---|
| 164 | if oldname: |
|---|
| 165 | e.action_kind = ChangesetEntry.RENAMED |
|---|
| 166 | e.old_name = oldname[0] |
|---|
| 167 | # hg copy can copy the same file to multiple destinations |
|---|
| 168 | # Currently this is handled as multiple renames. It would |
|---|
| 169 | # probably be better to have ChangesetEntry.COPIED. |
|---|
| 170 | if pms.has_key(oldname[0]): |
|---|
| 171 | pms.pop(oldname[0]) |
|---|
| 172 | else: |
|---|
| 173 | if pms.has_key(f): |
|---|
| 174 | e.action_kind = ChangesetEntry.UPDATED |
|---|
| 175 | else: |
|---|
| 176 | e.action_kind = ChangesetEntry.ADDED |
|---|
| 177 | |
|---|
| 178 | entries.append(e) |
|---|
| 179 | |
|---|
| 180 | for df in [file for file in pms.iterkeys() |
|---|
| 181 | if not manifest.has_key(file)]: |
|---|
| 182 | e = ChangesetEntry(df) |
|---|
| 183 | e.action_kind = ChangesetEntry.DELETED |
|---|
| 184 | entries.append(e) |
|---|
| 185 | |
|---|
| 186 | from mercurial.node import hex |
|---|
| 187 | revision = hex(node) |
|---|
| 188 | return Changeset(revision, date, user, message, entries, tags=tags) |
|---|
| 189 | |
|---|
| 190 | def _getUI(self): |
|---|
| 191 | try: |
|---|
| 192 | return self._ui |
|---|
| 193 | except AttributeError: |
|---|
| 194 | project = self.repository.projectref() |
|---|
| 195 | self._ui = ui.ui(project.verbose, |
|---|
| 196 | project.config.get(self.repository.name, |
|---|
| 197 | 'debug', False), |
|---|
| 198 | not project.verbose, False) |
|---|
| 199 | return self._ui |
|---|
| 200 | |
|---|
| 201 | def _getRepo(self): |
|---|
| 202 | try: |
|---|
| 203 | return self._hg |
|---|
| 204 | except AttributeError: |
|---|
| 205 | # dirstate walker uses simple string comparison between |
|---|
| 206 | # repo root and os.getcwd, so root should be canonified. |
|---|
| 207 | from os.path import realpath |
|---|
| 208 | |
|---|
| 209 | ui = self._getUI() |
|---|
| 210 | self._hg = hg.repository(ui=ui, path=realpath(self.repository.basedir), |
|---|
| 211 | create=False) |
|---|
| 212 | # Pick up repository-specific UI settings. |
|---|
| 213 | self._ui = self._hg.ui |
|---|
| 214 | return self._hg |
|---|
| 215 | |
|---|
| 216 | def _getNode(self, repo, revision): |
|---|
| 217 | """Convert a tailor revision ID into an hg node""" |
|---|
| 218 | if revision == "HEAD": |
|---|
| 219 | node = repo.changelog.tip() |
|---|
| 220 | else: |
|---|
| 221 | if revision == "INITIAL": |
|---|
| 222 | rev = "0" |
|---|
| 223 | else: |
|---|
| 224 | rev = revision |
|---|
| 225 | node = repo.changelog.lookup(rev) |
|---|
| 226 | |
|---|
| 227 | return node |
|---|
| 228 | |
|---|
| 229 | def _normalizeEntryPaths(self, entry): |
|---|
| 230 | """ |
|---|
| 231 | Normalize the name and old_name of an entry. |
|---|
| 232 | |
|---|
| 233 | This implementation uses ``mercurial.util.normpath()``, since |
|---|
| 234 | at this level hg is expecting UNIX style pathnames, with |
|---|
| 235 | forward slash"/" as separator, also under insane operating systems. |
|---|
| 236 | """ |
|---|
| 237 | |
|---|
| 238 | from mercurial.util import normpath |
|---|
| 239 | |
|---|
| 240 | entry.name = normpath(self.repository.encode(entry.name)) |
|---|
| 241 | if entry.old_name: |
|---|
| 242 | entry.old_name = normpath(self.repository.encode(entry.old_name)) |
|---|
| 243 | |
|---|
| 244 | def _removeDirs(self, names): |
|---|
| 245 | from os.path import isdir, join, normpath |
|---|
| 246 | """Remove the names that reference a directory.""" |
|---|
| 247 | return [n for n in names |
|---|
| 248 | if not isdir(join(self.repository.basedir, normpath(n)))] |
|---|
| 249 | return notdirs |
|---|
| 250 | |
|---|
| 251 | def _addPathnames(self, names): |
|---|
| 252 | from os.path import join |
|---|
| 253 | |
|---|
| 254 | notdirs = self._removeDirs(names) |
|---|
| 255 | if notdirs: |
|---|
| 256 | self.log.info('Adding %s...', ', '.join(notdirs)) |
|---|
| 257 | self._hg.add(notdirs) |
|---|
| 258 | |
|---|
| 259 | def _commit(self, date, author, patchname, changelog=None, names=[]): |
|---|
| 260 | from calendar import timegm # like mktime(), but returns UTC timestamp |
|---|
| 261 | |
|---|
| 262 | encode = self.repository.encode |
|---|
| 263 | |
|---|
| 264 | logmessage = [] |
|---|
| 265 | if patchname: |
|---|
| 266 | logmessage.append(patchname) |
|---|
| 267 | if changelog: |
|---|
| 268 | logmessage.append(changelog) |
|---|
| 269 | if logmessage: |
|---|
| 270 | self.log.info('Committing %r...', logmessage[0]) |
|---|
| 271 | logmessage = encode('\n'.join(logmessage)) |
|---|
| 272 | else: |
|---|
| 273 | self.log.info('Committing...') |
|---|
| 274 | logmessage = "Empty changelog" |
|---|
| 275 | |
|---|
| 276 | timestamp = timegm(date.utctimetuple()) |
|---|
| 277 | timezone = date.utcoffset().seconds + date.utcoffset().days * 24 * 3600 |
|---|
| 278 | |
|---|
| 279 | opts = {} |
|---|
| 280 | opts['message'] = logmessage |
|---|
| 281 | opts['user'] = encode(author) |
|---|
| 282 | opts['date'] = '%d %d' % (timestamp, -timezone) # note the minus sign! |
|---|
| 283 | notdirs = self._removeDirs(names) |
|---|
| 284 | self._hgCommand('commit', *[encode(n) for n in notdirs], **opts) |
|---|
| 285 | |
|---|
| 286 | def _tag(self, tag): |
|---|
| 287 | """ Tag the tip with a given identifier """ |
|---|
| 288 | # TODO: keep a handle on the changeset holding this tag? Then |
|---|
| 289 | # we can extract author, log, date from it. |
|---|
| 290 | |
|---|
| 291 | # This seems gross. I don't get why I'm getting a unicode tag when |
|---|
| 292 | # it's just ascii underneath. Something weird is happening in CVS. |
|---|
| 293 | tag = self.repository.encode(tag) |
|---|
| 294 | |
|---|
| 295 | # CVS can't tell when a tag was applied so it tends to pass around |
|---|
| 296 | # too many. We want to support retagging so we can't just ignore |
|---|
| 297 | # duplicates. But we can safely ignore a tag if it is contained |
|---|
| 298 | # in the commit history from tip back to the last non-tag commit. |
|---|
| 299 | repo = self._getRepo() |
|---|
| 300 | tagnodes = repo.tags().values() |
|---|
| 301 | try: |
|---|
| 302 | tagnode = repo.tags()[tag] |
|---|
| 303 | # tag commit can't be merge, right? |
|---|
| 304 | parent = repo.changelog.parents(repo.changelog.tip())[0] |
|---|
| 305 | while parent in tagnodes: |
|---|
| 306 | if tagnode == parent: |
|---|
| 307 | return |
|---|
| 308 | parent = repo.changelog.parents(parent)[0] |
|---|
| 309 | except KeyError: |
|---|
| 310 | pass |
|---|
| 311 | self._hgCommand('tag', tag) |
|---|
| 312 | |
|---|
| 313 | def _defaultOpts(self, cmd): |
|---|
| 314 | # Not sure this is public. commands.parse might be, but this |
|---|
| 315 | # is easier, and while dispatch is easiest, you lose ui. |
|---|
| 316 | if hasattr(commands, 'findcmd'): |
|---|
| 317 | if commands.findcmd.func_code.co_argcount == 1: |
|---|
| 318 | findcmd = commands.findcmd |
|---|
| 319 | else: |
|---|
| 320 | def findcmd(cmd): |
|---|
| 321 | return commands.findcmd(self._getUI(), cmd) |
|---|
| 322 | else: |
|---|
| 323 | findcmd = commands.find |
|---|
| 324 | return dict([(f[1].replace('-', '_'), f[2]) for f in findcmd(cmd)[1][1]]) |
|---|
| 325 | |
|---|
| 326 | def _hgCommand(self, cmd, *args, **opts): |
|---|
| 327 | import os |
|---|
| 328 | |
|---|
| 329 | allopts = self._defaultOpts(cmd) |
|---|
| 330 | allopts.update(opts) |
|---|
| 331 | cmd = getattr(commands, cmd) |
|---|
| 332 | cwd = os.getcwd() |
|---|
| 333 | os.chdir(self.repository.basedir) |
|---|
| 334 | try: |
|---|
| 335 | cmd(self._ui, self._hg, *args, **allopts) |
|---|
| 336 | finally: |
|---|
| 337 | os.chdir(cwd) |
|---|
| 338 | |
|---|
| 339 | def _removePathnames(self, names): |
|---|
| 340 | """Remove a sequence of entries""" |
|---|
| 341 | |
|---|
| 342 | from os.path import join |
|---|
| 343 | |
|---|
| 344 | repo = self._getRepo() |
|---|
| 345 | |
|---|
| 346 | self.log.info('Removing %s...', ', '.join(names)) |
|---|
| 347 | for name in names: |
|---|
| 348 | files = self._walk(name) |
|---|
| 349 | # We can't use isdir because the source has already |
|---|
| 350 | # removed the entry, so we do a dirstate lookup. |
|---|
| 351 | if files: |
|---|
| 352 | for f in self._walk(name): |
|---|
| 353 | repo.remove([join(name, f)]) |
|---|
| 354 | else: |
|---|
| 355 | repo.remove([name]) |
|---|
| 356 | |
|---|
| 357 | def _renamePathname(self, oldname, newname): |
|---|
| 358 | """Rename an entry""" |
|---|
| 359 | |
|---|
| 360 | from os.path import join, isdir, normpath |
|---|
| 361 | |
|---|
| 362 | repo = self._getRepo() |
|---|
| 363 | |
|---|
| 364 | self.log.info('Renaming %r to %r...', oldname, newname) |
|---|
| 365 | if isdir(join(self.repository.basedir, normpath(newname))): |
|---|
| 366 | # Given lack of support for directories in current HG, |
|---|
| 367 | # loop over all files under the old directory and |
|---|
| 368 | # do a copy on them. |
|---|
| 369 | for f in self._walk(oldname): |
|---|
| 370 | oldpath = join(oldname, f) |
|---|
| 371 | repo.copy(oldpath, join(newname, f)) |
|---|
| 372 | repo.remove([oldpath]) |
|---|
| 373 | else: |
|---|
| 374 | repo.copy(oldname, newname) |
|---|
| 375 | repo.remove([oldname]) |
|---|
| 376 | |
|---|
| 377 | def _prepareTargetRepository(self): |
|---|
| 378 | """ |
|---|
| 379 | Create the base directory if it doesn't exist, and the |
|---|
| 380 | repository as well in the new working directory. |
|---|
| 381 | """ |
|---|
| 382 | |
|---|
| 383 | from os.path import join, exists, realpath |
|---|
| 384 | |
|---|
| 385 | self._getUI() |
|---|
| 386 | |
|---|
| 387 | if exists(join(self.repository.basedir, self.repository.METADIR)): |
|---|
| 388 | create = 0 |
|---|
| 389 | else: |
|---|
| 390 | create = 1 |
|---|
| 391 | self.log.info('Initializing new repository in %r...', self.repository.basedir) |
|---|
| 392 | self._hg = hg.repository(ui=self._ui, path=realpath(self.repository.basedir), |
|---|
| 393 | create=create) |
|---|
| 394 | |
|---|
| 395 | def _prepareWorkingDirectory(self, source_repo): |
|---|
| 396 | """ |
|---|
| 397 | Create the .hgignore. |
|---|
| 398 | """ |
|---|
| 399 | |
|---|
| 400 | from os.path import join |
|---|
| 401 | from re import escape |
|---|
| 402 | from vcpx.dualwd import IGNORED_METADIRS |
|---|
| 403 | |
|---|
| 404 | # Create the .hgignore file, that contains a regexp per line |
|---|
| 405 | # with all known VCs metadirs to be skipped. |
|---|
| 406 | ignore = open(join(self.repository.basedir, '.hgignore'), 'w') |
|---|
| 407 | ignore.write('\n'.join(['(^|/)%s($|/)' % escape(md) |
|---|
| 408 | for md in IGNORED_METADIRS])) |
|---|
| 409 | ignore.write('\n') |
|---|
| 410 | if self.logfile.startswith(self.repository.basedir): |
|---|
| 411 | ignore.write('^') |
|---|
| 412 | ignore.write(self.logfile[len(self.repository.basedir)+1:]) |
|---|
| 413 | ignore.write('$\n') |
|---|
| 414 | if self.state_file.filename.startswith(self.repository.basedir): |
|---|
| 415 | sfrelname = self.state_file.filename[len(self.repository.basedir)+1:] |
|---|
| 416 | ignore.write('^') |
|---|
| 417 | ignore.write(sfrelname) |
|---|
| 418 | ignore.write('$\n') |
|---|
| 419 | ignore.write('^') |
|---|
| 420 | ignore.write(sfrelname+'.old') |
|---|
| 421 | ignore.write('$\n') |
|---|
| 422 | ignore.write('^') |
|---|
| 423 | ignore.write(sfrelname+'.journal') |
|---|
| 424 | ignore.write('$\n') |
|---|
| 425 | ignore.close() |
|---|
| 426 | |
|---|
| 427 | def _initializeWorkingDir(self): |
|---|
| 428 | self._hgCommand('add') |
|---|
| 429 | |
|---|
| 430 | def _walk(self, subdir): |
|---|
| 431 | """ |
|---|
| 432 | Returns the files mercurial knows about under subdir, relative |
|---|
| 433 | to subdir. |
|---|
| 434 | """ |
|---|
| 435 | from os.path import join, split |
|---|
| 436 | |
|---|
| 437 | files = [] |
|---|
| 438 | for src, path in self._getRepo().dirstate.walk([subdir]): |
|---|
| 439 | # If subdir is a plain file, just return |
|---|
| 440 | if path == subdir: |
|---|
| 441 | return None |
|---|
| 442 | (hd, tl) = split(path) |
|---|
| 443 | while hd != subdir and hd != '': |
|---|
| 444 | hd, nt = split(hd) |
|---|
| 445 | tl = join(nt, tl) |
|---|
| 446 | files.append(tl) |
|---|
| 447 | return files |
|---|