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 2009 Sun Microsystems, Inc. All rights reserved. 18# Use is subject to license terms. 19# 20# Copyright 2008, 2011, Richard Lowe 21# 22 23 24''' 25Workspace backup 26 27Backup format is: 28 backupdir/ 29 wsname/ 30 generation#/ 31 dirstate (handled by CdmUncommittedBackup) 32 File containing dirstate nodeid (the changeset we need 33 to update the workspace to after applying the bundle). 34 This is the node to which the working copy changes 35 (see 'diff', below) will be applied if applicable. 36 37 bundle (handled by CdmCommittedBackup) 38 An Hg bundle containing outgoing committed changes. 39 40 nodes (handled by CdmCommittedBackup) 41 A text file listing the full (hex) nodeid of all nodes in 42 bundle, used by need_backup. 43 44 diff (handled by CdmUncommittedBackup) 45 A Git-formatted diff containing uncommitted changes. 46 47 renames (handled by CdmUncommittedBackup) 48 A list of renames in the working copy that have to be 49 applied manually, rather than by the diff. 50 51 metadata.tar.gz (handled by CdmMetadataBackup) 52 $CODEMGR_WS/.hg/hgrc 53 $CODEMGR_WS/.hg/localtags 54 $CODEMGR_WS/.hg/patches (Mq data) 55 56 clear.tar.gz (handled by CdmClearBackup) 57 <short node>/ 58 copies of each modified or added file, as it is in 59 this head. 60 61 ... for each outgoing head 62 63 working/ 64 copies of each modified or added file in the 65 working copy if any. 66 67 latest -> generation# 68 Newest backup generation. 69 70All files in a given backup generation, with the exception of 71dirstate, are optional. 72''' 73 74import grp, os, pwd, shutil, tarfile, time, traceback 75from cStringIO import StringIO 76 77from mercurial import changegroup, cmdutil, error, node, patch, util 78from onbld.Scm import Version 79 80 81class CdmNodeMissing(util.Abort): 82 '''a required node is not present in the destination workspace. 83 84 This may occur both in the case where the bundle contains a 85 changeset which is a child of a node not present in the 86 destination workspace (because the destination workspace is not as 87 up-to-date as the source), or because the source and destination 88 workspace are not related. 89 90 It may also happen in cases where the uncommitted changes need to 91 be applied onto a node that the workspace does not possess even 92 after application of the bundle (on a branch not present 93 in the bundle or destination workspace, for instance)''' 94 95 def __init__(self, msg, name): 96 # 97 # If e.name is a string 20 characters long, it is 98 # assumed to be a node. (Mercurial makes this 99 # same assumption, when creating a LookupError) 100 # 101 if isinstance(name, str) and len(name) == 20: 102 n = node.short(name) 103 else: 104 n = name 105 106 util.Abort.__init__(self, "%s: changeset '%s' is missing\n" 107 "Your workspace is either not " 108 "sufficiently up to date,\n" 109 "or is unrelated to the workspace from " 110 "which the backup was taken.\n" % (msg, n)) 111 112 113class CdmTarFile(tarfile.TarFile): 114 '''Tar file access + simple comparison to the filesystem, and 115 creation addition of files from Mercurial filectx objects.''' 116 117 def __init__(self, *args, **kwargs): 118 tarfile.TarFile.__init__(self, *args, **kwargs) 119 self.errorlevel = 2 120 121 def members_match_fs(self, rootpath): 122 '''Compare the contents of the tar archive to the directory 123 specified by rootpath. Return False if they differ. 124 125 Every file in the archive must match the equivalent file in 126 the filesystem. 127 128 The existence, modification time, and size of each file are 129 compared, content is not.''' 130 131 def _member_matches_fs(member, rootpath): 132 '''Compare a single member to its filesystem counterpart''' 133 fpath = os.path.join(rootpath, member.name) 134 135 if not os.path.exists(fpath): 136 return False 137 elif ((os.path.isfile(fpath) != member.isfile()) or 138 (os.path.isdir(fpath) != member.isdir()) or 139 (os.path.islink(fpath) != member.issym())): 140 return False 141 142 # 143 # The filesystem may return a modification time with a 144 # fractional component (as a float), whereas the tar format 145 # only stores it to the whole second, perform the comparison 146 # using integers (truncated, not rounded) 147 # 148 elif member.mtime != int(os.path.getmtime(fpath)): 149 return False 150 elif not member.isdir() and member.size != os.path.getsize(fpath): 151 return False 152 else: 153 return True 154 155 for elt in self: 156 if not _member_matches_fs(elt, rootpath): 157 return False 158 159 return True 160 161 def addfilectx(self, filectx, path=None): 162 '''Add a filectx object to the archive. 163 164 Use the path specified by the filectx object or, if specified, 165 the PATH argument. 166 167 The size, modification time, type and permissions of the tar 168 member are taken from the filectx object, user and group id 169 are those of the invoking user, user and group name are those 170 of the invoking user if information is available, or "unknown" 171 if it is not. 172 ''' 173 174 t = tarfile.TarInfo(path or filectx.path()) 175 t.size = filectx.size() 176 t.mtime = filectx.date()[0] 177 t.uid = os.getuid() 178 t.gid = os.getgid() 179 180 try: 181 t.uname = pwd.getpwuid(t.uid).pw_name 182 except KeyError: 183 t.uname = "unknown" 184 185 try: 186 t.gname = grp.getgrgid(t.gid).gr_name 187 except KeyError: 188 t.gname = "unknown" 189 190 # 191 # Mercurial versions symlinks by setting a flag and storing 192 # the destination path in place of the file content. The 193 # actual contents (in the tar), should be empty. 194 # 195 if 'l' in filectx.flags(): 196 t.type = tarfile.SYMTYPE 197 t.mode = 0777 198 t.linkname = filectx.data() 199 data = None 200 else: 201 t.type = tarfile.REGTYPE 202 t.mode = 'x' in filectx.flags() and 0755 or 0644 203 data = StringIO(filectx.data()) 204 205 self.addfile(t, data) 206 207 208class CdmCommittedBackup(object): 209 '''Backup of committed changes''' 210 211 def __init__(self, backup, ws): 212 self.ws = ws 213 self.bu = backup 214 self.files = ('bundle', 'nodes') 215 216 def _outgoing_nodes(self, parent): 217 '''Return a list of all outgoing nodes in hex format''' 218 219 if parent: 220 outgoing = self.ws.findoutgoing(parent) 221 nodes = self.ws.repo.changelog.nodesbetween(outgoing)[0] 222 return map(node.hex, nodes) 223 else: 224 return [] 225 226 def backup(self): 227 '''Backup committed changes''' 228 parent = self.ws.parent() 229 230 if not parent: 231 self.ws.ui.warn('Workspace has no parent, committed changes will ' 232 'not be backed up\n') 233 return 234 235 out = self.ws.findoutgoing(parent) 236 if not out: 237 return 238 239 cg = self.ws.repo.changegroup(out, 'bundle') 240 changegroup.writebundle(cg, self.bu.backupfile('bundle'), 'HG10BZ') 241 242 outnodes = self._outgoing_nodes(parent) 243 if not outnodes: 244 return 245 246 fp = None 247 try: 248 try: 249 fp = self.bu.open('nodes', 'w') 250 fp.write('%s\n' % '\n'.join(outnodes)) 251 except EnvironmentError, e: 252 raise util.Abort("couldn't store outgoing nodes: %s" % e) 253 finally: 254 if fp and not fp.closed: 255 fp.close() 256 257 def restore(self): 258 '''Restore committed changes from backup''' 259 260 if not self.bu.exists('bundle'): 261 return 262 263 bpath = self.bu.backupfile('bundle') 264 f = None 265 try: 266 try: 267 f = self.bu.open('bundle') 268 bundle = changegroup.readbundle(f, bpath) 269 self.ws.repo.addchangegroup(bundle, 'strip', 270 'bundle:%s' % bpath) 271 except EnvironmentError, e: 272 raise util.Abort("couldn't restore committed changes: %s\n" 273 " %s" % (bpath, e)) 274 except error.LookupError, e: 275 raise CdmNodeMissing("couldn't restore committed changes", 276 e.name) 277 finally: 278 if f and not f.closed: 279 f.close() 280 281 def need_backup(self): 282 '''Compare backup of committed changes to workspace''' 283 284 if self.bu.exists('nodes'): 285 f = None 286 try: 287 try: 288 f = self.bu.open('nodes') 289 bnodes = set(line.rstrip('\r\n') for line in f.readlines()) 290 f.close() 291 except EnvironmentError, e: 292 raise util.Abort("couldn't open backup node list: %s" % e) 293 finally: 294 if f and not f.closed: 295 f.close() 296 else: 297 bnodes = set() 298 299 outnodes = set(self._outgoing_nodes(self.ws.parent())) 300 301 # 302 # If there are outgoing nodes not in the prior backup we need 303 # to take a new backup; it's fine if there are nodes in the 304 # old backup which are no longer outgoing, however. 305 # 306 if not outnodes <= bnodes: 307 return True 308 309 return False 310 311 def cleanup(self): 312 '''Remove backed up committed changes''' 313 314 for f in self.files: 315 self.bu.unlink(f) 316 317 318class CdmUncommittedBackup(object): 319 '''Backup of uncommitted changes''' 320 321 def __init__(self, backup, ws): 322 self.ws = ws 323 self.bu = backup 324 self.wctx = self.ws.workingctx(worklist=True) 325 326 def _clobbering_renames(self): 327 '''Return a list of pairs of files representing renames/copies 328 that clobber already versioned files. [(old-name new-name)...] 329 ''' 330 331 # 332 # Note that this doesn't handle uncommitted merges 333 # as CdmUncommittedBackup itself doesn't. 334 # 335 parent = self.wctx.parents()[0] 336 337 ret = [] 338 for fname in self.wctx.added() + self.wctx.modified(): 339 rn = self.wctx.filectx(fname).renamed() 340 if rn and fname in parent: 341 ret.append((rn[0], fname)) 342 return ret 343 344 def backup(self): 345 '''Backup uncommitted changes''' 346 347 if self.ws.merged(): 348 raise util.Abort("Unable to backup an uncommitted merge.\n" 349 "Please complete your merge and commit") 350 351 dirstate = node.hex(self.wctx.parents()[0].node()) 352 353 fp = None 354 try: 355 try: 356 fp = self.bu.open('dirstate', 'w') 357 fp.write(dirstate + '\n') 358 fp.close() 359 except EnvironmentError, e: 360 raise util.Abort("couldn't save working copy parent: %s" % e) 361 362 try: 363 fp = self.bu.open('renames', 'w') 364 for cons in self._clobbering_renames(): 365 fp.write("%s %s\n" % cons) 366 fp.close() 367 except EnvironmentError, e: 368 raise util.Abort("couldn't save clobbering copies: %s" % e) 369 370 try: 371 fp = self.bu.open('diff', 'w') 372 match = self.ws.matcher(files=self.wctx.files()) 373 fp.write(self.ws.diff(opts={'git': True}, match=match)) 374 except EnvironmentError, e: 375 raise util.Abort("couldn't save working copy diff: %s" % e) 376 finally: 377 if fp and not fp.closed: 378 fp.close() 379 380 def _dirstate(self): 381 '''Return the desired working copy node from the backup''' 382 fp = None 383 try: 384 try: 385 fp = self.bu.open('dirstate') 386 dirstate = fp.readline().strip() 387 except EnvironmentError, e: 388 raise util.Abort("couldn't read saved parent: %s" % e) 389 finally: 390 if fp and not fp.closed: 391 fp.close() 392 393 return dirstate 394 395 def restore(self): 396 '''Restore uncommitted changes''' 397 dirstate = self._dirstate() 398 399 # 400 # Check that the patch's parent changeset exists. 401 # 402 try: 403 n = node.bin(dirstate) 404 self.ws.repo.changelog.lookup(n) 405 except error.LookupError, e: 406 raise CdmNodeMissing("couldn't restore uncommitted changes", 407 e.name) 408 409 try: 410 self.ws.clean(rev=dirstate) 411 except util.Abort, e: 412 raise util.Abort("couldn't update to saved node: %s" % e) 413 414 if not self.bu.exists('diff'): 415 return 416 417 # 418 # There's a race here whereby if the patch (or part thereof) 419 # is applied within the same second as the clean above (such 420 # that modification time doesn't change) and if the size of 421 # that file does not change, Hg may not see the change. 422 # 423 # We sleep a full second to avoid this, as sleeping merely 424 # until the next second begins would require very close clock 425 # synchronization on network filesystems. 426 # 427 time.sleep(1) 428 429 files = {} 430 try: 431 diff = self.bu.backupfile('diff') 432 try: 433 fuzz = patch.patch(diff, self.ws.ui, strip=1, 434 cwd=self.ws.repo.root, files=files) 435 if fuzz: 436 raise util.Abort('working copy diff applied with fuzz') 437 except Exception, e: 438 raise util.Abort("couldn't apply working copy diff: %s\n" 439 " %s" % (diff, e)) 440 finally: 441 if Version.at_least("1.7"): 442 cmdutil.updatedir(self.ws.ui, self.ws.repo, files) 443 else: 444 patch.updatedir(self.ws.ui, self.ws.repo, files) 445 446 if not self.bu.exists('renames'): 447 return 448 449 # 450 # We need to re-apply name changes where the new name 451 # (rename/copy destination) is an already versioned file, as 452 # Hg would otherwise ignore them. 453 # 454 try: 455 fp = self.bu.open('renames') 456 for line in fp: 457 source, dest = line.strip().split() 458 self.ws.copy(source, dest) 459 except EnvironmentError, e: 460 raise util.Abort('unable to open renames file: %s' % e) 461 except ValueError: 462 raise util.Abort('corrupt renames file: %s' % 463 self.bu.backupfile('renames')) 464 465 def need_backup(self): 466 '''Compare backup of uncommitted changes to workspace''' 467 cnode = self.wctx.parents()[0].node() 468 if self._dirstate() != node.hex(cnode): 469 return True 470 471 fd = None 472 match = self.ws.matcher(files=self.wctx.files()) 473 curdiff = self.ws.diff(opts={'git': True}, match=match) 474 475 try: 476 if self.bu.exists('diff'): 477 try: 478 fd = self.bu.open('diff') 479 backdiff = fd.read() 480 fd.close() 481 except EnvironmentError, e: 482 raise util.Abort("couldn't open backup diff %s\n" 483 " %s" % (self.bu.backupfile('diff'), e)) 484 else: 485 backdiff = '' 486 487 if backdiff != curdiff: 488 return True 489 490 currrenamed = self._clobbering_renames() 491 bakrenamed = None 492 493 if self.bu.exists('renames'): 494 try: 495 fd = self.bu.open('renames') 496 bakrenamed = [tuple(line.strip().split(' ')) for line in fd] 497 fd.close() 498 except EnvironmentError, e: 499 raise util.Abort("couldn't open renames file %s: %s\n" % 500 (self.bu.backupfile('renames'), e)) 501 502 if currrenamed != bakrenamed: 503 return True 504 finally: 505 if fd and not fd.closed: 506 fd.close() 507 508 return False 509 510 def cleanup(self): 511 '''Remove backed up uncommitted changes''' 512 513 for f in ('dirstate', 'diff', 'renames'): 514 self.bu.unlink(f) 515 516 517class CdmMetadataBackup(object): 518 '''Backup of workspace metadata''' 519 520 def __init__(self, backup, ws): 521 self.bu = backup 522 self.ws = ws 523 self.files = ('hgrc', 'localtags', 'patches', 'cdm') 524 525 def backup(self): 526 '''Backup workspace metadata''' 527 528 tarpath = self.bu.backupfile('metadata.tar.gz') 529 530 # 531 # Files is a list of tuples (name, path), where name is as in 532 # self.files, and path is the absolute path. 533 # 534 files = filter(lambda (name, path): os.path.exists(path), 535 zip(self.files, map(self.ws.repo.join, self.files))) 536 537 if not files: 538 return 539 540 try: 541 tar = CdmTarFile.gzopen(tarpath, 'w') 542 except (EnvironmentError, tarfile.TarError), e: 543 raise util.Abort("couldn't open %s for writing: %s" % 544 (tarpath, e)) 545 546 try: 547 for name, path in files: 548 try: 549 tar.add(path, name) 550 except (EnvironmentError, tarfile.TarError), e: 551 # 552 # tarfile.TarError doesn't include the tar member or file 553 # in question, so we have to do so ourselves. 554 # 555 if isinstance(e, tarfile.TarError): 556 errstr = "%s: %s" % (name, e) 557 else: 558 errstr = str(e) 559 560 raise util.Abort("couldn't backup metadata to %s:\n" 561 " %s" % (tarpath, errstr)) 562 finally: 563 tar.close() 564 565 def old_restore(self): 566 '''Restore workspace metadata from an pre-tar backup''' 567 568 for fname in self.files: 569 if self.bu.exists(fname): 570 bfile = self.bu.backupfile(fname) 571 wfile = self.ws.repo.join(fname) 572 573 try: 574 shutil.copy2(bfile, wfile) 575 except EnvironmentError, e: 576 raise util.Abort("couldn't restore metadata from %s:\n" 577 " %s" % (bfile, e)) 578 579 def tar_restore(self): 580 '''Restore workspace metadata (from a tar-style backup)''' 581 582 if not self.bu.exists('metadata.tar.gz'): 583 return 584 585 tarpath = self.bu.backupfile('metadata.tar.gz') 586 587 try: 588 tar = CdmTarFile.gzopen(tarpath) 589 except (EnvironmentError, tarfile.TarError), e: 590 raise util.Abort("couldn't open %s: %s" % (tarpath, e)) 591 592 try: 593 for elt in tar: 594 try: 595 tar.extract(elt, path=self.ws.repo.path) 596 except (EnvironmentError, tarfile.TarError), e: 597 # Make sure the member name is in the exception message. 598 if isinstance(e, tarfile.TarError): 599 errstr = "%s: %s" % (elt.name, e) 600 else: 601 errstr = str(e) 602 603 raise util.Abort("couldn't restore metadata from %s:\n" 604 " %s" % 605 (tarpath, errstr)) 606 finally: 607 if tar and not tar.closed: 608 tar.close() 609 610 def restore(self): 611 '''Restore workspace metadata''' 612 613 if self.bu.exists('hgrc'): 614 self.old_restore() 615 else: 616 self.tar_restore() 617 618 def _walk(self): 619 '''Yield the repo-relative path to each file we operate on, 620 including each file within any affected directory''' 621 622 for elt in self.files: 623 path = self.ws.repo.join(elt) 624 625 if not os.path.exists(path): 626 continue 627 628 if os.path.isdir(path): 629 for root, dirs, files in os.walk(path, topdown=True): 630 yield root 631 632 for f in files: 633 yield os.path.join(root, f) 634 else: 635 yield path 636 637 def need_backup(self): 638 '''Compare backed up workspace metadata to workspace''' 639 640 def strip_trailing_pathsep(pathname): 641 '''Remove a possible trailing path separator from PATHNAME''' 642 return pathname.endswith('/') and pathname[:-1] or pathname 643 644 if self.bu.exists('metadata.tar.gz'): 645 tarpath = self.bu.backupfile('metadata.tar.gz') 646 try: 647 tar = CdmTarFile.gzopen(tarpath) 648 except (EnvironmentError, tarfile.TarError), e: 649 raise util.Abort("couldn't open metadata tarball: %s\n" 650 " %s" % (tarpath, e)) 651 652 if not tar.members_match_fs(self.ws.repo.path): 653 tar.close() 654 return True 655 656 tarnames = map(strip_trailing_pathsep, tar.getnames()) 657 tar.close() 658 else: 659 tarnames = [] 660 661 repopath = self.ws.repo.path 662 if not repopath.endswith('/'): 663 repopath += '/' 664 665 for path in self._walk(): 666 if path.replace(repopath, '', 1) not in tarnames: 667 return True 668 669 return False 670 671 def cleanup(self): 672 '''Remove backed up workspace metadata''' 673 self.bu.unlink('metadata.tar.gz') 674 675 676class CdmClearBackup(object): 677 '''A backup (in tar format) of complete source files from every 678 workspace head. 679 680 Paths in the tarball are prefixed by the revision and node of the 681 head, or "working" for the working directory. 682 683 This is done purely for the benefit of the user, and as such takes 684 no part in restore or need_backup checking, restore always 685 succeeds, need_backup always returns False 686 ''' 687 688 def __init__(self, backup, ws): 689 self.bu = backup 690 self.ws = ws 691 692 def _branch_pairs(self): 693 '''Return a list of tuples (parenttip, localtip) for each 694 outgoing head. If the working copy contains modified files, 695 it is a head, and neither of its parents are. 696 ''' 697 698 parent = self.ws.parent() 699 700 if parent: 701 outgoing = self.ws.findoutgoing(parent) 702 outnodes = set(self.ws.repo.changelog.nodesbetween(outgoing)[0]) 703 704 heads = [self.ws.repo.changectx(n) for n in self.ws.repo.heads() 705 if n in outnodes] 706 else: 707 heads = [] 708 outnodes = [] 709 710 wctx = self.ws.workingctx() 711 if wctx.files(): # We only care about file changes. 712 heads = filter(lambda x: x not in wctx.parents(), heads) + [wctx] 713 714 pairs = [] 715 for head in heads: 716 if head.rev() is None: 717 c = head.parents() 718 else: 719 c = [head] 720 721 pairs.append((self.ws.parenttip(c, outnodes), head)) 722 return pairs 723 724 def backup(self): 725 '''Save a clear copy of each source file modified between each 726 head and that head's parenttip (see WorkSpace.parenttip). 727 ''' 728 729 tarpath = self.bu.backupfile('clear.tar.gz') 730 branches = self._branch_pairs() 731 732 if not branches: 733 return 734 735 try: 736 tar = CdmTarFile.gzopen(tarpath, 'w') 737 except (EnvironmentError, tarfile.TarError), e: 738 raise util.Abort("Could not open %s for writing: %s" % 739 (tarpath, e)) 740 741 try: 742 for parent, child in branches: 743 tpath = child.node() and node.short(child.node()) or "working" 744 745 for fname, change in self.ws.status(parent, child).iteritems(): 746 if change not in ('added', 'modified'): 747 continue 748 749 try: 750 tar.addfilectx(child.filectx(fname), 751 os.path.join(tpath, fname)) 752 except ValueError, e: 753 crev = child.rev() 754 if crev is None: 755 crev = "working copy" 756 raise util.Abort("Could not backup clear file %s " 757 "from %s: %s\n" % (fname, crev, e)) 758 finally: 759 tar.close() 760 761 def cleanup(self): 762 '''Cleanup a failed Clear backup. 763 764 Remove the clear tarball from the backup directory. 765 ''' 766 767 self.bu.unlink('clear.tar.gz') 768 769 def restore(self): 770 '''Clear backups are never restored, do nothing''' 771 pass 772 773 def need_backup(self): 774 '''Clear backups are never compared, return False (no backup needed). 775 776 Should a backup actually be needed, one of the other 777 implementation classes would notice in any situation we would. 778 ''' 779 780 return False 781 782 783class CdmBackup(object): 784 '''A backup of a given workspace''' 785 786 def __init__(self, ui, ws, name): 787 self.ws = ws 788 self.ui = ui 789 self.backupdir = self._find_backup_dir(name) 790 791 # 792 # The order of instances here controls the order the various operations 793 # are run. 794 # 795 # There's some inherent dependence, in that on restore we need 796 # to restore committed changes prior to uncommitted changes 797 # (as the parent revision of any uncommitted changes is quite 798 # likely to not exist until committed changes are restored). 799 # Metadata restore can happen at any point, but happens last 800 # as a matter of convention. 801 # 802 self.modules = [x(self, ws) for x in [CdmCommittedBackup, 803 CdmUncommittedBackup, 804 CdmClearBackup, 805 CdmMetadataBackup]] 806 807 if os.path.exists(os.path.join(self.backupdir, 'latest')): 808 generation = os.readlink(os.path.join(self.backupdir, 'latest')) 809 self.generation = int(os.path.split(generation)[1]) 810 else: 811 self.generation = 0 812 813 def _find_backup_dir(self, name): 814 '''Find the path to an appropriate backup directory based on NAME''' 815 816 if os.path.isabs(name): 817 return name 818 819 if self.ui.config('cdm', 'backupdir'): 820 backupbase = os.path.expanduser(self.ui.config('cdm', 'backupdir')) 821 else: 822 home = None 823 824 try: 825 home = os.getenv('HOME') or pwd.getpwuid(os.getuid()).pw_dir 826 except KeyError: 827 pass # Handled anyway 828 829 if not home: 830 raise util.Abort('Could not determine your HOME directory to ' 831 'find backup path') 832 833 backupbase = os.path.join(home, 'cdm.backup') 834 835 backupdir = os.path.join(backupbase, name) 836 837 # If backupdir exists, it must be a directory. 838 if (os.path.exists(backupdir) and not os.path.isdir(backupdir)): 839 raise util.Abort('%s exists but is not a directory' % backupdir) 840 841 return backupdir 842 843 def _update_latest(self, gen): 844 '''Update latest symlink to point to the current generation''' 845 linkpath = os.path.join(self.backupdir, 'latest') 846 847 if os.path.lexists(linkpath): 848 os.unlink(linkpath) 849 850 os.symlink(str(gen), linkpath) 851 852 def _create_gen(self, gen): 853 '''Create a new backup generation''' 854 try: 855 os.makedirs(os.path.join(self.backupdir, str(gen))) 856 self._update_latest(gen) 857 except EnvironmentError, e: 858 raise util.Abort("Couldn't create backup generation %s: %s" % 859 (os.path.join(self.backupdir, str(gen)), e)) 860 861 def backupfile(self, path): 862 '''return full path to backup file FILE at GEN''' 863 return os.path.join(self.backupdir, str(self.generation), path) 864 865 def unlink(self, name): 866 '''Unlink the specified path from the backup directory. 867 A no-op if the path does not exist. 868 ''' 869 870 fpath = self.backupfile(name) 871 if os.path.exists(fpath): 872 os.unlink(fpath) 873 874 def open(self, name, mode='r'): 875 '''Open the specified file in the backup directory''' 876 return open(self.backupfile(name), mode) 877 878 def exists(self, name): 879 '''Return boolean indicating wether a given file exists in the 880 backup directory.''' 881 return os.path.exists(self.backupfile(name)) 882 883 def need_backup(self): 884 '''Compare backed up changes to workspace''' 885 # 886 # If there's no current backup generation, or the last backup was 887 # invalid (lacking the dirstate file), we need a backup regardless 888 # of anything else. 889 # 890 if not self.generation or not self.exists('dirstate'): 891 return True 892 893 for x in self.modules: 894 if x.need_backup(): 895 return True 896 897 return False 898 899 def backup(self): 900 '''Take a backup of the current workspace 901 902 Calling code is expected to hold both the working copy lock 903 and repository lock.''' 904 905 if not os.path.exists(self.backupdir): 906 try: 907 os.makedirs(self.backupdir) 908 except EnvironmentError, e: 909 raise util.Abort('Could not create backup directory %s: %s' % 910 (self.backupdir, e)) 911 912 self.generation += 1 913 self._create_gen(self.generation) 914 915 try: 916 for x in self.modules: 917 x.backup() 918 except Exception, e: 919 if isinstance(e, KeyboardInterrupt): 920 self.ws.ui.warn("Interrupted\n") 921 else: 922 self.ws.ui.warn("Error: %s\n" % e) 923 show_traceback = self.ws.ui.configbool('ui', 'traceback', 924 False) 925 926 # 927 # If it's not a 'normal' error, we want to print a stack 928 # trace now in case the attempt to remove the partial 929 # backup also fails, and raises a second exception. 930 # 931 if (not isinstance(e, (EnvironmentError, util.Abort)) 932 or show_traceback): 933 traceback.print_exc() 934 935 for x in self.modules: 936 x.cleanup() 937 938 os.rmdir(os.path.join(self.backupdir, str(self.generation))) 939 self.generation -= 1 940 941 if self.generation != 0: 942 self._update_latest(self.generation) 943 else: 944 os.unlink(os.path.join(self.backupdir, 'latest')) 945 946 raise util.Abort('Backup failed') 947 948 def restore(self, gen=None): 949 '''Restore workspace from backup 950 951 Restores from backup generation GEN (defaulting to the latest) 952 into workspace WS. 953 954 Calling code is expected to hold both the working copy lock 955 and repository lock of the destination workspace.''' 956 957 if not os.path.exists(self.backupdir): 958 raise util.Abort('Backup directory does not exist: %s' % 959 (self.backupdir)) 960 961 if gen: 962 if not os.path.exists(os.path.join(self.backupdir, str(gen))): 963 raise util.Abort('Backup generation does not exist: %s' % 964 (os.path.join(self.backupdir, str(gen)))) 965 self.generation = int(gen) 966 967 if not self.generation: # This is OK, 0 is not a valid generation 968 raise util.Abort('Backup has no generations: %s' % self.backupdir) 969 970 if not self.exists('dirstate'): 971 raise util.Abort('Backup %s/%s is incomplete (dirstate missing)' % 972 (self.backupdir, self.generation)) 973 974 try: 975 for x in self.modules: 976 x.restore() 977 except util.Abort, e: 978 raise util.Abort('Error restoring workspace:\n' 979 '%s\n' 980 'Workspace may be partially restored' % e) 981