1# 2# This program is free software; you can redistribute it and/or modify 3# it under the terms of the GNU General Public License version 2 4# as published by the Free Software Foundation. 5# 6# This program is distributed in the hope that it will be useful, 7# but WITHOUT ANY WARRANTY; without even the implied warranty of 8# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 9# GNU General Public License for more details. 10# 11# You should have received a copy of the GNU General Public License 12# along with this program; if not, write to the Free Software 13# Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA. 14# 15 16# 17# Copyright (c) 2008, 2010, Oracle and/or its affiliates. All rights reserved. 18# 19 20# 21# Theory: 22# 23# Workspaces have a non-binding parent/child relationship. 24# All important operations apply to the changes between the two. 25# 26# However, for the sake of remote operation, the 'parent' of a 27# workspace is not seen as a literal entity, instead the figurative 28# parent contains the last changeset common to both parent and child, 29# as such the 'parent tip' is actually nothing of the sort, but instead a 30# convenient imitation. 31# 32# Any change made to a workspace is a change to a file therein, such 33# changes can be represented briefly as whether the file was 34# modified/added/removed as compared to the parent workspace, whether 35# the file has a different name in the parent and if so, whether it 36# was renamed or merely copied. Each changed file has an 37# associated ActiveEntry. 38# 39# The ActiveList being a list ActiveEntrys can thus present the entire 40# change in workspace state between a parent and its child, and is the 41# important bit here (in that if it is incorrect, everything else will 42# be as incorrect, or more) 43# 44 45import cStringIO 46import os 47from mercurial import cmdutil, context, hg, node, patch, repair, util 48from hgext import mq 49 50from onbld.Scm import Version 51 52# 53# Mercurial >= 1.2 has its exception types in a mercurial.error 54# module, prior versions had them in their associated modules. 55# 56if Version.at_least("1.2"): 57 from mercurial import error 58 HgRepoError = error.RepoError 59 HgLookupError = error.LookupError 60else: 61 from mercurial import repo, revlog 62 HgRepoError = repo.RepoError 63 HgLookupError = revlog.LookupError 64 65 66class ActiveEntry(object): 67 '''Representation of the changes made to a single file. 68 69 MODIFIED - Contents changed, but no other changes were made 70 ADDED - File is newly created 71 REMOVED - File is being removed 72 73 Copies are represented by an Entry whose .parentname is non-nil 74 75 Truly copied files have non-nil .parentname and .renamed = False 76 Renames have non-nil .parentname and .renamed = True 77 78 Do not access any of this information directly, do so via the 79 80 .is_<change>() methods.''' 81 82 MODIFIED = 1 83 ADDED = 2 84 REMOVED = 3 85 86 def __init__(self, name): 87 self.name = name 88 self.change = None 89 self.parentname = None 90 # As opposed to copied (or neither) 91 self.renamed = False 92 self.comments = [] 93 94 # 95 # ActiveEntrys sort by the name of the file they represent. 96 # 97 def __cmp__(self, other): 98 return cmp(self.name, other.name) 99 100 def is_added(self): 101 return self.change == self.ADDED 102 103 def is_modified(self): 104 return self.change == self.MODIFIED 105 106 def is_removed(self): 107 return self.change == self.REMOVED 108 109 def is_renamed(self): 110 return self.parentname and self.renamed 111 112 def is_copied(self): 113 return self.parentname and not self.renamed 114 115 116class ActiveList(object): 117 '''Complete representation of workspace change. 118 119 In practice, a container for ActiveEntrys, and methods to build them, 120 update them, and deal with them en masse.''' 121 122 def __init__(self, ws, parenttip, revs=None): 123 self._active = {} 124 self.ws = ws 125 126 self.revs = revs 127 128 self.base = None 129 self.parenttip = parenttip 130 131 # 132 # If we couldn't find a parenttip, the two repositories must 133 # be unrelated (Hg catches most of this, but this case is valid for it 134 # but invalid for us) 135 # 136 if self.parenttip == None: 137 raise util.Abort('repository is unrelated') 138 self.localtip = None 139 140 if revs: 141 self.base = revs[0] 142 self.localtip = revs[-1] 143 144 self._comments = [] 145 146 self._build(revs) 147 148 def _build(self, revs): 149 if not revs: 150 return 151 152 status = self.ws.status(self.parenttip.node(), self.localtip.node()) 153 154 files = [] 155 for ctype in status.values(): 156 files.extend(ctype) 157 158 # 159 # When a file is renamed, two operations actually occur. 160 # A file copy from source to dest and a removal of source. 161 # 162 # These are represented as two distinct entries in the 163 # changectx and status (one on the dest file for the 164 # copy, one on the source file for the remove). 165 # 166 # Since these are unconnected in both the context and 167 # status we can only make the association by explicitly 168 # looking for it. 169 # 170 # We deal with this thusly: 171 # 172 # We maintain a dict dest -> source of all copies 173 # (updating dest as appropriate, but leaving source alone). 174 # 175 # After all other processing, we mark as renamed any pair 176 # where source is on the removed list. 177 # 178 copies = {} 179 180 # 181 # Walk revs looking for renames and adding files that 182 # are in both change context and status to the active 183 # list. 184 # 185 for ctx in revs: 186 desc = ctx.description().splitlines() 187 188 self._comments.extend(desc) 189 190 for fname in ctx.files(): 191 # 192 # We store comments per-entry as well, for the sake of 193 # webrev and similar. We store twice to avoid the problems 194 # of uniquifying comments for the general list (and possibly 195 # destroying multi-line entities in the process). 196 # 197 if fname not in self: 198 self._addentry(fname) 199 self[fname].comments.extend(desc) 200 201 try: 202 fctx = ctx.filectx(fname) 203 except HgLookupError: 204 continue 205 206 # 207 # NB: .renamed() is a misnomer, this actually checks 208 # for copies. 209 # 210 rn = fctx.renamed() 211 if rn: 212 # 213 # If the source file is a known copy we know its 214 # ancestry leads us to the parent. 215 # Otherwise make sure the source file is known to 216 # be in the parent, we need not care otherwise. 217 # 218 # We detect cycles at a later point. There is no 219 # reason to continuously handle them. 220 # 221 if rn[0] in copies: 222 copies[fname] = copies[rn[0]] 223 elif rn[0] in self.parenttip.manifest(): 224 copies[fname] = rn[0] 225 226 # 227 # Walk the copy list marking as copied any non-cyclic pair 228 # where the destination file is still present in the local 229 # tip (to avoid ephemeral changes) 230 # 231 # Where source is removed, mark as renamed, and remove the 232 # AL entry for the source file 233 # 234 for fname, oldname in copies.iteritems(): 235 if fname == oldname or fname not in self.localtip.manifest(): 236 continue 237 238 self[fname].parentname = oldname 239 240 if oldname in status['removed']: 241 self[fname].renamed = True 242 if oldname in self: 243 del self[oldname] 244 245 # 246 # Walk the active list setting the change type for each active 247 # file. 248 # 249 # In the case of modified files that are not renames or 250 # copies, we do a content comparison, and drop entries that 251 # are not actually modified. 252 # 253 # We walk a copy of the AL such that we can drop entries 254 # within the loop. 255 # 256 for entry in self._active.values(): 257 if entry.name not in files: 258 del self[entry.name] 259 continue 260 261 if entry.name in status['added']: 262 entry.change = ActiveEntry.ADDED 263 elif entry.name in status['removed']: 264 entry.change = ActiveEntry.REMOVED 265 elif entry.name in status['modified']: 266 entry.change = ActiveEntry.MODIFIED 267 268 # 269 # There are cases during a merge where a file will be in 270 # the status return as modified, but in reality be an 271 # addition (ie, not in the parenttip). 272 # 273 # We need to check whether the file is actually present 274 # in the parenttip, and set it as an add, if not. 275 # 276 if entry.name not in self.parenttip.manifest(): 277 entry.change = ActiveEntry.ADDED 278 elif entry.is_modified(): 279 if not self._changed_file(entry.name): 280 del self[entry.name] 281 continue 282 283 assert entry.change 284 285 def __contains__(self, fname): 286 return fname in self._active 287 288 def __getitem__(self, key): 289 return self._active[key] 290 291 def __setitem__(self, key, value): 292 self._active[key] = value 293 294 def __delitem__(self, key): 295 del self._active[key] 296 297 def __iter__(self): 298 for entry in self._active.values(): 299 yield entry 300 301 def _addentry(self, fname): 302 if fname not in self: 303 self[fname] = ActiveEntry(fname) 304 305 def files(self): 306 '''Return the list of pathnames of all files touched by this 307 ActiveList 308 309 Where files have been renamed, this will include both their 310 current name and the name which they had in the parent tip. 311 ''' 312 313 ret = self._active.keys() 314 ret.extend([x.parentname for x in self 315 if x.is_renamed() and x.parentname not in ret]) 316 return ret 317 318 def comments(self): 319 return self._comments 320 321 def bases(self): 322 '''Return the list of changesets that are roots of the ActiveList. 323 324 This is the set of active changesets where neither parent 325 changeset is itself active.''' 326 327 revset = set(self.revs) 328 return filter(lambda ctx: not [p for p in ctx.parents() if p in revset], 329 self.revs) 330 331 def tags(self): 332 '''Find tags that refer to a changeset in the ActiveList, 333 returning a list of 3-tuples (tag, node, is_local) for each. 334 335 We return all instances of a tag that refer to such a node, 336 not just that which takes precedence.''' 337 338 def colliding_tags(iterable, nodes, local): 339 for nd, name in [line.rstrip().split(' ', 1) for line in iterable]: 340 if nd in nodes: 341 yield (name, self.ws.repo.lookup(nd), local) 342 343 tags = [] 344 nodes = set(node.hex(ctx.node()) for ctx in self.revs) 345 346 if os.path.exists(self.ws.repo.join('localtags')): 347 fh = self.ws.repo.opener('localtags') 348 tags.extend(colliding_tags(fh, nodes, True)) 349 fh.close() 350 351 # We want to use the tags file from the localtip 352 if '.hgtags' in self.localtip: 353 data = self.localtip.filectx('.hgtags').data().splitlines() 354 tags.extend(colliding_tags(data, nodes, False)) 355 356 return tags 357 358 def prune_tags(self, data): 359 '''Return a copy of data, which should correspond to the 360 contents of a Mercurial tags file, with any tags that refer to 361 changesets which are components of the ActiveList removed.''' 362 363 nodes = set(node.hex(ctx.node()) for ctx in self.revs) 364 return [t for t in data if t.split(' ', 1)[0] not in nodes] 365 366 def _changed_file(self, path): 367 '''Compare the parent and local versions of a given file. 368 Return True if file changed, False otherwise. 369 370 Note that this compares the given path in both versions, not the given 371 entry; renamed and copied files are compared by name, not history. 372 373 The fast path compares file metadata, slow path is a 374 real comparison of file content.''' 375 376 # Note that we use localtip.manifest() here because of a bug in 377 # Mercurial 1.1.2's workingctx.__contains__ 378 if ((path in self.parenttip) != (path in self.localtip.manifest())): 379 return True 380 381 parentfile = self.parenttip.filectx(path) 382 localfile = self.localtip.filectx(path) 383 384 # 385 # NB: Keep these ordered such as to make every attempt 386 # to short-circuit the more time consuming checks. 387 # 388 if parentfile.size() != localfile.size(): 389 return True 390 391 if parentfile.flags() != localfile.flags(): 392 return True 393 394 if parentfile.cmp(localfile.data()): 395 return True 396 397 def context(self, message, user): 398 '''Return a Mercurial context object representing the entire 399 ActiveList as one change.''' 400 return activectx(self, message, user) 401 402 403class activectx(context.memctx): 404 '''Represent an ActiveList as a Mercurial context object. 405 406 Part of the WorkSpace.squishdeltas implementation.''' 407 408 def __init__(self, active, message, user): 409 '''Build an activectx object. 410 411 active - The ActiveList object used as the source for all data. 412 message - Changeset description 413 user - Committing user''' 414 415 def filectxfn(repository, ctx, fname): 416 fctx = active.localtip.filectx(fname) 417 data = fctx.data() 418 419 # 420 # .hgtags is a special case, tags referring to active list 421 # component changesets should be elided. 422 # 423 if fname == '.hgtags': 424 data = '\n'.join(active.prune_tags(data.splitlines())) 425 426 return context.memfilectx(fname, data, 'l' in fctx.flags(), 427 'x' in fctx.flags(), 428 active[fname].parentname) 429 430 self.__active = active 431 parents = (active.parenttip.node(), node.nullid) 432 extra = {'branch': active.localtip.branch()} 433 context.memctx.__init__(self, active.ws.repo, parents, message, 434 active.files(), filectxfn, user=user, 435 extra=extra) 436 437 def modified(self): 438 return [entry.name for entry in self.__active if entry.is_modified()] 439 440 def added(self): 441 return [entry.name for entry in self.__active if entry.is_added()] 442 443 def removed(self): 444 ret = set(entry.name for entry in self.__active if entry.is_removed()) 445 ret.update(set(x.parentname for x in self.__active if x.is_renamed())) 446 return list(ret) 447 448 def files(self): 449 return self.__active.files() 450 451 452class WorkSpace(object): 453 454 def __init__(self, repository): 455 self.repo = repository 456 self.ui = self.repo.ui 457 self.name = self.repo.root 458 459 self.activecache = {} 460 461 def parent(self, spec=None): 462 '''Return the canonical workspace parent, either SPEC (which 463 will be expanded) if provided or the default parent 464 otherwise.''' 465 466 if spec: 467 return self.ui.expandpath(spec) 468 469 p = self.ui.expandpath('default') 470 if p == 'default': 471 return None 472 else: 473 return p 474 475 def _localtip(self, outgoing, wctx): 476 '''Return the most representative changeset to act as the 477 localtip. 478 479 If the working directory is modified (has file changes, is a 480 merge, or has switched branches), this will be a workingctx. 481 482 If the working directory is unmodified, this will be the most 483 recent (highest revision number) local (outgoing) head on the 484 current branch, if no heads are determined to be outgoing, it 485 will be the most recent head on the current branch. 486 ''' 487 488 # 489 # A modified working copy is seen as a proto-branch, and thus 490 # our only option as the local tip. 491 # 492 if (wctx.files() or len(wctx.parents()) > 1 or 493 wctx.branch() != wctx.parents()[0].branch()): 494 return wctx 495 496 heads = self.repo.heads(start=wctx.parents()[0].node()) 497 headctxs = [self.repo.changectx(n) for n in heads] 498 localctxs = [c for c in headctxs if c.node() in outgoing] 499 500 ltip = sorted(localctxs or headctxs, key=lambda x: x.rev())[-1] 501 502 if len(heads) > 1: 503 self.ui.warn('The current branch has more than one head, ' 504 'using %s\n' % ltip.rev()) 505 506 return ltip 507 508 def _parenttip(self, heads, outgoing): 509 '''Return the highest-numbered, non-outgoing changeset that is 510 an ancestor of a changeset in heads. 511 512 This is intended to find the most recent changeset on a given 513 branch that is shared between a parent and child workspace, 514 such that it can act as a stand-in for the parent workspace. 515 ''' 516 517 def tipmost_shared(head, outnodes): 518 '''Return the tipmost node on the same branch as head that is not 519 in outnodes. 520 521 We walk from head to the bottom of the workspace (revision 522 0) collecting nodes not in outnodes during the add phase 523 and return the first node we see in the iter phase that 524 was previously collected. 525 526 If no node is found (all revisions >= 0 are outgoing), the 527 only possible parenttip is the null node (node.nullid) 528 which is returned explicitly. 529 530 See the docstring of mercurial.cmdutil.walkchangerevs() 531 for the phased approach to the iterator returned. The 532 important part to note is that the 'add' phase gathers 533 nodes, which the 'iter' phase then iterates through.''' 534 535 opts = {'rev': ['%s:0' % head.rev()], 536 'follow': True} 537 get = util.cachefunc(lambda r: self.repo.changectx(r).changeset()) 538 changeiter = cmdutil.walkchangerevs(self.repo.ui, self.repo, [], 539 get, opts)[0] 540 seen = [] 541 for st, rev, fns in changeiter: 542 n = self.repo.changelog.node(rev) 543 if st == 'add': 544 if n not in outnodes: 545 seen.append(n) 546 elif st == 'iter': 547 if n in seen: 548 return rev 549 return self.repo.changelog.rev(node.nullid) 550 551 nodes = set(outgoing) 552 ptips = map(lambda x: tipmost_shared(x, nodes), heads) 553 return self.repo.changectx(sorted(ptips)[-1]) 554 555 def status(self, base='.', head=None): 556 '''Translate from the hg 6-tuple status format to a hash keyed 557 on change-type''' 558 559 states = ['modified', 'added', 'removed', 'deleted', 'unknown', 560 'ignored'] 561 562 chngs = self.repo.status(base, head) 563 return dict(zip(states, chngs)) 564 565 def findoutgoing(self, parent): 566 '''Return the base set of outgoing nodes. 567 568 A caching wrapper around mercurial.localrepo.findoutgoing(). 569 Complains (to the user), if the parent workspace is 570 non-existent or inaccessible''' 571 572 self.ui.pushbuffer() 573 try: 574 try: 575 ui = self.ui 576 if hasattr(cmdutil, 'remoteui'): 577 ui = cmdutil.remoteui(ui, {}) 578 pws = hg.repository(ui, parent) 579 return self.repo.findoutgoing(pws) 580 except HgRepoError: 581 self.ui.warn("Warning: Parent workspace '%s' is not " 582 "accessible\n" 583 "active list will be incomplete\n\n" % parent) 584 return [] 585 finally: 586 self.ui.popbuffer() 587 findoutgoing = util.cachefunc(findoutgoing) 588 589 def modified(self): 590 '''Return a list of files modified in the workspace''' 591 wctx = self.workingctx() 592 return sorted(wctx.files() + wctx.deleted()) or None 593 594 def merged(self): 595 '''Return boolean indicating whether the workspace has an uncommitted 596 merge''' 597 wctx = self.workingctx() 598 return len(wctx.parents()) > 1 599 600 def branched(self): 601 '''Return boolean indicating whether the workspace has an 602 uncommitted named branch''' 603 604 wctx = self.workingctx() 605 return wctx.branch() != wctx.parents()[0].branch() 606 607 def active(self, parent=None): 608 '''Return an ActiveList describing changes between workspace 609 and parent workspace (including uncommitted changes). 610 If workspace has no parent ActiveList will still describe any 611 uncommitted changes''' 612 613 parent = self.parent(parent) 614 if parent in self.activecache: 615 return self.activecache[parent] 616 617 if parent: 618 outgoing = self.findoutgoing(parent) 619 outnodes = self.repo.changelog.nodesbetween(outgoing)[0] 620 else: 621 outgoing = [] # No parent, no outgoing nodes 622 outnodes = [] 623 624 localtip = self._localtip(outnodes, self.workingctx()) 625 626 if localtip.rev() is None: 627 heads = localtip.parents() 628 else: 629 heads = [localtip] 630 631 ctxs = [self.repo.changectx(n) for n in 632 self.repo.changelog.nodesbetween(outgoing, 633 [h.node() for h in heads])[0]] 634 635 if localtip.rev() is None: 636 ctxs.append(localtip) 637 638 act = ActiveList(self, self._parenttip(heads, outnodes), ctxs) 639 640 self.activecache[parent] = act 641 return act 642 643 def pdiff(self, pats, opts, parent=None): 644 'Return diffs relative to PARENT, as best as we can make out' 645 646 parent = self.parent(parent) 647 act = self.active(parent) 648 649 # 650 # act.localtip maybe nil, in the case of uncommitted local 651 # changes. 652 # 653 if not act.revs: 654 return 655 656 matchfunc = cmdutil.match(self.repo, pats, opts) 657 opts = patch.diffopts(self.ui, opts) 658 659 return self.diff(act.parenttip.node(), act.localtip.node(), 660 match=matchfunc, opts=opts) 661 662 def squishdeltas(self, active, message, user=None): 663 '''Create a single conglomerate changeset based on a given 664 active list. Removes the original changesets comprising the 665 given active list, and any tags pointing to them. 666 667 Operation: 668 669 - Commit an activectx object representing the specified 670 active list, 671 672 - Remove any local tags pointing to changesets in the 673 specified active list. 674 675 - Remove the changesets comprising the specified active 676 list. 677 678 - Remove any metadata that may refer to changesets that were 679 removed. 680 681 Calling code is expected to hold both the working copy lock 682 and repository lock of the destination workspace 683 ''' 684 685 def strip_local_tags(active): 686 '''Remove any local tags referring to the specified nodes.''' 687 688 if os.path.exists(self.repo.join('localtags')): 689 fh = None 690 try: 691 fh = self.repo.opener('localtags') 692 tags = active.prune_tags(fh) 693 fh.close() 694 695 fh = self.repo.opener('localtags', 'w', atomictemp=True) 696 fh.writelines(tags) 697 fh.rename() 698 finally: 699 if fh and not fh.closed: 700 fh.close() 701 702 if active.files(): 703 for entry in active: 704 # 705 # Work around Mercurial issue #1666, if the source 706 # file of a rename exists in the working copy 707 # Mercurial will complain, and remove the file. 708 # 709 # We preemptively remove the file to avoid the 710 # complaint (the user was asked about this in 711 # cdm_recommit) 712 # 713 if entry.is_renamed(): 714 path = self.repo.wjoin(entry.parentname) 715 if os.path.exists(path): 716 os.unlink(path) 717 718 self.repo.commitctx(active.context(message, user)) 719 wsstate = "recommitted" 720 destination = self.repo.changelog.tip() 721 else: 722 # 723 # If all we're doing is stripping the old nodes, we want to 724 # update the working copy such that we're not at a revision 725 # that's about to go away. 726 # 727 wsstate = "tip" 728 destination = active.parenttip.node() 729 730 self.clean(destination) 731 732 # 733 # Tags were elided by the activectx object. Local tags, 734 # however, must be removed manually. 735 # 736 try: 737 strip_local_tags(active) 738 except EnvironmentError, e: 739 raise util.Abort('Could not recommit tags: %s\n' % e) 740 741 # Silence all the strip and update fun 742 self.ui.pushbuffer() 743 744 # 745 # Remove the active lists component changesets by stripping 746 # the base of any active branch (of which there may be 747 # several) 748 # 749 try: 750 try: 751 for base in active.bases(): 752 # 753 # Any cached information about the repository is 754 # likely to be invalid during the strip. The 755 # caching of branch tags is especially 756 # problematic. 757 # 758 self.repo.invalidate() 759 repair.strip(self.ui, self.repo, base.node(), backup=False) 760 except: 761 # 762 # If this fails, it may leave us in a surprising place in 763 # the history. 764 # 765 # We want to warn the user that something went wrong, 766 # and what will happen next, re-raise the exception, and 767 # bring the working copy back into a consistent state 768 # (which the finally block will do) 769 # 770 self.ui.warn("stripping failed, your workspace will have " 771 "superfluous heads.\n" 772 "your workspace has been updated to the " 773 "%s changeset.\n" % wsstate) 774 raise # Re-raise the exception 775 finally: 776 self.clean() 777 self.repo.dirstate.write() # Flush the dirstate 778 self.repo.invalidate() # Invalidate caches 779 780 # 781 # We need to remove Hg's undo information (used for rollback), 782 # since it refers to data that will probably not exist after 783 # the strip. 784 # 785 if os.path.exists(self.repo.sjoin('undo')): 786 try: 787 os.unlink(self.repo.sjoin('undo')) 788 except EnvironmentError, e: 789 raise util.Abort('failed to remove undo data: %s\n' % e) 790 791 self.ui.popbuffer() 792 793 def filepath(self, path): 794 'Return the full path to a workspace file.' 795 return self.repo.pathto(path) 796 797 def clean(self, rev=None): 798 '''Bring workspace up to REV (or tip) forcefully (discarding in 799 progress changes)''' 800 801 if rev != None: 802 rev = self.repo.lookup(rev) 803 else: 804 rev = self.repo.changelog.tip() 805 806 hg.clean(self.repo, rev, show_stats=False) 807 808 def mq_applied(self): 809 '''True if the workspace has Mq patches applied''' 810 q = mq.queue(self.ui, self.repo.join('')) 811 return q.applied 812 813 def workingctx(self): 814 return self.repo.changectx(None) 815 816 def diff(self, node1=None, node2=None, match=None, opts=None): 817 ret = cStringIO.StringIO() 818 try: 819 for chunk in patch.diff(self.repo, node1, node2, match=match, 820 opts=opts): 821 ret.write(chunk) 822 finally: 823 # Workaround Hg bug 1651 824 if not Version.at_least("1.3"): 825 self.repo.dirstate.invalidate() 826 827 return ret.getvalue() 828