xref: /titanic_50/usr/src/tools/onbld/Scm/Backup.py (revision 76d1b5a97b0704bc67fbdcb9fede23c1587c2ec2)
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 2008 Sun Microsystems, Inc.  All rights reserved.
18# Use is subject to license terms.
19#
20# ident	"%Z%%M%	%I%	%E% SMI"
21#
22
23'''
24Workspace backup
25
26Backup format is:
27   backupdir/
28      wsname/
29         generation#/
30            dirstate (handled by CdmUncommittedBackup)
31                File containing dirstate nodeid (the tip we expect to be at
32                after applying the bundle).
33
34            bundle (handled by CdmCommittedBackup)
35                An Hg bundle containing outgoing committed changes.
36
37            nodes (handled by CdmCommittedBackup)
38                A text file listing the full (hex) nodeid of all nodes in
39                bundle, used by need_backup.
40
41            diff (handled by CdmUncommittedBackup)
42                A Git-formatted diff containing uncommitted changes.
43
44            renames (handled by CdmUncommittedBackup)
45                A list of renames in the working copy that have to be
46                applied manually, rather than by the diff.
47
48            metadata.tar.gz (handled by CdmMetadataBackup)
49                $CODEMGR_WS/.hg/hgrc
50                $CODEMGR_WS/.hg/localtags
51                $CODEMGR_WS/.hg/patches (Mq data)
52
53         latest -> generation#
54            Newest backup generation.
55
56All files in a given backup generation, with the exception of
57dirstate, are optional.
58'''
59
60import os, pwd, shutil, traceback, tarfile, time
61from mercurial import changegroup, patch, node, util
62from cStringIO import StringIO
63
64
65class CdmCommittedBackup(object):
66    '''Backup of committed changes'''
67
68    def __init__(self, backup, ws):
69        self.ws = ws
70        self.bu = backup
71        self.files = ('bundle', 'nodes')
72
73    def _outgoing_nodes(self, parent):
74        '''Return a list of all outgoing nodes in hex format'''
75
76        if parent:
77            outgoing = self.ws.findoutgoing(parent)
78            nodes = self.ws.repo.changelog.nodesbetween(outgoing)[0]
79            return map(node.hex, nodes)
80        else:
81            return []
82
83    def backup(self):
84        '''Backup committed changes'''
85        parent = self.ws.parent()
86
87        if not parent:
88            self.ws.ui.warn('Workspace has no parent, committed changes will '
89                            'not be backed up\n')
90            return
91
92        out = self.ws.findoutgoing(parent)
93        if not out:
94            return
95
96        cg = self.ws.repo.changegroup(out, 'bundle')
97        changegroup.writebundle(cg, self.bu.backupfile('bundle'), 'HG10BZ')
98
99        outnodes = self._outgoing_nodes(parent)
100        if outnodes:
101            fp = None
102            try:
103                try:
104                    fp = open(self.bu.backupfile('nodes'), 'w')
105                    fp.write('%s\n' % '\n'.join(outnodes))
106                except EnvironmentError, e:
107                    raise util.Abort("couldn't store outgoing nodes: %s" % e)
108            finally:
109                if fp and not fp.closed:
110                    fp.close()
111
112    def restore(self):
113        '''Restore committed changes from backup'''
114        bfile = self.bu.backupfile('bundle')
115
116        if os.path.exists(bfile):
117            f = None
118            try:
119                try:
120                    f = open(bfile, 'r')
121                    bundle = changegroup.readbundle(f, bfile)
122                    self.ws.repo.addchangegroup(bundle, 'strip',
123                                                'bundle:%s' % bfile)
124                except EnvironmentError, e:
125                    raise util.Abort("couldn't restore committed changes: %s\n"
126                                     "   %s" % (bfile, e))
127            finally:
128                if f and not f.closed:
129                    f.close()
130
131    def need_backup(self):
132        '''Compare backup of committed changes to workspace'''
133
134        if os.path.exists(self.bu.backupfile('nodes')):
135            f = None
136            try:
137                try:
138                    f = open(self.bu.backupfile('nodes'))
139                    bnodes = set([line.rstrip('\r\n')
140                                  for line in f.readlines()])
141                    f.close()
142                except EnvironmentError, e:
143                    raise util.Abort("couldn't open backup node list: %s" % e)
144            finally:
145                if f and not f.closed:
146                    f.close()
147        else:
148            bnodes = set()
149
150        outnodes = set(self._outgoing_nodes(self.ws.parent()))
151        if outnodes != bnodes:
152            return True
153
154        return False
155
156    def cleanup(self):
157        '''Remove backed up committed changes'''
158
159        for fname in self.files:
160            if os.path.exists(self.bu.backupfile(fname)):
161                os.unlink(self.bu.backupfile(fname))
162
163
164class CdmUncommittedBackup(object):
165    '''Backup of uncommitted changes'''
166
167    def __init__(self, backup, ws):
168        self.ws = ws
169        self.bu = backup
170
171    def _clobbering_renames(self):
172        '''Return a list of pairs of files representing renames/copies
173        that clobber already versioned files.  [(oldname newname)...]'''
174
175        #
176        # Note that this doesn't handle uncommitted merges
177        # as CdmUncommittedBackup itself doesn't.
178        #
179        wctx = self.ws.repo.workingctx()
180        parent = wctx.parents()[0]
181
182        ret = []
183        for fname in wctx.added() + wctx.modified():
184            rn = wctx.filectx(fname).renamed()
185            if rn and fname in parent:
186                ret.append((rn[0], fname))
187        return ret
188
189    def backup(self):
190        '''Backup uncommitted changes'''
191
192        if self.ws.merged():
193            raise util.Abort("Unable to backup an uncommitted merge.\n"
194                             "Please complete your merge and commit")
195
196        dirstate = node.hex(self.ws.repo.changectx().node())
197
198        fp = None
199        try:
200            try:
201                fp = open(self.bu.backupfile('dirstate'), 'w')
202                fp.write(dirstate + '\n')
203            except EnvironmentError, e:
204                raise util.Abort("couldn't save working copy parent: %s" % e)
205        finally:
206            if fp and not fp.closed:
207                fp.close()
208
209        try:
210            try:
211                fp = open(self.bu.backupfile('renames'), 'w')
212                for cons in self._clobbering_renames():
213                    fp.write("%s %s\n" % cons)
214            except EnvironmentError, e:
215                raise util.Abort("couldn't save clobbering copies: %s" % e)
216        finally:
217            if fp and not fp.closed:
218                fp.close()
219
220        try:
221            try:
222                fp = open(self.bu.backupfile('diff'), 'w')
223                patch.diff(self.ws.repo, fp=fp,
224                           opts=patch.diffopts(self.ws.ui, opts={'git': True}))
225            except EnvironmentError, e:
226                raise util.Abort("couldn't save working copy diff: %s" % e)
227        finally:
228            if fp and not fp.closed:
229                fp.close()
230
231    def _dirstate(self):
232        '''Return the current working copy node'''
233        fp = None
234        try:
235            try:
236                fp = open(self.bu.backupfile('dirstate'))
237                dirstate = fp.readline().strip()
238                return dirstate
239            except EnvironmentError, e:
240                raise util.Abort("couldn't read saved parent: %s" % e)
241        finally:
242            if fp and not fp.closed:
243                fp.close()
244
245    def restore(self):
246        '''Restore uncommitted changes'''
247        diff = self.bu.backupfile('diff')
248        dirstate = self._dirstate()
249
250        try:
251            self.ws.clean(rev=dirstate)
252        except util.Abort, e:
253            raise util.Abort("couldn't update to saved node: %s" % e)
254
255        if not os.path.exists(diff):
256            return
257
258        #
259        # There's a race here whereby if the patch (or part thereof)
260        # is applied within the same second as the clean above (such
261        # that mtime doesn't change) and if the size of that file
262        # does not change, Hg may not see the change.
263        #
264        # We sleep a full second to avoid this, as sleeping merely
265        # until the next second begins would require very close clock
266        # synchronization on network filesystems.
267        #
268        time.sleep(1)
269
270        files = {}
271        try:
272            try:
273                fuzz = patch.patch(diff, self.ws.ui, strip=1,
274                                   cwd=self.ws.repo.root, files=files)
275                if fuzz:
276                    raise util.Abort('working copy diff applied with fuzz')
277            except Exception, e:
278                raise util.Abort("couldn't apply working copy diff: %s\n"
279                                 "   %s" % (diff, e))
280        finally:
281            patch.updatedir(self.ws.ui, self.ws.repo, files)
282
283        if not os.path.exists(self.bu.backupfile('renames')):
284            return
285
286        #
287        # We need to re-apply name changes where the new name
288        # (rename/copy destination) is an already versioned file, as
289        # Hg would otherwise ignore them.
290        #
291        try:
292            fp = open(self.bu.backupfile('renames'))
293            for line in fp:
294                source, dest = line.strip().split()
295                self.ws.repo.copy(source, dest)
296        except EnvironmentError, e:
297            raise util.Abort('unable to open renames file: %s' % e)
298        except ValueError:
299            raise util.Abort('corrupt renames file: %s' %
300                             self.bu.backupfile('renames'))
301
302    def need_backup(self):
303        '''Compare backup of uncommitted changes to workspace'''
304        if self._dirstate() != node.hex(self.ws.repo.changectx().node()):
305            return True
306
307        curdiff = StringIO()
308        diff = self.bu.backupfile('diff')
309        fd = None
310
311        patch.diff(self.ws.repo, fp=curdiff,
312                   opts=patch.diffopts(self.ws.ui, opts={'git': True}))
313
314        if os.path.exists(diff):
315            try:
316                try:
317                    fd = open(diff)
318                    backdiff = fd.read()
319                except EnvironmentError, e:
320                    raise util.Abort("couldn't open backup diff %s\n"
321                                     "   %s" % (diff, e))
322            finally:
323                if fd and not fd.closed:
324                    fd.close()
325        else:
326            backdiff = ''
327
328        if backdiff != curdiff.getvalue():
329            return True
330
331
332        currrenamed = self._clobbering_renames()
333        bakrenamed = None
334
335        if os.path.exists(self.bu.backupfile('renames')):
336            try:
337                try:
338                    fd = open(self.bu.backupfile('renames'))
339                    bakrenamed = [line.strip().split(' ') for line in fd]
340                except EnvironmentError, e:
341                    raise util.Abort("couldn't open renames file %s: %s\n" %
342                                     (self.bu.backupfile('renames'), e))
343            finally:
344                if fd and not fd.closed:
345                    fd.close()
346
347            if currrenamed != bakrenamed:
348                return True
349
350        return False
351
352    def cleanup(self):
353        '''Remove backed up uncommitted changes'''
354        for fname in ('dirstate', 'diff', 'renames'):
355            if os.path.exists(self.bu.backupfile(fname)):
356                os.unlink(self.bu.backupfile(fname))
357
358
359class CdmMetadataBackup(object):
360    '''Backup of workspace metadata'''
361
362    def __init__(self, backup, ws):
363        self.bu = backup
364        self.ws = ws
365        self.files = ('hgrc', 'localtags', 'patches')
366
367    def backup(self):
368        '''Backup workspace metadata'''
369
370        tar = None
371
372        try:
373            try:
374                tar = tarfile.open(self.bu.backupfile('metadata.tar.gz'),
375                                   'w:gz')
376                tar.errorlevel = 2
377            except (EnvironmentError, tarfile.TarError), e:
378                raise util.Abort("couldn't open %s for writing: %s" %
379                                 (self.bu.backupfile('metadata.tar.gz'), e))
380
381            try:
382                for elt in self.files:
383                    fpath = self.ws.repo.join(elt)
384                    if os.path.exists(fpath):
385                        tar.add(fpath, elt)
386            except (EnvironmentError, tarfile.TarError), e:
387                #
388                # tarfile.TarError doesn't include the tar member or file
389                # in question, so we have to do so ourselves.
390                #
391                if isinstance(e, tarfile.TarError):
392                    error = "%s: %s" % (elt, e)
393                else:
394                    error = str(e)
395
396                raise util.Abort("couldn't backup metadata to %s:\n"
397                                 "  %s" %
398                                 (self.bu.backupfile('metadata.tar.gz'),
399                                  error))
400        finally:
401            if tar and not tar.closed:
402                tar.close()
403
404    def old_restore(self):
405        '''Restore workspace metadata from an pre-tar backup'''
406
407        for fname in self.files:
408            bfile = self.bu.backupfile(fname)
409            wfile = self.ws.repo.join(fname)
410
411            if os.path.exists(bfile):
412                try:
413                    shutil.copy2(bfile, wfile)
414                except EnvironmentError, e:
415                    raise util.Abort("couldn't restore metadata from %s:\n"
416                                     "   %s" % (bfile, e))
417
418    def tar_restore(self):
419        '''Restore workspace metadata (from a tar-style backup)'''
420
421        if os.path.exists(self.bu.backupfile('metadata.tar.gz')):
422            tar = None
423
424            try:
425                try:
426                    tar = tarfile.open(self.bu.backupfile('metadata.tar.gz'))
427                    tar.errorlevel = 2
428                except (EnvironmentError, tarfile.TarError), e:
429                    raise util.Abort("couldn't open %s: %s" %
430                                 (self.bu.backupfile('metadata.tar.gz'), e))
431
432                try:
433                    for elt in tar:
434                        tar.extract(elt, path=self.ws.repo.path)
435                except (EnvironmentError, tarfile.TarError), e:
436                    # Make sure the member name is in the exception message.
437                    if isinstance(e, tarfile.TarError):
438                        error = "%s: %s" % (elt.name, e)
439                    else:
440                        error = str(e)
441
442                    raise util.Abort("couldn't restore metadata from %s:\n"
443                                     "   %s" %
444                                     (self.bu.backupfile('metadata.tar.gz'),
445                                      error))
446            finally:
447                if tar and not tar.closed:
448                    tar.close()
449
450    def restore(self):
451        '''Restore workspace metadata'''
452
453        if os.path.exists(self.bu.backupfile('hgrc')):
454            self.old_restore()
455        else:
456            self.tar_restore()
457
458    def need_backup(self):
459        '''Compare backed up workspace metadata to workspace'''
460
461        if os.path.exists(self.bu.backupfile('metadata.tar.gz')):
462            try:
463                tar = tarfile.open(self.bu.backupfile('metadata.tar.gz'))
464                tar.errorlevel = 2
465            except (EnvironmentError, tarfile.TarError), e:
466                raise util.Abort("couldn't open metadata tarball: %s\n"
467                                 "   %s" %
468                                 (self.bu.backupfile('metadata.tar.gz'), e))
469
470            for elt in tar:
471                fpath = self.ws.repo.join(elt.name)
472                if not os.path.exists(fpath):
473                    return True     # File in tar, not workspace
474
475                if elt.isdir():     # Don't care about directories
476                    continue
477
478                if (elt.mtime != os.path.getmtime(fpath) or
479                    elt.size != os.path.getsize(fpath)):
480                    return True
481
482            tarnames = tar.getnames()
483            tar.close()
484        else:
485            tarnames = []
486
487        for mfile in self.files:
488            fpath = self.ws.repo.join(mfile)
489
490            if os.path.isdir(fpath):
491                # Directories in tarfile always end with a '/'
492                if not mfile.endswith('/'):
493                    mfile += '/'
494
495                if mfile not in tarnames:
496                    return True
497
498                for root, dirs, files in os.walk(fpath, topdown=True):
499                    for elt in files:
500                        path = os.path.join(root, elt)
501
502                        rpath = self.ws.repo.path
503                        if not rpath.endswith('/'):
504                            rpath += '/'
505
506                        path = path.replace(rpath, '', 1)
507                        if path not in tarnames:
508                            return True # In workspace not tar
509            else:
510                if os.path.exists(fpath) and mfile not in tarnames:
511                    return True
512
513        return False
514
515    def cleanup(self):
516        '''Remove backed up workspace metadata'''
517        if os.path.exists(self.bu.backupfile('metadata.tar.gz')):
518            os.unlink(self.bu.backupfile('metadata.tar.gz'))
519
520
521class CdmBackup(object):
522    '''A backup of a given workspace'''
523
524    def __init__(self, ui, ws, name):
525        self.ws = ws
526        self.ui = ui
527        self.backupdir = self._find_backup_dir(name)
528
529        #
530        # The order of instances here controls the order the various operations
531        # are run.
532        #
533        # There's some inherent dependence, in that on restore we need
534        # to restore committed changes prior to uncommitted changes
535        # (as the parent revision of any uncommitted changes is quite
536        # likely to not exist until committed changes are restored).
537        # Metadata restore can happen at any point, but happens last
538        # as a matter of convention.
539        #
540        self.modules = [x(self, ws) for x in [CdmCommittedBackup,
541                                              CdmUncommittedBackup,
542                                              CdmMetadataBackup]]
543
544
545        if os.path.exists(os.path.join(self.backupdir, 'latest')):
546            generation = os.readlink(os.path.join(self.backupdir, 'latest'))
547            self.generation = int(os.path.split(generation)[1])
548        else:
549            self.generation = 0
550
551    def _find_backup_dir(self, name):
552        '''Find the path to an appropriate backup directory based on NAME'''
553        backupdir = None
554        backupbase = None
555
556        if os.path.isabs(name):
557            return name
558
559        if self.ui.config('cdm', 'backupdir'):
560            backupbase = os.path.expanduser(self.ui.config('cdm', 'backupdir'))
561        else:
562            home = None
563
564            try:
565                home = os.getenv('HOME') or pwd.getpwuid(os.getuid()).pw_dir
566            except KeyError:
567                pass                    # Handled anyway
568
569            if not home:
570                raise util.Abort('Could not determine your HOME directory to '
571                                 'find backup path')
572
573            backupbase = os.path.join(home, 'cdm.backup')
574
575        backupdir = os.path.join(backupbase, name)
576
577        # If backupdir exists, it must be a directory.
578        if (os.path.exists(backupdir) and not os.path.isdir(backupdir)):
579            raise util.Abort('%s exists but is not a directory' % backupdir)
580
581        return backupdir
582
583    def backupfile(self, path):
584        '''return full path to backup file FILE at GEN'''
585        return os.path.join(self.backupdir, str(self.generation), path)
586
587    def update_latest(self, gen):
588        '''Update latest symlink to point to the current generation'''
589        linkpath = os.path.join(self.backupdir, 'latest')
590
591        if os.path.lexists(linkpath):
592            os.unlink(linkpath)
593
594        os.symlink(str(gen), linkpath)
595
596    def create_gen(self, gen):
597        '''Create a new backup generation'''
598        try:
599            os.makedirs(os.path.join(self.backupdir, str(gen)))
600            self.update_latest(gen)
601        except EnvironmentError, e:
602            raise util.Abort("Couldn't create backup generation %s: %s" %
603                             (os.path.join(self.backupdir, str(gen)), e))
604
605    def need_backup(self):
606        '''Compare backed up changes to workspace'''
607        #
608        # If there's no current backup generation, or the last backup was
609        # invalid (lacking the dirstate file), we need a backup regardless
610        # of anything else.
611        #
612        if (not self.generation or
613            not os.path.exists(self.backupfile('dirstate'))):
614            return True
615
616        for x in self.modules:
617            if x.need_backup():
618                return True
619
620        return False
621
622    def backup(self):
623        '''Take a backup of the current workspace'''
624
625        if not os.path.exists(self.backupdir):
626            try:
627                os.makedirs(self.backupdir)
628            except EnvironmentError, e:
629                raise util.Abort('Could not create backup directory %s: %s' %
630                                 (self.backupdir, e))
631
632        self.generation += 1
633        self.create_gen(self.generation)
634
635        #
636        # Lock the repo, so the backup can be consistent.  We need the
637        # wlock too to make sure the dirstate parent doesn't change
638        # underneath us.
639        #
640
641        lock = self.ws.repo.lock()
642        wlock = self.ws.repo.lock()
643
644        try:
645            for x in self.modules:
646                x.backup()
647        except Exception, e:
648            if isinstance(e, KeyboardInterrupt):
649                self.ws.ui.warn("Interrupted\n")
650            else:
651                self.ws.ui.warn("Error: %s\n" % e)
652
653                #
654                # If it's not a 'normal' error, we want to print a stack
655                # trace now in case the attempt to remove the partial
656                # backup also fails, and raises a second exception.
657                #
658                if (not isinstance(e, (EnvironmentError, util.Abort))
659                    or self.ws.ui.traceback):
660                    traceback.print_exc()
661
662            for x in self.modules:
663                x.cleanup()
664
665            os.rmdir(os.path.join(self.backupdir, str(self.generation)))
666            self.generation -= 1
667
668            if self.generation != 0:
669                self.update_latest(self.generation)
670            else:
671                os.unlink(os.path.join(self.backupdir, 'latest'))
672
673            raise util.Abort('Backup failed')
674
675    def restore(self, gen=None):
676        '''Restore workspace from backup
677
678        Restores from backup generation GEN (defaulting to the latest)
679        into workspace WS.'''
680
681        wlock = self.ws.repo.wlock()
682        lock = self.ws.repo.lock()
683
684        if not os.path.exists(self.backupdir):
685            raise util.Abort('Backup directory does not exist: %s' %
686                             (self.backupdir))
687
688        if gen:
689            if not os.path.exists(os.path.join(self.backupdir, str(gen))):
690                raise util.Abort('Backup generation does not exist: %s' %
691                                 (os.path.join(self.backupdir, str(gen))))
692            self.generation = int(gen)
693
694        if not self.generation: # This is ok, 0 is not a valid generation
695            raise util.Abort('Backup has no generations: %s' % self.backupdir)
696
697        if not os.path.exists(self.backupfile('dirstate')):
698            raise util.Abort('Backup %s/%s is incomplete (dirstate missing)' %
699                             (self.backupdir, self.generation))
700
701        try:
702            for x in self.modules:
703                x.restore()
704        except util.Abort, e:
705            raise util.Abort('Error restoring workspace:\n'
706                             '%s\n'
707                             'Workspace will be partially restored' % e)
708